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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const path = require('path');
|
|
9
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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 '
|
|
57
|
-
return args.url ? `
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
return
|
|
86
|
-
|
|
87
|
-
|
|
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 '
|
|
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 '
|
|
105
|
-
return
|
|
106
|
-
|
|
107
|
-
return args.
|
|
108
|
-
case '
|
|
109
|
-
return
|
|
110
|
-
case '
|
|
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 '
|
|
82
|
+
case 'cua_double_click':
|
|
113
83
|
return 'Double-click browser coordinates';
|
|
114
|
-
case '
|
|
84
|
+
case 'cua_move':
|
|
115
85
|
return 'Move browser mouse';
|
|
116
|
-
case '
|
|
86
|
+
case 'cua_scroll':
|
|
117
87
|
return 'Scroll browser';
|
|
118
|
-
case '
|
|
88
|
+
case 'cua_type':
|
|
119
89
|
return args.text ? `Type ${clipText(args.text, 40)} in browser` : 'Type in browser';
|
|
120
|
-
case '
|
|
90
|
+
case 'cua_keypress':
|
|
121
91
|
return 'Press browser key';
|
|
122
|
-
case '
|
|
92
|
+
case 'cua_drag':
|
|
123
93
|
return 'Drag in browser';
|
|
124
|
-
case '
|
|
94
|
+
case 'cua_screenshot':
|
|
125
95
|
return 'Take visible browser screenshot';
|
|
126
|
-
case '
|
|
127
|
-
return
|
|
128
|
-
case '
|
|
129
|
-
return '
|
|
130
|
-
case '
|
|
131
|
-
return '
|
|
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, '&')
|
|
88
|
+
.replace(/"/g, '"')
|
|
89
|
+
.replace(/</g, '<')
|
|
90
|
+
.replace(/>/g, '>');
|
|
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
|
+
};
|