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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README-zh.md +41 -0
- package/README.md +19 -2
- package/client.js +557 -0
- package/cordis.patch.yml +22 -0
- package/docs/DETAIL.md +151 -0
- package/icon.svg +17 -0
- package/index.js +776 -0
- package/ledger.js +352 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +72 -3
- package/reminder.js +83 -0
- package/store.js +895 -0
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
|
+
}
|