min-agent 0.4.0 → 0.5.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 (54) hide show
  1. package/README.md +169 -284
  2. package/dist/agent.js +36 -22
  3. package/dist/cli/commands/chat.js +3 -0
  4. package/dist/cli/commands/exec.js +3 -0
  5. package/dist/cli/commands/index.js +22 -5
  6. package/dist/cli/commands/memory.js +33 -15
  7. package/dist/cli/commands/think.js +12 -0
  8. package/dist/cli/commands/write-config.js +22 -0
  9. package/dist/cli/option-helpers.js +13 -1
  10. package/dist/cli/program.js +50 -13
  11. package/dist/code-mode.js +1 -1
  12. package/dist/config.js +41 -0
  13. package/dist/context-window.js +8 -28
  14. package/dist/memory-cli.js +33 -0
  15. package/dist/memory.js +127 -46
  16. package/dist/model-catalog.js +285 -0
  17. package/dist/permission-cli.js +1 -4
  18. package/dist/provider.js +4 -1
  19. package/dist/reasoning-stream.js +158 -0
  20. package/dist/sandbox-cli.js +1 -4
  21. package/dist/scope.js +23 -0
  22. package/dist/serve/common.js +22 -1
  23. package/dist/serve/routes-chat.js +21 -1
  24. package/dist/serve/routes-memory.js +31 -2
  25. package/dist/serve/routes-meta.js +34 -6
  26. package/dist/think-cli.js +36 -0
  27. package/dist/thinking-wire.js +228 -0
  28. package/dist/thinking.js +142 -0
  29. package/dist/token-display.js +10 -7
  30. package/dist/tools/todo.js +22 -8
  31. package/dist/tui/App.js +36 -8
  32. package/dist/tui/InputBar.js +109 -36
  33. package/dist/tui/MessageList.js +53 -22
  34. package/dist/tui/StatusBar.js +7 -3
  35. package/dist/tui/ThinkPicker.js +77 -0
  36. package/dist/tui/bracketed-paste.js +37 -0
  37. package/dist/tui/caret-pos.js +10 -8
  38. package/dist/tui/index.js +7 -1
  39. package/dist/tui/layout.js +17 -0
  40. package/dist/tui/overlay-input.js +12 -0
  41. package/dist/tui/paste-draft.js +173 -0
  42. package/dist/tui/selection.js +8 -2
  43. package/dist/tui/slash-commands.js +18 -1
  44. package/dist/tui/slash-handler.js +61 -17
  45. package/dist/tui/text-width.js +6 -6
  46. package/dist/tui-chat.js +63 -7
  47. package/docs/API.md +50 -4
  48. package/docs/superpowers/plans/2026-08-23-input-paste-attachments.md +475 -0
  49. package/docs/superpowers/plans/2026-08-23-thinking-wire-profile.md +450 -0
  50. package/docs/superpowers/specs/2026-08-23-input-paste-attachments-design.md +174 -0
  51. package/docs/superpowers/specs/2026-08-23-thinking-wire-profile-design.md +140 -0
  52. package/package.json +1 -1
  53. package/skills/self-config/SKILL.md +5 -4
  54. package/skills/self-config/reference.md +10 -5
@@ -0,0 +1,450 @@
1
+ # 思考线协议适配 — 实现计划
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** 用户仍只配置 `off|low|medium|high|max`;发出 chat 请求时按 `(host, model)` 选择 `thinking.type` / `reasoning_effort` 的线协议。MiniMax 启发式先发 `adaptive`;其余若 400 且错误可解析,静默改 profile、同一请求只重试一次,并缓存 7 天。
6
+
7
+ **Architecture:** 用户意图留在 `ThinkingRequestBody`(ALS)。新建 `thinking-wire.ts`:启发式、学习缓存、`parseThinkingWireHint`、`fetchWithThinkingWire`。`applyThinkingToChatBody` 增加 profile 参数。`modelFetch` 改为 `fetchWithThinkingWire(inner, …).then(foldReasoningInChatResponse)`。Lonae 目录不改。
8
+
9
+ **Tech Stack:** TypeScript, bun:test
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-08-23-thinking-wire-profile-design.md`
12
+
13
+ 测试命令(`packages/min-agent` 下):`bun test tests/<file>`;类型:`npx tsc --noEmit`;仓库根目录:`bun run typecheck`、`bun run check`。
14
+
15
+ ---
16
+
17
+ ## 文件结构
18
+
19
+ | 文件 | 职责 |
20
+ |------|------|
21
+ | `src/thinking-wire.ts`(新) | profile 类型、默认值、缓存键、启发式、缓存、hint 解析、`fetchWithThinkingWire` |
22
+ | `src/thinking.ts`(改) | `applyThinkingToChatBody(body, spec, profile?)` |
23
+ | `src/provider.ts`(改) | `modelFetch` 走 `fetchWithThinkingWire` |
24
+ | `tests/thinking-wire.test.ts`(新) | 启发式、hint、缓存、fetch 重试 |
25
+ | `tests/thinking.test.ts`(改) | 三种 thinkingType × 开/关、effort omit |
26
+ | `skills/self-config/SKILL.md`(改) | 映射说明 + 缓存文件名 |
27
+ | `skills/self-config/reference.md`(改) | `MIN_AGENT_CONFIG_DIR` 旁注明缓存文件 |
28
+
29
+ `docs/API.md`、`README.md`、`/help`、Lonae catalog、`/think` UI **不改**。
30
+
31
+ ---
32
+
33
+ ## Task 1: thinking-wire 纯函数与缓存
34
+
35
+ **Files:**
36
+ - Create: `src/thinking-wire.ts`
37
+ - Test: `tests/thinking-wire.test.ts`
38
+
39
+ - [ ] **Step 1: 写失败测试**
40
+
41
+ ```ts
42
+ import { expect, test, afterEach } from "bun:test"
43
+ import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"
44
+ import os from "os"
45
+ import path from "path"
46
+ import {
47
+ DEFAULT_THINKING_WIRE,
48
+ THINKING_WIRE_TTL,
49
+ clearThinkingWireCache,
50
+ inferThinkingWire,
51
+ parseThinkingWireHint,
52
+ resolveThinkingWireProfile,
53
+ saveLearnedThinkingWire,
54
+ thinkingWireCacheKey,
55
+ } from "../src/thinking-wire.js"
56
+
57
+ function withIsolatedConfig(fn: () => void): void {
58
+ const home = realpathSync(mkdtempSync(path.join(os.tmpdir(), "min-agent-wire-")))
59
+ const old = process.env.MIN_AGENT_CONFIG_DIR
60
+ process.env.MIN_AGENT_CONFIG_DIR = home
61
+ clearThinkingWireCache()
62
+ try {
63
+ fn()
64
+ } finally {
65
+ clearThinkingWireCache()
66
+ if (old) process.env.MIN_AGENT_CONFIG_DIR = old
67
+ else delete process.env.MIN_AGENT_CONFIG_DIR
68
+ rmSync(home, { recursive: true, force: true })
69
+ }
70
+ }
71
+
72
+ afterEach(() => clearThinkingWireCache())
73
+
74
+ test("cache key lowercases host and model", () => {
75
+ expect(thinkingWireCacheKey("Api.Example.COM", "MiniMax-M3")).toBe("api.example.com::minimax-m3")
76
+ expect(thinkingWireCacheKey("", "m")).toBe("m")
77
+ })
78
+
79
+ test("inferThinkingWire prefers minimax then openai then default", () => {
80
+ expect(inferThinkingWire("minimax-m3", "api.console.go", "openai-compatible")).toEqual({
81
+ thinkingType: "adaptive-disabled",
82
+ effort: "reasoning_effort",
83
+ })
84
+ expect(inferThinkingWire("gpt-4", "api.minimax.io", undefined)).toEqual({
85
+ thinkingType: "adaptive-disabled",
86
+ effort: "reasoning_effort",
87
+ })
88
+ expect(inferThinkingWire("gpt-5", "api.openai.com", "openai")).toEqual({
89
+ thinkingType: "omit",
90
+ effort: "reasoning_effort",
91
+ })
92
+ expect(inferThinkingWire("deepseek-v3", "api.deepseek.com", "openai-compatible")).toEqual({
93
+ thinkingType: "enabled-disabled",
94
+ effort: "reasoning_effort",
95
+ })
96
+ })
97
+
98
+ test("parseThinkingWireHint reads MiniMax allowed list", () => {
99
+ const raw =
100
+ 'AI_APICallError: Error from provider (Console Go): Upstream request failed: [bad_request_error] invalid params, invalid thinking.type: "enabled" (allowed: adaptive, disabled) (2013)'
101
+ expect(parseThinkingWireHint(raw)).toEqual({ thinkingType: "adaptive-disabled" })
102
+ expect(parseThinkingWireHint('invalid thinking.type: "adaptive" (allowed: enabled, disabled)')).toEqual({
103
+ thinkingType: "enabled-disabled",
104
+ })
105
+ })
106
+
107
+ test("parseThinkingWireHint reads unknown parameter", () => {
108
+ expect(parseThinkingWireHint("Unknown parameter: 'thinking'")).toEqual({ thinkingType: "omit" })
109
+ expect(parseThinkingWireHint("unknown parameter: reasoning_effort")).toEqual({ effort: "omit" })
110
+ expect(parseThinkingWireHint("rate limit exceeded")).toBeNull()
111
+ })
112
+
113
+ test("resolveThinkingWireProfile prefers learned cache over heuristic", () => {
114
+ withIsolatedConfig(() => {
115
+ expect(resolveThinkingWireProfile("h.example", "minimax-m3").thinkingType).toBe("adaptive-disabled")
116
+ saveLearnedThinkingWire("h.example", "minimax-m3", {
117
+ ...DEFAULT_THINKING_WIRE,
118
+ thinkingType: "omit",
119
+ effort: "omit",
120
+ })
121
+ const learned = resolveThinkingWireProfile("h.example", "minimax-m3")
122
+ expect(learned.thinkingType).toBe("omit")
123
+ expect(learned.effort).toBe("omit")
124
+ const disk = JSON.parse(readFileSync(path.join(process.env.MIN_AGENT_CONFIG_DIR!, "thinking-wire-cache.json"), "utf-8"))
125
+ expect(disk["h.example::minimax-m3"].thinkingType).toBe("omit")
126
+ })
127
+ })
128
+
129
+ test("expired or wrong version cache is ignored", () => {
130
+ withIsolatedConfig(() => {
131
+ const file = path.join(process.env.MIN_AGENT_CONFIG_DIR!, "thinking-wire-cache.json")
132
+ writeFileSync(
133
+ file,
134
+ JSON.stringify({
135
+ "h::m": { version: 1, thinkingType: "omit", effort: "omit", timestamp: Date.now() - THINKING_WIRE_TTL - 1 },
136
+ "h::old": { version: 99, thinkingType: "omit", effort: "omit", timestamp: Date.now() },
137
+ }),
138
+ )
139
+ expect(resolveThinkingWireProfile("h", "m", "openai-compatible").thinkingType).toBe("enabled-disabled")
140
+ expect(resolveThinkingWireProfile("h", "old", "openai-compatible").thinkingType).toBe("enabled-disabled")
141
+ })
142
+ })
143
+ ```
144
+
145
+ 先跑确认失败:
146
+
147
+ ```bash
148
+ cd packages/min-agent && bun test tests/thinking-wire.test.ts
149
+ ```
150
+
151
+ - [ ] **Step 2: 实现**
152
+
153
+ `src/thinking-wire.ts`(与 `model-catalog.ts` 同样用 `getConfigDir()` + `atomicWriteFileSync`):
154
+
155
+ ```ts
156
+ export const THINKING_WIRE_VERSION = 1
157
+ export const THINKING_WIRE_TTL = 7 * 24 * 60 * 60 * 1000
158
+ export const DEFAULT_THINKING_WIRE = {
159
+ version: THINKING_WIRE_VERSION,
160
+ thinkingType: "enabled-disabled" as const,
161
+ effort: "reasoning_effort" as const,
162
+ }
163
+
164
+ export type ThinkingTypeMode = "enabled-disabled" | "adaptive-disabled" | "omit"
165
+ export type EffortMode = "reasoning_effort" | "omit"
166
+ export interface ThinkingWireProfile {
167
+ version: number
168
+ thinkingType: ThinkingTypeMode
169
+ effort: EffortMode
170
+ timestamp: number
171
+ }
172
+ export type ThinkingWireHint = Partial<Pick<ThinkingWireProfile, "thinkingType" | "effort">>
173
+ ```
174
+
175
+ - `thinkingWireCacheKey(host, modelId)`:都 `toLowerCase()`;host 空则只要 modelId
176
+ - `inferThinkingWire(modelId, host, providerType?)`:`minimax` 出现在 model 或 host(大小写不敏感)→ adaptive-disabled;`providerType === "openai"` → omit thinking;否则默认
177
+ - `resolveThinkingWireProfile(host, modelId, providerType?)`:有效学习缓存 → 否则启发式填 `timestamp: Date.now()`(启发式**不写盘**)
178
+ - `saveLearnedThinkingWire` 写内存 + `thinking-wire-cache.json`
179
+ - `clearThinkingWireCache()` 清内存(测试用);磁盘随 `MIN_AGENT_CONFIG_DIR` 隔离
180
+ - `parseThinkingWireHint(text)`:
181
+ - `/thinking\.type[\s\S]*?allowed:\s*([^)]+)/i`,列表按逗号拆、trim、去引号、小写;含 `adaptive` 且含 `disabled` → `adaptive-disabled`;含 `enabled` 且含 `disabled` → `enabled-disabled`
182
+ - `/unknown parameter[:\s]+['"`]?thinking['"`]?/i` → omit thinking(不要误匹配 `thinking.type` 那段里的 thinking)
183
+ - `/unknown parameter[:\s]+['"`]?reasoning_effort['"`]?/i` → omit effort
184
+ - 都不中 → `null`
185
+ - 未知 `version` 或过期 → 当未命中。损坏 JSON → `{}`
186
+
187
+ 本任务**不要**实现 fetch 包装。
188
+
189
+ - [ ] **Step 3: 测试通过并提交**
190
+
191
+ ```bash
192
+ cd packages/min-agent && bun test tests/thinking-wire.test.ts
193
+ git add src/thinking-wire.ts tests/thinking-wire.test.ts
194
+ git commit -m "feat: 增加思考线协议 profile 与错误 hint 解析"
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Task 2: applyThinkingToChatBody 按 profile 拼 body
200
+
201
+ **Files:**
202
+ - Modify: `src/thinking.ts`
203
+ - Modify: `tests/thinking.test.ts`
204
+
205
+ - [ ] **Step 1: 改测试**
206
+
207
+ 把现有「injects reasoning_effort and thinking.type」扩成显式 profile(缺省第三参 = `DEFAULT_THINKING_WIRE` 的 type/effort):
208
+
209
+ ```ts
210
+ import { DEFAULT_THINKING_WIRE } from "../src/thinking-wire.js"
211
+
212
+ test("applyThinkingToChatBody follows thinkingType and effort modes", () => {
213
+ const specOn = { reasoningEffort: "high", thinkingEnabled: true }
214
+ const specOff = { reasoningEffort: "none", thinkingEnabled: false }
215
+ expect(applyThinkingToChatBody({ model: "m" }, specOn)).toEqual({
216
+ model: "m",
217
+ reasoning_effort: "high",
218
+ thinking: { type: "enabled" },
219
+ })
220
+ expect(
221
+ applyThinkingToChatBody({ model: "m" }, specOn, { ...DEFAULT_THINKING_WIRE, thinkingType: "adaptive-disabled" }),
222
+ ).toEqual({
223
+ model: "m",
224
+ reasoning_effort: "high",
225
+ thinking: { type: "adaptive" },
226
+ })
227
+ expect(applyThinkingToChatBody({ model: "m" }, specOff, { ...DEFAULT_THINKING_WIRE, thinkingType: "adaptive-disabled" })).toEqual({
228
+ model: "m",
229
+ reasoning_effort: "none",
230
+ thinking: { type: "disabled" },
231
+ })
232
+ expect(applyThinkingToChatBody({ thinking: { type: "enabled" }, reasoning_effort: "x" }, specOn, {
233
+ ...DEFAULT_THINKING_WIRE,
234
+ thinkingType: "omit",
235
+ effort: "omit",
236
+ })).toEqual({})
237
+ })
238
+ ```
239
+
240
+ - [ ] **Step 2: 实现**
241
+
242
+ ```ts
243
+ export function applyThinkingToChatBody(
244
+ body: Record<string, unknown>,
245
+ spec: ThinkingRequestBody,
246
+ profile: Pick<ThinkingWireProfile, "thinkingType" | "effort"> = DEFAULT_THINKING_WIRE,
247
+ ): Record<string, unknown> {
248
+ const next: Record<string, unknown> = { ...body }
249
+ if (profile.effort === "omit") delete next.reasoning_effort
250
+ else next.reasoning_effort = spec.reasoningEffort
251
+ if (profile.thinkingType === "omit") delete next.thinking
252
+ else {
253
+ const on = profile.thinkingType === "adaptive-disabled" ? "adaptive" : "enabled"
254
+ next.thinking = { type: spec.thinkingEnabled ? on : "disabled" }
255
+ }
256
+ return next
257
+ }
258
+ ```
259
+
260
+ `ThinkingRequestBody` 不变。`resolveThinkingRequest` / `toWireEffort` 不变。
261
+
262
+ - [ ] **Step 3: 测试通过并提交**
263
+
264
+ ```bash
265
+ cd packages/min-agent && bun test tests/thinking.test.ts
266
+ git add src/thinking.ts tests/thinking.test.ts
267
+ git commit -m "feat: 按 wire profile 组装思考请求体"
268
+ ```
269
+
270
+ ---
271
+
272
+ ## Task 3: fetch 400 改参并只重试一次
273
+
274
+ **Files:**
275
+ - Modify: `src/thinking-wire.ts`
276
+ - Modify: `tests/thinking-wire.test.ts`
277
+
278
+ - [ ] **Step 1: 写失败测试**
279
+
280
+ 在 `thinking-wire.test.ts` 增加(仍用隔离 `MIN_AGENT_CONFIG_DIR` + `thinkingRequestStore.run`):
281
+
282
+ ```ts
283
+ import { thinkingRequestStore } from "../src/thinking.js"
284
+ import { fetchWithThinkingWire } from "../src/thinking-wire.js"
285
+
286
+ test("fetchWithThinkingWire retries once when 400 names adaptive", async () => {
287
+ withIsolatedConfig(() => {
288
+ const bodies: string[] = []
289
+ const inner: FetchLike = async (_input, init) => {
290
+ bodies.push(String(init?.body ?? ""))
291
+ if (bodies.length === 1) {
292
+ return new Response(
293
+ 'invalid thinking.type: "enabled" (allowed: adaptive, disabled)',
294
+ { status: 400 },
295
+ )
296
+ }
297
+ return new Response("{}", { status: 200 })
298
+ }
299
+ return thinkingRequestStore.run({ reasoningEffort: "high", thinkingEnabled: true }, async () => {
300
+ const res = await fetchWithThinkingWire(inner, "https://gw.example/v1/chat/completions", {
301
+ method: "POST",
302
+ body: JSON.stringify({ model: "other-model", messages: [] }),
303
+ })
304
+ expect(res.status).toBe(200)
305
+ expect(bodies).toHaveLength(2)
306
+ expect(JSON.parse(bodies[0]!).thinking).toEqual({ type: "enabled" })
307
+ expect(JSON.parse(bodies[1]!).thinking).toEqual({ type: "adaptive" })
308
+ })
309
+ })
310
+ })
311
+
312
+ test("fetchWithThinkingWire does not retry unrelated 400", async () => {
313
+ withIsolatedConfig(() => {
314
+ let n = 0
315
+ const inner: FetchLike = async () => {
316
+ n++
317
+ return new Response("insufficient quota", { status: 400 })
318
+ }
319
+ return thinkingRequestStore.run({ reasoningEffort: "high", thinkingEnabled: true }, async () => {
320
+ const res = await fetchWithThinkingWire(inner, "https://gw.example/v1/chat/completions", {
321
+ method: "POST",
322
+ body: JSON.stringify({ model: "m" }),
323
+ })
324
+ expect(res.status).toBe(400)
325
+ expect(await res.text()).toBe("insufficient quota")
326
+ expect(n).toBe(1)
327
+ })
328
+ })
329
+ })
330
+ ```
331
+
332
+ `withIsolatedConfig` 若目前是同步的,改成支持 `fn: () => void | Promise<void>` 并 `await`。`FetchLike` 从 `fetch-timeout.js` 导入。
333
+
334
+ 再加一条:hint 相对当前 profile 无变化(已经是 adaptive,400 仍说 allowed adaptive)→ 不重试。可用 minimax 模型名让第一次就发 adaptive,inner 只 400 一次。
335
+
336
+ - [ ] **Step 2: 实现 `fetchWithThinkingWire`**
337
+
338
+ 签名:
339
+
340
+ ```ts
341
+ export async function fetchWithThinkingWire(
342
+ inner: FetchLike,
343
+ input: string | URL | Request,
344
+ init?: RequestInit,
345
+ opts?: { providerType?: string },
346
+ ): Promise<Response>
347
+ ```
348
+
349
+ 逻辑:
350
+
351
+ 1. `spec = thinkingRequestStore.getStore()`;无 spec 或 `init.body` 非字符串 → `return inner(input, init)`
352
+ 2. JSON.parse body;非对象 → `return inner(input, init)`
353
+ 3. `host` 从 `input` 解析 `URL.hostname`(`Request` 用 `.url`);失败则 `""`
354
+ 4. `modelId` = body.model 字符串,否则 `""`
355
+ 5. `providerType` = `opts?.providerType ?? getActiveProvider(getEffectiveConfig())?.type`
356
+ 6. `profile = resolveThinkingWireProfile(host, modelId, providerType)`
357
+ 7. `firstInit = { ...init, body: JSON.stringify(applyThinkingToChatBody(parsed, spec, profile)) }`
358
+ 8. `first = await inner(input, firstInit)`;`first.status !== 400` → `return first`
359
+ 9. `text = await first.text()`;`hint = parseThinkingWireHint(text)`
360
+ 10. 合并 hint:`next = { ...profile, ...hint, version: 1, timestamp: Date.now() }`;`thinkingType` 与 `effort` 都与当前相同 → `return rebuild400(first, text)`
361
+ 11. `saveLearnedThinkingWire(host, modelId, next)`;第二次 `inner(input, { ...init, body: JSON.stringify(applyThinkingToChatBody(parsed, spec, next)) })`,**不再**根据第二次状态重试
362
+ 12. `rebuild400`:`new Response(text, { status: 400, statusText: first.statusText, headers: first.headers })`
363
+
364
+ `getActiveProvider` 在 `thinking-wire.ts` 引用 `config.js` 可以;测试不依赖真实 config(默认走 enabled-disabled)。
365
+
366
+ - [ ] **Step 3: 测试通过并提交**
367
+
368
+ ```bash
369
+ cd packages/min-agent && bun test tests/thinking-wire.test.ts
370
+ git add src/thinking-wire.ts tests/thinking-wire.test.ts
371
+ git commit -m "feat: 思考字段 400 时改 profile 并只重试一次"
372
+ ```
373
+
374
+ ---
375
+
376
+ ## Task 4: 接入 modelFetch
377
+
378
+ **Files:**
379
+ - Modify: `src/provider.ts`
380
+
381
+ - [ ] **Step 1: 接线**
382
+
383
+ 删除 `withThinkingBody`。`modelFetch`:
384
+
385
+ ```ts
386
+ return (input, init) => fetchWithThinkingWire(inner, input, init).then(foldReasoningInChatResponse)
387
+ ```
388
+
389
+ 从 `thinking-wire.js` 导入 `fetchWithThinkingWire`;`thinking.ts` 不再被 `provider.ts` 直接用来拼 body(ALS 仍由 agent 设置,fetch 内部读)。
390
+
391
+ 现有 `tests/provider.test.ts` 应仍通过(不发真实请求)。
392
+
393
+ - [ ] **Step 2: 测试并提交**
394
+
395
+ ```bash
396
+ cd packages/min-agent && bun test tests/provider.test.ts tests/thinking-wire.test.ts tests/thinking.test.ts
397
+ git add src/provider.ts
398
+ git commit -m "feat: 模型请求走思考线协议适配"
399
+ ```
400
+
401
+ ---
402
+
403
+ ## Task 5: self-config 文档
404
+
405
+ **Files:**
406
+ - Modify: `skills/self-config/SKILL.md`
407
+ - Modify: `skills/self-config/reference.md`
408
+
409
+ - [ ] **Step 1: 文案**
410
+
411
+ `SKILL.md` 思考段落里「The request sends `reasoning_effort` plus `thinking.type`」改为:请求会按当前模型和网关映射思考字段(例如 MiniMax 用 `adaptive`/`disabled`,官方 OpenAI 不发 `thinking` 对象);学到的映射缓存在 `~/.min-agent/thinking-wire-cache.json`(7 天)。官方 OpenAI 仍把 `max` 映射为 `xhigh`。不要写 400 重试、fetch 包装、profile 类型名。
412
+
413
+ `reference.md` 环境变量表 `MIN_AGENT_CONFIG_DIR` 一行改为同时提到目录缓存与思考映射缓存,例如:`Replaces ~/.min-agent`(含 `model-catalog-cache.json`、`thinking-wire-cache.json`)。
414
+
415
+ README / API.md / `/help` 不改。
416
+
417
+ - [ ] **Step 2: 提交**
418
+
419
+ ```bash
420
+ git add packages/min-agent/skills/self-config/SKILL.md packages/min-agent/skills/self-config/reference.md
421
+ git commit -m "docs: 说明思考请求按模型映射线协议"
422
+ ```
423
+
424
+ (在仓库根目录 add 时用上表路径;在 `packages/min-agent` 内则用 `skills/self-config/…`。)
425
+
426
+ ---
427
+
428
+ ## Task 6: 回归
429
+
430
+ - [ ] **Step 1: 全量相关测试与检查**
431
+
432
+ ```bash
433
+ cd packages/min-agent && bun test tests/thinking.test.ts tests/thinking-wire.test.ts tests/provider.test.ts
434
+ # 仓库根目录
435
+ bun run typecheck
436
+ bun run check
437
+ ```
438
+
439
+ - [ ] **Step 2: 若有格式问题则 `bun run format` 后再 check,不要 `--no-verify`**
440
+
441
+ ---
442
+
443
+ ## 手工验收
444
+
445
+ 在 `packages/min-agent` 下用 MiniMax-M3(思考非 off)发一句 `hello`:
446
+
447
+ 1. 不应再出现 `invalid thinking.type: "enabled"`
448
+ 2. 状态栏仍是「思考 high」这类用户档位,不出现 `adaptive`
449
+ 3. 换一个会 400 且错误带 `allowed: adaptive, disabled` 的网关:第一次失败后同一句应自动成功;`~/.min-agent/thinking-wire-cache.json` 出现对应 host+model 条目
450
+ 4. `/think off` 发送 `disabled`,对话仍可用
@@ -0,0 +1,174 @@
1
+ # 输入框粘贴图片与文本片段设计
2
+
3
+ 日期: 2026-08-23
4
+ 状态: 已批准(方案 1:输入框内原子标记)
5
+
6
+ ## 问题
7
+
8
+ 交互会话的输入框不能把剪贴板图片挂到当前草稿:贴图只能 `/paste`(立刻发送)或 `/attach <路径>`。多行文本粘贴会整段铺进输入框,末尾换行还可能被当成 Enter 误发送。
9
+
10
+ ## 目标
11
+
12
+ 1. 剪贴板图片用 Ctrl+V 插到光标处,显示为 `[图片 #N]`,写完说明再 Enter 发送
13
+ 2. 一次粘贴只要超过一行,收成 `[粘贴 #N · X 行]`,发送时带上全文
14
+ 3. 单行粘贴仍直接插入
15
+ 4. 标记在输入框里是原子块:左右移、退格、Delete、点击都不拆开
16
+ 5. 只有附件、没打字也可以发送
17
+
18
+ ## 不做
19
+
20
+ - 不改 HTTP `/v1/paste`,不新增配置项,不改 `skills/self-config`
21
+ - 不把 Cmd+V 从终端抢过来(macOS 贴图用 Ctrl+V)
22
+ - 不把附件正文写入 `input-history.json`
23
+ - 不做拖拽文件进终端、不做输入框内 Shift 选区
24
+ - `/paste [t]` 仍是立刻发送剪贴板图片,不改成挂草稿
25
+
26
+ ## 设计决策
27
+
28
+ | 决策 | 结论 |
29
+ |------|------|
30
+ | 呈现 | 草稿字符串里每个附件一个私有区码点;屏幕上画成标记 |
31
+ | 文本门槛 | 规范化后仍含换行 → 片段;否则当普通字插入 |
32
+ | 贴图快捷键 | Ctrl+V 读系统剪贴板;无图时静默 |
33
+ | `/paste` | 保持立刻发送 |
34
+ | 斜杠命令 | `display` 去空白后以 `/` 开头则走现有命令处理,附件不进入对话 |
35
+ | 历史 | 只存展开后的短标记字面量,不存图片/长文;翻历史不会恢复附件 |
36
+
37
+ ---
38
+
39
+ ## 1. 数据模型
40
+
41
+ `InputBar` 继续用 `{ value, caretIndex }`。`caretIndex` 仍是 `Array.from(value)` 的码点下标。
42
+
43
+ 每个附件占用 **一个** Unicode 私有区码点,从 `U+E000` 起按插入顺序分配,本条草稿内不复用。旁路表:
44
+
45
+ ```ts
46
+ type DraftAttachment =
47
+ | { kind: "image"; id: number; data: Buffer; mimeType: string }
48
+ | { kind: "snippet"; id: number; text: string; lineCount: number }
49
+ ```
50
+
51
+ `id` 按类型分开从 1 递增:`[图片 #1]` 与 `[粘贴 #1 · 3 行]` 可以同时存在。
52
+
53
+ 屏幕标记(用户可见,无实现细节):
54
+
55
+ - 图片:`[图片 #${id}]`
56
+ - 片段:`[粘贴 #${id} · ${lineCount} 行]`
57
+
58
+ 未在旁路表中的私有区字符按宽 1 的 `�` 绘制,不当附件。
59
+
60
+ 每次编辑后扫描 `value`:旁路表里码点已不在 `value` 中的条目删除(退格、Ctrl+U/K/W、Ctrl+C 清空都走这条)。Ctrl+C 清空或提交成功后:`value` 清空,旁路表清空,两个计数器回到 1。
61
+
62
+ 折行与点击:该码点是一个 cluster,显示宽度为 `min(displayWidth(标记), 输入区宽度)`。过宽时单独占一行,绘制时末尾用 `…` 截断;点击该行任一单元格都落在这一个码点上。硬件光标列用展开后的显示宽度计算,与现有 IME 锚点一致。
63
+
64
+ ---
65
+
66
+ ## 2. 文本粘贴
67
+
68
+ 挂载输入框时打开 bracketed paste(`CSI ? 2004 h`),卸载时关闭(`CSI ? 2004 l`),与现有鼠标协议一起写 stdout。
69
+
70
+ 在 InputBar 已有的 raw stdin 监听里拼包:`ESC[200~` 起、`ESC[201~` 止。包收齐前 `useInput` 丢掉这段输入(含 Ink 剥掉 ESC 后的 `[200~` / `[201~` 碎片),避免插两遍,也避免粘贴里的换行走现有「末尾 `\r`/`\n` 当 Enter」逻辑。
71
+
72
+ 粘贴正文处理(纯函数,可单测):
73
+
74
+ 1. `\r\n` 与 `\r` 全部变成 `\n`
75
+ 2. 去掉**末尾一个** `\n`(编辑器复制常带)
76
+ 3. 若仍含 `\n`:分配片段码点,插入光标处,`lineCount = text.split("\n").length`
77
+ 4. 否则:当普通文本插入(与打字相同)
78
+
79
+ 片段正文上限 **1_000_000** 个 UTF-16 码元。超过则不插入,系统提示 `粘贴内容过大,未附加`。
80
+
81
+ 单行过长仍插入,不收成片段。
82
+
83
+ ---
84
+
85
+ ## 3. 图片粘贴
86
+
87
+ `useInput` 在现有 `Ctrl+B` 之后、吞掉其它 Ctrl 组合之前处理 `key.ctrl && input === "v"`:调用已有 `getClipboardImage()`。
88
+
89
+ - 有图且 `data.length <=` 现有 `MAX_IMAGE_BYTES`(5MB):插入图片码点,MIME 用剪贴板返回值
90
+ - 有图但超过 5MB:提示 `图片超过 5MB,未附加`,不插入
91
+ - 无图:静默返回(不提示),避免想贴字时误按 Ctrl+V 刷警告
92
+
93
+ Mac 上 Cmd+V 仍由终端注入文字。README 与 `/help` 写明贴图用 Ctrl+V。
94
+
95
+ ---
96
+
97
+ ## 4. 编辑
98
+
99
+ 因为每个标记是一个码点,现有 `moveCaretHorizontal` / `backspaceAt` / `deleteAt` 已整块移动或删除。`Ctrl+W` / `Ctrl+K` / `Ctrl+U` 按现有「删一段字符串」处理,事后扫描丢掉孤儿附件。
100
+
101
+ 点击:先按展开后的显示列命中 cluster,再映射回 `value` 码点下标;点在标记上时 caret 落在该码点前(与现在点在某字前一致),再按右键一次跳过整块。
102
+
103
+ Enter:`display` 去空白后非空,**或**旁路表非空,即可发送。发送后清空草稿与表。
104
+
105
+ ---
106
+
107
+ ## 5. 提交与展示
108
+
109
+ `onSubmit` 改为接收一次提交的结构化结果:
110
+
111
+ ```ts
112
+ type SubmittedPrompt = {
113
+ display: string
114
+ content: string | Array<{ type: "text"; text: string } | { type: "image"; image: Buffer; mimeType: string }>
115
+ }
116
+ ```
117
+
118
+ `display`:把 `value` 里的附件码点换成标记文案,用于对话区用户气泡和输入历史。
119
+
120
+ `content` 按 `value` 从左到右展开:
121
+
122
+ - 连续普通字合成一个 `text` part
123
+ - 片段换成一个 `text` part,正文前后各单独一行分隔:`--- 粘贴 #${id} ---`
124
+ - 图片一个 `image` part(`Buffer` + `mimeType`)
125
+
126
+ 若没有任何图片 part,`content` 收成单个字符串(与现在 `messages.push({ content: text })` 兼容)。有图片则为数组。
127
+
128
+ `tui-chat`:`addMessage` 用 `display`;`messages.push({ role: "user", content })` 用 `content`。`lastUserMessage` 存 `display`。
129
+
130
+ `display` 去空白后以 `/` 开头:原样交给现有 `handleSlashCommand`,旁路表丢弃、不写入 `messages`(避免 `/help` 误带一张图)。否则按上面的 `content` 入列。
131
+
132
+ 超限提示不进输入框正文:`InputBar` 增加 `onNotice(text: string)`,`tui-chat` 接到现有系统消息通道(与 `/paste` 失败提示同一处)。
133
+
134
+ 输入历史:`pushInput` 写入 `display`(短标记,无二进制、无片段全文)。从历史上召回的标记是普通字符,旁路表为空。
135
+
136
+ ---
137
+
138
+ ## 6. 文件与同步
139
+
140
+ | 文件 | 改动 |
141
+ |------|------|
142
+ | `src/tui/paste-draft.ts`(新) | 规范化粘贴、单行/多行判定、码点分配、展开 `display`/`content`、孤儿回收;无 React |
143
+ | `src/tui/bracketed-paste.ts`(新) | 从 stdin 块里扫描 bracketed paste 包 |
144
+ | `src/tui/caret-pos.ts` / `text-width.ts` | cluster 宽度可注入「此码点显示宽」;`visualRows` / 点击换算认识附件宽 |
145
+ | `src/tui/InputBar.tsx` | 打开/关闭 bracketed paste、粘贴包、Ctrl+V、旁路表、`onNotice`、按新规则提交 |
146
+ | `src/tui/index.tsx` / `App.tsx` | `onSubmit` 改为 `SubmittedPrompt`;透传 `onNotice` |
147
+ | `src/tui-chat.ts` | 按 `display`/`content` 入列;斜杠判定见上;`onNotice` 走系统消息 |
148
+ | `src/tui/slash-handler.ts` | `/help` 快捷键增加 Ctrl+V 贴图、多行粘贴收成片段 |
149
+ | `README.md` | 中英快捷键各补一句 |
150
+ | `docs/API.md` | 不改(TUI 便利,协议不变) |
151
+
152
+ `/paste`、`/attach`、CLI `-i` 行为不变。
153
+
154
+ ---
155
+
156
+ ## 7. 测试
157
+
158
+ 纯函数(`tests/paste-draft.test.ts`、`tests/bracketed-paste.test.ts`,必要时扩 `tests/caret-pos.test.ts`):
159
+
160
+ - 单行;`a\nb`;`a\nb\n` 去掉末尾换行后仍是两行片段;仅 `hello\n` → 单行插入
161
+ - 片段展开顺序:字 + 图 + 片段不错位;无图时 `content` 为字符串
162
+ - 删除码点后旁路表回收;清空后计数从 1 再起
163
+ - 超 1_000_000 码元拒绝;标记过宽时点击仍落在该码点
164
+ - bracketed paste 扫描:跨 chunk、内含 `\n`/`\r`、无结束符时保持缓冲
165
+
166
+ `InputBar` 不测 Ink 渲染。stdin 夹具覆盖:粘贴多行不提交;粘贴中的 `useInput` 碎片不插入。
167
+
168
+ ## 错误文案
169
+
170
+ | 情况 | 文案 |
171
+ |------|------|
172
+ | 图片 > 5MB | `图片超过 5MB,未附加` |
173
+ | 片段 > 1_000_000 码元 | `粘贴内容过大,未附加` |
174
+ | Ctrl+V 无图 | (无文案) |