'agent', 'templates' => 'template', 'starters' => 'starter', 'policy' => 'policy']; public const BUILT_IN = 'MazeGraph'; public const STORED = 'site'; public const MAX_CONTEXT = 4; /** @var array, source: string}>> */ private array $items = []; /** @var list */ private array $diagnostics = []; /** @var list */ private array $context = []; public function __construct(private JsonSchema $schema) { } /** * The schema shipped with the plugin. * * @since 0.1.0 */ public static function schema(): JsonSchema { return JsonSchema::file(dirname(__DIR__, 2) . '/resources/protocol/maze-graph-manifest-v1.schema.json'); } /** * Problems with one definition of `$kind`, without adding it. * * @since 0.1.0 * @return list */ public function check(string $kind, mixed $data, string $source, string $pointer = ''): array { if (!in_array($kind, self::KINDS, true)) { return ["{$pointer}/kind: must be one of " . implode(', ', self::KINDS)]; } if (!is_array($data) || array_is_list($data)) { return [($pointer === '' ? '/' : $pointer) . ': must be an object']; } $errors = $this->schema->errors($data, $kind, $pointer); $id = is_string($data['id'] ?? null) ? $data['id'] : ''; if ($errors === [] && $source !== self::BUILT_IN && self::reserved($id)) { $errors[] = "{$pointer}/id: \"{$id}\" is reserved for MazeGraph; use vendor/name"; } return $errors; } /** * Add one definition. Returns false (with a diagnostic) when it is invalid or its id is taken. * * @since 0.1.0 */ public function add(string $kind, mixed $data, string $source, string $pointer = ''): bool { $errors = $this->check($kind, $data, $source, $pointer); if ($errors !== [] || !is_array($data)) { foreach ($errors as $error) { [$at, $message] = array_pad(explode(': ', $error, 2), 2, ''); $this->report($source, $message, $at); } return false; } unset($data['kind']); $id = (string) $data['id']; if (isset($this->items[$kind][$id])) { $this->report($source, sprintf('%s "%s" is already defined by %s; this one is ignored', $kind, $id, $this->items[$kind][$id]['source']), $pointer); return false; } $this->items[$kind][$id] = ['data' => $data, 'source' => $source]; return true; } /** * Add every definition of a decoded `maze-graph.json`. * * @since 0.1.0 * @param mixed $manifest */ public function addManifest(mixed $manifest, string $source): void { if (!is_array($manifest) || array_is_list($manifest)) { $this->report($source, 'the file must hold one JSON object'); return; } if (($manifest['version'] ?? null) !== 1) { $this->report($source, 'must say "version": 1', '/version'); return; } foreach ($manifest as $section => $list) { if (in_array($section, ['$schema', 'version'], true)) { continue; } if ($section === 'context') { foreach (is_array($list) ? array_values($list) : [] as $i => $line) { is_string($line) ? $this->addContext($line, $source) : $this->report($source, 'must be text', "/context/{$i}"); } continue; } $kind = self::SECTIONS[$section] ?? null; if ($kind === null || !is_array($list) || !array_is_list($list)) { $this->report($source, $kind === null ? 'is not a known section' : 'must be a list', '/' . $section); continue; } foreach ($list as $i => $item) { $this->add($kind, $item, $source, "/{$section}/{$i}"); } } } /** * Read and add a `maze-graph.json` file. * * @since 0.1.0 */ public function addFile(string $path, string $source): void { if (!is_readable($path)) { $this->report($source, 'the file cannot be read'); return; } $manifest = json_decode((string) file_get_contents($path), true); if (json_last_error() !== JSON_ERROR_NONE) { $this->report($source, 'is not valid JSON: ' . json_last_error_msg()); return; } $this->addManifest($manifest, $source); } /** * A short trusted line about the site for the system prompt. At most four are kept. * * @since 0.1.0 */ public function addContext(string $line, string $source): void { $line = trim((string) preg_replace('/\s+/u', ' ', $line)); if ($line === '' || mb_strlen($line) > 300) { $this->report($source, 'context lines are 1 to 300 characters', '/context'); return; } if (count($this->context) >= self::MAX_CONTEXT) { $this->report($source, 'only four context lines are kept; this one is ignored', '/context'); return; } $this->context[] = ['text' => $line, 'source' => $source]; } /** * @since 0.1.0 */ public function report(string $source, string $message, string $pointer = ''): void { $this->diagnostics[] = ['source' => $source, 'pointer' => $pointer, 'message' => $message]; } /** * @since 0.1.0 * @return list, source: string}> */ public function all(string $kind): array { return array_values($this->items[$kind] ?? []); } /** * @since 0.1.0 * @return array{data: array, source: string}|null */ public function get(string $kind, string $id): ?array { return $this->items[$kind][$id] ?? null; } /** * @since 0.1.0 * @return list */ public function context(): array { return $this->context; } /** * @since 0.1.0 * @return list */ public function diagnostics(): array { return $this->diagnostics; } /** * Bare ids and `maze-graph/*` belong to MazeGraph. * * @since 0.1.0 */ public static function reserved(string $id): bool { return !str_contains($id, '/') || str_starts_with($id, 'maze-graph/'); } }