@aiwayds/dsh-topics-memory 0.13.1 → 0.14.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,639 @@
1
+ /**
2
+ * Consolidation lane — periodic LLM gardening over the topic pool itself.
3
+ *
4
+ * Where the distill lane turns observations into topics, this lane turns the
5
+ * EXISTING pool into a better one: merging near-duplicates, promoting drafts
6
+ * whose conclusions have settled, deprecating superseded entries, refreshing
7
+ * metadata (title/tags/triggers). Triggered by cadence at session start
8
+ * (`consolidate-cadence`: off | daily | 3d | 7d) or manually via
9
+ * `/topics consolidate`. The model route is shared with the distill lane
10
+ * (distill-provider/distill-model) — one route to configure, one caller
11
+ * instance injected.
12
+ *
13
+ * Safety model:
14
+ * - grounded proposals: clusters are built locally (lexical similarity over
15
+ * title+tags tokens), so the model only ever judges topics that already
16
+ * look alike and every op names its slugs verbatim from the bundle;
17
+ * - four op kinds only, and refresh may NOT touch the conclusion body —
18
+ * rewriting conclusions is merge's exclusive path, so a metadata refresh
19
+ * can never silently lose knowledge; no create: consolidation prunes,
20
+ * it never grows;
21
+ * - every write rides the normal saveTopic path (one git commit per change),
22
+ * so the whole run is one `git revert` away;
23
+ * - the cadence stamp (meta/consolidate-state.json) advances only when the
24
+ * model actually evaluated at least one cluster — a run that died on the
25
+ * first call leaves the stamp alone, and the next session start retries.
26
+ *
27
+ * @module consolidate
28
+ */
29
+
30
+ import * as okf from './okf.ts'
31
+ import type { TopicsService } from './service.ts'
32
+ import { parseOps, type ModelCaller } from './distill.ts'
33
+
34
+ /** Below this pairwise Jaccard two topics never share a cluster. */
35
+ const CLUSTER_THRESHOLD = 0.3
36
+ /** Clusters are capped — a giant cluster drowns the model in candidates. */
37
+ export const MAX_CLUSTER_SIZE = 6
38
+ /** Model calls (≈ clusters) per run; the rest wait for the next cadence. */
39
+ const MAX_MODEL_CALLS_PER_RUN = 8
40
+ /** Hard cap on applied ops per run — consolidation lands in reviewable steps. */
41
+ const MAX_OPS_PER_RUN = 12
42
+ /** Conclusion chars fed per topic in a cluster payload. */
43
+ const CONCLUSION_SNIPPET_CHARS = 600
44
+ const CONSOLIDATE_MAX_TOKENS = 2500
45
+
46
+ export interface ConsolidateOp {
47
+ op: 'merge' | 'promote' | 'deprecate' | 'refresh'
48
+ /** promote/deprecate/refresh target. */
49
+ slug?: string
50
+ /** merge: the surviving slug. */
51
+ survivor?: string
52
+ /** merge: slugs absorbed INTO the survivor (≥ 1). */
53
+ merged?: string[]
54
+ title?: string
55
+ description?: string
56
+ tags?: string[]
57
+ triggers?: string[]
58
+ /** merge only (refresh may not rewrite conclusions). */
59
+ conclusion?: string
60
+ reason?: string
61
+ }
62
+
63
+ export interface ConsolidateAction {
64
+ kind: ConsolidateOp['op']
65
+ /** merge: survivor; others: the target slug. */
66
+ slug: string
67
+ /** merge: the absorbed slugs. */
68
+ merged?: string[]
69
+ reason?: string
70
+ }
71
+
72
+ export interface ConsolidateResult {
73
+ ok: boolean
74
+ reason?: 'no-model' | 'no-clusters' | 'in-flight' | 'model-error' | 'invalid-output'
75
+ merged: string[]
76
+ promoted: string[]
77
+ deprecated: string[]
78
+ refreshed: string[]
79
+ /** Ops rejected by sanitization, with the model's raw op count for context. */
80
+ droppedOps?: number
81
+ rawOps?: number
82
+ calls?: number
83
+ actions?: ConsolidateAction[]
84
+ detail?: string
85
+ }
86
+
87
+ export const CONSOLIDATE_SYSTEM_PROMPT = [
88
+ '你是 topic 记忆库的整理引擎(园丁)。输入是一个候选簇:若干条可能相近的已有 topic(JSON,含 slug/标题/状态/标签/结论摘要)。',
89
+ '任务:判断簇内有没有值得整理的动作,输出严格 JSON:{"ops":[...]},不要任何其他文字或代码块解释。只允许四类 op:',
90
+ ' {"op":"merge","survivor":"保留条目的slug","merged":["被并入的slug",...],"conclusion":"合并后的完整结论","title":"合并后的标题(可选)","tags":["并集标签(可选)"],"reason":"一句话理由"}',
91
+ ' {"op":"promote","slug":"...","reason":"一句话理由"}  # draft 且结论已自含、有效 → stable',
92
+ ' {"op":"deprecate","slug":"...","reason":"一句话理由"} # 已被更新结论取代/不再成立 → deprecated',
93
+ ' {"op":"refresh","slug":"...","title":"...","description":"...","tags":[...],"triggers":[...],"reason":"一句话理由"} # 只修元数据,让标题/标签/触发词更准确、可检索',
94
+ '规则:',
95
+ '- merge 只用于结论重复或高度重叠的条目:conclusion 必须是合并后的完整结论(自含、不依赖原文也能读懂),取双方有效信息的并集,不丢关键事实;survivor 选信息更全或更新的那条。',
96
+ '- refresh 禁止改结论——它只能修 title/description/tags/triggers;想改结论就用 merge 或不要动。',
97
+ '- slug 必须从输入里逐字复制,禁止改写、缩写或编造;survivor 不得出现在它自己的 merged 里。',
98
+ '- payload 里的 injections30d/opens30d 是该 topic 近 30 天被注入命中的次数与被点开的次数:0 表示近期从未被检索命中,可作为 deprecate 或 refresh(修命名/标签)的支持证据;但不要仅凭零使用就 deprecate——新条目和小众但关键的条目也会零命中。',
99
+ '- 拿不准就不动:宁可输出 {"ops":[]}。禁止 create 新 topic,禁止输出这四类之外的 op。',
100
+ '- 全部用中文写内容;tags 全小写。',
101
+ ].join('\n')
102
+
103
+ // --- Local clustering (lexical, no model) ------------------------------------
104
+
105
+ /** Latin words + CJK bigrams — the token space title/tags similarity lives in. */
106
+ export function topicTokens(text: string): Set<string> {
107
+ const tokens = new Set<string>()
108
+ const lower = text.toLowerCase()
109
+ for (const m of lower.matchAll(/[a-z0-9][a-z0-9._-]+/g)) tokens.add(m[0])
110
+ for (const m of lower.matchAll(/[\u4e00-\u9fff]+/g)) {
111
+ const seg = m[0]
112
+ if (seg.length === 1) {
113
+ tokens.add(seg)
114
+ continue
115
+ }
116
+ for (let i = 0; i < seg.length - 1; i += 1) tokens.add(seg.slice(i, i + 2))
117
+ }
118
+ return tokens
119
+ }
120
+
121
+ function jaccard(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
122
+ if (a.size === 0 || b.size === 0) return 0
123
+ let inter = 0
124
+ for (const t of a) if (b.has(t)) inter += 1
125
+ return inter / (a.size + b.size - inter)
126
+ }
127
+
128
+ export interface ClusterEntry {
129
+ slug: string
130
+ title: string
131
+ status: string
132
+ tags: readonly string[]
133
+ description?: string
134
+ conclusion: string
135
+ }
136
+
137
+ export interface TopicCluster {
138
+ entries: ClusterEntry[]
139
+ /** Peak pairwise similarity inside the cluster — runs process peaks first. */
140
+ similarity: number
141
+ }
142
+
143
+ /**
144
+ * Group entries whose title+description+tags tokens clear the pairwise
145
+ * threshold (union-find over similar pairs). Singletons are dropped — the
146
+ * model only ever sees topics that already look alike. Clusters larger than
147
+ * {@link MAX_CLUSTER_SIZE} keep their most-connected members.
148
+ */
149
+ export function clusterTopics(
150
+ entries: readonly ClusterEntry[],
151
+ threshold = CLUSTER_THRESHOLD,
152
+ ): TopicCluster[] {
153
+ const tokens = entries.map((e) => topicTokens(`${e.title} ${e.description ?? ''} ${e.tags.join(' ')}`))
154
+ const parent = entries.map((_, i) => i)
155
+ const find = (x: number): number => {
156
+ while (parent[x] !== x) {
157
+ parent[x] = parent[parent[x]]
158
+ x = parent[x]
159
+ }
160
+ return x
161
+ }
162
+ const scores = new Map<number, number>()
163
+ for (let i = 0; i < entries.length; i += 1) {
164
+ for (let j = i + 1; j < entries.length; j += 1) {
165
+ const s = jaccard(tokens[i] ?? new Set(), tokens[j] ?? new Set())
166
+ if (s >= threshold) {
167
+ parent[find(i)] = find(j)
168
+ scores.set(i * entries.length + j, s)
169
+ }
170
+ }
171
+ }
172
+ const groups = new Map<number, number[]>()
173
+ for (let i = 0; i < entries.length; i += 1) {
174
+ const root = find(i)
175
+ const g = groups.get(root)
176
+ if (g === undefined) groups.set(root, [i])
177
+ else g.push(i)
178
+ }
179
+ const clusters: TopicCluster[] = []
180
+ for (const members of groups.values()) {
181
+ if (members.length < 2) continue
182
+ let peak = 0
183
+ for (const [key, s] of scores) {
184
+ const i = Math.floor(key / entries.length)
185
+ if (members.includes(i)) peak = Math.max(peak, s)
186
+ }
187
+ let kept = members
188
+ if (members.length > MAX_CLUSTER_SIZE) {
189
+ // Most-connected members stay; the tail waits for a later run.
190
+ const degree = new Map<number, number>()
191
+ for (const [key, s] of scores) {
192
+ const i = Math.floor(key / entries.length)
193
+ const j = key % entries.length
194
+ if (!members.includes(i)) continue
195
+ degree.set(i, Math.max(degree.get(i) ?? 0, s))
196
+ degree.set(j, Math.max(degree.get(j) ?? 0, s))
197
+ }
198
+ kept = [...members]
199
+ .sort((a, b) => (degree.get(b) ?? 0) - (degree.get(a) ?? 0))
200
+ .slice(0, MAX_CLUSTER_SIZE)
201
+ }
202
+ clusters.push({
203
+ entries: kept.map((i) => entries[i] as ClusterEntry),
204
+ similarity: peak,
205
+ })
206
+ }
207
+ clusters.sort((a, b) => b.similarity - a.similarity)
208
+ return clusters
209
+ }
210
+
211
+ // --- Deprecated-TTL housekeeping (local rule, no model) ----------------------
212
+
213
+ /**
214
+ * Drop `deprecated` topics older than `ttlDays` — pure housekeeping, never
215
+ * touches the model. `generated.at` is the right clock: every save restamps
216
+ * it and nothing ever touches a deprecated entry afterwards, so it IS the
217
+ * "became deprecated at" timestamp. Deletion rides the normal store path
218
+ * (one commit per drop, index regenerated), so the history stays recoverable
219
+ * on the remote; an unparsable stamp is skipped, not guessed. Returns the
220
+ * dropped slugs. `ttlDays <= 0` disables the sweep.
221
+ */
222
+ export async function dropExpiredDeprecated(
223
+ service: TopicsService,
224
+ ttlDays: number,
225
+ now = Date.now(),
226
+ ): Promise<string[]> {
227
+ if (!Number.isFinite(ttlDays) || ttlDays <= 0) return []
228
+ const metas = await service.store.listTopics()
229
+ const cutoff = now - ttlDays * 86_400_000
230
+ const dropped: string[] = []
231
+ for (const m of metas) {
232
+ if (m.status !== 'deprecated') continue
233
+ const at = Date.parse(m.generatedAt)
234
+ if (!Number.isFinite(at) || at > cutoff) continue
235
+ try {
236
+ const removed = await service.store.deleteTopic(
237
+ m.slug,
238
+ `topics(topic): drop deprecated ${m.slug} (TTL ${ttlDays}d, deprecated ${new Date(at).toISOString().slice(0, 10)})`,
239
+ )
240
+ if (removed) dropped.push(m.slug)
241
+ } catch {
242
+ // one unreadable entry never stops the sweep
243
+ }
244
+ }
245
+ if (dropped.length > 0) {
246
+ service.invalidate()
247
+ void service.sync?.schedulePush()
248
+ }
249
+ return dropped
250
+ }
251
+
252
+ // --- The lane ----------------------------------------------------------------
253
+
254
+ interface ClusterOutcome {
255
+ fatal?: 'model-error' | 'invalid-output'
256
+ detail?: string
257
+ ops: ConsolidateOp[]
258
+ }
259
+
260
+ interface AppliedConsolidation {
261
+ merged: string[]
262
+ promoted: string[]
263
+ deprecated: string[]
264
+ refreshed: string[]
265
+ actions: ConsolidateAction[]
266
+ dropped: number
267
+ }
268
+
269
+ export class Consolidator {
270
+ private inFlight: Promise<ConsolidateResult> | undefined
271
+ private readonly service: TopicsService
272
+ private readonly caller: ModelCaller | undefined
273
+
274
+ constructor(service: TopicsService, caller: ModelCaller | undefined) {
275
+ this.service = service
276
+ this.caller = caller
277
+ }
278
+
279
+ get configured(): boolean {
280
+ return this.caller !== undefined
281
+ }
282
+
283
+ /** Cadence days for a config value; undefined = off (or unrecognized). */
284
+ static cadenceDays(value: string | undefined): number | undefined {
285
+ switch (value) {
286
+ case 'daily':
287
+ return 1
288
+ case '3d':
289
+ return 3
290
+ case '7d':
291
+ return 7
292
+ default:
293
+ return undefined
294
+ }
295
+ }
296
+
297
+ get hasPending(): boolean {
298
+ return this.inFlight !== undefined
299
+ }
300
+
301
+ /**
302
+ * Cadence-gated entry (session start): silently skips when off, not due,
303
+ * or already running. Due = no stamp yet (first run) or the last EVALUATED
304
+ * run is older than the cadence.
305
+ */
306
+ async maybeRun(opts: { sessionId?: string; now?: number } = {}): Promise<ConsolidateResult | undefined> {
307
+ const days = Consolidator.cadenceDays(this.service.cfg.consolidateCadence)
308
+ if (days === undefined) return undefined
309
+ if (this.inFlight !== undefined) return undefined
310
+ const state = await this.service.store.readConsolidateState()
311
+ if (state !== undefined && typeof state.at === 'string') {
312
+ const last = Date.parse(state.at)
313
+ if (Number.isFinite(last) && (opts.now ?? Date.now()) - last < days * 86_400_000) return undefined
314
+ }
315
+ return this.start(opts.sessionId)
316
+ }
317
+
318
+ /** Manual entry (/topics consolidate): bypasses the cadence, keeps the guard. */
319
+ run(sessionId?: string): Promise<ConsolidateResult> {
320
+ return this.start(sessionId)
321
+ }
322
+
323
+ private start(sessionId?: string): Promise<ConsolidateResult> {
324
+ if (this.inFlight !== undefined) return this.inFlight
325
+ const run = this.runInner(sessionId).finally(() => {
326
+ this.inFlight = undefined
327
+ })
328
+ this.inFlight = run
329
+ void run.catch(() => undefined)
330
+ return run
331
+ }
332
+
333
+ private async runInner(sessionId?: string): Promise<ConsolidateResult> {
334
+ const base = { merged: [] as string[], promoted: [] as string[], deprecated: [] as string[], refreshed: [] as string[] }
335
+ if (this.caller === undefined) {
336
+ return { ok: false, reason: 'no-model', ...base, detail: '整理 lane 未接线(无模型调用器)' }
337
+ }
338
+ const cfg = this.service.cfg
339
+ if (cfg.distillProvider === '' || cfg.distillModel === '') {
340
+ return {
341
+ ok: false,
342
+ reason: 'no-model',
343
+ ...base,
344
+ detail: '整理复用蒸馏模型路由(distill-provider / distill-model),当前未配置',
345
+ }
346
+ }
347
+ const clusters = await this.buildClusters()
348
+ if (clusters.length === 0) {
349
+ // Nothing looks alike — a clean bill of health, but the model never
350
+ // evaluated, so the stamp stays unset and the next window re-checks.
351
+ return { ok: false, reason: 'no-clusters', ...base }
352
+ }
353
+ const applied: AppliedConsolidation = { merged: [], promoted: [], deprecated: [], refreshed: [], actions: [], dropped: 0 }
354
+ let rawOps = 0
355
+ let calls = 0
356
+ let evaluated = 0
357
+ let fatal: 'model-error' | 'invalid-output' | undefined
358
+ let failureDetail: string | undefined
359
+ for (const cluster of clusters) {
360
+ if (calls >= MAX_MODEL_CALLS_PER_RUN) break
361
+ const outcome = await this.runCluster(sessionId, cluster)
362
+ calls += 1
363
+ if (outcome.fatal !== undefined) {
364
+ fatal = outcome.fatal
365
+ failureDetail = outcome.detail
366
+ break
367
+ }
368
+ evaluated += 1
369
+ rawOps += outcome.ops.length
370
+ if (applied.merged.length + applied.promoted.length + applied.deprecated.length + applied.refreshed.length >= MAX_OPS_PER_RUN) {
371
+ break
372
+ }
373
+ const room = MAX_OPS_PER_RUN - (applied.merged.length + applied.promoted.length + applied.deprecated.length + applied.refreshed.length)
374
+ const result = await this.applyOps(outcome.ops.slice(0, room), cluster)
375
+ applied.merged.push(...result.merged)
376
+ applied.promoted.push(...result.promoted)
377
+ applied.deprecated.push(...result.deprecated)
378
+ applied.refreshed.push(...result.refreshed)
379
+ applied.actions.push(...result.actions)
380
+ applied.dropped += result.dropped
381
+ }
382
+ const progress = applied.merged.length + applied.promoted.length + applied.deprecated.length + applied.refreshed.length > 0
383
+ // The stamp advances only when the model actually evaluated something —
384
+ // a run that died on its first call retries at the next session start.
385
+ let stampNote = ''
386
+ if (evaluated > 0) {
387
+ await this.service.store
388
+ .writeConsolidateState({
389
+ at: new Date().toISOString(),
390
+ ok: !fatal,
391
+ calls,
392
+ merged: applied.merged,
393
+ promoted: applied.promoted,
394
+ deprecated: applied.deprecated,
395
+ refreshed: applied.refreshed,
396
+ droppedOps: applied.dropped,
397
+ detail: failureDetail,
398
+ })
399
+ .catch((err: unknown) => {
400
+ stampNote = `;consolidate-state 写入失败: ${err instanceof Error ? err.message : String(err)}`
401
+ })
402
+ } else if (fatal !== undefined) {
403
+ return { ok: false, reason: fatal, ...base, calls, detail: failureDetail }
404
+ }
405
+ const parts: string[] = []
406
+ if (applied.merged.length > 0) parts.push(`合并 ${applied.merged.length} 组`)
407
+ if (applied.promoted.length > 0) parts.push(`晋升 ${applied.promoted.length}`)
408
+ if (applied.deprecated.length > 0) parts.push(`废弃 ${applied.deprecated.length}`)
409
+ if (applied.refreshed.length > 0) parts.push(`刷新 ${applied.refreshed.length}`)
410
+ const summary = parts.length === 0 ? '无需整理' : parts.join(';')
411
+ const tail = [
412
+ applied.dropped > 0 ? `丢弃 ${applied.dropped} 个无效 op` : undefined,
413
+ `${calls} 次模型调用(评估 ${evaluated} 簇 / 候选 ${clusters.length} 簇)`,
414
+ fatal !== undefined ? `中途失败:${failureDetail ?? 'unknown'}` : undefined,
415
+ stampNote === '' ? undefined : stampNote.replace(/^;/, ''),
416
+ ]
417
+ .filter(Boolean)
418
+ .join(';')
419
+ void this.service.sync?.schedulePush()
420
+ return {
421
+ ok: evaluated > 0,
422
+ ...(fatal !== undefined && evaluated === 0 ? { reason: fatal } : {}),
423
+ merged: [...applied.merged],
424
+ promoted: [...applied.promoted],
425
+ deprecated: [...applied.deprecated],
426
+ refreshed: [...applied.refreshed],
427
+ droppedOps: applied.dropped,
428
+ rawOps,
429
+ calls,
430
+ actions: applied.actions,
431
+ detail: `${summary}${tail === '' ? '' : `(${tail})`}`,
432
+ }
433
+ }
434
+
435
+ /** Read the active pool and cluster it lexically. */
436
+ private async buildClusters(): Promise<TopicCluster[]> {
437
+ const metas = await this.service.store.listTopics()
438
+ const usage = this.service.usageSignalsSync()
439
+ const entries: ClusterEntry[] = []
440
+ for (const m of metas) {
441
+ if (m.status === 'deprecated') continue
442
+ const doc = await this.service.store.readTopic(m.slug).catch(() => undefined)
443
+ if (doc === undefined) continue
444
+ entries.push({
445
+ slug: m.slug,
446
+ title: m.title,
447
+ status: m.status,
448
+ tags: m.tags,
449
+ description: doc.fm.description,
450
+ conclusion: (okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '')
451
+ .slice(0, CONCLUSION_SNIPPET_CHARS)
452
+ .trim(),
453
+ })
454
+ }
455
+ return clusterTopics(entries).slice(0, MAX_MODEL_CALLS_PER_RUN)
456
+ }
457
+
458
+ /** One model call over one cluster, ops shape-validated but not yet applied. */
459
+ private async runCluster(sessionId: string | undefined, cluster: TopicCluster): Promise<ClusterOutcome> {
460
+ const caller = this.caller
461
+ if (caller === undefined) return { ops: [], fatal: 'model-error', detail: 'consolidate caller unavailable' }
462
+ const usage = this.service.usageSignalsSync()
463
+ // The aggregate map only keys slugs WITH signals — but "0 hits in 30d" is
464
+ // exactly the gardening evidence the guardrail sentence talks about. When
465
+ // the logs are non-empty, absence from the map IS a measured zero; when
466
+ // they're empty (fresh bundle) the fields stay absent rather than lying.
467
+ const usageKnown = usage.size > 0
468
+ const usageOf = (slug: string): { injections30d: number; opens30d: number } | undefined => {
469
+ if (!usageKnown) return undefined
470
+ const u = usage.get(slug)
471
+ return { injections30d: u?.hits ?? 0, opens30d: u?.opens ?? 0 }
472
+ }
473
+ const payload = cluster.entries.map((e) => {
474
+ const base: Record<string, unknown> = {
475
+ slug: e.slug,
476
+ title: e.title,
477
+ status: e.status,
478
+ tags: e.tags,
479
+ conclusion: e.conclusion,
480
+ }
481
+ const u = usageOf(e.slug)
482
+ if (u !== undefined) Object.assign(base, u)
483
+ return base
484
+ })
485
+ const user = [
486
+ `候选簇(${payload.length} 条可能相近的 topic):`,
487
+ JSON.stringify(payload),
488
+ '',
489
+ '请输出整理结果(严格 JSON,{"ops":[...]});没有值得整理的就输出 {"ops":[]}:',
490
+ ].join('\n')
491
+ let raw: string
492
+ try {
493
+ raw = await caller({
494
+ system: CONSOLIDATE_SYSTEM_PROMPT,
495
+ user,
496
+ purpose: 'topics-consolidate',
497
+ sessionId,
498
+ maxTokens: CONSOLIDATE_MAX_TOKENS,
499
+ })
500
+ } catch (e) {
501
+ return { ops: [], fatal: 'model-error', detail: String(e instanceof Error ? e.message : e).slice(0, 200) }
502
+ }
503
+ let ops: ConsolidateOp[]
504
+ try {
505
+ // parseOps extracts the first balanced {"ops":[...]} and filters
506
+ // non-object entries; the per-op shape re-validation happens in
507
+ // sanitizeOps (a consolidate op carries different fields than a
508
+ // distill op, so nothing here trusts the cast).
509
+ ops = parseOps(raw) as unknown as ConsolidateOp[]
510
+ } catch (e) {
511
+ return { ops: [], fatal: 'invalid-output', detail: String(e instanceof Error ? e.message : e).slice(0, 200) }
512
+ }
513
+ return { ops }
514
+ }
515
+
516
+ /**
517
+ * Validate and apply ops against the live bundle. Slugs are slugify-normalized
518
+ * and must exist; merge requires a non-empty conclusion and forbids self-merge;
519
+ * refresh never receives the conclusion field (merge's exclusive path); ops on
520
+ * slugs already merged away earlier in this run are skipped (the deprecation
521
+ * already redirects readers to the survivor). One bad op never sinks the rest.
522
+ */
523
+ private async applyOps(ops: readonly ConsolidateOp[], cluster: TopicCluster): Promise<AppliedConsolidation> {
524
+ const applied: AppliedConsolidation = { merged: [], promoted: [], deprecated: [], refreshed: [], actions: [], dropped: 0 }
525
+ const clusterSlugs = new Set(cluster.entries.map((e) => e.slug))
526
+ for (const op of ops) {
527
+ try {
528
+ if (applied.merged.length + applied.promoted.length + applied.deprecated.length + applied.refreshed.length >= MAX_OPS_PER_RUN) break
529
+ if (typeof op !== 'object' || op === null) {
530
+ applied.dropped += 1
531
+ continue
532
+ }
533
+ if (op.op === 'merge') {
534
+ const survivor = typeof op.survivor === 'string' ? okf.slugify(op.survivor) : undefined
535
+ const mergedRaw = Array.isArray(op.merged) ? op.merged : []
536
+ const merged = mergedRaw.filter((s): s is string => typeof s === 'string').map((s) => okf.slugify(s))
537
+ if (
538
+ survivor === undefined ||
539
+ survivor === '' ||
540
+ merged.length === 0 ||
541
+ merged.includes(survivor) ||
542
+ !clusterSlugs.has(survivor) ||
543
+ !merged.every((s) => clusterSlugs.has(s)) ||
544
+ typeof op.conclusion !== 'string' ||
545
+ op.conclusion.trim() === ''
546
+ ) {
547
+ applied.dropped += 1
548
+ continue
549
+ }
550
+ const survivorDoc = await this.service.store.readTopic(survivor)
551
+ if (survivorDoc === undefined) {
552
+ applied.dropped += 1
553
+ continue
554
+ }
555
+ await this.service.saveTopic({
556
+ slug: survivor,
557
+ title: typeof op.title === 'string' && op.title.trim() !== '' ? op.title : survivorDoc.fm.title,
558
+ description: op.description,
559
+ tags: op.tags,
560
+ conclusion: op.conclusion,
561
+ })
562
+ for (const slug of merged) {
563
+ const doc = await this.service.store.readTopic(slug)
564
+ if (doc === undefined) continue
565
+ const original = okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? ''
566
+ const pointer = `> 已并入 topics/${survivor}.md(consolidation ${new Date().toISOString().slice(0, 10)}${op.reason ? `:${op.reason}` : ''})。以下结论保留作历史参考。`
567
+ await this.service.saveTopic({
568
+ slug,
569
+ title: doc.fm.title,
570
+ conclusion: `${pointer}\n\n${original}`.trim(),
571
+ status: 'deprecated',
572
+ })
573
+ }
574
+ applied.merged.push(survivor)
575
+ applied.actions.push({ kind: 'merge', slug: survivor, merged, reason: op.reason })
576
+ } else if (op.op === 'promote' || op.op === 'deprecate') {
577
+ const slug = typeof op.slug === 'string' ? okf.slugify(op.slug) : undefined
578
+ if (slug === undefined || slug === '' || !clusterSlugs.has(slug)) {
579
+ applied.dropped += 1
580
+ continue
581
+ }
582
+ const doc = await this.service.store.readTopic(slug)
583
+ if (doc === undefined || doc.fm.status === (op.op === 'promote' ? 'stable' : 'deprecated')) {
584
+ applied.dropped += 1
585
+ continue
586
+ }
587
+ await this.service.saveTopic({
588
+ slug,
589
+ title: doc.fm.title,
590
+ status: op.op === 'promote' ? 'stable' : 'deprecated',
591
+ })
592
+ if (op.op === 'promote') applied.promoted.push(slug)
593
+ else applied.deprecated.push(slug)
594
+ applied.actions.push({ kind: op.op, slug, reason: op.reason })
595
+ } else if (op.op === 'refresh') {
596
+ const slug = typeof op.slug === 'string' ? okf.slugify(op.slug) : undefined
597
+ if (slug === undefined || slug === '' || !clusterSlugs.has(slug)) {
598
+ applied.dropped += 1
599
+ continue
600
+ }
601
+ const doc = await this.service.store.readTopic(slug)
602
+ if (doc === undefined) {
603
+ applied.dropped += 1
604
+ continue
605
+ }
606
+ // Conclusion is merge's exclusive path — a refresh op carrying one
607
+ // is dropped rather than silently rewritten.
608
+ if (op.conclusion !== undefined) {
609
+ applied.dropped += 1
610
+ continue
611
+ }
612
+ const hasField =
613
+ (typeof op.title === 'string' && op.title.trim() !== '') ||
614
+ (typeof op.description === 'string' && op.description.trim() !== '') ||
615
+ (Array.isArray(op.tags) && op.tags.length > 0) ||
616
+ (Array.isArray(op.triggers) && op.triggers.length > 0)
617
+ if (!hasField) {
618
+ applied.dropped += 1
619
+ continue
620
+ }
621
+ await this.service.saveTopic({
622
+ slug,
623
+ title: typeof op.title === 'string' && op.title.trim() !== '' ? op.title : doc.fm.title,
624
+ description: op.description,
625
+ tags: op.tags,
626
+ triggers: op.triggers,
627
+ })
628
+ applied.refreshed.push(slug)
629
+ applied.actions.push({ kind: 'refresh', slug, reason: op.reason })
630
+ } else {
631
+ applied.dropped += 1
632
+ }
633
+ } catch {
634
+ applied.dropped += 1
635
+ }
636
+ }
637
+ return applied
638
+ }
639
+ }
package/src/ilog.ts CHANGED
@@ -138,3 +138,56 @@ export function querySample(query: string): string {
138
138
  const words = query.replace(/\s+/g, ' ').trim()
139
139
  return words.length <= 40 ? words : `${words.slice(0, 40)}…`
140
140
  }
141
+
142
+ /** Rolling usage-signal window (ADR 0015) — days of Injection/Open history. */
143
+ export const USAGE_WINDOW_DAYS = 30
144
+
145
+ export interface UsageSignal {
146
+ hits: number
147
+ opens: number
148
+ }
149
+
150
+ /**
151
+ * Usage signals per slug over a rolling window (ADR 0015): an injection hit
152
+ * (truly assembled, not deduped/echoed/dropped) counts 1, a topic_open counts
153
+ * 3 — the model chose to read the full text, the strongest "this helped"
154
+ * evidence available. Downstream, only PRESENCE matters (the boost is a
155
+ * fixed capped bonus, never linear in votes); the counts exist for the
156
+ * consolidation payload, where "0 vs many" is the gardening evidence.
157
+ */
158
+ export function aggregateUsage(
159
+ injections: readonly InjectionRecord[],
160
+ opens: readonly { slug: string; at: string }[],
161
+ windowDays: number,
162
+ now: number,
163
+ ): Map<string, UsageSignal> {
164
+ const cutoff = now - windowDays * 86_400_000
165
+ const out = new Map<string, UsageSignal>()
166
+ const bump = (slug: string, kind: keyof UsageSignal): void => {
167
+ if (slug === '') return
168
+ const entry = out.get(slug) ?? { hits: 0, opens: 0 }
169
+ entry[kind] += 1
170
+ out.set(slug, entry)
171
+ }
172
+ for (const record of injections) {
173
+ const at = Date.parse(record.at)
174
+ if (!Number.isFinite(at) || at < cutoff) continue
175
+ if (record.injected !== true) continue
176
+ const excluded = new Set<string>([
177
+ ...(record.deduped ?? []),
178
+ ...(record.echoed ?? []),
179
+ ...(record.dropped ?? []).map((d) => d.slug),
180
+ ])
181
+ for (const hit of record.hits) {
182
+ if (excluded.has(hit.slug)) continue
183
+ bump(hit.slug, 'hits')
184
+ }
185
+ for (const slow of record.slow ?? []) bump(slow.slug, 'hits')
186
+ }
187
+ for (const open of opens) {
188
+ const at = Date.parse(open.at)
189
+ if (!Number.isFinite(at) || at < cutoff) continue
190
+ bump(open.slug, 'opens')
191
+ }
192
+ return out
193
+ }