agent-working-memory 0.17.0 → 0.18.1

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 (165) hide show
  1. package/README.md +321 -319
  2. package/dist/adapters/common.d.ts +34 -0
  3. package/dist/adapters/common.d.ts.map +1 -1
  4. package/dist/adapters/common.js +528 -446
  5. package/dist/adapters/common.js.map +1 -1
  6. package/dist/api/routes.d.ts.map +1 -1
  7. package/dist/api/routes.js +19 -0
  8. package/dist/api/routes.js.map +1 -1
  9. package/dist/cli/migrate.d.ts.map +1 -1
  10. package/dist/cli/migrate.js +31 -29
  11. package/dist/cli/migrate.js.map +1 -1
  12. package/dist/core/asked-value.d.ts +37 -0
  13. package/dist/core/asked-value.d.ts.map +1 -0
  14. package/dist/core/asked-value.js +77 -0
  15. package/dist/core/asked-value.js.map +1 -0
  16. package/dist/core/conflicts.d.ts +89 -0
  17. package/dist/core/conflicts.d.ts.map +1 -0
  18. package/dist/core/conflicts.js +267 -0
  19. package/dist/core/conflicts.js.map +1 -0
  20. package/dist/core/embeddings.d.ts +14 -2
  21. package/dist/core/embeddings.d.ts.map +1 -1
  22. package/dist/core/embeddings.js +32 -10
  23. package/dist/core/embeddings.js.map +1 -1
  24. package/dist/core/format-recall.d.ts +21 -1
  25. package/dist/core/format-recall.d.ts.map +1 -1
  26. package/dist/core/format-recall.js +60 -5
  27. package/dist/core/format-recall.js.map +1 -1
  28. package/dist/core/ml-worker.d.ts +24 -1
  29. package/dist/core/ml-worker.d.ts.map +1 -1
  30. package/dist/core/ml-worker.js +165 -4
  31. package/dist/core/ml-worker.js.map +1 -1
  32. package/dist/core/model-daemon-client.d.ts +77 -0
  33. package/dist/core/model-daemon-client.d.ts.map +1 -0
  34. package/dist/core/model-daemon-client.js +383 -0
  35. package/dist/core/model-daemon-client.js.map +1 -0
  36. package/dist/core/model-daemon-server.d.ts +71 -0
  37. package/dist/core/model-daemon-server.d.ts.map +1 -0
  38. package/dist/core/model-daemon-server.js +363 -0
  39. package/dist/core/model-daemon-server.js.map +1 -0
  40. package/dist/core/model-daemon.d.ts +137 -0
  41. package/dist/core/model-daemon.d.ts.map +1 -0
  42. package/dist/core/model-daemon.js +231 -0
  43. package/dist/core/model-daemon.js.map +1 -0
  44. package/dist/core/query-expander.d.ts +5 -0
  45. package/dist/core/query-expander.d.ts.map +1 -1
  46. package/dist/core/query-expander.js +3 -2
  47. package/dist/core/query-expander.js.map +1 -1
  48. package/dist/core/recall-config.d.ts +1 -1
  49. package/dist/core/recall-config.d.ts.map +1 -1
  50. package/dist/core/recall-config.js +5 -0
  51. package/dist/core/recall-config.js.map +1 -1
  52. package/dist/core/rerank-window.d.ts +7 -2
  53. package/dist/core/rerank-window.d.ts.map +1 -1
  54. package/dist/core/rerank-window.js +9 -4
  55. package/dist/core/rerank-window.js.map +1 -1
  56. package/dist/core/rerank2.d.ts +52 -2
  57. package/dist/core/rerank2.d.ts.map +1 -1
  58. package/dist/core/rerank2.js +60 -3
  59. package/dist/core/rerank2.js.map +1 -1
  60. package/dist/core/reranker.d.ts +19 -0
  61. package/dist/core/reranker.d.ts.map +1 -1
  62. package/dist/core/reranker.js +76 -12
  63. package/dist/core/reranker.js.map +1 -1
  64. package/dist/core/trust.d.ts +32 -0
  65. package/dist/core/trust.d.ts.map +1 -0
  66. package/dist/core/trust.js +66 -0
  67. package/dist/core/trust.js.map +1 -0
  68. package/dist/core/validity.d.ts +21 -0
  69. package/dist/core/validity.d.ts.map +1 -0
  70. package/dist/core/validity.js +41 -0
  71. package/dist/core/validity.js.map +1 -0
  72. package/dist/core/whoami.d.ts +3 -0
  73. package/dist/core/whoami.d.ts.map +1 -1
  74. package/dist/core/whoami.js +14 -0
  75. package/dist/core/whoami.js.map +1 -1
  76. package/dist/core/write-pipeline.d.ts +48 -0
  77. package/dist/core/write-pipeline.d.ts.map +1 -1
  78. package/dist/core/write-pipeline.js +80 -3
  79. package/dist/core/write-pipeline.js.map +1 -1
  80. package/dist/engine/activation.d.ts.map +1 -1
  81. package/dist/engine/activation.js +27 -4
  82. package/dist/engine/activation.js.map +1 -1
  83. package/dist/engine/consolidation.d.ts.map +1 -1
  84. package/dist/engine/consolidation.js +53 -4
  85. package/dist/engine/consolidation.js.map +1 -1
  86. package/dist/hooks/prime-query.d.ts +57 -0
  87. package/dist/hooks/prime-query.d.ts.map +1 -0
  88. package/dist/hooks/prime-query.js +388 -0
  89. package/dist/hooks/prime-query.js.map +1 -0
  90. package/dist/hooks/prime.d.ts +8 -0
  91. package/dist/hooks/prime.d.ts.map +1 -1
  92. package/dist/hooks/prime.js +21 -2
  93. package/dist/hooks/prime.js.map +1 -1
  94. package/dist/hooks/sidecar.d.ts +7 -0
  95. package/dist/hooks/sidecar.d.ts.map +1 -1
  96. package/dist/hooks/sidecar.js +10 -2
  97. package/dist/hooks/sidecar.js.map +1 -1
  98. package/dist/index.js +11 -3
  99. package/dist/index.js.map +1 -1
  100. package/dist/mcp.js +148 -103
  101. package/dist/mcp.js.map +1 -1
  102. package/dist/model-daemon.d.ts +8 -0
  103. package/dist/model-daemon.d.ts.map +1 -0
  104. package/dist/model-daemon.js +45 -0
  105. package/dist/model-daemon.js.map +1 -0
  106. package/dist/storage/factory.d.ts.map +1 -1
  107. package/dist/storage/factory.js +5 -0
  108. package/dist/storage/factory.js.map +1 -1
  109. package/dist/storage/pglite-schema.d.ts.map +1 -1
  110. package/dist/storage/pglite-schema.js +176 -170
  111. package/dist/storage/pglite-schema.js.map +1 -1
  112. package/dist/storage/pglite.d.ts +5 -0
  113. package/dist/storage/pglite.d.ts.map +1 -1
  114. package/dist/storage/pglite.js +165 -153
  115. package/dist/storage/pglite.js.map +1 -1
  116. package/dist/storage/postgres.d.ts +5 -0
  117. package/dist/storage/postgres.d.ts.map +1 -1
  118. package/dist/storage/postgres.js +165 -153
  119. package/dist/storage/postgres.js.map +1 -1
  120. package/dist/storage/sqlite.d.ts +22 -0
  121. package/dist/storage/sqlite.d.ts.map +1 -1
  122. package/dist/storage/sqlite.js +400 -363
  123. package/dist/storage/sqlite.js.map +1 -1
  124. package/dist/types/engram.d.ts +33 -0
  125. package/dist/types/engram.d.ts.map +1 -1
  126. package/dist/types/engram.js.map +1 -1
  127. package/package.json +1 -1
  128. package/plugin/.claude-plugin/plugin.json +1 -1
  129. package/plugin/README.md +1 -1
  130. package/plugin/bin/awm-mcp-launcher.cjs +2 -2
  131. package/plugin/skills/awm-memory/SKILL.md +11 -2
  132. package/src/adapters/common.ts +881 -802
  133. package/src/api/routes.ts +20 -0
  134. package/src/cli/migrate.ts +309 -307
  135. package/src/core/asked-value.ts +74 -0
  136. package/src/core/conflicts.ts +280 -0
  137. package/src/core/embeddings.ts +35 -10
  138. package/src/core/format-recall.ts +83 -25
  139. package/src/core/ml-worker.ts +458 -281
  140. package/src/core/model-daemon-client.ts +374 -0
  141. package/src/core/model-daemon-server.ts +344 -0
  142. package/src/core/model-daemon.ts +243 -0
  143. package/src/core/query-expander.ts +127 -126
  144. package/src/core/recall-config.ts +5 -0
  145. package/src/core/rerank-window.ts +164 -159
  146. package/src/core/rerank2.ts +143 -82
  147. package/src/core/reranker.ts +78 -11
  148. package/src/core/trust.ts +65 -0
  149. package/src/core/validity.ts +47 -0
  150. package/src/core/whoami.ts +136 -120
  151. package/src/core/write-pipeline.ts +119 -3
  152. package/src/engine/activation.ts +27 -4
  153. package/src/engine/consolidation.ts +950 -899
  154. package/src/hooks/prime-query.ts +375 -0
  155. package/src/hooks/prime.ts +25 -2
  156. package/src/hooks/sidecar.ts +13 -3
  157. package/src/index.ts +255 -248
  158. package/src/mcp.ts +1521 -1475
  159. package/src/model-daemon.ts +48 -0
  160. package/src/storage/factory.ts +25 -19
  161. package/src/storage/pglite-schema.ts +199 -193
  162. package/src/storage/pglite.ts +1513 -1498
  163. package/src/storage/postgres.ts +1613 -1598
  164. package/src/storage/sqlite.ts +2056 -2019
  165. package/src/types/engram.ts +30 -0
package/src/mcp.ts CHANGED
@@ -1,1475 +1,1521 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * MCP Server — Model Context Protocol interface for AgentWorkingMemory.
5
- *
6
- * Runs as a stdio-based MCP server that Claude Code connects to directly.
7
- * Uses the storage and engine layers in-process (no HTTP overhead).
8
- *
9
- * Tools exposed (19):
10
- * memory_write — store a memory (salience filter decides disposition)
11
- * memory_recall — activate memories by context (cognitive retrieval)
12
- * memory_feedback — report whether a recalled memory was useful
13
- * memory_retract — invalidate a wrong memory with optional correction
14
- * memory_supersede — replace an outdated memory with a current one
15
- * memory_stats — get memory health metrics
16
- * memory_whoami — identify this instance, mode, store, and sibling agent spaces
17
- * memory_checkpoint — save structured execution state (survives compaction)
18
- * memory_restore — restore state + targeted recall after compaction
19
- * memory_task_add — create a prioritized task
20
- * memory_task_update — change task status, priority, or blocking
21
- * memory_task_list — list tasks filtered by status
22
- * memory_task_next — get the highest-priority actionable task
23
- * memory_task_begin — start a task (auto-checkpoint + recall)
24
- * memory_task_end — end a task (write summary + checkpoint)
25
- * compress_output — encode structured tool output as TOON (token-efficient, lossless)
26
- * retrieve_original — get the verbatim source for a compress_output ref
27
- *
28
- * Run: npx tsx src/mcp.ts
29
- * Config: add to ~/.claude.json or .mcp.json
30
- */
31
-
32
- import { readFileSync } from 'node:fs';
33
- import { deriveAgentFromDir as deriveAgentFromDirShared } from './core/agent-id.js';
34
- import { resolve, basename } from 'node:path';
35
- import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
36
-
37
- // Load .env file if present (no external dependency)
38
- try {
39
- const envPath = resolve(process.cwd(), '.env');
40
- const envContent = readFileSync(envPath, 'utf-8');
41
- for (const line of envContent.split('\n')) {
42
- const trimmed = line.trim();
43
- if (!trimmed || trimmed.startsWith('#')) continue;
44
- const eqIdx = trimmed.indexOf('=');
45
- if (eqIdx === -1) continue;
46
- const key = trimmed.slice(0, eqIdx).trim();
47
- const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
48
- if (!process.env[key]) process.env[key] = val;
49
- }
50
- } catch { /* No .env file */ }
51
-
52
- // MCP uses stdout for JSON-RPC. Redirect console.log to stderr so engine
53
- // startup messages (ConsolidationScheduler, model loading, etc.) don't
54
- // corrupt the transport. This MUST happen before any engine imports.
55
- console.log = console.error;
56
-
57
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
58
- import { z } from 'zod';
59
-
60
- import { EngramStore } from './storage/sqlite.js';
61
- import { openStore, getConfiguredBackend, type StoreBackend } from './storage/factory.js';
62
- import type { IEngramStore } from './storage/store.js';
63
- import { ActivationEngine } from './engine/activation.js';
64
- import { ConnectionEngine } from './engine/connections.js';
65
- import { StagingBuffer } from './engine/staging.js';
66
- import { EvictionEngine } from './engine/eviction.js';
67
- import { RetractionEngine } from './engine/retraction.js';
68
- import { EvalEngine } from './engine/eval.js';
69
- import { ConsolidationEngine } from './engine/consolidation.js';
70
- import { ConsolidationScheduler } from './engine/consolidation-scheduler.js';
71
- import { evaluateSalience, computeNovelty, computeNoveltyWithMatch } from './core/salience.js';
72
- import { performWrite } from './core/write-pipeline.js';
73
- import type { ConsciousState } from './types/checkpoint.js';
74
- import type { SalienceEventType } from './core/salience.js';
75
- import type { TaskStatus, TaskPriority, AbstentionInfo } from './types/engram.js';
76
- import { DEFAULT_AGENT_CONFIG } from './types/agent.js';
77
- import { embed, getEmbedder } from './core/embeddings.js';
78
- import { getReranker } from './core/reranker.js';
79
- import { getExpander } from './core/query-expander.js';
80
- import { startSidecar } from './hooks/sidecar.js';
81
- import { initLogger, log, getLogPath } from './core/logger.js';
82
- import { VERSION } from './version.js';
83
- import { buildPack, INTERVIEW_QUESTIONS } from './onboard/index.js';
84
- import { liteCompress, retrieveOriginal } from './core/lite-compress.js';
85
- import { queryPeerDecisions, formatPeerDecisions } from './coordination/peer-decisions.js';
86
- import { startLoopLagMonitor } from './core/write-telemetry.js';
87
- import { buildWhoami, formatWhoami } from './core/whoami.js';
88
- import { renderTaskEndInvitation, validateRecipeWrite, recipeSlug, getRecipe } from './recipes/index.js';
89
- import { formatRecallResultLine } from './core/format-recall.js';
90
- import { packRecallByBudget, formatTokenFooter, estimateTokens } from './core/token-budget.js';
91
-
92
- // --- Incognito Mode ---
93
- // When AWM_INCOGNITO=1, register zero tools. Claude won't see memory tools at all.
94
- // No DB, no engines, no sidecar — just a bare MCP server that exposes nothing.
95
-
96
- const INCOGNITO = process.env.AWM_INCOGNITO === '1' || process.env.AWM_INCOGNITO === 'true';
97
-
98
- if (INCOGNITO) {
99
- console.error('AWM: incognito mode — all memory tools disabled, nothing will be recorded');
100
- const server = new McpServer({ name: 'agent-working-memory', version: VERSION });
101
- const transport = new StdioServerTransport();
102
- server.connect(transport).catch(err => {
103
- console.error('MCP server failed:', err);
104
- process.exit(1);
105
- });
106
- // No tools registered — Claude won't see any memory_* tools
107
- } else {
108
-
109
- // --- Setup ---
110
-
111
- const BACKEND: StoreBackend = getConfiguredBackend();
112
- const DB_PATH = process.env.AWM_DB_PATH ?? (BACKEND === 'pglite' ? 'memory-pglite' : 'memory.db');
113
-
114
- // Fallback agent selection when AWM_AGENT_ID/WORKER_NAME are unset: derive from
115
- // the project directory (core/agent-id.ts — one definition shared with `awm setup`
116
- // and mirrored in the shipped hook scripts, so the server, the installer and the
117
- // hooks always name the same pool for a given directory).
118
- const deriveAgentFromDir = () => deriveAgentFromDirShared(process.env.CLAUDE_PROJECT_DIR ?? process.cwd());
119
- const AGENT_ID = process.env.AWM_AGENT_ID ?? process.env.WORKER_NAME ?? deriveAgentFromDir();
120
- const HOOK_PORT = parseInt(process.env.AWM_HOOK_PORT ?? '8401', 10);
121
- // 0.14.2: ports to try upward from HOOK_PORT when it is busy (see sidecar.ts).
122
- const HOOK_PORT_RANGE = Math.max(1, parseInt(process.env.AWM_HOOK_PORT_RANGE ?? '10', 10) || 10);
123
- // Set once the sidecar starts; whoami reads the bound port from it.
124
- let sidecarHandle: { boundPort: () => number | null } | null = null;
125
- function sidecarPortLabel(): string {
126
- const p = sidecarHandle?.boundPort() ?? null;
127
- if (p === null) return `not bound (preferred ${HOOK_PORT}; hooks disabled)`;
128
- return p === HOOK_PORT ? `127.0.0.1:${p}` : `127.0.0.1:${p} (preferred ${HOOK_PORT} was busy)`;
129
- }
130
- const HOOK_SECRET = process.env.AWM_HOOK_SECRET ?? null;
131
-
132
- initLogger(DB_PATH);
133
- log(AGENT_ID, 'startup', `MCP server starting (backend: ${BACKEND}, db: ${DB_PATH}, hooks: ${HOOK_PORT})`);
134
-
135
- // AWM 0.8.x: openStore() returns either SQLite (sync) or PGlite (async) store.
136
- // Engines accept either via IEngramStore (MaybePromise<T> contract).
137
- const { store: storeAny } = await openStore();
138
- // Engines accept the async contract; SQLite-only call sites must guard on BACKEND.
139
- const store = storeAny as unknown as IEngramStore & Partial<EngramStore>;
140
- const activationEngine = new ActivationEngine(store);
141
- const connectionEngine = new ConnectionEngine(store, activationEngine);
142
- const stagingBuffer = new StagingBuffer(store, activationEngine);
143
- const evictionEngine = new EvictionEngine(store);
144
- const retractionEngine = new RetractionEngine(store);
145
- const evalEngine = new EvalEngine(store);
146
- const consolidationEngine = new ConsolidationEngine(store, connectionEngine);
147
- const consolidationScheduler = new ConsolidationScheduler(store, consolidationEngine);
148
-
149
- stagingBuffer.start(DEFAULT_AGENT_CONFIG.stagingTtlMs);
150
- consolidationScheduler.start();
151
- startLoopLagMonitor();
152
-
153
- // Coordination DB handle — set when AWM_COORDINATION=true, used by memory_write for decision propagation
154
- let coordDb: import('better-sqlite3').Database | null = null;
155
-
156
- const server = new McpServer({
157
- name: 'agent-working-memory',
158
- version: VERSION,
159
- });
160
-
161
- server.registerResource(
162
- 'awm-overview',
163
- 'awm://server/overview',
164
- {
165
- title: 'AWM Overview',
166
- description: 'AgentWorkingMemory MCP server metadata and discovery notes',
167
- mimeType: 'text/markdown',
168
- },
169
- async () => ({
170
- contents: [{
171
- uri: 'awm://server/overview',
172
- text: [
173
- '# Agent Working Memory',
174
- '',
175
- `Agent: ${AGENT_ID}`,
176
- `DB: ${DB_PATH}`,
177
- `Coordination: ${process.env.AWM_COORDINATION === 'true' || process.env.AWM_COORDINATION === '1' ? 'enabled' : 'disabled'}`,
178
- '',
179
- 'This MCP server primarily exposes tools such as `memory_restore`, `memory_recall`, `memory_write`, and task/checkpoint operations.',
180
- 'The resources below exist so generic MCP clients can discover the server through `resources/list` and `resources/templates/list`.',
181
- ].join('\n'),
182
- mimeType: 'text/markdown',
183
- }],
184
- })
185
- );
186
-
187
- server.registerResource(
188
- 'awm-memory-template',
189
- new ResourceTemplate('awm://memory/{id}', { list: undefined }),
190
- {
191
- title: 'AWM Memory By ID',
192
- description: 'Metadata resource template for a memory identifier',
193
- mimeType: 'text/markdown',
194
- },
195
- async (_uri, variables) => ({
196
- contents: [{
197
- uri: `awm://memory/${variables.id ?? ''}`,
198
- text: [
199
- '# AWM Memory Reference',
200
- '',
201
- `Requested memory id: ${variables.id ?? ''}`,
202
- '',
203
- 'Use the AWM memory tools for actual retrieval and mutation:',
204
- '- `memory_recall` for cognitive retrieval',
205
- '- `memory_restore` for session state',
206
- '- `memory_feedback`, `memory_retract`, `memory_supersede` for memory maintenance',
207
- ].join('\n'),
208
- mimeType: 'text/markdown',
209
- }],
210
- })
211
- );
212
-
213
- // --- Auto-classification for memory types ---
214
-
215
- function classifyMemoryType(content: string): 'episodic' | 'semantic' | 'procedural' | 'unclassified' {
216
- const lower = content.toLowerCase();
217
- // Procedural: how-to, steps, numbered lists
218
- if (/\bhow to\b|\bsteps?:/i.test(content) || /^\s*\d+[\.\)]\s/m.test(content) || /\bthen run\b|\bfirst,?\s/i.test(content)) {
219
- return 'procedural';
220
- }
221
- // Episodic: past tense events, incidents, specific time references
222
- if (/\b(discovered|debugged|fixed|encountered|happened|resolved|found that|we did|i did|yesterday|last week|today)\b/i.test(content)) {
223
- return 'episodic';
224
- }
225
- // Semantic: facts, decisions, rules, patterns
226
- if (/\b(is|are|should|always|never|must|uses?|requires?|means|pattern|decision|rule|convention)\b/i.test(content) && content.length < 500) {
227
- return 'semantic';
228
- }
229
- return 'unclassified';
230
- }
231
-
232
- // --- Tools ---
233
-
234
- server.tool(
235
- 'memory_write',
236
- `Store a memory. The salience filter decides whether it's worth keeping (active), needs more evidence (staging), or should be discarded.
237
-
238
- CALL THIS PROACTIVELY — do not wait to be asked. Write memories when you:
239
- - Discover something about the codebase, bugs, or architecture
240
- - Make a decision and want to remember why
241
- - Encounter and resolve an error
242
- - Learn a user preference or project pattern
243
- - Complete a significant piece of work
244
-
245
- The concept should be a short label (3-8 words). The content should be the full detail.`,
246
- {
247
- concept: z.string().describe('Short label for this memory (3-8 words)'),
248
- content: z.string().describe('Full detail of what was learned'),
249
- tags: z.array(z.string()).optional().describe('Optional tags for categorization'),
250
- event_type: z.enum(['observation', 'decision', 'friction', 'surprise', 'causal'])
251
- .optional().default('observation')
252
- .describe('Type of event: observation (default), decision, friction (error/blocker), surprise, causal (root cause)'),
253
- surprise: z.number().min(0).max(1).optional().default(0.3)
254
- .describe('How surprising was this? 0=expected, 1=very unexpected'),
255
- decision_made: z.boolean().optional().default(false)
256
- .describe('Was a decision made? True boosts importance'),
257
- causal_depth: z.number().min(0).max(1).optional().default(0.3)
258
- .describe('How deep is the causal understanding? 0=surface, 1=root cause'),
259
- resolution_effort: z.number().min(0).max(1).optional().default(0.3)
260
- .describe('How much effort to resolve? 0=trivial, 1=significant debugging'),
261
- memory_class: z.enum(['canonical', 'working', 'ephemeral']).optional().default('working')
262
- .describe('Memory class: canonical (source-of-truth, never stages), working (default), ephemeral (temporary, decays faster)'),
263
- memory_type: z.enum(['episodic', 'semantic', 'procedural', 'unclassified']).optional()
264
- .describe('Memory type: episodic (events/incidents), semantic (facts/decisions), procedural (how-to/steps). Auto-classified if omitted.'),
265
- supersedes: z.string().optional()
266
- .describe('ID of an older memory this one replaces. The old memory is down-ranked, not deleted.'),
267
- // --- Agent-provided metadata (stored as searchable tags) ---
268
- project: z.string().optional()
269
- .describe('Project context (e.g., "EquiHub", "AWM"). Becomes a searchable tag.'),
270
- topic: z.string().optional()
271
- .describe('Subject area (e.g., "database-migration", "auth-flow"). Becomes a searchable tag.'),
272
- source: z.enum(['code-reading', 'debugging', 'discussion', 'research', 'testing', 'observation']).optional()
273
- .describe('How this knowledge was acquired.'),
274
- confidence_level: z.enum(['verified', 'observed', 'assumed']).optional()
275
- .describe('Confidence: verified (tested), observed (read in code), assumed (reasoning).'),
276
- session_id: z.string().optional()
277
- .describe('Session/conversation grouping ID. Memories with same session_id are associated.'),
278
- origin_class: z.enum(['user-stated', 'tool-output', 'inference', 'recipe']).optional()
279
- .describe('Provenance (D5, log-only): where this knowledge came from. user-stated = the human said it; tool-output = read from a tool/system; inference = your reasoning; recipe = produced by a cognition recipe.'),
280
- recipe_id: z.string().optional()
281
- .describe('Cognition-recipe id+version when origin_class is recipe.'),
282
- valid_from: z.string().optional()
283
- .describe('ISO date when the FACT becomes valid (temporal validity, not ingestion time).'),
284
- valid_to: z.string().optional()
285
- .describe('ISO date when the FACT stops being valid (e.g., a deadline or a superseding change).'),
286
- intent: z.enum(['decision', 'question', 'todo', 'finding', 'context']).optional()
287
- .describe('What kind of memory this is.'),
288
- },
289
- async (params) => {
290
- // Assemble tags: user-provided + agent metadata (stored as searchable prefixed tags)
291
- const userTags = params.tags ?? [];
292
- const metaTags: string[] = [];
293
- if (params.project) metaTags.push(`proj=${params.project}`);
294
- if (params.topic) metaTags.push(`topic=${params.topic}`);
295
- if (params.source) metaTags.push(`src=${params.source}`);
296
- if (params.confidence_level) metaTags.push(`conf=${params.confidence_level}`);
297
- if (params.session_id) metaTags.push(`sid=${params.session_id}`);
298
- if (params.intent) metaTags.push(`intent=${params.intent}`);
299
-
300
- // D14 (2026-07-30): recipe write-backs are contract-checked. Provenance
301
- // must never claim a recipe that does not exist, and malformed
302
- // derivations are rejected with the contract echoed back so the host
303
- // can self-correct in one retry.
304
- if (params.origin_class === 'recipe') {
305
- if (!params.recipe_id) {
306
- return { content: [{ type: 'text' as const, text: "Recipe write rejected: origin_class 'recipe' requires recipe_id (e.g. 'skill-derivation@1')." }] };
307
- }
308
- const v = validateRecipeWrite(params.recipe_id, params.concept, params.content);
309
- if (!v.ok) {
310
- const contract = getRecipe(params.recipe_id)?.writeBack ?? 'unknown recipe';
311
- return { content: [{ type: 'text' as const, text: `Recipe write rejected (${params.recipe_id}): ${v.errors.join('; ')}\nContract: ${contract}` }] };
312
- }
313
- // Standardize recipe write-backs: canonical class, standard tags.
314
- const slug = recipeSlug(params.concept);
315
- const ensure = (tag: string) => { if (!userTags.includes(tag) && !metaTags.includes(tag)) metaTags.push(tag); };
316
- if (params.recipe_id.startsWith('skill-derivation')) {
317
- ensure('topic=skill'); ensure(`skill=${slug}`);
318
- params.memory_type = params.memory_type ?? 'procedural';
319
- } else if (params.recipe_id.startsWith('friction-lesson')) {
320
- ensure('topic=friction'); ensure(`about=${slug}`);
321
- // zod defaults event_type to 'observation', so force the recipe's
322
- // contract value rather than ??-guarding against undefined.
323
- params.event_type = 'friction';
324
- }
325
- params.memory_class = 'canonical';
326
- }
327
-
328
- const memoryType = params.memory_type ?? classifyMemoryType(params.content);
329
-
330
- const result = await performWrite({ store, connectionEngine }, {
331
- agentId: AGENT_ID,
332
- concept: params.concept,
333
- content: params.content,
334
- tags: [...userTags, ...metaTags],
335
- eventType: params.event_type as SalienceEventType,
336
- surprise: params.surprise,
337
- decisionMade: params.decision_made,
338
- causalDepth: params.causal_depth,
339
- resolutionEffort: params.resolution_effort,
340
- memoryClass: params.memory_class,
341
- memoryType,
342
- supersedes: params.supersedes,
343
- originClass: params.origin_class,
344
- writerSession: params.session_id,
345
- recipeId: params.recipe_id,
346
- validFrom: params.valid_from,
347
- validTo: params.valid_to,
348
- });
349
-
350
- // Auto-checkpoint — covers create/reinforce/supersede uniformly
351
- try { await store.updateAutoCheckpointWrite(AGENT_ID, result.engram.id); } catch { /* non-fatal */ }
352
-
353
- if (result.action === 'reinforce') {
354
- log(AGENT_ID, 'write:reinforce', `"${params.concept}" → reinforced "${result.engram.concept}" (conf ${result.reinforce!.previousConfidence.toFixed(2)} → ${result.reinforce!.newConfidence.toFixed(2)}, novelty=${result.noveltyResult.novelty.toFixed(2)})`);
355
- return {
356
- content: [{
357
- type: 'text' as const,
358
- text: `Reinforced existing memory "${result.engram.concept}" (confidence ${result.reinforce!.previousConfidence.toFixed(2)} → ${result.reinforce!.newConfidence.toFixed(2)})`,
359
- }],
360
- };
361
- }
362
-
363
- const engram = result.engram;
364
- const salience = result.salience!; // create/supersede always have salience
365
- const novelty = result.noveltyResult.novelty;
366
- const isLowSalience = salience.disposition === 'discard';
367
-
368
- // Decision propagation: when decision_made=true and coordination is enabled,
369
- // broadcast to coord_decisions so other agents can discover it
370
- if (params.decision_made && coordDb) {
371
- try {
372
- const agent = coordDb.prepare(
373
- `SELECT id, current_task FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
374
- ).get(AGENT_ID) as { id: string; current_task: string | null } | undefined;
375
- if (agent) {
376
- coordDb.prepare(
377
- `INSERT INTO coord_decisions (author_id, assignment_id, tags, summary) VALUES (?, ?, ?, ?)`
378
- ).run(agent.id, agent.current_task, params.tags ? JSON.stringify(params.tags) : null, params.concept);
379
- }
380
- } catch { /* decision propagation is non-fatal */ }
381
- }
382
-
383
- const logDisposition = isLowSalience ? 'low-salience' : salience.disposition;
384
- log(AGENT_ID, `write:${logDisposition}`, `"${params.concept}" salience=${salience.score.toFixed(2)} novelty=${novelty.toFixed(1)} id=${engram.id}`);
385
-
386
- return {
387
- content: [{
388
- type: 'text' as const,
389
- text: `Stored (${salience.disposition}) "${params.concept}" [${salience.score.toFixed(2)}]\nID: ${engram.id}`
390
- + (isLowSalience
391
- ? `\nNOTE: low salience — this memory is kept but demoted and may fade first. If it MUST survive and be recallable, retry with memory_class: 'canonical'. (Discards are audited: reason codes ${JSON.stringify(salience.reasonCodes.slice(0, 4))})`
392
- : ''),
393
- }],
394
- };
395
- }
396
- );
397
-
398
- server.tool(
399
- 'memory_recall',
400
- `Recall memories relevant to a query. Uses cognitive activation — not keyword search.
401
-
402
- ALWAYS call this when:
403
- - Starting work on a project or topic (recall what you know)
404
- - Debugging (recall similar errors and solutions)
405
- - Making decisions (recall past decisions and outcomes)
406
- - The user mentions a topic you might have stored memories about
407
-
408
- Accepts either "query" or "context" parameter — both work identically.
409
- Returns the most relevant memories ranked by text relevance, temporal recency, and associative strength.`,
410
- {
411
- query: z.string().optional().describe('What to search for — describe the situation, question, or topic'),
412
- context: z.string().optional().describe('Alias for query (either works)'),
413
- limit: z.number().optional().default(3).describe('Max memories to return (default 3). Measured on 400 real-store probes: k=3 is the only token-POSITIVE setting (+115 tok/recall, against -680 at k=5) and is 41% faster at p50, because `limit` sizes the cross-encoder rerank pool (min(limit*8, 40) — so 24 at k=3 against 40 at k=5) and rerank is 83% of warm recall time. It costs ~0.5pp success@1 versus k=5 — about 3 queries in 400, inside noise. Raise it explicitly when you want breadth for orientation rather than a specific fact; that is the case this default deliberately does not optimize for.'),
414
- min_score: z.number().optional().default(0.05).describe('Minimum relevance score (default 0.05)'),
415
- include_staging: z.boolean().optional().default(false).describe('Include weak/unconfirmed memories?'),
416
- use_reranker: z.boolean().optional().default(true).describe('Use cross-encoder re-ranking for better relevance (default true)'),
417
- use_expansion: z.boolean().optional().default(true).describe('Expand query with synonyms for better recall (default true)'),
418
- memory_type: z.enum(['episodic', 'semantic', 'procedural']).optional().describe('Filter by memory type (omit to search all types)'),
419
- workspace: z.string().optional().describe('Search across all agents in this workspace (hive mode). Omit for agent-scoped recall only.'),
420
- require_confidence: z.number().optional().default(0.05).describe('Abstain (return nothing) when recall confidence is below this threshold. Defaults to 0.05 — a LIGHT filter, chosen from measurement: it halves the rate of answering off-topic queries at zero cost to hit rate. Raise it only for push-style use where nobody asked (the prime hook uses 0.10 — it was 0.25 until 0.14.6, when 0.25 was measured silencing prompts with a clear winner). Do NOT raise it for ordinary recall: a miss is expensive, because the agent then reads the codebase instead (~2,106 tokens), so aggressive thresholds measurably destroy value — 0.20+ cut net tokens saved by 25% in tests/abstention-eval. Pass 0 to disable. NOTE: this is NOT min_score — it gates on the SHAPE of the score distribution across the whole result set, not per-result relevance, so memories that pass min_score can still be withheld. When that happens the reply says RECALL ABSTAINED and reports how many were withheld; an empty result is only absence when it does not.'),
421
- granularity: z.enum(['full', 'compact', 'auto']).optional().describe('Output granularity (Paper 3: cognitive teaming). "full" (default): no change. "compact": every result carries a short summary field. "auto": confidence-adaptive — top result gets a longer summary when there is a clear winner, otherwise everything is compact for scanning.'),
422
- max_tokens: z.number().optional().describe('Token budget for the response. `limit` is a COUNT and is token-blind — 5 results may cost 400 tokens or 4,000. Use this when context is tight: results are packed by value-per-token until the budget is reached, the top-scored match always gets first refusal, and the reply reports what it cost and what was withheld. Omit for no budget (everything is returned, still with accounting).'),
423
- },
424
- async (params) => {
425
- const queryText = params.query ?? params.context;
426
- if (!queryText) {
427
- return {
428
- content: [{
429
- type: 'text' as const,
430
- text: 'Error: provide either "query" or "context" parameter with your search text.',
431
- }],
432
- };
433
- }
434
- // Use workspace from param, env var, or omit for agent-scoped
435
- const workspace = params.workspace ?? process.env.AWM_WORKSPACE ?? undefined;
436
- // Set only when a gate WITHHELD results. Distinguishes abstention from absence,
437
- // which an empty array cannot.
438
- let abstained: AbstentionInfo | undefined;
439
- const results = await activationEngine.activate({
440
- agentId: AGENT_ID,
441
- context: queryText,
442
- limit: params.limit,
443
- minScore: params.min_score,
444
- includeStaging: params.include_staging,
445
- useReranker: params.use_reranker,
446
- useExpansion: params.use_expansion,
447
- memoryType: params.memory_type,
448
- workspace,
449
- requireConfidence: params.require_confidence,
450
- granularity: params.granularity,
451
- onAbstain: (info) => { abstained = info; },
452
- });
453
-
454
- // Auto-checkpoint: track recall
455
- try {
456
- const ids = results.map(r => r.engram.id);
457
- await store.updateAutoCheckpointRecall(AGENT_ID, queryText, ids);
458
- } catch { /* non-fatal */ }
459
-
460
- log(AGENT_ID, 'recall', `"${queryText.slice(0, 80)}" → ${results.length} results`);
461
-
462
- // Peer decisions: append recent decisions by other agents relevant to this query
463
- const peerSuffix = coordDb
464
- ? formatPeerDecisions(queryPeerDecisions(coordDb, AGENT_ID, queryText))
465
- : '';
466
-
467
- if (results.length === 0) {
468
- // Two very different conditions used to render identically as
469
- // "No relevant memories found." — a claim of absence the system cannot make
470
- // when a gate withheld matches. Callers read it as absence and stopped
471
- // looking, for memories scoring well above minScore.
472
- const text = abstained
473
- ? [
474
- `RECALL ABSTAINED — this is NOT "no memories exist".`,
475
- ``,
476
- `${abstained.candidates} candidate${abstained.candidates === 1 ? '' : 's'} matched ` +
477
- `(best score ${abstained.topScore.toFixed(3)}) and ${abstained.candidates === 1 ? 'was' : 'were'} ` +
478
- `withheld because recall confidence ` +
479
- `${abstained.confidence !== undefined ? abstained.confidence.toFixed(3) + ' ' : ''}` +
480
- `fell below your require_confidence of ${abstained.threshold ?? '?'}.`,
481
- ``,
482
- `require_confidence is NOT min_score. It gates on the SHAPE of the score` +
483
- ` distribution across the whole result set, not on how relevant any single` +
484
- ` memory is — so results that comfortably pass min_score can still be withheld here.`,
485
- ``,
486
- `To see them: re-run this query with require_confidence: 0.`,
487
- `Do not conclude the memories are absent without doing that.`,
488
- ].join('\n') + peerSuffix
489
- : 'No relevant memories found.' + peerSuffix;
490
- return { content: [{ type: 'text' as const, text }] };
491
- }
492
-
493
- // Confidence-adaptive output (Paper 3: cognitive teaming) and D8
494
- // (2026-07-30) conflict surfacing both live in the shared formatter now —
495
- // see core/format-recall.ts for why it's extracted (0.12.1: unit-testable
496
- // without booting the server) and why the id sits after the score.
497
- // 0.13.3: pack to a token budget (if given) and always report the spend.
498
- // `limit` is a count and is token-blind; max_tokens bounds what this call
499
- // actually costs the caller's context. See core/token-budget.ts for why the
500
- // top-scored result gets first refusal rather than pure density packing.
501
- // Reserve what the rest of the reply will cost, so max_tokens bounds the
502
- // WHOLE response and not just the result lines. FOOTER_ALLOWANCE is a
503
- // fixed upper bound on the accounting line (measured at ~15-25 tokens; the
504
- // unit test asserts it stays under 40).
505
- const FOOTER_ALLOWANCE = 40;
506
- const reserved = estimateTokens(peerSuffix) + FOOTER_ALLOWANCE;
507
- const packed = packRecallByBudget(
508
- results, formatRecallResultLine, params.max_tokens, reserved,
509
- );
510
-
511
- // A budget too small to admit even the best match must not look like "no
512
- // memories found" — that is a different fact and would send the caller off
513
- // to read code that AWM could have answered. Say what actually happened so
514
- // the fix (raise the budget) is obvious.
515
- if (packed.kept === 0 && packed.total > 0) {
516
- return {
517
- content: [{
518
- type: 'text' as const,
519
- text: `${packed.total} memories matched, but none fit a ${params.max_tokens}-token budget `
520
- + `(smallest result is ~${packed.withheldTokens} tok across ${packed.total}). `
521
- + `Raise max_tokens, or use granularity: 'compact' to shrink each result.`
522
- + peerSuffix,
523
- }],
524
- };
525
- }
526
-
527
- // 0.14.3: surface the activation event id once, so memory_feedback can join
528
- // to this recall. Rendered after the token footer; cheap (one short line).
529
- const evId = results[0]?.activationEventId;
530
- const evFooter = evId ? `\n[recall_id: ${evId}]` : '';
531
-
532
- return {
533
- content: [{
534
- type: 'text' as const,
535
- text: packed.lines.join('\n') + peerSuffix + formatTokenFooter(packed, params.max_tokens) + evFooter,
536
- }],
537
- };
538
- }
539
- );
540
-
541
- server.tool(
542
- 'memory_feedback',
543
- `Report whether a recalled memory was actually useful. This updates the memory's confidence score — useful memories become stronger, useless ones weaken.
544
-
545
- Always call this after using a recalled memory so the system learns what's valuable.`,
546
- {
547
- engram_id: z.string().describe('ID of the memory (from memory_recall results)'),
548
- useful: z.boolean().describe('Was this memory actually helpful?'),
549
- context: z.string().optional().describe('Brief note on why it was/wasn\'t useful'),
550
- recall_id: z.string().optional().describe(
551
- 'The [recall_id: …] printed at the end of the memory_recall output that returned this memory. '
552
- + 'Pass it so the feedback is joined to that recall; omitted, it defaults to the most recent recall in this session.',
553
- ),
554
- },
555
- async (params) => {
556
- // 0.14.3: join feedback to the recall that produced it. Explicit recall_id
557
- // wins; otherwise fall back to the engine's most recent logged activation —
558
- // the common case is "recall, use, feedback" in one turn. Before this the
559
- // MCP path hardcoded null (the HTTP route already accepted the id), which
560
- // is why every retrieval_feedback row in the live store was orphaned.
561
- const eventId = params.recall_id ?? activationEngine.lastActivationEventId ?? null;
562
- await store.logRetrievalFeedback(eventId, params.engram_id, params.useful, params.context ?? '');
563
-
564
- const engram = await store.getEngram(params.engram_id);
565
- if (engram) {
566
- const delta = params.useful
567
- ? DEFAULT_AGENT_CONFIG.feedbackPositiveBoost
568
- : -DEFAULT_AGENT_CONFIG.feedbackNegativePenalty;
569
- await store.updateConfidence(engram.id, engram.confidence + delta);
570
- }
571
-
572
- // Validation-gated Hebbian: resolve pending co-activation pairs for this engram
573
- const hebbianUpdated = await activationEngine.resolveHebbianFeedback(params.engram_id, params.useful);
574
-
575
- return {
576
- content: [{
577
- type: 'text' as const,
578
- text: `Feedback: ${params.useful ? '+useful' : '-not useful'}${hebbianUpdated > 0 ? ` (${hebbianUpdated} association${hebbianUpdated > 1 ? 's' : ''} ${params.useful ? 'strengthened' : 'weakened'})` : ''}`,
579
- }],
580
- };
581
- }
582
- );
583
-
584
- server.tool(
585
- 'memory_retract',
586
- `Retract a memory that turned out to be wrong. Creates a correction and reduces confidence of related memories.
587
-
588
- Use this when you discover a memory contains incorrect information.`,
589
- {
590
- engram_id: z.string().describe('ID of the wrong memory'),
591
- reason: z.string().describe('Why is this memory wrong?'),
592
- correction: z.string().optional().describe('What is the correct information? (creates a new memory)'),
593
- },
594
- async (params) => {
595
- const result = await retractionEngine.retract({
596
- agentId: AGENT_ID,
597
- targetEngramId: params.engram_id,
598
- reason: params.reason,
599
- counterContent: params.correction,
600
- });
601
-
602
- const parts = [`Memory ${params.engram_id} retracted.`];
603
- if (result.correctionId) {
604
- parts.push(`Correction stored as ${result.correctionId}.`);
605
- }
606
- parts.push(`${result.associatesAffected} related memories had confidence reduced.`);
607
-
608
- return {
609
- content: [{
610
- type: 'text' as const,
611
- text: parts.join(' '),
612
- }],
613
- };
614
- }
615
- );
616
-
617
- server.tool(
618
- 'memory_supersede',
619
- `Replace an outdated memory with a newer one. Unlike retraction (which marks memories as wrong), supersession marks the old memory as outdated but historically correct.
620
-
621
- Use this when:
622
- - A status or count has changed (e.g., "5 reviews done" → "7 reviews done")
623
- - Architecture or infrastructure evolved (e.g., "two-repo model" → "three-repo model")
624
- - A schedule or plan was updated
625
-
626
- The old memory stays in the database (searchable for history) but is heavily down-ranked in recall so the current version dominates.`,
627
- {
628
- old_engram_id: z.string().describe('ID of the outdated memory (from memory_recall results, or memory_write\'s own response if you just wrote it)'),
629
- new_engram_id: z.string().describe('ID of the replacement memory'),
630
- reason: z.string().optional().describe('Why the old memory is outdated'),
631
- },
632
- async (params) => {
633
- const oldEngram = await store.getEngram(params.old_engram_id);
634
- if (!oldEngram) {
635
- return { content: [{ type: 'text' as const, text: `Old memory not found: ${params.old_engram_id}` }] };
636
- }
637
- const newEngram = await store.getEngram(params.new_engram_id);
638
- if (!newEngram) {
639
- return { content: [{ type: 'text' as const, text: `New memory not found: ${params.new_engram_id}` }] };
640
- }
641
-
642
- await store.supersedeEngram(params.old_engram_id, params.new_engram_id);
643
-
644
- // Create supersession association (new → old)
645
- await store.upsertAssociation(params.new_engram_id, params.old_engram_id, 0.8, 'causal', 0.9);
646
-
647
- // Reduce old memory's confidence (not to zero — it's historical, not wrong)
648
- await store.updateConfidence(params.old_engram_id, Math.max(0.2, oldEngram.confidence * 0.4));
649
-
650
- log(AGENT_ID, 'supersede', `"${oldEngram.concept}" → "${newEngram.concept}"${params.reason ? ` (${params.reason})` : ''}`);
651
-
652
- return {
653
- content: [{
654
- type: 'text' as const,
655
- text: `Superseded: "${oldEngram.concept}" → "${newEngram.concept}"`,
656
- }],
657
- };
658
- }
659
- );
660
-
661
- server.tool(
662
- 'memory_whoami',
663
- `Identify THIS AWM instance — agent id, mode (standalone/hive), backend, store path, code provenance, ports, and the sibling agent spaces present in the same store. Call when unsure which AWM instance or memory space you are talking to.`,
664
- {},
665
- async () => {
666
- const info = await buildWhoami(store, AGENT_ID, 'mcp', sidecarHandle ? sidecarHandle.boundPort() : undefined);
667
- return { content: [{ type: 'text', text: formatWhoami(info) }] };
668
- },
669
- );
670
-
671
- server.tool(
672
- 'memory_stats',
673
- `Get memory health stats — how many memories, confidence levels, association count, and system performance.
674
- Also shows the activity log path so the user can tail it to see what's happening.`,
675
- {},
676
- async () => {
677
- const metrics = await evalEngine.computeMetrics(AGENT_ID);
678
- const checkpoint = await store.getCheckpoint(AGENT_ID);
679
- // 0.14.3: outcome numbers, not activity counters. "Edge utility" (share of
680
- // edges ever activated) was monotone — it could only rise — and read as
681
- // health when it was not. Latency was a mean over a column that mixes cold
682
- // loads and stalls (live store: mean 20 s, median 1.6 s); medians only now.
683
- const usage = await evalEngine.computeUsage(AGENT_ID);
684
- const lines = [
685
- `Agent: ${AGENT_ID}`,
686
- `Active memories: ${metrics.activeEngramCount}`,
687
- `Staging: ${metrics.stagingEngramCount}`,
688
- `Retracted: ${metrics.retractedCount}`,
689
- `Avg confidence: ${metrics.avgConfidence.toFixed(3)}`,
690
- `Total edges: ${metrics.totalEdges}`,
691
- ``,
692
- `Write:recall (30d): 1 : ${usage.recallsPerWrite30d.toFixed(2)} (${usage.writes30d} writes, ${usage.recalls30d} recalls)`,
693
- `Never recalled: ${(usage.neverRecalledShare * 100).toFixed(0)}% of active memories`,
694
- `Recall→use (7d): ${usage.feedbackLinked7d > 0 ? (usage.usefulShare7d * 100).toFixed(0) + '% useful of ' + usage.feedbackLinked7d + ' judged' : 'no linked feedback yet'}`,
695
- `Activations (24h): ${metrics.activationCount}`,
696
- `Recall latency (24h): p50 ${metrics.p50LatencyMs.toFixed(0)}ms p90 ${metrics.p90LatencyMs.toFixed(0)}ms`,
697
- ``,
698
- `Session writes: ${checkpoint?.auto.writeCountSinceConsolidation ?? 0}`,
699
- `Session recalls: ${checkpoint?.auto.recallCountSinceConsolidation ?? 0}`,
700
- `Last activity: ${checkpoint?.auto.lastActivityAt?.toISOString() ?? 'never'}`,
701
- `Checkpoint: ${checkpoint?.executionState ? checkpoint.executionState.currentTask : 'none'}`,
702
- ``,
703
- `Activity log: ${getLogPath() ?? 'not configured'}`,
704
- // Report the port actually BOUND, not the one configured. Before 0.14.2
705
- // this line said 8401 in every session, including the ones whose sidecar
706
- // had lost the port and silently disabled hooks.
707
- `Hook sidecar: ${sidecarPortLabel()}`,
708
- ];
709
-
710
- return {
711
- content: [{
712
- type: 'text' as const,
713
- text: lines.join('\n'),
714
- }],
715
- };
716
- }
717
- );
718
-
719
- // --- Checkpointing Tools ---
720
-
721
- server.tool(
722
- 'memory_checkpoint',
723
- `Save your current execution state so you can recover after context compaction.
724
-
725
- ALWAYS call this before:
726
- - Long operations (multi-file generation, large refactors, overnight work)
727
- - Anything that might fill the context window
728
- - Switching to a different task
729
-
730
- Also call periodically during long sessions to avoid losing state. The state is saved per-agent and overwrites any previous checkpoint.`,
731
- {
732
- current_task: z.string().describe('What you are currently working on'),
733
- decisions: z.array(z.string()).optional().default([])
734
- .describe('Key decisions made so far'),
735
- active_files: z.array(z.string()).optional().default([])
736
- .describe('Files you are currently working with'),
737
- next_steps: z.array(z.string()).optional().default([])
738
- .describe('What needs to happen next'),
739
- related_memory_ids: z.array(z.string()).optional().default([])
740
- .describe('IDs of memories relevant to current work'),
741
- notes: z.string().optional().default('')
742
- .describe('Any other context worth preserving'),
743
- episode_id: z.string().optional()
744
- .describe('Current episode ID if known'),
745
- },
746
- async (params) => {
747
- const state: ConsciousState = {
748
- currentTask: params.current_task,
749
- decisions: params.decisions,
750
- activeFiles: params.active_files,
751
- nextSteps: params.next_steps,
752
- relatedMemoryIds: params.related_memory_ids,
753
- notes: params.notes,
754
- episodeId: params.episode_id ?? null,
755
- };
756
-
757
- await store.saveCheckpoint(AGENT_ID, state);
758
- log(AGENT_ID, 'checkpoint', `"${params.current_task}" decisions=${params.decisions.length} files=${params.active_files.length}`);
759
-
760
- return {
761
- content: [{
762
- type: 'text' as const,
763
- text: `Checkpoint saved: "${params.current_task}" (${params.decisions.length} decisions, ${params.active_files.length} files)`,
764
- }],
765
- };
766
- }
767
- );
768
-
769
- server.tool(
770
- 'memory_restore',
771
- `Restore your previous execution state after context compaction or at session start.
772
-
773
- Returns:
774
- - Your saved execution state (task, decisions, next steps, files)
775
- - Recently recalled memories for context
776
- - Your last write for continuity
777
- - How long you were idle
778
-
779
- Use this at the start of every session or after compaction to pick up where you left off.`,
780
- {},
781
- async () => {
782
- const checkpoint = await store.getCheckpoint(AGENT_ID);
783
-
784
- // Cold-store nudge: an empty store means the agent has nothing to recall — offer to warm-start.
785
- let coldStoreNudge = '';
786
- try {
787
- const activeCount = (await store.getEngramsByAgent(AGENT_ID)).length;
788
- if (activeCount < 3) {
789
- coldStoreNudge = `🌱 **This memory store is nearly empty (${activeCount} ${activeCount === 1 ? 'memory' : 'memories'}).** Warm-start it before other work: recall the "onboard a new project" skill and follow it — or call \`onboard_scan\` on this project's docs/repo, refine the results, and save them with \`memory_write\` (canonical). Recall becomes useful immediately.`;
790
- }
791
- } catch { /* count is best-effort */ }
792
-
793
- const now = Date.now();
794
- const idleMs = checkpoint
795
- ? now - checkpoint.auto.lastActivityAt.getTime()
796
- : 0;
797
-
798
- // Get last written engram
799
- let lastWrite: { id: string; concept: string; content: string } | null = null;
800
- if (checkpoint?.auto.lastWriteId) {
801
- const engram = await store.getEngram(checkpoint.auto.lastWriteId);
802
- if (engram) {
803
- lastWrite = { id: engram.id, concept: engram.concept, content: engram.content };
804
- }
805
- }
806
-
807
- // Recall memories using last context
808
- let recalledMemories: Array<{ id: string; concept: string; content: string; score: number }> = [];
809
- const recallContext = checkpoint?.auto.lastRecallContext
810
- ?? checkpoint?.executionState?.currentTask
811
- ?? null;
812
-
813
- if (recallContext) {
814
- try {
815
- const results = await activationEngine.activate({
816
- agentId: AGENT_ID,
817
- context: recallContext,
818
- // 3, not 5 — measured token-positive (+115/recall vs -680) and 41% faster.
819
- // The case is stronger here than for explicit recall: this is push-style, so
820
- // nobody asked for these results and every one of them costs context the user
821
- // did not request.
822
- limit: 3,
823
- minScore: 0.05,
824
- useReranker: true,
825
- useExpansion: true,
826
- workspace: process.env.AWM_WORKSPACE ?? undefined,
827
- });
828
- recalledMemories = results.map(r => ({
829
- id: r.engram.id,
830
- concept: r.engram.concept,
831
- content: r.engram.content,
832
- score: r.score,
833
- }));
834
- } catch { /* recall failure is non-fatal */ }
835
- }
836
-
837
- // Consolidation on restore:
838
- // - If idle >5min but last consolidation was recent (graceful exit ran it), skip
839
- // - If idle >5min and no recent consolidation, run full cycle (non-graceful exit fallback)
840
- const MINI_IDLE_MS = 5 * 60_000;
841
- const FULL_CONSOLIDATION_GAP_MS = 10 * 60_000; // 10 min — if last consolidation was longer ago, run full
842
- let miniConsolidationTriggered = false;
843
- let fullConsolidationTriggered = false;
844
-
845
- if (idleMs > MINI_IDLE_MS) {
846
- const sinceLastConsolidation = checkpoint?.lastConsolidationAt
847
- ? now - checkpoint.lastConsolidationAt.getTime()
848
- : Infinity;
849
-
850
- if (sinceLastConsolidation > FULL_CONSOLIDATION_GAP_MS) {
851
- // No recent consolidation — graceful exit didn't happen, run full cycle
852
- fullConsolidationTriggered = true;
853
- try {
854
- const result = await consolidationEngine.consolidate(AGENT_ID);
855
- await store.markConsolidation(AGENT_ID, false);
856
- log(AGENT_ID, 'consolidation', `full sleep cycle on restore (no graceful exit, idle ${Math.round(idleMs / 60_000)}min, last consolidation ${Math.round(sinceLastConsolidation / 60_000)}min ago) — ${result.edgesStrengthened} strengthened, ${result.memoriesForgotten} forgotten`);
857
- } catch { /* consolidation failure is non-fatal */ }
858
- } else {
859
- // Recent consolidation exists — graceful exit already handled it, just do mini
860
- miniConsolidationTriggered = true;
861
- consolidationScheduler.runMiniConsolidation(AGENT_ID).catch(() => {});
862
- }
863
- }
864
-
865
- // Format response
866
- const parts: string[] = [];
867
- const idleMin = Math.round(idleMs / 60_000);
868
- const consolidationNote = fullConsolidationTriggered
869
- ? ' (full consolidation — no graceful exit detected)'
870
- : miniConsolidationTriggered
871
- ? ' (mini-consolidation triggered)'
872
- : '';
873
- log(AGENT_ID, 'restore', `idle=${idleMin}min checkpoint=${!!checkpoint?.executionState} recalled=${recalledMemories.length} lastWrite=${lastWrite?.concept ?? 'none'}${fullConsolidationTriggered ? ' FULL_CONSOLIDATION' : ''}`);
874
- parts.push(`Idle: ${idleMin}min${consolidationNote}`);
875
- if (coldStoreNudge) parts.push(`\n${coldStoreNudge}`);
876
-
877
- if (checkpoint?.executionState) {
878
- const s = checkpoint.executionState;
879
- parts.push(`\n**Current task:** ${s.currentTask}`);
880
- if (s.decisions.length) parts.push(`**Decisions:** ${s.decisions.join('; ')}`);
881
- if (s.nextSteps.length) parts.push(`**Next steps:** ${s.nextSteps.map((st, i) => `${i + 1}. ${st}`).join(', ')}`);
882
- if (s.activeFiles.length) parts.push(`**Active files:** ${s.activeFiles.join(', ')}`);
883
- if (s.notes) parts.push(`**Notes:** ${s.notes}`);
884
- if (checkpoint.checkpointAt) parts.push(`_Saved at: ${checkpoint.checkpointAt.toISOString()}_`);
885
- } else {
886
- parts.push('\nNo explicit checkpoint saved.');
887
- parts.push('\n**Tip:** Use memory_write to save important learnings, and memory_checkpoint before long operations so you can recover state.');
888
- }
889
-
890
- if (lastWrite) {
891
- parts.push(`\n**Last write:** ${lastWrite.concept}\n${lastWrite.content}`);
892
- }
893
-
894
- if (recalledMemories.length > 0) {
895
- parts.push(`\n**Recalled memories (${recalledMemories.length}):**`);
896
- for (const m of recalledMemories) {
897
- parts.push(`- **${m.concept}** (${m.score.toFixed(3)}): ${m.content.slice(0, 150)}${m.content.length > 150 ? '...' : ''}`);
898
- }
899
- }
900
-
901
- // Peer decisions: show recent decisions from other agents (last 30 min)
902
- if (coordDb) {
903
- try {
904
- const myAgent = coordDb.prepare(
905
- `SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
906
- ).get(AGENT_ID) as { id: string } | undefined;
907
-
908
- const peerDecisions = coordDb.prepare(
909
- `SELECT d.summary, a.name AS author_name, d.created_at
910
- FROM coord_decisions d JOIN coord_agents a ON d.author_id = a.id
911
- WHERE d.author_id != ? AND d.created_at > datetime('now', '-30 minutes')
912
- ORDER BY d.created_at DESC LIMIT 10`
913
- ).all(myAgent?.id ?? '') as Array<{ summary: string; author_name: string; created_at: string }>;
914
-
915
- if (peerDecisions.length > 0) {
916
- parts.push(`\n**Peer decisions (last 30 min):**`);
917
- for (const d of peerDecisions) {
918
- parts.push(`- [${d.author_name}] ${d.summary} (${d.created_at})`);
919
- }
920
- }
921
- } catch { /* peer decisions are non-fatal */ }
922
- }
923
-
924
- return {
925
- content: [{
926
- type: 'text' as const,
927
- text: parts.join('\n'),
928
- }],
929
- };
930
- }
931
- );
932
-
933
- // --- Onboarding Tools (warm-start a cold store) ---
934
-
935
- server.tool(
936
- 'onboard_scan',
937
- `Scan a project's documentation + repository and return CANDIDATE memories to seed a cold store.
938
-
939
- Use this when the store is empty / you're new to a project. The scan is deterministic
940
- (real file contents, not guesses) — YOUR job is to refine the candidates into atomic,
941
- recall-shaped memories, run the interview (onboard_questions), confirm with the user, then
942
- save the good ones with memory_write (memory_class="canonical"). Nothing is saved by this tool.`,
943
- {
944
- docs: z.array(z.string()).optional()
945
- .describe('Doc files/dirs to scan (Markdown/text). Defaults to the repo (or cwd).'),
946
- repo: z.string().optional()
947
- .describe('Repo root — also derives stack (package.json) + layout memories.'),
948
- project: z.string().optional()
949
- .describe('Project name (becomes a tag). Defaults to the repo/dir name.'),
950
- purpose: z.string().optional()
951
- .describe('The goal of this memory system, if known — becomes the anchor memory.'),
952
- },
953
- async (params) => {
954
- const repo = params.repo;
955
- const docs = params.docs && params.docs.length ? params.docs : [repo ?? process.cwd()];
956
- const project = params.project ?? basename(resolve(repo ?? docs[0] ?? process.cwd()));
957
- const pack = buildPack({ docs, repo, project, agentId: AGENT_ID, purpose: params.purpose });
958
- log(AGENT_ID, 'onboard', `scan ${docs.join(',')}${repo ? ' +repo' : ''} → ${pack.memories.length} candidates`);
959
- const text = [
960
- `Scanned ${docs.join(', ')}${repo ? ` (+repo ${repo})` : ''} → ${pack.memories.length} CANDIDATE memories (NOT saved).`,
961
- `Next: refine each into an atomic memory (lead with the fact + identifiers), run onboard_questions,`,
962
- `confirm with the user, then save the good ones with memory_write (memory_class="canonical").`,
963
- ``,
964
- JSON.stringify({ project, candidates: pack.memories, questions: pack.questions }, null, 2),
965
- ].join('\n');
966
- return { content: [{ type: 'text' as const, text }] };
967
- }
968
- );
969
-
970
- server.tool(
971
- 'onboard_questions',
972
- `Return the onboarding interview questions. Ask the user ONE at a time, starting with the
973
- goal of the memory system, and ask follow-ups for clarity. Turn each answer into a canonical memory.`,
974
- {},
975
- async () => ({
976
- content: [{ type: 'text' as const, text: INTERVIEW_QUESTIONS.map((q, i) => `${i + 1}. ${q}`).join('\n') }],
977
- })
978
- );
979
-
980
- // --- Task Management Tools ---
981
-
982
- server.tool(
983
- 'memory_task_add',
984
- `Create a task that you need to come back to. Tasks are memories with status and priority tracking.
985
-
986
- Use this when:
987
- - You identify work that needs doing but can't do it right now
988
- - The user mentions something to do later
989
- - You want to park a sub-task while focusing on something more urgent
990
-
991
- Tasks automatically get high salience so they won't be discarded.`,
992
- {
993
- concept: z.string().describe('Short task title (3-10 words)'),
994
- content: z.string().describe('Full task description — what needs doing, context, acceptance criteria'),
995
- tags: z.array(z.string()).optional().describe('Tags for categorization'),
996
- priority: z.enum(['urgent', 'high', 'medium', 'low']).default('medium')
997
- .describe('Task priority: urgent (do now), high (do soon), medium (normal), low (backlog)'),
998
- blocked_by: z.string().optional().describe('ID of a task that must finish first'),
999
- },
1000
- async (params) => {
1001
- const engram = await store.createEngram({
1002
- agentId: AGENT_ID,
1003
- concept: params.concept,
1004
- content: params.content,
1005
- tags: [...(params.tags ?? []), 'task'],
1006
- salience: 0.9, // Tasks always high salience
1007
- confidence: 0.8,
1008
- salienceFeatures: {
1009
- surprise: 0.5,
1010
- decisionMade: true,
1011
- causalDepth: 0.5,
1012
- resolutionEffort: 0.5,
1013
- eventType: 'decision',
1014
- },
1015
- reasonCodes: ['task-created'],
1016
- taskStatus: params.blocked_by ? 'blocked' : 'open',
1017
- taskPriority: params.priority as TaskPriority,
1018
- blockedBy: params.blocked_by,
1019
- });
1020
-
1021
- connectionEngine.enqueue(engram.id);
1022
-
1023
- // Generate embedding asynchronously
1024
- embed(`${params.concept} ${params.content}`).then(async vec => {
1025
- await store.updateEmbedding(engram.id, vec);
1026
- }).catch(() => {});
1027
-
1028
- return {
1029
- content: [{
1030
- type: 'text' as const,
1031
- text: `Task created: "${params.concept}" (${params.priority})`,
1032
- }],
1033
- };
1034
- }
1035
- );
1036
-
1037
- server.tool(
1038
- 'memory_task_update',
1039
- `Update a task's status or priority. Use this to:
1040
- - Start working on a task (open → in_progress)
1041
- - Mark a task done (→ done)
1042
- - Block a task on another (→ blocked)
1043
- - Reprioritize (change priority)
1044
- - Unblock a task (clear blocked_by)`,
1045
- {
1046
- task_id: z.string().describe('ID of the task to update'),
1047
- status: z.enum(['open', 'in_progress', 'blocked', 'done']).optional()
1048
- .describe('New status'),
1049
- priority: z.enum(['urgent', 'high', 'medium', 'low']).optional()
1050
- .describe('New priority'),
1051
- blocked_by: z.string().optional().describe('ID of blocking task (set to empty string to unblock)'),
1052
- },
1053
- async (params) => {
1054
- const engram = await store.getEngram(params.task_id);
1055
- if (!engram || !engram.taskStatus) {
1056
- return { content: [{ type: 'text' as const, text: `Task not found: ${params.task_id}` }] };
1057
- }
1058
-
1059
- if (params.blocked_by !== undefined) {
1060
- await store.updateBlockedBy(params.task_id, params.blocked_by || null);
1061
- }
1062
- if (params.status) {
1063
- await store.updateTaskStatus(params.task_id, params.status as TaskStatus);
1064
- }
1065
- if (params.priority) {
1066
- await store.updateTaskPriority(params.task_id, params.priority as TaskPriority);
1067
- }
1068
-
1069
- const updated = (await store.getEngram(params.task_id))!;
1070
- return {
1071
- content: [{
1072
- type: 'text' as const,
1073
- text: `Updated: "${updated.concept}" → ${updated.taskStatus} (${updated.taskPriority})`,
1074
- }],
1075
- };
1076
- }
1077
- );
1078
-
1079
- server.tool(
1080
- 'memory_task_list',
1081
- `List tasks with optional status filter. Shows tasks ordered by priority (urgent first).
1082
-
1083
- Use at the start of a session to see what's pending, or to check blocked/done tasks.`,
1084
- {
1085
- status: z.enum(['open', 'in_progress', 'blocked', 'done']).optional()
1086
- .describe('Filter by status (omit to see all active tasks)'),
1087
- include_done: z.boolean().optional().default(false)
1088
- .describe('Include completed tasks?'),
1089
- },
1090
- async (params) => {
1091
- let tasks = await store.getTasks(AGENT_ID, params.status as TaskStatus | undefined);
1092
- if (!params.include_done && !params.status) {
1093
- tasks = tasks.filter(t => t.taskStatus !== 'done');
1094
- }
1095
-
1096
- if (tasks.length === 0) {
1097
- return { content: [{ type: 'text' as const, text: 'No tasks found.' }] };
1098
- }
1099
-
1100
- const lines = tasks.map((t, i) => {
1101
- const blocked = t.blockedBy ? ` [blocked by ${t.blockedBy}]` : '';
1102
- const tags = t.tags?.filter(tag => tag !== 'task').join(', ');
1103
- return `${i + 1}. [${t.taskStatus}] **${t.concept}** (${t.taskPriority})${blocked}\n ${t.content.slice(0, 120)}${t.content.length > 120 ? '...' : ''}\n ${tags ? `Tags: ${tags} | ` : ''}ID: ${t.id}`;
1104
- });
1105
-
1106
- return {
1107
- content: [{
1108
- type: 'text' as const,
1109
- text: `Tasks (${tasks.length}):\n\n${lines.join('\n\n')}`,
1110
- }],
1111
- };
1112
- }
1113
- );
1114
-
1115
- server.tool(
1116
- 'memory_task_next',
1117
- `Get the single most important task to work on next.
1118
-
1119
- Prioritizes: in_progress tasks first (finish what you started), then by priority level, then oldest first. Skips blocked and done tasks.
1120
-
1121
- Use this when you finish a task or need to decide what to do next.`,
1122
- {},
1123
- async () => {
1124
- const next = await store.getNextTask(AGENT_ID);
1125
- if (!next) {
1126
- return { content: [{ type: 'text' as const, text: 'No actionable tasks. All clear!' }] };
1127
- }
1128
-
1129
- const blocked = next.blockedBy ? `\nBlocked by: ${next.blockedBy}` : '';
1130
- const tags = next.tags?.filter(tag => tag !== 'task').join(', ');
1131
-
1132
- return {
1133
- content: [{
1134
- type: 'text' as const,
1135
- text: `Next task:\n**${next.concept}** (${next.taskPriority})\nStatus: ${next.taskStatus}\n${next.content}${blocked}\n${tags ? `Tags: ${tags}\n` : ''}ID: ${next.id}`,
1136
- }],
1137
- };
1138
- }
1139
- );
1140
-
1141
- // --- Task Bracket Tools ---
1142
-
1143
- server.tool(
1144
- 'memory_task_begin',
1145
- `Signal that you're starting a significant task. Auto-checkpoints current state and recalls relevant memories.
1146
-
1147
- CALL THIS when starting:
1148
- - A multi-step operation (doc generation, large refactor, migration)
1149
- - Work on a new topic or project area
1150
- - Anything that might fill the context window
1151
-
1152
- This ensures your state is saved before you start, and primes recall with relevant context.`,
1153
- {
1154
- topic: z.string().describe('What task are you starting? (3-15 words)'),
1155
- files: z.array(z.string()).optional().default([])
1156
- .describe('Files you expect to work with'),
1157
- notes: z.string().optional().default('')
1158
- .describe('Any additional context'),
1159
- },
1160
- async (params) => {
1161
- // 1. Checkpoint current state
1162
- const checkpoint = await store.getCheckpoint(AGENT_ID);
1163
- const prevTask = checkpoint?.executionState?.currentTask ?? 'None';
1164
-
1165
- await store.saveCheckpoint(AGENT_ID, {
1166
- currentTask: params.topic,
1167
- decisions: [],
1168
- activeFiles: params.files,
1169
- nextSteps: [],
1170
- relatedMemoryIds: [],
1171
- notes: params.notes || `Started via memory_task_begin. Previous task: ${prevTask}`,
1172
- episodeId: null,
1173
- });
1174
-
1175
- // 2. Auto-recall relevant memories
1176
- let recalledSummary = '';
1177
- try {
1178
- const results = await activationEngine.activate({
1179
- agentId: AGENT_ID,
1180
- context: params.topic,
1181
- // 3, not 5 — see memory_recall. Push-style auto-recall on task start.
1182
- limit: 3,
1183
- minScore: 0.05,
1184
- useReranker: true,
1185
- useExpansion: true,
1186
- workspace: process.env.AWM_WORKSPACE ?? undefined,
1187
- });
1188
-
1189
- if (results.length > 0) {
1190
- const lines = results.map((r, i) => {
1191
- const tags = r.engram.tags?.length ? ` [${r.engram.tags.join(', ')}]` : '';
1192
- return `${i + 1}. **${r.engram.concept}** (${r.score.toFixed(3)})${tags}\n ${r.engram.content.slice(0, 150)}${r.engram.content.length > 150 ? '...' : ''}`;
1193
- });
1194
- recalledSummary = `\n\n**Recalled memories (${results.length}):**\n${lines.join('\n')}`;
1195
-
1196
- // Track recall
1197
- await store.updateAutoCheckpointRecall(AGENT_ID, params.topic, results.map(r => r.engram.id));
1198
- }
1199
- } catch { /* recall failure is non-fatal */ }
1200
-
1201
- log(AGENT_ID, 'task:begin', `"${params.topic}" prev="${prevTask}"`);
1202
-
1203
- return {
1204
- content: [{
1205
- type: 'text' as const,
1206
- text: `Started: "${params.topic}" (prev: ${prevTask})${recalledSummary}`,
1207
- }],
1208
- };
1209
- }
1210
- );
1211
-
1212
- server.tool(
1213
- 'memory_task_end',
1214
- `Signal that you've finished a significant task. Writes a summary memory and auto-checkpoints.
1215
-
1216
- CALL THIS when you finish:
1217
- - A multi-step operation
1218
- - Before switching to a different topic
1219
- - At the end of a work session
1220
-
1221
- This captures what was accomplished so future sessions can recall it.`,
1222
- {
1223
- summary: z.string().describe('What was accomplished? Include key outcomes, decisions, and any issues.'),
1224
- tags: z.array(z.string()).optional().default([])
1225
- .describe('Tags for the summary memory'),
1226
- supersedes: z.array(z.string()).optional().default([])
1227
- .describe('IDs of older memories this task summary replaces (marks them as superseded)'),
1228
- },
1229
- async (params) => {
1230
- // 1. Write summary as a memory
1231
- const salience = evaluateSalience({
1232
- content: params.summary,
1233
- eventType: 'decision',
1234
- surprise: 0.3,
1235
- decisionMade: true,
1236
- causalDepth: 0.5,
1237
- resolutionEffort: 0.5,
1238
- });
1239
-
1240
- // Determine the real task name for the summary engram
1241
- const checkpoint = await store.getCheckpoint(AGENT_ID);
1242
- const rawTask = checkpoint?.executionState?.currentTask ?? 'Unknown task';
1243
- // Strip any "Completed: " prefixes to avoid cascading
1244
- const cleanedTask = rawTask.replace(/^(Completed: )+/, '');
1245
- // Don't use auto-checkpoint or already-completed tasks as real task names
1246
- const isNamedTask = !cleanedTask.startsWith('Auto-checkpoint') && cleanedTask !== 'Unknown task';
1247
- const completedTask = isNamedTask
1248
- ? cleanedTask
1249
- : params.summary.slice(0, 60).replace(/\n/g, ' ');
1250
-
1251
- const engram = await store.createEngram({
1252
- agentId: AGENT_ID,
1253
- concept: completedTask.slice(0, 80),
1254
- content: params.summary,
1255
- tags: [...params.tags, 'task-summary'],
1256
- salience: isNamedTask ? Math.max(salience.score, 0.7) : salience.score, // Only floor salience for named tasks
1257
- confidence: 0.65, // Task summaries are decision-grade (completed work)
1258
- salienceFeatures: salience.features,
1259
- reasonCodes: [...salience.reasonCodes, 'task-end'],
1260
- });
1261
-
1262
- connectionEngine.enqueue(engram.id);
1263
-
1264
- // 2. Handle supersessions — mark old memories as outdated
1265
- let supersededCount = 0;
1266
- for (const oldId of params.supersedes) {
1267
- const oldEngram = await store.getEngram(oldId);
1268
- if (oldEngram) {
1269
- await store.supersedeEngram(oldId, engram.id);
1270
- await store.upsertAssociation(engram.id, oldId, 0.8, 'causal', 0.9);
1271
- await store.updateConfidence(oldId, Math.max(0.2, oldEngram.confidence * 0.4));
1272
- supersededCount++;
1273
- }
1274
- }
1275
-
1276
- // Generate embedding asynchronously
1277
- embed(`Task completed: ${params.summary}`).then(async vec => {
1278
- await store.updateEmbedding(engram.id, vec);
1279
- }).catch(() => {});
1280
-
1281
- // 2. Update checkpoint to reflect completion
1282
- await store.saveCheckpoint(AGENT_ID, {
1283
- currentTask: `Completed: ${completedTask}`,
1284
- decisions: checkpoint?.executionState?.decisions ?? [],
1285
- activeFiles: [],
1286
- nextSteps: [],
1287
- relatedMemoryIds: [engram.id],
1288
- notes: `Task completed. Summary memory: ${engram.id}`,
1289
- episodeId: null,
1290
- });
1291
-
1292
- await store.updateAutoCheckpointWrite(AGENT_ID, engram.id);
1293
- log(AGENT_ID, 'task:end', `"${completedTask}" summary=${engram.id} salience=${salience.score.toFixed(2)} superseded=${supersededCount}`);
1294
-
1295
- const supersededNote = supersededCount > 0 ? ` (${supersededCount} old memories superseded)` : '';
1296
- return {
1297
- content: [{
1298
- type: 'text' as const,
1299
- // D14 (2026-07-30): task end is the recipe moment — invite the host
1300
- // to distill a skill and/or a failure lesson in separate focused
1301
- // passes. The host owns the gates; AWM validates the write-backs.
1302
- text: `Completed: "${completedTask}" [${salience.score.toFixed(2)}]${supersededNote}\n${renderTaskEndInvitation()}`,
1303
- }],
1304
- };
1305
- }
1306
- );
1307
-
1308
- server.tool(
1309
- 'compress_output',
1310
- `Compress a STRUCTURED tool output (JSON object/array, query rows, log records) into TOON —
1311
- a compact, schema-aware tabular encoding — before putting it in your context. Cuts ~50-65%
1312
- of the tokens on uniform arrays at zero comprehension cost (validated: models read TOON as
1313
- accurately as JSON). Use this on large tool results you need to keep in context.
1314
-
1315
- Output-only and safe: it never changes the data. Non-JSON / prose is returned unchanged.
1316
- TOON is only emitted when it reproduces the input exactly (self-verified round-trip);
1317
- otherwise you get plain JSON back. When compressed, you also get a 'ref' — call
1318
- retrieve_original(ref) to get the verbatim source back if you ever need it.`,
1319
- {
1320
- output: z.string().describe('The tool output to compress — JSON text (preferred) or any string. Non-JSON is returned unchanged.'),
1321
- min_saving_chars: z.number().optional().describe('Only emit TOON if it saves at least this many characters (default 40).'),
1322
- },
1323
- async (params) => {
1324
- const r = liteCompress(params.output, { minSavingChars: params.min_saving_chars });
1325
- log(AGENT_ID, 'compress', `${r.format} ${r.charsBefore}->${r.charsAfter} chars (${(r.ratio * 100).toFixed(0)}%)${r.ref ? ` ref=${r.ref}` : ''}`);
1326
- const header = r.format === 'toon'
1327
- ? `[TOON, ${(r.ratio * 100).toFixed(0)}% smaller — compact lossless JSON; read as data, ref=${r.ref}]\n`
1328
- : '';
1329
- return {
1330
- content: [{ type: 'text' as const, text: header + r.text }],
1331
- };
1332
- }
1333
- );
1334
-
1335
- server.tool(
1336
- 'retrieve_original',
1337
- `Retrieve the verbatim original text for a 'ref' returned by compress_output. Use this when
1338
- you need the exact, uncompressed source (e.g. to pass it to another tool unchanged). Returns
1339
- an error if the ref has expired (originals are kept for the most recent compressions only).`,
1340
- {
1341
- ref: z.string().describe('The ref handle returned by compress_output (e.g. "awm_orig_12").'),
1342
- },
1343
- async (params) => {
1344
- const original = retrieveOriginal(params.ref);
1345
- if (original === undefined) {
1346
- return {
1347
- content: [{ type: 'text' as const, text: `Error: ref "${params.ref}" not found or expired.` }],
1348
- };
1349
- }
1350
- return {
1351
- content: [{ type: 'text' as const, text: original }],
1352
- };
1353
- }
1354
- );
1355
-
1356
- // --- Start ---
1357
-
1358
- async function main() {
1359
- const transport = new StdioServerTransport();
1360
- await server.connect(transport);
1361
-
1362
- // Start hook sidecar (lightweight HTTP for Claude Code hooks)
1363
- const sidecar = startSidecar({
1364
- store,
1365
- agentId: AGENT_ID,
1366
- secret: HOOK_SECRET,
1367
- port: HOOK_PORT,
1368
- portRange: HOOK_PORT_RANGE,
1369
- version: VERSION,
1370
- // 0.12.2: warm recall for hooks — the sidecar shares this process's
1371
- // activation engine and loaded models, so a UserPromptSubmit hook can get
1372
- // warm-latency recall without any standing server. Trimmed result shape
1373
- // (no embeddings/phase scores — hooks don't need them and the vectors
1374
- // alone would 10x the payload).
1375
- activate: async (q) => {
1376
- const results = await activationEngine.activate({
1377
- agentId: AGENT_ID,
1378
- context: q.context,
1379
- limit: q.limit,
1380
- requireConfidence: q.requireConfidence,
1381
- granularity: q.granularity,
1382
- });
1383
- return results.map(r => ({
1384
- engram: {
1385
- id: r.engram.id,
1386
- concept: r.engram.concept,
1387
- content: r.engram.content,
1388
- createdAt: r.engram.createdAt instanceof Date
1389
- ? r.engram.createdAt.toISOString()
1390
- : (r.engram.createdAt as unknown as string | undefined),
1391
- memoryClass: r.engram.memoryClass,
1392
- validTo: r.engram.validTo,
1393
- },
1394
- score: r.score,
1395
- summary: r.summary,
1396
- confidence: r.confidence,
1397
- }));
1398
- },
1399
- onConsolidate: async (agentId, reason) => {
1400
- console.error(`[mcp] consolidation triggered: ${reason}`);
1401
- const result = await consolidationEngine.consolidate(agentId);
1402
- await store.markConsolidation(agentId, false);
1403
- console.error(`[mcp] consolidation done: ${result.edgesStrengthened} strengthened, ${result.memoriesForgotten} forgotten`);
1404
- },
1405
- });
1406
-
1407
- // 0.12.2: eager warm — fire-and-forget, mirrors index.ts:208-218. Without
1408
- // this, every Claude Code session paid the full cold cost (~3s measured on
1409
- // a 29.7k-engram store: slim cache ~0.9s + three model loads ~1.8s) on its
1410
- // FIRST recall, which is exactly the "first recall is slow → recall gets
1411
- // avoided" failure mode. Warming here overlaps session startup instead.
1412
- // All output is stderr-safe (stdout carries JSON-RPC frames).
1413
- // Escape hatch: AWM_NO_EAGER_WARM=1 restores lazy loading.
1414
- if (process.env.AWM_NO_EAGER_WARM !== '1') {
1415
- getEmbedder().catch(err => console.error('Embedding model unavailable:', err.message));
1416
- getReranker().catch(err => console.error('Reranker model unavailable:', err.message));
1417
- getExpander().catch(err => console.error('Query expander model unavailable:', err.message));
1418
- if (BACKEND === 'sqlite') {
1419
- setImmediate(() => {
1420
- try {
1421
- const t0 = Date.now();
1422
- (store as unknown as EngramStore).warmSlimCache();
1423
- const stats = (store as unknown as EngramStore).getSlimCacheStats();
1424
- console.error(`Slim cache warmed: ${stats.size} entries in ${Date.now() - t0}ms`);
1425
- } catch (err) {
1426
- console.error(`Slim cache warm failed: ${(err as Error).message}`);
1427
- }
1428
- });
1429
- }
1430
- }
1431
-
1432
- // Coordination MCP tools (opt-in via AWM_COORDINATION=true)
1433
- // AWM 0.8.x: coordination requires SQLite (uses store.getDb()). On PGlite,
1434
- // coordination is auto-disabled with a warning; re-enable when coordination
1435
- // is ported to async/PGlite.
1436
- const coordRequested = process.env.AWM_COORDINATION === 'true' || process.env.AWM_COORDINATION === '1';
1437
- const coordEnabled = coordRequested && BACKEND === 'sqlite';
1438
- if (coordEnabled) {
1439
- const { initCoordinationTables } = await import('./coordination/schema.js');
1440
- const { registerCoordinationTools } = await import('./coordination/mcp-tools.js');
1441
- const sqliteStore = store as unknown as EngramStore;
1442
- initCoordinationTables(sqliteStore.getDb());
1443
- registerCoordinationTools(server, sqliteStore.getDb());
1444
- coordDb = sqliteStore.getDb();
1445
- } else if (coordRequested && BACKEND === 'pglite') {
1446
- console.error('AWM: coordination requested but disabled — coordination plugin requires SQLite backend');
1447
- } else {
1448
- console.error('AWM: coordination tools disabled (set AWM_COORDINATION=true to enable)');
1449
- }
1450
-
1451
- // Log to stderr (stdout is reserved for MCP protocol)
1452
- console.error(`AgentWorkingMemory MCP server started (agent: ${AGENT_ID}, db: ${DB_PATH})`);
1453
- sidecarHandle = sidecar;
1454
- console.error(`Hook sidecar preferred 127.0.0.1:${HOOK_PORT} (range ${HOOK_PORT_RANGE})${HOOK_SECRET ? ' (auth enabled)' : ' (no auth — set AWM_HOOK_SECRET)'}`);
1455
-
1456
- // Clean shutdown
1457
- const cleanup = async () => {
1458
- sidecar.close();
1459
- consolidationScheduler.stop();
1460
- stagingBuffer.stop();
1461
- if (BACKEND === 'sqlite') {
1462
- try { (store as Partial<EngramStore>).walCheckpoint?.(); } catch { /* non-fatal */ }
1463
- }
1464
- try { await (store as any).close?.(); } catch { /* best-effort */ }
1465
- };
1466
- process.on('SIGINT', () => { void cleanup().finally(() => process.exit(0)); });
1467
- process.on('SIGTERM', () => { void cleanup().finally(() => process.exit(0)); });
1468
- }
1469
-
1470
- main().catch(err => {
1471
- console.error('MCP server failed:', err);
1472
- process.exit(1);
1473
- });
1474
-
1475
- } // end else (non-incognito)
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * MCP Server — Model Context Protocol interface for AgentWorkingMemory.
5
+ *
6
+ * Runs as a stdio-based MCP server that Claude Code connects to directly.
7
+ * Uses the storage and engine layers in-process (no HTTP overhead).
8
+ *
9
+ * Tools exposed (19):
10
+ * memory_write — store a memory (salience filter decides disposition)
11
+ * memory_recall — activate memories by context (cognitive retrieval)
12
+ * memory_feedback — report whether a recalled memory was useful
13
+ * memory_retract — invalidate a wrong memory with optional correction
14
+ * memory_supersede — replace an outdated memory with a current one
15
+ * memory_stats — get memory health metrics
16
+ * memory_whoami — identify this instance, mode, store, and sibling agent spaces
17
+ * memory_checkpoint — save structured execution state (survives compaction)
18
+ * memory_restore — restore state + targeted recall after compaction
19
+ * memory_task_add — create a prioritized task
20
+ * memory_task_update — change task status, priority, or blocking
21
+ * memory_task_list — list tasks filtered by status
22
+ * memory_task_next — get the highest-priority actionable task
23
+ * memory_task_begin — start a task (auto-checkpoint + recall)
24
+ * memory_task_end — end a task (write summary + checkpoint)
25
+ * compress_output — encode structured tool output as TOON (token-efficient, lossless)
26
+ * retrieve_original — get the verbatim source for a compress_output ref
27
+ *
28
+ * Run: npx tsx src/mcp.ts
29
+ * Config: add to ~/.claude.json or .mcp.json
30
+ */
31
+
32
+ import { readFileSync } from 'node:fs';
33
+ import { deriveAgentFromDir as deriveAgentFromDirShared } from './core/agent-id.js';
34
+ import { resolve, basename } from 'node:path';
35
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
36
+
37
+ // Load .env file if present (no external dependency)
38
+ try {
39
+ const envPath = resolve(process.cwd(), '.env');
40
+ const envContent = readFileSync(envPath, 'utf-8');
41
+ for (const line of envContent.split('\n')) {
42
+ const trimmed = line.trim();
43
+ if (!trimmed || trimmed.startsWith('#')) continue;
44
+ const eqIdx = trimmed.indexOf('=');
45
+ if (eqIdx === -1) continue;
46
+ const key = trimmed.slice(0, eqIdx).trim();
47
+ const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
48
+ if (!process.env[key]) process.env[key] = val;
49
+ }
50
+ } catch { /* No .env file */ }
51
+
52
+ // MCP uses stdout for JSON-RPC. Redirect console.log to stderr so engine
53
+ // startup messages (ConsolidationScheduler, model loading, etc.) don't
54
+ // corrupt the transport. This MUST happen before any engine imports.
55
+ console.log = console.error;
56
+
57
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
58
+ import { z } from 'zod';
59
+
60
+ import { EngramStore } from './storage/sqlite.js';
61
+ import { openStore, getConfiguredBackend, type StoreBackend } from './storage/factory.js';
62
+ import type { IEngramStore } from './storage/store.js';
63
+ import { ActivationEngine } from './engine/activation.js';
64
+ import { ConnectionEngine } from './engine/connections.js';
65
+ import { StagingBuffer } from './engine/staging.js';
66
+ import { EvictionEngine } from './engine/eviction.js';
67
+ import { RetractionEngine } from './engine/retraction.js';
68
+ import { EvalEngine } from './engine/eval.js';
69
+ import { ConsolidationEngine } from './engine/consolidation.js';
70
+ import { ConsolidationScheduler } from './engine/consolidation-scheduler.js';
71
+ import { evaluateSalience, computeNovelty, computeNoveltyWithMatch } from './core/salience.js';
72
+ import { performWrite } from './core/write-pipeline.js';
73
+ import type { ConsciousState } from './types/checkpoint.js';
74
+ import type { SalienceEventType } from './core/salience.js';
75
+ import type { TaskStatus, TaskPriority, AbstentionInfo } from './types/engram.js';
76
+ import { DEFAULT_AGENT_CONFIG } from './types/agent.js';
77
+ import { embed, getEmbedder } from './core/embeddings.js';
78
+ import { getReranker } from './core/reranker.js';
79
+ import { getExpander } from './core/query-expander.js';
80
+ import { warmModelDaemon } from './core/ml-worker.js';
81
+ import { isModelDaemonEnabled } from './core/model-daemon.js';
82
+ import { startSidecar } from './hooks/sidecar.js';
83
+ import { initLogger, log, getLogPath } from './core/logger.js';
84
+ import { VERSION } from './version.js';
85
+ import { buildPack, INTERVIEW_QUESTIONS } from './onboard/index.js';
86
+ import { liteCompress, retrieveOriginal } from './core/lite-compress.js';
87
+ import { queryPeerDecisions, formatPeerDecisions } from './coordination/peer-decisions.js';
88
+ import { startLoopLagMonitor } from './core/write-telemetry.js';
89
+ import { buildWhoami, formatWhoami } from './core/whoami.js';
90
+ import { renderTaskEndInvitation, validateRecipeWrite, recipeSlug, getRecipe } from './recipes/index.js';
91
+ import { formatRecallResultLine } from './core/format-recall.js';
92
+ import { newestConflicting } from './core/conflicts.js';
93
+ import { missingValueNote } from './core/asked-value.js';
94
+ import { packRecallByBudget, formatTokenFooter, estimateTokens } from './core/token-budget.js';
95
+
96
+ // --- Incognito Mode ---
97
+ // When AWM_INCOGNITO=1, register zero tools. Claude won't see memory tools at all.
98
+ // No DB, no engines, no sidecar — just a bare MCP server that exposes nothing.
99
+
100
+ const INCOGNITO = process.env.AWM_INCOGNITO === '1' || process.env.AWM_INCOGNITO === 'true';
101
+
102
+ if (INCOGNITO) {
103
+ console.error('AWM: incognito mode — all memory tools disabled, nothing will be recorded');
104
+ const server = new McpServer({ name: 'agent-working-memory', version: VERSION });
105
+ const transport = new StdioServerTransport();
106
+ server.connect(transport).catch(err => {
107
+ console.error('MCP server failed:', err);
108
+ process.exit(1);
109
+ });
110
+ // No tools registered — Claude won't see any memory_* tools
111
+ } else {
112
+
113
+ // --- Setup ---
114
+
115
+ const BACKEND: StoreBackend = getConfiguredBackend();
116
+ const DB_PATH = process.env.AWM_DB_PATH ?? (BACKEND === 'pglite' ? 'memory-pglite' : 'memory.db');
117
+
118
+ // Fallback agent selection when AWM_AGENT_ID/WORKER_NAME are unset: derive from
119
+ // the project directory (core/agent-id.ts — one definition shared with `awm setup`
120
+ // and mirrored in the shipped hook scripts, so the server, the installer and the
121
+ // hooks always name the same pool for a given directory).
122
+ const deriveAgentFromDir = () => deriveAgentFromDirShared(process.env.CLAUDE_PROJECT_DIR ?? process.cwd());
123
+ const AGENT_ID = process.env.AWM_AGENT_ID ?? process.env.WORKER_NAME ?? deriveAgentFromDir();
124
+ const HOOK_PORT = parseInt(process.env.AWM_HOOK_PORT ?? '8401', 10);
125
+ // 0.14.2: ports to try upward from HOOK_PORT when it is busy (see sidecar.ts).
126
+ const HOOK_PORT_RANGE = Math.max(1, parseInt(process.env.AWM_HOOK_PORT_RANGE ?? '10', 10) || 10);
127
+ // Set once the sidecar starts; whoami reads the bound port from it.
128
+ let sidecarHandle: { boundPort: () => number | null } | null = null;
129
+ function sidecarPortLabel(): string {
130
+ const p = sidecarHandle?.boundPort() ?? null;
131
+ if (p === null) return `not bound (preferred ${HOOK_PORT}; hooks disabled)`;
132
+ return p === HOOK_PORT ? `127.0.0.1:${p}` : `127.0.0.1:${p} (preferred ${HOOK_PORT} was busy)`;
133
+ }
134
+ const HOOK_SECRET = process.env.AWM_HOOK_SECRET ?? null;
135
+
136
+ initLogger(DB_PATH);
137
+ log(AGENT_ID, 'startup', `MCP server starting (backend: ${BACKEND}, db: ${DB_PATH}, hooks: ${HOOK_PORT})`);
138
+
139
+ // AWM 0.8.x: openStore() returns either SQLite (sync) or PGlite (async) store.
140
+ // Engines accept either via IEngramStore (MaybePromise<T> contract).
141
+ const { store: storeAny } = await openStore();
142
+ // Engines accept the async contract; SQLite-only call sites must guard on BACKEND.
143
+ const store = storeAny as unknown as IEngramStore & Partial<EngramStore>;
144
+ const activationEngine = new ActivationEngine(store);
145
+ const connectionEngine = new ConnectionEngine(store, activationEngine);
146
+ const stagingBuffer = new StagingBuffer(store, activationEngine);
147
+ const evictionEngine = new EvictionEngine(store);
148
+ const retractionEngine = new RetractionEngine(store);
149
+ const evalEngine = new EvalEngine(store);
150
+ const consolidationEngine = new ConsolidationEngine(store, connectionEngine);
151
+ const consolidationScheduler = new ConsolidationScheduler(store, consolidationEngine);
152
+
153
+ stagingBuffer.start(DEFAULT_AGENT_CONFIG.stagingTtlMs);
154
+ consolidationScheduler.start();
155
+ startLoopLagMonitor();
156
+
157
+ // Coordination DB handle — set when AWM_COORDINATION=true, used by memory_write for decision propagation
158
+ let coordDb: import('better-sqlite3').Database | null = null;
159
+
160
+ const server = new McpServer({
161
+ name: 'agent-working-memory',
162
+ version: VERSION,
163
+ });
164
+
165
+ server.registerResource(
166
+ 'awm-overview',
167
+ 'awm://server/overview',
168
+ {
169
+ title: 'AWM Overview',
170
+ description: 'AgentWorkingMemory MCP server metadata and discovery notes',
171
+ mimeType: 'text/markdown',
172
+ },
173
+ async () => ({
174
+ contents: [{
175
+ uri: 'awm://server/overview',
176
+ text: [
177
+ '# Agent Working Memory',
178
+ '',
179
+ `Agent: ${AGENT_ID}`,
180
+ `DB: ${DB_PATH}`,
181
+ `Coordination: ${process.env.AWM_COORDINATION === 'true' || process.env.AWM_COORDINATION === '1' ? 'enabled' : 'disabled'}`,
182
+ '',
183
+ 'This MCP server primarily exposes tools such as `memory_restore`, `memory_recall`, `memory_write`, and task/checkpoint operations.',
184
+ 'The resources below exist so generic MCP clients can discover the server through `resources/list` and `resources/templates/list`.',
185
+ ].join('\n'),
186
+ mimeType: 'text/markdown',
187
+ }],
188
+ })
189
+ );
190
+
191
+ server.registerResource(
192
+ 'awm-memory-template',
193
+ new ResourceTemplate('awm://memory/{id}', { list: undefined }),
194
+ {
195
+ title: 'AWM Memory By ID',
196
+ description: 'Metadata resource template for a memory identifier',
197
+ mimeType: 'text/markdown',
198
+ },
199
+ async (_uri, variables) => ({
200
+ contents: [{
201
+ uri: `awm://memory/${variables.id ?? ''}`,
202
+ text: [
203
+ '# AWM Memory Reference',
204
+ '',
205
+ `Requested memory id: ${variables.id ?? ''}`,
206
+ '',
207
+ 'Use the AWM memory tools for actual retrieval and mutation:',
208
+ '- `memory_recall` for cognitive retrieval',
209
+ '- `memory_restore` for session state',
210
+ '- `memory_feedback`, `memory_retract`, `memory_supersede` for memory maintenance',
211
+ ].join('\n'),
212
+ mimeType: 'text/markdown',
213
+ }],
214
+ })
215
+ );
216
+
217
+ // --- Auto-classification for memory types ---
218
+
219
+ function classifyMemoryType(content: string): 'episodic' | 'semantic' | 'procedural' | 'unclassified' {
220
+ const lower = content.toLowerCase();
221
+ // Procedural: how-to, steps, numbered lists
222
+ if (/\bhow to\b|\bsteps?:/i.test(content) || /^\s*\d+[\.\)]\s/m.test(content) || /\bthen run\b|\bfirst,?\s/i.test(content)) {
223
+ return 'procedural';
224
+ }
225
+ // Episodic: past tense events, incidents, specific time references
226
+ if (/\b(discovered|debugged|fixed|encountered|happened|resolved|found that|we did|i did|yesterday|last week|today)\b/i.test(content)) {
227
+ return 'episodic';
228
+ }
229
+ // Semantic: facts, decisions, rules, patterns
230
+ if (/\b(is|are|should|always|never|must|uses?|requires?|means|pattern|decision|rule|convention)\b/i.test(content) && content.length < 500) {
231
+ return 'semantic';
232
+ }
233
+ return 'unclassified';
234
+ }
235
+
236
+ // --- Tools ---
237
+
238
+ server.tool(
239
+ 'memory_write',
240
+ `Store a memory. The salience filter decides whether it's worth keeping (active), needs more evidence (staging), or should be discarded.
241
+
242
+ CALL THIS PROACTIVELY — do not wait to be asked. Write memories when you:
243
+ - Discover something about the codebase, bugs, or architecture
244
+ - Make a decision and want to remember why
245
+ - Encounter and resolve an error
246
+ - Learn a user preference or project pattern
247
+ - Complete a significant piece of work
248
+
249
+ The concept should be a short label (3-8 words). The content should be the full detail.`,
250
+ {
251
+ concept: z.string().describe('Short label for this memory (3-8 words)'),
252
+ content: z.string().describe('Full detail of what was learned'),
253
+ tags: z.array(z.string()).optional().describe('Optional tags for categorization'),
254
+ event_type: z.enum(['observation', 'decision', 'friction', 'surprise', 'causal'])
255
+ .optional().default('observation')
256
+ .describe('Type of event: observation (default), decision, friction (error/blocker), surprise, causal (root cause)'),
257
+ surprise: z.number().min(0).max(1).optional().default(0.3)
258
+ .describe('How surprising was this? 0=expected, 1=very unexpected'),
259
+ decision_made: z.boolean().optional().default(false)
260
+ .describe('Was a decision made? True boosts importance'),
261
+ causal_depth: z.number().min(0).max(1).optional().default(0.3)
262
+ .describe('How deep is the causal understanding? 0=surface, 1=root cause'),
263
+ resolution_effort: z.number().min(0).max(1).optional().default(0.3)
264
+ .describe('How much effort to resolve? 0=trivial, 1=significant debugging'),
265
+ memory_class: z.enum(['canonical', 'working', 'ephemeral']).optional().default('working')
266
+ .describe('Memory class: canonical (source-of-truth, never stages), working (default), ephemeral (temporary, decays faster)'),
267
+ memory_type: z.enum(['episodic', 'semantic', 'procedural', 'unclassified']).optional()
268
+ .describe('Memory type: episodic (events/incidents), semantic (facts/decisions), procedural (how-to/steps). Auto-classified if omitted.'),
269
+ supersedes: z.string().optional()
270
+ .describe('ID of an older memory this one replaces. The old memory is down-ranked, not deleted.'),
271
+ correction: z.boolean().optional()
272
+ .describe('This write corrects a fact AWM already holds and you do not have its id. AWM supersedes the closest active memory about the same thing, stores the correction active at no less than that memory\'s confidence, and says what it replaced. Use it whenever a value changed (a time, a price, a status, a decision); written as a plain observation, a changed fact merges into or ranks below the stale version. If nothing close enough exists the write is stored as new and the reply says so. An explicit supersedes outranks this.'),
273
+ // --- Agent-provided metadata (stored as searchable tags) ---
274
+ project: z.string().optional()
275
+ .describe('Project context (e.g., "EquiHub", "AWM"). Becomes a searchable tag.'),
276
+ topic: z.string().optional()
277
+ .describe('Subject area (e.g., "database-migration", "auth-flow"). Becomes a searchable tag.'),
278
+ source: z.enum(['code-reading', 'debugging', 'discussion', 'research', 'testing', 'observation']).optional()
279
+ .describe('How this knowledge was acquired.'),
280
+ confidence_level: z.enum(['verified', 'observed', 'assumed']).optional()
281
+ .describe('Confidence: verified (tested), observed (read in code), assumed (reasoning).'),
282
+ session_id: z.string().optional()
283
+ .describe('Session/conversation grouping ID. Memories with same session_id are associated.'),
284
+ origin_class: z.enum(['user-stated', 'tool-output', 'inference', 'recipe']).optional()
285
+ .describe('Provenance (D5, log-only): where this knowledge came from. user-stated = the human said it; tool-output = read from a tool/system; inference = your reasoning; recipe = produced by a cognition recipe.'),
286
+ recipe_id: z.string().optional()
287
+ .describe('Cognition-recipe id+version when origin_class is recipe.'),
288
+ valid_from: z.string().optional()
289
+ .describe('ISO date when the FACT becomes valid (temporal validity, not ingestion time).'),
290
+ valid_to: z.string().optional()
291
+ .describe('ISO date when the FACT stops being valid (e.g., a deadline or a superseding change).'),
292
+ intent: z.enum(['decision', 'question', 'todo', 'finding', 'context']).optional()
293
+ .describe('What kind of memory this is.'),
294
+ },
295
+ async (params) => {
296
+ // Assemble tags: user-provided + agent metadata (stored as searchable prefixed tags)
297
+ const userTags = params.tags ?? [];
298
+ const metaTags: string[] = [];
299
+ if (params.project) metaTags.push(`proj=${params.project}`);
300
+ if (params.topic) metaTags.push(`topic=${params.topic}`);
301
+ if (params.source) metaTags.push(`src=${params.source}`);
302
+ if (params.confidence_level) metaTags.push(`conf=${params.confidence_level}`);
303
+ if (params.session_id) metaTags.push(`sid=${params.session_id}`);
304
+ if (params.intent) metaTags.push(`intent=${params.intent}`);
305
+
306
+ // D14 (2026-07-30): recipe write-backs are contract-checked. Provenance
307
+ // must never claim a recipe that does not exist, and malformed
308
+ // derivations are rejected with the contract echoed back so the host
309
+ // can self-correct in one retry.
310
+ if (params.origin_class === 'recipe') {
311
+ if (!params.recipe_id) {
312
+ return { content: [{ type: 'text' as const, text: "Recipe write rejected: origin_class 'recipe' requires recipe_id (e.g. 'skill-derivation@1')." }] };
313
+ }
314
+ const v = validateRecipeWrite(params.recipe_id, params.concept, params.content);
315
+ if (!v.ok) {
316
+ const contract = getRecipe(params.recipe_id)?.writeBack ?? 'unknown recipe';
317
+ return { content: [{ type: 'text' as const, text: `Recipe write rejected (${params.recipe_id}): ${v.errors.join('; ')}\nContract: ${contract}` }] };
318
+ }
319
+ // Standardize recipe write-backs: canonical class, standard tags.
320
+ const slug = recipeSlug(params.concept);
321
+ const ensure = (tag: string) => { if (!userTags.includes(tag) && !metaTags.includes(tag)) metaTags.push(tag); };
322
+ if (params.recipe_id.startsWith('skill-derivation')) {
323
+ ensure('topic=skill'); ensure(`skill=${slug}`);
324
+ params.memory_type = params.memory_type ?? 'procedural';
325
+ } else if (params.recipe_id.startsWith('friction-lesson')) {
326
+ ensure('topic=friction'); ensure(`about=${slug}`);
327
+ // zod defaults event_type to 'observation', so force the recipe's
328
+ // contract value rather than ??-guarding against undefined.
329
+ params.event_type = 'friction';
330
+ }
331
+ params.memory_class = 'canonical';
332
+ }
333
+
334
+ const memoryType = params.memory_type ?? classifyMemoryType(params.content);
335
+
336
+ const result = await performWrite({ store, connectionEngine }, {
337
+ agentId: AGENT_ID,
338
+ concept: params.concept,
339
+ content: params.content,
340
+ tags: [...userTags, ...metaTags],
341
+ eventType: params.event_type as SalienceEventType,
342
+ surprise: params.surprise,
343
+ decisionMade: params.decision_made,
344
+ // Passed through so the salience filter sees a declared decision, not just an
345
+ // `intent=` tag. The mapping lives in write-pipeline.ts; see the measurement there.
346
+ intent: params.intent,
347
+ causalDepth: params.causal_depth,
348
+ resolutionEffort: params.resolution_effort,
349
+ memoryClass: params.memory_class,
350
+ memoryType,
351
+ supersedes: params.supersedes,
352
+ correction: params.correction,
353
+ originClass: params.origin_class,
354
+ writerSession: params.session_id,
355
+ recipeId: params.recipe_id,
356
+ validFrom: params.valid_from,
357
+ validTo: params.valid_to,
358
+ });
359
+
360
+ // Auto-checkpoint — covers create/reinforce/supersede uniformly
361
+ try { await store.updateAutoCheckpointWrite(AGENT_ID, result.engram.id); } catch { /* non-fatal */ }
362
+
363
+ if (result.action === 'reinforce') {
364
+ log(AGENT_ID, 'write:reinforce', `"${params.concept}" → reinforced "${result.engram.concept}" (conf ${result.reinforce!.previousConfidence.toFixed(2)} → ${result.reinforce!.newConfidence.toFixed(2)}, novelty=${result.noveltyResult.novelty.toFixed(2)})`);
365
+ return {
366
+ content: [{
367
+ type: 'text' as const,
368
+ text: `Reinforced existing memory "${result.engram.concept}" (confidence ${result.reinforce!.previousConfidence.toFixed(2)} → ${result.reinforce!.newConfidence.toFixed(2)})`,
369
+ }],
370
+ };
371
+ }
372
+
373
+ const engram = result.engram;
374
+ const salience = result.salience!; // create/supersede always have salience
375
+ const novelty = result.noveltyResult.novelty;
376
+ const isLowSalience = salience.disposition === 'discard';
377
+
378
+ // Decision propagation: when decision_made=true or intent='decision', and coordination is enabled,
379
+ // broadcast to coord_decisions so other agents can discover it
380
+ if ((params.decision_made || params.intent === 'decision') && coordDb) {
381
+ try {
382
+ const agent = coordDb.prepare(
383
+ `SELECT id, current_task FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
384
+ ).get(AGENT_ID) as { id: string; current_task: string | null } | undefined;
385
+ if (agent) {
386
+ coordDb.prepare(
387
+ `INSERT INTO coord_decisions (author_id, assignment_id, tags, summary) VALUES (?, ?, ?, ?)`
388
+ ).run(agent.id, agent.current_task, params.tags ? JSON.stringify(params.tags) : null, params.concept);
389
+ }
390
+ } catch { /* decision propagation is non-fatal */ }
391
+ }
392
+
393
+ const logDisposition = isLowSalience ? 'low-salience' : salience.disposition;
394
+ log(AGENT_ID, `write:${logDisposition}`, `"${params.concept}" salience=${salience.score.toFixed(2)} novelty=${novelty.toFixed(1)} id=${engram.id}`);
395
+
396
+ // 2026-10-19: say what a correction replaced, or that it replaced nothing.
397
+ const correctionNote = params.correction
398
+ ? (result.correctionOf
399
+ ? `\nCorrected "${result.correctionOf.concept}" (${result.correctionOf.id}) — it is now superseded by this memory.`
400
+ : `\nNOTE: no existing memory was close enough to correct, so this was stored as new. If you know which memory it replaces, write again with supersedes: '<id>'.`)
401
+ : '';
402
+
403
+ const trustNote = result.trustBlocked
404
+ ? `\nNOTE: stored as its own memory, not merged into ${result.trustBlocked.matchedId} — this write is less trusted than that memory (src/core/trust.ts), so it must not replace or be merged into it.`
405
+ : '';
406
+
407
+ return {
408
+ content: [{
409
+ type: 'text' as const,
410
+ text: `Stored (${salience.disposition}) "${params.concept}" [${salience.score.toFixed(2)}]\nID: ${engram.id}`
411
+ + correctionNote
412
+ + trustNote
413
+ + (isLowSalience
414
+ ? `\nNOTE: low salience — this memory is kept but demoted and may fade first. If it MUST survive and be recallable, retry with memory_class: 'canonical'. (Discards are audited: reason codes ${JSON.stringify(salience.reasonCodes.slice(0, 4))})`
415
+ : ''),
416
+ }],
417
+ };
418
+ }
419
+ );
420
+
421
+ server.tool(
422
+ 'memory_recall',
423
+ `Recall memories relevant to a query. Uses cognitive activation — not keyword search.
424
+
425
+ ALWAYS call this when:
426
+ - Starting work on a project or topic (recall what you know)
427
+ - Debugging (recall similar errors and solutions)
428
+ - Making decisions (recall past decisions and outcomes)
429
+ - The user mentions a topic you might have stored memories about
430
+
431
+ Accepts either "query" or "context" parameter — both work identically.
432
+ Returns the most relevant memories ranked by text relevance, temporal recency, and associative strength.`,
433
+ {
434
+ query: z.string().optional().describe('What to search for — describe the situation, question, or topic'),
435
+ context: z.string().optional().describe('Alias for query (either works)'),
436
+ limit: z.number().optional().default(3).describe('Max memories to return (default 3). Measured on 400 real-store probes: k=3 is the only token-POSITIVE setting (+115 tok/recall, against -680 at k=5) and is 41% faster at p50, because `limit` sizes the cross-encoder rerank pool (min(limit*8, 40) — so 24 at k=3 against 40 at k=5) and rerank is 83% of warm recall time. It costs ~0.5pp success@1 versus k=5 — about 3 queries in 400, inside noise. Raise it explicitly when you want breadth for orientation rather than a specific fact; that is the case this default deliberately does not optimize for.'),
437
+ min_score: z.number().optional().default(0.05).describe('Minimum relevance score (default 0.05)'),
438
+ include_staging: z.boolean().optional().default(false).describe('Include weak/unconfirmed memories?'),
439
+ use_reranker: z.boolean().optional().default(true).describe('Use cross-encoder re-ranking for better relevance (default true)'),
440
+ use_expansion: z.boolean().optional().default(true).describe('Expand query with synonyms for better recall (default true)'),
441
+ memory_type: z.enum(['episodic', 'semantic', 'procedural']).optional().describe('Filter by memory type (omit to search all types)'),
442
+ workspace: z.string().optional().describe('Search across all agents in this workspace (hive mode). Omit for agent-scoped recall only.'),
443
+ require_confidence: z.number().optional().default(0.05).describe('Abstain (return nothing) when recall confidence is below this threshold. Defaults to 0.05 — a LIGHT filter, chosen from measurement: it halves the rate of answering off-topic queries at zero cost to hit rate. Raise it only for push-style use where nobody asked (the prime hook uses 0.10 — it was 0.25 until 0.14.6, when 0.25 was measured silencing prompts with a clear winner). Do NOT raise it for ordinary recall: a miss is expensive, because the agent then reads the codebase instead (~2,106 tokens), so aggressive thresholds measurably destroy value — 0.20+ cut net tokens saved by 25% in tests/abstention-eval. Pass 0 to disable. NOTE: this is NOT min_score — it gates on the SHAPE of the score distribution across the whole result set, not per-result relevance, so memories that pass min_score can still be withheld. When that happens the reply says RECALL ABSTAINED and reports how many were withheld; an empty result is only absence when it does not.'),
444
+ granularity: z.enum(['full', 'compact', 'auto']).optional().describe('Output granularity (Paper 3: cognitive teaming). "full" (default): no change. "compact": every result carries a short summary field. "auto": confidence-adaptive — top result gets a longer summary when there is a clear winner, otherwise everything is compact for scanning.'),
445
+ max_tokens: z.number().optional().describe('Token budget for the response. `limit` is a COUNT and is token-blind — 5 results may cost 400 tokens or 4,000. Use this when context is tight: results are packed by value-per-token until the budget is reached, the top-scored match always gets first refusal, and the reply reports what it cost and what was withheld. Omit for no budget (everything is returned, still with accounting).'),
446
+ },
447
+ async (params) => {
448
+ const queryText = params.query ?? params.context;
449
+ if (!queryText) {
450
+ return {
451
+ content: [{
452
+ type: 'text' as const,
453
+ text: 'Error: provide either "query" or "context" parameter with your search text.',
454
+ }],
455
+ };
456
+ }
457
+ // Use workspace from param, env var, or omit for agent-scoped
458
+ const workspace = params.workspace ?? process.env.AWM_WORKSPACE ?? undefined;
459
+ // Set only when a gate WITHHELD results. Distinguishes abstention from absence,
460
+ // which an empty array cannot.
461
+ let abstained: AbstentionInfo | undefined;
462
+ const results = await activationEngine.activate({
463
+ agentId: AGENT_ID,
464
+ context: queryText,
465
+ limit: params.limit,
466
+ minScore: params.min_score,
467
+ includeStaging: params.include_staging,
468
+ useReranker: params.use_reranker,
469
+ useExpansion: params.use_expansion,
470
+ memoryType: params.memory_type,
471
+ workspace,
472
+ requireConfidence: params.require_confidence,
473
+ granularity: params.granularity,
474
+ onAbstain: (info) => { abstained = info; },
475
+ });
476
+
477
+ // Auto-checkpoint: track recall
478
+ try {
479
+ const ids = results.map(r => r.engram.id);
480
+ await store.updateAutoCheckpointRecall(AGENT_ID, queryText, ids);
481
+ } catch { /* non-fatal */ }
482
+
483
+ log(AGENT_ID, 'recall', `"${queryText.slice(0, 80)}" → ${results.length} results`);
484
+
485
+ // Peer decisions: append recent decisions by other agents relevant to this query
486
+ const peerSuffix = coordDb
487
+ ? formatPeerDecisions(queryPeerDecisions(coordDb, AGENT_ID, queryText))
488
+ : '';
489
+
490
+ if (results.length === 0) {
491
+ // Two very different conditions used to render identically as
492
+ // "No relevant memories found." — a claim of absence the system cannot make
493
+ // when a gate withheld matches. Callers read it as absence and stopped
494
+ // looking, for memories scoring well above minScore.
495
+ const text = abstained
496
+ ? [
497
+ `RECALL ABSTAINED — this is NOT "no memories exist".`,
498
+ ``,
499
+ `${abstained.candidates} candidate${abstained.candidates === 1 ? '' : 's'} matched ` +
500
+ `(best score ${abstained.topScore.toFixed(3)}) and ${abstained.candidates === 1 ? 'was' : 'were'} ` +
501
+ `withheld because recall confidence ` +
502
+ `${abstained.confidence !== undefined ? abstained.confidence.toFixed(3) + ' ' : ''}` +
503
+ `fell below your require_confidence of ${abstained.threshold ?? '?'}.`,
504
+ ``,
505
+ `require_confidence is NOT min_score. It gates on the SHAPE of the score` +
506
+ ` distribution across the whole result set, not on how relevant any single` +
507
+ ` memory is — so results that comfortably pass min_score can still be withheld here.`,
508
+ ``,
509
+ `To see them: re-run this query with require_confidence: 0.`,
510
+ `Do not conclude the memories are absent without doing that.`,
511
+ ].join('\n') + peerSuffix
512
+ : 'No relevant memories found.' + peerSuffix;
513
+ return { content: [{ type: 'text' as const, text }] };
514
+ }
515
+
516
+ // Confidence-adaptive output (Paper 3: cognitive teaming) and D8
517
+ // (2026-07-30) conflict surfacing both live in the shared formatter now —
518
+ // see core/format-recall.ts for why it's extracted (0.12.1: unit-testable
519
+ // without booting the server) and why the id sits after the score.
520
+ // 0.13.3: pack to a token budget (if given) and always report the spend.
521
+ // `limit` is a count and is token-blind; max_tokens bounds what this call
522
+ // actually costs the caller's context. See core/token-budget.ts for why the
523
+ // top-scored result gets first refusal rather than pure density packing.
524
+ // Reserve what the rest of the reply will cost, so max_tokens bounds the
525
+ // WHOLE response and not just the result lines. FOOTER_ALLOWANCE is a
526
+ // fixed upper bound on the accounting line (measured at ~15-25 tokens; the
527
+ // unit test asserts it stays under 40).
528
+ // 2026-10-19: recall confidence on every reply (it was printed only on
529
+ // abstention, so a caller could not tell a clear winner from a toss-up),
530
+ // and one line when the returned set holds two versions of a fact — see
531
+ // src/core/conflicts.ts. Both are reserved from the budget like the footer.
532
+ const topConfidence = results[0]?.confidence;
533
+ const confidenceFooter = topConfidence !== undefined ? `\n[confidence: ${topConfidence.toFixed(3)}]` : '';
534
+ const marked = results.filter(r => (r.conflictsWith ?? []).length > 0);
535
+ const newest = newestConflicting(results, results.map(r => r.conflictsWith ?? []));
536
+ const conflictFooter = marked.length > 0
537
+ ? `\n⚠ ${marked.length} of these memories disagree with each other${newest ? ` — the newest is ${newest}` : ''}. Confirm against the source before stating a value; supersede the stale one when you know which it is.`
538
+ : '';
539
+ // The near-miss (2026-10-19): memories plainly about the subject, none holding
540
+ // the kind of value asked for. That is the context shape that invites a made-up
541
+ // number; say so. See src/core/asked-value.ts.
542
+ const missingFooter = missingValueNote(queryText ?? '', results);
543
+ const FOOTER_ALLOWANCE = 40;
544
+ const reserved = estimateTokens(peerSuffix) + estimateTokens(confidenceFooter) + estimateTokens(conflictFooter) + estimateTokens(missingFooter) + FOOTER_ALLOWANCE;
545
+ const packed = packRecallByBudget(
546
+ results, formatRecallResultLine, params.max_tokens, reserved,
547
+ );
548
+
549
+ // A budget too small to admit even the best match must not look like "no
550
+ // memories found" — that is a different fact and would send the caller off
551
+ // to read code that AWM could have answered. Say what actually happened so
552
+ // the fix (raise the budget) is obvious.
553
+ if (packed.kept === 0 && packed.total > 0) {
554
+ return {
555
+ content: [{
556
+ type: 'text' as const,
557
+ text: `${packed.total} memories matched, but none fit a ${params.max_tokens}-token budget `
558
+ + `(smallest result is ~${packed.withheldTokens} tok across ${packed.total}). `
559
+ + `Raise max_tokens, or use granularity: 'compact' to shrink each result.`
560
+ + peerSuffix,
561
+ }],
562
+ };
563
+ }
564
+
565
+ // 0.14.3: surface the activation event id once, so memory_feedback can join
566
+ // to this recall. Rendered after the token footer; cheap (one short line).
567
+ const evId = results[0]?.activationEventId;
568
+ const evFooter = evId ? `\n[recall_id: ${evId}]` : '';
569
+
570
+ return {
571
+ content: [{
572
+ type: 'text' as const,
573
+ text: packed.lines.join('\n') + peerSuffix + conflictFooter + missingFooter + formatTokenFooter(packed, params.max_tokens) + confidenceFooter + evFooter,
574
+ }],
575
+ };
576
+ }
577
+ );
578
+
579
+ server.tool(
580
+ 'memory_feedback',
581
+ `Report whether a recalled memory was actually useful. This updates the memory's confidence score — useful memories become stronger, useless ones weaken.
582
+
583
+ Always call this after using a recalled memory so the system learns what's valuable.`,
584
+ {
585
+ engram_id: z.string().describe('ID of the memory (from memory_recall results)'),
586
+ useful: z.boolean().describe('Was this memory actually helpful?'),
587
+ context: z.string().optional().describe('Brief note on why it was/wasn\'t useful'),
588
+ recall_id: z.string().optional().describe(
589
+ 'The [recall_id: …] printed at the end of the memory_recall output that returned this memory. '
590
+ + 'Pass it so the feedback is joined to that recall; omitted, it defaults to the most recent recall in this session.',
591
+ ),
592
+ },
593
+ async (params) => {
594
+ // 0.14.3: join feedback to the recall that produced it. Explicit recall_id
595
+ // wins; otherwise fall back to the engine's most recent logged activation —
596
+ // the common case is "recall, use, feedback" in one turn. Before this the
597
+ // MCP path hardcoded null (the HTTP route already accepted the id), which
598
+ // is why every retrieval_feedback row in the live store was orphaned.
599
+ const eventId = params.recall_id ?? activationEngine.lastActivationEventId ?? null;
600
+ await store.logRetrievalFeedback(eventId, params.engram_id, params.useful, params.context ?? '');
601
+
602
+ const engram = await store.getEngram(params.engram_id);
603
+ if (engram) {
604
+ const delta = params.useful
605
+ ? DEFAULT_AGENT_CONFIG.feedbackPositiveBoost
606
+ : -DEFAULT_AGENT_CONFIG.feedbackNegativePenalty;
607
+ await store.updateConfidence(engram.id, engram.confidence + delta);
608
+ }
609
+
610
+ // Validation-gated Hebbian: resolve pending co-activation pairs for this engram
611
+ const hebbianUpdated = await activationEngine.resolveHebbianFeedback(params.engram_id, params.useful);
612
+
613
+ return {
614
+ content: [{
615
+ type: 'text' as const,
616
+ text: `Feedback: ${params.useful ? '+useful' : '-not useful'}${hebbianUpdated > 0 ? ` (${hebbianUpdated} association${hebbianUpdated > 1 ? 's' : ''} ${params.useful ? 'strengthened' : 'weakened'})` : ''}`,
617
+ }],
618
+ };
619
+ }
620
+ );
621
+
622
+ server.tool(
623
+ 'memory_retract',
624
+ `Retract a memory that turned out to be wrong. Creates a correction and reduces confidence of related memories.
625
+
626
+ Use this when you discover a memory contains incorrect information.`,
627
+ {
628
+ engram_id: z.string().describe('ID of the wrong memory'),
629
+ reason: z.string().describe('Why is this memory wrong?'),
630
+ correction: z.string().optional().describe('What is the correct information? (creates a new memory)'),
631
+ },
632
+ async (params) => {
633
+ const result = await retractionEngine.retract({
634
+ agentId: AGENT_ID,
635
+ targetEngramId: params.engram_id,
636
+ reason: params.reason,
637
+ counterContent: params.correction,
638
+ });
639
+
640
+ const parts = [`Memory ${params.engram_id} retracted.`];
641
+ if (result.correctionId) {
642
+ parts.push(`Correction stored as ${result.correctionId}.`);
643
+ }
644
+ parts.push(`${result.associatesAffected} related memories had confidence reduced.`);
645
+
646
+ return {
647
+ content: [{
648
+ type: 'text' as const,
649
+ text: parts.join(' '),
650
+ }],
651
+ };
652
+ }
653
+ );
654
+
655
+ server.tool(
656
+ 'memory_supersede',
657
+ `Replace an outdated memory with a newer one. Unlike retraction (which marks memories as wrong), supersession marks the old memory as outdated but historically correct.
658
+
659
+ Use this when:
660
+ - A status or count has changed (e.g., "5 reviews done" → "7 reviews done")
661
+ - Architecture or infrastructure evolved (e.g., "two-repo model" → "three-repo model")
662
+ - A schedule or plan was updated
663
+
664
+ The old memory stays in the database (searchable for history) but is heavily down-ranked in recall so the current version dominates.`,
665
+ {
666
+ old_engram_id: z.string().describe('ID of the outdated memory (from memory_recall results, or memory_write\'s own response if you just wrote it)'),
667
+ new_engram_id: z.string().describe('ID of the replacement memory'),
668
+ reason: z.string().optional().describe('Why the old memory is outdated'),
669
+ },
670
+ async (params) => {
671
+ const oldEngram = await store.getEngram(params.old_engram_id);
672
+ if (!oldEngram) {
673
+ return { content: [{ type: 'text' as const, text: `Old memory not found: ${params.old_engram_id}` }] };
674
+ }
675
+ const newEngram = await store.getEngram(params.new_engram_id);
676
+ if (!newEngram) {
677
+ return { content: [{ type: 'text' as const, text: `New memory not found: ${params.new_engram_id}` }] };
678
+ }
679
+
680
+ await store.supersedeEngram(params.old_engram_id, params.new_engram_id);
681
+
682
+ // Create supersession association (new → old)
683
+ await store.upsertAssociation(params.new_engram_id, params.old_engram_id, 0.8, 'causal', 0.9);
684
+
685
+ // Reduce old memory's confidence (not to zero — it's historical, not wrong)
686
+ await store.updateConfidence(params.old_engram_id, Math.max(0.2, oldEngram.confidence * 0.4));
687
+
688
+ log(AGENT_ID, 'supersede', `"${oldEngram.concept}" → "${newEngram.concept}"${params.reason ? ` (${params.reason})` : ''}`);
689
+
690
+ return {
691
+ content: [{
692
+ type: 'text' as const,
693
+ text: `Superseded: "${oldEngram.concept}" → "${newEngram.concept}"`,
694
+ }],
695
+ };
696
+ }
697
+ );
698
+
699
+ server.tool(
700
+ 'memory_whoami',
701
+ `Identify THIS AWM instance — agent id, mode (standalone/hive), backend, store path, code provenance, ports, and the sibling agent spaces present in the same store. Call when unsure which AWM instance or memory space you are talking to.`,
702
+ {},
703
+ async () => {
704
+ const info = await buildWhoami(store, AGENT_ID, 'mcp', sidecarHandle ? sidecarHandle.boundPort() : undefined);
705
+ return { content: [{ type: 'text', text: formatWhoami(info) }] };
706
+ },
707
+ );
708
+
709
+ server.tool(
710
+ 'memory_stats',
711
+ `Get memory health stats — how many memories, confidence levels, association count, and system performance.
712
+ Also shows the activity log path so the user can tail it to see what's happening.`,
713
+ {},
714
+ async () => {
715
+ const metrics = await evalEngine.computeMetrics(AGENT_ID);
716
+ const checkpoint = await store.getCheckpoint(AGENT_ID);
717
+ // 0.14.3: outcome numbers, not activity counters. "Edge utility" (share of
718
+ // edges ever activated) was monotone — it could only rise — and read as
719
+ // health when it was not. Latency was a mean over a column that mixes cold
720
+ // loads and stalls (live store: mean 20 s, median 1.6 s); medians only now.
721
+ const usage = await evalEngine.computeUsage(AGENT_ID);
722
+ const lines = [
723
+ `Agent: ${AGENT_ID}`,
724
+ `Active memories: ${metrics.activeEngramCount}`,
725
+ `Staging: ${metrics.stagingEngramCount}`,
726
+ `Retracted: ${metrics.retractedCount}`,
727
+ `Avg confidence: ${metrics.avgConfidence.toFixed(3)}`,
728
+ `Total edges: ${metrics.totalEdges}`,
729
+ ``,
730
+ `Write:recall (30d): 1 : ${usage.recallsPerWrite30d.toFixed(2)} (${usage.writes30d} writes, ${usage.recalls30d} recalls)`,
731
+ `Never recalled: ${(usage.neverRecalledShare * 100).toFixed(0)}% of active memories`,
732
+ `Recall→use (7d): ${usage.feedbackLinked7d > 0 ? (usage.usefulShare7d * 100).toFixed(0) + '% useful of ' + usage.feedbackLinked7d + ' judged' : 'no linked feedback yet'}`,
733
+ `Activations (24h): ${metrics.activationCount}`,
734
+ `Recall latency (24h): p50 ${metrics.p50LatencyMs.toFixed(0)}ms p90 ${metrics.p90LatencyMs.toFixed(0)}ms`,
735
+ ``,
736
+ `Session writes: ${checkpoint?.auto.writeCountSinceConsolidation ?? 0}`,
737
+ `Session recalls: ${checkpoint?.auto.recallCountSinceConsolidation ?? 0}`,
738
+ `Last activity: ${checkpoint?.auto.lastActivityAt?.toISOString() ?? 'never'}`,
739
+ `Checkpoint: ${checkpoint?.executionState ? checkpoint.executionState.currentTask : 'none'}`,
740
+ ``,
741
+ `Activity log: ${getLogPath() ?? 'not configured'}`,
742
+ // Report the port actually BOUND, not the one configured. Before 0.14.2
743
+ // this line said 8401 in every session, including the ones whose sidecar
744
+ // had lost the port and silently disabled hooks.
745
+ `Hook sidecar: ${sidecarPortLabel()}`,
746
+ ];
747
+
748
+ return {
749
+ content: [{
750
+ type: 'text' as const,
751
+ text: lines.join('\n'),
752
+ }],
753
+ };
754
+ }
755
+ );
756
+
757
+ // --- Checkpointing Tools ---
758
+
759
+ server.tool(
760
+ 'memory_checkpoint',
761
+ `Save your current execution state so you can recover after context compaction.
762
+
763
+ ALWAYS call this before:
764
+ - Long operations (multi-file generation, large refactors, overnight work)
765
+ - Anything that might fill the context window
766
+ - Switching to a different task
767
+
768
+ Also call periodically during long sessions to avoid losing state. The state is saved per-agent and overwrites any previous checkpoint.`,
769
+ {
770
+ current_task: z.string().describe('What you are currently working on'),
771
+ decisions: z.array(z.string()).optional().default([])
772
+ .describe('Key decisions made so far'),
773
+ active_files: z.array(z.string()).optional().default([])
774
+ .describe('Files you are currently working with'),
775
+ next_steps: z.array(z.string()).optional().default([])
776
+ .describe('What needs to happen next'),
777
+ related_memory_ids: z.array(z.string()).optional().default([])
778
+ .describe('IDs of memories relevant to current work'),
779
+ notes: z.string().optional().default('')
780
+ .describe('Any other context worth preserving'),
781
+ episode_id: z.string().optional()
782
+ .describe('Current episode ID if known'),
783
+ },
784
+ async (params) => {
785
+ const state: ConsciousState = {
786
+ currentTask: params.current_task,
787
+ decisions: params.decisions,
788
+ activeFiles: params.active_files,
789
+ nextSteps: params.next_steps,
790
+ relatedMemoryIds: params.related_memory_ids,
791
+ notes: params.notes,
792
+ episodeId: params.episode_id ?? null,
793
+ };
794
+
795
+ await store.saveCheckpoint(AGENT_ID, state);
796
+ log(AGENT_ID, 'checkpoint', `"${params.current_task}" decisions=${params.decisions.length} files=${params.active_files.length}`);
797
+
798
+ return {
799
+ content: [{
800
+ type: 'text' as const,
801
+ text: `Checkpoint saved: "${params.current_task}" (${params.decisions.length} decisions, ${params.active_files.length} files)`,
802
+ }],
803
+ };
804
+ }
805
+ );
806
+
807
+ server.tool(
808
+ 'memory_restore',
809
+ `Restore your previous execution state after context compaction or at session start.
810
+
811
+ Returns:
812
+ - Your saved execution state (task, decisions, next steps, files)
813
+ - Recently recalled memories for context
814
+ - Your last write for continuity
815
+ - How long you were idle
816
+
817
+ Use this at the start of every session or after compaction to pick up where you left off.`,
818
+ {},
819
+ async () => {
820
+ const checkpoint = await store.getCheckpoint(AGENT_ID);
821
+
822
+ // Cold-store nudge: an empty store means the agent has nothing to recall — offer to warm-start.
823
+ let coldStoreNudge = '';
824
+ try {
825
+ const activeCount = (await store.getEngramsByAgent(AGENT_ID)).length;
826
+ if (activeCount < 3) {
827
+ coldStoreNudge = `🌱 **This memory store is nearly empty (${activeCount} ${activeCount === 1 ? 'memory' : 'memories'}).** Warm-start it before other work: recall the "onboard a new project" skill and follow it — or call \`onboard_scan\` on this project's docs/repo, refine the results, and save them with \`memory_write\` (canonical). Recall becomes useful immediately.`;
828
+ }
829
+ } catch { /* count is best-effort */ }
830
+
831
+ const now = Date.now();
832
+ const idleMs = checkpoint
833
+ ? now - checkpoint.auto.lastActivityAt.getTime()
834
+ : 0;
835
+
836
+ // Get last written engram
837
+ let lastWrite: { id: string; concept: string; content: string } | null = null;
838
+ if (checkpoint?.auto.lastWriteId) {
839
+ const engram = await store.getEngram(checkpoint.auto.lastWriteId);
840
+ if (engram) {
841
+ lastWrite = { id: engram.id, concept: engram.concept, content: engram.content };
842
+ }
843
+ }
844
+
845
+ // Recall memories using last context
846
+ let recalledMemories: Array<{ id: string; concept: string; content: string; score: number }> = [];
847
+ const recallContext = checkpoint?.auto.lastRecallContext
848
+ ?? checkpoint?.executionState?.currentTask
849
+ ?? null;
850
+
851
+ if (recallContext) {
852
+ try {
853
+ const results = await activationEngine.activate({
854
+ agentId: AGENT_ID,
855
+ context: recallContext,
856
+ // 3, not 5 — measured token-positive (+115/recall vs -680) and 41% faster.
857
+ // The case is stronger here than for explicit recall: this is push-style, so
858
+ // nobody asked for these results and every one of them costs context the user
859
+ // did not request.
860
+ limit: 3,
861
+ minScore: 0.05,
862
+ useReranker: true,
863
+ useExpansion: true,
864
+ workspace: process.env.AWM_WORKSPACE ?? undefined,
865
+ });
866
+ recalledMemories = results.map(r => ({
867
+ id: r.engram.id,
868
+ concept: r.engram.concept,
869
+ content: r.engram.content,
870
+ score: r.score,
871
+ }));
872
+ } catch { /* recall failure is non-fatal */ }
873
+ }
874
+
875
+ // Consolidation on restore:
876
+ // - If idle >5min but last consolidation was recent (graceful exit ran it), skip
877
+ // - If idle >5min and no recent consolidation, run full cycle (non-graceful exit fallback)
878
+ const MINI_IDLE_MS = 5 * 60_000;
879
+ const FULL_CONSOLIDATION_GAP_MS = 10 * 60_000; // 10 min — if last consolidation was longer ago, run full
880
+ let miniConsolidationTriggered = false;
881
+ let fullConsolidationTriggered = false;
882
+
883
+ if (idleMs > MINI_IDLE_MS) {
884
+ const sinceLastConsolidation = checkpoint?.lastConsolidationAt
885
+ ? now - checkpoint.lastConsolidationAt.getTime()
886
+ : Infinity;
887
+
888
+ if (sinceLastConsolidation > FULL_CONSOLIDATION_GAP_MS) {
889
+ // No recent consolidation — graceful exit didn't happen, run full cycle
890
+ fullConsolidationTriggered = true;
891
+ try {
892
+ const result = await consolidationEngine.consolidate(AGENT_ID);
893
+ await store.markConsolidation(AGENT_ID, false);
894
+ log(AGENT_ID, 'consolidation', `full sleep cycle on restore (no graceful exit, idle ${Math.round(idleMs / 60_000)}min, last consolidation ${Math.round(sinceLastConsolidation / 60_000)}min ago) — ${result.edgesStrengthened} strengthened, ${result.memoriesForgotten} forgotten`);
895
+ } catch { /* consolidation failure is non-fatal */ }
896
+ } else {
897
+ // Recent consolidation exists — graceful exit already handled it, just do mini
898
+ miniConsolidationTriggered = true;
899
+ consolidationScheduler.runMiniConsolidation(AGENT_ID).catch(() => {});
900
+ }
901
+ }
902
+
903
+ // Format response
904
+ const parts: string[] = [];
905
+ const idleMin = Math.round(idleMs / 60_000);
906
+ const consolidationNote = fullConsolidationTriggered
907
+ ? ' (full consolidation — no graceful exit detected)'
908
+ : miniConsolidationTriggered
909
+ ? ' (mini-consolidation triggered)'
910
+ : '';
911
+ log(AGENT_ID, 'restore', `idle=${idleMin}min checkpoint=${!!checkpoint?.executionState} recalled=${recalledMemories.length} lastWrite=${lastWrite?.concept ?? 'none'}${fullConsolidationTriggered ? ' FULL_CONSOLIDATION' : ''}`);
912
+ parts.push(`Idle: ${idleMin}min${consolidationNote}`);
913
+ if (coldStoreNudge) parts.push(`\n${coldStoreNudge}`);
914
+
915
+ if (checkpoint?.executionState) {
916
+ const s = checkpoint.executionState;
917
+ parts.push(`\n**Current task:** ${s.currentTask}`);
918
+ if (s.decisions.length) parts.push(`**Decisions:** ${s.decisions.join('; ')}`);
919
+ if (s.nextSteps.length) parts.push(`**Next steps:** ${s.nextSteps.map((st, i) => `${i + 1}. ${st}`).join(', ')}`);
920
+ if (s.activeFiles.length) parts.push(`**Active files:** ${s.activeFiles.join(', ')}`);
921
+ if (s.notes) parts.push(`**Notes:** ${s.notes}`);
922
+ if (checkpoint.checkpointAt) parts.push(`_Saved at: ${checkpoint.checkpointAt.toISOString()}_`);
923
+ } else {
924
+ parts.push('\nNo explicit checkpoint saved.');
925
+ parts.push('\n**Tip:** Use memory_write to save important learnings, and memory_checkpoint before long operations so you can recover state.');
926
+ }
927
+
928
+ if (lastWrite) {
929
+ parts.push(`\n**Last write:** ${lastWrite.concept}\n${lastWrite.content}`);
930
+ }
931
+
932
+ if (recalledMemories.length > 0) {
933
+ parts.push(`\n**Recalled memories (${recalledMemories.length}):**`);
934
+ for (const m of recalledMemories) {
935
+ parts.push(`- **${m.concept}** (${m.score.toFixed(3)}): ${m.content.slice(0, 150)}${m.content.length > 150 ? '...' : ''}`);
936
+ }
937
+ }
938
+
939
+ // Peer decisions: show recent decisions from other agents (last 30 min)
940
+ if (coordDb) {
941
+ try {
942
+ const myAgent = coordDb.prepare(
943
+ `SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
944
+ ).get(AGENT_ID) as { id: string } | undefined;
945
+
946
+ const peerDecisions = coordDb.prepare(
947
+ `SELECT d.summary, a.name AS author_name, d.created_at
948
+ FROM coord_decisions d JOIN coord_agents a ON d.author_id = a.id
949
+ WHERE d.author_id != ? AND d.created_at > datetime('now', '-30 minutes')
950
+ ORDER BY d.created_at DESC LIMIT 10`
951
+ ).all(myAgent?.id ?? '') as Array<{ summary: string; author_name: string; created_at: string }>;
952
+
953
+ if (peerDecisions.length > 0) {
954
+ parts.push(`\n**Peer decisions (last 30 min):**`);
955
+ for (const d of peerDecisions) {
956
+ parts.push(`- [${d.author_name}] ${d.summary} (${d.created_at})`);
957
+ }
958
+ }
959
+ } catch { /* peer decisions are non-fatal */ }
960
+ }
961
+
962
+ return {
963
+ content: [{
964
+ type: 'text' as const,
965
+ text: parts.join('\n'),
966
+ }],
967
+ };
968
+ }
969
+ );
970
+
971
+ // --- Onboarding Tools (warm-start a cold store) ---
972
+
973
+ server.tool(
974
+ 'onboard_scan',
975
+ `Scan a project's documentation + repository and return CANDIDATE memories to seed a cold store.
976
+
977
+ Use this when the store is empty / you're new to a project. The scan is deterministic
978
+ (real file contents, not guesses) — YOUR job is to refine the candidates into atomic,
979
+ recall-shaped memories, run the interview (onboard_questions), confirm with the user, then
980
+ save the good ones with memory_write (memory_class="canonical"). Nothing is saved by this tool.`,
981
+ {
982
+ docs: z.array(z.string()).optional()
983
+ .describe('Doc files/dirs to scan (Markdown/text). Defaults to the repo (or cwd).'),
984
+ repo: z.string().optional()
985
+ .describe('Repo root — also derives stack (package.json) + layout memories.'),
986
+ project: z.string().optional()
987
+ .describe('Project name (becomes a tag). Defaults to the repo/dir name.'),
988
+ purpose: z.string().optional()
989
+ .describe('The goal of this memory system, if known — becomes the anchor memory.'),
990
+ },
991
+ async (params) => {
992
+ const repo = params.repo;
993
+ const docs = params.docs && params.docs.length ? params.docs : [repo ?? process.cwd()];
994
+ const project = params.project ?? basename(resolve(repo ?? docs[0] ?? process.cwd()));
995
+ const pack = buildPack({ docs, repo, project, agentId: AGENT_ID, purpose: params.purpose });
996
+ log(AGENT_ID, 'onboard', `scan ${docs.join(',')}${repo ? ' +repo' : ''} → ${pack.memories.length} candidates`);
997
+ const text = [
998
+ `Scanned ${docs.join(', ')}${repo ? ` (+repo ${repo})` : ''} → ${pack.memories.length} CANDIDATE memories (NOT saved).`,
999
+ `Next: refine each into an atomic memory (lead with the fact + identifiers), run onboard_questions,`,
1000
+ `confirm with the user, then save the good ones with memory_write (memory_class="canonical").`,
1001
+ ``,
1002
+ JSON.stringify({ project, candidates: pack.memories, questions: pack.questions }, null, 2),
1003
+ ].join('\n');
1004
+ return { content: [{ type: 'text' as const, text }] };
1005
+ }
1006
+ );
1007
+
1008
+ server.tool(
1009
+ 'onboard_questions',
1010
+ `Return the onboarding interview questions. Ask the user ONE at a time, starting with the
1011
+ goal of the memory system, and ask follow-ups for clarity. Turn each answer into a canonical memory.`,
1012
+ {},
1013
+ async () => ({
1014
+ content: [{ type: 'text' as const, text: INTERVIEW_QUESTIONS.map((q, i) => `${i + 1}. ${q}`).join('\n') }],
1015
+ })
1016
+ );
1017
+
1018
+ // --- Task Management Tools ---
1019
+
1020
+ server.tool(
1021
+ 'memory_task_add',
1022
+ `Create a task that you need to come back to. Tasks are memories with status and priority tracking.
1023
+
1024
+ Use this when:
1025
+ - You identify work that needs doing but can't do it right now
1026
+ - The user mentions something to do later
1027
+ - You want to park a sub-task while focusing on something more urgent
1028
+
1029
+ Tasks automatically get high salience so they won't be discarded.`,
1030
+ {
1031
+ concept: z.string().describe('Short task title (3-10 words)'),
1032
+ content: z.string().describe('Full task description — what needs doing, context, acceptance criteria'),
1033
+ tags: z.array(z.string()).optional().describe('Tags for categorization'),
1034
+ priority: z.enum(['urgent', 'high', 'medium', 'low']).default('medium')
1035
+ .describe('Task priority: urgent (do now), high (do soon), medium (normal), low (backlog)'),
1036
+ blocked_by: z.string().optional().describe('ID of a task that must finish first'),
1037
+ },
1038
+ async (params) => {
1039
+ const engram = await store.createEngram({
1040
+ agentId: AGENT_ID,
1041
+ concept: params.concept,
1042
+ content: params.content,
1043
+ tags: [...(params.tags ?? []), 'task'],
1044
+ salience: 0.9, // Tasks always high salience
1045
+ confidence: 0.8,
1046
+ salienceFeatures: {
1047
+ surprise: 0.5,
1048
+ decisionMade: true,
1049
+ causalDepth: 0.5,
1050
+ resolutionEffort: 0.5,
1051
+ eventType: 'decision',
1052
+ },
1053
+ reasonCodes: ['task-created'],
1054
+ taskStatus: params.blocked_by ? 'blocked' : 'open',
1055
+ taskPriority: params.priority as TaskPriority,
1056
+ blockedBy: params.blocked_by,
1057
+ });
1058
+
1059
+ connectionEngine.enqueue(engram.id);
1060
+
1061
+ // Generate embedding asynchronously
1062
+ embed(`${params.concept} ${params.content}`).then(async vec => {
1063
+ await store.updateEmbedding(engram.id, vec);
1064
+ }).catch(() => {});
1065
+
1066
+ return {
1067
+ content: [{
1068
+ type: 'text' as const,
1069
+ text: `Task created: "${params.concept}" (${params.priority})`,
1070
+ }],
1071
+ };
1072
+ }
1073
+ );
1074
+
1075
+ server.tool(
1076
+ 'memory_task_update',
1077
+ `Update a task's status or priority. Use this to:
1078
+ - Start working on a task (open → in_progress)
1079
+ - Mark a task done (→ done)
1080
+ - Block a task on another (→ blocked)
1081
+ - Reprioritize (change priority)
1082
+ - Unblock a task (clear blocked_by)`,
1083
+ {
1084
+ task_id: z.string().describe('ID of the task to update'),
1085
+ status: z.enum(['open', 'in_progress', 'blocked', 'done']).optional()
1086
+ .describe('New status'),
1087
+ priority: z.enum(['urgent', 'high', 'medium', 'low']).optional()
1088
+ .describe('New priority'),
1089
+ blocked_by: z.string().optional().describe('ID of blocking task (set to empty string to unblock)'),
1090
+ },
1091
+ async (params) => {
1092
+ const engram = await store.getEngram(params.task_id);
1093
+ if (!engram || !engram.taskStatus) {
1094
+ return { content: [{ type: 'text' as const, text: `Task not found: ${params.task_id}` }] };
1095
+ }
1096
+
1097
+ if (params.blocked_by !== undefined) {
1098
+ await store.updateBlockedBy(params.task_id, params.blocked_by || null);
1099
+ }
1100
+ if (params.status) {
1101
+ await store.updateTaskStatus(params.task_id, params.status as TaskStatus);
1102
+ }
1103
+ if (params.priority) {
1104
+ await store.updateTaskPriority(params.task_id, params.priority as TaskPriority);
1105
+ }
1106
+
1107
+ const updated = (await store.getEngram(params.task_id))!;
1108
+ return {
1109
+ content: [{
1110
+ type: 'text' as const,
1111
+ text: `Updated: "${updated.concept}" → ${updated.taskStatus} (${updated.taskPriority})`,
1112
+ }],
1113
+ };
1114
+ }
1115
+ );
1116
+
1117
+ server.tool(
1118
+ 'memory_task_list',
1119
+ `List tasks with optional status filter. Shows tasks ordered by priority (urgent first).
1120
+
1121
+ Use at the start of a session to see what's pending, or to check blocked/done tasks.`,
1122
+ {
1123
+ status: z.enum(['open', 'in_progress', 'blocked', 'done']).optional()
1124
+ .describe('Filter by status (omit to see all active tasks)'),
1125
+ include_done: z.boolean().optional().default(false)
1126
+ .describe('Include completed tasks?'),
1127
+ },
1128
+ async (params) => {
1129
+ let tasks = await store.getTasks(AGENT_ID, params.status as TaskStatus | undefined);
1130
+ if (!params.include_done && !params.status) {
1131
+ tasks = tasks.filter(t => t.taskStatus !== 'done');
1132
+ }
1133
+
1134
+ if (tasks.length === 0) {
1135
+ return { content: [{ type: 'text' as const, text: 'No tasks found.' }] };
1136
+ }
1137
+
1138
+ const lines = tasks.map((t, i) => {
1139
+ const blocked = t.blockedBy ? ` [blocked by ${t.blockedBy}]` : '';
1140
+ const tags = t.tags?.filter(tag => tag !== 'task').join(', ');
1141
+ return `${i + 1}. [${t.taskStatus}] **${t.concept}** (${t.taskPriority})${blocked}\n ${t.content.slice(0, 120)}${t.content.length > 120 ? '...' : ''}\n ${tags ? `Tags: ${tags} | ` : ''}ID: ${t.id}`;
1142
+ });
1143
+
1144
+ return {
1145
+ content: [{
1146
+ type: 'text' as const,
1147
+ text: `Tasks (${tasks.length}):\n\n${lines.join('\n\n')}`,
1148
+ }],
1149
+ };
1150
+ }
1151
+ );
1152
+
1153
+ server.tool(
1154
+ 'memory_task_next',
1155
+ `Get the single most important task to work on next.
1156
+
1157
+ Prioritizes: in_progress tasks first (finish what you started), then by priority level, then oldest first. Skips blocked and done tasks.
1158
+
1159
+ Use this when you finish a task or need to decide what to do next.`,
1160
+ {},
1161
+ async () => {
1162
+ const next = await store.getNextTask(AGENT_ID);
1163
+ if (!next) {
1164
+ return { content: [{ type: 'text' as const, text: 'No actionable tasks. All clear!' }] };
1165
+ }
1166
+
1167
+ const blocked = next.blockedBy ? `\nBlocked by: ${next.blockedBy}` : '';
1168
+ const tags = next.tags?.filter(tag => tag !== 'task').join(', ');
1169
+
1170
+ return {
1171
+ content: [{
1172
+ type: 'text' as const,
1173
+ text: `Next task:\n**${next.concept}** (${next.taskPriority})\nStatus: ${next.taskStatus}\n${next.content}${blocked}\n${tags ? `Tags: ${tags}\n` : ''}ID: ${next.id}`,
1174
+ }],
1175
+ };
1176
+ }
1177
+ );
1178
+
1179
+ // --- Task Bracket Tools ---
1180
+
1181
+ server.tool(
1182
+ 'memory_task_begin',
1183
+ `Signal that you're starting a significant task. Auto-checkpoints current state and recalls relevant memories.
1184
+
1185
+ CALL THIS when starting:
1186
+ - A multi-step operation (doc generation, large refactor, migration)
1187
+ - Work on a new topic or project area
1188
+ - Anything that might fill the context window
1189
+
1190
+ This ensures your state is saved before you start, and primes recall with relevant context.`,
1191
+ {
1192
+ topic: z.string().describe('What task are you starting? (3-15 words)'),
1193
+ files: z.array(z.string()).optional().default([])
1194
+ .describe('Files you expect to work with'),
1195
+ notes: z.string().optional().default('')
1196
+ .describe('Any additional context'),
1197
+ },
1198
+ async (params) => {
1199
+ // 1. Checkpoint current state
1200
+ const checkpoint = await store.getCheckpoint(AGENT_ID);
1201
+ const prevTask = checkpoint?.executionState?.currentTask ?? 'None';
1202
+
1203
+ await store.saveCheckpoint(AGENT_ID, {
1204
+ currentTask: params.topic,
1205
+ decisions: [],
1206
+ activeFiles: params.files,
1207
+ nextSteps: [],
1208
+ relatedMemoryIds: [],
1209
+ notes: params.notes || `Started via memory_task_begin. Previous task: ${prevTask}`,
1210
+ episodeId: null,
1211
+ });
1212
+
1213
+ // 2. Auto-recall relevant memories
1214
+ let recalledSummary = '';
1215
+ try {
1216
+ const results = await activationEngine.activate({
1217
+ agentId: AGENT_ID,
1218
+ context: params.topic,
1219
+ // 3, not 5 — see memory_recall. Push-style auto-recall on task start.
1220
+ limit: 3,
1221
+ minScore: 0.05,
1222
+ useReranker: true,
1223
+ useExpansion: true,
1224
+ workspace: process.env.AWM_WORKSPACE ?? undefined,
1225
+ });
1226
+
1227
+ if (results.length > 0) {
1228
+ const lines = results.map((r, i) => {
1229
+ const tags = r.engram.tags?.length ? ` [${r.engram.tags.join(', ')}]` : '';
1230
+ return `${i + 1}. **${r.engram.concept}** (${r.score.toFixed(3)})${tags}\n ${r.engram.content.slice(0, 150)}${r.engram.content.length > 150 ? '...' : ''}`;
1231
+ });
1232
+ recalledSummary = `\n\n**Recalled memories (${results.length}):**\n${lines.join('\n')}`;
1233
+
1234
+ // Track recall
1235
+ await store.updateAutoCheckpointRecall(AGENT_ID, params.topic, results.map(r => r.engram.id));
1236
+ }
1237
+ } catch { /* recall failure is non-fatal */ }
1238
+
1239
+ log(AGENT_ID, 'task:begin', `"${params.topic}" prev="${prevTask}"`);
1240
+
1241
+ return {
1242
+ content: [{
1243
+ type: 'text' as const,
1244
+ text: `Started: "${params.topic}" (prev: ${prevTask})${recalledSummary}`,
1245
+ }],
1246
+ };
1247
+ }
1248
+ );
1249
+
1250
+ server.tool(
1251
+ 'memory_task_end',
1252
+ `Signal that you've finished a significant task. Writes a summary memory and auto-checkpoints.
1253
+
1254
+ CALL THIS when you finish:
1255
+ - A multi-step operation
1256
+ - Before switching to a different topic
1257
+ - At the end of a work session
1258
+
1259
+ This captures what was accomplished so future sessions can recall it.`,
1260
+ {
1261
+ summary: z.string().describe('What was accomplished? Include key outcomes, decisions, and any issues.'),
1262
+ tags: z.array(z.string()).optional().default([])
1263
+ .describe('Tags for the summary memory'),
1264
+ supersedes: z.array(z.string()).optional().default([])
1265
+ .describe('IDs of older memories this task summary replaces (marks them as superseded)'),
1266
+ },
1267
+ async (params) => {
1268
+ // 1. Write summary as a memory
1269
+ const salience = evaluateSalience({
1270
+ content: params.summary,
1271
+ eventType: 'decision',
1272
+ surprise: 0.3,
1273
+ decisionMade: true,
1274
+ causalDepth: 0.5,
1275
+ resolutionEffort: 0.5,
1276
+ });
1277
+
1278
+ // Determine the real task name for the summary engram
1279
+ const checkpoint = await store.getCheckpoint(AGENT_ID);
1280
+ const rawTask = checkpoint?.executionState?.currentTask ?? 'Unknown task';
1281
+ // Strip any "Completed: " prefixes to avoid cascading
1282
+ const cleanedTask = rawTask.replace(/^(Completed: )+/, '');
1283
+ // Don't use auto-checkpoint or already-completed tasks as real task names
1284
+ const isNamedTask = !cleanedTask.startsWith('Auto-checkpoint') && cleanedTask !== 'Unknown task';
1285
+ const completedTask = isNamedTask
1286
+ ? cleanedTask
1287
+ : params.summary.slice(0, 60).replace(/\n/g, ' ');
1288
+
1289
+ const engram = await store.createEngram({
1290
+ agentId: AGENT_ID,
1291
+ concept: completedTask.slice(0, 80),
1292
+ content: params.summary,
1293
+ tags: [...params.tags, 'task-summary'],
1294
+ salience: isNamedTask ? Math.max(salience.score, 0.7) : salience.score, // Only floor salience for named tasks
1295
+ confidence: 0.65, // Task summaries are decision-grade (completed work)
1296
+ salienceFeatures: salience.features,
1297
+ reasonCodes: [...salience.reasonCodes, 'task-end'],
1298
+ });
1299
+
1300
+ connectionEngine.enqueue(engram.id);
1301
+
1302
+ // 2. Handle supersessions — mark old memories as outdated
1303
+ let supersededCount = 0;
1304
+ for (const oldId of params.supersedes) {
1305
+ const oldEngram = await store.getEngram(oldId);
1306
+ if (oldEngram) {
1307
+ await store.supersedeEngram(oldId, engram.id);
1308
+ await store.upsertAssociation(engram.id, oldId, 0.8, 'causal', 0.9);
1309
+ await store.updateConfidence(oldId, Math.max(0.2, oldEngram.confidence * 0.4));
1310
+ supersededCount++;
1311
+ }
1312
+ }
1313
+
1314
+ // Generate embedding asynchronously
1315
+ embed(`Task completed: ${params.summary}`).then(async vec => {
1316
+ await store.updateEmbedding(engram.id, vec);
1317
+ }).catch(() => {});
1318
+
1319
+ // 2. Update checkpoint to reflect completion
1320
+ await store.saveCheckpoint(AGENT_ID, {
1321
+ currentTask: `Completed: ${completedTask}`,
1322
+ decisions: checkpoint?.executionState?.decisions ?? [],
1323
+ activeFiles: [],
1324
+ nextSteps: [],
1325
+ relatedMemoryIds: [engram.id],
1326
+ notes: `Task completed. Summary memory: ${engram.id}`,
1327
+ episodeId: null,
1328
+ });
1329
+
1330
+ await store.updateAutoCheckpointWrite(AGENT_ID, engram.id);
1331
+ log(AGENT_ID, 'task:end', `"${completedTask}" summary=${engram.id} salience=${salience.score.toFixed(2)} superseded=${supersededCount}`);
1332
+
1333
+ const supersededNote = supersededCount > 0 ? ` (${supersededCount} old memories superseded)` : '';
1334
+ return {
1335
+ content: [{
1336
+ type: 'text' as const,
1337
+ // D14 (2026-07-30): task end is the recipe moment — invite the host
1338
+ // to distill a skill and/or a failure lesson in separate focused
1339
+ // passes. The host owns the gates; AWM validates the write-backs.
1340
+ text: `Completed: "${completedTask}" [${salience.score.toFixed(2)}]${supersededNote}\n${renderTaskEndInvitation()}`,
1341
+ }],
1342
+ };
1343
+ }
1344
+ );
1345
+
1346
+ server.tool(
1347
+ 'compress_output',
1348
+ `Compress a STRUCTURED tool output (JSON object/array, query rows, log records) into TOON —
1349
+ a compact, schema-aware tabular encoding — before putting it in your context. Cuts ~50-65%
1350
+ of the tokens on uniform arrays at zero comprehension cost (validated: models read TOON as
1351
+ accurately as JSON). Use this on large tool results you need to keep in context.
1352
+
1353
+ Output-only and safe: it never changes the data. Non-JSON / prose is returned unchanged.
1354
+ TOON is only emitted when it reproduces the input exactly (self-verified round-trip);
1355
+ otherwise you get plain JSON back. When compressed, you also get a 'ref' — call
1356
+ retrieve_original(ref) to get the verbatim source back if you ever need it.`,
1357
+ {
1358
+ output: z.string().describe('The tool output to compress — JSON text (preferred) or any string. Non-JSON is returned unchanged.'),
1359
+ min_saving_chars: z.number().optional().describe('Only emit TOON if it saves at least this many characters (default 40).'),
1360
+ },
1361
+ async (params) => {
1362
+ const r = liteCompress(params.output, { minSavingChars: params.min_saving_chars });
1363
+ log(AGENT_ID, 'compress', `${r.format} ${r.charsBefore}->${r.charsAfter} chars (${(r.ratio * 100).toFixed(0)}%)${r.ref ? ` ref=${r.ref}` : ''}`);
1364
+ const header = r.format === 'toon'
1365
+ ? `[TOON, ${(r.ratio * 100).toFixed(0)}% smaller — compact lossless JSON; read as data, ref=${r.ref}]\n`
1366
+ : '';
1367
+ return {
1368
+ content: [{ type: 'text' as const, text: header + r.text }],
1369
+ };
1370
+ }
1371
+ );
1372
+
1373
+ server.tool(
1374
+ 'retrieve_original',
1375
+ `Retrieve the verbatim original text for a 'ref' returned by compress_output. Use this when
1376
+ you need the exact, uncompressed source (e.g. to pass it to another tool unchanged). Returns
1377
+ an error if the ref has expired (originals are kept for the most recent compressions only).`,
1378
+ {
1379
+ ref: z.string().describe('The ref handle returned by compress_output (e.g. "awm_orig_12").'),
1380
+ },
1381
+ async (params) => {
1382
+ const original = retrieveOriginal(params.ref);
1383
+ if (original === undefined) {
1384
+ return {
1385
+ content: [{ type: 'text' as const, text: `Error: ref "${params.ref}" not found or expired.` }],
1386
+ };
1387
+ }
1388
+ return {
1389
+ content: [{ type: 'text' as const, text: original }],
1390
+ };
1391
+ }
1392
+ );
1393
+
1394
+ // --- Start ---
1395
+
1396
+ async function main() {
1397
+ const transport = new StdioServerTransport();
1398
+ await server.connect(transport);
1399
+
1400
+ // Start hook sidecar (lightweight HTTP for Claude Code hooks)
1401
+ const sidecar = startSidecar({
1402
+ store,
1403
+ agentId: AGENT_ID,
1404
+ secret: HOOK_SECRET,
1405
+ port: HOOK_PORT,
1406
+ portRange: HOOK_PORT_RANGE,
1407
+ version: VERSION,
1408
+ // 0.12.2: warm recall for hooks — the sidecar shares this process's
1409
+ // activation engine and loaded models, so a UserPromptSubmit hook can get
1410
+ // warm-latency recall without any standing server. Trimmed result shape
1411
+ // (no embeddings/phase scores — hooks don't need them and the vectors
1412
+ // alone would 10x the payload).
1413
+ activate: async (q) => {
1414
+ const results = await activationEngine.activate({
1415
+ agentId: AGENT_ID,
1416
+ context: q.context,
1417
+ limit: q.limit,
1418
+ requireConfidence: q.requireConfidence,
1419
+ granularity: q.granularity,
1420
+ });
1421
+ return results.map(r => ({
1422
+ engram: {
1423
+ id: r.engram.id,
1424
+ concept: r.engram.concept,
1425
+ content: r.engram.content,
1426
+ createdAt: r.engram.createdAt instanceof Date
1427
+ ? r.engram.createdAt.toISOString()
1428
+ : (r.engram.createdAt as unknown as string | undefined),
1429
+ memoryClass: r.engram.memoryClass,
1430
+ validFrom: r.engram.validFrom,
1431
+ validTo: r.engram.validTo,
1432
+ supersededBy: r.engram.supersededBy,
1433
+ },
1434
+ score: r.score,
1435
+ summary: r.summary,
1436
+ confidence: r.confidence,
1437
+ conflictsWith: r.conflictsWith,
1438
+ }));
1439
+ },
1440
+ onConsolidate: async (agentId, reason) => {
1441
+ console.error(`[mcp] consolidation triggered: ${reason}`);
1442
+ const result = await consolidationEngine.consolidate(agentId);
1443
+ await store.markConsolidation(agentId, false);
1444
+ console.error(`[mcp] consolidation done: ${result.edgesStrengthened} strengthened, ${result.memoriesForgotten} forgotten`);
1445
+ },
1446
+ });
1447
+
1448
+ // 0.12.2: eager warm — fire-and-forget, mirrors index.ts:208-218. Without
1449
+ // this, every Claude Code session paid the full cold cost (~3s measured on
1450
+ // a 29.7k-engram store: slim cache ~0.9s + three model loads ~1.8s) on its
1451
+ // FIRST recall, which is exactly the "first recall is slow → recall gets
1452
+ // avoided" failure mode. Warming here overlaps session startup instead.
1453
+ // All output is stderr-safe (stdout carries JSON-RPC frames).
1454
+ // Escape hatch: AWM_NO_EAGER_WARM=1 restores lazy loading.
1455
+ if (process.env.AWM_NO_EAGER_WARM !== '1') {
1456
+ // With the shared model daemon on, the daemon loads them and this process loads none.
1457
+ if (isModelDaemonEnabled()) {
1458
+ void warmModelDaemon();
1459
+ } else {
1460
+ getEmbedder().catch(err => console.error('Embedding model unavailable:', err.message));
1461
+ getReranker().catch(err => console.error('Reranker model unavailable:', err.message));
1462
+ getExpander().catch(err => console.error('Query expander model unavailable:', err.message));
1463
+ }
1464
+ if (BACKEND === 'sqlite') {
1465
+ setImmediate(() => {
1466
+ try {
1467
+ const t0 = Date.now();
1468
+ (store as unknown as EngramStore).warmSlimCache();
1469
+ const stats = (store as unknown as EngramStore).getSlimCacheStats();
1470
+ console.error(`Slim cache warmed: ${stats.size} entries in ${Date.now() - t0}ms`);
1471
+ } catch (err) {
1472
+ console.error(`Slim cache warm failed: ${(err as Error).message}`);
1473
+ }
1474
+ });
1475
+ }
1476
+ }
1477
+
1478
+ // Coordination MCP tools (opt-in via AWM_COORDINATION=true)
1479
+ // AWM 0.8.x: coordination requires SQLite (uses store.getDb()). On PGlite,
1480
+ // coordination is auto-disabled with a warning; re-enable when coordination
1481
+ // is ported to async/PGlite.
1482
+ const coordRequested = process.env.AWM_COORDINATION === 'true' || process.env.AWM_COORDINATION === '1';
1483
+ const coordEnabled = coordRequested && BACKEND === 'sqlite';
1484
+ if (coordEnabled) {
1485
+ const { initCoordinationTables } = await import('./coordination/schema.js');
1486
+ const { registerCoordinationTools } = await import('./coordination/mcp-tools.js');
1487
+ const sqliteStore = store as unknown as EngramStore;
1488
+ initCoordinationTables(sqliteStore.getDb());
1489
+ registerCoordinationTools(server, sqliteStore.getDb());
1490
+ coordDb = sqliteStore.getDb();
1491
+ } else if (coordRequested && BACKEND === 'pglite') {
1492
+ console.error('AWM: coordination requested but disabled — coordination plugin requires SQLite backend');
1493
+ } else {
1494
+ console.error('AWM: coordination tools disabled (set AWM_COORDINATION=true to enable)');
1495
+ }
1496
+
1497
+ // Log to stderr (stdout is reserved for MCP protocol)
1498
+ console.error(`AgentWorkingMemory MCP server started (agent: ${AGENT_ID}, db: ${DB_PATH})`);
1499
+ sidecarHandle = sidecar;
1500
+ console.error(`Hook sidecar preferred 127.0.0.1:${HOOK_PORT} (range ${HOOK_PORT_RANGE})${HOOK_SECRET ? ' (auth enabled)' : ' (no auth — set AWM_HOOK_SECRET)'}`);
1501
+
1502
+ // Clean shutdown
1503
+ const cleanup = async () => {
1504
+ sidecar.close();
1505
+ consolidationScheduler.stop();
1506
+ stagingBuffer.stop();
1507
+ if (BACKEND === 'sqlite') {
1508
+ try { (store as Partial<EngramStore>).walCheckpoint?.(); } catch { /* non-fatal */ }
1509
+ }
1510
+ try { await (store as any).close?.(); } catch { /* best-effort */ }
1511
+ };
1512
+ process.on('SIGINT', () => { void cleanup().finally(() => process.exit(0)); });
1513
+ process.on('SIGTERM', () => { void cleanup().finally(() => process.exit(0)); });
1514
+ }
1515
+
1516
+ main().catch(err => {
1517
+ console.error('MCP server failed:', err);
1518
+ process.exit(1);
1519
+ });
1520
+
1521
+ } // end else (non-incognito)