dsh-selfnote 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +445 -2
- package/assets/cover.jpg +0 -0
- package/cordis.patch.yml +4 -0
- package/lib/atomic.js +78 -0
- package/lib/capture-run.js +178 -0
- package/lib/capture.js +433 -0
- package/lib/client.js +1713 -0
- package/lib/index-store.js +404 -0
- package/lib/index.js +2097 -0
- package/lib/memory.js +1097 -0
- package/lib/profile.js +70 -0
- package/lib/store.js +257 -0
- package/lib/transfer.js +154 -0
- package/lib/warm.js +155 -0
- package/package.json +57 -4
package/lib/memory.js
ADDED
|
@@ -0,0 +1,1097 @@
|
|
|
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
|
+
const stamp = new Date(now).toISOString().slice(0, 10)
|
|
885
|
+
// 热度是软数据:抢不到锁就这回合先不记(lastHit 没变,下一回合还会再试),
|
|
886
|
+
// 别为了记一次点击把事件循环卡住 —— 所以给它的重试窗口比别的写入短。
|
|
887
|
+
const outcome = mutateFacts((facts) => {
|
|
888
|
+
let changed = 0
|
|
889
|
+
for (const fact of facts) {
|
|
890
|
+
if (!wanted.has(fact.id)) continue
|
|
891
|
+
const last = Date.parse(fact.lastHit ?? '')
|
|
892
|
+
if (!Number.isNaN(last) && now - last < TOUCH_INTERVAL_MS) continue
|
|
893
|
+
fact.hitCount = (Number(fact.hitCount ?? 0) || 0) + 1
|
|
894
|
+
fact.lastHit = stamp
|
|
895
|
+
changed += 1
|
|
896
|
+
}
|
|
897
|
+
return { changed: changed > 0, count: changed }
|
|
898
|
+
}, 2, 20)
|
|
899
|
+
return outcome.count ?? 0
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* 记录"这条事实被检索翻到过"。
|
|
904
|
+
*
|
|
905
|
+
* 与 touchFacts 的区别:这是**弱证据** —— 搜到不等于进了上下文,所以不动 hitCount
|
|
906
|
+
* (热度是"占了多少位置"),单独记 seenCount / lastSeen。
|
|
907
|
+
* 用途:归属统计不该把"被搜到过很多次"的记忆说成"从没进过"。
|
|
908
|
+
*/
|
|
909
|
+
export const SEEN_INTERVAL_MS = 10 * 60 * 1000
|
|
910
|
+
|
|
911
|
+
export function seenFacts(ids, now = Date.now()) {
|
|
912
|
+
const wanted = new Set(ids ?? [])
|
|
913
|
+
if (wanted.size === 0) return 0
|
|
914
|
+
const stamp = new Date(now).toISOString()
|
|
915
|
+
// 和 touchFacts 一样是软写入:抢不到锁就这次先不记,别为一次记账卡住事件循环。
|
|
916
|
+
const outcome = mutateFacts((facts) => {
|
|
917
|
+
let changed = 0
|
|
918
|
+
for (const fact of facts) {
|
|
919
|
+
if (!wanted.has(fact.id)) continue
|
|
920
|
+
const last = Date.parse(fact.lastSeen ?? '')
|
|
921
|
+
if (!Number.isNaN(last) && now - last < SEEN_INTERVAL_MS) continue
|
|
922
|
+
fact.seenCount = (Number(fact.seenCount ?? 0) || 0) + 1
|
|
923
|
+
fact.lastSeen = stamp
|
|
924
|
+
changed += 1
|
|
925
|
+
}
|
|
926
|
+
return { changed: changed > 0, count: changed }
|
|
927
|
+
}, 2, 20)
|
|
928
|
+
return outcome.count ?? 0
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** 按热度排序:热在前;同热度时新的在前。 */
|
|
932
|
+
export function rankByHeat(facts, options = {}) {
|
|
933
|
+
const now = Number(options.now ?? Date.now())
|
|
934
|
+
const halfLife = Number(options.halfLifeDays ?? DEFAULT_HALF_LIFE_DAYS)
|
|
935
|
+
return facts
|
|
936
|
+
.map((fact) => ({ fact, heat: factHeat(fact, now, halfLife) }))
|
|
937
|
+
.sort((a, b) => b.heat - a.heat || String(b.fact.updated ?? '').localeCompare(String(a.fact.updated ?? '')))
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
export function listFacts(status) {
|
|
941
|
+
const facts = readFacts()
|
|
942
|
+
return status === undefined ? facts : facts.filter((fact) => fact.status === status)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/* ------------------------------ 习惯 ------------------------------ */
|
|
946
|
+
|
|
947
|
+
export const habitsPath = () => join(memoryRoot(), 'habits.jsonl')
|
|
948
|
+
|
|
949
|
+
export function readHabits() {
|
|
950
|
+
const file = habitsPath()
|
|
951
|
+
if (!existsSync(file)) return []
|
|
952
|
+
const out = []
|
|
953
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
954
|
+
const text = line.trim()
|
|
955
|
+
if (text === '') continue
|
|
956
|
+
try {
|
|
957
|
+
const habit = JSON.parse(text)
|
|
958
|
+
if (habit !== null && typeof habit === 'object') out.push(habit)
|
|
959
|
+
} catch {
|
|
960
|
+
/* 坏行跳过 */
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return out
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function writeHabits(habits) {
|
|
967
|
+
ensureDir(memoryRoot())
|
|
968
|
+
writeAtomic(habitsPath(), habits.map((habit) => JSON.stringify(habit)).join('\n') + (habits.length > 0 ? '\n' : ''))
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export { writeHabits }
|
|
972
|
+
|
|
973
|
+
/** 升一条习惯(只能由人点,模型没有这个工具)。 */
|
|
974
|
+
export function setHabit(text, status, tag) {
|
|
975
|
+
const wanted = String(status ?? '')
|
|
976
|
+
const clean = String(text ?? '').trim()
|
|
977
|
+
if (clean === '') return { ok: false, error: 'text 不能为空' }
|
|
978
|
+
if (clean.length > 80) return { ok: false, error: '一条习惯最多 80 字' }
|
|
979
|
+
if (!['active', 'retired'].includes(wanted)) return { ok: false, error: 'status 只能是 active / retired' }
|
|
980
|
+
|
|
981
|
+
const habits = readHabits()
|
|
982
|
+
const existing = habits.find((habit) => habit.text === clean)
|
|
983
|
+
if (existing !== undefined) {
|
|
984
|
+
existing.status = wanted
|
|
985
|
+
if (tag !== undefined) existing.tag = String(tag)
|
|
986
|
+
} else {
|
|
987
|
+
habits.push({ text: clean, tag: String(tag ?? ''), status: wanted, created: new Date().toISOString().slice(0, 10) })
|
|
988
|
+
}
|
|
989
|
+
writeHabits(habits)
|
|
990
|
+
return { ok: true, habits: habits.filter((habit) => habit.status === 'active') }
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* 习惯建议:同一个标签在已采纳事实里出现 ≥3 次,就提示可以升成习惯。
|
|
995
|
+
* 不自动升 —— 模型对自己下判断这件事,得人点头。
|
|
996
|
+
*/
|
|
997
|
+
export function habitSuggestions(threshold = 3) {
|
|
998
|
+
const counts = new Map()
|
|
999
|
+
for (const fact of listFacts('approved')) {
|
|
1000
|
+
for (const tag of Array.isArray(fact.tags) ? fact.tags : []) {
|
|
1001
|
+
counts.set(tag, (counts.get(tag) ?? 0) + 1)
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
const active = new Set(
|
|
1005
|
+
readHabits()
|
|
1006
|
+
.filter((habit) => habit.status === 'active')
|
|
1007
|
+
.flatMap((habit) => [String(habit.tag ?? ''), String(habit.text ?? '')]),
|
|
1008
|
+
)
|
|
1009
|
+
return [...counts.entries()]
|
|
1010
|
+
.filter(([, count]) => count >= threshold)
|
|
1011
|
+
.sort((a, b) => b[1] - a[1])
|
|
1012
|
+
.map(([tag, count]) => ({ tag, count, active: active.has(tag) }))
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** citation 必须能落到真实的一轮上,否则这条事实不许存在。 */
|
|
1016
|
+
export function checkCitation(citation) {
|
|
1017
|
+
if (citation === null || typeof citation !== 'object') return { ok: false, error: '缺少 citation' }
|
|
1018
|
+
const sessionId = String(citation.sessionId ?? '')
|
|
1019
|
+
const index = Number(citation.index)
|
|
1020
|
+
if (sessionId === '') return { ok: false, error: 'citation.sessionId 为空' }
|
|
1021
|
+
if (!Number.isInteger(index) || index < 0) return { ok: false, error: 'citation.index 不是有效行号' }
|
|
1022
|
+
const session = listSessions().find((item) => item.id === sessionId)
|
|
1023
|
+
if (session === undefined) return { ok: false, error: `找不到会话 ${sessionId}` }
|
|
1024
|
+
const parsed = readSession(session)
|
|
1025
|
+
const record = parsed.records.find((item) => item.index === index)
|
|
1026
|
+
if (record === undefined) return { ok: false, error: `会话 ${sessionId} 第 ${index} 条不是可检索的消息` }
|
|
1027
|
+
// 日期优先用这条消息自己的时间;没有就退回会话文件时间
|
|
1028
|
+
const date = record.time > 0 ? new Date(record.time).toISOString().slice(0, 10) : parsed.date
|
|
1029
|
+
return { ok: true, session, record, date }
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
export function searchFacts(query, options = {}) {
|
|
1033
|
+
const terms = tokenize(query)
|
|
1034
|
+
const facts = readFacts().filter((fact) => fact.status === 'approved')
|
|
1035
|
+
const scored = []
|
|
1036
|
+
for (const fact of facts) {
|
|
1037
|
+
const lower = (String(fact.text ?? '') + ' ' + String(fact.detail ?? '')).toLowerCase()
|
|
1038
|
+
let matched = 0
|
|
1039
|
+
let score = 0
|
|
1040
|
+
for (const term of terms) {
|
|
1041
|
+
const count = countOccurrences(lower, term)
|
|
1042
|
+
if (count > 0) {
|
|
1043
|
+
matched += 1
|
|
1044
|
+
score += Math.min(count, 6) * 3
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (matched > 0) scored.push({ fact, matched, score })
|
|
1048
|
+
}
|
|
1049
|
+
const strict = scored.filter((item) => item.matched === terms.length)
|
|
1050
|
+
const pool = strict.length > 0 ? strict : scored
|
|
1051
|
+
pool.sort((a, b) => b.score - a.score)
|
|
1052
|
+
return {
|
|
1053
|
+
mode: 'facts',
|
|
1054
|
+
total: pool.length,
|
|
1055
|
+
hits: pool.slice(0, clampInt(options.limit, 5, 1, 20)).map((item) => item.fact),
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/* ------------------------------ 对外 ------------------------------ */
|
|
1060
|
+
|
|
1061
|
+
export function recall(query, options = {}) {
|
|
1062
|
+
const mode = String(options.mode ?? 'all')
|
|
1063
|
+
const limit = clampInt(options.limit, 5, 1, 20)
|
|
1064
|
+
const since = String(options.since ?? '').trim()
|
|
1065
|
+
const parts = {}
|
|
1066
|
+
if (mode === 'all' || mode === 'facts') parts.facts = searchFacts(query, { limit })
|
|
1067
|
+
if (mode === 'all' || mode === 'raw') {
|
|
1068
|
+
parts.raw = Array.isArray(options.ids)
|
|
1069
|
+
? searchByIds(options.ids, query, { limit, since })
|
|
1070
|
+
: searchRaw(query, { limit, since })
|
|
1071
|
+
}
|
|
1072
|
+
return { query, mode, since, ...parts }
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
export function memoryStats() {
|
|
1076
|
+
const facts = readFacts()
|
|
1077
|
+
const cache = existsSync(cacheDir()) ? readdirSync(cacheDir()).filter((n) => n.endsWith('.json')).length : 0
|
|
1078
|
+
const sessions = listSessions().length
|
|
1079
|
+
const state = readState()
|
|
1080
|
+
return {
|
|
1081
|
+
facts: facts.length,
|
|
1082
|
+
approved: facts.filter((fact) => fact.status === 'approved').length,
|
|
1083
|
+
candidates: facts.filter((fact) => fact.status === 'candidate').length,
|
|
1084
|
+
retired: facts.filter((fact) => fact.status === 'retired').length,
|
|
1085
|
+
habits: readHabits().filter((habit) => habit.status === 'active').length,
|
|
1086
|
+
sessions,
|
|
1087
|
+
cache,
|
|
1088
|
+
lastDigest: state.lastDigest ?? null,
|
|
1089
|
+
root: memoryRoot(),
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function clampInt(value, fallback, lo, hi) {
|
|
1094
|
+
const num = Number(value ?? fallback)
|
|
1095
|
+
const safe = Number.isFinite(num) ? Math.trunc(num) : fallback
|
|
1096
|
+
return Math.max(lo, Math.min(safe, hi))
|
|
1097
|
+
}
|