thincoder 0.12.54 → 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 (129) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +25 -3
  4. package/package.json +3 -7
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +38 -3
  7. package/src/advisor/run.mjs +91 -53
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +156 -39
  10. package/src/agent/helpers.mjs +46 -4
  11. package/src/agent/setup.mjs +102 -19
  12. package/src/agent/spawn-child.mjs +28 -1
  13. package/src/agent-tools/advisor.mjs +43 -11
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +4 -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 +565 -128
  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 +182 -81
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +27 -1
  31. package/src/cli/memory-command.mjs +28 -7
  32. package/src/cli/permission.mjs +8 -1
  33. package/src/config.mjs +125 -8
  34. package/src/context.mjs +115 -34
  35. package/src/distill.mjs +19 -1
  36. package/src/escape.mjs +82 -27
  37. package/src/log.mjs +195 -0
  38. package/src/mcp/transport-http.mjs +13 -1
  39. package/src/mcp.mjs +52 -7
  40. package/src/memory/code-sync.mjs +1 -1
  41. package/src/memory/core.mjs +204 -10
  42. package/src/memory/docs.mjs +197 -62
  43. package/src/memory.mjs +1 -1
  44. package/src/model-specs.mjs +38 -1
  45. package/src/prompts/advisor-design.md +46 -0
  46. package/src/prompts/advisor-round1.md +49 -2
  47. package/src/prompts/advisor-round2.md +47 -0
  48. package/src/prompts/advisor-round3.md +47 -0
  49. package/src/prompts/coder.md +22 -0
  50. package/src/prompts/consult-base.md +13 -0
  51. package/src/prompts/discipline.md +25 -6
  52. package/src/prompts/eng-coder.md +2 -2
  53. package/src/prompts/engineering-sub.md +23 -1
  54. package/src/prompts/engineering.md +157 -50
  55. package/src/prompts/explore.md +1 -2
  56. package/src/prompts/main.md +11 -5
  57. package/src/prompts/methodology-template.md +14 -0
  58. package/src/prompts/system.md +5 -2
  59. package/src/provider/anthropic.mjs +7 -5
  60. package/src/provider/core.mjs +104 -28
  61. package/src/provider/google.mjs +57 -24
  62. package/src/provider/normalize.mjs +1 -1
  63. package/src/provider/rate.mjs +0 -2
  64. package/src/provider/responses.mjs +8 -13
  65. package/src/provider/sse.mjs +20 -0
  66. package/src/session.mjs +15 -0
  67. package/src/tools/apply_patch.md +5 -1
  68. package/src/tools/bash.md +3 -3
  69. package/src/tools/delete.md +1 -0
  70. package/src/tools/edit-batch.mjs +92 -0
  71. package/src/tools/edit-diff.mjs +265 -0
  72. package/src/tools/edit.md +11 -6
  73. package/src/tools/execute.md +8 -8
  74. package/src/tools/execute.mjs +31 -35
  75. package/src/tools/file.mjs +26 -114
  76. package/src/tools/file_ops.md +3 -2
  77. package/src/tools/get_current_time.md +3 -1
  78. package/src/tools/git.md +1 -1
  79. package/src/tools/git.mjs +8 -16
  80. package/src/tools/hashline_edit.md +2 -0
  81. package/src/tools/index.mjs +3 -2
  82. package/src/tools/insert_after.md +2 -1
  83. package/src/tools/lint.md +3 -1
  84. package/src/tools/linter.mjs +9 -37
  85. package/src/tools/lsp.md +4 -1
  86. package/src/tools/patch.mjs +84 -13
  87. package/src/tools/pdf-parse-text.mjs +497 -0
  88. package/src/tools/pdf-parse-xref.mjs +499 -0
  89. package/src/tools/pdf.mjs +155 -0
  90. package/src/tools/question.md +2 -1
  91. package/src/tools/read.md +1 -0
  92. package/src/tools/read_pdf.md +21 -0
  93. package/src/tools/repomap.mjs +1 -1
  94. package/src/tools/shared.mjs +11 -32
  95. package/src/tools/system.mjs +6 -21
  96. package/src/tools/tree.md +2 -1
  97. package/src/tools/web.mjs +5 -3
  98. package/src/tools/websearch.md +2 -1
  99. package/src/tools/write.md +2 -0
  100. package/src/traces/trace-store.mjs +224 -0
  101. package/src/tui/agent-turn.mjs +387 -24
  102. package/src/tui/clipboard.mjs +17 -6
  103. package/src/tui/cmd-config.mjs +29 -9
  104. package/src/tui/cmd-eng.mjs +1 -0
  105. package/src/tui/cmd-extract.mjs +1 -1
  106. package/src/tui/cmd-mcp-form.mjs +197 -0
  107. package/src/tui/cmd-mcp.mjs +264 -114
  108. package/src/tui/cmd-think.mjs +1 -1
  109. package/src/tui/index.mjs +49 -95
  110. package/src/tui/interaction.mjs +41 -3
  111. package/src/tui/key-handler.mjs +105 -143
  112. package/src/tui/key-modes.mjs +215 -0
  113. package/src/tui/layout.mjs +22 -1
  114. package/src/tui/mouse.mjs +41 -1
  115. package/src/tui/pickers.mjs +73 -7
  116. package/src/tui/render-conversation.mjs +13 -161
  117. package/src/tui/render-frame.mjs +45 -20
  118. package/src/tui/render-loop.mjs +4 -1
  119. package/src/tui/render-segments.mjs +165 -0
  120. package/src/tui/render.mjs +4 -4
  121. package/src/tui/startup.mjs +40 -2
  122. package/src/tui/subagent-blocks.mjs +404 -111
  123. package/src/tui/subagent-panel.mjs +88 -13
  124. package/src/tui/tool-args.mjs +10 -2
  125. package/src/tui/tool-events.mjs +172 -95
  126. package/src/tui/update-notice.mjs +72 -0
  127. package/src/tui/wizard.mjs +36 -6
  128. package/src/agent-tools/escalate.mjs +0 -179
  129. package/src/tools/exec-prelude.mjs +0 -84
@@ -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: {