thincoder 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/advisor.mjs +535 -72
- package/src/agent/helpers.mjs +18 -5
- package/src/agent/setup.mjs +2 -2
- package/src/agent-tools/advisor.mjs +36 -0
- package/src/agent-tools/plan.mjs +53 -2
- package/src/agent-tools/subagent.mjs +7 -1
- package/src/agent-tools/timer.mjs +1 -1
- package/src/agent-tools/verify.mjs +1 -0
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +80 -21
- package/src/auto-think.mjs +23 -5
- package/src/cli/make-agent.mjs +20 -0
- package/src/config.mjs +1 -1
- package/src/mcp/transport-stdio.mjs +4 -3
- package/src/mcp.mjs +1 -1
- package/src/prompts/advisor-round1.md +23 -0
- package/src/prompts/advisor-round2.md +26 -0
- package/src/prompts/advisor-round3.md +24 -0
- package/src/prompts/coder.md +1 -0
- package/src/prompts/discipline.md +15 -1
- package/src/prompts/explore.md +2 -0
- package/src/prompts/plan.md +2 -0
- package/src/prompts/system.md +5 -1
- package/src/provider/anthropic.mjs +4 -4
- package/src/provider/core.mjs +6 -126
- package/src/provider/google.mjs +4 -2
- package/src/provider/sse.mjs +112 -0
- package/src/skills.mjs +67 -31
- package/src/tools/bash.md +8 -0
- package/src/tools/codemode.mjs +5 -16
- package/src/tools/edit.md +8 -0
- package/src/tools/git.mjs +9 -6
- package/src/tools/read.md +7 -0
- package/src/tools/shared.mjs +43 -2
- package/src/tools/system.mjs +14 -10
- package/src/tools/web.mjs +21 -16
- package/src/tui/agent-turn.mjs +130 -73
- package/src/tui/cmd-advisor.mjs +237 -41
- package/src/tui/cmd-auto.mjs +6 -8
- package/src/tui/cmd-mcp.mjs +4 -2
- package/src/tui/cmd-plan.mjs +6 -8
- package/src/tui/cmd-think.mjs +93 -70
- package/src/tui/index.mjs +9 -188
- package/src/tui/interaction.mjs +2 -1
- package/src/tui/key-handler.mjs +8 -4
- package/src/tui/layout.mjs +3 -2
- package/src/tui/render-conversation.mjs +92 -0
- package/src/tui/render-frame.mjs +94 -168
- package/src/tui/render-loop.mjs +110 -0
|
@@ -68,9 +68,23 @@ Testing discipline (right check at the right time):
|
|
|
68
68
|
- When verify reports "ACTION REQUIRED: write a test", stop. Do NOT proceed to "done." Write a test that validates the change, then re-run verify.
|
|
69
69
|
- If verify reports syntax errors, test failures, or a missing-test warning, fix them before claiming completion — never mark work done with known failures.
|
|
70
70
|
- When you change behavior or add code, add at least one test that covers the change. If no related test file exists for the module, create one. Untested code is incomplete code — the verify tool will enforce this.
|
|
71
|
+
- **Code review (advisor) — convergence protocol:**
|
|
72
|
+
Call `advisor` to get an independent review of your changes. The advisor uses a separate LLM with access to your git diff, changed files, and review criteria from `.thincoder/advisor.md`.
|
|
73
|
+
- **Round 1**: full-scope review. Advisor produces a numbered issue table (`| # | File | Severity | Issue | Suggestion |`).
|
|
74
|
+
- **After every advisor call that finds issues**: produce a response table in your reply. Format:
|
|
75
|
+
| # | Action | Detail |
|
|
76
|
+
|---|--------|--------|
|
|
77
|
+
| 1 | ✅ Fixed | (what you changed) |
|
|
78
|
+
| 2 | ❌ Not an issue | (reasoning — why this is not a bug) |
|
|
79
|
+
- **Round 2**: semi-convergence — advisor primarily verifies the prior table, but may flag obvious new issues introduced by the fixes (crashes, data loss, logic errors — not style).
|
|
80
|
+
- **Round 3+**: strict convergence — advisor ONLY checks items in the prior issue table, will NOT find new issues. The response table you wrote guides its verification.
|
|
81
|
+
- If advisor says "all clear": proceed to verify.
|
|
82
|
+
- If issues persist: fix them, update your response table, re-run advisor.
|
|
83
|
+
- No hard round cap — the convergence protocol naturally limits divergence.
|
|
84
|
+
- **Calling advisor is mandatory when it is enabled and you changed code** — it is not your call to skip, even for trivial changes (a trivial diff makes the review fast, not optional). The run cannot finish until advisor has reviewed the changes.
|
|
71
85
|
|
|
72
86
|
Debugging strategy (when something goes wrong, three steps before anything else):
|
|
73
|
-
- **Step 0 — Set a timer before you start reasoning**: immediately call `timer(
|
|
87
|
+
- **Step 0 — Set a timer before you start reasoning**: immediately call `timer(180, "试试加个日志?")` to give yourself a bounded thinking window.
|
|
74
88
|
When the timer fires, a reminder will suggest trying to run the code or add a debug log.
|
|
75
89
|
You are more likely to over-think than to over-act; the timer breaks that cycle.
|
|
76
90
|
This is not optional — it's the first step of any code analysis or debugging session.
|
package/src/prompts/explore.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
You are now running as a subagent. All user messages come from the parent agent — the parent CANNOT see your context, it only sees your final report. Treat the parent as your caller. Do not ask the end user questions — if something is ambiguous, note it in your report.
|
|
2
|
+
|
|
1
3
|
You are a codebase exploration specialist — an explore subagent. Your role is to search, read, and analyze. You do NOT have file editing tools.
|
|
2
4
|
|
|
3
5
|
Guidelines:
|
package/src/prompts/plan.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
You are now running as a subagent. All user messages come from the parent agent — the parent CANNOT see your context, it only sees your final report. Treat the parent as your caller. Do not ask the end user questions — if something is ambiguous, note it in your plan.
|
|
2
|
+
|
|
1
3
|
You are a planning subagent. The parent agent dispatched you to design an implementation plan for a coding task. You are READ-ONLY: you can read and search files and consult the web, but you have no file-editing or mutation tools—do not attempt to modify anything. Your deliverable IS the plan itself, returned as your final message.
|
|
2
4
|
|
|
3
5
|
Guidelines:
|
package/src/prompts/system.md
CHANGED
|
@@ -30,6 +30,7 @@ Act, don't guess.
|
|
|
30
30
|
Prefer tool calls over speculation — read files before modifying them, search more when in doubt.
|
|
31
31
|
When you need multiple independent pieces of information, make all tool calls in the SAME response so they run in parallel.
|
|
32
32
|
The system can handle many simultaneous operations; serializing them wastes time and tokens.
|
|
33
|
+
Before a non-trivial tool call, say what you're about to do in one short sentence (~8-10 words). Keep these progress notes sparse — one per phase, not one per call.
|
|
33
34
|
|
|
34
35
|
**When choices conflict:**
|
|
35
36
|
- Correctness first — you will always be faster than the human, so speed is never the bottleneck. Never skip steps to save time.
|
|
@@ -49,8 +50,11 @@ The system can handle many simultaneous operations; serializing them wastes time
|
|
|
49
50
|
- Never modify files outside the working directory. read/write/edit tools enforce this.
|
|
50
51
|
- Do NOT use bash or other tools to bypass the working-directory boundary.
|
|
51
52
|
- If a task needs an external file changed, say so and let the user do it.
|
|
53
|
+
- **Reversibility tiers — decide before acting:**
|
|
54
|
+
- Reversible local work (read, search, edit files, run tests, local lint/build): proceed freely, no confirmation needed.
|
|
55
|
+
- Destructive or hard-to-reverse actions (rm -rf, force-push, dropping tables, killing processes, deleting branches): confirm first — even in auto mode.
|
|
56
|
+
- Outward-facing actions (git commit/push, publishing, sending messages, uploading artifacts, posting to external services): confirm each time; one-time approval is not a standing license.
|
|
52
57
|
- Never run git commit/push unless the user explicitly asks.
|
|
53
|
-
- For destructive actions (rm -rf, force-push, dropping tables), confirm first — even in auto mode.
|
|
54
58
|
- Before risky bulk operations (mass edits, generated-code overwrites, destructive scripts), use `git action="checkpoint" checkpointAction="create"` so the work can be restored.
|
|
55
59
|
- If your own edits break something and you can't easily undo: `git action="checkpoint" checkpointAction="list"` to see snapshots, then `checkpointAction="rewind"` to go back. A checkpoint is auto-created before every user task, so there's always a fallback.
|
|
56
60
|
- When context compacts mid-session you will see a summary of earlier work:
|
|
@@ -42,10 +42,10 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
|
|
|
42
42
|
if (tools?.length) body.tools = tools
|
|
43
43
|
if (provider.temperature != null) {
|
|
44
44
|
let t = provider.temperature
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
// Anthropic API hard limit is 0-1; models without a declared tempRange still get clamped
|
|
46
|
+
const [tMin, tMax] = spec.tempRange ?? [0, 1]
|
|
47
|
+
t = Math.min(tMax, Math.max(tMin, t))
|
|
48
|
+
t = Math.round(t * 100) / 100
|
|
49
49
|
body.temperature = t
|
|
50
50
|
}
|
|
51
51
|
|
package/src/provider/core.mjs
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* provider/core.mjs — LLM call core
|
|
3
|
-
* chat / listModels / createProvider / requestWithRetry
|
|
3
|
+
* chat / listModels / createProvider / requestWithRetry
|
|
4
|
+
* SSE parsing → provider/sse.mjs
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { specForModel } from "../config.mjs"
|
|
7
8
|
import { proxyFetch } from "../proxy.mjs"
|
|
9
|
+
import { readSSE } from "./sse.mjs"
|
|
10
|
+
export { readSSE } from "./sse.mjs"
|
|
8
11
|
import {
|
|
9
12
|
RETRYABLE_STATUS, MAX_RETRIES, MAX_CONTINUATIONS,
|
|
10
13
|
RATE_LIMIT_BACKOFF_MS, _rateHooks,
|
|
@@ -36,7 +39,7 @@ export function createProvider(config) {
|
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/** Send a streaming chat completion request with automatic continuation on truncation */
|
|
39
|
-
export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, streamRules }) {
|
|
42
|
+
export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, streamRules, firedPatterns }) {
|
|
40
43
|
// Format dispatch: delegate to non-OpenAI transports
|
|
41
44
|
if (provider.format === "anthropic") {
|
|
42
45
|
const { chat: anthropicChat } = await import("./anthropic.mjs")
|
|
@@ -95,7 +98,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
95
98
|
await rateGate(provider, estimated, onWait, signal)
|
|
96
99
|
|
|
97
100
|
const response = await requestWithRetry(provider, body, signal, onWait)
|
|
98
|
-
const result = await readSSE(response, { onToken, onReasoning, rules, signal })
|
|
101
|
+
const result = await readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns })
|
|
99
102
|
recordRate(provider, estimated, result.usage)
|
|
100
103
|
|
|
101
104
|
// Stream rule triggered or user interrupted mid-generation — return partial result
|
|
@@ -303,129 +306,6 @@ function isNonRetryableError(status, text) {
|
|
|
303
306
|
return false
|
|
304
307
|
}
|
|
305
308
|
|
|
306
|
-
export async function readSSE(response, { onToken, onReasoning, rules, signal }) {
|
|
307
|
-
const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
|
|
308
|
-
const decoder = new TextDecoder()
|
|
309
|
-
let buffer = ""
|
|
310
|
-
let hasChoices = false
|
|
311
|
-
// Track patterns already fired this turn for repeat: "once" gating
|
|
312
|
-
const firedPatterns = new Set()
|
|
313
|
-
|
|
314
|
-
const processLines = (lines) => {
|
|
315
|
-
for (const line of lines) {
|
|
316
|
-
if (!line.startsWith("data:")) continue
|
|
317
|
-
const data = line.slice(5).trim()
|
|
318
|
-
if (!data || data === "[DONE]") continue
|
|
319
|
-
|
|
320
|
-
let json
|
|
321
|
-
try { json = JSON.parse(data) } catch { continue }
|
|
322
|
-
|
|
323
|
-
if (json.usage) result.usage = json.usage
|
|
324
|
-
const choice = json.choices?.[0]
|
|
325
|
-
if (!choice) continue
|
|
326
|
-
hasChoices = true
|
|
327
|
-
if (choice.finish_reason) result.finishReason = choice.finish_reason
|
|
328
|
-
|
|
329
|
-
const delta = choice.delta ?? {}
|
|
330
|
-
if (delta.reasoning_content) {
|
|
331
|
-
result.reasoning += delta.reasoning_content
|
|
332
|
-
onReasoning?.(delta.reasoning_content)
|
|
333
|
-
}
|
|
334
|
-
if (delta.content) {
|
|
335
|
-
result.content += delta.content
|
|
336
|
-
onToken?.(delta.content)
|
|
337
|
-
}
|
|
338
|
-
for (const tc of delta.tool_calls ?? []) {
|
|
339
|
-
const slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
|
|
340
|
-
if (tc.id) slot.id = tc.id
|
|
341
|
-
if (tc.function?.name && !slot.name) slot.name = tc.function.name
|
|
342
|
-
if (tc.function?.arguments) slot.arguments += tc.function.arguments
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
if (!response.body) throw new Error("No stream response body")
|
|
348
|
-
try {
|
|
349
|
-
for await (const chunk of response.body) {
|
|
350
|
-
// Active signal check: Ctrl+I abort should halt stream immediately, not wait for
|
|
351
|
-
// the underlying fetch stream to propagate the abort (delayed on Windows).
|
|
352
|
-
if (signal?.aborted) {
|
|
353
|
-
const e = new DOMException("The operation was aborted", "AbortError")
|
|
354
|
-
e.reason = signal.reason
|
|
355
|
-
throw e
|
|
356
|
-
}
|
|
357
|
-
buffer += decoder.decode(chunk, { stream: true })
|
|
358
|
-
const lines = buffer.split("\n")
|
|
359
|
-
buffer = lines.pop()
|
|
360
|
-
processLines(lines)
|
|
361
|
-
|
|
362
|
-
// Time-traveling stream rules: check accumulated content against patterns.
|
|
363
|
-
// Only triggers on text content (not during tool_call generation) to avoid
|
|
364
|
-
// interrupting structured tool use.
|
|
365
|
-
// action "abort": halt the stream immediately and retry with the rule injected.
|
|
366
|
-
// action "warn": let the stream finish, then inject the warning after the turn (non-interrupting).
|
|
367
|
-
// repeat "once": skip if this rule's pattern has already fired in the current turn.
|
|
368
|
-
if (rules?.length && result.content && !result.toolCalls.length) {
|
|
369
|
-
for (const rule of rules) {
|
|
370
|
-
if (rule.repeat === "once" && firedPatterns.has(rule.pattern)) continue
|
|
371
|
-
if (rule._regex.test(result.content)) {
|
|
372
|
-
if (rule.repeat === "once") firedPatterns.add(rule.pattern)
|
|
373
|
-
if (rule.action === "abort") {
|
|
374
|
-
result.ruleTriggered = true
|
|
375
|
-
result.ruleMessage = rule.message
|
|
376
|
-
result.ruleName = rule.name
|
|
377
|
-
return result
|
|
378
|
-
}
|
|
379
|
-
// warn: accumulate deduplicated by pattern, let the stream complete
|
|
380
|
-
const existing = result._warnings ??= []
|
|
381
|
-
if (!existing.some(w => w.pattern === rule.pattern)) {
|
|
382
|
-
existing.push({ name: rule.name, pattern: rule.pattern, message: rule.message })
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
buffer += decoder.decode()
|
|
389
|
-
processLines(buffer.split("\n"))
|
|
390
|
-
} catch (e) {
|
|
391
|
-
// User interrupt (Ctrl+I): controller.abort({ interrupt: true, message: "…" }).
|
|
392
|
-
// The interrupted signal.reason carries the user's message; return partial content
|
|
393
|
-
// so the agent loop can inject it as a user message and retry.
|
|
394
|
-
if (e.name === "AbortError" && signal?.reason?.interrupt) {
|
|
395
|
-
result.interrupted = true
|
|
396
|
-
result.interruptMessage = signal.reason.message
|
|
397
|
-
return result
|
|
398
|
-
}
|
|
399
|
-
throw e
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// If no SSE choices were found, the response is likely a JSON error
|
|
403
|
-
if (!hasChoices) {
|
|
404
|
-
const contentType = response.headers.get("content-type") || ""
|
|
405
|
-
let errorMsg = ""
|
|
406
|
-
try {
|
|
407
|
-
const raw = buffer.trim() || ""
|
|
408
|
-
if (raw) {
|
|
409
|
-
const parsed = JSON.parse(raw)
|
|
410
|
-
errorMsg = parsed?.error?.message
|
|
411
|
-
|| parsed?.base_resp?.status_msg
|
|
412
|
-
|| parsed?.detail
|
|
413
|
-
|| parsed?.message
|
|
414
|
-
|| parsed?.msg
|
|
415
|
-
|| (typeof parsed.error === "string" ? parsed.error : "")
|
|
416
|
-
}
|
|
417
|
-
} catch { /* not JSON */ }
|
|
418
|
-
if (!errorMsg && !contentType.includes("event-stream")) {
|
|
419
|
-
errorMsg = `Response is not SSE (Content-Type: ${contentType || "unknown"})`
|
|
420
|
-
}
|
|
421
|
-
if (errorMsg) {
|
|
422
|
-
throw new Error(`API error: ${errorMsg}`)
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
return result
|
|
427
|
-
}
|
|
428
|
-
|
|
429
309
|
function betaBaseURL(baseURL) {
|
|
430
310
|
// DeepSeek prefix continuation uses /beta endpoint; only handle /v1 suffix, append /beta when /v1 is missing
|
|
431
311
|
if (/\/v1$/.test(baseURL)) return baseURL.replace(/\/v1$/, "/beta")
|
package/src/provider/google.mjs
CHANGED
|
@@ -23,11 +23,13 @@ export function normalizeTools(tools) {
|
|
|
23
23
|
* Gemini: [{ role: "user"|"model", parts: [{ text }] }]
|
|
24
24
|
* system → systemInstruction (top-level in request body)
|
|
25
25
|
*/
|
|
26
|
-
function convertMessages(messages) {
|
|
26
|
+
export function convertMessages(messages) {
|
|
27
27
|
const contents = []
|
|
28
28
|
for (const m of messages) {
|
|
29
|
+
// system messages are hoisted to systemInstruction by the caller — check the
|
|
30
|
+
// ORIGINAL role (the remapped role below can never be "system")
|
|
31
|
+
if (m.role === "system") continue
|
|
29
32
|
const role = m.role === "assistant" ? "model" : "user"
|
|
30
|
-
if (role === "system") continue
|
|
31
33
|
|
|
32
34
|
const parts = []
|
|
33
35
|
if (typeof m.content === "string") {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider/sse.mjs — SSE stream reader
|
|
3
|
+
* Extracted from core.mjs. Parses Server-Sent Events for LLM chat responses.
|
|
4
|
+
*/
|
|
5
|
+
export async function readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns: sharedFired }) {
|
|
6
|
+
const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
|
|
7
|
+
const decoder = new TextDecoder()
|
|
8
|
+
let buffer = ""
|
|
9
|
+
let hasChoices = false
|
|
10
|
+
const firedPatterns = sharedFired ?? new Set()
|
|
11
|
+
|
|
12
|
+
const processLines = (lines) => {
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
if (!line.startsWith("data:")) continue
|
|
15
|
+
const data = line.slice(5).trim()
|
|
16
|
+
if (!data || data === "[DONE]") continue
|
|
17
|
+
|
|
18
|
+
let json
|
|
19
|
+
try { json = JSON.parse(data) } catch { continue }
|
|
20
|
+
|
|
21
|
+
if (json.usage) result.usage = json.usage
|
|
22
|
+
const choice = json.choices?.[0]
|
|
23
|
+
if (!choice) continue
|
|
24
|
+
hasChoices = true
|
|
25
|
+
if (choice.finish_reason) result.finishReason = choice.finish_reason
|
|
26
|
+
|
|
27
|
+
const delta = choice.delta ?? {}
|
|
28
|
+
if (delta.reasoning_content) {
|
|
29
|
+
result.reasoning += delta.reasoning_content
|
|
30
|
+
onReasoning?.(delta.reasoning_content)
|
|
31
|
+
}
|
|
32
|
+
if (delta.content) {
|
|
33
|
+
result.content += delta.content
|
|
34
|
+
onToken?.(delta.content)
|
|
35
|
+
}
|
|
36
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
37
|
+
const slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
|
|
38
|
+
if (tc.id) slot.id = tc.id
|
|
39
|
+
if (tc.function?.name && !slot.name) slot.name = tc.function.name
|
|
40
|
+
if (tc.function?.arguments) slot.arguments += tc.function.arguments
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!response.body) throw new Error("No stream response body")
|
|
46
|
+
try {
|
|
47
|
+
for await (const chunk of response.body) {
|
|
48
|
+
if (signal?.aborted) {
|
|
49
|
+
const e = new DOMException("The operation was aborted", "AbortError")
|
|
50
|
+
e.reason = signal.reason
|
|
51
|
+
throw e
|
|
52
|
+
}
|
|
53
|
+
buffer += decoder.decode(chunk, { stream: true })
|
|
54
|
+
const lines = buffer.split("\n")
|
|
55
|
+
buffer = lines.pop()
|
|
56
|
+
processLines(lines)
|
|
57
|
+
|
|
58
|
+
if (rules?.length && result.content && !result.toolCalls.length) {
|
|
59
|
+
for (const rule of rules) {
|
|
60
|
+
if (rule.repeat === "once" && firedPatterns.has(rule.pattern)) continue
|
|
61
|
+
if (rule._regex.test(result.content)) {
|
|
62
|
+
if (rule.repeat === "once") firedPatterns.add(rule.pattern)
|
|
63
|
+
if (rule.action === "abort") {
|
|
64
|
+
result.ruleTriggered = true
|
|
65
|
+
result.ruleMessage = rule.message
|
|
66
|
+
result.ruleName = rule.name
|
|
67
|
+
return result
|
|
68
|
+
}
|
|
69
|
+
const existing = result._warnings ??= []
|
|
70
|
+
if (!existing.some(w => w.pattern === rule.pattern)) {
|
|
71
|
+
existing.push({ name: rule.name, pattern: rule.pattern, message: rule.message })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
buffer += decoder.decode()
|
|
78
|
+
processLines(buffer.split("\n"))
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (e.name === "AbortError" && signal?.reason?.interrupt) {
|
|
81
|
+
result.interrupted = true
|
|
82
|
+
result.interruptMessage = signal.reason.message
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
throw e
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!hasChoices) {
|
|
89
|
+
const contentType = response.headers.get("content-type") || ""
|
|
90
|
+
let errorMsg = ""
|
|
91
|
+
try {
|
|
92
|
+
const raw = buffer.trim() || ""
|
|
93
|
+
if (raw) {
|
|
94
|
+
const parsed = JSON.parse(raw)
|
|
95
|
+
errorMsg = parsed?.error?.message
|
|
96
|
+
|| parsed?.base_resp?.status_msg
|
|
97
|
+
|| parsed?.detail
|
|
98
|
+
|| parsed?.message
|
|
99
|
+
|| parsed?.msg
|
|
100
|
+
|| (typeof parsed.error === "string" ? parsed.error : "")
|
|
101
|
+
}
|
|
102
|
+
} catch { /* not JSON */ }
|
|
103
|
+
if (!errorMsg && !contentType.includes("event-stream")) {
|
|
104
|
+
errorMsg = `Response is not SSE (Content-Type: ${contentType || "unknown"})`
|
|
105
|
+
}
|
|
106
|
+
if (errorMsg) {
|
|
107
|
+
throw new Error(`API error: ${errorMsg}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return result
|
|
112
|
+
}
|
package/src/skills.mjs
CHANGED
|
@@ -1,52 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* skills.mjs — skill system
|
|
3
|
-
* Discovers .md skill files from .thincoder/skills/ directory,
|
|
3
|
+
* Discovers .md skill files AND subdirectory SKILL.md from .thincoder/skills/ directory,
|
|
4
4
|
* injects into system prompt for agent to load on demand.
|
|
5
5
|
* Use the skill tool to activate a specific skill; content is written into conversation history wrapped in <skill-loaded>.
|
|
6
|
+
*
|
|
7
|
+
* Supported formats:
|
|
8
|
+
* .thincoder/skills/my-skill.md (flat, name = "my-skill")
|
|
9
|
+
* .thincoder/skills/my-skill/SKILL.md (subdirectory, name = "my-skill")
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import { readFile, readdir, stat } from "node:fs/promises"
|
|
9
13
|
import { join } from "node:path"
|
|
10
14
|
|
|
15
|
+
/** Valid skill name pattern (alphanumeric + hyphens/underscores) */
|
|
16
|
+
const NAME_RE = /^[a-zA-Z0-9_-]+$/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Try to read a skill from a path, return { name, path, description } or null.
|
|
20
|
+
*/
|
|
21
|
+
async function tryReadSkill(dir, name, filePath) {
|
|
22
|
+
try {
|
|
23
|
+
const s = await stat(filePath)
|
|
24
|
+
if (!s.isFile()) return null
|
|
25
|
+
const head = await readFile(filePath, "utf8")
|
|
26
|
+
const body = head.slice(0, 400).split("\n")
|
|
27
|
+
let desc = ""
|
|
28
|
+
let inFrontmatter = false
|
|
29
|
+
for (const line of body) {
|
|
30
|
+
const t = line.trim()
|
|
31
|
+
if (t === "---") { inFrontmatter = !inFrontmatter; continue }
|
|
32
|
+
if (inFrontmatter) continue
|
|
33
|
+
if (t && !t.startsWith("#")) {
|
|
34
|
+
desc = t.slice(0, 120)
|
|
35
|
+
break
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { name, path: filePath, description: desc || "(no description)" }
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
11
44
|
/**
|
|
12
45
|
* Scan .thincoder/skills/ directory, return skill list.
|
|
13
|
-
*
|
|
46
|
+
* Supports flat .md files and subdirectories with SKILL.md inside.
|
|
47
|
+
* Each skill: { name, path, description } — name derived from filename or directory.
|
|
14
48
|
* Returns empty array if directory is missing or empty.
|
|
15
49
|
*/
|
|
16
50
|
export async function loadSkills(cwd) {
|
|
17
51
|
const dir = join(cwd, ".thincoder", "skills")
|
|
18
52
|
let entries
|
|
19
53
|
try {
|
|
20
|
-
entries = await readdir(dir)
|
|
54
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
21
55
|
} catch {
|
|
22
56
|
return []
|
|
23
57
|
}
|
|
24
58
|
const skills = []
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
skills.push({ name: name.replace(/\.md$/, ""), path: p, description: desc || "(no description)" })
|
|
47
|
-
} catch {
|
|
48
|
-
// Read failure — skip
|
|
49
|
-
}
|
|
59
|
+
const added = new Set()
|
|
60
|
+
|
|
61
|
+
// Pass 1: subdirectories (higher priority — standard convention)
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (!entry.isDirectory()) continue
|
|
64
|
+
if (!NAME_RE.test(entry.name)) continue
|
|
65
|
+
const skill = await tryReadSkill(dir, entry.name, join(dir, entry.name, "SKILL.md"))
|
|
66
|
+
if (skill) { skills.push(skill); added.add(entry.name) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Pass 2: flat .md files (backward compat; skipped if subdirectory with same name exists)
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (!entry.isFile()) continue
|
|
72
|
+
const m = entry.name.match(/^([a-zA-Z0-9_-]+)\.md$/)
|
|
73
|
+
if (!m) continue
|
|
74
|
+
const name = m[1]
|
|
75
|
+
if (added.has(name)) continue
|
|
76
|
+
const skill = await tryReadSkill(dir, name, join(dir, entry.name))
|
|
77
|
+
if (skill) { skills.push(skill); added.add(name) }
|
|
50
78
|
}
|
|
51
79
|
return skills
|
|
52
80
|
}
|
|
@@ -66,13 +94,21 @@ export function formatSkillListing(skills) {
|
|
|
66
94
|
|
|
67
95
|
/**
|
|
68
96
|
* Read the full content of a specific skill file.
|
|
97
|
+
* Tries subdirectory format (name/SKILL.md) first, then flat format (name.md).
|
|
69
98
|
* Returns text, or null if not found.
|
|
70
99
|
*/
|
|
71
100
|
export async function readSkill(cwd, name) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
101
|
+
if (!NAME_RE.test(name)) return null
|
|
102
|
+
|
|
103
|
+
// Try subdirectory format: name/SKILL.md
|
|
104
|
+
try {
|
|
105
|
+
const p = join(cwd, ".thincoder", "skills", name, "SKILL.md")
|
|
106
|
+
return await readFile(p, "utf8")
|
|
107
|
+
} catch { /* not found, try flat */ }
|
|
108
|
+
|
|
109
|
+
// Fallback to flat format: name.md
|
|
75
110
|
try {
|
|
111
|
+
const p = join(cwd, ".thincoder", "skills", `${name}.md`)
|
|
76
112
|
return await readFile(p, "utf8")
|
|
77
113
|
} catch {
|
|
78
114
|
return null
|
package/src/tools/bash.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
Execute a shell command and return stdout+stderr. Use for running commands, builds, tests.
|
|
2
2
|
|
|
3
|
+
**Route to a dedicated tool instead of bash:**
|
|
4
|
+
- `cat file` / `head` / `tail` → `read`
|
|
5
|
+
- `ls` / `dir` → `ls`
|
|
6
|
+
- `find` / glob search → `glob`
|
|
7
|
+
- `grep` / `rg` → `grep`
|
|
8
|
+
- `echo >` / `sed` / `printf >` / `cat << EOF` → `write` / `edit` / `hashline_edit` / `apply_patch` (enforced: redirection is blocked)
|
|
9
|
+
- `git diff` / `git status` / `git log` → `git` tool
|
|
10
|
+
|
|
3
11
|
Parameters:
|
|
4
12
|
- command (required): Shell command to execute
|
|
5
13
|
- timeout: Timeout in milliseconds (default 120000, max ~300000)
|
package/src/tools/codemode.mjs
CHANGED
|
@@ -24,32 +24,21 @@
|
|
|
24
24
|
import { Script, createContext } from "node:vm"
|
|
25
25
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs"
|
|
26
26
|
import { join, dirname, relative, resolve } from "node:path"
|
|
27
|
-
import { globToRegex, normalizeEOL } from "./shared.mjs"
|
|
27
|
+
import { globToRegex, normalizeEOL, isPrivateHost } from "./shared.mjs"
|
|
28
28
|
|
|
29
29
|
const MAX_OUTPUT = 50_000
|
|
30
30
|
const MAX_SCRIPT = 50_000
|
|
31
31
|
const DEFAULT_TIMEOUT = 30_000
|
|
32
32
|
|
|
33
|
-
/** SSRF-safe fetch: only http/https, private IP rejection, 10s timeout */
|
|
33
|
+
/** SSRF-safe fetch: only http/https, private IP rejection, 10s timeout. */
|
|
34
34
|
async function sandboxFetch(url) {
|
|
35
35
|
const parsed = new URL(url)
|
|
36
36
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
37
37
|
throw new Error(`CodeMode fetch: protocol not allowed: ${parsed.protocol}`)
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
hostname.startsWith("192.168.") || hostname.startsWith("10.") ||
|
|
43
|
-
hostname.startsWith("172.16.") || hostname.startsWith("172.17.") ||
|
|
44
|
-
hostname.startsWith("172.18.") || hostname.startsWith("172.19.") ||
|
|
45
|
-
hostname.startsWith("172.20.") || hostname.startsWith("172.21.") ||
|
|
46
|
-
hostname.startsWith("172.22.") || hostname.startsWith("172.23.") ||
|
|
47
|
-
hostname.startsWith("172.24.") || hostname.startsWith("172.25.") ||
|
|
48
|
-
hostname.startsWith("172.26.") || hostname.startsWith("172.27.") ||
|
|
49
|
-
hostname.startsWith("172.28.") || hostname.startsWith("172.29.") ||
|
|
50
|
-
hostname.startsWith("172.30.") || hostname.startsWith("172.31.") ||
|
|
51
|
-
hostname === "0.0.0.0" || hostname.endsWith(".local")) {
|
|
52
|
-
throw new Error(`CodeMode fetch: private/internal host not allowed: ${hostname}`)
|
|
39
|
+
|
|
40
|
+
if (isPrivateHost(parsed.hostname)) {
|
|
41
|
+
throw new Error(`CodeMode fetch: private/internal host not allowed: ${parsed.hostname}`)
|
|
53
42
|
}
|
|
54
43
|
const ctrl = new AbortController()
|
|
55
44
|
const timer = setTimeout(() => ctrl.abort(), 10_000)
|
package/src/tools/edit.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
Edit a file by exact string replacement. old_string must match exactly once unless replace_all is set.
|
|
2
2
|
|
|
3
|
+
**Routing — pick the right edit tool:**
|
|
4
|
+
- Precise line-targeted change → `hashline_edit` (hash-based, immune to whitespace/encoding drift — preferred)
|
|
5
|
+
- One exact-string swap → this tool
|
|
6
|
+
- Add a function/block after a known line → `insert_after`
|
|
7
|
+
- Same change across multiple files or many spots → `apply_patch`
|
|
8
|
+
- Rewrite an entire file → `write`
|
|
9
|
+
- Rename a symbol project-wide → `lsp` or `grep` first to map every caller
|
|
10
|
+
|
|
3
11
|
Parameters:
|
|
4
12
|
- path (required): File path
|
|
5
13
|
- old_string (required): Exact text to find and replace
|
package/src/tools/git.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
truncate,
|
|
4
4
|
runGit
|
|
5
5
|
} from "./shared.mjs";
|
|
6
|
+
import { escapeXml } from "../agent/helpers.mjs";
|
|
6
7
|
import { execFileSync } from "node:child_process";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
|
|
@@ -35,7 +36,7 @@ export const gitTool = {
|
|
|
35
36
|
switch (args.action) {
|
|
36
37
|
case "diff": {
|
|
37
38
|
const ref = args.ref ?? "HEAD"
|
|
38
|
-
if (!/^[A-Za-z0-9._
|
|
39
|
+
if (!/^[A-Za-z0-9._\/~^@][A-Za-z0-9._\/~^@{}\-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
|
|
39
40
|
const flags = args.staged ? ["--staged"] : []
|
|
40
41
|
const paths = args.path ? [args.path] : []
|
|
41
42
|
const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
|
|
@@ -120,11 +121,12 @@ export const gitTool = {
|
|
|
120
121
|
return formatFileTree(cp)
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
// Overview: list of all snapshots
|
|
124
|
+
// Overview: list of all snapshots (file names are XML-escaped: they are
|
|
125
|
+
// untrusted input that flows back into the model's context)
|
|
124
126
|
return cps.map((c) => {
|
|
125
127
|
const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
|
|
126
|
-
if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.join(", ")}`)
|
|
127
|
-
if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.join(", ")}`)
|
|
128
|
+
if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.map(escapeXml).join(", ")}`)
|
|
129
|
+
if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.map(escapeXml).join(", ")}`)
|
|
128
130
|
return parts.join(" ")
|
|
129
131
|
}).join("\n")
|
|
130
132
|
}
|
|
@@ -162,9 +164,10 @@ export const questionTool = {
|
|
|
162
164
|
|
|
163
165
|
/** Format a checkpoint's file list as a directory tree (directories first, indented display) */
|
|
164
166
|
function formatFileTree(cp) {
|
|
167
|
+
// File names are XML-escaped: untrusted input that flows back into the model's context
|
|
165
168
|
const all = [
|
|
166
|
-
...(cp.tracked ?? []).map((f) => ({ path: f, type: "" })),
|
|
167
|
-
...(cp.untracked ?? []).map((f) => ({ path: f, type: " (untracked)" })),
|
|
169
|
+
...(cp.tracked ?? []).map((f) => ({ path: escapeXml(f), type: "" })),
|
|
170
|
+
...(cp.untracked ?? []).map((f) => ({ path: escapeXml(f), type: " (untracked)" })),
|
|
168
171
|
]
|
|
169
172
|
if (all.length === 0) return "(empty checkpoint)"
|
|
170
173
|
|
package/src/tools/read.md
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
Read a text file. Returns numbered lines. Use offset/limit to page large files.
|
|
2
|
+
|
|
3
|
+
**Routing:**
|
|
4
|
+
- Don't know which file? → `repo_outline` / `code_search` / `glob` first
|
|
5
|
+
- Know the symbol but not the location? → `code_search` or `lsp definition`
|
|
6
|
+
- Know the file but not the lines? → `grep` to find line numbers, then read that range with offset/limit
|
|
7
|
+
- Reading an image? → `read_image` instead
|
|
8
|
+
|
|
2
9
|
Parameters:
|
|
3
10
|
- path (required): File path, relative to cwd or absolute (alias: filePath)
|
|
4
11
|
- offset: 1-based line number to start reading from
|