dsh-memory-ledger 0.0.0-stage → 1.0.1

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.js ADDED
@@ -0,0 +1,776 @@
1
+ /**
2
+ * dsh-memory-ledger — a persistent, user-owned memory ledger for DeepSeek Harness.
3
+ *
4
+ * It contributes four things to the Harness:
5
+ *
6
+ * 1. A prompt section that surfaces the user's standing settings and the
7
+ * verified lessons collected so far, so a later session does not re-ask and
8
+ * does not re-pay for the same investigation. Lessons written in a working
9
+ * directory stay in that project's ledger.
10
+ * 2. Three model-facing tools (`memory_save`, `memory_search`, `memory_forget`)
11
+ * that maintain the ledger inside a conversation, with scoped retrieval
12
+ * (hybrid BM25 + TF-IDF vector cosine, keyword, or vector only).
13
+ * 3. Mechanical incident counting on `tools/result`: a failing tool call is
14
+ * reduced to a stable signature and counted, so a repeat is visible in the
15
+ * prompt without the model spending a token on detecting it.
16
+ * 4. A browser panel with a left-sidebar entry (`sidebar.panellist` + `main`)
17
+ * that lists, searches, and prunes the same ledger over gated HTTP routes.
18
+ *
19
+ * State lives outside the profile: `$DSH_HOME/memory/memory.json` for the user
20
+ * scope and `$DSH_HOME/memory/projects/<slug>.json` per working directory.
21
+ */
22
+
23
+ import { homedir } from 'node:os'
24
+ import { isAbsolute, join, resolve } from 'node:path'
25
+ import Schema from '@deepseek-ai/schemastery'
26
+ import { defineTool } from '@deepseek-ai/dsh-tools'
27
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
28
+ import { LedgerHub, SCOPES } from './ledger.js'
29
+ import { isUserStatement, looksLikeSetting, reminderText } from './reminder.js'
30
+
31
+ export const name = 'dsh-memory-ledger'
32
+ export const inject = ['tools', 'systemPrompt']
33
+
34
+ export const Config = Schema.object({
35
+ dataDir: Schema.string()
36
+ .default('')
37
+ .description('Absolute directory for the ledger files; empty means $DSH_HOME/memory.'),
38
+ projectMemory: Schema.boolean()
39
+ .default(true)
40
+ .description('Keep a separate ledger per working directory instead of one global bank.'),
41
+ maxPromptChars: Schema.number()
42
+ .default(1600)
43
+ .description('Hard character budget of the injected memory section.'),
44
+ maxLessonsInPrompt: Schema.number()
45
+ .default(8)
46
+ .description('Verified lessons surfaced in the prompt at most.'),
47
+ searchMode: Schema.string()
48
+ .default('hybrid')
49
+ .description('Default retrieval mode: hybrid, keyword, or vector.'),
50
+ captureFailures: Schema.boolean()
51
+ .default(true)
52
+ .description('Count failed tool calls mechanically and surface recurring ones.'),
53
+ incidentThreshold: Schema.number()
54
+ .default(2)
55
+ .description('Repeat count from which a recurring failure is surfaced in the prompt.'),
56
+ maxIncidents: Schema.number()
57
+ .default(40)
58
+ .description('Incident signatures kept on disk at most, per ledger.'),
59
+ incidentMaxAgeDays: Schema.number()
60
+ .default(60)
61
+ .description('Incidents older than this many days are dropped.'),
62
+ sectionOrder: Schema.number()
63
+ .default(100)
64
+ .description('Prompt section order; lower appears earlier. Defaults right after the persona.'),
65
+ includeUsageHint: Schema.boolean()
66
+ .default(true)
67
+ .description('Append the short maintenance reminder to the injected section.'),
68
+ constraintKeys: Schema.string()
69
+ .default('pen_name,signature')
70
+ .description('Comma-separated fact keys that become hard constraints in their own high-priority prompt section. Pinned facts are constraints too.'),
71
+ maxConstraintChars: Schema.number()
72
+ .default(300)
73
+ .description('Character budget of the hard-constraint section.'),
74
+ remindUnrecorded: Schema.boolean()
75
+ .default(true)
76
+ .description('When a turn ends with a likely unrecorded user setting, queue one short reminder for the next turn.'),
77
+ ui: Schema.boolean()
78
+ .default(true)
79
+ .description('Serve the browser panel routes and register the left-sidebar entry.'),
80
+ routePrefix: Schema.string()
81
+ .default('/dsh-memory/api/v1')
82
+ .description('HTTP path prefix of the panel routes.'),
83
+ })
84
+
85
+ /**
86
+ * Resolve the ledger directory.
87
+ * @param {string} configured Configured `dataDir`.
88
+ * @returns {string} Absolute directory.
89
+ */
90
+ export function resolveDataDir(configured) {
91
+ const trimmed = typeof configured === 'string' ? configured.trim() : ''
92
+ if (trimmed !== '') return isAbsolute(trimmed) ? trimmed : resolve(trimmed)
93
+ const home = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')
94
+ return join(home, 'memory')
95
+ }
96
+
97
+ /**
98
+ * Read the working directory a session is bound to.
99
+ * @param {{ session?: { header?: { cwd?: string } } }} agent
100
+ * @returns {string} The directory, or '' when unavailable.
101
+ */
102
+ function sessionCwd(agent) {
103
+ const cwd = agent?.session?.header?.cwd
104
+ return typeof cwd === 'string' ? cwd.trim() : ''
105
+ }
106
+
107
+ /**
108
+ * Collect the model-facing text of a failed tool result.
109
+ * @param {{ content?: Array<{ type?: string, text?: string }> }} result
110
+ * @returns {string}
111
+ */
112
+ function failureText(result) {
113
+ const blocks = Array.isArray(result?.content) ? result.content : []
114
+ return blocks
115
+ .map(block => (block && typeof block === 'object' && typeof block.text === 'string' ? block.text : ''))
116
+ .filter(Boolean)
117
+ .join('\n')
118
+ .trim()
119
+ }
120
+
121
+ function sendJson(response, status, payload) {
122
+ response.writeHead(status, {
123
+ 'cache-control': 'no-store',
124
+ 'content-type': 'application/json; charset=utf-8',
125
+ })
126
+ response.end(JSON.stringify(payload))
127
+ }
128
+
129
+ /** Whether a `Host` header names a loopback authority. */
130
+ function loopbackAuthority(host) {
131
+ if (typeof host !== 'string' || host === '') return false
132
+ const lower = host.toLowerCase()
133
+ const name = lower.startsWith('[') ? lower.slice(0, lower.indexOf(']') + 1) : lower.split(':')[0]
134
+ return name === '127.0.0.1' || name === 'localhost' || name === '[::1]'
135
+ }
136
+
137
+ /**
138
+ * Refuse another page driving these routes: loopback Host, no cross-site
139
+ * fetch, and an Origin that matches Host when one is present.
140
+ * @param {import('node:http').IncomingMessage} request
141
+ * @returns {boolean}
142
+ */
143
+ function sameOrigin(request) {
144
+ const host = request.headers.host
145
+ if (host !== undefined && !loopbackAuthority(host)) return false
146
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
147
+ const origin = request.headers.origin
148
+ if (origin === undefined) return true
149
+ try {
150
+ return new URL(origin).host === host
151
+ } catch {
152
+ return false
153
+ }
154
+ }
155
+
156
+ /** Read a size-capped JSON body. */
157
+ async function readJsonBody(request, maxBytes = 4096) {
158
+ const chunks = []
159
+ let size = 0
160
+ for await (const chunk of request) {
161
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
162
+ size += buffer.length
163
+ if (size > maxBytes) throw new Error('request body too large')
164
+ chunks.push(buffer)
165
+ }
166
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
167
+ }
168
+
169
+ /**
170
+ * @param {object} ctx Cordis plugin context supplied by the Harness.
171
+ * @param {{ dataDir: string, projectMemory: boolean, maxPromptChars: number,
172
+ * maxLessonsInPrompt: number, searchMode: string, captureFailures: boolean,
173
+ * incidentThreshold: number, maxIncidents: number, incidentMaxAgeDays: number,
174
+ * sectionOrder: number, includeUsageHint: boolean, ui: boolean,
175
+ * routePrefix: string }} config
176
+ */
177
+ export function apply(ctx, config) {
178
+ const logger = ctx.logger?.(name)
179
+ const hub = new LedgerHub({ dir: resolveDataDir(config?.dataDir ?? ''), logger })
180
+ const projectMemory = config.projectMemory !== false
181
+ const defaultMode = ['hybrid', 'keyword', 'vector'].includes(config.searchMode) ? config.searchMode : 'hybrid'
182
+ const prefix = String(config.routePrefix || '/dsh-memory/api/v1').replace(/\/+$/u, '')
183
+
184
+ const promptOptions = () => ({
185
+ maxChars: config.maxPromptChars,
186
+ maxLessons: config.maxLessonsInPrompt,
187
+ incidentThreshold: config.incidentThreshold,
188
+ includeUsageHint: config.includeUsageHint,
189
+ })
190
+ const cwdFor = (agent) => (projectMemory ? sessionCwd(agent) : '')
191
+
192
+ // ── 1. The injected memory section ────────────────────────────────────────
193
+ // `interpolate: false` keeps user-authored text containing `{{…}}` literal.
194
+ ctx.systemPrompt.section({
195
+ name: 'dsh-memory:ledger',
196
+ order: config.sectionOrder,
197
+ interpolate: false,
198
+ text: (context) => hub.renderFor(cwdFor(context?.agent), promptOptions()),
199
+ })
200
+
201
+ // A second, higher-priority section for the settings that must not be
202
+ // overridden by style defaults — pen name, signature, and anything pinned.
203
+ ctx.systemPrompt.section({
204
+ name: 'dsh-memory:constraints',
205
+ order: config.sectionOrder - 10,
206
+ interpolate: false,
207
+ text: (context) => hub.renderConstraintsFor(cwdFor(context?.agent), {
208
+ constraintKeys: config.constraintKeys,
209
+ maxChars: config.maxConstraintChars,
210
+ }),
211
+ })
212
+
213
+ // ── 2. Tools ──────────────────────────────────────────────────────────────
214
+ ctx.tools.register(defineTool({
215
+ name: 'memory_save',
216
+ description: [
217
+ 'Save one long-lived memory entry, or update the one it replaces.',
218
+ 'Use kind="fact" for a stable setting about the user or the project (identity, pen name, signature, language, stack, constraints): later sessions then treat it as a premise instead of asking again.',
219
+ 'Use kind="lesson" after a problem is actually solved, recording the cause and the exact fix, so the same investigation is not paid for twice.',
220
+ 'Facts default to the user-wide ledger so they follow the user everywhere; lessons default to the current working directory so one project\'s tooling quirks do not leak into another. Pass scope="user" to share a lesson everywhere.',
221
+ 'Saving a fact under the key it already has overwrites that value in the same scope, which is how a user-changed setting stays in sync.',
222
+ ].join(' '),
223
+ parameters: {
224
+ kind: {
225
+ type: 'string',
226
+ required: true,
227
+ description: '"fact" for a standing setting, "lesson" for a verified problem -> solution.',
228
+ },
229
+ title: {
230
+ type: 'string',
231
+ required: true,
232
+ description: 'One short line the model re-reads every step; keep it under 80 characters.',
233
+ },
234
+ body: {
235
+ type: 'string',
236
+ description: 'Optional detail: the symptom, the cause, the exact command or fix.',
237
+ },
238
+ key: {
239
+ type: 'string',
240
+ description: 'Stable slot for a fact, e.g. "pen_name". Saving the same key again in the same scope replaces the old value.',
241
+ },
242
+ tags: {
243
+ type: 'string',
244
+ description: 'Comma-separated lowercase tags, e.g. "filesystem,powershell".',
245
+ },
246
+ pinned: {
247
+ type: 'boolean',
248
+ description: 'Keep this entry at the top of the injected section.',
249
+ },
250
+ reason: {
251
+ type: 'string',
252
+ description: 'Why this is being recorded, one short line (for example "user stated their pen name" or "fixed the schema validation error"). Shown in the panel beside the entry so the user can judge whether it still applies.',
253
+ },
254
+ scope: {
255
+ type: 'string',
256
+ description: '"user" for every project, "project" for this working directory only. Defaults to the kind-based rule above.',
257
+ },
258
+ id: {
259
+ type: 'string',
260
+ description: 'Update this existing entry instead of creating a new one.',
261
+ },
262
+ },
263
+ output: {
264
+ schema: {
265
+ type: 'object',
266
+ additionalProperties: false,
267
+ properties: {
268
+ id: { type: 'string', required: true },
269
+ kind: { type: 'string', required: true },
270
+ title: { type: 'string', required: true },
271
+ key: { type: 'string', required: true },
272
+ scope: { type: 'string', required: true },
273
+ updated: { type: 'boolean', required: true },
274
+ facts: { type: 'number', required: true },
275
+ lessons: { type: 'number', required: true },
276
+ pendingIncidents: { type: 'number', required: true },
277
+ },
278
+ },
279
+ render: (_args, value) => [{
280
+ type: 'text',
281
+ text: `${value.updated ? 'Updated' : 'Saved'} ${value.kind} ${value.id} in the ${value.scope} ledger: ${value.title}`
282
+ + ` (this ledger now: ${value.facts} facts, ${value.lessons} lessons, ${value.pendingIncidents} pending incidents)`,
283
+ }],
284
+ },
285
+ async execute(args, exec) {
286
+ const cwd = cwdFor(exec?.agent)
287
+ const sessionId = String(exec?.agent?.id ?? '')
288
+ const origin = cwd === ''
289
+ ? undefined
290
+ : { cwd, label: cwd, sessionId, at: new Date().toISOString() }
291
+ const { entry, updated, scope } = hub.upsert({
292
+ id: args.id,
293
+ kind: args.kind,
294
+ title: args.title,
295
+ body: args.body,
296
+ key: args.key,
297
+ tags: args.tags,
298
+ pinned: args.pinned,
299
+ reason: args.reason !== undefined && String(args.reason).trim() !== ''
300
+ ? args.reason
301
+ : (args.kind === 'lesson' ? 'agent 在任务中验证后写入' : 'agent 在会话中写入的设定'),
302
+ origin,
303
+ scope: SCOPES.includes(String(args.scope)) ? String(args.scope) : undefined,
304
+ cwd,
305
+ })
306
+ const stats = scope === 'project' ? hub.project(cwd).stats() : hub.user().stats()
307
+ return {
308
+ id: entry.id,
309
+ kind: entry.kind,
310
+ title: entry.title,
311
+ key: entry.key,
312
+ scope,
313
+ updated,
314
+ facts: stats.facts,
315
+ lessons: stats.lessons,
316
+ pendingIncidents: stats.pending,
317
+ }
318
+ },
319
+ }))
320
+
321
+ ctx.tools.register(defineTool({
322
+ name: 'memory_search',
323
+ description: [
324
+ 'Search the persistent memory ledger before re-investigating a problem or re-asking about the user.',
325
+ 'Retrieval is hybrid by default: BM25 keyword scoring plus a TF-IDF vector cosine over word and CJK character-bigram terms, so a paraphrased problem still finds the stored lesson. `mode="keyword"` or `mode="vector"` narrows it.',
326
+ 'The user ledger and the current project ledger are searched together by default; an empty query lists the strongest entries (pinned, then most repeated, then most recent).',
327
+ ].join(' '),
328
+ parameters: {
329
+ query: {
330
+ type: 'string',
331
+ description: 'Free-form query. Empty lists the strongest entries.',
332
+ },
333
+ kind: {
334
+ type: 'string',
335
+ description: '"fact" or "lesson" to restrict the result.',
336
+ },
337
+ tags: {
338
+ type: 'string',
339
+ description: 'Comma-separated tags that must all match.',
340
+ },
341
+ mode: {
342
+ type: 'string',
343
+ description: '"hybrid" (default), "keyword", or "vector".',
344
+ },
345
+ scope: {
346
+ type: 'string',
347
+ description: '"auto" (default: user + current project), "user", "project", or "all" (every project on disk).',
348
+ },
349
+ limit: {
350
+ type: 'number',
351
+ description: 'Maximum matches, 1-50; defaults to 10.',
352
+ },
353
+ full: {
354
+ type: 'boolean',
355
+ description: 'Return complete bodies instead of 400-character previews.',
356
+ },
357
+ includeArchived: {
358
+ type: 'boolean',
359
+ description: 'Also return entries that were forgotten (archived).',
360
+ },
361
+ },
362
+ output: {
363
+ schema: {
364
+ type: 'object',
365
+ additionalProperties: false,
366
+ properties: {
367
+ total: { type: 'number', required: true },
368
+ mode: { type: 'string', required: true },
369
+ ledger: { type: 'string', required: true },
370
+ matches: {
371
+ type: 'array',
372
+ required: true,
373
+ items: {
374
+ type: 'object',
375
+ additionalProperties: false,
376
+ properties: {
377
+ id: { type: 'string', required: true },
378
+ kind: { type: 'string', required: true },
379
+ scope: { type: 'string', required: true },
380
+ title: { type: 'string', required: true },
381
+ key: { type: 'string', required: true },
382
+ tags: { type: 'array', items: { type: 'string' }, required: true },
383
+ body: { type: 'string', required: true },
384
+ pinned: { type: 'boolean', required: true },
385
+ archived: { type: 'boolean', required: true },
386
+ occurrences: { type: 'number', required: true },
387
+ reason: { type: 'string', required: true },
388
+ originLabel: { type: 'string', required: true },
389
+ score: { type: 'number', required: true },
390
+ updatedAt: { type: 'string', required: true },
391
+ },
392
+ },
393
+ },
394
+ },
395
+ },
396
+ render: (_args, value) => {
397
+ if (value.total === 0) {
398
+ return [{ type: 'text', text: `No memory entry matched. Ledger: ${value.ledger}` }]
399
+ }
400
+ const lines = value.matches.map(match => {
401
+ const slot = match.key !== '' ? ` [${match.key}]` : ''
402
+ const repeat = match.occurrences > 1 ? ` (${match.occurrences}x)` : ''
403
+ const archived = match.archived ? ' (archived)' : ''
404
+ const source = match.originLabel !== '' ? ` · 来源 ${match.originLabel}` : ''
405
+ const reason = match.reason !== '' ? ` · 原因 ${match.reason}` : ''
406
+ const body = match.body !== '' ? ` — ${match.body}` : ''
407
+ return `- ${match.id} (${match.kind}/${match.scope})${slot}${repeat}${archived} ${match.title}${source}${reason}${body}`
408
+ })
409
+ return [{ type: 'text', text: `${value.total} match(es) via ${value.mode}; ledger: ${value.ledger}\n${lines.join('\n')}` }]
410
+ },
411
+ },
412
+ async execute(args, exec) {
413
+ const cwd = cwdFor(exec?.agent)
414
+ const mode = ['hybrid', 'keyword', 'vector'].includes(String(args.mode)) ? String(args.mode) : defaultMode
415
+ const matches = hub.search({
416
+ text: args.query,
417
+ kind: args.kind,
418
+ tags: args.tags,
419
+ mode,
420
+ scope: args.scope,
421
+ cwd,
422
+ limit: args.limit ?? 10,
423
+ includeArchived: args.includeArchived === true,
424
+ })
425
+ const full = args.full === true
426
+ const userStats = hub.user().stats()
427
+ const projectStats = cwd === '' ? null : hub.project(cwd).stats()
428
+ return {
429
+ total: matches.length,
430
+ mode,
431
+ ledger: `${userStats.facts} user facts, ${userStats.lessons} user lessons`
432
+ + (projectStats === null ? '' : `; ${projectStats.facts} project facts, ${projectStats.lessons} project lessons (${cwd})`)
433
+ + `; ${userStats.pending + (projectStats?.pending ?? 0)} pending incidents`,
434
+ matches: matches.map(entry => ({
435
+ id: entry.id,
436
+ kind: entry.kind,
437
+ scope: entry.scope ?? 'user',
438
+ title: entry.title,
439
+ key: entry.key,
440
+ tags: entry.tags,
441
+ body: full ? entry.body : entry.body.slice(0, 400),
442
+ pinned: entry.pinned,
443
+ archived: entry.archived,
444
+ occurrences: entry.occurrences,
445
+ reason: typeof entry.reason === 'string' ? entry.reason : '',
446
+ originLabel: typeof entry.origin?.label === 'string' ? entry.origin.label : '',
447
+ score: Number(entry._score ?? 0),
448
+ updatedAt: entry.updatedAt,
449
+ })),
450
+ }
451
+ },
452
+ }))
453
+
454
+ ctx.tools.register(defineTool({
455
+ name: 'memory_forget',
456
+ description: [
457
+ 'Remove a memory entry by id, wherever it lives (user ledger or project ledger).',
458
+ 'The entry is archived by default so it stops entering the prompt while staying recoverable; pass hard=true to delete it from the ledger file.',
459
+ 'Use it when the user says a stored setting is wrong, outdated, or no longer applies.',
460
+ ].join(' '),
461
+ parameters: {
462
+ id: {
463
+ type: 'string',
464
+ required: true,
465
+ description: 'Entry id from memory_search.',
466
+ },
467
+ hard: {
468
+ type: 'boolean',
469
+ description: 'Delete the record instead of archiving it.',
470
+ },
471
+ },
472
+ output: {
473
+ schema: {
474
+ type: 'object',
475
+ additionalProperties: false,
476
+ properties: {
477
+ id: { type: 'string', required: true },
478
+ hard: { type: 'boolean', required: true },
479
+ title: { type: 'string', required: true },
480
+ scope: { type: 'string', required: true },
481
+ },
482
+ },
483
+ render: (_args, value) => [{
484
+ type: 'text',
485
+ text: `${value.hard ? 'Deleted' : 'Archived'} ${value.id} (${value.scope}): ${value.title}`,
486
+ }],
487
+ },
488
+ async execute(args) {
489
+ return hub.forget(args.id, args.hard === true)
490
+ },
491
+ }))
492
+
493
+ // ── 3. Mechanical incident counting ───────────────────────────────────────
494
+ let flushTimer = null
495
+ const pending = new Set()
496
+
497
+ const scheduleFlush = () => {
498
+ if (flushTimer !== null) return
499
+ flushTimer = setTimeout(() => {
500
+ flushTimer = null
501
+ try {
502
+ for (const store of pending) {
503
+ store.pruneIncidents({ max: config.maxIncidents, maxAgeDays: config.incidentMaxAgeDays })
504
+ }
505
+ pending.clear()
506
+ } catch (error) {
507
+ logger?.warn?.(`failed to persist incidents: ${error instanceof Error ? error.message : String(error)}`)
508
+ }
509
+ }, 1200)
510
+ if (typeof flushTimer.unref === 'function') flushTimer.unref()
511
+ }
512
+
513
+ ctx.effect(() => () => {
514
+ if (flushTimer !== null) {
515
+ clearTimeout(flushTimer)
516
+ flushTimer = null
517
+ }
518
+ })
519
+
520
+ if (config.captureFailures) {
521
+ ctx.on('tools/result', (exec, result) => {
522
+ try {
523
+ if (!result || result.isError !== true) return
524
+ const tool = String(exec?.name ?? '')
525
+ // The ledger's own tools never count as incidents.
526
+ if (tool === '' || tool.startsWith('memory_')) return
527
+ const message = typeof result.error?.message === 'string' && result.error.message !== ''
528
+ ? result.error.message
529
+ : failureText(result)
530
+ const cwd = cwdFor(exec?.agent)
531
+ const store = cwd === '' ? hub.user() : hub.project(cwd)
532
+ store.recordIncident(tool, message)
533
+ pending.add(store)
534
+ scheduleFlush()
535
+ } catch (error) {
536
+ logger?.warn?.(`failed to record incident: ${error instanceof Error ? error.message : String(error)}`)
537
+ }
538
+ })
539
+ }
540
+
541
+ // ── 4. Unrecorded-setting reminder ────────────────────────────────────────
542
+ // Zero model tokens: a regex over the claimed user message decides whether a
543
+ // reminder is queued at all, and only one reminder is ever emitted per
544
+ // pending statement.
545
+ const pendingReminders = new Map()
546
+
547
+ const messageText = (message) => {
548
+ const blocks = Array.isArray(message?.content) ? message.content : []
549
+ return blocks
550
+ .filter(block => block && block.type === 'text' && typeof block.text === 'string')
551
+ .map(block => block.text)
552
+ .join('\n')
553
+ .trim()
554
+ }
555
+
556
+ if (config.remindUnrecorded) {
557
+ ctx.on('agent/inbox/claimed', (payload) => {
558
+ try {
559
+ // Only what the human typed: an injected reminder mentions 笔名/署名
560
+ // itself and would otherwise re-trigger this detector forever.
561
+ if (!isUserStatement(payload?.message)) return
562
+ const text = messageText(payload?.message)
563
+ if (!looksLikeSetting(text)) return
564
+ const id = String(payload?.agent?.id ?? '')
565
+ if (id === '') return
566
+ const current = pendingReminders.get(id)
567
+ if (current !== undefined && current.reminded !== true) {
568
+ current.quotes.push(text)
569
+ if (current.quotes.length > 3) current.quotes.shift()
570
+ return
571
+ }
572
+ pendingReminders.set(id, { quotes: [text], turn: payload?.turn ?? 0, reminded: false, emitted: current?.emitted ?? 0 })
573
+ } catch (error) {
574
+ logger?.warn?.(`failed to inspect an inbox claim: ${error instanceof Error ? error.message : String(error)}`)
575
+ }
576
+ })
577
+
578
+ // A successful save answers the reminder: nothing more to say.
579
+ ctx.on('tools/result', (exec, result) => {
580
+ if (exec?.name !== 'memory_save' || result?.isError === true) return
581
+ const id = String(exec?.agent?.id ?? '')
582
+ if (id !== '') pendingReminders.delete(id)
583
+ })
584
+
585
+ ctx.on('agent/turn-stopping', (payload) => {
586
+ try {
587
+ const id = String(payload?.agent?.id ?? '')
588
+ const pending = pendingReminders.get(id)
589
+ if (pending === undefined || pending.reminded === true || pending.quotes.length === 0) return
590
+ // Belt and braces: never let one session be nagged without bound.
591
+ if ((pending.emitted ?? 0) >= 5) {
592
+ pendingReminders.delete(id)
593
+ return
594
+ }
595
+ const agent = payload?.agent
596
+ if (typeof agent?.inject !== 'function') return
597
+ agent.inject(createUserMessage({
598
+ content: [{ type: 'text', text: reminderText(pending.quotes[0]) }],
599
+ source: { kind: 'plugin:dsh-memory-ledger', plugin: 'dsh-memory-ledger' },
600
+ }))
601
+ pending.reminded = true
602
+ pending.emitted = (pending.emitted ?? 0) + 1
603
+ } catch (error) {
604
+ logger?.warn?.(`failed to queue the memory reminder: ${error instanceof Error ? error.message : String(error)}`)
605
+ }
606
+ })
607
+
608
+ ctx.on('agent/disposed', (payload) => {
609
+ const id = String(payload?.agent?.id ?? '')
610
+ if (id !== '') pendingReminders.delete(id)
611
+ })
612
+ }
613
+
614
+ // ── 5. Human entry points ─────────────────────────────────────────────────
615
+ const commands = ctx.get('commands')
616
+ if (commands) {
617
+ ctx.effect(() => commands.register({
618
+ name: 'memory',
619
+ description: 'Show, search, or forget persistent memory entries.',
620
+ input: { hint: '[list | search <keywords> | forget <id>]' },
621
+ handler: (invocation) => {
622
+ const raw = String(invocation?.rawInput ?? '').trim()
623
+ const [verb = 'list', ...rest] = raw.split(/\s+/u)
624
+ try {
625
+ if (verb === 'search') {
626
+ const matches = hub.search({ text: rest.join(' '), cwd: cwdFor(invocation?.agent), limit: 10 })
627
+ if (matches.length === 0) return { kind: 'success', text: `No match for "${rest.join(' ')}".` }
628
+ return {
629
+ kind: 'success',
630
+ text: matches.map(entry => `${entry.id} (${entry.kind}/${entry.scope ?? 'user'}) ${entry.title}`).join('\n'),
631
+ }
632
+ }
633
+ if (verb === 'forget') {
634
+ const id = rest[0]
635
+ if (!id) return { kind: 'error', text: '用法:/memory forget <id> --yes' }
636
+ if (!rest.includes('--yes')) {
637
+ const target = hub.locate(id)
638
+ if (target === undefined) return { kind: 'error', text: `没有 id 为 ${id} 的记忆条目` }
639
+ const entry = target.store.get(id)
640
+ return {
641
+ kind: 'success',
642
+ text: `确认要归档这条记忆吗?\n ${id} (${entry.kind}/${target.scope}) ${entry.title}\n归档不会删除,只是不再进入提示词,可恢复。确认请再输入:/memory forget ${id} --yes`,
643
+ }
644
+ }
645
+ const removed = hub.forget(id, false)
646
+ return { kind: 'success', text: `已归档 ${removed.id} (${removed.scope}):${removed.title}` }
647
+ }
648
+ if (verb === 'help') {
649
+ return { kind: 'success', text: 'Usage: /memory [list | search <keywords> | forget <id>]' }
650
+ }
651
+ const user = hub.user()
652
+ const cwd = cwdFor(invocation?.agent)
653
+ const project = cwd === '' ? undefined : hub.project(cwd)
654
+ const userFacts = user.list({ kind: 'fact', limit: 30 })
655
+ const userLessons = user.list({ kind: 'lesson', limit: 30 })
656
+ const projectFacts = project?.list({ kind: 'fact', limit: 30 }) ?? []
657
+ const projectLessons = project?.list({ kind: 'lesson', limit: 30 }) ?? []
658
+ const lines = [
659
+ `User ledger: ${user.path}`,
660
+ ` ${userFacts.length} facts, ${userLessons.length} lessons, ${user.stats().pending} pending incidents`,
661
+ ...(cwd === '' ? [] : ['', `Project ledger: ${project?.path ?? ''}`, ` ${projectFacts.length} facts, ${projectLessons.length} lessons`]),
662
+ '',
663
+ ...userFacts.map(entry => ` ${entry.id} [user] ${entry.title}`),
664
+ ...userLessons.map(entry => ` ${entry.id} [user] ${entry.title}`),
665
+ ...projectFacts.map(entry => ` ${entry.id} [project] ${entry.title}`),
666
+ ...projectLessons.map(entry => ` ${entry.id} [project] ${entry.title}`),
667
+ ]
668
+ return { kind: 'success', text: lines.join('\n') }
669
+ } catch (error) {
670
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
671
+ }
672
+ },
673
+ }))
674
+ }
675
+
676
+ // ── 6. Browser panel routes ───────────────────────────────────────────────
677
+ // The webserver is NOT ready when this plugin applies on a cold start (and a
678
+ // headless profile has none at all), so wait for the service instead of
679
+ // probing once: `ctx.inject` runs the callback when it appears and re-runs it
680
+ // after any change.
681
+ if (config.ui !== false) {
682
+ ctx.inject(['webServer'], (child) => {
683
+ const webServer = child.webServer
684
+ const guard = (handler) => async (request, response) => {
685
+ if (!sameOrigin(request)) {
686
+ sendJson(response, 403, { error: 'untrusted origin' })
687
+ return
688
+ }
689
+ try {
690
+ await handler(request, response)
691
+ } catch (error) {
692
+ logger?.warn?.(`${prefix}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`)
693
+ if (!response.headersSent) sendJson(response, 500, { error: 'memory ledger failure' })
694
+ }
695
+ }
696
+
697
+ child.effect(() => webServer.register({
698
+ kind: 'exact',
699
+ path: `${prefix}/ledger`,
700
+ handler: guard(async (request, response) => {
701
+ if (request.method !== 'GET') {
702
+ response.writeHead(405, { allow: 'GET' })
703
+ response.end()
704
+ return
705
+ }
706
+ const url = new URL(request.url ?? '/', 'http://localhost')
707
+ sendJson(response, 200, hub.snapshot(url.searchParams.get('cwd') ?? '', {
708
+ text: url.searchParams.get('q') ?? '',
709
+ kind: url.searchParams.get('kind') ?? undefined,
710
+ mode: url.searchParams.get('mode') ?? defaultMode,
711
+ scope: url.searchParams.get('scope') ?? undefined,
712
+ includeArchived: url.searchParams.get('archived') === '1',
713
+ }))
714
+ }),
715
+ }))
716
+
717
+ child.effect(() => webServer.register({
718
+ kind: 'exact',
719
+ path: `${prefix}/update`,
720
+ handler: guard(async (request, response) => {
721
+ if (request.method !== 'POST') {
722
+ response.writeHead(405, { allow: 'POST' })
723
+ response.end()
724
+ return
725
+ }
726
+ const body = await readJsonBody(request, 16_384)
727
+ const id = typeof body?.id === 'string' ? body.id : ''
728
+ if (id === '') {
729
+ sendJson(response, 400, { error: 'id is required' })
730
+ return
731
+ }
732
+ try {
733
+ const { entry, scope } = hub.patch(id, {
734
+ title: typeof body.title === 'string' ? body.title : undefined,
735
+ body: typeof body.body === 'string' ? body.body : undefined,
736
+ key: typeof body.key === 'string' ? body.key : undefined,
737
+ tags: typeof body.tags === 'string' || Array.isArray(body.tags) ? body.tags : undefined,
738
+ pinned: typeof body.pinned === 'boolean' ? body.pinned : undefined,
739
+ kind: typeof body.kind === 'string' ? body.kind : undefined,
740
+ reason: typeof body.reason === 'string' ? body.reason : undefined,
741
+ archived: typeof body.archived === 'boolean' ? body.archived : undefined,
742
+ })
743
+ sendJson(response, 200, { id: entry.id, kind: entry.kind, title: entry.title, key: entry.key, archived: entry.archived, scope })
744
+ } catch (error) {
745
+ const message = error instanceof Error ? error.message : String(error)
746
+ sendJson(response, /no memory entry/u.test(message) ? 404 : 400, { error: message })
747
+ }
748
+ }),
749
+ }))
750
+
751
+ child.effect(() => webServer.register({
752
+ kind: 'exact',
753
+ path: `${prefix}/forget`,
754
+ handler: guard(async (request, response) => {
755
+ if (request.method !== 'POST') {
756
+ response.writeHead(405, { allow: 'POST' })
757
+ response.end()
758
+ return
759
+ }
760
+ const body = await readJsonBody(request)
761
+ const id = typeof body?.id === 'string' ? body.id : ''
762
+ if (id === '') {
763
+ sendJson(response, 400, { error: 'id is required' })
764
+ return
765
+ }
766
+ try {
767
+ sendJson(response, 200, hub.forget(id, body?.hard === true))
768
+ } catch (error) {
769
+ const message = error instanceof Error ? error.message : String(error)
770
+ sendJson(response, /no memory entry/u.test(message) ? 404 : 500, { error: message })
771
+ }
772
+ }),
773
+ }))
774
+ })
775
+ }
776
+ }