@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/src/commands.ts CHANGED
@@ -12,6 +12,7 @@ import { join } from 'node:path'
12
12
  import { spawn } from 'node:child_process'
13
13
  import type { TopicsService } from './service.ts'
14
14
  import type { DistillResult } from './distill.ts'
15
+ import type { ConsolidateResult } from './consolidate.ts'
15
16
  import { serializeTopicDoc, slugify, firstParagraph } from './okf.ts'
16
17
  import { buildGraph, renderGraphHtml } from './viz.ts'
17
18
  import { CONFIG_KEYS, displayKey, type ConfigKey, type TopicsConfigValue, parseConfigValue } from './config.ts'
@@ -37,11 +38,19 @@ import {
37
38
  */
38
39
  export type ManualDistill = (invocation: CommandInvocation) => Promise<DistillResult>
39
40
 
41
+ /**
42
+ * Manual `/topics consolidate` trigger, wired by the host (index.ts owns the
43
+ * lane instance). Bypasses the cadence gate; the lane's own in-flight guard
44
+ * still applies.
45
+ */
46
+ export type ManualConsolidate = (invocation: CommandInvocation) => Promise<ConsolidateResult>
47
+
40
48
  export const HELP = [
41
49
  'dsh-topics-memory — OKF topic 记忆(本地 bundle,git 可追溯,可选 GitHub 同步)',
42
50
  ' /topics onboard 交互式配置向导(ask-user 面板逐项问答;无 UI 环境逐条输入)',
43
51
  ' /topics status bundle 健康:topic 数、观察积压、冲突、同步状态',
44
52
  ' /topics distill 手动触发一次蒸馏(观察池 → Topic,输出 marked/created/updated/gc 摘要)',
53
+ ' /topics consolidate 手动触发一次整理(合并重复/晋升 stable/废弃过时/刷新元数据,无视 cadence 立即跑)',
45
54
  ' /topics stats 注入统计:hit rate、top-N、near-miss 分布与调参建议',
46
55
  ' /topics list 列出全部 Topic',
47
56
  ' /topics show <slug> 查看一个 Topic 全文(含反向引用)',
@@ -55,17 +64,17 @@ export const HELP = [
55
64
  '凭据:$GITHUB_TOKEN 或已登录的 gh CLI;登录不在本插件职责内。',
56
65
  ].join('\n')
57
66
 
58
- export function buildTopicsCommand(service: TopicsService, mutate: MutateFn, resolveAsk: AskServiceResolver = () => undefined, resolveLlm: LlmDirectoryResolver = () => [], distillNow?: ManualDistill): CommandDefinition {
67
+ export function buildTopicsCommand(service: TopicsService, mutate: MutateFn, resolveAsk: AskServiceResolver = () => undefined, resolveLlm: LlmDirectoryResolver = () => [], distillNow?: ManualDistill, consolidateNow?: ManualConsolidate): CommandDefinition {
59
68
  const onboard = createOnboardHandler(service, mutate, undefined, resolveAsk, resolveLlm)
60
69
  return {
61
70
  name: 'topics',
62
- description: 'OKF topic 记忆:onboard | distill | status | stats | list | show | history | graph | sync | config | set',
63
- input: { hint: '[onboard | distill | status | stats | list | show <slug> | history <slug> | graph | sync [pull|push] | config | set <key> <value>]' },
64
- handler: (invocation) => handle(invocation, service, mutate, onboard, resolveAsk, resolveLlm, distillNow),
71
+ description: 'OKF topic 记忆:onboard | distill | consolidate | status | stats | list | show | history | graph | sync | config | set',
72
+ input: { hint: '[onboard | distill | consolidate | status | stats | list | show <slug> | history <slug> | graph | sync [pull|push] | config | set <key> <value>]' },
73
+ handler: (invocation) => handle(invocation, service, mutate, onboard, resolveAsk, resolveLlm, distillNow, consolidateNow),
65
74
  }
66
75
  }
67
76
 
68
- async function handle(invocation: CommandInvocation, service: TopicsService, mutate: MutateFn, onboard: (args: string[], invocation: CommandInvocation) => Promise<CommandResult>, resolveAsk: AskServiceResolver, resolveLlm: LlmDirectoryResolver, distillNow?: ManualDistill): Promise<CommandResult> {
77
+ async function handle(invocation: CommandInvocation, service: TopicsService, mutate: MutateFn, onboard: (args: string[], invocation: CommandInvocation) => Promise<CommandResult>, resolveAsk: AskServiceResolver, resolveLlm: LlmDirectoryResolver, distillNow?: ManualDistill, consolidateNow?: ManualConsolidate): Promise<CommandResult> {
69
78
  const raw = invocation.rawInput.trim()
70
79
  const [action = '', ...rest] = raw.split(/\s+/)
71
80
  try {
@@ -78,6 +87,8 @@ async function handle(invocation: CommandInvocation, service: TopicsService, mut
78
87
  return ok(await renderStatus(service))
79
88
  case 'distill':
80
89
  return await doDistill(service, invocation, distillNow)
90
+ case 'consolidate':
91
+ return await doConsolidate(service, invocation, consolidateNow)
81
92
  case 'stats':
82
93
  return ok(await renderStats(service))
83
94
  case 'list':
@@ -125,6 +136,7 @@ async function renderStatus(service: TopicsService): Promise<string> {
125
136
  ['注入去重', cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'],
126
137
  ['慢道', cfg.qualityLane === 'off' ? '关' : cfg.qualityLane === 'always' ? `每轮(${cfg.distillProvider}/${cfg.distillModel})` : `采样 1/3(${cfg.distillProvider}/${cfg.distillModel})`],
127
138
  ['蒸馏', cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'],
139
+ ['整理', cadenceLabel(cfg.consolidateCadence) + (cfg.consolidateCadence === 'off' ? '' : `(复用蒸馏模型,/topics consolidate 立即跑)`)],
128
140
  ]
129
141
  // Last lane outcome (distill-state summary) — what "checkable via
130
142
  // /topics status" promises; absent until the first run of this bundle.
@@ -137,6 +149,20 @@ async function renderStatus(service: TopicsService): Promise<string> {
137
149
  const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : ''
138
150
  rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`])
139
151
  }
152
+ const lastConsolidate = await service.store.readConsolidateState()
153
+ if (lastConsolidate !== undefined) {
154
+ const count = (v: unknown): number => (Array.isArray(v) ? v.length : 0)
155
+ const counts = [
156
+ `合并 ${count(lastConsolidate.merged)}`,
157
+ `晋升 ${count(lastConsolidate.promoted)}`,
158
+ `废弃 ${count(lastConsolidate.deprecated)}`,
159
+ `刷新 ${count(lastConsolidate.refreshed)}`,
160
+ ].join('/')
161
+ rows.push([
162
+ '最近整理',
163
+ `${counts} @ ${cell(String(lastConsolidate.at ?? '').slice(0, 19).replace('T', ' '))}${lastConsolidate.ok === false && lastConsolidate.detail ? `(${String(lastConsolidate.detail).slice(0, 80)})` : ''}`,
164
+ ])
165
+ }
140
166
  if (service.sync !== undefined) {
141
167
  rows.push(['上次推送', cell(String(service.sync.lastPushAt ?? '从未'))])
142
168
  if (service.sync.lastError !== '') rows.push(['同步错误', cell(service.sync.lastError.split('\n').at(-1) ?? '')])
@@ -156,6 +182,59 @@ async function renderStatus(service: TopicsService): Promise<string> {
156
182
  * summary mirrors the distill state fields (ok / marked / created / updated /
157
183
  * gc dropped / reason) so what the user sees is what the state file records.
158
184
  */
185
+ /** Human-readable cadence for status/consolidate output. */
186
+ function cadenceLabel(value: string): string {
187
+ switch (value) {
188
+ case 'daily':
189
+ return '每天'
190
+ case '3d':
191
+ return '每 3 天'
192
+ case '7d':
193
+ return '每 7 天'
194
+ default:
195
+ return '关'
196
+ }
197
+ }
198
+
199
+ /**
200
+ * `/topics consolidate` — one manual run of the consolidation lane (LLM
201
+ * gardener over the existing pool). Bypasses the cadence gate; the lane's
202
+ * own in-flight guard dedups a concurrent automatic run. Output mirrors the
203
+ * lane's actions with per-op reasons so the user can eyeball (and git-revert)
204
+ * what the model decided.
205
+ */
206
+ async function doConsolidate(service: TopicsService, invocation: CommandInvocation, consolidateNow: ManualConsolidate | undefined): Promise<CommandResult> {
207
+ if (consolidateNow === undefined) return fail('整理 lane 未接线(宿主未提供手动整理触发器)')
208
+ const cfg = service.cfg
209
+ if (cfg.distillProvider === '' || cfg.distillModel === '') {
210
+ return fail('整理复用蒸馏模型路由:请先 /topics set distill-provider 与 distill-model。')
211
+ }
212
+ const result = await consolidateNow(invocation)
213
+ if (!result.ok) {
214
+ const reasonNote =
215
+ result.reason === 'no-clusters'
216
+ ? '没有找到可能相近的 topic 候选簇(词面上没有疑似重复/相关对)'
217
+ : result.reason === 'no-model'
218
+ ? '蒸馏模型未配置(/topics set distill-provider / distill-model)'
219
+ : result.reason === 'in-flight'
220
+ ? '已有整理在跑,稍后再试'
221
+ : (result.reason ?? 'unknown')
222
+ return fail(`整理未执行:${reasonNote}${result.detail !== undefined ? `\n ${result.detail}` : ''}`)
223
+ }
224
+ const lines: string[] = []
225
+ const actions = result.actions ?? []
226
+ const KIND_LABEL: Record<string, string> = { merge: '合并', promote: '晋升', deprecate: '废弃', refresh: '刷新' }
227
+ for (const a of actions) {
228
+ if (a.kind === 'merge' && a.merged !== undefined) {
229
+ lines.push(`- 合并 ${a.merged.map((m) => cell(m)).join('、')} → ${cell(a.slug)}${a.reason !== undefined ? `:${a.reason}` : ''}`)
230
+ } else {
231
+ lines.push(`- ${KIND_LABEL[a.kind] ?? a.kind} ${cell(a.slug)}${a.reason !== undefined ? `:${a.reason}` : ''}`)
232
+ }
233
+ }
234
+ 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}` : ''}`
235
+ return ok(lines.length === 0 ? header : `${header}\n${lines.join('\n')}\n全部变更已逐条 git commit,可 /topics history <slug> 追溯、git revert 回滚。`)
236
+ }
237
+
159
238
  async function doDistill(service: TopicsService, invocation: CommandInvocation, distillNow: ManualDistill | undefined): Promise<CommandResult> {
160
239
  if (distillNow === undefined) return fail('蒸馏 lane 未接线(宿主未提供手动蒸馏触发器)')
161
240
  const pending = await service.store.undistilledObservations(1)
package/src/config.ts CHANGED
@@ -58,6 +58,20 @@ export const TopicsConfig = z.object({
58
58
  * Default 8: with the batch loop, one run then drains ~30-40 observations
59
59
  * instead of ~10, which is what makes a real backlog actually shrink. */
60
60
  distillMaxModelCalls: z.number().default(8),
61
+ /** Consolidation lane (整理) cadence: how often a session start may run the
62
+ * LLM gardener over the EXISTING pool (merge near-duplicates, promote
63
+ * settled drafts, deprecate superseded, refresh metadata). Reuses the
64
+ * distill model route; off disables the lane entirely. */
65
+ consolidateCadence: z.string().default('daily'),
66
+ /** Deprecated topics older than this many days are dropped at session start
67
+ * (local TTL sweep, no model; each drop is its own git commit, so history
68
+ * stays recoverable on the remote). 0 disables the sweep. */
69
+ deprecatedTtlDays: z.number().default(15),
70
+ /** UsageBoost (ADR 0015): behavioral bonus for topics injected/opened in
71
+ * the last 30 days, folded into the gate score — capped at 0.2 and never
72
+ * granted to zero-lexical candidates; the structural gate still applies.
73
+ * 0 = off. */
74
+ usageBoost: z.number().default(0.15),
61
75
  /** Debounced push delay in GitHub mode. */
62
76
  pushDebounceSeconds: z.number().default(45),
63
77
  })
@@ -85,6 +99,9 @@ export type TopicsConfigValue = {
85
99
  distillModel: string
86
100
  distillBatchSize: number
87
101
  distillMaxModelCalls: number
102
+ consolidateCadence: string
103
+ deprecatedTtlDays: number
104
+ usageBoost: number
88
105
  pushDebounceSeconds: number
89
106
  }
90
107
 
@@ -111,6 +128,9 @@ export const CONFIG_KEYS = [
111
128
  'distillModel',
112
129
  'distillBatchSize',
113
130
  'distillMaxModelCalls',
131
+ 'consolidateCadence',
132
+ 'deprecatedTtlDays',
133
+ 'usageBoost',
114
134
  'pushDebounceSeconds',
115
135
  ] as const
116
136
 
@@ -149,6 +169,7 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
149
169
  case 'distillEveryTurns':
150
170
  case 'distillBatchSize':
151
171
  case 'distillMaxModelCalls':
172
+ case 'deprecatedTtlDays':
152
173
  case 'pushDebounceSeconds': {
153
174
  const n = Number(raw)
154
175
  if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return { error: `${key} 需要非负整数` }
@@ -158,12 +179,17 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
158
179
  if (raw === 'pointer' || raw === 'digest') return raw
159
180
  return { error: 'inject-mode 取值 pointer|digest' }
160
181
  }
182
+ case 'consolidateCadence': {
183
+ if (raw === 'off' || raw === 'daily' || raw === '3d' || raw === '7d') return raw
184
+ return { error: 'consolidate-cadence 取值 off|daily|3d|7d' }
185
+ }
161
186
  case 'qualityLane': {
162
187
  if (raw === 'off' || raw === 'sampled' || raw === 'always') return raw
163
188
  return { error: 'quality-lane 取值 off|sampled|always' }
164
189
  }
165
190
  case 'matchThreshold':
166
- case 'tagBoost': {
191
+ case 'tagBoost':
192
+ case 'usageBoost': {
167
193
  const n = Number(raw)
168
194
  if (!Number.isFinite(n) || n < 0) return { error: `${key} 需要非负数` }
169
195
  return n