@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.
package/README.md CHANGED
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
4
4
 
5
5
  A dsh plugin: maintains "working topic memory" as an [OKF (Open Knowledge Format v0.2)](https://github.com/GoogleCloudPlatform/open-knowledge-format) knowledge bundle, persisted in a local git repository (optionally synced to a private GitHub repo), with conclusions traceable through git history, sessions automatically observed and distilled into knowledge, and relevant topics injected to the model before every turn.
6
6
 
7
- > **Requires dsh >= 0.1.2-rc.1** — this plugin targets the dsh RC/stable line only (CI and releases resolve the newest of the `latest`/`next` dist-tags at runtime). **The alpha line is no longer supported.**
7
+ > **Requires dsh >= 0.1.5-rc.2** — this plugin targets the dsh RC/stable line only (CI and releases resolve the newest of the `latest`/`next` dist-tags at runtime). **The alpha line is no longer supported.**
8
8
 
9
9
  ## Demo
10
10
 
@@ -58,6 +58,7 @@ This plugin is not trying to be the model's notebook. It is trying to be the mod
58
58
  | `/topics onboard` | Interactive setup wizard on dsh-native ask-user panels (mode / repo / distill model / injection tier / auto-observe); typed fallback where no ask-user UI exists |
59
59
  | `/topics status` | Bundle health: topic count, observation backlog, conflicts, last distill outcome, sync status |
60
60
  | `/topics distill` | Manually trigger one distill run over the current observation pool (same lane, same in-flight guard; summary mirrors the distill-state fields) |
61
+ | `/topics consolidate` | Manually trigger one consolidation run: the LLM gardener merges duplicates, promotes settled drafts, deprecates superseded entries, refreshes metadata — every change is its own git commit, revert to roll back |
61
62
  | `/topics stats` | Injection stats: hit rate, top-N, near-miss distribution, tuning advice |
62
63
  | `/topics list` / `show` / `history` | Browse topics, backlinks, and change history |
63
64
  | `/topics graph` | Generate a relationship-graph web page (force-directed, draggable/zoomable, hover for conclusions) and open it in the browser |
@@ -121,6 +122,9 @@ First-time setup belongs to `/topics onboard`; day-to-day tuning is `/topics set
121
122
  | `distillOnSessionEnd` | `true` | Distill once when a session ends |
122
123
  | `distillBatchSize` | `40` | Observations per distill model call. On an output-limit (`max-tokens`) failure the batch halves automatically (floor 5) and retries — a failing batch can no longer livelock the backlog; the shrink persists until reload or a config change. Note: `/topics set distillBatchSize` back to the same value does not reset the shrink — set a different value or reload the plugin |
123
124
  | `distillMaxModelCalls` | `8` | Max model calls per distill run, including the one corrective retry for ops echoing no valid `observed_ids` (the run stalls when the budget can't fit it). Batches already distilled keep their marks when the budget stops the run (partial progress), recorded as `partial: …` in the distill state |
125
+ | `consolidateCadence` | `daily` | Consolidation-lane cadence: `daily`/`3d`/`7d`/`off`. At session start the plugin checks the last consolidation time (`meta/consolidate-state.json`) and, when due, runs the LLM gardener in the background (reusing the distill model route): local lexical clustering only feeds near-look-alike candidate clusters; four op kinds merge/promote/deprecate/refresh, out-of-scope ops are dropped; a failed call never advances the stamp, so the next start retries |
126
+ | `deprecatedTtlDays` | `15` | Deprecated topics older than N days are dropped at session start (local rule, no model; each drop is its own git commit — history stays recoverable); `0` disables the sweep |
127
+ | `usageBoost` | `0.15` | Usage boost (ADR 0015): topics injected/opened in the last 30 days score higher at retrieval (gate-scoped, capped at 0.2, never granted to zero-lexical candidates, structural gate not waived); `0` disables |
124
128
  | `pushDebounceSeconds` | `45` | GitHub-mode debounced push interval |
125
129
 
126
130
  ## Acknowledgements
package/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  一个 dsh 插件:把「工作 topic 记忆」维护成 [OKF 标准(Open Knowledge Format v0.2)](https://github.com/GoogleCloudPlatform/open-knowledge-format)的知识 bundle,持久化在本地 git 仓库(可选同步到 GitHub 私有仓库),利用 git 历史提供结论可追溯性,自动观察会话沉淀知识,并在每轮对话前向模型注入相关 Topic。
6
6
 
7
- > **要求 dsh >= 0.1.2-rc.1** — 本插件只跟随 dsh RC/stable 线(CI 与发版在运行时解析 latest/next 中更新的 dist-tag)。**不再支持 alpha 线。**
7
+ > **要求 dsh >= 0.1.5-rc.2** — 本插件只跟随 dsh RC/stable 线(CI 与发版在运行时解析 latest/next 中更新的 dist-tag)。**不再支持 alpha 线。**
8
8
 
9
9
  ## 演示
10
10
 
@@ -52,6 +52,7 @@ https://github.com/user-attachments/assets/6e9b346d-2ec9-4148-ae62-7087797d3188
52
52
  | `/topics onboard` | 交互式配置向导:唤起 dsh 原生 ask-user 面板逐项问答(模式 / 仓库 / 蒸馏 / 注入档位 / 自动观察),末步确认才写入;无 ask-user UI 的环境自动退化为逐条输入 |
53
53
  | `/topics status` | bundle 健康:topic 数、观察积压、冲突、最近蒸馏结果、同步状态 |
54
54
  | `/topics distill` | 手动触发一次蒸馏 run(复用现有 lane 与 in-flight 守卫;输出摘要与 distill-state 字段一一对应) |
55
+ | `/topics consolidate` | 手动触发一次整理 run:LLM 园丁合并重复/晋升 stable/废弃过时/刷新元数据,逐条 git commit 可回滚 |
55
56
  | `/topics stats` | 注入统计:hit rate、top-N、near-miss 分布与调参建议 |
56
57
  | `/topics list` / `show` / `history` | 浏览 Topic、反向引用与变更史 |
57
58
  | `/topics graph` | 生成关系图网页(力导向、可拖拽缩放、悬停看结论)并自动在浏览器打开 |
@@ -121,6 +122,9 @@ dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
121
122
  | `distillOnSessionEnd` | `true` | session 结束时蒸馏一次 |
122
123
  | `distillBatchSize` | `40` | 每次蒸馏模型调用携带的观察条数。遇输出上限(`max-tokens`)失败自动减半重试(下限 5),失败批次不再活锁积压;缩小状态跨 run 保持,直到插件重载或配置变更。注意:`/topics set distillBatchSize` 重设为同值不触发复位,需设为不同值或重载插件 |
123
124
  | `distillMaxModelCalls` | `8` | 单次蒸馏 run 的模型调用预算,含 ops 未回显有效 `observed_ids` 时的纠错重试(至多一次,预算放不下即零消费停机)。预算耗尽即停,已成功的批次照常 markDistilled(部分前进优于零前进),distill state 记 `partial: …` |
125
+ | `consolidateCadence` | `daily` | 整理 lane 节拍:`daily`/`3d`/`7d`/`off`。会话启动时检查上次整理时间(`meta/consolidate-state.json`),到期即在后台自动跑 LLM 园丁(复用蒸馏模型路由):本地词面聚类只送「长得像」的候选簇,四类动作 merge/promote/deprecate/refresh,越权 op 一律丢弃;模型调用失败不推进节拍,下次启动重试 |
126
+ | `deprecatedTtlDays` | `15` | deprecated 条目超过 N 天在会话启动时自动删除(本地规则不依赖模型,逐条 git commit 可回溯找回);`0` 关闭清扫 |
127
+ | `usageBoost` | `0.15` | 使用加成(ADR 0015):近 30 天被注入命中/点开过的 Topic 检索加分(计入门槛分、帽 0.2、零词面相关不加、结构门不豁免);`0` 关闭 |
124
128
  | `pushDebounceSeconds` | `45` | GitHub 模式去抖推送间隔 |
125
129
 
126
130
  ## Acknowledgements
package/lib/commands.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import type { CommandDefinition, CommandInvocation } from '@deepseek-ai/dsh-commands';
9
9
  import type { TopicsService } from './service.ts';
10
10
  import type { DistillResult } from './distill.ts';
11
+ import type { ConsolidateResult } from './consolidate.ts';
11
12
  import { aggregateStats } from './ilog.ts';
12
13
  import { type AskServiceResolver, type LlmDirectoryResolver, type MutateFn } from './onboard.ts';
13
14
  /**
@@ -16,6 +17,12 @@ import { type AskServiceResolver, type LlmDirectoryResolver, type MutateFn } fro
16
17
  * the command's normal error path.
17
18
  */
18
19
  export type ManualDistill = (invocation: CommandInvocation) => Promise<DistillResult>;
20
+ /**
21
+ * Manual `/topics consolidate` trigger, wired by the host (index.ts owns the
22
+ * lane instance). Bypasses the cadence gate; the lane's own in-flight guard
23
+ * still applies.
24
+ */
25
+ export type ManualConsolidate = (invocation: CommandInvocation) => Promise<ConsolidateResult>;
19
26
  export declare const HELP: string;
20
- export declare function buildTopicsCommand(service: TopicsService, mutate: MutateFn, resolveAsk?: AskServiceResolver, resolveLlm?: LlmDirectoryResolver, distillNow?: ManualDistill): CommandDefinition;
27
+ export declare function buildTopicsCommand(service: TopicsService, mutate: MutateFn, resolveAsk?: AskServiceResolver, resolveLlm?: LlmDirectoryResolver, distillNow?: ManualDistill, consolidateNow?: ManualConsolidate): CommandDefinition;
21
28
  export declare function tuningHint(stats: ReturnType<typeof aggregateStats>, threshold: number): string | undefined;
package/lib/commands.js CHANGED
@@ -18,6 +18,7 @@ export const HELP = [
18
18
  ' /topics onboard 交互式配置向导(ask-user 面板逐项问答;无 UI 环境逐条输入)',
19
19
  ' /topics status bundle 健康:topic 数、观察积压、冲突、同步状态',
20
20
  ' /topics distill 手动触发一次蒸馏(观察池 → Topic,输出 marked/created/updated/gc 摘要)',
21
+ ' /topics consolidate 手动触发一次整理(合并重复/晋升 stable/废弃过时/刷新元数据,无视 cadence 立即跑)',
21
22
  ' /topics stats 注入统计:hit rate、top-N、near-miss 分布与调参建议',
22
23
  ' /topics list 列出全部 Topic',
23
24
  ' /topics show <slug> 查看一个 Topic 全文(含反向引用)',
@@ -30,16 +31,16 @@ export const HELP = [
30
31
  '',
31
32
  '凭据:$GITHUB_TOKEN 或已登录的 gh CLI;登录不在本插件职责内。',
32
33
  ].join('\n');
33
- export function buildTopicsCommand(service, mutate, resolveAsk = () => undefined, resolveLlm = () => [], distillNow) {
34
+ export function buildTopicsCommand(service, mutate, resolveAsk = () => undefined, resolveLlm = () => [], distillNow, consolidateNow) {
34
35
  const onboard = createOnboardHandler(service, mutate, undefined, resolveAsk, resolveLlm);
35
36
  return {
36
37
  name: 'topics',
37
- description: 'OKF topic 记忆:onboard | distill | status | stats | list | show | history | graph | sync | config | set',
38
- input: { hint: '[onboard | distill | status | stats | list | show <slug> | history <slug> | graph | sync [pull|push] | config | set <key> <value>]' },
39
- handler: (invocation) => handle(invocation, service, mutate, onboard, resolveAsk, resolveLlm, distillNow),
38
+ description: 'OKF topic 记忆:onboard | distill | consolidate | status | stats | list | show | history | graph | sync | config | set',
39
+ input: { hint: '[onboard | distill | consolidate | status | stats | list | show <slug> | history <slug> | graph | sync [pull|push] | config | set <key> <value>]' },
40
+ handler: (invocation) => handle(invocation, service, mutate, onboard, resolveAsk, resolveLlm, distillNow, consolidateNow),
40
41
  };
41
42
  }
42
- async function handle(invocation, service, mutate, onboard, resolveAsk, resolveLlm, distillNow) {
43
+ async function handle(invocation, service, mutate, onboard, resolveAsk, resolveLlm, distillNow, consolidateNow) {
43
44
  const raw = invocation.rawInput.trim();
44
45
  const [action = '', ...rest] = raw.split(/\s+/);
45
46
  try {
@@ -52,6 +53,8 @@ async function handle(invocation, service, mutate, onboard, resolveAsk, resolveL
52
53
  return ok(await renderStatus(service));
53
54
  case 'distill':
54
55
  return await doDistill(service, invocation, distillNow);
56
+ case 'consolidate':
57
+ return await doConsolidate(service, invocation, consolidateNow);
55
58
  case 'stats':
56
59
  return ok(await renderStats(service));
57
60
  case 'list':
@@ -97,6 +100,7 @@ async function renderStatus(service) {
97
100
  ['注入去重', cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'],
98
101
  ['慢道', cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`],
99
102
  ['蒸馏', cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'],
103
+ ['整理', cadenceLabel(cfg.consolidateCadence) + (cfg.consolidateCadence === 'off' ? '' : `(复用蒸馏模型,/topics consolidate 立即跑)`)],
100
104
  ];
101
105
  // Last lane outcome (distill-state summary) — what "checkable via
102
106
  // /topics status" promises; absent until the first run of this bundle.
@@ -108,6 +112,20 @@ async function renderStatus(service) {
108
112
  const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : '';
109
113
  rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`]);
110
114
  }
115
+ const lastConsolidate = await service.store.readConsolidateState();
116
+ if (lastConsolidate !== undefined) {
117
+ const count = (v) => (Array.isArray(v) ? v.length : 0);
118
+ const counts = [
119
+ `合并 ${count(lastConsolidate.merged)}`,
120
+ `晋升 ${count(lastConsolidate.promoted)}`,
121
+ `废弃 ${count(lastConsolidate.deprecated)}`,
122
+ `刷新 ${count(lastConsolidate.refreshed)}`,
123
+ ].join('/');
124
+ rows.push([
125
+ '最近整理',
126
+ `${counts} @ ${cell(String(lastConsolidate.at ?? '').slice(0, 19).replace('T', ' '))}${lastConsolidate.ok === false && lastConsolidate.detail ? `(${String(lastConsolidate.detail).slice(0, 80)})` : ''}`,
127
+ ]);
128
+ }
111
129
  if (service.sync !== undefined) {
112
130
  rows.push(['上次推送', cell(String(service.sync.lastPushAt ?? '从未'))]);
113
131
  if (service.sync.lastError !== '')
@@ -127,6 +145,58 @@ async function renderStatus(service) {
127
145
  * summary mirrors the distill state fields (ok / marked / created / updated /
128
146
  * gc dropped / reason) so what the user sees is what the state file records.
129
147
  */
148
+ /** Human-readable cadence for status/consolidate output. */
149
+ function cadenceLabel(value) {
150
+ switch (value) {
151
+ case 'daily':
152
+ return '每天';
153
+ case '3d':
154
+ return '每 3 天';
155
+ case '7d':
156
+ return '每 7 天';
157
+ default:
158
+ return '关';
159
+ }
160
+ }
161
+ /**
162
+ * `/topics consolidate` — one manual run of the consolidation lane (LLM
163
+ * gardener over the existing pool). Bypasses the cadence gate; the lane's
164
+ * own in-flight guard dedups a concurrent automatic run. Output mirrors the
165
+ * lane's actions with per-op reasons so the user can eyeball (and git-revert)
166
+ * what the model decided.
167
+ */
168
+ async function doConsolidate(service, invocation, consolidateNow) {
169
+ if (consolidateNow === undefined)
170
+ return fail('整理 lane 未接线(宿主未提供手动整理触发器)');
171
+ const cfg = service.cfg;
172
+ if (cfg.distillProvider === '' || cfg.distillModel === '') {
173
+ return fail('整理复用蒸馏模型路由:请先 /topics set distill-provider 与 distill-model。');
174
+ }
175
+ const result = await consolidateNow(invocation);
176
+ if (!result.ok) {
177
+ const reasonNote = result.reason === 'no-clusters'
178
+ ? '没有找到可能相近的 topic 候选簇(词面上没有疑似重复/相关对)'
179
+ : result.reason === 'no-model'
180
+ ? '蒸馏模型未配置(/topics set distill-provider / distill-model)'
181
+ : result.reason === 'in-flight'
182
+ ? '已有整理在跑,稍后再试'
183
+ : (result.reason ?? 'unknown');
184
+ return fail(`整理未执行:${reasonNote}${result.detail !== undefined ? `\n ${result.detail}` : ''}`);
185
+ }
186
+ const lines = [];
187
+ const actions = result.actions ?? [];
188
+ const KIND_LABEL = { merge: '合并', promote: '晋升', deprecate: '废弃', refresh: '刷新' };
189
+ for (const a of actions) {
190
+ if (a.kind === 'merge' && a.merged !== undefined) {
191
+ lines.push(`- 合并 ${a.merged.map((m) => cell(m)).join('、')} → ${cell(a.slug)}${a.reason !== undefined ? `:${a.reason}` : ''}`);
192
+ }
193
+ else {
194
+ lines.push(`- ${KIND_LABEL[a.kind] ?? a.kind} ${cell(a.slug)}${a.reason !== undefined ? `:${a.reason}` : ''}`);
195
+ }
196
+ }
197
+ const header = `✅ 整理完成:合并 ${result.merged.length} 组;晋升 ${result.promoted.length};废弃 ${result.deprecated.length};刷新 ${result.refreshed.length}${result.droppedOps ? `(丢弃 ${result.droppedOps} 个无效 op)` : ''}${result.detail !== undefined ? `\n ${result.detail}` : ''}`;
198
+ return ok(lines.length === 0 ? header : `${header}\n${lines.join('\n')}\n全部变更已逐条 git commit,可 /topics history <slug> 追溯、git revert 回滚。`);
199
+ }
130
200
  async function doDistill(service, invocation, distillNow) {
131
201
  if (distillNow === undefined)
132
202
  return fail('蒸馏 lane 未接线(宿主未提供手动蒸馏触发器)');
package/lib/config.d.ts CHANGED
@@ -56,6 +56,20 @@ export declare const TopicsConfig: z<Schemastery.ObjectS<{
56
56
  * Default 8: with the batch loop, one run then drains ~30-40 observations
57
57
  * instead of ~10, which is what makes a real backlog actually shrink. */
58
58
  distillMaxModelCalls: z<number, number>;
59
+ /** Consolidation lane (整理) cadence: how often a session start may run the
60
+ * LLM gardener over the EXISTING pool (merge near-duplicates, promote
61
+ * settled drafts, deprecate superseded, refresh metadata). Reuses the
62
+ * distill model route; off disables the lane entirely. */
63
+ consolidateCadence: z<string, string>;
64
+ /** Deprecated topics older than this many days are dropped at session start
65
+ * (local TTL sweep, no model; each drop is its own git commit, so history
66
+ * stays recoverable on the remote). 0 disables the sweep. */
67
+ deprecatedTtlDays: z<number, number>;
68
+ /** UsageBoost (ADR 0015): behavioral bonus for topics injected/opened in
69
+ * the last 30 days, folded into the gate score — capped at 0.2 and never
70
+ * granted to zero-lexical candidates; the structural gate still applies.
71
+ * 0 = off. */
72
+ usageBoost: z<number, number>;
59
73
  /** Debounced push delay in GitHub mode. */
60
74
  pushDebounceSeconds: z<number, number>;
61
75
  }>, Schemastery.ObjectT<{
@@ -109,6 +123,20 @@ export declare const TopicsConfig: z<Schemastery.ObjectS<{
109
123
  * Default 8: with the batch loop, one run then drains ~30-40 observations
110
124
  * instead of ~10, which is what makes a real backlog actually shrink. */
111
125
  distillMaxModelCalls: z<number, number>;
126
+ /** Consolidation lane (整理) cadence: how often a session start may run the
127
+ * LLM gardener over the EXISTING pool (merge near-duplicates, promote
128
+ * settled drafts, deprecate superseded, refresh metadata). Reuses the
129
+ * distill model route; off disables the lane entirely. */
130
+ consolidateCadence: z<string, string>;
131
+ /** Deprecated topics older than this many days are dropped at session start
132
+ * (local TTL sweep, no model; each drop is its own git commit, so history
133
+ * stays recoverable on the remote). 0 disables the sweep. */
134
+ deprecatedTtlDays: z<number, number>;
135
+ /** UsageBoost (ADR 0015): behavioral bonus for topics injected/opened in
136
+ * the last 30 days, folded into the gate score — capped at 0.2 and never
137
+ * granted to zero-lexical candidates; the structural gate still applies.
138
+ * 0 = off. */
139
+ usageBoost: z<number, number>;
112
140
  /** Debounced push delay in GitHub mode. */
113
141
  pushDebounceSeconds: z<number, number>;
114
142
  }>>;
@@ -135,9 +163,12 @@ export type TopicsConfigValue = {
135
163
  distillModel: string;
136
164
  distillBatchSize: number;
137
165
  distillMaxModelCalls: number;
166
+ consolidateCadence: string;
167
+ deprecatedTtlDays: number;
168
+ usageBoost: number;
138
169
  pushDebounceSeconds: number;
139
170
  };
140
- export declare const CONFIG_KEYS: readonly ["repo", "autoInject", "injectDedup", "suppressEcho", "topK", "perTopicBudget", "totalBudget", "matchThreshold", "tagBoost", "injectMode", "qualityLane", "graphDepth", "recencyWindowDays", "autoObserve", "includeSubagents", "observationMaxChars", "distillEveryTurns", "distillOnSessionEnd", "distillProvider", "distillModel", "distillBatchSize", "distillMaxModelCalls", "pushDebounceSeconds"];
171
+ export declare const CONFIG_KEYS: readonly ["repo", "autoInject", "injectDedup", "suppressEcho", "topK", "perTopicBudget", "totalBudget", "matchThreshold", "tagBoost", "injectMode", "qualityLane", "graphDepth", "recencyWindowDays", "autoObserve", "includeSubagents", "observationMaxChars", "distillEveryTurns", "distillOnSessionEnd", "distillProvider", "distillModel", "distillBatchSize", "distillMaxModelCalls", "consolidateCadence", "deprecatedTtlDays", "usageBoost", "pushDebounceSeconds"];
141
172
  export type ConfigKey = (typeof CONFIG_KEYS)[number];
142
173
  /** CamelCase → dash-display (topK → top-k) for command output. */
143
174
  export declare function displayKey(key: string): string;
package/lib/config.js CHANGED
@@ -56,6 +56,20 @@ export const TopicsConfig = z.object({
56
56
  * Default 8: with the batch loop, one run then drains ~30-40 observations
57
57
  * instead of ~10, which is what makes a real backlog actually shrink. */
58
58
  distillMaxModelCalls: z.number().default(8),
59
+ /** Consolidation lane (整理) cadence: how often a session start may run the
60
+ * LLM gardener over the EXISTING pool (merge near-duplicates, promote
61
+ * settled drafts, deprecate superseded, refresh metadata). Reuses the
62
+ * distill model route; off disables the lane entirely. */
63
+ consolidateCadence: z.string().default('daily'),
64
+ /** Deprecated topics older than this many days are dropped at session start
65
+ * (local TTL sweep, no model; each drop is its own git commit, so history
66
+ * stays recoverable on the remote). 0 disables the sweep. */
67
+ deprecatedTtlDays: z.number().default(15),
68
+ /** UsageBoost (ADR 0015): behavioral bonus for topics injected/opened in
69
+ * the last 30 days, folded into the gate score — capped at 0.2 and never
70
+ * granted to zero-lexical candidates; the structural gate still applies.
71
+ * 0 = off. */
72
+ usageBoost: z.number().default(0.15),
59
73
  /** Debounced push delay in GitHub mode. */
60
74
  pushDebounceSeconds: z.number().default(45),
61
75
  });
@@ -82,6 +96,9 @@ export const CONFIG_KEYS = [
82
96
  'distillModel',
83
97
  'distillBatchSize',
84
98
  'distillMaxModelCalls',
99
+ 'consolidateCadence',
100
+ 'deprecatedTtlDays',
101
+ 'usageBoost',
85
102
  'pushDebounceSeconds',
86
103
  ];
87
104
  /** CamelCase → dash-display (topK → top-k) for command output. */
@@ -118,6 +135,7 @@ export function parseConfigValue(key, raw) {
118
135
  case 'distillEveryTurns':
119
136
  case 'distillBatchSize':
120
137
  case 'distillMaxModelCalls':
138
+ case 'deprecatedTtlDays':
121
139
  case 'pushDebounceSeconds': {
122
140
  const n = Number(raw);
123
141
  if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n))
@@ -129,13 +147,19 @@ export function parseConfigValue(key, raw) {
129
147
  return raw;
130
148
  return { error: 'inject-mode 取值 pointer|digest' };
131
149
  }
150
+ case 'consolidateCadence': {
151
+ if (raw === 'off' || raw === 'daily' || raw === '3d' || raw === '7d')
152
+ return raw;
153
+ return { error: 'consolidate-cadence 取值 off|daily|3d|7d' };
154
+ }
132
155
  case 'qualityLane': {
133
156
  if (raw === 'off' || raw === 'sampled' || raw === 'always')
134
157
  return raw;
135
158
  return { error: 'quality-lane 取值 off|sampled|always' };
136
159
  }
137
160
  case 'matchThreshold':
138
- case 'tagBoost': {
161
+ case 'tagBoost':
162
+ case 'usageBoost': {
139
163
  const n = Number(raw);
140
164
  if (!Number.isFinite(n) || n < 0)
141
165
  return { error: `${key} 需要非负数` };
@@ -0,0 +1,138 @@
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 type { TopicsService } from './service.ts';
30
+ import { type ModelCaller } from './distill.ts';
31
+ /** Clusters are capped — a giant cluster drowns the model in candidates. */
32
+ export declare const MAX_CLUSTER_SIZE = 6;
33
+ export interface ConsolidateOp {
34
+ op: 'merge' | 'promote' | 'deprecate' | 'refresh';
35
+ /** promote/deprecate/refresh target. */
36
+ slug?: string;
37
+ /** merge: the surviving slug. */
38
+ survivor?: string;
39
+ /** merge: slugs absorbed INTO the survivor (≥ 1). */
40
+ merged?: string[];
41
+ title?: string;
42
+ description?: string;
43
+ tags?: string[];
44
+ triggers?: string[];
45
+ /** merge only (refresh may not rewrite conclusions). */
46
+ conclusion?: string;
47
+ reason?: string;
48
+ }
49
+ export interface ConsolidateAction {
50
+ kind: ConsolidateOp['op'];
51
+ /** merge: survivor; others: the target slug. */
52
+ slug: string;
53
+ /** merge: the absorbed slugs. */
54
+ merged?: string[];
55
+ reason?: string;
56
+ }
57
+ export interface ConsolidateResult {
58
+ ok: boolean;
59
+ reason?: 'no-model' | 'no-clusters' | 'in-flight' | 'model-error' | 'invalid-output';
60
+ merged: string[];
61
+ promoted: string[];
62
+ deprecated: string[];
63
+ refreshed: string[];
64
+ /** Ops rejected by sanitization, with the model's raw op count for context. */
65
+ droppedOps?: number;
66
+ rawOps?: number;
67
+ calls?: number;
68
+ actions?: ConsolidateAction[];
69
+ detail?: string;
70
+ }
71
+ export declare const CONSOLIDATE_SYSTEM_PROMPT: string;
72
+ /** Latin words + CJK bigrams — the token space title/tags similarity lives in. */
73
+ export declare function topicTokens(text: string): Set<string>;
74
+ export interface ClusterEntry {
75
+ slug: string;
76
+ title: string;
77
+ status: string;
78
+ tags: readonly string[];
79
+ description?: string;
80
+ conclusion: string;
81
+ }
82
+ export interface TopicCluster {
83
+ entries: ClusterEntry[];
84
+ /** Peak pairwise similarity inside the cluster — runs process peaks first. */
85
+ similarity: number;
86
+ }
87
+ /**
88
+ * Group entries whose title+description+tags tokens clear the pairwise
89
+ * threshold (union-find over similar pairs). Singletons are dropped — the
90
+ * model only ever sees topics that already look alike. Clusters larger than
91
+ * {@link MAX_CLUSTER_SIZE} keep their most-connected members.
92
+ */
93
+ export declare function clusterTopics(entries: readonly ClusterEntry[], threshold?: number): TopicCluster[];
94
+ /**
95
+ * Drop `deprecated` topics older than `ttlDays` — pure housekeeping, never
96
+ * touches the model. `generated.at` is the right clock: every save restamps
97
+ * it and nothing ever touches a deprecated entry afterwards, so it IS the
98
+ * "became deprecated at" timestamp. Deletion rides the normal store path
99
+ * (one commit per drop, index regenerated), so the history stays recoverable
100
+ * on the remote; an unparsable stamp is skipped, not guessed. Returns the
101
+ * dropped slugs. `ttlDays <= 0` disables the sweep.
102
+ */
103
+ export declare function dropExpiredDeprecated(service: TopicsService, ttlDays: number, now?: number): Promise<string[]>;
104
+ export declare class Consolidator {
105
+ private inFlight;
106
+ private readonly service;
107
+ private readonly caller;
108
+ constructor(service: TopicsService, caller: ModelCaller | undefined);
109
+ get configured(): boolean;
110
+ /** Cadence days for a config value; undefined = off (or unrecognized). */
111
+ static cadenceDays(value: string | undefined): number | undefined;
112
+ get hasPending(): boolean;
113
+ /**
114
+ * Cadence-gated entry (session start): silently skips when off, not due,
115
+ * or already running. Due = no stamp yet (first run) or the last EVALUATED
116
+ * run is older than the cadence.
117
+ */
118
+ maybeRun(opts?: {
119
+ sessionId?: string;
120
+ now?: number;
121
+ }): Promise<ConsolidateResult | undefined>;
122
+ /** Manual entry (/topics consolidate): bypasses the cadence, keeps the guard. */
123
+ run(sessionId?: string): Promise<ConsolidateResult>;
124
+ private start;
125
+ private runInner;
126
+ /** Read the active pool and cluster it lexically. */
127
+ private buildClusters;
128
+ /** One model call over one cluster, ops shape-validated but not yet applied. */
129
+ private runCluster;
130
+ /**
131
+ * Validate and apply ops against the live bundle. Slugs are slugify-normalized
132
+ * and must exist; merge requires a non-empty conclusion and forbids self-merge;
133
+ * refresh never receives the conclusion field (merge's exclusive path); ops on
134
+ * slugs already merged away earlier in this run are skipped (the deprecation
135
+ * already redirects readers to the survivor). One bad op never sinks the rest.
136
+ */
137
+ private applyOps;
138
+ }