@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.
Files changed (61) hide show
  1. package/README.md +112 -0
  2. package/README.zh.md +112 -0
  3. package/cordis.patch.yml +8 -0
  4. package/lib/commands.d.ts +21 -0
  5. package/lib/commands.js +368 -0
  6. package/lib/config.d.ts +122 -0
  7. package/lib/config.js +123 -0
  8. package/lib/delegation.d.ts +23 -0
  9. package/lib/delegation.js +21 -0
  10. package/lib/digest.d.ts +42 -0
  11. package/lib/digest.js +101 -0
  12. package/lib/distill.d.ts +186 -0
  13. package/lib/distill.js +759 -0
  14. package/lib/git.d.ts +66 -0
  15. package/lib/git.js +169 -0
  16. package/lib/ilog.d.ts +53 -0
  17. package/lib/ilog.js +70 -0
  18. package/lib/index.d.ts +73 -0
  19. package/lib/index.js +504 -0
  20. package/lib/observer.d.ts +41 -0
  21. package/lib/observer.js +128 -0
  22. package/lib/okf.d.ts +112 -0
  23. package/lib/okf.js +279 -0
  24. package/lib/onboard.d.ts +205 -0
  25. package/lib/onboard.js +664 -0
  26. package/lib/paths.d.ts +48 -0
  27. package/lib/paths.js +84 -0
  28. package/lib/retrieval.d.ts +61 -0
  29. package/lib/retrieval.js +178 -0
  30. package/lib/service.d.ts +96 -0
  31. package/lib/service.js +375 -0
  32. package/lib/store.d.ts +153 -0
  33. package/lib/store.js +489 -0
  34. package/lib/sync.d.ts +52 -0
  35. package/lib/sync.js +154 -0
  36. package/lib/tools.d.ts +8 -0
  37. package/lib/tools.js +208 -0
  38. package/lib/viz.d.ts +33 -0
  39. package/lib/viz.js +310 -0
  40. package/lib/yaml.d.ts +18 -0
  41. package/lib/yaml.js +334 -0
  42. package/package.json +97 -0
  43. package/src/commands.ts +397 -0
  44. package/src/config.ts +149 -0
  45. package/src/delegation.ts +27 -0
  46. package/src/digest.ts +125 -0
  47. package/src/distill.ts +885 -0
  48. package/src/git.ts +209 -0
  49. package/src/ilog.ts +99 -0
  50. package/src/index.ts +574 -0
  51. package/src/observer.ts +150 -0
  52. package/src/okf.ts +317 -0
  53. package/src/onboard.ts +762 -0
  54. package/src/paths.ts +104 -0
  55. package/src/retrieval.ts +219 -0
  56. package/src/service.ts +400 -0
  57. package/src/store.ts +567 -0
  58. package/src/sync.ts +170 -0
  59. package/src/tools.ts +225 -0
  60. package/src/viz.ts +333 -0
  61. package/src/yaml.ts +322 -0
package/src/onboard.ts ADDED
@@ -0,0 +1,762 @@
1
+ /**
2
+ * `/topics onboard` — an a/b/c configuration wizard that walks a new user
3
+ * through the five decisions that matter (storage mode, GitHub repo, distill
4
+ * model, injection budget, auto-observe) one command at a time.
5
+ *
6
+ * Design (ADR 0009):
7
+ * - one invocation = one step: the wizard renders the current question with
8
+ * lettered options; the answer arrives as the next invocation's argument
9
+ * (`/topics onboard b`). Works unchanged on every surface (tui, web, feishu,
10
+ * headless one-shots) because it never needs intra-turn interactivity.
11
+ * - answers accumulate in a pending batch; only the final confirm step
12
+ * writes settings — quitting midway leaves the config untouched.
13
+ * - the step machine is pure (`applyAnswer` / `renderStep`), so tests drive
14
+ * it without spawning `gh`; the handler injects the login detector.
15
+ *
16
+ * @module onboard
17
+ */
18
+
19
+ import { spawn } from 'node:child_process'
20
+ import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
21
+ import type { TopicsService } from './service.ts'
22
+ import { pickLlmCandidate } from './distill.ts'
23
+ import { CONFIG_KEYS, parseConfigValue, type TopicsConfigValue } from './config.ts'
24
+
25
+ /** CamelCase → dash-display, shared with /topics config rendering. */
26
+ export function displayKey(key: string): string {
27
+ return key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
28
+ }
29
+
30
+ export type StepId = 'mode' | 'repo' | 'distill' | 'inject' | 'observe' | 'confirm' | 'done'
31
+
32
+ export interface OnboardState {
33
+ step: StepId
34
+ /** Answers so far; only written to settings at the confirm step. */
35
+ pending: Partial<TopicsConfigValue>
36
+ /** Whether the user picked GitHub mode (adds the repo step). */
37
+ pathHasRepo: boolean
38
+ }
39
+
40
+ export type MutateFn = (ops: readonly { op: 'set'; path: string[]; value: unknown }[]) => Promise<void>
41
+
42
+ export type GithubLoginDetector = () => Promise<string | undefined>
43
+
44
+ const FOOTER = '→ 回复 /topics onboard a|b|c(自定义项把值直接打在后面);quit 退出不写入'
45
+
46
+ /**
47
+ * Suggested remote repo name. Deliberately NOT `dsh-topics-memory`: that is
48
+ * the plugin's own source repository, and the default data repo must never
49
+ * collide with it (ADR 0002/0009).
50
+ */
51
+ export const DEFAULT_TOPICS_REPO = 'dsh-topics-data'
52
+
53
+ export function freshState(): OnboardState {
54
+ return { step: 'mode', pending: {}, pathHasRepo: false }
55
+ }
56
+
57
+ /** Human-facing step sequence for the progress marker, honoring the path taken. */
58
+ function sequence(state: OnboardState): StepId[] {
59
+ return state.pathHasRepo ? ['mode', 'repo', 'distill', 'inject', 'observe'] : ['mode', 'distill', 'inject', 'observe']
60
+ }
61
+
62
+ function header(state: OnboardState, title: string): string {
63
+ if (state.step === 'confirm') return `🧭 topics 配置向导(确认)—— ${title}`
64
+ const seq = sequence(state)
65
+ const index = seq.indexOf(state.step)
66
+ const total = seq.length
67
+ const at = index === -1 ? total : index + 1
68
+ return `🧭 topics 配置向导(${at}/${total})—— ${title}`
69
+ }
70
+
71
+ /** Render the current step against live config overlaid with pending answers. */
72
+ export function renderStep(state: OnboardState, cfg: TopicsConfigValue): string {
73
+ const cur = { ...cfg, ...state.pending }
74
+ switch (state.step) {
75
+ case 'mode':
76
+ return [
77
+ header(state, '存储模式'),
78
+ `当前:${cur.repo === '' ? 'local-only(数据只在本地 bundle)' : `GitHub 同步(${cur.repo})`}`,
79
+ ' a. local-only —— 零配置零凭据,先本地用起来(推荐)',
80
+ ' b. GitHub 同步 —— 指定私有仓,写穿 + 去抖推送,跨机共享',
81
+ ' c. 跳过这项,保持现状',
82
+ '',
83
+ FOOTER,
84
+ ].join('\n')
85
+ case 'repo':
86
+ return [
87
+ header(state, 'GitHub 仓库'),
88
+ `当前:${cur.repo === '' ? '未设置' : cur.repo}`,
89
+ ' a. 自动探测 gh 登录名,仓库用 <登录名>/' + DEFAULT_TOPICS_REPO,
90
+ ' b. 自定义:把 owner/name 直接打在命令里,如 /topics onboard myname/my-wiki',
91
+ ' c. 跳过(留在 local-only)',
92
+ '',
93
+ FOOTER,
94
+ ].join('\n')
95
+ case 'distill':
96
+ return [
97
+ header(state, '后台蒸馏模型'),
98
+ `当前:${cur.distillProvider !== '' && cur.distillModel !== '' ? `${cur.distillProvider} / ${cur.distillModel}` : '未配置(观察只积累,不自动蒸馏成 Topic)'}`,
99
+ ' a. 暂不开 —— 先用一阵,随时 /topics set distill-provider 再开(推荐)',
100
+ ' b. 配置:把 provider model 打在命令里,如 /topics onboard zai-coding-cn glm-4.7-air(也支持 provider/model)',
101
+ ' c. 跳过',
102
+ '',
103
+ FOOTER,
104
+ ].join('\n')
105
+ case 'inject':
106
+ return [
107
+ header(state, '注入档位'),
108
+ `当前:topK ${cur.topK} / 总预算 ${cur.totalBudget} tok / 阈值 ${cur.matchThreshold}`,
109
+ `会话级去重:${cur.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'}`,
110
+ ' a. 保守 —— topK 2,总预算 800 tok(少而准,token 敏感选这个)',
111
+ ' b. 标准 —— topK 4,总预算 1500 tok(默认,推荐)',
112
+ ' c. 放量 —— topK 6,总预算 2500 tok(记忆多、上下文宽裕)',
113
+ '',
114
+ FOOTER,
115
+ ].join('\n')
116
+ case 'observe':
117
+ return [
118
+ header(state, '自动观察'),
119
+ `当前:${cur.autoObserve ? `开(每轮自动抓原子观察,每侧 ≤${cur.observationMaxChars} 字)` : '关(只手动 topic_save / topic_observe)'}`,
120
+ ' a. 保持开(推荐)—— 随手聊就被记录,后台蒸馏成 Topic',
121
+ ' b. 关 —— 只在你明确调用工具时记录',
122
+ ' c. 跳过',
123
+ '',
124
+ FOOTER,
125
+ ].join('\n')
126
+ case 'confirm': {
127
+ const keys = CONFIG_KEYS.filter((k) => k in state.pending)
128
+ if (keys.length === 0) {
129
+ return '向导结束——本次没有选择任何改动,配置保持原样。\n重新 /topics onboard 可再来;/topics set 可微调单项。'
130
+ }
131
+ return [
132
+ header(state, '待写入 settings(topics namespace)'),
133
+ ...keys.map((k) => ` ${displayKey(k)} = ${String(state.pending[k])}`),
134
+ '(未列出的项保持现状)',
135
+ '',
136
+ ' a. 确认写入 —— 下次会话启动后生效',
137
+ ' b. 放弃,不写入任何改动',
138
+ ].join('\n')
139
+ }
140
+ case 'done':
141
+ return '向导已结束。重新 /topics onboard 可再来;/topics config 查看当前配置。'
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Pure step machine: consume one answer, return the next state. The caller
147
+ * intercepts control answers (quit) and confirm-write before calling this.
148
+ */
149
+ export function applyAnswer(state: OnboardState, answer: string, cfg: TopicsConfigValue): { state: OnboardState; error?: string } {
150
+ const letter = /^[a-c]$/i.exec(answer)?.[0]?.toLowerCase()
151
+ const next = (patch: Partial<OnboardState>): OnboardState => ({ ...state, ...patch })
152
+ switch (state.step) {
153
+ case 'mode': {
154
+ if (letter === 'b') return { state: next({ step: 'repo', pathHasRepo: true }) }
155
+ if (letter === 'a' || letter === 'c') return { state: next({ step: 'distill', pathHasRepo: false }) }
156
+ return { state, error: '请回复 a(local-only)、b(GitHub 同步)或 c(跳过)' }
157
+ }
158
+ case 'repo': {
159
+ if (letter === 'c') return { state: next({ step: 'distill' }) }
160
+ const parsed = parseConfigValue('repo', answer)
161
+ if (typeof parsed === 'object' && parsed !== null && 'error' in parsed) return { state, error: `${parsed.error}(或回复 c 跳过)` }
162
+ return { state: next({ step: 'distill', pending: { ...state.pending, repo: parsed as string } }) }
163
+ }
164
+ case 'distill': {
165
+ if (letter === 'a' || letter === 'c') return { state: next({ step: 'inject' }) }
166
+ // Shared with the /topics set mixed-value split: space form wins so model
167
+ // ids may themselves contain slashes (`openrouter meta-llama/llama-3`).
168
+ const parsed = parseDistillInput(answer)
169
+ if ('error' in parsed) return { state, error: parsed.error }
170
+ return { state: next({ step: 'inject', pending: { ...state.pending, distillProvider: parsed.provider, distillModel: parsed.model } }) }
171
+ }
172
+ case 'inject': {
173
+ if (letter === 'a') return { state: next({ step: 'observe', pending: { ...state.pending, topK: 2, totalBudget: 800 } }) }
174
+ if (letter === 'b') return { state: next({ step: 'observe', pending: { ...state.pending, topK: 4, totalBudget: 1500 } }) }
175
+ if (letter === 'c') return { state: next({ step: 'observe' }) }
176
+ return { state, error: '请回复 a(保守)、b(标准)或 c(放量)' }
177
+ }
178
+ case 'observe': {
179
+ if (letter === 'a') return { state: next({ step: 'confirm', pending: { ...state.pending, autoObserve: true } }) }
180
+ if (letter === 'b') return { state: next({ step: 'confirm', pending: { ...state.pending, autoObserve: false } }) }
181
+ if (letter === 'c') return { state: next({ step: 'confirm' }) }
182
+ return { state, error: '请回复 a(开)、b(关)或 c(跳过)' }
183
+ }
184
+ case 'confirm':
185
+ return { state, error: '确认页只认 a(写入)或 b(放弃)' }
186
+ case 'done':
187
+ return { state: freshState() }
188
+ }
189
+ }
190
+
191
+ /** Settings ops for the pending batch, in stable CONFIG_KEYS order. */
192
+ export function confirmOps(pending: Partial<TopicsConfigValue>): { op: 'set'; path: string[]; value: unknown }[] {
193
+ return CONFIG_KEYS.filter((k) => k in pending).map((k) => ({ op: 'set' as const, path: [k], value: pending[k] }))
194
+ }
195
+
196
+ /**
197
+ * `gh api user --jq .login` with a hard timeout; undefined on any failure
198
+ * (gh missing, not logged in, slow). Never throws.
199
+ */
200
+ export function detectGithubLogin(timeoutMs = 5000): Promise<string | undefined> {
201
+ return new Promise((resolve) => {
202
+ let settled = false
203
+ const done = (login: string | undefined): void => {
204
+ if (settled) return
205
+ settled = true
206
+ resolve(login)
207
+ }
208
+ try {
209
+ const child = spawn('gh', ['api', 'user', '--jq', '.login'], { stdio: ['ignore', 'pipe', 'ignore'] })
210
+ let out = ''
211
+ child.stdout?.on('data', (chunk: Buffer) => {
212
+ out += String(chunk)
213
+ })
214
+ const timer = setTimeout(() => {
215
+ child.kill('SIGKILL')
216
+ done(undefined)
217
+ }, timeoutMs)
218
+ child.on('error', () => {
219
+ clearTimeout(timer)
220
+ done(undefined)
221
+ })
222
+ child.on('close', (code) => {
223
+ clearTimeout(timer)
224
+ const login = out.trim()
225
+ done(code === 0 && /^[A-Za-z0-9-]+$/.test(login) ? login : undefined)
226
+ })
227
+ } catch {
228
+ done(undefined)
229
+ }
230
+ })
231
+ }
232
+
233
+ const QUIT = /^(q|quit|exit|退出)$/i
234
+
235
+ // ---- Interactive path: the native ask-user seam (ctx.userQuestions) --------
236
+ // Structural views only — this plugin depends on the wire shape of the seam,
237
+ // never on any UI (tui-pi, web, feishu) or on dsh-ask-router. Whatever surface
238
+ // registered the provider renders the questions natively; the typed wizard
239
+ // above remains the fallback for hosts without a provider.
240
+
241
+ export interface AskOptionShape {
242
+ label: string
243
+ description?: string
244
+ }
245
+
246
+ export interface AskItemShape {
247
+ id: string
248
+ question: string
249
+ detail?: string
250
+ header?: string
251
+ options?: AskOptionShape[]
252
+ }
253
+
254
+ export interface AskAnswerItemShape {
255
+ id: string
256
+ selected: string[]
257
+ custom?: string
258
+ }
259
+
260
+ export interface AskServiceShape {
261
+ ask(request: { questions: AskItemShape[]; agent?: unknown; signal?: AbortSignal }): Promise<{ answers: AskAnswerItemShape[] }>
262
+ }
263
+
264
+ export type AskServiceResolver = () => AskServiceShape | undefined
265
+
266
+ /** Structural view of the dsh llm service used for distill-route pickers. */
267
+ export interface LlmDirectoryShape {
268
+ /** Live routes only (providers with a registered adapter), in registration order. */
269
+ listProviders(): readonly { id: string; name: string }[]
270
+ /** Advisory catalog; an unlisted model id may still be usable. */
271
+ listModels(provider: string): Promise<readonly { provider: string; id: string; name: string; description?: string }[]>
272
+ /** Pure in-memory exact-route check; rejects with coded errors (e.g. NO_ADAPTER). */
273
+ resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<unknown>
274
+ }
275
+
276
+ /**
277
+ * Ordered llm candidates for the distill pickers: session-scoped capture
278
+ * first (the instance this session's own loop streams through), apply-time
279
+ * root second. Consumers pick with `pickLlmDirectory` — the first candidate
280
+ * whose `listProviders()` is non-empty wins — so neither source's emptiness
281
+ * shadows the other.
282
+ */
283
+ export type LlmDirectoryResolver = () => readonly (LlmDirectoryShape | undefined)[]
284
+
285
+ /** Where the candidate walk for the distill pickers landed. */
286
+ export type LlmDirectoryPick =
287
+ | { kind: 'ok'; llm: LlmDirectoryShape }
288
+ | { kind: 'empty-directory' }
289
+ | { kind: 'no-instance' }
290
+
291
+ /**
292
+ * First candidate with a non-empty live route table wins. Distinguishes
293
+ * 「有实例但列表空」(service present, zero enabled providers) from
294
+ * 「均不可用」(nothing reachable) so each gets its own user hint.
295
+ */
296
+ export function pickLlmDirectory(candidates: readonly (LlmDirectoryShape | undefined)[]): LlmDirectoryPick {
297
+ const { llm, probed } = pickLlmCandidate(candidates, (providers) => providers.length > 0)
298
+ if (llm !== undefined) return { kind: 'ok', llm: llm as LlmDirectoryShape }
299
+ return probed > 0 ? { kind: 'empty-directory' } : { kind: 'no-instance' }
300
+ }
301
+
302
+ /** One-line why-not for the distill pick panels; undefined when usable. */
303
+ export function llmDirectoryNotice(pick: LlmDirectoryPick): string | undefined {
304
+ if (pick.kind === 'ok') return undefined
305
+ if (pick.kind === 'empty-directory') {
306
+ return 'llm 服务在,但本机没有已启用的模型 provider(先在 dsh 配置中启用模型适配器)'
307
+ }
308
+ return '本机未检测到可用模型路由(llm 服务缺失或会话作用域已释放)'
309
+ }
310
+
311
+ /** Build one ask() closure that rides the invocation's agent/signal context. */
312
+ export function makePanel(
313
+ ask: AskServiceShape,
314
+ agent: unknown,
315
+ signal: AbortSignal | undefined,
316
+ ): (questions: AskItemShape[]) => Promise<AskAnswerItemShape[]> {
317
+ return async (questions) => {
318
+ const r = await ask.ask({
319
+ questions,
320
+ ...(agent === undefined ? {} : { agent }),
321
+ ...(signal === undefined ? {} : { signal }),
322
+ })
323
+ // Normalize: tolerate providers that omit `selected` on pure-custom answers.
324
+ return (r.answers ?? []).map((a) => ({ ...a, selected: a.selected ?? [] }))
325
+ }
326
+ }
327
+
328
+ interface InvocationAgentShape {
329
+ id?: unknown
330
+ session?: { id?: unknown }
331
+ }
332
+
333
+ /** provider model(空格)或 provider/model(斜杠);model 本身可含斜杠。 */
334
+ export function parseDistillInput(raw: string): { provider: string; model: string } | { error: string } {
335
+ let provider: string
336
+ let model: string
337
+ if (/\s/.test(raw.trim())) {
338
+ const split = raw.trim().split(/\s+/)
339
+ provider = split[0] ?? ''
340
+ model = split.slice(1).join(' ')
341
+ } else {
342
+ const slash = raw.indexOf('/')
343
+ provider = slash === -1 ? raw : raw.slice(0, slash)
344
+ model = slash === -1 ? '' : raw.slice(slash + 1)
345
+ }
346
+ provider = provider.trim()
347
+ model = model.trim()
348
+ if (provider === '' || model === '' || /\s/.test(provider) || /\s/.test(model)) {
349
+ return { error: '蒸馏模型格式:provider model(空格分隔)或 provider/model(斜杠分隔),如 zai-coding-cn glm-4.7-air' }
350
+ }
351
+ return { provider, model }
352
+ }
353
+
354
+ function answerFor(answers: AskAnswerItemShape[] | undefined, id: string): AskAnswerItemShape | undefined {
355
+ return answers?.find((a) => a.id === id)
356
+ }
357
+
358
+ /** Blank item = the UI's "skipped" encoding: keep the current value. */
359
+ export function isAnswered(a: AskAnswerItemShape | undefined): a is AskAnswerItemShape {
360
+ return a !== undefined && !(a.selected.length === 0 && (a.custom === undefined || a.custom.trim() === ''))
361
+ }
362
+
363
+ /** Custom input wins over a selected option (both may arrive). */
364
+ function pickText(a: AskAnswerItemShape): string {
365
+ return (a.custom !== undefined && a.custom.trim() !== '' ? a.custom : a.selected[0] ?? '').trim()
366
+ }
367
+
368
+ const SKIP_LABEL = '暂不启用蒸馏(跳过)'
369
+ const MAX_PANEL_OPTIONS = 10
370
+ const MAX_MODEL_OPTIONS = 8
371
+
372
+ /** Safe catalog reads: the advisory directory must never break the wizard. */
373
+ function safeProviders(llm: LlmDirectoryShape): { id: string; name: string }[] {
374
+ try {
375
+ return [...llm.listProviders()]
376
+ } catch {
377
+ return []
378
+ }
379
+ }
380
+
381
+ async function safeModels(llm: LlmDirectoryShape, provider: string): Promise<{ id: string; name: string; description?: string }[]> {
382
+ try {
383
+ return [...(await llm.listModels(provider))]
384
+ } catch {
385
+ return []
386
+ }
387
+ }
388
+
389
+ /** Panel P — which provider owns the distill route? Options = live ids (+ skip in the wizard). */
390
+ function distillProviderQuestion(llm: LlmDirectoryShape, current: string, notice: string | undefined, header: string, withSkip: boolean): AskItemShape {
391
+ const providers = safeProviders(llm)
392
+ const listed = providers.slice(0, MAX_PANEL_OPTIONS - (withSkip ? 1 : 0))
393
+ return {
394
+ id: 'distill-provider',
395
+ header,
396
+ question: '后台蒸馏用哪个 provider?',
397
+ detail: [
398
+ `当前:${current === '' ? '未启用蒸馏(观察只积累,不自动蒸馏成 Topic)' : current}`,
399
+ ...(notice === undefined ? [] : [`⚠️ ${notice}`]),
400
+ ...(providers.length > listed.length ? [`共 ${providers.length} 个已启用 provider(列出前 ${listed.length}),其他选 Other 手输 provider id`] : []),
401
+ ].join('\n'),
402
+ options: [
403
+ ...listed.map((p) => ({ label: p.id, description: p.name })),
404
+ ...(withSkip ? [{ label: SKIP_LABEL, description: '先不启用蒸馏,随时 /topics set distill-provider 再开' }] : []),
405
+ ],
406
+ }
407
+ }
408
+
409
+ /** Panel M — which model on that provider? Options = top of the advisory catalog + custom. */
410
+ async function distillModelQuestion(llm: LlmDirectoryShape, provider: string, current: string, header: string, notice?: string): Promise<AskItemShape> {
411
+ const models = await safeModels(llm, provider)
412
+ const listed = models.slice(0, MAX_MODEL_OPTIONS)
413
+ return {
414
+ id: 'distill-model',
415
+ header,
416
+ question: `蒸馏用 ${provider} 的哪个 model?`,
417
+ detail: [
418
+ `当前 model:${current === '' ? '未设置' : current}`,
419
+ ...(notice === undefined ? [] : [`⚠️ ${notice}`]),
420
+ `该 provider 目录共 ${models.length} 个模型${models.length > listed.length ? `(列出前 ${listed.length})` : ''};目录仅供参考,其他选 Other 手输 model id`,
421
+ ].join('\n'),
422
+ options: listed.map((m) => ({ label: m.id, description: m.description === undefined ? m.name : `${m.name} — ${m.description}` })),
423
+ }
424
+ }
425
+
426
+ const WIZARD_HEADER = 'topics 配置向导(2/4)'
427
+ const SET_HEADER = 'topics distill 配置'
428
+
429
+ /**
430
+ * Two dependent panels — provider first, then that provider's models — so the
431
+ * model list can come from the chosen provider's catalog. Answers run through
432
+ * `resolveModelInfo` before entering the pending batch: NO_ADAPTER blocks and
433
+ * re-asks (bounded), any other rejection (model outside the advisory catalog,
434
+ * possibly still usable) warns but allows. Returns undefined when the user
435
+ * skips or blanks the panel (keep current).
436
+ */
437
+ export async function askDistillRoute(
438
+ llm: LlmDirectoryShape,
439
+ panel: (questions: AskItemShape[]) => Promise<AskAnswerItemShape[]>,
440
+ cfg: { distillProvider: string; distillModel: string },
441
+ maxAttempts = 3,
442
+ ): Promise<{ provider: string; model: string; unknownModel: boolean } | undefined> {
443
+ let notice: string | undefined
444
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
445
+ const pAnswers = await panel([distillProviderQuestion(llm, cfg.distillProvider, notice, WIZARD_HEADER, true)])
446
+ const pPick = answerFor(pAnswers, 'distill-provider')
447
+ if (!isAnswered(pPick) || pickText(pPick) === SKIP_LABEL) return undefined
448
+ const provider = pickText(pPick)
449
+ const mAnswers = await panel([await distillModelQuestion(llm, provider, cfg.distillModel, WIZARD_HEADER)])
450
+ const mPick = answerFor(mAnswers, 'distill-model')
451
+ if (!isAnswered(mPick)) return undefined
452
+ const model = pickText(mPick)
453
+ try {
454
+ await llm.resolveModelInfo(provider, model)
455
+ return { provider, model, unknownModel: false }
456
+ } catch (error) {
457
+ if ((error as { code?: string }).code === 'NO_ADAPTER') {
458
+ notice = `provider ${provider} 当前没有可用的模型路由,请重新选择`
459
+ continue
460
+ }
461
+ // Non-NO_ADAPTER failure: the catalog is advisory — warn, allow.
462
+ return { provider, model, unknownModel: true }
463
+ }
464
+ }
465
+ throw new Error(`多次选择仍没有可用的模型路由(${notice ?? 'NO_ADAPTER'}),未写入任何改动;请确认 provider 已启用后重试 /topics onboard`)
466
+ }
467
+
468
+ /**
469
+ * Single-panel variant behind `/topics set distill-provider|distill-model`:
470
+ * one question, current value shown, blank answer = keep. Validation mirrors
471
+ * the wizard (`askDistillRoute`): a provider pick must sit in the live route
472
+ * table (the NO_ADAPTER equivalent — blocked, bounded re-ask); a model pick
473
+ * runs through `resolveModelInfo` — NO_ADAPTER blocks, any other failure
474
+ * warns but allows (model outside the advisory catalog, possibly still
475
+ * usable). Returns the picked value (plus the warning when a failed check was
476
+ * allowed through), or undefined on skip/blank.
477
+ */
478
+ export async function askDistillSingle(
479
+ llm: LlmDirectoryShape,
480
+ panel: (questions: AskItemShape[]) => Promise<AskAnswerItemShape[]>,
481
+ key: 'distill-provider' | 'distill-model',
482
+ cfg: { distillProvider: string; distillModel: string },
483
+ maxAttempts = 3,
484
+ ): Promise<{ value: string; warning?: string } | undefined> {
485
+ let notice: string | undefined
486
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
487
+ const question = key === 'distill-provider'
488
+ ? distillProviderQuestion(llm, cfg.distillProvider, notice, SET_HEADER, false)
489
+ : await distillModelQuestion(llm, cfg.distillProvider, cfg.distillModel, SET_HEADER, notice)
490
+ const a = answerFor(await panel([question]), question.id)
491
+ if (!isAnswered(a) || pickText(a) === SKIP_LABEL) return undefined
492
+ const value = pickText(a)
493
+ if (key === 'distill-provider') {
494
+ // Custom ids must still be a live route — the NO_ADAPTER equivalent.
495
+ if (safeProviders(llm).some((p) => p.id === value)) return { value }
496
+ notice = `provider ${value} 当前没有可用的模型路由,请重新选择`
497
+ continue
498
+ }
499
+ try {
500
+ await llm.resolveModelInfo(cfg.distillProvider, value)
501
+ return { value }
502
+ } catch (error) {
503
+ if ((error as { code?: string }).code === 'NO_ADAPTER') {
504
+ notice = `provider ${cfg.distillProvider} 当前没有可用的模型路由,请重新选择`
505
+ continue
506
+ }
507
+ return {
508
+ value,
509
+ warning: `${value} 未通过 ${cfg.distillProvider} 的模型目录校验(模型目录外,可能仍可用),已放行;若实际不可用可再改`,
510
+ }
511
+ }
512
+ }
513
+ throw new Error('多次选择仍没有可用的模型路由,未写入任何改动;请确认 provider 已启用后重试')
514
+ }
515
+
516
+ /**
517
+ * The interactive flow: storage mode, then the distill route as two dependent
518
+ * panels (provider → that provider's models), then a batched panel (repo when
519
+ * GitHub mode, inject tier, observe), then a confirm panel whose detail lists
520
+ * exactly what will be written. A closed panel or a skipped question never
521
+ * writes. Hosts without the llm directory never enter here (the handler gates
522
+ * on it and falls back to the typed wizard).
523
+ */
524
+ export async function runInteractiveOnboard(
525
+ service: TopicsService,
526
+ mutate: MutateFn,
527
+ ask: AskServiceShape,
528
+ detectLogin: GithubLoginDetector,
529
+ agent: InvocationAgentShape | undefined,
530
+ signal: AbortSignal | undefined,
531
+ llm: LlmDirectoryShape,
532
+ ): Promise<CommandResult> {
533
+ const ok = (text: string): CommandResult => ({ kind: 'success', text })
534
+ const fail = (text: string): CommandResult => ({ kind: 'error', text })
535
+ const panel = makePanel(ask, agent, signal)
536
+ try {
537
+ const cfg = service.cfg
538
+ // Stage 1 — storage mode (its own panel: the batch depends on it).
539
+ const modeAnswers = await panel([{
540
+ id: 'mode',
541
+ header: 'topics 配置向导(1/4)',
542
+ question: 'Topic 记忆存在哪里?',
543
+ detail: `当前:${cfg.repo === '' ? 'local-only' : `GitHub 同步(${cfg.repo})`}`,
544
+ options: [
545
+ { label: 'local-only', description: '零配置零凭据,数据只在本地 bundle(推荐)' },
546
+ { label: 'GitHub 同步', description: '指定私有仓,写穿 + 去抖推送,跨机共享' },
547
+ ],
548
+ }])
549
+ const modePick = answerFor(modeAnswers, 'mode')
550
+ if (!isAnswered(modePick)) return ok('向导结束——未选择存储模式,配置保持原样。')
551
+ const githubMode = (modePick.selected[0] ?? '').includes('GitHub')
552
+
553
+ // Stage 2 — the distill route: two dependent asks (the model list comes
554
+ // from the chosen provider's catalog), pre-validated via resolveModelInfo.
555
+ const route = await askDistillRoute(llm, panel, cfg)
556
+
557
+ // Stage 3 — the remaining decisions in one batched panel (tabs / pager).
558
+ const questions: AskItemShape[] = []
559
+ let suggestedRepo: string | undefined
560
+ if (githubMode) {
561
+ const login = await detectLogin()
562
+ if (login !== undefined) suggestedRepo = `${login}/${DEFAULT_TOPICS_REPO}`
563
+ questions.push({
564
+ id: 'repo',
565
+ header: 'topics 配置向导(3/4)',
566
+ question: 'GitHub 仓库(owner/name)?',
567
+ detail: `留空跳过 = 留在 local-only。默认建议 ${suggestedRepo ?? `${DEFAULT_TOPICS_REPO}(gh 登录名探测失败,请选 Other 输入完整 owner/name)`}`,
568
+ ...(suggestedRepo === undefined ? {} : { options: [{ label: suggestedRepo, description: '自动探测的 gh 登录名 + 默认仓库名' }] }),
569
+ })
570
+ }
571
+ questions.push(
572
+ {
573
+ id: 'inject',
574
+ header: 'topics 配置向导(3/4)',
575
+ question: '注入档位?',
576
+ detail: `当前:topK ${cfg.topK} / 总预算 ${cfg.totalBudget} tok / 阈值 ${cfg.matchThreshold};会话级去重${cfg.injectDedup ? '开(同会话已注入不重注)' : '关'}`,
577
+ options: [
578
+ { label: '保守(topK 2 · 800 tok)', description: '少而准,token 敏感选这个' },
579
+ { label: '标准(topK 4 · 1.5k tok)', description: '默认档(推荐)' },
580
+ { label: '放量(topK 6 · 2.5k tok)', description: '记忆多、上下文宽裕' },
581
+ ],
582
+ },
583
+ {
584
+ id: 'observe',
585
+ header: 'topics 配置向导(3/4)',
586
+ question: '自动观察?',
587
+ detail: `当前:${cfg.autoObserve ? `开(每轮自动抓原子观察,每侧 ≤${cfg.observationMaxChars} 字)` : '关(只手动 topic_save / topic_observe)'}`,
588
+ options: [
589
+ { label: '保持开(推荐)', description: '随手聊就被记录,后台蒸馏成 Topic' },
590
+ { label: '关闭', description: '只在你明确调用工具时记录' },
591
+ ],
592
+ },
593
+ )
594
+ const batchAnswers = await panel(questions)
595
+
596
+ // Collect into the same pending batch the typed wizard confirms with.
597
+ const pending: Partial<TopicsConfigValue> = {}
598
+ if (githubMode) {
599
+ const a = answerFor(batchAnswers, 'repo')
600
+ if (isAnswered(a)) {
601
+ const raw = (a.custom !== undefined && a.custom.trim() !== '' ? a.custom : a.selected[0] ?? '').trim()
602
+ const parsed = parseConfigValue('repo', raw)
603
+ if (typeof parsed === 'object' && parsed !== null && 'error' in parsed) {
604
+ return fail(`${parsed.error}\n未写入任何改动;稍后可用 /topics set repo <owner/name> 单独设置。`)
605
+ }
606
+ pending.repo = parsed as string
607
+ }
608
+ }
609
+ if (route !== undefined) {
610
+ pending.distillProvider = route.provider
611
+ pending.distillModel = route.model
612
+ }
613
+ const inject = answerFor(batchAnswers, 'inject')
614
+ if (isAnswered(inject)) {
615
+ const label = inject.selected[0] ?? ''
616
+ if (label.includes('保守')) { pending.topK = 2; pending.totalBudget = 800 } else if (label.includes('放量')) { pending.topK = 6; pending.totalBudget = 2500 } else { pending.topK = 4; pending.totalBudget = 1500 }
617
+ }
618
+ const observe = answerFor(batchAnswers, 'observe')
619
+ if (isAnswered(observe)) pending.autoObserve = (observe.selected[0] ?? '').includes('关闭') ? false : true
620
+
621
+ const ops = confirmOps(pending)
622
+ if (ops.length === 0) return ok('向导结束——本次没有选择任何改动,配置保持原样。')
623
+
624
+ // Stage 4 — confirm with the exact write set in the detail.
625
+ const keys = CONFIG_KEYS.filter((k) => k in pending)
626
+ const confirmAnswers = await panel([{
627
+ id: 'confirm',
628
+ header: 'topics 配置向导(4/4)',
629
+ question: `写入这 ${ops.length} 项配置?(下次会话启动后生效)`,
630
+ detail: [
631
+ ...keys.map((k) => `- ${displayKey(k)} = ${String(pending[k])}`),
632
+ ...(route?.unknownModel === true ? [`- ⚠️ ${route.model} 未通过 ${route.provider} 的模型目录校验(模型目录外,可能仍可用)——已放行,若实际不可用可再改`] : []),
633
+ ].join('\n'),
634
+ options: [
635
+ { label: '写入', description: '批量写入 settings(topics namespace)' },
636
+ { label: '放弃', description: '不写入任何改动' },
637
+ ],
638
+ }])
639
+ const confirmPick = answerFor(confirmAnswers, 'confirm')
640
+ if (!isAnswered(confirmPick) || (confirmPick.selected[0] ?? '').includes('放弃')) {
641
+ return ok('已放弃,未写入任何改动。随时 /topics onboard 重新开始。')
642
+ }
643
+ try {
644
+ await mutate(ops)
645
+ } catch (error) {
646
+ const message = error instanceof Error ? error.message : String(error)
647
+ return fail(`写入 settings 失败:${message}`)
648
+ }
649
+ if (pending.repo !== undefined || pending.autoInject !== undefined) service.invalidate()
650
+ return ok(
651
+ [
652
+ `✅ 已写入 topics 配置(${ops.length} 项):`,
653
+ ...keys.map((k) => ` ${displayKey(k)} = ${String(pending[k])}`),
654
+ '下次会话启动后生效。',
655
+ '之后:/topics status 看健康;/topics stats 看注入命中;会话里说「记住…」就会沉淀 Topic。',
656
+ ].join('\n'),
657
+ )
658
+ } catch (error) {
659
+ const code = (error as { code?: string }).code
660
+ if (code === 'ASK_CANCELLED' || code === 'ASK_ABORTED') {
661
+ return ok('已取消配置向导,未写入任何改动。随时 /topics onboard 重新开始。')
662
+ }
663
+ if (code === 'ASK_MISSING_AGENT') {
664
+ return fail('当前 surface 的 ask-user 需要会话上下文,无法从命令发起。请改用 /topics set,或在 TUI / 浏览器会话里重跑 /topics onboard。')
665
+ }
666
+ const message = error instanceof Error ? error.message : String(error)
667
+ return fail(`配置向导中断:${message}\n未写入任何改动。`)
668
+ }
669
+ }
670
+
671
+ /**
672
+ * The `/topics onboard <args>` handler. One instance per command registration —
673
+ * wizard state lives in the closure, so tests get a fresh wizard per
674
+ * buildTopicsCommand() and concurrent surfaces don't share progress.
675
+ *
676
+ * Primary path is the native ask-user seam when BOTH providers are present:
677
+ * `resolveAsk()` (TUI panel, web composer, feishu card — whichever surface
678
+ * owns the slot, with dsh-ask-router optionally fanning out) and a usable
679
+ * `resolveLlm()` candidate (the first with a non-empty live route table,
680
+ * feeding the distill provider/model panels). Either one missing — bare
681
+ * hosts, no UI, no model route — falls back to the typed wizard.
682
+ */
683
+ export function createOnboardHandler(
684
+ service: TopicsService,
685
+ mutate: MutateFn,
686
+ detectLogin: GithubLoginDetector = detectGithubLogin,
687
+ resolveAsk: AskServiceResolver = () => undefined,
688
+ resolveLlm: LlmDirectoryResolver = () => [],
689
+ ): (args: string[], invocation: CommandInvocation) => Promise<CommandResult> {
690
+ let state = freshState()
691
+ const ok = (text: string): CommandResult => ({ kind: 'success', text })
692
+ const fail = (text: string): CommandResult => ({ kind: 'error', text })
693
+ const advance = (answer: string): CommandResult => {
694
+ const r = applyAnswer(state, answer, service.cfg)
695
+ if (r.error !== undefined) return fail(`${r.error}\n\n${renderStep(state, service.cfg)}`)
696
+ state = r.state
697
+ return ok(renderStep(state, service.cfg))
698
+ }
699
+ return async (args, invocation) => {
700
+ const input = args.join(' ').trim()
701
+ if (input === '') {
702
+ // Bare `/topics onboard` with a live ask-user provider AND a usable llm
703
+ // directory (first candidate with a non-empty route table) opens the
704
+ // native panel flow; explicit args always mean typed-wizard answers.
705
+ const askService = resolveAsk()
706
+ const pick = pickLlmDirectory(resolveLlm())
707
+ if (askService !== undefined && typeof askService.ask === 'function' && pick.kind === 'ok') {
708
+ const invocationLike = invocation as { agent?: InvocationAgentShape; signal?: AbortSignal }
709
+ return runInteractiveOnboard(service, mutate, askService, detectLogin, invocationLike.agent, invocationLike.signal, pick.llm)
710
+ }
711
+ if (state.step === 'done') state = freshState()
712
+ // Typed fallback; when only the model route is missing, say why the
713
+ // pick panels did not open.
714
+ const notice = llmDirectoryNotice(pick)
715
+ return ok(notice === undefined ? renderStep(state, service.cfg) : `${notice}\n${renderStep(state, service.cfg)}`)
716
+ }
717
+ if (QUIT.test(input)) {
718
+ state = freshState()
719
+ return ok('已退出配置向导,未写入任何改动。随时 /topics onboard 重新开始。')
720
+ }
721
+ if (state.step === 'done') state = freshState()
722
+ if (state.step === 'confirm') {
723
+ const ops = confirmOps(state.pending)
724
+ if (ops.length === 0) {
725
+ state = freshState()
726
+ return ok('向导结束——本次没有选择任何改动,配置保持原样。随时 /topics onboard 重新开始。')
727
+ }
728
+ if (/^a$/i.test(input)) {
729
+ try {
730
+ await mutate(ops)
731
+ } catch (error) {
732
+ const message = error instanceof Error ? error.message : String(error)
733
+ return fail(`写入 settings 失败:${message}\n\n${renderStep(state, service.cfg)}`)
734
+ }
735
+ if (ops.some((o) => o.path[0] === 'repo' || o.path[0] === 'autoInject')) service.invalidate()
736
+ const keys = ops.map((o) => ` ${displayKey(String(o.path[0]))} = ${String(o.value)}`)
737
+ state = freshState()
738
+ return ok(
739
+ [
740
+ `✅ 已写入 topics 配置(${ops.length} 项):`,
741
+ ...keys,
742
+ '下次会话启动后生效。',
743
+ '之后:/topics status 看健康;/topics stats 看注入命中;会话里说「记住…」就会沉淀 Topic。',
744
+ ].join('\n'),
745
+ )
746
+ }
747
+ if (/^b$/i.test(input)) {
748
+ state = freshState()
749
+ return ok('已放弃,未写入任何改动。随时 /topics onboard 重新开始。')
750
+ }
751
+ return fail(`确认页只认 a(写入)或 b(放弃)。\n\n${renderStep(state, service.cfg)}`)
752
+ }
753
+ if (state.step === 'repo' && /^a$/i.test(input)) {
754
+ const login = await detectLogin()
755
+ if (login === undefined) {
756
+ return fail('探测 gh 登录名失败(gh 未登录或不可用)。直接输入 owner/name,或回复 c 跳过。')
757
+ }
758
+ return advance(`${login}/${DEFAULT_TOPICS_REPO}`)
759
+ }
760
+ return advance(input)
761
+ }
762
+ }