@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/CHANGELOG.md +40 -0
- package/README.en.md +73 -12
- package/README.md +73 -12
- package/client.js +433 -1
- package/docs/update-settings.png +0 -0
- package/index.js +42 -3
- package/package.json +5 -3
- package/src/auto.js +78 -14
- package/src/capture.js +37 -175
- package/src/config.js +37 -0
- package/src/distillapi.js +4 -1
- package/src/distillrun.js +7 -3
- package/src/extract.js +42 -18
- package/src/graph.js +10 -5
- package/src/graphdata.js +10 -1
- package/src/history.js +467 -0
- package/src/pageapi.js +2 -2
- package/src/schema.js +55 -0
- package/src/tools.js +76 -2
- package/src/turns.js +265 -0
- package/src/update.js +406 -0
- package/src/vault.js +21 -11
- package/test/auto.mjs +68 -0
- package/test/client.mjs +15 -5
- package/test/fixtures/session.v4.jsonl.zstd +0 -0
- package/test/history.mjs +249 -0
- package/test/tools.mjs +2 -1
- package/test/update.mjs +401 -0
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
|
|
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 ?? '
|
|
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
|
-
: `
|
|
507
|
-
await audit('card',
|
|
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) || '
|
|
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}
|
|
551
|
+
await audit('card', `${old.id} 已被 ${card.id} 替换(superseded)`)
|
|
542
552
|
}
|
|
543
553
|
|
|
544
|
-
await audit('card',
|
|
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',
|
|
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',
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
[
|
|
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')
|
|
Binary file
|
package/test/history.mjs
ADDED
|
@@ -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
|
|
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',
|