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.
@@ -0,0 +1,404 @@
1
+ /**
2
+ * 记忆索引(P5b 的 B 方案)
3
+ *
4
+ * 只索引「哪个词出现在哪条记录」,不索引正文:
5
+ * postings: 词元 → ["sessionId#行号", …]
6
+ * records: 行号 → { r: 'u'|'a', t: 时间 }
7
+ *
8
+ * 查询时先用倒排拿到候选 id,再只读那几个会话的缓存取正文做打分和截片段。
9
+ * 所以索引体积跟「词元数量」成正比,而不是跟原文长度成正比。
10
+ *
11
+ * 中文按 bigram 切,英文按单词;两种混在一张表里。
12
+ */
13
+
14
+ import { existsSync, readFileSync, statSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+
17
+ import { writeJsonAtomic } from './atomic.js'
18
+ import { listSessions, memoryRoot, readSession } from './memory.js'
19
+
20
+ const INDEX_VERSION = 3
21
+
22
+ export function indexPath() {
23
+ return join(memoryRoot(), 'index.json')
24
+ }
25
+
26
+ const CJK_RUN = /[\u4e00-\u9fff]+/g
27
+ const WORD = /[a-z0-9_]{2,}/g
28
+
29
+ /** 把一段文本切成索引词元:中文 bigram + 英文单词。 */
30
+ export function indexTerms(text) {
31
+ const lowered = String(text ?? '').toLowerCase()
32
+ const terms = new Set()
33
+ for (const run of lowered.match(CJK_RUN) ?? []) {
34
+ if (run.length === 1) {
35
+ terms.add(run)
36
+ continue
37
+ }
38
+ for (let i = 0; i + 1 < run.length; i += 1) terms.add(run.slice(i, i + 2))
39
+ }
40
+ for (const word of lowered.match(WORD) ?? []) terms.add(word)
41
+ return [...terms]
42
+ }
43
+
44
+ /**
45
+ * 解析好的索引缓存。
46
+ * 一份索引接近 10 MB,JSON.parse 一次要 200–300ms;设置页一次刷新会问好几遍,
47
+ * 所以要按「文件 mtime + 大小」缓存,文件一变就重解析。
48
+ */
49
+ let parsedCache = null // { stamp, index }
50
+
51
+ function indexStamp(file) {
52
+ try {
53
+ const info = statSync(file)
54
+ return info.mtimeMs + ':' + info.size
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ export function readIndex() {
61
+ const file = indexPath()
62
+ if (!existsSync(file)) {
63
+ parsedCache = null
64
+ return null
65
+ }
66
+ const stamp = indexStamp(file)
67
+ if (stamp !== null && parsedCache !== null && parsedCache.stamp === stamp) return parsedCache.index
68
+ try {
69
+ const data = JSON.parse(readFileSync(file, 'utf8'))
70
+ const index = data?.v === INDEX_VERSION ? data : null
71
+ parsedCache = { stamp, index }
72
+ return index
73
+ } catch {
74
+ parsedCache = null
75
+ return null
76
+ }
77
+ }
78
+
79
+ /** 索引是否还能代表当前磁盘状态(所有会话的 mtime 都没变)。 */
80
+ /** 最近 5 分钟内还在写的会话:它们的 mtime 一直在变,不能拿它们判「索引过期」。 */
81
+ const HOT_MS = 5 * 60 * 1000
82
+
83
+ /**
84
+ * 索引与磁盘的偏差。
85
+ * fresh 只看「已冷下来的会话」;hot 记有多少个会话太新,索引里可能是旧的。
86
+ */
87
+ export function freshness(index, sessions = listSessions()) {
88
+ if (index === null) return { fresh: false, hot: 0, missing: 0 }
89
+ return freshnessOfSessions(index.sessions ?? {}, sessions)
90
+ }
91
+
92
+ /**
93
+ * 只拿「会话 id → mtime」这张表算偏差。
94
+ * 索引本体有 10 MB,设置页不该为了报个数字去解析它 ——
95
+ * 所以重建时顺手把这张小表写进 `index.meta.json`。
96
+ */
97
+ export function freshnessOfSessions(known, sessions = listSessions()) {
98
+ let hot = 0
99
+ let missing = 0
100
+ const ids = new Set()
101
+ for (const session of sessions) {
102
+ ids.add(session.id)
103
+ const entry = known[session.id]
104
+ // 索引本体里存的是 { mtime, date, size };门牌文件里为了小,只存 mtime 数字
105
+ const entryMtime = entry !== null && typeof entry === 'object' ? entry.mtime : entry
106
+ const isHot = Date.now() - session.mtime < HOT_MS
107
+ if (entry === undefined) {
108
+ if (isHot) hot += 1
109
+ else missing += 1
110
+ continue
111
+ }
112
+ if (entryMtime === session.mtime) continue
113
+ if (isHot) hot += 1
114
+ else missing += 1
115
+ }
116
+ let extra = 0
117
+ for (const id of Object.keys(known)) if (!ids.has(id)) extra += 1
118
+ return { fresh: missing === 0 && extra === 0, hot, missing }
119
+ }
120
+
121
+ export function isFresh(index) {
122
+ return freshness(index).fresh
123
+ }
124
+
125
+ /**
126
+ * 同一时刻只准有一次重建:两次并行跑,index.json 与 index.meta.json 会来自不同的两次构建,
127
+ * 之后 fresh 判断永远对不上,还白白多占几倍内存。
128
+ */
129
+ let building = false
130
+
131
+ export function isBuilding() {
132
+ return building
133
+ }
134
+
135
+ /** 全量重建。305 个会话大约十秒,跑一次就够。 */
136
+ export function buildIndex() {
137
+ if (building) throw new Error('正在重建索引,等这次跑完')
138
+ building = true
139
+ try {
140
+ const builder = createBuilder()
141
+ for (const session of listSessions()) builder.feed(session)
142
+ return builder.finish()
143
+ } finally {
144
+ building = false
145
+ }
146
+ }
147
+
148
+ /**
149
+ * 分片重建:每片之间让出事件循环,重建索引时对话不会被冻住。
150
+ * 一片的耗时跟会话大小有关,取 8 个一片,让出间隔大约 200–400ms。
151
+ */
152
+ export async function buildIndexChunked(options = {}) {
153
+ const chunk = Number.isInteger(options.chunk) && options.chunk > 0 ? options.chunk : 8
154
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null
155
+ if (building) throw new Error('正在重建索引,等这次跑完')
156
+ building = true
157
+ try {
158
+ const sessions = listSessions()
159
+ const builder = createBuilder()
160
+ const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve))
161
+ for (let i = 0; i < sessions.length; i += 1) {
162
+ builder.feed(sessions[i])
163
+ if ((i + 1) % chunk === 0) {
164
+ if (onProgress !== null) onProgress({ done: i + 1, total: sessions.length })
165
+ await yieldToEventLoop()
166
+ }
167
+ }
168
+ if (onProgress !== null) onProgress({ done: sessions.length, total: sessions.length })
169
+ return builder.finish()
170
+ } finally {
171
+ building = false
172
+ }
173
+ }
174
+
175
+ /**
176
+ * 索引落后几个会话时,就地补上再查 —— 不写盘。
177
+ *
178
+ * 背景:会话只要被追加过、又冷过 5 分钟,`freshness` 就判它 missing,整份索引随之作废,
179
+ * 于是「聊完一轮,过一会儿查昨天的事」正好落进线性扫描。落后的一般就是当前这一两个会话,
180
+ * 把它们的记录现解析进内存索引,既不误报新鲜,也不用重建十秒。
181
+ *
182
+ * 落后太多(默认 >20)就不折腾了,直接返回 null 让调用方退回线性扫描。
183
+ */
184
+ export function topUpIndex(index, sessions = listSessions(), options = {}) {
185
+ const maxMissing = Number.isInteger(options.maxMissing) ? options.maxMissing : 20
186
+ if (index === null) return { index: null, gaps: 0, degraded: true }
187
+ const known = index.sessions ?? {}
188
+ const ids = new Set(sessions.map((session) => session.id))
189
+ for (const id of Object.keys(known)) if (!ids.has(id)) return { index: null, gaps: 0, degraded: true, extra: true }
190
+
191
+ const gaps = []
192
+ for (const session of sessions) {
193
+ const entry = known[session.id]
194
+ const mtime = entry !== null && typeof entry === 'object' ? entry.mtime : entry
195
+ if (mtime !== session.mtime) gaps.push(session)
196
+ }
197
+ if (gaps.length === 0) return { index, gaps: 0, degraded: false }
198
+ if (gaps.length > maxMissing) return { index: null, gaps: gaps.length, degraded: true }
199
+
200
+ const records = index.records.slice()
201
+ const postings = { ...index.postings }
202
+ for (const session of gaps) {
203
+ let parsed = null
204
+ try {
205
+ parsed = readSession(session)
206
+ } catch {
207
+ return { index: null, gaps: gaps.length, degraded: true }
208
+ }
209
+ if (parsed.failed === true) return { index: null, gaps: gaps.length, degraded: true }
210
+ for (const record of parsed.records) {
211
+ const id = records.length
212
+ records.push({ s: session.id, i: record.index, r: record.role === 'user' ? 'u' : record.role === 'delegated' ? 'd' : 'a' })
213
+ for (const term of indexTerms(record.text)) {
214
+ postings[term] = [...(postings[term] ?? []), id]
215
+ }
216
+ }
217
+ }
218
+ return { index: { ...index, records, postings }, gaps: gaps.length, degraded: false }
219
+ }
220
+
221
+ function createBuilder() {
222
+ const sessions = []
223
+ const postings = Object.create(null)
224
+ const records = []
225
+ const meta = {}
226
+ const state = { failed: 0, scanned: 0 }
227
+
228
+ return {
229
+ feed(session) {
230
+ state.scanned += 1
231
+ sessions.push(session.id)
232
+ let parsed = null
233
+ try {
234
+ parsed = readSession(session)
235
+ } catch {
236
+ state.failed += 1
237
+ return
238
+ }
239
+ // 解不开的会话仍然记 meta(文件确实在这儿),但要计入 failed ——
240
+ // 否则「重建完成」等于宣称这些会话也收进来了
241
+ if (parsed.failed === true) state.failed += 1
242
+ meta[session.id] = { mtime: session.mtime, date: parsed.date, size: sizeOf(session) }
243
+ for (const record of parsed.records) {
244
+ const id = records.length
245
+ records.push({ s: session.id, i: record.index, r: record.role === 'user' ? 'u' : record.role === 'delegated' ? 'd' : 'a' })
246
+ for (const term of indexTerms(record.text)) {
247
+ const bucket = postings[term] ?? (postings[term] = [])
248
+ bucket.push(id)
249
+ }
250
+ }
251
+ },
252
+ finish() {
253
+ const index = {
254
+ v: INDEX_VERSION,
255
+ builtAt: new Date().toISOString(),
256
+ sessions: meta,
257
+ records,
258
+ postings,
259
+ failed: state.failed,
260
+ scanned: state.scanned,
261
+ }
262
+ writeJsonAtomic(indexPath(), index, { pretty: false })
263
+ writeIndexMeta(index)
264
+ return index
265
+ },
266
+ }
267
+ }
268
+
269
+ /**
270
+ * 索引的「门牌文件」:只有统计数字和会话 mtime 表,几十 KB。
271
+ * 设置页读它就能报全部指标,不用解析 10 MB 的索引本体。
272
+ */
273
+ export function indexMetaPath() {
274
+ return join(memoryRoot(), 'index.meta.json')
275
+ }
276
+
277
+ function writeIndexMeta(index) {
278
+ const counts = {
279
+ sessions: Object.keys(index.sessions ?? {}).length,
280
+ records: Array.isArray(index.records) ? index.records.length : 0,
281
+ terms: Object.keys(index.postings ?? {}).length,
282
+ failed: index.failed ?? 0,
283
+ bytes: 0,
284
+ }
285
+ try {
286
+ counts.bytes = statSync(indexPath()).size
287
+ } catch {
288
+ counts.bytes = 0
289
+ }
290
+ const sessions = {}
291
+ for (const [id, entry] of Object.entries(index.sessions ?? {})) sessions[id] = entry.mtime
292
+ writeJsonAtomic(indexMetaPath(), { v: INDEX_VERSION, builtAt: index.builtAt, sessions, counts })
293
+ }
294
+
295
+ export function readIndexMeta() {
296
+ const file = indexMetaPath()
297
+ if (!existsSync(file)) return null
298
+ try {
299
+ const data = JSON.parse(readFileSync(file, 'utf8'))
300
+ return data?.v === INDEX_VERSION ? data : null
301
+ } catch {
302
+ return null
303
+ }
304
+ }
305
+
306
+ function sizeOf(session) {
307
+ try {
308
+ return statSync(session.file).size
309
+ } catch {
310
+ return 0
311
+ }
312
+ }
313
+
314
+ export function ensureIndex(options = {}) {
315
+ const existing = readIndex()
316
+ if (options.force !== true && isFresh(existing)) return { index: existing, built: false }
317
+ return { index: buildIndex(), built: true }
318
+ }
319
+
320
+ /** AND 优先:所有词都出现的记录;没有就退回「出现任意词」。 */
321
+ export function queryIndex(index, terms) {
322
+ const buckets = []
323
+ for (const term of terms) {
324
+ const hits = index.postings?.[term]
325
+ if (hits === undefined) buckets.push([])
326
+ else buckets.push(hits)
327
+ }
328
+ if (buckets.length === 0) return { ids: [], degraded: false }
329
+
330
+ const intersect = (a, b) => {
331
+ const set = new Set(b)
332
+ return a.filter((id) => set.has(id))
333
+ }
334
+
335
+ const nonEmpty = buckets.filter((bucket) => bucket.length > 0)
336
+ if (nonEmpty.length === 0) return { ids: [], degraded: false }
337
+
338
+ let strict = nonEmpty[0].slice()
339
+ for (let i = 1; i < nonEmpty.length; i += 1) strict = intersect(strict, nonEmpty[i])
340
+
341
+ if (strict.length > 0) return { ids: strict, degraded: false }
342
+
343
+ const union = new Set()
344
+ for (const bucket of nonEmpty) for (const id of bucket) union.add(id)
345
+ return { ids: [...union], degraded: true }
346
+ }
347
+
348
+ /** 倒排命中的记录号 → 「会话#行号」字符串,交给 memory.searchByIds。 */
349
+ export function hitKeys(index, ids) {
350
+ const out = []
351
+ for (const id of ids) {
352
+ const record = index.records?.[id]
353
+ if (record === undefined) continue
354
+ out.push(record.s + '#' + record.i)
355
+ }
356
+ return out
357
+ }
358
+
359
+ export function indexStats(sessions = listSessions()) {
360
+ const disk = sessions.length
361
+ // 优先走门牌文件:几十 KB,免得为了报数字去解析 10 MB 索引
362
+ const meta = readIndexMeta()
363
+ if (meta !== null) {
364
+ const state = freshnessOfSessions(meta.sessions ?? {}, sessions)
365
+ return {
366
+ present: true,
367
+ fresh: state.fresh,
368
+ hot: state.hot,
369
+ missing: state.missing,
370
+ builtAt: meta.builtAt,
371
+ sessions: meta.counts?.sessions ?? Object.keys(meta.sessions ?? {}).length,
372
+ disk,
373
+ records: meta.counts?.records ?? 0,
374
+ terms: meta.counts?.terms ?? 0,
375
+ bytes: meta.counts?.bytes ?? 0,
376
+ failed: meta.counts?.failed ?? 0,
377
+ }
378
+ }
379
+ const index = readIndex()
380
+ if (index === null) {
381
+ return { present: false, fresh: false, builtAt: null, sessions: 0, disk, records: 0, terms: 0, bytes: 0, hot: 0, missing: 0, failed: 0 }
382
+ }
383
+ let bytes = 0
384
+ try {
385
+ bytes = statSync(indexPath()).size
386
+ } catch {
387
+ bytes = 0
388
+ }
389
+ const state = freshnessOfSessions(index.sessions ?? {}, sessions)
390
+ return {
391
+ present: true,
392
+ fresh: state.fresh,
393
+ hot: state.hot,
394
+ missing: state.missing,
395
+ builtAt: index.builtAt,
396
+ sessions: Object.keys(index.sessions ?? {}).length,
397
+ // 磁盘上一共有多少个会话:设置页要用「已索引 / 磁盘」这一对数字判断覆盖率
398
+ disk,
399
+ records: Array.isArray(index.records) ? index.records.length : Object.keys(index.records ?? {}).length,
400
+ terms: Object.keys(index.postings ?? {}).length,
401
+ bytes,
402
+ failed: index.failed ?? 0,
403
+ }
404
+ }