amalgm 0.1.88 → 0.1.89
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.
- package/lib/cli.js +16 -6
- package/lib/service.js +19 -0
- package/lib/supervisor.js +51 -0
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/apps/supervisor.js +0 -25
- package/runtime/scripts/amalgm-mcp/browser/auth-tools.js +179 -0
- package/runtime/scripts/amalgm-mcp/browser/cli.js +217 -0
- package/runtime/scripts/amalgm-mcp/browser/cua-tools.js +147 -0
- package/runtime/scripts/amalgm-mcp/browser/engine.js +454 -0
- package/runtime/scripts/amalgm-mcp/browser/recorder-tools.js +82 -0
- package/runtime/scripts/amalgm-mcp/browser/recorder.js +199 -0
- package/runtime/scripts/amalgm-mcp/browser/rest.js +5 -1
- package/runtime/scripts/amalgm-mcp/browser/tools.js +104 -731
- package/runtime/scripts/amalgm-mcp/fs/rest.js +5 -6
- package/runtime/scripts/amalgm-mcp/index.js +2 -0
- package/runtime/scripts/amalgm-mcp/lib/tool-result.js +32 -66
- package/runtime/scripts/amalgm-mcp/project-context/paths.js +266 -0
- package/runtime/scripts/amalgm-mcp/project-context/prompt.js +232 -0
- package/runtime/scripts/amalgm-mcp/project-context/rest.js +100 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +671 -0
- package/runtime/scripts/amalgm-mcp/project-context/tools.js +146 -0
- package/runtime/scripts/amalgm-mcp/server/core-tools.js +35 -2
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/project-context.js +33 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +11 -0
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
- package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +37 -4
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +328 -0
- package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +9 -9
- package/runtime/scripts/amalgm-mcp/tests/toolbox-service.test.js +2 -2
- package/runtime/scripts/amalgm-mcp/toolbox/loadout-context.js +1 -0
- package/runtime/scripts/amalgm-mcp/toolbox/store.js +0 -15
- package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +11 -7
- package/runtime/scripts/chat-core/adapters/opencode.js +1 -1
- package/runtime/scripts/chat-core/engine.js +16 -2
- package/runtime/scripts/chat-core/input.js +19 -1
- package/runtime/scripts/chat-core/tool-shape.js +6 -4
- package/runtime/scripts/chat-core/tooling/system-instructions.js +51 -0
- package/runtime/scripts/chat-core/tooling/system-prompt.js +34 -8
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/browser/agent-browser.js +0 -569
- package/runtime/scripts/amalgm-mcp/browser/page.js +0 -25
- package/runtime/scripts/chat-core/tooling/active-memory.js +0 -574
- package/runtime/scripts/chat-core/tooling/passive-memory.js +0 -576
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Project context store — the canonical mutation path for project memory.
|
|
5
|
+
*
|
|
6
|
+
* Files stay human-readable on disk, but every write should flow through this
|
|
7
|
+
* store so timestamps, revisions, the SQLite `project_context` row, and Local
|
|
8
|
+
* Live events stay coherent:
|
|
9
|
+
*
|
|
10
|
+
* change-log.md append-only via appendChangeLog (runtime owns the
|
|
11
|
+
* timestamp; entries land directly under the title)
|
|
12
|
+
* project-state.md latest write wins; revision kept as metadata only
|
|
13
|
+
* instructions.md user-authored; latest write wins via the UI path
|
|
14
|
+
*
|
|
15
|
+
* Direct disk edits (editors, agents with their own file tools) are picked up
|
|
16
|
+
* by the per-project fs watcher and the fs REST hooks, which funnel into the
|
|
17
|
+
* same reconcile path. Reconcile is idempotent: unchanged revisions emit no
|
|
18
|
+
* events.
|
|
19
|
+
*
|
|
20
|
+
* Everything that emits events runs in the amalgm-mcp process — Local Live
|
|
21
|
+
* only streams events appended in-process. chat-core (chat-server process)
|
|
22
|
+
* must stay read-only against this table.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
const { openLocalDb } = require('../state/db');
|
|
30
|
+
const { insertStateEvent, publishStateEvent } = require('../state/events');
|
|
31
|
+
const { AMALGM_DIR } = require('../config');
|
|
32
|
+
const { canonicalProjectPath } = require('../../lib/project-paths');
|
|
33
|
+
const {
|
|
34
|
+
CHANGE_LOG_TITLE,
|
|
35
|
+
INSTRUCTIONS_TITLE,
|
|
36
|
+
PROJECT_STATE_TITLE,
|
|
37
|
+
changeLogEntryHeading,
|
|
38
|
+
changeLogRecentWindow,
|
|
39
|
+
cappedDocContent,
|
|
40
|
+
contextPaths,
|
|
41
|
+
insertChangeLogEntry,
|
|
42
|
+
isEmptyContextDoc,
|
|
43
|
+
latestChangeLogEntry,
|
|
44
|
+
localTimestamp,
|
|
45
|
+
projectPathForContextPath,
|
|
46
|
+
sanitizeChangeLogBody,
|
|
47
|
+
} = require('./paths');
|
|
48
|
+
|
|
49
|
+
const RESOURCE = 'project_context';
|
|
50
|
+
const WATCH_DEBOUNCE_MS = 200;
|
|
51
|
+
// The system scope carries user-authored instructions that apply to every
|
|
52
|
+
// session on this computer. It has no change log or project state.
|
|
53
|
+
const SYSTEM_CONTEXT_ID = 'system';
|
|
54
|
+
|
|
55
|
+
const watchers = new Map(); // projectId -> { watcher, timer, projectPath }
|
|
56
|
+
|
|
57
|
+
function systemProject() {
|
|
58
|
+
return { id: SYSTEM_CONTEXT_ID, path: path.resolve(AMALGM_DIR), name: 'Amalgm System' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function systemContextPaths() {
|
|
62
|
+
// AMALGM_DIR *is* the .amalgm dir, so the system context sits directly
|
|
63
|
+
// under it — no extra .amalgm segment like project contexts have.
|
|
64
|
+
const root = path.join(path.resolve(AMALGM_DIR), 'context');
|
|
65
|
+
return { root, instructions: path.join(root, 'instructions.md') };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isSystemProject(project) {
|
|
69
|
+
return project?.id === SYSTEM_CONTEXT_ID;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Containment check against the system context dir that tolerates symlinked
|
|
74
|
+
* roots (macOS /var vs /private/var): compare both the configured and the
|
|
75
|
+
* realpathed form.
|
|
76
|
+
*/
|
|
77
|
+
function isWithinSystemContext(targetPath) {
|
|
78
|
+
const resolved = cleanString(targetPath) ? path.resolve(targetPath) : '';
|
|
79
|
+
if (!resolved) return false;
|
|
80
|
+
const systemRoot = systemContextPaths().root;
|
|
81
|
+
const candidates = new Set([systemRoot]);
|
|
82
|
+
try {
|
|
83
|
+
candidates.add(fs.realpathSync(systemRoot));
|
|
84
|
+
} catch {
|
|
85
|
+
// Dir may not exist yet.
|
|
86
|
+
}
|
|
87
|
+
let realTarget = resolved;
|
|
88
|
+
try {
|
|
89
|
+
realTarget = fs.realpathSync(resolved);
|
|
90
|
+
} catch {
|
|
91
|
+
// Path may not exist; compare the resolved string.
|
|
92
|
+
}
|
|
93
|
+
for (const root of candidates) {
|
|
94
|
+
if (resolved === root || isWithin(root, resolved)) return true;
|
|
95
|
+
if (realTarget === root || isWithin(root, realTarget)) return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function contextPathsFor(project) {
|
|
101
|
+
return isSystemProject(project) ? systemContextPaths() : contextPaths(project.path);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cleanString(value) {
|
|
105
|
+
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function hash(value) {
|
|
109
|
+
return crypto.createHash('sha256').update(String(value ?? '')).digest('hex');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isWithin(root, target) {
|
|
113
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
114
|
+
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Map any path to the registered workspace that contains it (deepest wins).
|
|
119
|
+
* The system, not the model, owns project identity — tool callers may pass a
|
|
120
|
+
* cwd or any path inside a project and land on the canonical workspace.
|
|
121
|
+
*/
|
|
122
|
+
function resolveProject(inputPath) {
|
|
123
|
+
const expanded = canonicalProjectPath(inputPath);
|
|
124
|
+
if (!expanded) return null;
|
|
125
|
+
let resolved = expanded;
|
|
126
|
+
try {
|
|
127
|
+
resolved = fs.realpathSync(expanded);
|
|
128
|
+
} catch {
|
|
129
|
+
// Path may not exist (yet); match on the resolved string.
|
|
130
|
+
}
|
|
131
|
+
// The system context dir is more specific than any workspace — check first.
|
|
132
|
+
if (isWithinSystemContext(resolved)) {
|
|
133
|
+
return systemProject();
|
|
134
|
+
}
|
|
135
|
+
let workspaces = [];
|
|
136
|
+
try {
|
|
137
|
+
workspaces = require('../workspace/store').listWorkspaces();
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
let best = null;
|
|
142
|
+
for (const workspace of workspaces) {
|
|
143
|
+
if (!workspace?.path) continue;
|
|
144
|
+
if (!isWithin(workspace.path, resolved)) continue;
|
|
145
|
+
if (!best || String(workspace.path).length > String(best.path).length) best = workspace;
|
|
146
|
+
}
|
|
147
|
+
if (!best) return null;
|
|
148
|
+
return { id: best.id, path: best.path, name: best.name || path.basename(best.path) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function projectFromInput(input) {
|
|
152
|
+
if (input && typeof input === 'object' && cleanString(input.path)) {
|
|
153
|
+
return {
|
|
154
|
+
id: cleanString(input.id) || null,
|
|
155
|
+
path: path.resolve(input.path),
|
|
156
|
+
name: cleanString(input.name) || path.basename(path.resolve(input.path)),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return resolveProject(input);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function atomicWrite(filePath, content) {
|
|
163
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
164
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
165
|
+
fs.writeFileSync(tmpPath, content, 'utf8');
|
|
166
|
+
fs.renameSync(tmpPath, filePath);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function readDoc(filePath) {
|
|
170
|
+
let content = '';
|
|
171
|
+
let stat = null;
|
|
172
|
+
try {
|
|
173
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
174
|
+
stat = fs.statSync(filePath);
|
|
175
|
+
} catch {
|
|
176
|
+
return { content: '', revision: hash('').slice(0, 16), exists: false, empty: true, size: 0, mtimeMs: 0, updatedAt: null };
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
content,
|
|
180
|
+
revision: hash(content).slice(0, 16),
|
|
181
|
+
exists: true,
|
|
182
|
+
empty: isEmptyContextDoc(content),
|
|
183
|
+
size: stat.size,
|
|
184
|
+
mtimeMs: Math.round(stat.mtimeMs),
|
|
185
|
+
updatedAt: stat.mtime.toISOString(),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function statFingerprint(paths) {
|
|
190
|
+
return [paths.instructions, paths.changeLog, paths.projectState]
|
|
191
|
+
.filter(Boolean)
|
|
192
|
+
.map((filePath) => {
|
|
193
|
+
try {
|
|
194
|
+
const stat = fs.statSync(filePath);
|
|
195
|
+
return `${Math.round(stat.mtimeMs)}:${stat.size}`;
|
|
196
|
+
} catch {
|
|
197
|
+
return 'missing';
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
.join('|');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function docMeta(doc) {
|
|
204
|
+
return {
|
|
205
|
+
revision: doc.revision,
|
|
206
|
+
exists: doc.exists,
|
|
207
|
+
empty: doc.empty,
|
|
208
|
+
size: doc.size,
|
|
209
|
+
updatedAt: doc.updatedAt,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildContextRecord(project) {
|
|
214
|
+
if (isSystemProject(project)) {
|
|
215
|
+
const paths = systemContextPaths();
|
|
216
|
+
const instructions = readDoc(paths.instructions);
|
|
217
|
+
return {
|
|
218
|
+
id: SYSTEM_CONTEXT_ID,
|
|
219
|
+
projectId: SYSTEM_CONTEXT_ID,
|
|
220
|
+
kind: 'system',
|
|
221
|
+
name: project.name || 'Amalgm System',
|
|
222
|
+
projectPath: project.path,
|
|
223
|
+
contextRevision: hash(instructions.revision).slice(0, 20),
|
|
224
|
+
statFingerprint: statFingerprint(paths),
|
|
225
|
+
paths,
|
|
226
|
+
files: {
|
|
227
|
+
instructions: docMeta(instructions),
|
|
228
|
+
},
|
|
229
|
+
prompt: {
|
|
230
|
+
instructions: instructions.empty ? '' : cappedDocContent(instructions.content),
|
|
231
|
+
},
|
|
232
|
+
updatedAt: new Date().toISOString(),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const paths = contextPaths(project.path);
|
|
237
|
+
const instructions = readDoc(paths.instructions);
|
|
238
|
+
const changeLog = readDoc(paths.changeLog);
|
|
239
|
+
const projectState = readDoc(paths.projectState);
|
|
240
|
+
const latestEntry = latestChangeLogEntry(changeLog.content);
|
|
241
|
+
const contextRevision = hash(
|
|
242
|
+
[instructions.revision, changeLog.revision, projectState.revision].join(':'),
|
|
243
|
+
).slice(0, 20);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
id: project.id,
|
|
247
|
+
projectId: project.id,
|
|
248
|
+
kind: 'project',
|
|
249
|
+
name: project.name,
|
|
250
|
+
projectPath: project.path,
|
|
251
|
+
contextRevision,
|
|
252
|
+
statFingerprint: statFingerprint(paths),
|
|
253
|
+
paths,
|
|
254
|
+
files: {
|
|
255
|
+
instructions: docMeta(instructions),
|
|
256
|
+
changeLog: {
|
|
257
|
+
...docMeta(changeLog),
|
|
258
|
+
latestEntryAt: latestEntry.iso,
|
|
259
|
+
latestEntryAtMs: latestEntry.ms,
|
|
260
|
+
},
|
|
261
|
+
projectState: docMeta(projectState),
|
|
262
|
+
},
|
|
263
|
+
prompt: {
|
|
264
|
+
instructions: instructions.empty ? '' : cappedDocContent(instructions.content),
|
|
265
|
+
projectState: projectState.empty ? '' : cappedDocContent(projectState.content),
|
|
266
|
+
changeLogRecent: changeLog.empty ? '' : changeLogRecentWindow(changeLog.content),
|
|
267
|
+
},
|
|
268
|
+
updatedAt: new Date().toISOString(),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseRow(row) {
|
|
273
|
+
if (!row) return null;
|
|
274
|
+
try {
|
|
275
|
+
return JSON.parse(row.context_json);
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readContextRow(projectId) {
|
|
282
|
+
if (!projectId) return null;
|
|
283
|
+
const row = openLocalDb()
|
|
284
|
+
.prepare('SELECT * FROM project_context WHERE project_id = ?')
|
|
285
|
+
.get(projectId);
|
|
286
|
+
return parseRow(row);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function upsertContextRow(record, options = {}) {
|
|
290
|
+
const db = openLocalDb();
|
|
291
|
+
let event = null;
|
|
292
|
+
db.transaction(() => {
|
|
293
|
+
db.prepare(`
|
|
294
|
+
INSERT INTO project_context (project_id, project_path, context_revision, updated_at, context_json)
|
|
295
|
+
VALUES (?, ?, ?, ?, ?)
|
|
296
|
+
ON CONFLICT(project_id) DO UPDATE SET
|
|
297
|
+
project_path = excluded.project_path,
|
|
298
|
+
context_revision = excluded.context_revision,
|
|
299
|
+
updated_at = excluded.updated_at,
|
|
300
|
+
context_json = excluded.context_json
|
|
301
|
+
`).run(record.projectId, record.projectPath, record.contextRevision, record.updatedAt, JSON.stringify(record));
|
|
302
|
+
event = insertStateEvent(db, {
|
|
303
|
+
resource: RESOURCE,
|
|
304
|
+
op: 'update',
|
|
305
|
+
id: record.projectId,
|
|
306
|
+
value: record,
|
|
307
|
+
source: options.source || 'project-context',
|
|
308
|
+
});
|
|
309
|
+
})();
|
|
310
|
+
publishStateEvent(event);
|
|
311
|
+
return record;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Bring the SQLite row in line with disk. Cheap when nothing moved: a stat
|
|
316
|
+
* fingerprint short-circuits before any file is read, and an unchanged
|
|
317
|
+
* contextRevision emits no event.
|
|
318
|
+
*/
|
|
319
|
+
function reconcileProjectContext(input, options = {}) {
|
|
320
|
+
const project = projectFromInput(input);
|
|
321
|
+
if (!project) return null;
|
|
322
|
+
if (!project.id) {
|
|
323
|
+
const registered = resolveProject(project.path);
|
|
324
|
+
if (!registered) return null;
|
|
325
|
+
project.id = registered.id;
|
|
326
|
+
project.path = registered.path;
|
|
327
|
+
project.name = registered.name;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const existing = readContextRow(project.id);
|
|
331
|
+
if (!options.force && existing?.statFingerprint === statFingerprint(contextPathsFor(project))) {
|
|
332
|
+
return existing;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const record = buildContextRecord(project);
|
|
336
|
+
if (!options.force && existing?.contextRevision === record.contextRevision) {
|
|
337
|
+
// Content identical; refresh the fingerprint quietly so the fast path
|
|
338
|
+
// works next time (e.g. after a touch with no content change).
|
|
339
|
+
if (existing.statFingerprint !== record.statFingerprint) {
|
|
340
|
+
openLocalDb()
|
|
341
|
+
.prepare('UPDATE project_context SET context_json = ? WHERE project_id = ?')
|
|
342
|
+
.run(JSON.stringify({ ...existing, statFingerprint: record.statFingerprint }), project.id);
|
|
343
|
+
}
|
|
344
|
+
return existing;
|
|
345
|
+
}
|
|
346
|
+
return upsertContextRow(record, options);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const INSTRUCTIONS_TEMPLATE = `${INSTRUCTIONS_TITLE}\n`;
|
|
350
|
+
const CHANGE_LOG_TEMPLATE = `${CHANGE_LOG_TITLE}\n`;
|
|
351
|
+
const PROJECT_STATE_TEMPLATE = `${PROJECT_STATE_TITLE}\n`;
|
|
352
|
+
|
|
353
|
+
function ensureContextFiles(project) {
|
|
354
|
+
const paths = contextPathsFor(project);
|
|
355
|
+
const docs = isSystemProject(project)
|
|
356
|
+
? [[paths.instructions, INSTRUCTIONS_TEMPLATE]]
|
|
357
|
+
: [
|
|
358
|
+
[paths.instructions, INSTRUCTIONS_TEMPLATE],
|
|
359
|
+
[paths.changeLog, CHANGE_LOG_TEMPLATE],
|
|
360
|
+
[paths.projectState, PROJECT_STATE_TEMPLATE],
|
|
361
|
+
];
|
|
362
|
+
const created = [];
|
|
363
|
+
fs.mkdirSync(paths.root, { recursive: true });
|
|
364
|
+
for (const [filePath, template] of docs) {
|
|
365
|
+
if (!fs.existsSync(filePath)) {
|
|
366
|
+
atomicWrite(filePath, template);
|
|
367
|
+
created.push(filePath);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return created;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Registration-time setup: create the context folder (additive — never touches
|
|
375
|
+
* .gitignore or any existing file), sync the row, and start watching.
|
|
376
|
+
*/
|
|
377
|
+
function ensureProjectContext(workspace, options = {}) {
|
|
378
|
+
const project = projectFromInput(workspace);
|
|
379
|
+
if (!project?.id || !project?.path) return null;
|
|
380
|
+
try {
|
|
381
|
+
ensureContextFiles(project);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
384
|
+
console.warn(`[ProjectContext] Could not create context files for ${project.path}: ${message}`);
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
const record = reconcileProjectContext(project, {
|
|
388
|
+
source: options.source || 'project-context:ensure',
|
|
389
|
+
});
|
|
390
|
+
watchProjectContext(project);
|
|
391
|
+
return record;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function getProjectContext(inputPath, options = {}) {
|
|
395
|
+
const project = projectFromInput(inputPath);
|
|
396
|
+
if (!project?.id) return null;
|
|
397
|
+
if (options.reconcile === false) return readContextRow(project.id);
|
|
398
|
+
return reconcileProjectContext(project, { source: options.source || 'project-context:read' });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Read-only freshness: the stored row when disk hasn't moved, otherwise a
|
|
403
|
+
* record built straight from disk with NO row/event write. Prompt composition
|
|
404
|
+
* runs in the chat-server process, which must not publish Local Live events —
|
|
405
|
+
* only the amalgm-mcp process streams them.
|
|
406
|
+
*/
|
|
407
|
+
function peekProjectContext(inputPath) {
|
|
408
|
+
const project = projectFromInput(inputPath);
|
|
409
|
+
if (!project?.id) return null;
|
|
410
|
+
const existing = readContextRow(project.id);
|
|
411
|
+
if (existing?.statFingerprint === statFingerprint(contextPathsFor(project))) {
|
|
412
|
+
return existing;
|
|
413
|
+
}
|
|
414
|
+
return buildContextRecord(project);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function listProjectContexts(options = {}) {
|
|
418
|
+
let workspaces = [];
|
|
419
|
+
try {
|
|
420
|
+
workspaces = require('../workspace/store').listWorkspaces();
|
|
421
|
+
} catch {
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
const records = [];
|
|
425
|
+
try {
|
|
426
|
+
const system = options.reconcile === false
|
|
427
|
+
? readContextRow(SYSTEM_CONTEXT_ID)
|
|
428
|
+
: reconcileProjectContext(systemProject(), { source: 'project-context:list' });
|
|
429
|
+
if (system) records.push(system);
|
|
430
|
+
} catch {
|
|
431
|
+
// System context is best-effort; projects still list.
|
|
432
|
+
}
|
|
433
|
+
for (const workspace of workspaces) {
|
|
434
|
+
if (!workspace?.path) continue;
|
|
435
|
+
try {
|
|
436
|
+
const record = options.reconcile === false
|
|
437
|
+
? readContextRow(workspace.id)
|
|
438
|
+
: reconcileProjectContext(workspace, { source: 'project-context:list' });
|
|
439
|
+
if (record) records.push(record);
|
|
440
|
+
} catch {
|
|
441
|
+
// One broken project must not take down the snapshot.
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return records;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Append-only change-log write. The model supplies the body; the runtime owns
|
|
449
|
+
* the timestamp (local time with offset + IANA zone), placement directly under
|
|
450
|
+
* the title, the row update, and the Local Live event.
|
|
451
|
+
*/
|
|
452
|
+
function appendChangeLog(inputPath, body, options = {}) {
|
|
453
|
+
const project = projectFromInput(inputPath);
|
|
454
|
+
if (!project?.id) {
|
|
455
|
+
throw new Error(`No registered Amalgm project contains: ${cleanString(typeof inputPath === 'string' ? inputPath : inputPath?.path) || '(no path)'}`);
|
|
456
|
+
}
|
|
457
|
+
if (isSystemProject(project)) {
|
|
458
|
+
throw new Error('The system context has instructions only — change logs are per-project.');
|
|
459
|
+
}
|
|
460
|
+
const entryBody = sanitizeChangeLogBody(body);
|
|
461
|
+
if (!entryBody) throw new Error('Change log entry body is required');
|
|
462
|
+
|
|
463
|
+
ensureContextFiles(project);
|
|
464
|
+
const stamp = localTimestamp();
|
|
465
|
+
const entry = `${changeLogEntryHeading(stamp)}\n\n${entryBody}`;
|
|
466
|
+
const filePath = contextPaths(project.path).changeLog;
|
|
467
|
+
let current = '';
|
|
468
|
+
try {
|
|
469
|
+
current = fs.readFileSync(filePath, 'utf8');
|
|
470
|
+
} catch {
|
|
471
|
+
current = '';
|
|
472
|
+
}
|
|
473
|
+
atomicWrite(filePath, insertChangeLogEntry(current, entry));
|
|
474
|
+
const record = reconcileProjectContext(project, {
|
|
475
|
+
force: true,
|
|
476
|
+
source: options.source || 'project-context:append-change-log',
|
|
477
|
+
});
|
|
478
|
+
watchProjectContext(project);
|
|
479
|
+
return { project, entry, timestamp: stamp, record };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function writeContextDoc(inputPath, docKey, content, options = {}) {
|
|
483
|
+
const project = projectFromInput(inputPath);
|
|
484
|
+
if (!project?.id) {
|
|
485
|
+
throw new Error(`No registered Amalgm project contains: ${cleanString(typeof inputPath === 'string' ? inputPath : inputPath?.path) || '(no path)'}`);
|
|
486
|
+
}
|
|
487
|
+
if (isSystemProject(project) && docKey !== 'instructions') {
|
|
488
|
+
throw new Error('The system context has instructions only — change logs and project state are per-project.');
|
|
489
|
+
}
|
|
490
|
+
if (typeof content !== 'string') throw new Error('content must be a string');
|
|
491
|
+
ensureContextFiles(project);
|
|
492
|
+
const filePath = contextPathsFor(project)[docKey];
|
|
493
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
494
|
+
atomicWrite(filePath, normalized.endsWith('\n') || normalized === '' ? normalized : `${normalized}\n`);
|
|
495
|
+
const record = reconcileProjectContext(project, {
|
|
496
|
+
force: true,
|
|
497
|
+
source: options.source || `project-context:write-${docKey}`,
|
|
498
|
+
});
|
|
499
|
+
watchProjectContext(project);
|
|
500
|
+
return { project, record };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** project-state.md: latest write wins; revision is metadata, not a lock. */
|
|
504
|
+
function writeProjectState(inputPath, content, options = {}) {
|
|
505
|
+
return writeContextDoc(inputPath, 'projectState', content, {
|
|
506
|
+
source: options.source || 'project-context:write-project-state',
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** instructions.md: user-authored; latest write wins via the UI path. */
|
|
511
|
+
function writeInstructions(inputPath, content, options = {}) {
|
|
512
|
+
return writeContextDoc(inputPath, 'instructions', content, {
|
|
513
|
+
source: options.source || 'project-context:write-instructions',
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* change-log.md full write — the human curation path (UI editor). Agents must
|
|
519
|
+
* use appendChangeLog; this exists so the user can prune or fix history.
|
|
520
|
+
*/
|
|
521
|
+
function writeChangeLog(inputPath, content, options = {}) {
|
|
522
|
+
return writeContextDoc(inputPath, 'changeLog', content, {
|
|
523
|
+
source: options.source || 'project-context:write-change-log',
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function removeProjectContext(projectId, options = {}) {
|
|
528
|
+
if (!projectId || projectId === SYSTEM_CONTEXT_ID) return;
|
|
529
|
+
closeWatcher(projectId);
|
|
530
|
+
const db = openLocalDb();
|
|
531
|
+
let event = null;
|
|
532
|
+
db.transaction(() => {
|
|
533
|
+
const removed = db.prepare('DELETE FROM project_context WHERE project_id = ?').run(projectId);
|
|
534
|
+
if (removed.changes > 0) {
|
|
535
|
+
event = insertStateEvent(db, {
|
|
536
|
+
resource: RESOURCE,
|
|
537
|
+
op: 'delete',
|
|
538
|
+
id: projectId,
|
|
539
|
+
source: options.source || 'project-context:remove',
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
})();
|
|
543
|
+
publishStateEvent(event);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function closeWatcher(projectId) {
|
|
547
|
+
const entry = watchers.get(projectId);
|
|
548
|
+
if (!entry) return;
|
|
549
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
550
|
+
try {
|
|
551
|
+
entry.watcher.close();
|
|
552
|
+
} catch {
|
|
553
|
+
// Already closed.
|
|
554
|
+
}
|
|
555
|
+
watchers.delete(projectId);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Watch a project's context dir so out-of-band edits (VS Code, agents with
|
|
560
|
+
* their own file tools) reconcile into the canonical row. Self-writes are
|
|
561
|
+
* harmless: by the time the debounce fires, disk matches the row and no event
|
|
562
|
+
* is emitted.
|
|
563
|
+
*/
|
|
564
|
+
function watchProjectContext(input) {
|
|
565
|
+
const project = projectFromInput(input);
|
|
566
|
+
if (!project?.id || !project?.path) return;
|
|
567
|
+
const existing = watchers.get(project.id);
|
|
568
|
+
if (existing?.projectPath === project.path) return;
|
|
569
|
+
if (existing) closeWatcher(project.id);
|
|
570
|
+
|
|
571
|
+
const root = contextPathsFor(project).root;
|
|
572
|
+
let watcher;
|
|
573
|
+
try {
|
|
574
|
+
watcher = fs.watch(root, { persistent: false }, () => {
|
|
575
|
+
const entry = watchers.get(project.id);
|
|
576
|
+
if (!entry) return;
|
|
577
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
578
|
+
entry.timer = setTimeout(() => {
|
|
579
|
+
entry.timer = null;
|
|
580
|
+
try {
|
|
581
|
+
reconcileProjectContext(project, { source: 'project-context:watch' });
|
|
582
|
+
} catch (error) {
|
|
583
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
584
|
+
console.warn(`[ProjectContext] Watch reconcile failed for ${project.path}: ${message}`);
|
|
585
|
+
}
|
|
586
|
+
}, WATCH_DEBOUNCE_MS);
|
|
587
|
+
if (typeof entry.timer.unref === 'function') entry.timer.unref();
|
|
588
|
+
});
|
|
589
|
+
} catch {
|
|
590
|
+
// Context dir missing or unwatchable — best effort; REST hooks still cover writes.
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
watcher.on('error', () => closeWatcher(project.id));
|
|
594
|
+
if (typeof watcher.unref === 'function') watcher.unref();
|
|
595
|
+
watchers.set(project.id, { watcher, timer: null, projectPath: project.path });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** fs REST hook: reconcile when a context doc is touched outside the store. */
|
|
599
|
+
function notifyProjectContextPathChanged(filePath, source = 'fs') {
|
|
600
|
+
try {
|
|
601
|
+
if (isWithinSystemContext(filePath)) {
|
|
602
|
+
reconcileProjectContext(systemProject(), { source: `project-context:${source}` });
|
|
603
|
+
watchProjectContext(systemProject());
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const projectPath = projectPathForContextPath(filePath);
|
|
607
|
+
if (!projectPath) return;
|
|
608
|
+
const project = resolveProject(projectPath);
|
|
609
|
+
if (!project) return;
|
|
610
|
+
reconcileProjectContext(project, { source: `project-context:${source}` });
|
|
611
|
+
watchProjectContext(project);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
614
|
+
console.warn(`[ProjectContext] Reconcile after ${source} failed: ${message}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Boot-time pass: every registered workspace gets context files, a fresh row,
|
|
620
|
+
* and a watcher. Existing projects registered before this feature are picked
|
|
621
|
+
* up here.
|
|
622
|
+
*/
|
|
623
|
+
function startProjectContextService() {
|
|
624
|
+
try {
|
|
625
|
+
ensureProjectContext(systemProject(), { source: 'project-context:boot' });
|
|
626
|
+
} catch (error) {
|
|
627
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
628
|
+
console.warn(`[ProjectContext] System context setup failed: ${message}`);
|
|
629
|
+
}
|
|
630
|
+
let workspaces = [];
|
|
631
|
+
try {
|
|
632
|
+
workspaces = require('../workspace/store').listWorkspaces();
|
|
633
|
+
} catch (error) {
|
|
634
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
635
|
+
console.warn(`[ProjectContext] Could not list workspaces at boot: ${message}`);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
for (const workspace of workspaces) {
|
|
639
|
+
if (!workspace?.path) continue;
|
|
640
|
+
try {
|
|
641
|
+
if (!fs.existsSync(workspace.path)) continue;
|
|
642
|
+
ensureProjectContext(workspace, { source: 'project-context:boot' });
|
|
643
|
+
} catch (error) {
|
|
644
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
645
|
+
console.warn(`[ProjectContext] Boot setup failed for ${workspace.path}: ${message}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function stopProjectContextService() {
|
|
651
|
+
for (const projectId of Array.from(watchers.keys())) closeWatcher(projectId);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
module.exports = {
|
|
655
|
+
RESOURCE,
|
|
656
|
+
appendChangeLog,
|
|
657
|
+
ensureProjectContext,
|
|
658
|
+
getProjectContext,
|
|
659
|
+
listProjectContexts,
|
|
660
|
+
notifyProjectContextPathChanged,
|
|
661
|
+
peekProjectContext,
|
|
662
|
+
reconcileProjectContext,
|
|
663
|
+
removeProjectContext,
|
|
664
|
+
resolveProject,
|
|
665
|
+
startProjectContextService,
|
|
666
|
+
stopProjectContextService,
|
|
667
|
+
watchProjectContext,
|
|
668
|
+
writeChangeLog,
|
|
669
|
+
writeInstructions,
|
|
670
|
+
writeProjectState,
|
|
671
|
+
};
|