dsh-selfnote 0.13.3 → 0.14.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/README.md +129 -32
- package/lib/capture-run.js +17 -1
- package/lib/client.js +285 -8
- package/lib/corrections.js +353 -0
- package/lib/index-store.js +14 -5
- package/lib/index.js +561 -75
- package/lib/persona-tx.js +308 -0
- package/lib/persona.js +248 -0
- package/lib/transfer.js +40 -2
- package/package.json +2 -2
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 纠偏证据的存储。
|
|
3
|
+
*
|
|
4
|
+
* 这里只做一件事:把「用户纠正过模型的那些原话」一行一条存下来,并记住哪条被哪个事务消费过。
|
|
5
|
+
* 采集(信号词、水位、去重)在别处;语义归类永远不在这里做 ——
|
|
6
|
+
* 插件只收集事实,规律由模型提、由人决定。
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { selfnoteRoot } from './store.js'
|
|
12
|
+
import { writeAtomic, writeJsonAtomic } from './atomic.js'
|
|
13
|
+
|
|
14
|
+
export const CORRECTIONS_PATH = join(selfnoteRoot(), 'corrections.jsonl')
|
|
15
|
+
const WATERMARK_PATH = join(selfnoteRoot(), 'corrections.watermark.json')
|
|
16
|
+
|
|
17
|
+
/** 池子上限:满了丢最旧的 —— 前 200 条足够让人看出规律,再多就是噪声压着真话。 */
|
|
18
|
+
const MAX_EVENTS = 200
|
|
19
|
+
/** 未被处理的证据留 90 天;处理过的留 30 天(留着只为对账)。 */
|
|
20
|
+
const UNUSED_DAYS = 90
|
|
21
|
+
const USED_DAYS = 30
|
|
22
|
+
|
|
23
|
+
/** 强信号:出现就基本可以确定是在纠正上一轮的做法,不需要别的上下文。 */
|
|
24
|
+
export const STRONG_SIGNALS = ['我说过', '我说的是', '谁让你', '怎么又', '别再', '你错了', '你又错']
|
|
25
|
+
/** 弱信号:单看太模糊(「别」「不是」),必须带了「你」或指代上一版才算数。 */
|
|
26
|
+
export const WEAK_SIGNALS = ['不要', '别', '不是', '改成', '应该是', '重新', '又']
|
|
27
|
+
|
|
28
|
+
/** 「不要太长」「别太啰嗦」是在提要求的下限,不是纠正,别当证据。 */
|
|
29
|
+
const DEGREE = /^(太|很|特别|更|过)/
|
|
30
|
+
/** 指代上一轮的词:有它,弱信号才站得住。 */
|
|
31
|
+
const REFER = /(刚才|上一版|上一步|上一轮|上面|前面)/
|
|
32
|
+
const SENTENCE = /[。!?!?\n]+/
|
|
33
|
+
/** 一句话太长不像「纠偏」,像在写需求文档。 */
|
|
34
|
+
const MAX_QUOTE_CHARS = 80
|
|
35
|
+
|
|
36
|
+
/** 一条证据的字段齐不齐:坏行宁可跳过,也别把整个池子读挂。 */
|
|
37
|
+
function normalizeEvent(raw) {
|
|
38
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
39
|
+
const id = String(raw.id ?? '').trim()
|
|
40
|
+
if (id === '') return null
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
at: String(raw.at ?? ''),
|
|
44
|
+
session: String(raw.session ?? ''),
|
|
45
|
+
idx: Number(raw.idx ?? 0),
|
|
46
|
+
quote: String(raw.quote ?? ''),
|
|
47
|
+
used: raw.used === true,
|
|
48
|
+
usedAt: raw.usedAt === null || raw.usedAt === undefined ? null : String(raw.usedAt),
|
|
49
|
+
consumedBy: raw.consumedBy === null || raw.consumedBy === undefined ? null : String(raw.consumedBy),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readEvents() {
|
|
54
|
+
if (!existsSync(CORRECTIONS_PATH)) return []
|
|
55
|
+
const list = []
|
|
56
|
+
for (const line of readFileSync(CORRECTIONS_PATH, 'utf8').split('\n')) {
|
|
57
|
+
const text = line.trim()
|
|
58
|
+
if (text === '') continue
|
|
59
|
+
try {
|
|
60
|
+
const entry = normalizeEvent(JSON.parse(text))
|
|
61
|
+
if (entry !== null) list.push(entry)
|
|
62
|
+
} catch {
|
|
63
|
+
// 一行坏了不等于整个池子坏了:留着它也没用,跳过
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return list
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function writeEvents(list) {
|
|
70
|
+
const rows = list.map((entry) => JSON.stringify(normalizeEvent(entry) ?? entry))
|
|
71
|
+
writeAtomic(CORRECTIONS_PATH, rows.length === 0 ? '' : rows.join('\n') + '\n')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function findById(id) {
|
|
75
|
+
const key = String(id ?? '')
|
|
76
|
+
return readEvents().find((entry) => entry.id === key)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 标记消费。
|
|
81
|
+
* 同一个 txId 重复标是幂等的(恢复重放会走到这里);
|
|
82
|
+
* 已经被别的 txId 消费过的,**不覆盖**并如实报冲突 —— 那是两个事务抢一条证据,得让人看见。
|
|
83
|
+
*/
|
|
84
|
+
export function markUsed(ids, txId) {
|
|
85
|
+
const wanted = new Set(ids.map((id) => String(id)))
|
|
86
|
+
const conflict = []
|
|
87
|
+
let marked = 0
|
|
88
|
+
const events = readEvents()
|
|
89
|
+
const now = new Date().toISOString()
|
|
90
|
+
for (const entry of events) {
|
|
91
|
+
if (!wanted.has(entry.id)) continue
|
|
92
|
+
if (entry.used && entry.consumedBy === txId) continue
|
|
93
|
+
if (entry.used) {
|
|
94
|
+
// 已被别的 tx 消费、或者被用户丢弃(consumedBy 为空)—— 都不许再改写
|
|
95
|
+
conflict.push(entry.id)
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
entry.used = true
|
|
99
|
+
entry.usedAt = now
|
|
100
|
+
entry.consumedBy = txId
|
|
101
|
+
marked += 1
|
|
102
|
+
}
|
|
103
|
+
if (marked > 0) writeEvents(events)
|
|
104
|
+
return { marked, conflict }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ---------------- 采集:从对话里挑出「他在纠正上一轮」的句子 ---------------- */
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 一句话里挑出候选。
|
|
111
|
+
* 强信号单看就够;弱信号(「别」「不是」)必须还带着「你」或指代上一版 ——
|
|
112
|
+
* 否则「这次不是技术问题」这种陈述句会被当成纠偏,池子被噪声占满。
|
|
113
|
+
*/
|
|
114
|
+
export function extractSignals({ text, isReply = false } = {}) {
|
|
115
|
+
const body = String(text ?? '')
|
|
116
|
+
if (body.trim() === '') return []
|
|
117
|
+
// 「我又想到一个新方案」是自述进展,不是纠错;整条排除,别按句拆。
|
|
118
|
+
if (body.includes('我又想')) return []
|
|
119
|
+
const hits = []
|
|
120
|
+
for (const raw of body.split(SENTENCE)) {
|
|
121
|
+
if (hits.length >= 2) break
|
|
122
|
+
const sentence = raw.trim()
|
|
123
|
+
if (sentence === '') continue
|
|
124
|
+
if (countOf(sentence) > MAX_QUOTE_CHARS) continue
|
|
125
|
+
const strong = STRONG_SIGNALS.find((signal) => sentence.includes(signal))
|
|
126
|
+
if (strong !== undefined) {
|
|
127
|
+
hits.push({ quote: sentence, signal: strong, strength: 'strong' })
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
const weak = WEAK_SIGNALS.find((signal) => {
|
|
131
|
+
const at = sentence.indexOf(signal)
|
|
132
|
+
if (at < 0) return false
|
|
133
|
+
// 「不要太长」「别太难」是在给下限,不是纠正
|
|
134
|
+
return !DEGREE.test(sentence.slice(at + signal.length))
|
|
135
|
+
})
|
|
136
|
+
if (weak === undefined) continue
|
|
137
|
+
const anchored = sentence.includes('你') || REFER.test(sentence) || isReply
|
|
138
|
+
if (!anchored) continue
|
|
139
|
+
hits.push({ quote: sentence, signal: weak, strength: 'weak' })
|
|
140
|
+
}
|
|
141
|
+
return hits.slice(0, 2)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 码点计数:与 persona 那边同一个口径,别一处按 UTF-16、一处按肉眼。 */
|
|
145
|
+
function countOf(text) {
|
|
146
|
+
return [...String(text)].length
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 规范化只为了算 id:同一个意思换个空白/全角,不该变成两条证据。 */
|
|
150
|
+
function normalizeQuote(text) {
|
|
151
|
+
return String(text ?? '').normalize('NFKC').replace(/\s+/gu, ' ').trim()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 事件级 id:同一会话同一条消息重扫是同一个 id;换一条消息就算两次纠正。 */
|
|
155
|
+
export function eventId(session, idx, quote) {
|
|
156
|
+
return createHash('sha1').update(`${session}:${idx}:${normalizeQuote(quote)}`).digest('hex').slice(0, 12)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 追加证据。按 id 去重(重扫同一条消息不重复),按 at 升序,超过 200 条丢最旧的。
|
|
161
|
+
* 返回真正新增的条数 —— 全是旧的时返回 0,调用方据此别报「又记了几条」。
|
|
162
|
+
*/
|
|
163
|
+
export function appendEvents(events = []) {
|
|
164
|
+
const byId = new Map()
|
|
165
|
+
for (const entry of readEvents()) byId.set(entry.id, entry)
|
|
166
|
+
let added = 0
|
|
167
|
+
for (const raw of events) {
|
|
168
|
+
if (raw === null || typeof raw !== 'object') continue
|
|
169
|
+
const quote = String(raw.quote ?? '')
|
|
170
|
+
if (quote.trim() === '') continue
|
|
171
|
+
const session = String(raw.session ?? '')
|
|
172
|
+
const idx = Number(raw.idx ?? 0)
|
|
173
|
+
const id = String(raw.id ?? eventId(session, idx, quote))
|
|
174
|
+
if (byId.has(id)) continue
|
|
175
|
+
byId.set(id, normalizeEvent({ ...raw, id, session, idx, quote }))
|
|
176
|
+
added += 1
|
|
177
|
+
}
|
|
178
|
+
if (added === 0) return 0
|
|
179
|
+
const list = [...byId.values()].sort(compareEvents)
|
|
180
|
+
const trimmed = list.length > MAX_EVENTS ? list.slice(list.length - MAX_EVENTS) : list
|
|
181
|
+
writeEvents(trimmed)
|
|
182
|
+
return added
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function compareEvents(a, b) {
|
|
186
|
+
const left = String(a.at ?? '')
|
|
187
|
+
const right = String(b.at ?? '')
|
|
188
|
+
if (left !== right) return left < right ? -1 : 1
|
|
189
|
+
return String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function encodeCursor(event) {
|
|
193
|
+
return Buffer.from(`${event.at}|${event.id}`, 'utf8').toString('base64')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function decodeCursor(cursor) {
|
|
197
|
+
if (typeof cursor !== 'string' || cursor === '') return null
|
|
198
|
+
const raw = Buffer.from(cursor, 'base64').toString('utf8')
|
|
199
|
+
const sep = raw.lastIndexOf('|')
|
|
200
|
+
// 没有分隔符就是一段垃圾:宁可当作「从头开始」,也不要拿半截字符串去比对
|
|
201
|
+
if (sep <= 0 || sep === raw.length - 1) return null
|
|
202
|
+
const at = raw.slice(0, sep)
|
|
203
|
+
const id = raw.slice(sep + 1)
|
|
204
|
+
if (at === '' || id === '') return null
|
|
205
|
+
return { at, id }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 翻页用「上一条的 (at,id)」当游标,不用 offset ——
|
|
210
|
+
* 池子边翻边有新证据进来时,offset 会让同一条被看两次或漏一次。
|
|
211
|
+
*/
|
|
212
|
+
export function listCorrections({ cursor = null, limit = 30, includeUsed = false } = {}) {
|
|
213
|
+
const all = readEvents().sort(compareEvents)
|
|
214
|
+
// 默认只给未处理的:模型和界面要的都是「还能当依据的」,
|
|
215
|
+
// 已消费/已丢弃的那些留在池子里只是为了计数和审计。
|
|
216
|
+
const pool = includeUsed ? all : all.filter((entry) => entry.used !== true)
|
|
217
|
+
const mark = decodeCursor(cursor)
|
|
218
|
+
const rest = mark === null ? pool : pool.filter((entry) => compareEvents(entry, mark) > 0)
|
|
219
|
+
const size = Math.max(1, Number(limit) || 30)
|
|
220
|
+
const items = rest.slice(0, size)
|
|
221
|
+
const nextCursor = rest.length > items.length ? encodeCursor(items[items.length - 1]) : null
|
|
222
|
+
return { items, nextCursor, total: all.length, unused: all.filter((entry) => !entry.used).length }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ---------------- 水位:从哪条消息之后才开始扫 ---------------- */
|
|
226
|
+
|
|
227
|
+
export function readWatermark() {
|
|
228
|
+
try {
|
|
229
|
+
const raw = JSON.parse(readFileSync(WATERMARK_PATH, 'utf8'))
|
|
230
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
231
|
+
// 两个时间戳都是空的等于没开闸:文件在不在都一样,别让空记录冒充「已启用」
|
|
232
|
+
if (String(raw.enabledAt ?? '') === '' && String(raw.lastScannedAt ?? '') === '') return null
|
|
233
|
+
return raw
|
|
234
|
+
} catch {
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function writeWatermark(value) {
|
|
240
|
+
const payload = value === null || typeof value !== 'object' ? {} : value
|
|
241
|
+
writeJsonAtomic(WATERMARK_PATH, {
|
|
242
|
+
enabledAt: String(payload.enabledAt ?? ''),
|
|
243
|
+
lastScannedAt: String(payload.lastScannedAt ?? ''),
|
|
244
|
+
lastSession: String(payload.lastSession ?? ''),
|
|
245
|
+
lastIdx: Number(payload.lastIdx ?? 0),
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 这条消息要不要扫。
|
|
251
|
+
* 没有水位就是第一次启用:返回 false,**不回填历史** ——
|
|
252
|
+
* 历史里没有「这句是不是在回上一句」的上下文,回填只会把旧闲聊当纠偏。
|
|
253
|
+
*/
|
|
254
|
+
export function shouldScan(message, watermark = readWatermark()) {
|
|
255
|
+
if (message === null || typeof message !== 'object') return false
|
|
256
|
+
if (watermark === null || typeof watermark !== 'object') return false
|
|
257
|
+
const since = String(watermark.lastScannedAt ?? '') || String(watermark.enabledAt ?? '')
|
|
258
|
+
if (since === '') return false
|
|
259
|
+
const at = String(message.at ?? '')
|
|
260
|
+
if (at === '') return false
|
|
261
|
+
return at > since
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* ---------------- 清理 ---------------- */
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 清理过期证据:处理过的按 usedAt 算 30 天,没处理的按 at 算 90 天。
|
|
268
|
+
* 按 usedAt 而不是 at,是为了不误伤「一个月前发生、今天才处理」的证据。
|
|
269
|
+
*/
|
|
270
|
+
export function sweep(now = Date.now()) {
|
|
271
|
+
const list = readEvents()
|
|
272
|
+
const keep = []
|
|
273
|
+
let removed = 0
|
|
274
|
+
for (const entry of list) {
|
|
275
|
+
const stamp = entry.used ? String(entry.usedAt ?? entry.at ?? '') : String(entry.at ?? '')
|
|
276
|
+
const days = entry.used ? USED_DAYS : UNUSED_DAYS
|
|
277
|
+
const at = Date.parse(stamp)
|
|
278
|
+
if (Number.isFinite(at) && now - at > days * 86400000) {
|
|
279
|
+
removed += 1
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
keep.push(entry)
|
|
283
|
+
}
|
|
284
|
+
if (removed > 0) writeEvents(keep)
|
|
285
|
+
return { removed }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* ---------------- 一条消息进来:扫、记、推水位 ---------------- */
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 采集入口:capture 每收到一条人说过的消息就调一次。
|
|
292
|
+
*
|
|
293
|
+
* 第一次调用只写水位(`enabledAt = lastScannedAt = 此刻`),不扫 ——
|
|
294
|
+
* 从这一刻开始攒,之前的历史一条都不回填。之后只扫比水位新的消息。
|
|
295
|
+
* 任何异常都吞掉并如实返回:采集坏了不能连累候选事实那条主路径。
|
|
296
|
+
*/
|
|
297
|
+
export function collectCorrections({ session = '', idx = 0, text = '', at = '', isReply = false, now = new Date() } = {}) {
|
|
298
|
+
const stamp = String(at ?? '') === '' ? now.toISOString() : String(at)
|
|
299
|
+
try {
|
|
300
|
+
const current = readWatermark()
|
|
301
|
+
if (current === null) {
|
|
302
|
+
writeWatermark({ enabledAt: stamp, lastScannedAt: stamp, lastSession: String(session), lastIdx: Number(idx) || 0 })
|
|
303
|
+
return { enabled: true, scanned: false, collected: 0, reason: 'just-enabled' }
|
|
304
|
+
}
|
|
305
|
+
if (shouldScan({ at: stamp }, current) === false) {
|
|
306
|
+
return { enabled: true, scanned: false, collected: 0, reason: 'before-watermark' }
|
|
307
|
+
}
|
|
308
|
+
const hits = extractSignals({ text, isReply })
|
|
309
|
+
const events = hits.map((hit) => ({
|
|
310
|
+
id: eventId(session, idx, hit.quote),
|
|
311
|
+
session: String(session),
|
|
312
|
+
idx: Number(idx) || 0,
|
|
313
|
+
quote: hit.quote,
|
|
314
|
+
at: stamp,
|
|
315
|
+
strength: hit.strength,
|
|
316
|
+
}))
|
|
317
|
+
const added = events.length === 0 ? 0 : appendEvents(events)
|
|
318
|
+
writeWatermark({
|
|
319
|
+
enabledAt: current.enabledAt,
|
|
320
|
+
lastScannedAt: stamp,
|
|
321
|
+
lastSession: String(session),
|
|
322
|
+
lastIdx: Number(idx) || 0,
|
|
323
|
+
})
|
|
324
|
+
return { enabled: true, scanned: true, collected: added, reason: added === 0 ? 'no-signal' : 'collected' }
|
|
325
|
+
} catch (error) {
|
|
326
|
+
return { enabled: true, scanned: false, collected: 0, reason: 'failed', error: String(error?.message ?? error) }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 丢弃证据:人看不上眼的候选,标成 used 但不算「被某条规则用掉」。
|
|
332
|
+
* 只认未处理的 id —— 已处理的再丢没有意义,还会把 consumedBy 搅乱。
|
|
333
|
+
*/
|
|
334
|
+
export function discardByIds(ids, now = new Date()) {
|
|
335
|
+
const wanted = [...new Set((Array.isArray(ids) ? ids : []).map((id) => String(id)).filter((id) => id !== ''))]
|
|
336
|
+
if (wanted.length === 0) return { ok: false, error: 'ids 不能为空' }
|
|
337
|
+
const events = readEvents()
|
|
338
|
+
const known = new Map(events.map((entry) => [entry.id, entry]))
|
|
339
|
+
for (const id of wanted) {
|
|
340
|
+
const entry = known.get(id)
|
|
341
|
+
if (entry === undefined) return { ok: false, error: `没有这条证据:${id}` }
|
|
342
|
+
if (entry.used) return { ok: false, error: `证据 ${id} 已经处理过了` }
|
|
343
|
+
}
|
|
344
|
+
const stamp = now.toISOString()
|
|
345
|
+
for (const id of wanted) {
|
|
346
|
+
const entry = known.get(id)
|
|
347
|
+
entry.used = true
|
|
348
|
+
entry.usedAt = stamp
|
|
349
|
+
entry.consumedBy = null
|
|
350
|
+
}
|
|
351
|
+
writeEvents(events)
|
|
352
|
+
return { ok: true, discarded: wanted }
|
|
353
|
+
}
|
package/lib/index-store.js
CHANGED
|
@@ -96,7 +96,8 @@ export function freshness(index, sessions = listSessions()) {
|
|
|
96
96
|
*/
|
|
97
97
|
export function freshnessOfSessions(known, sessions = listSessions()) {
|
|
98
98
|
let hot = 0
|
|
99
|
-
let
|
|
99
|
+
let absent = 0
|
|
100
|
+
let stale = 0
|
|
100
101
|
const ids = new Set()
|
|
101
102
|
for (const session of sessions) {
|
|
102
103
|
ids.add(session.id)
|
|
@@ -106,16 +107,20 @@ export function freshnessOfSessions(known, sessions = listSessions()) {
|
|
|
106
107
|
const isHot = Date.now() - session.mtime < HOT_MS
|
|
107
108
|
if (entry === undefined) {
|
|
108
109
|
if (isHot) hot += 1
|
|
109
|
-
else
|
|
110
|
+
else absent += 1
|
|
110
111
|
continue
|
|
111
112
|
}
|
|
112
113
|
if (entryMtime === session.mtime) continue
|
|
113
114
|
if (isHot) hot += 1
|
|
114
|
-
else
|
|
115
|
+
else stale += 1
|
|
115
116
|
}
|
|
116
117
|
let extra = 0
|
|
117
118
|
for (const id of Object.keys(known)) if (!ids.has(id)) extra += 1
|
|
118
|
-
|
|
119
|
+
// missing 是「待同步」总数(没进去的 + 进去了但建完又改过的)。
|
|
120
|
+
// 两个数分开留着:体检那句「N/M 个会话在索引里,另有 K 个没进去」里,
|
|
121
|
+
// K 要是把「又改过」也算进去,就会和 M−N 对不上 —— 看着像算错了,其实是在说两件事。
|
|
122
|
+
const missing = absent + stale
|
|
123
|
+
return { fresh: missing === 0 && extra === 0, hot, missing, absent, stale, extra }
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
export function isFresh(index) {
|
|
@@ -367,6 +372,8 @@ export function indexStats(sessions = listSessions()) {
|
|
|
367
372
|
fresh: state.fresh,
|
|
368
373
|
hot: state.hot,
|
|
369
374
|
missing: state.missing,
|
|
375
|
+
absent: state.absent,
|
|
376
|
+
stale: state.stale,
|
|
370
377
|
builtAt: meta.builtAt,
|
|
371
378
|
sessions: meta.counts?.sessions ?? Object.keys(meta.sessions ?? {}).length,
|
|
372
379
|
disk,
|
|
@@ -378,7 +385,7 @@ export function indexStats(sessions = listSessions()) {
|
|
|
378
385
|
}
|
|
379
386
|
const index = readIndex()
|
|
380
387
|
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 }
|
|
388
|
+
return { present: false, fresh: false, builtAt: null, sessions: 0, disk, records: 0, terms: 0, bytes: 0, hot: 0, missing: 0, absent: 0, stale: 0, failed: 0 }
|
|
382
389
|
}
|
|
383
390
|
let bytes = 0
|
|
384
391
|
try {
|
|
@@ -392,6 +399,8 @@ export function indexStats(sessions = listSessions()) {
|
|
|
392
399
|
fresh: state.fresh,
|
|
393
400
|
hot: state.hot,
|
|
394
401
|
missing: state.missing,
|
|
402
|
+
absent: state.absent,
|
|
403
|
+
stale: state.stale,
|
|
395
404
|
builtAt: index.builtAt,
|
|
396
405
|
sessions: Object.keys(index.sessions ?? {}).length,
|
|
397
406
|
// 磁盘上一共有多少个会话:设置页要用「已索引 / 磁盘」这一对数字判断覆盖率
|