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.
- package/CHANGELOG.md +1009 -0
- package/LICENSE +21 -0
- package/README.md +1015 -2
- package/README_EN.md +1000 -0
- package/assets/icon.svg +30 -0
- package/client/index.mjs +6295 -0
- package/cordis.patch.yml +3 -0
- package/index.mjs +3097 -0
- package/lib/audit.mjs +126 -0
- package/lib/config.mjs +617 -0
- package/lib/decay.mjs +131 -0
- package/lib/import.mjs +408 -0
- package/lib/injection.mjs +280 -0
- package/lib/memory-audit.mjs +262 -0
- package/lib/memory.mjs +703 -0
- package/lib/patrol.mjs +231 -0
- package/lib/presets.mjs +310 -0
- package/lib/prompt.mjs +416 -0
- package/lib/quality.mjs +718 -0
- package/lib/retrospect.mjs +296 -0
- package/lib/sessions.mjs +174 -0
- package/lib/store.mjs +755 -0
- package/lib/wording.mjs +139 -0
- package/lib/workspace.mjs +168 -0
- package/package.json +72 -4
package/index.mjs
ADDED
|
@@ -0,0 +1,3097 @@
|
|
|
1
|
+
// index.mjs — dsh-anima(host 半边)
|
|
2
|
+
//
|
|
3
|
+
// 定位(用户 m03846 拍板「A 自建补齐」):
|
|
4
|
+
// 本插件**自持全部人设字段**,不依赖任何其它插件。字段设计对照某产品
|
|
5
|
+
// 设置页(「个性化」+「记忆与进化」两页)与某同类人设插件 0.7.0 的字段集,但代码
|
|
6
|
+
// 全部自写 —— 字段名、界面布局、功能划分属思想,不受版权保护;MIT 只约束代码复制。
|
|
7
|
+
//
|
|
8
|
+
// 三类能力:
|
|
9
|
+
// ① 助手自身身份(agentName/agentRole)+ 用户背景 + 风格/特质/长度/语言 + 补充指令
|
|
10
|
+
// ② 按工作区两级覆盖(field-level merge,工作区只覆盖它明确声明的字段)
|
|
11
|
+
// ③ 带待审缓冲区的长期记忆(15+ 记忆插件无一做过「自动整理 → 缓冲区 → 用户裁决」)
|
|
12
|
+
//
|
|
13
|
+
// 独立插件:字段集不依赖任何其它插件,装上即可用。
|
|
14
|
+
//
|
|
15
|
+
// 三条硬约束(取证所得,改动这里前先读):
|
|
16
|
+
// - section 名必须避开 它注册的那条 context / `deployment:persona-prefix` / `deployment:persona-suffix`
|
|
17
|
+
// - `section.text` 可以是函数,但装配期是**同步**调用(dsh-system-prompt\lib\index.js:342),
|
|
18
|
+
// 所以工作区覆盖的读取必须走同步路径(lib/workspace.mjs 的 resolveOverridesFromCwdSync)
|
|
19
|
+
// - 会话格式 v4 要求注入消息自持 producer kind(lib/injection.mjs)
|
|
20
|
+
// - 存储层**没有文件后端兜底**(2026-10-10 决定,与某同生态插件的取舍一致):
|
|
21
|
+
// storageDomain 缺失或打开失败时抛 StorageUnavailableError(statusCode 503),
|
|
22
|
+
// 由路由与命令各自呈现给用户;首次装载失败时挂 ctx.inject 就绪信号重试一次,
|
|
23
|
+
// 绝不悄悄换一个落盘点。未装载成功前,五段 section 一律返回空串 —— 宁可没有人设,
|
|
24
|
+
// 也不要把一份「默认档」当成用户的人设注入进去。
|
|
25
|
+
|
|
26
|
+
import { existsSync } from 'node:fs'
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_CONFIG,
|
|
29
|
+
FIELD_LIMITS,
|
|
30
|
+
MEMORY_TEXT_MAX,
|
|
31
|
+
OVERRIDABLE_FIELDS,
|
|
32
|
+
formatFieldErrors,
|
|
33
|
+
mergePersona,
|
|
34
|
+
sanitizeConfig,
|
|
35
|
+
sanitizeOverrides
|
|
36
|
+
} from './lib/config.mjs'
|
|
37
|
+
import { StateStore } from './lib/store.mjs'
|
|
38
|
+
import { FIELD_EXAMPLES, WORDING_TABLES } from './lib/wording.mjs'
|
|
39
|
+
import {
|
|
40
|
+
compileDisabledNotice,
|
|
41
|
+
compileIdentity,
|
|
42
|
+
compileInstructions,
|
|
43
|
+
compileMemory,
|
|
44
|
+
compilePendingNotice,
|
|
45
|
+
compilePersona,
|
|
46
|
+
compileRememberGuide,
|
|
47
|
+
compileRules,
|
|
48
|
+
compileStyle,
|
|
49
|
+
compileUserProfile,
|
|
50
|
+
describeSources,
|
|
51
|
+
safeSection
|
|
52
|
+
} from './lib/prompt.mjs'
|
|
53
|
+
import {
|
|
54
|
+
BUILTIN_PRESETS,
|
|
55
|
+
PRESET_COUNT_LIMIT,
|
|
56
|
+
PRESET_FIELDS,
|
|
57
|
+
applyPreset,
|
|
58
|
+
findActivePreset,
|
|
59
|
+
listPresets,
|
|
60
|
+
resolvePreset,
|
|
61
|
+
sanitizeCustomPreset
|
|
62
|
+
} from './lib/presets.mjs'
|
|
63
|
+
import {
|
|
64
|
+
configChangedNoticeText,
|
|
65
|
+
createInjectionSource,
|
|
66
|
+
createNoticeMessage,
|
|
67
|
+
patrolNoticeText,
|
|
68
|
+
qualityReminderText,
|
|
69
|
+
triageNoticeText
|
|
70
|
+
} from './lib/injection.mjs'
|
|
71
|
+
import {
|
|
72
|
+
ensureIgnored,
|
|
73
|
+
readOverrides,
|
|
74
|
+
removeOverrides,
|
|
75
|
+
resolveOverridesFromCwdSync,
|
|
76
|
+
writeOverrides
|
|
77
|
+
} from './lib/workspace.mjs'
|
|
78
|
+
import {
|
|
79
|
+
applyMemoryMeta,
|
|
80
|
+
clusterPending,
|
|
81
|
+
groupByStatus,
|
|
82
|
+
makeCandidate,
|
|
83
|
+
selectForInjection,
|
|
84
|
+
summarizeOmission,
|
|
85
|
+
triage
|
|
86
|
+
} from './lib/memory.mjs'
|
|
87
|
+
import {
|
|
88
|
+
IMPORT_MAX_ITEMS,
|
|
89
|
+
IMPORT_MAX_SOURCE,
|
|
90
|
+
IMPORT_PROMPT_FULL,
|
|
91
|
+
findImportOverlaps,
|
|
92
|
+
parseImport
|
|
93
|
+
} from './lib/import.mjs'
|
|
94
|
+
import { auditMemories } from './lib/memory-audit.mjs'
|
|
95
|
+
import { assessPatrol, compactFlagged, planNightlyWake } from './lib/patrol.mjs'
|
|
96
|
+
import {
|
|
97
|
+
assessRetrospect,
|
|
98
|
+
compactLeads,
|
|
99
|
+
extractLeads,
|
|
100
|
+
retrospectNoticeText
|
|
101
|
+
} from './lib/retrospect.mjs'
|
|
102
|
+
import {
|
|
103
|
+
hasZstdSupport,
|
|
104
|
+
isSubagentSession,
|
|
105
|
+
listSessionFiles,
|
|
106
|
+
readSessionEvents,
|
|
107
|
+
sessionsRoot,
|
|
108
|
+
toLeadEvents
|
|
109
|
+
} from './lib/sessions.mjs'
|
|
110
|
+
import { AUDIT_ACTORS, AUDIT_MAX_RECORDS, AUDIT_SOURCE_TEXT, summarizeAudit } from './lib/audit.mjs'
|
|
111
|
+
import {
|
|
112
|
+
checkResponse,
|
|
113
|
+
compileLedgerRules,
|
|
114
|
+
compileQualityRules,
|
|
115
|
+
detectLedgerTriggers,
|
|
116
|
+
hasLedgerBlock
|
|
117
|
+
} from './lib/quality.mjs'
|
|
118
|
+
|
|
119
|
+
export const name = 'anima'
|
|
120
|
+
|
|
121
|
+
const LOG = '[dsh-anima]'
|
|
122
|
+
|
|
123
|
+
// 排在 DEPLOYMENT_PERSONA_PREFIX(0) 之后、PLAN_POLICY(500) 之前。
|
|
124
|
+
//
|
|
125
|
+
// 这里**不要**再写「与某同类人设插件注册的那条 section(order 0)错开」——
|
|
126
|
+
// 那是 1.0.0 时期的注释,现在已经不成立:那款插件早就把它
|
|
127
|
+
// 从 section 改成了 context(本机调研副本 `lib/index.js:633`),
|
|
128
|
+
// 而 section 与 context 是两条互不相干的数轴(宿主
|
|
129
|
+
// `SECTION_ORDERS` / `CONTEXT_ORDERS` 各自独立排序,见
|
|
130
|
+
// docs/host-prompt-semantics.md 第 1 节)。拿别人的 section order
|
|
131
|
+
// 当参照物是无效的,拿别人的 context order 当参照物更是无效。
|
|
132
|
+
// 现在这句话留着只会让下一个人以为需要避让——注释里的错误比没有注释更贵。
|
|
133
|
+
const SECTION_IDENTITY = 'anima:identity'
|
|
134
|
+
const SECTION_MEMORY = 'anima:memory'
|
|
135
|
+
const SECTION_PENDING = 'anima:pending'
|
|
136
|
+
// 质检段排在待审提示之后、PLAN_POLICY(500) 之前:
|
|
137
|
+
// 它是「怎么回答」的约束,必须比「有哪些记忆」更靠近生成点,
|
|
138
|
+
// 否则模型会先读到一堆背景再看到规则,规则容易被当成背景的一部分。
|
|
139
|
+
const SECTION_QUALITY = 'anima:quality'
|
|
140
|
+
// remember 行为指引,order 22 = 紧跟在「有哪些记忆」(20) 与「有几条待审」(21) 之后:
|
|
141
|
+
// 先说清已经有了什么、还有什么没生效,再讲「以后要记什么」——顺序反过来,
|
|
142
|
+
// 模型会先看到一堆操作指引,却不知道自己现在手上有什么。
|
|
143
|
+
// 排在质检段(30)之前:它是「知识怎么积累」,质检是「话怎么说」,前者更靠前。
|
|
144
|
+
const SECTION_REMEMBER = 'anima:remember'
|
|
145
|
+
const ORDER_IDENTITY = 10
|
|
146
|
+
const ORDER_MEMORY = 20
|
|
147
|
+
const ORDER_PENDING = 21
|
|
148
|
+
const ORDER_REMEMBER = 22
|
|
149
|
+
const ORDER_QUALITY = 30
|
|
150
|
+
|
|
151
|
+
const MAX_BODY_BYTES = 256 * 1024
|
|
152
|
+
|
|
153
|
+
const warn = (ctx, message) => {
|
|
154
|
+
try {
|
|
155
|
+
ctx?.logger?.warn?.(`${LOG} ${message}`)
|
|
156
|
+
} catch { /* 日志失败不影响主流程 */ }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 把「跳过原因」渲染成一句人话,附在 /anima show 的质检运行行后面。
|
|
161
|
+
*
|
|
162
|
+
* 空计数返回空串(没跳过就不占版面)。用固定顺序而不是 Object.keys 的插入顺序:
|
|
163
|
+
* 计数器的键顺序取决于代码里 return 的先后,重构一次就会变,而这一行是给人看的诊断。
|
|
164
|
+
*/
|
|
165
|
+
function describeSkips(skipped) {
|
|
166
|
+
const order = [
|
|
167
|
+
['not-loaded', '未装载'],
|
|
168
|
+
['disabled', '质检关闭'],
|
|
169
|
+
['pre-step-check-off', '机制检查关闭'],
|
|
170
|
+
['no-session', '拿不到会话'],
|
|
171
|
+
['no-events', '读不到事件'],
|
|
172
|
+
['no-assistant-message', '还没有回答'],
|
|
173
|
+
// empty-text 的实际含义是「最近一条 assistant 事件的正文是空的」,
|
|
174
|
+
// 最典型的情形就是工具调用步 —— 那一步本来就没有话说。
|
|
175
|
+
// 旧措辞「最近一条无正文」读起来像是出了问题,让人以为质检在漏查;
|
|
176
|
+
// 但它是**正常且必然**发生的一类跳过,措辞不该带故障暗示。
|
|
177
|
+
['empty-text', '工具步无正文'],
|
|
178
|
+
['already-checked', '同一条已查过'],
|
|
179
|
+
['turn-budget', '本轮提醒额度用完'],
|
|
180
|
+
['reject', '链路已拒绝'],
|
|
181
|
+
['error', '钩子异常']
|
|
182
|
+
]
|
|
183
|
+
const parts = order
|
|
184
|
+
.filter(([key]) => (skipped?.[key] ?? 0) > 0)
|
|
185
|
+
.map(([key, label]) => `${label} ${skipped[key]}`)
|
|
186
|
+
return parts.length ? `;跳过:${parts.join('、')}` : ''
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ==================== 运行期状态 ====================
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 运行期缓存:section.text 在装配期同步执行,不能 await 存储,
|
|
193
|
+
* 因此用一个「内存快照」承接已加载的数据,写入路径负责刷新它。
|
|
194
|
+
*/
|
|
195
|
+
const runtime = {
|
|
196
|
+
config: DEFAULT_CONFIG,
|
|
197
|
+
adopted: [],
|
|
198
|
+
pendingCount: 0,
|
|
199
|
+
store: null,
|
|
200
|
+
ctx: null,
|
|
201
|
+
refresh: null,
|
|
202
|
+
/** 是否已成功把落盘状态读进内存。false 时提示词段一律空 —— 见文件头第四条约束。 */
|
|
203
|
+
loaded: false,
|
|
204
|
+
/**
|
|
205
|
+
* 待确认的人设变更提议(confirm 模式用;进程内短生命周期,不做持久化)。
|
|
206
|
+
* 与某同类人设插件的 `pendingPersonaProposal` 同款设计:它只是「模型提了个请求,
|
|
207
|
+
* 等用户点头」的暂存区,重启即失效 —— 一份没被确认的提议本就不该跨会话活着。
|
|
208
|
+
*/
|
|
209
|
+
pendingProposal: null,
|
|
210
|
+
/**
|
|
211
|
+
* 质检钩子的运行计数。
|
|
212
|
+
*
|
|
213
|
+
* 为什么必须有这个计数器:没有它,「一条提醒都没有」这一现象有两种完全相反的
|
|
214
|
+
* 解读 —— (a) 钩子跑了、判了 N 条、确实没有该报的;(b) 钩子根本没跑,或者
|
|
215
|
+
* 每轮都在读空文本就早早返回。两种情况下用户看到的东西一模一样:什么都没有。
|
|
216
|
+
* 而 (b) 意味着整个质检层是死的,却表现得和「一切正常」完全相同。
|
|
217
|
+
*
|
|
218
|
+
* 这和「不许谎报检查结果」是同一条要求的另一面:**不许让「没查」长得像「查过没事」**。
|
|
219
|
+
* 计数器是唯一能把这两者分开的东西,所以它不是调试残留,是交付物的一部分。
|
|
220
|
+
*
|
|
221
|
+
* 口径是**跨重启累计**:落盘在 meta.qualityStats,启动时读回接着累加。
|
|
222
|
+
* (早期版本只放内存、重启清零 —— 那会让「0 次」这个数字同时代表
|
|
223
|
+
* 「真的没查」和「查过但刚重启」,把计数器本身的意义抹掉。)
|
|
224
|
+
* `startedAt` 是本次启动的时刻,与累计值分开表达。
|
|
225
|
+
*/
|
|
226
|
+
qualityStats: {
|
|
227
|
+
checks: 0, // 真正进入判定的次数(读到非空文本并跑了 checkResponse)
|
|
228
|
+
reminders: 0, // 实际发出提醒的次数
|
|
229
|
+
suppressed: 0, // 判到了问题、但被「每轮上限」压住没发的次数
|
|
230
|
+
skipped: {}, // 每个提前返回原因的累计计数
|
|
231
|
+
last: null, // 最近一次判定的摘要(不落盘:时间久了没有诊断价值)
|
|
232
|
+
startedAt: null, // 本次启动时刻(ISO)
|
|
233
|
+
flushedAt: null // 最后一次成功落盘的时刻(ISO)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 工具可写的人设字段。
|
|
239
|
+
*
|
|
240
|
+
* 与 OVERRIDABLE_FIELDS 同集合,但**刻意不共用**:那个常量决定「工作区能覆盖谁」,
|
|
241
|
+
* 这个决定「模型能改谁」。两者今天重合,明天未必 —— 比如给工作区加一个
|
|
242
|
+
* 只读的派生字段时,不该顺带把它变成模型可写的。
|
|
243
|
+
*/
|
|
244
|
+
const TOOL_FIELDS = [
|
|
245
|
+
'agentName', 'agentRole', 'addressUserAs',
|
|
246
|
+
'userOccupation', 'userBio',
|
|
247
|
+
'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language',
|
|
248
|
+
'customInstructions'
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
// ==================== 提示词装配 ====================
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 求当前会话的工作区覆盖。
|
|
255
|
+
* context.agent 由 dsh-agent 的 assembleContextFor 填入(dsh-agent\lib\index.js:291),
|
|
256
|
+
* `context.agent.session.header.cwd` 即会话工作目录。
|
|
257
|
+
*/
|
|
258
|
+
function overridesForContext(context) {
|
|
259
|
+
if (!runtime.config.workspaceOverridesEnabled) return null
|
|
260
|
+
const cwd = context?.agent?.session?.header?.cwd
|
|
261
|
+
if (!cwd) return null
|
|
262
|
+
try {
|
|
263
|
+
return resolveOverridesFromCwdSync(cwd, runtime.config.workspaceFileDir)
|
|
264
|
+
} catch (err) {
|
|
265
|
+
warn(runtime.ctx, `读取工作区覆盖失败:${String(err?.message || err)}`)
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 本地绑定:把 lib 层那个「绝不抛」的 `safeSection` 接到宿主 logger 上。
|
|
272
|
+
*
|
|
273
|
+
* 为什么实现放在 lib、只有接线放在这里:
|
|
274
|
+
* 这个文件被宿主的插件加载器直接执行,`verify-core` 没法 import 它(依赖宿主包),
|
|
275
|
+
* 于是任何写在这里的函数都只能靠「读源码字符串 + 正则匹配」来断言 ——
|
|
276
|
+
* 那测的是拼写,不是行为。把它挪进 `lib/prompt.mjs` 之后,
|
|
277
|
+
* 回归可以真的传一个会抛的渲染函数进去,断言它返回 ''、断言回调收到消息。
|
|
278
|
+
*/
|
|
279
|
+
const renderSection = (label, render) => safeSection(label, render, (message) => warn(runtime.ctx, message))
|
|
280
|
+
|
|
281
|
+
/** 身份段:每轮装配期实时求值,因此工作区覆盖能立刻生效,无需重启会话。 */
|
|
282
|
+
function identityText(context) {
|
|
283
|
+
// 装载失败时返回空串:宁可没有人设,也不注入一份默认档冒充用户配置。
|
|
284
|
+
if (!runtime.loaded) return ''
|
|
285
|
+
// 关掉插件时改为注入一句「已关闭」的声明 —— 见 compileDisabledNotice 的注释:
|
|
286
|
+
// 会话历史里的旧设定不会自己消失,只有明说作废才能让模型停止沿用。
|
|
287
|
+
if (!runtime.config.enabled) return compileDisabledNotice()
|
|
288
|
+
return renderSection('identity', () => {
|
|
289
|
+
const local = overridesForContext(context)
|
|
290
|
+
const { effective } = mergePersona(runtime.config, local?.overrides ?? null)
|
|
291
|
+
return compilePersona(effective)
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 长期记忆段:只含用户已采纳的条目,且只含**适用于当前会话工作区**的条目。
|
|
297
|
+
*
|
|
298
|
+
* 这里接 context 不是为了工作区覆盖(记忆不参与 override),而是为了拿到
|
|
299
|
+
* 会话的 cwd —— `selectForInjection` 靠它判断一条 workspace 记忆是否属于
|
|
300
|
+
* 当前项目。没有它,所有工作区记忆在注入期都无法匹配(等于全部丢失)。
|
|
301
|
+
*/
|
|
302
|
+
function memoryText(context) {
|
|
303
|
+
if (!runtime.loaded) return ''
|
|
304
|
+
if (!runtime.config.enabled) return ''
|
|
305
|
+
if (!runtime.config.memory.enabled) return ''
|
|
306
|
+
return renderSection('memory', () => {
|
|
307
|
+
const selection = injectionSelection(context)
|
|
308
|
+
return compileMemory(selection.selected, summarizeOmission(selection))
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 统一的注入筛选入口:段渲染与设置页预览必须走同一套参数,否则「预览里有的、实际没有」。 */
|
|
313
|
+
function injectionSelection(context) {
|
|
314
|
+
return selectForInjection(runtime.adopted, runtime.config.memory.maxInjected, {
|
|
315
|
+
maxInjectedChars: runtime.config.memory.maxInjectedChars,
|
|
316
|
+
cwd: context?.agent?.session?.header?.cwd ?? ''
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 待审提示段:让模型知道有候选但没有生效。 */
|
|
321
|
+
function pendingText() {
|
|
322
|
+
if (!runtime.loaded) return ''
|
|
323
|
+
if (!runtime.config.enabled) return ''
|
|
324
|
+
if (!runtime.config.memory.enabled) return ''
|
|
325
|
+
return renderSection('pending', () => compilePendingNotice(runtime.pendingCount))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* remember 行为指引段。
|
|
330
|
+
*
|
|
331
|
+
* 注册条件刻意写在**函数内部**而不是注册点:section 的注册发生在配置可能还没读完的
|
|
332
|
+
* 时候,而 `text` 每轮装配期都会重新求值。放外面的后果是「用户在设置里打开自动捕获,
|
|
333
|
+
* 但这一段要等重启才出现」——那正好是最需要它出现的时刻。
|
|
334
|
+
*
|
|
335
|
+
* 但 `autoCapture` 关掉时必须返回空串:`remember` 工具在这种情况下会直接拒绝
|
|
336
|
+
* (`index.mjs` 的 execute 里第一条判断),告诉模型去用一个必然失败的工具,
|
|
337
|
+
* 比什么都不说更糟 —— 它会反复尝试,然后把失败解释成「系统坏了」。
|
|
338
|
+
*/
|
|
339
|
+
function rememberText() {
|
|
340
|
+
if (!runtime.loaded) return ''
|
|
341
|
+
if (!runtime.config.enabled) return ''
|
|
342
|
+
if (!runtime.config.memory.enabled) return ''
|
|
343
|
+
if (!runtime.config.memory.autoCapture) return ''
|
|
344
|
+
return renderSection('remember', () => compileRememberGuide())
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 质检段:十二条判据 + 对账三档。
|
|
349
|
+
*
|
|
350
|
+
* 之所以放进提示词而不是只靠 pre-step 机制:pre-step 只能在**事后**指出
|
|
351
|
+
* 「上一轮少写了什么」,它没法让这一轮就写对。两者是配套的 ——
|
|
352
|
+
* 提示词负责让规则可被遵守,机制负责让违反可被看见。
|
|
353
|
+
*/
|
|
354
|
+
/**
|
|
355
|
+
* 质检段的**唯一**拼装入口。
|
|
356
|
+
*
|
|
357
|
+
* 之前这段文本被抄了三处(提示词 section、getSystemPrompt、/api/anima/preview),
|
|
358
|
+
* 结果第三处漏掉了末尾「关于上一段怎么被检查」那三个小节 —— 同一个概念有三份
|
|
359
|
+
* 实现,迟早会漂成三份不一样的文本。现在三处都只调这一个函数:
|
|
360
|
+
* 有任何一处想加内容,另外两处自动跟上;没有任何一处能悄悄单独改变。
|
|
361
|
+
*
|
|
362
|
+
* @param {object|undefined} quality 质检配置;缺失或 enabled=false 时返回空串。
|
|
363
|
+
* @returns {string}
|
|
364
|
+
*/
|
|
365
|
+
function compileQualitySection(quality) {
|
|
366
|
+
if (!quality || !quality.enabled) return ''
|
|
367
|
+
const parts = [compileQualityRules(), compileLedgerRules(quality.ledgerLevel)].filter(Boolean)
|
|
368
|
+
if (parts.length === 0) return ''
|
|
369
|
+
// 末句把「谁在判」说清楚:这段话是判据,不是我已经通过了的证明。
|
|
370
|
+
parts.push([
|
|
371
|
+
'## 关于上一段怎么被检查',
|
|
372
|
+
'每轮结束前会有代码(不是你自己)用上面这些形状检查你的回答,命中就在下一步开始时提醒你。',
|
|
373
|
+
'那是机器判词,不是你的自我评价;被提醒时照它改,不要辩解称自己「已经注意了」。'
|
|
374
|
+
].join('\n'))
|
|
375
|
+
return parts.join('\n\n')
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function qualityText() {
|
|
379
|
+
if (!runtime.loaded) return ''
|
|
380
|
+
if (!runtime.config.enabled) return ''
|
|
381
|
+
return renderSection('quality', () => compileQualitySection(runtime.config.quality))
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ==================== 质检层:pre-step 机制检查 ====================
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 每会话的检查痕迹:记录上一条被检查过的 assistant 事件序号,避免同一句话被反复提醒。
|
|
388
|
+
* 用 WeakMap 挂 session 对象,会话销毁即回收,不需要手动清理。
|
|
389
|
+
*/
|
|
390
|
+
const checkedSeq = new WeakMap()
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 每会话每轮的提醒次数上限。
|
|
394
|
+
*
|
|
395
|
+
* 为什么必须有这个闸门:提醒是一条 user 消息,它会让我再答一条;那条回答如果
|
|
396
|
+
* 仍然命中(比如我确实补不出证据,只能重复说「没核过」),就会再触发一次提醒。
|
|
397
|
+
* 宿主的一个 turn 内可以有多步,理论上能滚成一轮内无限重来 —— 用户看到的是
|
|
398
|
+
* 回答一次,实际后台在空转,烧的是他的额度。这类「机制自己把自己拉起来」的
|
|
399
|
+
* 循环比漏报危险得多:漏报只是没抓住我,自激是把我变成了不可控的刷子。
|
|
400
|
+
* 上限不是调参问题,是安全阀:到顶就停,并明确说「这一轮不再提醒」。
|
|
401
|
+
*/
|
|
402
|
+
const MAX_REMINDERS_PER_TURN = 2
|
|
403
|
+
const remainingReminders = new WeakMap() // session → { turn, left }
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 从 assistant/message 事件里取出纯文本(跳过工具调用块等非文本内容)。
|
|
407
|
+
*
|
|
408
|
+
* ⚠️ 事件形状是实测出来的,不要凭直觉改。DSH 落盘的 assistant/message 事件是:
|
|
409
|
+
* { type:'assistant/message', seq, time, data: { turn, step, message, usage, stream } }
|
|
410
|
+
* 消息本体在 data.message({ role, content, source, id })里,
|
|
411
|
+
* 所以文本在 **data.message.content**,不在 data.content。
|
|
412
|
+
*
|
|
413
|
+
* 这里踩过坑并且值得写下来:第一版读的是 data.content,永远取到空串,
|
|
414
|
+
* 钩子每轮都在 `text === ''` 处静默返回 —— 日志里零报错,看起来像「没命中」。
|
|
415
|
+
* 更糟的是单元测试的夹具照抄了这个错误形状,所以测试全绿也抓不到。
|
|
416
|
+
* 夹具必须照抄现实,不能照抄实现。
|
|
417
|
+
*
|
|
418
|
+
* 现在两种形状都认:优先真实形状,再退回简写形状(兼容测试与未来版本)。
|
|
419
|
+
*/
|
|
420
|
+
function eventText(event) {
|
|
421
|
+
const collected = []
|
|
422
|
+
const pushFrom = (blocks) => {
|
|
423
|
+
if (!Array.isArray(blocks)) return
|
|
424
|
+
for (const b of blocks) {
|
|
425
|
+
if (b?.type === 'text' && typeof b.text === 'string') collected.push(b.text)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
pushFrom(event?.data?.message?.content)
|
|
429
|
+
if (collected.length === 0) pushFrom(event?.data?.content)
|
|
430
|
+
return collected.join('\n').trim()
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* 挂 agent/pre-step:读**上一轮**的 assistant 回答,用 lib/quality.mjs 的纯函数判定,
|
|
435
|
+
* 命中就往下一条 user 消息批里追加一条 plugin 来源的提醒。
|
|
436
|
+
*
|
|
437
|
+
* 为什么这是整套设计里唯一「不靠我自检」的位置:
|
|
438
|
+
* 检查发生在模型之外——代码先读已落盘的 assistant 消息,再决定要不要提醒。
|
|
439
|
+
* 模型无法「觉得自己没问题」来绕过它,因为它根本不参与这个判定。
|
|
440
|
+
*
|
|
441
|
+
* 但它也有明确的天花板,写在这里防止被当成万能:
|
|
442
|
+
* - 它只能看**写出来的字**,看不到我没说的话(R07 选择性呈现它判不了);
|
|
443
|
+
* - 它判的是形状(有没有来源、有没有对账块),不是内容对不对;
|
|
444
|
+
* - 提示发出去之后,我到底改没改,它管不了 —— 那是下一轮 shape 检查的事了。
|
|
445
|
+
*
|
|
446
|
+
* 失败一律静默降级:质检是增强能力,任何异常都不该让对话本身失败。
|
|
447
|
+
*/
|
|
448
|
+
function registerQualityHook(ctx) {
|
|
449
|
+
/**
|
|
450
|
+
* 记一次「进去了但没查成」。必须带原因,否则计数器只会告诉用户「跳过很多次」,
|
|
451
|
+
* 却说不清是预算用完了还是根本没读到文本 —— 那和不计数没区别。
|
|
452
|
+
*/
|
|
453
|
+
const skip = (reason) => {
|
|
454
|
+
const s = runtime.qualityStats.skipped
|
|
455
|
+
s[reason] = (s[reason] ?? 0) + 1
|
|
456
|
+
// 每一项跳过原因是「没查」的证据,值得落盘 —— 尤其 empty-text / no-events
|
|
457
|
+
// 这种高频项:它们比 checks 更能说明钩子在空转。
|
|
458
|
+
scheduleQualityStatsFlush()
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
462
|
+
const decision = await next()
|
|
463
|
+
try {
|
|
464
|
+
if (decision?.kind === 'reject') { skip('reject'); return decision }
|
|
465
|
+
if (!runtime.loaded || !runtime.config?.enabled) { skip('not-loaded'); return decision }
|
|
466
|
+
const q = runtime.config.quality
|
|
467
|
+
if (!q || !q.enabled) { skip('disabled'); return decision }
|
|
468
|
+
if (!q.preStepCheck) { skip('pre-step-check-off'); return decision }
|
|
469
|
+
|
|
470
|
+
const agent = payload?.agent
|
|
471
|
+
const session = agent?.session
|
|
472
|
+
if (!session || typeof session.snapshotEvents !== 'function') { skip('no-session'); return decision }
|
|
473
|
+
|
|
474
|
+
// 每轮独立计数:换轮就回满。用 payload.turn(宿主在 position 里给的,和 step 同级)。
|
|
475
|
+
//
|
|
476
|
+
// ⚠️ 额度只限制「发不发提醒」,**不限制「查不查」**。
|
|
477
|
+
// 早期版本把额度判断放在这里(检查之前),后果是被挡掉的那些回答根本没被
|
|
478
|
+
// 检查过 —— 于是「这一轮有几条该提醒而没提醒」这个问题永远没有答案,
|
|
479
|
+
// 统计里只有一个笼统的「额度用完 N 次」。那正是「没查」长得像「查过没事」。
|
|
480
|
+
// 现在改成:每一条都查,查完再决定发不发;被挡的单独记成 suppressed。
|
|
481
|
+
const turn = typeof payload?.turn === 'number' ? payload.turn : -1
|
|
482
|
+
const budget = remainingReminders.get(session)
|
|
483
|
+
if (!budget || budget.turn !== turn) {
|
|
484
|
+
remainingReminders.set(session, { turn, left: MAX_REMINDERS_PER_TURN })
|
|
485
|
+
}
|
|
486
|
+
const state = remainingReminders.get(session)
|
|
487
|
+
|
|
488
|
+
// 找最后一条 assistant 回答。step === 1 时上一轮刚结束,正是要检查的对象;
|
|
489
|
+
// 后续 step 是同一轮内的工具循环,最后一条往往是只带工具调用的空文本消息。
|
|
490
|
+
const events = session.snapshotEvents()
|
|
491
|
+
if (!Array.isArray(events)) { skip('no-events'); return decision }
|
|
492
|
+
let idx = -1
|
|
493
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
494
|
+
if (events[i]?.type === 'assistant/message') { idx = i; break }
|
|
495
|
+
}
|
|
496
|
+
if (idx === -1) { skip('no-assistant-message'); return decision }
|
|
497
|
+
|
|
498
|
+
const event = events[idx]
|
|
499
|
+
const seq = typeof event.seq === 'number' ? event.seq : idx
|
|
500
|
+
if (checkedSeq.get(session) === seq) { skip('already-checked'); return decision }
|
|
501
|
+
|
|
502
|
+
// 只带工具调用的 assistant 消息(同一轮内的中间步)一定会走到这里。
|
|
503
|
+
// 它们不是「回答」,没有可检查的文字,所以是 skip 而不是 check。
|
|
504
|
+
const text = eventText(event)
|
|
505
|
+
if (text === '') { skip('empty-text'); return decision }
|
|
506
|
+
|
|
507
|
+
// 到这里才算一次真正的检查 —— 上面所有 return 都不算。
|
|
508
|
+
// 注意:额度(state.left)在这之后才看。查一定要查,发不发才受额度约束。
|
|
509
|
+
runtime.qualityStats.checks += 1
|
|
510
|
+
checkedSeq.set(session, seq)
|
|
511
|
+
scheduleQualityStatsFlush()
|
|
512
|
+
|
|
513
|
+
// 先算判词,确定了要提醒才占配额、才记账 —— 否则「检查过但不发」也会吃掉次数。
|
|
514
|
+
const result = checkResponse(text, { ledgerLevel: q.ledgerLevel, strict: q.strict === true })
|
|
515
|
+
const notice = qualityReminderText(result, q.maxFindingsInReminder ?? 5)
|
|
516
|
+
// 额度用完时这条回答仍然被检查了,只是提醒没发出去。
|
|
517
|
+
// 它必须**单独计数**而不是混进 reminders:否则「发了几条」有了,
|
|
518
|
+
// 「因为闸门压住了几条」永远查不到,用户看到的覆盖率就是一个无法解释的数。
|
|
519
|
+
const suppressed = notice !== '' && state.left <= 0
|
|
520
|
+
if (suppressed) {
|
|
521
|
+
runtime.qualityStats.suppressed = (runtime.qualityStats.suppressed ?? 0) + 1
|
|
522
|
+
skip('turn-budget')
|
|
523
|
+
}
|
|
524
|
+
runtime.qualityStats.last = {
|
|
525
|
+
seq,
|
|
526
|
+
at: new Date().toISOString(),
|
|
527
|
+
chars: text.length,
|
|
528
|
+
findings: (result.findings || []).map((f) => f.ruleId),
|
|
529
|
+
uncovered: result.uncovered || [],
|
|
530
|
+
injected: notice !== '' && !suppressed
|
|
531
|
+
}
|
|
532
|
+
if (notice === '' || suppressed) return decision
|
|
533
|
+
|
|
534
|
+
state.left -= 1
|
|
535
|
+
runtime.qualityStats.reminders += 1
|
|
536
|
+
scheduleQualityStatsFlush()
|
|
537
|
+
const message = createNoticeMessage(notice)
|
|
538
|
+
return { ...decision, messages: [...decision.messages, message] }
|
|
539
|
+
} catch (err) {
|
|
540
|
+
skip('error')
|
|
541
|
+
warn(ctx, `质检钩子异常(已跳过本轮检查):${String(err?.message || err)}`)
|
|
542
|
+
return decision
|
|
543
|
+
}
|
|
544
|
+
}, { prepend: true })
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function registerSystemPrompt(ctx) {
|
|
548
|
+
ctx.inject(['systemPrompt'], (spCtx) => {
|
|
549
|
+
let dispose = []
|
|
550
|
+
|
|
551
|
+
const register = () => {
|
|
552
|
+
for (const d of dispose) {
|
|
553
|
+
try { d() } catch { /* 已释放 */ }
|
|
554
|
+
}
|
|
555
|
+
// 先释放旧 section 再注册新的,触发 system-prompt/change,
|
|
556
|
+
// 让 DSH 丢弃当前会话已缓存的 system prompt 快照(手法同某同类人设插件)。
|
|
557
|
+
//
|
|
558
|
+
// `interpolate: false` 是必需的安全阀,不是性能优化。
|
|
559
|
+
// 宿主 renderPrompt 会对每个 section 跑 interpolate(),规则是:
|
|
560
|
+
// `{{` 后面必须紧跟 `标识符}}`,否则抛 malformed;
|
|
561
|
+
// 标识符没注册在 assembly.variables 里就抛 unknown。
|
|
562
|
+
// 抛出点在 systemPrompt.assemble() 内部 —— 也就是整轮装配失败,
|
|
563
|
+
// 不是「这一段空着」,是整次请求起不来。
|
|
564
|
+
// 而我们的五段里有**两段**含用户自由输入或模型写入的文本:
|
|
565
|
+
// identity 段 ← persona(userBio / customInstructions / style …)
|
|
566
|
+
// memory 段 ← 用户采纳的记忆正文
|
|
567
|
+
// 其余三段(pending 只有数量、remember 与 quality 是静态文案)不含外部文本。
|
|
568
|
+
// 用户只要在设定里写「我的笔记格式是 {{key: value}}」,
|
|
569
|
+
// 或者存一条含 `{{x}}` 的记忆,装配就会抛。
|
|
570
|
+
// 已实测:compilePersona 把 userBio 原样透传,`{{a.b}}` 一路活到宿主
|
|
571
|
+
// 的 interpolate 并抛 malformed(细节见 docs/host-prompt-semantics.md 第 6 节)。
|
|
572
|
+
// 我们从不注册宿主变量,关掉零代价。
|
|
573
|
+
dispose = [
|
|
574
|
+
spCtx.systemPrompt.section({ name: SECTION_IDENTITY, order: ORDER_IDENTITY, text: identityText, interpolate: false }),
|
|
575
|
+
spCtx.systemPrompt.section({ name: SECTION_MEMORY, order: ORDER_MEMORY, text: memoryText, interpolate: false }),
|
|
576
|
+
spCtx.systemPrompt.section({ name: SECTION_PENDING, order: ORDER_PENDING, text: pendingText, interpolate: false }),
|
|
577
|
+
spCtx.systemPrompt.section({ name: SECTION_REMEMBER, order: ORDER_REMEMBER, text: rememberText, interpolate: false }),
|
|
578
|
+
spCtx.systemPrompt.section({ name: SECTION_QUALITY, order: ORDER_QUALITY, text: qualityText, interpolate: false })
|
|
579
|
+
]
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
register()
|
|
583
|
+
runtime.refresh = register
|
|
584
|
+
|
|
585
|
+
ctx.effect(() => () => {
|
|
586
|
+
for (const d of dispose) {
|
|
587
|
+
try { d() } catch { /* 已释放 */ }
|
|
588
|
+
}
|
|
589
|
+
dispose = []
|
|
590
|
+
runtime.refresh = null
|
|
591
|
+
}, 'dsh-anima: system prompt sections')
|
|
592
|
+
})
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ==================== 状态快照 ====================
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* 真正会改变**注入文本**的配置键。
|
|
599
|
+
*
|
|
600
|
+
* 为什么需要这份名单:配置写入后要通知活动会话「旧设定作废」,但并非每次写入都值得
|
|
601
|
+
* 打扰用户 —— 改一个光轨颜色、勾一下工具确认,注入的文字一个字都不会变。只有
|
|
602
|
+
* 名单里的键变了才发通知,否则每点一下开关就在对话里插一条系统消息。
|
|
603
|
+
*/
|
|
604
|
+
const PROMPT_AFFECTING_FIELDS = new Set([
|
|
605
|
+
'enabled',
|
|
606
|
+
'agentName', 'agentRole', 'addressUserAs', 'userOccupation', 'userBio',
|
|
607
|
+
'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language',
|
|
608
|
+
'customInstructions',
|
|
609
|
+
'workspaceOverridesEnabled', 'workspaceFileDir'
|
|
610
|
+
])
|
|
611
|
+
|
|
612
|
+
const affectsPrompt = (changed) => changed.some(
|
|
613
|
+
(key) => PROMPT_AFFECTING_FIELDS.has(key)
|
|
614
|
+
|| String(key).startsWith('memory.')
|
|
615
|
+
|| String(key).startsWith('quality.')
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* 比较两份工作区覆盖,返回发生变化的字段名。
|
|
620
|
+
*
|
|
621
|
+
* 不复用 store 的 `diffConfig`:那个函数是为「整份配置」写的(还带 presets 与
|
|
622
|
+
* memory 段),而覆盖文件里只有 OVERRIDABLE_FIELDS 这十二个字段。用同一份名单
|
|
623
|
+
* 比较,才不会出现「覆盖里冒出一个不该有的键,通知里也跟着念一遍」。
|
|
624
|
+
*/
|
|
625
|
+
function diffOverrides(before, after) {
|
|
626
|
+
const a = before && typeof before === 'object' ? before : {}
|
|
627
|
+
const b = after && typeof after === 'object' ? after : {}
|
|
628
|
+
return { changed: OVERRIDABLE_FIELDS.filter((key) => a[key] !== b[key]) }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* 把落盘状态读进 runtime,并在必要时触发提示词重注册。
|
|
633
|
+
*
|
|
634
|
+
* @param {object} [options]
|
|
635
|
+
* @param {boolean} [options.refreshPrompt] 是否重注册 section(默认 true)
|
|
636
|
+
* @param {?{changed: string[], source?: string}} [options.configChange]
|
|
637
|
+
* 本次配置变更的字段清单与来源。给了就代表「这是一次由用户发起的配置写入」,
|
|
638
|
+
* reload 会在重注册之后向活动会话注入一条「旧设定作废」的说明。
|
|
639
|
+
* 只影响提示词的字段才会真的发通知,见 PROMPT_AFFECTING_FIELDS。
|
|
640
|
+
*/
|
|
641
|
+
async function reload(ctx, { refreshPrompt = true, configChange = null } = {}) {
|
|
642
|
+
const store = runtime.store
|
|
643
|
+
if (!store) return
|
|
644
|
+
const config = await store.getConfig()
|
|
645
|
+
runtime.config = config
|
|
646
|
+
// 读到了才置 true:五段 section 靠它判断「内存快照是否有效」,
|
|
647
|
+
// 首次加载失败时宁可没有人设,也不注入一份默认档冒充用户配置。
|
|
648
|
+
runtime.loaded = true
|
|
649
|
+
|
|
650
|
+
if (config.memory.enabled) {
|
|
651
|
+
const all = await store.listMemories()
|
|
652
|
+
const groups = groupByStatus(all)
|
|
653
|
+
runtime.adopted = groups.adopted
|
|
654
|
+
runtime.pendingCount = groups.pending.length
|
|
655
|
+
} else {
|
|
656
|
+
runtime.adopted = []
|
|
657
|
+
runtime.pendingCount = 0
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// 质检计数从介质读回:这几个数字是「质检层活着没有」的唯一证据,
|
|
661
|
+
// 只放内存会让每次重启都重置成 0,而 0 同时也代表「真的没查过」——
|
|
662
|
+
// 两种相反的事实长得一样,正是计数器本来要消灭的沉默。
|
|
663
|
+
await restoreQualityStats(store)
|
|
664
|
+
|
|
665
|
+
if (refreshPrompt && typeof runtime.refresh === 'function') {
|
|
666
|
+
try { runtime.refresh() } catch (err) { warn(ctx, `提示词重注册失败:${String(err?.message || err)}`) }
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// 重注册之后才发通知:先让下一轮的系统提示变成新内容,模型收到通知时看到的
|
|
670
|
+
// 已经是新设定,不会出现「通知说改了、这一轮读到的还是旧的」。
|
|
671
|
+
if (configChange) {
|
|
672
|
+
const changed = Array.isArray(configChange.changed) ? configChange.changed : []
|
|
673
|
+
if (changed.length > 0 && affectsPrompt(changed)) {
|
|
674
|
+
await notifyConfigChange(ctx, changed, configChange.source)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ==================== 质检计数的持久化 ====================
|
|
680
|
+
|
|
681
|
+
/** meta 表里存质检计数的键。与 triage / patrol 同级,靠键区分形状。 */
|
|
682
|
+
const QUALITY_STATS_KEY = 'qualityStats'
|
|
683
|
+
|
|
684
|
+
/** meta 表里存回扫记录的键。与 triage / patrol / qualityStats 同级,靠键区分形状。 */
|
|
685
|
+
const RETROSPECT_KEY = 'retrospect'
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* 回扫是否「今天已经扫过」。
|
|
689
|
+
*
|
|
690
|
+
* 判据只看 meta.retrospect 自己的 scannedAt,不看 meta.triage 的 lastTriageDay ——
|
|
691
|
+
* 两者是独立的动作,用巡夜的日期去挡回扫,会出现「巡夜今天跑过了,回扫就被跳过」。
|
|
692
|
+
* 还没扫过(没有记录)时返回 false,即「该扫」。
|
|
693
|
+
*/
|
|
694
|
+
function retrospectScannedToday(meta) {
|
|
695
|
+
const at = meta?.scannedAt
|
|
696
|
+
if (typeof at !== 'string' || at === '') return false
|
|
697
|
+
const d = new Date(at)
|
|
698
|
+
if (Number.isNaN(d.getTime())) return false
|
|
699
|
+
return dayKey(d) === dayKey()
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** 两次落盘之间的最小间隔(毫秒)。pre-step 每步都跑,不节流等于把磁盘当计数器用。 */
|
|
703
|
+
const QUALITY_STATS_FLUSH_MS = 10_000
|
|
704
|
+
|
|
705
|
+
/** 节流器:为 null 表示当前没有排队中的写盘。 */
|
|
706
|
+
let qualityStatsFlushTimer = null
|
|
707
|
+
let qualityStatsFlushAt = 0
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* 从介质读回质检计数。
|
|
711
|
+
*
|
|
712
|
+
* 用**累加**而不是覆盖:一份计数是这台机器上这个插件总共跑了多少次,
|
|
713
|
+
* 重启不该把它抹掉。代价是「本次启动查了多少」这个数字会丢 —— 所以另外
|
|
714
|
+
* 用 `startedAt` 记住本次启动的时刻,两者一起给出去,用户自己能分开看。
|
|
715
|
+
*
|
|
716
|
+
* @param {object} store StateStore 实例
|
|
717
|
+
*/
|
|
718
|
+
async function restoreQualityStats(store) {
|
|
719
|
+
try {
|
|
720
|
+
const table = await store.table?.('meta')
|
|
721
|
+
const saved = table?.get?.(QUALITY_STATS_KEY)
|
|
722
|
+
if (!saved || typeof saved !== 'object') {
|
|
723
|
+
// 头一回运行:记下启动时刻即可,计数保持 0。
|
|
724
|
+
runtime.qualityStats.startedAt = new Date().toISOString()
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0)
|
|
728
|
+
runtime.qualityStats.checks = num(saved.checks)
|
|
729
|
+
runtime.qualityStats.reminders = num(saved.reminders)
|
|
730
|
+
runtime.qualityStats.suppressed = num(saved.suppressed)
|
|
731
|
+
// skipped 逐键累加:不同版本的键集合会变,整体覆盖会让新键凭空多出来。
|
|
732
|
+
const skipped = saved.skipped && typeof saved.skipped === 'object' ? saved.skipped : {}
|
|
733
|
+
for (const [key, value] of Object.entries(skipped)) {
|
|
734
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
735
|
+
runtime.qualityStats.skipped[key] = value
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
runtime.qualityStats.startedAt = new Date().toISOString()
|
|
739
|
+
runtime.qualityStats.flushedAt = typeof saved.flushedAt === 'string' ? saved.flushedAt : null
|
|
740
|
+
} catch (err) {
|
|
741
|
+
// 读不回来不能让插件起不来:计数是诊断信息,不是主功能。
|
|
742
|
+
// 但要留一行日志,否则「重启之后数字归零」会被当成正常行为。
|
|
743
|
+
warn(runtime.ctx, `质检计数读回失败(本次从 0 开始累加):${String(err?.message || err)}`)
|
|
744
|
+
runtime.qualityStats.startedAt = new Date().toISOString()
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* 把质检计数写回介质。
|
|
750
|
+
*
|
|
751
|
+
* 失败只 warn,不重试、不抛:计数丢一次不影响任何判断,而它抛出去会连累
|
|
752
|
+
* 正在跑的 pre-step 钩子 —— 那个钩子的失败会让整轮对话失败。
|
|
753
|
+
*/
|
|
754
|
+
async function flushQualityStats(store = runtime.store) {
|
|
755
|
+
if (!store) return { ok: false, error: '存储未就绪' }
|
|
756
|
+
try {
|
|
757
|
+
const table = await store.table?.('meta')
|
|
758
|
+
if (!table || typeof table.put !== 'function') return { ok: false, error: 'meta 表不可用' }
|
|
759
|
+
const stats = runtime.qualityStats
|
|
760
|
+
const payload = {
|
|
761
|
+
checks: stats.checks,
|
|
762
|
+
reminders: stats.reminders,
|
|
763
|
+
suppressed: stats.suppressed,
|
|
764
|
+
skipped: { ...stats.skipped },
|
|
765
|
+
startedAt: stats.startedAt,
|
|
766
|
+
flushedAt: new Date().toISOString()
|
|
767
|
+
}
|
|
768
|
+
await table.put(QUALITY_STATS_KEY, payload)
|
|
769
|
+
stats.flushedAt = payload.flushedAt
|
|
770
|
+
return { ok: true }
|
|
771
|
+
} catch (err) {
|
|
772
|
+
warn(runtime.ctx, `质检计数落盘失败(下次会重试):${String(err?.message || err)}`)
|
|
773
|
+
return { ok: false, error: String(err?.message || err) }
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* 标记计数已变脏,安排一次节流写盘。
|
|
779
|
+
*
|
|
780
|
+
* 为什么不每步直接 await 写盘:pre-step 位于每一次模型调用之前,写盘是异步 I/O,
|
|
781
|
+
* 在高频场景(一次任务几十步)里会变成纯粹的浪费 —— 而这段时间里计数只增不减,
|
|
782
|
+
* 中途丢掉一次的真实损失是「少记几步」,不是「数据不一致」。
|
|
783
|
+
*
|
|
784
|
+
* 反过来也不能完全不写:插件崩掉或用户强退时,内存里的数字直接消失,
|
|
785
|
+
* 那正好是最需要它的时候。10 秒节流是这两者之间的折中,不是拍脑袋的常数 ——
|
|
786
|
+
* 比一次典型对话的间隔短,比单步的间隔长。
|
|
787
|
+
*/
|
|
788
|
+
function scheduleQualityStatsFlush() {
|
|
789
|
+
const now = Date.now()
|
|
790
|
+
const elapsed = now - qualityStatsFlushAt
|
|
791
|
+
if (qualityStatsFlushTimer) return
|
|
792
|
+
if (elapsed >= QUALITY_STATS_FLUSH_MS) {
|
|
793
|
+
// 距上次写入已经够久,直接进队列(不阻塞调用方)。
|
|
794
|
+
qualityStatsFlushAt = now
|
|
795
|
+
qualityStatsFlushTimer = Promise.resolve()
|
|
796
|
+
.then(() => flushQualityStats())
|
|
797
|
+
.catch(() => {})
|
|
798
|
+
.finally(() => { qualityStatsFlushTimer = null })
|
|
799
|
+
return
|
|
800
|
+
}
|
|
801
|
+
const wait = QUALITY_STATS_FLUSH_MS - elapsed
|
|
802
|
+
const timer = setTimeout(() => {
|
|
803
|
+
qualityStatsFlushAt = Date.now()
|
|
804
|
+
qualityStatsFlushTimer = Promise.resolve()
|
|
805
|
+
.then(() => flushQualityStats())
|
|
806
|
+
.catch(() => {})
|
|
807
|
+
.finally(() => { qualityStatsFlushTimer = null })
|
|
808
|
+
}, wait)
|
|
809
|
+
// Node 里 unref 让这个定时器不阻止进程退出 —— 否则一个 10 秒的防抖
|
|
810
|
+
// 能把 DSH 的退出拖住。浏览器/宿主环境没有 unref,判一下再用。
|
|
811
|
+
try { timer?.unref?.() } catch { /* 没有 unref 就算了 */ }
|
|
812
|
+
qualityStatsFlushTimer = timer
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ==================== 每晚整理 ====================
|
|
816
|
+
|
|
817
|
+
const dayKey = (d = new Date()) => {
|
|
818
|
+
const t = new Date(d)
|
|
819
|
+
return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* 执行一次缓冲区整理。
|
|
824
|
+
*
|
|
825
|
+
* 这一步**纯本地、不调用模型**:只做去重、与已采纳项比对、超限淘汰。
|
|
826
|
+
* 语义归纳由 Agent 在对话中通过 remember 工具完成 —— 用户要的是
|
|
827
|
+
* 「自动整理到缓冲区,然后我来裁决」,不是「模型悄悄替我决定什么值得记」。
|
|
828
|
+
*/
|
|
829
|
+
async function runTriage(ctx, { reason = 'nightly', notify = true } = {}) {
|
|
830
|
+
const store = runtime.store
|
|
831
|
+
if (!store) return { ok: false, error: '存储未就绪' }
|
|
832
|
+
|
|
833
|
+
const config = await store.getConfig()
|
|
834
|
+
if (!config.memory.enabled) return { ok: false, error: '记忆层已关闭' }
|
|
835
|
+
|
|
836
|
+
const all = await store.listMemories()
|
|
837
|
+
const { toDiscard, kept, summary } = triage(all, { maxPending: config.memory.maxPending })
|
|
838
|
+
|
|
839
|
+
for (const item of toDiscard) {
|
|
840
|
+
const record = all.find((r) => r.id === item.id)
|
|
841
|
+
if (!record) continue
|
|
842
|
+
await store.putMemory({
|
|
843
|
+
...record,
|
|
844
|
+
status: record.status === 'pending' ? 'discarded' : record.status,
|
|
845
|
+
note: item.note,
|
|
846
|
+
decidedAt: new Date().toISOString()
|
|
847
|
+
})
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const metaTable = await store.table?.('meta')
|
|
851
|
+
if (metaTable) {
|
|
852
|
+
await metaTable.put('triage', {
|
|
853
|
+
lastTriageDay: dayKey(),
|
|
854
|
+
lastTriageAt: new Date().toISOString(),
|
|
855
|
+
lastTriageSummary: summary
|
|
856
|
+
})
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
await reload(ctx, { refreshPrompt: true })
|
|
860
|
+
|
|
861
|
+
// notify=false 时不在这里发:由 runPatrol 把整理结果与体检结果合并成一条再发。
|
|
862
|
+
// 两条通知同一晚各发一遍,等于把「说过的话」拆成两份噪音。
|
|
863
|
+
if (notify && config.memory.notifyOnSessionStart) {
|
|
864
|
+
await notifyAgents(ctx, summary, kept)
|
|
865
|
+
}
|
|
866
|
+
warn(ctx, `整理完成(${reason}):待审 ${summary.pendingBefore} → ${summary.pendingAfter},自动清理 ${toDiscard.length} 条`)
|
|
867
|
+
return { ok: true, summary, kept }
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* 巡夜:整理 + 体检 + 决定要不要说话。
|
|
872
|
+
*
|
|
873
|
+
* 用户定的规则(m05776):「有需要汇报的就汇报没有就不汇报」。
|
|
874
|
+
*
|
|
875
|
+
* 这个函数是唯一同时知道三件事的地方:今晚整理动了什么、体检查出什么、
|
|
876
|
+
* 上次汇报时的指纹是什么。分开写会让「要不要说」散落在三处,而三处各自判断
|
|
877
|
+
* 就会各自说谎。
|
|
878
|
+
*
|
|
879
|
+
* **无论说不说,都写一条 meta.patrol。** 这是这条规则能成立的前提:
|
|
880
|
+
* 只报有事,沉默就有两种意思(没事 / 没跑),必须留痕才能分开。
|
|
881
|
+
*
|
|
882
|
+
* @param {object} ctx 插件上下文
|
|
883
|
+
* @param {{reason?: string}} [options] reason 只进日志与记录,不改变判据
|
|
884
|
+
*/
|
|
885
|
+
async function runPatrol(ctx, { reason = 'nightly' } = {}) {
|
|
886
|
+
const store = runtime.store
|
|
887
|
+
if (!store) return { ok: false, error: '存储未就绪' }
|
|
888
|
+
|
|
889
|
+
const config = await store.getConfig()
|
|
890
|
+
if (!config.memory.enabled) return { ok: false, error: '记忆层已关闭' }
|
|
891
|
+
|
|
892
|
+
const triaged = await runTriage(ctx, { reason: `patrol:${reason}`, notify: false })
|
|
893
|
+
if (!triaged.ok) return triaged
|
|
894
|
+
|
|
895
|
+
const all = await store.listMemories()
|
|
896
|
+
const groups = groupByStatus(all)
|
|
897
|
+
const audit = auditMemories(all, { probe: (p) => existsSync(p) })
|
|
898
|
+
|
|
899
|
+
const metaTable = await store.table?.('meta')
|
|
900
|
+
const previous = metaTable ? metaTable.get('patrol') : null
|
|
901
|
+
|
|
902
|
+
const verdict = assessPatrol({
|
|
903
|
+
pendingIds: groups.pending.map((r) => r.id),
|
|
904
|
+
flaggedIds: audit.flagged.map((r) => r.id),
|
|
905
|
+
removed: triaged.summary.duplicatesRemoved + triaged.summary.overflowRemoved,
|
|
906
|
+
previousSignature: previous?.signature ?? ''
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
const at = new Date().toISOString()
|
|
910
|
+
// 体检明细压成可落盘的那一份。压缩在写入前做,不在读的时候做 ——
|
|
911
|
+
// 这样落盘的内容本身就是「界面要显示的东西」,读回不必再猜。
|
|
912
|
+
const compact = compactFlagged(audit.flagged)
|
|
913
|
+
let delivered = 0
|
|
914
|
+
|
|
915
|
+
if (verdict.shouldReport && config.memory.notifyOnSessionStart) {
|
|
916
|
+
const text = patrolNoticeText({
|
|
917
|
+
facts: verdict.facts,
|
|
918
|
+
pendingTitles: groups.pending.map((r) => r.text),
|
|
919
|
+
flaggedDetails: audit.flagged,
|
|
920
|
+
triage: triaged.summary,
|
|
921
|
+
checked: audit.checked
|
|
922
|
+
})
|
|
923
|
+
delivered = await injectToAgents(ctx, text, '巡夜汇报')
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (metaTable) {
|
|
927
|
+
await metaTable.put('patrol', {
|
|
928
|
+
at,
|
|
929
|
+
day: dayKey(),
|
|
930
|
+
reason,
|
|
931
|
+
signature: verdict.signature,
|
|
932
|
+
// 上次汇报时的指纹与这次一样 ⇒ 这次没说。用户查到这条就能分清
|
|
933
|
+
// 「巡过了没事」和「巡夜没跑」—— 后者根本没有这一行。
|
|
934
|
+
reported: delivered > 0,
|
|
935
|
+
wouldReport: verdict.shouldReport,
|
|
936
|
+
delivered,
|
|
937
|
+
reasons: verdict.reasons,
|
|
938
|
+
facts: verdict.facts,
|
|
939
|
+
// 新增/减少分开记:判据只看「新增」,但「这一周清掉了 4 条」这种事实
|
|
940
|
+
// 必须留在记录里,否则用户没法回答「我上周处理的那批东西哪儿去了」。
|
|
941
|
+
added: verdict.added ?? null,
|
|
942
|
+
cleared: verdict.cleared ?? null,
|
|
943
|
+
checked: audit.checked,
|
|
944
|
+
// 体检明细落盘(2026-10-25):`facts.flagged` 只说「有几条」,说不出「哪几条」。
|
|
945
|
+
// 汇报消息会点名,但消息进的是会话历史,翻不到就没了;界面上只有个数字,
|
|
946
|
+
// 用户想问「是哪条」时无处可查。这里存一份压缩后的明细,供设置页直接展示。
|
|
947
|
+
flaggedItems: compact.items.length > 0 ? compact.items : null,
|
|
948
|
+
flaggedDropped: compact.dropped
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
warn(ctx, `巡夜完成(${reason}):待审 ${verdict.facts.pending},体检 ${verdict.facts.flagged},`
|
|
953
|
+
+ `整理动过 ${verdict.facts.removed} 条 → ${delivered > 0 ? `已汇报(${delivered} 个会话)` : '无事,未汇报'}`)
|
|
954
|
+
return { ok: true, verdict, delivered, at, triage: triaged.summary, audit: { checked: audit.checked, flagged: audit.flagged.length } }
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* 回扫式复盘:回读旧会话,捞出「用户当时说过、但没记下来」的话。
|
|
959
|
+
*
|
|
960
|
+
* 为什么是「回扫」而不是「实时闸门」:拿 925 条真实发言量过 mnemon 那套两级漏斗,
|
|
961
|
+
* 第一段四条件只命中 4 条(0.4%),第二段打分公式却让 44.6% 的轮次都要送审 ——
|
|
962
|
+
* 中间没有可用档位。而真正该记的东西信号极稀:937 轮里只有 2 次抱怨「我说过」。
|
|
963
|
+
* 所以不值得为它每步跑一遍判据;定期回头扫一遍更划算。
|
|
964
|
+
*
|
|
965
|
+
* 决定性证据(这条功能存在的理由):2026-11-04T10:13:24 用户抱怨「我有没有说过
|
|
966
|
+
* 不允许一直往 c 盘写东西」,而那条规则 2026-11-05T04:59:24 才入库 —— 晚了一天。
|
|
967
|
+
* 问题在「当时没写下来」,不在「写了没用上」。
|
|
968
|
+
*
|
|
969
|
+
* 与 runPatrol 同一条纪律:**无论说不说,都写一条 meta.retrospect。**
|
|
970
|
+
*
|
|
971
|
+
* @param {object} ctx 插件上下文
|
|
972
|
+
* @param {{reason?: string}} [options] reason 只进日志与记录,不改变判据
|
|
973
|
+
*/
|
|
974
|
+
async function runRetrospect(ctx, { reason = 'manual' } = {}) {
|
|
975
|
+
const store = runtime.store
|
|
976
|
+
if (!store) return { ok: false, error: '存储未就绪' }
|
|
977
|
+
|
|
978
|
+
const config = await store.getConfig()
|
|
979
|
+
if (!config.memory.enabled) return { ok: false, error: '记忆层已关闭' }
|
|
980
|
+
// 这个开关必须真的被读,否则它就只是个写着「关」却关不掉的配置项
|
|
981
|
+
// (auto-memory 那个项目踩过:开关没有读取点,等于没有开关)。
|
|
982
|
+
// 关着的时候**不写 meta** —— 「被用户关掉」和「跑了没成」是两种事实,
|
|
983
|
+
// 混进同一条记录里,用户以后看到「上次回扫失败」会以为是坏了。
|
|
984
|
+
if (!config.memory.retrospectEnabled) {
|
|
985
|
+
return { ok: false, error: '回扫式复盘当前已关闭(设置 → 灵台 里可以打开)' }
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const metaTable = await store.table?.('meta')
|
|
989
|
+
const previous = metaTable ? metaTable.get(RETROSPECT_KEY) : null
|
|
990
|
+
const days = config.memory.retrospectDays
|
|
991
|
+
const sinceMs = Date.now() - days * 86400000
|
|
992
|
+
const at = new Date().toISOString()
|
|
993
|
+
|
|
994
|
+
// 记录「为什么这次没扫」也必须落盘 —— 否则「扫了没事」和「没扫」一样沉默。
|
|
995
|
+
const recordFailure = async (error) => {
|
|
996
|
+
if (metaTable) {
|
|
997
|
+
await metaTable.put(RETROSPECT_KEY, {
|
|
998
|
+
scannedAt: at,
|
|
999
|
+
scannedDays: days,
|
|
1000
|
+
files: null,
|
|
1001
|
+
messages: null,
|
|
1002
|
+
found: null,
|
|
1003
|
+
leadKeys: previous?.leadKeys ?? [],
|
|
1004
|
+
leads: previous?.leads ?? null,
|
|
1005
|
+
leadsAt: previous?.leadsAt ?? null,
|
|
1006
|
+
leadsDropped: previous?.leadsDropped ?? null,
|
|
1007
|
+
delivered: 0,
|
|
1008
|
+
error
|
|
1009
|
+
})
|
|
1010
|
+
}
|
|
1011
|
+
warn(ctx, `复盘跳过(${reason}):${error}`)
|
|
1012
|
+
return { ok: false, error }
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Node 版本太老时 zlib 没有 zstdDecompressSync。这时候干跑一遍只会得到
|
|
1016
|
+
// 「0 条线索」,而那看起来和「扫过没事」一模一样 —— 必须说清是没扫成。
|
|
1017
|
+
if (!hasZstdSupport()) {
|
|
1018
|
+
return recordFailure(`当前 Node(${process.version})不支持 zstd 解压,无法读取会话日志`)
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const root = sessionsRoot()
|
|
1022
|
+
const listing = listSessionFiles({ root, sinceMs })
|
|
1023
|
+
if (!listing.rootExists) return recordFailure(listing.error ?? `会话目录不存在:${root}`)
|
|
1024
|
+
|
|
1025
|
+
let messages = 0
|
|
1026
|
+
let skippedSessions = 0
|
|
1027
|
+
let failedFrames = 0
|
|
1028
|
+
const leads = []
|
|
1029
|
+
for (const entry of listing.files) {
|
|
1030
|
+
let events
|
|
1031
|
+
try {
|
|
1032
|
+
const read = readSessionEvents(entry.file)
|
|
1033
|
+
events = read.events
|
|
1034
|
+
failedFrames += read.failedFrames ?? 0
|
|
1035
|
+
} catch {
|
|
1036
|
+
skippedSessions += 1
|
|
1037
|
+
continue
|
|
1038
|
+
}
|
|
1039
|
+
// subagent 会话里 source.kind 也是 'user',但那些话是父 agent 写的任务书。
|
|
1040
|
+
if (isSubagentSession(events)) {
|
|
1041
|
+
skippedSessions += 1
|
|
1042
|
+
continue
|
|
1043
|
+
}
|
|
1044
|
+
const leadEvents = toLeadEvents(events)
|
|
1045
|
+
const sessionRef = entry.file.slice(root.length + 1).replace(/\\/g, '/').replace(/\/session\.v4\.jsonl\.zstd$/, '')
|
|
1046
|
+
const r = extractLeads({ events: leadEvents, sinceMs, maxLeads: 500, sessionRef })
|
|
1047
|
+
messages += r.scanned
|
|
1048
|
+
leads.push(...r.leads)
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const verdict = assessRetrospect({ leads, previousKeys: previous?.leadKeys ?? [] })
|
|
1052
|
+
const compact = compactLeads(verdict.newLeads)
|
|
1053
|
+
|
|
1054
|
+
// 已报过的键要累积着留,否则下一轮会把老线索当成新的再报一次。
|
|
1055
|
+
const mergedKeys = [...new Set([...(previous?.leadKeys ?? []), ...verdict.newLeads.map((l) => l.key)])].slice(-500)
|
|
1056
|
+
|
|
1057
|
+
let delivered = 0
|
|
1058
|
+
if (verdict.shouldReport && config.memory.notifyOnSessionStart) {
|
|
1059
|
+
const text = retrospectNoticeText(verdict.newLeads)
|
|
1060
|
+
delivered = await injectToAgents(ctx, text, '复盘线索')
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (metaTable) {
|
|
1064
|
+
// 「这一轮找到的线索」什么时候算数,取决于 leadsAt 而不是 scannedAt。
|
|
1065
|
+
//
|
|
1066
|
+
// 早先每次都写 `leads: 新的一批 or null`,于是用户第二次点「回扫一次」
|
|
1067
|
+
// 之后明细被清空 —— 界面上一片安静,读起来像「上次找到的都丢了」。
|
|
1068
|
+
// 而 leadKeys 还在、不会再报,所以那些线索其实并没有消失,只是不再显示。
|
|
1069
|
+
// 现在改成:没有新线索时保留上一批明细与它自己的时间戳,
|
|
1070
|
+
// 界面便能把「这批线索是 3 天前找到的」和「刚扫过、没有新的」分开说。
|
|
1071
|
+
const hasNew = compact.items.length > 0
|
|
1072
|
+
await metaTable.put(RETROSPECT_KEY, {
|
|
1073
|
+
scannedAt: at,
|
|
1074
|
+
scannedDays: days,
|
|
1075
|
+
files: listing.files.length - skippedSessions,
|
|
1076
|
+
messages,
|
|
1077
|
+
found: verdict.newLeads.length,
|
|
1078
|
+
leadKeys: mergedKeys,
|
|
1079
|
+
leads: hasNew ? compact.items : (previous?.leads ?? null),
|
|
1080
|
+
leadsAt: hasNew ? at : (previous?.leadsAt ?? null),
|
|
1081
|
+
leadsDropped: hasNew ? compact.dropped : (previous?.leadsDropped ?? null),
|
|
1082
|
+
delivered,
|
|
1083
|
+
error: failedFrames > 0 ? `有 ${failedFrames} 帧解压失败,这部分内容不可见` : undefined
|
|
1084
|
+
})
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
warn(ctx, `复盘完成(${reason}):扫 ${listing.files.length - skippedSessions} 个会话 / ${messages} 条发言,`
|
|
1088
|
+
+ `新线索 ${verdict.newLeads.length} 条 → ${delivered > 0 ? `已提示(${delivered} 个会话)` : '无新线索,未提示'}`)
|
|
1089
|
+
return { ok: true, at, days, files: listing.files.length - skippedSessions, messages, verdict, delivered, failedFrames }
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* 把一条系统级说明注入所有活动会话(一次性提示,不唤醒 Agent)。
|
|
1094
|
+
*
|
|
1095
|
+
* @param {string} text 注入正文
|
|
1096
|
+
* @param {string} label 失败日志里的用途标注
|
|
1097
|
+
* @returns {Promise<number>} 成功注入的会话数
|
|
1098
|
+
*/
|
|
1099
|
+
async function injectToAgents(ctx, text, label) {
|
|
1100
|
+
// 空文本不注入:调用方用空串表示「这次没有要说的」,那是**沉默**,
|
|
1101
|
+
// 不是一条空消息。真正的空消息会出现在对话历史里,模型读到会以为用户说了话。
|
|
1102
|
+
if (typeof text !== 'string' || text.trim() === '') return 0
|
|
1103
|
+
const agents = ctx.get?.('agents')
|
|
1104
|
+
if (!agents || typeof agents.list !== 'function') return 0
|
|
1105
|
+
let createUserMessage
|
|
1106
|
+
try {
|
|
1107
|
+
({ createUserMessage } = await import('@deepseek-ai/dsh-llm'))
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
warn(ctx, `dsh-llm 不可用,跳过${label}:${String(err?.message || err)}`)
|
|
1110
|
+
return 0
|
|
1111
|
+
}
|
|
1112
|
+
let delivered = 0
|
|
1113
|
+
for (const agent of agents.list()) {
|
|
1114
|
+
try {
|
|
1115
|
+
agent.inject(createUserMessage({
|
|
1116
|
+
content: [{ type: 'text', text }],
|
|
1117
|
+
source: createInjectionSource(text)
|
|
1118
|
+
}))
|
|
1119
|
+
delivered += 1
|
|
1120
|
+
} catch (err) {
|
|
1121
|
+
warn(ctx, `向会话注入${label}失败:${String(err?.message || err)}`)
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return delivered
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** 把整理结果注入所有活动会话。 */
|
|
1128
|
+
async function notifyAgents(ctx, summary, kept) {
|
|
1129
|
+
return injectToAgents(ctx, triageNoticeText(summary, kept.map((r) => r.text)), '整理通知')
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* 变更来源的内部键 → 通知里的可读标注。
|
|
1134
|
+
*
|
|
1135
|
+
* 查不到就**原样使用传进来的字符串**:工作区覆盖那条会传「本工作区覆盖(<路径>)」,
|
|
1136
|
+
* 硬套一张固定表会把它悄悄改写成「设置页」,等于对模型说假话。
|
|
1137
|
+
*/
|
|
1138
|
+
const CONFIG_SOURCE_LABELS = {
|
|
1139
|
+
settings: '设置页',
|
|
1140
|
+
preset: '设置页(套用预设)',
|
|
1141
|
+
command: '命令',
|
|
1142
|
+
tool: '模型工具',
|
|
1143
|
+
proposal: '设置页(确认模型提议)'
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* 告知活动会话「人设配置刚改过,旧设定作废」。
|
|
1148
|
+
*
|
|
1149
|
+
* 只发一次、且不带新值:新值已经在重注册后的系统提示里,这里要解决的是
|
|
1150
|
+
* 「对话历史里还留着旧规则」这件事(详见 lib/injection.mjs 的注释)。
|
|
1151
|
+
*/
|
|
1152
|
+
async function notifyConfigChange(ctx, changed, source) {
|
|
1153
|
+
const label = CONFIG_SOURCE_LABELS[source] ?? (typeof source === 'string' && source ? source : '设置页')
|
|
1154
|
+
return injectToAgents(ctx, configChangedNoticeText(changed, label), '配置变更通知')
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** 定时器:每天到点跑一次;DSH 没开机就下次启动补跑。 */
|
|
1158
|
+
function scheduleNightly(ctx, state) {
|
|
1159
|
+
const clear = () => {
|
|
1160
|
+
if (state.timer) {
|
|
1161
|
+
clearTimeout(state.timer)
|
|
1162
|
+
state.timer = null
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const arm = () => {
|
|
1167
|
+
clear()
|
|
1168
|
+
const hour = runtime.config.memory.nightlyHour
|
|
1169
|
+
const plan = planNightlyWake({ now: new Date(), hour })
|
|
1170
|
+
state.timer = setTimeout(async () => {
|
|
1171
|
+
try {
|
|
1172
|
+
const config = runtime.config.memory
|
|
1173
|
+
if (!config.enabled) return
|
|
1174
|
+
if (!plan.isTarget) {
|
|
1175
|
+
// 中途醒来只为重新定闹钟。这里**不能**跑巡夜:否则「几点跑巡夜」
|
|
1176
|
+
// 会由「开机补跑有没有成功」决定,而不是由 nightlyHour 决定
|
|
1177
|
+
// (m06351 实测到的就是这个)。
|
|
1178
|
+
return
|
|
1179
|
+
}
|
|
1180
|
+
// 再核一次小时。`isTarget` 是挂闹钟时算的,而 setTimeout 在机器休眠、
|
|
1181
|
+
// 系统改时间、长时间挂起之后可能拖到别的钟点才回调;这时候光信
|
|
1182
|
+
// isTarget 就会在上班时间跑一遍「夜间巡夜」。
|
|
1183
|
+
const nowHour = new Date().getHours()
|
|
1184
|
+
if (nowHour !== hour) {
|
|
1185
|
+
warn(ctx, `巡夜跳过:闹钟落在 ${nowHour} 点,不是设定的 ${hour} 点`)
|
|
1186
|
+
return
|
|
1187
|
+
}
|
|
1188
|
+
// 巡夜与回扫是**两个开关、两种动作**,各自判各自的日期,也各自判自己的开关。
|
|
1189
|
+
// 曾经把回扫写在 nightlyTriage 的分支里 —— 结果「关掉巡夜」会连带让回扫
|
|
1190
|
+
// 永远不跑,而用户在设置页明明单独打开了回扫。开关必须有它自己的读取点。
|
|
1191
|
+
if (config.nightlyTriage) {
|
|
1192
|
+
const last = await state.lastTriageDay()
|
|
1193
|
+
// 到点就跑巡夜,不再只跑整理:整理是巡夜的一部分,汇报与否由
|
|
1194
|
+
// runPatrol 依据「有没有新的事」机械判定(见 lib/patrol.mjs)。
|
|
1195
|
+
if (last !== dayKey()) await runPatrol(ctx, { reason: 'nightly' })
|
|
1196
|
+
}
|
|
1197
|
+
// 回扫排在巡夜之后:它的线索多半来自「今天用户的纠正」,晚一点扫才能
|
|
1198
|
+
// 把当天的话也吃进去。
|
|
1199
|
+
if (config.retrospectEnabled) {
|
|
1200
|
+
const retroMeta = (await runtime.store?.table?.('meta'))?.get(RETROSPECT_KEY)
|
|
1201
|
+
if (!retrospectScannedToday(retroMeta)) {
|
|
1202
|
+
await runRetrospect(ctx, { reason: 'nightly' })
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
warn(ctx, `每晚巡夜失败:${String(err?.message || err)}`)
|
|
1207
|
+
} finally {
|
|
1208
|
+
arm()
|
|
1209
|
+
}
|
|
1210
|
+
}, plan.delayMs)
|
|
1211
|
+
state.timer.unref?.()
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
arm()
|
|
1215
|
+
ctx.effect(() => clear, 'dsh-anima: nightly timer')
|
|
1216
|
+
return { arm, clear }
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// ==================== HTTP 路由(供设置页调用) ====================
|
|
1220
|
+
|
|
1221
|
+
// 本插件的路由会读写人设配置与记忆正文,而宿主 webserver 默认可能绑在
|
|
1222
|
+
// 0.0.0.0 且**自身不带任何鉴权**(dsh-host-webserver/README.md 原文:
|
|
1223
|
+
// "the server carries no TLS, authentication, or origin policy of its own")。
|
|
1224
|
+
// 因此这里照生态通行做法(@michengai/dsh-skills-manager、@linxin666/dsh-remote-web-ui)
|
|
1225
|
+
// 自加一道同源 + 回环守卫:非本机来源一律 403,浏览器跨站请求也拒掉。
|
|
1226
|
+
function headerOf(req, name) {
|
|
1227
|
+
const value = req?.headers?.[name]
|
|
1228
|
+
return Array.isArray(value) ? value[0] : value
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function isLoopbackAddress(value) {
|
|
1232
|
+
const address = String(value ?? '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
1233
|
+
return (
|
|
1234
|
+
address === 'localhost' ||
|
|
1235
|
+
address === 'localhost.' ||
|
|
1236
|
+
address === '::1' ||
|
|
1237
|
+
address.startsWith('127.') ||
|
|
1238
|
+
address.startsWith('::ffff:127.')
|
|
1239
|
+
)
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* 判定请求是否可信。
|
|
1244
|
+
* @returns {true|string} true 表示放行;字符串是拒绝原因(供日志/响应体使用)。
|
|
1245
|
+
*/
|
|
1246
|
+
function trustRequest(req) {
|
|
1247
|
+
if (!isLoopbackAddress(req?.socket?.remoteAddress)) return '仅允许本机访问'
|
|
1248
|
+
const site = headerOf(req, 'sec-fetch-site')
|
|
1249
|
+
if (site !== undefined && site !== 'same-origin') return '拒绝跨站请求'
|
|
1250
|
+
const origin = headerOf(req, 'origin')
|
|
1251
|
+
// 同源导航型 GET 可能不带 Origin;带了就必须与本机 authoritative host 一致。
|
|
1252
|
+
if (origin === undefined) return true
|
|
1253
|
+
const host = headerOf(req, 'host')
|
|
1254
|
+
if (host === undefined) return '缺少 Host 头'
|
|
1255
|
+
try {
|
|
1256
|
+
const url = new URL(origin)
|
|
1257
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'Origin 协议不受支持'
|
|
1258
|
+
if (!isLoopbackAddress(url.hostname)) return 'Origin 不是本机'
|
|
1259
|
+
if (url.host !== host) return 'Origin 与 Host 不一致'
|
|
1260
|
+
return true
|
|
1261
|
+
} catch {
|
|
1262
|
+
return 'Origin 不是合法 URL'
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
async function readJsonBody(req, limit = MAX_BODY_BYTES) {
|
|
1267
|
+
const chunks = []
|
|
1268
|
+
let size = 0
|
|
1269
|
+
for await (const chunk of req) {
|
|
1270
|
+
size += chunk.length
|
|
1271
|
+
if (size > limit) {
|
|
1272
|
+
const err = new Error('请求体过大')
|
|
1273
|
+
err.statusCode = 413
|
|
1274
|
+
throw err
|
|
1275
|
+
}
|
|
1276
|
+
chunks.push(chunk)
|
|
1277
|
+
}
|
|
1278
|
+
if (chunks.length === 0) return {}
|
|
1279
|
+
try {
|
|
1280
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
1281
|
+
} catch {
|
|
1282
|
+
const err = new Error('请求体不是合法 JSON')
|
|
1283
|
+
err.statusCode = 400
|
|
1284
|
+
throw err
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function workspaceList(ctx) {
|
|
1289
|
+
const registry = ctx.get?.('workspaceRegistry')
|
|
1290
|
+
if (!registry || typeof registry.list !== 'function') return []
|
|
1291
|
+
let items = []
|
|
1292
|
+
try {
|
|
1293
|
+
items = registry.list() ?? []
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
warn(ctx, `读取工作区列表失败:${String(err?.message || err)}`)
|
|
1296
|
+
return []
|
|
1297
|
+
}
|
|
1298
|
+
return items.map((w) => {
|
|
1299
|
+
const path = String(w?.path ?? w?.directory ?? w?.cwd ?? '')
|
|
1300
|
+
return {
|
|
1301
|
+
id: String(w?.id ?? path),
|
|
1302
|
+
path,
|
|
1303
|
+
title: String(w?.title ?? ''),
|
|
1304
|
+
...(path ? { overridePath: overridePathFor(path) } : {})
|
|
1305
|
+
}
|
|
1306
|
+
}).filter((w) => w.path)
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function overridePathFor(workspacePath) {
|
|
1310
|
+
return `${workspacePath.replace(/[\\/]+$/, '')}\\${runtime.config.workspaceFileDir}\\override.json`
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
async function workspaceState(ctx, workspacePath) {
|
|
1314
|
+
const { overrides, path, exists, errors } = await readOverrides(workspacePath, runtime.config.workspaceFileDir)
|
|
1315
|
+
const globalConfig = await runtime.store.getConfig()
|
|
1316
|
+
const { effective, sources } = mergePersona(globalConfig, overrides)
|
|
1317
|
+
return { workspacePath, path, exists, overrides, errors, effective, sources, description: describeSources(sources) }
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function registerRoutes(ctx) {
|
|
1321
|
+
ctx.inject(['webServer'], (wsCtx) => {
|
|
1322
|
+
const send = (res, status, body) => {
|
|
1323
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
1324
|
+
res.end(JSON.stringify(body))
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// register() 返回 disposer,必须留住并在卸载时调用:宿主里 webServer 的
|
|
1328
|
+
// 路由表是全局的,丢掉 disposer 会让插件停用后旧闭包继续应答
|
|
1329
|
+
// (闭包内 runtime 已置空,表现为 500 而不是 404,容易误判成插件还在跑)。
|
|
1330
|
+
const disposers = []
|
|
1331
|
+
const route = (path, handler) => {
|
|
1332
|
+
disposers.push(wsCtx.webServer.register({
|
|
1333
|
+
kind: 'exact',
|
|
1334
|
+
path,
|
|
1335
|
+
handler: async (req, res) => {
|
|
1336
|
+
// 同源 + 回环守卫:宿主 webserver 自身不做鉴权,而本插件的路由
|
|
1337
|
+
// 读写人设与记忆正文,必须自己把住这道门。
|
|
1338
|
+
const trust = trustRequest(req)
|
|
1339
|
+
if (trust !== true) {
|
|
1340
|
+
send(res, 403, { ok: false, error: trust })
|
|
1341
|
+
return
|
|
1342
|
+
}
|
|
1343
|
+
try {
|
|
1344
|
+
await handler(req, res)
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
send(res, err?.statusCode ?? 500, { ok: false, error: String(err?.message || err) })
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}))
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
wsCtx.effect(() => () => {
|
|
1353
|
+
for (const dispose of disposers.splice(0)) {
|
|
1354
|
+
try {
|
|
1355
|
+
dispose()
|
|
1356
|
+
} catch {
|
|
1357
|
+
// 单个路由摘除失败不应阻断其余路由的清理
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}, 'dsh-anima: web routes')
|
|
1361
|
+
|
|
1362
|
+
// 状态快照。存储层不可用时这里**必须**返回结构化失败而不是 500 空壳:
|
|
1363
|
+
// 设置页要靠它区分「插件没跑」与「跑了但存储栈没组合」,前者该重装、后者该补三行。
|
|
1364
|
+
route('/api/anima/state', async (req, res) => {
|
|
1365
|
+
let config
|
|
1366
|
+
try {
|
|
1367
|
+
config = await runtime.store.getConfig()
|
|
1368
|
+
} catch (err) {
|
|
1369
|
+
send(res, err?.statusCode ?? 503, {
|
|
1370
|
+
ok: false,
|
|
1371
|
+
error: String(err?.message || err),
|
|
1372
|
+
code: err?.code ?? 'storage-unavailable',
|
|
1373
|
+
source: {
|
|
1374
|
+
kind: runtime.store.srcKind,
|
|
1375
|
+
path: runtime.store.srcPath,
|
|
1376
|
+
reason: runtime.store.srcReason,
|
|
1377
|
+
message: runtime.store.srcMessage,
|
|
1378
|
+
domainVersion: runtime.store.srcDomainVersion
|
|
1379
|
+
},
|
|
1380
|
+
loaded: runtime.loaded
|
|
1381
|
+
})
|
|
1382
|
+
return
|
|
1383
|
+
}
|
|
1384
|
+
const all = await runtime.store.listMemories()
|
|
1385
|
+
const groups = groupByStatus(all)
|
|
1386
|
+
const metaTable = await runtime.store.table?.('meta')
|
|
1387
|
+
const meta = metaTable?.get('triage')
|
|
1388
|
+
const patrolMeta = metaTable?.get('patrol')
|
|
1389
|
+
const retrospectMeta = metaTable?.get(RETROSPECT_KEY)
|
|
1390
|
+
// 「每条记忆为什么进 / 为什么不进」随 state 下发。设置页只能通过 HTTP 拿到
|
|
1391
|
+
// 状态,没有会话 context,因此 cwd 走查询参数 —— 不传时按「无工作区上下文」
|
|
1392
|
+
// 计算,此时工作区记忆一律显示为「当前会话没有工作区信息」,这本身也是
|
|
1393
|
+
// 有用的诊断:用户会立刻明白为什么项目记忆在预览里消失了。
|
|
1394
|
+
const stateUrl = new URL(req.url, 'http://localhost')
|
|
1395
|
+
const stateCwd = String(stateUrl.searchParams.get('cwd') ?? '').trim()
|
|
1396
|
+
const injection = selectForInjection(all, config.memory.maxInjected, {
|
|
1397
|
+
maxInjectedChars: config.memory.maxInjectedChars,
|
|
1398
|
+
cwd: stateCwd
|
|
1399
|
+
})
|
|
1400
|
+
const { clusters, conflicts } = clusterPending(groups.pending)
|
|
1401
|
+
// 审计日志尾部随 state 一起下发:设置页要能直接画出「最近谁改了人设」,
|
|
1402
|
+
// 不必再发一次请求(客户端首次 RPC 通道未就绪时可能长时间挂起,
|
|
1403
|
+
// 少一次往返就少一个失败面)。完整列表与清空走 /api/anima/audit。
|
|
1404
|
+
const auditAll = await runtime.store.listAudit()
|
|
1405
|
+
// 「当前生效的是哪条预设」随 state 一起下发:设置页那颗下拉框要在打开时就选中它,
|
|
1406
|
+
// 而不是等用户点一次「套用」才显示。预设是一次性的,配置里不留痕迹,
|
|
1407
|
+
// 所以每轮都由 findActivePreset 按字段比对算出来。
|
|
1408
|
+
const presets = listPresets(config.presets)
|
|
1409
|
+
const activePreset = findActivePreset(config, presets)
|
|
1410
|
+
send(res, 200, {
|
|
1411
|
+
ok: true,
|
|
1412
|
+
config,
|
|
1413
|
+
loaded: runtime.loaded,
|
|
1414
|
+
presets,
|
|
1415
|
+
activeId: activePreset?.id ?? null,
|
|
1416
|
+
builtinPresetCount: BUILTIN_PRESETS.length,
|
|
1417
|
+
source: {
|
|
1418
|
+
kind: runtime.store.srcKind,
|
|
1419
|
+
path: runtime.store.srcPath,
|
|
1420
|
+
reason: runtime.store.srcReason,
|
|
1421
|
+
message: runtime.store.srcMessage,
|
|
1422
|
+
// 域打开时的版本戳。它同时是一个「插件真的把域打开了」的信号:
|
|
1423
|
+
// null 表示域没打开,正整数表示打开了哪种格式的介质。
|
|
1424
|
+
domainVersion: runtime.store.srcDomainVersion
|
|
1425
|
+
},
|
|
1426
|
+
limits: { ...FIELD_LIMITS, overridable: OVERRIDABLE_FIELDS, presetFields: PRESET_FIELDS, toolFields: TOOL_FIELDS },
|
|
1427
|
+
// 档位文案表下发给设置页的「一键填入」按钮。
|
|
1428
|
+
//
|
|
1429
|
+
// 为什么由 host 下发而不是客户端自己抄一份:这份文案**同时**是迁移期翻译旧配置的
|
|
1430
|
+
// 依据(lib/config.mjs 的 migrateConfig)。两边各存一份的话,改了一处漏了另一处,
|
|
1431
|
+
// 结果是「点按钮填进去的」与「旧配置升级后得到的」不是同一句话 —— 用户会以为
|
|
1432
|
+
// 插件在偷偷改他的风格。表在 lib/wording.mjs,这里是它的唯一出口。
|
|
1433
|
+
//
|
|
1434
|
+
// 过滤掉空串档位(三项特质的 default):空串在自由文本模型里等于「不提这件事」,
|
|
1435
|
+
// 做成按钮会让人以为点一下能得到什么,其实只是把输入框清空。
|
|
1436
|
+
wording: Object.fromEntries(
|
|
1437
|
+
Object.entries(WORDING_TABLES).map(([key, table]) => [
|
|
1438
|
+
key,
|
|
1439
|
+
Object.entries(table).filter(([, value]) => value !== '').map(([id, value]) => ({ id, text: value }))
|
|
1440
|
+
])
|
|
1441
|
+
),
|
|
1442
|
+
// 身份与背景字段的示例(用户 m08898:「每个文本都要有例子或者是一些预设」)。
|
|
1443
|
+
//
|
|
1444
|
+
// 与上面的 wording 分开下发,因为两者在界面上的行为不同:wording 是「点一下
|
|
1445
|
+
// 就定稿的档位」,示例是「点一下填进去、还要改成你自己的」。合成一张表会让
|
|
1446
|
+
// 前端分不清该不该提示「记得改成你自己的情况」。
|
|
1447
|
+
//
|
|
1448
|
+
// 结构是 `{ 字段名: [文本, …] }`;空数组(agentName)表示这个字段不给示例,
|
|
1449
|
+
// 前端据此不画按钮组,而不是画一个空组。
|
|
1450
|
+
examples: Object.fromEntries(
|
|
1451
|
+
Object.entries(FIELD_EXAMPLES).map(([key, list]) => [key, list.slice()])
|
|
1452
|
+
),
|
|
1453
|
+
// 待确认提议随 state 一起下发:设置页要能画出「模型提了个改动,等你点头」那一条,
|
|
1454
|
+
// 用户不必去敲命令才发现有东西挂着。
|
|
1455
|
+
pendingProposal: runtime.pendingProposal,
|
|
1456
|
+
memories: {
|
|
1457
|
+
pending: groups.pending,
|
|
1458
|
+
adopted: groups.adopted,
|
|
1459
|
+
discarded: groups.discarded.slice(-50)
|
|
1460
|
+
},
|
|
1461
|
+
// 注入可解释性 + 预算用量 + 本地聚类,三样都是纯计算结果,不落盘。
|
|
1462
|
+
injection: {
|
|
1463
|
+
cwd: stateCwd,
|
|
1464
|
+
decisions: injection.decisions,
|
|
1465
|
+
budget: injection.budget
|
|
1466
|
+
},
|
|
1467
|
+
clusters,
|
|
1468
|
+
conflicts,
|
|
1469
|
+
audit: {
|
|
1470
|
+
total: auditAll.length,
|
|
1471
|
+
max: AUDIT_MAX_RECORDS,
|
|
1472
|
+
entries: auditAll.slice(-20),
|
|
1473
|
+
sourceText: AUDIT_SOURCE_TEXT,
|
|
1474
|
+
actors: AUDIT_ACTORS
|
|
1475
|
+
},
|
|
1476
|
+
triageMeta: meta ?? null,
|
|
1477
|
+
// 巡夜记录:**这是「没汇报」可反查的唯一出口**。
|
|
1478
|
+
//
|
|
1479
|
+
// 只报有事之后,沉默有两种意思 —— 「巡过了,没事」和「巡夜没跑」。前者会
|
|
1480
|
+
// 写这一行,后者根本没有这一行,于是用户随时能分清。reported=false 且
|
|
1481
|
+
// facts 全 0 就是「真的没事」,不是「它死了」。
|
|
1482
|
+
patrolMeta: patrolMeta ?? null,
|
|
1483
|
+
// 回扫记录:与 patrolMeta 同理。「没线索」和「没扫」必须能分开 ——
|
|
1484
|
+
// 前者写着 scannedAt,后者根本没有这一行。
|
|
1485
|
+
retrospectMeta: retrospectMeta ?? null,
|
|
1486
|
+
// 质检钩子的运行计数。
|
|
1487
|
+
//
|
|
1488
|
+
// 口径是**累计**(跨重启),不是「本次运行」。曾经的注释写的是「仅内存,
|
|
1489
|
+
// 重启清零」,那让这个数字在重启后既可能是「真的没查过」也可能是
|
|
1490
|
+
// 「查了几百次但刚重启」—— 两种相反的事实长得一样,正是这个计数器
|
|
1491
|
+
// 存在的意义所在,清零等于把它废掉。现在落盘到 meta.qualityStats,
|
|
1492
|
+
// 读回时累加;`startedAt` 单独标出本次启动时刻,两种口径都看得到。
|
|
1493
|
+
//
|
|
1494
|
+
// 为什么把它放进 /state 而不是只在日志里:这个计数器的唯一用途就是让用户
|
|
1495
|
+
// 区分「查了、没事」和「根本没查」。放在需要翻日志才看得到的地方,等于
|
|
1496
|
+
// 在需要它的时候用不上它。设置页/诊断口一拉就能看到 checks=0 → 钩子是死的。
|
|
1497
|
+
qualityStats: {
|
|
1498
|
+
...runtime.qualityStats,
|
|
1499
|
+
skipped: { ...runtime.qualityStats.skipped },
|
|
1500
|
+
note: 'checks 是真正跑过判定的次数;skipped 里的原因说明为什么没跑。checks=0 就说明这一整套没在工作,别把「没有提醒」当成「没有问题」。'
|
|
1501
|
+
},
|
|
1502
|
+
// 一键导入(Phase ②):那段「复制给别的 AI」的提示词随 state 下发。
|
|
1503
|
+
//
|
|
1504
|
+
// 放 state 而不是单独开一条路由,理由是它**永远不变**(是常量,不随配置走):
|
|
1505
|
+
// 单独一条路由等于让设置页多一次往返、多一个失败面,而它本来就是打开页面就要用的。
|
|
1506
|
+
// 上限两个数字也一并下发,前端据此做字数提示与「分批」提示,不必各抄一份常量。
|
|
1507
|
+
memoryImport: {
|
|
1508
|
+
prompt: IMPORT_PROMPT_FULL,
|
|
1509
|
+
maxItems: IMPORT_MAX_ITEMS,
|
|
1510
|
+
maxSource: IMPORT_MAX_SOURCE,
|
|
1511
|
+
// 单条正文上限。以前前端把它硬编码成 500,与 lib/config.mjs 里的
|
|
1512
|
+
// MEMORY_TEXT_MAX 是两份独立的数字 —— 改了一边另一边就撒谎,
|
|
1513
|
+
// 而且是「提示语说 500,实际闸门是别的值」这种最难被发现的偏差。
|
|
1514
|
+
maxText: MEMORY_TEXT_MAX
|
|
1515
|
+
},
|
|
1516
|
+
workspaces: workspaceList(ctx)
|
|
1517
|
+
})
|
|
1518
|
+
})
|
|
1519
|
+
|
|
1520
|
+
// 预览:按当前全局配置 + 指定工作区覆盖,渲染最终送进模型的人设文本。
|
|
1521
|
+
// 设置页用它做「实时预览」——用户改一个字段就能看到提示词变成什么样。
|
|
1522
|
+
//
|
|
1523
|
+
// 两种调用形态:
|
|
1524
|
+
// ① 只给 workspacePath ⇒ 按**已保存**的全局配置渲染;
|
|
1525
|
+
// ② 同时带上人设字段(agentName=…&style=…)⇒ 用这些值临时覆盖全局配置再渲染,
|
|
1526
|
+
// 于是用户**不必先保存**就能看到预览。字段值走 sanitizeOverrides 白名单,
|
|
1527
|
+
// 非法值直接忽略(预览是只读展示,不该因为打到一半的字而报错)。
|
|
1528
|
+
route('/api/anima/preview', async (req, res) => {
|
|
1529
|
+
const url = new URL(req.url, 'http://localhost')
|
|
1530
|
+
const workspacePath = String(url.searchParams.get('workspacePath') ?? '').trim()
|
|
1531
|
+
const globalConfig = await runtime.store.getConfig()
|
|
1532
|
+
|
|
1533
|
+
const draft = {}
|
|
1534
|
+
for (const key of OVERRIDABLE_FIELDS) {
|
|
1535
|
+
const value = url.searchParams.get(key)
|
|
1536
|
+
if (typeof value === 'string' && value !== '') draft[key] = value
|
|
1537
|
+
}
|
|
1538
|
+
const { overrides: draftClean, errors: draftErrors } = sanitizeOverrides(draft)
|
|
1539
|
+
const draftConfig = Object.keys(draftClean).length > 0
|
|
1540
|
+
? { ...globalConfig, ...draftClean }
|
|
1541
|
+
: globalConfig
|
|
1542
|
+
// 被拒字段清单。预览刻意**不**因为半截输入而报错(用户还在打字,报错会打断输入),
|
|
1543
|
+
// 但静默忽略更糟:用户会以为「预览里就是这样」而实际上预览用的是上一次的合法值。
|
|
1544
|
+
// 把「你这次传的这几个字段没被采纳」明说出来,界面上就有地方可以标黄。
|
|
1545
|
+
const invalid = Object.entries(draftErrors).map(([key, reason]) => ({ key, reason }))
|
|
1546
|
+
|
|
1547
|
+
let overrides = null
|
|
1548
|
+
if (workspacePath && globalConfig.workspaceOverridesEnabled) {
|
|
1549
|
+
const local = await readOverrides(workspacePath, globalConfig.workspaceFileDir)
|
|
1550
|
+
overrides = local?.overrides ?? null
|
|
1551
|
+
}
|
|
1552
|
+
const { effective, sources } = mergePersona(draftConfig, overrides)
|
|
1553
|
+
// 预览里的记忆段必须与真实注入用**同一套**筛选参数(含该工作区的 cwd),
|
|
1554
|
+
// 否则用户会看到「预览里有这条、实际对话里没有」——那比没有预览更糟。
|
|
1555
|
+
const injection = selectForInjection(runtime.adopted, globalConfig.memory.maxInjected, {
|
|
1556
|
+
maxInjectedChars: globalConfig.memory.maxInjectedChars,
|
|
1557
|
+
cwd: workspacePath
|
|
1558
|
+
})
|
|
1559
|
+
send(res, 200, {
|
|
1560
|
+
ok: true,
|
|
1561
|
+
effective,
|
|
1562
|
+
draft: Object.keys(draftClean).length > 0,
|
|
1563
|
+
sources,
|
|
1564
|
+
description: describeSources(sources),
|
|
1565
|
+
sections: {
|
|
1566
|
+
identity: compileIdentity(effective),
|
|
1567
|
+
userProfile: compileUserProfile(effective),
|
|
1568
|
+
style: compileStyle(effective),
|
|
1569
|
+
instructions: compileInstructions(effective),
|
|
1570
|
+
rules: compileRules(),
|
|
1571
|
+
memory: compileMemory(injection.selected, summarizeOmission(injection)),
|
|
1572
|
+
pending: compilePendingNotice(runtime.pendingCount),
|
|
1573
|
+
// remember 指引段同样必须在预览里出现,理由与下面质检段完全一致:
|
|
1574
|
+
// 预览是「模型这一轮到底看到什么」的唯一对外答案。漏一段就等于
|
|
1575
|
+
// 对外保证了一份与实际注入不符的文本。
|
|
1576
|
+
remember: globalConfig.memory.enabled && globalConfig.memory.autoCapture
|
|
1577
|
+
? compileRememberGuide()
|
|
1578
|
+
: '',
|
|
1579
|
+
// 质检段也必须在预览里出现。
|
|
1580
|
+
//
|
|
1581
|
+
// 漏掉它的后果不是「少显示一段」——预览是唯一一个「模型这一轮到底看到什么」
|
|
1582
|
+
// 的对外答案,少了这一段就等于对外保证了一份与实际注入不符的文本。
|
|
1583
|
+
// 同一个疏漏此前在 getSystemPrompt 上犯过一次,这里不再犯第二次。
|
|
1584
|
+
quality: compileQualitySection(globalConfig.quality)
|
|
1585
|
+
},
|
|
1586
|
+
// 预览的是「配置生效时」的文本。插件被关掉时实际注入的是关闭声明,
|
|
1587
|
+
// 那属于状态而不是人设,用 enabled 单独表达,不塞进 sections 里冒充一段设定。
|
|
1588
|
+
enabled: runtime.config.enabled,
|
|
1589
|
+
persona: runtime.config.enabled ? compilePersona(effective) : compileDisabledNotice(),
|
|
1590
|
+
// 本次请求里被校验挡下的草稿字段。空数组 = 全部采纳。
|
|
1591
|
+
invalid,
|
|
1592
|
+
injection: { decisions: injection.decisions, budget: injection.budget }
|
|
1593
|
+
})
|
|
1594
|
+
})
|
|
1595
|
+
|
|
1596
|
+
// ---- 人设预设 ----
|
|
1597
|
+
route('/api/anima/presets', async (req, res) => {
|
|
1598
|
+
const config = await runtime.store.getConfig()
|
|
1599
|
+
const presets = listPresets(config.presets)
|
|
1600
|
+
// active 是**算出来的**,不是存下来的:预设套用后就散成字段值了,配置里没有
|
|
1601
|
+
// 「我来自哪条预设」这种痕迹。按字段逐条比对才能反映「用户现在这套到底还是不是它」。
|
|
1602
|
+
const active = findActivePreset(config, presets)
|
|
1603
|
+
send(res, 200, { ok: true, presets, activeId: active?.id ?? null })
|
|
1604
|
+
})
|
|
1605
|
+
|
|
1606
|
+
route('/api/anima/presets/apply', async (req, res) => {
|
|
1607
|
+
const parsed = await readJsonBody(req)
|
|
1608
|
+
const config = await runtime.store.getConfig()
|
|
1609
|
+
const preset = resolvePreset(config.presets, parsed?.id ?? parsed?.name)
|
|
1610
|
+
if (!preset) {
|
|
1611
|
+
send(res, 404, { ok: false, error: `找不到预设 ${String(parsed?.id ?? parsed?.name ?? '')}` })
|
|
1612
|
+
return
|
|
1613
|
+
}
|
|
1614
|
+
// 只覆盖预设声明的字段,其余保持用户当前值 —— 与工作区覆盖同一套语义。
|
|
1615
|
+
const patch = applyPreset(preset)
|
|
1616
|
+
const { patch: clean, errors } = sanitizeConfig(patch)
|
|
1617
|
+
if (Object.keys(errors).length > 0) {
|
|
1618
|
+
send(res, 400, { ok: false, error: `预设字段校验失败:${formatFieldErrors(errors)}`, errors })
|
|
1619
|
+
return
|
|
1620
|
+
}
|
|
1621
|
+
const { config: next, changed } = await runtime.store.updateConfig(clean, { actor: 'user', source: 'preset', note: `应用预设 ${preset.name}` })
|
|
1622
|
+
await reload(ctx, { refreshPrompt: changed.length > 0, configChange: { changed, source: 'preset' } })
|
|
1623
|
+
send(res, 200, { ok: true, preset: preset.name, changed, config: next })
|
|
1624
|
+
})
|
|
1625
|
+
|
|
1626
|
+
route('/api/anima/presets/save', async (req, res) => {
|
|
1627
|
+
const parsed = await readJsonBody(req)
|
|
1628
|
+
const config = await runtime.store.getConfig()
|
|
1629
|
+
const { preset, errors } = sanitizeCustomPreset(parsed?.preset ?? parsed)
|
|
1630
|
+
if (!preset) {
|
|
1631
|
+
send(res, 400, { ok: false, error: `预设校验失败:${formatFieldErrors(errors)}`, errors })
|
|
1632
|
+
return
|
|
1633
|
+
}
|
|
1634
|
+
const existing = config.presets ?? []
|
|
1635
|
+
if (!existing.some((p) => p.id === preset.id) && existing.length >= PRESET_COUNT_LIMIT) {
|
|
1636
|
+
send(res, 400, { ok: false, error: `自建预设最多 ${PRESET_COUNT_LIMIT} 条` })
|
|
1637
|
+
return
|
|
1638
|
+
}
|
|
1639
|
+
const next = existing.some((p) => p.id === preset.id)
|
|
1640
|
+
? existing.map((p) => (p.id === preset.id ? preset : p))
|
|
1641
|
+
: [...existing, preset]
|
|
1642
|
+
const { patch, errors: patchErrors } = sanitizeConfig({ presets: next })
|
|
1643
|
+
if (Object.keys(patchErrors).length > 0) {
|
|
1644
|
+
send(res, 400, { ok: false, error: formatFieldErrors(patchErrors), errors: patchErrors })
|
|
1645
|
+
return
|
|
1646
|
+
}
|
|
1647
|
+
const { config: saved } = await runtime.store.updateConfig(patch, { actor: 'user', source: 'preset', note: `保存预设 ${preset.name}` })
|
|
1648
|
+
send(res, 200, { ok: true, preset, presets: listPresets(saved.presets) })
|
|
1649
|
+
})
|
|
1650
|
+
|
|
1651
|
+
route('/api/anima/presets/delete', async (req, res) => {
|
|
1652
|
+
const parsed = await readJsonBody(req)
|
|
1653
|
+
const id = String(parsed?.id ?? '')
|
|
1654
|
+
if (!id) {
|
|
1655
|
+
send(res, 400, { ok: false, error: '缺少 id' })
|
|
1656
|
+
return
|
|
1657
|
+
}
|
|
1658
|
+
if (id.startsWith('builtin:')) {
|
|
1659
|
+
send(res, 400, { ok: false, error: '内置预设不可删除' })
|
|
1660
|
+
return
|
|
1661
|
+
}
|
|
1662
|
+
const config = await runtime.store.getConfig()
|
|
1663
|
+
const existing = config.presets ?? []
|
|
1664
|
+
const next = existing.filter((p) => p.id !== id)
|
|
1665
|
+
if (next.length === existing.length) {
|
|
1666
|
+
send(res, 404, { ok: false, error: `找不到自建预设 ${id}` })
|
|
1667
|
+
return
|
|
1668
|
+
}
|
|
1669
|
+
const { config: saved } = await runtime.store.updateConfig({ presets: next }, { actor: 'user', source: 'preset', note: `删除预设 ${id}` })
|
|
1670
|
+
send(res, 200, { ok: true, presets: listPresets(saved.presets) })
|
|
1671
|
+
})
|
|
1672
|
+
|
|
1673
|
+
route('/api/anima/config', async (req, res) => {
|
|
1674
|
+
if (req.method === 'GET') {
|
|
1675
|
+
send(res, 200, { ok: true, config: await runtime.store.getConfig() })
|
|
1676
|
+
return
|
|
1677
|
+
}
|
|
1678
|
+
const parsed = await readJsonBody(req)
|
|
1679
|
+
const { patch, errors } = sanitizeConfig(parsed)
|
|
1680
|
+
if (Object.keys(errors).length > 0) {
|
|
1681
|
+
send(res, 400, { ok: false, error: `配置校验失败:${formatFieldErrors(errors)}`, errors })
|
|
1682
|
+
return
|
|
1683
|
+
}
|
|
1684
|
+
const { config, changed } = await runtime.store.updateConfig(patch, { actor: 'user', source: 'settings' })
|
|
1685
|
+
await reload(ctx, { refreshPrompt: changed.length > 0, configChange: { changed, source: 'settings' } })
|
|
1686
|
+
send(res, 200, { ok: true, config, changed })
|
|
1687
|
+
})
|
|
1688
|
+
|
|
1689
|
+
// 破坏性动作:必须显式带确认字段。
|
|
1690
|
+
//
|
|
1691
|
+
// 2026-10-10 的事故:我为确认路由是否注册,对写路由逐一 POST 空体 `{}`,
|
|
1692
|
+
// 这条当场 200 并把人设重置成默认值 —— 三个字段被清空。设置页的按钮是
|
|
1693
|
+
// 人类点的,但「空 body 就等于确认」这个默认值本身就是缺陷。
|
|
1694
|
+
// 与某同生态插件的失败关闭(fail closed)语义一致:
|
|
1695
|
+
// 拿不到明确确认就什么都不做,并说明怎么做才有效。
|
|
1696
|
+
route('/api/anima/config/reset', async (req, res) => {
|
|
1697
|
+
const parsed = await readJsonBody(req)
|
|
1698
|
+
if (parsed?.confirm !== true) {
|
|
1699
|
+
send(res, 400, {
|
|
1700
|
+
ok: false,
|
|
1701
|
+
code: 'confirmation-required',
|
|
1702
|
+
error: '重置会清空已保存的人设字段。请在请求体里带 {"confirm": true} 再试。'
|
|
1703
|
+
})
|
|
1704
|
+
return
|
|
1705
|
+
}
|
|
1706
|
+
const { config, changed } = await runtime.store.resetConfig({ actor: 'user' })
|
|
1707
|
+
await reload(ctx, { refreshPrompt: true, configChange: { changed, source: 'settings' } })
|
|
1708
|
+
send(res, 200, { ok: true, config, changed })
|
|
1709
|
+
})
|
|
1710
|
+
|
|
1711
|
+
// 待确认提议的裁决(设置页的确认条与 /anima confirm|reject 走同一条逻辑)。
|
|
1712
|
+
// 提议是「模型提了个改动、等用户点头」的暂存区:确认即写盘,拒绝即丢弃。
|
|
1713
|
+
route('/api/anima/proposal', async (req, res) => {
|
|
1714
|
+
const parsed = await readJsonBody(req)
|
|
1715
|
+
const action = String(parsed?.action ?? '').trim()
|
|
1716
|
+
const proposal = runtime.pendingProposal
|
|
1717
|
+
if (!proposal) {
|
|
1718
|
+
send(res, 404, { ok: false, error: '当前没有待确认的人设变更提议。' })
|
|
1719
|
+
return
|
|
1720
|
+
}
|
|
1721
|
+
if (action !== 'confirm' && action !== 'reject') {
|
|
1722
|
+
send(res, 400, { ok: false, error: 'action 必须是 confirm 或 reject' })
|
|
1723
|
+
return
|
|
1724
|
+
}
|
|
1725
|
+
runtime.pendingProposal = null
|
|
1726
|
+
if (action === 'reject') {
|
|
1727
|
+
send(res, 200, { ok: true, rejected: true, config: runtime.config, changed: [] })
|
|
1728
|
+
return
|
|
1729
|
+
}
|
|
1730
|
+
const { patch, errors } = sanitizeConfig(proposal.patch)
|
|
1731
|
+
if (Object.keys(errors).length > 0) {
|
|
1732
|
+
send(res, 400, { ok: false, error: `提议已失效:${formatFieldErrors(errors)}` })
|
|
1733
|
+
return
|
|
1734
|
+
}
|
|
1735
|
+
const { config, changed } = await runtime.store.updateConfig(patch, { actor: 'user', source: 'proposal', note: '用户在设置页确认了模型提议' })
|
|
1736
|
+
await reload(ctx, { refreshPrompt: changed.length > 0, configChange: { changed, source: 'settings' } })
|
|
1737
|
+
send(res, 200, { ok: true, rejected: false, config, changed })
|
|
1738
|
+
})
|
|
1739
|
+
|
|
1740
|
+
// ---- 人设变更审计日志 ----
|
|
1741
|
+
//
|
|
1742
|
+
// 存在的理由:本插件有三个入口能改人设(设置页 / 命令 / 模型工具),
|
|
1743
|
+
// 而「工具确认」默认关闭 —— 也就是说模型可以直接改写「你是谁、我叫你什么、
|
|
1744
|
+
// 用什么语气」。用户第二天发现语气变了,无从知道是谁、何时、把哪个字段改成了什么。
|
|
1745
|
+
// 这条路由就是把那份记录原样交出去,不做任何判断。
|
|
1746
|
+
route('/api/anima/audit', async (req, res) => {
|
|
1747
|
+
if (req.method === 'DELETE') {
|
|
1748
|
+
const removed = await runtime.store.clearAudit()
|
|
1749
|
+
send(res, 200, { ok: true, removed })
|
|
1750
|
+
return
|
|
1751
|
+
}
|
|
1752
|
+
const entries = await runtime.store.listAudit()
|
|
1753
|
+
const limit = Number(new URL(req.url, 'http://localhost').searchParams.get('limit') ?? 0)
|
|
1754
|
+
const list = Number.isFinite(limit) && limit > 0 ? entries.slice(-limit) : entries
|
|
1755
|
+
send(res, 200, {
|
|
1756
|
+
ok: true,
|
|
1757
|
+
entries: list,
|
|
1758
|
+
total: entries.length,
|
|
1759
|
+
max: AUDIT_MAX_RECORDS,
|
|
1760
|
+
actors: AUDIT_ACTORS,
|
|
1761
|
+
sourceText: AUDIT_SOURCE_TEXT
|
|
1762
|
+
})
|
|
1763
|
+
})
|
|
1764
|
+
|
|
1765
|
+
route('/api/anima/workspace', async (req, res) => {
|
|
1766
|
+
const parsed = req.method === 'GET'
|
|
1767
|
+
? { workspacePath: new URL(req.url, 'http://localhost').searchParams.get('workspacePath') }
|
|
1768
|
+
: await readJsonBody(req)
|
|
1769
|
+
const workspacePath = String(parsed?.workspacePath ?? '').trim()
|
|
1770
|
+
if (!workspacePath) {
|
|
1771
|
+
send(res, 400, { ok: false, error: '缺少 workspacePath' })
|
|
1772
|
+
return
|
|
1773
|
+
}
|
|
1774
|
+
if (req.method === 'GET') {
|
|
1775
|
+
send(res, 200, { ok: true, ...(await workspaceState(ctx, workspacePath)) })
|
|
1776
|
+
return
|
|
1777
|
+
}
|
|
1778
|
+
const { overrides, errors } = sanitizeOverrides(parsed?.overrides ?? {})
|
|
1779
|
+
if (Object.keys(errors).length > 0) {
|
|
1780
|
+
send(res, 400, { ok: false, error: `覆盖校验失败:${formatFieldErrors(errors)}`, errors })
|
|
1781
|
+
return
|
|
1782
|
+
}
|
|
1783
|
+
const before = await readOverrides(workspacePath, runtime.config.workspaceFileDir)
|
|
1784
|
+
if (Object.keys(overrides).length === 0) {
|
|
1785
|
+
await removeOverrides(workspacePath, runtime.config.workspaceFileDir)
|
|
1786
|
+
} else {
|
|
1787
|
+
await writeOverrides(workspacePath, runtime.config.workspaceFileDir, overrides)
|
|
1788
|
+
await ensureIgnored(workspacePath, runtime.config.workspaceFileDir)
|
|
1789
|
+
}
|
|
1790
|
+
// 覆盖文件是配置的另一个写入面:改完也要向活动会话声明旧设定作废,
|
|
1791
|
+
// 否则用户在本工作区换了一套说法,模型还在按全局那套回答。
|
|
1792
|
+
const { changed } = diffOverrides(before.overrides, overrides)
|
|
1793
|
+
await reload(ctx, {
|
|
1794
|
+
refreshPrompt: true,
|
|
1795
|
+
configChange: changed.length > 0
|
|
1796
|
+
? { changed, source: `本工作区覆盖(${workspacePath})` }
|
|
1797
|
+
: null
|
|
1798
|
+
})
|
|
1799
|
+
send(res, 200, { ok: true, ...(await workspaceState(ctx, workspacePath)) })
|
|
1800
|
+
})
|
|
1801
|
+
|
|
1802
|
+
route('/api/anima/memory/decide', async (req, res) => {
|
|
1803
|
+
const parsed = await readJsonBody(req)
|
|
1804
|
+
const id = String(parsed?.id ?? '')
|
|
1805
|
+
const decision = String(parsed?.decision ?? '')
|
|
1806
|
+
if (!id) {
|
|
1807
|
+
send(res, 400, { ok: false, error: '缺少 id' })
|
|
1808
|
+
return
|
|
1809
|
+
}
|
|
1810
|
+
if (decision !== 'adopt' && decision !== 'discard') {
|
|
1811
|
+
send(res, 400, { ok: false, error: 'decision 只能是 adopt 或 discard' })
|
|
1812
|
+
return
|
|
1813
|
+
}
|
|
1814
|
+
const record = await runtime.store.getMemory(id)
|
|
1815
|
+
if (!record) {
|
|
1816
|
+
send(res, 404, { ok: false, error: `找不到记忆 ${id}` })
|
|
1817
|
+
return
|
|
1818
|
+
}
|
|
1819
|
+
await runtime.store.putMemory({
|
|
1820
|
+
...record,
|
|
1821
|
+
status: decision === 'adopt' ? 'adopted' : 'discarded',
|
|
1822
|
+
decidedAt: new Date().toISOString()
|
|
1823
|
+
})
|
|
1824
|
+
await reload(ctx, { refreshPrompt: true })
|
|
1825
|
+
send(res, 200, { ok: true, id, status: decision === 'adopt' ? 'adopted' : 'discarded' })
|
|
1826
|
+
})
|
|
1827
|
+
|
|
1828
|
+
route('/api/anima/memory/delete', async (req, res) => {
|
|
1829
|
+
const parsed = await readJsonBody(req)
|
|
1830
|
+
const id = String(parsed?.id ?? '')
|
|
1831
|
+
if (!id) {
|
|
1832
|
+
send(res, 400, { ok: false, error: '缺少 id' })
|
|
1833
|
+
return
|
|
1834
|
+
}
|
|
1835
|
+
const removed = await runtime.store.deleteMemory(id)
|
|
1836
|
+
await reload(ctx, { refreshPrompt: true })
|
|
1837
|
+
send(res, 200, { ok: true, removed })
|
|
1838
|
+
})
|
|
1839
|
+
|
|
1840
|
+
// 改写一条已有记忆的元数据(类型 / 生命周期 / 复核点 / 来源引用)。
|
|
1841
|
+
//
|
|
1842
|
+
// 为什么单独开一条路由而不是塞进 memory/decide:decide 的语义是「用户裁决这条
|
|
1843
|
+
// 要不要留下」,它只能改 status;而分类是在「已经留下的」条目上做的二次整理。
|
|
1844
|
+
// 两者混在一个接口里,将来任何一次误传 decision 值都会同时改动裁决状态。
|
|
1845
|
+
route('/api/anima/memory/meta', async (req, res) => {
|
|
1846
|
+
const parsed = await readJsonBody(req)
|
|
1847
|
+
const id = String(parsed?.id ?? '')
|
|
1848
|
+
if (!id) {
|
|
1849
|
+
send(res, 400, { ok: false, error: '缺少 id' })
|
|
1850
|
+
return
|
|
1851
|
+
}
|
|
1852
|
+
const record = await runtime.store.getMemory(id)
|
|
1853
|
+
if (!record) {
|
|
1854
|
+
send(res, 404, { ok: false, error: `找不到记忆 ${id}` })
|
|
1855
|
+
return
|
|
1856
|
+
}
|
|
1857
|
+
// 取代指针必须指向一条真的存在的记忆。活着的时候不查,将来读的人
|
|
1858
|
+
// 会以为有一条点得开的「后来那条」,实际上什么也没有。
|
|
1859
|
+
if (typeof parsed?.supersededBy === 'string' && parsed.supersededBy.trim() !== '') {
|
|
1860
|
+
const other = await runtime.store.getMemory(parsed.supersededBy.trim())
|
|
1861
|
+
if (!other) {
|
|
1862
|
+
send(res, 400, { ok: false, error: `supersededBy 指向的记忆不存在:${parsed.supersededBy.trim()}` })
|
|
1863
|
+
return
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
const applied = applyMemoryMeta(record, {
|
|
1867
|
+
type: parsed?.type,
|
|
1868
|
+
state: parsed?.state,
|
|
1869
|
+
verifyAt: parsed?.verifyAt,
|
|
1870
|
+
sourceRef: parsed?.sourceRef,
|
|
1871
|
+
supersededBy: parsed?.supersededBy
|
|
1872
|
+
})
|
|
1873
|
+
if (!applied.ok) {
|
|
1874
|
+
send(res, 400, { ok: false, error: applied.error })
|
|
1875
|
+
return
|
|
1876
|
+
}
|
|
1877
|
+
// 一条都没变就别写盘,更别回「已更新」——那是谎报。
|
|
1878
|
+
if (applied.changed.length === 0) {
|
|
1879
|
+
send(res, 200, { ok: true, id, changed: [], record })
|
|
1880
|
+
return
|
|
1881
|
+
}
|
|
1882
|
+
await runtime.store.putMemory(applied.record)
|
|
1883
|
+
await reload(ctx, { refreshPrompt: true })
|
|
1884
|
+
send(res, 200, { ok: true, id, changed: applied.changed, record: applied.record })
|
|
1885
|
+
})
|
|
1886
|
+
|
|
1887
|
+
// 批量回填:一次提交多条 [{id, type, ...}],逐条应用并汇总。
|
|
1888
|
+
//
|
|
1889
|
+
// 为什么必须有批量口:21 条旧记忆要补类型,逐条发 21 次请求意味着 21 次写盘
|
|
1890
|
+
// 与 21 次提示词重注册;中途失败还会留下「一半有条目有一半没有」的状态,
|
|
1891
|
+
// 而用户看到的只是一个失败提示,不知道已经改到哪儿了。
|
|
1892
|
+
route('/api/anima/memory/meta/batch', async (req, res) => {
|
|
1893
|
+
const parsed = await readJsonBody(req)
|
|
1894
|
+
const items = Array.isArray(parsed?.items) ? parsed.items : []
|
|
1895
|
+
if (items.length === 0) {
|
|
1896
|
+
send(res, 400, { ok: false, error: 'items 不能为空' })
|
|
1897
|
+
return
|
|
1898
|
+
}
|
|
1899
|
+
if (items.length > 500) {
|
|
1900
|
+
send(res, 400, { ok: false, error: '一次最多 500 条' })
|
|
1901
|
+
return
|
|
1902
|
+
}
|
|
1903
|
+
const results = []
|
|
1904
|
+
let written = 0
|
|
1905
|
+
for (const item of items) {
|
|
1906
|
+
const id = String(item?.id ?? '')
|
|
1907
|
+
if (!id) {
|
|
1908
|
+
results.push({ id, ok: false, error: '缺少 id' })
|
|
1909
|
+
continue
|
|
1910
|
+
}
|
|
1911
|
+
const record = await runtime.store.getMemory(id)
|
|
1912
|
+
if (!record) {
|
|
1913
|
+
results.push({ id, ok: false, error: `找不到记忆 ${id}` })
|
|
1914
|
+
continue
|
|
1915
|
+
}
|
|
1916
|
+
if (typeof item?.supersededBy === 'string' && item.supersededBy.trim() !== '') {
|
|
1917
|
+
const other = await runtime.store.getMemory(item.supersededBy.trim())
|
|
1918
|
+
if (!other) {
|
|
1919
|
+
results.push({ id, ok: false, error: `supersededBy 指向的记忆不存在:${item.supersededBy.trim()}` })
|
|
1920
|
+
continue
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
const applied = applyMemoryMeta(record, {
|
|
1924
|
+
type: item?.type,
|
|
1925
|
+
state: item?.state,
|
|
1926
|
+
verifyAt: item?.verifyAt,
|
|
1927
|
+
sourceRef: item?.sourceRef,
|
|
1928
|
+
supersededBy: item?.supersededBy
|
|
1929
|
+
})
|
|
1930
|
+
if (!applied.ok) {
|
|
1931
|
+
results.push({ id, ok: false, error: applied.error })
|
|
1932
|
+
continue
|
|
1933
|
+
}
|
|
1934
|
+
if (applied.changed.length > 0) {
|
|
1935
|
+
await runtime.store.putMemory(applied.record)
|
|
1936
|
+
written += 1
|
|
1937
|
+
}
|
|
1938
|
+
results.push({ id, ok: true, changed: applied.changed })
|
|
1939
|
+
}
|
|
1940
|
+
if (written > 0) await reload(ctx, { refreshPrompt: true })
|
|
1941
|
+
// 逐条回执而不是只说「成功 N 条」:失败的那些必须带回原因,否则用户
|
|
1942
|
+
// 无法判断是「这条不需要改」还是「这条改不了」。
|
|
1943
|
+
send(res, 200, { ok: true, written, total: items.length, results })
|
|
1944
|
+
})
|
|
1945
|
+
|
|
1946
|
+
route('/api/anima/memory/triage', async (req, res) => {
|
|
1947
|
+
const result = await runTriage(ctx, { reason: 'manual' })
|
|
1948
|
+
send(res, result.ok ? 200 : 400, result)
|
|
1949
|
+
})
|
|
1950
|
+
|
|
1951
|
+
// 手动跑一次巡夜。与定时调度走同一条判据(见 lib/patrol.mjs),
|
|
1952
|
+
// 否则「手点一次」和「它自己跑一次」会给出不同答案,那这个机制就没法验。
|
|
1953
|
+
route('/api/anima/patrol', async (req, res) => {
|
|
1954
|
+
const result = await runPatrol(ctx, { reason: 'manual' })
|
|
1955
|
+
send(res, result.ok ? 200 : 400, result)
|
|
1956
|
+
})
|
|
1957
|
+
|
|
1958
|
+
// 回扫式复盘:与巡夜走同一条判据纪律(无论说不说都留痕),但触发时机不同 ——
|
|
1959
|
+
// 它可以手动跑,也可以在启动补跑时顺带跑一次。
|
|
1960
|
+
route('/api/anima/retrospect', async (req, res) => {
|
|
1961
|
+
const result = await runRetrospect(ctx, { reason: 'manual' })
|
|
1962
|
+
send(res, result.ok ? 200 : 400, result)
|
|
1963
|
+
})
|
|
1964
|
+
|
|
1965
|
+
// 记忆体检:只做机械能验的事(路径还在不在、复核点过没过),不改任何状态。
|
|
1966
|
+
//
|
|
1967
|
+
// 为什么不自动改:判断「这两条讲的是不是同一件事、新的有没有推翻旧的」是语义
|
|
1968
|
+
// 判断,而记忆库一旦自动做语义判断,就会生成用户没说过的话。所以这里产出的
|
|
1969
|
+
// 是**带证据的候选**,每条都附一条可以直接复制的 /anima mark 命令 ——
|
|
1970
|
+
// 扫描可以自动,裁决必须由用户点。
|
|
1971
|
+
route('/api/anima/memory/audit', async (req, res) => {
|
|
1972
|
+
const records = await runtime.store.listMemories()
|
|
1973
|
+
const result = auditMemories(records, { probe: (p) => existsSync(p) })
|
|
1974
|
+
send(res, 200, { ok: true, ...result })
|
|
1975
|
+
})
|
|
1976
|
+
|
|
1977
|
+
route('/api/anima/memory/add', async (req, res) => {
|
|
1978
|
+
const parsed = await readJsonBody(req)
|
|
1979
|
+
const created = makeCandidate({
|
|
1980
|
+
text: parsed?.text,
|
|
1981
|
+
note: parsed?.note,
|
|
1982
|
+
source: 'manual',
|
|
1983
|
+
scope: typeof parsed?.scope === 'string' ? parsed.scope : undefined,
|
|
1984
|
+
// 这三样以前没透传,于是「手动加一条」永远只能得到默认的 fact 分类:
|
|
1985
|
+
// stance 与 event 在真实存储里因此恒为 0,「我改主意了→曾如此」那条
|
|
1986
|
+
// 链路一次都没跑过(m06419 之后实测发现)。
|
|
1987
|
+
type: typeof parsed?.type === 'string' ? parsed.type : undefined,
|
|
1988
|
+
sourceRef: typeof parsed?.sourceRef === 'string' ? parsed.sourceRef : undefined,
|
|
1989
|
+
verifyAt: typeof parsed?.verifyAt === 'string' ? parsed.verifyAt : undefined
|
|
1990
|
+
})
|
|
1991
|
+
if (!created.ok) {
|
|
1992
|
+
send(res, 400, { ok: false, error: created.error })
|
|
1993
|
+
return
|
|
1994
|
+
}
|
|
1995
|
+
await runtime.store.putMemory(created.record)
|
|
1996
|
+
await reload(ctx, { refreshPrompt: true })
|
|
1997
|
+
send(res, 200, { ok: true, record: created.record })
|
|
1998
|
+
})
|
|
1999
|
+
|
|
2000
|
+
// ---- 一键导入外部 AI 记忆(Phase ②,用户 m13808)----
|
|
2001
|
+
//
|
|
2002
|
+
// 做成「先解析、再提交」两步,而不是一个接口吃掉粘贴文本:
|
|
2003
|
+
// ① 解析结果里有一批**不能入库**的东西(命中凭据闸门的、超长的、与已有记忆重复的),
|
|
2004
|
+
// 用户必须在写盘之前看见它们 —— 事后才发现凭据被拒,他会怀疑是不是真的没落盘;
|
|
2005
|
+
// ② 自动归类是**建议**,用户要能逐条改;改完再提交,才谈得上「这些是我认可的」。
|
|
2006
|
+
// 两步都走同一条凭据闸门(lib/import.mjs 里的 parseImport),不存在「预览过了就能绕过」。
|
|
2007
|
+
route('/api/anima/memory/import/parse', async (req, res) => {
|
|
2008
|
+
const parsed = await readJsonBody(req)
|
|
2009
|
+
const text = String(parsed?.text ?? '')
|
|
2010
|
+
// cwd 与 /state 同一口径:走查询参数。设置页没有会话 context,
|
|
2011
|
+
// 归类建议里的「本工作区」需要这个坐标才能落到对的路径上。
|
|
2012
|
+
const importCwd = String(new URL(req.url, 'http://localhost').searchParams.get('cwd') ?? '').trim()
|
|
2013
|
+
// 已有记忆与待审候选一起参与查重:粘贴回来的往往就是本插件导出过的内容。
|
|
2014
|
+
const existing = await runtime.store.listMemories()
|
|
2015
|
+
const result = parseImport(text, existing, { cwd: importCwd })
|
|
2016
|
+
if (!result.ok) {
|
|
2017
|
+
send(res, 400, { ok: false, error: result.error })
|
|
2018
|
+
return
|
|
2019
|
+
}
|
|
2020
|
+
// 「这一批里有一条跟我缓冲区里某条几乎一样」——只在预览里提示,不自动合并、
|
|
2021
|
+
// 不自动丢弃。用户看一眼就知道该留哪条,而自动合并会生成一段他没说过的话,
|
|
2022
|
+
// 那违背「写进来容易、生效要人点头」这条立场(与 clusterPending 同一口径)。
|
|
2023
|
+
const overlaps = findImportOverlaps(
|
|
2024
|
+
result.items.map((item) => ({ text: item.text })),
|
|
2025
|
+
existing.filter((record) => record?.status === 'pending')
|
|
2026
|
+
)
|
|
2027
|
+
// 预览只回可入库的那批(附归类建议),其余按类别计数回给前端做说明。
|
|
2028
|
+
send(res, 200, {
|
|
2029
|
+
ok: true,
|
|
2030
|
+
items: result.items.map((item, index) => ({
|
|
2031
|
+
index,
|
|
2032
|
+
text: item.text,
|
|
2033
|
+
layer: item.suggested.layer,
|
|
2034
|
+
reason: item.suggested.reason,
|
|
2035
|
+
confidence: item.suggested.confidence,
|
|
2036
|
+
// 同义提示挂在条目上而不是单独一组:用户是在逐条扫的,
|
|
2037
|
+
// 提示要出现在他正看着的那一条旁边,翻到别处对照就不是提示了。
|
|
2038
|
+
overlap: overlaps.find((entry) => entry.itemIndex === index) ?? null
|
|
2039
|
+
})),
|
|
2040
|
+
counts: result.counts,
|
|
2041
|
+
// 拦下来的内容回**原文**而不是计数:用户得能看见「哪一条被拒了、为什么」。
|
|
2042
|
+
// 凭据那类只回命中类型,不回凭据本身(回显一次就多一份副本)。
|
|
2043
|
+
blocked: result.blocked.map((entry) => ({
|
|
2044
|
+
text: entry.text,
|
|
2045
|
+
credentials: entry.credentials
|
|
2046
|
+
})),
|
|
2047
|
+
tooLong: result.tooLong,
|
|
2048
|
+
duplicates: result.duplicates,
|
|
2049
|
+
overflow: result.overflow
|
|
2050
|
+
})
|
|
2051
|
+
})
|
|
2052
|
+
|
|
2053
|
+
route('/api/anima/memory/import/commit', async (req, res) => {
|
|
2054
|
+
const parsed = await readJsonBody(req)
|
|
2055
|
+
const items = Array.isArray(parsed?.items) ? parsed.items : null
|
|
2056
|
+
if (!items || items.length === 0) {
|
|
2057
|
+
send(res, 400, { ok: false, error: '没有要导入的条目' })
|
|
2058
|
+
return
|
|
2059
|
+
}
|
|
2060
|
+
if (items.length > IMPORT_MAX_ITEMS) {
|
|
2061
|
+
send(res, 400, { ok: false, error: `一次最多导入 ${IMPORT_MAX_ITEMS} 条(收到 ${items.length})` })
|
|
2062
|
+
return
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// cwd 与 parse 同一口径:走查询参数,**绝不信 body 里的 item.cwd**。
|
|
2066
|
+
// 这条曾经是一个静默的功能性 bug(2026-11-08):parse 阶段把 cwd 写进了
|
|
2067
|
+
// record,commit 阶段却又 makeCandidate 了一次、没带 cwd ⇒ 落库记录没有
|
|
2068
|
+
// cwd 字段 ⇒ matchLayer 判 workspace-scope-unknown ⇒ 用户明明采纳了,
|
|
2069
|
+
// 这条记忆却永远不会被注入,而界面上一切正常。
|
|
2070
|
+
// 为什么不信 item.cwd:提交阶段的输入来自网络请求,可以伪造;而 cwd 决定
|
|
2071
|
+
// 「这条记忆在哪些工作区生效」,让请求方自报坐标等于让它自己划范围。
|
|
2072
|
+
const commitCwd = String(new URL(req.url, 'http://localhost').searchParams.get('cwd') ?? '').trim()
|
|
2073
|
+
|
|
2074
|
+
const added = []
|
|
2075
|
+
const rejected = []
|
|
2076
|
+
for (const item of items) {
|
|
2077
|
+
// layer 由用户在预览里决定(可能被改过),这里照收;但 workspace 级
|
|
2078
|
+
// 必须有坐标可挂,否则收下的就是一条永远不会注入的死记录。逐条拒绝、
|
|
2079
|
+
// 说清原因,比静默降级成全局诚实 —— 降级会改变它生效的范围,那是
|
|
2080
|
+
// 用户的决定,不是服务端的。
|
|
2081
|
+
const wantedLayer = item?.layer === 'workspace' ? 'workspace' : 'global'
|
|
2082
|
+
if (wantedLayer === 'workspace' && !commitCwd) {
|
|
2083
|
+
rejected.push({
|
|
2084
|
+
text: String(item?.text ?? ''),
|
|
2085
|
+
error: '这条选了「本工作区」,但这次提交没带工作区坐标(当前没选定工作区),所以没有入库。请先在设置页选一个工作区,或把它改成「全局」再导入。'
|
|
2086
|
+
})
|
|
2087
|
+
continue
|
|
2088
|
+
}
|
|
2089
|
+
// 重新走一遍 makeCandidate:提交阶段的输入来自网络请求,不能假定它还是
|
|
2090
|
+
// 解析阶段那批东西(用户可以在预览里改 layer,也可以在控制台里直接发请求)。
|
|
2091
|
+
const created = makeCandidate({
|
|
2092
|
+
text: item?.text,
|
|
2093
|
+
layer: wantedLayer,
|
|
2094
|
+
source: 'import',
|
|
2095
|
+
note: item?.note,
|
|
2096
|
+
cwd: wantedLayer === 'workspace' ? commitCwd : ''
|
|
2097
|
+
})
|
|
2098
|
+
if (!created.ok) {
|
|
2099
|
+
rejected.push({ text: String(item?.text ?? ''), error: created.error })
|
|
2100
|
+
continue
|
|
2101
|
+
}
|
|
2102
|
+
await runtime.store.putMemory(created.record)
|
|
2103
|
+
added.push(created.record)
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
if (added.length > 0) {
|
|
2107
|
+
// 导入进来的是 pending 候选,不进提示词 ⇒ 不刷新提示词;
|
|
2108
|
+
// 但「待审数量」变了,会话开始时的提醒条要对得上。
|
|
2109
|
+
await reload(ctx, { refreshPrompt: false })
|
|
2110
|
+
}
|
|
2111
|
+
// `added` 回**完整记录数组**,不是计数:它与 `rejected` 对称,调用方既能
|
|
2112
|
+
// 报「导入了 N 条」,也能自己挑 id 做后续动作。曾经这里回的是
|
|
2113
|
+
// `added.length`(数字),而设置页按数组读 `.length` ⇒ 成功提示恒显示
|
|
2114
|
+
// 0 条 —— 冒烟夹具当时手写成数组,正好把这个不一致掩盖过去。
|
|
2115
|
+
send(res, 200, { ok: true, added, rejected })
|
|
2116
|
+
})
|
|
2117
|
+
})
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// ==================== 命令 ====================
|
|
2121
|
+
|
|
2122
|
+
/**
|
|
2123
|
+
* `/anima set` 能写的键。
|
|
2124
|
+
*
|
|
2125
|
+
* 顶层是人设与界面字段,`memory.*` 是记忆段的子键 —— 两者都是标量,能用 `key=value`
|
|
2126
|
+
* 表达。刻意**不含** `presets`:预设是一组结构化数据(每条含 name / fields /
|
|
2127
|
+
* description),等号拼不出来;硬做只会做出一个能写坏配置的入口,而设置页那条路
|
|
2128
|
+
* 已经够用。
|
|
2129
|
+
*/
|
|
2130
|
+
const SET_TOP_KEYS = new Set([
|
|
2131
|
+
'enabled', 'agentName', 'agentRole', 'addressUserAs', 'userOccupation', 'userBio',
|
|
2132
|
+
'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language',
|
|
2133
|
+
'customInstructions', 'requireToolConfirmation', 'trailEnabled', 'trailColor',
|
|
2134
|
+
'trailSpeed', 'trailWidth', 'workspaceOverridesEnabled', 'workspaceFileDir'
|
|
2135
|
+
])
|
|
2136
|
+
|
|
2137
|
+
const SET_MEMORY_KEYS = new Set([
|
|
2138
|
+
'enabled', 'autoCapture', 'nightlyTriage', 'nightlyHour', 'maxPending',
|
|
2139
|
+
'maxInjected', 'maxInjectedChars', 'notifyOnSessionStart', 'defaultLayer',
|
|
2140
|
+
'retrospectEnabled', 'retrospectDays'
|
|
2141
|
+
])
|
|
2142
|
+
|
|
2143
|
+
/**
|
|
2144
|
+
* 质检段可在命令行里改的键。
|
|
2145
|
+
*
|
|
2146
|
+
* 为什么这几个必须能从命令行关:质检层是唯一会**主动往对话里插消息**的部件,
|
|
2147
|
+
* 一旦它误判(strict 模式的误报率本来就不低),用户得有一个不用改 JSON、
|
|
2148
|
+
* 不用重启的方式立刻让它闭嘴。`quality.enabled=false` 就是那个刹车。
|
|
2149
|
+
*/
|
|
2150
|
+
const SET_QUALITY_KEYS = new Set(['enabled', 'ledgerLevel', 'strict', 'preStepCheck', 'maxFindingsInReminder'])
|
|
2151
|
+
|
|
2152
|
+
/**
|
|
2153
|
+
* 命令行里按布尔解析的键。
|
|
2154
|
+
*
|
|
2155
|
+
* 为什么需要这份名单:`set trailEnabled=off` 里的 `off` 是**字符串**,
|
|
2156
|
+
* 直接交给 sanitizeConfig 会被判「必须为布尔值」而拒绝。命令层必须先把人话
|
|
2157
|
+
* (on/off/yes/no)翻成 true/false —— 但只在名单内的键上翻,`set agentName=off`
|
|
2158
|
+
* 还得是那个名字叫「off」的助手。
|
|
2159
|
+
*/
|
|
2160
|
+
const SET_BOOLEAN_KEYS = new Set([
|
|
2161
|
+
'enabled', 'requireToolConfirmation', 'trailEnabled', 'workspaceOverridesEnabled',
|
|
2162
|
+
'memory.enabled', 'memory.autoCapture', 'memory.nightlyTriage', 'memory.notifyOnSessionStart',
|
|
2163
|
+
'memory.retrospectEnabled',
|
|
2164
|
+
'quality.enabled', 'quality.strict', 'quality.preStepCheck'
|
|
2165
|
+
])
|
|
2166
|
+
|
|
2167
|
+
/** 按整数解析的键。**范围不在这里判** —— 越界要报「必须是 0..23」,交给 sanitizeConfig。 */
|
|
2168
|
+
const SET_NUMBER_KEYS = new Set([
|
|
2169
|
+
'memory.nightlyHour', 'memory.maxPending', 'memory.maxInjected', 'memory.maxInjectedChars',
|
|
2170
|
+
'memory.retrospectDays',
|
|
2171
|
+
'quality.maxFindingsInReminder'
|
|
2172
|
+
])
|
|
2173
|
+
|
|
2174
|
+
const SET_BOOLEAN_WORDS = new Map([
|
|
2175
|
+
['true', true], ['1', true], ['on', true], ['yes', true],
|
|
2176
|
+
['false', false], ['0', false], ['off', false], ['no', false]
|
|
2177
|
+
])
|
|
2178
|
+
|
|
2179
|
+
/**
|
|
2180
|
+
* 解析 `key=value` 参数。
|
|
2181
|
+
*
|
|
2182
|
+
* 规则:不含 `=` 的 token 追加到**上一个**值上。于是
|
|
2183
|
+
* `/anima set customInstructions=先给结论 再给理由` 的后半句不会被当成键名丢掉,
|
|
2184
|
+
* 而 `set =x` 会因「没有键」直接报用法错误 —— 笔误在这里就该失败。
|
|
2185
|
+
*/
|
|
2186
|
+
function parseSetArgs(rest) {
|
|
2187
|
+
const pairs = []
|
|
2188
|
+
for (const token of String(rest ?? '').split(/\s+/)) {
|
|
2189
|
+
if (!token) continue
|
|
2190
|
+
const at = token.indexOf('=')
|
|
2191
|
+
if (at > 0) pairs.push([token.slice(0, at), token.slice(at + 1)])
|
|
2192
|
+
else if (pairs.length > 0) pairs[pairs.length - 1][1] += ` ${token}`
|
|
2193
|
+
else return null
|
|
2194
|
+
}
|
|
2195
|
+
return pairs.length > 0 ? pairs : null
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
/** 把点路径键转成真实写入路径;不认识的键返回 null(由调用方报「不认识的字段」)。 */
|
|
2199
|
+
function setKeyPath(key) {
|
|
2200
|
+
if (SET_TOP_KEYS.has(key)) return [key]
|
|
2201
|
+
const parts = String(key).split('.')
|
|
2202
|
+
if (parts.length === 2 && parts[0] === 'memory' && SET_MEMORY_KEYS.has(parts[1])) {
|
|
2203
|
+
return ['memory', parts[1]]
|
|
2204
|
+
}
|
|
2205
|
+
if (parts.length === 2 && parts[0] === 'quality' && SET_QUALITY_KEYS.has(parts[1])) {
|
|
2206
|
+
return ['quality', parts[1]]
|
|
2207
|
+
}
|
|
2208
|
+
return null
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
/** 按点路径取值,用于把「改成了什么」念回给用户(`memory.maxInjected` 取不到就直接下标)。 */
|
|
2212
|
+
// 这里刻意同时挡 null 与 undefined(原写法 `acc == null` 是两者的并集):
|
|
2213
|
+
// 只用 `acc === null` 会在中间键缺失(undefined)时抛 TypeError。
|
|
2214
|
+
const isNullish = (value) => value === null || value === undefined
|
|
2215
|
+
const readPath = (obj, key) => key.split('.').reduce((acc, part) => (isNullish(acc) ? acc : acc[part]), obj)
|
|
2216
|
+
|
|
2217
|
+
/** 补丁展平成点路径集合,只用于比对「哪些键真的被 sanitizeConfig 吃下了」。 */
|
|
2218
|
+
function flattenPatch(patch) {
|
|
2219
|
+
const flat = new Set()
|
|
2220
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
2221
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
2222
|
+
for (const sub of Object.keys(value)) flat.add(`${key}.${sub}`)
|
|
2223
|
+
} else flat.add(key)
|
|
2224
|
+
}
|
|
2225
|
+
return flat
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/**
|
|
2229
|
+
* 把解析结果转成配置补丁。
|
|
2230
|
+
* @returns {{patch: object, flat: string[]} | {error: string}}
|
|
2231
|
+
*/
|
|
2232
|
+
function buildSetPatch(pairs) {
|
|
2233
|
+
const patch = {}
|
|
2234
|
+
const flat = []
|
|
2235
|
+
for (const [key, raw] of pairs) {
|
|
2236
|
+
const path = setKeyPath(key)
|
|
2237
|
+
// 未知键**也照样写进补丁**再靠比对发现「被丢弃」:那样报错文案能同时覆盖
|
|
2238
|
+
// 「键名不认识」与「值被校验拒了」两种情形,而不必在这里维护第二份字段表。
|
|
2239
|
+
const target = path ?? [key]
|
|
2240
|
+
let value = raw
|
|
2241
|
+
if (path && SET_BOOLEAN_KEYS.has(key)) {
|
|
2242
|
+
value = SET_BOOLEAN_WORDS.get(String(raw).toLowerCase())
|
|
2243
|
+
if (value === undefined) return { error: `${key} 只能是 true / false(也可以写 on / off、yes / no)` }
|
|
2244
|
+
} else if (path && SET_NUMBER_KEYS.has(key)) {
|
|
2245
|
+
const n = Number(String(raw).trim())
|
|
2246
|
+
if (!Number.isFinite(n)) return { error: `${key} 必须是数字,收到「${raw}」` }
|
|
2247
|
+
value = n
|
|
2248
|
+
}
|
|
2249
|
+
if (target.length === 1) patch[target[0]] = value
|
|
2250
|
+
else {
|
|
2251
|
+
if (!patch[target[0]] || typeof patch[target[0]] !== 'object') patch[target[0]] = {}
|
|
2252
|
+
patch[target[0]][target[1]] = value
|
|
2253
|
+
}
|
|
2254
|
+
flat.push(key)
|
|
2255
|
+
}
|
|
2256
|
+
return { patch, flat }
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function registerCommands(ctx) {
|
|
2260
|
+
ctx.inject(['commands'], (cmdCtx) => {
|
|
2261
|
+
cmdCtx.commands.register({
|
|
2262
|
+
name: 'anima',
|
|
2263
|
+
description: '管理灵台:AI 名字、工作区覆盖与待审记忆缓冲区',
|
|
2264
|
+
input: { hint: '[show|pending|adopt <id>|discard <id>|mark <id> type=… state=…|triage|patrol|复盘|checkup|audit|name <名字>|set k=v …|enable|disable|confirm|reject|reset]' },
|
|
2265
|
+
async handler(invocation) {
|
|
2266
|
+
const raw = String(invocation?.rawInput || '').trim()
|
|
2267
|
+
const spaceAt = raw.search(/\s/)
|
|
2268
|
+
const verb = (spaceAt === -1 ? raw : raw.slice(0, spaceAt)).toLowerCase()
|
|
2269
|
+
const rest = spaceAt === -1 ? '' : raw.slice(spaceAt + 1).trim()
|
|
2270
|
+
|
|
2271
|
+
// show 是只读诊断命令:存储栈没组合时它恰恰最该跑得通 —— 用户正是靠它
|
|
2272
|
+
// 才知道「插件在,是存储没接上」,所以这里容忍装载失败并把原因留给 show 分支渲染。
|
|
2273
|
+
// 其余子命令都要读写存储,失败就照旧报错(错误文案里已带组合指引)。
|
|
2274
|
+
let loadError = null
|
|
2275
|
+
try {
|
|
2276
|
+
await reload(ctx, { refreshPrompt: false })
|
|
2277
|
+
} catch (err) {
|
|
2278
|
+
if (!verb || verb === 'show') loadError = err
|
|
2279
|
+
else throw err
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
try {
|
|
2283
|
+
if (!verb || verb === 'show') {
|
|
2284
|
+
const config = runtime.config
|
|
2285
|
+
// 只在存储真能读到时去取审计:装载失败时 srcKind 为 null,此时 listAudit 会再抛一次,
|
|
2286
|
+
// 那正好把 show 变成「存储坏了就什么都不显示」——它偏要在这时也能跑通。
|
|
2287
|
+
const recentAudit = loadError
|
|
2288
|
+
? null
|
|
2289
|
+
: (await runtime.store.listAudit().catch(() => [])).at(-1) ?? null
|
|
2290
|
+
// 角色可能很长,展示时截到 60 字并补省略号;未设置时给占位文案。
|
|
2291
|
+
// 拆出来是为了不写嵌套三元(规则禁止)。
|
|
2292
|
+
const rolePreview = (() => {
|
|
2293
|
+
if (!config.agentRole) return '(未设置)'
|
|
2294
|
+
const head = config.agentRole.slice(0, 60)
|
|
2295
|
+
return config.agentRole.length > 60 ? `${head}…` : head
|
|
2296
|
+
})()
|
|
2297
|
+
const lines = [
|
|
2298
|
+
`灵台:${config.enabled ? '已启用' : '已关闭'}`,
|
|
2299
|
+
`AI 名字:${config.agentName || '(未设置)'}`,
|
|
2300
|
+
`AI 角色:${rolePreview}`,
|
|
2301
|
+
`工作区覆盖:${config.workspaceOverridesEnabled ? `已启用(目录 ${config.workspaceFileDir})` : '已关闭'}`,
|
|
2302
|
+
`记忆层:${config.memory.enabled ? '已启用' : '已关闭'};待审 ${runtime.pendingCount} 条,已采纳 ${runtime.adopted.length} 条`,
|
|
2303
|
+
// 质检层的状态必须能被一眼看到:它是唯一会主动往对话里插消息的部件,
|
|
2304
|
+
// 「我怎么突然被顶了」的答案应该在这里,而不是要用户去翻配置 JSON。
|
|
2305
|
+
`质检层:${config.quality.enabled ? '已启用' : '已关闭'}(对账 ${config.quality.ledgerLevel},严格 ${config.quality.strict ? '开' : '关'},机制检查 ${config.quality.preStepCheck ? '开' : '关'})`,
|
|
2306
|
+
// 运行计数放在配置状态之后:配置说「应该是开的」,计数说「实际跑过几次」。
|
|
2307
|
+
// 两者必须并排出现 —— 只有前者时,一个从未被调用过的钩子看起来完全正常。
|
|
2308
|
+
`质检运行:已查 ${runtime.qualityStats.checks} 次,已提醒 ${runtime.qualityStats.reminders} 次`
|
|
2309
|
+
+ (runtime.qualityStats.suppressed > 0 ? `,被每轮上限压住 ${runtime.qualityStats.suppressed} 次` : '')
|
|
2310
|
+
+ describeSkips(runtime.qualityStats.skipped),
|
|
2311
|
+
// 这几个数是**累计**的(跨重启),所以必须说清从什么时候开始算、
|
|
2312
|
+
// 最后一次落盘是什么时候。只报「已查 N 次」而不报基准点,读者没法
|
|
2313
|
+
// 判断这个 N 是本次启动的还是历史累计的 —— 那又变成了一句无法核对的话。
|
|
2314
|
+
`质检计数:${runtime.qualityStats.startedAt ? `本次启动于 ${runtime.qualityStats.startedAt.slice(0, 19).replace('T', ' ')},累计口径` : '仅本次运行(尚未落盘)'}`
|
|
2315
|
+
+ (runtime.qualityStats.flushedAt ? `;最后一次落盘 ${String(runtime.qualityStats.flushedAt).slice(0, 19).replace('T', ' ')}` : ';尚未落盘,重启会丢'),
|
|
2316
|
+
`存储:${runtime.store.srcKind ?? '未打开'}${runtime.store.srcPath ? ` (${runtime.store.srcPath})` : ''}${runtime.store.srcReason ? ` 不可用原因:${runtime.store.srcReason} —— ${runtime.store.srcMessage ?? ''}` : ''}${runtime.store.srcDomainVersion !== null ? ` 存储格式 v${runtime.store.srcDomainVersion}` : ''}`,
|
|
2317
|
+
// 最近一次人设变更是「谁在什么时候动了什么」——三个入口(设置页 / 本命令 / 模型工具)
|
|
2318
|
+
// 都可能改写人设,show 是唯一能一眼看到这件事的地方。只显示一条:它是诊断行,不是列表。
|
|
2319
|
+
...(recentAudit ? [`最近变更:${summarizeAudit(recentAudit)}`] : []),
|
|
2320
|
+
...(runtime.pendingProposal
|
|
2321
|
+
? [`有一份待确认的人设变更提议(${Object.keys(runtime.pendingProposal.patch).join(', ')})—— /anima confirm 确认,/anima reject 拒绝。`]
|
|
2322
|
+
: []),
|
|
2323
|
+
...(loadError ? ['(以上是内置默认值;存储不可用时读不到你保存的人设)'] : [])
|
|
2324
|
+
]
|
|
2325
|
+
return { kind: loadError ? 'error' : 'success', text: lines.join('\n') }
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
if (verb === 'pending') {
|
|
2329
|
+
const all = await runtime.store.listMemories()
|
|
2330
|
+
const { pending } = groupByStatus(all)
|
|
2331
|
+
if (pending.length === 0) return { kind: 'success', text: '待审缓冲区为空。' }
|
|
2332
|
+
return {
|
|
2333
|
+
kind: 'success',
|
|
2334
|
+
text: pending.slice(0, 20).map((r) => `${r.id}\n ${r.text}`).join('\n')
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
if (verb === 'adopt' || verb === 'discard') {
|
|
2339
|
+
if (!rest) return { kind: 'error', text: `用法:/anima ${verb} <id>` }
|
|
2340
|
+
const record = await runtime.store.getMemory(rest)
|
|
2341
|
+
if (!record) return { kind: 'error', text: `找不到记忆 ${rest}` }
|
|
2342
|
+
await runtime.store.putMemory({
|
|
2343
|
+
...record,
|
|
2344
|
+
status: verb === 'adopt' ? 'adopted' : 'discarded',
|
|
2345
|
+
decidedAt: new Date().toISOString()
|
|
2346
|
+
})
|
|
2347
|
+
await reload(ctx, { refreshPrompt: true })
|
|
2348
|
+
return { kind: 'success', text: `已${verb === 'adopt' ? '采纳' : '丢弃'}:${record.text}` }
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
if (verb === 'mark') {
|
|
2352
|
+
// 用法:/anima mark <id> type=fact state=superseded supersededBy=<另一条 id>
|
|
2353
|
+
//
|
|
2354
|
+
// 为什么要有命令行:回填时用户可能正在会话里,逐条开设置页太慢;
|
|
2355
|
+
// 而「改已有记忆的分类」又不能靠模型工具(那等于让被检查者自己填考卷)。
|
|
2356
|
+
const parts = rest.split(/\s+/).filter(Boolean)
|
|
2357
|
+
const id = parts.shift() ?? ''
|
|
2358
|
+
if (!id) return { kind: 'error', text: '用法:/anima mark <id> [type=…] [state=…] [supersededBy=<id>] [verifyAt=YYYY-MM-DD] [sourceRef=…]' }
|
|
2359
|
+
const changes = {}
|
|
2360
|
+
for (const token of parts) {
|
|
2361
|
+
const at = token.indexOf('=')
|
|
2362
|
+
if (at <= 0) return { kind: 'error', text: `看不懂这一项:${token}(应为 key=value)` }
|
|
2363
|
+
const key = token.slice(0, at)
|
|
2364
|
+
if (!['type', 'state', 'supersededBy', 'verifyAt', 'sourceRef'].includes(key)) {
|
|
2365
|
+
return { kind: 'error', text: `不支持的键:${key}(只有 type / state / supersededBy / verifyAt / sourceRef)` }
|
|
2366
|
+
}
|
|
2367
|
+
changes[key] = token.slice(at + 1)
|
|
2368
|
+
}
|
|
2369
|
+
if (Object.keys(changes).length === 0) {
|
|
2370
|
+
return { kind: 'error', text: '没有要改的项:至少给一个 type= / state= / supersededBy= / verifyAt= / sourceRef=' }
|
|
2371
|
+
}
|
|
2372
|
+
const record = await runtime.store.getMemory(id)
|
|
2373
|
+
if (!record) return { kind: 'error', text: `找不到记忆 ${id}` }
|
|
2374
|
+
// 指向的另一条必须真的存在,否则记下一个指向空气的指针,
|
|
2375
|
+
// 读的人和将来的人都会以为有一条能点开的「后来那条」。
|
|
2376
|
+
if (typeof changes.supersededBy === 'string' && changes.supersededBy !== '') {
|
|
2377
|
+
const other = await runtime.store.getMemory(changes.supersededBy)
|
|
2378
|
+
if (!other) return { kind: 'error', text: `supersededBy 指向的记忆不存在:${changes.supersededBy}` }
|
|
2379
|
+
}
|
|
2380
|
+
const applied = applyMemoryMeta(record, changes)
|
|
2381
|
+
if (!applied.ok) return { kind: 'error', text: applied.error }
|
|
2382
|
+
if (applied.changed.length === 0) {
|
|
2383
|
+
return { kind: 'success', text: `没有变化(${Object.keys(changes).join('、')} 与现状相同)。` }
|
|
2384
|
+
}
|
|
2385
|
+
await runtime.store.putMemory(applied.record)
|
|
2386
|
+
await reload(ctx, { refreshPrompt: true })
|
|
2387
|
+
return {
|
|
2388
|
+
kind: 'success',
|
|
2389
|
+
text: `已更新 ${id}:${applied.changed.join('、')}\n ${applied.record.text}`
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
if (verb === 'triage') {
|
|
2394
|
+
const result = await runTriage(ctx, { reason: 'command' })
|
|
2395
|
+
if (!result.ok) return { kind: 'error', text: result.error }
|
|
2396
|
+
const s = result.summary
|
|
2397
|
+
return {
|
|
2398
|
+
kind: 'success',
|
|
2399
|
+
text: `整理完成:待审 ${s.pendingBefore} → ${s.pendingAfter},自动清理 ${s.discardedTotal} 条。`
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
if (verb === 'patrol' || verb === '巡夜') {
|
|
2404
|
+
const result = await runPatrol(ctx, { reason: 'command' })
|
|
2405
|
+
if (!result.ok) return { kind: 'error', text: result.error }
|
|
2406
|
+
const { verdict, delivered } = result
|
|
2407
|
+
const lines = [
|
|
2408
|
+
`巡夜完成:待审 ${verdict.facts.pending} 条,体检 ${verdict.facts.flagged} 条,整理动过 ${verdict.facts.removed} 条。`
|
|
2409
|
+
]
|
|
2410
|
+
// 说清「为什么没说」—— 只报有事之后,「没报」必须自己解释自己,
|
|
2411
|
+
// 否则用户会以为它坏了。这一行就是那句解释。
|
|
2412
|
+
lines.push(delivered > 0
|
|
2413
|
+
? `已汇报(${delivered} 个会话)。`
|
|
2414
|
+
: '判定为无事,没有汇报。这条记录留在 meta.patrol 里,可反查。')
|
|
2415
|
+
return { kind: 'success', text: lines.join('\n') }
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
if (verb === 'retrospect' || verb === '复盘') {
|
|
2419
|
+
const result = await runRetrospect(ctx, { reason: 'command' })
|
|
2420
|
+
if (!result.ok) return { kind: 'error', text: result.error }
|
|
2421
|
+
const lines = [
|
|
2422
|
+
`复盘完成:回扫最近 ${result.days} 天的 ${result.files} 个会话、${result.messages} 条发言,新线索 ${result.verdict.newLeads.length} 条。`
|
|
2423
|
+
]
|
|
2424
|
+
if (result.verdict.newLeads.length > 0) {
|
|
2425
|
+
for (const lead of result.verdict.newLeads.slice(0, 8)) {
|
|
2426
|
+
lines.push(`· [${lead.kind}] ${lead.text}${lead.sessionRef ? `(${lead.sessionRef})` : ''}`)
|
|
2427
|
+
}
|
|
2428
|
+
if (result.verdict.newLeads.length > 8) {
|
|
2429
|
+
lines.push(`· 另有 ${result.verdict.newLeads.length - 8} 条,见 meta.retrospect。`)
|
|
2430
|
+
}
|
|
2431
|
+
} else {
|
|
2432
|
+
lines.push('没有新线索。这条记录留在 meta.retrospect 里,可反查。')
|
|
2433
|
+
}
|
|
2434
|
+
lines.push(result.delivered > 0
|
|
2435
|
+
? `已提示(${result.delivered} 个会话)。`
|
|
2436
|
+
: '没有提示。')
|
|
2437
|
+
return { kind: 'success', text: lines.join('\n') }
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
if (verb === 'name') {
|
|
2441
|
+
if (!rest) return { kind: 'error', text: '用法:/anima name <名字>' }
|
|
2442
|
+
const { patch, errors } = sanitizeConfig({ agentName: rest })
|
|
2443
|
+
if (Object.keys(errors).length > 0) return { kind: 'error', text: formatFieldErrors(errors) }
|
|
2444
|
+
const { config, changed } = await runtime.store.updateConfig(patch, { actor: 'user', source: 'command', note: '/anima name' })
|
|
2445
|
+
await reload(ctx, { refreshPrompt: true, configChange: { changed, source: 'command' } })
|
|
2446
|
+
return { kind: 'success', text: `AI 名字已设为:${config.agentName}` }
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// enable / disable 单独给两个子命令,而不是让用户敲 `set enabled=false`:
|
|
2450
|
+
// 开关人设是最高频的一次操作,值得一个能背下来的短词。
|
|
2451
|
+
if (verb === 'enable' || verb === 'disable') {
|
|
2452
|
+
const wanted = verb === 'enable'
|
|
2453
|
+
if (runtime.config.enabled === wanted) {
|
|
2454
|
+
return { kind: 'success', text: wanted ? '助手人格已是启用状态。' : '助手人格已是关闭状态。' }
|
|
2455
|
+
}
|
|
2456
|
+
const { changed } = await runtime.store.updateConfig(
|
|
2457
|
+
{ enabled: wanted },
|
|
2458
|
+
{ actor: 'user', source: 'command', note: `/anima ${verb}` }
|
|
2459
|
+
)
|
|
2460
|
+
await reload(ctx, { refreshPrompt: true, configChange: { changed, source: 'command' } })
|
|
2461
|
+
return {
|
|
2462
|
+
kind: 'success',
|
|
2463
|
+
text: wanted
|
|
2464
|
+
? '助手人格已启用。'
|
|
2465
|
+
: '助手人格已关闭 —— 已告诉当前会话里的人设设定作废;随时用 /anima enable 打开。'
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
if (verb === 'set') {
|
|
2470
|
+
const pairs = parseSetArgs(rest)
|
|
2471
|
+
if (!pairs) {
|
|
2472
|
+
return { kind: 'error', text: '用法:/anima set key=value [key=value …](值含空格时直接写在后面即可)' }
|
|
2473
|
+
}
|
|
2474
|
+
const built = buildSetPatch(pairs)
|
|
2475
|
+
if (built.error) return { kind: 'error', text: built.error }
|
|
2476
|
+
const { patch, errors } = sanitizeConfig(built.patch)
|
|
2477
|
+
const errorKeys = Object.keys(errors)
|
|
2478
|
+
if (errorKeys.length > 0) {
|
|
2479
|
+
// 只念第一条:用户一次写错多个键时,先把第一个修好再看下一个,
|
|
2480
|
+
// 一次全倒出来反而看不清从哪改起。
|
|
2481
|
+
return { kind: 'error', text: `${errorKeys[0]}:${errors[errorKeys[0]]}` }
|
|
2482
|
+
}
|
|
2483
|
+
// sanitizeConfig 会**静默丢弃**它不认识的键(这是它作为落盘闸门的正确行为),
|
|
2484
|
+
// 因此命令层必须自己比对一次:否则 `set agnetName=x` 会回一句「已更新」而实际没改。
|
|
2485
|
+
const accepted = flattenPatch(patch)
|
|
2486
|
+
const unknown = built.flat.filter((key) => !accepted.has(key))
|
|
2487
|
+
if (unknown.length > 0 && Object.keys(patch).length === 0) {
|
|
2488
|
+
return { kind: 'error', text: `不认识的字段:${unknown.join('、')}。用 /anima show 看当前有哪些字段。` }
|
|
2489
|
+
}
|
|
2490
|
+
if (unknown.length > 0) {
|
|
2491
|
+
return { kind: 'error', text: `不认识的字段:${unknown.join('、')}(本次未做任何改动,请去掉它后重试)` }
|
|
2492
|
+
}
|
|
2493
|
+
const { config, changed } = await runtime.store.updateConfig(patch, {
|
|
2494
|
+
actor: 'user', source: 'command', note: `/anima set ${built.flat.join(' ')}`
|
|
2495
|
+
})
|
|
2496
|
+
await reload(ctx, {
|
|
2497
|
+
refreshPrompt: changed.length > 0,
|
|
2498
|
+
configChange: changed.length > 0 ? { changed, source: 'command' } : null
|
|
2499
|
+
})
|
|
2500
|
+
if (changed.length === 0) return { kind: 'success', text: '这些字段本来就等于你要设的值,未做改动。' }
|
|
2501
|
+
const shown = changed.map((key) => `${key}=${JSON.stringify(readPath(config, key))}`).join('、')
|
|
2502
|
+
return { kind: 'success', text: `已更新:${shown}` }
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// 自检命令:把我上一条回答交给**同一套**机器判据重算一遍,并把判据本身摊开。
|
|
2506
|
+
//
|
|
2507
|
+
// 为什么必须有这个入口:pre-step 的提醒只在命中时出现,没命中时用户看到的
|
|
2508
|
+
// 是「什么都没发生」—— 那和「检查根本没跑」长得一模一样。用户没法区分,
|
|
2509
|
+
// 就只能靠信我。这个命令把「检查跑了没有、查了什么、没查什么」变成可主动索取的东西。
|
|
2510
|
+
//
|
|
2511
|
+
// 它刻意不做的事:不给分数,不说「通过」。没有 findings 只说明「机器在它
|
|
2512
|
+
// 能查的那些形状上没抓到东西」,不等于内容正确 —— 这句话必须每次都印出来,
|
|
2513
|
+
// 否则这个命令自己就成了新的表演面。
|
|
2514
|
+
if (verb === 'checkup' || verb === '体检') {
|
|
2515
|
+
// 记忆体检:和 /anima audit(检查我上一条回答)是两回事,别混。
|
|
2516
|
+
// 那条查「我说的话」,这条查「记下来的东西还成不成立」。
|
|
2517
|
+
const records = await runtime.store.listMemories()
|
|
2518
|
+
const result = auditMemories(records, { probe: (p) => existsSync(p) })
|
|
2519
|
+
const lines = []
|
|
2520
|
+
// 截断必须写在第一行。放在末尾就等于:读者先看到「0 条要你看一眼」,
|
|
2521
|
+
// 形成一个「全查过了」的印象,再看到「其实有 12 个没查」—— 先入为主的是前一句。
|
|
2522
|
+
const truncHead = result.truncated?.records
|
|
2523
|
+
? `(另有 ${result.truncated.paths} 个路径超出单条上限没查)`
|
|
2524
|
+
: ''
|
|
2525
|
+
lines.push(`记忆体检:查了 ${result.checked} 条已采纳记忆,${result.clean} 条没问题,${result.flagged.length} 条要你看一眼${truncHead}。`)
|
|
2526
|
+
lines.push('')
|
|
2527
|
+
if (result.flagged.length === 0) {
|
|
2528
|
+
// 这里以前无条件说「都没发现问题」。有截断时这句就是假话:
|
|
2529
|
+
// 那 12 个路径确实没被探过,它们里面可能就有一个已经没了。
|
|
2530
|
+
if (result.truncated?.records) {
|
|
2531
|
+
lines.push(`机器验到的部分没发现问题,但**没有查全**:${result.truncated.note}`)
|
|
2532
|
+
} else {
|
|
2533
|
+
lines.push('机器能验的两项(路径还在不在、复核点过没过)都没发现问题。')
|
|
2534
|
+
}
|
|
2535
|
+
lines.push(' 注意:这只说明「写下来的路径和日期还对得上」,不说明这些话现在仍然成立。')
|
|
2536
|
+
} else {
|
|
2537
|
+
for (const item of result.flagged) {
|
|
2538
|
+
lines.push(`- ${item.id}`)
|
|
2539
|
+
lines.push(` ${String(item.text).slice(0, 60)}${String(item.text).length > 60 ? '…' : ''}`)
|
|
2540
|
+
for (const f of item.findings) {
|
|
2541
|
+
lines.push(` · ${f.detail}`)
|
|
2542
|
+
lines.push(` 证据:${f.evidence}`)
|
|
2543
|
+
}
|
|
2544
|
+
if (item.suggestion.state) {
|
|
2545
|
+
lines.push(` 建议:${item.suggestion.why}`)
|
|
2546
|
+
} else {
|
|
2547
|
+
lines.push(` 建议:${item.suggestion.why}`)
|
|
2548
|
+
}
|
|
2549
|
+
lines.push(` 执行:${item.suggestion.command}`)
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
lines.push('')
|
|
2553
|
+
if (result.truncated?.records) lines.push(result.truncated.note)
|
|
2554
|
+
lines.push(result.note)
|
|
2555
|
+
lines.push('这条命令不会改任何状态;上面每条都是候选,要你自己敲一次才会生效。')
|
|
2556
|
+
return { kind: 'success', text: lines.join('\n') }
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
if (verb === 'audit' || verb === '检查') {
|
|
2560
|
+
const agent = invocation?.agent
|
|
2561
|
+
const session = agent?.session
|
|
2562
|
+
if (!session || typeof session.snapshotEvents !== 'function') {
|
|
2563
|
+
return { kind: 'error', text: '当前会话读不到消息记录,无法检查。' }
|
|
2564
|
+
}
|
|
2565
|
+
const events = session.snapshotEvents()
|
|
2566
|
+
let last = null
|
|
2567
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
2568
|
+
if (events[i]?.type === 'assistant/message') { last = events[i]; break }
|
|
2569
|
+
}
|
|
2570
|
+
if (!last) return { kind: 'success', text: '这个会话里还没有助手回答可供检查。' }
|
|
2571
|
+
const body = eventText(last)
|
|
2572
|
+
if (body === '') return { kind: 'success', text: '上一条助手消息里没有文本内容(可能只有工具调用),没有可检查的文字。' }
|
|
2573
|
+
|
|
2574
|
+
const q = runtime.config.quality
|
|
2575
|
+
const result = checkResponse(body, { ledgerLevel: q.ledgerLevel, strict: q.strict === true })
|
|
2576
|
+
const triggers = detectLedgerTriggers(body, q.ledgerLevel)
|
|
2577
|
+
const lines = []
|
|
2578
|
+
lines.push(`检查对象:本会话最后一条助手回答(${body.length} 字,事件 seq=${last.seq ?? '未知'})`)
|
|
2579
|
+
lines.push(`判据版本:对账档位 ${q.ledgerLevel},严格模式 ${q.strict ? '开' : '关'}`)
|
|
2580
|
+
lines.push('')
|
|
2581
|
+
if (triggers.hits.length === 0) {
|
|
2582
|
+
lines.push('对账触发:没有命中任何一档(不含数字/日期、没有「已确认」类断言、没有归属引用)。')
|
|
2583
|
+
} else {
|
|
2584
|
+
lines.push(`对账触发:命中 ${triggers.hits.length} 条 —— ${triggers.hits.map((h) => `${h.tier}「${h.excerpt}」`).join(';')}`)
|
|
2585
|
+
}
|
|
2586
|
+
lines.push(`对账块:${hasLedgerBlock(body) ? '有' : '没有'}`)
|
|
2587
|
+
lines.push('')
|
|
2588
|
+
if (result.findings.length === 0) {
|
|
2589
|
+
lines.push('机器判词:在它能查的形状上没抓到问题。')
|
|
2590
|
+
lines.push(' 注意:这不等于内容正确,也不等于十二条全过(见下面的未查清单)。')
|
|
2591
|
+
} else {
|
|
2592
|
+
lines.push(`机器判词:${result.findings.length} 处`)
|
|
2593
|
+
for (const f of result.findings) {
|
|
2594
|
+
lines.push(` - ${f.ruleId} ${f.name}(${f.severity}):${f.detail}`)
|
|
2595
|
+
if (f.excerpt) lines.push(` 原文:「${f.excerpt}」`)
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
lines.push('')
|
|
2599
|
+
lines.push(`本次检查了:${result.checked.length > 0 ? result.checked.join('、') : '(无)'}`)
|
|
2600
|
+
lines.push(`本次未查:${result.uncovered.map((r) => r.id).join('、') || '(无)'} —— 这些机器判不了,只能由你自己看。`)
|
|
2601
|
+
lines.push('')
|
|
2602
|
+
lines.push('说明:这条结论由代码按固定正则算出,不经过模型。它能证明「某个形状在不在」,不能证明「内容对不对」。')
|
|
2603
|
+
return { kind: 'success', text: lines.join('\n') }
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
if (verb === 'confirm' || verb === 'reject') {
|
|
2607
|
+
const proposal = runtime.pendingProposal
|
|
2608
|
+
if (!proposal) {
|
|
2609
|
+
return { kind: 'error', text: `当前没有待确认的人设变更提议。` }
|
|
2610
|
+
}
|
|
2611
|
+
runtime.pendingProposal = null
|
|
2612
|
+
if (verb === 'reject') {
|
|
2613
|
+
return { kind: 'success', text: `已拒绝该提议,配置未改动。` }
|
|
2614
|
+
}
|
|
2615
|
+
const { patch, errors } = sanitizeConfig(proposal.patch)
|
|
2616
|
+
if (Object.keys(errors).length > 0) {
|
|
2617
|
+
return { kind: 'error', text: `提议已失效(${formatFieldErrors(errors)}),请让模型重新提交。` }
|
|
2618
|
+
}
|
|
2619
|
+
const { changed } = await runtime.store.updateConfig(patch, { actor: 'user', source: 'proposal', note: '命令确认了模型提议' })
|
|
2620
|
+
await reload(ctx, { refreshPrompt: changed.length > 0, configChange: { changed, source: 'command' } })
|
|
2621
|
+
return {
|
|
2622
|
+
kind: 'success',
|
|
2623
|
+
text: `已确认并生效:${changed.map((k) => `${k}=${JSON.stringify(runtime.config[k])}`).join(', ') || '(无实际变化)'}`
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
if (verb === 'reset') {
|
|
2628
|
+
const { changed } = await runtime.store.resetConfig({ actor: 'user', source: 'command' })
|
|
2629
|
+
await reload(ctx, { refreshPrompt: true, configChange: { changed, source: 'command' } })
|
|
2630
|
+
return { kind: 'success', text: '全局配置已重置为默认值(记忆与工作区覆盖未动)。' }
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
return { kind: 'error', text: `未知子命令 ${verb}。可用:show|pending|adopt|discard|mark|triage|checkup|audit|name|set|enable|disable|confirm|reject|reset` }
|
|
2634
|
+
} catch (err) {
|
|
2635
|
+
return { kind: 'error', text: `执行失败:${String(err?.message || err)}` }
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
})
|
|
2639
|
+
})
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
// ==================== Agent 工具 ====================
|
|
2643
|
+
|
|
2644
|
+
function registerTools(ctx) {
|
|
2645
|
+
ctx.inject(['tools'], (toolsCtx) => {
|
|
2646
|
+
import('@deepseek-ai/dsh-tools').then(({ defineTool, TOOL_RUNTIME_SCHEDULER }) => {
|
|
2647
|
+
if (typeof TOOL_RUNTIME_SCHEDULER !== 'symbol') {
|
|
2648
|
+
throw new Error('dsh-anima: resolved @deepseek-ai/dsh-tools lacks TOOL_RUNTIME_SCHEDULER — requires ^0.1.0-rc.6')
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
toolsCtx.tools.register(defineTool({
|
|
2652
|
+
name: 'remember',
|
|
2653
|
+
description: [
|
|
2654
|
+
'把一条值得长期记住的信息投递到待审缓冲区。',
|
|
2655
|
+
'注意:投递只是「提名」,不会立即生效 —— 内容要等用户在设置页里采纳后才会进入长期记忆。',
|
|
2656
|
+
'适用于用户明确表达的稳定偏好、长期约束、重要背景;不要记录临时任务状态、寒暄或可从代码/文件直接读出的信息。',
|
|
2657
|
+
'请尽量给出 type(这条是什么)与 sourceRef(凭什么记这条):没有来源的记忆,日后无法核对真假。',
|
|
2658
|
+
'不要在此写入任何密钥、令牌、密码;凭据会被直接拒绝。'
|
|
2659
|
+
].join(''),
|
|
2660
|
+
parameters: {
|
|
2661
|
+
text: { type: 'string', description: '要记住的完整陈述句(不超过 500 字)' },
|
|
2662
|
+
note: { type: 'string', description: '可选:为什么值得记住,便于用户裁决' },
|
|
2663
|
+
scope: { type: 'string', description: '可选:该记忆的适用范围标注(如 global / 项目名)' },
|
|
2664
|
+
type: {
|
|
2665
|
+
type: 'string',
|
|
2666
|
+
// 不做 enum:宿主参数 DSL 允许 enum,但这里刻意不用 —— 模型面对封闭枚举时
|
|
2667
|
+
// 会倾向硬套一个「最像的」,而「这条既像偏好又像立场」正是需要它说清楚的时刻。
|
|
2668
|
+
// 写错的值由 lib/memory.mjs 归一成默认类型,不会污染介质。
|
|
2669
|
+
description: '可选:这条记忆是什么 —— fact〔事实〕/ preference〔偏好〕/ stance〔立场〕/ event〔事件〕。'
|
|
2670
|
+
+ '四者的失效方式不同:事实改了才覆盖,偏好半衰期很长,立场在「我改主意了」时要归档成「曾如此」,事件永不失效但会归档。'
|
|
2671
|
+
},
|
|
2672
|
+
sourceRef: {
|
|
2673
|
+
type: 'string',
|
|
2674
|
+
description: '可选:凭什么记这条 —— 可反查的出处(如 m00123 这类消息编号、文件:行号、URL)。'
|
|
2675
|
+
+ '不接受「对话里说过」这种无法核对的表述;没有出处就留空,不要编。'
|
|
2676
|
+
},
|
|
2677
|
+
verifyAt: {
|
|
2678
|
+
type: 'string',
|
|
2679
|
+
description: '可选:复核点(YYYY-MM-DD)。到这天之后注入时会标注「可能已过时」,由用户决定要不要更新。'
|
|
2680
|
+
+ '只给状态类记忆设复核点(「最近在焦虑什么」),事实类(职业、技术栈)不要设。'
|
|
2681
|
+
}
|
|
2682
|
+
},
|
|
2683
|
+
output: {
|
|
2684
|
+
schema: {
|
|
2685
|
+
// ⚠️ 此处**不得**写 `required`(根与嵌套都不行)。宿主 value schema DSL 只在
|
|
2686
|
+
// 参数属性层放行 `required`(parameterSchemaSpecToJsonSchema → allowRequired:true),
|
|
2687
|
+
// 而 output.schema 走 compileValueSchema(allowRequired:false)⇒ 写错会让
|
|
2688
|
+
// defineTool() 在定义期抛 JsonSchemaError,工具永不注册,再被入口的 .catch 吞成一行提示。
|
|
2689
|
+
// 实测报错原文:`unsupported JSON schema: schema.required is not supported by the value schema DSL`。
|
|
2690
|
+
type: 'object',
|
|
2691
|
+
additionalProperties: false,
|
|
2692
|
+
properties: {
|
|
2693
|
+
ok: { type: 'boolean' },
|
|
2694
|
+
message: { type: 'string' },
|
|
2695
|
+
id: { type: 'string' },
|
|
2696
|
+
pending: { type: 'number' }
|
|
2697
|
+
}
|
|
2698
|
+
},
|
|
2699
|
+
// ⚠️ `render` 是**必需**的,不是可选装饰:defineTool 会无条件执行
|
|
2700
|
+
// `const userRender = options.output.render` 并在包出的 render 里直接调用它
|
|
2701
|
+
// (dsh-tools/lib/types/schema.js:283 与 :304-306),所以只给 schema 不给 render
|
|
2702
|
+
// 能在定义期注册成功、却让**每次调用**在展示层炸掉,报
|
|
2703
|
+
// `tool "remember" returned invalid output: output.render failed: userRender is not a function`。
|
|
2704
|
+
// 注意工具本体其实已经执行完毕(记忆确实进了缓冲区)——炸的只是结果渲染。
|
|
2705
|
+
render: (_args, value) => [{
|
|
2706
|
+
type: 'text',
|
|
2707
|
+
text: String(value?.message ?? '') +
|
|
2708
|
+
(value?.id ? `(id: ${value.id})` : '') +
|
|
2709
|
+
(typeof value?.pending === 'number' ? `|当前待审 ${value.pending} 条` : '')
|
|
2710
|
+
}]
|
|
2711
|
+
},
|
|
2712
|
+
async execute(args, exec) {
|
|
2713
|
+
const config = await runtime.store.getConfig()
|
|
2714
|
+
if (!config.memory.enabled) {
|
|
2715
|
+
return { ok: false, message: '记忆层当前已关闭,未记录。用户可在「设置 → 灵台」里重新开启。' }
|
|
2716
|
+
}
|
|
2717
|
+
if (!config.memory.autoCapture) {
|
|
2718
|
+
return { ok: false, message: '自动捕获当前已关闭;请让用户手动在设置页添加。' }
|
|
2719
|
+
}
|
|
2720
|
+
// cwd 不在 args 里,而在执行上下文的 agent 上(宿主约定,见 @deepseek-ai/dsh-tools
|
|
2721
|
+
// 的 exec.agent?.session.header.cwd);读 args.__cwd 永远拿不到值。
|
|
2722
|
+
const cwd = exec?.agent?.session?.header?.cwd
|
|
2723
|
+
const created = makeCandidate({
|
|
2724
|
+
text: args?.text,
|
|
2725
|
+
note: args?.note,
|
|
2726
|
+
scope: args?.scope,
|
|
2727
|
+
type: args?.type,
|
|
2728
|
+
sourceRef: args?.sourceRef,
|
|
2729
|
+
verifyAt: args?.verifyAt,
|
|
2730
|
+
source: 'agent',
|
|
2731
|
+
cwd: typeof cwd === 'string' ? cwd : undefined
|
|
2732
|
+
})
|
|
2733
|
+
if (!created.ok) return { ok: false, message: created.error }
|
|
2734
|
+
|
|
2735
|
+
// 内容已存在(无论状态)则不再重复提名
|
|
2736
|
+
const all = await runtime.store.listMemories()
|
|
2737
|
+
const dup = all.find((r) => r.digest === created.record.digest || r.text === created.record.text)
|
|
2738
|
+
if (dup) {
|
|
2739
|
+
return {
|
|
2740
|
+
ok: true,
|
|
2741
|
+
message: dup.status === 'adopted'
|
|
2742
|
+
? '这条已经在长期记忆里了,未重复记录。'
|
|
2743
|
+
: `这条已在缓冲区中(状态:${dup.status}),未重复记录。`,
|
|
2744
|
+
id: dup.id,
|
|
2745
|
+
pending: runtime.pendingCount
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
await runtime.store.putMemory(created.record)
|
|
2750
|
+
await reload(ctx, { refreshPrompt: true })
|
|
2751
|
+
return {
|
|
2752
|
+
ok: true,
|
|
2753
|
+
message: '已投递到待审缓冲区,等待用户裁决后才会生效。',
|
|
2754
|
+
id: created.record.id,
|
|
2755
|
+
pending: runtime.pendingCount
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
}))
|
|
2759
|
+
|
|
2760
|
+
// ── 工具:改人设(同生态的插件普遍把这个工具叫同一个名字) ────────────────
|
|
2761
|
+
//
|
|
2762
|
+
// **名字刻意与它们错开**:dsh-tools 的命名表遇重名直接抛
|
|
2763
|
+
// `tool "${name}" is already registered (for a per-agent variant, register
|
|
2764
|
+
// through that agent's \`agent.ctx\` instead)`
|
|
2765
|
+
// (dsh-tools\lib\index.js:2634 的 NamedEntries 冲突工厂),且注册顺序决定谁死。
|
|
2766
|
+
// 同生态的几款插件也用这个名字,共存时必有一方的工具凭空消失;对本机是过渡期的
|
|
2767
|
+
// 静默故障,对将来的用户(可能同时装着两者)是发布级缺陷。功能对齐不要求撞名。
|
|
2768
|
+
//
|
|
2769
|
+
// 参数里的六个风格字段是**自由文本**,不是枚举(用户 m06593 ① 已定「全字段自由文本」)。
|
|
2770
|
+
// 因此这里刻意不写 `enum`、也不写「建议值:professional / casual / …」这类键名提示 ——
|
|
2771
|
+
// 那会让模型把一段自然语言要求硬塞进一个英文键里,用户看到的风格就成了 'roast'
|
|
2772
|
+
// 这种自己没写过的字样。描述里明确要求「写一句中文」,把模型引到正确的形态上。
|
|
2773
|
+
// 档位文案(专业严谨 / 毒舌吐槽 …)只作为设置页的「一键填入」按钮存在,是给用户的。
|
|
2774
|
+
toolsCtx.tools.register(defineTool({
|
|
2775
|
+
name: 'set_assistant_persona',
|
|
2776
|
+
description: [
|
|
2777
|
+
'调整助手自身的人设(AI 的名字、角色设定、对用户的称呼、用户背景、回复风格与自定义指令)。',
|
|
2778
|
+
'当用户明确要求改变称呼、语气、风格或角色时使用。只需要传入要修改的字段,未提供的字段保持不变。',
|
|
2779
|
+
'风格类字段请直接写中文的自然语言要求(例如「结论先行,不展开背景」),不要写英文档位键。',
|
|
2780
|
+
'若用户的配置开启了「工具确认」,本工具只提交提议、不立即生效,需要用户在命令里确认。'
|
|
2781
|
+
].join(''),
|
|
2782
|
+
parameters: {
|
|
2783
|
+
agentName: { type: 'string', description: '助手自己的名字(注意:不是用户的称呼)' },
|
|
2784
|
+
agentRole: { type: 'string', description: '助手的角色设定,例如「长期 AI 助手,学生/研究者为主」' },
|
|
2785
|
+
addressUserAs: { type: 'string', description: '助手对用户的称呼' },
|
|
2786
|
+
userOccupation: { type: 'string', description: '用户的职业' },
|
|
2787
|
+
userBio: { type: 'string', description: '关于用户的介绍与背景' },
|
|
2788
|
+
style: { type: 'string', description: '回复风格与语调,一段中文描述,例如「专业严谨,结论先行,不用口语」' },
|
|
2789
|
+
headingLists: { type: 'string', description: '标题与列表的使用倾向,一段中文描述,例如「多用标题与列表组织内容」' },
|
|
2790
|
+
emoji: { type: 'string', description: '表情符号的使用倾向,一段中文描述,例如「尽量不使用表情符号」' },
|
|
2791
|
+
tables: { type: 'string', description: '表格的使用倾向,一段中文描述,例如「适合对比时优先用表格」' },
|
|
2792
|
+
replyLength: { type: 'string', description: '回复长度偏好,一段中文描述,例如「简洁,直击要点」' },
|
|
2793
|
+
language: { type: 'string', description: '回复语言,一段中文描述,例如「使用简体中文回复」' },
|
|
2794
|
+
customInstructions: { type: 'string', description: '额外的自定义指令,用于覆盖或补充当前人设' }
|
|
2795
|
+
},
|
|
2796
|
+
output: {
|
|
2797
|
+
schema: {
|
|
2798
|
+
// 与 remember 同一套 DSL 约束:不得写 `required`,object 节点必须显式声明
|
|
2799
|
+
// additionalProperties。详见上方 remember 的注释。
|
|
2800
|
+
type: 'object',
|
|
2801
|
+
additionalProperties: false,
|
|
2802
|
+
properties: {
|
|
2803
|
+
ok: { type: 'boolean' },
|
|
2804
|
+
message: { type: 'string' },
|
|
2805
|
+
pending: { type: 'boolean' },
|
|
2806
|
+
changes: {
|
|
2807
|
+
type: 'object',
|
|
2808
|
+
additionalProperties: false,
|
|
2809
|
+
description: '实际发生变化的字段(字段名 → 新值)',
|
|
2810
|
+
properties: Object.fromEntries(TOOL_FIELDS.map((key) => [key, { type: 'string' }]))
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
},
|
|
2814
|
+
render: (_args, value) => {
|
|
2815
|
+
const lines = []
|
|
2816
|
+
if (value && typeof value.message === 'string' && value.message !== '') lines.push(value.message)
|
|
2817
|
+
const changes = value && value.changes
|
|
2818
|
+
if (changes && typeof changes === 'object' && !Array.isArray(changes)) {
|
|
2819
|
+
const detail = Object.entries(changes)
|
|
2820
|
+
.map(([key, val]) => `${key}=${JSON.stringify(val)}`)
|
|
2821
|
+
.join(', ')
|
|
2822
|
+
if (detail !== '') lines.push(`变更:${detail}`)
|
|
2823
|
+
}
|
|
2824
|
+
if (lines.length === 0) lines.push(value && value.ok ? 'ok' : '失败')
|
|
2825
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
2826
|
+
}
|
|
2827
|
+
},
|
|
2828
|
+
async execute(args) {
|
|
2829
|
+
try {
|
|
2830
|
+
const { patch, errors } = sanitizeConfig(args)
|
|
2831
|
+
if (Object.keys(errors).length > 0) {
|
|
2832
|
+
return { ok: false, message: `配置校验失败:${formatFieldErrors(errors)}` }
|
|
2833
|
+
}
|
|
2834
|
+
// 只接受工具字段:sanitizeConfig 认得的键比这里多(enabled / memory / presets…),
|
|
2835
|
+
// 不能让模型透过本工具改到那些字段。
|
|
2836
|
+
const writable = {}
|
|
2837
|
+
for (const key of TOOL_FIELDS) {
|
|
2838
|
+
if (key in patch) writable[key] = patch[key]
|
|
2839
|
+
}
|
|
2840
|
+
if (Object.keys(writable).length === 0) {
|
|
2841
|
+
return { ok: true, message: '没有要更改的设置' }
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
const config = await runtime.store.getConfig()
|
|
2845
|
+
const changedKeys = Object.keys(writable).filter((key) => config[key] !== writable[key])
|
|
2846
|
+
if (changedKeys.length === 0) {
|
|
2847
|
+
return { ok: true, message: '没有要更改的设置' }
|
|
2848
|
+
}
|
|
2849
|
+
const effective = Object.fromEntries(changedKeys.map((key) => [key, writable[key]]))
|
|
2850
|
+
|
|
2851
|
+
if (config.requireToolConfirmation) {
|
|
2852
|
+
runtime.pendingProposal = { patch: effective, proposedAt: new Date().toISOString() }
|
|
2853
|
+
const detail = Object.entries(effective).map(([key, val]) => `${key}=${JSON.stringify(val)}`).join(', ')
|
|
2854
|
+
return {
|
|
2855
|
+
ok: true,
|
|
2856
|
+
pending: true,
|
|
2857
|
+
changes: effective,
|
|
2858
|
+
message: [
|
|
2859
|
+
'已收到人设变更请求(确认模式已开启,尚未生效):',
|
|
2860
|
+
detail,
|
|
2861
|
+
'在用户确认前不会生效;用户可用 /anima confirm 确认,或 /anima reject 拒绝。'
|
|
2862
|
+
].join('\n')
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
const { changed } = await runtime.store.updateConfig(effective, {
|
|
2867
|
+
actor: 'agent',
|
|
2868
|
+
source: config.requireToolConfirmation ? 'proposal' : 'tool',
|
|
2869
|
+
note: 'set_assistant_persona'
|
|
2870
|
+
})
|
|
2871
|
+
await reload(ctx, { refreshPrompt: changed.length > 0, configChange: { changed, source: 'tool' } })
|
|
2872
|
+
return {
|
|
2873
|
+
ok: true,
|
|
2874
|
+
message: '已更新人设设置',
|
|
2875
|
+
changes: Object.fromEntries(changed.map((key) => [key, runtime.config[key]]))
|
|
2876
|
+
}
|
|
2877
|
+
} catch (err) {
|
|
2878
|
+
// 存储不可用时给结构化失败,而不是把 StorageUnavailableError 抛给模型
|
|
2879
|
+
return { ok: false, message: `执行失败:${String(err?.message || err)}` }
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
}))
|
|
2883
|
+
}).catch((err) => {
|
|
2884
|
+
warn(ctx, `工具未注册:${String(err?.message || err)}`)
|
|
2885
|
+
})
|
|
2886
|
+
})
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
// ==================== 入口 ====================
|
|
2890
|
+
|
|
2891
|
+
export async function apply(ctx) {
|
|
2892
|
+
runtime.ctx = ctx
|
|
2893
|
+
|
|
2894
|
+
const store = new StateStore(ctx, ctx.logger)
|
|
2895
|
+
runtime.store = store
|
|
2896
|
+
|
|
2897
|
+
const state = {
|
|
2898
|
+
timer: null,
|
|
2899
|
+
lastTriageDay: async () => {
|
|
2900
|
+
const table = await store.table?.('meta')
|
|
2901
|
+
const meta = table?.get('triage')
|
|
2902
|
+
return meta?.lastTriageDay
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
// 公开服务:供其它插件或脚本读取有效人设(同生态里的人设插件也各有同类服务)
|
|
2907
|
+
ctx.provide('animaConfig', {
|
|
2908
|
+
/** 有效人设(全局 + 指定工作区合并后的结果) */
|
|
2909
|
+
async getEffective(workspacePath) {
|
|
2910
|
+
const config = await store.getConfig()
|
|
2911
|
+
if (!workspacePath || !config.workspaceOverridesEnabled) {
|
|
2912
|
+
return { effective: mergePersona(config, null).effective, sources: {}, workspacePath: null }
|
|
2913
|
+
}
|
|
2914
|
+
const { overrides } = await readOverrides(workspacePath, config.workspaceFileDir)
|
|
2915
|
+
const { effective, sources } = mergePersona(config, overrides)
|
|
2916
|
+
return { effective, sources, workspacePath }
|
|
2917
|
+
},
|
|
2918
|
+
async getConfig() {
|
|
2919
|
+
return store.getConfig()
|
|
2920
|
+
},
|
|
2921
|
+
/**
|
|
2922
|
+
* 本插件这一轮会注入的全部文本(人设 + 记忆 + 待审提示)。
|
|
2923
|
+
*
|
|
2924
|
+
* 门控与三个 section 函数**逐条对齐**(未装载 ⇒ 空串;关闭 ⇒ 关闭声明;
|
|
2925
|
+
* 记忆层单独有开关),否则这个服务会答出一份模型实际看不到的文本 ——
|
|
2926
|
+
* 那比不提供这个服务更坏。
|
|
2927
|
+
*
|
|
2928
|
+
* 不复用 /api/anima/preview:那是**设置页的展示口径**,接受草稿覆盖、
|
|
2929
|
+
* 返回逐段 sections 与注入决策。服务调用方问的是「现在真正注入什么」,
|
|
2930
|
+
* 两者混用会让人误以为还没保存的草稿也算数。
|
|
2931
|
+
*/
|
|
2932
|
+
async getSystemPrompt(workspacePath) {
|
|
2933
|
+
if (!runtime.loaded) return ''
|
|
2934
|
+
const config = await store.getConfig()
|
|
2935
|
+
if (!config.enabled) return compileDisabledNotice()
|
|
2936
|
+
const overrides = workspacePath && config.workspaceOverridesEnabled
|
|
2937
|
+
? (await readOverrides(workspacePath, config.workspaceFileDir)).overrides
|
|
2938
|
+
: null
|
|
2939
|
+
const { effective } = mergePersona(config, overrides)
|
|
2940
|
+
const memory = config.memory.enabled
|
|
2941
|
+
? (() => {
|
|
2942
|
+
const selection = selectForInjection(runtime.adopted, config.memory.maxInjected, {
|
|
2943
|
+
maxInjectedChars: config.memory.maxInjectedChars,
|
|
2944
|
+
cwd: workspacePath ?? ''
|
|
2945
|
+
})
|
|
2946
|
+
return compileMemory(selection.selected, summarizeOmission(selection))
|
|
2947
|
+
})()
|
|
2948
|
+
: ''
|
|
2949
|
+
const pending = config.memory.enabled ? compilePendingNotice(runtime.pendingCount) : ''
|
|
2950
|
+
// remember 指引段的门控必须与 rememberText 逐条一致(含 autoCapture):
|
|
2951
|
+
// 这个方法是「模型这一轮到底看到什么」的权威答案,多一段少一段都是错的。
|
|
2952
|
+
const remember = config.memory.enabled && config.memory.autoCapture ? compileRememberGuide() : ''
|
|
2953
|
+
// 质检段必须在这里也出现:这个方法的名字是 getSystemPrompt,调用方拿它当
|
|
2954
|
+
// 「模型这一轮到底看到什么」的权威答案。漏掉质检段就等于对外保证了一个
|
|
2955
|
+
// 与实际注入不符的文本 —— 正是本方法注释里说要避免的那种坏。
|
|
2956
|
+
const quality = compileQualitySection(config.quality)
|
|
2957
|
+
return [compilePersona(effective), memory, pending, remember, quality].filter(Boolean).join('\n\n')
|
|
2958
|
+
},
|
|
2959
|
+
/**
|
|
2960
|
+
* 重置全局人设(保留记忆、预设与工作区覆盖)。
|
|
2961
|
+
*
|
|
2962
|
+
* 与 HTTP 的 /reset 走同一个 store.resetConfig,因此同样留审计、同样发变更通知;
|
|
2963
|
+
* 单独给服务开一条「静默重置」的路等于给审计留个后门。
|
|
2964
|
+
*/
|
|
2965
|
+
async resetConfig() {
|
|
2966
|
+
const { config, changed } = await store.resetConfig({ actor: 'system', source: 'other' })
|
|
2967
|
+
await reload(ctx, {
|
|
2968
|
+
refreshPrompt: changed.length > 0,
|
|
2969
|
+
configChange: changed.length > 0 ? { changed, source: '外部调用(其它插件或脚本)' } : null
|
|
2970
|
+
})
|
|
2971
|
+
return config
|
|
2972
|
+
},
|
|
2973
|
+
async updateConfig(patch) {
|
|
2974
|
+
const { patch: clean, errors } = sanitizeConfig(patch)
|
|
2975
|
+
if (Object.keys(errors).length > 0) {
|
|
2976
|
+
throw new Error(`配置校验失败:${formatFieldErrors(errors)}`)
|
|
2977
|
+
}
|
|
2978
|
+
const { config, changed } = await store.updateConfig(clean, { actor: 'system', source: 'other', note: '外部调用 animaConfig.updateConfig' })
|
|
2979
|
+
// 走这个入口的是别的插件或脚本:来源无法归到「设置页/命令/模型工具」里的任何一个,
|
|
2980
|
+
// 因此标注写「外部调用」而不是冒充用户操作 —— 模型需要知道自己手上的设定来自哪里。
|
|
2981
|
+
await reload(ctx, {
|
|
2982
|
+
refreshPrompt: changed.length > 0,
|
|
2983
|
+
configChange: changed.length > 0 ? { changed, source: '外部调用(其它插件或脚本)' } : null
|
|
2984
|
+
})
|
|
2985
|
+
return config
|
|
2986
|
+
},
|
|
2987
|
+
async listPending() {
|
|
2988
|
+
const all = await store.listMemories()
|
|
2989
|
+
return groupByStatus(all).pending
|
|
2990
|
+
},
|
|
2991
|
+
async runTriage() {
|
|
2992
|
+
return runTriage(ctx, { reason: 'api' })
|
|
2993
|
+
},
|
|
2994
|
+
/**
|
|
2995
|
+
* 手动跑一次巡夜(整理 + 体检 + 决定说不说)。
|
|
2996
|
+
*
|
|
2997
|
+
* 手动触发与定时触发走**同一条**判据:如果手动那次无视「指纹没变就不说」,
|
|
2998
|
+
* 用户就永远测不到定时触发的真实行为 —— 手点一次和它自己跑一次会给出
|
|
2999
|
+
* 不同答案的机制,等于没法验证。
|
|
3000
|
+
*/
|
|
3001
|
+
async runPatrol() {
|
|
3002
|
+
return runPatrol(ctx, { reason: 'api' })
|
|
3003
|
+
}
|
|
3004
|
+
})
|
|
3005
|
+
|
|
3006
|
+
registerSystemPrompt(ctx)
|
|
3007
|
+
registerQualityHook(ctx)
|
|
3008
|
+
registerRoutes(ctx)
|
|
3009
|
+
registerCommands(ctx)
|
|
3010
|
+
registerTools(ctx)
|
|
3011
|
+
|
|
3012
|
+
// 首次装载:把落盘状态读进内存快照(此时才真正打开存储后端)。
|
|
3013
|
+
//
|
|
3014
|
+
// 这里**不能**假设 storageDomain 已就绪:dsh-storage-domain 是在它自己的
|
|
3015
|
+
// ctx.inject(backendServices, …) 回调里 provide 的(异步)。早期版本在 apply 阶段
|
|
3016
|
+
// 一次性 ctx.get() 判空后「退化到文件」,结果是冷启动时静默换了一个落盘点,
|
|
3017
|
+
// 用户先前填的人设读不出来(2026-10-10 实测)。
|
|
3018
|
+
//
|
|
3019
|
+
// 现在的做法(与某同生态插件的「惰性解析」一致 + 本仓实证的就绪信号):
|
|
3020
|
+
// - 服务缺失 ⇒ 挂 ctx.inject 等它就绪,届时重试一次(探针实证:服务晚提供时
|
|
3021
|
+
// 回调会被触发;已就绪则立即触发);
|
|
3022
|
+
// - 其它失败(词汇表缺失 / open 被拒)⇒ 服务是就绪的,重试没有意义,告警即可。
|
|
3023
|
+
// 三处不写 storageDomain 进 inject 的原因见文件头第四条约束与 lib/store.mjs 头注释。
|
|
3024
|
+
try {
|
|
3025
|
+
await reload(ctx, { refreshPrompt: false })
|
|
3026
|
+
} catch (err) {
|
|
3027
|
+
if (err?.code === 'storage-not-composed') {
|
|
3028
|
+
warn(ctx, `存储栈未就绪,等待 storageDomain 提供后重试:${String(err?.message || err)}`)
|
|
3029
|
+
ctx.inject(['storageDomain'], () => {
|
|
3030
|
+
reload(ctx, { refreshPrompt: true }).catch((retryErr) => {
|
|
3031
|
+
warn(ctx, `storageDomain 就绪后重试仍失败:${String(retryErr?.message || retryErr)}`)
|
|
3032
|
+
})
|
|
3033
|
+
})
|
|
3034
|
+
} else {
|
|
3035
|
+
warn(ctx, `初始加载失败(${err?.code ?? 'unknown'}):${String(err?.message || err)}`)
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
const scheduler = scheduleNightly(ctx, state)
|
|
3040
|
+
|
|
3041
|
+
// 启动补跑:DSH 没在整点时开机的话,开机后补一次
|
|
3042
|
+
ctx.effect(() => {
|
|
3043
|
+
let cancelled = false
|
|
3044
|
+
;(async () => {
|
|
3045
|
+
try {
|
|
3046
|
+
await store.table?.('meta').then((t) => t)
|
|
3047
|
+
await new Promise((r) => setTimeout(r, 5000))
|
|
3048
|
+
if (cancelled) return
|
|
3049
|
+
// 首次装载没成功(存储栈未组合 / 还没就绪)时不硬试:这条补跑只是便利,
|
|
3050
|
+
// 存储不可用时它必然读不到 lastTriageDay,报一条「补跑失败」纯属噪音。
|
|
3051
|
+
// 真正就绪后会由 ctx.inject 的 retry 走一次 reload,后续整点仍按 nightlyHour 触发。
|
|
3052
|
+
if (!runtime.loaded) return
|
|
3053
|
+
const config = await store.getConfig()
|
|
3054
|
+
if (!config.memory.enabled) return
|
|
3055
|
+
// 巡夜与回扫各自判各自的开关与日期。曾经这里是一个 `||` 直接 return ——
|
|
3056
|
+
// 那个写法让「关掉巡夜」连带停掉回扫的补跑,而回扫在设置里是单开的。
|
|
3057
|
+
if (config.memory.nightlyTriage) {
|
|
3058
|
+
const last = await state.lastTriageDay()
|
|
3059
|
+
if (last !== dayKey()) {
|
|
3060
|
+
await runPatrol(ctx, { reason: 'catch-up' })
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
// 回扫的补跑单独判日期。缺了这一段,只有巡夜会补跑、回扫会一直等到
|
|
3064
|
+
// 下一个整点 —— 而这类用户往往开机就没有整点(休眠/关机是常态)。
|
|
3065
|
+
if (config.memory.retrospectEnabled) {
|
|
3066
|
+
const retroMeta = (await store.table?.('meta'))?.get(RETROSPECT_KEY)
|
|
3067
|
+
if (!retrospectScannedToday(retroMeta)) {
|
|
3068
|
+
await runRetrospect(ctx, { reason: 'catch-up' })
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
} catch (err) {
|
|
3072
|
+
warn(ctx, `启动补跑巡夜失败:${String(err?.message || err)}`)
|
|
3073
|
+
}
|
|
3074
|
+
})()
|
|
3075
|
+
return () => {
|
|
3076
|
+
cancelled = true
|
|
3077
|
+
scheduler.clear()
|
|
3078
|
+
}
|
|
3079
|
+
}, 'dsh-anima: catch-up patrol')
|
|
3080
|
+
|
|
3081
|
+
ctx.effect(() => () => {
|
|
3082
|
+
// 卸载前把计数落一次盘:节流窗口里攒下的那些数字,如果不在这里写,
|
|
3083
|
+
// 就随着进程消失 —— 而那恰好是「刚跑了几十步就退出」的场景,最需要它。
|
|
3084
|
+
// 注意顺序:先取到 store,再清空 runtime,否则 flushQualityStats 拿不到后端。
|
|
3085
|
+
const store = runtime.store
|
|
3086
|
+
if (store) {
|
|
3087
|
+
try { void flushQualityStats(store) } catch { /* 卸载失败不阻塞退出 */ }
|
|
3088
|
+
}
|
|
3089
|
+
runtime.ctx = null
|
|
3090
|
+
runtime.store = null
|
|
3091
|
+
runtime.refresh = null
|
|
3092
|
+
}, 'dsh-anima: teardown')
|
|
3093
|
+
|
|
3094
|
+
warn(ctx, `已装载(存储后端:${store.srcKind ?? `未打开,原因 ${store.srcReason ?? 'unknown'}`})`)
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
export { runtime }
|