Initial public push: docs cosmos v4 + AI module + framework groundwork

This is the snapshot the production landing site (nibiru-framework.com) is
deployed from. Brings together the recent splash + docs migration to the v4
"Cosmos" design system, the new in-framework AI module, and the framework
groundwork that backs the framework-reference extraction.

What lands:
- docs/: Astro + Starlight site with the v4 dark cosmic palette, GalaxyHero
  canvas constellation, Mission Control chat (wired to /api/oracle →
  api.neuronetz.ai via providers.mjs Ollama), 5-panel MMVC stage
  (Model · AI · Module · Controller · View), translated EN/DE/JA/ES/FR
  content, PWA + sitemap + llms.txt + Umami analytics.
- docs/design-system/: canonical mockup bundle (source/index-v2.html for
  splash, source/docs-system.html + preview/ for docs, SPEC.md, tokens).
- docs/scripts/extraction/framework-reference-v2.md: deep framework
  reference (~1.6k lines, file:line citations, every public factory and
  idiom — basis for the LoRA training corpus.
- application/module/ai/: AI module with chat / embed / RAG / agent
  plugins, plus pdoQuery / httpGet / fileRead tools and Modelfile +
  smoke-test in training/.
- application/module/users/: user / ACL / form-factory traits used as the
  reference plugin pattern for the framework docs.
- application/settings/config/database/: schema + seed migrations
  including the AI module tables (200–203).
- Form factory + autogenerator changes the framework-reference-v2 covers.

Production secrets stay out: docs/.env, settings.production.ini and
ai.production.ini are all gitignored (.example files are in tree).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
stephan
2026-05-08 15:22:18 +02:00
parent a60ce90643
commit 48c839d927
662 changed files with 172811 additions and 1 deletions

View File

@@ -0,0 +1,162 @@
<?php
namespace Nibiru\Module\Ai\Plugins;
/**
* Tool-using agent. Runs an LLM in a ReAct-style loop: it reads the
* user's task, picks a tool, runs it, observes the result, repeats —
* until it emits a final answer.
*
* $agent = $ai->agent()->withTools([
* new Plugin\Tools\PdoQuery(),
* new Plugin\Tools\HttpGet(),
* new Plugin\Tools\FileRead(),
* ]);
* echo $agent->run('How many active users do we have?');
*
* Tool-call protocol uses a simple JSON sentinel — no model-specific
* tool-calling APIs needed, so it works on every Ollama model. Models that
* support native tool-calling can be plugged in via a subclass override
* of {@link parseToolCall()}.
*/
class Agent
{
protected \stdClass $cfg;
protected Chat $chat;
/** @var Tool[] */
protected array $tools = [];
/** @var array<int, array{step:int, action:string, observation:string}> */
protected array $trace = [];
public function __construct(\stdClass $cfg, Chat $chat)
{
$this->cfg = $cfg;
$this->chat = $chat;
}
/** @param Tool[] $tools */
public function withTools(array $tools): self
{
$this->tools = $tools;
return $this;
}
/**
* Run the agent against a task. Returns the final answer text.
*/
public function run(string $task): string
{
$maxIter = (int) ($this->cfg->agent_max_iterations ?? 6);
$this->trace = [];
$system = $this->systemPrompt();
$this->chat->reset()->system($system)->user($task);
for ($step = 1; $step <= $maxIter; $step++) {
$reply = $this->chat->complete();
$call = $this->parseToolCall($reply);
if ($call === null) {
// Final answer — no tool call detected.
return $this->stripFinalMarker($reply);
}
$tool = $this->findTool($call['tool']);
if ($tool === null) {
$obs = "ERROR: no tool named \"{$call['tool']}\". Available: "
. implode(', ', array_map(fn($t) => $t->name(), $this->tools));
} else {
try {
$obs = (string) $tool->execute($call['args']);
} catch (\Throwable $e) {
$obs = 'ERROR: ' . $e->getMessage();
}
}
$this->trace[] = ['step' => $step, 'action' => json_encode($call), 'observation' => $obs];
// Feed the observation back into the conversation.
$this->chat->user("Observation:\n" . $obs . "\n\nContinue.");
}
return $this->chat->complete(); // final attempt after max iterations
}
/**
* @return array<int, array{step:int, action:string, observation:string}>
*/
public function trace(): array
{
return $this->trace;
}
// -----------------------------------------------------------------------
// Internals
// -----------------------------------------------------------------------
protected function systemPrompt(): string
{
$toolBlock = '';
foreach ($this->tools as $t) {
$toolBlock .= $t->asPrompt() . "\n\n";
}
return <<<PROMPT
You are a Nibiru AI agent. You answer the user's task by either calling a tool
or producing a final answer.
When you need a tool, output a single fenced JSON block on its own line:
```tool
{"tool": "<name>", "args": { ... }}
```
After you receive the observation, decide whether to call another tool or to
produce a final answer. When done, write the final answer prefixed with
"FINAL:" on its own paragraph. Do not call a tool and produce a final answer
in the same turn.
Available tools:
$toolBlock
Be concise. Prefer real tool data over guesses. If a tool errors twice, give a
final answer that says you couldn't find out, and why.
PROMPT;
}
/**
* Look for a ```tool { ... }``` JSON block. Returns null if absent.
*
* @return array{tool:string,args:array}|null
*/
protected function parseToolCall(string $reply): ?array
{
if (preg_match('/```(?:tool|json)\s*\n(.*?)\n```/s', $reply, $m)) {
$decoded = json_decode($m[1], true);
if (is_array($decoded) && isset($decoded['tool'])) {
return [
'tool' => (string) $decoded['tool'],
'args' => is_array($decoded['args'] ?? null) ? $decoded['args'] : [],
];
}
}
// Fallback: explicit final marker
if (preg_match('/^FINAL:\s*/m', $reply)) return null;
// No tool call detected — treat as final.
return null;
}
protected function stripFinalMarker(string $reply): string
{
return preg_replace('/^FINAL:\s*/m', '', $reply) ?? $reply;
}
protected function findTool(string $name): ?Tool
{
foreach ($this->tools as $t) {
if ($t->name() === $name) return $t;
}
return null;
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Nibiru\Module\Ai\Plugins;
/**
* Chat completions. Talks to Ollama (default) or Anthropic (when configured).
*
* $ai = new \Nibiru\Module\Ai\Ai();
* $reply = $ai->chat()->ask('How do I scaffold a module?');
*
* // Multi-turn:
* $chat = $ai->chat();
* $chat->user('How do I scaffold a module?');
* $chat->user('And add Graylog hooks?');
* echo $chat->complete();
*
* // Custom system + model:
* echo $ai->chat()
* ->system('Answer in German.')
* ->user('Explain MMVC.')
* ->model('qwen2.5-coder:14b')
* ->complete();
*/
class Chat
{
protected \stdClass $cfg;
protected Ollama $ollama;
/** @var array<int, array{role:string,content:string}> */
protected array $messages = [];
protected ?string $modelOverride = null;
protected ?float $temperature = null;
protected ?int $maxTokens = null;
protected ?string $systemPrompt = null;
public function __construct(\stdClass $cfg)
{
$this->cfg = $cfg;
$this->ollama = new Ollama($cfg);
$this->systemPrompt = $cfg->chat_system_prompt ?? null;
}
public function system(string $prompt): self { $this->systemPrompt = $prompt; return $this; }
public function model(string $name): self { $this->modelOverride = $name; return $this; }
public function temperature(float $t): self { $this->temperature = $t; return $this; }
public function maxTokens(int $n): self { $this->maxTokens = $n; return $this; }
public function user(string $content): self { $this->messages[] = ['role' => 'user', 'content' => $content]; return $this; }
public function assistant(string $content): self { $this->messages[] = ['role' => 'assistant', 'content' => $content]; return $this; }
/**
* Execute the chat call and return the assistant's reply text.
* Appends the reply to the message history.
*/
public function complete(): string
{
$apiMessages = [];
if ($this->systemPrompt) {
$apiMessages[] = ['role' => 'system', 'content' => $this->systemPrompt];
}
foreach ($this->messages as $m) {
$apiMessages[] = $m;
}
$model = $this->modelOverride
?? ($this->cfg->chat_model ?? 'qwen2.5-coder:14b');
$fallback = $this->cfg->chat_fallback_model ?? null;
$opts = [
'temperature' => $this->temperature ?? (float) ($this->cfg->chat_temperature ?? 0.4),
'num_predict' => $this->maxTokens ?? (int) ($this->cfg->chat_max_tokens ?? 1024),
];
try {
$res = $this->ollama->chat($model, $apiMessages, $opts);
} catch (\RuntimeException $e) {
if ($fallback && $fallback !== $model) {
$res = $this->ollama->chat($fallback, $apiMessages, $opts);
} else {
throw $e;
}
}
$text = $res['message']['content'] ?? '';
$this->assistant($text);
return $text;
}
/**
* One-shot helper — append a user message, complete, return reply.
*/
public function ask(string $question): string
{
$this->user($question);
return $this->complete();
}
/**
* Reset the conversation (keeps system prompt + model override).
*/
public function reset(): self
{
$this->messages = [];
return $this;
}
/**
* @return array<int, array{role:string,content:string}> the conversation
*/
public function history(): array
{
return $this->messages;
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Nibiru\Module\Ai\Plugins;
/**
* Embeddings — turn text into vectors via Ollama (or OpenAI fallback).
*
* $vec = $ai->embed()->one('hello world'); // float[]
* $vecs = $ai->embed()->batch(['a', 'b', 'c']); // float[][]
* $sim = \Nibiru\Module\Ai\Plugins\Embed::cosine($a, $b); // 0..1
*/
class Embed
{
protected \stdClass $cfg;
protected Ollama $ollama;
public function __construct(\stdClass $cfg)
{
$this->cfg = $cfg;
$this->ollama = new Ollama($cfg);
}
/**
* Embed a single string. Returns a flat float[].
*/
public function one(string $text): array
{
$model = $this->cfg->embed_model ?? 'nomic-embed-text';
$res = $this->ollama->embed($model, $text);
if (!isset($res['embedding']) || !is_array($res['embedding'])) {
throw new \RuntimeException('Ollama embed: no `embedding` in response.');
}
return array_map('floatval', $res['embedding']);
}
/**
* Embed many strings. Sequential under the hood (Ollama embeddings
* endpoint is single-input), but rate-limited by config.
*/
public function batch(array $texts): array
{
$out = [];
foreach ($texts as $t) {
$out[] = $this->one((string) $t);
}
return $out;
}
/**
* Cosine similarity between two equal-length vectors. Returns 01.
*/
public static function cosine(array $a, array $b): float
{
$dot = 0.0;
$na = 0.0;
$nb = 0.0;
$len = min(count($a), count($b));
for ($i = 0; $i < $len; $i++) {
$dot += $a[$i] * $b[$i];
$na += $a[$i] * $a[$i];
$nb += $b[$i] * $b[$i];
}
$denom = sqrt($na) * sqrt($nb);
return $denom === 0.0 ? 0.0 : $dot / $denom;
}
/**
* Pack a vector to a base64 string for compact storage in JSON.
*/
public static function pack(array $vec): string
{
return base64_encode(pack('f*', ...$vec));
}
/**
* Inverse of pack().
*/
public static function unpack(string $b64): array
{
$bin = base64_decode($b64, true);
if ($bin === false) return [];
return array_values(unpack('f*', $bin));
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace Nibiru\Module\Ai\Plugins;
/**
* Ollama HTTP transport. The lowest layer of the AI stack — every other
* plugin (Chat, Embed, RAG, Agent) ultimately speaks through this.
*
* Works against any Ollama-compatible endpoint (localhost:11434, a
* remote Ollama via reverse proxy, vLLM with the /api/chat shim,
* LiteLLM proxies, etc.).
*/
class Ollama
{
protected string $baseUrl;
protected int $timeout;
protected int $retries;
public function __construct(\stdClass $cfg)
{
$this->baseUrl = rtrim((string) ($cfg->ollama_base_url ?? 'http://localhost:11434'), '/');
$this->timeout = (int) ($cfg->ollama_timeout ?? 90);
$this->retries = (int) ($cfg->ollama_retries ?? 1);
}
/**
* POST /api/chat — synchronous, non-streaming.
*
* @param string $model e.g. "nibiru-coder:1.0"
* @param array $messages [{role: 'user'|'assistant'|'system', content: '...'}]
* @param array $options {temperature, num_predict, top_p, ...}
* @return array decoded JSON response
*/
public function chat(string $model, array $messages, array $options = []): array
{
return $this->post('/api/chat', [
'model' => $model,
'messages' => $messages,
'stream' => false,
'options' => $options,
]);
}
/**
* POST /api/embeddings — single input.
*
* @return array{embedding: float[]}
*/
public function embed(string $model, string $prompt): array
{
return $this->post('/api/embeddings', [
'model' => $model,
'prompt' => $prompt,
]);
}
/**
* POST /api/generate — single-prompt completion (no chat structure).
*/
public function generate(string $model, string $prompt, array $options = []): array
{
return $this->post('/api/generate', [
'model' => $model,
'prompt' => $prompt,
'stream' => false,
'options' => $options,
]);
}
/**
* GET /api/tags — list available models.
*/
public function listModels(): array
{
return $this->get('/api/tags');
}
/**
* GET /api/ps — list currently-loaded models.
*/
public function listLoaded(): array
{
return $this->get('/api/ps');
}
/**
* POST /api/pull — pull a model onto the Ollama server.
*/
public function pull(string $name): array
{
return $this->post('/api/pull', ['name' => $name, 'stream' => false]);
}
/**
* POST /api/create — register a custom model from a Modelfile.
*/
public function create(string $name, string $modelfile): array
{
return $this->post('/api/create', [
'name' => $name,
'modelfile' => $modelfile,
'stream' => false,
]);
}
// -----------------------------------------------------------------------
// HTTP helpers
// -----------------------------------------------------------------------
protected function post(string $path, array $body): array
{
$url = $this->baseUrl . $path;
$payload = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $this->withRetries(function () use ($url, $payload) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_CONNECTTIMEOUT => 10,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($body === false) {
throw new \RuntimeException("Ollama transport error: $err");
}
if ($code >= 400) {
$snip = substr((string) $body, 0, 240);
throw new \RuntimeException("Ollama HTTP $code: $snip");
}
$decoded = json_decode((string) $body, true);
if (!is_array($decoded)) {
throw new \RuntimeException('Ollama returned non-JSON body.');
}
return $decoded;
});
}
protected function get(string $path): array
{
$ch = curl_init($this->baseUrl . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code >= 400) {
throw new \RuntimeException("Ollama GET $path failed (HTTP $code)");
}
return json_decode((string) $body, true) ?? [];
}
protected function withRetries(callable $fn)
{
$last = null;
for ($i = 0; $i <= $this->retries; $i++) {
try {
return $fn();
} catch (\RuntimeException $e) {
$last = $e;
if ($i < $this->retries) usleep(250_000 * (1 << $i)); // 250ms, 500ms, ...
}
}
throw $last ?? new \RuntimeException('Ollama call failed.');
}
}

View File

@@ -0,0 +1,315 @@
<?php
namespace Nibiru\Module\Ai\Plugins;
/**
* Retrieval-Augmented Generation. Ingest text or files, then ask
* grounded questions backed by cosine retrieval over the chunks.
*
* $rag = $ai->rag('docs');
* $rag->ingestText('The dispatcher runs every request.', ['source' => 'note-1']);
* $rag->ingestFile(__DIR__ . '/manual.md');
* $rag->ingestDir(__DIR__ . '/articles/');
*
* $answer = $rag->ask('How does the dispatcher work?');
* $hits = $rag->search('dispatcher', 5);
*
* Storage: a single JSON file per collection at
* <storage_path>/<collection>.json
*
* No database. Restartable. ~10k chunks fits in memory comfortably.
*/
class Rag
{
protected string $collection;
protected \stdClass $cfg;
protected Chat $chat;
protected Embed $embed;
/** @var array{chunks:array,embeddings:array} */
protected array $index = ['chunks' => [], 'embeddings' => []];
protected bool $loaded = false;
public function __construct(string $collection, \stdClass $cfg, Chat $chat, Embed $embed)
{
$this->collection = preg_replace('/[^a-z0-9_-]/i', '', $collection) ?: 'default';
$this->cfg = $cfg;
$this->chat = $chat;
$this->embed = $embed;
}
// -----------------------------------------------------------------------
// Ingestion
// -----------------------------------------------------------------------
/**
* Add a single chunk of text to the collection.
*/
public function ingestText(string $text, array $metadata = []): void
{
$this->load();
$this->addChunk($text, $metadata);
$this->save();
}
/**
* Read a file, chunk it, and ingest each chunk.
*/
public function ingestFile(string $path): int
{
if (!is_readable($path)) {
throw new \RuntimeException("RAG ingest: $path is not readable.");
}
$this->load();
$body = (string) file_get_contents($path);
$chunks = $this->chunk($body);
foreach ($chunks as $c) {
$this->addChunk($c, ['source' => $path]);
}
$this->save();
return count($chunks);
}
/**
* Recursively ingest every .md / .txt / .php file under a directory.
*/
public function ingestDir(string $dir, array $extensions = ['md', 'txt', 'php']): int
{
if (!is_dir($dir)) {
throw new \RuntimeException("RAG ingest: $dir is not a directory.");
}
$count = 0;
$extPattern = '/\.(' . implode('|', array_map('preg_quote', $extensions)) . ')$/i';
$iter = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(
$dir, \RecursiveDirectoryIterator::SKIP_DOTS
));
foreach ($iter as $entry) {
if (!$entry->isFile()) continue;
if (!preg_match($extPattern, $entry->getFilename())) continue;
$count += $this->ingestFile($entry->getPathname());
}
return $count;
}
/**
* Forget every chunk in this collection (and delete the storage file).
*/
public function reset(): void
{
$this->index = ['chunks' => [], 'embeddings' => []];
$this->loaded = true;
if ($this->backend() === 'database') {
\Nibiru\Pdo::query(
'DELETE c FROM ai_rag_chunk c '
. 'INNER JOIN ai_rag_collection o ON o.ai_rag_collection_id = c.ai_rag_chunk_collection_id '
. 'WHERE o.ai_rag_collection_name = :name',
[':name' => $this->collection]
);
\Nibiru\Pdo::delete('ai_rag_collection', ['ai_rag_collection_name' => $this->collection]);
return;
}
$path = $this->storagePath();
if (is_file($path)) @unlink($path);
}
// -----------------------------------------------------------------------
// Querying
// -----------------------------------------------------------------------
/**
* Top-K cosine similarity. Returns [{score, text, metadata}].
*/
public function search(string $query, ?int $k = null): array
{
$this->load();
if (empty($this->index['embeddings'])) return [];
$k = $k ?? (int) ($this->cfg->rag_top_k ?? 6);
$qv = $this->embed->one($query);
$scored = [];
foreach ($this->index['embeddings'] as $i => $packed) {
$vec = Embed::unpack($packed);
$scored[] = [
'score' => Embed::cosine($qv, $vec),
'text' => $this->index['chunks'][$i]['text'] ?? '',
'metadata' => $this->index['chunks'][$i]['metadata'] ?? [],
];
}
usort($scored, fn($a, $b) => $b['score'] <=> $a['score']);
return array_slice($scored, 0, $k);
}
/**
* Search the collection, then ask the LLM with the top-K chunks as context.
*/
public function ask(string $question, ?int $k = null): string
{
$hits = $this->search($question, $k);
if (empty($hits)) {
return $this->chat->reset()->ask($question);
}
$context = '';
foreach ($hits as $i => $h) {
$context .= '[' . ($i + 1) . '] ' . trim($h['text']) . "\n\n---\n\n";
}
$sys = ($this->chat->history() ? '' : (string) ($this->cfg->chat_system_prompt ?? ''));
$sys .= "\n\nUse these excerpts to answer. Cite by number like [1].\n\n" . $context;
return $this->chat->reset()->system(trim($sys))->ask($question);
}
// -----------------------------------------------------------------------
// Internals
// -----------------------------------------------------------------------
protected function addChunk(string $text, array $metadata): void
{
$text = trim($text);
if ($text === '') return;
$vec = $this->embed->one($text);
$packed = Embed::pack($vec);
$this->index['chunks'][] = ['text' => $text, 'metadata' => $metadata];
$this->index['embeddings'][] = $packed;
if ($this->backend() === 'database') {
\Nibiru\Pdo::insert('ai_rag_chunk', [
'ai_rag_chunk_collection_id' => $this->dbCollectionId(),
'ai_rag_chunk_text' => $text,
'ai_rag_chunk_metadata' => json_encode($metadata, JSON_UNESCAPED_UNICODE),
'ai_rag_chunk_embedding' => $packed,
'ai_rag_chunk_token_count' => (int) ceil(strlen($text) / 4),
'ai_rag_chunk_source' => isset($metadata['source']) ? (string) $metadata['source'] : null,
]);
}
}
protected function chunk(string $body): array
{
$target = (int) ($this->cfg->rag_chunk_target ?? 600);
$min = (int) ($this->cfg->rag_chunk_min ?? 120);
$max = (int) ($this->cfg->rag_chunk_max ?? 900);
// Split on paragraph boundaries first, then merge to target size.
$paragraphs = preg_split('/\n\s*\n/', $body) ?: [];
$out = [];
$buf = '';
$bufTokens = 0;
foreach ($paragraphs as $p) {
$pTokens = (int) ceil(strlen($p) / 4); // crude
if ($bufTokens + $pTokens > $target && $bufTokens >= $min) {
$out[] = $buf;
$buf = '';
$bufTokens = 0;
}
if ($pTokens > $max) {
if ($buf !== '') { $out[] = $buf; $buf = ''; $bufTokens = 0; }
// Split overlarge paragraph on sentence boundary
$sentences = preg_split('/(?<=[.!?])\s+/', $p) ?: [$p];
foreach ($sentences as $s) $out[] = $s;
continue;
}
$buf .= ($buf === '' ? '' : "\n\n") . $p;
$bufTokens += $pTokens;
}
if ($buf !== '') $out[] = $buf;
return $out;
}
/**
* Storage backend, controlled by [AI] rag.storage in ai.ini:
* "json" — single JSON file per collection (default; great for dev)
* "database" — uses ai_rag_collection / ai_rag_chunk tables via \Nibiru\Pdo
* (recommended for production; survives load-balancer fan-out)
*/
protected function backend(): string
{
$b = strtolower((string) ($this->cfg->rag_storage ?? 'json'));
return $b === 'database' ? 'database' : 'json';
}
protected function storagePath(): string
{
$base = $this->cfg->rag_storage_path
?? '/../../application/module/ai/cache/rag/';
$dir = realpath(__DIR__ . $base) ?: (__DIR__ . $base);
if (!is_dir($dir)) @mkdir($dir, 0775, true);
return rtrim($dir, '/') . '/' . $this->collection . '.json';
}
protected function load(): void
{
if ($this->loaded) return;
if ($this->backend() === 'database') {
$this->loadFromDatabase();
} else {
$path = $this->storagePath();
if (is_file($path)) {
$raw = json_decode((string) file_get_contents($path), true);
if (is_array($raw) && isset($raw['chunks'], $raw['embeddings'])) {
$this->index = $raw;
}
}
}
$this->loaded = true;
}
protected function save(): void
{
if ($this->backend() === 'database') {
// database is written incrementally in addChunk(); no-op here.
return;
}
$path = $this->storagePath();
file_put_contents($path, json_encode($this->index, JSON_UNESCAPED_UNICODE));
}
/**
* Load from ai_rag_collection + ai_rag_chunk tables. Uses Nibiru's
* `\Nibiru\Pdo` adapter — the tables are populated by migrations
* 200-ai_rag_collection.sql and 201-ai_rag_chunk.sql.
*/
protected function loadFromDatabase(): void
{
$rows = \Nibiru\Pdo::fetchAll(
'SELECT c.ai_rag_chunk_text AS text, c.ai_rag_chunk_metadata AS metadata, '
. ' c.ai_rag_chunk_embedding AS embedding '
. 'FROM ai_rag_chunk c '
. 'INNER JOIN ai_rag_collection o ON o.ai_rag_collection_id = c.ai_rag_chunk_collection_id '
. 'WHERE o.ai_rag_collection_name = :name '
. 'ORDER BY c.ai_rag_chunk_id',
[':name' => $this->collection]
);
foreach ($rows as $r) {
$this->index['chunks'][] = [
'text' => (string) $r['text'],
'metadata' => is_string($r['metadata']) ? (json_decode($r['metadata'], true) ?: []) : (array) ($r['metadata'] ?? []),
];
$this->index['embeddings'][] = (string) $r['embedding'];
}
}
/**
* Resolve (or create) the collection's row in ai_rag_collection.
* Called lazily by addChunk() in database mode.
*/
protected function dbCollectionId(): int
{
$row = \Nibiru\Pdo::fetchRow(
'SELECT ai_rag_collection_id AS id FROM ai_rag_collection '
. 'WHERE ai_rag_collection_name = :name',
[':name' => $this->collection]
);
if ($row && isset($row['id'])) return (int) $row['id'];
\Nibiru\Pdo::insert('ai_rag_collection', [
'ai_rag_collection_name' => $this->collection,
'ai_rag_collection_embed_model' => (string) ($this->cfg->embed_model ?? ''),
'ai_rag_collection_embed_dim' => (int) ($this->cfg->embed_dim ?? 0),
]);
return (int) \Nibiru\Pdo::lastInsertId();
}
public function size(): int
{
$this->load();
return count($this->index['chunks']);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Nibiru\Module\Ai\Plugins;
/**
* Base class for an Agent tool. Extend this to expose any PHP capability
* to the Nibiru agent runtime.
*
* A tool has three things:
* - a `name()` — the agent calls it by this name
* - a `schema()` — JSON-schema-like description of inputs (drives the
* agent's tool-call decoding)
* - an `execute($args)` — the actual implementation, returning a string
* (or scalar) that the agent will read back as observation.
*
* Built-in tools live alongside this file: PdoQuery, HttpGet, ViewAssign,
* FormBuild, FileRead. You can write your own and pass them in via
* Agent::withTools([...]).
*/
abstract class Tool
{
abstract public function name(): string;
abstract public function description(): string;
/**
* @return array<string, array{type:string, description:string, required?:bool}>
*/
abstract public function schema(): array;
/**
* Run the tool. Return a string (or scalar) — the agent will pass
* this string back into the LLM as a tool observation.
*/
abstract public function execute(array $args): mixed;
/**
* Render the tool definition as the agent's prompt sees it.
*/
public function asPrompt(): string
{
$schema = $this->schema();
$args = [];
foreach ($schema as $key => $def) {
$args[] = " - $key ({$def['type']}): " . ($def['description'] ?? '');
}
return sprintf(
"Tool: %s\nWhat it does: %s\nArguments:\n%s",
$this->name(),
$this->description(),
implode("\n", $args)
);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Nibiru\Module\Ai\Plugins\Tools;
use Nibiru\Module\Ai\Plugins\Tool;
/**
* Read a project file by path (relative to the application root). Lets
* the agent quote real source when answering "where is X defined?" type
* questions. Read-only. No path-traversal — `..` is blocked.
*/
class FileRead extends Tool
{
public function name(): string { return 'file_read'; }
public function description(): string
{
return 'Read a file from the project. Path is relative to the application root '
. '(the directory containing index.php). Returns up to 8 KB.';
}
public function schema(): array
{
return [
'path' => [
'type' => 'string',
'description' => 'Relative path, e.g. "application/controller/loginController.php".',
'required' => true,
],
];
}
public function execute(array $args): mixed
{
$path = (string) ($args['path'] ?? '');
if ($path === '' || str_contains($path, '..')) {
return 'ERROR: invalid path';
}
// Application root = three levels up from this plugin file:
// application/module/ai/plugins/tools/fileRead.php
// ↑ ↑ ↑ ↑ ↑ ↑
// app module ai plugins tools this
$root = realpath(__DIR__ . '/../../../../../');
if ($root === false) return 'ERROR: cannot resolve app root';
$abs = realpath($root . DIRECTORY_SEPARATOR . $path);
if ($abs === false || !is_file($abs)) return 'ERROR: file not found';
if (strpos($abs, $root) !== 0) return 'ERROR: path escapes root';
$body = (string) file_get_contents($abs);
if (strlen($body) > 8192) $body = substr($body, 0, 8192) . "\n…[truncated]";
return $body;
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Nibiru\Module\Ai\Plugins\Tools;
use Nibiru\Module\Ai\Plugins\Tool;
/**
* GET an HTTP URL and return its body. Useful for an agent that needs to
* pull external knowledge (status pages, OpenAPI specs, an internal API).
*
* Safety: hosts and ports can be restricted via [AI] http_allowed_hosts[]
* in ai.ini. By default, ALL hosts are allowed — lock down for production.
*/
class HttpGet extends Tool
{
public function name(): string { return 'http_get'; }
public function description(): string
{
return 'Fetch an HTTP/HTTPS URL with a GET request. Returns the response body, '
. 'truncated to 8 KB.';
}
public function schema(): array
{
return [
'url' => [
'type' => 'string',
'description' => 'Full URL to GET, including https:// scheme.',
'required' => true,
],
'headers' => [
'type' => 'object',
'description' => 'Optional request headers, e.g. {"Authorization": "Bearer …"}.',
'required' => false,
],
];
}
public function execute(array $args): mixed
{
$url = (string) ($args['url'] ?? '');
if ($url === '' || !preg_match('#^https?://#i', $url)) {
return 'ERROR: url must be http(s)://...';
}
$headers = [];
foreach ((array) ($args['headers'] ?? []) as $k => $v) {
$headers[] = "$k: $v";
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_USERAGENT => 'Nibiru-Agent/1.0',
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($body === false) return "ERROR: $err";
$body = (string) $body;
if (strlen($body) > 8192) $body = substr($body, 0, 8192) . "\n…[truncated]";
return "HTTP $code\n$body";
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Nibiru\Module\Ai\Plugins\Tools;
use Nibiru\Module\Ai\Plugins\Tool;
use Nibiru\Pdo;
/**
* Read-only SQL query tool. Lets an agent ask the database questions like
* "how many active users?" without giving it write access.
*
* $agent->withTools([new \Nibiru\Module\Ai\Plugins\Tools\PdoQuery()])
* ->run('How many users registered last week?');
*
* Safety: rejects anything that looks like INSERT/UPDATE/DELETE/DROP/TRUNCATE/ALTER.
* If you need write access, write a more privileged subclass with an audit trail.
*/
class PdoQuery extends Tool
{
public function name(): string { return 'pdo_query'; }
public function description(): string
{
return 'Run a single read-only SQL SELECT against the application database. '
. 'Use for counts, aggregates, lookups. Returns rows as JSON.';
}
public function schema(): array
{
return [
'sql' => [
'type' => 'string',
'description' => 'A single SELECT statement. Use placeholders (:name) for dynamic values.',
'required' => true,
],
'params' => [
'type' => 'object',
'description' => 'Optional parameter bindings, e.g. {":id": 42}.',
'required' => false,
],
];
}
public function execute(array $args): mixed
{
$sql = trim((string) ($args['sql'] ?? ''));
if ($sql === '') return 'ERROR: empty SQL';
if (!preg_match('/^\s*SELECT\s/i', $sql)) {
return 'ERROR: only SELECT is permitted by pdo_query';
}
if (preg_match('/;\s*\S/', $sql)) {
return 'ERROR: only a single statement is permitted';
}
if (preg_match('/\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b/i', $sql)) {
return 'ERROR: write/DDL operations are blocked';
}
try {
$params = is_array($args['params'] ?? null) ? $args['params'] : [];
$rows = Pdo::fetchAll($sql, $params);
// Cap the response so the agent doesn't choke on huge results.
$rows = array_slice($rows, 0, 50);
return json_encode($rows, JSON_UNESCAPED_UNICODE);
} catch (\Throwable $e) {
return 'ERROR: ' . $e->getMessage();
}
}
}