@ilaomianyang/dsh-memory-graph 0.10.0

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 (50) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +466 -0
  4. package/README.md +459 -0
  5. package/client.js +1953 -0
  6. package/cordis.patch.yml +13 -0
  7. package/docs/auto-settings.png +0 -0
  8. package/docs/distill-button.png +0 -0
  9. package/docs/graph-dark.png +0 -0
  10. package/docs/graph-light.png +0 -0
  11. package/docs/session-tab-cards.png +0 -0
  12. package/docs/session-tab-open.png +0 -0
  13. package/docs/session-tab.png +0 -0
  14. package/docs/vault-panel.png +0 -0
  15. package/eval/REPORT.md +437 -0
  16. package/eval/questions.json +277 -0
  17. package/eval/run.mjs +460 -0
  18. package/eval/seed.mjs +489 -0
  19. package/icon.svg +26 -0
  20. package/index.js +243 -0
  21. package/locale/en.json +6 -0
  22. package/locale/zh.json +6 -0
  23. package/package.json +77 -0
  24. package/src/auto.js +194 -0
  25. package/src/capture.js +237 -0
  26. package/src/config.js +280 -0
  27. package/src/distillapi.js +30 -0
  28. package/src/distillrun.js +106 -0
  29. package/src/extract.js +660 -0
  30. package/src/graph.js +1582 -0
  31. package/src/graphdata.js +475 -0
  32. package/src/http.js +32 -0
  33. package/src/pageapi.js +40 -0
  34. package/src/privacy.js +78 -0
  35. package/src/schema.js +323 -0
  36. package/src/search.js +317 -0
  37. package/src/tools.js +534 -0
  38. package/src/vault.js +888 -0
  39. package/test/auto.mjs +295 -0
  40. package/test/capture.mjs +264 -0
  41. package/test/client.mjs +1035 -0
  42. package/test/distillapi.mjs +212 -0
  43. package/test/extract.mjs +660 -0
  44. package/test/graphdata.mjs +295 -0
  45. package/test/live-supersede.mjs +78 -0
  46. package/test/live.mjs +156 -0
  47. package/test/page.mjs +116 -0
  48. package/test/schema.mjs +256 -0
  49. package/test/smoke.mjs +428 -0
  50. package/test/tools.mjs +238 -0
package/src/tools.js ADDED
@@ -0,0 +1,534 @@
1
+ /**
2
+ * The model-facing surface. Seven small tools, all explicit.
3
+ *
4
+ * Nothing here runs on its own: retrieval only happens when the agent decides
5
+ * to ask, which keeps memory out of the context (and out of the attention
6
+ * budget) on the turns that do not need it.
7
+ */
8
+ import path from 'node:path'
9
+ import { searchCards, timelineCards } from './search.js'
10
+ import { distill, extractorServices, resolveRoute } from './extract.js'
11
+ import { CARD_KINDS, CONFIDENCE, slugify } from './vault.js'
12
+
13
+ const OUTPUT_SCHEMA = { type: 'object', additionalProperties: true }
14
+
15
+ function json(args, value) {
16
+ return [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }]
17
+ }
18
+
19
+ function projectFor(ctx, exec, cfg) {
20
+ try {
21
+ const sessions = ctx.get?.('sessions', false)
22
+ const session = sessions?.get?.(exec?.agent?.id)
23
+ const cwd = session?.header?.cwd ?? session?.meta?.cwd ?? session?.cwd
24
+ if (typeof cwd === 'string' && cwd.trim() !== '') {
25
+ const base = path.basename(cwd.replace(/[\\/]+$/, ''))
26
+ if (base !== '') return base
27
+ }
28
+ } catch {
29
+ // No sessions service, or the agent has no session: fall through.
30
+ }
31
+ return cfg.defaultProject
32
+ }
33
+
34
+ function formatHits(hits) {
35
+ if (hits.length === 0) return 'No memories matched.'
36
+ return hits
37
+ .map((hit) => {
38
+ const card = hit.card
39
+ const status = card.status === 'active' ? '' : ` [${card.status}]`
40
+ const via = hit.via === 'keyword' ? '' : ` <${hit.via}>`
41
+ return [
42
+ `${card.id} · ${String(card.created).slice(0, 10)} · ${card.kind} · ${card.project}${status}${via}`,
43
+ ` ${card.title}`,
44
+ hit.snippet ? ` ${hit.snippet}` : '',
45
+ ]
46
+ .filter(Boolean)
47
+ .join('\n')
48
+ })
49
+ .join('\n\n')
50
+ }
51
+
52
+ /**
53
+ * The harness services the extractor borrows its route from. Both are optional:
54
+ * without them the extractor can still post to an endpoint itself, so their
55
+ * absence is a reason to report, never to fail the surrounding operation.
56
+ */
57
+
58
+ /** One line describing what a distill call would do right now, for memory_stats. */
59
+ function describeExtractor(ctx, cfg) {
60
+ if (!cfg.extractor.enabled) return 'extractor: off (nothing calls a model)'
61
+ if (cfg.extractor.transport === 'http') {
62
+ return `extractor: http ${cfg.extractor.baseUrl}${cfg.extractor.path} model=${cfg.extractor.model}`
63
+ }
64
+ const { llm, selection } = extractorServices(ctx)
65
+ try {
66
+ const route = resolveRoute(cfg, selection)
67
+ const effort = route.effort === '' ? '' : ` effort=${route.effort}`
68
+ const model = `${route.provider}/${route.model}`
69
+ return `extractor: ${llm ? 'harness' : 'harness (no llm service!)'} ${model}${effort} · ${route.source}`
70
+ } catch (error) {
71
+ return `extractor: harness · ${error.message}`
72
+ }
73
+ }
74
+
75
+ export function registerTools(ctx, { cfg, vault, redactor, runner = null }) {
76
+ const disposers = []
77
+
78
+ /**
79
+ * README and graph.html are derived views: rendered from the cards, never the
80
+ * source of truth. A view that fails to render must not fail the write that
81
+ * triggered it — the card is already on disk and the next write rebuilds it.
82
+ */
83
+ async function refreshViews() {
84
+ for (const render of [vault.regenerateReadme, vault.regenerateGraph]) {
85
+ if (typeof render !== 'function') continue
86
+ try {
87
+ await render.call(vault)
88
+ } catch (error) {
89
+ ctx.logger?.warn?.(`[memory-graph] could not render a vault view: ${error?.message ?? error}`)
90
+ }
91
+ }
92
+ }
93
+
94
+ /* ---------------------------------------------------------------- search */
95
+ disposers.push(
96
+ ctx.tools.register({
97
+ name: 'memory_search',
98
+ description:
99
+ 'Search long-term memory that persists across sessions and across projects. Use it before asking the user to repeat something, and whenever they refer to an earlier decision, a past conversation, a preference, or "the thing we did before". Results are memories with ids: call memory_get for one in full. Returns nothing useful on topics never discussed; that is not an error.',
100
+ parameters: {
101
+ type: 'object',
102
+ properties: {
103
+ query: { type: 'string', description: 'What to recall, in natural language. Chinese or English.' },
104
+ project: { type: 'string', description: 'Limit to one project slug. Omit to search every project.' },
105
+ kinds: {
106
+ type: 'array',
107
+ items: { type: 'string', enum: CARD_KINDS },
108
+ description: 'Restrict to these card kinds.',
109
+ },
110
+ limit: { type: 'integer', minimum: 1, maximum: 40, description: 'Max results. Default 8.' },
111
+ },
112
+ required: ['query'],
113
+ additionalProperties: false,
114
+ },
115
+ output: {
116
+ schema: OUTPUT_SCHEMA,
117
+ render: (args, value) => json(args, value.text ?? value),
118
+ },
119
+ async execute(args, exec) {
120
+ const cards = await vault.listCards()
121
+ const hits = searchCards(cards, args.query, {
122
+ limit: args.limit ?? cfg.retrieval.limit,
123
+ hops: cfg.retrieval.hops,
124
+ snippetChars: cfg.retrieval.snippetChars,
125
+ project: args.project ?? null,
126
+ kinds: args.kinds ?? null,
127
+ })
128
+ const value = {
129
+ count: hits.length,
130
+ vault: vault.dir,
131
+ currentProject: projectFor(ctx, exec, cfg),
132
+ text: hits.length === 0
133
+ ? `No memories matched "${args.query}".`
134
+ : `${hits.length} memories:\n\n${formatHits(hits)}`,
135
+ }
136
+ return value
137
+ },
138
+ }),
139
+ )
140
+
141
+ /* ---------------------------------------------------------------- get */
142
+ disposers.push(
143
+ ctx.tools.register({
144
+ name: 'memory_get',
145
+ description:
146
+ 'Read one memory card in full, plus the entities it is linked to. Use after memory_search or memory_timeline returns an id you need the details of.',
147
+ parameters: {
148
+ type: 'object',
149
+ properties: { id: { type: 'string', description: 'Card id, e.g. c-20261102-ab12cd.' } },
150
+ required: ['id'],
151
+ additionalProperties: false,
152
+ },
153
+ output: { schema: OUTPUT_SCHEMA, render: (args, value) => json(args, value.text ?? value) },
154
+ async execute(args) {
155
+ const card = await vault.getCard(String(args.id))
156
+ if (!card) return { found: false, text: `No card with id ${args.id}.` }
157
+ const entities = []
158
+ for (const slug of card.entities ?? []) {
159
+ const entity = await vault.readEntity(slug)
160
+ if (entity) entities.push({ name: entity.name, slug, type: entity.type, mentions: entity.mentions.length })
161
+ }
162
+ return {
163
+ found: true,
164
+ card,
165
+ entities,
166
+ text: [
167
+ `${card.id} · ${card.kind} · ${card.project} · ${card.status}`,
168
+ `title: ${card.title}`,
169
+ `created: ${card.created} valid: ${card.validFrom ?? '?'} → ${card.validTo ?? 'now'}`,
170
+ `confidence: ${card.confidence}`,
171
+ card.supersedes?.length ? `supersedes: ${card.supersedes.join(', ')}` : '',
172
+ card.supersededBy ? `superseded by: ${card.supersededBy}` : '',
173
+ `entities: ${(card.entities ?? []).join(', ') || '—'}`,
174
+ '',
175
+ card.body,
176
+ ]
177
+ .filter((line) => line !== '')
178
+ .join('\n'),
179
+ }
180
+ },
181
+ }),
182
+ )
183
+
184
+ /* ---------------------------------------------------------------- timeline */
185
+ disposers.push(
186
+ ctx.tools.register({
187
+ name: 'memory_timeline',
188
+ description:
189
+ 'Chronological view of memories, newest first. Use for "how did this develop", "what changed", "what did we decide then, and what is it now". Keeps superseded cards visible on purpose, so the revision chain stays readable.',
190
+ parameters: {
191
+ type: 'object',
192
+ properties: {
193
+ entity: { type: 'string', description: 'Only memories mentioning this entity (name or slug).' },
194
+ project: { type: 'string', description: 'Only this project.' },
195
+ from: { type: 'string', description: 'YYYY-MM-DD lower bound.' },
196
+ to: { type: 'string', description: 'YYYY-MM-DD upper bound.' },
197
+ limit: { type: 'integer', minimum: 1, maximum: 200 },
198
+ },
199
+ additionalProperties: false,
200
+ },
201
+ output: { schema: OUTPUT_SCHEMA, render: (args, value) => json(args, value.text ?? value) },
202
+ async execute(args) {
203
+ const cards = await vault.listCards()
204
+ const list = timelineCards(cards, {
205
+ limit: args.limit ?? cfg.retrieval.timelineLimit,
206
+ project: args.project ?? null,
207
+ entity: args.entity ?? null,
208
+ from: args.from ?? null,
209
+ to: args.to ?? null,
210
+ })
211
+ const text =
212
+ list.length === 0
213
+ ? 'No memories in that range.'
214
+ : list
215
+ .map((card) => {
216
+ const chain = card.supersededBy ? ` → superseded by ${card.supersededBy}` : ''
217
+ return `${String(card.created).slice(0, 10)} · ${card.kind} · ${card.project} · ${card.id}${card.status === 'active' ? '' : ` [${card.status}]`}\n ${card.title}${chain}`
218
+ })
219
+ .join('\n')
220
+ return { count: list.length, text }
221
+ },
222
+ }),
223
+ )
224
+
225
+ /* ---------------------------------------------------------------- graph */
226
+ disposers.push(
227
+ ctx.tools.register({
228
+ name: 'memory_graph',
229
+ description:
230
+ 'Walk the memory graph around one node. Given an entity name, an entity slug or a card id, list the memories it is part of and the neighbouring entities that share those memories, with the reason for each link. Use it when the question is "what else is connected to this" and you have no keyword to search for. An edge here means two things were mentioned in the same memory; it is not a claim that one caused the other.',
231
+ parameters: {
232
+ type: 'object',
233
+ properties: {
234
+ node: { type: 'string', description: 'Entity name, entity slug, or a card id (c-...).' },
235
+ depth: { type: 'integer', minimum: 1, maximum: 2, description: 'Hops to expand. Default 1.' },
236
+ limit: { type: 'integer', minimum: 1, maximum: 60, description: 'Max memories listed. Default 12.' },
237
+ },
238
+ required: ['node'],
239
+ additionalProperties: false,
240
+ },
241
+ output: { schema: OUTPUT_SCHEMA, render: (args, value) => json(args, value.text ?? value) },
242
+ async execute(args) {
243
+ const depth = args.depth ?? 1
244
+ const limit = args.limit ?? 12
245
+ const node = String(args.node ?? '').trim()
246
+ const cards = await vault.listCards()
247
+ const entities = await vault.listEntities()
248
+ const bySlug = new Map(entities.map((entity) => [entity.slug, entity]))
249
+
250
+ // Resolve the seed: a card id, an exact entity, or a loose name match.
251
+ let seeds = []
252
+ const asCard = cards.find((card) => card.id === node)
253
+ if (asCard) {
254
+ seeds = (asCard.entities ?? []).filter((slug) => bySlug.has(slug))
255
+ } else {
256
+ const wanted = slugify(node)
257
+ const lower = node.toLowerCase()
258
+ const exact = entities.find(
259
+ (entity) =>
260
+ entity.slug === wanted ||
261
+ entity.name.toLowerCase() === lower ||
262
+ entity.aliases.some((alias) => String(alias).toLowerCase() === lower),
263
+ )
264
+ seeds = exact
265
+ ? [exact.slug]
266
+ : entities
267
+ .filter((entity) => entity.name.toLowerCase().includes(lower) || entity.slug.includes(wanted))
268
+ .slice(0, 3)
269
+ .map((entity) => entity.slug)
270
+ }
271
+
272
+ if (seeds.length === 0) {
273
+ return { found: false, node, text: `Nothing in memory is linked to "${node}".` }
274
+ }
275
+
276
+ const visited = new Set()
277
+ const frontier = [...seeds]
278
+ const reached = []
279
+ for (let hop = 0; hop < depth && frontier.length > 0; hop++) {
280
+ const next = []
281
+ for (const slug of frontier) {
282
+ if (visited.has(slug)) continue
283
+ visited.add(slug)
284
+ const entity = bySlug.get(slug)
285
+ if (!entity) continue
286
+ const linked = cards.filter((card) => card.status !== 'retracted' && (card.entities ?? []).includes(slug))
287
+ reached.push({ slug, entity, linked })
288
+ for (const card of linked) {
289
+ for (const other of card.entities ?? []) {
290
+ if (!visited.has(other) && bySlug.has(other)) next.push(other)
291
+ }
292
+ }
293
+ }
294
+ frontier.length = 0
295
+ frontier.push(...next)
296
+ }
297
+
298
+ const primary = reached[0]
299
+ const memories = []
300
+ const seenCards = new Set()
301
+ for (const entry of reached) {
302
+ for (const card of entry.linked) {
303
+ if (seenCards.has(card.id)) continue
304
+ seenCards.add(card.id)
305
+ memories.push(card)
306
+ }
307
+ }
308
+ memories.sort((a, b) => String(b.created).localeCompare(String(a.created)))
309
+
310
+ const neighbours = new Map()
311
+ for (const card of memories) {
312
+ for (const slug of card.entities ?? []) {
313
+ if (visited.has(slug)) continue
314
+ neighbours.set(slug, (neighbours.get(slug) ?? 0) + 1)
315
+ }
316
+ }
317
+ const rankedNeighbours = [...neighbours.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)
318
+
319
+ const lines = []
320
+ lines.push(
321
+ asCard
322
+ ? `card ${asCard.id} · ${asCard.kind} · ${asCard.title}`
323
+ : `entity "${primary.entity.name}" · ${asCard ? '' : `${primary.entity.mentions.length} mention(s)`}`.trim(),
324
+ )
325
+ lines.push('')
326
+ lines.push(`memories (${Math.min(memories.length, limit)} of ${memories.length}):`)
327
+ for (const card of memories.slice(0, limit)) {
328
+ const mark = card.status === 'active' ? '' : ` [${card.status}]`
329
+ lines.push(` ${String(card.created).slice(0, 10)} · ${card.kind} · ${card.project} · ${card.id}${mark} — ${card.title}`)
330
+ }
331
+ if (rankedNeighbours.length > 0) {
332
+ lines.push('')
333
+ lines.push('neighbours (things sharing those memories):')
334
+ lines.push(` ${rankedNeighbours.map(([slug, count]) => `${bySlug.get(slug)?.name ?? slug} (${count})`).join(' · ')}`)
335
+ }
336
+ return {
337
+ found: true,
338
+ node,
339
+ seeds,
340
+ nodes: reached.map((entry) => ({ slug: entry.slug, name: entry.entity.name, type: entry.entity.type })),
341
+ memories: memories.slice(0, limit).map((card) => ({ id: card.id, kind: card.kind, title: card.title, created: card.created, project: card.project })),
342
+ neighbours: rankedNeighbours.map(([slug, count]) => ({ slug, name: bySlug.get(slug)?.name ?? slug, shared: count })),
343
+ text: lines.join('\n'),
344
+ }
345
+ },
346
+ }),
347
+ )
348
+
349
+ /* ---------------------------------------------------------------- remember */
350
+ disposers.push(
351
+ ctx.tools.register({
352
+ name: 'memory_remember',
353
+ description:
354
+ 'Store one durable memory the user would not want to lose: a decision and its reason, a stable fact, a preference about how they want things done, or an unresolved question. Write it so it still makes sense with no conversation around it. Do not store transient state, restatements, or anything already covered by an existing card.',
355
+ parameters: {
356
+ type: 'object',
357
+ properties: {
358
+ kind: { type: 'string', enum: CARD_KINDS },
359
+ title: { type: 'string', description: 'One short sentence, no trailing period, self-contained.' },
360
+ body: { type: 'string', description: '1-4 sentences: what, why, and what it replaced if anything.' },
361
+ entities: {
362
+ type: 'array',
363
+ items: { type: 'string' },
364
+ description: 'Concrete things involved: projects, files, tools, people, concepts. Canonical short names.',
365
+ },
366
+ confidence: { type: 'string', enum: CONFIDENCE },
367
+ supersedes: {
368
+ type: 'array',
369
+ items: { type: 'string' },
370
+ description: 'Ids of cards this one makes obsolete. They are marked superseded, never deleted.',
371
+ },
372
+ project: { type: 'string', description: 'Project slug. Defaults to the current project.' },
373
+ tags: { type: 'array', items: { type: 'string' } },
374
+ },
375
+ required: ['kind', 'title', 'body'],
376
+ additionalProperties: false,
377
+ },
378
+ output: { schema: OUTPUT_SCHEMA, render: (args, value) => json(args, value.text ?? value) },
379
+ async execute(args, exec) {
380
+ const project = args.project ? slugify(args.project) : projectFor(ctx, exec, cfg)
381
+ const title = redactor.redact(String(args.title))
382
+ const body = redactor.redact(String(args.body))
383
+ const entities = (args.entities ?? []).map((entity) => redactor.redact(String(entity)).text)
384
+ const { card, deduped, entities: linked } = await vault.saveCard({
385
+ kind: args.kind,
386
+ title: title.text,
387
+ body: body.text,
388
+ entities,
389
+ confidence: args.confidence ?? 'user_stated',
390
+ supersedes: args.supersedes ?? [],
391
+ tags: args.tags ?? [],
392
+ project,
393
+ source: { kind: 'agent', project },
394
+ })
395
+ await refreshViews()
396
+ const redactions = title.hits.length + body.hits.length
397
+ return {
398
+ ok: true,
399
+ id: card.id,
400
+ deduped,
401
+ project,
402
+ entities: linked,
403
+ redactions,
404
+ text:
405
+ `${deduped ? 'Re-confirmed existing card' : 'Saved'} ${card.id} (${card.kind}, ${project})\n` +
406
+ ` ${card.title}\n` +
407
+ ` entities: ${linked.join(', ') || '—'}` +
408
+ (redactions > 0 ? `\n redacted ${redactions} sensitive fragment(s) before writing` : ''),
409
+ }
410
+ },
411
+ }),
412
+ )
413
+
414
+ /* ---------------------------------------------------------------- forget */
415
+ disposers.push(
416
+ ctx.tools.register({
417
+ name: 'memory_forget',
418
+ description:
419
+ 'Remove a memory. "retract" keeps the card and its trace but marks it withdrawn, so the model stops relying on it and the user can see what was withdrawn; "purge" deletes the file. Prefer retract; use purge only when the user asks for the content to be gone.',
420
+ parameters: {
421
+ type: 'object',
422
+ properties: {
423
+ id: { type: 'string' },
424
+ mode: { type: 'string', enum: ['retract', 'purge'], description: 'Default retract.' },
425
+ reason: { type: 'string', description: 'Why it is being forgotten; recorded in the audit log.' },
426
+ },
427
+ required: ['id'],
428
+ additionalProperties: false,
429
+ },
430
+ output: { schema: OUTPUT_SCHEMA, render: (args, value) => json(args, value.text ?? value) },
431
+ async execute(args) {
432
+ const mode = args.mode === 'purge' ? 'purge' : 'retract'
433
+ if (mode === 'purge') {
434
+ const done = await vault.purgeCard(String(args.id))
435
+ await refreshViews()
436
+ return { ok: done, text: done ? `Purged ${args.id}.` : `No card with id ${args.id}.` }
437
+ }
438
+ const card = await vault.retractCard(String(args.id), args.reason)
439
+ await refreshViews()
440
+ return {
441
+ ok: Boolean(card),
442
+ text: card ? `Retracted ${card.id} "${card.title}". The card and its history stay readable.` : `No card with id ${args.id}.`,
443
+ }
444
+ },
445
+ }),
446
+ )
447
+
448
+ /* ---------------------------------------------------------------- distill */
449
+ disposers.push(
450
+ ctx.tools.register({
451
+ name: 'memory_distill',
452
+ description:
453
+ 'Turn captured raw episodes into memory cards using a model. By default it borrows the model selected in Settings → Models, so it needs no key of its own. Run it after a long work session, or when the user asks what you have learned lately. Returns a clear reason when it is not configured.',
454
+ parameters: {
455
+ type: 'object',
456
+ properties: {
457
+ project: { type: 'string', description: 'Attribute new cards to this project. Default: the newest episode project.' },
458
+ dryRun: { type: 'boolean', description: 'Show what would be written without writing it.' },
459
+ },
460
+ additionalProperties: false,
461
+ },
462
+ output: { schema: OUTPUT_SCHEMA, render: (args, value) => json(args, value.text ?? value) },
463
+ async execute(args, exec) {
464
+ const target = { project: args.project ?? projectFor(ctx, exec, cfg), dryRun: Boolean(args.dryRun), signal: exec?.signal }
465
+ // Through the shared runner when the plugin wired one: a distillation the
466
+ // model asks for must not overlap an automatic run or a pressed button.
467
+ const report = runner
468
+ ? await runner.run(target)
469
+ : await distill({ cfg, vault, redactor, ...target, ...extractorServices(ctx) })
470
+ const lines = []
471
+ const batches = report.batches
472
+ if (!report.ok) {
473
+ lines.push(`Not distilled: ${report.reason}`)
474
+ // Batches before the failure already landed; say so, or the next call
475
+ // looks like it started from nowhere.
476
+ if (report.written > 0) {
477
+ lines.push(` kept ${report.written} card(s) from ${batches?.done ?? 1} earlier batch(es).`)
478
+ for (const card of report.cards ?? []) lines.push(` ${card.id} · ${card.kind} · ${card.title}`)
479
+ }
480
+ } else if (report.episodes === 0) lines.push(report.reason ?? 'Nothing new to distill.')
481
+ else {
482
+ const calls = batches && batches.done > 1 ? ` in ${batches.done} batches of ${batches.size}` : ''
483
+ const count = report.written ?? report.proposed?.length ?? 0
484
+ const found = report.cards?.length ?? count
485
+ const fresh = report.cards ? report.cards.filter((card) => !card.deduped).length : count
486
+ const reconfirmed = found - fresh > 0 ? ` · ${found - fresh} re-confirmed` : ''
487
+ lines.push(`Read ${report.episodes} episode(s) → ${fresh} card(s)${reconfirmed}${calls}${report.dryRun ? ' (dry run, nothing written)' : ''}.`)
488
+ for (const card of report.cards ?? report.proposed ?? []) {
489
+ lines.push(` ${card.id ?? '(proposed)'} · ${card.kind} · ${card.title}${card.deduped ? ' (re-confirmed)' : ''}`)
490
+ }
491
+ if (report.dryRun && report.remaining > 0) {
492
+ lines.push(` dry run covered the first ${report.episodes} of ${report.episodes + report.remaining}; a real run keeps going in batches.`)
493
+ }
494
+ if (report.skipped) lines.push(` skipped: ${report.skipped}`)
495
+ if (report.model) lines.push(` model: ${report.model}${report.keySource ? ` · ${report.keySource}` : ''}`)
496
+ if (report.usage) lines.push(` tokens: ${JSON.stringify(report.usage)}`)
497
+ }
498
+ // Distillation is the biggest writer of all: without this the graph and
499
+ // the README would only catch up on the next remember/forget.
500
+ if (!report.dryRun && report.written > 0) await refreshViews()
501
+ return { ...report, text: lines.join('\n') }
502
+ },
503
+ }),
504
+ )
505
+
506
+ /* ---------------------------------------------------------------- stats */
507
+ disposers.push(
508
+ ctx.tools.register({
509
+ name: 'memory_stats',
510
+ description:
511
+ 'Where the memory vault lives and what it contains: card counts by kind and status, entities, projects, captured episodes, last distillation, privacy rule count. Use to confirm memory is actually working.',
512
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
513
+ output: { schema: OUTPUT_SCHEMA, render: (args, value) => json(args, value.text ?? value) },
514
+ async execute() {
515
+ const stats = await vault.stats()
516
+ const text = [
517
+ `vault: ${stats.vaultDir}`,
518
+ `cards: ${stats.cards.active} active / ${stats.cards.superseded} superseded / ${stats.cards.retracted} retracted (${stats.cards.total} total)`,
519
+ `kinds: ${JSON.stringify(stats.cards.byKind)}`,
520
+ `entities: ${stats.entities}`,
521
+ `projects: ${stats.projects.join(', ') || '—'}`,
522
+ `episodes: ${stats.episodes.records} records in ${stats.episodes.files} file(s)`,
523
+ `last distill: ${stats.distill.lastRun ?? 'never'}`,
524
+ describeExtractor(ctx, cfg),
525
+ `privacy rules: ${stats.privacy.rules}`,
526
+ `views: README.md and graph.html in the vault root (open the HTML directly, no server needed)`,
527
+ ].join('\n')
528
+ return { ...stats, text }
529
+ },
530
+ }),
531
+ )
532
+
533
+ return disposers
534
+ }