@technomoron/agent-run 0.99.30 → 0.99.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultSocketPath = void 0;
4
+ exports.createBrainServer = createBrainServer;
5
+ exports.serveApiCore = serveApiCore;
6
+ exports.proxyStdio = proxyStdio;
7
+ const fs = require("node:fs");
8
+ const path = require("node:path");
9
+ const http = require("node:http");
10
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
11
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
12
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
13
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
14
+ const apicore_server_1 = require("@technomoron/apicore-server");
15
+ const zod_1 = require("zod");
16
+ const version_1 = require("../version");
17
+ const store_1 = require("./store");
18
+ const skills_1 = require("./skills");
19
+ const todos_1 = require("./todos");
20
+ const connectors_1 = require("./connectors");
21
+ const git_1 = require("./git");
22
+ var config_1 = require("./config");
23
+ Object.defineProperty(exports, "defaultSocketPath", { enumerable: true, get: function () { return config_1.defaultSocketPath; } });
24
+ function createBrainServer(store) {
25
+ const server = new mcp_js_1.McpServer({ name: 'agent-brain', version: (0, version_1.packageVersion)() });
26
+ function tool(name, description, shape, readOnly, handler) {
27
+ const schema = zod_1.z.object(shape).strict();
28
+ server.registerTool(name, { description, inputSchema: schema, annotations: { readOnlyHint: readOnly, destructiveHint: false, openWorldHint: false } }, async (input) => {
29
+ try {
30
+ const result = await handler(zod_1.z.object(shape).strict().parse(input));
31
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
32
+ }
33
+ catch (error) {
34
+ return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }] };
35
+ }
36
+ });
37
+ }
38
+ const id = zod_1.z.string().min(1).max(200);
39
+ const revision = zod_1.z.string().regex(/^[a-f0-9]{64}$/);
40
+ tool('brain_status', 'Show the active project and available scopes.', {}, true, () => ({ profile: store.profile, scopes: store.scopes.map((entry) => entry.scope) }));
41
+ tool('sync_status', 'Preview Git state and the canonical files eligible for saving. Runtime files, indexes, credentials, and native agent state are excluded.', {}, true, () => (0, git_1.gitPreview)(store));
42
+ server.registerTool('sync', { description: 'Initialize, save, pull, or push the configuration Git repository. Review sync_status first. Requires explicit user authorization; saving also requires the approved commit message.',
43
+ inputSchema: { action: zod_1.z.enum(['init', 'save', 'pull', 'push']), confirmed: zod_1.z.literal(true), message: zod_1.z.string().min(1).max(1000).optional() },
44
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
45
+ }, async ({ action, message }) => {
46
+ try {
47
+ return { content: [{ type: 'text', text: JSON.stringify((0, git_1.syncGit)(store, action, message)) }] };
48
+ }
49
+ catch (error) {
50
+ return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }] };
51
+ }
52
+ });
53
+ tool('get_context', 'Retrieve relevant constraints and knowledge before substantial work. Check omitted; explicitly fetch required knowledge if the budget excluded it.', {
54
+ task: zod_1.z.string().max(10000), files: zod_1.z.array(zod_1.z.string()).max(100).optional(), symbols: zod_1.z.array(zod_1.z.string()).max(100).optional()
55
+ }, true, ({ task, files, symbols }) => store.context(task, files, symbols));
56
+ tool('search_knowledge', 'Search Markdown knowledge in the active scopes using SQLite FTS.', {
57
+ query: zod_1.z.string().max(10000), scopes: zod_1.z.array(store_1.scopeSchema).optional(), types: zod_1.z.array(store_1.typeSchema).optional(),
58
+ includeDeprecated: zod_1.z.boolean().optional(), limit: zod_1.z.number().int().min(1).max(100).optional()
59
+ }, true, ({ query, ...options }) => store.search(query, options));
60
+ tool('get_knowledge', 'Read a complete knowledge item and its current revision.', { id }, true, ({ id }) => store.get(id));
61
+ tool('remember', 'Persist durable knowledge when authorized by the user. Choose scope and a singular type; the server stores it in the matching directory (for example constraint in constraints/, preference in preferences/). Explicit user instructions have authority=user; deductions remain inferred observations. Global writes require explicit global intent. Use todo tools for tasks; skills and templates are separate files.', store_1.knowledgeInput.shape, false, (input) => store.remember(input));
62
+ tool('amend_knowledge', 'Revise an active item in place using its last-read revision, keeping its id, file name, scope, type, authority, and history. Use this to correct or extend existing knowledge instead of writing a near-duplicate. Scope, type, authority, and review severity cannot be changed this way.', {
63
+ id, revision, changes: store_1.knowledgeChanges
64
+ }, false, ({ id, revision, changes }) => store.amend(id, revision, changes));
65
+ tool('promote', 'Copy active knowledge to another available scope with user authority. Requires explicit user approval; the original is preserved.', {
66
+ id, scope: store_1.scopeSchema, revision, confirmed: zod_1.z.literal(true)
67
+ }, false, ({ id, scope, revision }) => store.promote(id, scope, revision));
68
+ tool('deprecate_knowledge', 'Deprecate outdated knowledge without deleting its history.', { id, revision, reason: zod_1.z.string().max(2000).optional() }, false, ({ id, revision, reason }) => store.deprecate(id, revision, reason));
69
+ tool('list_skills', 'List available skills and descriptions. Local skills override global skills with the same name.', {}, true, () => (0, skills_1.listSkills)(store));
70
+ tool('get_skill', 'Load a full skill only when it applies to the current task.', { name: zod_1.z.string().max(100) }, true, ({ name }) => (0, skills_1.getSkill)(store, name));
71
+ tool('review_context', 'Retrieve constraints, decisions, prior review findings, and path-specific knowledge for a code review.', {
72
+ task: zod_1.z.string().max(10000), files: zod_1.z.array(zod_1.z.string()).max(100).optional()
73
+ }, true, ({ task, files }) => store.context(`review constraint decision ${task}`, files));
74
+ tool('review_list', 'List review findings in severity then number order. Open findings only unless states are given. Each finding carries its label, severity, state, and the revision needed to resolve it.', {
75
+ severity: zod_1.z.array(store_1.severitySchema).optional(), state: zod_1.z.array(store_1.reviewStateSchema).optional()
76
+ }, true, ({ severity, state }) => store.reviews({ severity, state }).map((item) => ({
77
+ finding: item.finding, severity: item.severity, state: item.state ?? 'open', title: item.title,
78
+ applies_to: item.applies_to, id: item.id, revision: item.revision
79
+ })));
80
+ tool('resolve_review', 'Close a review finding as fixed or wontfix, with a reason and the revision you last read. Use wontfix only when the user has said the finding is intentional.', {
81
+ id, revision, state: zod_1.z.enum(['fixed', 'wontfix']), reason: zod_1.z.string().max(2000).optional()
82
+ }, false, ({ id, revision, state, reason }) => store.resolveReview(id, revision, state, reason));
83
+ tool('todo_list', 'List structured tasks in the active scopes.', { status: todos_1.todoInput.shape.status.optional() }, true, ({ status }) => (0, todos_1.listTodos)(store).filter((item) => !status || item.status === status));
84
+ tool('todo_get', 'Read a task and its revision.', { id }, true, ({ id }) => (0, todos_1.getTodo)(store, id));
85
+ tool('todo_add', 'Create a local task. Reimporting the same external task returns the existing task.', todos_1.todoInput.shape, false, (input) => (0, todos_1.addTodo)(store, input));
86
+ tool('todo_update', 'Update a local task using its last-read revision. Does not change remote tasks.', { id, revision, changes: todos_1.todoChanges }, false, ({ id, revision, changes }) => (0, todos_1.updateTodo)(store, id, revision, changes));
87
+ tool('todo_complete', 'Mark a local task done. Does not change remote tasks.', { id, revision }, false, ({ id, revision }) => (0, todos_1.updateTodo)(store, id, revision, { status: 'done' }));
88
+ for (const name of ['todo_import', 'todo_sync']) {
89
+ server.registerTool(name, { description: 'Import tasks from a configured connector. Merge remote changes only when they do not conflict with local edits; report conflicting task IDs. Remote tasks are never modified.',
90
+ inputSchema: { source: zod_1.z.string().max(100), scope: store_1.scopeSchema }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
91
+ }, async ({ source, scope }) => {
92
+ try {
93
+ return { content: [{ type: 'text', text: JSON.stringify(await (0, connectors_1.syncConnector)(store, source, scope)) }] };
94
+ }
95
+ catch (error) {
96
+ return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }] };
97
+ }
98
+ });
99
+ }
100
+ return server;
101
+ }
102
+ function requirePrivateSocketDirectory(socketPath) {
103
+ if (process.platform === 'win32')
104
+ throw new Error('The agent-brain Unix-socket service requires Linux or macOS.');
105
+ const stat = fs.lstatSync(path.dirname(socketPath));
106
+ if (!stat.isDirectory() || stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0)
107
+ throw new Error('The socket directory must be owned by the current user with mode 0700.');
108
+ }
109
+ async function serveApiCore(configRoot, socketPath) {
110
+ const directory = path.dirname(socketPath);
111
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
112
+ requirePrivateSocketDirectory(socketPath);
113
+ if (fs.existsSync(socketPath))
114
+ throw new Error(`Socket already exists: ${socketPath}. Stop the running service, or remove the socket after confirming it is stale.`);
115
+ const api = new apicore_server_1.ApiServer({ authApi: false, origins: [], devMode: false });
116
+ api.fastify.post('/mcp', async (request, reply) => {
117
+ if (request.headers.origin)
118
+ return reply.code(403).send({ error: 'Browser requests are not supported' });
119
+ if (request.headers['x-agent-brain-root'] !== encodeURIComponent(path.resolve(configRoot)))
120
+ return reply.code(409).send({ error: 'This service uses a different configuration directory' });
121
+ const cwdHeader = request.headers['x-agent-brain-cwd'];
122
+ if (typeof cwdHeader !== 'string')
123
+ return reply.code(400).send({ error: 'Missing project directory' });
124
+ let cwd;
125
+ try {
126
+ cwd = decodeURIComponent(cwdHeader);
127
+ }
128
+ catch {
129
+ return reply.code(400).send({ error: 'Invalid project directory' });
130
+ }
131
+ if (!path.isAbsolute(cwd) || !fs.statSync(cwd, { throwIfNoEntry: false })?.isDirectory())
132
+ return reply.code(400).send({ error: 'Invalid project directory' });
133
+ const store = new store_1.BrainStore(configRoot, cwd);
134
+ const server = createBrainServer(store);
135
+ const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
136
+ reply.hijack();
137
+ try {
138
+ await server.connect(transport);
139
+ await transport.handleRequest(request.raw, reply.raw, request.body);
140
+ }
141
+ finally {
142
+ await server.close();
143
+ store.close();
144
+ }
145
+ });
146
+ api.fastify.route({ method: ['GET', 'DELETE'], url: '/mcp', handler: async (_request, reply) => reply.code(405).send() });
147
+ await api.fastify.listen({ path: socketPath });
148
+ fs.chmodSync(socketPath, 0o600);
149
+ return { close: () => api.fastify.close() };
150
+ }
151
+ async function proxyStdio(socketPath, cwd, configRoot) {
152
+ const socket = fs.lstatSync(socketPath, { throwIfNoEntry: false });
153
+ if (!socket)
154
+ throw new Error(`Agent-brain service is not running at ${socketPath}. Start agent-brain serve with the same --configdir and --socket, or enable its systemd user service.`);
155
+ requirePrivateSocketDirectory(socketPath);
156
+ if (!socket.isSocket() || socket.uid !== process.getuid() || (socket.mode & 0o077) !== 0)
157
+ throw new Error('The brain socket must belong to the current user with mode 0600.');
158
+ const stdio = new stdio_js_1.StdioServerTransport();
159
+ let protocolVersion;
160
+ async function send(message) {
161
+ const response = await new Promise((resolve, reject) => {
162
+ const request = http.request({ socketPath, path: '/mcp', method: 'POST', headers: {
163
+ 'content-type': 'application/json', accept: 'application/json, text/event-stream',
164
+ 'x-agent-brain-cwd': encodeURIComponent(cwd), 'x-agent-brain-root': encodeURIComponent(path.resolve(configRoot)),
165
+ ...(protocolVersion ? { 'mcp-protocol-version': protocolVersion } : {})
166
+ } }, (response) => {
167
+ const chunks = [];
168
+ let bytes = 0;
169
+ response.on('data', (chunk) => { bytes += chunk.length; if (bytes > 4 * 1024 * 1024)
170
+ response.destroy(new Error('Brain response exceeds 4 MiB'));
171
+ else
172
+ chunks.push(chunk); });
173
+ response.on('error', reject);
174
+ response.on('end', () => response.statusCode && response.statusCode >= 200 && response.statusCode < 300
175
+ ? resolve(Buffer.concat(chunks).toString('utf8')) : reject(new Error(`Brain service returned HTTP ${response.statusCode}`)));
176
+ });
177
+ request.setTimeout(30000, () => request.destroy(new Error('Brain service request timed out')));
178
+ request.on('error', reject);
179
+ request.end(JSON.stringify(message));
180
+ });
181
+ if (response) {
182
+ const parsed = types_js_1.JSONRPCMessageSchema.parse(JSON.parse(response));
183
+ if ('result' in parsed && typeof parsed.result.protocolVersion === 'string')
184
+ protocolVersion = parsed.result.protocolVersion;
185
+ await stdio.send(parsed);
186
+ }
187
+ }
188
+ stdio.onmessage = (message) => {
189
+ void send(message).catch(async (error) => {
190
+ if ('id' in message && 'method' in message)
191
+ await stdio.send({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: error instanceof Error ? error.message : String(error) } });
192
+ else
193
+ process.stderr.write(`${String(error)}\n`);
194
+ });
195
+ };
196
+ await stdio.start();
197
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getSkills = getSkills;
4
+ exports.listSkills = listSkills;
5
+ exports.getSkill = getSkill;
6
+ const path = require("node:path");
7
+ const zod_1 = require("zod");
8
+ const store_1 = require("./store");
9
+ const templates_1 = require("../templates");
10
+ const manifest_1 = require("../manifest");
11
+ const metadata = zod_1.z.object({
12
+ name: zod_1.z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/),
13
+ description: zod_1.z.string().trim().min(1),
14
+ tags: zod_1.z.array(zod_1.z.string()).default([]),
15
+ extends: zod_1.z.string().regex(/^global:[a-zA-Z0-9][a-zA-Z0-9._-]*$/).optional()
16
+ });
17
+ function getSkills(store) {
18
+ const profile = store.profile ?? 'default';
19
+ const directory = store.scopeDirectory(store.profile ? 'project' : 'default');
20
+ const env = (0, templates_1.createNunjucksEnv)(store.configRoot);
21
+ const context = (0, templates_1.buildRenderContext)(store.projectRoot, directory, store.configRoot, (0, manifest_1.normalizeManifest)((0, manifest_1.loadManifest)(store.configRoot, directory, profile), profile), env);
22
+ const skills = new Map();
23
+ const globalSkills = new Map();
24
+ for (const { scope, directory } of store.scopes) {
25
+ for (const file of store.files(path.join(directory, 'skills')).filter((file) => path.basename(file) === 'SKILL.md')) {
26
+ const text = env.renderString(store.read(file), context);
27
+ const document = (0, store_1.readMarkdown)(text);
28
+ const parsed = metadata.parse(document.metadata);
29
+ if (path.basename(path.dirname(file)) !== parsed.name)
30
+ throw new Error(`Skill name must match directory: ${file}`);
31
+ let content = document.content;
32
+ if (parsed.extends) {
33
+ const parent = globalSkills.get(parsed.extends.slice('global:'.length));
34
+ if (!parent || parent.scope !== 'global' || scope === 'global')
35
+ throw new Error(`Missing global parent for skill ${parsed.name}`);
36
+ content = `${parent.content}\n\n${content}`;
37
+ }
38
+ skills.set(parsed.name, { ...parsed, scope, content, source: path.relative(store.configRoot, file) });
39
+ if (scope === 'global')
40
+ globalSkills.set(parsed.name, skills.get(parsed.name));
41
+ }
42
+ }
43
+ return [...skills.values()];
44
+ }
45
+ function listSkills(store) {
46
+ return getSkills(store).map(({ content: _content, ...skill }) => skill);
47
+ }
48
+ function getSkill(store, name) {
49
+ const skill = getSkills(store).find((item) => item.name === name);
50
+ if (!skill)
51
+ throw new Error(`Skill not found: ${name}`);
52
+ return skill;
53
+ }
@@ -0,0 +1,343 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BrainStore = exports.knowledgeDirectories = exports.knowledgeChanges = exports.knowledgeMetadata = exports.knowledgeInput = exports.reviewStateSchema = exports.severitySchema = exports.typeSchema = exports.scopeSchema = void 0;
4
+ exports.readMarkdown = readMarkdown;
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const node_crypto_1 = require("node:crypto");
8
+ const node_sqlite_1 = require("node:sqlite");
9
+ const yaml_1 = require("yaml");
10
+ const zod_1 = require("zod");
11
+ const project_1 = require("../project");
12
+ const utils_1 = require("../utils");
13
+ const config_1 = require("./config");
14
+ exports.scopeSchema = zod_1.z.enum(['global', 'project', 'default']);
15
+ exports.typeSchema = zod_1.z.enum(['rule', 'preference', 'convention', 'constraint', 'decision', 'spec', 'review', 'memory', 'observation']);
16
+ exports.severitySchema = zod_1.z.enum(['critical', 'high', 'medium', 'low']);
17
+ exports.reviewStateSchema = zod_1.z.enum(['open', 'fixed', 'wontfix']);
18
+ const severityPrefix = { critical: 'C', high: 'H', medium: 'M', low: 'L' };
19
+ const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
20
+ exports.knowledgeInput = zod_1.z.object({
21
+ scope: exports.scopeSchema,
22
+ type: exports.typeSchema,
23
+ title: zod_1.z.string().trim().min(1).max(300),
24
+ content: zod_1.z.string().trim().min(1).max(200000),
25
+ authority: zod_1.z.enum(['user', 'inferred']).default('inferred'),
26
+ recall: zod_1.z.enum(['always', 'relevant']).default('relevant'),
27
+ severity: exports.severitySchema.optional(),
28
+ tags: zod_1.z.array(zod_1.z.string().max(100)).max(50).default([]),
29
+ applies_to: zod_1.z.array(zod_1.z.string().max(500)).max(50).default([]),
30
+ // Naming only. The id in the front matter stays the identity, so files can be renamed freely.
31
+ filename: zod_1.z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lower-case words separated by single dashes, with no extension').max(100).optional()
32
+ }).strict();
33
+ // finding and state stay optional so review files written before they existed keep parsing.
34
+ exports.knowledgeMetadata = exports.knowledgeInput.omit({ content: true, filename: true }).extend({
35
+ id: zod_1.z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/),
36
+ status: zod_1.z.enum(['active', 'deprecated']).default('active'),
37
+ finding: zod_1.z.string().regex(/^[CHML][1-9][0-9]*$/).optional(),
38
+ state: exports.reviewStateSchema.optional(),
39
+ created: zod_1.z.string().default(''),
40
+ updated: zod_1.z.string().default(''),
41
+ reason: zod_1.z.string().optional(),
42
+ promoted_from: zod_1.z.string().optional()
43
+ }).strict();
44
+ // Spelled out rather than derived from knowledgeInput: .partial() keeps .default(), so a
45
+ // derived schema would silently reset every field the caller did not mention.
46
+ exports.knowledgeChanges = zod_1.z.object({
47
+ title: zod_1.z.string().trim().min(1).max(300).optional(),
48
+ content: zod_1.z.string().trim().min(1).max(200000).optional(),
49
+ recall: zod_1.z.enum(['always', 'relevant']).optional(),
50
+ tags: zod_1.z.array(zod_1.z.string().max(100)).max(50).optional(),
51
+ applies_to: zod_1.z.array(zod_1.z.string().max(500)).max(50).optional()
52
+ }).strict();
53
+ exports.knowledgeDirectories = {
54
+ rule: 'rules', preference: 'preferences', convention: 'conventions', constraint: 'constraints',
55
+ decision: 'decisions', spec: 'specs', review: 'reviews', memory: 'memory', observation: 'observations'
56
+ };
57
+ function readMarkdown(text) {
58
+ const normalized = text.replace(/\r\n/g, '\n');
59
+ const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(normalized);
60
+ return match ? { metadata: (0, yaml_1.parse)(match[1], { maxAliasCount: 50 }), content: normalized.slice(match[0].length).trim() }
61
+ : { metadata: null, content: normalized.trim() };
62
+ }
63
+ function hash(text) { return (0, node_crypto_1.createHash)('sha256').update(text).digest('hex'); }
64
+ class BrainStore {
65
+ constructor(configRoot, cwd) {
66
+ this.configRoot = configRoot;
67
+ this.cwd = cwd;
68
+ this.configRoot = path.resolve(configRoot);
69
+ this.projectRoot = (0, config_1.registeredBrainProject)(this.configRoot, cwd)?.root ?? (0, project_1.findProjectRoot)(cwd);
70
+ const resolved = (0, project_1.resolveProfileResult)(this.projectRoot, this.configRoot).profile;
71
+ this.profile = resolved && resolved !== 'default' && ['agent-run.jsonc', 'local.md.njk', 'AGENTS-MODS.md'].some((file) => fs.existsSync(path.join(this.configRoot, resolved, file))) ? resolved : null;
72
+ this.scopes = [{ scope: 'global', directory: path.join(this.configRoot, 'global') },
73
+ { scope: this.profile ? 'project' : 'default', directory: path.join(this.configRoot, this.profile ?? 'default') }];
74
+ this.budget = (0, config_1.readBrainConfig)(this.configRoot)?.contextBudget ?? 16000;
75
+ this.directory('index');
76
+ this.directory('runtime');
77
+ this.db = new node_sqlite_1.DatabaseSync(this.safePath('index', 'knowledge.sqlite'));
78
+ this.db.exec('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; CREATE VIRTUAL TABLE IF NOT EXISTS knowledge USING fts5(source UNINDEXED, environment UNINDEXED, title, content, tags);');
79
+ }
80
+ close() { this.db.close(); }
81
+ safePath(...parts) {
82
+ const target = path.resolve(this.configRoot, ...parts);
83
+ if (!(0, utils_1.isSamePathOrDescendant)(target, this.configRoot))
84
+ throw new Error('Path escapes the brain configuration directory');
85
+ let current = this.configRoot;
86
+ for (const part of path.relative(this.configRoot, target).split(path.sep).filter(Boolean)) {
87
+ current = path.join(current, part);
88
+ try {
89
+ if (fs.lstatSync(current).isSymbolicLink())
90
+ throw new Error(`Symlinks are not supported in brain storage: ${current}`);
91
+ }
92
+ catch (error) {
93
+ if (error.code !== 'ENOENT')
94
+ throw error;
95
+ }
96
+ }
97
+ return target;
98
+ }
99
+ directory(...parts) {
100
+ const directory = this.safePath(...parts);
101
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
102
+ return directory;
103
+ }
104
+ scopeDirectory(scope) {
105
+ const directory = this.scopes.find((entry) => entry.scope === scope)?.directory;
106
+ if (!directory)
107
+ throw new Error(`Scope ${scope} is not available in this environment`);
108
+ return this.safePath(directory);
109
+ }
110
+ files(directory) {
111
+ this.safePath(directory);
112
+ if (!fs.existsSync(directory))
113
+ return [];
114
+ return fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => {
115
+ const file = this.safePath(directory, entry.name);
116
+ return entry.isDirectory() ? this.files(file) : entry.isFile() && entry.name.endsWith('.md') ? [file] : [];
117
+ });
118
+ }
119
+ read(file) {
120
+ const target = this.safePath(file);
121
+ if (fs.statSync(target).size > 1024 * 1024)
122
+ throw new Error(`Markdown file exceeds 1 MiB: ${file}`);
123
+ return fs.readFileSync(target, 'utf8');
124
+ }
125
+ atomicWrite(file, text) {
126
+ const target = this.safePath(file);
127
+ this.directory(path.dirname(target));
128
+ const temporary = `${target}.${(0, node_crypto_1.randomUUID)()}.tmp`;
129
+ try {
130
+ fs.writeFileSync(temporary, text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
131
+ fs.renameSync(temporary, target);
132
+ }
133
+ finally {
134
+ fs.rmSync(temporary, { force: true });
135
+ }
136
+ }
137
+ writeLocked(fn) {
138
+ const file = this.safePath('runtime', 'brain-write.lock');
139
+ let descriptor;
140
+ try {
141
+ descriptor = fs.openSync(file, 'wx', 0o600);
142
+ }
143
+ catch (error) {
144
+ if (error.code === 'EEXIST')
145
+ throw new Error('Another brain write is in progress. Retry; after a crashed writer, remove runtime/brain-write.lock.');
146
+ throw error;
147
+ }
148
+ try {
149
+ fs.writeSync(descriptor, String(process.pid));
150
+ return fn();
151
+ }
152
+ finally {
153
+ fs.closeSync(descriptor);
154
+ fs.unlinkSync(file);
155
+ }
156
+ }
157
+ allKnowledge() {
158
+ const result = [];
159
+ for (const { scope, directory } of this.scopes) {
160
+ const directories = [...Object.entries(exports.knowledgeDirectories), ['memory', path.join('notes', 'memory')]];
161
+ if (scope === 'global')
162
+ directories.push(['memory', path.relative(directory, path.join(this.configRoot, 'notes', 'memory'))]);
163
+ for (const [type, folder] of directories) {
164
+ for (const file of this.files(path.join(directory, folder))) {
165
+ const text = this.read(file);
166
+ const document = readMarkdown(text);
167
+ const source = path.relative(this.configRoot, file);
168
+ const legacy = document.metadata !== null && typeof document.metadata === 'object' && !Array.isArray(document.metadata)
169
+ && !('id' in document.metadata) && !('type' in document.metadata);
170
+ const legacyStatus = legacy ? document.metadata.status : undefined;
171
+ const metadata = document.metadata === null || legacy ? {
172
+ id: `document-${hash(source).slice(0, 24)}`, scope, type,
173
+ title: /^#\s+(.+)$/m.exec(document.content)?.[1] ?? path.basename(file, '.md'),
174
+ status: type === 'review' && ['addressed', 'resolved', 'done', 'closed'].includes(String(legacyStatus)) ? 'deprecated' : 'active',
175
+ created: '', updated: ''
176
+ } : { title: /^#\s+(.+)$/m.exec(document.content)?.[1] ?? path.basename(file, '.md'), ...document.metadata };
177
+ const parsed = exports.knowledgeMetadata.safeParse(metadata);
178
+ if (!parsed.success)
179
+ throw new Error(`Invalid knowledge metadata in ${source}: ${parsed.error.message}`);
180
+ if (parsed.data.scope !== scope)
181
+ throw new Error(`Knowledge scope does not match its directory: ${source}`);
182
+ result.push({ ...parsed.data, content: legacy ? text.trim() : document.content, source, revision: hash(text) });
183
+ }
184
+ }
185
+ }
186
+ const ids = new Set();
187
+ for (const item of result) {
188
+ if (ids.has(item.id))
189
+ throw new Error(`Duplicate knowledge id: ${item.id}`);
190
+ ids.add(item.id);
191
+ }
192
+ return result;
193
+ }
194
+ search(query, options = {}) {
195
+ const items = this.allKnowledge();
196
+ const environment = this.profile ?? 'default';
197
+ this.db.exec('BEGIN IMMEDIATE');
198
+ try {
199
+ this.db.prepare('DELETE FROM knowledge WHERE environment = ?').run(environment);
200
+ const insert = this.db.prepare('INSERT INTO knowledge(source, environment, title, content, tags) VALUES (?, ?, ?, ?, ?)');
201
+ for (const item of items)
202
+ insert.run(item.source, environment, item.title, item.content, [...item.tags, item.finding ?? ''].join(' ').trim());
203
+ this.db.exec('COMMIT');
204
+ }
205
+ catch (error) {
206
+ this.db.exec('ROLLBACK');
207
+ throw error;
208
+ }
209
+ const terms = query.match(/[\p{L}\p{N}_-]+/gu)?.slice(0, 50) ?? [];
210
+ const ranked = terms.length ? this.db.prepare('SELECT source FROM knowledge WHERE knowledge MATCH ? AND environment = ? ORDER BY bm25(knowledge, 0, 0, 5, 1, 3), source')
211
+ .all(terms.map((term) => `"${term.replace(/"/g, '""')}"`).join(' OR '), environment).map((row) => String(row.source)) : items.map((item) => item.source);
212
+ const bySource = new Map(items.map((item) => [item.source, item]));
213
+ return ranked.map((source) => bySource.get(source)).filter((item) => (options.includeDeprecated || item.status === 'active') && (!options.scopes || options.scopes.includes(item.scope)) &&
214
+ (!options.types || options.types.includes(item.type))).slice(0, options.limit ?? 50);
215
+ }
216
+ context(task, files = [], symbols = []) {
217
+ const all = this.allKnowledge().filter((item) => item.status === 'active');
218
+ const always = all.filter((item) => item.recall === 'always');
219
+ const matching = all.filter((item) => item.applies_to.some((pattern) => files.some((file) => {
220
+ const relative = path.isAbsolute(file) ? path.relative(this.projectRoot, file) : file;
221
+ return path.matchesGlob(relative, pattern);
222
+ })));
223
+ const selected = [...new Map([...always, ...matching, ...this.search([task, ...symbols].join(' '))].map((item) => [item.id, item])).values()];
224
+ let size = 0;
225
+ const items = selected.filter((item) => {
226
+ const length = JSON.stringify(item).length;
227
+ if (size + length > this.budget)
228
+ return false;
229
+ size += length;
230
+ return true;
231
+ });
232
+ return { items, omitted: selected.length - items.length, profile: this.profile, scopes: this.scopes.map((scope) => scope.scope) };
233
+ }
234
+ remember(input) {
235
+ const value = exports.knowledgeInput.parse(input);
236
+ if (value.authority === 'inferred' && value.type !== 'observation' && value.type !== 'memory' && value.type !== 'review') {
237
+ throw new Error('Inferred knowledge must remain an observation, memory, or review until explicitly confirmed.');
238
+ }
239
+ if (value.scope === 'global' && value.authority !== 'user')
240
+ throw new Error('Global writes require explicit user authority.');
241
+ if (value.type === 'review' && !value.severity)
242
+ throw new Error('Review findings require a severity: critical, high, medium, or low.');
243
+ if (value.type !== 'review' && value.severity)
244
+ throw new Error('Severity applies only to review findings.');
245
+ return this.writeLocked(() => {
246
+ const now = new Date().toISOString();
247
+ const { content, filename, ...metadata } = value;
248
+ const review = value.severity ? { finding: this.nextFinding(value.scope, value.severity), state: 'open' } : {};
249
+ const item = { ...metadata, ...review, id: (0, node_crypto_1.randomUUID)(), status: 'active', created: now, updated: now };
250
+ const directory = path.join(this.scopeDirectory(value.scope), exports.knowledgeDirectories[value.type]);
251
+ this.atomicWrite(this.availableFile(directory, filename, item.id), `---\n${(0, yaml_1.stringify)(item)}---\n\n${content}\n`);
252
+ return this.allKnowledge().find((entry) => entry.id === item.id);
253
+ });
254
+ }
255
+ /** Chosen name when one is given and free, otherwise the same name with the id appended. */
256
+ availableFile(directory, filename, id) {
257
+ if (!filename)
258
+ return path.join(directory, `${id}.md`);
259
+ const preferred = path.join(directory, `${filename}.md`);
260
+ return fs.existsSync(preferred) ? path.join(directory, `${filename}-${id}.md`) : preferred;
261
+ }
262
+ /** Revise an item in place, keeping its id, filename, scope, type, authority, and history. */
263
+ amend(id, revision, changes) {
264
+ const parsed = exports.knowledgeChanges.parse(changes);
265
+ if (Object.keys(parsed).length === 0)
266
+ throw new Error('Supply at least one field to change.');
267
+ return this.writeLocked(() => {
268
+ const item = this.get(id);
269
+ if (item.revision !== revision)
270
+ throw new Error('Knowledge changed; read the current revision before amending.');
271
+ if (item.status !== 'active')
272
+ throw new Error('Deprecated knowledge cannot be amended.');
273
+ const { source, revision: _revision, content, ...metadata } = item;
274
+ const { content: nextContent, ...nextMetadata } = parsed;
275
+ this.atomicWrite(source, `---\n${(0, yaml_1.stringify)({ ...metadata, ...nextMetadata, updated: new Date().toISOString() })}---\n\n${nextContent ?? content}\n`);
276
+ return this.get(id);
277
+ });
278
+ }
279
+ /** Next unused finding label for a severity, counting resolved findings so numbers are never reused. */
280
+ nextFinding(scope, severity) {
281
+ const prefix = severityPrefix[severity];
282
+ const used = this.allKnowledge()
283
+ .filter((item) => item.scope === scope && item.type === 'review' && item.finding?.startsWith(prefix))
284
+ .map((item) => Number.parseInt(item.finding.slice(prefix.length), 10))
285
+ .filter((sequence) => Number.isSafeInteger(sequence));
286
+ return `${prefix}${Math.max(0, ...used) + 1}`;
287
+ }
288
+ /** Review findings in severity then sequence order. Open findings only unless states are given. */
289
+ reviews(options = {}) {
290
+ const sequence = (item) => Number.parseInt(item.finding.slice(1), 10);
291
+ return this.allKnowledge()
292
+ .filter((item) => item.type === 'review' && item.finding && item.severity)
293
+ .filter((item) => !options.severity || options.severity.includes(item.severity))
294
+ .filter((item) => (options.state ?? ['open']).includes(item.state ?? 'open'))
295
+ .sort((left, right) => severityOrder[left.severity] - severityOrder[right.severity] || sequence(left) - sequence(right));
296
+ }
297
+ resolveReview(id, revision, state, reason) {
298
+ return this.writeLocked(() => {
299
+ const item = this.get(id);
300
+ if (item.type !== 'review' || !item.finding)
301
+ throw new Error('Only review findings can be resolved.');
302
+ if (item.revision !== revision)
303
+ throw new Error('Knowledge changed; read the current revision before updating.');
304
+ const { source, revision: _revision, content, ...metadata } = item;
305
+ this.atomicWrite(source, `---\n${(0, yaml_1.stringify)({ ...metadata, state, status: 'deprecated', updated: new Date().toISOString(), ...(reason ? { reason } : {}) })}---\n\n${content}\n`);
306
+ return this.get(id);
307
+ });
308
+ }
309
+ deprecate(id, revision, reason) {
310
+ return this.writeLocked(() => {
311
+ const item = this.get(id);
312
+ if (item.revision !== revision)
313
+ throw new Error('Knowledge changed; read the current revision before updating.');
314
+ const { source, revision: _revision, content, ...metadata } = item;
315
+ this.atomicWrite(source, `---\n${(0, yaml_1.stringify)({ ...metadata, status: 'deprecated', updated: new Date().toISOString(), ...(reason ? { reason } : {}) })}---\n\n${content}\n`);
316
+ return this.get(id);
317
+ });
318
+ }
319
+ promote(id, scope, revision) {
320
+ return this.writeLocked(() => {
321
+ const item = this.get(id);
322
+ if (item.revision !== revision)
323
+ throw new Error('Knowledge changed; read the current revision before promoting.');
324
+ if (item.status !== 'active')
325
+ throw new Error('Deprecated knowledge cannot be promoted.');
326
+ if (item.scope === scope)
327
+ throw new Error('Knowledge already belongs to this scope.');
328
+ const { source: _source, revision: _revision, content, ...metadata } = item;
329
+ const renumbered = item.severity ? { finding: this.nextFinding(scope, item.severity) } : {};
330
+ const promoted = { ...metadata, ...renumbered, id: (0, node_crypto_1.randomUUID)(), scope, authority: 'user', promoted_from: id, updated: new Date().toISOString() };
331
+ const target = path.join(this.scopeDirectory(scope), exports.knowledgeDirectories[item.type]);
332
+ this.atomicWrite(this.availableFile(target, path.basename(item.source, '.md'), promoted.id), `---\n${(0, yaml_1.stringify)(promoted)}---\n\n${content}\n`);
333
+ return this.get(promoted.id);
334
+ });
335
+ }
336
+ get(id) {
337
+ const item = this.allKnowledge().find((entry) => entry.id === id);
338
+ if (!item)
339
+ throw new Error(`Knowledge not found in the active scopes: ${id}`);
340
+ return item;
341
+ }
342
+ }
343
+ exports.BrainStore = BrainStore;