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.
- package/CHANGELOG.md +42 -1
- package/README.md +1 -1
- package/bin/thincoder.mjs +8 -0
- package/package.json +1 -1
- package/src/acp/bridge.mjs +132 -26
- package/src/advisor/messages.mjs +34 -1
- package/src/advisor/run.mjs +89 -51
- package/src/advisor.mjs +15 -7
- package/src/agent/dispatch.mjs +91 -14
- package/src/agent/helpers.mjs +35 -4
- package/src/agent/setup.mjs +90 -19
- package/src/agent/spawn-child.mjs +25 -0
- package/src/agent-tools/advisor.mjs +24 -2
- package/src/agent-tools/consult.mjs +37 -6
- package/src/agent-tools/eng.mjs +2 -1
- package/src/agent-tools/goal.mjs +11 -1
- package/src/agent-tools/read-history.mjs +160 -0
- package/src/agent-tools/settings.mjs +162 -0
- package/src/agent-tools/skill.mjs +2 -1
- package/src/agent-tools/subagent-actions.mjs +432 -0
- package/src/agent-tools/subagent-async.mjs +427 -0
- package/src/agent-tools/subagent-scheduler.mjs +319 -0
- package/src/agent-tools/subagent.mjs +467 -193
- package/src/agent-tools/task.mjs +4 -3
- package/src/agent-tools/timer.mjs +9 -4
- package/src/agent-tools/verify.mjs +161 -49
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +161 -125
- package/src/auto-think.mjs +14 -0
- package/src/cli/make-agent.mjs +2 -1
- package/src/cli/permission.mjs +8 -1
- package/src/config.mjs +5 -0
- package/src/context.mjs +87 -27
- package/src/distill.mjs +19 -1
- package/src/escape.mjs +6 -4
- package/src/log.mjs +195 -0
- package/src/memory/code-sync.mjs +1 -1
- package/src/memory/core.mjs +126 -0
- package/src/memory/docs.mjs +196 -87
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +15 -1
- package/src/prompts/advisor-design.md +46 -0
- package/src/prompts/advisor-round1.md +49 -2
- package/src/prompts/advisor-round2.md +47 -0
- package/src/prompts/advisor-round3.md +47 -0
- package/src/prompts/coder.md +22 -0
- package/src/prompts/consult-base.md +13 -0
- package/src/prompts/discipline.md +10 -5
- package/src/prompts/eng-coder.md +2 -2
- package/src/prompts/engineering-sub.md +23 -1
- package/src/prompts/engineering.md +106 -56
- package/src/prompts/explore.md +1 -2
- package/src/prompts/main.md +11 -6
- package/src/prompts/methodology-template.md +14 -0
- package/src/prompts/system.md +4 -2
- package/src/provider/core.mjs +56 -2
- package/src/tools/apply_patch.md +3 -1
- package/src/tools/bash.md +1 -1
- package/src/tools/delete.md +1 -0
- package/src/tools/edit-batch.mjs +31 -43
- package/src/tools/edit-diff.mjs +265 -0
- package/src/tools/edit.md +10 -8
- package/src/tools/execute.md +7 -7
- package/src/tools/execute.mjs +24 -20
- package/src/tools/file.mjs +18 -68
- package/src/tools/file_ops.md +2 -1
- package/src/tools/get_current_time.md +3 -1
- package/src/tools/hashline_edit.md +2 -0
- package/src/tools/index.mjs +3 -2
- package/src/tools/insert_after.md +2 -1
- package/src/tools/lint.md +2 -0
- package/src/tools/lsp.md +4 -1
- package/src/tools/patch.mjs +84 -13
- package/src/tools/pdf-parse-text.mjs +497 -0
- package/src/tools/pdf-parse-xref.mjs +499 -0
- package/src/tools/pdf.mjs +155 -0
- package/src/tools/question.md +2 -1
- package/src/tools/read.md +1 -0
- package/src/tools/read_pdf.md +21 -0
- package/src/tools/repomap.mjs +1 -1
- package/src/tools/shared.mjs +4 -12
- package/src/tools/system.mjs +6 -21
- package/src/tools/tree.md +2 -1
- package/src/tools/web.mjs +5 -3
- package/src/tools/websearch.md +2 -1
- package/src/tools/write.md +2 -0
- package/src/traces/trace-store.mjs +224 -0
- package/src/tui/agent-turn.mjs +385 -22
- package/src/tui/clipboard.mjs +15 -4
- package/src/tui/cmd-config.mjs +29 -9
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-mcp.mjs +9 -0
- package/src/tui/cmd-think.mjs +1 -1
- package/src/tui/index.mjs +29 -95
- package/src/tui/interaction.mjs +13 -2
- package/src/tui/key-handler.mjs +105 -155
- package/src/tui/key-modes.mjs +215 -0
- package/src/tui/layout.mjs +22 -1
- package/src/tui/mouse.mjs +40 -0
- package/src/tui/pickers.mjs +11 -3
- package/src/tui/render-conversation.mjs +13 -161
- package/src/tui/render-frame.mjs +27 -10
- package/src/tui/render-loop.mjs +4 -1
- package/src/tui/render-segments.mjs +165 -0
- package/src/tui/startup.mjs +36 -0
- package/src/tui/subagent-blocks.mjs +322 -144
- package/src/tui/subagent-panel.mjs +88 -13
- package/src/tui/tool-args.mjs +10 -2
- package/src/tui/tool-events.mjs +132 -100
- package/src/tui/update-notice.mjs +72 -0
- package/src/tui/wizard.mjs +36 -6
- package/src/agent-tools/escalate.mjs +0 -179
- package/src/agent-tools/subagent-check.mjs +0 -107
- 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
|
|
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
|
|
160
|
-
// effort "high" (enum is xhigh/medium/low).
|
|
161
|
-
// (the provider preset default may ALSO be
|
|
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)
|
|
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
|
|
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.",
|
package/src/agent-tools/eng.mjs
CHANGED
|
@@ -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: {
|
package/src/agent-tools/goal.mjs
CHANGED
|
@@ -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: {
|