@aiwayds/dsh-topics-memory 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/README.zh.md +112 -0
- package/cordis.patch.yml +8 -0
- package/lib/commands.d.ts +21 -0
- package/lib/commands.js +368 -0
- package/lib/config.d.ts +122 -0
- package/lib/config.js +123 -0
- package/lib/delegation.d.ts +23 -0
- package/lib/delegation.js +21 -0
- package/lib/digest.d.ts +42 -0
- package/lib/digest.js +101 -0
- package/lib/distill.d.ts +186 -0
- package/lib/distill.js +759 -0
- package/lib/git.d.ts +66 -0
- package/lib/git.js +169 -0
- package/lib/ilog.d.ts +53 -0
- package/lib/ilog.js +70 -0
- package/lib/index.d.ts +73 -0
- package/lib/index.js +504 -0
- package/lib/observer.d.ts +41 -0
- package/lib/observer.js +128 -0
- package/lib/okf.d.ts +112 -0
- package/lib/okf.js +279 -0
- package/lib/onboard.d.ts +205 -0
- package/lib/onboard.js +664 -0
- package/lib/paths.d.ts +48 -0
- package/lib/paths.js +84 -0
- package/lib/retrieval.d.ts +61 -0
- package/lib/retrieval.js +178 -0
- package/lib/service.d.ts +96 -0
- package/lib/service.js +375 -0
- package/lib/store.d.ts +153 -0
- package/lib/store.js +489 -0
- package/lib/sync.d.ts +52 -0
- package/lib/sync.js +154 -0
- package/lib/tools.d.ts +8 -0
- package/lib/tools.js +208 -0
- package/lib/viz.d.ts +33 -0
- package/lib/viz.js +310 -0
- package/lib/yaml.d.ts +18 -0
- package/lib/yaml.js +334 -0
- package/package.json +97 -0
- package/src/commands.ts +397 -0
- package/src/config.ts +149 -0
- package/src/delegation.ts +27 -0
- package/src/digest.ts +125 -0
- package/src/distill.ts +885 -0
- package/src/git.ts +209 -0
- package/src/ilog.ts +99 -0
- package/src/index.ts +574 -0
- package/src/observer.ts +150 -0
- package/src/okf.ts +317 -0
- package/src/onboard.ts +762 -0
- package/src/paths.ts +104 -0
- package/src/retrieval.ts +219 -0
- package/src/service.ts +400 -0
- package/src/store.ts +567 -0
- package/src/sync.ts +170 -0
- package/src/tools.ts +225 -0
- package/src/viz.ts +333 -0
- package/src/yaml.ts +322 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/topics` slash command family — status | stats | list | show | history |
|
|
3
|
+
* sync | config | set. Registered through the shared dsh-commands registry
|
|
4
|
+
* as an optional peer (vault pattern): hosts without it still load the plugin.
|
|
5
|
+
*
|
|
6
|
+
* @module commands
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CommandDefinition, CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
|
|
10
|
+
import { writeFile } from 'node:fs/promises'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import type { TopicsService } from './service.ts'
|
|
14
|
+
import type { DistillResult } from './distill.ts'
|
|
15
|
+
import { serializeTopicDoc, slugify, firstParagraph } from './okf.ts'
|
|
16
|
+
import { buildGraph, renderGraphHtml } from './viz.ts'
|
|
17
|
+
import { CONFIG_KEYS, displayKey, type ConfigKey, type TopicsConfigValue, parseConfigValue } from './config.ts'
|
|
18
|
+
import { aggregateStats } from './ilog.ts'
|
|
19
|
+
import {
|
|
20
|
+
askDistillSingle,
|
|
21
|
+
createOnboardHandler,
|
|
22
|
+
llmDirectoryNotice,
|
|
23
|
+
makePanel,
|
|
24
|
+
parseDistillInput,
|
|
25
|
+
pickLlmDirectory,
|
|
26
|
+
type AskServiceResolver,
|
|
27
|
+
type AskServiceShape,
|
|
28
|
+
type LlmDirectoryResolver,
|
|
29
|
+
type LlmDirectoryShape,
|
|
30
|
+
type MutateFn,
|
|
31
|
+
} from './onboard.ts'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Manual `/topics distill` trigger, wired by the host (index.ts owns the lane
|
|
35
|
+
* instance). Returns the lane's own result shape; rejections surface through
|
|
36
|
+
* the command's normal error path.
|
|
37
|
+
*/
|
|
38
|
+
export type ManualDistill = (invocation: CommandInvocation) => Promise<DistillResult>
|
|
39
|
+
|
|
40
|
+
export const HELP = [
|
|
41
|
+
'dsh-topics-memory — OKF topic 记忆(本地 bundle,git 可追溯,可选 GitHub 同步)',
|
|
42
|
+
' /topics onboard 交互式配置向导(ask-user 面板逐项问答;无 UI 环境逐条输入)',
|
|
43
|
+
' /topics status bundle 健康:topic 数、观察积压、冲突、同步状态',
|
|
44
|
+
' /topics distill 手动触发一次蒸馏(观察池 → Topic,输出 marked/created/updated/gc 摘要)',
|
|
45
|
+
' /topics stats 注入统计:hit rate、top-N、near-miss 分布与调参建议',
|
|
46
|
+
' /topics list 列出全部 Topic',
|
|
47
|
+
' /topics show <slug> 查看一个 Topic 全文(含反向引用)',
|
|
48
|
+
' /topics history <slug> 一个 Topic 的结论变更史(git log)',
|
|
49
|
+
' /topics graph 生成关系图网页并在浏览器打开',
|
|
50
|
+
' /topics sync [pull|push] GitHub 模式:手动拉取/推送(默认模式自动)',
|
|
51
|
+
' /topics config 查看当前配置',
|
|
52
|
+
' /topics set <key> <value> 修改配置;key: ' + CONFIG_KEYS.join(' | '),
|
|
53
|
+
' (distill-provider / distill-model 不带 value 且有 ask UI + 可用模型路由时弹选择面板;distill-model 支持 "provider model" 混写自动拆分)',
|
|
54
|
+
'',
|
|
55
|
+
'凭据:$GITHUB_TOKEN 或已登录的 gh CLI;登录不在本插件职责内。',
|
|
56
|
+
].join('\n')
|
|
57
|
+
|
|
58
|
+
export function buildTopicsCommand(service: TopicsService, mutate: MutateFn, resolveAsk: AskServiceResolver = () => undefined, resolveLlm: LlmDirectoryResolver = () => [], distillNow?: ManualDistill): CommandDefinition {
|
|
59
|
+
const onboard = createOnboardHandler(service, mutate, undefined, resolveAsk, resolveLlm)
|
|
60
|
+
return {
|
|
61
|
+
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),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
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> {
|
|
69
|
+
const raw = invocation.rawInput.trim()
|
|
70
|
+
const [action = '', ...rest] = raw.split(/\s+/)
|
|
71
|
+
try {
|
|
72
|
+
switch (action) {
|
|
73
|
+
case '':
|
|
74
|
+
return ok(HELP)
|
|
75
|
+
case 'onboard':
|
|
76
|
+
return await onboard(rest, invocation)
|
|
77
|
+
case 'status':
|
|
78
|
+
return ok(await renderStatus(service))
|
|
79
|
+
case 'distill':
|
|
80
|
+
return await doDistill(service, invocation, distillNow)
|
|
81
|
+
case 'stats':
|
|
82
|
+
return ok(await renderStats(service))
|
|
83
|
+
case 'list':
|
|
84
|
+
return ok(await renderList(service))
|
|
85
|
+
case 'show':
|
|
86
|
+
return await renderShow(service, rest[0])
|
|
87
|
+
case 'history':
|
|
88
|
+
return await renderHistory(service, rest[0])
|
|
89
|
+
case 'graph':
|
|
90
|
+
return await doGraph(service)
|
|
91
|
+
case 'sync':
|
|
92
|
+
return await doSync(service, rest[0])
|
|
93
|
+
case 'config':
|
|
94
|
+
return ok(renderConfig(service.cfg))
|
|
95
|
+
case 'set':
|
|
96
|
+
return await doSet(service, rest, mutate, resolveAsk, resolveLlm, invocation)
|
|
97
|
+
default:
|
|
98
|
+
return fail(`未知子动作 “${action}”。\n\n${HELP}`)
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
102
|
+
return fail(`topics ${action} 失败:${message}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ok(text: string): CommandResult {
|
|
107
|
+
return { kind: 'success', text }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fail(text: string): CommandResult {
|
|
111
|
+
return { kind: 'error', text }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function renderStatus(service: TopicsService): Promise<string> {
|
|
115
|
+
const s = await service.store.status()
|
|
116
|
+
const cfg = service.cfg
|
|
117
|
+
const lines = [
|
|
118
|
+
`dsh-topics-memory @ ${s.root}`,
|
|
119
|
+
` 模式:${service.githubMode ? `github(${cfg.repo})` : 'local-only'}`,
|
|
120
|
+
` Topics:${s.topicCount}(draft ${s.byStatus.draft} / stable ${s.byStatus.stable} / deprecated ${s.byStatus.deprecated})`,
|
|
121
|
+
` 观察积压:${s.observationsPending} 未蒸馏 / ${s.observationsTotal} 总量`,
|
|
122
|
+
` 冲突:${s.conflicts.length === 0 ? '无' : s.conflicts.join('、')}`,
|
|
123
|
+
` 损坏文件:${s.broken.length === 0 ? '无' : s.broken.join('、')}`,
|
|
124
|
+
` git:${s.git ? `是(HEAD ${s.head?.slice(0, 10) ?? '??'})` : '否'}`,
|
|
125
|
+
` 注入:${cfg.autoInject ? `开(topK ${cfg.topK},预算 ${cfg.totalBudget} tok,阈值 ${cfg.matchThreshold})` : '关'}`,
|
|
126
|
+
` 去重:${cfg.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'}`,
|
|
127
|
+
` 蒸馏:${cfg.distillProvider !== '' && cfg.distillModel !== '' ? `${cfg.distillProvider}/${cfg.distillModel},每 ${cfg.distillEveryTurns} 轮` : '未配置模型(/topics set distill-provider / distill-model)'}`,
|
|
128
|
+
]
|
|
129
|
+
// Last lane outcome (distill-state summary) — what "checkable via
|
|
130
|
+
// /topics status" promises; absent until the first run of this bundle.
|
|
131
|
+
const lastRun = await service.store.readDistillState()
|
|
132
|
+
if (lastRun !== undefined) {
|
|
133
|
+
const outcome =
|
|
134
|
+
lastRun.ok === true
|
|
135
|
+
? `标记 ${String(lastRun.marked ?? 0)} 条观察`
|
|
136
|
+
: `失败(${String(lastRun.reason ?? 'unknown')})`
|
|
137
|
+
const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : ''
|
|
138
|
+
lines.push(` 最近蒸馏:${outcome}${gc} @ ${String(lastRun.at ?? '').slice(0, 19).replace('T', ' ')}`)
|
|
139
|
+
}
|
|
140
|
+
if (service.sync !== undefined) {
|
|
141
|
+
lines.push(` 上次推送:${service.sync.lastPushAt ?? '从未'}`)
|
|
142
|
+
if (service.sync.lastError !== '') lines.push(` 同步错误:${service.sync.lastError.split('\n').at(-1)}`)
|
|
143
|
+
}
|
|
144
|
+
return lines.join('\n')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `/topics distill` — one manual distill run over the current observation pool.
|
|
149
|
+
* The empty-pool case answers immediately without touching the lane; the
|
|
150
|
+
* summary mirrors the distill state fields (ok / marked / created / updated /
|
|
151
|
+
* gc dropped / reason) so what the user sees is what the state file records.
|
|
152
|
+
*/
|
|
153
|
+
async function doDistill(service: TopicsService, invocation: CommandInvocation, distillNow: ManualDistill | undefined): Promise<CommandResult> {
|
|
154
|
+
if (distillNow === undefined) return fail('蒸馏 lane 未接线(宿主未提供手动蒸馏触发器)')
|
|
155
|
+
const pending = await service.store.undistilledObservations(1)
|
|
156
|
+
if (pending.length === 0) return ok('观察池为空(no-observations):没有未蒸馏的观察,无需触发。')
|
|
157
|
+
const result = await distillNow(invocation)
|
|
158
|
+
if (!result.ok) {
|
|
159
|
+
const reasonNote =
|
|
160
|
+
result.reason === 'no-observations'
|
|
161
|
+
? '观察池为空(no-observations)'
|
|
162
|
+
: result.reason === 'no-model'
|
|
163
|
+
? '蒸馏模型未配置(/topics set distill-provider / distill-model)'
|
|
164
|
+
: result.reason === 'in-flight'
|
|
165
|
+
? '已有蒸馏在跑,稍后再试'
|
|
166
|
+
: (result.reason ?? 'unknown')
|
|
167
|
+
return fail(`蒸馏未产出:${reasonNote}${result.detail !== undefined ? `\n ${result.detail}` : ''}`)
|
|
168
|
+
}
|
|
169
|
+
const parts = [`标记 ${result.marked} 条观察`, `新建 ${result.created.length} 个 Topic`, `更新 ${result.updated.length} 个 Topic`]
|
|
170
|
+
if (result.gcDropped !== undefined && result.gcDropped > 0) parts.push(`GC 回收 ${result.gcDropped} 条不可处理观察`)
|
|
171
|
+
return ok(`✅ 蒸馏完成:${parts.join(';')}${result.detail !== undefined ? `\n ${result.detail}` : ''}`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function renderStats(service: TopicsService): Promise<string> {
|
|
175
|
+
const records = await service.store.readInjectionRecords()
|
|
176
|
+
const stats = aggregateStats(records as never)
|
|
177
|
+
if (records.length === 0) return '还没有注入记录 —— 用起来之后这里会有 hit rate / top-N / near-miss 分布。'
|
|
178
|
+
const lines = [
|
|
179
|
+
`注入统计(最近 ${records.length} 轮):`,
|
|
180
|
+
` hit rate:${(stats.hitRate * 100).toFixed(1)}%(${stats.injectedRounds}/${stats.rounds} 轮注入)`,
|
|
181
|
+
` 零命中轮:${stats.zeroHitRounds};平均命中 ${stats.avgHitsPerRound} 条/轮`,
|
|
182
|
+
` 平均预算占用:${stats.avgBudgetUtilization} tok`,
|
|
183
|
+
]
|
|
184
|
+
if (stats.topTopics.length > 0) {
|
|
185
|
+
lines.push(' Top-N 被注入 Topic:')
|
|
186
|
+
for (const t of stats.topTopics.slice(0, 5)) lines.push(` ${t.slug} ×${t.count}`)
|
|
187
|
+
}
|
|
188
|
+
if (stats.nearMissHistogram.length > 0) {
|
|
189
|
+
lines.push(' Near-miss 分布(低于阈值被挡):')
|
|
190
|
+
for (const b of stats.nearMissHistogram) lines.push(` ${b.bucket}: ${b.count}`)
|
|
191
|
+
const hint = tuningHint(stats, service.cfg.matchThreshold)
|
|
192
|
+
if (hint !== undefined) lines.push(` 💡 ${hint}`)
|
|
193
|
+
}
|
|
194
|
+
return lines.join('\n')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function tuningHint(stats: ReturnType<typeof aggregateStats>, threshold: number): string | undefined {
|
|
198
|
+
// A dense band just below the threshold with zero overflow above it means
|
|
199
|
+
// the threshold, not the corpus, is the bottleneck.
|
|
200
|
+
const justBelow = stats.nearMissHistogram.filter((b) => Number(b.bucket.split('–')[0]) >= threshold - 0.15 && Number(b.bucket.split('–')[0]) < threshold)
|
|
201
|
+
const justBelowCount = justBelow.reduce((acc, b) => acc + b.count, 0)
|
|
202
|
+
if (stats.rounds >= 20 && justBelowCount >= stats.rounds * 0.3 && stats.hitRate < 0.5) {
|
|
203
|
+
return `near-miss 集中在阈值 ${threshold} 下方(${justBelowCount} 次),可尝试 /topics set match-threshold ${(Math.max(0.05, threshold - 0.1)).toFixed(2)}`
|
|
204
|
+
}
|
|
205
|
+
return undefined
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function renderList(service: TopicsService): Promise<string> {
|
|
209
|
+
const metas = await service.store.listTopics()
|
|
210
|
+
if (metas.length === 0) return 'Bundle 里还没有 Topic —— 在会话里让我记点什么,或 /topics set 配置好蒸馏。'
|
|
211
|
+
const lines = [`共 ${metas.length} 个 Topic:`]
|
|
212
|
+
for (const m of metas.sort((a, b) => a.slug.localeCompare(b.slug))) {
|
|
213
|
+
lines.push(` ${m.slug} [${m.status}] ${m.title}${m.tags.length > 0 ? ` #${m.tags.join(' #')}` : ''}`)
|
|
214
|
+
}
|
|
215
|
+
return lines.join('\n')
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function renderShow(service: TopicsService, slug: string | undefined): Promise<CommandResult> {
|
|
219
|
+
if (slug === undefined || slug === '') return fail('用法:/topics show <slug>')
|
|
220
|
+
const doc = await service.store.readTopic(slug)
|
|
221
|
+
if (doc === undefined) return fail(`Topic “${slug}” 不存在(/topics list 查看)`)
|
|
222
|
+
const text = serializeTopicDoc(doc)
|
|
223
|
+
const backlinks = await service.store.readBacklinks()
|
|
224
|
+
const refs = backlinks[slugify(slug)] ?? []
|
|
225
|
+
if (refs.length === 0) return ok(text)
|
|
226
|
+
const VIA_LABEL: Record<'depends' | 'link', string> = { depends: '依赖', link: '链接' }
|
|
227
|
+
const lines = [
|
|
228
|
+
text,
|
|
229
|
+
'---',
|
|
230
|
+
`反向引用(${refs.length} 条)——改动本条结论时这些 Topic 可能需要跟进:`,
|
|
231
|
+
...refs.map((r) => `- ${r.slug}(${VIA_LABEL[r.via]})`),
|
|
232
|
+
]
|
|
233
|
+
return ok(lines.join('\n'))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function renderHistory(service: TopicsService, slug: string | undefined): Promise<CommandResult> {
|
|
237
|
+
if (slug === undefined || slug === '') return fail('用法:/topics history <slug>')
|
|
238
|
+
const { entries } = await service.history(slug, 30)
|
|
239
|
+
if (entries.length === 0) return fail(`Topic “${slug}” 没有历史(不存在或 bundle 不是 git 仓库)`)
|
|
240
|
+
const lines = [`${slug} 的变更史(${entries.length} 条):`]
|
|
241
|
+
for (const e of entries) {
|
|
242
|
+
lines.push(` ${e.hash} ${e.date.slice(0, 19).replace('T', ' ')} ${e.message}`)
|
|
243
|
+
if (e.conclusion !== undefined && e.conclusion !== '') lines.push(` └ 结论当时:${e.conclusion}`)
|
|
244
|
+
}
|
|
245
|
+
return ok(lines.join('\n'))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* /topics graph — render the bundle's relationship graph into a self-contained
|
|
250
|
+
* HTML page and open it in the default browser. Set DSH_TOPICS_NO_OPEN=1 to
|
|
251
|
+
* skip the browser launch (tests, headless use).
|
|
252
|
+
*/
|
|
253
|
+
async function doGraph(service: TopicsService): Promise<CommandResult> {
|
|
254
|
+
const roster = await service.roster()
|
|
255
|
+
if (roster.length === 0) return fail('Bundle 里还没有 Topic,无从画起(先记点什么)')
|
|
256
|
+
const graph = buildGraph(roster)
|
|
257
|
+
const conclusions: Record<string, string> = {}
|
|
258
|
+
for (const topic of roster) {
|
|
259
|
+
conclusions[topic.slug] = firstParagraph(topic.conclusion)
|
|
260
|
+
}
|
|
261
|
+
const html = renderGraphHtml(graph, { conclusions })
|
|
262
|
+
const file = join(service.store.root, 'meta', 'graph.html')
|
|
263
|
+
await writeFile(file, html, 'utf8')
|
|
264
|
+
const opened = openInBrowser(file)
|
|
265
|
+
const summary = `✅ 关系图已生成:${file}(${graph.nodes.length} 节点 / ${graph.edges.length} 边)` +
|
|
266
|
+
(opened ? '——已在浏览器打开' : '(浏览器未自动打开,手动用浏览器打开该文件即可)')
|
|
267
|
+
return ok(summary)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function openInBrowser(file: string): boolean {
|
|
271
|
+
if (process.env.DSH_TOPICS_NO_OPEN === '1') return false
|
|
272
|
+
try {
|
|
273
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
|
|
274
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', file] : [file]
|
|
275
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' })
|
|
276
|
+
child.unref?.()
|
|
277
|
+
return true
|
|
278
|
+
} catch {
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function doSync(service: TopicsService, direction: string | undefined): Promise<CommandResult> {
|
|
284
|
+
if (!service.githubMode) return fail('当前是 local-only 模式(/topics set repo <owner/name> 启用 GitHub 同步)')
|
|
285
|
+
if (service.sync === undefined) return fail('同步层未就绪')
|
|
286
|
+
if (direction === undefined || direction === 'push') {
|
|
287
|
+
await service.sync.commitMeta()
|
|
288
|
+
const r = await service.sync.flush()
|
|
289
|
+
return r.ok ? ok(`✅ ${r.message}`) : fail(r.message)
|
|
290
|
+
}
|
|
291
|
+
if (direction === 'pull') {
|
|
292
|
+
const r = await service.sync.pull()
|
|
293
|
+
service.invalidate()
|
|
294
|
+
return r.ok ? ok(`✅ ${r.message}`) : fail(r.message)
|
|
295
|
+
}
|
|
296
|
+
return fail('用法:/topics sync [pull|push]')
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function renderConfig(cfg: TopicsConfigValue): string {
|
|
300
|
+
const lines = ['当前配置:']
|
|
301
|
+
for (const key of CONFIG_KEYS) {
|
|
302
|
+
lines.push(` ${displayKey(key)} = ${String(cfg[key])}`)
|
|
303
|
+
}
|
|
304
|
+
return lines.join('\n')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* One ask-user panel for `/topics set distill-provider|distill-model` without a
|
|
309
|
+
* value. Picks are pre-validated like the onboard wizard (live-route check
|
|
310
|
+
* for providers; resolveModelInfo for models — NO_ADAPTER blocks, off-catalog
|
|
311
|
+
* warns but allows). Cancellation and blank answers write nothing;
|
|
312
|
+
* ASK_CANCELLED / ASK_ABORTED surface as a clean no-write success.
|
|
313
|
+
*/
|
|
314
|
+
async function setDistillInteractive(
|
|
315
|
+
service: TopicsService,
|
|
316
|
+
key: 'distillProvider' | 'distillModel',
|
|
317
|
+
ask: AskServiceShape,
|
|
318
|
+
llm: LlmDirectoryShape,
|
|
319
|
+
mutate: (ops: readonly { op: 'set'; path: string[]; value: unknown }[]) => Promise<void>,
|
|
320
|
+
invocation: CommandInvocation | undefined,
|
|
321
|
+
): Promise<CommandResult> {
|
|
322
|
+
const fail = (text: string): CommandResult => ({ kind: 'error', text })
|
|
323
|
+
if (key === 'distillModel' && service.cfg.distillProvider === '') {
|
|
324
|
+
return fail('请先配置 distill-provider 再选模型:/topics set distill-provider(有 UI 时会弹 provider 选择面板)')
|
|
325
|
+
}
|
|
326
|
+
const invocationLike = invocation as { agent?: unknown; signal?: AbortSignal }
|
|
327
|
+
const panel = makePanel(ask, invocationLike.agent, invocationLike.signal)
|
|
328
|
+
const askKey = key === 'distillProvider' ? 'distill-provider' : 'distill-model'
|
|
329
|
+
try {
|
|
330
|
+
const picked = await askDistillSingle(llm, panel, askKey, service.cfg)
|
|
331
|
+
if (picked === undefined) return ok('未选择,配置保持原样。')
|
|
332
|
+
await mutate([{ op: 'set', path: [key], value: picked.value }])
|
|
333
|
+
return ok(`✅ topics.${displayKey(key)} = ${picked.value}${picked.warning === undefined ? '' : `\n⚠️ ${picked.warning}`}`)
|
|
334
|
+
} catch (error) {
|
|
335
|
+
const code = (error as { code?: string }).code
|
|
336
|
+
if (code === 'ASK_CANCELLED' || code === 'ASK_ABORTED') return ok('已取消,未写入任何改动。')
|
|
337
|
+
if (code === 'ASK_MISSING_AGENT') return fail('当前 surface 的 ask-user 需要会话上下文。请改用 /topics set 带值输入。')
|
|
338
|
+
throw error
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function doSet(
|
|
343
|
+
service: TopicsService,
|
|
344
|
+
tokens: string[],
|
|
345
|
+
mutate: (ops: readonly { op: 'set'; path: string[]; value: unknown }[]) => Promise<void>,
|
|
346
|
+
resolveAsk: AskServiceResolver = () => undefined,
|
|
347
|
+
resolveLlm: LlmDirectoryResolver = () => [],
|
|
348
|
+
invocation?: CommandInvocation,
|
|
349
|
+
): Promise<CommandResult> {
|
|
350
|
+
const [rawKey = '', ...valueParts] = tokens
|
|
351
|
+
const rawValue = valueParts.join(' ').trim()
|
|
352
|
+
const normalized = rawKey.toLowerCase().replace(/[-_]/g, '')
|
|
353
|
+
const key = CONFIG_KEYS.find((k) => k.toLowerCase() === normalized)
|
|
354
|
+
if (key === undefined) {
|
|
355
|
+
return fail(`未知配置项 “${rawKey}”。可选:${CONFIG_KEYS.map(displayKey).join('、')}`)
|
|
356
|
+
}
|
|
357
|
+
// No value → a single-question picker for the two distill route keys when a
|
|
358
|
+
// live UI AND a usable llm directory exist. Every other shape errors out:
|
|
359
|
+
// falling through to the typed path would silently CLEAR the key.
|
|
360
|
+
if ((key === 'distillProvider' || key === 'distillModel') && rawValue === '') {
|
|
361
|
+
const ask = resolveAsk()
|
|
362
|
+
const pick = pickLlmDirectory(resolveLlm())
|
|
363
|
+
if (ask !== undefined && typeof ask.ask === 'function' && pick.kind === 'ok') {
|
|
364
|
+
return setDistillInteractive(service, key, ask, pick.llm, mutate, invocation)
|
|
365
|
+
}
|
|
366
|
+
const reason = llmDirectoryNotice(pick) ?? '当前 surface 没有 ask-user 面板'
|
|
367
|
+
return fail(`${displayKey(key)} 需要一个值(如 /topics set distill-model zai-coding-cn glm-4.7-air),未写入任何改动。选择面板不可用:${reason}`)
|
|
368
|
+
}
|
|
369
|
+
// Mixed "provider model" / "provider/model" values for distill-model split
|
|
370
|
+
// into BOTH keys — the historical root of `distillModel: "prov model"` dirt.
|
|
371
|
+
// Splitting overwrites distill-provider; when that key already held a
|
|
372
|
+
// different provider, say so.
|
|
373
|
+
if (key === 'distillModel' && rawValue !== '' && /[\s/]/.test(rawValue)) {
|
|
374
|
+
const parsed = parseDistillInput(rawValue)
|
|
375
|
+
if ('error' in parsed) return fail(parsed.error)
|
|
376
|
+
const previousProvider = service.cfg.distillProvider
|
|
377
|
+
await mutate([
|
|
378
|
+
{ op: 'set', path: ['distillProvider'], value: parsed.provider },
|
|
379
|
+
{ op: 'set', path: ['distillModel'], value: parsed.model },
|
|
380
|
+
])
|
|
381
|
+
const overwrote = previousProvider !== '' && previousProvider !== parsed.provider
|
|
382
|
+
return ok([
|
|
383
|
+
`✅ topics.distill-provider = ${parsed.provider}`,
|
|
384
|
+
`✅ topics.distill-model = ${parsed.model}`,
|
|
385
|
+
'(混写值已拆分写入 distill-provider 与 distill-model 两个键)',
|
|
386
|
+
...(overwrote ? [`⚠️ 已覆盖原 distill-provider «${previousProvider}»`] : []),
|
|
387
|
+
].join('\n'))
|
|
388
|
+
}
|
|
389
|
+
if (key === 'distillProvider' && rawValue !== '' && /[\s/]/.test(rawValue)) {
|
|
390
|
+
return fail('distill-provider 只接受单段 provider id(不含空格或斜杠)。混合值请用 /topics set distill-model "provider model" 一次写入两个键。')
|
|
391
|
+
}
|
|
392
|
+
const parsed = parseConfigValue(key, rawValue)
|
|
393
|
+
if (typeof parsed === 'object' && parsed !== null && 'error' in parsed) return fail(parsed.error)
|
|
394
|
+
await mutate([{ op: 'set', path: [key], value: parsed }])
|
|
395
|
+
if (key === 'repo' || key === 'autoInject') service.invalidate()
|
|
396
|
+
return ok(`✅ topics.${displayKey(key)} = ${String(parsed)}`)
|
|
397
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration — the `topics` settings namespace, user-editable in
|
|
3
|
+
* settings.yaml and via `/topics set` (ADR 0006/0007 tunables).
|
|
4
|
+
*
|
|
5
|
+
* @module config
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import z from '@deepseek-ai/schemastery'
|
|
9
|
+
|
|
10
|
+
export const TopicsConfig = z.object({
|
|
11
|
+
/** GitHub repo `owner/name`; empty = local-only mode (ADR 0008). */
|
|
12
|
+
repo: z.string().default(''),
|
|
13
|
+
/** Master switch for per-turn injection. */
|
|
14
|
+
autoInject: z.boolean().default(true),
|
|
15
|
+
/** Skip re-injecting topics already injected earlier in the same session. */
|
|
16
|
+
injectDedup: z.boolean().default(true),
|
|
17
|
+
/** Max topics injected per round (ADR 0006: ≤4). */
|
|
18
|
+
topK: z.number().default(4),
|
|
19
|
+
/** Per-topic digest budget in tokens. */
|
|
20
|
+
perTopicBudget: z.number().default(300),
|
|
21
|
+
/** Total injection budget in tokens. */
|
|
22
|
+
totalBudget: z.number().default(1500),
|
|
23
|
+
/** Retrieval score threshold — tune via /topics stats near-miss evidence. */
|
|
24
|
+
matchThreshold: z.number().default(0.3),
|
|
25
|
+
/** Additive boost per tag hit. */
|
|
26
|
+
tagBoost: z.number().default(0.15),
|
|
27
|
+
/** depends-graph expansion depth (0 disables). */
|
|
28
|
+
graphDepth: z.number().default(2),
|
|
29
|
+
/** Days within which a topic counts as recent (+0.2). */
|
|
30
|
+
recencyWindowDays: z.number().default(7),
|
|
31
|
+
/** Capture each turn's user/assistant text as raw observations (M2). */
|
|
32
|
+
autoObserve: z.boolean().default(true),
|
|
33
|
+
/** Whether injection and observation also engage delegated subagent sessions (ADR 0011). */
|
|
34
|
+
includeSubagents: z.boolean().default(true),
|
|
35
|
+
/** Max auto-captured chars per side (user/assistant) per turn. */
|
|
36
|
+
observationMaxChars: z.number().default(2000),
|
|
37
|
+
/** Background distill cadence: every N turns of a long session. */
|
|
38
|
+
distillEveryTurns: z.number().default(5),
|
|
39
|
+
/** Distill once when a session ends. */
|
|
40
|
+
distillOnSessionEnd: z.boolean().default(true),
|
|
41
|
+
/** Distill lane model route; both must be set, else distill stays idle. */
|
|
42
|
+
distillProvider: z.string().default(''),
|
|
43
|
+
distillModel: z.string().default(''),
|
|
44
|
+
/** Observations per distill model call; auto-halves on output-limit failures (floor 5). */
|
|
45
|
+
distillBatchSize: z.number().default(40),
|
|
46
|
+
/** Max model calls per distill run — successful batches keep their marks (partial progress).
|
|
47
|
+
* Default 8: with the batch loop, one run then drains ~30-40 observations
|
|
48
|
+
* instead of ~10, which is what makes a real backlog actually shrink. */
|
|
49
|
+
distillMaxModelCalls: z.number().default(8),
|
|
50
|
+
/** Debounced push delay in GitHub mode. */
|
|
51
|
+
pushDebounceSeconds: z.number().default(45),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
export type TopicsConfigValue = {
|
|
55
|
+
repo: string
|
|
56
|
+
autoInject: boolean
|
|
57
|
+
injectDedup: boolean
|
|
58
|
+
topK: number
|
|
59
|
+
perTopicBudget: number
|
|
60
|
+
totalBudget: number
|
|
61
|
+
matchThreshold: number
|
|
62
|
+
tagBoost: number
|
|
63
|
+
graphDepth: number
|
|
64
|
+
recencyWindowDays: number
|
|
65
|
+
autoObserve: boolean
|
|
66
|
+
includeSubagents: boolean
|
|
67
|
+
observationMaxChars: number
|
|
68
|
+
distillEveryTurns: number
|
|
69
|
+
distillOnSessionEnd: boolean
|
|
70
|
+
distillProvider: string
|
|
71
|
+
distillModel: string
|
|
72
|
+
distillBatchSize: number
|
|
73
|
+
distillMaxModelCalls: number
|
|
74
|
+
pushDebounceSeconds: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const CONFIG_KEYS = [
|
|
78
|
+
'repo',
|
|
79
|
+
'autoInject',
|
|
80
|
+
'injectDedup',
|
|
81
|
+
'topK',
|
|
82
|
+
'perTopicBudget',
|
|
83
|
+
'totalBudget',
|
|
84
|
+
'matchThreshold',
|
|
85
|
+
'tagBoost',
|
|
86
|
+
'graphDepth',
|
|
87
|
+
'recencyWindowDays',
|
|
88
|
+
'autoObserve',
|
|
89
|
+
'includeSubagents',
|
|
90
|
+
'observationMaxChars',
|
|
91
|
+
'distillEveryTurns',
|
|
92
|
+
'distillOnSessionEnd',
|
|
93
|
+
'distillProvider',
|
|
94
|
+
'distillModel',
|
|
95
|
+
'distillBatchSize',
|
|
96
|
+
'distillMaxModelCalls',
|
|
97
|
+
'pushDebounceSeconds',
|
|
98
|
+
] as const
|
|
99
|
+
|
|
100
|
+
export type ConfigKey = (typeof CONFIG_KEYS)[number]
|
|
101
|
+
|
|
102
|
+
/** CamelCase → dash-display (topK → top-k) for command output. */
|
|
103
|
+
export function displayKey(key: string): string {
|
|
104
|
+
return key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** /topics set <key> <value> — parse the raw string into the typed value. */
|
|
108
|
+
export function parseConfigValue(key: ConfigKey, raw: string): boolean | number | string | { error: string } {
|
|
109
|
+
switch (key) {
|
|
110
|
+
case 'repo': {
|
|
111
|
+
if (raw !== '' && !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(raw)) {
|
|
112
|
+
return { error: 'repo 需要形如 owner/name,或留空切换回 local-only 模式' }
|
|
113
|
+
}
|
|
114
|
+
return raw
|
|
115
|
+
}
|
|
116
|
+
case 'autoInject':
|
|
117
|
+
case 'injectDedup':
|
|
118
|
+
case 'autoObserve':
|
|
119
|
+
case 'distillOnSessionEnd':
|
|
120
|
+
case 'includeSubagents': {
|
|
121
|
+
if (raw === 'on' || raw === 'true') return true
|
|
122
|
+
if (raw === 'off' || raw === 'false') return false
|
|
123
|
+
return { error: `${key} 取值 on|off` }
|
|
124
|
+
}
|
|
125
|
+
case 'topK':
|
|
126
|
+
case 'perTopicBudget':
|
|
127
|
+
case 'totalBudget':
|
|
128
|
+
case 'graphDepth':
|
|
129
|
+
case 'recencyWindowDays':
|
|
130
|
+
case 'observationMaxChars':
|
|
131
|
+
case 'distillEveryTurns':
|
|
132
|
+
case 'distillBatchSize':
|
|
133
|
+
case 'distillMaxModelCalls':
|
|
134
|
+
case 'pushDebounceSeconds': {
|
|
135
|
+
const n = Number(raw)
|
|
136
|
+
if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return { error: `${key} 需要非负整数` }
|
|
137
|
+
return n
|
|
138
|
+
}
|
|
139
|
+
case 'matchThreshold':
|
|
140
|
+
case 'tagBoost': {
|
|
141
|
+
const n = Number(raw)
|
|
142
|
+
if (!Number.isFinite(n) || n < 0) return { error: `${key} 需要非负数` }
|
|
143
|
+
return n
|
|
144
|
+
}
|
|
145
|
+
case 'distillProvider':
|
|
146
|
+
case 'distillModel':
|
|
147
|
+
return raw
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural read of dsh's delegation depth: delegated children carry a
|
|
3
|
+
* positive depth — the persisted session header is authoritative, runtime
|
|
4
|
+
* `AgentOptions.subagentDepth` may deepen it. Absence or malformed values
|
|
5
|
+
* mean top-level (0): this plugin only branches on `> 0` and must never
|
|
6
|
+
* throw from an event path.
|
|
7
|
+
*
|
|
8
|
+
* @module delegation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface DelegationDepthShape {
|
|
12
|
+
options?: { subagentDepth?: number }
|
|
13
|
+
session?: { header?: { delegationDepth?: number } }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Non-negative delegation depth of the agent; 0 for top-level or unknown. */
|
|
17
|
+
export function delegationDepth(agent: unknown): number {
|
|
18
|
+
const a = agent as DelegationDepthShape | null | undefined
|
|
19
|
+
if (a === null || a === undefined || typeof a !== 'object') return 0
|
|
20
|
+
const clamp = (v: unknown): number => (typeof v === 'number' && Number.isSafeInteger(v) && v > 0 ? v : 0)
|
|
21
|
+
return Math.max(clamp(a.session?.header?.delegationDepth), clamp(a.options?.subagentDepth))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** True when the agent is a delegated child; never for top-level or unknown. */
|
|
25
|
+
export function isDelegated(agent: unknown): boolean {
|
|
26
|
+
return delegationDepth(agent) > 0
|
|
27
|
+
}
|