@heixui/dsh-session-suite 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,640 @@
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
+
114
+ // --- 1. Recall into the system prompt -----------------------------------
115
+ const systemPrompt = ctx.get('systemPrompt')
116
+ if (systemPrompt?.context) {
117
+ safe('prompt context', () =>
118
+ ctx.effect(
119
+ () =>
120
+ systemPrompt.context({
121
+ name: 'session-suite-memory',
122
+ order: 900,
123
+ text: context => {
124
+ const scoped = context?.scope ? recallByScope.get(context.scope) : undefined
125
+ return scoped?.text || lastRecall || ''
126
+ },
127
+ }),
128
+ 'session-suite: memory context',
129
+ ),
130
+ )
131
+ }
132
+
133
+ // --- 2. Explicit writes, before the step runs ---------------------------
134
+ safe('explicit writes', () =>
135
+ ctx.effect(
136
+ () =>
137
+ ctx.on('agent/pre-step', async (payload, next) => {
138
+ const decision = await next()
139
+ try {
140
+ const agent = payload.agent
141
+ const cwd = resolveCwd(ctx, agent)
142
+ const messages = decision?.messages ?? payload.messages ?? []
143
+
144
+ // Explicit instructions are written before anything else, so the very
145
+ // same step already sees them through recall.
146
+ for (const instruction of collectExplicitWrites(messages)) {
147
+ await engine.remember({
148
+ scope: instruction.scope,
149
+ cwd,
150
+ text: instruction.text,
151
+ tags: inferTags(instruction.text),
152
+ affect: DURABLE_HINTS.test(instruction.text) ? 0.85 : 0.6,
153
+ source: 'explicit',
154
+ sessionId: agent?.sessionId,
155
+ })
156
+ log(`explicit memory stored (${instruction.scope})`)
157
+ }
158
+
159
+ const question = lastUserText(messages)
160
+ const text = await buildRecallText({ engine, cwd, question, config })
161
+ if (agent) recallByScope.set(agent, { text, at: Date.now() })
162
+ lastRecall = text
163
+ } catch (error) {
164
+ log('memory recall failed', error)
165
+ }
166
+ return decision
167
+ }),
168
+ 'session-suite: memory recall and explicit writes',
169
+ ),
170
+ )
171
+
172
+ // --- 3. Implicit extraction after a turn --------------------------------
173
+ safe('implicit extraction', () =>
174
+ ctx.effect(
175
+ () =>
176
+ ctx.on('session/event', (session, event) => {
177
+ if (!event || event.type !== 'turn/end') return
178
+ const sessionId = session?.id
179
+ if (typeof sessionId !== 'string') return
180
+ const cwd = resolveCwd(ctx, session)
181
+ void extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }).catch(error =>
182
+ log('implicit memory extraction failed', error),
183
+ )
184
+ }),
185
+ 'session-suite: implicit memory',
186
+ ),
187
+ )
188
+
189
+ // --- 3b. Automatic maintenance -------------------------------------------
190
+ // Scoring, decay, clustering, and cold archiving are otherwise only reachable
191
+ // through the settings page, which means a memory library that nobody opens
192
+ // never compresses. Run it on an interval instead, over the global store plus
193
+ // whatever workspace the most recent turn used.
194
+ //
195
+ // The pass is skipped when nothing has changed since the previous one: an idle
196
+ // Host should not wake up on a timer to rescore memory it has already scored.
197
+ // Skipping is also the honest signal that the timer is alive, because a pass
198
+ // that runs always writes its bookkeeping.
199
+ const maintainEveryMs = numberOr(config.maintainIntervalMs, 30 * 60 * 1000)
200
+ if (maintainEveryMs > 0) {
201
+ safe('automatic maintenance', () =>
202
+ ctx.effect(() => {
203
+ let lastCwd
204
+ let running = false
205
+ const tick = async () => {
206
+ if (running) return
207
+ running = true
208
+ try {
209
+ const due = await engine.needsMaintenance(lastCwd)
210
+ if (!due) return
211
+ const report = await engine.maintain(lastCwd)
212
+ log(
213
+ `memory maintenance: scored ${report.scored}, archived ${report.archived}, ` +
214
+ `compressed ${report.compressed}, clusters ${report.clusters}, rules ${report.rules}`,
215
+ )
216
+ } catch (error) {
217
+ log('memory maintenance failed', error)
218
+ } finally {
219
+ running = false
220
+ }
221
+ }
222
+ const timer = setInterval(tick, maintainEveryMs)
223
+ timer.unref?.()
224
+ // Remember which workspace to maintain as turns arrive.
225
+ const off = ctx.on('session/event', (session, event) => {
226
+ if (!event || event.type !== 'turn/end') return
227
+ try {
228
+ const cwd = resolveCwd(ctx, session)
229
+ if (cwd) lastCwd = cwd
230
+ } catch {
231
+ // leave the previous workspace in place
232
+ }
233
+ })
234
+ return () => {
235
+ clearInterval(timer)
236
+ off?.()
237
+ }
238
+ }),
239
+ )
240
+ }
241
+
242
+ // --- 4. The memory tool -------------------------------------------------
243
+ // `ToolDefinition` requires an `output` descriptor; without it `register`
244
+ // throws and would take the whole bundle down, so it is provided here and the
245
+ // registration itself stays inside a guard.
246
+ const tools = ctx.get('tools')
247
+ if (tools?.register) {
248
+ ctx.effect(() => {
249
+ try {
250
+ return tools.register({
251
+ name: 'memory',
252
+ description:
253
+ '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.',
254
+ parameters: {
255
+ type: 'object',
256
+ properties: {
257
+ action: { type: 'string', enum: ['remember', 'recall', 'list', 'forget', 'freeze'] },
258
+ text: { type: 'string', description: 'the memory text (remember)' },
259
+ scope: { type: 'string', enum: ['workspace', 'global'] },
260
+ query: { type: 'string', description: 'what to look for (recall)' },
261
+ id: { type: 'string', description: 'entry id (forget / freeze)' },
262
+ frozen: { type: 'boolean', description: 'freeze state (freeze)' },
263
+ tags: {
264
+ type: 'array',
265
+ items: { type: 'string' },
266
+ description:
267
+ '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.',
268
+ },
269
+ factKey: {
270
+ type: 'string',
271
+ description:
272
+ '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.',
273
+ },
274
+ },
275
+ required: ['action'],
276
+ },
277
+ output: {
278
+ schema: { type: 'object' },
279
+ render: (_args, value) => [
280
+ {
281
+ type: 'text',
282
+ text: typeof value === 'string' ? value : JSON.stringify(value, null, 2).slice(0, 4000),
283
+ },
284
+ ],
285
+ },
286
+ async execute(args, exec) {
287
+ const cwd = resolveCwd(ctx, exec?.agent)
288
+ if (args.action === 'remember') {
289
+ if (typeof args.text !== 'string' || !args.text.trim()) throw new Error('text is required')
290
+ const result = await engine.remember({
291
+ scope: args.scope === 'global' ? 'global' : 'workspace',
292
+ cwd,
293
+ text: args.text,
294
+ // Tags are normalised rather than trusted verbatim. The library
295
+ // had `dsh` and `DSH` as separate domains and UI words like
296
+ // `快照` filed as if they were subjects — see lib/tags.js.
297
+ tags: Array.isArray(args.tags) && args.tags.length > 0
298
+ ? normalizeTags(args.tags)
299
+ : inferTags(args.text),
300
+ factKey: typeof args.factKey === 'string' ? args.factKey : undefined,
301
+ affect: DURABLE_HINTS.test(args.text) ? 0.85 : 0.6,
302
+ source: 'tool',
303
+ })
304
+ return { stored: result.entry, duplicate: Boolean(result.duplicateOf) }
305
+ }
306
+ if (args.action === 'recall') {
307
+ const items = await engine.recall({ cwd, query: args.query ?? '', limit: 8 })
308
+ return { memories: items.map(brief) }
309
+ }
310
+ if (args.action === 'list') {
311
+ const items = await engine.list({ cwd })
312
+ return { memories: items.map(brief), pending: (await engine.pending(cwd)).map(brief) }
313
+ }
314
+ if (args.action === 'forget') {
315
+ if (!args.id) throw new Error('id is required')
316
+ return engine.remove({ id: args.id, cwd, scope: args.scope === 'global' ? 'global' : undefined })
317
+ }
318
+ if (args.action === 'freeze') {
319
+ if (!args.id) throw new Error('id is required')
320
+ return engine.update({ id: args.id, cwd, scope: args.scope === 'global' ? 'global' : undefined, patch: { frozen: args.frozen !== false } })
321
+ }
322
+ throw new Error(`unknown action: ${args.action}`)
323
+ },
324
+ })
325
+ } catch (error) {
326
+ log('memory tool registration failed; continuing without it', error)
327
+ return () => {}
328
+ }
329
+ }, 'session-suite: memory tool')
330
+ }
331
+
332
+ return { engine, dispose: () => {} }
333
+ }
334
+
335
+ /**
336
+ * Compose the recall block injected into the prompt.
337
+ *
338
+ * @param {object} input recall input
339
+ * @param {MemoryEngine} input.engine memory engine
340
+ * @param {string} [input.cwd] workspace
341
+ * @param {string} [input.question] the current question
342
+ * @param {any} input.config plugin config
343
+ * @returns {Promise<string>} prompt text (empty when nothing is relevant)
344
+ */
345
+ async function buildRecallText({ engine, cwd, question, config }) {
346
+ const limit = typeof config?.recallLimit === 'number' ? config.recallLimit : 6
347
+ const residentLimit = typeof config?.residentLimit === 'number' ? config.residentLimit : 2
348
+
349
+ // Query-matched memories first. `select` decides how many the evidence
350
+ // actually justifies, and records the reason for every candidate it dropped so
351
+ // the settings page can explain a recall instead of leaving it a mystery.
352
+ const { items: matched } = await engine.select({ cwd, query: question ?? '', limit })
353
+ const usable = matched.filter(item => !item.archived)
354
+
355
+ // Then a small resident layer: the most important memories ride along whatever
356
+ // the question happened to be. Without it, a follow-up like "继续" shares no
357
+ // characters with any memory, so a strict relevance filter would inject nothing
358
+ // at all — and a feature that vanishes on ordinary follow-ups reads as broken,
359
+ // not as a relevance decision. This is the same shape as the resident layer in
360
+ // dsh-fish-memory's design: durable facts get their own small quota instead of
361
+ // competing with question-matched events.
362
+ if (residentLimit > 0 && usable.length < limit) {
363
+ const ranked = await engine.recall({ cwd, query: '', limit, touch: false })
364
+ let residents = 0
365
+ for (const item of ranked) {
366
+ if (residents >= residentLimit || usable.length >= limit) break
367
+ if (item.archived) continue
368
+ if (usable.some(existing => existing.id === item.id)) continue
369
+ usable.push(item)
370
+ residents += 1
371
+ }
372
+ }
373
+
374
+ if (usable.length === 0) return ''
375
+ const lines = usable.map(item => `- ${item.scope === 'global' ? '[全局]' : '[本工作区]'} ${item.text}`)
376
+ return [
377
+ '## 长期记忆(跨会话)',
378
+ '',
379
+ '以下条目来自长期记忆库,与本轮问题相关。它们是稳定事实或约定,不是本轮对话内容;冲突时以用户当前的话为准。',
380
+ '',
381
+ ...lines,
382
+ '',
383
+ '如果用户说【记住:…】或【新增全局记忆:…】,立即照做;如果这些记忆已经过时,用 memory 工具更正。',
384
+ ].join('\n')
385
+ }
386
+
387
+ /** @param {readonly any[]} messages step messages @returns {string} last user text */
388
+ function lastUserText(messages) {
389
+ for (let index = (messages?.length ?? 0) - 1; index >= 0; index -= 1) {
390
+ const message = messages[index]
391
+ if (message?.role !== 'user') continue
392
+ const text = contentText(message.content)
393
+ if (text) return text
394
+ }
395
+ return ''
396
+ }
397
+
398
+ /**
399
+ * Ask a small model call what, if anything, is worth remembering from this turn.
400
+ *
401
+ * @param {object} input extraction input
402
+ * @param {any} input.ctx plugin context
403
+ * @param {MemoryEngine} input.engine memory engine
404
+ * @param {string} input.sessionId session
405
+ * @param {string} [input.cwd] workspace
406
+ * @param {(message: string, error?: unknown) => void} input.log logger
407
+ * @param {any} input.config plugin config
408
+ */
409
+ async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
410
+ const settings = await engine.settings()
411
+ if (settings.implicit === false) return
412
+ const llm = ctx.get('llm')
413
+ if (!llm?.stream) return
414
+
415
+ const sessionController = ctx.get('sessionController')
416
+ const inspection = await sessionController?.inspect?.(sessionId).catch(() => undefined)
417
+ const events = inspection?.events ?? []
418
+ const turn = lastCompletedTurn(events)
419
+ if (typeof turn !== 'number') return
420
+ const transcript = turnTranscript(events, turn)
421
+ if (!transcript) return
422
+
423
+ // The memory model is a fallback chain: a free provider that is rate limited
424
+ // (429) or unreachable must not silently disable extraction for the session,
425
+ // so every candidate is tried in order until one answers.
426
+ const candidates = memoryCandidates(config)
427
+ if (candidates.length === 0) return
428
+
429
+ const prompt = `下面是一轮对话。找出其中**长期有效**的信息:稳定偏好、固定技术约定、长期策略、用户固有习惯。\n单次任务、一次性查询、临时调试、闲聊都不要。\n只输出 JSON:{"memories":[{"text":"...","scope":"workspace|global","affect":0.0-1.0,"tags":["..."]}]}\n没有就输出 {"memories":[]}。\n\n对话:\n${transcript}`
430
+
431
+ let answer = ''
432
+ let used
433
+ for (const candidate of candidates) {
434
+ answer = ''
435
+ try {
436
+ for await (const chunk of llm.stream({
437
+ provider: candidate.provider,
438
+ model: candidate.model,
439
+ reasoningEffort: 'low',
440
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
441
+ maxTokens: 400,
442
+ purpose: 'compaction',
443
+ })) {
444
+ if (chunk?.type === 'text-delta') answer += chunk.text
445
+ }
446
+ if (answer.trim()) {
447
+ used = candidate
448
+ break
449
+ }
450
+ log(`memory extraction returned nothing from ${candidate.provider}/${candidate.model}; trying the next model`)
451
+ } catch (error) {
452
+ log(`memory extraction failed on ${candidate.provider}/${candidate.model}: ${errorMessage(error)}`)
453
+ }
454
+ }
455
+ if (!answer.trim()) return
456
+ log(`memory extraction answered by ${used?.provider}/${used?.model}`)
457
+
458
+ const parsed = parseJsonBlock(answer)
459
+ if (!parsed?.memories?.length) return
460
+ for (const item of parsed.memories.slice(0, 4)) {
461
+ const text = String(item.text ?? '').trim()
462
+ if (!text || !DURABLE_HINTS.test(text)) continue
463
+ if (TRANSIENT_HINTS.test(text)) continue
464
+ await engine.suggest({
465
+ text,
466
+ scope: item.scope === 'global' ? 'global' : 'workspace',
467
+ cwd,
468
+ tags: Array.isArray(item.tags) ? item.tags : inferTags(text),
469
+ affect: typeof item.affect === 'number' ? item.affect : 0.6,
470
+ sessionId,
471
+ })
472
+ }
473
+ }
474
+
475
+ /** @param {string} text model output @returns {any | undefined} parsed JSON */
476
+ function parseJsonBlock(text) {
477
+ const match = /\{[\s\S]*\}/.exec(String(text ?? ''))
478
+ if (!match) return undefined
479
+ try {
480
+ return JSON.parse(match[0])
481
+ } catch {
482
+ return undefined
483
+ }
484
+ }
485
+
486
+ /** @param {unknown} error @returns {string} readable message */
487
+ function errorMessage(error) {
488
+ if (error instanceof Error) return error.message
489
+ return String(error)
490
+ }
491
+
492
+ /**
493
+ * Coerce a config value to a finite number.
494
+ *
495
+ * @param {unknown} value raw config value
496
+ * @param {number} fallback used when the value is absent or not a number
497
+ * @returns {number} the number
498
+ */
499
+ function numberOr(value, fallback) {
500
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
501
+ }
502
+
503
+ /**
504
+ * The ordered model fallback chain for implicit extraction.
505
+ *
506
+ * `memoryModel` may be a single `{ provider, model }` or an array of them; a
507
+ * `memoryModels` array is also accepted so the profile can list the whole chain
508
+ * without nesting. Entries missing a provider or model are dropped.
509
+ *
510
+ * @param {any} config plugin config
511
+ * @returns {{ provider: string, model: string }[]} candidates in priority order
512
+ */
513
+ function memoryCandidates(config) {
514
+ const raw = []
515
+ const push = value => {
516
+ if (Array.isArray(value)) raw.push(...value)
517
+ else if (value) raw.push(value)
518
+ }
519
+ push(config?.memoryModels)
520
+ push(config?.memoryModel)
521
+ const out = []
522
+ const seen = new Set()
523
+ for (const entry of raw) {
524
+ if (!entry || typeof entry !== 'object') continue
525
+ const provider = entry.provider
526
+ const model = entry.model
527
+ if (typeof provider !== 'string' || !provider) continue
528
+ if (typeof model !== 'string' || !model) continue
529
+ const key = `${provider}/${model}`
530
+ if (seen.has(key)) continue
531
+ seen.add(key)
532
+ out.push({ provider, model })
533
+ }
534
+ return out
535
+ }
536
+
537
+ /** @param {string} text memory text @returns {string[]} tags */
538
+ function inferTags(text) {
539
+ const value = String(text ?? '')
540
+ const tags = []
541
+ if (/(trae|workbuddy|open code|opencode)/i.test(value)) tags.push('调度')
542
+ if (/(插件|plugin|bundle|dsh)/i.test(value)) tags.push('dsh')
543
+ if (/(模型|model|积分|额度|credit)/i.test(value)) tags.push('模型')
544
+ if (/(文件|目录|路径|workspace|工作区)/i.test(value)) tags.push('工作区')
545
+ if (/(习惯|偏好|喜欢|prefer)/i.test(value)) tags.push('偏好')
546
+ return tags
547
+ }
548
+
549
+ /** @param {readonly any[]} events log @param {number} turn target @returns {string} transcript */
550
+ function turnTranscript(events, turn) {
551
+ const lines = []
552
+ for (const event of events) {
553
+ if (typeof event?.data?.turn !== 'number' || event.data.turn !== turn) continue
554
+ if (event.type === 'user/message') {
555
+ const kind = event.data?.source?.kind
556
+ if (kind !== undefined && kind !== 'user') continue
557
+ const text = contentText(event.data.content)
558
+ if (text) lines.push(`用户:${text}`)
559
+ } else if (event.type === 'assistant/message') {
560
+ const text = contentText(event.data?.message?.content)
561
+ if (text) lines.push(`助手:${text}`)
562
+ }
563
+ }
564
+ const joined = lines.join('\n')
565
+ return joined.length > 4000 ? joined.slice(-4000) : joined
566
+ }
567
+
568
+ /** @param {readonly any[]} events log @returns {number | undefined} turn */
569
+ function lastCompletedTurn(events) {
570
+ let turn
571
+ for (const event of events) if (event?.type === 'turn/end' && typeof event.data?.turn === 'number') turn = event.data.turn
572
+ return turn
573
+ }
574
+
575
+ /** @param {any} content message content @returns {string} text */
576
+ function contentText(content) {
577
+ if (typeof content === 'string') return content
578
+ if (!Array.isArray(content)) return ''
579
+ return content
580
+ .filter(block => block?.type === 'text' && typeof block.text === 'string')
581
+ .map(block => block.text)
582
+ .join('\n')
583
+ .trim()
584
+ }
585
+
586
+ /** @param {any} entry memory entry @returns {any} compact view */
587
+ function brief(entry) {
588
+ return {
589
+ id: entry.id,
590
+ text: entry.text,
591
+ scope: entry.scope,
592
+ kind: entry.kind,
593
+ score: entry.score,
594
+ state: entry.state ?? (entry.archived ? 'archived' : 'active'),
595
+ frozen: entry.frozen === true,
596
+ tags: entry.tags ?? [],
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Resolve the workspace directory an agent or session belongs to.
602
+ *
603
+ * The cwd is needed to pick the per-workspace memory store, so a miss here
604
+ * silently downgrades every workspace memory to the global one. There is no
605
+ * `workingDirectory` service in this harness; the authoritative source is the
606
+ * session header (`header.cwd`), which the session service validates as an
607
+ * absolute path. An agent reaches it through its own `session`, and an agent
608
+ * that exposes the header directly is handled too.
609
+ *
610
+ * @param {any} ctx plugin context
611
+ * @param {any} owner agent or session
612
+ * @returns {string | undefined} absolute cwd, when one is known
613
+ */
614
+ function resolveCwd(ctx, owner) {
615
+ if (!owner) return undefined
616
+
617
+ // A session, or anything carrying the immutable header.
618
+ const direct = owner.header ?? owner.meta
619
+ if (typeof direct?.cwd === 'string' && direct.cwd.length > 0) return direct.cwd
620
+
621
+ // An agent: prefer its own session, then the session store by id.
622
+ const session = owner.session ?? owner.agent?.session
623
+ const header = session?.header
624
+ if (typeof header?.cwd === 'string' && header.cwd.length > 0) return header.cwd
625
+
626
+ const sessionId = owner.sessionId ?? session?.id
627
+ if (typeof sessionId === 'string') {
628
+ try {
629
+ const sessions = ctx.get('sessions')
630
+ const found = sessions?.get?.(sessionId)
631
+ const foundHeader = found?.header
632
+ if (typeof foundHeader?.cwd === 'string' && foundHeader.cwd.length > 0) return foundHeader.cwd
633
+ } catch {
634
+ // no session service, or the lookup failed; report "unknown" instead of guessing
635
+ }
636
+ }
637
+ return undefined
638
+ }
639
+
640
+ export { EXPLICIT_PATTERNS, inferTags, resolveCwd }