@heixui/dsh-session-suite 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +381 -2
- package/README.zh.md +178 -0
- package/THIRD-PARTY.md +64 -0
- package/client.js +2974 -0
- package/cordis.patch.yml +77 -0
- package/icon.svg +19 -0
- package/index.js +211 -0
- package/lib/api.js +387 -0
- package/lib/checkpoints.js +774 -0
- package/lib/data/jieba-dict.txt +109750 -0
- package/lib/factkeys.js +212 -0
- package/lib/markdown.js +252 -0
- package/lib/memory-engine.js +1323 -0
- package/lib/memory-host.js +683 -0
- package/lib/memory.js +224 -0
- package/lib/retrieval.js +217 -0
- package/lib/service.js +1273 -0
- package/lib/share.js +641 -0
- package/lib/step-tag-filter.js +199 -0
- package/lib/tags.js +140 -0
- package/lib/util.js +261 -0
- package/lib/wordlist.js +183 -0
- package/locale/en.json +87 -0
- package/locale/zh.json +87 -0
- package/package.json +76 -4
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host wiring for the long-term memory engine.
|
|
3
|
+
*
|
|
4
|
+
* Three responsibilities:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Recall into the prompt** — a `systemPrompt.context()` contribution that
|
|
7
|
+
* injects only the memories relevant to the current question (never the whole
|
|
8
|
+
* library), workspace entries first.
|
|
9
|
+
* 2. **Explicit writes** — the `【记住:…】` / `【新增全局记忆:…】` forms are parsed
|
|
10
|
+
* from the user's message before the step runs, so they never depend on the
|
|
11
|
+
* model deciding to call a tool.
|
|
12
|
+
* 3. **Implicit extraction** — after a turn ends, one cheap model call proposes
|
|
13
|
+
* durable facts; they are queued as *pending suggestions* and only become
|
|
14
|
+
* memories when the user confirms them.
|
|
15
|
+
*
|
|
16
|
+
* Plus the `memory` tool and the HTTP surface the Client settings page uses.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { MemoryEngine } from './memory-engine.js'
|
|
20
|
+
import { normalizeTags } from './tags.js'
|
|
21
|
+
import { isMeaningfulText, isUserAuthored } from './util.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The bracket forms that count as an explicit memory instruction.
|
|
25
|
+
*
|
|
26
|
+
* Exported (in the export block at the foot of this file) so that
|
|
27
|
+
* `tools/audit-explicit-captures.mjs` can replay the *production* patterns
|
|
28
|
+
* against real session logs instead of a copy that drifts from them.
|
|
29
|
+
*/
|
|
30
|
+
const EXPLICIT_PATTERNS = [
|
|
31
|
+
{ pattern: /【(?:新增)?全局记忆[::]\s*(.*?)】/g, scope: 'global' },
|
|
32
|
+
{ pattern: /【(?:新增)?本工作区记忆[::]\s*(.*?)】/g, scope: 'workspace' },
|
|
33
|
+
{ pattern: /【记住[::]\s*(.*?)】/g, scope: 'workspace' },
|
|
34
|
+
{ pattern: /\[(?:新增)?全局记忆[::]\s*(.*?)\]/g, scope: 'global' },
|
|
35
|
+
{ pattern: /\[记住[::]\s*(.*?)\]/g, scope: 'workspace' },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pull the explicit memory instructions out of one step's messages.
|
|
40
|
+
*
|
|
41
|
+
* Exported so the policy can be tested without standing up a Host context. The
|
|
42
|
+
* two guards here are the whole point: only user-authored messages count, and
|
|
43
|
+
* only meaningful text is stored. Both exist because their absence produced 154
|
|
44
|
+
* identical placeholder memories — the plugin read its own instruction footer
|
|
45
|
+
* back out of a runtime-context message and obeyed itself.
|
|
46
|
+
*
|
|
47
|
+
* @param {readonly any[]} messages step messages
|
|
48
|
+
* @returns {Array<{ scope: string, text: string }>} instructions, in order
|
|
49
|
+
*/
|
|
50
|
+
export function collectExplicitWrites(messages) {
|
|
51
|
+
const out = []
|
|
52
|
+
for (const message of messages ?? []) {
|
|
53
|
+
if (!isUserAuthored(message)) continue
|
|
54
|
+
const text = contentText(message.content)
|
|
55
|
+
if (!text) continue
|
|
56
|
+
const found = []
|
|
57
|
+
for (const { pattern, scope } of EXPLICIT_PATTERNS) {
|
|
58
|
+
pattern.lastIndex = 0
|
|
59
|
+
for (const match of text.matchAll(pattern)) {
|
|
60
|
+
const value = (match[1] ?? '').trim()
|
|
61
|
+
if (!isMeaningfulText(value)) continue
|
|
62
|
+
found.push({ scope, text: value, at: match.index ?? 0 })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Return instructions in the order they were written. Looping patterns on
|
|
66
|
+
// the outside grouped them by which pattern matched, so a global
|
|
67
|
+
// instruction written second came back first.
|
|
68
|
+
found.sort((a, b) => a.at - b.at)
|
|
69
|
+
for (const { scope, text: value } of found) out.push({ scope, text: value })
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Words that make a sentence a durable rule rather than a one-off task. */
|
|
75
|
+
const DURABLE_HINTS = /(以后|今后|之后|默认|一律|总是|永远|每次|始终|不要|必须|禁止|优先|习惯|偏好|规则|约定|记住|always|never|prefer|by default|from now on)/i
|
|
76
|
+
/** Words that make a sentence a one-off request, which must not be remembered. */
|
|
77
|
+
const TRANSIENT_HINTS = /(现在|这次|本次|帮我|马上|临时|今天|刚刚|试一下|测试|just now|this time|temporarily|for now)/i
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {object} options wiring options
|
|
81
|
+
* @param {any} options.ctx plugin context
|
|
82
|
+
* @param {string} options.root suite data directory
|
|
83
|
+
* @param {any} options.config plugin config
|
|
84
|
+
* @param {(message: string, error?: unknown) => void} options.log logger
|
|
85
|
+
* @returns {{ engine: MemoryEngine, dispose: () => void }} engine and disposer
|
|
86
|
+
*/
|
|
87
|
+
export function installMemory({ ctx, root, config, log }) {
|
|
88
|
+
const engine = new MemoryEngine({ root, log, config })
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Run one optional registration; a failure is reported and skipped so a single
|
|
92
|
+
* incompatible hook can never disable the rest of the bundle.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} label what is being installed
|
|
95
|
+
* @param {() => void} install registration body
|
|
96
|
+
*/
|
|
97
|
+
const safe = (label, install) => {
|
|
98
|
+
try {
|
|
99
|
+
install()
|
|
100
|
+
} catch (error) {
|
|
101
|
+
log(`long-term memory: ${label} failed to install`, error)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Recall text per agent scope. `agent/pre-step` runs before the prompt is
|
|
107
|
+
* assembled for that step and is the only place the current question is
|
|
108
|
+
* visible, so it computes the recall here and the context provider below
|
|
109
|
+
* reads it back by scope.
|
|
110
|
+
*/
|
|
111
|
+
const recallByScope = new Map()
|
|
112
|
+
let lastRecall = ''
|
|
113
|
+
// Diagnostics for the settings page: "did the last rebuild fail, and why" must
|
|
114
|
+
// be answerable, because the failure mode is invisible otherwise — the model
|
|
115
|
+
// simply behaves as if the memory is missing.
|
|
116
|
+
let lastRecallFailed = false
|
|
117
|
+
let lastRecallError = ''
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* How long a computed recall may be reused before it must be recomputed.
|
|
121
|
+
*
|
|
122
|
+
* `agent/pre-step` normally refreshes it every step, so this only matters when
|
|
123
|
+
* a step does not run the hook (a resumed session, a step served from a cached
|
|
124
|
+
* decision). Without a bound, a memory written elsewhere could stay invisible
|
|
125
|
+
* for the life of the process.
|
|
126
|
+
*/
|
|
127
|
+
const RECALL_TTL_MS = 60_000
|
|
128
|
+
|
|
129
|
+
// --- 1. Recall into the system prompt -----------------------------------
|
|
130
|
+
const systemPrompt = ctx.get('systemPrompt')
|
|
131
|
+
if (systemPrompt?.context) {
|
|
132
|
+
safe('prompt context', () =>
|
|
133
|
+
ctx.effect(
|
|
134
|
+
() =>
|
|
135
|
+
systemPrompt.context({
|
|
136
|
+
name: 'session-suite-memory',
|
|
137
|
+
order: 900,
|
|
138
|
+
text: context => {
|
|
139
|
+
const scoped = context?.scope ? recallByScope.get(context.scope) : undefined
|
|
140
|
+
// Expiry is checked here rather than only at write time: a cached
|
|
141
|
+
// value outlives its usefulness the moment the library changes
|
|
142
|
+
// underneath it, and this is the only place that can see the age.
|
|
143
|
+
if (scoped && Date.now() - scoped.at < RECALL_TTL_MS) return scoped.text
|
|
144
|
+
return lastRecall || ''
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
'session-suite: memory context',
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- 2. Explicit writes, before the step runs ---------------------------
|
|
153
|
+
safe('explicit writes', () =>
|
|
154
|
+
ctx.effect(
|
|
155
|
+
() =>
|
|
156
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
157
|
+
const decision = await next()
|
|
158
|
+
try {
|
|
159
|
+
const agent = payload.agent
|
|
160
|
+
const cwd = resolveCwd(ctx, agent)
|
|
161
|
+
const messages = decision?.messages ?? payload.messages ?? []
|
|
162
|
+
|
|
163
|
+
// Explicit instructions are written before anything else, so the very
|
|
164
|
+
// same step already sees them through recall.
|
|
165
|
+
for (const instruction of collectExplicitWrites(messages)) {
|
|
166
|
+
await engine.remember({
|
|
167
|
+
scope: instruction.scope,
|
|
168
|
+
cwd,
|
|
169
|
+
text: instruction.text,
|
|
170
|
+
tags: inferTags(instruction.text),
|
|
171
|
+
affect: DURABLE_HINTS.test(instruction.text) ? 0.85 : 0.6,
|
|
172
|
+
source: 'explicit',
|
|
173
|
+
sessionId: agent?.sessionId,
|
|
174
|
+
})
|
|
175
|
+
log(`explicit memory stored (${instruction.scope})`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const question = lastUserText(messages)
|
|
179
|
+
const text = await buildRecallText({ engine, cwd, question, config })
|
|
180
|
+
if (agent) recallByScope.set(agent, { text, at: Date.now() })
|
|
181
|
+
lastRecall = text
|
|
182
|
+
lastRecallFailed = false
|
|
183
|
+
} catch (error) {
|
|
184
|
+
// A failed rebuild must not leave the previous answer in place.
|
|
185
|
+
//
|
|
186
|
+
// Observed in the wild: one failed rebuild, and every later turn kept
|
|
187
|
+
// injecting the memory text computed BEFORE it — a memory written after
|
|
188
|
+
// that point was invisible for the rest of the process, which reads as
|
|
189
|
+
// "the other workspace does not know". Stale recall is worse than no
|
|
190
|
+
// recall: empty at least tells the truth.
|
|
191
|
+
lastRecall = ''
|
|
192
|
+
if (agent) recallByScope.delete(agent)
|
|
193
|
+
lastRecallFailed = true
|
|
194
|
+
lastRecallError = error instanceof Error ? error.message : String(error)
|
|
195
|
+
log('memory recall failed', error)
|
|
196
|
+
}
|
|
197
|
+
return decision
|
|
198
|
+
}),
|
|
199
|
+
'session-suite: memory recall and explicit writes',
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
// --- 3. Implicit extraction after a turn --------------------------------
|
|
204
|
+
safe('implicit extraction', () =>
|
|
205
|
+
ctx.effect(
|
|
206
|
+
() =>
|
|
207
|
+
ctx.on('session/event', (session, event) => {
|
|
208
|
+
if (!event || event.type !== 'turn/end') return
|
|
209
|
+
const sessionId = session?.id
|
|
210
|
+
if (typeof sessionId !== 'string') return
|
|
211
|
+
const cwd = resolveCwd(ctx, session)
|
|
212
|
+
void extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }).catch(error =>
|
|
213
|
+
log('implicit memory extraction failed', error),
|
|
214
|
+
)
|
|
215
|
+
}),
|
|
216
|
+
'session-suite: implicit memory',
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
// --- 3b. Automatic maintenance -------------------------------------------
|
|
221
|
+
// Scoring, decay, clustering, and cold archiving are otherwise only reachable
|
|
222
|
+
// through the settings page, which means a memory library that nobody opens
|
|
223
|
+
// never compresses. Run it on an interval instead, over the global store plus
|
|
224
|
+
// whatever workspace the most recent turn used.
|
|
225
|
+
//
|
|
226
|
+
// The pass is skipped when nothing has changed since the previous one: an idle
|
|
227
|
+
// Host should not wake up on a timer to rescore memory it has already scored.
|
|
228
|
+
// Skipping is also the honest signal that the timer is alive, because a pass
|
|
229
|
+
// that runs always writes its bookkeeping.
|
|
230
|
+
const maintainEveryMs = numberOr(config.maintainIntervalMs, 30 * 60 * 1000)
|
|
231
|
+
if (maintainEveryMs > 0) {
|
|
232
|
+
safe('automatic maintenance', () =>
|
|
233
|
+
ctx.effect(() => {
|
|
234
|
+
let lastCwd
|
|
235
|
+
let running = false
|
|
236
|
+
const tick = async () => {
|
|
237
|
+
if (running) return
|
|
238
|
+
running = true
|
|
239
|
+
try {
|
|
240
|
+
const due = await engine.needsMaintenance(lastCwd)
|
|
241
|
+
if (!due) return
|
|
242
|
+
const report = await engine.maintain(lastCwd)
|
|
243
|
+
log(
|
|
244
|
+
`memory maintenance: scored ${report.scored}, archived ${report.archived}, ` +
|
|
245
|
+
`compressed ${report.compressed}, clusters ${report.clusters}, rules ${report.rules}`,
|
|
246
|
+
)
|
|
247
|
+
} catch (error) {
|
|
248
|
+
log('memory maintenance failed', error)
|
|
249
|
+
} finally {
|
|
250
|
+
running = false
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const timer = setInterval(tick, maintainEveryMs)
|
|
254
|
+
timer.unref?.()
|
|
255
|
+
// Remember which workspace to maintain as turns arrive.
|
|
256
|
+
const off = ctx.on('session/event', (session, event) => {
|
|
257
|
+
if (!event || event.type !== 'turn/end') return
|
|
258
|
+
try {
|
|
259
|
+
const cwd = resolveCwd(ctx, session)
|
|
260
|
+
if (cwd) lastCwd = cwd
|
|
261
|
+
} catch {
|
|
262
|
+
// leave the previous workspace in place
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
return () => {
|
|
266
|
+
clearInterval(timer)
|
|
267
|
+
off?.()
|
|
268
|
+
}
|
|
269
|
+
}),
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- 4. The memory tool -------------------------------------------------
|
|
274
|
+
// `ToolDefinition` requires an `output` descriptor; without it `register`
|
|
275
|
+
// throws and would take the whole bundle down, so it is provided here and the
|
|
276
|
+
// registration itself stays inside a guard.
|
|
277
|
+
const tools = ctx.get('tools')
|
|
278
|
+
if (tools?.register) {
|
|
279
|
+
ctx.effect(() => {
|
|
280
|
+
try {
|
|
281
|
+
return tools.register({
|
|
282
|
+
name: 'memory',
|
|
283
|
+
description:
|
|
284
|
+
'Manage long-term memory. `remember` stores a durable fact or preference (scope: workspace by default, or global). `recall` searches memory. `list` shows scores and decay state. `forget` deletes one entry. Use it when the user states a lasting rule, preference, or convention.',
|
|
285
|
+
parameters: {
|
|
286
|
+
type: 'object',
|
|
287
|
+
properties: {
|
|
288
|
+
action: { type: 'string', enum: ['remember', 'recall', 'list', 'forget', 'freeze'] },
|
|
289
|
+
text: { type: 'string', description: 'the memory text (remember)' },
|
|
290
|
+
scope: { type: 'string', enum: ['workspace', 'global'] },
|
|
291
|
+
query: { type: 'string', description: 'what to look for (recall)' },
|
|
292
|
+
id: { type: 'string', description: 'entry id (forget / freeze)' },
|
|
293
|
+
frozen: { type: 'boolean', description: 'freeze state (freeze)' },
|
|
294
|
+
tags: {
|
|
295
|
+
type: 'array',
|
|
296
|
+
items: { type: 'string' },
|
|
297
|
+
description:
|
|
298
|
+
'domain tags for remember, e.g. 错误教训 / 迭代版本 / 技术方案. Tags are canonicalised and filtered: spellings of the same domain fold together (DSH → dsh), and words that name UI rather than a subject (快照 / 删除) are dropped. Unknown subjects are kept, so a new domain is fine.',
|
|
299
|
+
},
|
|
300
|
+
factKey: {
|
|
301
|
+
type: 'string',
|
|
302
|
+
description:
|
|
303
|
+
'optional "subject.aspect" key for remember, e.g. 她.身高 or 约定.见面地点. Use it only for a fact whose value can change: entries sharing a key are treated as the same fact, so the newest one supersedes the older ones at recall time. NEVER put the value in the key (write 她.身高, not 她.身高170) — a key containing the value changes whenever the value does, so the two records never meet. Leave it out when unsure; an absent key is harmless, a wrong key can retire a good memory.',
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
required: ['action'],
|
|
307
|
+
},
|
|
308
|
+
output: {
|
|
309
|
+
schema: { type: 'object' },
|
|
310
|
+
render: (_args, value) => [
|
|
311
|
+
{
|
|
312
|
+
type: 'text',
|
|
313
|
+
text: typeof value === 'string' ? value : JSON.stringify(value, null, 2).slice(0, 4000),
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
async execute(args, exec) {
|
|
318
|
+
const cwd = resolveCwd(ctx, exec?.agent)
|
|
319
|
+
if (args.action === 'remember') {
|
|
320
|
+
if (typeof args.text !== 'string' || !args.text.trim()) throw new Error('text is required')
|
|
321
|
+
const result = await engine.remember({
|
|
322
|
+
scope: args.scope === 'global' ? 'global' : 'workspace',
|
|
323
|
+
cwd,
|
|
324
|
+
text: args.text,
|
|
325
|
+
// Tags are normalised rather than trusted verbatim. The library
|
|
326
|
+
// had `dsh` and `DSH` as separate domains and UI words like
|
|
327
|
+
// `快照` filed as if they were subjects — see lib/tags.js.
|
|
328
|
+
tags: Array.isArray(args.tags) && args.tags.length > 0
|
|
329
|
+
? normalizeTags(args.tags)
|
|
330
|
+
: inferTags(args.text),
|
|
331
|
+
factKey: typeof args.factKey === 'string' ? args.factKey : undefined,
|
|
332
|
+
affect: DURABLE_HINTS.test(args.text) ? 0.85 : 0.6,
|
|
333
|
+
source: 'tool',
|
|
334
|
+
})
|
|
335
|
+
return { stored: result.entry, duplicate: Boolean(result.duplicateOf) }
|
|
336
|
+
}
|
|
337
|
+
if (args.action === 'recall') {
|
|
338
|
+
const items = await engine.recall({ cwd, query: args.query ?? '', limit: 8 })
|
|
339
|
+
return { memories: items.map(brief) }
|
|
340
|
+
}
|
|
341
|
+
if (args.action === 'list') {
|
|
342
|
+
const items = await engine.list({ cwd })
|
|
343
|
+
return { memories: items.map(brief), pending: (await engine.pending(cwd)).map(brief) }
|
|
344
|
+
}
|
|
345
|
+
if (args.action === 'forget') {
|
|
346
|
+
if (!args.id) throw new Error('id is required')
|
|
347
|
+
return engine.remove({ id: args.id, cwd, scope: args.scope === 'global' ? 'global' : undefined })
|
|
348
|
+
}
|
|
349
|
+
if (args.action === 'freeze') {
|
|
350
|
+
if (!args.id) throw new Error('id is required')
|
|
351
|
+
return engine.update({ id: args.id, cwd, scope: args.scope === 'global' ? 'global' : undefined, patch: { frozen: args.frozen !== false } })
|
|
352
|
+
}
|
|
353
|
+
throw new Error(`unknown action: ${args.action}`)
|
|
354
|
+
},
|
|
355
|
+
})
|
|
356
|
+
} catch (error) {
|
|
357
|
+
log('memory tool registration failed; continuing without it', error)
|
|
358
|
+
return () => {}
|
|
359
|
+
}
|
|
360
|
+
}, 'session-suite: memory tool')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
engine,
|
|
365
|
+
dispose: () => {},
|
|
366
|
+
// Exposed so the settings page and the status endpoint can report whether the
|
|
367
|
+
// last recall rebuild failed. Without this the failure is silent: the model
|
|
368
|
+
// simply acts as if the memory is not there.
|
|
369
|
+
recallStatus: () => ({
|
|
370
|
+
failed: lastRecallFailed,
|
|
371
|
+
error: lastRecallError,
|
|
372
|
+
hasText: Boolean(lastRecall) || recallByScope.size > 0,
|
|
373
|
+
scopes: recallByScope.size,
|
|
374
|
+
}),
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Compose the recall block injected into the prompt.
|
|
380
|
+
*
|
|
381
|
+
* @param {object} input recall input
|
|
382
|
+
* @param {MemoryEngine} input.engine memory engine
|
|
383
|
+
* @param {string} [input.cwd] workspace
|
|
384
|
+
* @param {string} [input.question] the current question
|
|
385
|
+
* @param {any} input.config plugin config
|
|
386
|
+
* @returns {Promise<string>} prompt text (empty when nothing is relevant)
|
|
387
|
+
*/
|
|
388
|
+
async function buildRecallText({ engine, cwd, question, config }) {
|
|
389
|
+
const limit = typeof config?.recallLimit === 'number' ? config.recallLimit : 6
|
|
390
|
+
const residentLimit = typeof config?.residentLimit === 'number' ? config.residentLimit : 2
|
|
391
|
+
|
|
392
|
+
// Query-matched memories first. `select` decides how many the evidence
|
|
393
|
+
// actually justifies, and records the reason for every candidate it dropped so
|
|
394
|
+
// the settings page can explain a recall instead of leaving it a mystery.
|
|
395
|
+
const { items: matched } = await engine.select({ cwd, query: question ?? '', limit })
|
|
396
|
+
const usable = matched.filter(item => !item.archived)
|
|
397
|
+
|
|
398
|
+
// Then a small resident layer: the most important memories ride along whatever
|
|
399
|
+
// the question happened to be. Without it, a follow-up like "继续" shares no
|
|
400
|
+
// characters with any memory, so a strict relevance filter would inject nothing
|
|
401
|
+
// at all — and a feature that vanishes on ordinary follow-ups reads as broken,
|
|
402
|
+
// not as a relevance decision. This is the same shape as the resident layer in
|
|
403
|
+
// dsh-fish-memory's design: durable facts get their own small quota instead of
|
|
404
|
+
// competing with question-matched events.
|
|
405
|
+
if (residentLimit > 0 && usable.length < limit) {
|
|
406
|
+
const ranked = await engine.recall({ cwd, query: '', limit, touch: false })
|
|
407
|
+
let residents = 0
|
|
408
|
+
for (const item of ranked) {
|
|
409
|
+
if (residents >= residentLimit || usable.length >= limit) break
|
|
410
|
+
if (item.archived) continue
|
|
411
|
+
if (usable.some(existing => existing.id === item.id)) continue
|
|
412
|
+
usable.push(item)
|
|
413
|
+
residents += 1
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (usable.length === 0) return ''
|
|
418
|
+
const lines = usable.map(item => `- ${item.scope === 'global' ? '[全局]' : '[本工作区]'} ${item.text}`)
|
|
419
|
+
return [
|
|
420
|
+
'## 长期记忆(跨会话)',
|
|
421
|
+
'',
|
|
422
|
+
'以下条目来自长期记忆库,与本轮问题相关。它们是稳定事实或约定,不是本轮对话内容;冲突时以用户当前的话为准。',
|
|
423
|
+
'',
|
|
424
|
+
...lines,
|
|
425
|
+
'',
|
|
426
|
+
'如果用户说【记住:…】或【新增全局记忆:…】,立即照做;如果这些记忆已经过时,用 memory 工具更正。',
|
|
427
|
+
].join('\n')
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** @param {readonly any[]} messages step messages @returns {string} last user text */
|
|
431
|
+
function lastUserText(messages) {
|
|
432
|
+
for (let index = (messages?.length ?? 0) - 1; index >= 0; index -= 1) {
|
|
433
|
+
const message = messages[index]
|
|
434
|
+
if (message?.role !== 'user') continue
|
|
435
|
+
const text = contentText(message.content)
|
|
436
|
+
if (text) return text
|
|
437
|
+
}
|
|
438
|
+
return ''
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Ask a small model call what, if anything, is worth remembering from this turn.
|
|
443
|
+
*
|
|
444
|
+
* @param {object} input extraction input
|
|
445
|
+
* @param {any} input.ctx plugin context
|
|
446
|
+
* @param {MemoryEngine} input.engine memory engine
|
|
447
|
+
* @param {string} input.sessionId session
|
|
448
|
+
* @param {string} [input.cwd] workspace
|
|
449
|
+
* @param {(message: string, error?: unknown) => void} input.log logger
|
|
450
|
+
* @param {any} input.config plugin config
|
|
451
|
+
*/
|
|
452
|
+
async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
|
|
453
|
+
const settings = await engine.settings()
|
|
454
|
+
if (settings.implicit === false) return
|
|
455
|
+
const llm = ctx.get('llm')
|
|
456
|
+
if (!llm?.stream) return
|
|
457
|
+
|
|
458
|
+
const sessionController = ctx.get('sessionController')
|
|
459
|
+
const inspection = await sessionController?.inspect?.(sessionId).catch(() => undefined)
|
|
460
|
+
const events = inspection?.events ?? []
|
|
461
|
+
const turn = lastCompletedTurn(events)
|
|
462
|
+
if (typeof turn !== 'number') return
|
|
463
|
+
const transcript = turnTranscript(events, turn)
|
|
464
|
+
if (!transcript) return
|
|
465
|
+
|
|
466
|
+
// The memory model is a fallback chain: a free provider that is rate limited
|
|
467
|
+
// (429) or unreachable must not silently disable extraction for the session,
|
|
468
|
+
// so every candidate is tried in order until one answers.
|
|
469
|
+
const candidates = memoryCandidates(config)
|
|
470
|
+
if (candidates.length === 0) return
|
|
471
|
+
|
|
472
|
+
const prompt = `下面是一轮对话。找出其中**长期有效**的信息:稳定偏好、固定技术约定、长期策略、用户固有习惯。\n单次任务、一次性查询、临时调试、闲聊都不要。\n只输出 JSON:{"memories":[{"text":"...","scope":"workspace|global","affect":0.0-1.0,"tags":["..."]}]}\n没有就输出 {"memories":[]}。\n\n对话:\n${transcript}`
|
|
473
|
+
|
|
474
|
+
let answer = ''
|
|
475
|
+
let used
|
|
476
|
+
for (const candidate of candidates) {
|
|
477
|
+
answer = ''
|
|
478
|
+
try {
|
|
479
|
+
for await (const chunk of llm.stream({
|
|
480
|
+
provider: candidate.provider,
|
|
481
|
+
model: candidate.model,
|
|
482
|
+
reasoningEffort: 'low',
|
|
483
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
484
|
+
maxTokens: 400,
|
|
485
|
+
purpose: 'compaction',
|
|
486
|
+
})) {
|
|
487
|
+
if (chunk?.type === 'text-delta') answer += chunk.text
|
|
488
|
+
}
|
|
489
|
+
if (answer.trim()) {
|
|
490
|
+
used = candidate
|
|
491
|
+
break
|
|
492
|
+
}
|
|
493
|
+
log(`memory extraction returned nothing from ${candidate.provider}/${candidate.model}; trying the next model`)
|
|
494
|
+
} catch (error) {
|
|
495
|
+
log(`memory extraction failed on ${candidate.provider}/${candidate.model}: ${errorMessage(error)}`)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (!answer.trim()) return
|
|
499
|
+
log(`memory extraction answered by ${used?.provider}/${used?.model}`)
|
|
500
|
+
|
|
501
|
+
const parsed = parseJsonBlock(answer)
|
|
502
|
+
if (!parsed?.memories?.length) return
|
|
503
|
+
for (const item of parsed.memories.slice(0, 4)) {
|
|
504
|
+
const text = String(item.text ?? '').trim()
|
|
505
|
+
if (!text || !DURABLE_HINTS.test(text)) continue
|
|
506
|
+
if (TRANSIENT_HINTS.test(text)) continue
|
|
507
|
+
await engine.suggest({
|
|
508
|
+
text,
|
|
509
|
+
scope: item.scope === 'global' ? 'global' : 'workspace',
|
|
510
|
+
cwd,
|
|
511
|
+
tags: Array.isArray(item.tags) ? item.tags : inferTags(text),
|
|
512
|
+
affect: typeof item.affect === 'number' ? item.affect : 0.6,
|
|
513
|
+
sessionId,
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** @param {string} text model output @returns {any | undefined} parsed JSON */
|
|
519
|
+
function parseJsonBlock(text) {
|
|
520
|
+
const match = /\{[\s\S]*\}/.exec(String(text ?? ''))
|
|
521
|
+
if (!match) return undefined
|
|
522
|
+
try {
|
|
523
|
+
return JSON.parse(match[0])
|
|
524
|
+
} catch {
|
|
525
|
+
return undefined
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** @param {unknown} error @returns {string} readable message */
|
|
530
|
+
function errorMessage(error) {
|
|
531
|
+
if (error instanceof Error) return error.message
|
|
532
|
+
return String(error)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Coerce a config value to a finite number.
|
|
537
|
+
*
|
|
538
|
+
* @param {unknown} value raw config value
|
|
539
|
+
* @param {number} fallback used when the value is absent or not a number
|
|
540
|
+
* @returns {number} the number
|
|
541
|
+
*/
|
|
542
|
+
function numberOr(value, fallback) {
|
|
543
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The ordered model fallback chain for implicit extraction.
|
|
548
|
+
*
|
|
549
|
+
* `memoryModel` may be a single `{ provider, model }` or an array of them; a
|
|
550
|
+
* `memoryModels` array is also accepted so the profile can list the whole chain
|
|
551
|
+
* without nesting. Entries missing a provider or model are dropped.
|
|
552
|
+
*
|
|
553
|
+
* @param {any} config plugin config
|
|
554
|
+
* @returns {{ provider: string, model: string }[]} candidates in priority order
|
|
555
|
+
*/
|
|
556
|
+
function memoryCandidates(config) {
|
|
557
|
+
const raw = []
|
|
558
|
+
const push = value => {
|
|
559
|
+
if (Array.isArray(value)) raw.push(...value)
|
|
560
|
+
else if (value) raw.push(value)
|
|
561
|
+
}
|
|
562
|
+
push(config?.memoryModels)
|
|
563
|
+
push(config?.memoryModel)
|
|
564
|
+
const out = []
|
|
565
|
+
const seen = new Set()
|
|
566
|
+
for (const entry of raw) {
|
|
567
|
+
if (!entry || typeof entry !== 'object') continue
|
|
568
|
+
const provider = entry.provider
|
|
569
|
+
const model = entry.model
|
|
570
|
+
if (typeof provider !== 'string' || !provider) continue
|
|
571
|
+
if (typeof model !== 'string' || !model) continue
|
|
572
|
+
const key = `${provider}/${model}`
|
|
573
|
+
if (seen.has(key)) continue
|
|
574
|
+
seen.add(key)
|
|
575
|
+
out.push({ provider, model })
|
|
576
|
+
}
|
|
577
|
+
return out
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** @param {string} text memory text @returns {string[]} tags */
|
|
581
|
+
function inferTags(text) {
|
|
582
|
+
const value = String(text ?? '')
|
|
583
|
+
const tags = []
|
|
584
|
+
if (/(trae|workbuddy|open code|opencode)/i.test(value)) tags.push('调度')
|
|
585
|
+
if (/(插件|plugin|bundle|dsh)/i.test(value)) tags.push('dsh')
|
|
586
|
+
if (/(模型|model|积分|额度|credit)/i.test(value)) tags.push('模型')
|
|
587
|
+
if (/(文件|目录|路径|workspace|工作区)/i.test(value)) tags.push('工作区')
|
|
588
|
+
if (/(习惯|偏好|喜欢|prefer)/i.test(value)) tags.push('偏好')
|
|
589
|
+
return tags
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** @param {readonly any[]} events log @param {number} turn target @returns {string} transcript */
|
|
593
|
+
function turnTranscript(events, turn) {
|
|
594
|
+
const lines = []
|
|
595
|
+
for (const event of events) {
|
|
596
|
+
if (typeof event?.data?.turn !== 'number' || event.data.turn !== turn) continue
|
|
597
|
+
if (event.type === 'user/message') {
|
|
598
|
+
const kind = event.data?.source?.kind
|
|
599
|
+
if (kind !== undefined && kind !== 'user') continue
|
|
600
|
+
const text = contentText(event.data.content)
|
|
601
|
+
if (text) lines.push(`用户:${text}`)
|
|
602
|
+
} else if (event.type === 'assistant/message') {
|
|
603
|
+
const text = contentText(event.data?.message?.content)
|
|
604
|
+
if (text) lines.push(`助手:${text}`)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const joined = lines.join('\n')
|
|
608
|
+
return joined.length > 4000 ? joined.slice(-4000) : joined
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** @param {readonly any[]} events log @returns {number | undefined} turn */
|
|
612
|
+
function lastCompletedTurn(events) {
|
|
613
|
+
let turn
|
|
614
|
+
for (const event of events) if (event?.type === 'turn/end' && typeof event.data?.turn === 'number') turn = event.data.turn
|
|
615
|
+
return turn
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** @param {any} content message content @returns {string} text */
|
|
619
|
+
function contentText(content) {
|
|
620
|
+
if (typeof content === 'string') return content
|
|
621
|
+
if (!Array.isArray(content)) return ''
|
|
622
|
+
return content
|
|
623
|
+
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
|
624
|
+
.map(block => block.text)
|
|
625
|
+
.join('\n')
|
|
626
|
+
.trim()
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** @param {any} entry memory entry @returns {any} compact view */
|
|
630
|
+
function brief(entry) {
|
|
631
|
+
return {
|
|
632
|
+
id: entry.id,
|
|
633
|
+
text: entry.text,
|
|
634
|
+
scope: entry.scope,
|
|
635
|
+
kind: entry.kind,
|
|
636
|
+
score: entry.score,
|
|
637
|
+
state: entry.state ?? (entry.archived ? 'archived' : 'active'),
|
|
638
|
+
frozen: entry.frozen === true,
|
|
639
|
+
tags: entry.tags ?? [],
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Resolve the workspace directory an agent or session belongs to.
|
|
645
|
+
*
|
|
646
|
+
* The cwd is needed to pick the per-workspace memory store, so a miss here
|
|
647
|
+
* silently downgrades every workspace memory to the global one. There is no
|
|
648
|
+
* `workingDirectory` service in this harness; the authoritative source is the
|
|
649
|
+
* session header (`header.cwd`), which the session service validates as an
|
|
650
|
+
* absolute path. An agent reaches it through its own `session`, and an agent
|
|
651
|
+
* that exposes the header directly is handled too.
|
|
652
|
+
*
|
|
653
|
+
* @param {any} ctx plugin context
|
|
654
|
+
* @param {any} owner agent or session
|
|
655
|
+
* @returns {string | undefined} absolute cwd, when one is known
|
|
656
|
+
*/
|
|
657
|
+
function resolveCwd(ctx, owner) {
|
|
658
|
+
if (!owner) return undefined
|
|
659
|
+
|
|
660
|
+
// A session, or anything carrying the immutable header.
|
|
661
|
+
const direct = owner.header ?? owner.meta
|
|
662
|
+
if (typeof direct?.cwd === 'string' && direct.cwd.length > 0) return direct.cwd
|
|
663
|
+
|
|
664
|
+
// An agent: prefer its own session, then the session store by id.
|
|
665
|
+
const session = owner.session ?? owner.agent?.session
|
|
666
|
+
const header = session?.header
|
|
667
|
+
if (typeof header?.cwd === 'string' && header.cwd.length > 0) return header.cwd
|
|
668
|
+
|
|
669
|
+
const sessionId = owner.sessionId ?? session?.id
|
|
670
|
+
if (typeof sessionId === 'string') {
|
|
671
|
+
try {
|
|
672
|
+
const sessions = ctx.get('sessions')
|
|
673
|
+
const found = sessions?.get?.(sessionId)
|
|
674
|
+
const foundHeader = found?.header
|
|
675
|
+
if (typeof foundHeader?.cwd === 'string' && foundHeader.cwd.length > 0) return foundHeader.cwd
|
|
676
|
+
} catch {
|
|
677
|
+
// no session service, or the lookup failed; report "unknown" instead of guessing
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return undefined
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export { EXPLICIT_PATTERNS, inferTags, resolveCwd }
|