dsh-anima 0.0.0-stage → 2.2.2

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,280 @@
1
+ // lib/injection.mjs — 会话注入消息的 source 构造(纯函数,仅依赖 node 内建)
2
+ //
3
+ // 为什么必须自持 kind(与某同类人设插件的行为对齐:同样自持 kind,但名字取本包自己的):
4
+ // 会话格式 v4 的来源准入明确拒绝共享的 `kind: 'plugin'`:
5
+ //
6
+ // if (typeof value.kind !== 'string' || value.kind.length === 0 || value.kind === 'plugin')
7
+ // throw new SessionFormatError('format v4 message requires a producer-owned source kind')
8
+ //
9
+ // 也就是说不声明自持 kind 的话,注入会把**每一轮**都打成失败。
10
+ // 本插件只在「每晚整理完成」时注入一次(低频),但仍必须走合规来源。
11
+
12
+ import { randomUUID } from 'node:crypto'
13
+
14
+ /** 生产者自持 kind:取 `plugin:<包名>` 形态,与 v3→v4 迁移的回落分支保持同一身份。 */
15
+ export const INJECTION_PRODUCER_KIND = 'plugin:dsh-anima'
16
+
17
+ /** 快照区块名,与 systemPrompt section 的 name 对应。 */
18
+ export const INJECTION_SECTION_NAME = 'anima:identity'
19
+
20
+ /**
21
+ * 构造一条**模型可见**的注入消息。
22
+ *
23
+ * 与 `createInjectionSource` 的区别:那个只造 source(供宿主自己拼消息时用),
24
+ * 这个直接返回可 push 进 messages 的完整 user 消息。pre-step 钩子需要后者。
25
+ *
26
+ * 三个字段都不是可选的:
27
+ * - `kind` 必须自持(会话格式 v4 拒绝共享的 `plugin`,见文件头);
28
+ * - `form: 'snapshot'` 声明这是快照而非用户发言 —— 用它冒充用户会污染对话历史;
29
+ * - `sections` 必须给出文本,否则宿主无法把这条消息归档回具体区块。
30
+ *
31
+ * @param {string} text 注入文本
32
+ * @param {string} [sectionName] 区块名,默认用质检段的
33
+ * @returns {object}
34
+ */
35
+ export function createNoticeMessage(text, sectionName = QUALITY_SECTION_NAME) {
36
+ return {
37
+ // 与宿主 createMessage() 同构:消息必须自带稳定 id。
38
+ // 缺 id 的消息落库时无人校验,但加载时 assertMessageEventShape 会拒收,
39
+ // 后果是**整个会话打不开**(session event at seq N lacks an identified message)。
40
+ // 这里自持 id,不再依赖外部 pre-step 钩子事后补齐。
41
+ id: randomUUID(),
42
+ role: 'user',
43
+ content: [{ type: 'text', text }],
44
+ source: {
45
+ kind: INJECTION_PRODUCER_KIND,
46
+ form: 'snapshot',
47
+ sections: [{ name: sectionName, text }]
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * 构造注入消息的 source:自持 kind + snapshot 形态 + 命名区块。
54
+ * @param {string} snapshotText 注入给模型的快照文本
55
+ * @returns {{kind: string, form: string, sections: Array<{name:string,text:string}>}}
56
+ */
57
+ export function createInjectionSource(snapshotText) {
58
+ return {
59
+ kind: INJECTION_PRODUCER_KIND,
60
+ form: 'snapshot',
61
+ sections: [{ name: INJECTION_SECTION_NAME, text: snapshotText }]
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 每晚整理完成后的通知文案。
67
+ *
68
+ * **没事就说空串,调用方据此不注入。** 这条规则来自用户(m05776):
69
+ * 「有需要汇报的就汇报没有就不汇报」。修改前这段文案无条件返回,
70
+ * 于是出现过注入「待审:0 条(整理前 0 条,自动去重/合并 0 条,超限淘汰 0 条)」
71
+ * 的情况 —— 一条纯粹占注意力的通知,正是这个规则要消灭的形状。
72
+ *
73
+ * 判据全在 summary 里,不含任何模型判断:
74
+ * - 整理后仍有待审候选,或
75
+ * - 这次真的动了数据(去重 / 超限淘汰)
76
+ *
77
+ * @param {object} summary triage() 的 summary
78
+ * @param {string[]} [pendingTitles] 存活候选的正文
79
+ * @returns {string} 无事可说时返回空串(不是空行,是 '')
80
+ */
81
+ export function triageNoticeText(summary, pendingTitles) {
82
+ const after = Number(summary?.pendingAfter ?? 0)
83
+ const removed = Number(summary?.duplicatesRemoved ?? 0) + Number(summary?.overflowRemoved ?? 0)
84
+ if (after <= 0 && removed <= 0) return ''
85
+
86
+ const lines = [
87
+ '[dsh-anima 记忆缓冲区] 每晚整理已完成。',
88
+ `待审:${after} 条(整理前 ${summary.pendingBefore} 条,自动去重/合并 ${summary.duplicatesRemoved} 条,超限淘汰 ${summary.overflowRemoved} 条)。`
89
+ ]
90
+ if (Array.isArray(pendingTitles) && pendingTitles.length > 0) {
91
+ lines.push('', '等待你裁决的候选:')
92
+ for (const title of pendingTitles.slice(0, 10)) lines.push(`- ${title}`)
93
+ if (pendingTitles.length > 10) lines.push(`- …还有 ${pendingTitles.length - 10} 条`)
94
+ }
95
+ lines.push('', '请在「设置 → 灵台」里逐条采纳或丢弃;未裁决的候选不会进入我的长期记忆。')
96
+ return lines.join('\n')
97
+ }
98
+
99
+ /**
100
+ * 巡夜汇报文案:每晚到点把「机器数得出来的事」汇总成一条。
101
+ *
102
+ * 与 triageNoticeText 的分工:那个只讲缓冲区整理,这个讲整晚的结论(缓冲区 +
103
+ * 体检),同一晚只该出现一条。合并由调用方做(runPatrol 里传 triage 进来)。
104
+ *
105
+ * 措辞约束(同样是防止这条提醒变成表演):
106
+ * ① 只列数字与来源,不写「我注意到你最近…」——那种话零新信息,全是编的;
107
+ * ② 必须写「没报的是什么」,否则用户会以为查过的地方都没问题;
108
+ * ③ 结尾给出可反查的出口(哪个命令/哪个页面能自己复算)。
109
+ *
110
+ * @param {object} input
111
+ * @param {{pending:number, flagged:number, removed:number}} input.facts
112
+ * @param {string[]} [input.pendingTitles]
113
+ * @param {Array<{id:string, text:string, findings:Array}>} [input.flaggedDetails]
114
+ * @param {object|null} [input.triage] 当晚的整理 summary(没有就传 null)
115
+ * @param {number} [input.checked] 体检扫过的记忆条数
116
+ * @returns {string}
117
+ */
118
+ export function patrolNoticeText({ facts, pendingTitles = [], flaggedDetails = [], triage = null, checked = 0 }) {
119
+ const f = facts ?? {}
120
+ const lines = ['[dsh-anima 巡夜] 到点跑完,有下面这些要你看一眼。']
121
+
122
+ if (Number(f.pending) > 0) {
123
+ lines.push('', `待审候选:${f.pending} 条(未裁决的不会进长期记忆)`)
124
+ for (const title of pendingTitles.slice(0, 5)) lines.push(`- ${title}`)
125
+ if (pendingTitles.length > 5) lines.push(`- …还有 ${pendingTitles.length - 5} 条`)
126
+ }
127
+
128
+ if (Number(f.flagged) > 0) {
129
+ lines.push('', `记忆体检:${f.flagged} 条需要你判(扫了 ${checked} 条)`)
130
+ for (const item of flaggedDetails.slice(0, 5)) {
131
+ const first = Array.isArray(item.findings) ? item.findings[0] : null
132
+ lines.push(`- ${first?.detail ?? item.text}`)
133
+ lines.push(` ${`/anima mark ${item.id} state=contradicted`}`)
134
+ }
135
+ if (flaggedDetails.length > 5) lines.push(`- …还有 ${flaggedDetails.length - 5} 条,看设置页「记忆体检」`)
136
+ }
137
+
138
+ if (Number(f.removed) > 0) {
139
+ lines.push('', `整理时动过 ${f.removed} 条(去重或超限淘汰),明细在设置页的整理记录里`)
140
+ }
141
+
142
+ if (triage && Number(triage.pendingBefore) !== Number(triage.pendingAfter) && Number(f.pending) === 0) {
143
+ lines.push('', `缓冲区:${triage.pendingBefore} → ${triage.pendingAfter} 条`)
144
+ }
145
+
146
+ // ③ 必须写清这次没说什么。只报查到的问题,会让人误以为剩下的都通过了。
147
+ const silent = []
148
+ if (Number(f.pending) === 0) silent.push('待审候选(0 条)')
149
+ if (Number(f.flagged) === 0) silent.push('体检问题(0 条)')
150
+ lines.push(
151
+ '',
152
+ `没报的:${silent.join('、') || '无'}。`,
153
+ '这两个数可以自己复算:设置页的记忆体检按钮,或 `GET /api/anima/memory/audit`。',
154
+ '「这条记忆还算不算数」不在巡夜范围内 —— 那需要理解语义,代码不判。'
155
+ )
156
+ return lines.join('\n')
157
+ }
158
+
159
+ /**
160
+ * 人设配置变更后的告知文案。
161
+ *
162
+ * 为什么需要单独注入一句:system prompt 的段落是每轮重新装配的,改完下一轮就已经是
163
+ * 新内容;但**对话历史里**仍然躺着旧设定生效期间的那条系统提示。模型看得到两套规则,
164
+ * 而没有任何东西告诉它哪套算数 —— 它会倾向沿用先出现的那套,于是「改了没生效」。
165
+ * 一句明说「刚才那套作废」比重新注入整份提示词便宜得多,也够用。
166
+ *
167
+ * 只列字段名不列新值:新值已经在下一轮的提示词里了,重复一遍只是浪费上下文。
168
+ *
169
+ * 措辞刻意不写「用户刚刚改了」:同一个函数既服务于用户在设置页的改动,也服务于
170
+ * 模型自己调用的 `set_assistant_persona`(工具确认关闭时它直接落盘)。把来源如实
171
+ * 写出来,比默认假称是用户做的更可信 —— 这条消息的作用是让模型知道自己手上的
172
+ * 设定来自哪里。
173
+ *
174
+ * @param {string[]} changedKeys 发生变化的配置键
175
+ * @param {string} [source] 变更来源的可读标注(设置页 / 命令 / 模型工具 / 本工作区覆盖)
176
+ * @returns {string}
177
+ */
178
+ export function configChangedNoticeText(changedKeys, source = '设置页') {
179
+ const list = Array.isArray(changedKeys) ? changedKeys.filter(Boolean) : []
180
+ // 标题必须说真话:这段文案原来一律叫「助手人格设定刚有更新」,于是改一下对账档位
181
+ // 也会被说成人格变了。用户看到的第一句话就是错的,后面再准确也没用 —— 而且它还会
182
+ // 让模型以为「我这个人变了」,实际变的只是质检的判据。
183
+ //
184
+ // 分界不按「是不是提示词字段」分(两者都在提示词里),而按「改的是谁」分:
185
+ // memory.* / quality.* 改的是「怎么记、怎么查」,人设字段改的是「我是谁」。
186
+ const isPersona = (key) => !String(key).startsWith('memory.') && !String(key).startsWith('quality.')
187
+ const personaCount = list.filter(isPersona).length
188
+ const settingsCount = list.length - personaCount
189
+ let header
190
+ if (personaCount === 0) {
191
+ header = '[dsh-anima 配置] 提示词里的设定刚有更新'
192
+ } else if (settingsCount === 0) {
193
+ header = '[dsh-anima 人格配置] 助手人格设定刚有更新'
194
+ } else {
195
+ header = '[dsh-anima 配置] 助手人格与其它提示词设定刚有更新'
196
+ }
197
+ const lines = [
198
+ `${header}(来源:${source})。`,
199
+ list.length > 0 ? `变更字段:${list.map(describeConfigKey).join('、')}。` : '配置有更新。',
200
+ personaCount > 0
201
+ ? '新设定立刻生效,之前那套**不再适用**;被清空的字段按「没有这项设定」处理,不要沿用旧值。'
202
+ : '新设定立刻生效,旧值**不再适用**,不要沿用。'
203
+ ]
204
+ return lines.join('\n')
205
+ }
206
+
207
+ /** 配置键 → 用户看得懂的名字。未收录的键原样显示(宁可露出内部名,也不要漏报变更)。 */
208
+ const CONFIG_KEY_LABELS = {
209
+ enabled: '启用状态',
210
+ agentName: '助手名字',
211
+ agentRole: '角色设定',
212
+ addressUserAs: '对用户的称呼',
213
+ userOccupation: '用户职业',
214
+ userBio: '用户介绍',
215
+ style: '风格与语调',
216
+ headingLists: '标题与列表',
217
+ emoji: '表情符号',
218
+ tables: '表格',
219
+ replyLength: '回复长度',
220
+ language: '回复语言',
221
+ customInstructions: '补充指令',
222
+ requireToolConfirmation: '工具改动确认',
223
+ trailEnabled: '输入框光轨',
224
+ trailColor: '光轨颜色',
225
+ trailSpeed: '光轨速度',
226
+ trailWidth: '光轨粗细',
227
+ workspaceOverridesEnabled: '按工作区覆盖',
228
+ workspaceFileDir: '覆盖文件目录',
229
+ presets: '人设预设'
230
+ }
231
+
232
+ function describeConfigKey(key) {
233
+ if (typeof key !== 'string') return String(key)
234
+ if (CONFIG_KEY_LABELS[key]) return CONFIG_KEY_LABELS[key]
235
+ if (key.startsWith('memory.')) return `记忆设置(${key.slice(7)})`
236
+ if (key.startsWith('quality.')) return `质检设置(${key.slice(8)})`
237
+ return key
238
+ }
239
+
240
+ // ==================== 质检提醒 ====================
241
+
242
+ /** 质检提醒的区块名,与 systemPrompt 的 anima:quality 段对应。 */
243
+ export const QUALITY_SECTION_NAME = 'anima:quality'
244
+
245
+ /**
246
+ * 把一次代码检查的结果渲染成一条「下一步要照做」的提醒。
247
+ *
248
+ * 三条措辞约束(都是为了防止这条提醒本身变成表演):
249
+ * ① 只说**机器判到了什么**,不说「我觉得你敷衍了」—— 后者是解释,前者是证据;
250
+ * ② 必须把**没查的**一起列出。只报查到的问题,会让人误以为剩下的都通过了;
251
+ * ③ 不给「已通过」这种话。没有 findings 时不发提醒,而不是发一句夸奖。
252
+ *
253
+ * @param {{findings:Array, uncovered:string[], checked:string[], meta:object}} result
254
+ * @param {number} [limit] 最多列几条,超出的只说总数
255
+ * @returns {string} 没有 findings 时返回 ''
256
+ */
257
+ export function qualityReminderText(result, limit = 5) {
258
+ const findings = Array.isArray(result?.findings) ? result.findings : []
259
+ if (findings.length === 0) return ''
260
+ const shown = findings.slice(0, Math.max(1, limit))
261
+ const lines = [
262
+ '[dsh-anima 质检] 代码在你上一条回答里判到了下面这些形状。这是机器判词,不是评价:'
263
+ ]
264
+ for (const f of shown) {
265
+ lines.push(`- ${f.ruleId} ${f.name}:${f.detail}`)
266
+ }
267
+ if (findings.length > shown.length) {
268
+ lines.push(`- …还有 ${findings.length - shown.length} 条同类问题未列出`)
269
+ }
270
+ const uncovered = Array.isArray(result?.uncovered) ? result.uncovered : []
271
+ if (uncovered.length > 0) {
272
+ lines.push(
273
+ '',
274
+ `这次没查:${uncovered.join('、')}。`
275
+ + '别把上面这几条修掉就当「十二条全过」——没查的仍然是没查。'
276
+ )
277
+ }
278
+ lines.push('', '下一步:能补的补(把来源、命令、原话补上),补不了的直接说没核过。不要在回答里声明自己「已经注意了」。')
279
+ return lines.join('\n')
280
+ }
@@ -0,0 +1,262 @@
1
+ // lib/memory-audit.mjs — 记忆的机械体检
2
+ //
3
+ // 为什么需要这个文件:`state` 字段(current / superseded / contradicted / dropped)
4
+ // 加进 schema 之后一直是空的,因为**没有任何代码写它**。而「哪条记忆被哪条取代了」
5
+ // 是语义判断 —— 记忆库一旦自动做语义判断,就会生成用户没说过的话,那正是这个插件
6
+ // 从第一天起拒绝的事(见 README「刻意不做静默自动写入」与 lib/memory.mjs 里
7
+ // applyMemoryMeta 的注释)。
8
+ //
9
+ // 所以这里只做**机械能验的**那一半,并且严格区分两件事:
10
+ //
11
+ // 能自动的:这条记忆里写的路径还在不在、复核点过没过。这些答案来自文件系统与
12
+ // 日历,不是来自我的判断;就算我判错了,也能拿一条命令复现。
13
+ // 不能自动的:「这句话现在还算不算数」「新的那条有没有推翻旧的」。这需要理解语义,
14
+ // 而理解语义就等于替用户下结论。这一半永远留给用户,代码只负责把
15
+ // 「哪些条目值得你复核」摆出来,并附上可以直接复制的命令。
16
+ //
17
+ // 因此本文件的输出一律叫「体检结果」,不叫「判定」;每条建议都带 evidence,
18
+ // 且调用方不得在没有用户动作的情况下写盘(见 index.mjs 的 /memory/audit 路由)。
19
+
20
+ /** 一条记忆最多取多少个路径去探 —— 防止有人在一条记忆里塞一百个路径把磁盘打爆。 */
21
+ export const MAX_PATHS_PER_MEMORY = 8
22
+
23
+ /** Windows 盘符路径 / UNC 路径。刻意只认 Windows 形态:本插件的用户数据全在这台机器上。 */
24
+ const WIN_PATH = /(?:[A-Za-z]:[\\/]|\\\\)[^\s,。;、))\]】》」"'`|]+/g
25
+
26
+ /** 路径里出现这些字符说明它是个占位样板,不是一个真实路径。 */
27
+ const PLACEHOLDER = /[<>{}*?]/
28
+
29
+ /** 去掉路径尾部的标点:中文标点常紧跟路径,不剥掉就永远探不到。 */
30
+ function trimPath(raw) {
31
+ let s = String(raw ?? '').trim()
32
+ // 成对括号可能被正则一起吃进来(例如 `(D:\a\b)` 的右括号),从尾部反复剥。
33
+ // 尾部的分隔符 `\` `/` 也要剥:样板路径切出来的前缀会以分隔符结尾,
34
+ // 而 `existsSync('D:\\a\\b\\')` 与 `existsSync('D:\\a\\b')` 虽然等价,
35
+ // 写进报告的路径却会多一个尾巴,用户复制去手测时容易怀疑是不是自己看错了。
36
+ while (s.length > 0 && /[,。;:、))\]】》」"'`,.;:!?\\/]/.test(s.slice(-1))) s = s.slice(0, -1)
37
+ return s
38
+ }
39
+
40
+ /**
41
+ * 从一条记忆里取出所有「看起来是路径」的片段。
42
+ *
43
+ * 为什么要带占位符的路径单独标出来:`D:\c\VC\Tools\MSVC\<版本号>\bin\...` 里
44
+ * `<版本号>` 是用户自己记得的样板,不是磁盘上真实的目录名。直接拿去探必然 false,
45
+ * 那会产出一条假警报 —— 而假警报比不检查更糟,它会让用户学会无视整份报告。
46
+ * 正确做法是探它前面的真实前缀,并把「这条是样板」如实说出来。
47
+ *
48
+ * @param {string} text
49
+ * @returns {{raw:string, path:string, templated:boolean}[]}
50
+ */
51
+ export function extractPaths(text) {
52
+ return extractPathsWithStats(text).paths
53
+ }
54
+
55
+ /**
56
+ * extractPaths 的带统计版本。
57
+ *
58
+ * 为什么需要它:`MAX_PATHS_PER_MEMORY` 截断时,`extractPaths` 会**安静地**
59
+ * 少还几个路径。调用方拿到一个 8 条的结果,无法分辨「这条记忆里只有 8 个路径」
60
+ * 和「有 20 个,只查了前 8 个」。两种情况下体检报告长得一模一样 —— 都是
61
+ * 「查了、没发现问题」,而后者实际上有 12 个路径从来没被探过。
62
+ *
63
+ * 这正是「不许让没查长得像查过没事」在这份报告里的具体形态:假阴性比假阳性
64
+ * 更难发现,因为它什么都不显示。
65
+ *
66
+ * 保留 `extractPaths` 原来的签名(返回数组)是因为它已经被测试与调用方依赖,
67
+ * 改形状会牵连一串;统计另走一个函数,两者共用同一份实现。
68
+ *
69
+ * @param {string} text
70
+ * @returns {{paths: {raw:string, path:string, templated:boolean}[], total:number, truncated:boolean, dropped:number}}
71
+ * total 是**匹配到的路径总数**(截断前),dropped = total - paths.length。
72
+ */
73
+ export function extractPathsWithStats(text) {
74
+ const body = String(text ?? '')
75
+ const out = []
76
+ const seen = new Set()
77
+ let total = 0
78
+ for (const match of body.matchAll(WIN_PATH)) {
79
+ const raw = trimPath(match[0])
80
+ if (!raw) continue
81
+ const templated = PLACEHOLDER.test(raw)
82
+ // 样板路径只探占位符之前的那一段:`D:\c\VC\Tools\MSVC\<版本号>\...` → `D:\c\VC\Tools\MSVC`
83
+ const probePath = templated ? trimPath(raw.split(PLACEHOLDER)[0]) : raw
84
+ if (!probePath || probePath.length < 3) continue
85
+ // 只有盘符(`D:\`)没有信息量,探它永远是 true。
86
+ if (/^[A-Za-z]:[\\/]?$/.test(probePath)) continue
87
+ if (seen.has(probePath)) continue
88
+ seen.add(probePath)
89
+ // 先去重再计数:total 要表达的是「有多少个不同的路径值得探」,
90
+ // 而不是「正则匹配了多少次」—— 同一条路径在一句记忆里出现三次,
91
+ // 报「还有 2 个没查」是假警报。
92
+ total += 1
93
+ if (out.length >= MAX_PATHS_PER_MEMORY) continue
94
+ out.push({ raw, path: probePath, templated, at: match.index, end: match.index + match[0].length })
95
+ }
96
+ return {
97
+ paths: out,
98
+ total,
99
+ truncated: total > out.length,
100
+ dropped: Math.max(0, total - out.length)
101
+ }
102
+ }
103
+
104
+ /**
105
+ * 路径里带空格时,正则会在第一个空格处截断。
106
+ *
107
+ * `D:\Windows Kits` 会被截成 `D:\Windows`,而后者不存在 —— 于是产出一条
108
+ * 「记忆里写的路径已经不存在」的假警报。**假警报比不检查更糟**:它会让用户
109
+ * 学会无视整份报告,那时真警报也一起失效了。
110
+ *
111
+ * 修法不是「把空格也吃进正则」(那会把后面的中文整段吞掉),而是:
112
+ * 只有当**延长后的路径确实在磁盘上存在**时才采用它。延长必须由文件系统证实,
113
+ * 不是由我猜 —— 这样既不会误报,也不会凭空造出一个路径。
114
+ */
115
+ function extendToExisting(text, candidate, endIndex, checkPath) {
116
+ let rest = text.slice(endIndex)
117
+ let current = candidate
118
+ for (let i = 0; i < 4; i += 1) {
119
+ const piece = /^[ \t][A-Za-z0-9_.\-\\/]+/.exec(rest)
120
+ if (!piece) return null
121
+ current += piece[0]
122
+ rest = rest.slice(piece[0].length)
123
+ if (checkPath(current) === true) return current
124
+ }
125
+ return null
126
+ }
127
+
128
+ /**
129
+ * 体检一组记忆。
130
+ *
131
+ * @param {object[]} records 全量记忆(只查 status==='adopted' 的)
132
+ * @param {object} options
133
+ * @param {(p:string)=>boolean} options.probe 路径是否存在。注入而不是直接 import fs:
134
+ * 这样单测可以喂一个纯函数,不必依赖这台机器上真实有什么。
135
+ * @param {Date} [options.now]
136
+ * @returns {{checked:number, flagged:object[], clean:number, note:string}}
137
+ */
138
+ export function auditMemories(records, { probe, now = new Date() } = {}) {
139
+ const list = Array.isArray(records) ? records.filter((r) => r?.status === 'adopted') : []
140
+ const today = now.toISOString().slice(0, 10)
141
+ const checkPath = typeof probe === 'function' ? probe : () => true
142
+ const flagged = []
143
+ let clean = 0
144
+ // 截断统计:只要有一条记忆的路径被截断了,整份报告就必须说清「还有 N 个没查」。
145
+ // 不说的话,报告读起来就是「全查过了,没问题」—— 而实际上有没查的部分。
146
+ let truncatedRecords = 0
147
+ let truncatedPaths = 0
148
+
149
+ for (const record of list) {
150
+ const findings = []
151
+
152
+ // ① 路径存在性:答案来自磁盘,不来自我。
153
+ const pathScan = extractPathsWithStats(record.text)
154
+ if (pathScan.truncated) {
155
+ truncatedRecords += 1
156
+ truncatedPaths += pathScan.dropped
157
+ findings.push({
158
+ kind: 'paths-truncated',
159
+ detail: `这条记忆里有 ${pathScan.total} 个不同的路径,单条最多只查 ${MAX_PATHS_PER_MEMORY} 个,`
160
+ + `还有 ${pathScan.dropped} 个**没有检查**。别把这份结果当成「全部路径都在」`,
161
+ evidence: `extractPathsWithStats:total=${pathScan.total} checked=${pathScan.paths.length} dropped=${pathScan.dropped}`
162
+ })
163
+ }
164
+ for (const p of pathScan.paths) {
165
+ let exists = false
166
+ try {
167
+ exists = checkPath(p.path) === true
168
+ // 截断的路径先别急着报「不存在」:它可能只是被空格切断了
169
+ // (`D:\Windows Kits` → `D:\Windows`)。只有当延长后的真实路径确实存在时
170
+ // 才采信,这一步由文件系统证实,不是由我猜。
171
+ if (!exists && !p.templated) {
172
+ const longer = extendToExisting(record.text, p.path, p.end, checkPath)
173
+ if (longer) exists = true
174
+ }
175
+ } catch {
176
+ // 探针本身抛错(权限、非法字符)不能算「不存在」—— 那会把「我查不到」
177
+ // 说成「它没有了」,正是用户最烦的那种糊弄。
178
+ findings.push({
179
+ kind: 'path-unprobed',
180
+ detail: `探不到「${p.path}」:探测时出错,无法判断它还在不在`,
181
+ evidence: `probe(${JSON.stringify(p.path)}) threw`
182
+ })
183
+ continue
184
+ }
185
+ if (!exists) {
186
+ if (p.templated) {
187
+ findings.push({
188
+ kind: 'path-prefix-missing',
189
+ detail: `这条记忆里的路径样板,前缀「${p.path}」已经不存在了`,
190
+ evidence: `fs.existsSync(${JSON.stringify(p.path)}) === false(原样 ${p.raw})`
191
+ })
192
+ } else {
193
+ findings.push({
194
+ kind: 'path-missing',
195
+ detail: `记忆里写的路径「${p.path}」现在已经不存在`,
196
+ evidence: `fs.existsSync(${JSON.stringify(p.path)}) === false`
197
+ })
198
+ }
199
+ }
200
+ }
201
+
202
+ // ② 复核点过期:答案来自日历,不来自我。
203
+ const verify = String(record.verifyAt ?? '').slice(0, 10)
204
+ if (/^\d{4}-\d{2}-\d{2}$/.test(verify) && verify < today) {
205
+ findings.push({
206
+ kind: 'verify-expired',
207
+ detail: `复核点是 ${verify},已经过去了 —— 这条是不是还成立,只有你知道`,
208
+ evidence: `verifyAt=${verify} < today=${today}`
209
+ })
210
+ }
211
+
212
+ if (findings.length === 0) {
213
+ clean += 1
214
+ continue
215
+ }
216
+
217
+ // 建议状态:只给「事实已经不成立」的那两类。复核点过期只提示,不建议改状态 ——
218
+ // 「过没过期」是日历说的,「还算不算数」得用户说。
219
+ const hasMissing = findings.some((f) => f.kind === 'path-missing' || f.kind === 'path-prefix-missing')
220
+ flagged.push({
221
+ id: record.id,
222
+ text: record.text,
223
+ type: record.type,
224
+ state: record.state,
225
+ capturedAt: record.capturedAt,
226
+ verifyAt: record.verifyAt,
227
+ findings,
228
+ suggestion: hasMissing
229
+ ? {
230
+ state: 'contradicted',
231
+ why: '这条记忆指向的东西已经不在了;标成「有争议」后,注入时会写明它可能不成立,而不是照旧当真',
232
+ command: `/anima mark ${record.id} state=contradicted`
233
+ }
234
+ : {
235
+ state: null,
236
+ why: '只有复核点过期。要不要标「可能已过时」由你决定,代码不替你判',
237
+ command: `/anima mark ${record.id} state=contradicted`
238
+ }
239
+ })
240
+ }
241
+
242
+ return {
243
+ checked: list.length,
244
+ flagged,
245
+ clean,
246
+ // 截断的显式告知。放在顶层而不是只埋在 flagged 里,是因为它是**关于这份
247
+ // 报告本身**的事实,不是某一条记忆的问题:读者看汇总就要知道「没查全」。
248
+ truncated: {
249
+ records: truncatedRecords,
250
+ paths: truncatedPaths,
251
+ limit: MAX_PATHS_PER_MEMORY,
252
+ note: truncatedRecords === 0
253
+ ? ''
254
+ : `有 ${truncatedRecords} 条记忆的路径数超过单条上限 ${MAX_PATHS_PER_MEMORY},`
255
+ + `合计 ${truncatedPaths} 个路径**这次没有检查**。`
256
+ + `「没报问题」在这些条上不等于「没有问题」。`
257
+ },
258
+ note: '这份体检只做两件机械能验的事:路径还在不在、复核点过没过。'
259
+ + '「这句话还算不算数」需要理解语义,代码不判 —— 那等于替用户下结论。'
260
+ + '所有状态都不会被自动修改,每条建议都得你自己点一次。'
261
+ }
262
+ }