@ilaomianyang/dsh-memory-graph 0.10.4 → 0.11.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 +21 -0
- package/README.en.md +24 -3
- package/README.md +24 -3
- package/client.js +1 -1
- package/package.json +5 -3
- package/src/auto.js +10 -7
- package/src/capture.js +37 -175
- 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 +440 -0
- package/src/pageapi.js +2 -2
- package/src/tools.js +52 -1
- package/src/turns.js +265 -0
- package/src/vault.js +20 -10
- package/test/fixtures/session.v4.jsonl.zstd +0 -0
- package/test/history.mjs +249 -0
- package/test/tools.mjs +2 -1
package/src/tools.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The model-facing surface.
|
|
2
|
+
* The model-facing surface. Eight small tools, all explicit.
|
|
3
3
|
*
|
|
4
4
|
* Nothing here runs on its own: retrieval only happens when the agent decides
|
|
5
5
|
* to ask, which keeps memory out of the context (and out of the attention
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { searchCards, timelineCards } from './search.js'
|
|
10
10
|
import { distill, extractorServices, resolveRoute } from './extract.js'
|
|
11
|
+
import { runBackfill, defaultSessionsDir } from './history.js'
|
|
11
12
|
import { CARD_KINDS, CONFIDENCE, slugify } from './vault.js'
|
|
12
13
|
|
|
13
14
|
const OUTPUT_SCHEMA = { type: 'object', additionalProperties: true }
|
|
@@ -530,5 +531,55 @@ export function registerTools(ctx, { cfg, vault, redactor, runner = null }) {
|
|
|
530
531
|
}),
|
|
531
532
|
)
|
|
532
533
|
|
|
534
|
+
/* --------------------------------------------------------------- backfill */
|
|
535
|
+
disposers.push(
|
|
536
|
+
ctx.tools.register({
|
|
537
|
+
name: 'memory_backfill',
|
|
538
|
+
description:
|
|
539
|
+
'把「安装插件之前的」会话历史一次性导入记忆库。采集只在宿主运行时看得见当轮对话,所以新装的记忆库一开始是空的;这个工具回放磁盘上的历史会话文件(~/.dsh/sessions 下的 session.v4.jsonl.zstd),走的是与实时采集完全相同的过滤与脱敏逻辑,因此回填出来的情节和实时采集的是同一种记录。可重复执行:已经导入过的轮次会跳过,只补新的部分;时间戳用会话文件里的原始时间,所以历史落在时间轴的正确位置。默认只统计不写库(dryRun),确认后再真正导入。',
|
|
540
|
+
parameters: {
|
|
541
|
+
type: 'object',
|
|
542
|
+
properties: {
|
|
543
|
+
dryRun: { type: 'boolean', description: '只统计会导入多少条,不写库。默认 true。' },
|
|
544
|
+
limit: { type: 'integer', minimum: 0, description: '最多回填多少个会话,按最近修改时间优先。0 = 全部。默认 0。' },
|
|
545
|
+
sessionsDir: {
|
|
546
|
+
type: 'string',
|
|
547
|
+
description: '会话存储目录。省略则用环境变量 DSH_HOME 下的 sessions,再退到 ~/.dsh/sessions。',
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
additionalProperties: false,
|
|
551
|
+
},
|
|
552
|
+
output: { schema: OUTPUT_SCHEMA, render: (args, value) => json(args, value.text ?? value) },
|
|
553
|
+
async execute(args) {
|
|
554
|
+
const dryRun = args.dryRun !== false
|
|
555
|
+
const report = await runBackfill({
|
|
556
|
+
cfg,
|
|
557
|
+
vault,
|
|
558
|
+
redactor,
|
|
559
|
+
dir: args.sessionsDir ?? defaultSessionsDir(),
|
|
560
|
+
limit: args.limit ?? 0,
|
|
561
|
+
dryRun,
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
const lines = [
|
|
565
|
+
`${dryRun ? '试运行' : '已导入'}:会话目录 ${report.dir}(解码器 ${report.decoder})`,
|
|
566
|
+
`扫描 ${report.scanned} / 共找到 ${report.found} 个会话,用时 ${(report.ms / 1000).toFixed(1)}s`,
|
|
567
|
+
`${dryRun ? '将写入' : '已写入'} ${report.imported} 条情节;已存在跳过 ${report.duplicates} 条${report.malformed > 0 ? `;坏行 ${report.malformed} 条` : ''}${report.failed > 0 ? `;读不了 ${report.failed} 个会话` : ''}`,
|
|
568
|
+
]
|
|
569
|
+
if (report.exhausted) lines.push('达到单次读取上限,剩下的会话下次再跑即可(已导入的会自动跳过)。')
|
|
570
|
+
if (report.sessions.length > 0) {
|
|
571
|
+
lines.push('贡献最多的会话:')
|
|
572
|
+
for (const session of report.sessions.slice(0, 5)) {
|
|
573
|
+
lines.push(` ${session.id} ${session.project} ${session.episodes} 条${session.duplicates > 0 ? `(跳过 ${session.duplicates})` : ''}`)
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (report.imported > 0 && !dryRun) {
|
|
577
|
+
lines.push('这些情节还没有变成卡片:蒸馏一次(memory_distill 或设置页的「蒸馏一次」)才会进入检索层。')
|
|
578
|
+
}
|
|
579
|
+
return { ...report, text: lines.join('\n') }
|
|
580
|
+
},
|
|
581
|
+
}),
|
|
582
|
+
)
|
|
583
|
+
|
|
533
584
|
return disposers
|
|
534
585
|
}
|
package/src/turns.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn assembly: live events and a replayed session file go through one reducer.
|
|
3
|
+
*
|
|
4
|
+
* Two things must never be implemented twice. First, what counts as a message a
|
|
5
|
+
* person actually typed — the harness pushes runtime snapshots, skill catalogs,
|
|
6
|
+
* sampled time and compaction checkpoints down the same channel, sometimes
|
|
7
|
+
* tagged `user`. Second, the truncation and redaction applied before anything
|
|
8
|
+
* touches disk. If the live listener and the history importer had separate
|
|
9
|
+
* copies of either, they would drift and the drift would be silent: a leaked
|
|
10
|
+
* snapshot on one side, a lost message on the other.
|
|
11
|
+
*
|
|
12
|
+
* So the filter lives here. The callers only decide *when* a turn is closed and
|
|
13
|
+
* *what timestamp* its episode carries.
|
|
14
|
+
*/
|
|
15
|
+
import { nowIso } from './vault.js'
|
|
16
|
+
|
|
17
|
+
/** Assistant text is context, not the record; a handful of parts is plenty. */
|
|
18
|
+
const MAX_ASSISTANT_PARTS = 6
|
|
19
|
+
|
|
20
|
+
function collectText(node, depth = 0, seen = new Set(), out = []) {
|
|
21
|
+
if (node === null || node === undefined || depth > 4) return out
|
|
22
|
+
if (typeof node === 'string') return out
|
|
23
|
+
if (Array.isArray(node)) {
|
|
24
|
+
for (const item of node) collectText(item, depth + 1, seen, out)
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
if (typeof node !== 'object') return out
|
|
28
|
+
if (seen.has(node)) return out
|
|
29
|
+
seen.add(node)
|
|
30
|
+
|
|
31
|
+
if (node.type === 'text' && typeof node.text === 'string') {
|
|
32
|
+
out.push(node.text)
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
for (const key of ['content', 'message', 'text', 'parts', 'blocks']) {
|
|
36
|
+
if (key in node) collectText(node[key], depth + 1, seen, out)
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function eventText(event) {
|
|
42
|
+
const parts = collectText(event?.data)
|
|
43
|
+
return parts.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function truncate(text, max) {
|
|
47
|
+
const value = String(text ?? '').trim()
|
|
48
|
+
if (value.length <= max) return value
|
|
49
|
+
return `${value.slice(0, max)}…`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Delimited harness blocks come off before any pattern is consulted.
|
|
54
|
+
*
|
|
55
|
+
* They are the one kind of injection whose extent is knowable, so removing them
|
|
56
|
+
* keeps the person's own words when a real message happens to carry one — the
|
|
57
|
+
* alternative (dropping the whole message) loses the part that was worth
|
|
58
|
+
* keeping. Undelimited blocks (a runtime snapshot) are handled by the patterns.
|
|
59
|
+
*/
|
|
60
|
+
export function stripInjectedBlocks(text) {
|
|
61
|
+
return String(text ?? '')
|
|
62
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
|
|
63
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
64
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
65
|
+
.trim()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The project a session belongs to: the last segment of the cwd it started in. */
|
|
69
|
+
export function projectOf(session, fallback) {
|
|
70
|
+
const cwd = session?.header?.cwd ?? session?.meta?.cwd ?? session?.cwd
|
|
71
|
+
if (typeof cwd === 'string' && cwd.trim() !== '') {
|
|
72
|
+
// Both separators, whatever platform this is running on: the path comes from
|
|
73
|
+
// the session header, which may well have been written on the other OS.
|
|
74
|
+
const parts = cwd.replace(/[\\/]+$/, '').split(/[\\/]/)
|
|
75
|
+
const base = parts[parts.length - 1] ?? ''
|
|
76
|
+
if (base !== '') return base
|
|
77
|
+
}
|
|
78
|
+
return fallback
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The same shape as vault's nowIso(), for an arbitrary instant. */
|
|
82
|
+
export function isoAt(ms) {
|
|
83
|
+
const d = new Date(ms)
|
|
84
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
85
|
+
const offset = -d.getTimezoneOffset()
|
|
86
|
+
const sign = offset >= 0 ? '+' : '-'
|
|
87
|
+
const abs = Math.abs(offset)
|
|
88
|
+
return (
|
|
89
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
90
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
|
|
91
|
+
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Feed it events in order; it hands back finished episodes.
|
|
97
|
+
*
|
|
98
|
+
* `push()` returns an array because one event can close two turns: a user
|
|
99
|
+
* message that arrives while the previous turn never closed flushes that one
|
|
100
|
+
* first, then opens the next.
|
|
101
|
+
*/
|
|
102
|
+
export function createTurnAssembler({ cfg, redactor }) {
|
|
103
|
+
const buffers = new Map()
|
|
104
|
+
|
|
105
|
+
// Both the switch and the skip list are editable while the plugin runs (they
|
|
106
|
+
// are `.volatile()` in the schema), so neither is baked in here: the switch is
|
|
107
|
+
// read per event, and the compiled rules are rebuilt only when the list's text
|
|
108
|
+
// actually changes — a `new RegExp` on every token would be pure waste.
|
|
109
|
+
let skipKey
|
|
110
|
+
let skipRules = []
|
|
111
|
+
|
|
112
|
+
function compiledSkipRules() {
|
|
113
|
+
const list = cfg.capture.skipPatterns ?? []
|
|
114
|
+
const key = list.join('\u0000')
|
|
115
|
+
if (key !== skipKey) {
|
|
116
|
+
skipKey = key
|
|
117
|
+
skipRules = list.flatMap((pattern) => {
|
|
118
|
+
try {
|
|
119
|
+
return [new RegExp(pattern, 'i')]
|
|
120
|
+
} catch {
|
|
121
|
+
return []
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
return skipRules
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** True when the "user" side is really a harness injection, not a person typing. */
|
|
129
|
+
const injected = (text) => text !== '' && compiledSkipRules().some((re) => re.test(text))
|
|
130
|
+
|
|
131
|
+
function openBuffer(sessionId, project) {
|
|
132
|
+
let buffer = buffers.get(sessionId)
|
|
133
|
+
if (!buffer) {
|
|
134
|
+
buffer = {
|
|
135
|
+
project: project ?? cfg.defaultProject,
|
|
136
|
+
user: '',
|
|
137
|
+
assistant: [],
|
|
138
|
+
tools: 0,
|
|
139
|
+
turn: null,
|
|
140
|
+
at: null,
|
|
141
|
+
}
|
|
142
|
+
buffers.set(sessionId, buffer)
|
|
143
|
+
}
|
|
144
|
+
return buffer
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function finish(sessionId, at) {
|
|
148
|
+
const buffer = buffers.get(sessionId)
|
|
149
|
+
if (!buffer) return null
|
|
150
|
+
buffers.delete(sessionId)
|
|
151
|
+
|
|
152
|
+
const toolCount = buffer.tools
|
|
153
|
+
const user = truncate(buffer.user, cfg.capture.userChars)
|
|
154
|
+
const assistant = truncate(buffer.assistant.join('\n'), cfg.capture.assistantChars)
|
|
155
|
+
|
|
156
|
+
// Every episode is anchored to something the user actually said. A turn with
|
|
157
|
+
// no user side is context noise, and one too short to mean anything — such as
|
|
158
|
+
// "ok" — would only fill the vault with lines the extractor cannot use.
|
|
159
|
+
if (user === '' || user.length + assistant.length < cfg.capture.minChars) return null
|
|
160
|
+
|
|
161
|
+
// The turn's own clock when the log carries one (a replayed session, or a
|
|
162
|
+
// turn flushed a minute late), otherwise now: the timeline should say when
|
|
163
|
+
// it happened, not when it was written down.
|
|
164
|
+
const stamp = at ?? buffer.at
|
|
165
|
+
const when = typeof stamp === 'string' ? stamp : stamp === null || stamp === undefined ? nowIso() : isoAt(stamp)
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
t: when,
|
|
169
|
+
project: buffer.project,
|
|
170
|
+
session: sessionId,
|
|
171
|
+
turn: buffer.turn,
|
|
172
|
+
user: redactor.redact(user).text,
|
|
173
|
+
assistant: redactor.redact(assistant).text,
|
|
174
|
+
...(cfg.capture.includeToolOutput && toolCount > 0 ? { tools: toolCount } : {}),
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
/**
|
|
180
|
+
* @param {{sessionId: string, project?: string, event: object}} input
|
|
181
|
+
* @returns {object[]} episodes that are finished and ready to append
|
|
182
|
+
*/
|
|
183
|
+
push({ sessionId, project, event }) {
|
|
184
|
+
if (!cfg.capture.enabled) return []
|
|
185
|
+
const type = event?.type
|
|
186
|
+
const at = Number.isFinite(event?.time) ? event.time : null
|
|
187
|
+
const done = []
|
|
188
|
+
|
|
189
|
+
if (type === 'turn/end') {
|
|
190
|
+
const buffer = buffers.get(sessionId)
|
|
191
|
+
if (!buffer) return done
|
|
192
|
+
if (typeof event?.data?.turn === 'number') buffer.turn = event.data.turn
|
|
193
|
+
const episode = finish(sessionId, at)
|
|
194
|
+
if (episode) done.push(episode)
|
|
195
|
+
return done
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (type !== 'user/message' && type !== 'assistant/message' && type !== 'tool/result') return done
|
|
199
|
+
|
|
200
|
+
if (type === 'user/message') {
|
|
201
|
+
// The harness delivers runtime snapshots, skill catalogs, sampled time,
|
|
202
|
+
// approval notices and compaction checkpoints through the same channel as
|
|
203
|
+
// the person typing — and not always with a telling `source.kind`: a real
|
|
204
|
+
// runtime snapshot has arrived here tagged `user` (it was captured as if
|
|
205
|
+
// the person had typed it). So provenance can only *add* a skip, never
|
|
206
|
+
// veto the patterns; both are consulted, and delimited blocks come off
|
|
207
|
+
// first so a message that merely carried a reminder keeps its own words.
|
|
208
|
+
const kind = event?.data?.source?.kind
|
|
209
|
+
const text = stripInjectedBlocks(eventText(event))
|
|
210
|
+
const synthetic = (typeof kind === 'string' && kind !== 'user') || text === '' || injected(text)
|
|
211
|
+
if (synthetic) return done
|
|
212
|
+
|
|
213
|
+
const open = buffers.get(sessionId)
|
|
214
|
+
if (open && (open.user !== '' || open.assistant.length > 0)) {
|
|
215
|
+
// A turn that never closed (interrupted, or an older harness): keep it.
|
|
216
|
+
const episode = finish(sessionId, at)
|
|
217
|
+
if (episode) done.push(episode)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const buffer = openBuffer(sessionId, project)
|
|
221
|
+
buffer.user = text
|
|
222
|
+
buffer.turn = typeof event?.data?.turn === 'number' ? event.data.turn : buffer.turn
|
|
223
|
+
if (at !== null) buffer.at = at
|
|
224
|
+
return done
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const buffer = openBuffer(sessionId, project)
|
|
228
|
+
if (at !== null) buffer.at = at
|
|
229
|
+
|
|
230
|
+
if (type === 'assistant/message') {
|
|
231
|
+
const text = eventText(event)
|
|
232
|
+
if (text !== '' && buffer.assistant.length < MAX_ASSISTANT_PARTS) buffer.assistant.push(text)
|
|
233
|
+
if (typeof event?.data?.turn === 'number') buffer.turn = event.data.turn
|
|
234
|
+
return done
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
buffer.tools += 1
|
|
238
|
+
return done
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
/** Close one session's open turn. Returns the episode, or null if it was noise. */
|
|
242
|
+
flush(sessionId, at) {
|
|
243
|
+
return finish(sessionId, at ?? undefined)
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/** Close every open turn (shutdown, or the end of a replayed file). */
|
|
247
|
+
flushAll(at) {
|
|
248
|
+
const out = []
|
|
249
|
+
for (const sessionId of [...buffers.keys()]) {
|
|
250
|
+
const episode = finish(sessionId, at ?? undefined)
|
|
251
|
+
if (episode) out.push(episode)
|
|
252
|
+
}
|
|
253
|
+
return out
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
/** True when this session has an open turn — a hint for the timer bookkeeping. */
|
|
257
|
+
isOpen(sessionId) {
|
|
258
|
+
return buffers.has(sessionId)
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
forget(sessionId) {
|
|
262
|
+
buffers.delete(sessionId)
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
package/src/vault.js
CHANGED
|
@@ -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
|
}
|
|
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',
|