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
@@ -6,7 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const os = require('os');
8
8
  const path = require('path');
9
- const activeMemory = require('../../chat-core/tooling/active-memory');
9
+ const { notifyProjectContextPathChanged } = require('../project-context/store');
10
10
  const { AMALGM_DIR } = require('../config');
11
11
  const { appendStateEvent } = require('../state/events');
12
12
 
@@ -538,7 +538,7 @@ async function handleWrite(body, sendJson) {
538
538
 
539
539
  await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
540
540
  await fs.promises.writeFile(targetPath, data);
541
- activeMemory.notifyMemoryPathChanged(targetPath, 'fs:write');
541
+ notifyProjectContextPathChanged(targetPath, 'fs:write');
542
542
  await publishFilesystemChange([targetPath], 'fs:write');
543
543
 
544
544
  sendJson(200, { success: true, path: targetPath });
@@ -553,7 +553,7 @@ async function handleDelete(body, sendJson) {
553
553
  const targetPath = await resolveSafePath(body.path);
554
554
  const stats = await fs.promises.lstat(targetPath).catch(() => null);
555
555
  await fs.promises.rm(targetPath, { recursive: true, force: true });
556
- activeMemory.notifyMemoryPathChanged(targetPath, 'fs:delete');
556
+ notifyProjectContextPathChanged(targetPath, 'fs:delete');
557
557
  await publishFilesystemChange([targetPath], 'fs:delete');
558
558
  if (stats?.isDirectory()) {
559
559
  appendStateEvent({
@@ -575,7 +575,6 @@ async function handleMkdir(body, sendJson) {
575
575
  try {
576
576
  const targetPath = await resolveSafePath(body.path, { forCreate: true });
577
577
  await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o755 });
578
- activeMemory.notifyMemoryPathChanged(targetPath, 'fs:mkdir');
579
578
  await publishFilesystemChange([targetPath], 'fs:mkdir');
580
579
  await publishFilesResourceForPath(targetPath, 'fs:mkdir');
581
580
  sendJson(200, { success: true, path: targetPath });
@@ -592,8 +591,8 @@ async function handleRename(body, sendJson) {
592
591
  const stats = await fs.promises.lstat(oldPath).catch(() => null);
593
592
  await fs.promises.mkdir(path.dirname(newPath), { recursive: true });
594
593
  await fs.promises.rename(oldPath, newPath);
595
- activeMemory.notifyMemoryPathChanged(oldPath, 'fs:rename');
596
- activeMemory.notifyMemoryPathChanged(newPath, 'fs:rename');
594
+ notifyProjectContextPathChanged(oldPath, 'fs:rename');
595
+ notifyProjectContextPathChanged(newPath, 'fs:rename');
597
596
  await publishFilesystemChange([oldPath, newPath], 'fs:rename');
598
597
  if (stats?.isDirectory()) {
599
598
  appendStateEvent({
@@ -82,12 +82,14 @@ async function boot() {
82
82
  const { ensureAppsDirs } = require('./apps/store');
83
83
  const { startAppRouteSyncLoop } = require('./apps/advertise');
84
84
  const { startRegisteredApps } = require('./apps/supervisor');
85
+ const { startProjectContextService } = require('./project-context/store');
85
86
  const { listen } = require('./server/http');
86
87
 
87
88
  // Make sure every storage dir/file exists before we accept traffic.
88
89
  ensureAutomationsStore();
89
90
  ensureAgentsDirs();
90
91
  ensureAppsDirs();
92
+ startProjectContextService();
91
93
 
92
94
  await listen();
93
95
  await startRegisteredApps();
@@ -53,86 +53,52 @@ function buildActionDescriptor(toolName, args = {}) {
53
53
  case 'talk_to_agent':
54
54
  if (args.response_to_caller) return 'Deliver peer response';
55
55
  return `Talk to agent${quoteIfPresent(args.agent || args.agent_id)}`;
56
- case 'browser_navigate':
57
- return args.url ? `Navigate browser to ${clipText(args.url, 90)}` : 'Navigate browser';
58
- case 'browser_tabs_list':
59
- return 'List browser tabs';
60
- case 'browser_tabs_selected':
61
- return 'Get selected browser tab';
62
- case 'browser_tabs_new':
63
- return args.url ? `Open browser tab ${clipText(args.url, 90)}` : 'Open browser tab';
64
- case 'browser_tabs_get':
65
- return 'Get browser tab';
66
- case 'browser_tabs_select':
67
- return 'Select browser tab';
68
- case 'browser_tabs_close':
69
- return 'Close browser tab';
70
- case 'browser_state':
71
- return 'Get browser state';
72
- case 'browser_back':
73
- return 'Go back in browser';
74
- case 'browser_forward':
75
- return 'Go forward in browser';
76
- case 'browser_reload':
77
- return 'Reload browser';
78
- case 'browser_screenshot':
79
- return args.full_page ? 'Take full-page browser screenshot' : 'Take browser screenshot';
56
+ case 'browser_open':
57
+ return args.url ? `Open ${clipText(args.url, 90)} in browser` : 'Open browser page';
80
58
  case 'browser_snapshot':
81
59
  return 'Capture browser snapshot';
60
+ case 'browser_screenshot':
61
+ return args.fullPage ? 'Take full-page browser screenshot' : 'Take browser screenshot';
82
62
  case 'browser_click':
83
- if (args.text) return `Click browser text${quoteIfPresent(args.text)}`;
84
- if (args.selector) return `Click browser selector${quoteIfPresent(args.selector)}`;
85
- return 'Click in browser';
86
- case 'browser_type':
87
- if (args.selector && args.text) {
88
- return `Type ${clipText(args.text, 40)} into ${clipText(args.selector, 40)}`;
89
- }
90
- if (args.selector) return `Type into ${clipText(args.selector, 60)}`;
91
- return args.text ? `Type ${clipText(args.text, 40)} in browser` : 'Type in browser';
63
+ return `Click${quoteIfPresent(args.target)} in browser`;
64
+ case 'browser_fill':
65
+ return args.target
66
+ ? `Fill ${clipText(args.target, 40)} with ${clipText(args.text, 40)}`
67
+ : 'Fill browser field';
92
68
  case 'browser_press':
93
69
  return args.key ? `Press ${clipText(args.key, 30)} in browser` : 'Press key in browser';
94
- case 'browser_get_text':
95
- return args.selector
96
- ? `Get browser text from ${clipText(args.selector, 60)}`
97
- : 'Get browser page text';
98
- case 'browser_locator_count':
99
- return 'Count browser locator';
100
- case 'browser_locator_text':
101
- return 'Read browser locator text';
102
- case 'browser_evaluate':
70
+ case 'browser_eval':
103
71
  return 'Evaluate browser script';
104
- case 'browser_wait_for_load_state':
105
- return 'Wait for browser load state';
106
- case 'browser_wait_for_url':
107
- return args.url ? `Wait for browser URL ${clipText(args.url, 80)}` : 'Wait for browser URL';
108
- case 'browser_wait_for_selector':
109
- return args.selector ? `Wait for browser selector ${clipText(args.selector, 60)}` : 'Wait for browser selector';
110
- case 'browser_cua_click':
72
+ case 'browser_wait':
73
+ if (args.selector) return `Wait for browser selector ${clipText(args.selector, 60)}`;
74
+ if (args.url) return `Wait for browser URL ${clipText(args.url, 80)}`;
75
+ return args.ms ? `Wait ${args.ms}ms in browser` : 'Wait in browser';
76
+ case 'browser_cli':
77
+ return `Browser CLI${quoteIfPresent(Array.isArray(args.args) ? args.args.join(' ') : '')}`;
78
+ case 'browser_close':
79
+ return 'Close browser';
80
+ case 'cua_click':
111
81
  return 'Click browser coordinates';
112
- case 'browser_cua_double_click':
82
+ case 'cua_double_click':
113
83
  return 'Double-click browser coordinates';
114
- case 'browser_cua_move':
84
+ case 'cua_move':
115
85
  return 'Move browser mouse';
116
- case 'browser_cua_scroll':
86
+ case 'cua_scroll':
117
87
  return 'Scroll browser';
118
- case 'browser_cua_type':
88
+ case 'cua_type':
119
89
  return args.text ? `Type ${clipText(args.text, 40)} in browser` : 'Type in browser';
120
- case 'browser_cua_keypress':
90
+ case 'cua_keypress':
121
91
  return 'Press browser key';
122
- case 'browser_cua_drag':
92
+ case 'cua_drag':
123
93
  return 'Drag in browser';
124
- case 'browser_cua_screenshot':
94
+ case 'cua_screenshot':
125
95
  return 'Take visible browser screenshot';
126
- case 'browser_clipboard_read_text':
127
- return 'Read browser clipboard';
128
- case 'browser_clipboard_write_text':
129
- return 'Write browser clipboard';
130
- case 'browser_start_video':
131
- return 'Start browser video recording';
132
- case 'browser_stop_video':
133
- return 'Stop browser video recording';
134
- case 'browser_close':
135
- return 'Close browser';
96
+ case 'record_start':
97
+ return `Start browser recording${quoteIfPresent(args.name)}`;
98
+ case 'record_stop':
99
+ return 'Stop browser recording';
100
+ case 'record_list':
101
+ return 'List browser recordings';
136
102
  default: {
137
103
  const humanized = humanizeToolName(toolName);
138
104
  return humanized ? humanized.charAt(0).toUpperCase() + humanized.slice(1) : 'Tool call';
@@ -0,0 +1,266 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Project context paths + pure text helpers.
5
+ *
6
+ * Every registered workspace carries `<project>/.amalgm/context/` with three
7
+ * markdown docs:
8
+ * instructions.md user-authored durable instructions
9
+ * change-log.md append-only operational history, newest entry on top
10
+ * project-state.md consolidated "where the project stands" snapshot
11
+ *
12
+ * This module is dependency-free (no db/event imports) so chat-core and tests
13
+ * can use the path/text helpers without pulling in the store.
14
+ */
15
+
16
+ const path = require('path');
17
+
18
+ const CONTEXT_DIR_SEGMENTS = ['.amalgm', 'context'];
19
+ const INSTRUCTIONS_FILENAME = 'instructions.md';
20
+ const CHANGE_LOG_FILENAME = 'change-log.md';
21
+ const PROJECT_STATE_FILENAME = 'project-state.md';
22
+
23
+ const CHANGE_LOG_TITLE = '# Change Log';
24
+ const PROJECT_STATE_TITLE = '# Project State';
25
+ const INSTRUCTIONS_TITLE = '# Instructions';
26
+
27
+ // "Roughly the last 2,000 words" of history, kept deterministic in chars.
28
+ const CHANGE_LOG_CONTEXT_MAX_CHARS = 12_000;
29
+ const CONTEXT_FILE_MAX_CHARS = 12_000;
30
+
31
+ function cleanString(value) {
32
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
33
+ }
34
+
35
+ function contextRoot(projectPath) {
36
+ return path.join(path.resolve(projectPath), ...CONTEXT_DIR_SEGMENTS);
37
+ }
38
+
39
+ function contextPaths(projectPath) {
40
+ const root = contextRoot(projectPath);
41
+ return {
42
+ root,
43
+ instructions: path.join(root, INSTRUCTIONS_FILENAME),
44
+ changeLog: path.join(root, CHANGE_LOG_FILENAME),
45
+ projectState: path.join(root, PROJECT_STATE_FILENAME),
46
+ };
47
+ }
48
+
49
+ const CONTEXT_DOC_FILENAMES = new Set([
50
+ INSTRUCTIONS_FILENAME,
51
+ CHANGE_LOG_FILENAME,
52
+ PROJECT_STATE_FILENAME,
53
+ ]);
54
+
55
+ /** True when filePath points at one of the context docs (any project). */
56
+ function isProjectContextDocPath(filePath) {
57
+ const resolved = cleanString(filePath) ? path.resolve(filePath) : '';
58
+ if (!resolved) return false;
59
+ if (!CONTEXT_DOC_FILENAMES.has(path.basename(resolved))) return false;
60
+ const dir = path.dirname(resolved);
61
+ return path.basename(dir) === CONTEXT_DIR_SEGMENTS[1]
62
+ && path.basename(path.dirname(dir)) === CONTEXT_DIR_SEGMENTS[0];
63
+ }
64
+
65
+ /** Project root implied by a context doc/dir path, or '' when not one. */
66
+ function projectPathForContextPath(filePath) {
67
+ const resolved = cleanString(filePath) ? path.resolve(filePath) : '';
68
+ if (!resolved) return '';
69
+ const dir = isProjectContextDocPath(resolved) ? path.dirname(resolved) : resolved;
70
+ if (path.basename(dir) !== CONTEXT_DIR_SEGMENTS[1]) return '';
71
+ const amalgmDir = path.dirname(dir);
72
+ if (path.basename(amalgmDir) !== CONTEXT_DIR_SEGMENTS[0]) return '';
73
+ return path.dirname(amalgmDir);
74
+ }
75
+
76
+ /**
77
+ * Timestamp for change-log entries: ISO-like local time with offset plus the
78
+ * IANA timezone name. The runtime owns this — models never write timestamps.
79
+ */
80
+ function localTimestamp(date = new Date()) {
81
+ const pad = (value) => String(value).padStart(2, '0');
82
+ const offsetMinutes = -date.getTimezoneOffset();
83
+ const sign = offsetMinutes < 0 ? '-' : '+';
84
+ const absMinutes = Math.abs(offsetMinutes);
85
+ const offset = `${sign}${pad(Math.floor(absMinutes / 60))}:${pad(absMinutes % 60)}`;
86
+ const iso = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
87
+ + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${offset}`;
88
+ let timeZone = 'UTC';
89
+ try {
90
+ timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
91
+ } catch {
92
+ // Keep UTC.
93
+ }
94
+ return { iso, timeZone, ms: date.getTime() };
95
+ }
96
+
97
+ function changeLogEntryHeading(stamp = localTimestamp()) {
98
+ return `## ${stamp.iso} [${stamp.timeZone}]`;
99
+ }
100
+
101
+ /**
102
+ * The model supplies only the entry body. Strip a timestamp-style heading if
103
+ * one slipped in anyway so runtime timestamps stay canonical.
104
+ */
105
+ function sanitizeChangeLogBody(body) {
106
+ let text = String(body || '').replace(/\r\n/g, '\n').trim();
107
+ text = text.replace(/^##\s+\d{4}-\d{2}-\d{2}T[^\n]*\n+/, '').trim();
108
+ return text;
109
+ }
110
+
111
+ /**
112
+ * Prepend an entry directly under the `# Change Log` title. Self-heals a
113
+ * missing or mangled title instead of failing the append.
114
+ */
115
+ function insertChangeLogEntry(current, entry) {
116
+ const normalizedEntry = String(entry || '').trim();
117
+ const content = String(current || '').replace(/^\uFEFF/, '');
118
+ if (!content.trim()) {
119
+ return `${CHANGE_LOG_TITLE}\n\n${normalizedEntry}\n`;
120
+ }
121
+ const lines = content.split('\n');
122
+ const titleIndex = lines.findIndex((line) => line.trim() === CHANGE_LOG_TITLE);
123
+ if (titleIndex === -1) {
124
+ return `${CHANGE_LOG_TITLE}\n\n${normalizedEntry}\n\n${content.trim()}\n`;
125
+ }
126
+ const before = lines.slice(0, titleIndex + 1).join('\n');
127
+ const rest = lines.slice(titleIndex + 1).join('\n').trim();
128
+ return rest
129
+ ? `${before}\n\n${normalizedEntry}\n\n${rest}\n`
130
+ : `${before}\n\n${normalizedEntry}\n`;
131
+ }
132
+
133
+ function changeLogEntryOffsets(content) {
134
+ const offsets = [];
135
+ const re = /^##\s/gm;
136
+ let match;
137
+ while ((match = re.exec(content)) !== null) offsets.push(match.index);
138
+ return offsets;
139
+ }
140
+
141
+ /**
142
+ * Newest entries up to maxChars, always cut on an entry boundary — a
143
+ * truncated half-entry with no timestamp is worse than a smaller window.
144
+ */
145
+ function changeLogRecentWindow(content, maxChars = CHANGE_LOG_CONTEXT_MAX_CHARS) {
146
+ const body = String(content || '');
147
+ const offsets = changeLogEntryOffsets(body);
148
+ if (offsets.length === 0) {
149
+ const trimmed = body.trim();
150
+ if (!trimmed || trimmed === CHANGE_LOG_TITLE) return '';
151
+ return trimmed.length <= maxChars
152
+ ? trimmed
153
+ : `${trimmed.slice(0, maxChars).trimEnd()}\n[truncated]`;
154
+ }
155
+ const start = offsets[0];
156
+ let end = body.length;
157
+ if (end - start > maxChars) {
158
+ end = start;
159
+ for (let i = 1; i <= offsets.length; i += 1) {
160
+ const boundary = i < offsets.length ? offsets[i] : body.length;
161
+ if (boundary - start <= maxChars) {
162
+ end = boundary;
163
+ } else {
164
+ break;
165
+ }
166
+ }
167
+ if (end === start) {
168
+ return `${body.slice(start, start + maxChars).trimEnd()}\n[truncated]`;
169
+ }
170
+ }
171
+ const window = body.slice(start, end).trim();
172
+ return end < body.length ? `${window}\n\n[older entries truncated]` : window;
173
+ }
174
+
175
+ function parseEntryTimestamp(headingLine) {
176
+ const text = String(headingLine || '').replace(/^##\s+/, '');
177
+ const iso = text.split(' [')[0].trim();
178
+ const ms = Date.parse(iso);
179
+ return { iso, ms: Number.isFinite(ms) ? ms : null };
180
+ }
181
+
182
+ /** Timestamp of the newest entry (first heading), or nulls when none parse. */
183
+ function latestChangeLogEntry(content) {
184
+ const body = String(content || '');
185
+ const match = body.match(/^##\s+[^\n]*/m);
186
+ if (!match) return { iso: null, ms: null };
187
+ const parsed = parseEntryTimestamp(match[0]);
188
+ return { iso: parsed.ms === null ? null : parsed.iso, ms: parsed.ms };
189
+ }
190
+
191
+ /**
192
+ * Entries newer than sinceMs (newest first), capped at maxChars on entry
193
+ * boundaries. Entries without a parsable timestamp are skipped — the
194
+ * append-only convention keeps tool-written entries parsable.
195
+ */
196
+ function changeLogEntriesSince(content, sinceMs, maxChars = CHANGE_LOG_CONTEXT_MAX_CHARS) {
197
+ const body = String(content || '');
198
+ const offsets = changeLogEntryOffsets(body);
199
+ if (offsets.length === 0) return '';
200
+ const floor = Number.isFinite(sinceMs) ? sinceMs : null;
201
+ const kept = [];
202
+ let total = 0;
203
+ let omitted = false;
204
+ for (let i = 0; i < offsets.length; i += 1) {
205
+ const sliceEnd = i + 1 < offsets.length ? offsets[i + 1] : body.length;
206
+ const entry = body.slice(offsets[i], sliceEnd).trim();
207
+ const heading = entry.split('\n', 1)[0];
208
+ const { ms } = parseEntryTimestamp(heading);
209
+ if (ms === null) continue;
210
+ if (floor !== null && ms <= floor) continue;
211
+ if (total + entry.length > maxChars && kept.length > 0) {
212
+ omitted = true;
213
+ break;
214
+ }
215
+ kept.push(entry);
216
+ total += entry.length;
217
+ }
218
+ if (kept.length === 0) return '';
219
+ const joined = kept.join('\n\n');
220
+ return omitted ? `${joined}\n\n[older new entries truncated]` : joined;
221
+ }
222
+
223
+ /**
224
+ * Body emptiness: nothing left after dropping the title heading. The legacy
225
+ * placeholder line ("No instructions for X yet.") counts as empty so files
226
+ * created by the old context system don't inject noise.
227
+ */
228
+ function isEmptyContextDoc(content) {
229
+ const body = String(content || '')
230
+ .replace(/^\uFEFF/, '')
231
+ .replace(/^#[^\n]*\n?/, '')
232
+ .trim();
233
+ if (!body) return true;
234
+ return /^No instructions for .{1,200} yet\.$/.test(body);
235
+ }
236
+
237
+ function cappedDocContent(content, maxChars = CONTEXT_FILE_MAX_CHARS) {
238
+ const text = String(content || '').trim();
239
+ if (!text) return '';
240
+ if (text.length <= maxChars) return text;
241
+ return `${text.slice(0, maxChars).trimEnd()}\n[truncated]`;
242
+ }
243
+
244
+ module.exports = {
245
+ CHANGE_LOG_CONTEXT_MAX_CHARS,
246
+ CHANGE_LOG_FILENAME,
247
+ CHANGE_LOG_TITLE,
248
+ CONTEXT_FILE_MAX_CHARS,
249
+ INSTRUCTIONS_FILENAME,
250
+ INSTRUCTIONS_TITLE,
251
+ PROJECT_STATE_FILENAME,
252
+ PROJECT_STATE_TITLE,
253
+ cappedDocContent,
254
+ changeLogEntriesSince,
255
+ changeLogEntryHeading,
256
+ changeLogRecentWindow,
257
+ contextPaths,
258
+ contextRoot,
259
+ insertChangeLogEntry,
260
+ isEmptyContextDoc,
261
+ isProjectContextDocPath,
262
+ latestChangeLogEntry,
263
+ localTimestamp,
264
+ projectPathForContextPath,
265
+ sanitizeChangeLogBody,
266
+ };
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Project context prompt blocks.
5
+ *
6
+ * Fresh provider threads get the full block in the system prompt. Resumed
7
+ * threads can't change their system prompt without busting the provider's
8
+ * prompt cache, so freshness is per-turn instead: the runtime tracks the
9
+ * contextRevision each session last saw (local_meta), and when the canonical
10
+ * row moved, the next turn gets a compact <project_context_update> prepended.
11
+ * Because the change log is append-only, the delta is cheap: entries newer
12
+ * than the last-seen entry timestamp, plus full project-state/instructions
13
+ * only when their revisions changed.
14
+ *
15
+ * Invariant: the prompt for turn N reflects every context write committed
16
+ * before turn N was sent. Mid-turn writes show up next turn.
17
+ *
18
+ * This module runs in the chat-server process — reads rows, writes only
19
+ * seen-state to local_meta, never emits Local Live events.
20
+ */
21
+
22
+ const fs = require('fs');
23
+
24
+ const SEEN_META_PREFIX = 'project_context_seen:';
25
+
26
+ function cleanString(value) {
27
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
28
+ }
29
+
30
+ function contractProjectPath(contract) {
31
+ const origin = contract?.origin && typeof contract.origin === 'object' ? contract.origin : {};
32
+ return cleanString(origin.projectPath || contract?.projectPath || contract?.cwd);
33
+ }
34
+
35
+ function peekForContract(contract) {
36
+ const projectPath = contractProjectPath(contract);
37
+ if (!projectPath) return null;
38
+ try {
39
+ return require('./store').peekProjectContext(projectPath);
40
+ } catch (error) {
41
+ const message = error instanceof Error ? error.message.split('\n')[0] : String(error);
42
+ console.warn('[ProjectContext] Prompt peek failed:', message);
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function readSeen(sessionId) {
48
+ if (!cleanString(sessionId)) return null;
49
+ try {
50
+ const { openLocalDb } = require('../state/db');
51
+ const row = openLocalDb()
52
+ .prepare('SELECT value FROM local_meta WHERE key = ?')
53
+ .get(`${SEEN_META_PREFIX}${sessionId}`);
54
+ return row?.value ? JSON.parse(row.value) : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function writeSeen(sessionId, record) {
61
+ if (!cleanString(sessionId) || !record) return;
62
+ try {
63
+ const { openLocalDb } = require('../state/db');
64
+ openLocalDb()
65
+ .prepare(`
66
+ INSERT INTO local_meta (key, value, updated_at)
67
+ VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
68
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
69
+ `)
70
+ .run(`${SEEN_META_PREFIX}${sessionId}`, JSON.stringify({
71
+ projectId: record.projectId,
72
+ contextRevision: record.contextRevision,
73
+ instructionsRevision: record.files?.instructions?.revision || null,
74
+ projectStateRevision: record.files?.projectState?.revision || null,
75
+ changeLogRevision: record.files?.changeLog?.revision || null,
76
+ latestEntryAtMs: record.files?.changeLog?.latestEntryAtMs ?? null,
77
+ updatedAt: new Date().toISOString(),
78
+ }));
79
+ } catch (error) {
80
+ const message = error instanceof Error ? error.message.split('\n')[0] : String(error);
81
+ console.warn('[ProjectContext] Seen-state write failed:', message);
82
+ }
83
+ }
84
+
85
+ function escapeAttribute(value) {
86
+ return String(value || '')
87
+ .replace(/&/g, '&amp;')
88
+ .replace(/"/g, '&quot;')
89
+ .replace(/</g, '&lt;')
90
+ .replace(/>/g, '&gt;');
91
+ }
92
+
93
+ function memoryProtocolBlock() {
94
+ return [
95
+ '<project_memory_protocol>',
96
+ 'Project memory is maintained through the memories tool — never by editing these files directly.',
97
+ '- When you complete durable, user-visible work in this project, or make decisions future agents need, call toolbox__memories_append_change_log with a concise markdown body: 3-6 factual bullets such as "- Completed: ...", "- Decisions: ...", "- Notes: ...". No transcripts, no essays; mark uncertain items as open questions.',
98
+ '- The runtime owns timestamps and entry placement. Never write timestamps, never rewrite change-log.md — it is append-only.',
99
+ '- Call toolbox__memories_write_project_state only when the consolidated "where this project stands" snapshot must be rewritten (e.g. after a milestone or when consolidating the change log).',
100
+ '- instructions.md is user-authored. Do not modify it unless the user explicitly asks.',
101
+ '</project_memory_protocol>',
102
+ ].join('\n');
103
+ }
104
+
105
+ function contentBlocks(record) {
106
+ const blocks = [];
107
+ if (record.prompt?.instructions) {
108
+ blocks.push([
109
+ `<project_instructions path="${escapeAttribute(record.paths?.instructions)}">`,
110
+ record.prompt.instructions,
111
+ '</project_instructions>',
112
+ ].join('\n'));
113
+ }
114
+ if (record.prompt?.projectState) {
115
+ blocks.push([
116
+ `<project_state path="${escapeAttribute(record.paths?.projectState)}">`,
117
+ record.prompt.projectState,
118
+ '</project_state>',
119
+ ].join('\n'));
120
+ }
121
+ if (record.prompt?.changeLogRecent) {
122
+ blocks.push([
123
+ `<recent_change_log path="${escapeAttribute(record.paths?.changeLog)}">`,
124
+ record.prompt.changeLogRecent,
125
+ '</recent_change_log>',
126
+ ].join('\n'));
127
+ }
128
+ return blocks;
129
+ }
130
+
131
+ /**
132
+ * Full block for the system prompt. Only fresh provider threads get it —
133
+ * resumed threads stay on the per-turn update path.
134
+ */
135
+ function projectContextPromptBlock(contract) {
136
+ if (contract?.providerSessionId) return '';
137
+ const record = peekForContract(contract);
138
+ if (!record) return '';
139
+
140
+ const lines = [
141
+ '<project_context>',
142
+ `Project: ${record.name} (${record.projectPath})`,
143
+ 'This is the project\'s durable memory. The change log is operational history (newest first); project state is the consolidated snapshot of where things stand; instructions are user-authored guidance for this project.',
144
+ '',
145
+ ...contentBlocks(record).flatMap((block) => [block, '']),
146
+ memoryProtocolBlock(),
147
+ '</project_context>',
148
+ ];
149
+
150
+ writeSeen(contract?.sessionId, record);
151
+ return lines.join('\n');
152
+ }
153
+
154
+ function readFileSafe(filePath) {
155
+ try {
156
+ return fs.readFileSync(filePath, 'utf8');
157
+ } catch {
158
+ return '';
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Per-turn freshness for resumed threads. Empty in the common case (revision
164
+ * unchanged). A session that predates the feature (or switched projects) gets
165
+ * the full context once, then deltas.
166
+ */
167
+ function projectContextTurnUpdateBlock(contract) {
168
+ if (!contract?.providerSessionId) return '';
169
+ const record = peekForContract(contract);
170
+ if (!record) return '';
171
+
172
+ const seen = readSeen(contract?.sessionId);
173
+ if (seen && seen.projectId === record.projectId && seen.contextRevision === record.contextRevision) {
174
+ return '';
175
+ }
176
+
177
+ const blocks = [];
178
+ if (!seen || seen.projectId !== record.projectId) {
179
+ blocks.push(...contentBlocks(record));
180
+ blocks.push(memoryProtocolBlock());
181
+ } else {
182
+ const { changeLogEntriesSince } = require('./paths');
183
+ if (seen.instructionsRevision !== record.files?.instructions?.revision && record.prompt?.instructions) {
184
+ blocks.push([
185
+ `<project_instructions path="${escapeAttribute(record.paths?.instructions)}">`,
186
+ record.prompt.instructions,
187
+ '</project_instructions>',
188
+ ].join('\n'));
189
+ }
190
+ if (seen.projectStateRevision !== record.files?.projectState?.revision && record.prompt?.projectState) {
191
+ blocks.push([
192
+ `<project_state path="${escapeAttribute(record.paths?.projectState)}">`,
193
+ record.prompt.projectState,
194
+ '</project_state>',
195
+ ].join('\n'));
196
+ }
197
+ if (seen.changeLogRevision !== record.files?.changeLog?.revision) {
198
+ const content = readFileSafe(record.paths?.changeLog);
199
+ const floor = Number.isFinite(seen.latestEntryAtMs) ? seen.latestEntryAtMs : null;
200
+ let newEntries = changeLogEntriesSince(content, floor);
201
+ if (!newEntries && floor !== null) {
202
+ // Entry timestamps have second resolution, so an append within the
203
+ // same second as the previously-newest entry hides behind a strict
204
+ // ">" filter. Re-sending that second is cheaper than a context gap.
205
+ newEntries = changeLogEntriesSince(content, floor - 1);
206
+ }
207
+ if (newEntries) {
208
+ blocks.push([
209
+ `<new_change_log_entries path="${escapeAttribute(record.paths?.changeLog)}">`,
210
+ newEntries,
211
+ '</new_change_log_entries>',
212
+ ].join('\n'));
213
+ }
214
+ }
215
+ }
216
+
217
+ writeSeen(contract?.sessionId, record);
218
+ if (blocks.length === 0) return '';
219
+
220
+ return [
221
+ '<project_context_update>',
222
+ `Project context for ${record.name} (${record.projectPath}) changed since the last turn. Current state:`,
223
+ '',
224
+ ...blocks.flatMap((block) => [block, '']),
225
+ '</project_context_update>',
226
+ ].join('\n').trimEnd();
227
+ }
228
+
229
+ module.exports = {
230
+ projectContextPromptBlock,
231
+ projectContextTurnUpdateBlock,
232
+ };