amalgm 0.1.107 → 0.1.108
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/package.json +3 -3
- package/runtime/scripts/amalgm-mcp/project-context/paths.js +41 -10
- package/runtime/scripts/amalgm-mcp/project-context/prompt.js +20 -4
- package/runtime/scripts/amalgm-mcp/project-context/rest.js +2 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +51 -21
- package/runtime/scripts/amalgm-mcp/project-context/tools.js +41 -2
- package/runtime/scripts/amalgm-mcp/server/core-tools.js +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/project-context.js +4 -0
- package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +1 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +61 -13
- package/runtime/scripts/chat-core/tooling/system-instructions.js +10 -8
- package/runtime/scripts/chat-core/tooling/system-prompt.js +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amalgm",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.108",
|
|
4
4
|
"description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -29,13 +29,13 @@
|
|
|
29
29
|
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
|
|
30
30
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
31
31
|
"@openai/codex": "^0.128.0",
|
|
32
|
-
"@opencode-ai/sdk": "^1.17.
|
|
32
|
+
"@opencode-ai/sdk": "^1.17.6",
|
|
33
33
|
"agent-browser": "^0.27.3",
|
|
34
34
|
"ai": "^6.0.116",
|
|
35
35
|
"better-sqlite3": "^12.10.0",
|
|
36
36
|
"cron-parser": "^5.5.0",
|
|
37
37
|
"ffmpeg-static": "^5.3.0",
|
|
38
|
-
"opencode-ai": "^1.17.
|
|
38
|
+
"opencode-ai": "^1.17.6",
|
|
39
39
|
"tsx": "^4.21.0",
|
|
40
40
|
"ws": "^8.18.3",
|
|
41
41
|
"zod": "^4.1.8"
|
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Project context paths + pure text helpers.
|
|
5
5
|
*
|
|
6
|
-
* Every registered workspace carries `<project>/.amalgm/context/` with
|
|
6
|
+
* Every registered workspace carries `<project>/.amalgm/context/` with four
|
|
7
7
|
* markdown docs:
|
|
8
|
-
* instructions.md user-authored durable instructions
|
|
9
|
-
*
|
|
10
|
-
* project-state.md
|
|
8
|
+
* project-instructions.md user-authored durable project instructions
|
|
9
|
+
* project-goals.md durable goals and success criteria
|
|
10
|
+
* project-state.md consolidated "where the project stands" snapshot
|
|
11
|
+
* change-log.md append-only operational history, newest entry on top
|
|
12
|
+
*
|
|
13
|
+
* The machine-wide context is `<AMALGM_DIR>/context/system-instructions.md`.
|
|
11
14
|
*
|
|
12
15
|
* This module is dependency-free (no db/event imports) so chat-core and tests
|
|
13
16
|
* can use the path/text helpers without pulling in the store.
|
|
@@ -16,13 +19,18 @@
|
|
|
16
19
|
const path = require('path');
|
|
17
20
|
|
|
18
21
|
const CONTEXT_DIR_SEGMENTS = ['.amalgm', 'context'];
|
|
19
|
-
const
|
|
22
|
+
const LEGACY_INSTRUCTIONS_FILENAME = 'instructions.md';
|
|
23
|
+
const SYSTEM_INSTRUCTIONS_FILENAME = 'system-instructions.md';
|
|
24
|
+
const PROJECT_INSTRUCTIONS_FILENAME = 'project-instructions.md';
|
|
25
|
+
const PROJECT_GOALS_FILENAME = 'project-goals.md';
|
|
20
26
|
const CHANGE_LOG_FILENAME = 'change-log.md';
|
|
21
27
|
const PROJECT_STATE_FILENAME = 'project-state.md';
|
|
22
28
|
|
|
23
29
|
const CHANGE_LOG_TITLE = '# Change Log';
|
|
24
30
|
const PROJECT_STATE_TITLE = '# Project State';
|
|
25
|
-
const
|
|
31
|
+
const PROJECT_GOALS_TITLE = '# Project Goals';
|
|
32
|
+
const PROJECT_INSTRUCTIONS_TITLE = '# Project Instructions';
|
|
33
|
+
const SYSTEM_INSTRUCTIONS_TITLE = '# System Instructions';
|
|
26
34
|
|
|
27
35
|
// "Roughly the last 2,000 words" of history, kept deterministic in chars.
|
|
28
36
|
const CHANGE_LOG_CONTEXT_MAX_CHARS = 12_000;
|
|
@@ -36,18 +44,34 @@ function contextRoot(projectPath) {
|
|
|
36
44
|
return path.join(path.resolve(projectPath), ...CONTEXT_DIR_SEGMENTS);
|
|
37
45
|
}
|
|
38
46
|
|
|
47
|
+
function systemContextRoot(amalgmDir) {
|
|
48
|
+
return path.join(path.resolve(amalgmDir), 'context');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function systemContextPaths(amalgmDir) {
|
|
52
|
+
const root = systemContextRoot(amalgmDir);
|
|
53
|
+
return {
|
|
54
|
+
root,
|
|
55
|
+
instructions: path.join(root, SYSTEM_INSTRUCTIONS_FILENAME),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
function contextPaths(projectPath) {
|
|
40
60
|
const root = contextRoot(projectPath);
|
|
41
61
|
return {
|
|
42
62
|
root,
|
|
43
|
-
instructions: path.join(root,
|
|
63
|
+
instructions: path.join(root, PROJECT_INSTRUCTIONS_FILENAME),
|
|
64
|
+
goals: path.join(root, PROJECT_GOALS_FILENAME),
|
|
44
65
|
changeLog: path.join(root, CHANGE_LOG_FILENAME),
|
|
45
66
|
projectState: path.join(root, PROJECT_STATE_FILENAME),
|
|
46
67
|
};
|
|
47
68
|
}
|
|
48
69
|
|
|
49
70
|
const CONTEXT_DOC_FILENAMES = new Set([
|
|
50
|
-
|
|
71
|
+
LEGACY_INSTRUCTIONS_FILENAME,
|
|
72
|
+
SYSTEM_INSTRUCTIONS_FILENAME,
|
|
73
|
+
PROJECT_INSTRUCTIONS_FILENAME,
|
|
74
|
+
PROJECT_GOALS_FILENAME,
|
|
51
75
|
CHANGE_LOG_FILENAME,
|
|
52
76
|
PROJECT_STATE_FILENAME,
|
|
53
77
|
]);
|
|
@@ -246,10 +270,15 @@ module.exports = {
|
|
|
246
270
|
CHANGE_LOG_FILENAME,
|
|
247
271
|
CHANGE_LOG_TITLE,
|
|
248
272
|
CONTEXT_FILE_MAX_CHARS,
|
|
249
|
-
|
|
250
|
-
|
|
273
|
+
LEGACY_INSTRUCTIONS_FILENAME,
|
|
274
|
+
PROJECT_GOALS_FILENAME,
|
|
275
|
+
PROJECT_GOALS_TITLE,
|
|
276
|
+
PROJECT_INSTRUCTIONS_FILENAME,
|
|
277
|
+
PROJECT_INSTRUCTIONS_TITLE,
|
|
251
278
|
PROJECT_STATE_FILENAME,
|
|
252
279
|
PROJECT_STATE_TITLE,
|
|
280
|
+
SYSTEM_INSTRUCTIONS_FILENAME,
|
|
281
|
+
SYSTEM_INSTRUCTIONS_TITLE,
|
|
253
282
|
cappedDocContent,
|
|
254
283
|
changeLogEntriesSince,
|
|
255
284
|
changeLogEntryHeading,
|
|
@@ -263,4 +292,6 @@ module.exports = {
|
|
|
263
292
|
localTimestamp,
|
|
264
293
|
projectPathForContextPath,
|
|
265
294
|
sanitizeChangeLogBody,
|
|
295
|
+
systemContextPaths,
|
|
296
|
+
systemContextRoot,
|
|
266
297
|
};
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* contextRevision each session last saw (local_meta), and when the canonical
|
|
10
10
|
* row moved, the next turn gets a compact <project_context_update> prepended.
|
|
11
11
|
* Because the change log is append-only, the delta is cheap: entries newer
|
|
12
|
-
* than the last-seen entry timestamp, plus full project
|
|
12
|
+
* than the last-seen entry timestamp, plus full project goals/state/instructions
|
|
13
13
|
* only when their revisions changed.
|
|
14
14
|
*
|
|
15
15
|
* Invariant: the prompt for turn N reflects every context write committed
|
|
@@ -71,6 +71,7 @@ function writeSeen(sessionId, record) {
|
|
|
71
71
|
projectId: record.projectId,
|
|
72
72
|
contextRevision: record.contextRevision,
|
|
73
73
|
instructionsRevision: record.files?.instructions?.revision || null,
|
|
74
|
+
projectGoalsRevision: record.files?.goals?.revision || null,
|
|
74
75
|
projectStateRevision: record.files?.projectState?.revision || null,
|
|
75
76
|
changeLogRevision: record.files?.changeLog?.revision || null,
|
|
76
77
|
latestEntryAtMs: record.files?.changeLog?.latestEntryAtMs ?? null,
|
|
@@ -96,8 +97,9 @@ function memoryProtocolBlock() {
|
|
|
96
97
|
'Project memory is maintained through the memories tool — never by editing these files directly.',
|
|
97
98
|
'- 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
99
|
'- The runtime owns timestamps and entry placement. Never write timestamps, never rewrite change-log.md — it is append-only.',
|
|
100
|
+
'- Call toolbox__memories_write_project_goals when durable goals, success criteria, or intended outcomes have intentionally changed. Goals are desired future state, not progress notes.',
|
|
99
101
|
'- 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.',
|
|
102
|
+
'- project-instructions.md is user-authored. Do not modify it unless the user explicitly asks.',
|
|
101
103
|
'</project_memory_protocol>',
|
|
102
104
|
].join('\n');
|
|
103
105
|
}
|
|
@@ -111,6 +113,13 @@ function contentBlocks(record) {
|
|
|
111
113
|
'</project_instructions>',
|
|
112
114
|
].join('\n'));
|
|
113
115
|
}
|
|
116
|
+
if (record.prompt?.goals) {
|
|
117
|
+
blocks.push([
|
|
118
|
+
`<project_goals path="${escapeAttribute(record.paths?.goals)}">`,
|
|
119
|
+
record.prompt.goals,
|
|
120
|
+
'</project_goals>',
|
|
121
|
+
].join('\n'));
|
|
122
|
+
}
|
|
114
123
|
if (record.prompt?.projectState) {
|
|
115
124
|
blocks.push([
|
|
116
125
|
`<project_state path="${escapeAttribute(record.paths?.projectState)}">`,
|
|
@@ -140,7 +149,7 @@ function projectContextPromptBlock(contract) {
|
|
|
140
149
|
const lines = [
|
|
141
150
|
'<project_context>',
|
|
142
151
|
`Project: ${record.name} (${record.projectPath})`,
|
|
143
|
-
'This is the project\'s durable memory.
|
|
152
|
+
'This is the project\'s durable memory. Project instructions are user-authored guidance; project goals are intended outcomes; project state is the current consolidated snapshot; the change log is operational history (newest first).',
|
|
144
153
|
'',
|
|
145
154
|
...contentBlocks(record).flatMap((block) => [block, '']),
|
|
146
155
|
memoryProtocolBlock(),
|
|
@@ -187,6 +196,13 @@ function projectContextTurnUpdateBlock(contract) {
|
|
|
187
196
|
'</project_instructions>',
|
|
188
197
|
].join('\n'));
|
|
189
198
|
}
|
|
199
|
+
if (seen.projectGoalsRevision !== record.files?.goals?.revision && record.prompt?.goals) {
|
|
200
|
+
blocks.push([
|
|
201
|
+
`<project_goals path="${escapeAttribute(record.paths?.goals)}">`,
|
|
202
|
+
record.prompt.goals,
|
|
203
|
+
'</project_goals>',
|
|
204
|
+
].join('\n'));
|
|
205
|
+
}
|
|
190
206
|
if (seen.projectStateRevision !== record.files?.projectState?.revision && record.prompt?.projectState) {
|
|
191
207
|
blocks.push([
|
|
192
208
|
`<project_state path="${escapeAttribute(record.paths?.projectState)}">`,
|
|
@@ -219,7 +235,7 @@ function projectContextTurnUpdateBlock(contract) {
|
|
|
219
235
|
|
|
220
236
|
return [
|
|
221
237
|
'<project_context_update>',
|
|
222
|
-
`Project context for ${record.name} (${record.projectPath}) changed since the last turn. Current
|
|
238
|
+
`Project context for ${record.name} (${record.projectPath}) changed since the last turn. Current memory:`,
|
|
223
239
|
'',
|
|
224
240
|
...blocks.flatMap((block) => [block, '']),
|
|
225
241
|
'</project_context_update>',
|
|
@@ -87,6 +87,7 @@ function writeDocHandler(writeFn) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
const handleWriteProjectState = writeDocHandler(store.writeProjectState);
|
|
90
|
+
const handleWriteProjectGoals = writeDocHandler(store.writeProjectGoals);
|
|
90
91
|
const handleWriteInstructions = writeDocHandler(store.writeInstructions);
|
|
91
92
|
const handleWriteChangeLog = writeDocHandler(store.writeChangeLog);
|
|
92
93
|
|
|
@@ -96,5 +97,6 @@ module.exports = {
|
|
|
96
97
|
handleList,
|
|
97
98
|
handleWriteChangeLog,
|
|
98
99
|
handleWriteInstructions,
|
|
100
|
+
handleWriteProjectGoals,
|
|
99
101
|
handleWriteProjectState,
|
|
100
102
|
};
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
* store so timestamps, revisions, the SQLite `project_context` row, and Local
|
|
8
8
|
* Live events stay coherent:
|
|
9
9
|
*
|
|
10
|
-
* change-log.md
|
|
11
|
-
*
|
|
12
|
-
* project-
|
|
13
|
-
*
|
|
10
|
+
* change-log.md append-only via appendChangeLog (runtime owns the
|
|
11
|
+
* timestamp; entries land directly under the title)
|
|
12
|
+
* project-goals.md latest write wins; writable by UI and memories tool
|
|
13
|
+
* project-state.md latest write wins; revision kept as metadata only
|
|
14
|
+
* project-instructions.md user-authored; latest write wins via the UI path
|
|
15
|
+
* system-instructions.md global user-authored instructions under AMALGM_DIR
|
|
14
16
|
*
|
|
15
17
|
* Direct disk edits (editors, agents with their own file tools) are picked up
|
|
16
18
|
* by the per-project fs watcher and the fs REST hooks, which funnel into the
|
|
@@ -32,8 +34,11 @@ const { AMALGM_DIR } = require('../config');
|
|
|
32
34
|
const { canonicalProjectPath } = require('../../lib/project-paths');
|
|
33
35
|
const {
|
|
34
36
|
CHANGE_LOG_TITLE,
|
|
35
|
-
|
|
37
|
+
LEGACY_INSTRUCTIONS_FILENAME,
|
|
38
|
+
PROJECT_GOALS_TITLE,
|
|
39
|
+
PROJECT_INSTRUCTIONS_TITLE,
|
|
36
40
|
PROJECT_STATE_TITLE,
|
|
41
|
+
SYSTEM_INSTRUCTIONS_TITLE,
|
|
37
42
|
changeLogEntryHeading,
|
|
38
43
|
changeLogRecentWindow,
|
|
39
44
|
cappedDocContent,
|
|
@@ -44,6 +49,7 @@ const {
|
|
|
44
49
|
localTimestamp,
|
|
45
50
|
projectPathForContextPath,
|
|
46
51
|
sanitizeChangeLogBody,
|
|
52
|
+
systemContextPaths: systemContextPathsForAmalgmDir,
|
|
47
53
|
} = require('./paths');
|
|
48
54
|
|
|
49
55
|
const RESOURCE = 'project_context';
|
|
@@ -59,10 +65,7 @@ function systemProject() {
|
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
function systemContextPaths() {
|
|
62
|
-
|
|
63
|
-
// under it — no extra .amalgm segment like project contexts have.
|
|
64
|
-
const root = path.join(path.resolve(AMALGM_DIR), 'context');
|
|
65
|
-
return { root, instructions: path.join(root, 'instructions.md') };
|
|
68
|
+
return systemContextPathsForAmalgmDir(AMALGM_DIR);
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
function isSystemProject(project) {
|
|
@@ -187,7 +190,7 @@ function readDoc(filePath) {
|
|
|
187
190
|
}
|
|
188
191
|
|
|
189
192
|
function statFingerprint(paths) {
|
|
190
|
-
return [paths.instructions, paths.changeLog, paths.projectState]
|
|
193
|
+
return [paths.instructions, paths.goals, paths.changeLog, paths.projectState]
|
|
191
194
|
.filter(Boolean)
|
|
192
195
|
.map((filePath) => {
|
|
193
196
|
try {
|
|
@@ -235,11 +238,12 @@ function buildContextRecord(project) {
|
|
|
235
238
|
|
|
236
239
|
const paths = contextPaths(project.path);
|
|
237
240
|
const instructions = readDoc(paths.instructions);
|
|
241
|
+
const goals = readDoc(paths.goals);
|
|
238
242
|
const changeLog = readDoc(paths.changeLog);
|
|
239
243
|
const projectState = readDoc(paths.projectState);
|
|
240
244
|
const latestEntry = latestChangeLogEntry(changeLog.content);
|
|
241
245
|
const contextRevision = hash(
|
|
242
|
-
[instructions.revision, changeLog.revision, projectState.revision].join(':'),
|
|
246
|
+
[instructions.revision, goals.revision, changeLog.revision, projectState.revision].join(':'),
|
|
243
247
|
).slice(0, 20);
|
|
244
248
|
|
|
245
249
|
return {
|
|
@@ -253,6 +257,7 @@ function buildContextRecord(project) {
|
|
|
253
257
|
paths,
|
|
254
258
|
files: {
|
|
255
259
|
instructions: docMeta(instructions),
|
|
260
|
+
goals: docMeta(goals),
|
|
256
261
|
changeLog: {
|
|
257
262
|
...docMeta(changeLog),
|
|
258
263
|
latestEntryAt: latestEntry.iso,
|
|
@@ -262,6 +267,7 @@ function buildContextRecord(project) {
|
|
|
262
267
|
},
|
|
263
268
|
prompt: {
|
|
264
269
|
instructions: instructions.empty ? '' : cappedDocContent(instructions.content),
|
|
270
|
+
goals: goals.empty ? '' : cappedDocContent(goals.content),
|
|
265
271
|
projectState: projectState.empty ? '' : cappedDocContent(projectState.content),
|
|
266
272
|
changeLogRecent: changeLog.empty ? '' : changeLogRecentWindow(changeLog.content),
|
|
267
273
|
},
|
|
@@ -346,24 +352,40 @@ function reconcileProjectContext(input, options = {}) {
|
|
|
346
352
|
return upsertContextRow(record, options);
|
|
347
353
|
}
|
|
348
354
|
|
|
349
|
-
const
|
|
355
|
+
const SYSTEM_INSTRUCTIONS_TEMPLATE = `${SYSTEM_INSTRUCTIONS_TITLE}\n`;
|
|
356
|
+
const PROJECT_INSTRUCTIONS_TEMPLATE = `${PROJECT_INSTRUCTIONS_TITLE}\n`;
|
|
357
|
+
const PROJECT_GOALS_TEMPLATE = `${PROJECT_GOALS_TITLE}\n`;
|
|
350
358
|
const CHANGE_LOG_TEMPLATE = `${CHANGE_LOG_TITLE}\n`;
|
|
351
359
|
const PROJECT_STATE_TEMPLATE = `${PROJECT_STATE_TITLE}\n`;
|
|
352
360
|
|
|
361
|
+
function legacyInstructionsPath(paths) {
|
|
362
|
+
return path.join(paths.root, LEGACY_INSTRUCTIONS_FILENAME);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function writeInitialDoc(filePath, template, legacyPath = null) {
|
|
366
|
+
if (fs.existsSync(filePath)) return false;
|
|
367
|
+
if (legacyPath && fs.existsSync(legacyPath)) {
|
|
368
|
+
atomicWrite(filePath, fs.readFileSync(legacyPath, 'utf8'));
|
|
369
|
+
} else {
|
|
370
|
+
atomicWrite(filePath, template);
|
|
371
|
+
}
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
353
375
|
function ensureContextFiles(project) {
|
|
354
376
|
const paths = contextPathsFor(project);
|
|
355
377
|
const docs = isSystemProject(project)
|
|
356
|
-
? [[paths.instructions,
|
|
378
|
+
? [[paths.instructions, SYSTEM_INSTRUCTIONS_TEMPLATE, legacyInstructionsPath(paths)]]
|
|
357
379
|
: [
|
|
358
|
-
[paths.instructions,
|
|
359
|
-
[paths.
|
|
380
|
+
[paths.instructions, PROJECT_INSTRUCTIONS_TEMPLATE, legacyInstructionsPath(paths)],
|
|
381
|
+
[paths.goals, PROJECT_GOALS_TEMPLATE],
|
|
360
382
|
[paths.projectState, PROJECT_STATE_TEMPLATE],
|
|
383
|
+
[paths.changeLog, CHANGE_LOG_TEMPLATE],
|
|
361
384
|
];
|
|
362
385
|
const created = [];
|
|
363
386
|
fs.mkdirSync(paths.root, { recursive: true });
|
|
364
|
-
for (const [filePath, template] of docs) {
|
|
365
|
-
if (
|
|
366
|
-
atomicWrite(filePath, template);
|
|
387
|
+
for (const [filePath, template, legacyPath] of docs) {
|
|
388
|
+
if (writeInitialDoc(filePath, template, legacyPath)) {
|
|
367
389
|
created.push(filePath);
|
|
368
390
|
}
|
|
369
391
|
}
|
|
@@ -455,7 +477,7 @@ function appendChangeLog(inputPath, body, options = {}) {
|
|
|
455
477
|
throw new Error(`No registered Amalgm project contains: ${cleanString(typeof inputPath === 'string' ? inputPath : inputPath?.path) || '(no path)'}`);
|
|
456
478
|
}
|
|
457
479
|
if (isSystemProject(project)) {
|
|
458
|
-
throw new Error('The system context has instructions only — change logs are per-project.');
|
|
480
|
+
throw new Error('The system context has system-instructions.md only — change logs are per-project.');
|
|
459
481
|
}
|
|
460
482
|
const entryBody = sanitizeChangeLogBody(body);
|
|
461
483
|
if (!entryBody) throw new Error('Change log entry body is required');
|
|
@@ -485,7 +507,7 @@ function writeContextDoc(inputPath, docKey, content, options = {}) {
|
|
|
485
507
|
throw new Error(`No registered Amalgm project contains: ${cleanString(typeof inputPath === 'string' ? inputPath : inputPath?.path) || '(no path)'}`);
|
|
486
508
|
}
|
|
487
509
|
if (isSystemProject(project) && docKey !== 'instructions') {
|
|
488
|
-
throw new Error('The system context has instructions only —
|
|
510
|
+
throw new Error('The system context has system-instructions.md only — project goals, project state, and change logs are per-project.');
|
|
489
511
|
}
|
|
490
512
|
if (typeof content !== 'string') throw new Error('content must be a string');
|
|
491
513
|
ensureContextFiles(project);
|
|
@@ -500,6 +522,13 @@ function writeContextDoc(inputPath, docKey, content, options = {}) {
|
|
|
500
522
|
return { project, record };
|
|
501
523
|
}
|
|
502
524
|
|
|
525
|
+
/** project-goals.md: latest write wins; writable by UI and the memories tool. */
|
|
526
|
+
function writeProjectGoals(inputPath, content, options = {}) {
|
|
527
|
+
return writeContextDoc(inputPath, 'goals', content, {
|
|
528
|
+
source: options.source || 'project-context:write-project-goals',
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
503
532
|
/** project-state.md: latest write wins; revision is metadata, not a lock. */
|
|
504
533
|
function writeProjectState(inputPath, content, options = {}) {
|
|
505
534
|
return writeContextDoc(inputPath, 'projectState', content, {
|
|
@@ -507,7 +536,7 @@ function writeProjectState(inputPath, content, options = {}) {
|
|
|
507
536
|
});
|
|
508
537
|
}
|
|
509
538
|
|
|
510
|
-
/** instructions
|
|
539
|
+
/** project/system instructions: user-authored; latest write wins via the UI path. */
|
|
511
540
|
function writeInstructions(inputPath, content, options = {}) {
|
|
512
541
|
return writeContextDoc(inputPath, 'instructions', content, {
|
|
513
542
|
source: options.source || 'project-context:write-instructions',
|
|
@@ -667,5 +696,6 @@ module.exports = {
|
|
|
667
696
|
watchProjectContext,
|
|
668
697
|
writeChangeLog,
|
|
669
698
|
writeInstructions,
|
|
699
|
+
writeProjectGoals,
|
|
670
700
|
writeProjectState,
|
|
671
701
|
};
|
|
@@ -72,6 +72,42 @@ module.exports = [
|
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
},
|
|
75
|
+
{
|
|
76
|
+
name: 'write_project_goals',
|
|
77
|
+
description:
|
|
78
|
+
'Rewrite the active project\'s goals document (project-goals.md). Use this when durable goals, intended outcomes, success criteria, or explicit priorities have changed. Latest write wins; submit the COMPLETE new document as markdown. Keep goals distinct from progress: use project-state.md for current status and append_change_log for event history.',
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
content: {
|
|
83
|
+
type: 'string',
|
|
84
|
+
description: 'Full markdown content of the new project-goals.md (start with "# Project Goals").',
|
|
85
|
+
},
|
|
86
|
+
projectPath: PROJECT_PATH_INPUT,
|
|
87
|
+
},
|
|
88
|
+
required: ['content'],
|
|
89
|
+
},
|
|
90
|
+
async handler({ content, projectPath } = {}, context = {}) {
|
|
91
|
+
const target = targetPathFor({ projectPath }, context);
|
|
92
|
+
if (!target) return noProjectError('');
|
|
93
|
+
if (typeof content !== 'string' || !content.trim()) {
|
|
94
|
+
return errorResult('content is required (full markdown for project-goals.md)');
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const result = store.writeProjectGoals(target, content, {
|
|
98
|
+
source: 'project-context:tool',
|
|
99
|
+
});
|
|
100
|
+
return textResult(
|
|
101
|
+
`Project goals updated for ${result.project.name} (revision ${result.record?.files?.goals?.revision || 'unknown'}).\n`
|
|
102
|
+
+ `File: ${result.record?.paths?.goals || ''}`,
|
|
103
|
+
);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
106
|
+
if (message.startsWith('No registered Amalgm project')) return noProjectError(target);
|
|
107
|
+
return errorResult(message);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
},
|
|
75
111
|
{
|
|
76
112
|
name: 'write_project_state',
|
|
77
113
|
description:
|
|
@@ -111,7 +147,7 @@ module.exports = [
|
|
|
111
147
|
{
|
|
112
148
|
name: 'read_project_context',
|
|
113
149
|
description:
|
|
114
|
-
'Read the active project\'s memory: instructions.md (user-authored), project-state.md (consolidated snapshot), and the recent change-log.md window (newest first). Use when you need to re-check project memory mid-session or inspect another project\'s context via projectPath.',
|
|
150
|
+
'Read the active project\'s memory: project-instructions.md (user-authored), project-goals.md (intended outcomes), project-state.md (consolidated snapshot), and the recent change-log.md window (newest first). Use when you need to re-check project memory mid-session or inspect another project\'s context via projectPath.',
|
|
115
151
|
inputSchema: {
|
|
116
152
|
type: 'object',
|
|
117
153
|
properties: {
|
|
@@ -128,9 +164,12 @@ module.exports = [
|
|
|
128
164
|
`Project: ${record.name} (${record.projectPath})`,
|
|
129
165
|
`Context revision: ${record.contextRevision}`,
|
|
130
166
|
'',
|
|
131
|
-
'## instructions.md',
|
|
167
|
+
'## project-instructions.md',
|
|
132
168
|
record.prompt?.instructions || '(empty)',
|
|
133
169
|
'',
|
|
170
|
+
'## project-goals.md',
|
|
171
|
+
record.prompt?.goals || '(empty)',
|
|
172
|
+
'',
|
|
134
173
|
'## project-state.md',
|
|
135
174
|
record.prompt?.projectState || '(empty)',
|
|
136
175
|
'',
|
|
@@ -43,7 +43,7 @@ const CORE_TOOL_GROUPS = [
|
|
|
43
43
|
group(
|
|
44
44
|
'memories',
|
|
45
45
|
'Memories',
|
|
46
|
-
'Maintain project memory: append change-log entries and keep
|
|
46
|
+
'Maintain project memory: append change-log entries and keep project goals/state current.',
|
|
47
47
|
require('../project-context/tools'),
|
|
48
48
|
),
|
|
49
49
|
group(
|
|
@@ -23,6 +23,10 @@ async function handleProjectContextRoutes(ctx) {
|
|
|
23
23
|
await projectContextRest.handleWriteProjectState(await ctx.readJsonBody(), ctx.sendJson);
|
|
24
24
|
return true;
|
|
25
25
|
}
|
|
26
|
+
if (ctx.pathname === '/project-context/project-goals' && ctx.method === 'PUT') {
|
|
27
|
+
await projectContextRest.handleWriteProjectGoals(await ctx.readJsonBody(), ctx.sendJson);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
26
30
|
if (ctx.pathname === '/project-context/instructions' && ctx.method === 'PUT') {
|
|
27
31
|
await projectContextRest.handleWriteInstructions(await ctx.readJsonBody(), ctx.sendJson);
|
|
28
32
|
return true;
|
|
@@ -27,6 +27,7 @@ test('core tool grouping preserves handler definitions for MCP surface wrapping'
|
|
|
27
27
|
assert.equal(CORE_TOOLS.some((tool) => tool.name === 'open' && tool.toolboxToolId === 'browser'), true);
|
|
28
28
|
assert.equal(CORE_TOOLS.some((tool) => tool.name === 'notify_user'), true);
|
|
29
29
|
assert.equal(CORE_TOOLS.some((tool) => tool.name === 'append_change_log'), true);
|
|
30
|
+
assert.equal(CORE_TOOLS.some((tool) => tool.name === 'write_project_goals'), true);
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
test('core tool actions carry stable toolbox ids', () => {
|
|
@@ -41,7 +41,7 @@ test.after(() => {
|
|
|
41
41
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
test('ensureProjectContext creates
|
|
44
|
+
test('ensureProjectContext creates project docs and a row, additively', () => {
|
|
45
45
|
const { projectPath, workspace } = makeProject('ensure');
|
|
46
46
|
const preexisting = path.join(projectPath, 'keep.txt');
|
|
47
47
|
fs.writeFileSync(preexisting, 'keep me');
|
|
@@ -50,15 +50,27 @@ test('ensureProjectContext creates the three docs and a row, additively', () =>
|
|
|
50
50
|
|
|
51
51
|
assert.deepEqual(
|
|
52
52
|
fs.readdirSync(path.join(projectPath, '.amalgm', 'context')).sort(),
|
|
53
|
-
['change-log.md', 'instructions.md', 'project-state.md'],
|
|
53
|
+
['change-log.md', 'project-goals.md', 'project-instructions.md', 'project-state.md'],
|
|
54
54
|
);
|
|
55
55
|
assert.equal(fs.readFileSync(preexisting, 'utf8'), 'keep me');
|
|
56
56
|
assert.equal(record.projectId, workspace.id);
|
|
57
57
|
assert.ok(record.contextRevision);
|
|
58
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');
|
|
59
|
+
fs.writeFileSync(path.join(projectPath, '.amalgm/context/project-instructions.md'), '# Project Instructions\n\nKeep these.\n');
|
|
60
60
|
store.ensureProjectContext(workspace, { source: 'test' });
|
|
61
|
-
assert.match(readContextFile(projectPath, 'instructions.md'), /Keep these\./);
|
|
61
|
+
assert.match(readContextFile(projectPath, 'project-instructions.md'), /Keep these\./);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('ensureProjectContext migrates legacy instructions.md without deleting it', () => {
|
|
65
|
+
const { projectPath, workspace } = makeProject('legacy-instructions');
|
|
66
|
+
const contextDir = path.join(projectPath, '.amalgm', 'context');
|
|
67
|
+
fs.mkdirSync(contextDir, { recursive: true });
|
|
68
|
+
fs.writeFileSync(path.join(contextDir, 'instructions.md'), '# Instructions\n\nLegacy project guidance.\n');
|
|
69
|
+
|
|
70
|
+
store.ensureProjectContext(workspace, { source: 'test' });
|
|
71
|
+
|
|
72
|
+
assert.match(readContextFile(projectPath, 'project-instructions.md'), /Legacy project guidance\./);
|
|
73
|
+
assert.match(readContextFile(projectPath, 'instructions.md'), /Legacy project guidance\./);
|
|
62
74
|
});
|
|
63
75
|
|
|
64
76
|
test('appendChangeLog stamps runtime timestamps and prepends under the title', () => {
|
|
@@ -115,6 +127,23 @@ test('writeProjectState is latest-write-wins with revision metadata', () => {
|
|
|
115
127
|
assert.notEqual(first.record.contextRevision, second.record.contextRevision);
|
|
116
128
|
});
|
|
117
129
|
|
|
130
|
+
test('writeProjectGoals is latest-write-wins and separate from project state', () => {
|
|
131
|
+
const { projectPath, workspace } = makeProject('goals');
|
|
132
|
+
store.ensureProjectContext(workspace);
|
|
133
|
+
|
|
134
|
+
const first = store.writeProjectGoals(projectPath, '# Project Goals\n\nDraft goal.');
|
|
135
|
+
const second = store.writeProjectGoals(projectPath, '# Project Goals\n\nFinal goal.');
|
|
136
|
+
const record = store.getProjectContext(projectPath, { reconcile: false });
|
|
137
|
+
|
|
138
|
+
assert.equal(readContextFile(projectPath, 'project-goals.md'), '# Project Goals\n\nFinal goal.\n');
|
|
139
|
+
assert.equal(record.prompt.goals.includes('Final goal.'), true);
|
|
140
|
+
assert.equal(record.prompt.projectState, '');
|
|
141
|
+
assert.notEqual(
|
|
142
|
+
first.record.files.goals.revision,
|
|
143
|
+
second.record.files.goals.revision,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
118
147
|
test('reconcile is idempotent and catches out-of-band edits', () => {
|
|
119
148
|
const { projectPath, workspace } = makeProject('reconcile');
|
|
120
149
|
store.ensureProjectContext(workspace);
|
|
@@ -180,14 +209,17 @@ test('fresh threads get the full prompt block; resumed threads get deltas', asyn
|
|
|
180
209
|
const { projectPath, workspace } = makeProject('prompt');
|
|
181
210
|
store.ensureProjectContext(workspace);
|
|
182
211
|
store.appendChangeLog(projectPath, '- Completed: baseline work.');
|
|
212
|
+
store.writeProjectGoals(projectPath, '# Project Goals\n\nShip the memory system.');
|
|
183
213
|
store.writeProjectState(projectPath, '# Project State\n\nBaseline state.');
|
|
184
214
|
|
|
185
215
|
const fresh = { sessionId: 'sess-prompt', cwd: projectPath, providerSessionId: null };
|
|
186
216
|
const block = prompt.projectContextPromptBlock(fresh);
|
|
187
217
|
assert.ok(block.includes('<project_context>'));
|
|
188
218
|
assert.ok(block.includes('Baseline state.'));
|
|
219
|
+
assert.ok(block.includes('Ship the memory system.'));
|
|
189
220
|
assert.ok(block.includes('baseline work.'));
|
|
190
221
|
assert.ok(block.includes('toolbox__memories_append_change_log'));
|
|
222
|
+
assert.ok(block.includes('toolbox__memories_write_project_goals'));
|
|
191
223
|
// Empty instructions are omitted entirely.
|
|
192
224
|
assert.ok(!block.includes('<project_instructions'));
|
|
193
225
|
|
|
@@ -201,8 +233,16 @@ test('fresh threads get the full prompt block; resumed threads get deltas', asyn
|
|
|
201
233
|
assert.ok(update.includes('<project_context_update>'));
|
|
202
234
|
assert.ok(update.includes('same-second follow-up.'));
|
|
203
235
|
assert.ok(!update.includes('Baseline state.'), 'unchanged state stays out of the delta');
|
|
236
|
+
assert.ok(!update.includes('Ship the memory system.'), 'unchanged goals stay out of the delta');
|
|
204
237
|
assert.equal(prompt.projectContextTurnUpdateBlock(resumed), '', 'update settles');
|
|
205
238
|
|
|
239
|
+
// Goals rewrite -> full new goals in the delta.
|
|
240
|
+
store.writeProjectGoals(projectPath, '# Project Goals\n\nLaunch project memory.');
|
|
241
|
+
const goalsUpdate = prompt.projectContextTurnUpdateBlock(resumed);
|
|
242
|
+
assert.ok(goalsUpdate.includes('<project_goals'));
|
|
243
|
+
assert.ok(goalsUpdate.includes('Launch project memory.'));
|
|
244
|
+
assert.ok(!goalsUpdate.includes('new_change_log_entries'));
|
|
245
|
+
|
|
206
246
|
// State rewrite -> full new state in the delta.
|
|
207
247
|
store.writeProjectState(projectPath, '# Project State\n\nNew state.');
|
|
208
248
|
const stateUpdate = prompt.projectContextTurnUpdateBlock(resumed);
|
|
@@ -235,8 +275,13 @@ test('memories tools resolve the session project and enforce registration', asyn
|
|
|
235
275
|
const wrote = await byName.write_project_state.handler({ content: '# Project State\n\nTool state.' }, context);
|
|
236
276
|
assert.ok(!wrote.isError);
|
|
237
277
|
|
|
278
|
+
const goals = await byName.write_project_goals.handler({ content: '# Project Goals\n\nTool goals.' }, context);
|
|
279
|
+
assert.ok(!goals.isError);
|
|
280
|
+
assert.ok(readContextFile(projectPath, 'project-goals.md').includes('Tool goals.'));
|
|
281
|
+
|
|
238
282
|
const read = await byName.read_project_context.handler({}, context);
|
|
239
283
|
assert.ok(read.content[0].text.includes('Tool state.'));
|
|
284
|
+
assert.ok(read.content[0].text.includes('Tool goals.'));
|
|
240
285
|
|
|
241
286
|
const missing = await byName.append_change_log.handler({ body: '- x' }, { callerSessionId: 'no-cwd' });
|
|
242
287
|
assert.ok(missing.isError);
|
|
@@ -245,7 +290,7 @@ test('memories tools resolve the session project and enforce registration', asyn
|
|
|
245
290
|
|
|
246
291
|
test('system scope: instructions-only record, guarded writes, canonical save path', () => {
|
|
247
292
|
const systemContextDir = path.join(process.env.AMALGM_DIR, 'context');
|
|
248
|
-
const systemInstructionsPath = path.join(systemContextDir, 'instructions.md');
|
|
293
|
+
const systemInstructionsPath = path.join(systemContextDir, 'system-instructions.md');
|
|
249
294
|
|
|
250
295
|
// The boot pass owns file creation for the system scope.
|
|
251
296
|
store.startProjectContextService();
|
|
@@ -258,17 +303,18 @@ test('system scope: instructions-only record, guarded writes, canonical save pat
|
|
|
258
303
|
assert.equal(system.name, 'Amalgm System');
|
|
259
304
|
assert.equal(system.paths.instructions, systemInstructionsPath);
|
|
260
305
|
assert.equal(system.paths.changeLog, undefined, 'system has no change log');
|
|
306
|
+
assert.equal(system.paths.goals, undefined, 'system has no project goals');
|
|
261
307
|
assert.equal(system.files.projectState, undefined, 'system has no project state');
|
|
262
308
|
assert.ok(fs.existsSync(systemInstructionsPath), 'boot/list ensured the file');
|
|
263
309
|
assert.deepEqual(
|
|
264
310
|
fs.readdirSync(systemContextDir).filter((name) => name.endsWith('.md')).sort(),
|
|
265
|
-
['instructions.md'],
|
|
266
|
-
'only instructions.md is created for the system scope',
|
|
311
|
+
['system-instructions.md'],
|
|
312
|
+
'only system-instructions.md is created for the system scope',
|
|
267
313
|
);
|
|
268
314
|
|
|
269
315
|
// The UI editor saves by file path — it must resolve to the system scope.
|
|
270
316
|
const before = currentSeq();
|
|
271
|
-
const written = store.writeInstructions(systemInstructionsPath, '# Instructions\n\nAlways be definitive.');
|
|
317
|
+
const written = store.writeInstructions(systemInstructionsPath, '# System Instructions\n\nAlways be definitive.');
|
|
272
318
|
assert.equal(written.project.id, 'system');
|
|
273
319
|
const events = listEventsAfter(before).filter((event) => event.resource === 'project_context');
|
|
274
320
|
assert.equal(events.length, 1);
|
|
@@ -276,23 +322,25 @@ test('system scope: instructions-only record, guarded writes, canonical save pat
|
|
|
276
322
|
assert.ok(events[0].value.prompt.instructions.includes('Always be definitive.'));
|
|
277
323
|
|
|
278
324
|
// System scope rejects per-project docs.
|
|
279
|
-
assert.throws(() => store.appendChangeLog(systemInstructionsPath, '- x'), /instructions only/);
|
|
280
|
-
assert.throws(() => store.
|
|
325
|
+
assert.throws(() => store.appendChangeLog(systemInstructionsPath, '- x'), /system-instructions\.md only/);
|
|
326
|
+
assert.throws(() => store.writeProjectGoals(systemContextDir, '# Project Goals'), /system-instructions\.md only/);
|
|
327
|
+
assert.throws(() => store.writeProjectState(systemContextDir, '# Project State'), /system-instructions\.md only/);
|
|
281
328
|
});
|
|
282
329
|
|
|
283
330
|
test('system instructions prompt block reads the canonical file', () => {
|
|
284
331
|
const { systemInstructionsBlock, systemInstructionsPath } = require('../../chat-core/tooling/system-instructions');
|
|
285
332
|
const filePath = systemInstructionsPath();
|
|
333
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
286
334
|
|
|
287
|
-
fs.writeFileSync(filePath, '# Instructions\n\nNo instructions for Amalgm System yet.\n');
|
|
335
|
+
fs.writeFileSync(filePath, '# System Instructions\n\nNo instructions for Amalgm System yet.\n');
|
|
288
336
|
assert.equal(systemInstructionsBlock(), '', 'legacy placeholder counts as empty');
|
|
289
337
|
|
|
290
|
-
fs.writeFileSync(filePath, '# Instructions\n\nPrefer pnpm everywhere.\n');
|
|
338
|
+
fs.writeFileSync(filePath, '# System Instructions\n\nPrefer pnpm everywhere.\n');
|
|
291
339
|
const block = systemInstructionsBlock();
|
|
292
340
|
assert.ok(block.includes('<system_instructions'));
|
|
293
341
|
assert.ok(block.includes('Prefer pnpm everywhere.'));
|
|
294
342
|
|
|
295
|
-
fs.writeFileSync(filePath, '# Instructions\n');
|
|
343
|
+
fs.writeFileSync(filePath, '# System Instructions\n');
|
|
296
344
|
assert.equal(systemInstructionsBlock(), '', 'blank doc injects nothing');
|
|
297
345
|
});
|
|
298
346
|
|
|
@@ -3,20 +3,22 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* System-level instructions — the one machine-wide context doc.
|
|
5
5
|
*
|
|
6
|
-
* `<AMALGM_DIR>/context/instructions.md` is user-authored and applies
|
|
7
|
-
* session on this computer. Project memory
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* definitive memory surfaces.
|
|
6
|
+
* `<AMALGM_DIR>/context/system-instructions.md` is user-authored and applies
|
|
7
|
+
* to every session on this computer. Project memory is handled separately by
|
|
8
|
+
* the project-context block. The legacy active/passive memory blocks are gone
|
|
9
|
+
* — this and project context are the definitive memory surfaces.
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
12
|
const fs = require('fs');
|
|
14
|
-
const path = require('path');
|
|
15
13
|
const { AMALGM_DIR } = require('../../amalgm-mcp/config');
|
|
16
|
-
const {
|
|
14
|
+
const {
|
|
15
|
+
cappedDocContent,
|
|
16
|
+
isEmptyContextDoc,
|
|
17
|
+
systemContextPaths,
|
|
18
|
+
} = require('../../amalgm-mcp/project-context/paths');
|
|
17
19
|
|
|
18
20
|
function systemInstructionsPath() {
|
|
19
|
-
return
|
|
21
|
+
return systemContextPaths(AMALGM_DIR).instructions;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
function escapeAttribute(value) {
|
|
@@ -33,9 +33,10 @@ function systemInstructionsBlockSafe() {
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Definitive memory model: platform context, system instructions
|
|
36
|
-
* (machine-wide, user-authored), project context (instructions +
|
|
37
|
-
* state + recent change log, maintained through the memories tool),
|
|
38
|
-
* agent's own instructions. The legacy active/passive memory blocks
|
|
36
|
+
* (machine-wide, user-authored), project context (project instructions +
|
|
37
|
+
* goals + state + recent change log, maintained through the memories tool),
|
|
38
|
+
* and the agent's own instructions. The legacy active/passive memory blocks
|
|
39
|
+
* are gone.
|
|
39
40
|
*/
|
|
40
41
|
function composeSystemPrompt(contract) {
|
|
41
42
|
const parts = [];
|