dsh-memento 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs ADDED
@@ -0,0 +1,1849 @@
1
+ // index.mjs — dsh-memento 插件入口(唯一 host 面文件)。
2
+ //
3
+ // 三角色 seam:
4
+ // - Service Definition:ctx.memory(add/replace/remove/query/seed + budgets),
5
+ // 写方法内部强制走审批门(waterfall 审批接缝),模型无论经哪个工具/插件
6
+ // 间接调用服务都无法绕过(S3)。
7
+ // - Provider:lib/store.mjs 本地 SQLite(node:sqlite,零依赖,WAL)。
8
+ // - Consumer:memory 工具 + 冻结快照注入(systemPrompt 段,同步提供者)。
9
+ //
10
+ // 只消费公开服务:tools / systemPrompt / approval(inject 声明)。
11
+ // DSH 依赖只出现在本文件;lib/ 零 DSH 依赖。
12
+
13
+ import { defineTool } from '@deepseek-ai/dsh-tools'
14
+ import Schema from '@deepseek-ai/schemastery'
15
+ import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session'
16
+ import {
17
+ TRACKS,
18
+ SCOPES,
19
+ TOOL_NAME,
20
+ DEFAULT_SOURCE,
21
+ SESSION_EVENTS,
22
+ PANEL_AUDIT_CEILING,
23
+ } from './lib/constants.mjs'
24
+ import {
25
+ MemoryError,
26
+ InvalidInputError,
27
+ BudgetExceededError,
28
+ EntryNotFoundError,
29
+ AmbiguousMatchError,
30
+ WriteDeniedError,
31
+ NoAgentError,
32
+ ProposalNotFoundError,
33
+ } from './lib/errors.mjs'
34
+ import { checkBudget, budgetReport, budgetLimits, validateBudgets } from './lib/budget.mjs'
35
+ import { buildWriteReason, isMemoryWriteRequest, applyWritePolicy, normalizeWritePolicy, resolveWritePolicy, validateWritePolicies, parseWriteReason } from './lib/gate.mjs'
36
+ import { renderSnapshot, visibleEntries, visibleProposals } from './lib/snapshot.mjs'
37
+ import { openMemoryStore, resolveDbPath } from './lib/store.mjs'
38
+ import { workspaceKeyOf, agentKeyOf } from './lib/workspace.mjs'
39
+ import { extractEventText } from './lib/extract.mjs'
40
+
41
+ /**
42
+ * @typedef {import('./types.js').MemoryEntry} MemoryEntry
43
+ * @typedef {import('./types.js').MemoryQueryResult} MemoryQueryResult
44
+ * @typedef {import('./types.js').MemoryWriteContext} MemoryWriteContext
45
+ * @typedef {import('./types.js').MemorySessionLike} MemorySessionLike
46
+ * @typedef {{track: string, scope: string, used: number, limit: number}} MemoryUsage
47
+ * @typedef {{id: string, kind: string, track: string, scope: string, workspaceKey: string, agentKey: string, text: string, source: string, sessionId: string | null, status: string, createdAt: number, decidedAt: number | null}} MemoryProposal
48
+ * @typedef {{user: {userGlobal: number, workspace: number}, agent: {userGlobal: number, workspace: number}}} BudgetsConfig
49
+ * @typedef {object} StoreHandle - ctx.memory 依赖的 Provider 面。
50
+ * @property {(filter?: {track?: string, scope?: string, text?: string, limit?: number}) => MemoryQueryResult} queryEntries
51
+ * @property {() => MemoryEntry[]} listEntries
52
+ * @property {(track: string, scope: string, match: string) => MemoryEntry[]} matchCandidates
53
+ * @property {(track: string, scope: string) => number} usage
54
+ * @property {(input: object) => MemoryEntry} insertEntry
55
+ * @property {(inputs: object[]) => MemoryEntry[]} seedEntries
56
+ * @property {(input: object) => {previous: MemoryEntry, entry: MemoryEntry}} replaceEntry
57
+ * @property {(input: object) => MemoryEntry} removeEntry
58
+ * @property {(input: object) => {removed: MemoryEntry[], entry: MemoryEntry}} consolidateEntries
59
+ * @property {(row: object) => object} auditAppend
60
+ * @property {(limit?: number) => object[]} auditList
61
+ * @property {(input: object) => object | null} proposalUpsert
62
+ * @property {(status?: string, limit?: number) => object[]} proposalList
63
+ * @property {(id: string, status: string) => object} proposalDecide
64
+ * @property {() => void} close
65
+ * @typedef {{request: (req: object) => Promise<string>, overrideOf?: (session: unknown) => string | undefined, config?: {policy?: string}}} ApprovalLike
66
+ * @typedef {object} ServiceDeps
67
+ * @property {StoreHandle} store
68
+ * @property {BudgetsConfig} budgets
69
+ * @property {string} writePolicy
70
+ * @property {number} maxEntriesPerQuery
71
+ * @property {number} commandListLimit
72
+ * @property {number} commandAuditLimit
73
+ * @property {'en'|'zh'} language
74
+ * @property {ApprovalLike} approval
75
+ * @property {string} [sourceLabel]
76
+ * @typedef {object} PluginConfig - apply 的宽松配置形状(cordis loader 已套 schema 默认值)。
77
+ * @property {boolean} [enabled]
78
+ * @property {string} [dbPath]
79
+ * @property {{user?: {userGlobal?: number, workspace?: number}, agent?: {userGlobal?: number, workspace?: number}}} [budgets]
80
+ * @property {string} [writePolicy]
81
+ * @property {Record<string, string>} [writePolicies]
82
+ * @property {'en'|'zh'} [language]
83
+ * @property {number} [snapshotOrder]
84
+ * @property {number} [maxEntriesPerQuery]
85
+ * @property {number} [commandListLimit]
86
+ * @property {number} [commandAuditLimit]
87
+ * @property {{historyLimitDefault?: number, snippetCap?: number, snippetChars?: number, windowDays?: number}} [recall]
88
+ * @property {number} [panelEntriesLimit]
89
+ * @property {number} [panelAuditLimit]
90
+ * @property {number} [auditRetentionDays]
91
+ * @property {{enabled?: boolean, maxChars?: number, maxPending?: number}} [proposals]
92
+ * @typedef {{action: string, track: string, scope: string, text: string, count?: number, source?: string}} WritePayload
93
+ * @typedef {{agent?: {session?: MemorySessionLike | null} | null, callId?: unknown, signal?: AbortSignal}} AskWrite
94
+ * @typedef {{track: string, scope: string, text: string}} PublicEntry
95
+ * @typedef {object} MemoryToolValue - memory 工具规范结果形状。
96
+ * @property {boolean} ok
97
+ * @property {string} action
98
+ * @property {{message: string}} [error]
99
+ * @property {PublicEntry[]} [entries]
100
+ * @property {boolean} [truncated]
101
+ * @property {number} [total]
102
+ * @property {PublicEntry} [entry]
103
+ * @property {Array<{id: string, text: string}>} [removed]
104
+ * @property {{used: number, limit: number}} [usage]
105
+ * @typedef {object} RecallToolValue - memory_recall 工具规范结果形状。
106
+ * @property {{total: number, entries: PublicEntry[], truncated: boolean}} memory
107
+ * @property {{available: boolean, error?: string, sessions: Array<{sessionId: string, matches: number, snippets: string[]}>}} history
108
+ * @typedef {object} PanelResponse - node:http 响应最小面。
109
+ * @property {(status: number, headers?: object) => unknown} writeHead
110
+ * @property {(body: string) => unknown} end
111
+ */
112
+
113
+ export const name = 'memento'
114
+
115
+ export const inject = ['tools', 'systemPrompt', 'approval']
116
+
117
+ /** 默认预算:user 轨 2000 字符/层,agent 轨 4000 字符/层(中文场景按需调大,见 README)。 */
118
+ export const DEFAULT_BUDGETS = Object.freeze({
119
+ user: Object.freeze({ userGlobal: 2000, workspace: 2000 }),
120
+ agent: Object.freeze({ userGlobal: 4000, workspace: 4000 }),
121
+ })
122
+
123
+ /** 快照段注入顺序:harness identity(-100) 之后、persona(0) 之前(负数=靠前)。 */
124
+ export const DEFAULT_SNAPSHOT_ORDER = -50
125
+
126
+ /**
127
+ * 插件配置(Schemastery,全部可 cordis.yml 覆盖;无硬编码 tunable)。
128
+ * @typedef {object} Config
129
+ * @property {boolean} [enabled] 整体开关;false 时工具/注入/服务/审批 answerer 全部消失。
130
+ * @property {string} [dbPath] 记忆库路径;空 = $DSH_HOME/dsh-memento/memory.db。
131
+ * @property {{user: {userGlobal: number, workspace: number}, agent: {userGlobal: number, workspace: number}}} [budgets]
132
+ * 每轨每层硬字符预算。
133
+ * @property {'ask'|'auto'|'off'} [writePolicy] 写审批策略;模型不可见、不可改。
134
+ * @property {Record<string, 'ask'|'auto'|'off'>} [writePolicies] 粒度写策略(键 `track/scope` 或 `source:<name>`;未命中回退 writePolicy)。
135
+ * @property {'en'|'zh'} [language] 模型可见文案与命令输出语言(默认 en;快照/工具描述/命令随选)。
136
+ * @property {number} [snapshotOrder] 快照段注入顺序(默认 -50,靠前负值)。
137
+ * @property {number} [maxEntriesPerQuery] query 默认返回条目上限(显式 limit 可超出,Provider 硬钳 1000)。
138
+ * @property {number} [commandListLimit] /memory list|query 单次渲染条目上限(默认 50)。
139
+ * @property {number} [commandAuditLimit] /memory audit 单次渲染审计行上限(默认 10)。
140
+ * @property {{historyLimitDefault?: number, snippetCap?: number, snippetChars?: number, windowDays?: number}} [recall]
141
+ * memory_recall 历史段默认值(默认 8/5/300/30)。
142
+ * @property {number} [panelEntriesLimit] 面板条目页上限与钳制(默认 200)。
143
+ * @property {number} [panelAuditLimit] 面板审计默认条数(默认 20;上限 200 为协议常量)。
144
+ * @property {number} [auditRetentionDays] 审计保留天数(默认 0 = 不限)。
145
+ * @property {{enabled?: boolean, maxChars?: number, maxPending?: number}} [proposals]
146
+ * auto-capture 压缩记忆提案(默认 true / 2000 / 8)。
147
+ */
148
+ export const Config = Schema.object({
149
+ enabled: Schema.boolean().default(true),
150
+ dbPath: Schema.string().default(''),
151
+ budgets: Schema.object({
152
+ user: Schema.object({
153
+ userGlobal: Schema.number().default(DEFAULT_BUDGETS.user.userGlobal),
154
+ workspace: Schema.number().default(DEFAULT_BUDGETS.user.workspace),
155
+ }),
156
+ agent: Schema.object({
157
+ userGlobal: Schema.number().default(DEFAULT_BUDGETS.agent.userGlobal),
158
+ workspace: Schema.number().default(DEFAULT_BUDGETS.agent.workspace),
159
+ }),
160
+ }),
161
+ writePolicy: Schema.union(['ask', 'auto', 'off']).default('ask'),
162
+ writePolicies: Schema.dict(Schema.union(['ask', 'auto', 'off'])).default({}),
163
+ language: Schema.union(['en', 'zh']).default('en'),
164
+ snapshotOrder: Schema.number().default(DEFAULT_SNAPSHOT_ORDER),
165
+ maxEntriesPerQuery: Schema.number().default(20),
166
+ commandListLimit: Schema.number().default(50),
167
+ commandAuditLimit: Schema.number().default(10),
168
+ recall: Schema.object({
169
+ historyLimitDefault: Schema.number().default(8),
170
+ snippetCap: Schema.number().default(5),
171
+ snippetChars: Schema.number().default(300),
172
+ windowDays: Schema.number().default(30),
173
+ }),
174
+ panelEntriesLimit: Schema.number().default(200),
175
+ panelAuditLimit: Schema.number().default(20),
176
+ auditRetentionDays: Schema.number().default(0),
177
+ proposals: Schema.object({
178
+ enabled: Schema.boolean().default(true),
179
+ maxChars: Schema.number().default(2000),
180
+ maxPending: Schema.number().default(8),
181
+ }),
182
+ })
183
+
184
+ /**
185
+ * 记忆写审批请求:service 层强制走 ctx.approval.request(waterfall 接缝)。
186
+ * approval/asked + approval/decided(会话日志已知事件类型)由审批服务自动落盘;
187
+ * reason 携带完整写载荷,S2"变更可自会话日志重建"由此成立。
188
+ * @param {ApprovalLike} approval - ApprovalService。
189
+ * @param {WritePayload} payload - {action, track, scope, text, count?}。
190
+ * @param {AskWrite} write - {agent, callId?, signal?}。
191
+ * @returns {Promise<string>} ApprovalOutcome。
192
+ */
193
+ async function askApproval(approval, payload, write) {
194
+ const request = {
195
+ agent: write.agent,
196
+ toolName: TOOL_NAME,
197
+ reason: buildWriteReason(payload),
198
+ ...(write.callId === undefined ? {} : { callId: write.callId }),
199
+ ...(write.signal === undefined ? {} : { signal: write.signal }),
200
+ }
201
+ try {
202
+ return await approval.request(request)
203
+ } catch (error) {
204
+ if (error instanceof MemoryError) throw error
205
+ const message = error instanceof Error ? error.message : String(error)
206
+ throw new WriteDeniedError('unavailable', `approval ask failed: ${message}`)
207
+ }
208
+ }
209
+
210
+ /**
211
+ * 自适应会话事件派发:只有 harness 已知该事件类型才 append。
212
+ * rc.6 无插件事件注册面(KNOWN_SESSION_EVENT_TYPES 不含 memory/*,且
213
+ * Session.append 无法标记 ignorable):append 未注册类型会让该会话下次加载
214
+ * 被持久化层拒绝。因此默认跳过,审计由审批审计对 + 审计表承担;未来 harness
215
+ * 收录 memory/* 进已知集合后自动开启。
216
+ * @param {{append?: (type: string, data: object) => unknown} | null | undefined} session - Session。
217
+ * @param {string} type - 事件类型。
218
+ * @param {object} data - 载荷。
219
+ */
220
+ function maybeAppendSessionEvent(session, type, data) {
221
+ if (session === undefined || session === null) return
222
+ if (KNOWN_SESSION_EVENT_TYPES.has(type)) session.append(type, data)
223
+ }
224
+
225
+ /**
226
+ * ctx.memory 服务(Service Definition 实现)。
227
+ * 写方法内部强制过审批门:预算预检 → 审批 → 预算复审 → 落盘 → 审计。
228
+ * 读方法无审批;禁用时本服务整体不存在。
229
+ */
230
+ export class MemoryService {
231
+ /**
232
+ * @param {ServiceDeps} deps - {store, budgets, writePolicy, maxEntriesPerQuery, approval, sourceLabel}。
233
+ */
234
+ constructor(deps) {
235
+ this.store = deps.store
236
+ this.budgetsConfig = deps.budgets
237
+ this.limits = budgetLimits(deps.budgets)
238
+ this.writePolicy = normalizeWritePolicy(deps.writePolicy)
239
+ this.maxEntriesPerQuery = deps.maxEntriesPerQuery
240
+ this.commandListLimit = deps.commandListLimit
241
+ this.commandAuditLimit = deps.commandAuditLimit
242
+ this.language = deps.language
243
+ this.approval = deps.approval
244
+ this.sourceLabel = deps.sourceLabel ?? DEFAULT_SOURCE
245
+ }
246
+
247
+ /** @returns {Array<{track: string, scope: string, used: number, limit: number}>} 预算报表。 */
248
+ budgets() {
249
+ return budgetReport(this.store.listEntries(), this.budgetsConfig)
250
+ }
251
+
252
+ /**
253
+ * 查询条目(无审批;带 sessionId 时记一条 recalled 审计)。
254
+ * 显式合法 limit 生效但被 Provider 硬钳到 MAX_QUERY_LIMIT(1000);缺省/非法 limit 用
255
+ * maxEntriesPerQuery(Config 默认 20,语义是"默认返回上限"而非硬顶)。
256
+ * @param {{track?: string, scope?: string, text?: string, limit?: number}} [filter] - {track, scope, text, limit}。
257
+ * @param {{sessionId?: string, session?: MemorySessionLike | null}} [opts] - {sessionId, session};session 用于 memory/recalled
258
+ * 事件的按已知类型自适应派发(与写事件同一 maybeAppendSessionEvent 门)。
259
+ * @returns {MemoryQueryResult}。
260
+ */
261
+ query(filter = {}, opts = {}) {
262
+ const { entries, total, truncated } = this.store.queryEntries({
263
+ ...(filter.track === undefined ? {} : { track: filter.track }),
264
+ ...(filter.scope === undefined ? {} : { scope: filter.scope }),
265
+ ...(typeof filter.text === 'string' && filter.text.length > 0 ? { text: filter.text } : {}),
266
+ limit: Number.isInteger(filter.limit) && filter.limit > 0 ? filter.limit : this.maxEntriesPerQuery,
267
+ })
268
+ if (opts.sessionId !== undefined) {
269
+ this.store.auditAppend({
270
+ action: 'recalled',
271
+ ...(filter.track === undefined ? {} : { track: filter.track }),
272
+ ...(filter.scope === undefined ? {} : { scope: filter.scope }),
273
+ text: typeof filter.text === 'string' ? filter.text : null,
274
+ outcome: 'ok',
275
+ source: this.sourceLabel,
276
+ sessionId: opts.sessionId,
277
+ })
278
+ }
279
+ if (opts.session !== undefined && opts.session !== null) {
280
+ maybeAppendSessionEvent(opts.session, SESSION_EVENTS.recalled, {
281
+ query: typeof filter.text === 'string' ? filter.text : '',
282
+ matches: total,
283
+ sessionId: opts.sessionId ?? opts.session.id ?? '',
284
+ })
285
+ }
286
+ return { entries, total, truncated }
287
+ }
288
+
289
+ /**
290
+ * 写路径审批门。默认走 ctx.approval.request(turn 内,落 approval/asked +
291
+ * approval/decided 审计对)。write.gate 为可选自定义传输(/memory 命令在
292
+ * turn 外使用:同一 approval/request waterfall + 同一 answerer 链裁决,
293
+ * 会话级 never 策略由调用方预检;审计落在插件审计表 + command/done)。
294
+ * @param {WritePayload} payload - {action, track, scope, text, count?}。
295
+ * @param {MemoryWriteContext} write - {agent, callId?, signal?, gate?}。
296
+ * @returns {Promise<{outcome: string, source: 'approval'|'gate'}>} 实际裁决结果与传输来源(审计标签用)。
297
+ */
298
+ async #ask(payload, write) {
299
+ if (typeof write.gate === 'function') {
300
+ const outcome = await write.gate(payload, write)
301
+ this.#assertOutcome(outcome)
302
+ return { outcome, source: 'gate' }
303
+ }
304
+ const outcome = await askApproval(this.approval, payload, write)
305
+ this.#assertOutcome(outcome)
306
+ return { outcome, source: 'approval' }
307
+ }
308
+
309
+ /** 审计 outcome 标签:审批传输标注策略,gate 传输标注 gate(真实裁决来源,不张冠李戴)。 */
310
+ #outcomeLabel(/** @type {{outcome: string, source: 'approval'|'gate'}} */ via) {
311
+ return via.source === 'gate'
312
+ ? `${via.outcome} (via write gate)`
313
+ : `${via.outcome} (via approval, writePolicy ${this.writePolicy})`
314
+ }
315
+
316
+ /**
317
+ * 新增条目(写:审批门 + 预算门)。
318
+ * @param {{track: string, scope: string, text: string, source?: string, workspaceKey?: string, agentKey?: string}} input - {track, scope, text, source?, workspaceKey?, agentKey?}。
319
+ * @param {MemoryWriteContext} write - {agent, callId?, signal?, gate?};agent 缺失即失败封闭。
320
+ * @returns {Promise<{entry: MemoryEntry, usage: {track: string, scope: string, used: number, limit: number}}>}。
321
+ */
322
+ async add(input, write) {
323
+ const { track, scope, text } = this.#validateEntry(input, write)
324
+ this.#assertBudget(track, scope, this.store.usage(track, scope), text.length)
325
+ const via = await this.#ask({ action: 'add', track, scope, text, source: input.source ?? this.sourceLabel }, write)
326
+ this.#throwIfAborted(write)
327
+ // 审批等待期间用量可能变化:以此刻用量为权威复审。
328
+ const now = this.store.usage(track, scope)
329
+ this.#assertBudget(track, scope, now, text.length)
330
+ const entry = this.store.insertEntry({
331
+ track, scope, text,
332
+ workspaceKey: input.workspaceKey ?? this.#workspaceKeyOf(write),
333
+ agentKey: input.agentKey ?? this.#agentKeyOf(write),
334
+ source: input.source ?? this.sourceLabel,
335
+ sessionId: write.agent.session?.id ?? null,
336
+ })
337
+ this.#auditWrite('add', track, scope, entry, write, via)
338
+ this.#appendWriteEvent(write, SESSION_EVENTS.added, { entry, source: entry.source })
339
+ return { entry, usage: this.#usage(track, scope) }
340
+ }
341
+
342
+ /**
343
+ * 按唯一子串替换条目(写:审批门 + 预算门;零/多命中报错,绝不截断)。
344
+ * @param {{track: string, scope: string, match: string, text: string, source?: string}} input - {track, scope, match, text, source?}。
345
+ * @param {MemoryWriteContext} write - {agent, callId?, signal?}。
346
+ * @returns {Promise<{previous: MemoryEntry, entry: MemoryEntry, usage: {track: string, scope: string, used: number, limit: number}}>}。
347
+ */
348
+ async replace(input, write) {
349
+ const { track, scope, text } = this.#validateEntry(input, write)
350
+ this.#assertMatch(input)
351
+ // 审批前先定位:零/多命中在打扰用户之前就响亮失败。
352
+ const initial = this.#resolveMatch(input, track, scope)
353
+ this.#assertBudget(track, scope, this.store.usage(track, scope), text.length - initial.text.length)
354
+ const via = await this.#ask({ action: 'replace', track, scope, text, source: input.source ?? this.sourceLabel }, write)
355
+ this.#throwIfAborted(write)
356
+ // 审批等待期间目标条目可能已被并发写改动:以此刻重新定位的 previous 为权威重算净变化,
357
+ // 再用此刻用量复审。复审与 replaceEntry 之间无 await,判断与实际写入之间不存在窗口。
358
+ const current = this.#resolveMatch(input, track, scope)
359
+ const net = text.length - current.text.length
360
+ this.#assertBudget(track, scope, this.store.usage(track, scope), net)
361
+ // 事务内重新定位+更新:零/多命中仍会响亮报错(不静默)。
362
+ const replaced = this.store.replaceEntry({
363
+ track, scope, match: input.match, text,
364
+ sessionId: write.agent.session?.id ?? null,
365
+ })
366
+ this.#auditWrite('replace', track, scope, replaced.entry, write, via)
367
+ this.#appendWriteEvent(write, SESSION_EVENTS.updated, {
368
+ previous: replaced.previous,
369
+ entry: replaced.entry,
370
+ source: replaced.entry.source,
371
+ })
372
+ return { previous: replaced.previous, entry: replaced.entry, usage: this.#usage(track, scope) }
373
+ }
374
+
375
+ /**
376
+ * 按唯一子串删除条目(写:审批门;零/多命中报错)。
377
+ * @param {{track: string, scope: string, match: string}} input - {track, scope, match}。
378
+ * @param {MemoryWriteContext} write - {agent, callId?, signal?}。
379
+ * @returns {Promise<{entry: MemoryEntry, usage: {track: string, scope: string, used: number, limit: number}}>}。
380
+ */
381
+ async remove(input, write) {
382
+ this.#assertAgent(write)
383
+ this.#assertScope(input.track, input.scope)
384
+ this.#assertMatch(input)
385
+ // 审批前先定位:零/多命中在打扰用户之前就响亮失败。
386
+ const target = this.#resolveMatch(input, input.track, input.scope)
387
+ const via = await this.#ask({ action: 'remove', track: input.track, scope: input.scope, text: input.match, source: target.source }, write)
388
+ this.#throwIfAborted(write)
389
+ const removed = this.store.removeEntry({ track: input.track, scope: input.scope, match: input.match })
390
+ this.#auditWrite('remove', input.track, input.scope, removed, write, via)
391
+ this.#appendWriteEvent(write, SESSION_EVENTS.removed, { entry: removed, source: removed.source })
392
+ return { entry: removed, usage: this.#usage(input.track, input.scope) }
393
+ }
394
+
395
+ /**
396
+ * 批量种子(一次 ask 审批整个批次;dsh-claude-move 等插件喂数据用)。
397
+ * 任一条超预算 → 整批拒绝(先全量预检再落盘,无部分写入)。
398
+ * @param {Array<{track: string, scope: string, text: string, source?: string, workspaceKey?: string, agentKey?: string}>} inputs - 条目数组(track/scope/text/source/workspaceKey/agentKey)。
399
+ * @param {MemoryWriteContext} write - {agent, callId?, signal?}。
400
+ * @returns {Promise<{added: number, entries: MemoryEntry[]}>}。
401
+ */
402
+ async seed(inputs, write) {
403
+ this.#assertAgent(write)
404
+ if (!Array.isArray(inputs) || inputs.length === 0) {
405
+ throw new InvalidInputError('seed requires a non-empty entry list')
406
+ }
407
+ const normalized = inputs.map((input) => {
408
+ const { track, scope, text } = this.#validateEntry(input, write)
409
+ return {
410
+ track, scope, text,
411
+ source: input.source ?? this.sourceLabel,
412
+ workspaceKey: input.workspaceKey ?? this.#workspaceKeyOf(write),
413
+ agentKey: input.agentKey ?? this.#agentKeyOf(write),
414
+ }
415
+ })
416
+ const summary = normalized.map((entry) => `${entry.track}/${entry.scope}: ${entry.text}`).join('\n')
417
+ const via = await this.#ask({
418
+ action: 'seed', track: 'batch', scope: 'batch', text: summary, count: normalized.length,
419
+ }, write)
420
+ this.#throwIfAborted(write)
421
+ // 全量预检(任意一条超限整批拒绝);通过后同步插入,无 await 间隔。
422
+ for (const [track, scope] of uniqueScopes(normalized)) {
423
+ const used = this.store.usage(track, scope)
424
+ const addition = normalized
425
+ .filter((entry) => entry.track === track && entry.scope === scope)
426
+ .reduce((sum, entry) => sum + entry.text.length, 0)
427
+ this.#assertBudget(track, scope, used, addition)
428
+ }
429
+ const sessionId = write.agent.session?.id ?? null
430
+ const entries = this.store.seedEntries(normalized.map((entry) => ({ ...entry, sessionId })))
431
+ this.store.auditAppend({
432
+ action: 'seed',
433
+ track: null, scope: null, entryId: null,
434
+ text: summary, outcome: this.#outcomeLabel(via),
435
+ source: this.sourceLabel, sessionId,
436
+ })
437
+ for (const entry of entries) {
438
+ this.#auditWrite('add', entry.track, entry.scope, entry, write, via)
439
+ this.#appendWriteEvent(write, SESSION_EVENTS.added, { entry, source: entry.source })
440
+ }
441
+ return { added: entries.length, entries }
442
+ }
443
+
444
+ /**
445
+ * 整合多个条目为一条新条目(写:审批门 + 预算门;一次审批 + Provider 单事务原子执行)。
446
+ * 零/多命中、超预算、审批拒绝、目标在审批期间消失都响亮失败;任一步失败无部分写入。
447
+ * @param {{track: string, scope: string, matches: string[], text: string, source?: string, workspaceKey?: string, agentKey?: string}} input - 整合方案。
448
+ * @param {MemoryWriteContext} write - {agent, callId?, signal?, gate?}。
449
+ * @returns {Promise<{removed: MemoryEntry[], entry: MemoryEntry, usage: MemoryUsage}>}。
450
+ */
451
+ async consolidate(input, write) {
452
+ const { track, scope, text } = this.#validateEntry(input, write)
453
+ this.#assertConsolidateMatches(input)
454
+ // 审批前先定位全部目标:零/多命中在打扰用户之前就响亮失败。
455
+ const initial = input.matches.map((match) => this.#resolveMatch({ match }, track, scope))
456
+ const removalBefore = initial.reduce((sum, entry) => sum + entry.text.length, 0)
457
+ this.#assertBudget(track, scope, this.store.usage(track, scope), text.length - removalBefore)
458
+ const plan = `${input.matches.map((match) => `remove: ${match}`).join('\n')}\nnew text: ${text}`
459
+ const via = await this.#ask({ action: 'consolidate', track, scope, text: plan, source: input.source ?? this.sourceLabel }, write)
460
+ this.#throwIfAborted(write)
461
+ // 审批等待期间目标可能被并发写改动:重新定位并以此刻为权威重算净变化。
462
+ const current = input.matches.map((match) => this.#resolveMatch({ match }, track, scope))
463
+ const removalNow = current.reduce((sum, entry) => sum + entry.text.length, 0)
464
+ this.#assertBudget(track, scope, this.store.usage(track, scope), text.length - removalNow)
465
+ const { removed, entry } = this.store.consolidateEntries({
466
+ track, scope, matches: input.matches, text,
467
+ source: input.source ?? this.sourceLabel,
468
+ workspaceKey: input.workspaceKey ?? this.#workspaceKeyOf(write),
469
+ agentKey: input.agentKey ?? this.#agentKeyOf(write),
470
+ sessionId: write.agent.session?.id ?? null,
471
+ })
472
+ const sessionId = write.agent.session?.id ?? null
473
+ for (const old of removed) {
474
+ this.store.auditAppend({
475
+ action: 'consolidate-remove', track, scope, entryId: old.id, text: old.text,
476
+ outcome: this.#outcomeLabel(via), source: old.source, sessionId,
477
+ })
478
+ this.#appendWriteEvent(write, SESSION_EVENTS.removed, { entry: old, source: old.source })
479
+ }
480
+ this.store.auditAppend({
481
+ action: 'consolidate-add', track, scope, entryId: entry.id, text: entry.text,
482
+ outcome: this.#outcomeLabel(via), source: entry.source, sessionId,
483
+ })
484
+ this.#appendWriteEvent(write, SESSION_EVENTS.added, { entry, source: entry.source })
485
+ return { removed, entry, usage: this.#usage(track, scope) }
486
+ }
487
+
488
+ /** 写路径必须有 agent(审批路由与审计归属):缺失即失败封闭。 */
489
+ #assertAgent(/** @type {MemoryWriteContext} */ write) {
490
+ if (write === null || typeof write !== 'object' || write.agent === undefined || write.agent === null) {
491
+ throw new NoAgentError()
492
+ }
493
+ }
494
+
495
+ /** track/scope 词汇校验(响亮失败,绝不落到 SQL)。 */
496
+ #assertScope(/** @type {string} */ track, /** @type {string} */ scope) {
497
+ if (!/** @type {readonly string[]} */ (TRACKS).includes(track) || !/** @type {readonly string[]} */ (SCOPES).includes(scope)) {
498
+ throw new InvalidInputError(`invalid memory scope: track=${JSON.stringify(track)} scope=${JSON.stringify(scope)} (track ∈ ${TRACKS.join('|')}, scope ∈ ${SCOPES.join('|')})`)
499
+ }
500
+ }
501
+
502
+ /** match 参数校验(replace/remove 共用)。 */
503
+ #assertMatch(/** @type {{match?: unknown}} */ input) {
504
+ if (typeof input.match !== 'string' || input.match.length === 0) {
505
+ throw new InvalidInputError('replace/remove match must be a non-empty string')
506
+ }
507
+ }
508
+
509
+ /** consolidate matches 校验(1..20 个非空字符串)。 */
510
+ #assertConsolidateMatches(/** @type {{matches?: unknown}} */ input) {
511
+ if (!Array.isArray(input.matches) || input.matches.length === 0 || input.matches.length > 20) {
512
+ throw new InvalidInputError('consolidate matches must be an array of 1..20 non-empty strings')
513
+ }
514
+ for (const match of input.matches) {
515
+ if (typeof match !== 'string' || match.length === 0) {
516
+ throw new InvalidInputError('consolidate matches must be an array of 1..20 non-empty strings')
517
+ }
518
+ }
519
+ }
520
+
521
+ /** 条目公共校验:agent + scope + 非空文本。 */
522
+ #validateEntry(/** @type {{track: string, scope: string, text: string}} */ input, /** @type {MemoryWriteContext} */ write) {
523
+ this.#assertAgent(write)
524
+ this.#assertScope(input.track, input.scope)
525
+ if (typeof input.text !== 'string' || input.text.length === 0) {
526
+ throw new InvalidInputError('entry text must be a non-empty string')
527
+ }
528
+ return { track: input.track, scope: input.scope, text: input.text }
529
+ }
530
+
531
+ /** 审批前定位替换/删除目标(唯一子串语义,大小写不敏感,零/多命中结构化报错)。 */
532
+ #resolveMatch(/** @type {{match: string}} */ input, /** @type {string} */ track, /** @type {string} */ scope) {
533
+ const hits = /** @type {MemoryEntry[]} */ (this.store.matchCandidates(track, scope, input.match))
534
+ .filter((entry) => entry.text.toLowerCase().includes(input.match.toLowerCase()))
535
+ if (hits.length === 0) throw new EntryNotFoundError({ track, scope, match: input.match })
536
+ if (hits.length > 1) {
537
+ throw new AmbiguousMatchError({
538
+ track, scope, match: input.match,
539
+ candidates: hits.length,
540
+ sample: hits.map((entry) => entry.text.length > 200 ? `${entry.text.slice(0, 200)}…` : entry.text),
541
+ })
542
+ }
543
+ return hits[0]
544
+ }
545
+
546
+ /** 预算门:超限抛 BudgetExceededError(结构化,含用量与上限),绝不截断。 */
547
+ #assertBudget(/** @type {string} */ track, /** @type {string} */ scope, /** @type {number} */ used, /** @type {number} */ addition) {
548
+ const checked = checkBudget(used, this.limits[/** @type {'user'|'agent'} */ (track)][/** @type {'user-global'|'workspace'} */ (scope)], addition)
549
+ if (!checked.ok) {
550
+ const d = /** @type {{used: number, limit: number, needed: number}} */ (checked)
551
+ throw new BudgetExceededError({ track, scope, used: d.used, limit: d.limit, needed: d.needed })
552
+ }
553
+ }
554
+
555
+ /** 审批结果门:唯一放行是 allowed-once。 */
556
+ #assertOutcome(/** @type {string} */ outcome) {
557
+ if (outcome !== 'allowed-once') throw new WriteDeniedError(outcome)
558
+ }
559
+
560
+ /** @param {{signal?: AbortSignal}} write - {signal?}。 */
561
+ #throwIfAborted(write) {
562
+ write.signal?.throwIfAborted()
563
+ }
564
+
565
+ /** 写成功的审计行(outcome 携带真实裁决来源:审批传输标注策略,gate 传输标注 gate)。 */
566
+ #auditWrite(/** @type {string} */ action, /** @type {string} */ track, /** @type {string} */ scope, /** @type {MemoryEntry} */ entry, /** @type {MemoryWriteContext} */ write, /** @type {{outcome: string, source: 'approval'|'gate'}} */ via) {
567
+ this.store.auditAppend({
568
+ action,
569
+ track,
570
+ scope,
571
+ entryId: entry.id,
572
+ text: entry.text,
573
+ outcome: this.#outcomeLabel(via),
574
+ source: entry.source,
575
+ sessionId: write.agent.session?.id ?? null,
576
+ })
577
+ }
578
+
579
+ /** 写事件自适应派发(带上写方会话)。 */
580
+ #appendWriteEvent(/** @type {MemoryWriteContext} */ write, /** @type {string} */ type, /** @type {object} */ data) {
581
+ const sessionId = write.agent.session?.id ?? ''
582
+ maybeAppendSessionEvent(write.agent.session, type, { ...data, sessionId })
583
+ }
584
+
585
+ /** (track, scope) 用量与上限(工具结果回带,模型据此整合重试)。 */
586
+ #usage(/** @type {string} */ track, /** @type {string} */ scope) {
587
+ return { track, scope, used: this.store.usage(track, scope), limit: this.limits[/** @type {'user'|'agent'} */ (track)][/** @type {'user-global'|'workspace'} */ (scope)] }
588
+ }
589
+
590
+ /** @param {{agent?: {session?: MemorySessionLike | null} | null}} write - {agent}。 */
591
+ #workspaceKeyOf(write) {
592
+ return workspaceKeyOf(/** @type {string | undefined} */ (write.agent?.session?.header?.cwd))
593
+ }
594
+
595
+ /** @param {{agent?: {session?: MemorySessionLike | null} | null}} write - {agent}。 */
596
+ #agentKeyOf(write) {
597
+ return agentKeyOf(/** @type {string | undefined} */ (write.agent?.session?.header?.agentPreset))
598
+ }
599
+ }
600
+
601
+ /** 去重的 (track, scope) 组合(seed 预检用)。 */
602
+ function uniqueScopes(/** @type {Array<{track: string, scope: string}>} */ entries) {
603
+ const seen = new Set()
604
+ const result = []
605
+ for (const entry of entries) {
606
+ const key = `${entry.track}/${entry.scope}`
607
+ if (!seen.has(key)) {
608
+ seen.add(key)
609
+ result.push([entry.track, entry.scope])
610
+ }
611
+ }
612
+ return result
613
+ }
614
+
615
+ /** 工具结果里的固定错误形状(schema additionalProperties:false 需要显式字段)。 */
616
+ function toToolError(/** @type {unknown} */ error) {
617
+ if (error instanceof MemoryError) {
618
+ const { code, message, ...details } = error.toPublic()
619
+ return {
620
+ code,
621
+ message,
622
+ ...(details.outcome === undefined ? {} : { outcome: details.outcome }),
623
+ ...(details.used === undefined ? {} : { usage: { track: details.track, scope: details.scope, used: details.used, limit: details.limit } }),
624
+ ...(details.candidates === undefined ? {} : { candidates: details.candidates }),
625
+ ...(details.sample === undefined ? {} : { sample: details.sample }),
626
+ }
627
+ }
628
+ return { code: 'INTERNAL', message: error instanceof Error ? error.message : String(error) }
629
+ }
630
+
631
+ /** 记忆工具描述:内嵌 Save/Skip 行为指引(学 Hermes 官方 memory.md 清单)。en 为源文,zh 为对应译文。 */
632
+ const MEMORY_TOOL_DESCRIPTION = {
633
+ en: [
634
+ 'Read and write the bounded, layered, approval-gated cross-session memory store (dsh-memento).',
635
+ '',
636
+ 'Tracks: "user" holds facts about the user (preferences, communication style, landmines, corrections); "agent" holds environment facts, project conventions, lessons learned, and completed-work summaries. Layers: "user-global" applies to every workspace; "workspace" applies only to the current working directory.',
637
+ '',
638
+ 'Each track/layer pair has a hard character budget (shown in the session memory snapshot header). A write that would exceed it FAILS with a structured error carrying current usage and the limit — consolidate or remove entries, then retry. Never truncate or silently drop content.',
639
+ '',
640
+ 'SAVE: user preferences and corrections; environment facts and project conventions; lessons learned from mistakes; summaries of completed work; anything the user explicitly asks you to remember.',
641
+ 'SKIP: trivial or re-derivable facts; encyclopedia knowledge a fresh search can answer; large data dumps or logs; one-off file paths; content already available in the current workspace.',
642
+ '',
643
+ 'Writes (add/replace/remove/consolidate) require approval under the configured policy and are audited; reads (query) are free. replace/remove target an entry by a UNIQUE case-insensitive substring — an ambiguous match fails with the candidate list, so use a longer substring. consolidate merges 1..20 existing entries (unique substrings) into ONE new entry with a single approval and one atomic write — use it when a layer is over budget. Each session receives a frozen snapshot of current memory at startup; the snapshot never changes mid-session.',
644
+ ].join('\n'),
645
+ zh: [
646
+ '读写有界、分层、带审批门、可审计的跨会话记忆库(dsh-memento)。',
647
+ '',
648
+ '轨道:"user" 存用户相关事实(偏好、沟通风格、雷区、纠正);"agent" 存环境事实、项目约定、教训与已完成工作总结。层:"user-global" 对所有工作区生效;"workspace" 只对当前工作目录生效。',
649
+ '',
650
+ '每对轨道/层有硬字符预算(显示在会话记忆快照头部)。会超限的写入以结构化错误失败(携带当前用量与上限)——整合或删除条目后重试。绝不截断、绝不静默丢弃内容。',
651
+ '',
652
+ '应存(SAVE):用户偏好与纠正;环境事实与项目约定;犯错得到的教训;已完成工作总结;用户明确要求记住的内容。',
653
+ '应跳过(SKIP):琐碎或可再推导的事实;重新搜索即可回答的百科知识;大数据转储或日志;一次性文件路径;当前工作区已有的内容。',
654
+ '',
655
+ '写(add/replace/remove/consolidate)需按配置策略审批并落审计;读(query)免费。replace/remove 用唯一大小写不敏感子串定位——歧义时报候选清单,请用更长子串。consolidate 以一次审批 + 一次原子写把 1..20 条整合为一条——层超预算时使用。每个会话启动时获得当前记忆的冻结快照;会话内快照不变。',
656
+ ].join('\n'),
657
+ }
658
+
659
+ /** 记忆工具参数描述(双语)。 */
660
+ const MEMORY_TOOL_PARAMETERS = {
661
+ en: {
662
+ action: 'add = insert a new entry; replace = rewrite one existing entry; remove = delete one existing entry; consolidate = merge 1..20 existing entries into one new entry (single approval, atomic); query = substring search over existing entries.',
663
+ track: 'Memory track. Defaults to "user". user = facts about the user; agent = environment/project facts and conventions.',
664
+ scope: 'Layer. Defaults to "workspace". user-global applies to every workspace; workspace applies only to this working directory.',
665
+ text: 'add/replace: the exact entry text. query: case-insensitive substring filter.',
666
+ match: 'replace/remove: a UNIQUE case-insensitive substring of the existing entry to target.',
667
+ matches: 'consolidate: 1..20 UNIQUE case-insensitive substrings of the entries to merge into the new text.',
668
+ limit: 'query: maximum entries to return (default 20; hard-capped at 1000).',
669
+ },
670
+ zh: {
671
+ action: 'add = 新增一条;replace = 改写一条既有条目;remove = 删除一条既有条目;consolidate = 把 1..20 条既有条目整合为一条新条目(单次审批、原子执行);query = 对既有条目的子串检索。',
672
+ track: '记忆轨道。默认 "user"。user = 用户相关事实;agent = 环境/项目事实与约定。',
673
+ scope: '层。默认 "workspace"。user-global 对所有工作区生效;workspace 只对当前工作目录生效。',
674
+ text: 'add/replace:完整条目文本。query:大小写不敏感子串过滤。',
675
+ match: 'replace/remove:目标条目的唯一大小写不敏感子串。',
676
+ matches: 'consolidate:要并入新文本的 1..20 个唯一大小写不敏感子串。',
677
+ limit: 'query:最多返回条数(默认 20;硬钳 1000)。',
678
+ },
679
+ }
680
+
681
+ /**
682
+ * memory 工具定义(Consumer)。execute 尊重 exec.signal;领域失败返回
683
+ * ok:false + 结构化 error,基础设施失败才抛出(isError)。
684
+ * @param {MemoryService} service - ctx.memory。
685
+ * @param {'en'|'zh'} [language] - 'en' | 'zh'。
686
+ * @returns {object} 工具定义。
687
+ */
688
+ export function makeMemoryTool(service, language = 'en') {
689
+ const parameters = MEMORY_TOOL_PARAMETERS[language] ?? MEMORY_TOOL_PARAMETERS.en
690
+ return defineTool({
691
+ name: 'memory',
692
+ description: MEMORY_TOOL_DESCRIPTION[language] ?? MEMORY_TOOL_DESCRIPTION.en,
693
+ parameters: {
694
+ action: {
695
+ type: 'string',
696
+ required: true,
697
+ enum: ['add', 'replace', 'remove', 'consolidate', 'query'],
698
+ description: parameters.action,
699
+ },
700
+ track: {
701
+ type: 'string',
702
+ enum: ['user', 'agent'],
703
+ description: parameters.track,
704
+ },
705
+ scope: {
706
+ type: 'string',
707
+ enum: ['user-global', 'workspace'],
708
+ description: parameters.scope,
709
+ },
710
+ text: {
711
+ type: 'string',
712
+ description: parameters.text,
713
+ },
714
+ match: {
715
+ type: 'string',
716
+ description: parameters.match,
717
+ },
718
+ matches: {
719
+ type: 'array',
720
+ items: { type: 'string' },
721
+ description: parameters.matches,
722
+ },
723
+ limit: {
724
+ type: 'integer',
725
+ description: parameters.limit,
726
+ },
727
+ },
728
+ output: {
729
+ schema: {
730
+ type: 'object',
731
+ additionalProperties: false,
732
+ properties: {
733
+ action: { type: 'string', required: true, enum: ['add', 'replace', 'remove', 'consolidate', 'query'] },
734
+ ok: { type: 'boolean', required: true },
735
+ entry: {
736
+ type: 'object',
737
+ additionalProperties: false,
738
+ properties: {
739
+ id: { type: 'string', required: true },
740
+ track: { type: 'string', required: true },
741
+ scope: { type: 'string', required: true },
742
+ text: { type: 'string', required: true },
743
+ source: { type: 'string', required: true },
744
+ },
745
+ },
746
+ removed: {
747
+ type: 'array',
748
+ items: {
749
+ type: 'object',
750
+ additionalProperties: false,
751
+ properties: {
752
+ id: { type: 'string', required: true },
753
+ text: { type: 'string', required: true },
754
+ },
755
+ },
756
+ },
757
+ previous: {
758
+ type: 'object',
759
+ additionalProperties: false,
760
+ properties: {
761
+ id: { type: 'string', required: true },
762
+ text: { type: 'string', required: true },
763
+ },
764
+ },
765
+ entries: {
766
+ type: 'array',
767
+ items: {
768
+ type: 'object',
769
+ additionalProperties: false,
770
+ properties: {
771
+ id: { type: 'string', required: true },
772
+ track: { type: 'string', required: true },
773
+ scope: { type: 'string', required: true },
774
+ text: { type: 'string', required: true },
775
+ source: { type: 'string', required: true },
776
+ },
777
+ },
778
+ },
779
+ total: { type: 'integer' },
780
+ truncated: { type: 'boolean' },
781
+ usage: {
782
+ type: 'object',
783
+ additionalProperties: false,
784
+ properties: {
785
+ track: { type: 'string', required: true },
786
+ scope: { type: 'string', required: true },
787
+ used: { type: 'integer', required: true },
788
+ limit: { type: 'integer', required: true },
789
+ },
790
+ },
791
+ error: {
792
+ type: 'object',
793
+ additionalProperties: false,
794
+ properties: {
795
+ code: { type: 'string', required: true },
796
+ message: { type: 'string', required: true },
797
+ outcome: { type: 'string' },
798
+ usage: {
799
+ type: 'object',
800
+ additionalProperties: false,
801
+ properties: {
802
+ track: { type: 'string', required: true },
803
+ scope: { type: 'string', required: true },
804
+ used: { type: 'integer', required: true },
805
+ limit: { type: 'integer', required: true },
806
+ },
807
+ },
808
+ candidates: { type: 'integer' },
809
+ sample: { type: 'array', items: { type: 'string' } },
810
+ },
811
+ },
812
+ },
813
+ },
814
+ render: renderMemoryResult,
815
+ },
816
+ execute: /** @type {(args: any, exec: any) => Promise<any>} */ (async (args, exec) => {
817
+ exec.signal.throwIfAborted()
818
+ const write = {
819
+ agent: exec.agent,
820
+ ...(exec.callId === undefined ? {} : { callId: exec.callId }),
821
+ signal: exec.signal,
822
+ }
823
+ try {
824
+ switch (args.action) {
825
+ case 'query': {
826
+ const result = service.query(
827
+ {
828
+ ...(args.track === undefined ? {} : { track: args.track }),
829
+ ...(args.scope === undefined ? {} : { scope: args.scope }),
830
+ ...(args.text === undefined ? {} : { text: args.text }),
831
+ ...(args.limit === undefined ? {} : { limit: args.limit }),
832
+ },
833
+ { sessionId: exec.agent?.session?.id, session: exec.agent?.session },
834
+ )
835
+ return {
836
+ action: 'query',
837
+ ok: true,
838
+ entries: result.entries.map(publicEntry),
839
+ total: result.total,
840
+ truncated: result.truncated,
841
+ }
842
+ }
843
+ case 'add': {
844
+ const result = await service.add(
845
+ {
846
+ track: args.track ?? 'user',
847
+ scope: args.scope ?? 'workspace',
848
+ text: args.text,
849
+ source: 'memory-tool',
850
+ },
851
+ write,
852
+ )
853
+ return { action: 'add', ok: true, entry: publicEntry(result.entry), usage: result.usage }
854
+ }
855
+ case 'replace': {
856
+ const result = await service.replace(
857
+ {
858
+ track: args.track ?? 'user',
859
+ scope: args.scope ?? 'workspace',
860
+ match: args.match,
861
+ text: args.text,
862
+ source: 'memory-tool',
863
+ },
864
+ write,
865
+ )
866
+ return {
867
+ action: 'replace',
868
+ ok: true,
869
+ entry: publicEntry(result.entry),
870
+ previous: { id: result.previous.id, text: result.previous.text },
871
+ usage: result.usage,
872
+ }
873
+ }
874
+ case 'remove': {
875
+ const result = await service.remove(
876
+ {
877
+ track: args.track ?? 'user',
878
+ scope: args.scope ?? 'workspace',
879
+ match: args.match,
880
+ },
881
+ write,
882
+ )
883
+ return { action: 'remove', ok: true, entry: publicEntry(result.entry), usage: result.usage }
884
+ }
885
+ case 'consolidate': {
886
+ const result = await service.consolidate(
887
+ {
888
+ track: args.track ?? 'user',
889
+ scope: args.scope ?? 'workspace',
890
+ matches: args.matches,
891
+ text: args.text,
892
+ source: 'memory-tool',
893
+ },
894
+ write,
895
+ )
896
+ return {
897
+ action: 'consolidate',
898
+ ok: true,
899
+ entry: publicEntry(result.entry),
900
+ removed: result.removed.map((old) => ({ id: old.id, text: old.text })),
901
+ usage: result.usage,
902
+ }
903
+ }
904
+ default: {
905
+ throw new InvalidInputError(`unknown memory action ${JSON.stringify(args.action)}`)
906
+ }
907
+ }
908
+ } catch (error) {
909
+ if (error instanceof MemoryError) {
910
+ return { action: args.action, ok: false, error: toToolError(error) }
911
+ }
912
+ throw error
913
+ }
914
+ }),
915
+ })
916
+ }
917
+
918
+ /** 工具结果里的公开条目投影(只带声明过的字段)。 */
919
+ function publicEntry(/** @type {MemoryEntry} */ entry) {
920
+ return {
921
+ id: entry.id,
922
+ track: entry.track,
923
+ scope: entry.scope,
924
+ text: entry.text,
925
+ source: entry.source,
926
+ }
927
+ }
928
+
929
+ /**
930
+ * 工具结果渲染(纯函数)。
931
+ * @param {object} _args - 调用参数(未用)。
932
+ * @param {object} value - 规范 JSON 结果。
933
+ * @returns {Array<{type: 'text', text: string}>} 模型可见文本。
934
+ */
935
+ export function renderMemoryResult(/** @type {object} */ _args, /** @type {MemoryToolValue} */ value) {
936
+ if (!value.ok) {
937
+ return [{ type: 'text', text: `memory ${value.action} failed: ${value.error.message}` }]
938
+ }
939
+ switch (value.action) {
940
+ case 'query':
941
+ return [{
942
+ type: 'text',
943
+ text: value.entries.length === 0
944
+ ? 'memory query: no entries matched'
945
+ : `memory query: ${value.entries.length} match${value.entries.length === 1 ? '' : 'es'}${value.truncated ? ` (of ${value.total} total; refine the filter for more)` : ''}\n${value.entries.map((entry) => `- [${entry.track}/${entry.scope}] ${entry.text}`).join('\n')}`,
946
+ }]
947
+ case 'add':
948
+ return [{ type: 'text', text: `memory entry added (${value.entry.track}/${value.entry.scope}): ${value.entry.text}\nbudget: ${value.usage.used}/${value.usage.limit} chars used` }]
949
+ case 'replace':
950
+ return [{ type: 'text', text: `memory entry replaced (${value.entry.track}/${value.entry.scope}): ${value.entry.text}\nbudget: ${value.usage.used}/${value.usage.limit} chars used` }]
951
+ case 'remove':
952
+ return [{ type: 'text', text: `memory entry removed (${value.entry.track}/${value.entry.scope}): ${value.entry.text}\nbudget: ${value.usage.used}/${value.usage.limit} chars used` }]
953
+ case 'consolidate':
954
+ return [{ type: 'text', text: `memory entries consolidated (${value.entry.track}/${value.entry.scope}): ${value.removed.length} removed → ${value.entry.text}\nbudget: ${value.usage.used}/${value.usage.limit} chars used` }]
955
+ default:
956
+ return [{ type: 'text', text: `memory ${value.action}: ok` }]
957
+ }
958
+ }
959
+
960
+ /**
961
+ * 插件挂载。enabled:false 时不注册任何东西(工具/注入/服务/审批 answerer
962
+ * 整体消失,不留半残状态);库损坏/迁移失败/非法配置在加载期响亮抛错(S5)。
963
+ * 缺省字段在此显式补默认(与 Config schema 的默认值同源,DEFAULT_* 常量)。
964
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Cordis 上下文。
965
+ * @param {object} config - 插件配置(cordis loader 已套 schema 默认值)。
966
+ */
967
+ export function apply(ctx, /** @type {PluginConfig} */ config = {}) {
968
+ const resolved = {
969
+ enabled: config.enabled ?? true,
970
+ dbPath: config.dbPath ?? '',
971
+ budgets: {
972
+ user: {
973
+ userGlobal: config.budgets?.user?.userGlobal ?? DEFAULT_BUDGETS.user.userGlobal,
974
+ workspace: config.budgets?.user?.workspace ?? DEFAULT_BUDGETS.user.workspace,
975
+ },
976
+ agent: {
977
+ userGlobal: config.budgets?.agent?.userGlobal ?? DEFAULT_BUDGETS.agent.userGlobal,
978
+ workspace: config.budgets?.agent?.workspace ?? DEFAULT_BUDGETS.agent.workspace,
979
+ },
980
+ },
981
+ writePolicy: config.writePolicy ?? 'ask',
982
+ writePolicies: config.writePolicies ?? {},
983
+ language: config.language ?? 'en',
984
+ snapshotOrder: config.snapshotOrder ?? DEFAULT_SNAPSHOT_ORDER,
985
+ maxEntriesPerQuery: config.maxEntriesPerQuery ?? 20,
986
+ commandListLimit: config.commandListLimit ?? 50,
987
+ commandAuditLimit: config.commandAuditLimit ?? 10,
988
+ recall: {
989
+ historyLimitDefault: config.recall?.historyLimitDefault ?? 8,
990
+ snippetCap: config.recall?.snippetCap ?? 5,
991
+ snippetChars: config.recall?.snippetChars ?? 300,
992
+ windowDays: config.recall?.windowDays ?? 30,
993
+ },
994
+ panelEntriesLimit: config.panelEntriesLimit ?? 200,
995
+ panelAuditLimit: config.panelAuditLimit ?? 20,
996
+ auditRetentionDays: config.auditRetentionDays ?? 0,
997
+ proposals: {
998
+ enabled: config.proposals?.enabled ?? true,
999
+ maxChars: config.proposals?.maxChars ?? 2000,
1000
+ maxPending: config.proposals?.maxPending ?? 8,
1001
+ },
1002
+ }
1003
+ if (resolved.enabled === false) return
1004
+ const budgetCheck = validateBudgets(resolved.budgets)
1005
+ if (!budgetCheck.ok) throw new InvalidInputError(`dsh-memento config: ${/** @type {{message: string}} */ (budgetCheck).message}`)
1006
+ normalizeWritePolicy(resolved.writePolicy)
1007
+ validateWritePolicies(resolved.writePolicies)
1008
+ if (resolved.language !== 'en' && resolved.language !== 'zh') {
1009
+ throw new InvalidInputError(`dsh-memento config: language must be 'en' or 'zh' (got ${JSON.stringify(resolved.language)})`)
1010
+ }
1011
+ if (!Number.isFinite(resolved.snapshotOrder)) {
1012
+ throw new InvalidInputError('dsh-memento config: snapshotOrder must be a finite number')
1013
+ }
1014
+ if (!Number.isInteger(resolved.maxEntriesPerQuery) || resolved.maxEntriesPerQuery <= 0) {
1015
+ throw new InvalidInputError('dsh-memento config: maxEntriesPerQuery must be a positive integer')
1016
+ }
1017
+ if (!Number.isInteger(resolved.commandListLimit) || resolved.commandListLimit <= 0) {
1018
+ throw new InvalidInputError('dsh-memento config: commandListLimit must be a positive integer')
1019
+ }
1020
+ if (!Number.isInteger(resolved.commandAuditLimit) || resolved.commandAuditLimit <= 0) {
1021
+ throw new InvalidInputError('dsh-memento config: commandAuditLimit must be a positive integer')
1022
+ }
1023
+ for (const [key, value] of Object.entries(resolved.recall)) {
1024
+ if (!Number.isInteger(value) || value <= 0) {
1025
+ throw new InvalidInputError(`dsh-memento config: recall.${key} must be a positive integer`)
1026
+ }
1027
+ }
1028
+ if (!Number.isInteger(resolved.panelEntriesLimit) || resolved.panelEntriesLimit <= 0) {
1029
+ throw new InvalidInputError('dsh-memento config: panelEntriesLimit must be a positive integer')
1030
+ }
1031
+ if (!Number.isInteger(resolved.panelAuditLimit) || resolved.panelAuditLimit <= 0) {
1032
+ throw new InvalidInputError('dsh-memento config: panelAuditLimit must be a positive integer')
1033
+ }
1034
+ if (!Number.isInteger(resolved.auditRetentionDays) || resolved.auditRetentionDays < 0) {
1035
+ throw new InvalidInputError('dsh-memento config: auditRetentionDays must be a non-negative integer')
1036
+ }
1037
+ if (!Number.isInteger(resolved.proposals.maxChars) || resolved.proposals.maxChars <= 0) {
1038
+ throw new InvalidInputError('dsh-memento config: proposals.maxChars must be a positive integer')
1039
+ }
1040
+ if (!Number.isInteger(resolved.proposals.maxPending) || resolved.proposals.maxPending <= 0) {
1041
+ throw new InvalidInputError('dsh-memento config: proposals.maxPending must be a positive integer')
1042
+ }
1043
+ const dbPath = resolveDbPath(resolved.dbPath)
1044
+ const store = openMemoryStore(dbPath, { retentionDays: resolved.auditRetentionDays })
1045
+ const service = new MemoryService({
1046
+ store,
1047
+ budgets: resolved.budgets,
1048
+ writePolicy: resolved.writePolicy,
1049
+ maxEntriesPerQuery: resolved.maxEntriesPerQuery,
1050
+ commandListLimit: resolved.commandListLimit,
1051
+ commandAuditLimit: resolved.commandAuditLimit,
1052
+ language: resolved.language,
1053
+ approval: ctx.approval,
1054
+ sourceLabel: DEFAULT_SOURCE,
1055
+ })
1056
+
1057
+ ctx.provide('memory', service)
1058
+ ctx.effect(() => () => store.close(), 'dsh-memento.store.close')
1059
+
1060
+ // 审批 answerer:认领本插件的记忆写请求并按粒度策略裁决(writePolicies 精确键 >
1061
+ // track/scope > 全局 writePolicy;prepend 保证 auto/off 的确定性先于 UI answerer;
1062
+ // 会话级 never 策略在审批服务内部先裁决,任何 answerer 都无法绕过)。
1063
+ ctx.on('approval/request', async function answerer(req, next) {
1064
+ if (!isMemoryWriteRequest(req)) return next()
1065
+ const parsed = parseWriteReason(/** @type {string} */ (/** @type {{reason: string}} */ (req).reason))
1066
+ const effective = parsed === null
1067
+ ? resolved.writePolicy
1068
+ : resolveWritePolicy(resolved.writePolicies, resolved.writePolicy, parsed.track, parsed.scope, parsed.source)
1069
+ return applyWritePolicy(effective, req, next)
1070
+ }, { prepend: true })
1071
+
1072
+ ctx.tools.register(/** @type {import('@deepseek-ai/dsh-tools').ToolDefinition} */ (makeMemoryTool(service, resolved.language)))
1073
+
1074
+ // 冻结快照注入:会话首个 assemble 时同步读库渲染,WeakMap 按 Session 冻结。
1075
+ // 提供者必须同步(rc.6 不 await systemPrompt 提供者),SQLite 同步读满足。
1076
+ // 渲染文本同时进入 request/header(system 字段)→ 可自会话日志重建(S2)。
1077
+ const snapshots = new WeakMap()
1078
+ ctx.systemPrompt.section({
1079
+ name: 'dsh-memento:memory',
1080
+ order: resolved.snapshotOrder,
1081
+ text: (assemble) => {
1082
+ // rc.6 实测路径:assemble 携带 agent(AssembleContext 声明面未含该字段),收窄处理。
1083
+ const context = /** @type {{agent?: {session?: MemorySessionLike | null} | null} | null | undefined} */ (assemble)
1084
+ const agent = context?.agent
1085
+ const session = agent?.session
1086
+ if (session === undefined || session === null) return ''
1087
+ let frozen = snapshots.get(session)
1088
+ if (frozen === undefined) {
1089
+ const workspaceKey = workspaceKeyOf(/** @type {string | undefined} */ (session.header?.cwd))
1090
+ const agentKey = agentKeyOf(/** @type {string | undefined} */ (session.header?.agentPreset))
1091
+ const entries = visibleEntries(
1092
+ /** @type {Array<{id: string, track: string, scope: string, workspaceKey: string, agentKey: string, text: string, createdAt: number}>} */ (store.listEntries()),
1093
+ workspaceKey,
1094
+ agentKey,
1095
+ )
1096
+ const proposals = visibleProposals(
1097
+ /** @type {MemoryProposal[]} */ (store.proposalList('pending', resolved.proposals.maxPending)),
1098
+ workspaceKey,
1099
+ agentKey,
1100
+ )
1101
+ frozen = renderSnapshot(entries, resolved.budgets, proposals, resolved.language)
1102
+ snapshots.set(session, frozen)
1103
+ store.auditAppend({
1104
+ action: 'snapshot',
1105
+ track: null,
1106
+ scope: null,
1107
+ entryId: null,
1108
+ text: frozen,
1109
+ outcome: 'ok',
1110
+ source: DEFAULT_SOURCE,
1111
+ sessionId: /** @type {string | null} */ (session.id ?? null),
1112
+ })
1113
+ maybeAppendSessionEvent(session, SESSION_EVENTS.snapshot, {
1114
+ text: frozen,
1115
+ workspaceKey,
1116
+ at: Date.now(),
1117
+ })
1118
+ }
1119
+ return frozen
1120
+ },
1121
+ })
1122
+
1123
+ // V2 观察面:/memory 命令(用户触发)、memory_recall 工具、面板 JSON 路由。
1124
+ // commands/webServer 为可选服务,缺失(headless)自动跳过。
1125
+ registerCommands(ctx, service)
1126
+ ctx.tools.register(/** @type {import('@deepseek-ai/dsh-tools').ToolDefinition} */ (makeMemoryRecallTool(service, ctx, resolved.recall, resolved.language)))
1127
+ registerWebRoutes(ctx, service, resolved)
1128
+
1129
+ // auto-capture:监听会话事件火线,压缩结束后生成记忆提案(只落提案,不写记忆、不调模型)。
1130
+ const summaries = new WeakMap()
1131
+ ctx.on('session/event', (session, event) => {
1132
+ handleSessionEvent(store, session, event, resolved.proposals, summaries)
1133
+ })
1134
+ }
1135
+
1136
+ /**
1137
+ * auto-capture 提案生成:缓存每会话最近的 compaction/summary 文本,compaction/end
1138
+ * 成功时截断落 proposals 表((session_id, kind) 幂等;pending 满则弃新)。
1139
+ * 本函数不调用任何模型、不写任何记忆条目、不触碰审批 seam——提案是待审批数据。
1140
+ * @param {StoreHandle} store - Provider。
1141
+ * @param {MemorySessionLike | null | undefined} session - 会话。
1142
+ * @param {unknown} event - 会话事件({type, data})。
1143
+ * @param {{enabled: boolean, maxChars: number, maxPending: number}} proposals - Config.proposals。
1144
+ * @param {WeakMap<object, string>} summaries - 会话 → 最近 summary 文本缓存。
1145
+ */
1146
+ function handleSessionEvent(store, session, event, proposals, summaries) {
1147
+ if (!proposals.enabled || session === null || session === undefined) return
1148
+ if (event === null || typeof event !== 'object') return
1149
+ const record = /** @type {{type?: unknown, data?: unknown}} */ (event)
1150
+ if (record.type === 'compaction/summary') {
1151
+ const text = extractEventText(event)
1152
+ if (text.length > 0) summaries.set(session, text)
1153
+ return
1154
+ }
1155
+ if (record.type !== 'compaction/end') return
1156
+ const data = record.data
1157
+ const error = data !== null && typeof data === 'object' ? /** @type {{error?: unknown}} */ (data).error : undefined
1158
+ if (typeof error === 'string' && error.length > 0) return
1159
+ const text = summaries.get(session)
1160
+ summaries.delete(session)
1161
+ if (typeof text !== 'string' || text.length === 0) return
1162
+ const pending = /** @type {MemoryProposal[]} */ (store.proposalList('pending', proposals.maxPending))
1163
+ if (pending.length >= proposals.maxPending) return // 满则弃新
1164
+ const truncated = text.length > proposals.maxChars ? text.slice(0, proposals.maxChars) : text
1165
+ const proposal = store.proposalUpsert({
1166
+ kind: 'compaction-summary',
1167
+ track: 'agent',
1168
+ scope: 'workspace',
1169
+ workspaceKey: workspaceKeyOf(/** @type {string | undefined} */ (session.header?.cwd)),
1170
+ agentKey: agentKeyOf(/** @type {string | undefined} */ (session.header?.agentPreset)),
1171
+ text: truncated,
1172
+ source: 'compaction',
1173
+ sessionId: typeof session.id === 'string' ? session.id : '',
1174
+ })
1175
+ if (proposal === null) return // 同 session 已提案(幂等)
1176
+ const created = /** @type {MemoryProposal} */ (proposal)
1177
+ store.auditAppend({
1178
+ action: 'proposal',
1179
+ track: 'agent',
1180
+ scope: 'workspace',
1181
+ entryId: created.id,
1182
+ text: truncated,
1183
+ outcome: 'pending',
1184
+ source: 'compaction',
1185
+ sessionId: typeof session.id === 'string' ? session.id : null,
1186
+ })
1187
+ }
1188
+
1189
+ // ── V2 观察面 ────────────────────────────────────────────────────────────────
1190
+
1191
+ /**
1192
+ * 可选服务就绪即调用(服务缺失时跳过,不保持 PENDING)。apply 时已存在则
1193
+ * 立即调用;否则订阅 internal/service 事件,服务出现时再调用。
1194
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Cordis 上下文。
1195
+ * @param {string} serviceName - 服务名。
1196
+ * @param {(service: unknown) => void} fn - 就绪回调。
1197
+ */function withService(ctx, serviceName, fn) {
1198
+ const existing = ctx.get(serviceName)
1199
+ if (existing !== undefined && existing !== null) {
1200
+ fn(existing)
1201
+ return
1202
+ }
1203
+ const off = ctx.on('internal/service', (name) => {
1204
+ if (name !== serviceName) return
1205
+ const service = ctx.get(serviceName)
1206
+ if (service !== undefined && service !== null) {
1207
+ off()
1208
+ fn(service)
1209
+ }
1210
+ })
1211
+ }
1212
+
1213
+ /**
1214
+ * turn 外的写审批门(/memory 命令用)。走同一 approval/request waterfall 与
1215
+ * 同一 answerer 链(writePolicy 在此应用);与 turn 内路径的差异:审批服务
1216
+ * 的 approval/asked + approval/decided 审计对要求 open turn,turn 外无审计
1217
+ * 对可落——审计由插件审计表 + command/done 承担。会话级 never 策略在派发前
1218
+ * 由本函数按公开 API 预检(与审批服务同语义)。
1219
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Cordis 上下文。
1220
+ * @param {{agent?: {session?: MemorySessionLike | null} | null}} write - {agent}。
1221
+ * @returns {(payload: WritePayload) => Promise<string>} gate 函数。
1222
+ */
1223
+ function makeCommandGate(ctx, write) {
1224
+ return async (payload) => {
1225
+ const approval = ctx.approval
1226
+ const session = write.agent?.session
1227
+ const sessionPolicy = typeof approval?.overrideOf === 'function' && session !== undefined
1228
+ ? approval.overrideOf(session)
1229
+ : undefined
1230
+ const effective = sessionPolicy ?? approval?.config?.policy ?? 'ask'
1231
+ if (effective === 'never') {
1232
+ return 'rejected' // 会话级 never 不可绕过(与审批服务同语义的预检)
1233
+ }
1234
+ return ctx.waterfall('approval/request', {
1235
+ agent: write.agent,
1236
+ toolName: TOOL_NAME,
1237
+ reason: buildWriteReason(payload),
1238
+ }, async () => 'unavailable')
1239
+ }
1240
+ }
1241
+
1242
+ /**
1243
+ * /memory 命令文案(en 源文 / zh 译文;service.language 选择)。
1244
+ * @typedef {object} CommandTextBundle
1245
+ * @property {string} usage
1246
+ * @property {string} memoryEmpty
1247
+ * @property {(total: number, shown: number) => string} entries
1248
+ * @property {(total: number) => string} entriesFull
1249
+ * @property {string} queryNeedsWord
1250
+ * @property {(text: string) => string} noMatch
1251
+ * @property {(total: number, shown: number) => string} matches
1252
+ * @property {(total: number) => string} matchesFull
1253
+ * @property {string} budgets
1254
+ * @property {string} proposalsNone
1255
+ * @property {(n: number, rows: string) => string} proposalsList
1256
+ * @property {string} proposalsUsage
1257
+ * @property {(id: string) => string} proposalNotPending
1258
+ * @property {(track: string, scope: string, text: string, used: number, limit: number) => string} proposalApproved
1259
+ * @property {(id: string) => string} proposalDismissed
1260
+ * @property {string} auditEmpty
1261
+ * @property {(n: number) => string} audit
1262
+ * @property {string} addNeedsText
1263
+ * @property {(track: string, scope: string, text: string, used: number, limit: number) => string} added
1264
+ * @property {string} removeNeedsSubstring
1265
+ * @property {(track: string, scope: string, text: string, used: number, limit: number) => string} removed
1266
+ * @property {string} consolidateUsage
1267
+ * @property {string} consolidateNeedsMatches
1268
+ * @property {string} consolidateNeedsText
1269
+ * @property {(track: string, scope: string, removed: number, text: string, used: number, limit: number) => string} consolidated
1270
+ * @property {string} exportUsage
1271
+ * @property {(verb: string) => string} unknownVerb
1272
+ * @property {(message: string) => string} commandFailed
1273
+ */
1274
+ const COMMAND_TEXT = /** @type {{en: CommandTextBundle, zh: CommandTextBundle}} */ ({
1275
+ en: {
1276
+ usage: 'Usage: /memory list | query <word> | add <text> | remove <substring> | consolidate <substring...> => <new text> | proposals [approve|dismiss <id>] | budgets | audit | export',
1277
+ memoryEmpty: 'Memory is empty.',
1278
+ entries: (total, shown) => `Memory entries (${total} total, showing first ${shown}):`,
1279
+ entriesFull: (total) => `Memory entries (${total}):`,
1280
+ queryNeedsWord: 'query needs a keyword: /memory query <word>',
1281
+ noMatch: (text) => `No entry contains "${text}".`,
1282
+ matches: (total, shown) => `Matches (${total} total, showing first ${shown}):`,
1283
+ matchesFull: (total) => `Matches (${total}):`,
1284
+ budgets: 'Budget usage:',
1285
+ proposalsNone: 'No pending memory proposals.',
1286
+ proposalsList: (n, rows) => `Pending proposals (${n}):\n${rows}\nApprove: /memory proposals approve <id>; dismiss: /memory proposals dismiss <id>`,
1287
+ proposalsUsage: 'proposals usage: /memory proposals | proposals approve <id> | proposals dismiss <id>',
1288
+ proposalNotPending: (id) => `proposal ${JSON.stringify(id)} is not a pending proposal (decided or missing)`,
1289
+ proposalApproved: (track, scope, text, used, limit) => `Proposal approved and written to memory (${track}/${scope}): ${text}\nLayer usage: ${used}/${limit}`,
1290
+ proposalDismissed: (id) => `Proposal ${id} dismissed.`,
1291
+ auditEmpty: 'Audit is empty.',
1292
+ audit: (n) => `Recent audit (${n} rows):`,
1293
+ addNeedsText: 'add needs text: /memory add [--track=user|agent] [--scope=user-global|workspace] <text>',
1294
+ added: (track, scope, text, used, limit) => `Added (${track}/${scope}): ${text}\nLayer usage: ${used}/${limit}`,
1295
+ removeNeedsSubstring: 'remove needs a unique substring: /memory remove [--track=user|agent] [--scope=user-global|workspace] <substring>',
1296
+ removed: (track, scope, text, used, limit) => `Removed (${track}/${scope}): ${text}\nLayer usage: ${used}/${limit}`,
1297
+ consolidateUsage: 'consolidate usage: /memory consolidate [--track=user|agent] [--scope=user-global|workspace] <substring1> [<substring2> ...] => <new text>',
1298
+ consolidateNeedsMatches: 'consolidate needs 1..20 unique substrings (left of =>)',
1299
+ consolidateNeedsText: 'consolidate needs new text (right of =>)',
1300
+ consolidated: (track, scope, removed, text, used, limit) => `Consolidated (${track}/${scope}): removed ${removed}, added 1.\nNew entry: ${text}\nLayer usage: ${used}/${limit}`,
1301
+ exportUsage: 'export dumps all entries + budgets as one JSON document (read-only; redirect it to a file for backup/migration): /memory export',
1302
+ unknownVerb: (verb) => `Unknown subcommand "${verb}". Usage: /memory list | query <word> | add <text> | remove <substring> | consolidate <substring...> => <new text> | proposals [approve|dismiss <id>] | budgets | audit | export`,
1303
+ commandFailed: (message) => `memory command failed: ${message}`,
1304
+ },
1305
+ zh: {
1306
+ usage: '用法:/memory list | query <词> | add <文本> | remove <唯一子串> | consolidate <唯一子串...> => <新文本> | proposals [approve|dismiss <id>] | budgets | audit | export',
1307
+ memoryEmpty: '记忆为空。',
1308
+ entries: (total, shown) => `记忆条目(共 ${total} 条,显示前 ${shown} 条):`,
1309
+ entriesFull: (total) => `记忆条目(${total} 条):`,
1310
+ queryNeedsWord: 'query 需要一个关键词:/memory query <词>',
1311
+ noMatch: (text) => `没有条目包含「${text}」。`,
1312
+ matches: (total, shown) => `命中(共 ${total} 条,显示前 ${shown} 条):`,
1313
+ matchesFull: (total) => `命中(${total} 条):`,
1314
+ budgets: '预算用量:',
1315
+ proposalsNone: '暂无待审批记忆提案。',
1316
+ proposalsList: (n, rows) => `待审批提案(${n} 条):\n${rows}\n审批:/memory proposals approve <id>;驳回:/memory proposals dismiss <id>`,
1317
+ proposalsUsage: 'proposals 用法:/memory proposals | proposals approve <id> | proposals dismiss <id>',
1318
+ proposalNotPending: (id) => `proposal ${JSON.stringify(id)} 不是待审批提案(可能已裁决或不存在)`,
1319
+ proposalApproved: (track, scope, text, used, limit) => `已批准提案并写入记忆(${track}/${scope}):${text}\n该层用量:${used}/${limit}`,
1320
+ proposalDismissed: (id) => `已驳回提案 ${id}。`,
1321
+ auditEmpty: '审计为空。',
1322
+ audit: (n) => `最近审计(${n} 条):`,
1323
+ addNeedsText: 'add 需要文本:/memory add [--track=user|agent] [--scope=user-global|workspace] <文本>',
1324
+ added: (track, scope, text, used, limit) => `已添加(${track}/${scope}):${text}\n该层用量:${used}/${limit}`,
1325
+ removeNeedsSubstring: 'remove 需要一个唯一子串:/memory remove [--track=user|agent] [--scope=user-global|workspace] <唯一子串>',
1326
+ removed: (track, scope, text, used, limit) => `已删除(${track}/${scope}):${text}\n该层用量:${used}/${limit}`,
1327
+ consolidateUsage: 'consolidate 用法:/memory consolidate [--track=user|agent] [--scope=user-global|workspace] <唯一子串1> [<唯一子串2> ...] => <新文本>',
1328
+ consolidateNeedsMatches: 'consolidate 需要 1..20 个唯一子串(=> 左侧)',
1329
+ consolidateNeedsText: 'consolidate 需要新文本(=> 右侧)',
1330
+ consolidated: (track, scope, removed, text, used, limit) => `已整合(${track}/${scope}):删除 ${removed} 条,新增 1 条。\n新条目:${text}\n该层用量:${used}/${limit}`,
1331
+ exportUsage: 'export 把所有条目 + 预算导出为一份 JSON 文档(只读;可重定向到文件做备份/迁移):/memory export',
1332
+ unknownVerb: (verb) => `未知子命令「${verb}」。用法:/memory list | query <词> | add <文本> | remove <唯一子串> | consolidate <唯一子串...> => <新文本> | proposals [approve|dismiss <id>] | budgets | audit | export`,
1333
+ commandFailed: (message) => `memory 命令失败:${message}`,
1334
+ },
1335
+ })
1336
+
1337
+ /** 命令注册描述与输入提示(双语)。 */
1338
+ const COMMAND_DESCRIPTION = /** @type {{en: {description: string, hint: string}, zh: {description: string, hint: string}}} */ ({
1339
+ en: {
1340
+ description: 'View/manage dsh-memento memory: list | query <word> | add [--track=user|agent] [--scope=user-global|workspace] <text> | remove <substring> | consolidate <substring...> => <new text> | proposals [approve|dismiss <id>] | budgets | audit | export',
1341
+ hint: 'list | query <word> | add <text> | remove <substring> | consolidate <substring...> => <new text> | proposals [approve|dismiss <id>] | budgets | audit | export',
1342
+ },
1343
+ zh: {
1344
+ description: '查看/管理 dsh-memento 记忆:list | query <词> | add [--track=user|agent] [--scope=user-global|workspace] <文本> | remove <唯一子串> | consolidate <唯一子串...> => <新文本> | proposals [approve|dismiss <id>] | budgets | audit | export',
1345
+ hint: 'list | query <词> | add <文本> | remove <唯一子串> | consolidate <唯一子串...> => <新文本> | proposals [approve|dismiss <id>] | budgets | audit | export',
1346
+ },
1347
+ })
1348
+
1349
+ /**
1350
+ * 注册 /memory 命令(用户触发,非模型回合)。列出/查询/预算/审计/导出直接读;
1351
+ * add/remove/consolidate 走 turn 外审批门(同一 waterfall + writePolicy)。命令缺失的
1352
+ * profile(headless)自动跳过。
1353
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Cordis 上下文。
1354
+ * @param {MemoryService} service - ctx.memory。
1355
+ */
1356
+ export function registerCommands(ctx, service) {
1357
+ withService(ctx, 'commands', (/** @type {{register?: (def: object) => unknown} | null | undefined} */ commands) => {
1358
+ if (typeof commands?.register !== 'function') return
1359
+ const meta = COMMAND_DESCRIPTION[service.language] ?? COMMAND_DESCRIPTION.en
1360
+ commands.register({
1361
+ name: 'memory',
1362
+ description: meta.description,
1363
+ input: { hint: meta.hint },
1364
+ handler: async (/** @type {{rawInput?: unknown, agent?: unknown, signal?: AbortSignal}} */ invocation) => handleMemoryCommand(ctx, service, invocation),
1365
+ })
1366
+ })
1367
+ }
1368
+
1369
+ /**
1370
+ * /memory 命令处理器(导出供测试;自身捕获领域错误,返回规范结果)。
1371
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Cordis 上下文。
1372
+ * @param {MemoryService} service - ctx.memory。
1373
+ * @param {object} invocation - {rawInput, agent, signal}。
1374
+ * @returns {Promise<{kind: 'success'|'error', text: string}>}。
1375
+ */
1376
+ export async function handleMemoryCommand(ctx, service, /** @type {{rawInput?: unknown, agent?: {session?: MemorySessionLike | null} | null, signal?: AbortSignal}} */ invocation) {
1377
+ try {
1378
+ return await runMemoryCommand(ctx, service, invocation)
1379
+ } catch (error) {
1380
+ const text = COMMAND_TEXT[service.language] ?? COMMAND_TEXT.en
1381
+ if (error instanceof MemoryError) return { kind: 'error', text: `memory ${String(error.code)}: ${error.message}` }
1382
+ const message = error instanceof Error ? error.message : String(error)
1383
+ return { kind: 'error', text: text.commandFailed(message) }
1384
+ }
1385
+ }
1386
+
1387
+ /**
1388
+ * handleMemoryCommand 的裸实现(错误由外层包装)。
1389
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Cordis 上下文。
1390
+ * @param {MemoryService} service - ctx.memory。
1391
+ * @param {{rawInput?: unknown, agent?: {session?: MemorySessionLike | null} | null, signal?: AbortSignal}} invocation - {rawInput, agent, signal}。
1392
+ * @returns {Promise<{kind: 'success' | 'error', text: string}>}。
1393
+ */
1394
+ async function runMemoryCommand(ctx, service, invocation) {
1395
+ const text = COMMAND_TEXT[service.language] ?? COMMAND_TEXT.en
1396
+ const raw = String(invocation?.rawInput ?? '').trim()
1397
+ const [verb, ...rest] = raw.split(/\s+/)
1398
+ if (verb === undefined || verb.length === 0) {
1399
+ return { kind: 'success', text: text.usage }
1400
+ }
1401
+ switch (verb) {
1402
+ case 'list': {
1403
+ const { entries, total, truncated } = service.query(
1404
+ { limit: service.commandListLimit },
1405
+ { sessionId: /** @type {string | undefined} */ (invocation?.agent?.session?.id), session: invocation?.agent?.session },
1406
+ )
1407
+ if (total === 0) return { kind: 'success', text: text.memoryEmpty }
1408
+ const header = truncated ? text.entries(total, entries.length) : text.entriesFull(total)
1409
+ return { kind: 'success', text: `${header}\n${entries.map(renderEntryLine).join('\n')}` }
1410
+ }
1411
+ case 'query': {
1412
+ const query = rest.join(' ')
1413
+ if (query.length === 0) return { kind: 'error', text: text.queryNeedsWord }
1414
+ const { entries, total, truncated } = service.query(
1415
+ { text: query, limit: service.commandListLimit },
1416
+ { sessionId: /** @type {string | undefined} */ (invocation?.agent?.session?.id), session: invocation?.agent?.session },
1417
+ )
1418
+ if (total === 0) return { kind: 'success', text: text.noMatch(query) }
1419
+ const header = truncated ? text.matches(total, entries.length) : text.matchesFull(total)
1420
+ return { kind: 'success', text: `${header}\n${entries.map(renderEntryLine).join('\n')}` }
1421
+ }
1422
+ case 'budgets': {
1423
+ const rows = service.budgets()
1424
+ return { kind: 'success', text: `${text.budgets}\n${rows.map((/** @type {{track: string, scope: string, used: number, limit: number}} */ row) => `- ${row.track}/${row.scope}: ${row.used}/${row.limit}`).join('\n')}` }
1425
+ }
1426
+ case 'proposals': {
1427
+ const [sub, id] = rest
1428
+ if (sub === undefined) {
1429
+ const rows = /** @type {MemoryProposal[]} */ (service.store.proposalList('pending', 50))
1430
+ if (rows.length === 0) return { kind: 'success', text: text.proposalsNone }
1431
+ const lines = rows.map((proposal) => `- [${proposal.id}] ${proposal.track}/${proposal.scope}: ${proposal.text.length > 120 ? `${proposal.text.slice(0, 120)}…` : proposal.text}`).join('\n')
1432
+ return { kind: 'success', text: text.proposalsList(rows.length, lines) }
1433
+ }
1434
+ if (id === undefined) return { kind: 'error', text: text.proposalsUsage }
1435
+ if (sub === 'approve') {
1436
+ const proposal = /** @type {MemoryProposal | null} */ (service.store.proposalList('pending', 1000).find((/** @type {MemoryProposal} */ candidate) => candidate.id === id) ?? null)
1437
+ if (proposal === null) {
1438
+ return { kind: 'error', text: text.proposalNotPending(id) }
1439
+ }
1440
+ const result = await service.add(
1441
+ { track: proposal.track, scope: proposal.scope, text: proposal.text, source: 'proposal', workspaceKey: proposal.workspaceKey, agentKey: proposal.agentKey },
1442
+ { agent: invocation?.agent, gate: makeCommandGate(ctx, invocation) },
1443
+ )
1444
+ service.store.proposalDecide(id, 'approved')
1445
+ return { kind: 'success', text: text.proposalApproved(proposal.track, proposal.scope, result.entry.text, result.usage.used, result.usage.limit) }
1446
+ }
1447
+ if (sub === 'dismiss') {
1448
+ service.store.proposalDecide(id, 'dismissed')
1449
+ return { kind: 'success', text: text.proposalDismissed(id) }
1450
+ }
1451
+ return { kind: 'error', text: text.proposalsUsage }
1452
+ }
1453
+ case 'audit': {
1454
+ const rows = service.store.auditList(service.commandAuditLimit)
1455
+ if (rows.length === 0) return { kind: 'success', text: text.auditEmpty }
1456
+ return { kind: 'success', text: `${text.audit(rows.length)}\n${rows.map((/** @type {{ts: number, action: string, track?: string | null, scope?: string | null, outcome?: string | null, source?: string | null}} */ row) => `- ${new Date(row.ts).toISOString()} ${row.action}${row.track ? ` ${row.track}/${row.scope}` : ''} ${row.outcome ?? ''} (${row.source ?? ''})`.trim()).join('\n')}` }
1457
+ }
1458
+ case 'export': {
1459
+ if (rest.length > 0) return { kind: 'error', text: text.exportUsage }
1460
+ const entries = service.store.listEntries()
1461
+ const payload = {
1462
+ plugin: 'dsh-memento',
1463
+ schema: 'memory-export-v1',
1464
+ exportedAt: new Date().toISOString(),
1465
+ budgets: service.budgets(),
1466
+ entries: entries.map((/** @type {MemoryEntry} */ entry) => ({
1467
+ id: entry.id,
1468
+ track: entry.track,
1469
+ scope: entry.scope,
1470
+ workspaceKey: entry.workspaceKey,
1471
+ agentKey: entry.agentKey,
1472
+ text: entry.text,
1473
+ source: entry.source,
1474
+ createdAt: entry.createdAt,
1475
+ updatedAt: entry.updatedAt,
1476
+ lastRecalled: entry.lastRecalled,
1477
+ recallCount: entry.recallCount,
1478
+ })),
1479
+ }
1480
+ return { kind: 'success', text: JSON.stringify(payload, null, 2) }
1481
+ }
1482
+ case 'add': {
1483
+ const parsed = parseCommandWrite(rest, true)
1484
+ if (parsed.kind === 'error') {
1485
+ return { kind: 'error', text: text.addNeedsText }
1486
+ }
1487
+ const write = { agent: invocation?.agent, gate: makeCommandGate(ctx, invocation) }
1488
+ const result = await service.add(
1489
+ { track: parsed.track, scope: parsed.scope, text: parsed.text, source: 'command' },
1490
+ write,
1491
+ )
1492
+ return { kind: 'success', text: text.added(parsed.track, parsed.scope, result.entry.text, result.usage.used, result.usage.limit) }
1493
+ }
1494
+ case 'remove': {
1495
+ const parsed = parseCommandWrite(rest, true)
1496
+ if (parsed.kind === 'error') return { kind: 'error', text: text.removeNeedsSubstring }
1497
+ const result = await service.remove(
1498
+ { track: parsed.track, scope: parsed.scope, match: parsed.text },
1499
+ { agent: invocation?.agent, gate: makeCommandGate(ctx, invocation) },
1500
+ )
1501
+ return { kind: 'success', text: text.removed(parsed.track, parsed.scope, result.entry.text, result.usage.used, result.usage.limit) }
1502
+ }
1503
+ case 'consolidate': {
1504
+ const joined = rest.join(' ')
1505
+ const separator = joined.indexOf(' => ')
1506
+ if (separator === -1) {
1507
+ return { kind: 'error', text: text.consolidateUsage }
1508
+ }
1509
+ let track = 'user'
1510
+ let scope = 'workspace'
1511
+ const matches = []
1512
+ for (const part of joined.slice(0, separator).split(/\s+/)) {
1513
+ const trackMatch = /^--track=(user|agent)$/.exec(part)
1514
+ if (trackMatch !== null) { track = trackMatch[1]; continue }
1515
+ const scopeMatch = /^--scope=(user-global|workspace)$/.exec(part)
1516
+ if (scopeMatch !== null) { scope = scopeMatch[1]; continue }
1517
+ if (part.length > 0) matches.push(part)
1518
+ }
1519
+ const newText = joined.slice(separator + 4).trim()
1520
+ if (matches.length === 0 || matches.length > 20) return { kind: 'error', text: text.consolidateNeedsMatches }
1521
+ if (newText.length === 0) return { kind: 'error', text: text.consolidateNeedsText }
1522
+ const result = await service.consolidate(
1523
+ { track, scope, matches, text: newText, source: 'command' },
1524
+ { agent: invocation?.agent, gate: makeCommandGate(ctx, invocation) },
1525
+ )
1526
+ return { kind: 'success', text: text.consolidated(track, scope, result.removed.length, result.entry.text, result.usage.used, result.usage.limit) }
1527
+ }
1528
+ default:
1529
+ return { kind: 'error', text: text.unknownVerb(verb) }
1530
+ }
1531
+ }
1532
+
1533
+ /** 命令写参数解析:--track/--scope 可选(默认 user/workspace,与工具一致),余下为文本。 */
1534
+ function parseCommandWrite(/** @type {string[]} */ args, /** @type {boolean} */ requireText) {
1535
+ let track = 'user'
1536
+ let scope = 'workspace'
1537
+ const textParts = []
1538
+ for (const arg of args) {
1539
+ const trackMatch = /^--track=(user|agent)$/.exec(arg)
1540
+ if (trackMatch !== null) { track = trackMatch[1]; continue }
1541
+ const scopeMatch = /^--scope=(user-global|workspace)$/.exec(arg)
1542
+ if (scopeMatch !== null) { scope = scopeMatch[1]; continue }
1543
+ textParts.push(arg)
1544
+ }
1545
+ const text = textParts.join(' ')
1546
+ if (requireText === true && text.length === 0) {
1547
+ return { kind: 'error', text: '需要文本参数' }
1548
+ }
1549
+ return { kind: 'ok', track, scope, text }
1550
+ }
1551
+
1552
+ /** 条目渲染行(命令/面板共用格式)。 */
1553
+ function renderEntryLine(/** @type {{track: string, scope: string, workspaceKey?: string, text: string}} */ entry) {
1554
+ return `- [${entry.track}/${entry.scope}${entry.scope === 'workspace' ? ` @${entry.workspaceKey}` : ''}] ${entry.text}`
1555
+ }
1556
+
1557
+ /**
1558
+ * memory_recall 工具(F11):语义不明确时把记忆 query 与近期会话历史合并
1559
+ * 返回两段式召回("记忆 + 历史会话")。sessionQuery 服务缺失时降级为纯记忆
1560
+ * 结果(history 段为空,绝不报错)。
1561
+ * @param {MemoryService} service - ctx.memory。
1562
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Cordis 上下文(查 sessionQuery)。
1563
+ * @param {{historyLimitDefault: number, snippetCap: number, snippetChars: number, windowDays: number}} recall - Config.recall(默认值)。
1564
+ * @param {'en'|'zh'} [language] - 'en' | 'zh'。
1565
+ * @returns {object} 工具定义。
1566
+ */
1567
+ export function makeMemoryRecallTool(service, ctx, recall, language = 'en') {
1568
+ const description = language === 'zh'
1569
+ ? [
1570
+ '对记忆与会话历史的两段式召回:返回 (1) dsh-memento 库中与查询匹配的有界记忆条目,以及 (2) 经 session-query 服务的近期会话历史匹配。',
1571
+ '当仅凭记忆查询有歧义、或答案可能在更早的对话而非记忆中时使用。普通记忆查询请优先用 memory 工具的 action=query。',
1572
+ '查询对记忆条目是大小写不敏感子串(与 memory 工具一致),对会话历史是大小写不敏感语义文本扫描。',
1573
+ ].join('\n')
1574
+ : [
1575
+ 'Two-part recall over memory and session history: returns (1) bounded memory entries matching the query from the dsh-memento store, and (2) recent session-history matches via the session-query service.',
1576
+ 'Use when a memory query alone is ambiguous or when the answer may live in an earlier conversation rather than in memory. For plain memory lookup prefer the memory tool with action=query.',
1577
+ 'The query is a case-insensitive substring for memory entries (same as the memory tool) and a case-insensitive semantic-text scan for session history.',
1578
+ ].join('\n')
1579
+ const parameters = language === 'zh'
1580
+ ? {
1581
+ query: '两个数据源的大小写不敏感检索词。',
1582
+ memoryLimit: '最多返回的记忆条目数(默认 10)。',
1583
+ historyLimit: '最多扫描的历史会话数(默认 8)。',
1584
+ }
1585
+ : {
1586
+ query: 'Case-insensitive search terms for both sources.',
1587
+ memoryLimit: 'Max memory entries to return (default 10).',
1588
+ historyLimit: 'Max history sessions to scan (default 8).',
1589
+ }
1590
+ return defineTool({
1591
+ name: 'memory_recall',
1592
+ description,
1593
+ parameters: {
1594
+ query: { type: 'string', required: true, description: parameters.query },
1595
+ memoryLimit: { type: 'integer', description: parameters.memoryLimit },
1596
+ historyLimit: { type: 'integer', description: parameters.historyLimit },
1597
+ },
1598
+ output: {
1599
+ schema: {
1600
+ type: 'object',
1601
+ additionalProperties: false,
1602
+ properties: {
1603
+ ok: { type: 'boolean', required: true },
1604
+ memory: {
1605
+ type: 'object',
1606
+ additionalProperties: false,
1607
+ required: true,
1608
+ properties: {
1609
+ entries: {
1610
+ type: 'array',
1611
+ required: true,
1612
+ items: {
1613
+ type: 'object',
1614
+ additionalProperties: false,
1615
+ properties: {
1616
+ id: { type: 'string', required: true },
1617
+ track: { type: 'string', required: true },
1618
+ scope: { type: 'string', required: true },
1619
+ text: { type: 'string', required: true },
1620
+ },
1621
+ },
1622
+ },
1623
+ total: { type: 'integer', required: true },
1624
+ truncated: { type: 'boolean', required: true },
1625
+ },
1626
+ },
1627
+ history: {
1628
+ type: 'object',
1629
+ additionalProperties: false,
1630
+ required: true,
1631
+ properties: {
1632
+ available: { type: 'boolean', required: true },
1633
+ error: { type: 'string' },
1634
+ sessions: {
1635
+ type: 'array',
1636
+ required: true,
1637
+ items: {
1638
+ type: 'object',
1639
+ additionalProperties: false,
1640
+ properties: {
1641
+ sessionId: { type: 'string', required: true },
1642
+ matches: { type: 'integer', required: true },
1643
+ snippets: { type: 'array', required: true, items: { type: 'string' } },
1644
+ },
1645
+ },
1646
+ },
1647
+ },
1648
+ },
1649
+ },
1650
+ },
1651
+ render: (/** @type {object} */ _args, /** @type {RecallToolValue} */ value) => renderMemoryRecallResult(_args, value, language),
1652
+ },
1653
+ execute: /** @type {(args: any, exec: any) => Promise<any>} */ (async (args, exec) => {
1654
+ exec.signal.throwIfAborted()
1655
+ const memory = service.query(
1656
+ { text: args.query, limit: args.memoryLimit ?? 10 },
1657
+ { sessionId: exec.agent?.session?.id, session: exec.agent?.session },
1658
+ )
1659
+ const history = await recallHistory(
1660
+ ctx,
1661
+ args.query,
1662
+ args.historyLimit ?? recall.historyLimitDefault,
1663
+ recall.snippetCap,
1664
+ recall.snippetChars,
1665
+ exec.signal,
1666
+ /** @type {string | undefined} */ (exec.agent?.session?.header?.cwd),
1667
+ recall.windowDays,
1668
+ )
1669
+ return {
1670
+ ok: true,
1671
+ memory: {
1672
+ entries: memory.entries.map(publicEntry),
1673
+ total: memory.total,
1674
+ truncated: memory.truncated,
1675
+ },
1676
+ history,
1677
+ }
1678
+ }),
1679
+ })
1680
+ }
1681
+
1682
+ /**
1683
+ * 近期会话历史召回(sessionQuery 可选;rc.6 记录形状 = {header:{id}},事件为元数据记录)。
1684
+ * 服务端下推:filterSessions 以会话 cwd(原值直传,harness 按存储值比较)与
1685
+ * created-at 时间窗收窄候选,再对前 N 个候选做 filterEvents 定位——从"全量列举 +
1686
+ * 每候选一次扫描"变为"一次过滤 + ≤N 次定位"。
1687
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Cordis 上下文(查 sessionQuery)。
1688
+ * @param {string} query - 检索词。
1689
+ * @param {number} limit - 最多扫描的会话数。
1690
+ * @param {number} snippetCap - 每个会话最多展示的片段数。
1691
+ * @param {number} snippetChars - 每个片段的最大字符数。
1692
+ * @param {AbortSignal} signal - 取消信号。
1693
+ * @param {string | undefined} cwd - 当前会话 cwd(服务端 cwd 过滤;缺省不加该过滤器)。
1694
+ * @param {number} windowDays - 时间窗(天);>0 时加 created-at 下界。
1695
+ * @returns {Promise<{available: boolean, sessions: Array<{sessionId: string, matches: number, snippets: string[]}>, error?: string}>}。
1696
+ */
1697
+ async function recallHistory(ctx, query, limit, snippetCap, snippetChars, signal, cwd, windowDays) {
1698
+ const sessionQuery = ctx.get('sessionQuery')
1699
+ if (sessionQuery === undefined || sessionQuery === null) {
1700
+ return { available: false, sessions: [] }
1701
+ }
1702
+ const queryService = /** @type {{filterSessions: (filters: object[], signal?: AbortSignal) => Promise<Array<{header?: {id?: unknown}}>>, filterEvents: (sessionId: string, filters: object[]) => Promise<Array<{seq: number}>>, readSession: (sessionId: string) => Promise<{session?: unknown, events: Array<{seq: number, type?: string, data?: unknown}>}>}} */ (sessionQuery)
1703
+ try {
1704
+ const sessionFilters = []
1705
+ if (typeof cwd === 'string' && cwd.length > 0) {
1706
+ sessionFilters.push({ kind: 'cwd', values: [cwd] })
1707
+ }
1708
+ if (Number.isInteger(windowDays) && windowDays > 0) {
1709
+ sessionFilters.push({ kind: 'created-at', from: Date.now() - windowDays * 86400000 })
1710
+ }
1711
+ const records = await queryService.filterSessions(sessionFilters, signal)
1712
+ /** @type {Array<{sessionId: string, matches: number, snippets: string[]}>} */
1713
+ const results = []
1714
+ for (const record of records.slice(0, limit)) {
1715
+ const sessionId = typeof record?.header?.id === 'string' ? record.header.id : ''
1716
+ if (sessionId.length === 0) continue
1717
+ const matched = await queryService.filterEvents(sessionId, [{ kind: 'text', text: query }])
1718
+ if (matched.length === 0) continue
1719
+ // 事件记录是元数据(seq/type/time),片段文本从整段日志按 seq 抽取。
1720
+ const snippets = []
1721
+ try {
1722
+ const snapshot = await queryService.readSession(sessionId)
1723
+ const bySeq = new Map(snapshot.events.map((event) => [event.seq, event]))
1724
+ for (const hit of matched.slice(0, snippetCap)) {
1725
+ const event = bySeq.get(hit.seq)
1726
+ if (event === undefined) continue
1727
+ const text = extractEventText(event)
1728
+ if (text.length > 0) snippets.push(text.length > snippetChars ? `${text.slice(0, snippetChars)}…` : text)
1729
+ }
1730
+ } catch {
1731
+ // 片段提取失败不影响已确认的命中记录;空 catch 语义:只放弃片段装饰。
1732
+ }
1733
+ results.push({ sessionId, matches: matched.length, snippets })
1734
+ }
1735
+ return { available: true, sessions: results }
1736
+ } catch (error) {
1737
+ const message = error instanceof Error ? error.message : String(error)
1738
+ return { available: false, sessions: [], error: message }
1739
+ }
1740
+ }
1741
+
1742
+ /**
1743
+ * memory_recall 结果渲染(纯函数;language 选文案,未知回退 en)。
1744
+ * @param {object} _args - 调用参数(未用)。
1745
+ * @param {object} value - 规范 JSON 结果。
1746
+ * @param {string} [language] - 'en' | 'zh'。
1747
+ * @returns {Array<{type: 'text', text: string}>} 模型可见文本。
1748
+ */
1749
+ export function renderMemoryRecallResult(/** @type {object} */ _args, /** @type {RecallToolValue} */ value, language = 'en') {
1750
+ const zh = language === 'zh'
1751
+ const memoryLine = value.memory.total === 0
1752
+ ? (zh ? 'memory:没有条目命中' : 'memory: no entries matched')
1753
+ : (zh
1754
+ ? `memory:${value.memory.entries.length} 条命中${value.memory.truncated ? `(共 ${value.memory.total} 条)` : ''}\n${value.memory.entries.map((entry) => `- [${entry.track}/${entry.scope}] ${entry.text}`).join('\n')}`
1755
+ : `memory: ${value.memory.entries.length} match${value.memory.entries.length === 1 ? '' : 'es'}${value.memory.truncated ? ` (of ${value.memory.total})` : ''}\n${value.memory.entries.map((entry) => `- [${entry.track}/${entry.scope}] ${entry.text}`).join('\n')}`)
1756
+ const historyLines = []
1757
+ if (!value.history.available) {
1758
+ historyLines.push(value.history.error === undefined
1759
+ ? (zh ? 'history:本 profile 未提供 session-query 服务' : 'history: session-query unavailable in this profile')
1760
+ : (zh ? `history:session-query 失败(${value.history.error})` : `history: session-query failed (${value.history.error})`))
1761
+ } else if (value.history.sessions.length === 0) {
1762
+ historyLines.push(zh ? 'history:没有匹配的会话' : 'history: no matching sessions')
1763
+ } else {
1764
+ for (const session of value.history.sessions) {
1765
+ historyLines.push(zh
1766
+ ? `- 会话 ${session.sessionId}:${session.matches} 条事件命中`
1767
+ : `- session ${session.sessionId}: ${session.matches} event match${session.matches === 1 ? '' : 'es'}`)
1768
+ for (const snippet of session.snippets) historyLines.push(` ${snippet.replaceAll('\n', ' ')}`)
1769
+ }
1770
+ }
1771
+ return [{ type: 'text', text: `${memoryLine}\n\n${historyLines.join('\n')}` }]
1772
+ }
1773
+
1774
+ /**
1775
+ * 注册面板 JSON 路由(F9,只读;webServer 缺失的 profile 自动跳过)。
1776
+ * 写操作(含审批)不进面板路由:审批在 DSH 内置审批 UI 完成,面板只做
1777
+ * 条目浏览/搜索/预算条/审计尾。路由随插件生命周期自动撤销。
1778
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Cordis 上下文。
1779
+ * @param {MemoryService} service - ctx.memory。
1780
+ * @param {{panelEntriesLimit: number, panelAuditLimit: number}} options - Config 面板上限。
1781
+ */
1782
+ export function registerWebRoutes(ctx, service, options) {
1783
+ withService(ctx, 'webServer', (/** @type {{register?: (route: object) => unknown} | null | undefined} */ webServer) => {
1784
+ if (typeof webServer?.register !== 'function') return
1785
+ webServer.register({
1786
+ kind: 'exact',
1787
+ path: '/api/memento/entries',
1788
+ handler: async (/** @type {{url?: string}} */ req, /** @type {PanelResponse} */ res) => {
1789
+ try {
1790
+ const url = new URL(req.url ?? '', 'http://localhost')
1791
+ const filter = {
1792
+ ...(url.searchParams.get('text') ? { text: url.searchParams.get('text') } : {}),
1793
+ ...(url.searchParams.get('track') ? { track: url.searchParams.get('track') } : {}),
1794
+ ...(url.searchParams.get('scope') ? { scope: url.searchParams.get('scope') } : {}),
1795
+ }
1796
+ const rawParam = url.searchParams.get('limit')
1797
+ const raw = rawParam === null ? undefined : Number(rawParam)
1798
+ const limit = raw === undefined ? undefined : (Number.isInteger(raw) && raw > 0 ? Math.min(raw, options.panelEntriesLimit) : undefined)
1799
+ const { entries, total, truncated } = service.query({
1800
+ ...filter,
1801
+ ...(limit === undefined ? {} : { limit }),
1802
+ })
1803
+ sendPanelJson(res, 200, { entries, total, truncated, budgets: service.budgets(), language: service.language })
1804
+ } catch (error) {
1805
+ sendPanelJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
1806
+ }
1807
+ },
1808
+ })
1809
+ webServer.register({
1810
+ kind: 'exact',
1811
+ path: '/api/memento/audit',
1812
+ handler: async (/** @type {{url?: string}} */ req, /** @type {PanelResponse} */ res) => {
1813
+ try {
1814
+ const url = new URL(req.url ?? '', 'http://localhost')
1815
+ const raw = Number(url.searchParams.get('limit') ?? String(options.panelAuditLimit))
1816
+ const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, PANEL_AUDIT_CEILING) : options.panelAuditLimit
1817
+ sendPanelJson(res, 200, { rows: service.store.auditList(limit), language: service.language })
1818
+ } catch (error) {
1819
+ sendPanelJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
1820
+ }
1821
+ },
1822
+ })
1823
+ webServer.register({
1824
+ kind: 'exact',
1825
+ path: '/api/memento/proposals',
1826
+ handler: async (/** @type {{url?: string}} */ _req, /** @type {PanelResponse} */ res) => {
1827
+ try {
1828
+ // 只读:仅列出 pending 提案;approve/dismiss 走 /memory 命令(用户动作 + 审批门)。
1829
+ sendPanelJson(res, 200, { proposals: service.store.proposalList('pending', 50), language: service.language })
1830
+ } catch (error) {
1831
+ sendPanelJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
1832
+ }
1833
+ },
1834
+ })
1835
+ })
1836
+ }
1837
+
1838
+ /** 面板 JSON 响应(node:http)。 */
1839
+ function sendPanelJson(/** @type {PanelResponse} */ res, /** @type {number} */ status, /** @type {unknown} */ value) {
1840
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
1841
+ res.end(JSON.stringify(value))
1842
+ }
1843
+
1844
+ export { MemoryError, InvalidInputError, BudgetExceededError, EntryNotFoundError, AmbiguousMatchError, WriteDeniedError, NoAgentError, ProposalNotFoundError }
1845
+ export { buildWriteReason, isMemoryWriteRequest, applyWritePolicy, normalizeWritePolicy, resolveWritePolicy, validateWritePolicies, parseWriteReason }
1846
+ export { openMemoryStore, resolveDbPath }
1847
+ export { renderSnapshot, visibleEntries }
1848
+ export { workspaceKeyOf }
1849
+ export { validateBudgets, budgetReport, budgetLimits, checkBudget }