thincoder 0.12.60 → 0.12.62

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 (171) hide show
  1. package/CHANGELOG.md +49 -2
  2. package/README.md +8 -6
  3. package/bin/thincoder.mjs +38 -124
  4. package/package.json +3 -2
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +38 -17
  7. package/src/acp.mjs +19 -6
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +36 -134
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +124 -329
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +3 -2
  17. package/src/agent/completion.mjs +1 -1
  18. package/src/agent/dispatch.mjs +47 -12
  19. package/src/agent/helpers.mjs +71 -13
  20. package/src/agent/record-results.mjs +13 -5
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +24 -7
  23. package/src/agent/setup-reminders.mjs +16 -9
  24. package/src/agent/setup.mjs +92 -128
  25. package/src/agent/spawn-child.mjs +43 -11
  26. package/src/agent-tools/advisor-async.mjs +70 -180
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +69 -20
  29. package/src/agent-tools/async-settle.mjs +13 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +28 -10
  32. package/src/agent-tools/design-token.mjs +14 -1
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +3 -3
  35. package/src/agent-tools/escalate-async.mjs +22 -13
  36. package/src/agent-tools/read-history.mjs +31 -6
  37. package/src/agent-tools/review-streak.mjs +93 -0
  38. package/src/agent-tools/settings.mjs +130 -17
  39. package/src/agent-tools/subagent-actions.mjs +18 -6
  40. package/src/agent-tools/subagent-async.mjs +66 -14
  41. package/src/agent-tools/subagent-panel.mjs +22 -15
  42. package/src/agent-tools/subagent-run.mjs +10 -7
  43. package/src/agent-tools/subagent-scheduler.mjs +57 -8
  44. package/src/agent-tools/subagent-spawn.mjs +69 -16
  45. package/src/agent-tools/subagent.mjs +175 -49
  46. package/src/agent-tools/verify.mjs +13 -34
  47. package/src/agent-tools.mjs +1 -0
  48. package/src/agent.mjs +42 -21
  49. package/src/cli/distill-command.mjs +2 -2
  50. package/src/cli/make-agent.mjs +23 -7
  51. package/src/cli/memory-command.mjs +2 -2
  52. package/src/cli/setup-wizard.mjs +29 -9
  53. package/src/completions.mjs +114 -0
  54. package/src/config-migrate.mjs +70 -0
  55. package/src/config.mjs +132 -63
  56. package/src/context.mjs +12 -1
  57. package/src/conventions.mjs +223 -0
  58. package/src/crash-reports.mjs +35 -8
  59. package/src/expand-home.mjs +16 -0
  60. package/src/generate-title.mjs +9 -4
  61. package/src/heap-watch.mjs +88 -0
  62. package/src/hooks.mjs +7 -3
  63. package/src/ledger.mjs +227 -0
  64. package/src/memory/code-index.mjs +9 -3
  65. package/src/memory/code-sync.mjs +77 -36
  66. package/src/memory/core.mjs +8 -9
  67. package/src/memory/delete.mjs +2 -0
  68. package/src/memory/docs.mjs +17 -11
  69. package/src/memory/file-walk.mjs +109 -0
  70. package/src/memory/scan.mjs +95 -0
  71. package/src/memory/schema.mjs +15 -3
  72. package/src/model-ref.mjs +66 -0
  73. package/src/model-specs.mjs +42 -8
  74. package/src/prompt-overlays.mjs +73 -16
  75. package/src/prompts/advisor-design.md +19 -9
  76. package/src/prompts/advisor-round1.md +8 -2
  77. package/src/prompts/advisor-round2.md +14 -3
  78. package/src/prompts/advisor-round3.md +14 -3
  79. package/src/prompts/common.md +115 -0
  80. package/src/prompts/consult-base.md +2 -0
  81. package/src/prompts/discipline-engineering.md +258 -0
  82. package/src/prompts/discipline-normal.md +185 -0
  83. package/src/prompts/persona-coder.md +21 -0
  84. package/src/prompts/persona-eng-coder.md +37 -0
  85. package/src/prompts/persona-eng-designer.md +60 -0
  86. package/src/prompts/persona-engineering.md +55 -0
  87. package/src/prompts/persona-explore.md +15 -0
  88. package/src/prompts/persona-normal.md +27 -0
  89. package/src/prompts/persona-plan.md +26 -0
  90. package/src/provider/anthropic.mjs +4 -4
  91. package/src/provider/core.mjs +13 -32
  92. package/src/provider/errors.mjs +26 -1
  93. package/src/provider/google.mjs +5 -6
  94. package/src/provider/index.mjs +2 -1
  95. package/src/provider/list-models.mjs +93 -0
  96. package/src/provider/rate.mjs +2 -1
  97. package/src/provider/responses.mjs +5 -3
  98. package/src/provider/sse.mjs +3 -4
  99. package/src/proxy.mjs +9 -14
  100. package/src/session-gc.mjs +9 -2
  101. package/src/session-guard.mjs +12 -0
  102. package/src/session-segments.mjs +100 -0
  103. package/src/session-slots.mjs +10 -2
  104. package/src/session-store.mjs +441 -0
  105. package/src/session.mjs +137 -99
  106. package/src/text-budget.mjs +46 -0
  107. package/src/token-ttl.mjs +2 -1
  108. package/src/tools/{system.mjs → bash.mjs} +6 -243
  109. package/src/tools/file.mjs +30 -10
  110. package/src/tools/git.md +1 -1
  111. package/src/tools/git.mjs +15 -34
  112. package/src/tools/index.mjs +4 -2
  113. package/src/tools/ops.mjs +20 -7
  114. package/src/tools/question.md +1 -0
  115. package/src/tools/question.mjs +26 -0
  116. package/src/tools/read.md +1 -1
  117. package/src/tools/read_image.md +1 -1
  118. package/src/tools/search.mjs +236 -0
  119. package/src/traces/trace-store.mjs +195 -64
  120. package/src/tui/agent-turn.mjs +32 -13
  121. package/src/tui/ansi.mjs +2 -0
  122. package/src/tui/clipboard.mjs +7 -1
  123. package/src/tui/cmd-advisor.mjs +3 -2
  124. package/src/tui/cmd-clear.mjs +2 -0
  125. package/src/tui/cmd-config.mjs +108 -37
  126. package/src/tui/cmd-eng.mjs +11 -27
  127. package/src/tui/cmd-exit.mjs +6 -8
  128. package/src/tui/cmd-model.mjs +14 -12
  129. package/src/tui/cmd-new.mjs +5 -1
  130. package/src/tui/cmd-reindex.mjs +7 -0
  131. package/src/tui/cmd-session.mjs +8 -4
  132. package/src/tui/cmd-submodel.mjs +8 -5
  133. package/src/tui/cmd-undo.mjs +4 -3
  134. package/src/tui/display-budget.mjs +184 -0
  135. package/src/tui/index.mjs +69 -40
  136. package/src/tui/key-handler-search.mjs +9 -1
  137. package/src/tui/key-handler.mjs +61 -17
  138. package/src/tui/key-modes.mjs +86 -8
  139. package/src/tui/layout.mjs +18 -10
  140. package/src/tui/ledger-surface.mjs +69 -0
  141. package/src/tui/model-catalog.mjs +89 -0
  142. package/src/tui/model-picker.mjs +498 -0
  143. package/src/tui/mouse.mjs +47 -10
  144. package/src/tui/pickers.mjs +28 -410
  145. package/src/tui/render-frame.mjs +38 -16
  146. package/src/tui/render-loop.mjs +2 -0
  147. package/src/tui/render-segments.mjs +5 -19
  148. package/src/tui/render.mjs +37 -5
  149. package/src/tui/slash-commands.mjs +2 -2
  150. package/src/tui/startup.mjs +45 -13
  151. package/src/tui/subagent-blocks.mjs +70 -90
  152. package/src/tui/subagent-children.mjs +125 -67
  153. package/src/tui/subagent-freeze.mjs +48 -45
  154. package/src/tui/subagent-panel.mjs +21 -66
  155. package/src/tui/suspension-drive.mjs +31 -83
  156. package/src/tui/tool-args.mjs +9 -4
  157. package/src/tui/tool-display.mjs +20 -5
  158. package/src/tui/tool-events.mjs +76 -30
  159. package/src/tui/tui-lifecycle.mjs +27 -7
  160. package/src/tui/wizard.mjs +52 -18
  161. package/src/tui/wrapped-spawn.mjs +54 -0
  162. package/src/prompts/coder.md +0 -13
  163. package/src/prompts/discipline.md +0 -84
  164. package/src/prompts/eng-coder.md +0 -19
  165. package/src/prompts/engineering-sub.md +0 -14
  166. package/src/prompts/engineering.md +0 -87
  167. package/src/prompts/explore.md +0 -12
  168. package/src/prompts/main.md +0 -34
  169. package/src/prompts/methodology-template.md +0 -38
  170. package/src/prompts/plan.md +0 -9
  171. package/src/prompts/system.md +0 -44
@@ -8,6 +8,31 @@
8
8
  import { C } from "./ansi.mjs"
9
9
  import { readClipboardText, insertPastedText } from "./clipboard.mjs"
10
10
  import { QUESTION_CUSTOM } from "./interaction.mjs"
11
+ import { inputContentWidth } from "./layout.mjs"
12
+ import { moveCursorVertical } from "./render.mjs"
13
+
14
+ /** SYNC-CANCEL v2 模态 deny 解绕(用户裁——2026-09-09):⏹ 定向中止 sync child 时
15
+ * 顺带 deny 其 pending 权限/continue 模态——ask 不观 signal(R1——targeted abort 后
16
+ * child 停在模态则 runChildPipeline 不返回、父回合阻塞至模态回答)。owner 判定:
17
+ * 工具权限 ask name 前缀 `${key}/`(subagent-spawn.mjs 装配——owner key 标识);continue
18
+ * ask args.agent === key(subagent.mjs askSubagentContinue 既有参数)。副作用镜像
19
+ * handlePermissionMode deny 分支(清除模态 + deny 轨迹 + resolve(false)——子代理随即
20
+ * 在 abort 检出点解绕折叠)。返回 true = 本模态属该 child 且已 deny 解绕。鼠标 ⏹ 路径
21
+ * (mouse.mjs cancelSubagent)调用——key-modes 是权限模态归属语义的驻点。 */
22
+ export function denyModalForOwner(state, key, { pushLine, render } = {}) {
23
+ const p = state?.permission
24
+ if (!p) return false
25
+ const owned = (typeof p.name === "string" && p.name.startsWith(`${key}/`)) || p.args?.agent === key
26
+ if (!owned) return false
27
+ const { resolve, name } = p
28
+ state.permission = null
29
+ state.permissionPreview = []
30
+ state.status = "Processing..."
31
+ pushLine?.(` [denied] ${name}`, C.error)
32
+ resolve(false)
33
+ render?.()
34
+ return true
35
+ }
11
36
 
12
37
  /** permission 确认模态:y/n/a(a = approve + AUTO ON);batch(§16 D-B1):a/o/n(Esc = deny)。
13
38
  * consume:valid 键或 Esc 走 resolve 分支;其余键静默吞掉(模态独占)。 */
@@ -68,10 +93,11 @@ export function handleQuestionMode(str, key, ctx) {
68
93
  state.status = "Processing..."
69
94
  render()
70
95
  } else if (key.name === "up") {
71
- q.selected = Math.max(0, (q.selected ?? 0) - 1)
96
+ // A4(第 20 批 §12.4 契约——同义键同形):↑↓ 环绕(原钳位——对齐 picker/wizard 既有语义)
97
+ q.selected = ((q.selected ?? 0) - 1 + q.options.length) % q.options.length
72
98
  render()
73
99
  } else if (key.name === "down") {
74
- q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
100
+ q.selected = ((q.selected ?? 0) + 1) % q.options.length
75
101
  render()
76
102
  } else if (key.name === "return") {
77
103
  const answer = q.options[q.selected ?? 0]
@@ -182,16 +208,21 @@ export function handleQuestionMode(str, key, ctx) {
182
208
  }
183
209
 
184
210
  /** interruptPrompt 模态(Ctrl+I 后输入注入消息):Enter 提交 → controller.abort
185
- * { interrupt: true, message };Esc 取消;字符进 prompt;其余键静默吞(模态独占)。
211
+ * { interrupt: true, message };Esc 取消;编辑键 = §7.2 最小集 + 四方向键(第 31 批:
212
+ * `{ chars, cursor }` codepoint 模型——TUI-INPUT-BOX.md §8.1);其余键静默吞(模态独占)。
186
213
  * 返回 false 当模态未激活(key-handler 先检 state.interruptPrompt 入口再调)。 */
187
214
  export function handleInterruptMode(str, key, ctx) {
188
215
  const { state, pushLine, render } = ctx
189
216
  if (!state.interruptPrompt) return false
217
+ const p = state.interruptPrompt
218
+ if (!Array.isArray(p.chars)) p.chars = p.chars ? [...p.chars] : [] // 形态防御(同 question 自由文本态)
219
+ p.cursor = Math.max(0, Math.min(p.cursor ?? p.chars.length, p.chars.length))
220
+ const len = p.chars.length
190
221
  if (key.name === "escape") {
191
222
  state.interruptPrompt = null
192
223
  render()
193
224
  } else if (key.name === "return") {
194
- const msg = (state.interruptPrompt.text ?? "").trim()
225
+ const msg = p.chars.join("").trim()
195
226
  state.interruptPrompt = null
196
227
  if (msg) {
197
228
  // Guard: if the turn already finished while the user was typing, the controller
@@ -204,12 +235,59 @@ export function handleInterruptMode(str, key, ctx) {
204
235
  }
205
236
  render()
206
237
  }
207
- } else if (key.name === "backspace") {
208
- state.interruptPrompt.text = state.interruptPrompt.text.slice(0, -1)
238
+ } else if (key.name === "enter") {
239
+ // Ctrl+J(\n → name "enter")= no-op 吞——单行不变式:不插换行(§8.1)
240
+ } else if (key.name === "left") {
241
+ p.cursor = Math.max(0, p.cursor - 1)
242
+ render()
243
+ } else if (key.name === "right") {
244
+ p.cursor = Math.min(len, p.cursor + 1)
245
+ render()
246
+ } else if (key.name === "up" || key.name === "down") {
247
+ // 折行竖移(§8.1):显示列保持 + 短行端钳制;无邻行 = 吞(零副作用——无历史回落)
248
+ const cols = (state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80)
249
+ const next = moveCursorVertical(p.chars, p.cursor, inputContentWidth(cols), key.name)
250
+ if (next !== null) {
251
+ p.cursor = next
252
+ render()
253
+ }
254
+ } else if (key.name === "home") {
255
+ p.cursor = 0
209
256
  render()
210
- } else if (str && !key.ctrl && !key.meta) {
211
- state.interruptPrompt.text += str.replace(/[\r\n]+/g, "")
257
+ } else if (key.name === "end") {
258
+ p.cursor = len
212
259
  render()
260
+ } else if (key.ctrl && !key.alt && key.name === "u") {
261
+ p.chars = []
262
+ p.cursor = 0
263
+ render()
264
+ } else if (key.name === "backspace") {
265
+ if (p.cursor > 0) {
266
+ p.chars.splice(p.cursor - 1, 1) // codepoint 元素删除——emoji 不劈半
267
+ p.cursor--
268
+ render()
269
+ }
270
+ } else if (key.ctrl && !key.alt && !key.meta && key.name === "v") {
271
+ // Ctrl+V 粘贴:落 cursor 位置(\n/\r 去除、\t → 2 空格——insertPastedText 注入框分支)。
272
+ // 异步读剪贴板期间框可能已 Esc/Enter 关闭 → 回调守卫比对同一引用,防落主输入框
273
+ // (同 question 态 stale-paste 守卫语义;不加守卫会静默落 state.input)。
274
+ const target = p
275
+ readClipboardText().then((text) => {
276
+ if (text && state.interruptPrompt === target) {
277
+ insertPastedText(state, text)
278
+ render()
279
+ }
280
+ }).catch((e) => console.error(`[tui] clipboard paste failed: ${e.message}`))
281
+ } else if (str && !key.ctrl && !key.meta && key.name !== "tab" && key.name !== "enter") {
282
+ // 可打印字符插入光标位置(codepoint 拆字);\r\n 剥离 + \t→2 空格(单行不变式)
283
+ const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
284
+ if (chars.length) {
285
+ p.chars.splice(p.cursor, 0, ...chars)
286
+ p.cursor += chars.length
287
+ render()
288
+ }
213
289
  }
290
+ // 未列键(Delete/PgUp/PgDn/F1/…)一律 consume return——无 fall-through(模态独占;§8.1:
291
+ // Delete 不引入——与 §7.2 最小集对齐)
214
292
  return true
215
293
  }
@@ -3,7 +3,7 @@
3
3
  * Computes position and height of each panel from state + terminal dimensions.
4
4
  * Does not modify state — side effects are performed by the caller before rendering.
5
5
  *
6
- * header → conversation → subagent 面板 → todo → picker → permission → queue → input → status
6
+ * header → conversation → subagent 面板 → todo → picker → permission → input → status
7
7
  * Running subagent activity renders in a FIXED bottom panel between the
8
8
  * conversation and the todo panel (AGENT-LOOP.md §7.2.1) — full adaptive height
9
9
  * (the conversation shrinks); compressed away first on small terminals (to 0 =
@@ -26,6 +26,13 @@ const MAX_INPUT_LINES = 5
26
26
  const MAX_TASK_LINES = 5
27
27
  const QWIN = 5
28
28
 
29
+ /** 输入框内容宽度单源(第 31 批——TUI-INPUT-BOX.md §9.3 #3):`computeLayout` 的输入框
30
+ * layoutInput 宽度与键处理侧竖移(`moveCursorVertical`)共用——布局与竖移零漂移。
31
+ * 含 2 列行前缀(`▸ ` / 续行 2 空格);layoutInput 内部再为前缀预留 2 列。 */
32
+ export function inputContentWidth(cols) {
33
+ return Math.max(20, cols - 1) - 4
34
+ }
35
+
29
36
  /** question 自由文本态渲染布局:与主输入框同实现(TUI-INPUT-BOX.md §7.2 round2 #1/#6)——
30
37
  * 折行展开(`▸ ` 首行 + 续行 2 空格缩进)+ 光标行列;answer 单行不变式(无 \n 字符)由
31
38
  * 输入侧硬守卫(Ctrl+J no-op / 粘贴 \n→空格),此处只复用 layoutInput 的折行/光标语义,
@@ -40,11 +47,14 @@ export function layoutAnswer(chars, cursor, width) {
40
47
  */
41
48
  export function computeLayout(state, { cols, rows }) {
42
49
  const W = Math.max(20, cols - 1)
50
+ const inputW = inputContentWidth(cols) // 输入框内容宽(= W - 4)——单源:布局与竖移共用
43
51
 
44
52
  // --- input box ---
45
- const inputBuf = state.search ? [...state.search.query] : (state.interruptPrompt ? [...state.interruptPrompt.text] : state.input)
46
- const inputCursor = state.search ? inputBuf.length : (state.interruptPrompt ? inputBuf.length : state.cursor)
47
- const inputLayout = layoutInput(inputBuf, inputCursor, W - 4)
53
+ // Ctrl+I 注入框(第 31 批):{ chars, cursor } codepoint 数组 + 光标——与主输入框同渲染路径(§8)。
54
+ const ip = state.interruptPrompt
55
+ const inputBuf = state.search ? [...state.search.query] : (ip ? ip.chars : state.input)
56
+ const inputCursor = state.search ? inputBuf.length : (ip ? Math.max(0, Math.min(ip.cursor ?? inputBuf.length, inputBuf.length)) : state.cursor)
57
+ const inputLayout = layoutInput(inputBuf, inputCursor, inputW)
48
58
  let inputOffset = 0
49
59
  if (inputLayout.lines.length > MAX_INPUT_LINES) {
50
60
  inputOffset = Math.min(inputLayout.cursorLine, inputLayout.lines.length - MAX_INPUT_LINES)
@@ -66,7 +76,7 @@ export function computeLayout(state, { cols, rows }) {
66
76
  } else {
67
77
  const answerChars = Array.isArray(q.answer) ? q.answer : q.answer ? [...q.answer] : []
68
78
  const qCursor = Math.max(0, Math.min(q.cursor ?? answerChars.length, answerChars.length))
69
- questionLayout = layoutAnswer(answerChars, qCursor, W - 4)
79
+ questionLayout = layoutAnswer(answerChars, qCursor, inputW)
70
80
  if (questionLayout.lines.length > MAX_INPUT_LINES) {
71
81
  questionOffset = Math.min(questionLayout.cursorLine, questionLayout.lines.length - MAX_INPUT_LINES)
72
82
  }
@@ -127,11 +137,10 @@ export function computeLayout(state, { cols, rows }) {
127
137
  permPreviewH = 1 + permPreviewLines.length // 1 for header line
128
138
  }
129
139
 
130
- // Queue preview (1 line when queue has items and processing)
131
- const queueH = state.queue.length > 0 && state.processing ? 1 : 0
140
+ // Queue 面板已撤(INPUT-LOCK-ASYNC F-7——2026-09-09——busy 提交吞——无排队展示)
132
141
 
133
142
  // --- elastic panel: conversation takes remaining space ---
134
- const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH + queueH + subagentH
143
+ const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH + subagentH
135
144
  let convH = Math.max(1, rows - fixedH)
136
145
 
137
146
  // 小终端高度补偿:subagent 面板最先让位(可至 0 隐藏,活动仍进缓冲区不丢),
@@ -183,13 +192,12 @@ export function computeLayout(state, { cols, rows }) {
183
192
  const todo = todoFinalH > 0 ? { y, h: todoFinalH } : null; y += todoFinalH
184
193
  const picker = pickerFinalH > 0 ? { y, h: pickerFinalH } : null; y += pickerFinalH
185
194
  const permission = permFinalH > 0 ? { y, h: permFinalH } : null; y += permFinalH
186
- const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
187
195
  const inputBox = { y, h: inputBoxH }; y += inputBoxH
188
196
  const status = { y, h: statusH }
189
197
 
190
198
  return {
191
199
  W, cols, rows,
192
- panels: { header, conversation, subagent, picker, todo, permission, queue, inputBox, status },
200
+ panels: { header, conversation, subagent, picker, todo, permission, inputBox, status },
193
201
  // precomputed content (affects height, reused during render)
194
202
  inputLayout,
195
203
  inputOffset,
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ledger-surface.mjs — TUI 台账可见面胶水(LEDGER-SURFACE 批——设计档 §2.30.3.4)。
3
+ *
4
+ * 三面:① 启动行(首扫、**任一项目可动作**才出——明细行集每项目一行)② 变化行(老化首次
5
+ * 越线 / 池达阈值——一次性去重 + **送达门**:未送达不记账)③ 状态位(`state.ledger`——L1
6
+ * 常驻标记,`render-frame.mjs` 消费;**不受启动行门约束**——F2 按标记态照显)。
7
+ * 只读台账;唯一写面 = 去重档(`~/.thincoder/ledger-notify.json`)。
8
+ */
9
+ import { blameAges, detailScans, discoverFamily, formatDetailLine, formatMarker, loadNotifyState, NOTIFY_FILE, notifyKey, planChangeLines, REFRESH_MS, saveNotifyState, summarizeLedger } from "../ledger.mjs"
10
+ import { C } from "./ansi.mjs"
11
+
12
+ /** 单次扫描(直驱面——timer 包装见 `startLedgerSurface`)。
13
+ * `startup=true` = 会话首扫(补启动行;变化行在前、明细行在后)。 */
14
+ export function runLedgerScan({ state, agent = null, anchor = null, notifyFile = NOTIFY_FILE, pushLine = () => {}, render = () => {}, startup = false, ageOf = blameAges } = {}) {
15
+ const base = anchor ?? agent?.cwd ?? process.cwd()
16
+ const family = discoverFamily(base)
17
+ const scans = []
18
+ for (const p of family.projects) {
19
+ try { scans.push(summarizeLedger(p, { ageOf })) } catch { /* 台账不可读 → 该项目跳过(余者照常——N1/T110①) */ }
20
+ }
21
+ const current = family.current ? scans.find((s) => s.root === family.current.root) ?? null : null
22
+ const notify = loadNotifyState(notifyFile)
23
+ const plans = scans.map((s) => ({ s, plan: planChangeLines(notify.ledgers[notifyKey(s.ledger)], s) }))
24
+ const changeLines = plans.flatMap(({ plan }) => plan.lines)
25
+ const showStartup = startup && scans.some((s) => s.actionable)
26
+ const rows = showStartup
27
+ ? [...changeLines, ...detailScans(scans, current).map((s) => ({ text: formatDetailLine(s), warn: s.actionable }))]
28
+ : changeLines
29
+ let delivered = true
30
+ for (const line of rows) {
31
+ try { pushLine(line.text, line.warn ? C.warn : C.dim) } catch { delivered = false; break }
32
+ }
33
+ // 送达门(F5):有事件行 → 仅在送达后记账;无事件行 → 仅状态确有变化时静默同步(跌回 false 即重置)
34
+ const changed = plans.some(({ s, plan }) => {
35
+ const prev = notify.ledgers[notifyKey(s.ledger)]
36
+ return !prev || JSON.stringify(prev.aged ?? []) !== JSON.stringify(plan.next.aged) || Boolean(prev.threshold) !== plan.next.threshold
37
+ })
38
+ if (changeLines.length ? delivered : changed) {
39
+ const ledgers = { ...notify.ledgers }
40
+ for (const { s, plan } of plans) ledgers[notifyKey(s.ledger)] = { ...plan.next, updatedAt: Date.now() }
41
+ saveNotifyState(notifyFile, { version: 1, ledgers })
42
+ }
43
+ state.ledger = { marker: current ? formatMarker(current) : null, warn: Boolean(current && current.aged > 0), scannedAt: Date.now() }
44
+ render()
45
+ }
46
+
47
+ /** TUI 挂载:首扫(`setImmediate`——不抢首帧)+ 周期(`REFRESH_MS`;`state.processing` 期间跳过本轮)。 */
48
+ export function startLedgerSurface(ctx = {}) {
49
+ let disposed = false
50
+ let timer = null
51
+ const tick = (startup) => {
52
+ if (disposed) return
53
+ if (!startup && ctx.state?.processing) return // git 子进程不打断回合帧
54
+ try { runLedgerScan({ ...ctx, startup }) } catch { /* 可见面尽力——不崩 TUI(N1) */ }
55
+ }
56
+ setImmediate(() => {
57
+ if (disposed) return
58
+ tick(true)
59
+ timer = setInterval(() => tick(false), REFRESH_MS)
60
+ timer.unref?.()
61
+ })
62
+ return {
63
+ dispose() {
64
+ disposed = true
65
+ if (timer) clearInterval(timer)
66
+ timer = null
67
+ },
68
+ }
69
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * model-catalog.mjs — 会话级模型清单 helper(PROVIDER.md §16.2 M2/M9——2026-09-10 MODEL-SELECTION)。
3
+ *
4
+ * 清单唯一权威 = provider 运行期拉取(`GET /models`——provider/list-models.mjs 按 format 分派)。
5
+ * 本模块提供:
6
+ * - `getProviderModels(providerConfig)`:拉取 + **会话级缓存**(TTL 60s;失败不缓存——下次重试);
7
+ * - `probeChannelModels(providerConfig)`:配置阶段准入探(M9——探通入缓存候选直接可用 / 探不通返回
8
+ * 明示文案;调用方决定标注与「不入可选来源」——绝不阻断保存);
9
+ * - `modelListFailureText(error)`:M8 失败消息本体(逐字长句——界面明示的唯一断言对象);
10
+ * - `dedupeModels` / `modelSeries`:显示归并(自 model-picker.mjs 迁入——会话面与槽位面共用)。
11
+ *
12
+ * 缓存时钟可注入(`_catalogHooks.now`——先例 rate.mjs `_rateHooks`):测试假时钟确定性断言 T6 不依赖壁钟。
13
+ */
14
+ import { listModels } from "../provider/list-models.mjs"
15
+
16
+ /** 会话级缓存 TTL(§16.4 a2:连续操作不重复付网络;60s 新鲜窗口)。 */
17
+ const CACHE_TTL_MS = 60_000
18
+
19
+ /** 测试钩子(先例 `rate.mjs` `_rateHooks`):时钟可注入——T6 假时钟断言。 */
20
+ export const _catalogHooks = { now: () => Date.now() }
21
+
22
+ /** 会话内缓存:key = name + baseURL(同渠道不同端点各自成条);value = { models, at }。 */
23
+ const _cache = new Map()
24
+
25
+ /** 测试缝:清空会话缓存(跨用例隔离——生产无调用点)。 */
26
+ export function _clearModelCatalogCache() { _cache.clear() }
27
+
28
+ function cacheKey(providerConfig) {
29
+ return `${providerConfig?.name ?? ""}\u0000${providerConfig?.baseURL ?? ""}`
30
+ }
31
+
32
+ /**
33
+ * 拉取渠道模型清单(会话缓存 TTL 60s)。命中缓存(TTL 内)直接返回,否则走 M1 拉取;
34
+ * **失败不缓存**(下次调用重试——失败态由调用方降级展示)。
35
+ * `fresh: true` 跳过缓存读(仍写回——M9 准入探必须真发一次请求:换 key 不得被旧 key 的缓存短路)。
36
+ * 抛错契约与 listModels 一致。
37
+ */
38
+ export async function getProviderModels(providerConfig, { signal, fresh = false } = {}) {
39
+ const key = cacheKey(providerConfig)
40
+ if (!fresh) {
41
+ const hit = _cache.get(key)
42
+ if (hit && _catalogHooks.now() - hit.at < CACHE_TTL_MS) return hit.models
43
+ }
44
+ const models = await listModels(providerConfig, { signal })
45
+ _cache.set(key, { models, at: _catalogHooks.now() })
46
+ return models
47
+ }
48
+
49
+ /** M8 失败消息本体(逐字长句——状态 = HTTP 状态码或网络错误摘要;无绕过指引)。
50
+ * 分工:消息本体 = 本长句;状态标签 = `不可用`(列表行内短标)。 */
51
+ export function modelListFailureText(error) {
52
+ const status = /GET \/models failed (\d+)/.exec(error?.message ?? "")?.[1]
53
+ ?? String(error?.message ?? error)
54
+ return `该渠道不提供模型列表(GET /models ${status})——无法选择模型,请改用其他渠道`
55
+ }
56
+
57
+ /**
58
+ * M9 配置阶段准入探:对目标渠道探一次 `GET /models`(复用 M1 + 既有超时;**fresh——真发一次
59
+ * 请求**(换 key 不被旧缓存短路);探通入会话缓存——该流内候选直接可用)。
60
+ * 返回 `{ ok:true, list }` | `{ ok:false, message }`——**不抛错**(探不通不是异常:调用方标注
61
+ * 不可用 + 明示原因,配置流不阻断)。
62
+ */
63
+ export async function probeChannelModels(providerConfig) {
64
+ try {
65
+ return { ok: true, list: await getProviderModels(providerConfig, { fresh: true }) }
66
+ } catch (error) {
67
+ return { ok: false, message: modelListFailureText(error) }
68
+ }
69
+ }
70
+
71
+ /** Strip known version/date suffixes to get the "series" name of a model.
72
+ * e.g. "qwen-max-latest" → "qwen-max", "qwen-max-2024-09-19" → "qwen-max" */
73
+ export function modelSeries(name) {
74
+ return name
75
+ .replace(/-latest$/, "")
76
+ .replace(/-\d{4}-\d{2}-\d{2}$/, "") // date suffix like -2024-09-19
77
+ .replace(/-\d{8}$/, "") // date suffix like -20240919
78
+ }
79
+
80
+ /** Dedupe model list: group by series, keep shortest name per group. */
81
+ export function dedupeModels(models) {
82
+ const groups = new Map()
83
+ for (const m of models) {
84
+ const series = modelSeries(m)
85
+ const existing = groups.get(series)
86
+ if (!existing || m.length < existing.length) groups.set(series, m)
87
+ }
88
+ return [...groups.values()].sort()
89
+ }