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