thincoder 0.12.58 → 0.12.59

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 (114) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +8 -0
  4. package/package.json +1 -1
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +34 -1
  7. package/src/advisor/run.mjs +89 -51
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +91 -14
  10. package/src/agent/helpers.mjs +35 -4
  11. package/src/agent/setup.mjs +90 -19
  12. package/src/agent/spawn-child.mjs +25 -0
  13. package/src/agent-tools/advisor.mjs +24 -2
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +2 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +467 -193
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +161 -125
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +2 -1
  31. package/src/cli/permission.mjs +8 -1
  32. package/src/config.mjs +5 -0
  33. package/src/context.mjs +87 -27
  34. package/src/distill.mjs +19 -1
  35. package/src/escape.mjs +6 -4
  36. package/src/log.mjs +195 -0
  37. package/src/memory/code-sync.mjs +1 -1
  38. package/src/memory/core.mjs +126 -0
  39. package/src/memory/docs.mjs +196 -87
  40. package/src/memory.mjs +1 -1
  41. package/src/model-specs.mjs +15 -1
  42. package/src/prompts/advisor-design.md +46 -0
  43. package/src/prompts/advisor-round1.md +49 -2
  44. package/src/prompts/advisor-round2.md +47 -0
  45. package/src/prompts/advisor-round3.md +47 -0
  46. package/src/prompts/coder.md +22 -0
  47. package/src/prompts/consult-base.md +13 -0
  48. package/src/prompts/discipline.md +10 -5
  49. package/src/prompts/eng-coder.md +2 -2
  50. package/src/prompts/engineering-sub.md +23 -1
  51. package/src/prompts/engineering.md +106 -56
  52. package/src/prompts/explore.md +1 -2
  53. package/src/prompts/main.md +11 -6
  54. package/src/prompts/methodology-template.md +14 -0
  55. package/src/prompts/system.md +4 -2
  56. package/src/provider/core.mjs +56 -2
  57. package/src/tools/apply_patch.md +3 -1
  58. package/src/tools/bash.md +1 -1
  59. package/src/tools/delete.md +1 -0
  60. package/src/tools/edit-batch.mjs +31 -43
  61. package/src/tools/edit-diff.mjs +265 -0
  62. package/src/tools/edit.md +10 -8
  63. package/src/tools/execute.md +7 -7
  64. package/src/tools/execute.mjs +24 -20
  65. package/src/tools/file.mjs +18 -68
  66. package/src/tools/file_ops.md +2 -1
  67. package/src/tools/get_current_time.md +3 -1
  68. package/src/tools/hashline_edit.md +2 -0
  69. package/src/tools/index.mjs +3 -2
  70. package/src/tools/insert_after.md +2 -1
  71. package/src/tools/lint.md +2 -0
  72. package/src/tools/lsp.md +4 -1
  73. package/src/tools/patch.mjs +84 -13
  74. package/src/tools/pdf-parse-text.mjs +497 -0
  75. package/src/tools/pdf-parse-xref.mjs +499 -0
  76. package/src/tools/pdf.mjs +155 -0
  77. package/src/tools/question.md +2 -1
  78. package/src/tools/read.md +1 -0
  79. package/src/tools/read_pdf.md +21 -0
  80. package/src/tools/repomap.mjs +1 -1
  81. package/src/tools/shared.mjs +4 -12
  82. package/src/tools/system.mjs +6 -21
  83. package/src/tools/tree.md +2 -1
  84. package/src/tools/web.mjs +5 -3
  85. package/src/tools/websearch.md +2 -1
  86. package/src/tools/write.md +2 -0
  87. package/src/traces/trace-store.mjs +224 -0
  88. package/src/tui/agent-turn.mjs +385 -22
  89. package/src/tui/clipboard.mjs +15 -4
  90. package/src/tui/cmd-config.mjs +29 -9
  91. package/src/tui/cmd-extract.mjs +1 -1
  92. package/src/tui/cmd-mcp.mjs +9 -0
  93. package/src/tui/cmd-think.mjs +1 -1
  94. package/src/tui/index.mjs +29 -95
  95. package/src/tui/interaction.mjs +13 -2
  96. package/src/tui/key-handler.mjs +105 -155
  97. package/src/tui/key-modes.mjs +215 -0
  98. package/src/tui/layout.mjs +22 -1
  99. package/src/tui/mouse.mjs +40 -0
  100. package/src/tui/pickers.mjs +11 -3
  101. package/src/tui/render-conversation.mjs +13 -161
  102. package/src/tui/render-frame.mjs +27 -10
  103. package/src/tui/render-loop.mjs +4 -1
  104. package/src/tui/render-segments.mjs +165 -0
  105. package/src/tui/startup.mjs +36 -0
  106. package/src/tui/subagent-blocks.mjs +322 -144
  107. package/src/tui/subagent-panel.mjs +88 -13
  108. package/src/tui/tool-args.mjs +10 -2
  109. package/src/tui/tool-events.mjs +132 -100
  110. package/src/tui/update-notice.mjs +72 -0
  111. package/src/tui/wizard.mjs +36 -6
  112. package/src/agent-tools/escalate.mjs +0 -179
  113. package/src/agent-tools/subagent-check.mjs +0 -107
  114. package/src/tools/exec-prelude.mjs +0 -84
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { createAgent, runAgent, readonlyToolNames } from "../agent.mjs"
15
15
  import { resolveChildProvider } from "./subagent.mjs"
16
+ import { logEvent, errText } from "../log.mjs"
16
17
  import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort } from "../agent/spawn-child.mjs"
17
18
 
18
19
  // Named consult defaults (consult P2, 2026-08-30).
@@ -27,7 +28,7 @@ function consultLabel(m) {
27
28
  /** Narrow the configured consultModels pool to a requested subset.
28
29
  * Each selector is "provider:model", a bare provider name, or a bare model name
29
30
  * (case-insensitive). A trailing " (effort)" suffix is tolerated (round2 复核
30
- * 对齐 escalate.mjs:withPool 列表会带 " (high)" 后缀,模型照抄应可匹配).
31
+ * 对齐 escalate 动作(subagent action:"escalate"):withPool 列表会带 " (high)" 后缀,模型照抄应可匹配).
31
32
  * Returns { models, error } — error set when a selector matches
32
33
  * nothing (surface the typo rather than silently dropping it). Absent/empty selectors
33
34
  * → the full pool. */
@@ -145,6 +146,21 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
145
146
  }
146
147
  let watchdog = armWatchdog()
147
148
  const label = consultLabel(m)
149
+ // LOGGING(LOGGING.md):child:*(consult)——logSettle 在函数作用域声明(外层 catch
150
+ // 覆盖 spawn 前失败路径);spawn 事件在 relay 建立后发射(logArmed 翻转——provider/
151
+ // 创建失败 = 从未启动,不落子事件、错误仅经 settleChild 进会话)。
152
+ let childLogId = null
153
+ let logT0 = 0
154
+ let logArmed = false
155
+ let logDone = false
156
+ const logSettle = (kind, payload) => {
157
+ if (!logArmed || logDone || !childLogId) return
158
+ logDone = true
159
+ const ms = Date.now() - logT0
160
+ const base = { role: "consult", id: childLogId, ms }
161
+ if (kind === "ok" || kind === "partial") logEvent("child:done", { ...base, kind })
162
+ else logEvent("child:error", { ...base, err: errText(payload, 200) })
163
+ }
148
164
  try {
149
165
  // Provider resolution: consultModels entries are { provider, model, effort? } — resolve
150
166
  // via the subagent's provider resolver ("provider:model" handles cross-provider picks).
@@ -156,9 +172,10 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
156
172
  }
157
173
  // Clamp the pool's effort to the model's reasoningEffortEnum — an out-of-enum
158
174
  // value makes provider/core throw on EVERY chat call (candidate dies on takeoff).
159
- // Symmetric with escalate.mjs; 2026-08-16 a real consult died on qwen3.8-max
160
- // effort "high" (enum is xhigh/medium/low). Out-of-enum: DROP the effort entirely
161
- // (the provider preset default may ALSO be out-of-enum for this override model).
175
+ // Symmetric with the escalate action (subagent action:"escalate"); 2026-08-16
176
+ // a real consult died on qwen3.8-max effort "high" (enum is xhigh/medium/low).
177
+ // Out-of-enum: DROP the effort entirely (the provider preset default may ALSO be
178
+ // out-of-enum for this override model).
162
179
  clampEffort(provider, m.model, m.effort)
163
180
 
164
181
  // Read-only consultant: filter the parent tool set down to readonly tools + main_history.
@@ -183,6 +200,12 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
183
200
  // prefix (same channel subagent uses — parallel consultants stay independent) +
184
201
  // onToolOutput passthrough so the consultant's tool output lands in its TUI block.
185
202
  const relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
203
+ // LOGGING:arm(spawn 事件——relay 建立后;子内事件归属 _logId)
204
+ childLogId = relayPrefix.slice(0, -1)
205
+ child._logId = childLogId
206
+ logT0 = Date.now()
207
+ logArmed = true
208
+ logEvent("child:spawn", { role: "consult", id: childLogId, kind: "consult" })
186
209
  const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
187
210
  let declined = false // review #1: guard against double-settle when onDeclined fired
188
211
 
@@ -216,6 +239,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
216
239
  onDeclined: (e) => {
217
240
  declined = true
218
241
  settleChild(session, id, label, false, `turn cap reached (${e.turn} turns) — stopped, diagnosis may be partial`)
242
+ logSettle("partial", null)
219
243
  return undefined
220
244
  },
221
245
  },
@@ -224,18 +248,23 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
224
248
  // settling again here would push a phantom empty success reply and decrement
225
249
  // `pending` twice (negative pending → consult_check's two exits both
226
250
  // unreachable → permanent block until user abort).
227
- if (!declined) settleChild(session, id, label, true, String(result ?? ""))
251
+ if (!declined) {
252
+ settleChild(session, id, label, true, String(result ?? ""))
253
+ logSettle("ok", null)
254
+ }
228
255
  } catch (e) {
229
256
  // Runner errors (incl. the watchdog's abort) settle as a failed reply — the
230
257
  // continue/declined paths are already handled inside runWithContinue.
231
258
  const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : e?.message ?? String(e)
232
259
  settleChild(session, id, label, false, note)
260
+ logSettle("error", note)
233
261
  }
234
262
  } catch (e) {
235
263
  // Errors BEFORE the runner (provider resolution, createAgent) or a throwing
236
264
  // continue-prompt settle as failed replies — the runner's own errors are already
237
265
  // handled inside the loop above.
238
266
  settleChild(session, id, label, false, e?.message ?? String(e))
267
+ logSettle("error", e?.message ?? String(e))
239
268
  } finally {
240
269
  clearTimeout(watchdog)
241
270
  }
@@ -326,6 +355,7 @@ export const consultCheckTool = {
326
355
  "replies are coming.\n" +
327
356
  "Call it ALONE in a turn — do NOT batch it with calls that depend on its reply (readonly tools run in parallel).\n" +
328
357
  "Replies arrive in arrival order: call it repeatedly (n = 1, 2, 3, …) until done is true.\n" +
358
+ "Returns JSON: {reply, model, failedReply, received, failed, terminated, total, done} for a reply — or {done: true, received, failed, total} when none are left.\n" +
329
359
  "Parameters:\n" +
330
360
  "- id (required): the consult id from consult_start\n" +
331
361
  "- n (required): the 1-based read number for this consult — pass 1 on the first check, 2 on the next, and so on. It exists so consecutive checks are distinct tool calls (loop detectors) and the transcript reads as a sequence.",
@@ -382,7 +412,8 @@ export const consultStopTool = {
382
412
  sideEffectExempt: true,
383
413
  description:
384
414
  "Terminate the still-running consultations of a session once a reply is good enough — saves tokens and time. " +
385
- "Already-answered replies stay available for consult_check.\n" +
415
+ "Already-answered replies stay available for consult_check. " +
416
+ "Returns JSON {stopped: <n>, abandoned: <pending count>} — or {error: \"unknown consult id\"}.\n" +
386
417
  "Parameters:\n" +
387
418
  "- id (required): the consult id from consult_start\n" +
388
419
  "- n (required): incrementing call number for this consult (next value after the last consult_check/consult_stop) — keeps repeated calls distinct.",
@@ -8,7 +8,8 @@ import { ENG_ON_REMINDER, ENG_OFF_REMINDER } from "../agent.mjs"
8
8
  export const engTool = {
9
9
  name: "eng",
10
10
  description:
11
- "Enter or exit engineering mode. In engineering mode, follow design-before-code: write a design document, run advisor design review, get user approval, then implement via eng-coder subagents.",
11
+ "Enter or exit engineering mode. In engineering mode, follow design-before-code: write a design document, run advisor design review, get user approval, then implement via eng-coder subagents. " +
12
+ "Returns the mode state — 'Engineering mode activated/exited' (an already-active state is acknowledged).",
12
13
  parameters: {
13
14
  type: "object",
14
15
  properties: {
@@ -11,7 +11,8 @@ export const goalTool = {
11
11
  "action='set': create or replace the goal — must have a verifiable completion criterion (a machine-checkable proof, not vague effort). " +
12
12
  "action='complete': mark achieved — only after the criterion's check has actually passed. " +
13
13
  "action='blocked': report an impasse (requires 'reason') — only after 3 genuine attempts. " +
14
- "action='cancel': abandon the goal.",
14
+ "action='cancel': abandon the goal. " +
15
+ "Returns a status line — the goal set/updated/completed/blocked/cancelled confirmation, or Error: ... with the reason.",
15
16
  parameters: {
16
17
  type: "object",
17
18
  properties: {
@@ -76,6 +77,15 @@ Has this goal been achieved? Answer ONLY "YES" or "NO" followed by a one-sentenc
76
77
  }],
77
78
  tools: [],
78
79
  signal: AbortSignal.timeout(10_000),
80
+ // §18.6 D-TR4/D-TR6(2026-09-04 fix round1):goal 独立评审调用经 chat()
81
+ // 唯一采集点——补轨迹元数据 + traces 开关透传(agent.config.traces.enabled
82
+ // ——关=不落盘必须全覆盖——与 agent.mjs/context.mjs 同模式)
83
+ logCtx: {
84
+ stage: "goal", kind: "goal",
85
+ role: agent._role ?? null, depth: ctx.depth,
86
+ session: agent._sessionStart ?? null, cwd: agent.cwd,
87
+ traces: agent.config?.traces?.enabled !== false,
88
+ },
79
89
  })
80
90
  const verdict = (judgeRes.content ?? "").trim()
81
91
  if (verdict.toUpperCase().startsWith("NO")) {
@@ -0,0 +1,160 @@
1
+ /**
2
+ * agent-tools/read-history.mjs — read_history tool (SESSION.md §9).
3
+ *
4
+ * Query THIS session's message history — the full human-readable record
5
+ * (agent._fullHistory — NEVER compacted, audit-complete). Use to recall what
6
+ * was said or done earlier: design decisions, tool-call timing, past rulings.
7
+ *
8
+ * Filters AND together: role / keyword (message text) / tool (tool messages by
9
+ * name + assistant messages that declared the call) / since-until (epoch ms
10
+ * window, inclusive-inclusive, matches ONLY messages that carry ts) /
11
+ * limit (default 50, clamped to 200) / direction (oldest/newest — which end of
12
+ * the matched set the limit window is taken from).
13
+ *
14
+ * Returns a JSON array in chronological order. Every message without ts comes
15
+ * back as ts:null and can never match a time window (legacy sessions). Content
16
+ * is truncated to ~500 chars with an explicit marker — full text lives in the
17
+ * session file. assistant tool_calls are summarized to a name list (arguments
18
+ * never expanded).
19
+ *
20
+ * readonly: true — planMode pass / no permission ask. Registered depth-0 only:
21
+ * subagents get their own throwaway history, so querying "the session" from a
22
+ * child would be semantically confusing (SESSION.md §9.5 refinement 1).
23
+ * Mirrored 1:1 in thincoder-vscode/src/agent-tools/read-history.mjs.
24
+ */
25
+
26
+ const DEFAULT_LIMIT = 50
27
+ const MAX_LIMIT = 200
28
+ const CONTENT_CAP = 500
29
+ const VALID_ROLES = new Set(["user", "assistant", "tool"])
30
+
31
+ /** Message text for keyword matching + output: strings pass through; multimodal content arrays → text parts joined (never crashes, empty parts skipped, images ignored). */
32
+ function messageText(m) {
33
+ if (typeof m?.content === "string") return m.content
34
+ if (Array.isArray(m?.content)) {
35
+ return m.content
36
+ .map((p) => (p && typeof p === "object" && p.type === "text" ? p.text ?? "" : ""))
37
+ .filter((t) => t.length > 0)
38
+ .join(" ")
39
+ }
40
+ return ""
41
+ }
42
+
43
+ /** Truncate long content (~500 chars) with an explicit marker. The cut never splits a UTF-16
44
+ * surrogate pair (emoji etc.) — a lone high surrogate in tool output would be an eyesore at
45
+ * minimum; the send layer sanitizes it anyway, but clean output costs nothing (setup.mjs
46
+ * safeSliceUTF16 same rule). */
47
+ function truncateContent(text) {
48
+ const t = String(text ?? "")
49
+ if (t.length <= CONTENT_CAP) return t
50
+ let end = CONTENT_CAP
51
+ if (t.charCodeAt(end - 1) >= 0xd800 && t.charCodeAt(end - 1) <= 0xdbff) end-- // high surrogate at the cut → step back
52
+ return t.slice(0, end) + `\n… (truncated: ${t.length - end} chars — full text is in the session file)`
53
+ }
54
+
55
+ /** Tool name across both stored shapes ({function:{name}} and flat {name}). */
56
+ function toolCallName(tc) {
57
+ return tc?.function?.name ?? tc?.name ?? ""
58
+ }
59
+
60
+ /** Parse a ts window boundary (epoch ms number; numeric strings tolerated). Returns the number or an error string. */
61
+ function parseTs(value, label) {
62
+ if (value === undefined || value === null) return null
63
+ const n = typeof value === "number" ? value : Number(value)
64
+ if (!Number.isFinite(n)) return `Error: invalid ${label} "${value}" — must be epoch milliseconds (number)`
65
+ return n
66
+ }
67
+
68
+ /** Map one matched message to its JSON entry shape. */
69
+ function toEntry(m) {
70
+ const entry = {
71
+ ts: typeof m.ts === "number" ? m.ts : null,
72
+ role: m.role ?? null,
73
+ }
74
+ if (m.name !== undefined) entry.name = m.name
75
+ if (m.tool_call_id !== undefined) entry.tool_call_id = m.tool_call_id
76
+ entry.content = truncateContent(messageText(m))
77
+ if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
78
+ entry.tool_calls = m.tool_calls.map(toolCallName).filter(Boolean)
79
+ }
80
+ return entry
81
+ }
82
+
83
+ export const readHistoryTool = {
84
+ name: "read_history",
85
+ description:
86
+ "Query THIS session's message history (the full record — never compacted, audit-complete). " +
87
+ "Use when you need to recall what was said or done earlier: design decisions, tool-call timing, past rulings. " +
88
+ "Filters combine with AND: role / keyword (case-insensitive substring of message text) / " +
89
+ "tool (tool result messages by name AND the assistant messages that declared the call — pair with tool_call_id / ts for timing) / " +
90
+ "since-until (epoch ms time window; only messages with ts can match) / limit (default 50, clamped to 200) / direction (which end of the matches to take). " +
91
+ "Returns a JSON array in chronological order: [{ts, role, name?, tool_call_id?, content (≈500 chars, truncated marker), tool_calls (names only)}]. " +
92
+ "Messages without ts return ts:null. Content is truncated — the full text is in the session file. " +
93
+ "For file-level changes this run (not messages), use recent_changes.",
94
+ parameters: {
95
+ type: "object",
96
+ properties: {
97
+ role: { type: "string", enum: ["user", "assistant", "tool"], description: "Only messages with this role." },
98
+ keyword: { type: "string", description: "Case-insensitive substring of the message text (multimodal messages match on their text parts)." },
99
+ tool: { type: "string", description: "Only messages for this tool: role=tool messages with name=tool, plus assistant messages that declared a call to it." },
100
+ since: { type: "integer", description: "Earliest ts to match, epoch ms, INCLUSIVE. Messages without ts never match a time window." },
101
+ until: { type: "integer", description: "Latest ts to match, epoch ms, INCLUSIVE. since > until yields an empty result." },
102
+ limit: { type: "integer", description: "Maximum messages to return (default 50; larger values are clamped to 200)." },
103
+ direction: { type: "string", enum: ["oldest", "newest"], description: "Take the limit window from the oldest or newest end of the matched set (default newest)." },
104
+ },
105
+ },
106
+ readonly: true,
107
+ execute(args, ctx) {
108
+ const a = args ?? {}
109
+ const role = a.role
110
+ if (role !== undefined && (typeof role !== "string" || !VALID_ROLES.has(role))) {
111
+ return `Error: invalid role "${role}" — valid roles: user, assistant, tool`
112
+ }
113
+ const direction = a.direction ?? "newest"
114
+ if (direction !== "oldest" && direction !== "newest") {
115
+ return `Error: invalid direction "${direction}" — valid values: oldest, newest`
116
+ }
117
+ const since = parseTs(a.since, "since")
118
+ if (typeof since === "string") return since
119
+ const until = parseTs(a.until, "until")
120
+ if (typeof until === "string") return until
121
+ let limit = DEFAULT_LIMIT
122
+ if (a.limit !== undefined) {
123
+ limit = Math.floor(Number(a.limit))
124
+ if (!Number.isFinite(limit)) return `Error: invalid limit "${a.limit}" — must be a number`
125
+ limit = Math.min(Math.max(1, limit), MAX_LIMIT)
126
+ }
127
+ const keyword = typeof a.keyword === "string" && a.keyword.length > 0 ? a.keyword : null
128
+ // Case-insensitive substring WITHOUT copying the full message text: the human line is
129
+ // never compacted — single tool results can be hundreds of KB to MBs. Lowercase the
130
+ // needle once and run a regex-i test over the haystack (escaping regex metachars so the
131
+ // keyword stays a literal substring).
132
+ const kwRe = keyword ? new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") : null
133
+ const tool = typeof a.tool === "string" && a.tool.length > 0 ? a.tool : null
134
+
135
+ const history = Array.isArray(ctx.agent?._fullHistory) ? ctx.agent._fullHistory : []
136
+ const matched = history.filter((m) => {
137
+ if (!m || typeof m !== "object") return false
138
+ if (role !== undefined && m.role !== role) return false
139
+ if (kwRe) {
140
+ const text = messageText(m)
141
+ if (!kwRe.test(text)) return false
142
+ }
143
+ if (tool) {
144
+ const byName = m.role === "tool" && m.name === tool
145
+ const byDeclaration = m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.some((tc) => toolCallName(tc) === tool)
146
+ if (!byName && !byDeclaration) return false
147
+ }
148
+ const ts = m.ts
149
+ if (since !== null || until !== null) {
150
+ if (typeof ts !== "number") return false // no ts → no time-window match
151
+ if (since !== null && ts < since) return false
152
+ if (until !== null && ts > until) return false
153
+ }
154
+ return true
155
+ })
156
+ // Direction picks the END of the matched set; output stays chronological either way.
157
+ const windowed = direction === "oldest" ? matched.slice(0, limit) : matched.slice(-limit)
158
+ return JSON.stringify(windowed.map(toEntry))
159
+ },
160
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * settings tool — agent-side configuration adjustment (SETTINGS-TOOL.md, 2026-09-05).
3
+ * 用户裁定:全量 config.json 任意键(点分路径)+ 单工具多动作 list/get/set + 双端同批。
4
+ * 语义:set = 写盘(config.json——磁盘真相最小化:只写被设的键——默认值不固化)+
5
+ * 热应用(ctx.agent.config 内存对象立即更新——回合边界键下回合生效)。
6
+ * 护栏:敏感键(路径段含 apiKey/key/token/secret/password)回显/错误文本永不出现明文
7
+ * (••••(masked)——防密钥泄漏进会话历史/trace);已知键类型校验(类型表自动派生自
8
+ * config.mjs DEFAULTS——不手写防漂移);set 侧效走审批门(dispatch 动作级分类)。
9
+ */
10
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
11
+ import { join } from "node:path"
12
+ import { DEFAULTS, configPath } from "../config.mjs"
13
+
14
+ /** 敏感键段判定(完整点分路径的段级匹配——apiKey/api_key/api-key/token/secret/password 形态) */
15
+ const SENSITIVE_SEGMENT = /(^|[._-])(api[_-]?key|key|token|secret|password)($|[._-])/i
16
+ const MASKED = "••••(masked)"
17
+
18
+ function isSensitiveKey(path) {
19
+ return SENSITIVE_SEGMENT.test(path)
20
+ }
21
+
22
+ /**
23
+ * 类型表:递归遍历 DEFAULTS——叶子(非对象/数组值)记 `路径 → typeof 默认值`。
24
+ * 数组(providersList 等)不递归(下标元素无类型约束——全量域);对象节递归到叶子。
25
+ */
26
+ function buildTypeMap(obj, prefix = "", out = {}) {
27
+ for (const [k, v] of Object.entries(obj)) {
28
+ const p = prefix ? `${prefix}.${k}` : k
29
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) buildTypeMap(v, p, out)
30
+ else out[p] = Array.isArray(v) ? "array" : typeof v
31
+ }
32
+ return out
33
+ }
34
+ const TYPE_MAP = buildTypeMap(DEFAULTS)
35
+
36
+ /** 点分路径解析(含数组数字段)——逐段下钻;返回 { ok, value } 或缺失段信息 */
37
+ function resolvePath(obj, path) {
38
+ const segs = String(path).split(".")
39
+ let cur = obj
40
+ for (let i = 0; i < segs.length; i++) {
41
+ if (cur === null || typeof cur !== "object" || !(segs[i] in cur)) {
42
+ return { ok: false, reached: segs.slice(0, i).join("."), missing: segs[i], depth: i }
43
+ }
44
+ cur = cur[segs[i]]
45
+ }
46
+ return { ok: true, value: cur }
47
+ }
48
+
49
+ /** 点分路径写入(自动建中间对象——cmd-config 数值项同款);返回旧值 */
50
+ function setKeyPath(obj, path, value) {
51
+ const segs = String(path).split(".")
52
+ let cur = obj
53
+ for (let i = 0; i < segs.length - 1; i++) {
54
+ if (cur[segs[i]] === null || typeof cur[segs[i]] !== "object") cur[segs[i]] = {}
55
+ cur = cur[segs[i]]
56
+ }
57
+ const last = segs[segs.length - 1]
58
+ const old = cur[last]
59
+ cur[last] = value
60
+ return old
61
+ }
62
+
63
+ /** 递归展平 config 对象(数组下标段形态)→ 排序的 { path, value } 列表 */
64
+ function flatten(obj, prefix = "", out = []) {
65
+ for (const [k, v] of Object.entries(obj)) {
66
+ const p = prefix ? `${prefix}.${k}` : k
67
+ if (v !== null && typeof v === "object") flatten(v, p, out)
68
+ else out.push({ path: p, value: v })
69
+ }
70
+ return out.sort((a, b) => (a.path < b.path ? -1 : 1))
71
+ }
72
+
73
+ /** 行格式化:`key = value (type)`——敏感键值遮罩 */
74
+ function formatLine({ path, value }) {
75
+ const shown = isSensitiveKey(path) ? MASKED : value
76
+ return `${path} = ${shown} (${Array.isArray(value) ? "array" : typeof value})`
77
+ }
78
+
79
+ function parseValue(raw) {
80
+ const s = String(raw)
81
+ try {
82
+ const v = JSON.parse(s)
83
+ // JSON 顶层标量都接受;解析失败(裸字符串)→ 字符串字面
84
+ if (typeof v === "number" || typeof v === "boolean" || v === null || Array.isArray(v) || typeof v === "object") return v
85
+ return s // "abc"(带引号传的字符串)——JSON.parse 成功但返回字符串——保持解析结果
86
+ } catch {
87
+ return s // 裸 abc → 字符串字面
88
+ }
89
+ }
90
+
91
+ export function settingsTool(opts = {}) {
92
+ const cfgPath = opts.configPath ?? configPath // 测试注入 tmp 文件;默认全局 configPath
93
+
94
+ async function readDisk() {
95
+ let text
96
+ try { text = readFileSync(cfgPath, "utf8") } catch { return {} } // 不存在 → 空对象(写盘最小化——默认不固化)
97
+ try { return JSON.parse(text) } catch { throw new Error(`settings: config file not parseable — refusing to overwrite: ${cfgPath}`) }
98
+ }
99
+ async function writeDisk(disk) {
100
+ mkdirSync(join(cfgPath, ".."), { recursive: true })
101
+ writeFileSync(cfgPath, JSON.stringify(disk, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
102
+ }
103
+
104
+ return {
105
+ name: "settings",
106
+ description:
107
+ "Adjust ThinCoder runtime configuration — persisted to config.json AND hot-applied to the live agent config.\n" +
108
+ "Actions: list (all keys + values, flattened) | get <key> | set <key> <value> — dot paths into config.json (agent.maxTurns, traces.enabled, providers.0.model, any nesting).\n" +
109
+ "set persists to disk (only the set key is written — defaults are never baked in) and takes effect in the running session immediately (turn-boundary keys apply next turn); the value survives restarts.\n" +
110
+ "Known keys are type-checked against the built-in defaults (agent.maxTurns must be a number, traces.enabled a boolean); unknown keys under a known section are stored as given. Values parse as JSON first (true/false/numbers/objects/arrays), else stay strings.\n" +
111
+ "SENSITIVE keys (path segment contains apiKey/key/token/secret/password) are NEVER echoed in plaintext — list/get/set replies show ••••(masked); setting a sensitive key is allowed and stored, but never echoed back.\n" +
112
+ "list/get are read-only (planMode ok, no approval); set is a side effect (approval gate). The /config TUI command is the human equivalent.",
113
+ parameters: {
114
+ type: "object",
115
+ properties: {
116
+ action: { type: "string", enum: ["list", "get", "set"], description: "list — full key/value inventory; get <key> — one value; set <key> <value> — persist + hot-apply" },
117
+ key: { type: "string", description: "Dot path into config.json, e.g. agent.maxTurns / traces.enabled / providers.0.model" },
118
+ value: { type: "string", description: "New value for set — parsed as JSON first (true/5/{...}/[...]), otherwise kept as string" },
119
+ },
120
+ required: ["action"],
121
+ },
122
+ readonly: false, // set 是侧效——dispatch 动作级分类放行 list/get
123
+ async execute(args, ctx) {
124
+ const action = args?.action
125
+ if (!["list", "get", "set"].includes(action)) throw new Error(`settings: action must be one of list/get/set — got ${JSON.stringify(action)}`)
126
+ const config = ctx?.agent?.config ?? null
127
+ if (!config) throw new Error("settings: no live agent config (ctx.agent.config missing)")
128
+ if (action === "list") {
129
+ const rows = flatten(config)
130
+ if (rows.length === 0) return "(空配置——无已设置键)"
131
+ return rows.map(formatLine).join("\n")
132
+ }
133
+ if (action === "get") {
134
+ if (!args.key) throw new Error("settings get: key is required (dot path, e.g. agent.maxTurns)")
135
+ const r = resolvePath(config, args.key)
136
+ if (!r.ok) {
137
+ const hint = r.depth === 0 ? "(config 顶层无此键——顶层可用键见 settings list)" : `(父键 ${r.reached || "顶层"} 存在——无 ${r.missing} 子键)`
138
+ throw new Error(`settings get: no such key "${args.key}" ${hint}`)
139
+ }
140
+ return formatLine({ path: args.key, value: r.value })
141
+ }
142
+ // set
143
+ if (!args.key || args.value === undefined) throw new Error("settings set: key and value are required")
144
+ const value = parseValue(args.value)
145
+ // 已知键类型校验(类型表派生自 DEFAULTS——未知键 JSON 原样)
146
+ const want = TYPE_MAP[String(args.key)]
147
+ if (want && want !== "array") {
148
+ const got = value === null ? "null" : typeof value
149
+ if (got !== want && !(value === null && want === "object")) {
150
+ throw new Error(`settings set: "${args.key}" expects ${want} — got ${got} (${JSON.stringify(args.value)})`)
151
+ }
152
+ }
153
+ // 写盘(磁盘真相最小化:只改被设键——默认不固化)+ 热应用(内存对象)
154
+ const disk = await readDisk()
155
+ setKeyPath(disk, args.key, value)
156
+ await writeDisk(disk)
157
+ setKeyPath(config, args.key, value)
158
+ const shown = isSensitiveKey(String(args.key)) ? MASKED : value
159
+ return `settings set: ${args.key} = ${shown} (${Array.isArray(value) ? "array" : typeof value})${isSensitiveKey(String(args.key)) ? " — stored(值不回显)" : " — persisted + hot-applied(运行中已生效)"}`
160
+ },
161
+ }
162
+ }
@@ -9,7 +9,8 @@ import { escapeXml } from "../agent.mjs"
9
9
  export const skillTool = {
10
10
  name: "skill",
11
11
  description:
12
- "Load a project skill from .thincoder/skills/. Skills contain reusable instructions, workflows, or reference material. Use this when the user references a skill by name, or when a task matches a known skill's description. Call with action='list' to see available skills; call with action='load' and name=<skill> to activate one.",
12
+ "Load a project skill from .thincoder/skills/. Skills contain reusable instructions, workflows, or reference material. Use this when the user references a skill by name, or when a task matches a known skill's description. Call with action='list' to see available skills; call with action='load' and name=<skill> to activate one. " +
13
+ "Returns the skill list ('list'), the load confirmation ('load' — instructions arrive in the next message), or Error: ... with the available skills.",
13
14
  parameters: {
14
15
  type: "object",
15
16
  properties: {