@ilaomianyang/dsh-memory-graph 0.10.4 → 0.12.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.
package/src/vault.js CHANGED
@@ -260,7 +260,7 @@ export function createVault(cfg, redactor) {
260
260
  // The vault is plain text so the owner can version it themselves; keep the
261
261
  // OS junk and the per-machine distillation cursors out of that repo.
262
262
  if (!(await exists(P.gitignore))) {
263
- await writeAtomic(P.gitignore, '*.tmp\n.DS_Store\nThumbs.db\n.distill-state.json\n')
263
+ await writeAtomic(P.gitignore, '*.tmp\n.DS_Store\nThumbs.db\n.distill-state.json\n.auto-state.json\n.update-state.json\n')
264
264
  }
265
265
  })()
266
266
  return ready
@@ -301,9 +301,19 @@ export function createVault(cfg, redactor) {
301
301
 
302
302
  /* ------------------------------------------------------------ episodes */
303
303
 
304
+ /**
305
+ * Append one episode to the day it happened.
306
+ *
307
+ * The date in the filename is the record's own `t`, not "now": a backfilled
308
+ * session from last month belongs in last month's file, or the vault listing
309
+ * would claim that three months of history happened today. A record without a
310
+ * parseable timestamp falls back to today.
311
+ */
304
312
  async function appendEpisode(record) {
305
313
  await ensure()
306
- const file = path.join(P.episodes, `${today()}.jsonl`)
314
+ const stamp = typeof record?.t === 'string' ? record.t.slice(0, 10) : ''
315
+ const day = /^\d{4}-\d{2}-\d{2}$/.test(stamp) ? stamp : today()
316
+ const file = path.join(P.episodes, `${day}.jsonl`)
307
317
  const line = `${JSON.stringify(record)}\n`
308
318
  await withLock(`episode:${file}`, () => fsp.appendFile(file, line, 'utf8'))
309
319
  // Keep projects.json honest even before the first card is distilled.
@@ -402,7 +412,7 @@ export function createVault(cfg, redactor) {
402
412
  return {
403
413
  id: data.id,
404
414
  kind: data.kind ?? 'note',
405
- title: data.title ?? '(untitled)',
415
+ title: data.title ?? '(无标题 untitled)',
406
416
  project: data.project ?? cfg.defaultProject,
407
417
  entities: Array.isArray(data.entities) ? data.entities : [],
408
418
  created: data.created ?? nowIso(),
@@ -503,15 +513,15 @@ export function createVault(cfg, redactor) {
503
513
  await touchProject(project)
504
514
  const why = duplicate.exact
505
515
  ? ''
506
- : ` (near-duplicate ${duplicate.score.toFixed(2)} of "${String(input.title ?? '').trim()}")`
507
- await audit('card', `re-confirmed ${existing.id} "${existing.title}"${why}`)
516
+ : ` (近似重复 near-duplicate ${duplicate.score.toFixed(2)},对着 "${String(input.title ?? '').trim()}")`
517
+ await audit('card', `再次确认 ${existing.id} "${existing.title}"${why}`)
508
518
  return { card: existing, deduped: true }
509
519
  }
510
520
 
511
521
  const card = {
512
522
  id: input.id ?? newCardId(),
513
523
  kind: CARD_KINDS.includes(input.kind) ? input.kind : 'note',
514
- title: String(input.title ?? '').trim().slice(0, 160) || '(untitled)',
524
+ title: String(input.title ?? '').trim().slice(0, 160) || '(无标题 untitled)',
515
525
  project,
516
526
  entities: [...new Set((input.entities ?? []).map((e) => slugify(e)))].filter(Boolean),
517
527
  created: input.created ?? stamp,
@@ -538,10 +548,10 @@ export function createVault(cfg, redactor) {
538
548
  old.validTo = card.validFrom
539
549
  old.supersededBy = card.id
540
550
  await writeAtomic(cardPath(old), renderCard(old))
541
- await audit('card', `${old.id} superseded by ${card.id}`)
551
+ await audit('card', `${old.id} 已被 ${card.id} 替换(superseded)`)
542
552
  }
543
553
 
544
- await audit('card', `wrote ${card.id} [${card.kind}] "${card.title}" (${card.project})`)
554
+ await audit('card', `写入 ${card.id} [${card.kind}] "${card.title}" (${card.project})`)
545
555
  return { card, deduped: false }
546
556
  })
547
557
  }
@@ -552,10 +562,10 @@ export function createVault(cfg, redactor) {
552
562
  if (!card) return null
553
563
  card.status = 'retracted'
554
564
  card.validTo = card.validTo ?? today()
555
- card.body = `${card.body}\n\n<!-- retracted: ${String(reason ?? 'no reason given').slice(0, 200)} -->`
565
+ card.body = `${card.body}\n\n<!-- 已撤回 retracted: ${String(reason ?? '没有给出原因 · no reason given').slice(0, 200)} -->`
556
566
  await writeAtomic(cardPath(card), renderCard(card))
557
567
  invalidate()
558
- await audit('card', `retracted ${id}${reason ? ` (${reason})` : ''}`)
568
+ await audit('card', `已撤回 ${id}${reason ? ` (${reason})` : ''}`)
559
569
  return card
560
570
  })
561
571
  }
@@ -566,7 +576,7 @@ export function createVault(cfg, redactor) {
566
576
  if (!card) return false
567
577
  await fsp.rm(card.file, { force: true })
568
578
  invalidate()
569
- await audit('card', `purged ${id}`)
579
+ await audit('card', `已彻底删除 ${id}(purged)`)
570
580
  return true
571
581
  })
572
582
  }
package/test/auto.mjs CHANGED
@@ -125,6 +125,74 @@ await check('a threshold with no clock still fires at once: that rule is the pil
125
125
  assert.equal(calls.length, 1)
126
126
  })
127
127
 
128
+ /* -------------------------------------------------------------- catch-up */
129
+
130
+ const CATCH_UP = { ...AUTO, enabled: true, minEpisodes: 0, everyMinutes: 0, minGapMinutes: 30, catchUp: true, catchUpEveryMinutes: 10, maxBatchesPerDay: 3 }
131
+
132
+ await check('catch-up is off unless asked for: a full batch is not a queue', () => {
133
+ const base = { auto: { ...AUTO, minEpisodes: 0, everyMinutes: 0 }, now: 5_000_000, lastAttemptAt: null, pending: 500, batchSize: 40 }
134
+ assert.equal(autoDecision(base).due, false)
135
+ })
136
+
137
+ await check('catch-up runs on its own cadence even though minGapMinutes is longer', async () => {
138
+ const { distiller, calls, clock } = makeDistiller({ auto: CATCH_UP, pending: 500 })
139
+ const first = await distiller.tick()
140
+ assert.equal(first.rule, 'catch-up', 'a backlog is due on the first tick')
141
+ assert.equal(first.batchesToday, 1)
142
+ assert.equal(calls.length, 1)
143
+ clock.at += 5 * MINUTE
144
+ const tooSoon = await distiller.tick()
145
+ assert.equal(tooSoon.due, false, 'ten minutes is the catch-up cadence, five is not enough')
146
+ assert.match(tooSoon.reason, /catch-up gap/u)
147
+ clock.at += 6 * MINUTE
148
+ const again = await distiller.tick()
149
+ assert.equal(again.rule, 'catch-up')
150
+ assert.equal(again.batchesToday, 2)
151
+ assert.equal(calls.length, 2)
152
+ })
153
+
154
+ await check('catch-up stops at the daily cap and starts again the next day', async () => {
155
+ const { distiller, calls, clock, vault } = makeDistiller({ auto: CATCH_UP, pending: 500 })
156
+ for (let i = 0; i < 3; i += 1) {
157
+ assert.equal((await distiller.tick()).rule, 'catch-up')
158
+ clock.at += 11 * MINUTE
159
+ }
160
+ const capped = await distiller.tick()
161
+ assert.equal(capped.due, false)
162
+ assert.match(capped.reason, /上限 3/u)
163
+ assert.equal(calls.length, 3, 'three batches are the whole day')
164
+ assert.equal(vault.written.batchesToday, 3)
165
+
166
+ clock.at += 24 * 60 * MINUTE
167
+ const tomorrow = await distiller.tick()
168
+ assert.equal(tomorrow.rule, 'catch-up')
169
+ assert.equal(tomorrow.batchesToday, 1, 'a new day resets the counter')
170
+ })
171
+
172
+ await check('a pile smaller than one batch is not catch-up work', async () => {
173
+ const { distiller, calls } = makeDistiller({ auto: CATCH_UP, pending: 12 })
174
+ const decision = await distiller.tick()
175
+ assert.equal(decision.due, false, 'twelve episodes wait for a schedule or a threshold')
176
+ assert.equal(calls.length, 0)
177
+ })
178
+
179
+ await check('the daily counter is read back from the vault, not assumed to be zero', async () => {
180
+ const clock = { at: new Date(2026, 10, 3, 9, 0, 0).getTime() }
181
+ const state = { batchDay: '2026-11-03', batchesToday: 3, lastAttemptAt: clock.at - 20 * MINUTE }
182
+ const { distiller, calls } = makeDistiller({ auto: CATCH_UP, pending: 500, state, clock })
183
+ const decision = await distiller.tick()
184
+ assert.equal(decision.due, false, 'a restart must not hand out a fresh daily allowance')
185
+ assert.match(decision.reason, /上限 3/u)
186
+ assert.equal(calls.length, 0)
187
+ })
188
+
189
+ await check('quiet hours outrank catch-up', async () => {
190
+ const at = new Date(2026, 10, 3, 2, 0, 0).getTime()
191
+ const decision = autoDecision({ auto: { ...CATCH_UP, quietHours: '23:00-08:00' }, now: at, lastAttemptAt: null, pending: 500, batchSize: 40 })
192
+ assert.equal(decision.due, false)
193
+ assert.equal(decision.reason, 'quiet hours')
194
+ })
195
+
128
196
  await check('both rules can be on: whichever is satisfied first runs', () => {
129
197
  const auto = { ...AUTO, minEpisodes: 10, everyMinutes: 60 }
130
198
  const now = 100 * MINUTE
package/test/client.mjs CHANGED
@@ -321,7 +321,7 @@ await check('the page renders every knob the served value makes visible', () =>
321
321
  try {
322
322
  const expected = visibleFields(SERVED)
323
323
  // The harness route hides the seven http-only knobs and the provider field.
324
- assert.equal(expected.length, 27, `expected 27 visible fields, spec says ${expected.length}`)
324
+ assert.equal(expected.length, 34, `expected 34 visible fields, spec says ${expected.length}`)
325
325
  for (const field of expected) {
326
326
  const key = field.path.join('.')
327
327
  assert.ok(control(instance.tree, key), `${key} has no control on the page`)
@@ -335,18 +335,18 @@ await check('the page renders every knob the served value makes visible', () =>
335
335
  }
336
336
  })
337
337
 
338
- await check('the five sections are all there, each with a title', () => {
338
+ await check('the six sections are all there, each with a title', () => {
339
339
  const instance = render(makeForm(clone(SERVED)))
340
340
  try {
341
341
  const titles = byClass(instance.tree, 'mg-card-t').map(text)
342
- assert.equal(titles.length, 5)
342
+ assert.equal(titles.length, 6)
343
343
  for (const title of titles) assert.ok(title.length > 0)
344
344
  } finally {
345
345
  instance.unmount()
346
346
  }
347
347
  })
348
348
 
349
- await check('the automatic distiller has both triggers on the page, each with its own switch', async () => {
349
+ await check('the automatic distiller has its three rules on the page, each with its own switch', async () => {
350
350
  const form = makeForm(clone(SERVED))
351
351
  const instance = render(form)
352
352
  try {
@@ -354,7 +354,17 @@ await check('the automatic distiller has both triggers on the page, each with it
354
354
  assert.ok(section, 'automatic distillation needs a section of its own, not a corner of another one')
355
355
  assert.deepEqual(
356
356
  section.fields.map((field) => field.path.join('.')),
357
- ['auto.enabled', 'auto.minEpisodes', 'auto.everyMinutes', 'auto.minGapMinutes', 'auto.quietHours', 'auto.project'],
357
+ [
358
+ 'auto.enabled',
359
+ 'auto.minEpisodes',
360
+ 'auto.everyMinutes',
361
+ 'auto.minGapMinutes',
362
+ 'auto.quietHours',
363
+ 'auto.project',
364
+ 'auto.catchUp',
365
+ 'auto.catchUpEveryMinutes',
366
+ 'auto.maxBatchesPerDay',
367
+ ],
358
368
  )
359
369
  // Both rules show as 0 — "this rule is off" — rather than as a blank.
360
370
  assert.equal(control(instance.tree, 'auto.minEpisodes').props.value, '0')
@@ -0,0 +1,249 @@
1
+ /**
2
+ * History test: replay a session file the way the store actually laid it out,
3
+ * and check what reaches the vault. node test/history.mjs
4
+ *
5
+ * The fixture `test/fixtures/session.v4.jsonl.zstd` is a real zstd frame holding
6
+ * real event shapes, so the decompression path is exercised rather than mocked.
7
+ * It was produced once with:
8
+ *
9
+ * node -e "… zlib.zstdCompressSync(JSON.stringify(events).split…)" (node >= 22.15)
10
+ *
11
+ * and holds three turns: one real, one that is only a runtime snapshot, and one
12
+ * with a `<system-reminder>` wrapped around the person's own words.
13
+ */
14
+ import assert from 'node:assert/strict'
15
+ import fsp from 'node:fs/promises'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+
20
+ import { resolveConfig } from '../src/config.js'
21
+ import { createRedactor } from '../src/privacy.js'
22
+ import { createVault } from '../src/vault.js'
23
+ import { isoAt } from '../src/turns.js'
24
+ import { createReplay, importSession, listSessions, runBackfill } from '../src/history.js'
25
+
26
+ const here = path.dirname(fileURLToPath(import.meta.url))
27
+ const fixture = path.join(here, 'fixtures', 'session.v4.jsonl.zstd')
28
+
29
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-hist-'))
30
+ const cfg = resolveConfig({ vaultDir: dir })
31
+ const redactor = createRedactor(cfg.privacy)
32
+ const vault = createVault(cfg, redactor)
33
+
34
+ /** An empty store laid out exactly like ~/.dsh/sessions. */
35
+ async function makeStore(name = 'store') {
36
+ const root = path.join(dir, name)
37
+ return root
38
+ }
39
+
40
+ async function plant(root, { group = '--G-TempProject-Test--', id = 'session-history-fixture', file = fixture, mtime } = {}) {
41
+ const target = path.join(root, group, id)
42
+ await fsp.mkdir(target, { recursive: true })
43
+ const copy = path.join(target, 'session.v4.jsonl.zstd')
44
+ await fsp.copyFile(file, copy)
45
+ if (mtime) await fsp.utimes(copy, mtime, mtime)
46
+ return copy
47
+ }
48
+
49
+ const results = []
50
+ async function check(name, fn) {
51
+ try {
52
+ await fn()
53
+ results.push(` ok ${name}`)
54
+ } catch (error) {
55
+ results.push(` FAIL ${name}: ${error.message}`)
56
+ process.exitCode = 1
57
+ }
58
+ }
59
+
60
+ /* ------------------------------------------------------------------ store */
61
+
62
+ const store = await makeStore()
63
+ await plant(store)
64
+ await plant(store, { group: '--G-Article2--', id: 'session-older', mtime: new Date(Date.now() - 86_400_000) })
65
+
66
+ await check('the store is walked two levels deep, newest directory first', async () => {
67
+ const found = await listSessions(store)
68
+ assert.equal(found.length, 2, `expected 2 sessions, got ${found.length}`)
69
+ assert.equal(found[0].dirName, 'session-history-fixture')
70
+ assert.equal(found[1].dirName, 'session-older')
71
+ assert.ok(found[0].size > 0)
72
+ })
73
+
74
+ await check('a missing directory reports nothing instead of throwing', async () => {
75
+ const found = await listSessions(path.join(dir, 'nope'))
76
+ assert.deepEqual(found, [])
77
+ })
78
+
79
+ /* ----------------------------------------------------------------- replay */
80
+
81
+ await check('a real turn is replayed, a runtime snapshot is not', () => {
82
+ const seen = new Set()
83
+ const replay = createReplay({ cfg, redactor, seen })
84
+ replay.feed(JSON.stringify({ type: 'session', id: 'session-x', cwd: 'C:\\TempProject\\历史项目' }))
85
+ replay.feed(
86
+ JSON.stringify({
87
+ type: 'user/message',
88
+ time: 1793170060000,
89
+ data: { content: [{ type: 'text', text: '我们决定把记忆存成纯文本 vault,每一轮对话追加一条情节。' }], source: { kind: 'user' } },
90
+ }),
91
+ )
92
+ replay.feed(JSON.stringify({ type: 'turn/end', time: 1793170090000, data: { turn: 1 } }))
93
+ replay.feed(
94
+ JSON.stringify({
95
+ type: 'user/message',
96
+ time: 1793170100000,
97
+ data: {
98
+ content: [{ type: 'text', text: 'Current runtime context. This snapshot supersedes earlier snapshots.' }],
99
+ source: { kind: 'user' },
100
+ },
101
+ }),
102
+ )
103
+ replay.feed(JSON.stringify({ type: 'assistant/message', time: 1793170110000, data: { content: [{ type: 'text', text: '收到。' }] } }))
104
+ replay.feed(JSON.stringify({ type: 'turn/end', time: 1793170120000, data: { turn: 2 } }))
105
+ const report = replay.finish()
106
+
107
+ assert.equal(report.episodes.length, 1, 'only the turn a person typed should survive')
108
+ assert.equal(report.episodes[0].turn, 1)
109
+ assert.ok(report.episodes[0].user.includes('纯文本 vault'))
110
+ assert.equal(report.closedTurns, 2)
111
+ })
112
+
113
+ await check('a reminder inside a real message is stripped, not the message', () => {
114
+ const replay = createReplay({ cfg, redactor, seen: new Set() })
115
+ replay.feed(
116
+ JSON.stringify({
117
+ type: 'user/message',
118
+ time: 1793170060000,
119
+ data: {
120
+ content: [{ type: 'text', text: '<system-reminder>sampled time</system-reminder>把记忆图谱的面板改成中文。' }],
121
+ source: { kind: 'user' },
122
+ },
123
+ }),
124
+ )
125
+ replay.feed(JSON.stringify({ type: 'turn/end', time: 1793170090000, data: { turn: 3 } }))
126
+ const [episode] = replay.finish().episodes
127
+ assert.ok(episode, 'the message should survive')
128
+ assert.ok(!episode.user.includes('system-reminder'), 'the reminder must be gone')
129
+ assert.equal(episode.user, '把记忆图谱的面板改成中文。')
130
+ })
131
+
132
+ await check('the timestamp is the turn’s own, not the day of the import', () => {
133
+ const replay = createReplay({ cfg, redactor, seen: new Set() })
134
+ replay.feed(
135
+ JSON.stringify({
136
+ type: 'user/message',
137
+ time: 1793170060000,
138
+ data: { content: [{ type: 'text', text: '这段历史发生在上个月,应该落在上个月的时间轴上。' }], source: { kind: 'user' } },
139
+ }),
140
+ )
141
+ replay.feed(JSON.stringify({ type: 'turn/end', time: 1793170090000, data: { turn: 1 } }))
142
+ const [episode] = replay.finish().episodes
143
+ assert.equal(episode.t, isoAt(1793170090000))
144
+ assert.ok(episode.t.startsWith('2026-'), `expected a 2026 date, got ${episode.t}`)
145
+ })
146
+
147
+ await check('a secret in replayed text is redacted before it is stored', () => {
148
+ const replay = createReplay({ cfg, redactor, seen: new Set() })
149
+ replay.feed(
150
+ JSON.stringify({
151
+ type: 'user/message',
152
+ time: 1793170060000,
153
+ data: {
154
+ content: [{ type: 'text', text: '出问题就写信给 someone@example.com,别贴密钥 sk-live-abcdefghijklmnopqrstuvwx。' }],
155
+ source: { kind: 'user' },
156
+ },
157
+ }),
158
+ )
159
+ replay.feed(JSON.stringify({ type: 'turn/end', time: 1793170090000, data: { turn: 1 } }))
160
+ const [episode] = replay.finish().episodes
161
+ assert.ok(!episode.user.includes('someone@example.com'), `email leaked: ${episode.user}`)
162
+ assert.ok(!episode.user.includes('sk-live-abcdefghijklmnopqrstuvwx'), `secret leaked: ${episode.user}`)
163
+ })
164
+
165
+ await check('a corrupt line is counted, not thrown', () => {
166
+ const replay = createReplay({ cfg, redactor, seen: new Set() })
167
+ replay.feed('{not json')
168
+ replay.feed(JSON.stringify({ type: 'turn/end', time: 1793170090000, data: { turn: 1 } }))
169
+ const report = replay.finish()
170
+ assert.equal(report.malformed, 1)
171
+ assert.equal(report.episodes.length, 0)
172
+ })
173
+
174
+ await check('a turn already in the vault is skipped', () => {
175
+ const seen = new Set(['session-y#1'])
176
+ const replay = createReplay({ cfg, redactor, seen })
177
+ replay.feed(JSON.stringify({ type: 'session', id: 'session-y' }))
178
+ replay.feed(
179
+ JSON.stringify({
180
+ type: 'user/message',
181
+ time: 1793170060000,
182
+ data: { content: [{ type: 'text', text: '这一轮已经在记忆库里了,不该再写一遍。' }], source: { kind: 'user' } },
183
+ }),
184
+ )
185
+ replay.feed(JSON.stringify({ type: 'turn/end', time: 1793170090000, data: { turn: 1 } }))
186
+ const report = replay.finish()
187
+ assert.equal(report.episodes.length, 0)
188
+ assert.equal(report.duplicates, 1)
189
+ })
190
+
191
+ /* ------------------------------------------------------------------- file */
192
+
193
+ await check('the real zstd frame is decompressed into the vault', async () => {
194
+ const [entry] = await listSessions(store)
195
+ const report = await importSession({ entry, cfg, vault, redactor, seen: new Set() })
196
+ assert.equal(report.id, 'session-history-fixture')
197
+ assert.equal(report.project, '历史项目', 'the project comes from the session header')
198
+ assert.equal(report.truncated, false)
199
+ assert.equal(report.episodes, 2, `expected 2 usable turns, got ${report.episodes}`)
200
+
201
+ const stats = await vault.stats()
202
+ assert.equal(stats.episodes.records, 2, 'both turns should be on disk')
203
+ assert.deepEqual(stats.projects, ['历史项目'], 'the project file is touched too')
204
+ })
205
+
206
+ await check('a second run imports nothing twice', async () => {
207
+ const report = await runBackfill({ cfg, vault, redactor, dir: store })
208
+ assert.equal(report.found, 2)
209
+ assert.equal(report.imported, 0, `re-imported ${report.imported} episodes`)
210
+ assert.ok(report.duplicates >= 2, `expected the two known turns, got ${report.duplicates}`)
211
+ const stats = await vault.stats()
212
+ assert.equal(stats.episodes.records, 2, 'the vault must not have grown')
213
+ })
214
+
215
+ /* -------------------------------------------------------------- dry run */
216
+
217
+ await check('a dry run reports without writing', async () => {
218
+ // Its own vault: the dedupe key is the session id inside the file, so a
219
+ // second vault is what proves the dry run *would* have written these turns.
220
+ const freshDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-dry-'))
221
+ const freshCfg = resolveConfig({ vaultDir: freshDir })
222
+ const freshVault = createVault(freshCfg, createRedactor(freshCfg.privacy))
223
+ const fresh = await makeStore('dry')
224
+ await plant(fresh)
225
+
226
+ const report = await runBackfill({ cfg: freshCfg, vault: freshVault, redactor, dir: fresh, dryRun: true })
227
+ assert.equal(report.dryRun, true)
228
+ assert.equal(report.imported, 2, 'it should still say what it would import')
229
+ assert.equal((await freshVault.stats()).episodes.records, 0, 'a dry run must not write')
230
+
231
+ // And the same run without the flag does write those two.
232
+ const real = await runBackfill({ cfg: freshCfg, vault: freshVault, redactor, dir: fresh })
233
+ assert.equal(real.imported, 2)
234
+ assert.equal((await freshVault.stats()).episodes.records, 2)
235
+ })
236
+
237
+ await check('the limit takes the newest sessions first', async () => {
238
+ const report = await runBackfill({ cfg, vault, redactor, dir: store, limit: 1, dryRun: true })
239
+ assert.equal(report.scanned, 1)
240
+ assert.equal(report.sessions[0].id, 'session-history-fixture')
241
+ })
242
+
243
+ let failed = 0
244
+ for (const line of results) {
245
+ if (line.includes('FAIL')) failed += 1
246
+ }
247
+ console.log(results.join('\n'))
248
+ console.log(failed === 0 ? `\nall ${results.length} checks passed` : `\n${failed} of ${results.length} checks failed`)
249
+ if (failed === 0) process.exitCode = 0
package/test/tools.mjs CHANGED
@@ -57,8 +57,9 @@ async function call(name, args) {
57
57
  }
58
58
 
59
59
  /* ------------------------------------------------------------------ shape */
60
- check('registers the eight memory tools', () => {
60
+ check('registers the nine memory tools', () => {
61
61
  assert.deepEqual([...byName.keys()].sort(), [
62
+ 'memory_backfill',
62
63
  'memory_distill',
63
64
  'memory_forget',
64
65
  'memory_get',