@try-works/dsh-memory 0.1.19 → 0.1.21

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 (55) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +294 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/scripts/inspect-log.ts +40 -0
  8. package/scripts/repair-ignorable.ts +130 -0
  9. package/scripts/scan-sessions.ts +43 -0
  10. package/scripts/verify-log.ts +26 -0
  11. package/src/SYSTEM.template.md +311 -311
  12. package/src/capture.js +242 -242
  13. package/src/client/index.js +886 -886
  14. package/src/config.js +57 -57
  15. package/src/consolidate-run.js +132 -132
  16. package/src/consolidate.js +330 -330
  17. package/src/context.js +88 -88
  18. package/src/decisions.js +106 -106
  19. package/src/defaults.js +93 -93
  20. package/src/digest.js +255 -255
  21. package/src/entry.js +179 -179
  22. package/src/eval.js +72 -72
  23. package/src/files.js +60 -60
  24. package/src/fixture.js +56 -56
  25. package/src/forget.js +71 -71
  26. package/src/freshness.js +65 -65
  27. package/src/fts.js +166 -166
  28. package/src/git.js +53 -53
  29. package/src/history.js +226 -226
  30. package/src/host.js +884 -866
  31. package/src/index.js +115 -94
  32. package/src/inject.js +108 -108
  33. package/src/layers.js +178 -178
  34. package/src/ledger.js +19 -19
  35. package/src/map.js +184 -184
  36. package/src/models.js +136 -136
  37. package/src/queue.js +602 -602
  38. package/src/rank.js +31 -31
  39. package/src/recover.js +91 -91
  40. package/src/refresh.js +80 -80
  41. package/src/remember.js +58 -58
  42. package/src/rerank.js +90 -90
  43. package/src/search.js +75 -75
  44. package/src/select.js +57 -57
  45. package/src/session.js +60 -60
  46. package/src/state.js +240 -240
  47. package/src/store.js +128 -128
  48. package/src/summarize.js +195 -195
  49. package/src/system-doc.js +171 -171
  50. package/src/tasks.js +617 -617
  51. package/src/telemetry.js +122 -122
  52. package/src/tools.js +828 -818
  53. package/src/working.js +288 -288
  54. package/src/workspace-key.js +68 -68
  55. package/src/workspace.js +87 -87
package/src/tools.js CHANGED
@@ -1,818 +1,828 @@
1
- // Agent tools: memory_search / remember / workspaces / associate / sync / forget.
2
- import { realpathSync } from 'node:fs'
3
- import { isAbsolute, resolve } from 'node:path'
4
- import { defineTool } from '@deepseek-ai/dsh-tools'
5
- import { search } from './search.js'
6
- import { remember } from './remember.js'
7
- import { summarizeState, writeStateSection } from './state.js'
8
- import { rerankHits } from './rerank.js'
9
- import { logRun } from './telemetry.js'
10
- import { isSubagentSession } from './session.js'
11
- import { appendActivity, clearActivity, readWorking, workingSummary, writeWorkingSection } from './working.js'
12
- import { associate, listWorkspaces } from './workspace.js'
13
- import { forget, forgetUndo } from './forget.js'
14
- import { buildMap } from './map.js'
15
- import { backupTasks, defaultSchedulePath, describeTask, listTasks, planRestore, readScheduleStore, TASK_STATES, writeTask } from './tasks.js'
16
-
17
- function renderHits(hits) {
18
- if (!hits || hits.length === 0) return [{ type: 'text', text: '(no memory matches)' }]
19
- return [{ type: 'text', text: hits
20
- .map((h) => h.file + ':' + h.line + (h.id ? ' [' + h.id + ']' : '') + ' ' + h.snippet)
21
- .join('\n') }]
22
- }
23
-
24
- function canonicalPath(pathArg, exec) {
25
- if (!pathArg) {
26
- const cwd = exec?.agent?.session?.header?.cwd
27
- return cwd ? realpathSync(cwd) : undefined
28
- }
29
- const base = exec?.agent?.session?.header?.cwd
30
- const abs = isAbsolute(pathArg) ? pathArg : (base ? resolve(base, pathArg) : resolve(pathArg))
31
- return realpathSync(abs)
32
- }
33
-
34
-
35
- /** The text face of a map snapshot: what a caller without a UI still needs to know. */
36
- function renderMap(value) {
37
- const parts = []
38
- const store = value.store
39
- if (store) {
40
- const layers = store.layers.filter((l) => l.count > 0).map((l) => l.name + ' ' + l.count).join(', ')
41
- parts.push(store.total + ' entries at ' + store.root)
42
- parts.push('layers: ' + layers)
43
- const f = store.freshness
44
- parts.push('freshness: ' + f.fresh + ' fresh' + (f.stale ? ', ' + f.stale + ' STALE' : '') + (f.skip ? ', ' + f.skip + ' superseded' : ''))
45
- const q = store.queue
46
- const attention = q.attention.length
47
- ? ', needs attention: ' + q.attention.slice(0, 3).map((a) => a.count + ' ' + a.state + ' (' + a.reason + ')').join(', ')
48
- : ''
49
- parts.push('queue: ' + q.waiting + ' waiting' + (q.recoverable ? ', ' + q.recoverable + ' recoverable' : '') + attention)
50
- }
51
- const session = value.session
52
- if (session) {
53
- const received = session.received
54
- const sections = received.sections.map((s) => s.name + (s.marker ? ' ' + s.marker : '')).join(', ')
55
- parts.push('this session received ' + received.chars + ' chars: ' + (sections || '(nothing injected)'))
56
- const written = session.written
57
- const byLayer = Object.keys(written.byLayer).map((k) => k + ' ' + written.byLayer[k]).join(', ')
58
- parts.push('and wrote ' + written.total + ' entr' + (written.total === 1 ? 'y' : 'ies') + (byLayer ? ' (' + byLayer + ')' : ''))
59
- }
60
- return parts.join('; ') + '.'
61
- }
62
- /** The text face of a tasks report: what a caller without a UI still needs to know. */
63
- function renderTasks(value) {
64
- const parts = []
65
- parts.push('Scheduled tasks: ' + value.live + ' live, ' + value.backedUp + ' backed up')
66
- const p = value.pending ?? {}
67
- if (p.add) parts.push(p.add + ' would be restored')
68
- if (p.present) parts.push(p.present + ' already present')
69
- if (p.paused) parts.push(p.paused + ' held back')
70
- if (p.refused) parts.push(p.refused + ' refused')
71
- if (value.changed) parts.push('this call: ' + JSON.stringify(value.changed))
72
- const states = {}
73
- for (const task of value.tasks ?? []) states[task.state] = (states[task.state] ?? 0) + 1
74
- const summary = Object.keys(states).map((s) => s + ' ' + states[s]).join(', ')
75
- if (summary) parts.push('states: ' + summary)
76
- if (value.unreadable && value.unreadable.length) parts.push(value.unreadable.length + ' file(s) unreadable: ' + value.unreadable.join(', '))
77
- if (value.error) parts.push('COULD NOT READ THE SCHEDULE STORE: ' + value.error)
78
- return parts.join('; ') + '.'
79
- }
80
-
81
- export function registerTools(ctx, store, extras = {}) {
82
- /**
83
- * Refuse a WRITE from a subagent session.
84
- *
85
- * Memory is the main agent's account of the work. A subagent reports back to its parent, and
86
- * the parent decides what was durable; letting the child write as well produces two partial
87
- * accounts of one task, with the child's - written without the parent's context - competing
88
- * with it in search. Reads are unrestricted: a subagent may consult memory freely.
89
- */
90
- const refuseSubagentWrite = (exec, tool) => {
91
- if (!isSubagentSession(exec?.agent?.session)) return
92
- throw new Error(tool + ': memory is written by the main agent only. A subagent should return its findings to the caller, which decides what is durable; do not record memory from here.')
93
- }
94
- const syncNow = extras.syncNow
95
- const consolidate = extras.consolidate
96
- const workspaceOf = extras.workspaceOf
97
- const historyOf = extras.historyOf
98
- const refreshNow = extras.refresh
99
- const rerankSearch = extras.rerankSearch
100
- const statusNow = extras.status
101
- const recoverNow = extras.recover
102
- const injectionMaxChars = extras.injectionMaxChars
103
- const tasksNow = extras.tasksNow
104
- const tasksRestore = extras.tasksRestore
105
- const schedulePathOf = extras.schedulePath
106
-
107
- ctx.tools.register(defineTool({
108
- name: 'memory_search',
109
- description: 'Search the dsh-memory store (global + workspace layers). Returns ranked facts with file:line citations.',
110
- parameters: {
111
- query: { type: 'string', required: true, description: 'Natural-language query or keywords.' },
112
- scope: { type: 'string', description: 'global, workspace, or all (default all).' },
113
- workspace: { type: 'string', description: 'Workspace key when scope is workspace.' },
114
- limit: { type: 'integer', description: 'Maximum hits (default 10).' },
115
- rerank: { type: 'boolean', description: 'Ask the session model to reorder the hits by usefulness (costs one call; off unless enabled in config).' },
116
- },
117
- output: {
118
- schema: {
119
- type: 'object', additionalProperties: false,
120
- properties: {
121
- query: { type: 'string', required: true },
122
- hits: {
123
- type: 'array', required: true,
124
- items: {
125
- type: 'object', additionalProperties: false,
126
- properties: {
127
- file: { type: 'string', required: true },
128
- line: { type: 'integer', required: true },
129
- section: { type: 'string' },
130
- snippet: { type: 'string', required: true },
131
- id: { type: 'string' },
132
- score: { type: 'number' },
133
- },
134
- },
135
- },
136
- },
137
- },
138
- render: (_args, value) => renderHits(value.hits),
139
- },
140
- async execute(args, exec) {
141
- // Fetch a few extra so reranking has something to promote from, then trim to the limit.
142
- const limit = args.limit ?? 10
143
- const hits = search(store, args.query, { scope: args.scope, key: args.workspace, limit })
144
- // Reranking is advisory: rerankSearch returns the original order on any failure, so a
145
- // model that is missing, slow or unparseable cannot break search.
146
- if (rerankSearch) {
147
- const outcome = await rerankSearch(exec.agent, args.query, hits, { enabled: args.rerank !== false })
148
- return { query: args.query, hits: outcome.hits.slice(0, limit) }
149
- }
150
- return { query: args.query, hits }
151
- },
152
- }))
153
-
154
- ctx.tools.register(defineTool({
155
- name: 'memory_remember',
156
- description: 'Write a durable fact into the dsh-memory store (global or a workspace layer). Use the codebase layer to map a module or subsystem of the current repository. Do NOT call this to fill a gap: if the turn produced nothing durable, writing nothing is the correct outcome.',
157
- parameters: {
158
- text: { type: 'string', required: true, description: 'The fact body. The Id/Source/Date/… header is added automatically.' },
159
- layer: { type: 'string', required: true, description: 'semantic, episodic, procedural, learning, or codebase.' },
160
- scope: { type: 'string', description: 'global or workspace. Omit it and a fact recorded inside a known repository is filed to THAT WORKSPACE, because a fact about one repo is not global knowledge. Pass global explicitly for something true across repositories.' },
161
- workspace: { type: 'string', description: 'Workspace key when scope is workspace. Defaults to the workspace of the current session.' },
162
- topic: { type: 'string', description: 'Optional heading; defaults to the first sentence.' },
163
- source: { type: 'string', description: 'Provenance; defaults to agent:<sessionId>.' },
164
- importance: { type: 'integer', description: '1-3 (default 2).' },
165
- volatility: { type: 'string', description: 'How fast this decays: stable (a language or license choice), normal (default), volatile (a port, a version in flight, anything with a date).' },
166
- until: { type: 'string', description: 'Optional ISO expiry (Valid-until).' },
167
- },
168
- output: {
169
- schema: {
170
- type: 'object', additionalProperties: false,
171
- properties: {
172
- id: { type: 'string', required: true },
173
- file: { type: 'string', required: true },
174
- layer: { type: 'string', required: true },
175
- scopeId: { type: 'string', required: true },
176
- },
177
- },
178
- render: (_args, value) => [{ type: 'text', text: 'Remembered ' + value.id + ' -> ' + value.file }],
179
- },
180
- async execute(args, exec) {
181
- refuseSubagentWrite(exec, 'memory_remember')
182
- // DEFAULTS TO THE SESSION'S WORKSPACE, not to global. Defaulting to global is how a
183
- // session's entire body of work about one repository - 25 entries in one observed case -
184
- // was filed as cross-repository knowledge, where it is both wrong and unfindable from the
185
- // workspace it belongs to. A fact recorded inside a repo is about that repo unless the
186
- // caller says otherwise, so the safe default is the narrow one and global is opt-in.
187
- let scope = args.scope
188
- let key = args.workspace
189
- // Resolve the session workspace ONCE: it fills in a missing key as well as a missing
190
- // scope, so 'workspace' with no key is answerable whenever the session is in a repo.
191
- const inferred = (workspaceOf && exec.agent) ? await workspaceOf(exec.agent) : undefined
192
- if (!scope) {
193
- if (inferred) { scope = 'workspace'; key = key ?? inferred }
194
- else scope = 'global'
195
- } else if (scope === 'workspace' && !key) {
196
- key = inferred
197
- }
198
- if (scope === 'workspace' && !key) {
199
- throw new Error('memory_remember: scope workspace needs a workspace key, and none could be inferred for this session. Pass workspace, or use scope global for cross-repository knowledge.')
200
- }
201
- const result = remember(store, {
202
- scope,
203
- key,
204
- layer: args.layer,
205
- text: args.text,
206
- topic: args.topic,
207
- source: args.source ?? (exec.agent ? 'agent:' + exec.agent.session.id : 'manual'),
208
- importance: args.importance ?? 2,
209
- until: args.until,
210
- volatility: args.volatility,
211
- })
212
- return { id: result.id, file: result.file, layer: result.layer, scopeId: result.scopeId }
213
- },
214
- }))
215
-
216
- ctx.tools.register(defineTool({
217
- name: 'memory_codebase',
218
- description: 'Record what a module, file or subsystem of the CURRENT repository does, as you discover it. Builds the workspace codebase map. Use the path (or a subsystem name) as the topic so re-discoveries update the same topic file.',
219
- parameters: {
220
- topic: { type: 'string', required: true, description: 'Module path or subsystem name, e.g. src/layers.js or the capture pipeline.' },
221
- text: { type: 'string', required: true, description: 'What it is and does, its entry points, and anything surprising.' },
222
- importance: { type: 'integer', description: '1-3 (default 2).' },
223
- },
224
- output: {
225
- schema: {
226
- type: 'object', additionalProperties: false,
227
- properties: {
228
- id: { type: 'string', required: true },
229
- file: { type: 'string', required: true },
230
- workspace: { type: 'string', required: true },
231
- },
232
- },
233
- render: (_args, value) => [{ type: 'text', text: 'Mapped ' + value.id + ' -> ' + value.file }],
234
- },
235
- async execute(args, exec) {
236
- refuseSubagentWrite(exec, 'memory_codebase')
237
- if (!workspaceOf) throw new Error('memory_codebase is unavailable in this context')
238
- const workspace = await workspaceOf(exec.agent)
239
- if (!workspace) throw new Error('no workspace for this session yet: the codebase map is per repository. Associate one first (memory_associate) or run from inside the repo.')
240
- const r = remember(store, {
241
- scope: 'workspace', key: workspace, layer: 'codebase',
242
- text: args.text, topic: args.topic, importance: args.importance ?? 2,
243
- source: 'agent:' + (exec.agent && exec.agent.session ? exec.agent.session.id : 'unknown'),
244
- })
245
- return { id: r.id, file: r.file, workspace }
246
- },
247
- }))
248
-
249
- ctx.tools.register(defineTool({
250
- name: 'memory_decision',
251
- description: 'Record a settled decision with its context, rationale, alternatives considered, and consequences. Use for dependency choices, conventions, architecture and other choices future work must respect.',
252
- parameters: {
253
- decision: { type: 'string', required: true, description: 'The decision statement, used as the topic heading.' },
254
- context: { type: 'string', description: 'The problem or context that required a choice.' },
255
- rationale: { type: 'string', required: true, description: 'Why this option won.' },
256
- alternatives: { type: 'array', description: 'Other options considered and rejected.' },
257
- consequences: { type: 'string', description: 'What this commits us to, or what follows from it.' },
258
- scope: { type: 'string', description: 'global (default) or workspace.' },
259
- workspace: { type: 'string', description: 'Workspace key when scope is workspace.' },
260
- importance: { type: 'integer', description: '1-3 (default 2).' },
261
- },
262
- output: {
263
- schema: {
264
- type: 'object', additionalProperties: false,
265
- properties: {
266
- id: { type: 'string', required: true },
267
- file: { type: 'string', required: true },
268
- scopeId: { type: 'string', required: true },
269
- },
270
- },
271
- render: (_args, value) => [{ type: 'text', text: 'Decision recorded ' + value.id + ' -> ' + value.file }],
272
- },
273
- async execute(args, exec) {
274
- refuseSubagentWrite(exec, 'memory_decision')
275
- const scope = args.scope ?? 'global'
276
- const workspace = args.workspace ?? (scope === 'workspace' && workspaceOf ? await workspaceOf(exec.agent) : undefined)
277
- if (scope === 'workspace' && !workspace) throw new Error('workspace decisions need an associated repository')
278
- const r = remember(store, {
279
- scope, key: workspace, layer: 'decision', topic: args.decision,
280
- text: args.rationale, decision: { context: args.context, rationale: args.rationale, alternatives: args.alternatives, consequences: args.consequences },
281
- importance: args.importance ?? 2,
282
- source: 'agent:' + (exec.agent && exec.agent.session ? exec.agent.session.id : 'unknown'),
283
- })
284
- return { id: r.id, file: r.file, scopeId: r.scopeId }
285
- },
286
- }))
287
-
288
- ctx.tools.register(defineTool({
289
- name: 'memory_working',
290
- description: 'Record what YOU are working on in this workspace: the task in hand, what you recently did, and what comes next. This is agent working memory, NOT repository state (use memory_state for what the repo is). Set a section (Task, Next steps, Plan, Blocked by) or append to the rolling activity log.',
291
- parameters: {
292
- section: { type: 'string', description: 'A current-state section to replace: Task, Next steps, Plan, Blocked by (or any short name). Omit when only logging activity.' },
293
- content: { type: 'string', description: 'The content of that section. Required with section.' },
294
- activity: { type: 'array', description: 'One or more short notes about what you just did, appended to the rolling log (newest last, oldest trimmed).' },
295
- clearActivity: { type: 'boolean', description: 'Clear the activity log, keeping the current sections.' },
296
- },
297
- output: {
298
- schema: {
299
- type: 'object', additionalProperties: false,
300
- properties: {
301
- workspace: { type: 'string', required: true },
302
- sections: { type: 'array', items: { type: 'string' } },
303
- activity: { type: 'integer' },
304
- added: { type: 'integer' },
305
- verdict: { type: 'string' },
306
- },
307
- },
308
- render: (_args, value) => [{ type: 'text', text: 'Working memory (' + value.workspace + '): ' + (value.sections || []).join(', ') + ' | ' + value.activity + ' activity note(s)' + (value.added ? ', +' + value.added : '') + ' | ' + value.verdict }],
309
- },
310
- async execute(args, exec) {
311
- refuseSubagentWrite(exec, 'memory_working')
312
- if (!workspaceOf) throw new Error('memory_working is unavailable in this context')
313
- const workspace = await workspaceOf(exec.agent)
314
- if (!workspace) throw new Error('no workspace for this session yet: working memory is per workspace')
315
- let added = 0
316
- if (args.clearActivity) clearActivity(store.root, 'workspace', workspace)
317
- if (args.section && args.content) writeWorkingSection(store.root, 'workspace', workspace, args.section, args.content)
318
- if (args.activity && args.activity.length) added = appendActivity(store.root, 'workspace', workspace, args.activity).added
319
- if (!args.section && !args.activity && !args.clearActivity) throw new Error('nothing to record: pass section+content, activity, or clearActivity')
320
- const summary = workingSummary(store.root, 'workspace', workspace)
321
- const working = readWorking(store.root, 'workspace', workspace)
322
- return {
323
- workspace, added,
324
- sections: working ? working.sections.map((s) => s.name) : [],
325
- activity: working ? working.activity.length : 0,
326
- verdict: summary.verdict,
327
- }
328
- },
329
- }))
330
-
331
-
332
- ctx.tools.register(defineTool({
333
- name: 'memory_tasks',
334
- description: 'Show and manage the backup of this machine\'s DSH scheduled tasks. The schedule store is the only copy of them: it is in no git repository, and the host DELETES a task when its owning session stops, so a task can vanish with nothing to observe. Memory keeps one file per task under global/tasks/ and restores them automatically into a session on boot. Actions: report (default), backup, restore, pause, resume.',
335
- parameters: {
336
- action: { type: 'string', description: 'report (default), backup, restore, pause or resume.' },
337
- title: { type: 'string', description: 'Task title, required for pause and resume.' },
338
- },
339
- output: {
340
- schema: {
341
- type: 'object', additionalProperties: false,
342
- properties: {
343
- action: { type: 'string', required: true },
344
- schedulePath: { type: 'string', required: true },
345
- live: { type: 'integer', required: true },
346
- backedUp: { type: 'integer', required: true },
347
- tasks: { type: 'array', items: { type: 'object', additionalProperties: true }, required: true },
348
- unreadable: { type: 'array', items: { type: 'string' } },
349
- changed: { type: 'object', additionalProperties: true },
350
- // What a restore would do, which is the question a reader of this report is asking:
351
- // how many tasks would be re-armed, how many are already there, how many are held.
352
- pending: { type: 'object', additionalProperties: true },
353
- error: { type: 'string' },
354
- },
355
- },
356
- render: (_args, value) => [{ type: 'text', text: renderTasks(value) }],
357
- // The structured face. Without presentationMeta a tool result reaches its session event with
358
- // no meta at all (core/tools/src/index.ts:1845-1859), which is the only channel a UI has.
359
- presentationMeta: (_args, value) => value,
360
- },
361
- async execute(args, exec) {
362
- const action = ['report', 'backup', 'restore', 'pause', 'resume'].includes(args.action) ? args.action : 'report'
363
- const schedulePath = schedulePathOf || defaultSchedulePath()
364
- let changed = null
365
- if (action === 'backup') {
366
- if (!tasksNow) throw new Error('memory_tasks: the backup is unavailable in this context')
367
- const r = backupTasks(store.root, { schedulePath })
368
- changed = { written: r.written, refreshed: r.refreshed, stopped: r.stopped, skipped: r.skipped.length, error: r.error }
369
- } else if (action === 'restore') {
370
- if (!tasksRestore) throw new Error('memory_tasks: the restore is unavailable in this context')
371
- // FORCED, because the once-per-process guard has almost certainly already been consumed by
372
- // the boot restore - the manual action exists to run it again on demand.
373
- // The outcome is REPORTED rather than assumed: this used to say { restored: true } whatever
374
- // happened, including when nothing did.
375
- changed = await tasksRestore(exec.agent, { force: true })
376
- } else if (action === 'pause' || action === 'resume') {
377
- if (!args.title) throw new Error('memory_tasks: ' + action + ' needs a title')
378
- const { tasks } = listTasks(store.root)
379
- const match = tasks.find((task) => String(task.record.title ?? '') === args.title)
380
- if (!match) throw new Error('memory_tasks: no backed-up task titled "' + args.title + '"')
381
- // Pausing writes the STATE only. It does not touch the live store: a held task is not
382
- // disarmed here, it is simply never re-armed by an automatic pass, which is the decision a
383
- // pause expresses. Resuming returns it to the queue, and the next pass derives it afresh.
384
- const nextState = action === 'pause' ? TASK_STATES.PAUSED : TASK_STATES.PENDING
385
- writeTask(store.root, match.record, nextState, match.backedUpAt ?? new Date().toISOString())
386
- changed = { title: args.title, state: nextState }
387
- }
388
- // The report is read AFTER the action, so what the caller sees is the state they produced.
389
- const live = readScheduleStore(schedulePath)
390
- const { tasks, unreadable } = listTasks(store.root)
391
- const plan = planRestore(store.root, { liveTasks: live.tasks })
392
- const rows = tasks.map((task) => {
393
- const described = describeTask(task, Date.now())
394
- return {
395
- title: String(task.record.title ?? ''),
396
- kind: String(task.record.kind ?? ''),
397
- state: described.state,
398
- reason: described.reason,
399
- file: task.file,
400
- }
401
- })
402
- return {
403
- action,
404
- schedulePath,
405
- live: live.tasks.length,
406
- backedUp: tasks.length,
407
- tasks: rows,
408
- unreadable,
409
- ...(changed ? { changed } : {}),
410
- ...(live.error ? { error: live.error } : {}),
411
- // Carried for the tool's own use, never shown: the planner's own view of what a restore
412
- // would do, which is the question a reader of this report is actually asking.
413
- pending: { add: plan.add.length, present: plan.present.length, paused: plan.paused.length, refused: plan.refused.length },
414
- }
415
- },
416
- }))
417
-
418
- ctx.tools.register(defineTool({
419
- name: 'memory_map',
420
- description: 'Show what memory holds, and what THIS session has done with it. scope "store" reports the whole store - entries per layer, freshness, workspaces, and the ingest queue. scope "session" reports what memory injected into this conversation and what this conversation wrote back. "all" returns both. Read-only, and the data behind the Memory view.',
421
- parameters: {
422
- scope: { type: 'string', description: 'Which view: "store" (what exists), "session" (what this conversation received and wrote), or "all" (default).' },
423
- },
424
- output: {
425
- schema: {
426
- type: 'object', additionalProperties: false,
427
- properties: {
428
- scope: { type: 'string', required: true },
429
- generatedAt: { type: 'string', required: true },
430
- store: { type: 'object', additionalProperties: true },
431
- session: { type: 'object', additionalProperties: true },
432
- },
433
- },
434
- // The text face, for the agent and for any caller without a UI.
435
- render: (_args, value) => [{ type: 'text', text: renderMap(value) }],
436
- // The STRUCTURED face, and the only reason the Memory tab and the inline card can draw
437
- // anything. A tool result reaches the session event with a `meta` field ONLY when the tool
438
- // declares presentationMeta (core/tools/src/index.ts:1845-1859), and that field is what
439
- // ui-chat forwards to a view (conversation-nodes/tool.ts:77: meta: match.event.data.meta).
440
- // Without this, memory_map rendered its snapshot to prose and threw the value away: the tab
441
- // showed 'the result carried no snapshot' while the same call displayed 2,197 entries in
442
- // the conversation. The rendered text is not a substitute - rebuilding panels by parsing it
443
- // would be a second source of truth.
444
- presentationMeta: (_args, value) => value,
445
- },
446
- async execute(args, exec) {
447
- const scope = args.scope === 'store' || args.scope === 'session' ? args.scope : 'all'
448
- // The session id is what attributes writes: every tool records source 'agent:<sessionId>'.
449
- const sessionId = exec?.agent?.session?.id
450
- let key
451
- if (workspaceOf && exec?.agent) {
452
- try { key = await workspaceOf(exec.agent) } catch { key = undefined }
453
- }
454
- // The goal and the char cap are read exactly as the injection reads them, so the
455
- // RECEIVED panel describes the card that was actually built.
456
- const goal = ctx.get('goals')?.get(exec?.agent)?.objective
457
- return buildMap(store, {
458
- scope,
459
- sessionId,
460
- memoryScope: key && key !== 'global' ? 'workspace' : 'global',
461
- key: key && key !== 'global' ? key : undefined,
462
- goal,
463
- maxChars: injectionMaxChars,
464
- })
465
- },
466
- }))
467
- ctx.tools.register(defineTool({
468
- name: 'memory_state',
469
- description: 'Record or refresh one section of the CURRENT repository state overview (what is running, the layout, what is pending, health). Replaces that section: state describes now, so a newer statement supersedes the old one. Each section carries a Verified timestamp and stale sections are reported as such.',
470
- parameters: {
471
- section: { type: 'string', required: true, description: 'Section name: Summary, Layout, Running, Pending, Health (or any short name).' },
472
- content: { type: 'string', required: true, description: 'The current state of that area. Be concrete and short; this is an overview.' },
473
- },
474
- output: {
475
- schema: {
476
- type: 'object', additionalProperties: false,
477
- properties: {
478
- section: { type: 'string', required: true },
479
- created: { type: 'boolean', required: true },
480
- workspace: { type: 'string', required: true },
481
- sections: { type: 'array', required: true, items: { type: 'string' } },
482
- unverified: { type: 'array', items: { type: 'string' } },
483
- },
484
- },
485
- render: (_args, value) => [{ type: 'text', text: (value.created ? 'Recorded ' : 'Updated ') + 'state section ' + value.section + ' for ' + value.workspace + '. Sections: ' + (value.sections || []).join(', ') + (value.unverified && value.unverified.length ? ' (still unverified: ' + value.unverified.join(', ') + ')' : '') }],
486
- },
487
- async execute(args, exec) {
488
- refuseSubagentWrite(exec, 'memory_state')
489
- if (!workspaceOf) throw new Error('memory_state is unavailable in this context')
490
- const workspace = await workspaceOf(exec.agent)
491
- if (!workspace) throw new Error('no workspace for this session yet: repository state is per workspace')
492
- const r = writeStateSection(store.root, workspace, args.section, args.content, {
493
- source: 'agent:' + (exec.agent && exec.agent.session ? exec.agent.session.id : 'unknown'),
494
- })
495
- const summary = summarizeState(store.root, workspace)
496
- return { section: r.section, created: r.created, workspace, sections: r.sections, unverified: summary.stale }
497
- },
498
- }))
499
-
500
- ctx.tools.register(defineTool({
501
- name: 'memory_recover',
502
- description: 'Rebuild memory candidates whose working material was pruned but whose source session survives. Called with no arguments it REPORTS what is recoverable and changes nothing. Recovering a large session asks for approval first.',
503
- parameters: {
504
- id: { type: 'string', description: 'One candidate to recover. Omit to see a report, or to recover a bounded number of the smallest.' },
505
- limit: { type: 'integer', description: 'Recover up to this many, smallest first. Each large one asks for approval.' },
506
- },
507
- output: {
508
- schema: {
509
- type: 'object', additionalProperties: false,
510
- properties: {
511
- mode: { type: 'string', required: true },
512
- total: { type: 'integer' },
513
- recoverable: { type: 'integer' },
514
- oversized: { type: 'integer' },
515
- missing: { type: 'integer' },
516
- thresholdBytes: { type: 'integer' },
517
- recovered: { type: 'array', items: { type: 'object', additionalProperties: true } },
518
- declined: { type: 'array', items: { type: 'object', additionalProperties: true } },
519
- },
520
- },
521
- render: (_args, value) => {
522
- if (value.mode === 'report') {
523
- return [{ type: 'text', text: 'memory_recover: ' + value.total + ' candidate(s) stopped with missing material. '
524
- + value.recoverable + ' recoverable now, ' + value.oversized + ' need approval (>= '
525
- + Math.round((value.thresholdBytes ?? 0) / 1048576) + 'MB), ' + value.missing + ' have no locatable session.' }]
526
- }
527
- const done = value.recovered ?? []
528
- const no = value.declined ?? []
529
- return [{ type: 'text', text: 'memory_recover: rebuilt ' + done.length + ' candidate(s)'
530
- + (no.length ? '; ' + no.length + ' declined or refused' : '') + '.' }]
531
- },
532
- },
533
- async execute(args, exec) {
534
- refuseSubagentWrite(exec, 'memory_recover')
535
- if (!recoverNow) throw new Error('memory_recover is unavailable in this context')
536
- const plan = recoverNow.plan()
537
- if (!args.id && !args.limit) {
538
- // `entries: undefined` is NOT lossless JSON - JSON has no undefined - so the harness
539
- // rejected the entire result with 'value is not lossless JSON'. The field is a per-id
540
- // list that the report deliberately withholds, so it must be OMITTED, not blanked.
541
- const { entries: _omitted, ...summary } = plan
542
- return { mode: 'report', ...summary }
543
- }
544
- return recoverNow.run(args, exec)
545
- },
546
- }))
547
-
548
- ctx.tools.register(defineTool({
549
- name: 'memory_status',
550
- description: 'Report the memory store state: where it lives, how many digests await consolidation, and whether the last git sync left a conflict. Use it to check memory health before trusting it.',
551
- parameters: {},
552
- output: {
553
- schema: {
554
- type: 'object', additionalProperties: false,
555
- properties: {
556
- root: { type: 'string', required: true },
557
- pendingDigests: { type: 'integer', required: true },
558
- conflicted: { type: 'boolean', required: true },
559
- lastSyncOk: { type: 'boolean' },
560
- modelCalls: { type: 'integer' },
561
- eventCounts: { type: 'object', additionalProperties: true },
562
- savedBy: { type: 'object', additionalProperties: true },
563
- queue: {
564
- type: 'object', additionalProperties: false,
565
- properties: {
566
- states: { type: 'object', additionalProperties: true, required: true },
567
- waiting: { type: 'integer', required: true },
568
- retrying: { type: 'integer', required: true },
569
- recoverable: { type: 'integer', required: true },
570
- parked: { type: 'object', additionalProperties: true },
571
- attention: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'One row per CLASS of problem, largest first, each with a sample of ids.' },
572
- },
573
- },
574
- },
575
- },
576
- render: (_args, value) => {
577
- const q = value.queue ?? {}
578
- const parts = ['Memory at ' + value.root + ': ' + value.pendingDigests + ' digest(s) pending']
579
- // What stopped is reported by NAME. A count would say something is wrong; the list says
580
- // what, which is the difference between a warning and something a reader can act on.
581
- if (q.recoverable) parts.push(q.recoverable + ' need recovery (memory_recover)')
582
- if (q.retrying) parts.push(q.retrying + ' retrying')
583
- if (q.parked && Object.keys(q.parked).length) parts.push('parked: ' + Object.entries(q.parked).map(([why, c]) => c + ' ' + why).join(', '))
584
- const attention = q.attention ?? []
585
- if (attention.length) {
586
- // One clause per CLASS of problem, largest first: '78 orphaned (digest-missing)' says
587
- // more than 78 identical lines ever could.
588
- const classes = attention.slice(0, 3).map((a) => a.count + ' ' + a.state + (a.reason ? ' (' + a.reason + ')' : ''))
589
- parts.push('needs attention: ' + classes.join(', ') + (attention.length > 3 ? ', +' + (attention.length - 3) + ' more kinds' : ''))
590
- }
591
- if (value.conflicted) parts.push('THE LAST SYNC CONFLICTED (see state/CONFLICT.md)')
592
- parts.push('model calls ' + (value.modelCalls || 0))
593
- return [{ type: 'text', text: parts.join('; ') + '.' }]
594
- },
595
- },
596
- async execute() {
597
- if (!statusNow) throw new Error('memory_status is unavailable in this context')
598
- const s = statusNow()
599
- return {
600
- root: s.root, pendingDigests: s.pendingDigests, conflicted: s.conflicted,
601
- ...(s.lastSync && typeof s.lastSync.ok === 'boolean' ? { lastSyncOk: s.lastSync.ok } : {}),
602
- modelCalls: s.metrics?.modelCalls ?? 0, eventCounts: s.metrics?.events ?? {}, savedBy: s.metrics?.savedBy ?? {},
603
- ...(s.queue ? { queue: s.queue } : {}),
604
- }
605
- },
606
- }))
607
-
608
- ctx.tools.register(defineTool({
609
- name: 'memory_refresh',
610
- description: 'Run the freshness pass now instead of waiting for the daily cadence: stale and time-bounded entries are re-examined and kept, updated, or superseded by the model.',
611
- parameters: {
612
- limit: { type: 'integer', description: 'Maximum entries to review (default 3).' },
613
- },
614
- output: {
615
- schema: {
616
- type: 'object', additionalProperties: false,
617
- properties: {
618
- refreshed: { type: 'integer', required: true },
619
- updated: { type: 'integer', required: true },
620
- superseded: { type: 'integer', required: true },
621
- },
622
- },
623
- render: (_args, value) => [{ type: 'text', text: value.refreshed === 0 ? 'Nothing needed refreshing.' : 'Reviewed ' + value.refreshed + ' entr(ies): ' + value.updated + ' updated, ' + value.superseded + ' superseded.' }],
624
- },
625
- async execute(args, exec) {
626
- refuseSubagentWrite(exec, 'memory_refresh')
627
- if (!refreshNow) throw new Error('memory_refresh is unavailable in this context')
628
- const r = await refreshNow(exec.agent, { limit: args.limit })
629
- return { refreshed: r.refreshed ?? 0, updated: r.updated ?? 0, superseded: r.superseded ?? 0 }
630
- },
631
- }))
632
-
633
- ctx.tools.register(defineTool({
634
- name: 'memory_history',
635
- description: 'Ingest this repository commit history into the workspace history layer (how the codebase changed over time). Incremental: only commits not yet recorded are added. Safe to call repeatedly.',
636
- parameters: {
637
- limit: { type: 'integer', description: 'Maximum new commits to record (default 40).' },
638
- },
639
- output: {
640
- schema: {
641
- type: 'object', additionalProperties: false,
642
- properties: {
643
- ok: { type: 'boolean', required: true },
644
- added: { type: 'integer', required: true },
645
- skipped: { type: 'integer' },
646
- head: { type: 'string' },
647
- reason: { type: 'string' },
648
- },
649
- },
650
- render: (_args, value) => [{ type: 'text', text: value.ok
651
- ? 'Recorded ' + value.added + ' commit(s) into the history layer' + (value.skipped ? ' (' + value.skipped + ' already known)' : '') + '.'
652
- : 'No history recorded: ' + (value.reason || 'unavailable') }],
653
- },
654
- async execute(args, exec) {
655
- refuseSubagentWrite(exec, 'memory_history')
656
- if (!historyOf) throw new Error('memory_history is unavailable in this context')
657
- const r = await historyOf(exec.agent, { limit: args.limit })
658
- // A manual ingest must leave the same trace as the cadence, or the history layer grows
659
- // with nothing anywhere explaining where its files came from.
660
- logRun(store.root, 'history-ingest', { source: 'tool', count: (r && r.added) || 0, detail: { ok: Boolean(r && r.ok), reason: (r && r.reason) || '' } })
661
- return {
662
- ok: Boolean(r && r.ok), added: (r && r.added) || 0,
663
- ...(r && r.skipped ? { skipped: r.skipped } : {}),
664
- ...(r && r.head ? { head: String(r.head).slice(0, 12) } : {}),
665
- ...(r && r.reason ? { reason: String(r.reason) } : {}),
666
- }
667
- },
668
- }))
669
-
670
- ctx.tools.register(defineTool({
671
- name: 'memory_workspaces',
672
- description: 'List the workspaces (local folders/repos) associated with the dsh-memory store.',
673
- parameters: {},
674
- output: {
675
- schema: {
676
- type: 'object', additionalProperties: false,
677
- properties: {
678
- workspaces: {
679
- type: 'array', required: true,
680
- items: {
681
- type: 'object', additionalProperties: false,
682
- properties: {
683
- key: { type: 'string', required: true },
684
- title: { type: 'string', required: true },
685
- remotes: { type: 'array', required: true, items: { type: 'string' } },
686
- path: { type: 'string' },
687
- },
688
- },
689
- },
690
- },
691
- },
692
- render: (_args, value) => [{
693
- type: 'text',
694
- text: value.workspaces.length
695
- ? value.workspaces.map((w) => w.key + ' (' + w.title + ')' + (w.path ? ' -> ' + w.path : '')).join('\n')
696
- : '(no workspaces associated)',
697
- }],
698
- },
699
- async execute() {
700
- const workspaces = listWorkspaces(store).map((w) => ({ key: w.key, title: w.title, remotes: w.remotes, path: w.path }))
701
- return { workspaces }
702
- },
703
- }))
704
-
705
- ctx.tools.register(defineTool({
706
- name: 'memory_associate',
707
- description: 'Associate a local folder/repo with a workspace key (creates the workspace layer).',
708
- parameters: {
709
- key: { type: 'string', required: true, description: 'Workspace key (filesystem-safe slug).' },
710
- title: { type: 'string', description: 'Optional display title.' },
711
- path: { type: 'string', description: 'Local folder; defaults to the current session working directory.' },
712
- },
713
- output: {
714
- schema: {
715
- type: 'object', additionalProperties: false,
716
- properties: { key: { type: 'string', required: true } },
717
- },
718
- render: (_args, value) => [{ type: 'text', text: 'Associated workspace ' + value.key }],
719
- },
720
- async execute(args, exec) {
721
- refuseSubagentWrite(exec, 'memory_associate')
722
- let cwd
723
- try { cwd = canonicalPath(args.path, exec) } catch { cwd = undefined }
724
- const key = associate(store, { key: args.key, title: args.title, cwd })
725
- return { key }
726
- },
727
- }))
728
-
729
- ctx.tools.register(defineTool({
730
- name: 'memory_sync',
731
- description: 'Commit local memory changes and sync (pull + push) with the configured GitHub origin.',
732
- parameters: {},
733
- output: {
734
- schema: {
735
- type: 'object', additionalProperties: false,
736
- properties: {
737
- synced: { type: 'boolean', required: true },
738
- conflict: { type: 'boolean' },
739
- error: { type: 'string' },
740
- },
741
- },
742
- render: (_args, value) => [{ type: 'text', text: value.conflict
743
- ? 'Memory sync needs attention: ' + (value.error || 'git reported a failure') + ' (see state/CONFLICT.md)'
744
- : (value.synced ? 'Memory synced.' : 'Memory sync unavailable (no git remote configured).') }],
745
- },
746
- async execute(_args, exec) {
747
- refuseSubagentWrite(exec, 'memory_sync')
748
- if (!syncNow) return { synced: false }
749
- const status = await syncNow()
750
- return {
751
- synced: Boolean(status && (status.synced || status.committed || status.initialized)),
752
- ...(status && status.conflict ? { conflict: true } : {}),
753
- ...(status && status.error ? { error: String(status.error) } : {}),
754
- }
755
- },
756
- }))
757
-
758
- ctx.tools.register(defineTool({
759
- name: 'memory_forget',
760
- description: 'Forget a memory entry (supersede it and suppress re-adding), or release it with undo.',
761
- parameters: {
762
- id: { type: 'string', required: true, description: 'Entry id (e.g. global-sem-0001).' },
763
- undo: { type: 'boolean', description: 'Set true to release a previously forgotten entry.' },
764
- },
765
- output: {
766
- schema: {
767
- type: 'object', additionalProperties: false,
768
- properties: { id: { type: 'string', required: true }, undone: { type: 'boolean' }, hashPrefix: { type: 'string' } },
769
- },
770
- render: (_args, value) => [{ type: 'text', text: value.undone ? 'Released ' + value.id : 'Forgot ' + value.id }],
771
- },
772
- async execute(args, exec) {
773
- refuseSubagentWrite(exec, 'memory_forget')
774
- if (args.undo) return { id: args.id, undone: forgetUndo(store, args.id) }
775
- const r = forget(store, args.id)
776
- return { id: r.id, undone: false, hashPrefix: r.hash.slice(0, 12) }
777
- },
778
- }))
779
-
780
- ctx.tools.register(defineTool({
781
- name: 'memory_consolidate',
782
- description: 'Consolidate pending captured session digests into durable memory entries (LLM pass). Forces memory to update now instead of waiting for the automatic cadence.',
783
- parameters: {
784
- limit: { type: 'integer', description: 'Maximum digests to consolidate in this pass (default 3).' },
785
- },
786
- output: {
787
- schema: {
788
- type: 'object', additionalProperties: false,
789
- properties: {
790
- consolidated: { type: 'integer', required: true },
791
- written: { type: 'integer', required: true },
792
- pending: { type: 'integer', required: true },
793
- parseFailed: { type: 'boolean' },
794
- writeFailed: { type: 'integer' },
795
- writeErrors: { type: 'string' },
796
- },
797
- },
798
- render: (_args, value) => [{
799
- type: 'text',
800
- text: value.parseFailed
801
- ? 'The model reply could not be read; nothing was written (see logs/). ' + value.pending + ' digest(s) still pending.'
802
- : value.writeFailed
803
- ? 'Consolidated ' + value.consolidated + ' digest(s), but ' + value.writeFailed + ' entr(ies) failed to write: ' + (value.writeErrors || 'unknown error')
804
- : 'Consolidated ' + value.consolidated + ' digest(s) into ' + value.written + ' entr(ies); ' + value.pending + ' still pending.',
805
- }],
806
- },
807
- async execute(args, exec) {
808
- refuseSubagentWrite(exec, 'memory_consolidate')
809
- if (!consolidate) throw new Error('memory_consolidate is unavailable in this context')
810
- const r = await consolidate(exec.agent, { batchSize: args.limit })
811
- return {
812
- consolidated: r.consolidated, written: r.written, pending: r.pending,
813
- ...(r.parseFailed ? { parseFailed: true } : {}),
814
- ...(r.writeFailed ? { writeFailed: r.writeFailed, writeErrors: String(r.writeErrors ?? '').slice(0, 300) } : {}),
815
- }
816
- },
817
- }))
818
- }
1
+ // Agent tools: memory_search / remember / workspaces / associate / sync / forget.
2
+ import { realpathSync } from 'node:fs'
3
+ import { isAbsolute, resolve } from 'node:path'
4
+ import { defineTool } from '@deepseek-ai/dsh-tools'
5
+ import { search } from './search.js'
6
+ import { remember } from './remember.js'
7
+ import { summarizeState, writeStateSection } from './state.js'
8
+ import { rerankHits } from './rerank.js'
9
+ import { logRun } from './telemetry.js'
10
+ import { isSubagentSession } from './session.js'
11
+ import { appendActivity, clearActivity, readWorking, workingSummary, writeWorkingSection } from './working.js'
12
+ import { associate, listWorkspaces } from './workspace.js'
13
+ import { forget, forgetUndo } from './forget.js'
14
+ import { buildMap } from './map.js'
15
+ import { backupTasks, defaultSchedulePath, describeTask, listTasks, planRestore, readScheduleStore, TASK_STATES, writeTask } from './tasks.js'
16
+
17
+ function renderHits(hits) {
18
+ if (!hits || hits.length === 0) return [{ type: 'text', text: '(no memory matches)' }]
19
+ return [{ type: 'text', text: hits
20
+ .map((h) => h.file + ':' + h.line + (h.id ? ' [' + h.id + ']' : '') + ' ' + h.snippet)
21
+ .join('\n') }]
22
+ }
23
+
24
+ function canonicalPath(pathArg, exec) {
25
+ if (!pathArg) {
26
+ const cwd = exec?.agent?.session?.header?.cwd
27
+ return cwd ? realpathSync(cwd) : undefined
28
+ }
29
+ const base = exec?.agent?.session?.header?.cwd
30
+ const abs = isAbsolute(pathArg) ? pathArg : (base ? resolve(base, pathArg) : resolve(pathArg))
31
+ return realpathSync(abs)
32
+ }
33
+
34
+
35
+ /** The text face of a map snapshot: what a caller without a UI still needs to know. */
36
+ function renderMap(value) {
37
+ const parts = []
38
+ const store = value.store
39
+ if (store) {
40
+ const layers = store.layers.filter((l) => l.count > 0).map((l) => l.name + ' ' + l.count).join(', ')
41
+ parts.push(store.total + ' entries at ' + store.root)
42
+ parts.push('layers: ' + layers)
43
+ const f = store.freshness
44
+ parts.push('freshness: ' + f.fresh + ' fresh' + (f.stale ? ', ' + f.stale + ' STALE' : '') + (f.skip ? ', ' + f.skip + ' superseded' : ''))
45
+ const q = store.queue
46
+ const attention = q.attention.length
47
+ ? ', needs attention: ' + q.attention.slice(0, 3).map((a) => a.count + ' ' + a.state + ' (' + a.reason + ')').join(', ')
48
+ : ''
49
+ parts.push('queue: ' + q.waiting + ' waiting' + (q.recoverable ? ', ' + q.recoverable + ' recoverable' : '') + attention)
50
+ }
51
+ const session = value.session
52
+ if (session) {
53
+ const received = session.received
54
+ const sections = received.sections.map((s) => s.name + (s.marker ? ' ' + s.marker : '')).join(', ')
55
+ parts.push('this session received ' + received.chars + ' chars: ' + (sections || '(nothing injected)'))
56
+ const written = session.written
57
+ const byLayer = Object.keys(written.byLayer).map((k) => k + ' ' + written.byLayer[k]).join(', ')
58
+ parts.push('and wrote ' + written.total + ' entr' + (written.total === 1 ? 'y' : 'ies') + (byLayer ? ' (' + byLayer + ')' : ''))
59
+ }
60
+ return parts.join('; ') + '.'
61
+ }
62
+ /** The text face of a tasks report: what a caller without a UI still needs to know. */
63
+ function renderTasks(value) {
64
+ const parts = []
65
+ parts.push('Scheduled tasks: ' + value.live + ' live, ' + value.backedUp + ' backed up')
66
+ const p = value.pending ?? {}
67
+ if (p.add) parts.push(p.add + ' would be restored')
68
+ if (p.present) parts.push(p.present + ' already present')
69
+ if (p.paused) parts.push(p.paused + ' held back')
70
+ if (p.refused) parts.push(p.refused + ' refused')
71
+ if (value.changed) parts.push('this call: ' + JSON.stringify(value.changed))
72
+ const states = {}
73
+ for (const task of value.tasks ?? []) states[task.state] = (states[task.state] ?? 0) + 1
74
+ const summary = Object.keys(states).map((s) => s + ' ' + states[s]).join(', ')
75
+ if (summary) parts.push('states: ' + summary)
76
+ if (value.unreadable && value.unreadable.length) parts.push(value.unreadable.length + ' file(s) unreadable: ' + value.unreadable.join(', '))
77
+ if (value.error) parts.push('COULD NOT READ THE SCHEDULE STORE: ' + value.error)
78
+ return parts.join('; ') + '.'
79
+ }
80
+
81
+ export function registerTools(ctx, store, extras = {}) {
82
+ /**
83
+ * Refuse a WRITE from a subagent session.
84
+ *
85
+ * Memory is the main agent's account of the work. A subagent reports back to its parent, and
86
+ * the parent decides what was durable; letting the child write as well produces two partial
87
+ * accounts of one task, with the child's - written without the parent's context - competing
88
+ * with it in search. Reads are unrestricted: a subagent may consult memory freely.
89
+ */
90
+ const refuseSubagentWrite = (exec, tool) => {
91
+ if (!isSubagentSession(exec?.agent?.session)) return
92
+ throw new Error(tool + ': memory is written by the main agent only. A subagent should return its findings to the caller, which decides what is durable; do not record memory from here.')
93
+ }
94
+ const syncNow = extras.syncNow
95
+ const consolidate = extras.consolidate
96
+ const workspaceOf = extras.workspaceOf
97
+ const historyOf = extras.historyOf
98
+ const refreshNow = extras.refresh
99
+ const rerankSearch = extras.rerankSearch
100
+ const statusNow = extras.status
101
+ const recoverNow = extras.recover
102
+ const injectionMaxChars = extras.injectionMaxChars
103
+ const tasksNow = extras.tasksNow
104
+ const tasksRestore = extras.tasksRestore
105
+ const schedulePathOf = extras.schedulePath
106
+
107
+ ctx.tools.register(defineTool({
108
+ name: 'memory_search',
109
+ description: 'Search the dsh-memory store (global + workspace layers). Returns ranked facts with file:line citations.',
110
+ parameters: {
111
+ query: { type: 'string', required: true, description: 'Natural-language query or keywords.' },
112
+ scope: { type: 'string', description: 'global, workspace, or all (default all).' },
113
+ workspace: { type: 'string', description: 'Workspace key when scope is workspace.' },
114
+ limit: { type: 'integer', description: 'Maximum hits (default 10).' },
115
+ rerank: { type: 'boolean', description: 'Ask the session model to reorder the hits by usefulness (costs one call; off unless enabled in config).' },
116
+ },
117
+ output: {
118
+ schema: {
119
+ type: 'object', additionalProperties: false,
120
+ properties: {
121
+ query: { type: 'string', required: true },
122
+ hits: {
123
+ type: 'array', required: true,
124
+ items: {
125
+ type: 'object', additionalProperties: false,
126
+ properties: {
127
+ file: { type: 'string', required: true },
128
+ line: { type: 'integer', required: true },
129
+ section: { type: 'string' },
130
+ snippet: { type: 'string', required: true },
131
+ id: { type: 'string' },
132
+ score: { type: 'number' },
133
+ },
134
+ },
135
+ },
136
+ },
137
+ },
138
+ render: (_args, value) => renderHits(value.hits),
139
+ },
140
+ async execute(args, exec) {
141
+ // Fetch a few extra so reranking has something to promote from, then trim to the limit.
142
+ const limit = args.limit ?? 10
143
+ const hits = search(store, args.query, { scope: args.scope, key: args.workspace, limit })
144
+ // Reranking is advisory: rerankSearch returns the original order on any failure, so a
145
+ // model that is missing, slow or unparseable cannot break search.
146
+ if (rerankSearch) {
147
+ const outcome = await rerankSearch(exec.agent, args.query, hits, { enabled: args.rerank !== false })
148
+ return { query: args.query, hits: outcome.hits.slice(0, limit) }
149
+ }
150
+ return { query: args.query, hits }
151
+ },
152
+ }))
153
+
154
+ ctx.tools.register(defineTool({
155
+ name: 'memory_remember',
156
+ description: 'Write a durable fact into the dsh-memory store (global or a workspace layer). Use the codebase layer to map a module or subsystem of the current repository. Do NOT call this to fill a gap: if the turn produced nothing durable, writing nothing is the correct outcome.',
157
+ parameters: {
158
+ text: { type: 'string', required: true, description: 'The fact body. The Id/Source/Date/… header is added automatically.' },
159
+ layer: { type: 'string', required: true, description: 'semantic, episodic, procedural, learning, or codebase.' },
160
+ scope: { type: 'string', description: 'global or workspace. Omit it and a fact recorded inside a known repository is filed to THAT WORKSPACE, because a fact about one repo is not global knowledge. Pass global explicitly for something true across repositories.' },
161
+ workspace: { type: 'string', description: 'Workspace key when scope is workspace. Defaults to the workspace of the current session.' },
162
+ topic: { type: 'string', description: 'Optional heading; defaults to the first sentence.' },
163
+ source: { type: 'string', description: 'Provenance; defaults to agent:<sessionId>.' },
164
+ importance: { type: 'integer', description: '1-3 (default 2).' },
165
+ volatility: { type: 'string', description: 'How fast this decays: stable (a language or license choice), normal (default), volatile (a port, a version in flight, anything with a date).' },
166
+ until: { type: 'string', description: 'Optional ISO expiry (Valid-until).' },
167
+ },
168
+ output: {
169
+ schema: {
170
+ type: 'object', additionalProperties: false,
171
+ properties: {
172
+ id: { type: 'string', required: true },
173
+ file: { type: 'string', required: true },
174
+ layer: { type: 'string', required: true },
175
+ scopeId: { type: 'string', required: true },
176
+ },
177
+ },
178
+ render: (_args, value) => [{ type: 'text', text: 'Remembered ' + value.id + ' -> ' + value.file }],
179
+ },
180
+ async execute(args, exec) {
181
+ refuseSubagentWrite(exec, 'memory_remember')
182
+ // DEFAULTS TO THE SESSION'S WORKSPACE, not to global. Defaulting to global is how a
183
+ // session's entire body of work about one repository - 25 entries in one observed case -
184
+ // was filed as cross-repository knowledge, where it is both wrong and unfindable from the
185
+ // workspace it belongs to. A fact recorded inside a repo is about that repo unless the
186
+ // caller says otherwise, so the safe default is the narrow one and global is opt-in.
187
+ let scope = args.scope
188
+ let key = args.workspace
189
+ // Resolve the session workspace ONCE: it fills in a missing key as well as a missing
190
+ // scope, so 'workspace' with no key is answerable whenever the session is in a repo.
191
+ const inferred = (workspaceOf && exec.agent) ? await workspaceOf(exec.agent) : undefined
192
+ if (!scope) {
193
+ if (inferred) { scope = 'workspace'; key = key ?? inferred }
194
+ else scope = 'global'
195
+ } else if (scope === 'workspace' && !key) {
196
+ key = inferred
197
+ }
198
+ if (scope === 'workspace' && !key) {
199
+ throw new Error('memory_remember: scope workspace needs a workspace key, and none could be inferred for this session. Pass workspace, or use scope global for cross-repository knowledge.')
200
+ }
201
+ const result = remember(store, {
202
+ scope,
203
+ key,
204
+ layer: args.layer,
205
+ text: args.text,
206
+ topic: args.topic,
207
+ source: args.source ?? (exec.agent ? 'agent:' + exec.agent.session.id : 'manual'),
208
+ importance: args.importance ?? 2,
209
+ until: args.until,
210
+ volatility: args.volatility,
211
+ })
212
+ return { id: result.id, file: result.file, layer: result.layer, scopeId: result.scopeId }
213
+ },
214
+ }))
215
+
216
+ ctx.tools.register(defineTool({
217
+ name: 'memory_codebase',
218
+ description: 'Record what a module, file or subsystem of the CURRENT repository does, as you discover it. Builds the workspace codebase map. Use the path (or a subsystem name) as the topic so re-discoveries update the same topic file.',
219
+ parameters: {
220
+ topic: { type: 'string', required: true, description: 'Module path or subsystem name, e.g. src/layers.js or the capture pipeline.' },
221
+ text: { type: 'string', required: true, description: 'What it is and does, its entry points, and anything surprising.' },
222
+ importance: { type: 'integer', description: '1-3 (default 2).' },
223
+ },
224
+ output: {
225
+ schema: {
226
+ type: 'object', additionalProperties: false,
227
+ properties: {
228
+ id: { type: 'string', required: true },
229
+ file: { type: 'string', required: true },
230
+ workspace: { type: 'string', required: true },
231
+ },
232
+ },
233
+ render: (_args, value) => [{ type: 'text', text: 'Mapped ' + value.id + ' -> ' + value.file }],
234
+ },
235
+ async execute(args, exec) {
236
+ refuseSubagentWrite(exec, 'memory_codebase')
237
+ if (!workspaceOf) throw new Error('memory_codebase is unavailable in this context')
238
+ const workspace = await workspaceOf(exec.agent)
239
+ if (!workspace) throw new Error('no workspace for this session yet: the codebase map is per repository. Associate one first (memory_associate) or run from inside the repo.')
240
+ const r = remember(store, {
241
+ scope: 'workspace', key: workspace, layer: 'codebase',
242
+ text: args.text, topic: args.topic, importance: args.importance ?? 2,
243
+ source: 'agent:' + (exec.agent && exec.agent.session ? exec.agent.session.id : 'unknown'),
244
+ })
245
+ return { id: r.id, file: r.file, workspace }
246
+ },
247
+ }))
248
+
249
+ ctx.tools.register(defineTool({
250
+ name: 'memory_decision',
251
+ description: 'Record a settled decision with its context, rationale, alternatives considered, and consequences. Use for dependency choices, conventions, architecture and other choices future work must respect.',
252
+ parameters: {
253
+ decision: { type: 'string', required: true, description: 'The decision statement, used as the topic heading.' },
254
+ context: { type: 'string', description: 'The problem or context that required a choice.' },
255
+ rationale: { type: 'string', required: true, description: 'Why this option won.' },
256
+ alternatives: { type: 'array', description: 'Other options considered and rejected.' },
257
+ consequences: { type: 'string', description: 'What this commits us to, or what follows from it.' },
258
+ scope: { type: 'string', description: 'global (default) or workspace.' },
259
+ workspace: { type: 'string', description: 'Workspace key when scope is workspace.' },
260
+ importance: { type: 'integer', description: '1-3 (default 2).' },
261
+ },
262
+ output: {
263
+ schema: {
264
+ type: 'object', additionalProperties: false,
265
+ properties: {
266
+ id: { type: 'string', required: true },
267
+ file: { type: 'string', required: true },
268
+ scopeId: { type: 'string', required: true },
269
+ },
270
+ },
271
+ render: (_args, value) => [{ type: 'text', text: 'Decision recorded ' + value.id + ' -> ' + value.file }],
272
+ },
273
+ async execute(args, exec) {
274
+ refuseSubagentWrite(exec, 'memory_decision')
275
+ const scope = args.scope ?? 'global'
276
+ const workspace = args.workspace ?? (scope === 'workspace' && workspaceOf ? await workspaceOf(exec.agent) : undefined)
277
+ if (scope === 'workspace' && !workspace) throw new Error('workspace decisions need an associated repository')
278
+ const r = remember(store, {
279
+ scope, key: workspace, layer: 'decision', topic: args.decision,
280
+ text: args.rationale, decision: { context: args.context, rationale: args.rationale, alternatives: args.alternatives, consequences: args.consequences },
281
+ importance: args.importance ?? 2,
282
+ source: 'agent:' + (exec.agent && exec.agent.session ? exec.agent.session.id : 'unknown'),
283
+ })
284
+ return { id: r.id, file: r.file, scopeId: r.scopeId }
285
+ },
286
+ }))
287
+
288
+ ctx.tools.register(defineTool({
289
+ name: 'memory_working',
290
+ description: 'Record what YOU are working on in this workspace: the task in hand, what you recently did, and what comes next. This is agent working memory, NOT repository state (use memory_state for what the repo is). Set a section (Task, Next steps, Plan, Blocked by) or append to the rolling activity log.',
291
+ parameters: {
292
+ section: { type: 'string', description: 'A current-state section to replace: Task, Next steps, Plan, Blocked by (or any short name). Omit when only logging activity.' },
293
+ content: { type: 'string', description: 'The content of that section. Required with section.' },
294
+ activity: { type: 'array', description: 'One or more short notes about what you just did, appended to the rolling log (newest last, oldest trimmed).' },
295
+ clearActivity: { type: 'boolean', description: 'Clear the activity log, keeping the current sections.' },
296
+ },
297
+ output: {
298
+ schema: {
299
+ type: 'object', additionalProperties: false,
300
+ properties: {
301
+ workspace: { type: 'string', required: true },
302
+ sections: { type: 'array', items: { type: 'string' } },
303
+ activity: { type: 'integer' },
304
+ added: { type: 'integer' },
305
+ verdict: { type: 'string' },
306
+ },
307
+ },
308
+ render: (_args, value) => [{ type: 'text', text: 'Working memory (' + value.workspace + '): ' + (value.sections || []).join(', ') + ' | ' + value.activity + ' activity note(s)' + (value.added ? ', +' + value.added : '') + ' | ' + value.verdict }],
309
+ },
310
+ async execute(args, exec) {
311
+ refuseSubagentWrite(exec, 'memory_working')
312
+ if (!workspaceOf) throw new Error('memory_working is unavailable in this context')
313
+ const workspace = await workspaceOf(exec.agent)
314
+ if (!workspace) throw new Error('no workspace for this session yet: working memory is per workspace')
315
+ let added = 0
316
+ if (args.clearActivity) clearActivity(store.root, 'workspace', workspace)
317
+ if (args.section && args.content) writeWorkingSection(store.root, 'workspace', workspace, args.section, args.content)
318
+ if (args.activity && args.activity.length) added = appendActivity(store.root, 'workspace', workspace, args.activity).added
319
+ if (!args.section && !args.activity && !args.clearActivity) throw new Error('nothing to record: pass section+content, activity, or clearActivity')
320
+ const summary = workingSummary(store.root, 'workspace', workspace)
321
+ const working = readWorking(store.root, 'workspace', workspace)
322
+ return {
323
+ workspace, added,
324
+ sections: working ? working.sections.map((s) => s.name) : [],
325
+ activity: working ? working.activity.length : 0,
326
+ verdict: summary.verdict,
327
+ }
328
+ },
329
+ }))
330
+
331
+
332
+ ctx.tools.register(defineTool({
333
+ name: 'memory_tasks',
334
+ description: 'Show and manage the backup of this machine\'s DSH scheduled tasks. The schedule store is the only copy of them: it is in no git repository, and the host DELETES a task when its owning session stops, so a task can vanish with nothing to observe. Memory keeps one file per task under global/tasks/ and restores them automatically into a session on boot. Actions: report (default), backup, restore, pause, resume.',
335
+ parameters: {
336
+ action: { type: 'string', description: 'report (default), backup, restore, pause or resume.' },
337
+ title: { type: 'string', description: 'Task title, required for pause and resume.' },
338
+ },
339
+ output: {
340
+ schema: {
341
+ type: 'object', additionalProperties: false,
342
+ properties: {
343
+ action: { type: 'string', required: true },
344
+ schedulePath: { type: 'string', required: true },
345
+ live: { type: 'integer', required: true },
346
+ backedUp: { type: 'integer', required: true },
347
+ tasks: { type: 'array', items: { type: 'object', additionalProperties: true }, required: true },
348
+ unreadable: { type: 'array', items: { type: 'string' } },
349
+ changed: { type: 'object', additionalProperties: true },
350
+ // What a restore would do, which is the question a reader of this report is asking:
351
+ // how many tasks would be re-armed, how many are already there, how many are held.
352
+ pending: { type: 'object', additionalProperties: true },
353
+ error: { type: 'string' },
354
+ },
355
+ },
356
+ render: (_args, value) => [{ type: 'text', text: renderTasks(value) }],
357
+ // The structured face. Without presentationMeta a tool result reaches its session event with
358
+ // no meta at all (core/tools/src/index.ts:1845-1859), which is the only channel a UI has.
359
+ presentationMeta: (_args, value) => value,
360
+ },
361
+ async execute(args, exec) {
362
+ const action = ['report', 'backup', 'restore', 'pause', 'resume'].includes(args.action) ? args.action : 'report'
363
+ const schedulePath = schedulePathOf || defaultSchedulePath()
364
+ let changed = null
365
+ if (action === 'backup') {
366
+ if (!tasksNow) throw new Error('memory_tasks: the backup is unavailable in this context')
367
+ const r = backupTasks(store.root, { schedulePath })
368
+ changed = { written: r.written, refreshed: r.refreshed, stopped: r.stopped, skipped: r.skipped.length, error: r.error }
369
+ } else if (action === 'restore') {
370
+ if (!tasksRestore) throw new Error('memory_tasks: the restore is unavailable in this context')
371
+ // FORCED, because the once-per-process guard has almost certainly already been consumed by
372
+ // the boot restore - the manual action exists to run it again on demand.
373
+ // The outcome is REPORTED rather than assumed: this used to say { restored: true } whatever
374
+ // happened, including when nothing did.
375
+ changed = await tasksRestore(exec.agent, { force: true })
376
+ } else if (action === 'pause' || action === 'resume') {
377
+ if (!args.title) throw new Error('memory_tasks: ' + action + ' needs a title')
378
+ const { tasks } = listTasks(store.root)
379
+ const match = tasks.find((task) => String(task.record.title ?? '') === args.title)
380
+ if (!match) throw new Error('memory_tasks: no backed-up task titled "' + args.title + '"')
381
+ // Pausing writes the STATE only. It does not touch the live store: a held task is not
382
+ // disarmed here, it is simply never re-armed by an automatic pass, which is the decision a
383
+ // pause expresses. Resuming returns it to the queue, and the next pass derives it afresh.
384
+ const nextState = action === 'pause' ? TASK_STATES.PAUSED : TASK_STATES.PENDING
385
+ writeTask(store.root, match.record, nextState, match.backedUpAt ?? new Date().toISOString())
386
+ changed = { title: args.title, state: nextState }
387
+ }
388
+ // The report is read AFTER the action, so what the caller sees is the state they produced.
389
+ const live = readScheduleStore(schedulePath)
390
+ const { tasks, unreadable } = listTasks(store.root)
391
+ const plan = planRestore(store.root, { liveTasks: live.tasks })
392
+ const rows = tasks.map((task) => {
393
+ const described = describeTask(task, Date.now())
394
+ return {
395
+ title: String(task.record.title ?? ''),
396
+ kind: String(task.record.kind ?? ''),
397
+ state: described.state,
398
+ reason: described.reason,
399
+ file: task.file,
400
+ }
401
+ })
402
+ return {
403
+ action,
404
+ schedulePath,
405
+ live: live.tasks.length,
406
+ backedUp: tasks.length,
407
+ tasks: rows,
408
+ unreadable,
409
+ ...(changed ? { changed } : {}),
410
+ ...(live.error ? { error: live.error } : {}),
411
+ // Carried for the tool's own use, never shown: the planner's own view of what a restore
412
+ // would do, which is the question a reader of this report is actually asking.
413
+ pending: { add: plan.add.length, present: plan.present.length, paused: plan.paused.length, refused: plan.refused.length },
414
+ }
415
+ },
416
+ }))
417
+
418
+ ctx.tools.register(defineTool({
419
+ name: 'memory_map',
420
+ description: 'Show what memory holds, and what THIS session has done with it. scope "store" reports the whole store - entries per layer, freshness, workspaces, and the ingest queue. scope "session" reports what memory injected into this conversation and what this conversation wrote back. "all" returns both. Read-only, and the data behind the Memory view.',
421
+ parameters: {
422
+ scope: { type: 'string', description: 'Which view: "store" (what exists), "session" (what this conversation received and wrote), or "all" (default).' },
423
+ },
424
+ output: {
425
+ schema: {
426
+ type: 'object', additionalProperties: false,
427
+ properties: {
428
+ scope: { type: 'string', required: true },
429
+ generatedAt: { type: 'string', required: true },
430
+ store: { type: 'object', additionalProperties: true },
431
+ session: { type: 'object', additionalProperties: true },
432
+ },
433
+ },
434
+ // The text face, for the agent and for any caller without a UI.
435
+ render: (_args, value) => [{ type: 'text', text: renderMap(value) }],
436
+ // The STRUCTURED face, and the only reason the Memory tab and the inline card can draw
437
+ // anything. A tool result reaches the session event with a `meta` field ONLY when the tool
438
+ // declares presentationMeta (core/tools/src/index.ts:1845-1859), and that field is what
439
+ // ui-chat forwards to a view (conversation-nodes/tool.ts:77: meta: match.event.data.meta).
440
+ // Without this, memory_map rendered its snapshot to prose and threw the value away: the tab
441
+ // showed 'the result carried no snapshot' while the same call displayed 2,197 entries in
442
+ // the conversation. The rendered text is not a substitute - rebuilding panels by parsing it
443
+ // would be a second source of truth.
444
+ presentationMeta: (_args, value) => value,
445
+ },
446
+ async execute(args, exec) {
447
+ const scope = args.scope === 'store' || args.scope === 'session' ? args.scope : 'all'
448
+ // The session id is what attributes writes: every tool records source 'agent:<sessionId>'.
449
+ const sessionId = exec?.agent?.session?.id
450
+ let key
451
+ if (workspaceOf && exec?.agent) {
452
+ try { key = await workspaceOf(exec.agent) } catch { key = undefined }
453
+ }
454
+ // The goal and the char cap are read exactly as the injection reads them, so the
455
+ // RECEIVED panel describes the card that was actually built.
456
+ const goal = ctx.get('goals')?.get(exec?.agent)?.objective
457
+ return buildMap(store, {
458
+ scope,
459
+ sessionId,
460
+ memoryScope: key && key !== 'global' ? 'workspace' : 'global',
461
+ key: key && key !== 'global' ? key : undefined,
462
+ goal,
463
+ maxChars: injectionMaxChars,
464
+ })
465
+ },
466
+ }))
467
+ ctx.tools.register(defineTool({
468
+ name: 'memory_state',
469
+ description: 'Record or refresh one section of the CURRENT repository state overview (what is running, the layout, what is pending, health). Replaces that section: state describes now, so a newer statement supersedes the old one. Each section carries a Verified timestamp and stale sections are reported as such.',
470
+ parameters: {
471
+ section: { type: 'string', required: true, description: 'Section name: Summary, Layout, Running, Pending, Health (or any short name).' },
472
+ content: { type: 'string', required: true, description: 'The current state of that area. Be concrete and short; this is an overview.' },
473
+ },
474
+ output: {
475
+ schema: {
476
+ type: 'object', additionalProperties: false,
477
+ properties: {
478
+ section: { type: 'string', required: true },
479
+ created: { type: 'boolean', required: true },
480
+ workspace: { type: 'string', required: true },
481
+ sections: { type: 'array', required: true, items: { type: 'string' } },
482
+ unverified: { type: 'array', items: { type: 'string' } },
483
+ },
484
+ },
485
+ render: (_args, value) => [{ type: 'text', text: (value.created ? 'Recorded ' : 'Updated ') + 'state section ' + value.section + ' for ' + value.workspace + '. Sections: ' + (value.sections || []).join(', ') + (value.unverified && value.unverified.length ? ' (still unverified: ' + value.unverified.join(', ') + ')' : '') }],
486
+ },
487
+ async execute(args, exec) {
488
+ refuseSubagentWrite(exec, 'memory_state')
489
+ if (!workspaceOf) throw new Error('memory_state is unavailable in this context')
490
+ const workspace = await workspaceOf(exec.agent)
491
+ if (!workspace) throw new Error('no workspace for this session yet: repository state is per workspace')
492
+ const r = writeStateSection(store.root, workspace, args.section, args.content, {
493
+ source: 'agent:' + (exec.agent && exec.agent.session ? exec.agent.session.id : 'unknown'),
494
+ })
495
+ const summary = summarizeState(store.root, workspace)
496
+ return { section: r.section, created: r.created, workspace, sections: r.sections, unverified: summary.stale }
497
+ },
498
+ }))
499
+
500
+ ctx.tools.register(defineTool({
501
+ name: 'memory_recover',
502
+ description: 'Rebuild memory candidates whose working material was pruned but whose source session survives. Called with no arguments it REPORTS what is recoverable and changes nothing. Recovering a large session asks for approval first.',
503
+ parameters: {
504
+ id: { type: 'string', description: 'One candidate to recover. Omit to see a report, or to recover a bounded number of the smallest.' },
505
+ limit: { type: 'integer', description: 'Recover up to this many, smallest first. Each large one asks for approval.' },
506
+ },
507
+ output: {
508
+ schema: {
509
+ type: 'object', additionalProperties: false,
510
+ properties: {
511
+ mode: { type: 'string', required: true },
512
+ total: { type: 'integer' },
513
+ recoverable: { type: 'integer' },
514
+ oversized: { type: 'integer' },
515
+ missing: { type: 'integer' },
516
+ thresholdBytes: { type: 'integer' },
517
+ recovered: { type: 'array', items: { type: 'object', additionalProperties: true } },
518
+ declined: { type: 'array', items: { type: 'object', additionalProperties: true } },
519
+ },
520
+ },
521
+ render: (_args, value) => {
522
+ if (value.mode === 'report') {
523
+ return [{ type: 'text', text: 'memory_recover: ' + value.total + ' candidate(s) stopped with missing material. '
524
+ + value.recoverable + ' recoverable now, ' + value.oversized + ' need approval (>= '
525
+ + Math.round((value.thresholdBytes ?? 0) / 1048576) + 'MB), ' + value.missing + ' have no locatable session.' }]
526
+ }
527
+ const done = value.recovered ?? []
528
+ const no = value.declined ?? []
529
+ return [{ type: 'text', text: 'memory_recover: rebuilt ' + done.length + ' candidate(s)'
530
+ + (no.length ? '; ' + no.length + ' declined or refused' : '') + '.' }]
531
+ },
532
+ },
533
+ async execute(args, exec) {
534
+ refuseSubagentWrite(exec, 'memory_recover')
535
+ if (!recoverNow) throw new Error('memory_recover is unavailable in this context')
536
+ const plan = recoverNow.plan()
537
+ if (!args.id && !args.limit) {
538
+ // `entries: undefined` is NOT lossless JSON - JSON has no undefined - so the harness
539
+ // rejected the entire result with 'value is not lossless JSON'. The field is a per-id
540
+ // list that the report deliberately withholds, so it must be OMITTED, not blanked.
541
+ const { entries: _omitted, ...summary } = plan
542
+ return { mode: 'report', ...summary }
543
+ }
544
+ return recoverNow.run(args, exec)
545
+ },
546
+ }))
547
+
548
+ ctx.tools.register(defineTool({
549
+ name: 'memory_status',
550
+ description: 'Report the memory store state: where it lives, how many digests await consolidation, and whether the last git sync left a conflict. Use it to check memory health before trusting it.',
551
+ parameters: {},
552
+ output: {
553
+ schema: {
554
+ type: 'object', additionalProperties: false,
555
+ properties: {
556
+ root: { type: 'string', required: true },
557
+ pendingDigests: { type: 'integer', required: true },
558
+ conflicted: { type: 'boolean', required: true },
559
+ lastSyncOk: { type: 'boolean' },
560
+ modelCalls: { type: 'integer' },
561
+ eventCounts: { type: 'object', additionalProperties: true },
562
+ savedBy: { type: 'object', additionalProperties: true },
563
+ queue: {
564
+ type: 'object', additionalProperties: false,
565
+ properties: {
566
+ states: { type: 'object', additionalProperties: true, required: true },
567
+ waiting: { type: 'integer', required: true },
568
+ retrying: { type: 'integer', required: true },
569
+ recoverable: { type: 'integer', required: true },
570
+ parked: { type: 'object', additionalProperties: true },
571
+ attention: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'One row per CLASS of problem, largest first, each with a sample of ids.' },
572
+ },
573
+ },
574
+ },
575
+ },
576
+ render: (_args, value) => {
577
+ const q = value.queue ?? {}
578
+ const parts = ['Memory at ' + value.root + ': ' + value.pendingDigests + ' digest(s) pending']
579
+ // What stopped is reported by NAME. A count would say something is wrong; the list says
580
+ // what, which is the difference between a warning and something a reader can act on.
581
+ if (q.recoverable) parts.push(q.recoverable + ' need recovery (memory_recover)')
582
+ if (q.retrying) parts.push(q.retrying + ' retrying')
583
+ if (q.parked && Object.keys(q.parked).length) parts.push('parked: ' + Object.entries(q.parked).map(([why, c]) => c + ' ' + why).join(', '))
584
+ const attention = q.attention ?? []
585
+ if (attention.length) {
586
+ // One clause per CLASS of problem, largest first: '78 orphaned (digest-missing)' says
587
+ // more than 78 identical lines ever could.
588
+ const classes = attention.slice(0, 3).map((a) => a.count + ' ' + a.state + (a.reason ? ' (' + a.reason + ')' : ''))
589
+ parts.push('needs attention: ' + classes.join(', ') + (attention.length > 3 ? ', +' + (attention.length - 3) + ' more kinds' : ''))
590
+ }
591
+ if (value.conflicted) parts.push('THE LAST SYNC CONFLICTED (see state/CONFLICT.md)')
592
+ parts.push('model calls ' + (value.modelCalls || 0))
593
+ return [{ type: 'text', text: parts.join('; ') + '.' }]
594
+ },
595
+ },
596
+ async execute() {
597
+ if (!statusNow) throw new Error('memory_status is unavailable in this context')
598
+ const s = statusNow()
599
+ return {
600
+ root: s.root, pendingDigests: s.pendingDigests, conflicted: s.conflicted,
601
+ ...(s.lastSync && typeof s.lastSync.ok === 'boolean' ? { lastSyncOk: s.lastSync.ok } : {}),
602
+ modelCalls: s.metrics?.modelCalls ?? 0, eventCounts: s.metrics?.events ?? {}, savedBy: s.metrics?.savedBy ?? {},
603
+ ...(s.queue ? { queue: s.queue } : {}),
604
+ }
605
+ },
606
+ }))
607
+
608
+ ctx.tools.register(defineTool({
609
+ name: 'memory_refresh',
610
+ description: 'Run the freshness pass now instead of waiting for the daily cadence: stale and time-bounded entries are re-examined and kept, updated, or superseded by the model.',
611
+ parameters: {
612
+ limit: { type: 'integer', description: 'Maximum entries to review (default 3).' },
613
+ },
614
+ output: {
615
+ schema: {
616
+ type: 'object', additionalProperties: false,
617
+ properties: {
618
+ refreshed: { type: 'integer', required: true },
619
+ updated: { type: 'integer', required: true },
620
+ superseded: { type: 'integer', required: true },
621
+ },
622
+ },
623
+ render: (_args, value) => [{ type: 'text', text: value.refreshed === 0 ? 'Nothing needed refreshing.' : 'Reviewed ' + value.refreshed + ' entr(ies): ' + value.updated + ' updated, ' + value.superseded + ' superseded.' }],
624
+ },
625
+ async execute(args, exec) {
626
+ refuseSubagentWrite(exec, 'memory_refresh')
627
+ if (!refreshNow) throw new Error('memory_refresh is unavailable in this context')
628
+ const r = await refreshNow(exec.agent, { limit: args.limit })
629
+ return { refreshed: r.refreshed ?? 0, updated: r.updated ?? 0, superseded: r.superseded ?? 0 }
630
+ },
631
+ }))
632
+
633
+ ctx.tools.register(defineTool({
634
+ name: 'memory_history',
635
+ description: 'Ingest this repository commit history into the workspace history layer (how the codebase changed over time). Incremental: only commits not yet recorded are added. Safe to call repeatedly.',
636
+ parameters: {
637
+ limit: { type: 'integer', description: 'Maximum new commits to record (default 40).' },
638
+ },
639
+ output: {
640
+ schema: {
641
+ type: 'object', additionalProperties: false,
642
+ properties: {
643
+ ok: { type: 'boolean', required: true },
644
+ added: { type: 'integer', required: true },
645
+ skipped: { type: 'integer' },
646
+ head: { type: 'string' },
647
+ reason: { type: 'string' },
648
+ },
649
+ },
650
+ render: (_args, value) => [{ type: 'text', text: value.ok
651
+ ? 'Recorded ' + value.added + ' commit(s) into the history layer' + (value.skipped ? ' (' + value.skipped + ' already known)' : '') + '.'
652
+ : 'No history recorded: ' + (value.reason || 'unavailable') }],
653
+ },
654
+ async execute(args, exec) {
655
+ refuseSubagentWrite(exec, 'memory_history')
656
+ if (!historyOf) throw new Error('memory_history is unavailable in this context')
657
+ const r = await historyOf(exec.agent, { limit: args.limit })
658
+ // A manual ingest must leave the same trace as the cadence, or the history layer grows
659
+ // with nothing anywhere explaining where its files came from.
660
+ logRun(store.root, 'history-ingest', { source: 'tool', count: (r && r.added) || 0, detail: { ok: Boolean(r && r.ok), reason: (r && r.reason) || '' } })
661
+ return {
662
+ ok: Boolean(r && r.ok), added: (r && r.added) || 0,
663
+ ...(r && r.skipped ? { skipped: r.skipped } : {}),
664
+ ...(r && r.head ? { head: String(r.head).slice(0, 12) } : {}),
665
+ ...(r && r.reason ? { reason: String(r.reason) } : {}),
666
+ }
667
+ },
668
+ }))
669
+
670
+ ctx.tools.register(defineTool({
671
+ name: 'memory_workspaces',
672
+ description: 'List the workspaces (local folders/repos) associated with the dsh-memory store.',
673
+ parameters: {},
674
+ output: {
675
+ schema: {
676
+ type: 'object', additionalProperties: false,
677
+ properties: {
678
+ workspaces: {
679
+ type: 'array', required: true,
680
+ items: {
681
+ type: 'object', additionalProperties: false,
682
+ properties: {
683
+ key: { type: 'string', required: true },
684
+ title: { type: 'string', required: true },
685
+ remotes: { type: 'array', required: true, items: { type: 'string' } },
686
+ path: { type: 'string' },
687
+ },
688
+ },
689
+ },
690
+ },
691
+ },
692
+ render: (_args, value) => [{
693
+ type: 'text',
694
+ text: value.workspaces.length
695
+ ? value.workspaces.map((w) => w.key + ' (' + w.title + ')' + (w.path ? ' -> ' + w.path : '')).join('\n')
696
+ : '(no workspaces associated)',
697
+ }],
698
+ },
699
+ async execute() {
700
+ // `path` is OMITTED when the workspace has no local folder, rather than set to undefined.
701
+ // An explicit undefined is not lossless JSON, and the harness rejects the WHOLE result - so a
702
+ // single workspace with no folder on this machine broke the entire listing, and every other
703
+ // workspace went down with it. Found by calling the tool against the real store: 'puzzle' has
704
+ // no path, and the call failed with "value is not lossless JSON".
705
+ const workspaces = listWorkspaces(store).map((w) => ({
706
+ key: w.key,
707
+ title: w.title,
708
+ remotes: w.remotes,
709
+ ...(w.path === undefined ? {} : { path: w.path }),
710
+ }))
711
+ return { workspaces }
712
+ },
713
+ }))
714
+
715
+ ctx.tools.register(defineTool({
716
+ name: 'memory_associate',
717
+ description: 'Associate a local folder/repo with a workspace key (creates the workspace layer).',
718
+ parameters: {
719
+ key: { type: 'string', required: true, description: 'Workspace key (filesystem-safe slug).' },
720
+ title: { type: 'string', description: 'Optional display title.' },
721
+ path: { type: 'string', description: 'Local folder; defaults to the current session working directory.' },
722
+ },
723
+ output: {
724
+ schema: {
725
+ type: 'object', additionalProperties: false,
726
+ properties: { key: { type: 'string', required: true } },
727
+ },
728
+ render: (_args, value) => [{ type: 'text', text: 'Associated workspace ' + value.key }],
729
+ },
730
+ async execute(args, exec) {
731
+ refuseSubagentWrite(exec, 'memory_associate')
732
+ let cwd
733
+ try { cwd = canonicalPath(args.path, exec) } catch { cwd = undefined }
734
+ const key = associate(store, { key: args.key, title: args.title, cwd })
735
+ return { key }
736
+ },
737
+ }))
738
+
739
+ ctx.tools.register(defineTool({
740
+ name: 'memory_sync',
741
+ description: 'Commit local memory changes and sync (pull + push) with the configured GitHub origin.',
742
+ parameters: {},
743
+ output: {
744
+ schema: {
745
+ type: 'object', additionalProperties: false,
746
+ properties: {
747
+ synced: { type: 'boolean', required: true },
748
+ conflict: { type: 'boolean' },
749
+ error: { type: 'string' },
750
+ },
751
+ },
752
+ render: (_args, value) => [{ type: 'text', text: value.conflict
753
+ ? 'Memory sync needs attention: ' + (value.error || 'git reported a failure') + ' (see state/CONFLICT.md)'
754
+ : (value.synced ? 'Memory synced.' : 'Memory sync unavailable (no git remote configured).') }],
755
+ },
756
+ async execute(_args, exec) {
757
+ refuseSubagentWrite(exec, 'memory_sync')
758
+ if (!syncNow) return { synced: false }
759
+ const status = await syncNow()
760
+ return {
761
+ synced: Boolean(status && (status.synced || status.committed || status.initialized)),
762
+ ...(status && status.conflict ? { conflict: true } : {}),
763
+ ...(status && status.error ? { error: String(status.error) } : {}),
764
+ }
765
+ },
766
+ }))
767
+
768
+ ctx.tools.register(defineTool({
769
+ name: 'memory_forget',
770
+ description: 'Forget a memory entry (supersede it and suppress re-adding), or release it with undo.',
771
+ parameters: {
772
+ id: { type: 'string', required: true, description: 'Entry id (e.g. global-sem-0001).' },
773
+ undo: { type: 'boolean', description: 'Set true to release a previously forgotten entry.' },
774
+ },
775
+ output: {
776
+ schema: {
777
+ type: 'object', additionalProperties: false,
778
+ properties: { id: { type: 'string', required: true }, undone: { type: 'boolean' }, hashPrefix: { type: 'string' } },
779
+ },
780
+ render: (_args, value) => [{ type: 'text', text: value.undone ? 'Released ' + value.id : 'Forgot ' + value.id }],
781
+ },
782
+ async execute(args, exec) {
783
+ refuseSubagentWrite(exec, 'memory_forget')
784
+ if (args.undo) return { id: args.id, undone: forgetUndo(store, args.id) }
785
+ const r = forget(store, args.id)
786
+ return { id: r.id, undone: false, hashPrefix: r.hash.slice(0, 12) }
787
+ },
788
+ }))
789
+
790
+ ctx.tools.register(defineTool({
791
+ name: 'memory_consolidate',
792
+ description: 'Consolidate pending captured session digests into durable memory entries (LLM pass). Forces memory to update now instead of waiting for the automatic cadence.',
793
+ parameters: {
794
+ limit: { type: 'integer', description: 'Maximum digests to consolidate in this pass (default 3).' },
795
+ },
796
+ output: {
797
+ schema: {
798
+ type: 'object', additionalProperties: false,
799
+ properties: {
800
+ consolidated: { type: 'integer', required: true },
801
+ written: { type: 'integer', required: true },
802
+ pending: { type: 'integer', required: true },
803
+ parseFailed: { type: 'boolean' },
804
+ writeFailed: { type: 'integer' },
805
+ writeErrors: { type: 'string' },
806
+ },
807
+ },
808
+ render: (_args, value) => [{
809
+ type: 'text',
810
+ text: value.parseFailed
811
+ ? 'The model reply could not be read; nothing was written (see logs/). ' + value.pending + ' digest(s) still pending.'
812
+ : value.writeFailed
813
+ ? 'Consolidated ' + value.consolidated + ' digest(s), but ' + value.writeFailed + ' entr(ies) failed to write: ' + (value.writeErrors || 'unknown error')
814
+ : 'Consolidated ' + value.consolidated + ' digest(s) into ' + value.written + ' entr(ies); ' + value.pending + ' still pending.',
815
+ }],
816
+ },
817
+ async execute(args, exec) {
818
+ refuseSubagentWrite(exec, 'memory_consolidate')
819
+ if (!consolidate) throw new Error('memory_consolidate is unavailable in this context')
820
+ const r = await consolidate(exec.agent, { batchSize: args.limit })
821
+ return {
822
+ consolidated: r.consolidated, written: r.written, pending: r.pending,
823
+ ...(r.parseFailed ? { parseFailed: true } : {}),
824
+ ...(r.writeFailed ? { writeFailed: r.writeFailed, writeErrors: String(r.writeErrors ?? '').slice(0, 300) } : {}),
825
+ }
826
+ },
827
+ }))
828
+ }