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,328 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-project-context-test-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
11
|
+
process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
|
|
12
|
+
process.env.AMALGM_COMPUTER_ID = 'computer-test';
|
|
13
|
+
|
|
14
|
+
const { closeLocalDb } = require('../state/db');
|
|
15
|
+
const { currentSeq, listEventsAfter } = require('../state/events');
|
|
16
|
+
const { upsertWorkspace } = require('../workspace/store');
|
|
17
|
+
const store = require('../project-context/store');
|
|
18
|
+
const prompt = require('../project-context/prompt');
|
|
19
|
+
const {
|
|
20
|
+
changeLogEntriesSince,
|
|
21
|
+
changeLogRecentWindow,
|
|
22
|
+
insertChangeLogEntry,
|
|
23
|
+
latestChangeLogEntry,
|
|
24
|
+
localTimestamp,
|
|
25
|
+
sanitizeChangeLogBody,
|
|
26
|
+
} = require('../project-context/paths');
|
|
27
|
+
|
|
28
|
+
function makeProject(name) {
|
|
29
|
+
const projectPath = fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, `${name}-`)));
|
|
30
|
+
const workspace = upsertWorkspace({ path: projectPath, name, source: 'computer' }, { source: 'test' });
|
|
31
|
+
return { projectPath, workspace };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readContextFile(projectPath, fileName) {
|
|
35
|
+
return fs.readFileSync(path.join(projectPath, '.amalgm', 'context', fileName), 'utf8');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test.after(() => {
|
|
39
|
+
store.stopProjectContextService();
|
|
40
|
+
closeLocalDb();
|
|
41
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('ensureProjectContext creates the three docs and a row, additively', () => {
|
|
45
|
+
const { projectPath, workspace } = makeProject('ensure');
|
|
46
|
+
const preexisting = path.join(projectPath, 'keep.txt');
|
|
47
|
+
fs.writeFileSync(preexisting, 'keep me');
|
|
48
|
+
|
|
49
|
+
const record = store.ensureProjectContext(workspace, { source: 'test' });
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
fs.readdirSync(path.join(projectPath, '.amalgm', 'context')).sort(),
|
|
53
|
+
['change-log.md', 'instructions.md', 'project-state.md'],
|
|
54
|
+
);
|
|
55
|
+
assert.equal(fs.readFileSync(preexisting, 'utf8'), 'keep me');
|
|
56
|
+
assert.equal(record.projectId, workspace.id);
|
|
57
|
+
assert.ok(record.contextRevision);
|
|
58
|
+
// Existing content is never clobbered by a second ensure.
|
|
59
|
+
fs.writeFileSync(path.join(projectPath, '.amalgm/context/instructions.md'), '# Instructions\n\nKeep these.\n');
|
|
60
|
+
store.ensureProjectContext(workspace, { source: 'test' });
|
|
61
|
+
assert.match(readContextFile(projectPath, 'instructions.md'), /Keep these\./);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('appendChangeLog stamps runtime timestamps and prepends under the title', () => {
|
|
65
|
+
const { projectPath, workspace } = makeProject('append');
|
|
66
|
+
store.ensureProjectContext(workspace);
|
|
67
|
+
|
|
68
|
+
const first = store.appendChangeLog(projectPath, '- Completed: first thing.');
|
|
69
|
+
const second = store.appendChangeLog(
|
|
70
|
+
path.join(projectPath),
|
|
71
|
+
'## 2099-01-01T00:00:00Z\n- Completed: model tried to write its own timestamp.',
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const content = readContextFile(projectPath, 'change-log.md');
|
|
75
|
+
const lines = content.split('\n');
|
|
76
|
+
assert.equal(lines[0], '# Change Log');
|
|
77
|
+
// Newest entry sits directly under the title.
|
|
78
|
+
assert.equal(lines[2], `## ${second.timestamp.iso} [${second.timestamp.timeZone}]`);
|
|
79
|
+
assert.ok(content.indexOf('first thing') > content.indexOf('model tried'));
|
|
80
|
+
// The model-written timestamp heading was stripped, runtime timestamp won.
|
|
81
|
+
assert.ok(!content.includes('2099-01-01'));
|
|
82
|
+
assert.match(first.timestamp.iso, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/);
|
|
83
|
+
|
|
84
|
+
assert.throws(() => store.appendChangeLog(projectPath, ' '), /body is required/);
|
|
85
|
+
assert.throws(
|
|
86
|
+
() => store.appendChangeLog(path.join(tempRoot, 'not-registered'), '- x'),
|
|
87
|
+
/No registered Amalgm project/,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('appendChangeLog self-heals a mangled title', () => {
|
|
92
|
+
const { projectPath, workspace } = makeProject('selfheal');
|
|
93
|
+
store.ensureProjectContext(workspace);
|
|
94
|
+
fs.writeFileSync(path.join(projectPath, '.amalgm/context/change-log.md'), 'stray content, no heading\n');
|
|
95
|
+
|
|
96
|
+
store.appendChangeLog(projectPath, '- Completed: healed.');
|
|
97
|
+
const content = readContextFile(projectPath, 'change-log.md');
|
|
98
|
+
assert.ok(content.startsWith('# Change Log\n'));
|
|
99
|
+
assert.ok(content.includes('- Completed: healed.'));
|
|
100
|
+
assert.ok(content.includes('stray content, no heading'));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('writeProjectState is latest-write-wins with revision metadata', () => {
|
|
104
|
+
const { projectPath, workspace } = makeProject('state');
|
|
105
|
+
store.ensureProjectContext(workspace);
|
|
106
|
+
|
|
107
|
+
const first = store.writeProjectState(projectPath, '# Project State\n\nDraft.');
|
|
108
|
+
const second = store.writeProjectState(projectPath, '# Project State\n\nFinal.');
|
|
109
|
+
|
|
110
|
+
assert.equal(readContextFile(projectPath, 'project-state.md'), '# Project State\n\nFinal.\n');
|
|
111
|
+
assert.notEqual(
|
|
112
|
+
first.record.files.projectState.revision,
|
|
113
|
+
second.record.files.projectState.revision,
|
|
114
|
+
);
|
|
115
|
+
assert.notEqual(first.record.contextRevision, second.record.contextRevision);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('reconcile is idempotent and catches out-of-band edits', () => {
|
|
119
|
+
const { projectPath, workspace } = makeProject('reconcile');
|
|
120
|
+
store.ensureProjectContext(workspace);
|
|
121
|
+
store.appendChangeLog(projectPath, '- Completed: baseline.');
|
|
122
|
+
|
|
123
|
+
const before = currentSeq();
|
|
124
|
+
store.reconcileProjectContext(projectPath);
|
|
125
|
+
assert.equal(currentSeq(), before, 'unchanged disk must not emit events');
|
|
126
|
+
|
|
127
|
+
fs.appendFileSync(path.join(projectPath, '.amalgm/context/project-state.md'), '\nEdited in VS Code.\n');
|
|
128
|
+
store.notifyProjectContextPathChanged(path.join(projectPath, '.amalgm/context/project-state.md'), 'fs:write');
|
|
129
|
+
|
|
130
|
+
const events = listEventsAfter(before).filter((event) => event.resource === 'project_context');
|
|
131
|
+
assert.equal(events.length, 1);
|
|
132
|
+
assert.ok(events[0].value.prompt.projectState.includes('Edited in VS Code.'));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('snapshot resource lists project contexts', () => {
|
|
136
|
+
const { workspace } = makeProject('snapshot');
|
|
137
|
+
store.ensureProjectContext(workspace);
|
|
138
|
+
const snapshot = require('../state/snapshot').buildSnapshot('project_context');
|
|
139
|
+
assert.ok(Array.isArray(snapshot.resources.project_context));
|
|
140
|
+
assert.ok(snapshot.resources.project_context.some((row) => row.projectId === workspace.id));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('change-log window cuts on entry boundaries', () => {
|
|
144
|
+
const stamp = '## 2026-06-09T10:00:00-07:00 [America/Los_Angeles]';
|
|
145
|
+
let content = '# Change Log\n';
|
|
146
|
+
for (let i = 0; i < 5; i += 1) {
|
|
147
|
+
content = insertChangeLogEntry(content, `${stamp}\n\n- Entry ${i} ${'x'.repeat(50)}`);
|
|
148
|
+
}
|
|
149
|
+
const windowed = changeLogRecentWindow(content, 150);
|
|
150
|
+
assert.ok(windowed.startsWith('## '), 'window starts at an entry boundary');
|
|
151
|
+
assert.ok(windowed.includes('[older entries truncated]'));
|
|
152
|
+
// Never a dangling half-entry: every kept chunk begins with its heading.
|
|
153
|
+
for (const chunk of windowed.split('\n\n## ')) {
|
|
154
|
+
assert.ok(!chunk.startsWith('- Entry') || chunk.includes('##') === false);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('changeLogEntriesSince returns only newer entries', () => {
|
|
159
|
+
let content = '# Change Log\n';
|
|
160
|
+
content = insertChangeLogEntry(content, '## 2026-06-09T10:00:00-07:00 [America/Los_Angeles]\n\n- old');
|
|
161
|
+
content = insertChangeLogEntry(content, '## 2026-06-09T11:00:00-07:00 [America/Los_Angeles]\n\n- new');
|
|
162
|
+
const since = changeLogEntriesSince(content, Date.parse('2026-06-09T10:30:00-07:00'));
|
|
163
|
+
assert.ok(since.includes('- new'));
|
|
164
|
+
assert.ok(!since.includes('- old'));
|
|
165
|
+
assert.equal(changeLogEntriesSince(content, Date.parse('2026-06-09T12:00:00-07:00')), '');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('helper edge cases: sanitize, latest entry, timestamps', () => {
|
|
169
|
+
assert.equal(sanitizeChangeLogBody('## 2026-01-01T00:00:00Z [UTC]\n\n- body'), '- body');
|
|
170
|
+
assert.equal(sanitizeChangeLogBody('### Sub-heading stays\n- body'), '### Sub-heading stays\n- body');
|
|
171
|
+
const latest = latestChangeLogEntry('# Change Log\n\n## 2026-06-09T10:00:00-07:00 [America/Los_Angeles]\n\n- x');
|
|
172
|
+
assert.equal(latest.iso, '2026-06-09T10:00:00-07:00');
|
|
173
|
+
assert.equal(latest.ms, Date.parse('2026-06-09T10:00:00-07:00'));
|
|
174
|
+
const stamp = localTimestamp(new Date('2026-06-09T12:34:56-07:00'));
|
|
175
|
+
assert.match(stamp.iso, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/);
|
|
176
|
+
assert.ok(stamp.timeZone.length > 0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('fresh threads get the full prompt block; resumed threads get deltas', async () => {
|
|
180
|
+
const { projectPath, workspace } = makeProject('prompt');
|
|
181
|
+
store.ensureProjectContext(workspace);
|
|
182
|
+
store.appendChangeLog(projectPath, '- Completed: baseline work.');
|
|
183
|
+
store.writeProjectState(projectPath, '# Project State\n\nBaseline state.');
|
|
184
|
+
|
|
185
|
+
const fresh = { sessionId: 'sess-prompt', cwd: projectPath, providerSessionId: null };
|
|
186
|
+
const block = prompt.projectContextPromptBlock(fresh);
|
|
187
|
+
assert.ok(block.includes('<project_context>'));
|
|
188
|
+
assert.ok(block.includes('Baseline state.'));
|
|
189
|
+
assert.ok(block.includes('baseline work.'));
|
|
190
|
+
assert.ok(block.includes('toolbox__memories_append_change_log'));
|
|
191
|
+
// Empty instructions are omitted entirely.
|
|
192
|
+
assert.ok(!block.includes('<project_instructions'));
|
|
193
|
+
|
|
194
|
+
const resumed = { sessionId: 'sess-prompt', cwd: projectPath, providerSessionId: 'prov-1' };
|
|
195
|
+
assert.equal(prompt.projectContextPromptBlock(resumed), '', 'resumed threads skip the system block');
|
|
196
|
+
assert.equal(prompt.projectContextTurnUpdateBlock(resumed), '', 'no changes -> no update');
|
|
197
|
+
|
|
198
|
+
// Same-second append must still surface (second-resolution timestamps).
|
|
199
|
+
store.appendChangeLog(projectPath, '- Completed: same-second follow-up.');
|
|
200
|
+
const update = prompt.projectContextTurnUpdateBlock(resumed);
|
|
201
|
+
assert.ok(update.includes('<project_context_update>'));
|
|
202
|
+
assert.ok(update.includes('same-second follow-up.'));
|
|
203
|
+
assert.ok(!update.includes('Baseline state.'), 'unchanged state stays out of the delta');
|
|
204
|
+
assert.equal(prompt.projectContextTurnUpdateBlock(resumed), '', 'update settles');
|
|
205
|
+
|
|
206
|
+
// State rewrite -> full new state in the delta.
|
|
207
|
+
store.writeProjectState(projectPath, '# Project State\n\nNew state.');
|
|
208
|
+
const stateUpdate = prompt.projectContextTurnUpdateBlock(resumed);
|
|
209
|
+
assert.ok(stateUpdate.includes('New state.'));
|
|
210
|
+
assert.ok(!stateUpdate.includes('new_change_log_entries'));
|
|
211
|
+
|
|
212
|
+
// A session with no seen-state (predates the feature) gets the full context once.
|
|
213
|
+
const legacy = { sessionId: 'sess-legacy', cwd: projectPath, providerSessionId: 'prov-2' };
|
|
214
|
+
const legacyUpdate = prompt.projectContextTurnUpdateBlock(legacy);
|
|
215
|
+
assert.ok(legacyUpdate.includes('project_memory_protocol'));
|
|
216
|
+
|
|
217
|
+
// Outside any registered project -> nothing.
|
|
218
|
+
assert.equal(
|
|
219
|
+
prompt.projectContextPromptBlock({ sessionId: 's', cwd: path.join(tempRoot, 'unregistered') }),
|
|
220
|
+
'',
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('memories tools resolve the session project and enforce registration', async () => {
|
|
225
|
+
const { projectPath, workspace } = makeProject('tools');
|
|
226
|
+
store.ensureProjectContext(workspace);
|
|
227
|
+
const tools = require('../project-context/tools');
|
|
228
|
+
const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
|
|
229
|
+
const context = { callerSessionId: 'sess-tools', sessionMetadata: { cwd: projectPath } };
|
|
230
|
+
|
|
231
|
+
const appended = await byName.append_change_log.handler({ body: '- Completed: via tool.' }, context);
|
|
232
|
+
assert.ok(!appended.isError);
|
|
233
|
+
assert.ok(readContextFile(projectPath, 'change-log.md').includes('via tool.'));
|
|
234
|
+
|
|
235
|
+
const wrote = await byName.write_project_state.handler({ content: '# Project State\n\nTool state.' }, context);
|
|
236
|
+
assert.ok(!wrote.isError);
|
|
237
|
+
|
|
238
|
+
const read = await byName.read_project_context.handler({}, context);
|
|
239
|
+
assert.ok(read.content[0].text.includes('Tool state.'));
|
|
240
|
+
|
|
241
|
+
const missing = await byName.append_change_log.handler({ body: '- x' }, { callerSessionId: 'no-cwd' });
|
|
242
|
+
assert.ok(missing.isError);
|
|
243
|
+
assert.match(missing.content[0].text, /No registered Amalgm project/);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('system scope: instructions-only record, guarded writes, canonical save path', () => {
|
|
247
|
+
const systemContextDir = path.join(process.env.AMALGM_DIR, 'context');
|
|
248
|
+
const systemInstructionsPath = path.join(systemContextDir, 'instructions.md');
|
|
249
|
+
|
|
250
|
+
// The boot pass owns file creation for the system scope.
|
|
251
|
+
store.startProjectContextService();
|
|
252
|
+
|
|
253
|
+
const records = store.listProjectContexts();
|
|
254
|
+
const system = records.find((record) => record.kind === 'system');
|
|
255
|
+
assert.ok(system, 'system row present');
|
|
256
|
+
assert.equal(records[0].kind, 'system', 'system row sorts first');
|
|
257
|
+
assert.equal(system.id, 'system');
|
|
258
|
+
assert.equal(system.name, 'Amalgm System');
|
|
259
|
+
assert.equal(system.paths.instructions, systemInstructionsPath);
|
|
260
|
+
assert.equal(system.paths.changeLog, undefined, 'system has no change log');
|
|
261
|
+
assert.equal(system.files.projectState, undefined, 'system has no project state');
|
|
262
|
+
assert.ok(fs.existsSync(systemInstructionsPath), 'boot/list ensured the file');
|
|
263
|
+
assert.deepEqual(
|
|
264
|
+
fs.readdirSync(systemContextDir).filter((name) => name.endsWith('.md')).sort(),
|
|
265
|
+
['instructions.md'],
|
|
266
|
+
'only instructions.md is created for the system scope',
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// The UI editor saves by file path — it must resolve to the system scope.
|
|
270
|
+
const before = currentSeq();
|
|
271
|
+
const written = store.writeInstructions(systemInstructionsPath, '# Instructions\n\nAlways be definitive.');
|
|
272
|
+
assert.equal(written.project.id, 'system');
|
|
273
|
+
const events = listEventsAfter(before).filter((event) => event.resource === 'project_context');
|
|
274
|
+
assert.equal(events.length, 1);
|
|
275
|
+
assert.equal(events[0].id, 'system');
|
|
276
|
+
assert.ok(events[0].value.prompt.instructions.includes('Always be definitive.'));
|
|
277
|
+
|
|
278
|
+
// System scope rejects per-project docs.
|
|
279
|
+
assert.throws(() => store.appendChangeLog(systemInstructionsPath, '- x'), /instructions only/);
|
|
280
|
+
assert.throws(() => store.writeProjectState(systemContextDir, '# Project State'), /instructions only/);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('system instructions prompt block reads the canonical file', () => {
|
|
284
|
+
const { systemInstructionsBlock, systemInstructionsPath } = require('../../chat-core/tooling/system-instructions');
|
|
285
|
+
const filePath = systemInstructionsPath();
|
|
286
|
+
|
|
287
|
+
fs.writeFileSync(filePath, '# Instructions\n\nNo instructions for Amalgm System yet.\n');
|
|
288
|
+
assert.equal(systemInstructionsBlock(), '', 'legacy placeholder counts as empty');
|
|
289
|
+
|
|
290
|
+
fs.writeFileSync(filePath, '# Instructions\n\nPrefer pnpm everywhere.\n');
|
|
291
|
+
const block = systemInstructionsBlock();
|
|
292
|
+
assert.ok(block.includes('<system_instructions'));
|
|
293
|
+
assert.ok(block.includes('Prefer pnpm everywhere.'));
|
|
294
|
+
|
|
295
|
+
fs.writeFileSync(filePath, '# Instructions\n');
|
|
296
|
+
assert.equal(systemInstructionsBlock(), '', 'blank doc injects nothing');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('workspace removal drops the project_context row', () => {
|
|
300
|
+
const { workspace } = makeProject('remove');
|
|
301
|
+
store.ensureProjectContext(workspace);
|
|
302
|
+
assert.ok(store.getProjectContext(workspace.path, { reconcile: false }));
|
|
303
|
+
store.removeProjectContext(workspace.id, { source: 'test' });
|
|
304
|
+
assert.equal(store.getProjectContext(workspace.path, { reconcile: false }), null);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('watcher reconciles direct disk edits', async () => {
|
|
308
|
+
const { projectPath, workspace } = makeProject('watch');
|
|
309
|
+
store.ensureProjectContext(workspace);
|
|
310
|
+
const before = store.getProjectContext(projectPath, { reconcile: false }).contextRevision;
|
|
311
|
+
|
|
312
|
+
fs.writeFileSync(
|
|
313
|
+
path.join(projectPath, '.amalgm/context/project-state.md'),
|
|
314
|
+
'# Project State\n\nWatched edit.\n',
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// fs.watch + 200ms debounce; poll the row instead of guessing exact timing.
|
|
318
|
+
const deadline = Date.now() + 3000;
|
|
319
|
+
let row = null;
|
|
320
|
+
while (Date.now() < deadline) {
|
|
321
|
+
row = store.getProjectContext(projectPath, { reconcile: false });
|
|
322
|
+
if (row && row.contextRevision !== before) break;
|
|
323
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
324
|
+
}
|
|
325
|
+
assert.ok(row, 'row exists');
|
|
326
|
+
assert.notEqual(row.contextRevision, before, 'watcher updated the row');
|
|
327
|
+
assert.ok(row.prompt.projectState.includes('Watched edit.'));
|
|
328
|
+
});
|
|
@@ -17,12 +17,12 @@ test('system toolbox catalog exposes first-party default tools', () => {
|
|
|
17
17
|
assert.equal(catalog.tools.browser.origin, 'system');
|
|
18
18
|
assert.equal(catalog.tools.browser.policy.firstParty, true);
|
|
19
19
|
assert.equal(
|
|
20
|
-
catalog.toolActions['browser.
|
|
21
|
-
'
|
|
20
|
+
catalog.toolActions['browser.browser_open'].sourceAction.mcpToolName,
|
|
21
|
+
'toolbox__browser_browser_open',
|
|
22
22
|
);
|
|
23
23
|
assert.equal(
|
|
24
|
-
catalog.toolActions['browser.
|
|
25
|
-
'
|
|
24
|
+
catalog.toolActions['browser.browser_open'].sourceAction.handlerName,
|
|
25
|
+
'browser_open',
|
|
26
26
|
);
|
|
27
27
|
assert.equal(
|
|
28
28
|
catalog.toolActions['notifications.notify_user'].sourceAction.mcpToolName,
|
|
@@ -46,8 +46,8 @@ test('system toolbox catalog merge preserves persisted records', () => {
|
|
|
46
46
|
},
|
|
47
47
|
},
|
|
48
48
|
toolActions: {
|
|
49
|
-
'browser.
|
|
50
|
-
id: 'browser.
|
|
49
|
+
'browser.browser_open': {
|
|
50
|
+
id: 'browser.browser_open',
|
|
51
51
|
toolId: 'browser',
|
|
52
52
|
name: 'persisted_navigate',
|
|
53
53
|
},
|
|
@@ -57,7 +57,7 @@ test('system toolbox catalog merge preserves persisted records', () => {
|
|
|
57
57
|
assert.equal(merged.tools.browser.name, 'Persisted Browser');
|
|
58
58
|
assert.equal(merged.tools['custom.cli'].name, 'Custom CLI');
|
|
59
59
|
assert.equal(
|
|
60
|
-
merged.toolActions['browser.
|
|
60
|
+
merged.toolActions['browser.browser_open'].name,
|
|
61
61
|
'persisted_navigate',
|
|
62
62
|
);
|
|
63
63
|
assert.equal(merged.toolActions['apps.apps_list'].name, 'apps_list');
|
|
@@ -72,7 +72,7 @@ test('system toolbox catalog omits legacy aggregate amalgm records', () => {
|
|
|
72
72
|
},
|
|
73
73
|
toolActions: {
|
|
74
74
|
'amalgm.notify_user': { id: 'amalgm.notify_user', toolId: 'amalgm' },
|
|
75
|
-
'amalgm.browser.
|
|
75
|
+
'amalgm.browser.browser_open': { id: 'amalgm.browser.browser_open', toolId: 'amalgm.browser' },
|
|
76
76
|
'custom.cli.run': { id: 'custom.cli.run', toolId: 'custom.cli' },
|
|
77
77
|
},
|
|
78
78
|
});
|
|
@@ -80,7 +80,7 @@ test('system toolbox catalog omits legacy aggregate amalgm records', () => {
|
|
|
80
80
|
assert.equal(filtered.tools.amalgm, undefined);
|
|
81
81
|
assert.equal(filtered.tools['amalgm.browser'], undefined);
|
|
82
82
|
assert.equal(filtered.toolActions['amalgm.notify_user'], undefined);
|
|
83
|
-
assert.equal(filtered.toolActions['amalgm.browser.
|
|
83
|
+
assert.equal(filtered.toolActions['amalgm.browser.browser_open'], undefined);
|
|
84
84
|
assert.equal(filtered.tools['custom.cli'].id, 'custom.cli');
|
|
85
85
|
assert.equal(filtered.toolActions['custom.cli.run'].id, 'custom.cli.run');
|
|
86
86
|
});
|
|
@@ -161,7 +161,7 @@ test('toolbox service overlays first-party system catalog entries', () => {
|
|
|
161
161
|
const service = createToolboxService(createRepository());
|
|
162
162
|
const catalog = service.readCatalog();
|
|
163
163
|
const browser = service.getTool('browser', { includeActions: false });
|
|
164
|
-
const navigate = service.getAction('browser.
|
|
164
|
+
const navigate = service.getAction('browser.browser_open');
|
|
165
165
|
|
|
166
166
|
assert.equal(catalog.tools.amalgm, undefined);
|
|
167
167
|
assert.equal(catalog.toolActions['amalgm.notify_user'], undefined);
|
|
@@ -170,7 +170,7 @@ test('toolbox service overlays first-party system catalog entries', () => {
|
|
|
170
170
|
assert.equal(catalog.tools.browser.owner, 'amalgm');
|
|
171
171
|
assert.equal(browser.tool.name, 'Browser');
|
|
172
172
|
assert.equal(browser.actions, undefined);
|
|
173
|
-
assert.equal(navigate.action.sourceAction.mcpToolName, '
|
|
173
|
+
assert.equal(navigate.action.sourceAction.mcpToolName, 'toolbox__browser_browser_open');
|
|
174
174
|
assert.equal(service.listTools({ origin: 'system' }).tools.some((tool) => tool.id === 'browser'), true);
|
|
175
175
|
});
|
|
176
176
|
|
|
@@ -16,7 +16,6 @@ const { AMALGM_DIR } = require('../config');
|
|
|
16
16
|
const { ensureDir, readJson, writeJsonAtomic } = require('../lib/storage');
|
|
17
17
|
const { openLocalDb } = require('../state/db');
|
|
18
18
|
const { insertStateEvent, publishStateEvent } = require('../state/events');
|
|
19
|
-
const activeMemory = require('../../chat-core/tooling/active-memory');
|
|
20
19
|
|
|
21
20
|
const TOOLBOX_VERSION = 1;
|
|
22
21
|
const TOOLBOX_DIR = path.join(AMALGM_DIR, 'toolbox');
|
|
@@ -658,13 +657,6 @@ function upsertTool(input) {
|
|
|
658
657
|
return inserted;
|
|
659
658
|
})();
|
|
660
659
|
publishCommittedEvents(events);
|
|
661
|
-
if (tool.origin !== 'system') {
|
|
662
|
-
activeMemory.ensureConstructMemory({
|
|
663
|
-
type: 'tool',
|
|
664
|
-
id: tool.id,
|
|
665
|
-
name: tool.name,
|
|
666
|
-
}, { source: 'toolbox:upsert-tool' });
|
|
667
|
-
}
|
|
668
660
|
return { tool, actions };
|
|
669
661
|
}
|
|
670
662
|
|
|
@@ -741,13 +733,6 @@ function updateTool(input) {
|
|
|
741
733
|
})];
|
|
742
734
|
})();
|
|
743
735
|
publishCommittedEvents(events);
|
|
744
|
-
if (tool.origin !== 'system') {
|
|
745
|
-
activeMemory.ensureConstructMemory({
|
|
746
|
-
type: 'tool',
|
|
747
|
-
id: tool.id,
|
|
748
|
-
name: tool.name,
|
|
749
|
-
}, { source: 'toolbox:update-tool' });
|
|
750
|
-
}
|
|
751
736
|
return { tool, actions: toolActionsForTool(toolbox, tool.id) };
|
|
752
737
|
}
|
|
753
738
|
|
|
@@ -40,6 +40,8 @@ function systemToolForGroup(group) {
|
|
|
40
40
|
display: {
|
|
41
41
|
icon: '/assets/images/amalgm_logo.svg',
|
|
42
42
|
description: group.description,
|
|
43
|
+
...(group.family ? { family: group.family } : {}),
|
|
44
|
+
...(group.capability ? { capability: group.capability } : {}),
|
|
43
45
|
},
|
|
44
46
|
policy: {
|
|
45
47
|
system: true,
|
|
@@ -8,7 +8,6 @@ const path = require('path');
|
|
|
8
8
|
const { execFile } = require('child_process');
|
|
9
9
|
const { promisify } = require('util');
|
|
10
10
|
const { AMALGM_DIR, DEFAULT_CWD } = require('../config');
|
|
11
|
-
const activeMemory = require('../../chat-core/tooling/active-memory');
|
|
12
11
|
const {
|
|
13
12
|
listWorkspaces,
|
|
14
13
|
removeWorkspace,
|
|
@@ -50,12 +49,14 @@ async function ensureWorkspaceRoot() {
|
|
|
50
49
|
|
|
51
50
|
function ensureProjectMemory(workspace, source = 'workspace') {
|
|
52
51
|
if (!workspace) return;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
// Project memory lives in .amalgm/context/{instructions,change-log,project-state}.md,
|
|
53
|
+
// owned by the project-context store (row + Local Live event + watcher).
|
|
54
|
+
try {
|
|
55
|
+
require('../project-context/store').ensureProjectContext(workspace, { source });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
console.warn(`[Workspace] project context setup failed for ${workspace.path}: ${message}`);
|
|
59
|
+
}
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
async function uniqueProjectPath(workspaceRoot, requestedName) {
|
|
@@ -249,6 +250,9 @@ async function handleProjectDelete(body, sendJson) {
|
|
|
249
250
|
|
|
250
251
|
try {
|
|
251
252
|
const workspace = removeWorkspace(identifier, { source: 'workspace:delete' });
|
|
253
|
+
if (workspace?.id) {
|
|
254
|
+
require('../project-context/store').removeProjectContext(workspace.id, { source: 'workspace:delete' });
|
|
255
|
+
}
|
|
252
256
|
sendJson(200, { success: true, workspace });
|
|
253
257
|
} catch (error) {
|
|
254
258
|
sendJson(500, {
|
|
@@ -362,7 +362,7 @@ class OpenCodeAdapter {
|
|
|
362
362
|
model,
|
|
363
363
|
agent: process.env.CHAT_CORE_OPENCODE_AGENT || 'build',
|
|
364
364
|
system: composeSystemPrompt(contract) || undefined,
|
|
365
|
-
parts: openCodeParts(input),
|
|
365
|
+
parts: openCodeParts(input, contract),
|
|
366
366
|
},
|
|
367
367
|
});
|
|
368
368
|
promptPromise.then((result) => {
|
|
@@ -278,9 +278,17 @@ class ChatCore {
|
|
|
278
278
|
}));
|
|
279
279
|
}
|
|
280
280
|
} catch (err) {
|
|
281
|
-
append(errorEvent(err.message, { providerSessionId }));
|
|
282
|
-
append(done({ providerSessionId, stopReason: 'error' }));
|
|
283
281
|
stopReason = 'error';
|
|
282
|
+
// Error finalization must never throw: if the raw stream write also
|
|
283
|
+
// fails (e.g. the turn was aborted or sealed mid-stream), the turn
|
|
284
|
+
// would skip mark() below and hold a tempstore slot forever, which
|
|
285
|
+
// blocks runtime idle (and with it auto-update restarts).
|
|
286
|
+
try {
|
|
287
|
+
append(errorEvent(err.message, { providerSessionId }));
|
|
288
|
+
append(done({ providerSessionId, stopReason: 'error' }));
|
|
289
|
+
} catch (finalizeErr) {
|
|
290
|
+
console.error('[ChatCore] failed to finalize errored turn stream:', finalizeErr.message);
|
|
291
|
+
}
|
|
284
292
|
}
|
|
285
293
|
entry.streamEnded = true;
|
|
286
294
|
this.turns.mark(turnId, stopReason === 'error' ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'complete');
|
|
@@ -314,6 +322,12 @@ class ChatCore {
|
|
|
314
322
|
this.turns.clear(turnId);
|
|
315
323
|
return { providerSessionId, usage, parts: savedParts, stopReason };
|
|
316
324
|
} finally {
|
|
325
|
+
// Safety net: whatever path exits runTurn, the turn must end in a
|
|
326
|
+
// terminal state or it permanently occupies a tempstore slot.
|
|
327
|
+
const current = this.turns.get(turnId);
|
|
328
|
+
if (current && ['streaming', 'cancelling'].includes(current.status)) {
|
|
329
|
+
this.turns.mark(turnId, 'error');
|
|
330
|
+
}
|
|
317
331
|
if (resolveStreamCompleted) resolveStreamCompleted();
|
|
318
332
|
if (resolveFinalized) resolveFinalized();
|
|
319
333
|
}
|
|
@@ -24,8 +24,24 @@ function normalizeInput(payload) {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Resumed provider threads cannot change their system prompt without busting
|
|
28
|
+
// the provider's prompt cache, so context freshness rides on the turn instead:
|
|
29
|
+
// when the project context revision moved since this session last saw it, the
|
|
30
|
+
// turn gets a compact update block prepended. Empty in the common case.
|
|
31
|
+
function projectContextTurnUpdate(contract) {
|
|
32
|
+
try {
|
|
33
|
+
return require('../amalgm-mcp/project-context/prompt').projectContextTurnUpdateBlock(contract) || '';
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const message = error instanceof Error ? error.message.split('\n')[0] : String(error);
|
|
36
|
+
console.warn('[chat-core] project context turn update failed:', message);
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
function promptText(input, contract) {
|
|
28
42
|
const chunks = [];
|
|
43
|
+
const contextUpdate = projectContextTurnUpdate(contract);
|
|
44
|
+
if (contextUpdate) chunks.push(contextUpdate);
|
|
29
45
|
if (input.files?.length) {
|
|
30
46
|
chunks.push(`<files>\n${input.files.map((file) => JSON.stringify(file)).join('\n')}\n</files>`);
|
|
31
47
|
}
|
|
@@ -33,9 +49,11 @@ function promptText(input, contract) {
|
|
|
33
49
|
return chunks.filter(Boolean).join('\n\n');
|
|
34
50
|
}
|
|
35
51
|
|
|
36
|
-
function openCodeParts(input) {
|
|
52
|
+
function openCodeParts(input, contract) {
|
|
37
53
|
const text = input.text || input.parts.map(textFromPart).filter(Boolean).join('\n');
|
|
38
54
|
const out = [];
|
|
55
|
+
const contextUpdate = projectContextTurnUpdate(contract);
|
|
56
|
+
if (contextUpdate) out.push({ type: 'text', text: contextUpdate });
|
|
39
57
|
if (text) out.push({ type: 'text', text });
|
|
40
58
|
for (const file of input.files || []) {
|
|
41
59
|
if (file?.path) out.push({ type: 'file', path: file.path });
|
|
@@ -78,13 +78,15 @@ function toolKind(name = '', input = {}, metadata = {}) {
|
|
|
78
78
|
function titleFromMcp(tool, input = {}) {
|
|
79
79
|
const name = String(tool || '');
|
|
80
80
|
const action = name.replace(/^browser_/, '');
|
|
81
|
-
if (name === '
|
|
82
|
-
if (name === 'browser_tabs_new') return input.url ? `Opening browser tab ${hostLabel(input.url)}` : 'Opening browser tab';
|
|
81
|
+
if (name === 'browser_open') return input.url ? `Opening ${hostLabel(input.url)}` : 'Opening browser page';
|
|
83
82
|
if (name === 'browser_snapshot') return 'Reading browser page';
|
|
84
83
|
if (name === 'browser_screenshot') return 'Taking browser screenshot';
|
|
85
|
-
if (name === '
|
|
84
|
+
if (name === 'browser_fill') return input.text ? `Typing "${compactText(input.text, 60)}"` : 'Typing in browser';
|
|
86
85
|
if (name === 'browser_click') return 'Clicking in browser';
|
|
87
|
-
if (name === '
|
|
86
|
+
if (name === 'browser_wait') return 'Waiting for page';
|
|
87
|
+
if (name === 'browser_cli') return Array.isArray(input.args) ? `Browser: ${compactText(input.args.join(' '), 60)}` : 'Running browser command';
|
|
88
|
+
if (name === 'record_start') return 'Starting browser recording';
|
|
89
|
+
if (name === 'record_stop') return 'Saving browser recording';
|
|
88
90
|
if (name === 'apps_register') return input.name ? `Registering app ${compactText(input.name, 60)}` : 'Registering app';
|
|
89
91
|
if (name === 'apps_start') return 'Starting app';
|
|
90
92
|
if (name === 'apps_stop') return 'Stopping app';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* System-level instructions — the one machine-wide context doc.
|
|
5
|
+
*
|
|
6
|
+
* `<AMALGM_DIR>/context/instructions.md` is user-authored and applies to every
|
|
7
|
+
* session on this computer. Project memory (instructions, change log, project
|
|
8
|
+
* state) is handled separately by the project-context block. The legacy
|
|
9
|
+
* active/passive memory blocks are gone — this and project context are the
|
|
10
|
+
* definitive memory surfaces.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { AMALGM_DIR } = require('../../amalgm-mcp/config');
|
|
16
|
+
const { cappedDocContent, isEmptyContextDoc } = require('../../amalgm-mcp/project-context/paths');
|
|
17
|
+
|
|
18
|
+
function systemInstructionsPath() {
|
|
19
|
+
return path.join(path.resolve(AMALGM_DIR), 'context', 'instructions.md');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function escapeAttribute(value) {
|
|
23
|
+
return String(value || '')
|
|
24
|
+
.replace(/&/g, '&')
|
|
25
|
+
.replace(/"/g, '"')
|
|
26
|
+
.replace(/</g, '<')
|
|
27
|
+
.replace(/>/g, '>');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function systemInstructionsBlock() {
|
|
31
|
+
const filePath = systemInstructionsPath();
|
|
32
|
+
let content = '';
|
|
33
|
+
try {
|
|
34
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
35
|
+
} catch {
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
if (isEmptyContextDoc(content)) return '';
|
|
39
|
+
return [
|
|
40
|
+
`<system_instructions path="${escapeAttribute(filePath)}">`,
|
|
41
|
+
'These are user-authored instructions that apply to every session on this computer. Current user requests and higher-priority system/developer instructions win on conflict.',
|
|
42
|
+
'',
|
|
43
|
+
cappedDocContent(content),
|
|
44
|
+
'</system_instructions>',
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
systemInstructionsBlock,
|
|
50
|
+
systemInstructionsPath,
|
|
51
|
+
};
|