@aiwayds/dsh-topics-memory 0.6.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.
Files changed (61) hide show
  1. package/README.md +112 -0
  2. package/README.zh.md +112 -0
  3. package/cordis.patch.yml +8 -0
  4. package/lib/commands.d.ts +21 -0
  5. package/lib/commands.js +368 -0
  6. package/lib/config.d.ts +122 -0
  7. package/lib/config.js +123 -0
  8. package/lib/delegation.d.ts +23 -0
  9. package/lib/delegation.js +21 -0
  10. package/lib/digest.d.ts +42 -0
  11. package/lib/digest.js +101 -0
  12. package/lib/distill.d.ts +186 -0
  13. package/lib/distill.js +759 -0
  14. package/lib/git.d.ts +66 -0
  15. package/lib/git.js +169 -0
  16. package/lib/ilog.d.ts +53 -0
  17. package/lib/ilog.js +70 -0
  18. package/lib/index.d.ts +73 -0
  19. package/lib/index.js +504 -0
  20. package/lib/observer.d.ts +41 -0
  21. package/lib/observer.js +128 -0
  22. package/lib/okf.d.ts +112 -0
  23. package/lib/okf.js +279 -0
  24. package/lib/onboard.d.ts +205 -0
  25. package/lib/onboard.js +664 -0
  26. package/lib/paths.d.ts +48 -0
  27. package/lib/paths.js +84 -0
  28. package/lib/retrieval.d.ts +61 -0
  29. package/lib/retrieval.js +178 -0
  30. package/lib/service.d.ts +96 -0
  31. package/lib/service.js +375 -0
  32. package/lib/store.d.ts +153 -0
  33. package/lib/store.js +489 -0
  34. package/lib/sync.d.ts +52 -0
  35. package/lib/sync.js +154 -0
  36. package/lib/tools.d.ts +8 -0
  37. package/lib/tools.js +208 -0
  38. package/lib/viz.d.ts +33 -0
  39. package/lib/viz.js +310 -0
  40. package/lib/yaml.d.ts +18 -0
  41. package/lib/yaml.js +334 -0
  42. package/package.json +97 -0
  43. package/src/commands.ts +397 -0
  44. package/src/config.ts +149 -0
  45. package/src/delegation.ts +27 -0
  46. package/src/digest.ts +125 -0
  47. package/src/distill.ts +885 -0
  48. package/src/git.ts +209 -0
  49. package/src/ilog.ts +99 -0
  50. package/src/index.ts +574 -0
  51. package/src/observer.ts +150 -0
  52. package/src/okf.ts +317 -0
  53. package/src/onboard.ts +762 -0
  54. package/src/paths.ts +104 -0
  55. package/src/retrieval.ts +219 -0
  56. package/src/service.ts +400 -0
  57. package/src/store.ts +567 -0
  58. package/src/sync.ts +170 -0
  59. package/src/tools.ts +225 -0
  60. package/src/viz.ts +333 -0
  61. package/src/yaml.ts +322 -0
package/src/distill.ts ADDED
@@ -0,0 +1,885 @@
1
+ /**
2
+ * Distill lane (M2, ADR 0004) — background LLM process that turns undistilled
3
+ * observations into Topic writes.
4
+ *
5
+ * Single-flight per session, fire-and-forget from the observer, never throws
6
+ * into the caller. The model route comes from config (distill-provider +
7
+ * distill-model); when unset the lane stays idle with a visible status. The
8
+ * actual LLM seam is injected as a `ModelCaller` so unit tests run hermetic.
9
+ *
10
+ * @module distill
11
+ */
12
+
13
+ import * as okf from './okf.ts'
14
+ import type { TopicsService } from './service.ts'
15
+
16
+ export interface ModelRequest {
17
+ system: string
18
+ user: string
19
+ purpose: string
20
+ sessionId?: string
21
+ maxTokens: number
22
+ signal?: AbortSignal
23
+ }
24
+
25
+ export type ModelCaller = (req: ModelRequest) => Promise<string>
26
+
27
+ export interface DistillOp {
28
+ op: 'create' | 'update'
29
+ slug?: string
30
+ title?: string
31
+ description?: string
32
+ tags?: string[]
33
+ depends?: string[]
34
+ open_questions?: string[]
35
+ impact?: string[]
36
+ conclusion?: string
37
+ recommendations?: string
38
+ status?: string
39
+ observed_ids?: string[]
40
+ }
41
+
42
+ export interface DistillResult {
43
+ ok: boolean
44
+ reason?: 'no-model' | 'no-observations' | 'in-flight' | 'model-error' | 'invalid-output' | 'no-ops' | 'stalled'
45
+ created: string[]
46
+ updated: string[]
47
+ marked: number
48
+ /** Observations deleted by the post-run GC this run (present when > 0). */
49
+ gcDropped?: number
50
+ detail?: string
51
+ }
52
+
53
+ export const SYSTEM_PROMPT = [
54
+ '你是 topic 记忆库的蒸馏引擎。输入是若干条未蒸馏的会话观察(JSON)和现有 topic 索引。',
55
+ '任务:把观察沉淀成 Topic 操作。只输出一个 JSON 对象,不要任何其他文字、Markdown 代码块或解释。',
56
+ '输出格式:{"ops": [...]},每个元素:',
57
+ ' {"op":"create","title":"主题名","description":"一句话","tags":["小写标签"],"depends":["前置topic的slug"],',
58
+ ' "open_questions":["未决问题"],"impact":["影响面"],"conclusion":"当前结论(自含散文)","recommendations":"可执行建议","status":"draft",',
59
+ ' "observed_ids":["obs-..."]}',
60
+ ' {"op":"update","slug":"已有slug","conclusion":"修订后的完整结论","open_questions":[...],"observed_ids":["obs-..."]}',
61
+ '规则:',
62
+ '- 硬性要求:每个 op 必须带 observed_ids 字段,值只能从本次输入「未蒸馏观察」里列出的 id 中逐字复制(形如 "obs-...");create 填它所综合依据的观察 id,update 填促使本次修订的观察 id。含列表之外 id 的条目会被过滤;observed_ids 缺失、为空或过滤后不剩任何有效 id 的 op 会被整体丢弃。',
63
+ '- conclusion 必须自含(不依赖观察原文也能读懂),写「目前有效的结论」,不是流水账。',
64
+ '- 同一主题只允许一个 create;已有相近 topic 时用 update 修订它的 conclusion。',
65
+ '- 观察里有价值就沉淀,没价值就跳过该观察(被跳过观察的 id 不要出现在任何 op 里);没有可沉淀内容时输出 {"ops":[]}。',
66
+ '- 中文主题用中文写;tags 全小写。',
67
+ ].join('\n')
68
+
69
+ /** Adaptive-batch floor — below this, shrinking cannot rescue an output limit. */
70
+ const MIN_BATCH_SIZE = 5
71
+ /** Fallbacks when the config fields are absent (bare test harnesses). */
72
+ const DEFAULT_BATCH_SIZE = 40
73
+ const DEFAULT_MAX_MODEL_CALLS = 8
74
+
75
+ /** Minimal observation surface the batch payload needs (structural, no store import). */
76
+ interface ObservationLike {
77
+ id: string
78
+ kind: string
79
+ source: string
80
+ text: string
81
+ }
82
+
83
+ /** Outcome of one model call over one batch (see Distiller.runBatch). */
84
+ interface BatchOutcome {
85
+ created: string[]
86
+ updated: string[]
87
+ marked: number
88
+ /** Model calls this batch spent (1, or 2 when the corrective retry fired). */
89
+ callsUsed?: number
90
+ /** observed_ids entries dropped for not matching the batch's true id set. */
91
+ filteredIds?: number
92
+ /** Model returned valid JSON with an empty ops array. */
93
+ noOps?: boolean
94
+ /** Model call died on the output-token limit (retryable with a smaller batch). */
95
+ maxTokens?: boolean
96
+ /** Non-retryable call failure classification (parse or stream error). */
97
+ fatalReason?: 'model-error' | 'invalid-output'
98
+ /** Batch ids an op actually consumed — the GC's consumed set for this batch. */
99
+ consumedIds?: string[]
100
+ detail?: string
101
+ /**
102
+ * Specific zero-consumption explanation for the stalled stop (undefined =
103
+ * fall back to the generic slug hint).
104
+ */
105
+ stallDetail?: string
106
+ }
107
+
108
+ /** Outcome of executing (a sanitized subset of) one batch's ops. */
109
+ interface AppliedOps {
110
+ created: string[]
111
+ updated: string[]
112
+ /** Batch-valid observation ids consumed by executed ops. */
113
+ consumedIds: string[]
114
+ consumedSlugs: string[]
115
+ /** observed_ids entries dropped for not matching the batch's id set. */
116
+ filteredIds: number
117
+ /**
118
+ * Structurally valid ops held back entirely because none of their
119
+ * observed_ids matched the batch — the corrective-retry trigger.
120
+ */
121
+ droppedForIds: number
122
+ }
123
+
124
+ export class Distiller {
125
+ private inFlight = new Map<string, Promise<DistillResult>>()
126
+ private readonly service: TopicsService
127
+ private readonly caller: ModelCaller | undefined
128
+ /** Live batch size — adopted from config each run, shrunk on output-limit failures. */
129
+ private batchSize = DEFAULT_BATCH_SIZE
130
+ /** Config value the live batch size was adopted from; a config change resets the shrink state. */
131
+ private batchSizeBase = DEFAULT_BATCH_SIZE
132
+
133
+ constructor(service: TopicsService, caller: ModelCaller | undefined) {
134
+ this.service = service
135
+ this.caller = caller
136
+ }
137
+
138
+ /**
139
+ * Adopt the configured batch size for this run. The shrink state sticks
140
+ * across runs (a model that overflowed at 40 stays at 20 until restart),
141
+ * but an explicit config change resets it — /topics set is the manual
142
+ * grow-back lever, so the lane needs no speculative auto-recovery that
143
+ * would just re-pay a failed call on every run.
144
+ */
145
+ private adoptBatchSize(configured: number): void {
146
+ if (this.batchSizeBase !== configured) {
147
+ this.batchSizeBase = configured
148
+ this.batchSize = configured
149
+ }
150
+ }
151
+
152
+ get configured(): boolean {
153
+ return this.caller !== undefined
154
+ }
155
+
156
+ /**
157
+ * Fire-and-forget from the observer; dedups concurrent runs per session.
158
+ * Returns the run promise (undefined when deduped or unconfigured) so the
159
+ * caller can hook post-run cleanup — the per-session llm capture in
160
+ * index.ts must live exactly as long as a run that may read it. `manual`
161
+ * is the /topics distill trigger: same lane, same in-flight guard.
162
+ */
163
+ request(sessionId: string, reason: 'every-n' | 'session-end' | 'manual'): Promise<DistillResult> | undefined {
164
+ if (!this.configured) return undefined
165
+ if (this.inFlight.has(sessionId)) return undefined
166
+ const run = this.run(sessionId).finally(() => this.inFlight.delete(sessionId))
167
+ this.inFlight.set(sessionId, run)
168
+ void run.catch(() => undefined)
169
+ return run
170
+ }
171
+
172
+ /** True while a run for the session is still in flight (teardown guard). */
173
+ hasPending(sessionId: string): boolean {
174
+ return this.inFlight.has(sessionId)
175
+ }
176
+
177
+ /** True while ANY session's run is still in flight (exit-dispose guard). */
178
+ hasAnyPending(): boolean {
179
+ return this.inFlight.size > 0
180
+ }
181
+
182
+ async run(sessionId?: string): Promise<DistillResult> {
183
+ const result = await this.runInner(sessionId)
184
+ // The GC count rides the detail (and the state file) so /topics status and
185
+ // e2e diagnostics see how many observations were deleted, not just marked.
186
+ const gcNote =
187
+ result.gcDropped !== undefined && result.gcDropped > 0
188
+ ? `;gc: dropped ${result.gcDropped} unprocessable observation(s)`
189
+ : ''
190
+ const detail =
191
+ result.detail === undefined ? (gcNote === '' ? undefined : gcNote.replace(/^;/, '')) : `${result.detail}${gcNote}`
192
+ // Persist the outcome — /topics status and e2e diagnostics both read it.
193
+ // A failed write must not masquerade as success: the marks have already
194
+ // landed, so the only witness left is the run detail the command output
195
+ // shows. Surface the write error there instead of swallowing it.
196
+ let stateNote = ''
197
+ await this.service.store
198
+ .writeDistillState({
199
+ at: new Date().toISOString(),
200
+ ok: result.ok,
201
+ reason: result.reason,
202
+ created: result.created,
203
+ updated: result.updated,
204
+ marked: result.marked,
205
+ gcDropped: result.gcDropped,
206
+ detail,
207
+ })
208
+ .catch((err: unknown) => {
209
+ stateNote = `;distill-state 写入失败: ${err instanceof Error ? err.message : String(err)}`
210
+ })
211
+ const fullDetail =
212
+ stateNote === '' ? detail : detail === undefined ? stateNote.replace(/^;/, '') : `${detail}${stateNote}`
213
+ return { ...result, detail: fullDetail }
214
+ }
215
+
216
+ private async runInner(sessionId?: string): Promise<DistillResult> {
217
+ if (!this.configured || this.caller === undefined) {
218
+ return { ok: false, reason: 'no-model', created: [], updated: [], marked: 0 }
219
+ }
220
+ // Route gate — checked PER RUN here rather than once at wiring time, so
221
+ // `/topics set distill-provider` stays live without a plugin reload. An
222
+ // empty route must never start lane bookkeeping: a GC attempt recorded
223
+ // against a lane that cannot call the model would turn three automatic
224
+ // triggers into data deletion. Short-circuit BEFORE the first fetch,
225
+ // with the readable reason /topics distill surfaces.
226
+ const routeCfg = this.service.cfg
227
+ if ((routeCfg.distillProvider ?? '') === '' || (routeCfg.distillModel ?? '') === '') {
228
+ return {
229
+ ok: false,
230
+ reason: 'no-model',
231
+ created: [],
232
+ updated: [],
233
+ marked: 0,
234
+ detail: 'distill route not configured (set distill-provider and distill-model)',
235
+ }
236
+ }
237
+ // Bounded batch loop (livelock fix): the pre-fix lane fed the whole
238
+ // newest-40 undistilled window into one model call and failed the entire
239
+ // run on a single output-limit overflow — nothing was ever marked, so
240
+ // the same batch was re-fetched forever. The loop walks the pool in
241
+ // batches, halves the batch on an output-limit failure (the escape
242
+ // hatch: a smaller head provably converges toward the floor), stops when
243
+ // a batch cannot make progress, and keeps the marks of every successful
244
+ // batch (partial progress beats zero progress) under a per-run budget.
245
+ const cfg = this.service.cfg
246
+ // The floor bounds the ADAPTIVE shrink only — an explicit (small) config
247
+ // value is honored as-is, clamped merely to a positive fetch size.
248
+ this.adoptBatchSize(Math.max(1, cfg.distillBatchSize ?? DEFAULT_BATCH_SIZE))
249
+ const maxCalls = Math.max(1, cfg.distillMaxModelCalls ?? DEFAULT_MAX_MODEL_CALLS)
250
+ const created: string[] = []
251
+ const updated: string[] = []
252
+ let marked = 0
253
+ let calls = 0
254
+ // Why the loop ended before draining the pool (undefined = pool drained).
255
+ let stopped: 'budget' | 'no-ops' | 'stalled' | 'max-tokens' | 'failure' | undefined
256
+ let failureReason: 'model-error' | 'invalid-output' | undefined
257
+ let failureDetail: string | undefined
258
+ let firstFetch = true
259
+ let filteredTotal = 0
260
+ let stallDetail: string | undefined
261
+ // GC bookkeeping (store.recordUnconsumed): every id the model EVALUATED
262
+ // this run (parseable answer, however useless) minus the ids an op
263
+ // consumed = one failed attempt per observation; three failed attempts
264
+ // delete it from the pool. Collection happens per-outcome in the loop
265
+ // below, not at fetch time: a batch whose call threw (model-error) or
266
+ // produced unparseable text (invalid-output) never counts, and neither
267
+ // does a shrink retry that has not been evaluated yet (BLOCKER-1: three
268
+ // ECONNREFUSED runs must not delete data the model never saw).
269
+ const fedIds = new Set<string>()
270
+ const consumedRunIds = new Set<string>()
271
+ for (;;) {
272
+ const observations = await this.service.store.undistilledObservations(this.batchSize)
273
+ if (observations.length === 0) {
274
+ if (firstFetch) {
275
+ return { ok: false, reason: 'no-observations', created: [], updated: [], marked: 0 }
276
+ }
277
+ break
278
+ }
279
+ firstFetch = false
280
+ if (calls >= maxCalls) {
281
+ stopped = 'budget'
282
+ break
283
+ }
284
+ // The batch spends 1 call, or 2 when the corrective observed_ids retry
285
+ // fires — the budget check above already guaranteed room for both.
286
+ const outcome = await this.runBatch(sessionId, observations, maxCalls - calls)
287
+ calls += Math.max(1, outcome.callsUsed ?? 1)
288
+ for (const id of outcome.consumedIds ?? []) consumedRunIds.add(id)
289
+ filteredTotal += outcome.filteredIds ?? 0
290
+ created.push(...outcome.created)
291
+ updated.push(...outcome.updated)
292
+ marked += outcome.marked
293
+ if (outcome.maxTokens) {
294
+ // Provisional failure record: if a later batch succeeds it stays
295
+ // unused (progress branch wins); if the run ends without progress
296
+ // (budget out, floor reached) it is exactly what the state should show.
297
+ failureReason = 'model-error'
298
+ failureDetail = outcome.detail
299
+ if (this.batchSize > MIN_BATCH_SIZE) {
300
+ this.batchSize = Math.max(MIN_BATCH_SIZE, Math.floor(this.batchSize / 2))
301
+ // Not evaluated yet: the same head is re-fed smaller below, and
302
+ // only the outcome that actually renders a verdict counts a GC
303
+ // attempt (a mid-shrink overflow is capacity, not a content
304
+ // judgment).
305
+ continue // retry the smaller same-head batch — the livelock escape
306
+ }
307
+ // Floor reached: repeated output-limit finishes ARE an evaluation
308
+ // verdict on this content (the model saw it and could not fit an
309
+ // answer) — the batch counts toward the GC.
310
+ for (const o of observations) fedIds.add(o.id)
311
+ stopped = 'max-tokens'
312
+ failureDetail = `批次无法再缩小(当前 ${this.batchSize} 条)仍触发输出上限:${outcome.detail ?? 'model finish: max-tokens'}`
313
+ break
314
+ }
315
+ if (outcome.fatalReason !== undefined) {
316
+ // model-error (call threw) / invalid-output (unparseable text): the
317
+ // model never evaluated the content, so the batch gains NO GC
318
+ // attempt — three network blips must not delete the pool.
319
+ stopped = 'failure'
320
+ failureReason = outcome.fatalReason
321
+ failureDetail = outcome.detail
322
+ break
323
+ }
324
+ // Parseable answer (ops, empty or not): the batch was genuinely
325
+ // evaluated — its unconsumed ids gain one failed attempt.
326
+ for (const o of observations) fedIds.add(o.id)
327
+ if (outcome.noOps) {
328
+ stopped = 'no-ops'
329
+ break
330
+ }
331
+ if (outcome.marked === 0) {
332
+ // Ops came back but consumed none of this batch's observations — the
333
+ // head cannot advance, so re-calling would only repeat (or duplicate
334
+ // topics). Stop and surface what already landed.
335
+ stopped = 'stalled'
336
+ stallDetail = outcome.stallDetail
337
+ break
338
+ }
339
+ }
340
+ // Post-run GC: every fed-but-unconsumed observation gains a failed
341
+ // attempt; three strikes delete it. Best-effort — never fail a run over
342
+ // bookkeeping.
343
+ let gcDropped: number | undefined
344
+ if (fedIds.size > 0) {
345
+ try {
346
+ const { dropped } = await this.service.store.recordUnconsumed([...fedIds], [...consumedRunIds])
347
+ if (dropped > 0) gcDropped = dropped
348
+ } catch {
349
+ // contained — the run result stands without the GC note
350
+ }
351
+ }
352
+ const progress = created.length + updated.length > 0
353
+ let result: DistillResult
354
+ if (progress) {
355
+ const head =
356
+ stopped === 'budget'
357
+ ? `已达单次 run 模型调用预算(${calls}/${maxCalls}),剩余积压留待后续 run`
358
+ : stopped === 'max-tokens'
359
+ ? '随后批次触发输出上限,本轮停止'
360
+ : stopped === 'failure'
361
+ ? `随后批次失败,本轮停止:${failureDetail ?? 'unknown'}`
362
+ : stopped === 'no-ops'
363
+ ? '后续批次模型未产出 ops,本轮停止'
364
+ : stopped === 'stalled'
365
+ ? `后续批次未消费任何观察,本轮停止${stallDetail !== undefined ? `:${stallDetail}` : ''}`
366
+ : undefined
367
+ const filteredNote = filteredTotal > 0 ? `;filtered ${filteredTotal} invalid observed_ids` : ''
368
+ const base = head === undefined ? undefined : `partial: 已蒸馏标记 ${marked} 条观察(${calls} 次模型调用);${head}`
369
+ result = {
370
+ ok: true,
371
+ created,
372
+ updated,
373
+ marked,
374
+ detail:
375
+ base === undefined
376
+ ? filteredNote === ''
377
+ ? undefined
378
+ : filteredNote.replace(/^;/, '')
379
+ : `${base}${filteredNote}`,
380
+ }
381
+ } else {
382
+ result = {
383
+ ok: false,
384
+ reason:
385
+ failureReason ??
386
+ (stopped === 'no-ops' ? 'no-ops' : stopped === 'stalled' ? 'stalled' : undefined),
387
+ created: [],
388
+ updated: [],
389
+ marked: 0,
390
+ // A stalled run must never land as an unexplained failure: the batch
391
+ // names its own zero-consumption cause when it can (invalid
392
+ // observed_ids), otherwise the generic slug hint stands.
393
+ detail:
394
+ failureDetail ??
395
+ (stopped === 'stalled'
396
+ ? stallDetail ?? '批次 ops 未消费任何观察(常见原因:ops 引用了不存在的 topic slug)'
397
+ : undefined),
398
+ }
399
+ }
400
+ if (gcDropped !== undefined) result.gcDropped = gcDropped
401
+ return result
402
+ }
403
+
404
+ /**
405
+ * One model call (plus at most one corrective retry) over one batch:
406
+ * payload build → ops → topic writes → marks. `budgetLeft` is the calls
407
+ * still available to the run including this batch's first call (≥ 1); the
408
+ * corrective retry only fires when it can stay inside that budget.
409
+ */
410
+ private async runBatch(
411
+ sessionId: string | undefined,
412
+ observations: readonly ObservationLike[],
413
+ budgetLeft: number,
414
+ ): Promise<BatchOutcome> {
415
+ const metas = await this.service.store.listTopics()
416
+ // Real open questions need the docs; fetch for index (bounded).
417
+ const indexDetailed = []
418
+ for (const m of metas.slice(0, 100)) {
419
+ const doc = await this.service.store.readTopic(m.slug).catch(() => undefined)
420
+ indexDetailed.push({
421
+ slug: m.slug,
422
+ title: m.title,
423
+ status: m.status,
424
+ tags: m.tags,
425
+ open_questions: doc?.fm.open_questions ?? [],
426
+ conclusion: (okf.firstParagraph(okf.sectionOf(doc?.body ?? '', okf.CONCLUSION_HEADING) ?? '')).slice(0, 200),
427
+ })
428
+ }
429
+ const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }))
430
+ const user = [
431
+ `现有 topic 索引(${indexDetailed.length} 个):`,
432
+ JSON.stringify(indexDetailed),
433
+ '',
434
+ `未蒸馏观察(${observationsPayload.length} 条):`,
435
+ JSON.stringify(observationsPayload),
436
+ '',
437
+ '请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
438
+ ].join('\n')
439
+ let raw: string
440
+ const caller = this.caller
441
+ if (caller === undefined) {
442
+ // Unreachable via runInner (it guards), but never let a future call site
443
+ // turn a wiring bug into a silent invalid-output.
444
+ return { created: [], updated: [], marked: 0, fatalReason: 'model-error', detail: 'distill caller unavailable' }
445
+ }
446
+ try {
447
+ raw = await caller({ system: SYSTEM_PROMPT, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
448
+ } catch (e) {
449
+ const detail = String(e instanceof Error ? e.message : e).slice(0, 200)
450
+ return isMaxTokens(e)
451
+ ? { created: [], updated: [], marked: 0, maxTokens: true, detail }
452
+ : { created: [], updated: [], marked: 0, fatalReason: 'model-error', detail }
453
+ }
454
+ let ops: DistillOp[]
455
+ try {
456
+ ops = parseOps(raw)
457
+ } catch (e) {
458
+ return {
459
+ created: [],
460
+ updated: [],
461
+ marked: 0,
462
+ fatalReason: 'invalid-output',
463
+ detail: String(e instanceof Error ? e.message : e).slice(0, 200),
464
+ }
465
+ }
466
+ if (ops.length === 0) {
467
+ return { created: [], updated: [], marked: 0, noOps: true, callsUsed: 1 }
468
+ }
469
+ // observed_ids are the ONLY bridge between ops and marks — without a valid
470
+ // id the store cannot mark anything and the batch head can never advance
471
+ // (the real-machine zero-consumption livelock: topics landed, backlog
472
+ // never moved). Sanitize against the batch's TRUE id set BEFORE executing:
473
+ // an op whose ids all miss the batch is unattributable and gets held back,
474
+ // because executing it anyway would duplicate the topic when the
475
+ // corrective retry re-emits it with fixed ids.
476
+ const validIds = new Set(observations.map((o) => o.id))
477
+ const idList = observations.map((o) => o.id)
478
+ let applied = await this.applyOps(ops, validIds)
479
+ let callsUsed = 1
480
+ if (applied.consumedIds.length === 0 && applied.droppedForIds > 0) {
481
+ if (budgetLeft < 2) {
482
+ // No free lane: the retry must fit the same run budget.
483
+ return {
484
+ created: [],
485
+ updated: [],
486
+ marked: 0,
487
+ callsUsed,
488
+ filteredIds: applied.filteredIds,
489
+ stallDetail: `ops 未包含有效 observed_ids(已过滤 ${applied.filteredIds} 个无效 id),且模型调用预算不足以纠错重试(剩余 ${budgetLeft - 1} 次)`,
490
+ }
491
+ }
492
+ const pass1 = applied
493
+ const { applied: pass2, why } = await this.correctiveRetry(sessionId, user, idList, validIds)
494
+ callsUsed = 2
495
+ if (pass2 === undefined || pass2.consumedIds.length === 0) {
496
+ const tail =
497
+ pass2 === undefined
498
+ ? `纠错重试未产出可消费的 ops(${why})`
499
+ : `纠错重试后 ops 仍未包含有效 observed_ids(累计过滤 ${pass1.filteredIds + pass2.filteredIds} 个无效 id)`
500
+ return {
501
+ created: [...pass1.created, ...pass2?.created ?? []],
502
+ updated: [...pass1.updated, ...pass2?.updated ?? []],
503
+ marked: 0,
504
+ callsUsed,
505
+ filteredIds: pass1.filteredIds + (pass2?.filteredIds ?? 0),
506
+ stallDetail: `${tail};本批 ${observations.length} 条观察零消费`,
507
+ }
508
+ }
509
+ applied = {
510
+ created: [...pass1.created, ...pass2.created],
511
+ updated: [...pass1.updated, ...pass2.updated],
512
+ consumedIds: pass2.consumedIds,
513
+ consumedSlugs: pass2.consumedSlugs,
514
+ filteredIds: pass1.filteredIds + pass2.filteredIds,
515
+ droppedForIds: pass2.droppedForIds,
516
+ }
517
+ }
518
+ let marked = 0
519
+ if (applied.consumedIds.length > 0) {
520
+ marked = await this.service.store.markDistilled(applied.consumedIds, applied.consumedSlugs)
521
+ }
522
+ void this.service.sync?.schedulePush()
523
+ return {
524
+ created: applied.created,
525
+ updated: applied.updated,
526
+ marked,
527
+ callsUsed,
528
+ filteredIds: applied.filteredIds,
529
+ consumedIds: applied.consumedIds,
530
+ }
531
+ }
532
+
533
+ /**
534
+ * The ONE corrective retry for a zero-valid-observed_ids batch: re-ask with
535
+ * the batch's legal id list spelled out verbatim. It spends a second call
536
+ * from the same run budget; any failure here resolves to
537
+ * `{ applied: undefined, why }` and the caller falls through to the stalled
538
+ * stop — a max-tokens correction does NOT trigger the batch halving (that
539
+ * escape hatch belongs to the main pass).
540
+ */
541
+ private async correctiveRetry(
542
+ sessionId: string | undefined,
543
+ originalUser: string,
544
+ idList: readonly string[],
545
+ validIds: ReadonlySet<string>,
546
+ ): Promise<{ applied?: AppliedOps; why: string }> {
547
+ const caller = this.caller
548
+ if (caller === undefined) return { why: 'distill caller unavailable' }
549
+ const user = [
550
+ originalUser,
551
+ '',
552
+ '---',
553
+ '纠错重试:你上一次返回的 ops 未包含任何有效的 observed_ids。',
554
+ `本批合法观察 id 列表(共 ${idList.length} 个,必须逐字复制,禁止改写、缩写或编造):`,
555
+ JSON.stringify(idList),
556
+ '请重新输出完整蒸馏结果(严格 JSON,{"ops":[...]}):每个 op 必须带 observed_ids,值只能从上面的列表逐字选取;create 填它所综合依据的观察 id,update 填促使本次修订的观察 id。没有可沉淀内容就输出 {"ops":[]}。',
557
+ ].join('\n')
558
+ let raw: string
559
+ try {
560
+ raw = await caller({ system: SYSTEM_PROMPT, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
561
+ } catch (e) {
562
+ return { why: `模型调用失败:${String(e instanceof Error ? e.message : e).slice(0, 160)}` }
563
+ }
564
+ let retried: DistillOp[]
565
+ try {
566
+ retried = parseOps(raw)
567
+ } catch (e) {
568
+ return { why: `输出无法解析:${String(e instanceof Error ? e.message : e).slice(0, 160)}` }
569
+ }
570
+ if (retried.length === 0) return { why: '模型返回空 ops' }
571
+ return { applied: await this.applyOps(retried, validIds), why: '' }
572
+ }
573
+
574
+ /**
575
+ * Execute a batch's ops with observed_ids sanitized upfront. A scalar
576
+ * observed_ids string (the commonest model shape drift) is normalized to a
577
+ * one-element list; other non-array shapes are held back as droppedForIds
578
+ * rather than dying in the per-op catch. An op whose
579
+ * sanitized id set is empty is held back entirely (droppedForIds): an
580
+ * unattributable topic write is one the marks can never account for, and
581
+ * the stalled stop would leave it duplicated on the next run. Structural
582
+ * failures (missing fields, unknown update slug) keep the per-op try/catch
583
+ * isolation — one bad op never sinks the batch, and a stall they cause is
584
+ * NOT attributed to observed_ids (no corrective retry for those).
585
+ */
586
+ private async applyOps(ops: readonly DistillOp[], validIds: ReadonlySet<string>): Promise<AppliedOps> {
587
+ const applied: AppliedOps = {
588
+ created: [],
589
+ updated: [],
590
+ consumedIds: [],
591
+ consumedSlugs: [],
592
+ filteredIds: 0,
593
+ droppedForIds: 0,
594
+ }
595
+ for (const op of ops) {
596
+ try {
597
+ // Shape normalization first: models most often deviate by returning a
598
+ // scalar string instead of a list — rescue it into a one-element list.
599
+ // Any other non-array shape counts toward droppedForIds so the
600
+ // corrective-retry gate fires, instead of a silent TypeError in the
601
+ // per-op catch below that would misattribute the stall.
602
+ const rawIds: readonly unknown[] = Array.isArray(op.observed_ids)
603
+ ? op.observed_ids
604
+ : typeof op.observed_ids === 'string'
605
+ ? [op.observed_ids]
606
+ : []
607
+ const ids = rawIds.filter((id): id is string => typeof id === 'string' && validIds.has(id))
608
+ applied.filteredIds += Math.max(0, rawIds.length - ids.length)
609
+ if (ids.length === 0) {
610
+ applied.droppedForIds += 1
611
+ continue
612
+ }
613
+ if (op.op === 'create' && typeof op.title === 'string' && typeof op.conclusion === 'string') {
614
+ const res = await this.service.saveTopic({
615
+ title: op.title,
616
+ conclusion: op.conclusion,
617
+ description: op.description,
618
+ tags: op.tags,
619
+ depends: op.depends,
620
+ openQuestions: op.open_questions,
621
+ impact: op.impact,
622
+ recommendations: op.recommendations,
623
+ status: op.status === 'stable' ? 'stable' : 'draft',
624
+ source: 'distill',
625
+ })
626
+ applied.created.push(res.slug)
627
+ applied.consumedSlugs.push(res.slug)
628
+ } else if (op.op === 'update' && typeof op.slug === 'string') {
629
+ const existing = await this.service.store.readTopic(op.slug)
630
+ if (existing === undefined) continue
631
+ const res = await this.service.saveTopic({
632
+ title: existing.fm.title,
633
+ conclusion: typeof op.conclusion === 'string' ? op.conclusion : undefined,
634
+ openQuestions: op.open_questions,
635
+ status: op.status === 'stable' || op.status === 'deprecated' ? op.status : undefined,
636
+ slug: op.slug,
637
+ source: 'distill',
638
+ })
639
+ applied.updated.push(res.slug)
640
+ applied.consumedSlugs.push(res.slug)
641
+ } else {
642
+ continue
643
+ }
644
+ applied.consumedIds.push(...ids)
645
+ } catch {
646
+ // One bad op must not sink the batch; unconsumed observations stay pending.
647
+ }
648
+ }
649
+ return applied
650
+ }
651
+ }
652
+
653
+ /** Extract the first balanced JSON object from model output (tolerates fences). */
654
+ export function parseOps(raw: string): DistillOp[] {
655
+ let text = raw.trim()
656
+ const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text)
657
+ if (fence !== null) text = fence[1].trim()
658
+ const start = text.indexOf('{')
659
+ if (start < 0) throw new Error('no JSON object in output')
660
+ // Balanced-brace scan respecting strings.
661
+ let depth = 0
662
+ let inString = false
663
+ let escaped = false
664
+ for (let i = start; i < text.length; i += 1) {
665
+ const ch = text[i]
666
+ if (escaped) {
667
+ escaped = false
668
+ continue
669
+ }
670
+ if (ch === '\\') {
671
+ escaped = true
672
+ continue
673
+ }
674
+ if (ch === '"') inString = !inString
675
+ if (inString) continue
676
+ if (ch === '{') depth += 1
677
+ if (ch === '}') {
678
+ depth -= 1
679
+ if (depth === 0) {
680
+ const parsed: unknown = JSON.parse(text.slice(start, i + 1))
681
+ const ops = (parsed as { ops?: unknown }).ops
682
+ if (!Array.isArray(ops)) throw new Error('output has no ops array')
683
+ return ops.filter((o): o is DistillOp => o !== null && typeof o === 'object')
684
+ }
685
+ }
686
+ }
687
+ throw new Error('unbalanced JSON in output')
688
+ }
689
+
690
+ /**
691
+ * Minimal live-route probe surface, shared by the distill lane and the
692
+ * distill-route pickers: any llm-like service instance with an optional
693
+ * provider listing. A disposed scope throws on access.
694
+ */
695
+ export interface LlmProbeShape {
696
+ listProviders?(): readonly { id: string }[]
697
+ }
698
+
699
+ /**
700
+ * A candidate llm instance plus the cheap liveness probe. `listProviders` is
701
+ * optional so bare fakes still work; when present it must list the route's
702
+ * provider for the instance to qualify.
703
+ */
704
+ export interface LlmCandidateShape extends LlmProbeShape {
705
+ stream(options: unknown): AsyncIterable<unknown>
706
+ }
707
+
708
+ export interface LlmCandidatePick {
709
+ llm?: LlmProbeShape
710
+ /** How many candidates exposed a live route table (probe ran cleanly). */
711
+ probed: number
712
+ }
713
+
714
+ /**
715
+ * The one walker behind both llm-instance picks (distill lane + route pickers)
716
+ * so their semantics cannot drift: walk the candidates in priority order and
717
+ * return the first whose live route table satisfies `probe`, plus how many
718
+ * candidates were cleanly probed along the way (the caller uses that to tell
719
+ * 「有实例但不匹配」 apart from 「没有实例」). A disposed scope throws on
720
+ * access — that instance is skipped, not fatal. An instance WITHOUT the probe
721
+ * is accepted as-is unless `requireProbe` is set: it offers nothing to compare
722
+ * against, so an unprobable candidate can never vouch for the route.
723
+ */
724
+ export function pickLlmCandidate(
725
+ candidates: readonly (LlmProbeShape | undefined)[],
726
+ probe: (providers: readonly { id: string }[]) => boolean,
727
+ requireProbe = false,
728
+ ): LlmCandidatePick {
729
+ let probed = 0
730
+ for (const candidate of candidates) {
731
+ if (candidate === undefined) continue
732
+ try {
733
+ const providers = typeof candidate.listProviders === 'function' ? candidate.listProviders() : undefined
734
+ if (providers !== undefined) {
735
+ probed += 1
736
+ if (!probe(providers)) continue
737
+ } else if (requireProbe) {
738
+ // An unprobeable instance cannot prove an adapter serves the route —
739
+ // skipping it is what lets the pre-flight fail with the readable
740
+ // message instead of letting a later stream() surface a raw NO_ADAPTER.
741
+ continue
742
+ }
743
+ } catch {
744
+ continue // disposed scope: even touching the service throws
745
+ }
746
+ return { llm: candidate, probed }
747
+ }
748
+ return { probed }
749
+ }
750
+
751
+ /**
752
+ * Cheap pre-flight for the distill lane: walk the candidates in priority order
753
+ * (per-session capture first, then the session-wide capture, apply-time root
754
+ * last) and return the only instance that honestly qualifies — one whose live
755
+ * `listProviders()` PROVES a registered adapter serves `provider`. On dsh-llm
756
+ * 0.1.2-alpha.4 `listProviders()` reads the same adapter registry `stream()`
757
+ * dispatches through (lib/index.js:1308 vs :1626), so a probed match is an
758
+ * adapter guarantee; candidates that cannot be probed are skipped, not
759
+ * trusted. Fails BEFORE calling stream, with the failure mode readable: a
760
+ * reachable instance that lacks the route (scope released by a teardown, or a
761
+ * typo'd provider) says so; no reachable instance at all says that instead.
762
+ */
763
+ export function pickLiveLlm(candidates: readonly (LlmCandidateShape | undefined)[], provider: string): LlmCandidateShape {
764
+ const { llm, probed } = pickLlmCandidate(
765
+ candidates,
766
+ (providers) => providers.some((p) => p.id === provider),
767
+ true,
768
+ )
769
+ if (llm !== undefined) return llm as LlmCandidateShape
770
+ if (probed > 0) {
771
+ throw new Error(`distill-provider «${provider}» 没有匹配的模型路由(检查拼写或本机 provider 配置),等待下次会话启动重试`)
772
+ }
773
+ throw new Error(`蒸馏模型路由 ${provider} 暂不可用:没有可用的模型服务实例(会话已结束或本机 llm 服务缺失)`)
774
+ }
775
+
776
+ /** True for the adapter-registry miss thrown by `llm.stream()` on a route nobody registered. */
777
+ function isNoAdapter(error: unknown): boolean {
778
+ if (error === null || typeof error !== 'object') return false
779
+ if ((error as { code?: unknown }).code === 'NO_ADAPTER') return true
780
+ return typeof (error as { message?: unknown }).message === 'string' && /no adapter registered for provider/i.test((error as { message: string }).message)
781
+ }
782
+
783
+ /**
784
+ * True for the output-token-limit finish — the one model failure a smaller
785
+ * batch can actually rescue (fewer observations in, fewer ops out). Stamped
786
+ * as `code: 'MAX_TOKENS'` by defaultModelCaller's finish branch; the message
787
+ * fallback is anchored to the exact finish shape this lane itself emits
788
+ * (`model finish: <kind>`), so a request-side error that merely mentions
789
+ * max-tokens in its text can never trigger the persistent batch shrink.
790
+ */
791
+ function isMaxTokens(error: unknown): boolean {
792
+ if (error === null || typeof error !== 'object') return false
793
+ if ((error as { code?: unknown }).code === 'MAX_TOKENS') return true
794
+ return (
795
+ typeof (error as { message?: unknown }).message === 'string' &&
796
+ /^model finish: (max-tokens|length)$/i.test((error as { message: string }).message)
797
+ )
798
+ }
799
+
800
+ /**
801
+ * Default ModelCaller over the dsh LLM seam (`ctx.llm.stream` + BlockAssembler),
802
+ * following dsh-session-title-llm's call policy. `getCandidates` supplies the
803
+ * ordered instance candidates — production passes the triggering session's
804
+ * captured instance first, then the session-wide capture and the apply-time
805
+ * root; the first one whose live routes prove the configured provider wins.
806
+ * The configured-route gate is NOT here: `Distiller.runInner` checks the
807
+ * route per run (so `/topics set distill-provider` stays live without a
808
+ * reload); this closure's own route check at call time is defense-in-depth
809
+ * for direct callers.
810
+ *
811
+ * Last-line guarantee: whatever host shape the candidates come from, a
812
+ * `NO_ADAPTER` miss inside `stream()` is never recorded raw — it is rethrown
813
+ * as a readable failure naming the route and the likely cause, so the
814
+ * distill-state detail stays actionable (CHANGELOG promise).
815
+ *
816
+ * dsh 0.1.2-alpha.3 seam shape: `GenerateOptions.purpose` is the closed union
817
+ * `'compaction' | 'session-title'` — a distill call has no sanctioned value
818
+ * there, so it stays unset (ordinary-conversation classification). `sessionId`
819
+ * is `Branded<'SessionId'>`, stamped by the agent loop for request routing and
820
+ * replay cursors; this lane runs outside the loop (often after session end),
821
+ * so it stays unset too. `deepFreeze` moved to @deepseek-ai/dsh-util-values.
822
+ */
823
+ export function defaultModelCaller(
824
+ getCandidates: (req?: ModelRequest) => readonly (LlmCandidateShape | undefined)[],
825
+ getRoute: () => { provider: string; model: string } | undefined,
826
+ ): ModelCaller | undefined {
827
+ return async (req) => {
828
+ const route = getRoute()
829
+ if (route === undefined) throw new Error('distill route not configured (set distill-provider and distill-model)')
830
+ const llm = pickLiveLlm(getCandidates(req), route.provider)
831
+ const { BlockAssembler, createUserMessage } = await import('@deepseek-ai/dsh-llm')
832
+ const { deepFreeze } = await import('@deepseek-ai/dsh-util-values')
833
+ const messages = [
834
+ createUserMessage({
835
+ content: [{ type: 'text', text: req.user }],
836
+ source: { kind: 'plugin', plugin: 'dsh-topics-memory' },
837
+ }),
838
+ ]
839
+ const options = deepFreeze({
840
+ provider: route.provider,
841
+ model: route.model,
842
+ messages,
843
+ system: req.system,
844
+ maxTokens: req.maxTokens,
845
+ signal: req.signal,
846
+ })
847
+ const assembler = new BlockAssembler()
848
+ try {
849
+ for await (const chunk of llm.stream(options)) {
850
+ ;(assembler as unknown as { push(c: unknown): void }).push(chunk)
851
+ }
852
+ } catch (e) {
853
+ if (isNoAdapter(e)) {
854
+ const message = e instanceof Error ? e.message : String(e)
855
+ throw new Error(
856
+ `蒸馏模型路由 ${route.provider}/${route.model} 缺少 provider 适配器(${message}):` +
857
+ '会话级 llm 实例可能已随会话结束释放,请在会话活跃期间触发蒸馏,或检查宿主是否加载了 provider 适配器插件',
858
+ )
859
+ }
860
+ throw e
861
+ }
862
+ // Defensive: tolerate both object {kind, ...} and bare-string finish
863
+ // shapes; the bare-string form has never been observed in any known
864
+ // dsh-llm version.
865
+ const finish = assembler.finish as string | { kind?: string; failure?: { message: string } } | undefined
866
+ const finishKind = typeof finish === 'string' ? finish : finish?.kind
867
+ const failure = typeof finish === 'object' && finish !== undefined ? finish.failure : undefined
868
+ if (finish !== undefined && finishKind !== 'stop') {
869
+ const message = failure?.message ?? `model finish: ${String(finishKind)}`
870
+ if (finishKind === 'max-tokens' || finishKind === 'length') {
871
+ // Code-stamped so the distill loop can classify it as the one
872
+ // retryable-with-a-smaller-batch failure (isMaxTokens).
873
+ throw Object.assign(new Error(message), { code: 'MAX_TOKENS' })
874
+ }
875
+ throw new Error(message)
876
+ }
877
+ const blocks = assembler.blocks() as { type: string; text?: string }[]
878
+ const text = blocks
879
+ .filter((b) => b.type === 'text' && typeof b.text === 'string')
880
+ .map((b) => b.text as string)
881
+ .join('')
882
+ if (text.trim() === '') throw new Error('model produced no text')
883
+ return text
884
+ }
885
+ }