thincoder 0.8.2 → 0.8.3
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/README.md +2 -1
- package/bin/thincoder.mjs +25 -25
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +17 -12
- package/src/agent/helpers.mjs +22 -11
- package/src/agent/setup.mjs +32 -10
- package/src/agent-tools/goal.mjs +7 -6
- package/src/agent-tools/plan.mjs +3 -3
- package/src/agent-tools/recent-changes.mjs +3 -3
- package/src/agent-tools/skill.mjs +6 -6
- package/src/agent-tools/subagent.mjs +19 -18
- package/src/agent-tools/task.mjs +4 -4
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +58 -37
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +9 -9
- package/src/cli/memory-command.mjs +1 -1
- package/src/cli/permission.mjs +3 -3
- package/src/cli/setup-wizard.mjs +15 -15
- package/src/config.mjs +55 -53
- package/src/context.mjs +59 -56
- package/src/distill.mjs +35 -35
- package/src/embedding.mjs +17 -17
- package/src/git/checkpoint.mjs +211 -38
- package/src/git/gitmem.mjs +21 -20
- package/src/markdown.mjs +13 -13
- package/src/mcp/helpers.mjs +6 -1
- package/src/mcp/transport-http.mjs +2 -1
- package/src/mcp/transport-stdio.mjs +3 -2
- package/src/mcp/transport-ws.mjs +3 -2
- package/src/mcp.mjs +5 -2
- package/src/memory/code-index.mjs +16 -14
- package/src/memory/code-sync.mjs +36 -26
- package/src/memory/core.mjs +42 -35
- package/src/memory/docs.mjs +24 -15
- package/src/memory/schema.mjs +28 -27
- package/src/memory.mjs +2 -2
- package/src/prompts/coder.md +1 -1
- package/src/prompts/discipline.md +3 -0
- package/src/prompts/main.md +1 -2
- package/src/prompts/system.md +21 -16
- package/src/provider/core.mjs +11 -6
- package/src/provider/index.mjs +2 -2
- package/src/provider/rate.mjs +11 -11
- package/src/session.mjs +73 -40
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +16 -12
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +1 -1
- package/src/tools/patch.mjs +20 -18
- package/src/tools/repomap-parse.mjs +17 -17
- package/src/tools/repomap.mjs +29 -29
- package/src/tools/shared.mjs +49 -28
- package/src/tools/system.mjs +163 -118
- package/src/tools/web.mjs +6 -6
- package/src/tui/agent-turn.mjs +82 -26
- package/src/tui/ansi.mjs +4 -2
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +1 -1
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +2 -2
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-goal.mjs +4 -4
- package/src/tui/cmd-help.mjs +1 -1
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +5 -5
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +1 -1
- package/src/tui/cmd-plan.mjs +1 -1
- package/src/tui/cmd-reindex.mjs +2 -2
- package/src/tui/cmd-restore.mjs +1 -1
- package/src/tui/cmd-session.mjs +1 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +2 -2
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +3 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +31 -23
- package/src/tui/layout.mjs +22 -17
- package/src/tui/pickers.mjs +19 -19
- package/src/tui/render-frame.mjs +37 -11
- package/src/tui/render.mjs +25 -24
- package/src/tui/slash-commands.mjs +14 -14
- package/src/tui/startup.mjs +15 -14
- package/src/tui/wizard.mjs +11 -11
- package/src/tui.mjs +2 -2
- package/bin/thincoder.js +0 -4
- package/src/tools/bash.mjs +0 -144
- package/src/tools/glob.mjs +0 -51
- package/src/tools/grep.mjs +0 -100
- package/src/tools/ls.mjs +0 -36
- package/src/tools.mjs +0 -2
- package/src/tui-render.mjs +0 -4
package/README.md
CHANGED
|
@@ -12,7 +12,8 @@ Design philosophy (the entire meaning of the name): if the Node standard library
|
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
|
-
- **
|
|
15
|
+
- **Fix-verify loop**: file changes without `verify` get pushed back — syntax check + tests must pass before the agent can claim completion (auto-repair up to 3 rounds)
|
|
16
|
+
- **Checkpoint system**: auto-snapshot before every user task, `list`/`create`/`rewind` tools for the model, single-file restore — rewinding itself is reversible (pre-rewind state auto-saved)
|
|
16
17
|
- **Codebase understanding** ⭐0.5.0: `repo_outline` (dependency outline, auto-injected at startup), `code_search` (source FTS5 + vectors + JSDoc extraction), `doc_search` (docs chunked by ## headings) — background indexing, auto-incremental updates on file writes, three tools guided by "structure → intent → details"
|
|
17
18
|
- **Model adaptation** ⭐: top-tier only, latest only. Built-in flagship models from five leading Chinese vendors — DeepSeek / Kimi / GLM / Qwen / MiniMax. No legacy model compatibility, no local model support. Auto-matched context windows, truncation-resume protocols (prefix/partial), thinking-mode APIs (thinking.type / reasoning_effort), reasoning_content echo strategies (reasoningEcho), output limits, temperature range clamping — all five deeply adapted.
|
|
18
19
|
- **Toolset**: `read` / `write` / `edit` / `bash` / `glob` (supports `**`) / `grep` / `websearch` / `ls` / `fetch` + `read_image` (image/video paste) + three retrieval tools + MCP — all zero-dependency, file tools confined to the working directory
|
package/bin/thincoder.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* thincoder —
|
|
5
|
-
* thincoder
|
|
6
|
-
* thincoder chat "..."
|
|
7
|
-
* thincoder memory <sub>
|
|
8
|
-
* thincoder upgrade
|
|
9
|
-
* thincoder -v
|
|
10
|
-
* thincoder --help
|
|
4
|
+
* thincoder — CLI entry point
|
|
5
|
+
* thincoder Launch the interactive TUI
|
|
6
|
+
* thincoder chat "..." One-shot agent run (tools enabled, streamed)
|
|
7
|
+
* thincoder memory <sub> Memory management: list / search / put / remove
|
|
8
|
+
* thincoder upgrade Update to the latest version from npm
|
|
9
|
+
* thincoder -v Print version
|
|
10
|
+
* thincoder --help Print help
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { readFileSync } from "node:fs"
|
|
@@ -15,7 +15,7 @@ import { join } from "node:path"
|
|
|
15
15
|
import { runAgent } from "../src/agent.mjs"
|
|
16
16
|
import { loadConfig, configPath } from "../src/config.mjs"
|
|
17
17
|
import { createMemory, syncDir } from "../src/memory.mjs"
|
|
18
|
-
import {
|
|
18
|
+
import { assembleAgent, teamConfig, gitAuthor } from "../src/cli/make-agent.mjs"
|
|
19
19
|
import { memoryCommand } from "../src/cli/memory-command.mjs"
|
|
20
20
|
import { setupWizard } from "../src/cli/setup-wizard.mjs"
|
|
21
21
|
import { summarize, askPermission } from "../src/cli/permission.mjs"
|
|
@@ -24,7 +24,7 @@ import { distillCommand } from "../src/cli/distill-command.mjs"
|
|
|
24
24
|
const [command, ...args] = process.argv.slice(2)
|
|
25
25
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
|
|
26
26
|
|
|
27
|
-
//
|
|
27
|
+
// Top-level safety net: print one-line error and exit cleanly, no stack traces to the user
|
|
28
28
|
process.on("uncaughtException", (error) => {
|
|
29
29
|
console.error(`[error] ${error.message}`)
|
|
30
30
|
exitSoon(1)
|
|
@@ -38,7 +38,7 @@ const USAGE = `thincoder - thin coding agent
|
|
|
38
38
|
|
|
39
39
|
Usage:
|
|
40
40
|
thincoder Launch the interactive TUI
|
|
41
|
-
thincoder chat <prompt> One-shot agent run (tools enabled), streams reply to stdout
|
|
41
|
+
thincoder chat [--auto] <prompt> One-shot agent run (tools enabled), streams reply to stdout; --auto approves all tool calls
|
|
42
42
|
thincoder memory list [--type=<t>] List memory entries
|
|
43
43
|
thincoder memory search <query> Search memory
|
|
44
44
|
thincoder memory put --type=<t> --title=<t> --content=<c> [--tags=<t>]
|
|
@@ -51,21 +51,21 @@ Usage:
|
|
|
51
51
|
thincoder upgrade Update to the latest version from npm
|
|
52
52
|
thincoder -v, --version Print version
|
|
53
53
|
|
|
54
|
-
Config: ~/.thincoder/config.json (providers[] + activeProvider
|
|
54
|
+
Config: ~/.thincoder/config.json (providers[] + activeProvider; manage via /provider, /model in TUI)
|
|
55
55
|
Env: THINCODER_API_KEY, THINCODER_BASE_URL, THINCODER_MODEL, THINCODER_ACTIVE_PROVIDER
|
|
56
56
|
`
|
|
57
57
|
|
|
58
|
-
/**
|
|
58
|
+
/** Unified message when no API key is configured */
|
|
59
59
|
function noKeyMessage() {
|
|
60
|
-
return
|
|
60
|
+
return `No API key configured yet. Run "thincoder" to enter TUI, use /provider add and /provider key; or edit ${configPath} directly`
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
63
|
+
/** Delay exit: process.exit right after fetch triggers libuv assertion on Windows/Node 24; let handles drain first */
|
|
64
64
|
function exitSoon(code) {
|
|
65
65
|
setTimeout(() => process.exit(code), 100)
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/**
|
|
68
|
+
/** Semantic version comparison: a<b returns -1, equal 0, a>b returns 1; non-numeric segments compare as strings */
|
|
69
69
|
function compareVersions(a, b) {
|
|
70
70
|
const pa = String(a).split("."), pb = String(b).split(".")
|
|
71
71
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
@@ -90,7 +90,7 @@ switch (command) {
|
|
|
90
90
|
break
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
const agent = await
|
|
93
|
+
const agent = await assembleAgent()
|
|
94
94
|
if (!agent.provider.apiKey) {
|
|
95
95
|
if (!process.stdin.isTTY) {
|
|
96
96
|
console.error(noKeyMessage())
|
|
@@ -104,7 +104,7 @@ switch (command) {
|
|
|
104
104
|
}
|
|
105
105
|
agent.provider = p
|
|
106
106
|
agent.activeProvider = p.name
|
|
107
|
-
//
|
|
107
|
+
// Wizard may have configured an embedding key: attach vector search
|
|
108
108
|
const fresh = loadConfig()
|
|
109
109
|
if (fresh.embedding?.apiKey && agent.memory && !agent.memory.embedder) {
|
|
110
110
|
const { createEmbedder } = await import("../src/embedding.mjs")
|
|
@@ -112,13 +112,13 @@ switch (command) {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
if (auto) agent.autoApprove = true
|
|
115
|
-
//
|
|
115
|
+
// Accumulate token usage, output to stderr at the end (don't pollute stdout pipe)
|
|
116
116
|
const usageTotal = { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }
|
|
117
117
|
try {
|
|
118
118
|
await runAgent(agent, prompt, {
|
|
119
119
|
onToken: (text) => process.stdout.write(text),
|
|
120
120
|
onWait: ({ phase, seconds }) => {
|
|
121
|
-
console.error(phase === "gate" ? `[rate-limit] TPM
|
|
121
|
+
console.error(phase === "gate" ? `[rate-limit] TPM throttle waiting ~${seconds}s` : `[rate-limit] 429 response, retrying in ${seconds}s`)
|
|
122
122
|
},
|
|
123
123
|
onToolCall: (name, toolArgs) => {
|
|
124
124
|
console.error(`\n[tool] ${name} ${summarize(toolArgs)}`)
|
|
@@ -128,7 +128,7 @@ switch (command) {
|
|
|
128
128
|
console.error(`[done] ${name} -> ${preview.split("\n")[0]}`)
|
|
129
129
|
},
|
|
130
130
|
onToolOutput: (name, chunk) => process.stderr.write(chunk),
|
|
131
|
-
onCompress: () => console.error(`\n[context]
|
|
131
|
+
onCompress: () => console.error(`\n[context] Context too long, auto-compacted (early conversation summarized by LLM)`),
|
|
132
132
|
onTaskUpdate: (items) => {
|
|
133
133
|
const done = items.filter((i) => i.status === "done").length
|
|
134
134
|
const current = items.find((i) => i.status === "in_progress")
|
|
@@ -230,7 +230,7 @@ switch (command) {
|
|
|
230
230
|
|
|
231
231
|
case "tui":
|
|
232
232
|
case undefined: {
|
|
233
|
-
const agent = await
|
|
233
|
+
const agent = await assembleAgent()
|
|
234
234
|
const config = loadConfig()
|
|
235
235
|
// 恢复上次的会话(同一项目目录);provider 按保存的名字切回(用户上次可能换过模型)
|
|
236
236
|
const { loadSession, applySession } = await import("../src/session.mjs")
|
|
@@ -275,16 +275,16 @@ switch (command) {
|
|
|
275
275
|
try {
|
|
276
276
|
remote = execSync("npm view thincoder version", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim()
|
|
277
277
|
} catch {
|
|
278
|
-
console.error("[upgrade]
|
|
278
|
+
console.error("[upgrade] Unable to query npm registry — check your network connection and that npm is installed")
|
|
279
279
|
exitSoon(1)
|
|
280
280
|
break
|
|
281
281
|
}
|
|
282
282
|
if (compareVersions(local, remote) >= 0) {
|
|
283
|
-
console.log(`ThinCoder ${local}
|
|
283
|
+
console.log(`ThinCoder ${local} is already the latest.`)
|
|
284
284
|
} else {
|
|
285
|
-
console.log(
|
|
285
|
+
console.log(`Upgrading: ${local} → ${remote}`)
|
|
286
286
|
execSync("npm install -g thincoder@latest", { stdio: "inherit" })
|
|
287
|
-
console.log(
|
|
287
|
+
console.log(`Upgraded to ${remote}`)
|
|
288
288
|
}
|
|
289
289
|
break
|
|
290
290
|
}
|
package/package.json
CHANGED
package/src/agent/dispatch.mjs
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agent/dispatch.mjs —
|
|
2
|
+
* agent/dispatch.mjs — two-phase tool call execution
|
|
3
3
|
*/
|
|
4
4
|
import { offloadToolResult } from "./helpers.mjs"
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* Two-phase execution:
|
|
8
|
+
* Phase 1 (serial): parse args one by one + planMode check + permission confirmation (side-effecting tools)
|
|
9
|
+
* Phase 2 (order-preserving): strictly preserve model call order — consecutive readonly/parallel tools run as concurrent batches,
|
|
10
|
+
* side-effecting tools run serially in their original position (if a batch writes-then-reads the same file, the read must see the post-write content).
|
|
11
|
+
* Returns a results array in call order (each entry has an ok flag indicating success/failure).
|
|
12
12
|
*/
|
|
13
13
|
export async function executeToolCalls(agent, toolByName, toolCalls, callbacks, depth = 0, signal) {
|
|
14
|
-
// ----
|
|
14
|
+
// ---- Phase 1: serial preparation ----
|
|
15
15
|
const prepared = []
|
|
16
16
|
for (const toolCall of toolCalls) {
|
|
17
17
|
const tool = toolByName.get(toolCall.name)
|
|
@@ -34,7 +34,7 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
if (!tool.readonly) {
|
|
37
|
-
// autoApprove
|
|
37
|
+
// autoApprove short-circuit: skip prompt when agent is already marked for auto-approval
|
|
38
38
|
const allowed = agent.autoApprove
|
|
39
39
|
? true
|
|
40
40
|
: callbacks.onPermissionRequest
|
|
@@ -50,17 +50,20 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
50
50
|
prepared.push({ toolCall, tool, args })
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// ----
|
|
53
|
+
// ---- Phase 2: order-preserving execution ----
|
|
54
54
|
const runOne = async (item) => {
|
|
55
55
|
if (item.error) return { ...item, result: `Error: ${item.error}`, ok: false }
|
|
56
56
|
if (item.denied) {
|
|
57
57
|
const reason = item.reason === "plan mode"
|
|
58
58
|
? "Error: plan mode is active — only read-only tools are allowed. Exit plan mode first."
|
|
59
|
-
:
|
|
59
|
+
: item.reason === "denied by user"
|
|
60
|
+
? "Error: permission denied by user"
|
|
61
|
+
: "Error: no permission handler configured — this tool requires user approval but the current context doesn't support interaction (e.g. subagent or non-TUI mode)"
|
|
60
62
|
return { ...item, result: reason, ok: false }
|
|
61
63
|
}
|
|
62
64
|
try {
|
|
63
|
-
|
|
65
|
+
if (item.tool.outputPanel) callbacks.setupOutputPanel?.(item.toolCall.name)
|
|
66
|
+
const rawResult = await item.tool.execute(item.args, {
|
|
64
67
|
cwd: agent.cwd,
|
|
65
68
|
agent,
|
|
66
69
|
depth,
|
|
@@ -69,7 +72,9 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
69
72
|
onOutput: (chunk) => callbacks.onToolOutput?.(item.toolCall.name, chunk),
|
|
70
73
|
onQuestion: callbacks.onQuestion,
|
|
71
74
|
onPermissionRequest: callbacks.onPermissionRequest,
|
|
72
|
-
})
|
|
75
|
+
})
|
|
76
|
+
if (rawResult === undefined) throw new Error(`Tool "${item.toolCall.name}" returned undefined — all tools must return a string value`)
|
|
77
|
+
const raw = String(rawResult)
|
|
73
78
|
const result = item.toolCall.name === "read_image" ? raw : await offloadToolResult(raw, item.toolCall.id)
|
|
74
79
|
callbacks.onToolResult?.(item.toolCall.name, result)
|
|
75
80
|
return { ...item, result, ok: true }
|
package/src/agent/helpers.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agent/helpers.mjs — Agent
|
|
2
|
+
* agent/helpers.mjs — Agent utility functions and constants
|
|
3
3
|
*/
|
|
4
4
|
import { configDir } from "../config.mjs"
|
|
5
5
|
import { readFileSync, readdirSync } from "node:fs"
|
|
@@ -8,7 +8,7 @@ import { join } from "node:path"
|
|
|
8
8
|
import { execSync } from "node:child_process"
|
|
9
9
|
|
|
10
10
|
export const DEFAULT_MAX_TURNS = 100
|
|
11
|
-
export const DEFAULT_SUBAGENT_TURNS =
|
|
11
|
+
export const DEFAULT_SUBAGENT_TURNS = 100
|
|
12
12
|
export const DEFAULT_GOAL_TURNS = 200
|
|
13
13
|
export const MIN_REPORT_CHARS = 200
|
|
14
14
|
export const REPORT_CONTINUATION =
|
|
@@ -18,17 +18,23 @@ export const REPORT_CONTINUATION =
|
|
|
18
18
|
const TOOL_RESULT_OFFLOAD_LIMIT = 16_000
|
|
19
19
|
const TOOL_RESULT_PREVIEW = 2_000
|
|
20
20
|
|
|
21
|
+
const GIT_TIMEOUT_MS = 5000
|
|
22
|
+
const MAX_GIT_CHANGES_DISPLAY = 20
|
|
23
|
+
|
|
21
24
|
export const OUTLINE_INJECT_PREFIX = "[System reminder: project dependency outline:"
|
|
22
25
|
export const FILE_MUTATORS = new Set(["write", "edit", "insert_after", "apply_patch", "delete"])
|
|
23
26
|
|
|
27
|
+
/** Escape XML special characters in a string for safe embedding in XML/HTML */
|
|
24
28
|
export function escapeXml(s) {
|
|
25
29
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'")
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
/** Canonicalize a tool call signature for stall detection: name + stable JSON args */
|
|
28
33
|
export function tryCanonicalize(name, args) {
|
|
29
34
|
try { return name + ":" + JSON.stringify(JSON.parse(args)) } catch { return name + ":" + args }
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
/** Offload oversized tool results (>16k chars) to disk, returning a preview + file path */
|
|
32
38
|
export async function offloadToolResult(text, callId) {
|
|
33
39
|
if (text.length <= TOOL_RESULT_OFFLOAD_LIMIT) return text
|
|
34
40
|
try {
|
|
@@ -46,9 +52,10 @@ export async function offloadToolResult(text, callId) {
|
|
|
46
52
|
}
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
/** Collect git branch, recent commits, and working tree status as context text */
|
|
49
56
|
export function collectGitContext(cwd) {
|
|
50
57
|
try {
|
|
51
|
-
const opts = { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout:
|
|
58
|
+
const opts = { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: GIT_TIMEOUT_MS }
|
|
52
59
|
const branch = execSync("git branch --show-current", opts).trim()
|
|
53
60
|
const log = execSync("git --no-pager log --oneline -5", opts).trim()
|
|
54
61
|
const status = execSync("git status --short", opts).trim()
|
|
@@ -56,7 +63,7 @@ export function collectGitContext(cwd) {
|
|
|
56
63
|
return [
|
|
57
64
|
`Git context: on branch \`${branch || "(detached)"}\`${dirty ? `, ${dirty} uncommitted change(s)` : ", working tree clean"}.`,
|
|
58
65
|
log ? `Recent commits:\n${log}` : "",
|
|
59
|
-
status ? `Uncommitted:\n${status.split("\n").slice(0,
|
|
66
|
+
status ? `Uncommitted:\n${status.split("\n").slice(0, MAX_GIT_CHANGES_DISPLAY).join("\n")}${dirty > MAX_GIT_CHANGES_DISPLAY ? `\n… (${dirty - MAX_GIT_CHANGES_DISPLAY} more)` : ""}` : "",
|
|
60
67
|
].filter(Boolean).join("\n")
|
|
61
68
|
} catch {
|
|
62
69
|
return ""
|
|
@@ -71,18 +78,19 @@ export class ContinueError extends Error {
|
|
|
71
78
|
}
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
/** Repair malformed conversation history: remove orphan tool messages and fill missing tool results */
|
|
74
82
|
export function repairHistory(history) {
|
|
75
83
|
const out = []
|
|
76
84
|
let dirty = false
|
|
77
|
-
const knownIds = new Set() //
|
|
85
|
+
const knownIds = new Set() // tool_call ids declared by assistant so far
|
|
78
86
|
for (let i = 0; i < history.length; i++) {
|
|
79
87
|
const m = history[i]
|
|
80
|
-
//
|
|
88
|
+
// empty assistant message: no content and no tool_calls, discard
|
|
81
89
|
if (m.role === "assistant" && !m.tool_calls?.length && !m.content) {
|
|
82
90
|
dirty = true
|
|
83
91
|
continue
|
|
84
92
|
}
|
|
85
|
-
//
|
|
93
|
+
// orphan tool message: no matching assistant tool_calls declaration, discard
|
|
86
94
|
if (m.role === "tool" && !knownIds.has(m.tool_call_id)) {
|
|
87
95
|
dirty = true
|
|
88
96
|
continue
|
|
@@ -91,7 +99,7 @@ export function repairHistory(history) {
|
|
|
91
99
|
if (m.role !== "assistant" || !m.tool_calls?.length) continue
|
|
92
100
|
|
|
93
101
|
for (const tc of m.tool_calls) knownIds.add(tc.id)
|
|
94
|
-
//
|
|
102
|
+
// collect tool result ids that immediately follow (before the next non-tool message)
|
|
95
103
|
const answered = new Set()
|
|
96
104
|
let j = i + 1
|
|
97
105
|
while (j < history.length && history[j].role === "tool") {
|
|
@@ -99,11 +107,11 @@ export function repairHistory(history) {
|
|
|
99
107
|
answered.add(history[j].tool_call_id)
|
|
100
108
|
out.push(history[j])
|
|
101
109
|
} else {
|
|
102
|
-
dirty = true //
|
|
110
|
+
dirty = true // orphan tool result, discard
|
|
103
111
|
}
|
|
104
112
|
j++
|
|
105
113
|
}
|
|
106
|
-
i = j - 1 //
|
|
114
|
+
i = j - 1 // outer for will increment again
|
|
107
115
|
|
|
108
116
|
for (const tc of m.tool_calls) {
|
|
109
117
|
if (!answered.has(tc.id)) {
|
|
@@ -119,6 +127,7 @@ export function repairHistory(history) {
|
|
|
119
127
|
return dirty ? out : history
|
|
120
128
|
}
|
|
121
129
|
|
|
130
|
+
/** List working directory contents as a tree (directories expanded up to subMax entries each) */
|
|
122
131
|
export function listWorkDir(cwd, { rootMax = 30, subMax = 10 } = {}) {
|
|
123
132
|
const SKIP = new Set([".git", "node_modules"])
|
|
124
133
|
let entries
|
|
@@ -158,12 +167,14 @@ export function listWorkDir(cwd, { rootMax = 30, subMax = 10 } = {}) {
|
|
|
158
167
|
return lines.join("\n")
|
|
159
168
|
}
|
|
160
169
|
|
|
170
|
+
/** Return the set of tool names that are marked as read-only */
|
|
161
171
|
export function readonlyToolNames(tools) {
|
|
162
172
|
return new Set(tools.filter((t) => t.readonly).map((t) => t.name))
|
|
163
173
|
}
|
|
164
174
|
|
|
165
175
|
const MAX_INSTRUCTION_CHARS = 32_000
|
|
166
176
|
|
|
177
|
+
/** Load AGENTS.md / project_rules.md from the project root, return as project instructions */
|
|
167
178
|
export async function loadProjectInstructions(cwd) {
|
|
168
179
|
const parts = []
|
|
169
180
|
for (const name of ["AGENTS.md", "project_rules.md"]) {
|
|
@@ -172,7 +183,7 @@ export async function loadProjectInstructions(cwd) {
|
|
|
172
183
|
if (!content) continue
|
|
173
184
|
const key = name.toLowerCase()
|
|
174
185
|
parts.push(`<!-- From: ${join(cwd, name)} -->\n${content}`)
|
|
175
|
-
} catch { /*
|
|
186
|
+
} catch { /* file does not exist */ }
|
|
176
187
|
}
|
|
177
188
|
const merged = parts.join("\n\n")
|
|
178
189
|
if (!merged) return ""
|
package/src/agent/setup.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agent/setup.mjs — runAgent
|
|
2
|
+
* agent/setup.mjs — runAgent pre-flight setup: context injection, system prompt construction, tool injection
|
|
3
3
|
*/
|
|
4
4
|
import { compressIfNeeded, compressFallback, COMPRESS_FAILURE_LIMIT } from "../context.mjs"
|
|
5
5
|
import { search as memorySearch, docSearch } from "../memory.mjs"
|
|
@@ -14,26 +14,41 @@ import {
|
|
|
14
14
|
} from "./helpers.mjs"
|
|
15
15
|
|
|
16
16
|
const AUTO_REMINDER = "[System reminder: AUTO mode is active — all tool calls are automatically approved without asking.]"
|
|
17
|
+
const DEFAULT_COMPACT_THRESHOLD = 100_000
|
|
18
|
+
const DOC_SEARCH_LIMIT = 5
|
|
19
|
+
const DOC_CHUNK_PREVIEW_LEN = 300
|
|
20
|
+
const MEMORY_SEARCH_LIMIT = 3
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
23
|
+
* Prepare an agent run: inject context, build system prompt, inject tools.
|
|
24
|
+
* Returns all state needed by the main loop, and writes initialization messages into agent.history.
|
|
21
25
|
*/
|
|
22
26
|
export async function prepareRun(agent, input, callbacks, {
|
|
23
27
|
depth = 0, signal, overrideTurns, resume, systemPrompt: corePrompt, disciplineRules, mainOverlay,
|
|
24
28
|
} = {}) {
|
|
25
29
|
const maxTurns = overrideTurns ?? agent.config?.agent?.maxTurns ?? DEFAULT_MAX_TURNS
|
|
26
|
-
const threshold = agent.config?.agent?.compactThreshold ??
|
|
30
|
+
const threshold = agent.config?.agent?.compactThreshold ?? DEFAULT_COMPACT_THRESHOLD
|
|
27
31
|
|
|
28
32
|
agent._lastPromptTokens = null
|
|
29
33
|
agent._usageAtLen = null
|
|
30
34
|
agent.history = repairHistory(agent.history)
|
|
31
35
|
|
|
32
36
|
if (!resume) {
|
|
37
|
+
// Git context: branch, recent commits, uncommitted changes
|
|
38
|
+
if (depth === 0) {
|
|
39
|
+
const gitCtx = collectGitContext(agent.cwd)
|
|
40
|
+
if (gitCtx) {
|
|
41
|
+
agent.history.push({
|
|
42
|
+
role: "user",
|
|
43
|
+
content: `[System reminder: git context:\n${escapeXml(gitCtx)}]`,
|
|
44
|
+
transient: true,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
33
48
|
if (depth === 0) {
|
|
34
49
|
const tree = listWorkDir(agent.cwd)
|
|
35
50
|
if (tree) {
|
|
36
|
-
agent.history.push({ role: "user", content: `[System reminder: working directory snapshot:\n<untrusted_cwd_listing>\n${escapeXml(tree)}\n</untrusted_cwd_listing>]`, transient: true })
|
|
51
|
+
agent.history.push({ role: "user", content: `[System reminder: working directory snapshot (${new Date().toISOString()}):\n<untrusted_cwd_listing>\n${escapeXml(tree)}\n</untrusted_cwd_listing>]`, transient: true })
|
|
37
52
|
}
|
|
38
53
|
if (agent.memory && !agent.history.some((m) => typeof m.content === "string" && m.content.startsWith(OUTLINE_INJECT_PREFIX))) {
|
|
39
54
|
try {
|
|
@@ -42,11 +57,11 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
42
57
|
if (summary && !summary.startsWith("(no indexed")) {
|
|
43
58
|
agent.history.push({ role: "user", content: `${OUTLINE_INJECT_PREFIX}\n${summary}]`, transient: true })
|
|
44
59
|
}
|
|
45
|
-
} catch { /*
|
|
60
|
+
} catch { /* index not ready — suppress error */ }
|
|
46
61
|
}
|
|
47
62
|
}
|
|
48
63
|
if (agent.memory) {
|
|
49
|
-
const docs = await docSearch(agent.memory, input, { limit:
|
|
64
|
+
const docs = await docSearch(agent.memory, input, { limit: DOC_SEARCH_LIMIT })
|
|
50
65
|
if (docs.length > 0) {
|
|
51
66
|
const count = agent.memory.db.prepare(`SELECT COUNT(*) AS n FROM doc_chunks`).get()?.n ?? 0
|
|
52
67
|
const more = count > docs.length ? ` (${count} chunks indexed total — call doc_search if you need more)` : ""
|
|
@@ -54,12 +69,12 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
54
69
|
role: "user",
|
|
55
70
|
content:
|
|
56
71
|
`[Relevant documentation${more}:\n` +
|
|
57
|
-
docs.map((d) => `- ${d.path}${d.heading ? " > " + d.heading : ""}: <untrusted_doc_chunk>${escapeXml(d.content.slice(0,
|
|
72
|
+
docs.map((d) => `- ${d.path}${d.heading ? " > " + d.heading : ""}: <untrusted_doc_chunk>${escapeXml(d.content.slice(0, DOC_CHUNK_PREVIEW_LEN))}</untrusted_doc_chunk>`).join("\n") +
|
|
58
73
|
"]",
|
|
59
74
|
transient: true,
|
|
60
75
|
})
|
|
61
76
|
}
|
|
62
|
-
const memories = await memorySearch(agent.memory, input, { limit:
|
|
77
|
+
const memories = await memorySearch(agent.memory, input, { limit: MEMORY_SEARCH_LIMIT })
|
|
63
78
|
if (memories.length > 0) {
|
|
64
79
|
agent.history.push({
|
|
65
80
|
role: "user",
|
|
@@ -71,6 +86,13 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
71
86
|
})
|
|
72
87
|
}
|
|
73
88
|
}
|
|
89
|
+
if (depth === 0) {
|
|
90
|
+
agent.history.push({
|
|
91
|
+
role: "user",
|
|
92
|
+
content: `[System reminder: current time is ${new Date().toISOString()}.]`,
|
|
93
|
+
transient: true,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
74
96
|
agent.history.push({ role: "user", content: input })
|
|
75
97
|
}
|
|
76
98
|
|
|
@@ -81,7 +103,7 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
81
103
|
agent._pendingReminders = []
|
|
82
104
|
}
|
|
83
105
|
|
|
84
|
-
// task/plan
|
|
106
|
+
// task/plan tools are injected with the main loop; subagent/skill/goal/verify only at top level
|
|
85
107
|
const { planTool, subagentTool, taskTool, skillTool, goalTool, verifyTool, recentChangesTool } = await import("../agent-tools.mjs")
|
|
86
108
|
const tools = [...agent.tools, taskTool, planTool, ...(depth === 0 ? [subagentTool, skillTool, goalTool, verifyTool, recentChangesTool] : [])]
|
|
87
109
|
const toolSchemas = tools.map(toOpenAISchema)
|
package/src/agent-tools/goal.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* goal
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* goal tool: lifecycle management for long-running autonomous goals (completion contract).
|
|
3
|
+
* Three states: active / complete / blocked. Completion must pass a verify evidence threshold;
|
|
4
|
+
* blocked is only accepted after the same condition persists 3 consecutive times.
|
|
5
|
+
* The system injects status + budget progress + audit discipline every turn.
|
|
5
6
|
*/
|
|
6
7
|
export const goalTool = {
|
|
7
8
|
name: "goal",
|
|
@@ -39,7 +40,7 @@ export const goalTool = {
|
|
|
39
40
|
setAt: Date.now(),
|
|
40
41
|
status: "active",
|
|
41
42
|
turnsUsed: 0,
|
|
42
|
-
_blockTally: null, // { reason, count } —
|
|
43
|
+
_blockTally: null, // { reason, count } — consecutive count of the same blocking condition (for blocked audit)
|
|
43
44
|
}
|
|
44
45
|
return `Goal set: ${agent.goal.objective}\nDone when: ${agent.goal.criteria}\nThe system will inject goal status every turn. Completion and blocked claims are audited — see the reminders.`
|
|
45
46
|
}
|
|
@@ -47,7 +48,7 @@ export const goalTool = {
|
|
|
47
48
|
return `Error: no active goal to '${args.action}' (current: ${agent.goal?.status ?? "none"}). Set one first.`
|
|
48
49
|
}
|
|
49
50
|
if (args.action === "complete") {
|
|
50
|
-
//
|
|
51
|
+
// Evidence chain threshold: files were mutated this run without verify — refuse completion (aligns with completion guard)
|
|
51
52
|
if (agent._mutatedThisRun && !agent._verifiedThisRun) {
|
|
52
53
|
return "Error: files were modified but verify has not run. Run the check your criteria names AND the verify tool before marking the goal complete — false completion is the worst outcome of autonomous work."
|
|
53
54
|
}
|
|
@@ -56,7 +57,7 @@ export const goalTool = {
|
|
|
56
57
|
}
|
|
57
58
|
if (args.action === "blocked") {
|
|
58
59
|
if (!args.reason) return "Error: 'reason' required for 'blocked' action."
|
|
59
|
-
//
|
|
60
|
+
// Blocked audit: same condition must appear 3 consecutive times (only counts as real blocking if different approaches still hit the same wall)
|
|
60
61
|
const tally = agent.goal._blockTally
|
|
61
62
|
const count = tally?.reason === args.reason ? tally.count + 1 : 1
|
|
62
63
|
agent.goal._blockTally = { reason: args.reason, count }
|
package/src/agent-tools/plan.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* plan
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* plan tool: enter/exit plan mode.
|
|
3
|
+
* In plan mode only read-only tools are allowed — explore code, design solutions, no code writing.
|
|
4
|
+
* After the user approves the plan, exit plan mode and start implementing.
|
|
5
5
|
*/
|
|
6
6
|
export const planTool = {
|
|
7
7
|
name: "plan",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* recent_changes
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* recent_changes tool: list files touched by this agent run (write/edit/insert_after/delete).
|
|
3
|
+
* More precise than git status — only looks at this session's changes, independent of git tracking.
|
|
4
|
+
* Helps the model recall what it already modified during long tasks.
|
|
5
5
|
*/
|
|
6
6
|
export const recentChangesTool = {
|
|
7
7
|
name: "recent_changes",
|
|
@@ -2,9 +2,9 @@ import { loadSkills, formatSkillListing, readSkill } from "../skills.mjs"
|
|
|
2
2
|
import { escapeXml } from "../agent.mjs"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* skill
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* skill tool: load project skill files on demand (.thincoder/skills/*.md).
|
|
6
|
+
* After loading, skill content is injected into the conversation wrapped in <skill-loaded> for subsequent reference.
|
|
7
|
+
* Use action="list" to see all available skills.
|
|
8
8
|
*/
|
|
9
9
|
export const skillTool = {
|
|
10
10
|
name: "skill",
|
|
@@ -26,8 +26,8 @@ export const skillTool = {
|
|
|
26
26
|
return skills.map((s) => `- ${s.name}: ${s.description}`).join("\n")
|
|
27
27
|
}
|
|
28
28
|
if (!args.name) return "Error: skill name required for 'load' action."
|
|
29
|
-
//
|
|
30
|
-
//
|
|
29
|
+
// Dedup: skip reloading if history already contains an <skill-loaded> block with the same name
|
|
30
|
+
// (history is the ledger; if it got compacted away we naturally won't find it here — correct behavior)
|
|
31
31
|
if (ctx.agent.history?.some((m) => typeof m.content === "string" && m.content.includes(`<skill-loaded name="${args.name}"`))) {
|
|
32
32
|
return `Skill "${args.name}" is already loaded in this conversation — follow the instructions in the existing <skill-loaded> block above. Do not reload it.`
|
|
33
33
|
}
|
|
@@ -36,7 +36,7 @@ export const skillTool = {
|
|
|
36
36
|
const available = skills.map((s) => s.name).join(", ")
|
|
37
37
|
return `Error: skill "${args.name}" not found. Available: ${available || "(none)"}`
|
|
38
38
|
}
|
|
39
|
-
//
|
|
39
|
+
// Inject skill content into history (will appear as the next user message)
|
|
40
40
|
ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
|
|
41
41
|
ctx.agent._pendingReminders.push(
|
|
42
42
|
`<skill-loaded name="${args.name}" source=".thincoder/skills/${args.name}.md">\n${escapeXml(content)}\n</skill-loaded>\n\nFollow the skill's instructions above for the current task.`
|