dsh-selfnote 0.0.0-stage → 0.12.1

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/lib/memory.js ADDED
@@ -0,0 +1,1100 @@
1
+ /**
2
+ * dsh-selfnote · 记忆层(P1)
3
+ *
4
+ * 只做两件事:
5
+ * 1. 把 DSH 自己的会话记录(session.v4.jsonl.zstd)解出来,按需缓存,供检索;
6
+ * 2. 提供一个带出处的检索:返回原文片段 + citation(会话 id / 日期 / 第几条)。
7
+ *
8
+ * 不写会话文件,不改会话文件,只读。
9
+ * facts.jsonl 是给 P2 的提炼结果留的位子,本版只读不写。
10
+ */
11
+
12
+ import {
13
+ closeSync,
14
+ existsSync,
15
+ mkdirSync,
16
+ openSync,
17
+ readFileSync,
18
+ readSync,
19
+ readdirSync,
20
+ statSync,
21
+ unlinkSync,
22
+ } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { join } from 'node:path'
25
+ import { zstdDecompressSync } from 'node:zlib'
26
+
27
+ import { writeAtomic, writeJsonAtomic } from './atomic.js'
28
+
29
+ const SESSION_FILE = 'session.v4.jsonl.zstd'
30
+ /** 缓存格式版本:解析规则变了就 +1,旧缓存自动作废。 */
31
+ const CACHE_VERSION = 6
32
+ const SNIPPET_CHARS = 180
33
+ // 缓存一个会话是十 KB 左右,300 个会话也就三兆多。原来只留 60 个,
34
+ // 结果全量扫描时「读一个 → 写一个 → 淘汰掉下一个」,300 次 fsync 换几乎零命中。
35
+ const CACHE_LIMIT = 400
36
+
37
+ export function dshHome() {
38
+ const env = String(process.env.DSH_HOME ?? '').trim()
39
+ return env === '' ? join(homedir(), '.dsh') : env
40
+ }
41
+
42
+ export function memoryRoot() {
43
+ const env = String(process.env.DSH_MEMORY_DIR ?? '').trim()
44
+ return env === '' ? join(dshHome(), 'memory') : env
45
+ }
46
+
47
+ export const factsPath = () => join(memoryRoot(), 'facts.jsonl')
48
+ export const statePath = () => join(memoryRoot(), 'state.json')
49
+ export const cacheDir = () => join(memoryRoot(), 'cache')
50
+
51
+ export function sessionsRoot() {
52
+ const env = String(process.env.DSH_SESSIONS_DIR ?? '').trim()
53
+ return env === '' ? join(dshHome(), 'sessions') : env
54
+ }
55
+
56
+ function ensureDir(dir) {
57
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
58
+ }
59
+
60
+ /* ------------------------------ 状态 ------------------------------ */
61
+
62
+ export function readState() {
63
+ try {
64
+ const raw = readFileSync(statePath(), 'utf8')
65
+ const data = JSON.parse(raw)
66
+ return data !== null && typeof data === 'object' ? data : {}
67
+ } catch {
68
+ return {}
69
+ }
70
+ }
71
+
72
+ export function writeState(next) {
73
+ ensureDir(memoryRoot())
74
+ writeJsonAtomic(statePath(), next)
75
+ }
76
+
77
+ /* ------------------------------ 会话索引 ------------------------------ */
78
+
79
+ /**
80
+ * 一次 stat 三百多个会话文件要 80ms 左右。
81
+ * 设置页刷一次会问好几遍(检索统计、索引覆盖、文件区),
82
+ * 所以在这层做一个「本次调用栈内共用一份」的快照:
83
+ * 只在 withSessionSnapshot() 里面生效,出了这个门就恢复每次现扫,
84
+ * 免得测试改了文件却读到旧列表。
85
+ */
86
+ let snapshot = null
87
+
88
+ export function listSessions() {
89
+ if (snapshot !== null) return snapshot
90
+ return scanSessions()
91
+ }
92
+
93
+ /** 在这段同步代码里,listSessions() 只扫一次。 */
94
+ export function withSessionSnapshot(fn) {
95
+ const previous = snapshot
96
+ snapshot = scanSessions()
97
+ try {
98
+ return fn()
99
+ } finally {
100
+ snapshot = previous
101
+ }
102
+ }
103
+
104
+ function scanSessions() {
105
+ const root = sessionsRoot()
106
+ if (!existsSync(root)) return []
107
+ const out = []
108
+ for (const project of readdirSync(root, { withFileTypes: true })) {
109
+ if (!project.isDirectory()) continue
110
+ const projectDir = join(root, project.name)
111
+ for (const session of readdirSync(projectDir, { withFileTypes: true })) {
112
+ if (!session.isDirectory()) continue
113
+ const file = join(projectDir, session.name, SESSION_FILE)
114
+ if (!existsSync(file)) continue
115
+ const info = statSync(file)
116
+ out.push({ id: session.name, project: project.name, file, mtime: info.mtimeMs })
117
+ }
118
+ }
119
+ out.sort((a, b) => b.mtime - a.mtime)
120
+ return out
121
+ }
122
+
123
+ function blocksToText(blocks) {
124
+ if (!Array.isArray(blocks)) return ''
125
+ const parts = []
126
+ for (const block of blocks) {
127
+ // 只取正文:reasoning 和 tool-call 不进记忆,避免把思考噪声当事实
128
+ if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
129
+ parts.push(block.text)
130
+ }
131
+ }
132
+ return parts.join('\n').trim()
133
+ }
134
+
135
+ /** 解压一个会话,抽出可检索的轮次。失败返回空数组(格式变了解不开就跳过,不报错)。 */
136
+ /**
137
+ * 解 zstd。Electron 自带的 zlib 只解**第一个 frame**(真机实测:881KB 的会话只出来 189 字节),
138
+ * 而 DSH 的会话文件是逐条追加的多 frame。所以这里先按 magic 切帧逐帧解,
139
+ * 切分失败(magic 出现在压缩数据里)就退回整块解。
140
+ */
141
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
142
+
143
+ /**
144
+ * 从 from(必须是帧边界)开始往后解,返回解出来的文本和「最后一帧的结束偏移」。
145
+ *
146
+ * 增量解析的关键:**只解新追加的那段字节**。`end` 让下次知道从哪继续;
147
+ * 尾部那帧还没写完(写会话时被打断)时不报错,就地停下,下次接着解 ——
148
+ * 这样「半个会话突然解不开」就不会再变成「整个会话从检索里消失」。
149
+ */
150
+ function decodeFrom(buf, from = 0) {
151
+ const cuts = []
152
+ let at = buf.indexOf(ZSTD_MAGIC, Math.max(0, from))
153
+ while (at !== -1) {
154
+ cuts.push(at)
155
+ at = buf.indexOf(ZSTD_MAGIC, at + 4)
156
+ }
157
+ if (cuts.length === 0) return { text: '', end: buf.length, frames: 0 }
158
+ // 单帧文件(或整块能解):老路径,不切
159
+ if (from === 0 && cuts.length < 2) {
160
+ return { text: zstdDecompressSync(buf).toString('utf8'), end: buf.length, frames: 1 }
161
+ }
162
+ const parts = []
163
+ let start = from === 0 ? cuts[0] : from
164
+ let end = start
165
+ let frames = 0
166
+ for (let i = 0; i < cuts.length; i += 1) {
167
+ const segmentEnd = i + 1 < cuts.length ? cuts[i + 1] : buf.length
168
+ if (segmentEnd <= start) continue
169
+ try {
170
+ parts.push(zstdDecompressSync(buf.subarray(start, segmentEnd)))
171
+ start = segmentEnd
172
+ end = segmentEnd
173
+ frames += 1
174
+ } catch {
175
+ // 这一刀切错了(magic 落在压缩数据里),或者尾巴那帧还没写完 —— 停在上一帧结尾
176
+ break
177
+ }
178
+ }
179
+ return { text: Buffer.concat(parts).toString('utf8'), end, frames }
180
+ }
181
+
182
+ /**
183
+ * 解析一段文本(全量解析和增量解析共用)。
184
+ *
185
+ * linesBefore:这段文本在整份会话里的起始行号。增量解析时行号必须接上,
186
+ * 否则两批记录共用同一个 index,「会话#行号」这个出处就指错了。
187
+ * carry:文本末尾如果是一条没写完的行,留到下次拼在前面再解析,不丢也不报错。
188
+ */
189
+ function parseText(text, options = {}) {
190
+ const linesBefore = options.linesBefore ?? 0
191
+ const knownDelegated = options.delegated === true
192
+ const complete = text === '' || text.endsWith('\n')
193
+ const pieces = text.split('\n')
194
+ const carry = complete ? '' : pieces.pop() ?? ''
195
+
196
+ // 先认委派会话:子代理会话里那些「user」是委派方的任务书,不是这个人本人说的话
197
+ let delegated = knownDelegated
198
+ if (!delegated) {
199
+ for (const raw of pieces) {
200
+ if (!raw.includes('subagent/descriptor')) continue
201
+ try {
202
+ if (JSON.parse(raw).type === 'subagent/descriptor') {
203
+ delegated = true
204
+ break
205
+ }
206
+ } catch {
207
+ /* 半截行,忽略 */
208
+ }
209
+ }
210
+ }
211
+
212
+ const records = []
213
+ for (let i = 0; i < pieces.length; i += 1) {
214
+ const line = pieces[i].trim()
215
+ if (line === '') continue
216
+ let record = null
217
+ try {
218
+ record = JSON.parse(line)
219
+ } catch {
220
+ continue
221
+ }
222
+ const index = linesBefore + i
223
+ if (record.type === 'user/message') {
224
+ // 只留真人说的话:source.kind 不是 user 的都是插件/运行时注入的
225
+ // (mnemon 快照、系统提醒、skill 正文),那不是他的原话,进了记忆会污染出处。
226
+ const kind = record.data?.source?.kind
227
+ if (kind !== undefined && kind !== 'user') continue
228
+ const body = blocksToText(record.data?.content)
229
+ if (body !== '') {
230
+ records.push({ index, role: delegated ? 'delegated' : 'user', text: body, time: record.time ?? 0 })
231
+ }
232
+ } else if (record.type === 'assistant/message') {
233
+ const body = blocksToText(record.data?.message?.content)
234
+ if (body !== '') records.push({ index, role: 'assistant', text: body, time: record.time ?? 0 })
235
+ }
236
+ }
237
+ // 结尾那个换行不代表多了一行:不算进去,否则下一批的行号会整体错开一格
238
+ const lineCount = complete ? Math.max(0, pieces.length - 1) : pieces.length
239
+ return { records, delegated, lines: linesBefore + lineCount, carry }
240
+ }
241
+
242
+ function parseSession(file) {
243
+ let decoded
244
+ let size = 0
245
+ try {
246
+ size = statSync(file).size
247
+ decoded = decodeFrom(readFileSync(file), 0)
248
+ } catch {
249
+ // 解不开就如实报告:别让它装作「这个会话是空的」,否则索引会以为自己收全了
250
+ return { records: [], delegated: false, ok: false, end: 0, lines: 0, carry: '' }
251
+ }
252
+ // 文件有内容却一帧都没解出来 = 真的坏了,不能当成「空会话」放过去
253
+ if (size > 0 && decoded.frames === 0) {
254
+ return { records: [], delegated: false, ok: false, end: 0, lines: 0, carry: '' }
255
+ }
256
+ const parsed = parseText(decoded.text)
257
+ return {
258
+ records: parsed.records,
259
+ delegated: parsed.delegated,
260
+ lines: parsed.lines,
261
+ carry: parsed.carry,
262
+ end: decoded.end,
263
+ ok: true,
264
+ }
265
+ }
266
+
267
+ /** 文件开头 64 字节的指纹:用来判断「这份会话是被追写了,还是被重写过」。 */
268
+ function headOf(file) {
269
+ try {
270
+ const fd = openSync(file, 'r')
271
+ try {
272
+ const buf = Buffer.alloc(64)
273
+ const n = readSync(fd, buf, 0, 64, 0)
274
+ return buf.subarray(0, Math.max(0, n)).toString('hex')
275
+ } finally {
276
+ closeSync(fd)
277
+ }
278
+ } catch {
279
+ return ''
280
+ }
281
+ }
282
+
283
+ function tailFrom(file, offset) {
284
+ const fd = openSync(file, 'r')
285
+ try {
286
+ const size = statSync(file).size
287
+ const length = size - offset
288
+ if (length <= 0) return Buffer.alloc(0)
289
+ const buf = Buffer.alloc(length)
290
+ readSync(fd, buf, 0, length, offset)
291
+ return buf
292
+ } finally {
293
+ closeSync(fd)
294
+ }
295
+ }
296
+
297
+ /**
298
+ * 增量:只在「文件被追写」时走这条路 —— 前缀指纹没变、上次停在帧边界、文件变长了。
299
+ * 只读新增的字节再解压,长会话不必每回合把整份历史重新解一遍。
300
+ * 任何一条对不上(重写、截断、换了文件)就返回 null,退回全量解析 —— 慢一点,但不会读错。
301
+ */
302
+ function appendToCache(session, cached, size) {
303
+ try {
304
+ if (cached.failed === true) return null
305
+ if (typeof cached.end !== 'number' || cached.end <= 0 || cached.end > size) return null
306
+ if (cached.head !== headOf(session.file)) return null
307
+ const tail = tailFrom(session.file, cached.end)
308
+ if (tail.length === 0) return null
309
+ // 上次停的位置必须是帧边界,否则从中间切开只会解出一堆垃圾
310
+ if (tail.length >= 4 && !tail.subarray(0, 4).equals(ZSTD_MAGIC)) return null
311
+ const decoded = decodeFrom(tail, 0)
312
+ if (decoded.text === '') return null
313
+ const parsed = parseText((cached.carry ?? '') + decoded.text, {
314
+ delegated: cached.delegated === true,
315
+ linesBefore: cached.lines ?? 0,
316
+ })
317
+ return {
318
+ ...cached,
319
+ v: CACHE_VERSION,
320
+ project: session.project,
321
+ mtime: session.mtime,
322
+ date: new Date(session.mtime).toISOString().slice(0, 10),
323
+ delegated: parsed.delegated,
324
+ records: cached.records.concat(parsed.records),
325
+ failed: false,
326
+ bytes: size,
327
+ end: cached.end + decoded.end,
328
+ lines: parsed.lines,
329
+ carry: parsed.carry,
330
+ }
331
+ } catch {
332
+ return null
333
+ }
334
+ }
335
+
336
+ /** 读会话(带磁盘缓存 + 增量解析)。 */
337
+ export function readSession(session) {
338
+ const dir = cacheDir()
339
+ const cacheFile = join(dir, session.id + '.json')
340
+ let cached = null
341
+ try {
342
+ const raw = JSON.parse(readFileSync(cacheFile, 'utf8'))
343
+ if (raw?.v === CACHE_VERSION && raw.id === session.id && Array.isArray(raw.records)) cached = raw
344
+ } catch {
345
+ /* 没缓存或坏了,重建 */
346
+ }
347
+
348
+ let size = -1
349
+ try {
350
+ size = statSync(session.file).size
351
+ } catch {
352
+ size = -1
353
+ }
354
+
355
+ // 没变过:直接用缓存,一次解压都不做
356
+ if (cached !== null && cached.bytes === size && cached.mtime === session.mtime) return cached
357
+
358
+ // 只追加:只解新加的那几帧
359
+ let payload = null
360
+ if (cached !== null && size > cached.bytes && cached.bytes >= 0) {
361
+ payload = appendToCache(session, cached, size)
362
+ }
363
+
364
+ if (payload === null) {
365
+ const parsed = parseSession(session.file)
366
+ const failed = parsed.ok === false
367
+ payload = {
368
+ v: CACHE_VERSION,
369
+ id: session.id,
370
+ project: session.project,
371
+ mtime: session.mtime,
372
+ date: new Date(session.mtime).toISOString().slice(0, 10),
373
+ delegated: parsed.delegated,
374
+ records: parsed.records,
375
+ failed,
376
+ bytes: failed ? -1 : size,
377
+ end: failed ? 0 : parsed.end,
378
+ lines: parsed.lines,
379
+ carry: parsed.carry,
380
+ head: failed ? '' : headOf(session.file),
381
+ }
382
+ }
383
+ const failed = payload.failed === true
384
+ // 解不开的不要进缓存:缓存一写,下次就照着「空会话」返回,永久看不见问题
385
+ if (failed === false) {
386
+ try {
387
+ ensureDir(dir)
388
+ writeAtomic(cacheFile, JSON.stringify(payload))
389
+ pruneCache()
390
+ } catch {
391
+ /* 写不了缓存不影响检索 */
392
+ }
393
+ }
394
+ return payload
395
+ }
396
+
397
+ let lastPrune = 0
398
+ const PRUNE_MS = 30 * 1000
399
+
400
+ /**
401
+ * 清缓存要节流:全量扫描时每读一个会话就写一次缓存,如果每次写完都 readdir + stat 一遍缓存目录,
402
+ * 300 个会话就是 300 × 300 次 stat,而此刻缓存里大多还是「马上要读的下一个」。
403
+ * 半分钟扫一次足够。
404
+ */
405
+ function pruneCache() {
406
+ const now = Date.now()
407
+ if (now - lastPrune < PRUNE_MS) return
408
+ lastPrune = now
409
+ try {
410
+ const dir = cacheDir()
411
+ const files = readdirSync(dir)
412
+ .filter((name) => name.endsWith('.json'))
413
+ .map((name) => {
414
+ const file = join(dir, name)
415
+ return { file, mtime: statSync(file).mtimeMs }
416
+ })
417
+ .sort((a, b) => b.mtime - a.mtime)
418
+ for (const stale of files.slice(CACHE_LIMIT)) {
419
+ try {
420
+ unlinkSync(stale.file)
421
+ } catch {
422
+ /* Windows 上偶发占用,删不掉就留着 */
423
+ }
424
+ }
425
+ } catch {
426
+ /* 清缓存失败无所谓 */
427
+ }
428
+ }
429
+
430
+ /* ------------------------------ 检索 ------------------------------ */
431
+
432
+ const CJK = /[\u4e00-\u9fff]/
433
+
434
+ export function tokenize(query) {
435
+ return String(query ?? '')
436
+ .toLowerCase()
437
+ .split(/[\s,,.。;;::!!??、|/\\()[\]{}"'“”‘’<>《》]+/)
438
+ .map((term) => term.trim())
439
+ .filter((term) => term !== '')
440
+ }
441
+
442
+ function countOccurrences(haystack, needle) {
443
+ if (needle === '') return 0
444
+ let count = 0
445
+ let from = 0
446
+ for (;;) {
447
+ const at = haystack.indexOf(needle, from)
448
+ if (at < 0) break
449
+ count += 1
450
+ from = at + needle.length
451
+ if (count > 20) break
452
+ }
453
+ return count
454
+ }
455
+
456
+ function snippetAround(text, terms) {
457
+ const lower = text.toLowerCase()
458
+ let at = -1
459
+ for (const term of terms) {
460
+ const found = lower.indexOf(term)
461
+ if (found >= 0 && (at < 0 || found < at)) at = found
462
+ }
463
+ const flat = text.replace(/\s+/g, ' ').trim()
464
+ if (at < 0) return flat.slice(0, SNIPPET_CHARS)
465
+ const start = Math.max(0, at - Math.floor(SNIPPET_CHARS / 3))
466
+ const piece = flat.slice(start, start + SNIPPET_CHARS)
467
+ return (start > 0 ? '…' : '') + piece + (start + SNIPPET_CHARS < flat.length ? '…' : '')
468
+ }
469
+
470
+ /** 在一个会话里打分。AND 语义:每个词都要出现。 */
471
+ function scoreRecords(records, terms) {
472
+ const hits = []
473
+ for (const record of records) {
474
+ const lower = record.text.toLowerCase()
475
+ let matched = 0
476
+ let score = 0
477
+ for (const term of terms) {
478
+ const count = countOccurrences(lower, term)
479
+ if (count > 0) {
480
+ matched += 1
481
+ // 他本人说的话权重最高;委派提示是机器写的,和我的转述同权
482
+ score += Math.min(count, 6) * (record.role === 'user' ? 3 : 1)
483
+ }
484
+ }
485
+ if (matched > 0) hits.push({ record, matched, score })
486
+ }
487
+ return hits
488
+ }
489
+
490
+
491
+ /** 3 = 他本人说的;2 = 我说的;1 = 委派提示(机器写的任务书)。 */
492
+ function roleTier(role) {
493
+ if (role === 'user') return 3
494
+ if (role === 'delegated') return 1
495
+ return 2
496
+ }
497
+
498
+ function rankHits(hits) {
499
+ return hits.sort(
500
+ (a, b) =>
501
+ roleTier(b.role) - roleTier(a.role) ||
502
+ b.score - a.score ||
503
+ (a.date < b.date ? 1 : a.date > b.date ? -1 : 0),
504
+ )
505
+ }
506
+
507
+ /** 线性扫描的时间预算:超了就停,并如实报告没扫完。 */
508
+ export const SCAN_BUDGET_MS = 1200
509
+
510
+ function emptyResult(extra = {}) {
511
+ return { mode: 'raw', total: 0, degraded: false, hits: [], ms: 0, ...extra }
512
+ }
513
+
514
+ /**
515
+ * 线性扫描:从最新的会话往回扫,直到扫完或超时。
516
+ * 关键约定:limit 只砍「返回条数」,不影响「搜索范围」;范围没扫全就标 complete:false。
517
+ */
518
+ export function searchRaw(query, options = {}) {
519
+ const started = Date.now()
520
+ const budget = clampInt(options.budgetMs, SCAN_BUDGET_MS, 200, 10000)
521
+ const terms = tokenize(query)
522
+ const total = listSessions().length
523
+ if (terms.length === 0) {
524
+ return emptyResult({ complete: true, sessions_total: total, sessions_scanned: 0, sessions_failed: 0 })
525
+ }
526
+
527
+ const limit = clampInt(options.limit, 5, 1, 20)
528
+ const since = String(options.since ?? '').trim()
529
+ const pool = listSessions().filter((session) => {
530
+ if (since === '') return true
531
+ return new Date(session.mtime).toISOString().slice(0, 10) >= since
532
+ })
533
+
534
+ const strict = []
535
+ const loose = []
536
+ let scanned = 0
537
+ let failed = 0
538
+ let complete = true
539
+
540
+ for (const session of pool) {
541
+ if (Date.now() - started > budget) {
542
+ complete = false
543
+ break
544
+ }
545
+ scanned += 1
546
+ let parsed = null
547
+ try {
548
+ parsed = readSession(session)
549
+ } catch {
550
+ failed += 1
551
+ continue
552
+ }
553
+ for (const hit of scoreRecords(parsed.records, terms)) {
554
+ const item = {
555
+ session: session.id,
556
+ project: session.project,
557
+ date: parsed.date,
558
+ index: hit.record.index,
559
+ role: hit.record.role,
560
+ matched: hit.matched,
561
+ score: hit.score,
562
+ snippet: snippetAround(hit.record.text, terms),
563
+ }
564
+ if (hit.matched === terms.length) strict.push(item)
565
+ else loose.push(item)
566
+ }
567
+ }
568
+
569
+ const degraded = strict.length === 0 && loose.length > 0
570
+ const chosen = rankHits(degraded ? loose : strict)
571
+
572
+ return {
573
+ mode: 'raw',
574
+ total: chosen.length,
575
+ degraded,
576
+ terms,
577
+ hits: chosen.slice(0, limit),
578
+ ms: Date.now() - started,
579
+ sessions: scanned,
580
+ complete: complete && failed === 0,
581
+ sessions_total: pool.length,
582
+ sessions_scanned: scanned,
583
+ sessions_failed: failed,
584
+ partial: !complete,
585
+ budget_ms: budget,
586
+ }
587
+ }
588
+
589
+ /** 索引给出的候选 id("会话#行号")→ 打分 + 片段。只读命中的那些会话。 */
590
+ export function searchByIds(ids, query, options = {}) {
591
+ const started = Date.now()
592
+ const terms = tokenize(query)
593
+ const limit = clampInt(options.limit, 5, 1, 20)
594
+ // 索引路径也要认 since:否则带筛选的检索会悄悄返回更早的记录。
595
+ const since = String(options.since ?? '').trim()
596
+ const bySession = new Map()
597
+ for (const id of ids) {
598
+ const cut = id.lastIndexOf('#')
599
+ const sid = id.slice(0, cut)
600
+ const index = Number(id.slice(cut + 1))
601
+ if (!bySession.has(sid)) bySession.set(sid, new Set())
602
+ bySession.get(sid).add(index)
603
+ }
604
+ const sessions = new Map(listSessions().map((session) => [session.id, session]))
605
+ const hits = []
606
+ let failed = 0
607
+ for (const [sid, wanted] of bySession) {
608
+ const session = sessions.get(sid)
609
+ if (session === undefined) {
610
+ failed += 1
611
+ continue
612
+ }
613
+ let parsed = null
614
+ try {
615
+ parsed = readSession(session)
616
+ } catch {
617
+ failed += 1
618
+ continue
619
+ }
620
+ if (since !== '' && parsed.date < since) continue
621
+ for (const record of parsed.records) {
622
+ if (!wanted.has(record.index)) continue
623
+ const scored = scoreRecords([record], terms)[0]
624
+ hits.push({
625
+ session: sid,
626
+ project: session.project,
627
+ date: parsed.date,
628
+ index: record.index,
629
+ role: record.role,
630
+ matched: scored?.matched ?? 0,
631
+ score: scored?.score ?? 0,
632
+ snippet: snippetAround(record.text, terms),
633
+ })
634
+ }
635
+ }
636
+ rankHits(hits)
637
+ return {
638
+ mode: 'raw',
639
+ source: 'index',
640
+ total: hits.length,
641
+ // 索引那边没有严格命中时会退回「词的并集」并置 degraded;这个信息得透传出来,
642
+ // 不然界面会说「没放宽词」,用户以为结果本来就该这么少。
643
+ degraded: options.degraded === true,
644
+ terms,
645
+ hits: hits.slice(0, limit),
646
+ ms: Date.now() - started,
647
+ complete: failed === 0,
648
+ sessions_total: sessions.size,
649
+ sessions_scanned: bySession.size,
650
+ sessions_failed: failed,
651
+ }
652
+ }
653
+
654
+ /* ------------------------------ 事实(P2 的位子) ------------------------------ */
655
+
656
+ export function readFacts() {
657
+ const file = factsPath()
658
+ if (!existsSync(file)) return []
659
+ const out = []
660
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
661
+ const text = line.trim()
662
+ if (text === '') continue
663
+ try {
664
+ const fact = JSON.parse(text)
665
+ if (fact !== null && typeof fact === 'object') out.push(fact)
666
+ } catch {
667
+ /* 坏行跳过 */
668
+ }
669
+ }
670
+ return out
671
+ }
672
+
673
+ export function writeFacts(facts) {
674
+ ensureDir(memoryRoot())
675
+ writeAtomic(factsPath(), facts.map((fact) => JSON.stringify(fact)).join('\n') + (facts.length > 0 ? '\n' : ''))
676
+ }
677
+
678
+ const BACKOFF = new Int32Array(new SharedArrayBuffer(4))
679
+
680
+ /** 同步等一会儿再重试:mutate 里不能有 await,靠这个让出窗口给别的写入。 */
681
+ function pause(ms) {
682
+ try {
683
+ Atomics.wait(BACKOFF, 0, 0, ms)
684
+ } catch {
685
+ /* 部分环境不支持就退化成不让 */
686
+ }
687
+ }
688
+
689
+ /**
690
+ * 跨进程互斥:拿锁靠「独占创建」这一个原子动作(open 'wx'),不是先查后写 ——
691
+ * 先查再写那一步本身就不是原子的(两个进程会同时看到"没人占")。
692
+ * 进程被杀会留下锁文件,超过 STALE_LOCK_MS 就当成遗留废锁接管。
693
+ */
694
+ const STALE_LOCK_MS = 2000
695
+
696
+ function lockPath() {
697
+ return factsPath() + '.lock'
698
+ }
699
+
700
+ function acquireLock(deadlineMs = 150) {
701
+ ensureDir(memoryRoot())
702
+ const file = lockPath()
703
+ const start = Date.now()
704
+ for (;;) {
705
+ try {
706
+ closeSync(openSync(file, 'wx'))
707
+ return true
708
+ } catch (error) {
709
+ if (error?.code !== 'EEXIST') return false
710
+ try {
711
+ if (Date.now() - statSync(file).mtimeMs > STALE_LOCK_MS) {
712
+ unlinkSync(file)
713
+ continue
714
+ }
715
+ } catch {
716
+ // 锁文件刚被别人删掉:直接重试
717
+ }
718
+ if (Date.now() - start >= deadlineMs) return false
719
+ pause(1)
720
+ }
721
+ }
722
+ }
723
+
724
+ function releaseLock() {
725
+ try {
726
+ unlinkSync(lockPath())
727
+ } catch {
728
+ /* 已经没了就算了 */
729
+ }
730
+ }
731
+
732
+ /**
733
+ * facts.jsonl 的唯一写入入口:拿锁 → 读 → 改 → 写 → 放锁,全在一段同步代码里。
734
+ *
735
+ * 为什么需要这一层:
736
+ * 1. 注入时记热度(touchFacts)和自动采集写候选,都是「读整份 → 改 → 整份写回」。
737
+ * 谁最后写,谁的内容就赢;先写的那次改动会被静默覆盖(热度回滚、候选丢失)。
738
+ * 2. mutate() 里绝不能出现 await —— 整段同步执行,本进程内别的写入插不进来。
739
+ * 3. 跨进程(比如又开了一个窗口)靠锁文件互斥;拿不到锁就重试,几次都拿不到就放弃并返回 error,
740
+ * 绝不写半份。热度那种软数据下回合再来就行,候选这种别当成功。
741
+ */
742
+ export function mutateFacts(mutate, attempts = 5, deadlineMs = 150) {
743
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
744
+ if (attempt > 0) pause(1)
745
+ if (!acquireLock(deadlineMs)) continue
746
+ try {
747
+ const facts = readFacts()
748
+ const outcome = mutate(facts) ?? { changed: false }
749
+ if (outcome.changed === true) writeFacts(facts)
750
+ return outcome
751
+ } finally {
752
+ releaseLock()
753
+ }
754
+ }
755
+ return { changed: false, error: 'facts 一直被别的进程占着,这次没有写入' }
756
+ }
757
+
758
+ /** 写一条候选事实。没有可核对的出处就不许进库。 */
759
+ export function stageFact(input = {}) {
760
+ const text = String(input.text ?? '').trim()
761
+ if (text === '') return { ok: false, error: 'text 不能为空' }
762
+ if (text.length > 240) return { ok: false, error: '一条事实最多 240 字,拆成两条' }
763
+ const check = checkCitation(input.citation)
764
+ if (!check.ok) return { ok: false, error: check.error }
765
+
766
+ const fact = {
767
+ id: 'f' + Math.random().toString(36).slice(2, 10),
768
+ text,
769
+ kind: String(input.kind ?? 'fact').trim() || 'fact',
770
+ // channel 决定注入到哪一段:rel=关于这个人,env=本机环境。默认 rel,老数据没有这个字段也按 rel 算。
771
+ channel: input.channel === 'env' ? 'env' : 'rel',
772
+ tags: Array.isArray(input.tags) ? input.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 6) : [],
773
+ citation: {
774
+ sessionId: check.session.id,
775
+ index: Number(input.citation.index),
776
+ date: check.date,
777
+ },
778
+ status: 'candidate',
779
+ created: new Date().toISOString().slice(0, 10),
780
+ }
781
+ // 必须走事务:appendFileSync 绕开锁,而并发的「读整份 → rename 整份写」会拿没有这条的快照覆盖回去,
782
+ // 刚提交的候选就静默消失了。判重也放进锁里 —— 锁外那次只是省事,拦不住并发。
783
+ const outcome = mutateFacts((facts) => {
784
+ if (facts.some((item) => String(item.text ?? '').trim() === text)) return { changed: false }
785
+ facts.push(fact)
786
+ return { changed: true }
787
+ })
788
+ if (outcome?.changed !== true) {
789
+ return { ok: false, error: outcome?.error ?? '这条已经记过了' }
790
+ }
791
+ return { ok: true, fact }
792
+ }
793
+
794
+ /**
795
+ * 改一条事实的正文。状态不动、id 不动、出处照旧 —— 改的是措辞,不是换了一条结论。
796
+ * 和改状态一样走事务:并发写整份文件时不能拿旧快照覆盖回去。
797
+ */
798
+ export function setFactText(id, text, meta = {}) {
799
+ const clean = String(text ?? '').replace(/\s+/g, ' ').trim()
800
+ if (clean === '') return { ok: false, error: '内容不能为空' }
801
+ if (clean.length > 240) return { ok: false, error: '一条事实最多 240 字,拆成两条' }
802
+ let before = ''
803
+ let updated = null
804
+ const outcome = mutateFacts((facts) => {
805
+ const fact = facts.find((item) => item.id === id)
806
+ if (fact === undefined) return { changed: false, error: '没有这条记忆(可能刚被改过),刷新一下' }
807
+ if (String(fact.text ?? '') === clean) return { changed: false, unchanged: true }
808
+ before = String(fact.text ?? '')
809
+ const at = new Date().toISOString()
810
+ fact.text = clean
811
+ fact.updated = at.slice(0, 10)
812
+ // 和状态变更同一条留痕规则:谁、什么时候、从哪、改了什么
813
+ fact.history = [...(fact.history ?? []), { at, from: 'text', to: 'text', via: meta.via ?? 'unknown', before }]
814
+ updated = fact
815
+ return { changed: true }
816
+ })
817
+ if (outcome?.error) return { ok: false, error: outcome.error }
818
+ return { ok: true, fact: updated, unchanged: outcome?.unchanged === true }
819
+ }
820
+
821
+ /** 候选 → 采纳 / 丢弃;已采纳的还能退役。退役保留条目,只是不再注入和检索。 */
822
+ export function setFactStatus(id, status, meta = {}) {
823
+ const wanted = String(status ?? '')
824
+ if (!['approved', 'retired', 'candidate'].includes(wanted)) {
825
+ return { ok: false, error: `status 只能是 approved / retired / candidate` }
826
+ }
827
+ let updated = null
828
+ const outcome = mutateFacts((facts) => {
829
+ const fact = facts.find((item) => item.id === id)
830
+ if (fact === undefined) return { changed: false }
831
+ const at = new Date().toISOString()
832
+ fact.status = wanted
833
+ fact.updated = at.slice(0, 10)
834
+ fact.reviewedAt = at
835
+ if (wanted === 'retired') {
836
+ fact.retiredAt = fact.updated
837
+ } else {
838
+ // 从 retired 改回 approved / candidate 时,退役日期必须清掉。
839
+ // 否则会留下「status=approved 却带 retiredAt」的自相矛盾记录,
840
+ // 而且这个残留在后续任何一次改状态时都不会被清,属于永久脏数据。
841
+ delete fact.retiredAt
842
+ }
843
+ // 审核要留痕:谁在什么时候、从哪、把状态从什么改成了什么
844
+ fact.history = [...(fact.history ?? []), { at, from: meta.from ?? null, to: wanted, via: meta.via ?? 'unknown' }]
845
+ updated = fact
846
+ return { changed: true }
847
+ })
848
+ if (outcome?.error) return { ok: false, error: outcome.error }
849
+ if (updated === null) return { ok: false, error: `找不到事实 ${id}` }
850
+ return { ok: true, fact: updated }
851
+ }
852
+
853
+
854
+ /* ------------------------------ 热度(P0:遗忘曲线) ------------------------------ */
855
+
856
+ export const DEFAULT_HALF_LIFE_DAYS = 30
857
+
858
+ const TOUCH_INTERVAL_MS = 60 * 60 * 1000
859
+
860
+ /**
861
+ * 一条事实现在还有多"热":用得越多越高,越久没用越低。
862
+ * 只影响激活优先级,不改可信度 —— 冷下去的记忆照样在库里,随时能被检索捞回来。
863
+ */
864
+ export function factHeat(fact, now = Date.now(), halfLifeDays = DEFAULT_HALF_LIFE_DAYS) {
865
+ const hits = Number(fact.hitCount ?? 0) || 0
866
+ const importance = clampInt(fact.importance, 1, 0, 3)
867
+ const stamp = fact.lastHit ?? fact.updated ?? fact.created ?? ''
868
+ const parsed = Date.parse(stamp)
869
+ const base = Number.isNaN(parsed) ? now : parsed
870
+ const ageDays = Math.max(0, (now - base) / 86400000)
871
+ const decay = Math.pow(0.5, ageDays / Math.max(1, halfLifeDays))
872
+ const usage = 1 + Math.log1p(hits)
873
+ return Math.round(usage * (1 + importance * 0.5) * decay * 100) / 100
874
+ }
875
+
876
+ /**
877
+ * 记录"这条事实真的进了注入"。
878
+ * 注意:检索命中不调用它 —— 只被搜到、没进上下文的记忆不该因此保持活跃。
879
+ * 一小时内的重复注入只算一次,避免同一天里每回合都往上涨。
880
+ */
881
+ export function touchFacts(ids, now = Date.now()) {
882
+ const wanted = new Set(ids ?? [])
883
+ if (wanted.size === 0) return 0
884
+ // 必须存完整时刻,不能只存日期:下面用 now - Date.parse(lastHit) < 1h 去重,
885
+ // 而 'YYYY-MM-DD' 解析出来是当天 00:00 —— 过了午夜那一小时窗口就永远失效,
886
+ // 每装配一次都会 +1(实测 25 分钟能连涨 3 次,把「进过几次」记成「算过几次」)。
887
+ const stamp = new Date(now).toISOString()
888
+ // 热度是软数据:抢不到锁就这回合先不记(lastHit 没变,下一回合还会再试),
889
+ // 别为了记一次点击把事件循环卡住 —— 所以给它的重试窗口比别的写入短。
890
+ const outcome = mutateFacts((facts) => {
891
+ let changed = 0
892
+ for (const fact of facts) {
893
+ if (!wanted.has(fact.id)) continue
894
+ const last = Date.parse(fact.lastHit ?? '')
895
+ if (!Number.isNaN(last) && now - last < TOUCH_INTERVAL_MS) continue
896
+ fact.hitCount = (Number(fact.hitCount ?? 0) || 0) + 1
897
+ fact.lastHit = stamp
898
+ changed += 1
899
+ }
900
+ return { changed: changed > 0, count: changed }
901
+ }, 2, 20)
902
+ return outcome.count ?? 0
903
+ }
904
+
905
+ /**
906
+ * 记录"这条事实被检索翻到过"。
907
+ *
908
+ * 与 touchFacts 的区别:这是**弱证据** —— 搜到不等于进了上下文,所以不动 hitCount
909
+ * (热度是"占了多少位置"),单独记 seenCount / lastSeen。
910
+ * 用途:归属统计不该把"被搜到过很多次"的记忆说成"从没进过"。
911
+ */
912
+ export const SEEN_INTERVAL_MS = 10 * 60 * 1000
913
+
914
+ export function seenFacts(ids, now = Date.now()) {
915
+ const wanted = new Set(ids ?? [])
916
+ if (wanted.size === 0) return 0
917
+ const stamp = new Date(now).toISOString()
918
+ // 和 touchFacts 一样是软写入:抢不到锁就这次先不记,别为一次记账卡住事件循环。
919
+ const outcome = mutateFacts((facts) => {
920
+ let changed = 0
921
+ for (const fact of facts) {
922
+ if (!wanted.has(fact.id)) continue
923
+ const last = Date.parse(fact.lastSeen ?? '')
924
+ if (!Number.isNaN(last) && now - last < SEEN_INTERVAL_MS) continue
925
+ fact.seenCount = (Number(fact.seenCount ?? 0) || 0) + 1
926
+ fact.lastSeen = stamp
927
+ changed += 1
928
+ }
929
+ return { changed: changed > 0, count: changed }
930
+ }, 2, 20)
931
+ return outcome.count ?? 0
932
+ }
933
+
934
+ /** 按热度排序:热在前;同热度时新的在前。 */
935
+ export function rankByHeat(facts, options = {}) {
936
+ const now = Number(options.now ?? Date.now())
937
+ const halfLife = Number(options.halfLifeDays ?? DEFAULT_HALF_LIFE_DAYS)
938
+ return facts
939
+ .map((fact) => ({ fact, heat: factHeat(fact, now, halfLife) }))
940
+ .sort((a, b) => b.heat - a.heat || String(b.fact.updated ?? '').localeCompare(String(a.fact.updated ?? '')))
941
+ }
942
+
943
+ export function listFacts(status) {
944
+ const facts = readFacts()
945
+ return status === undefined ? facts : facts.filter((fact) => fact.status === status)
946
+ }
947
+
948
+ /* ------------------------------ 习惯 ------------------------------ */
949
+
950
+ export const habitsPath = () => join(memoryRoot(), 'habits.jsonl')
951
+
952
+ export function readHabits() {
953
+ const file = habitsPath()
954
+ if (!existsSync(file)) return []
955
+ const out = []
956
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
957
+ const text = line.trim()
958
+ if (text === '') continue
959
+ try {
960
+ const habit = JSON.parse(text)
961
+ if (habit !== null && typeof habit === 'object') out.push(habit)
962
+ } catch {
963
+ /* 坏行跳过 */
964
+ }
965
+ }
966
+ return out
967
+ }
968
+
969
+ function writeHabits(habits) {
970
+ ensureDir(memoryRoot())
971
+ writeAtomic(habitsPath(), habits.map((habit) => JSON.stringify(habit)).join('\n') + (habits.length > 0 ? '\n' : ''))
972
+ }
973
+
974
+ export { writeHabits }
975
+
976
+ /** 升一条习惯(只能由人点,模型没有这个工具)。 */
977
+ export function setHabit(text, status, tag) {
978
+ const wanted = String(status ?? '')
979
+ const clean = String(text ?? '').trim()
980
+ if (clean === '') return { ok: false, error: 'text 不能为空' }
981
+ if (clean.length > 80) return { ok: false, error: '一条习惯最多 80 字' }
982
+ if (!['active', 'retired'].includes(wanted)) return { ok: false, error: 'status 只能是 active / retired' }
983
+
984
+ const habits = readHabits()
985
+ const existing = habits.find((habit) => habit.text === clean)
986
+ if (existing !== undefined) {
987
+ existing.status = wanted
988
+ if (tag !== undefined) existing.tag = String(tag)
989
+ } else {
990
+ habits.push({ text: clean, tag: String(tag ?? ''), status: wanted, created: new Date().toISOString().slice(0, 10) })
991
+ }
992
+ writeHabits(habits)
993
+ return { ok: true, habits: habits.filter((habit) => habit.status === 'active') }
994
+ }
995
+
996
+ /**
997
+ * 习惯建议:同一个标签在已采纳事实里出现 ≥3 次,就提示可以升成习惯。
998
+ * 不自动升 —— 模型对自己下判断这件事,得人点头。
999
+ */
1000
+ export function habitSuggestions(threshold = 3) {
1001
+ const counts = new Map()
1002
+ for (const fact of listFacts('approved')) {
1003
+ for (const tag of Array.isArray(fact.tags) ? fact.tags : []) {
1004
+ counts.set(tag, (counts.get(tag) ?? 0) + 1)
1005
+ }
1006
+ }
1007
+ const active = new Set(
1008
+ readHabits()
1009
+ .filter((habit) => habit.status === 'active')
1010
+ .flatMap((habit) => [String(habit.tag ?? ''), String(habit.text ?? '')]),
1011
+ )
1012
+ return [...counts.entries()]
1013
+ .filter(([, count]) => count >= threshold)
1014
+ .sort((a, b) => b[1] - a[1])
1015
+ .map(([tag, count]) => ({ tag, count, active: active.has(tag) }))
1016
+ }
1017
+
1018
+ /** citation 必须能落到真实的一轮上,否则这条事实不许存在。 */
1019
+ export function checkCitation(citation) {
1020
+ if (citation === null || typeof citation !== 'object') return { ok: false, error: '缺少 citation' }
1021
+ const sessionId = String(citation.sessionId ?? '')
1022
+ const index = Number(citation.index)
1023
+ if (sessionId === '') return { ok: false, error: 'citation.sessionId 为空' }
1024
+ if (!Number.isInteger(index) || index < 0) return { ok: false, error: 'citation.index 不是有效行号' }
1025
+ const session = listSessions().find((item) => item.id === sessionId)
1026
+ if (session === undefined) return { ok: false, error: `找不到会话 ${sessionId}` }
1027
+ const parsed = readSession(session)
1028
+ const record = parsed.records.find((item) => item.index === index)
1029
+ if (record === undefined) return { ok: false, error: `会话 ${sessionId} 第 ${index} 条不是可检索的消息` }
1030
+ // 日期优先用这条消息自己的时间;没有就退回会话文件时间
1031
+ const date = record.time > 0 ? new Date(record.time).toISOString().slice(0, 10) : parsed.date
1032
+ return { ok: true, session, record, date }
1033
+ }
1034
+
1035
+ export function searchFacts(query, options = {}) {
1036
+ const terms = tokenize(query)
1037
+ const facts = readFacts().filter((fact) => fact.status === 'approved')
1038
+ const scored = []
1039
+ for (const fact of facts) {
1040
+ const lower = (String(fact.text ?? '') + ' ' + String(fact.detail ?? '')).toLowerCase()
1041
+ let matched = 0
1042
+ let score = 0
1043
+ for (const term of terms) {
1044
+ const count = countOccurrences(lower, term)
1045
+ if (count > 0) {
1046
+ matched += 1
1047
+ score += Math.min(count, 6) * 3
1048
+ }
1049
+ }
1050
+ if (matched > 0) scored.push({ fact, matched, score })
1051
+ }
1052
+ const strict = scored.filter((item) => item.matched === terms.length)
1053
+ const pool = strict.length > 0 ? strict : scored
1054
+ pool.sort((a, b) => b.score - a.score)
1055
+ return {
1056
+ mode: 'facts',
1057
+ total: pool.length,
1058
+ hits: pool.slice(0, clampInt(options.limit, 5, 1, 20)).map((item) => item.fact),
1059
+ }
1060
+ }
1061
+
1062
+ /* ------------------------------ 对外 ------------------------------ */
1063
+
1064
+ export function recall(query, options = {}) {
1065
+ const mode = String(options.mode ?? 'all')
1066
+ const limit = clampInt(options.limit, 5, 1, 20)
1067
+ const since = String(options.since ?? '').trim()
1068
+ const parts = {}
1069
+ if (mode === 'all' || mode === 'facts') parts.facts = searchFacts(query, { limit })
1070
+ if (mode === 'all' || mode === 'raw') {
1071
+ parts.raw = Array.isArray(options.ids)
1072
+ ? searchByIds(options.ids, query, { limit, since })
1073
+ : searchRaw(query, { limit, since })
1074
+ }
1075
+ return { query, mode, since, ...parts }
1076
+ }
1077
+
1078
+ export function memoryStats() {
1079
+ const facts = readFacts()
1080
+ const cache = existsSync(cacheDir()) ? readdirSync(cacheDir()).filter((n) => n.endsWith('.json')).length : 0
1081
+ const sessions = listSessions().length
1082
+ const state = readState()
1083
+ return {
1084
+ facts: facts.length,
1085
+ approved: facts.filter((fact) => fact.status === 'approved').length,
1086
+ candidates: facts.filter((fact) => fact.status === 'candidate').length,
1087
+ retired: facts.filter((fact) => fact.status === 'retired').length,
1088
+ habits: readHabits().filter((habit) => habit.status === 'active').length,
1089
+ sessions,
1090
+ cache,
1091
+ lastDigest: state.lastDigest ?? null,
1092
+ root: memoryRoot(),
1093
+ }
1094
+ }
1095
+
1096
+ function clampInt(value, fallback, lo, hi) {
1097
+ const num = Number(value ?? fallback)
1098
+ const safe = Number.isFinite(num) ? Math.trunc(num) : fallback
1099
+ return Math.max(lo, Math.min(safe, hi))
1100
+ }