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.
Files changed (45) hide show
  1. package/lib/cli.js +16 -6
  2. package/lib/service.js +19 -0
  3. package/lib/supervisor.js +51 -0
  4. package/package.json +1 -1
  5. package/runtime/scripts/amalgm-mcp/apps/supervisor.js +0 -25
  6. package/runtime/scripts/amalgm-mcp/browser/auth-tools.js +179 -0
  7. package/runtime/scripts/amalgm-mcp/browser/cli.js +217 -0
  8. package/runtime/scripts/amalgm-mcp/browser/cua-tools.js +147 -0
  9. package/runtime/scripts/amalgm-mcp/browser/engine.js +454 -0
  10. package/runtime/scripts/amalgm-mcp/browser/recorder-tools.js +82 -0
  11. package/runtime/scripts/amalgm-mcp/browser/recorder.js +199 -0
  12. package/runtime/scripts/amalgm-mcp/browser/rest.js +5 -1
  13. package/runtime/scripts/amalgm-mcp/browser/tools.js +104 -731
  14. package/runtime/scripts/amalgm-mcp/fs/rest.js +5 -6
  15. package/runtime/scripts/amalgm-mcp/index.js +2 -0
  16. package/runtime/scripts/amalgm-mcp/lib/tool-result.js +32 -66
  17. package/runtime/scripts/amalgm-mcp/project-context/paths.js +266 -0
  18. package/runtime/scripts/amalgm-mcp/project-context/prompt.js +232 -0
  19. package/runtime/scripts/amalgm-mcp/project-context/rest.js +100 -0
  20. package/runtime/scripts/amalgm-mcp/project-context/store.js +671 -0
  21. package/runtime/scripts/amalgm-mcp/project-context/tools.js +146 -0
  22. package/runtime/scripts/amalgm-mcp/server/core-tools.js +35 -2
  23. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  24. package/runtime/scripts/amalgm-mcp/server/routes/project-context.js +33 -0
  25. package/runtime/scripts/amalgm-mcp/state/db.js +11 -0
  26. package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
  27. package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +37 -4
  28. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +328 -0
  29. package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +9 -9
  30. package/runtime/scripts/amalgm-mcp/tests/toolbox-service.test.js +2 -2
  31. package/runtime/scripts/amalgm-mcp/toolbox/loadout-context.js +1 -0
  32. package/runtime/scripts/amalgm-mcp/toolbox/store.js +0 -15
  33. package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +2 -0
  34. package/runtime/scripts/amalgm-mcp/workspace/rest.js +11 -7
  35. package/runtime/scripts/chat-core/adapters/opencode.js +1 -1
  36. package/runtime/scripts/chat-core/engine.js +16 -2
  37. package/runtime/scripts/chat-core/input.js +19 -1
  38. package/runtime/scripts/chat-core/tool-shape.js +6 -4
  39. package/runtime/scripts/chat-core/tooling/system-instructions.js +51 -0
  40. package/runtime/scripts/chat-core/tooling/system-prompt.js +34 -8
  41. package/runtime/scripts/local-gateway.js +1 -0
  42. package/runtime/scripts/amalgm-mcp/browser/agent-browser.js +0 -569
  43. package/runtime/scripts/amalgm-mcp/browser/page.js +0 -25
  44. package/runtime/scripts/chat-core/tooling/active-memory.js +0 -574
  45. 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.browser_navigate'].sourceAction.mcpToolName,
21
- 'toolbox__browser_browser_navigate',
20
+ catalog.toolActions['browser.browser_open'].sourceAction.mcpToolName,
21
+ 'toolbox__browser_browser_open',
22
22
  );
23
23
  assert.equal(
24
- catalog.toolActions['browser.browser_navigate'].sourceAction.handlerName,
25
- 'browser_navigate',
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.browser_navigate': {
50
- id: 'browser.browser_navigate',
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.browser_navigate'].name,
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.browser_navigate': { id: 'amalgm.browser.browser_navigate', toolId: '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.browser_navigate'], undefined);
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.browser_navigate');
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, 'toolbox__browser_browser_navigate');
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
 
@@ -5,6 +5,7 @@ const { loadoutToolIds } = require('../agent-config/loadout');
5
5
  const FIRST_PARTY_TOOL_IDS = new Set([
6
6
  'automations',
7
7
  'notifications',
8
+ 'memories',
8
9
  'agents',
9
10
  'apps',
10
11
  'browser',
@@ -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
- activeMemory.ensureConstructMemory({
54
- type: 'project',
55
- id: workspace.id,
56
- name: workspace.name,
57
- projectPath: workspace.path,
58
- }, { source });
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 === 'browser_navigate') return input.url ? `Opening ${hostLabel(input.url)}` : 'Opening browser page';
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 === 'browser_type') return input.text ? `Typing "${compactText(input.text, 60)}"` : 'Typing in browser';
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 === 'browser_wait_for_load_state') return 'Waiting for page load';
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, '&amp;')
25
+ .replace(/"/g, '&quot;')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;');
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
+ };