@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.
@@ -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 }