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.
Files changed (51) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/advisor.mjs +535 -72
  4. package/src/agent/helpers.mjs +18 -5
  5. package/src/agent/setup.mjs +2 -2
  6. package/src/agent-tools/advisor.mjs +36 -0
  7. package/src/agent-tools/plan.mjs +53 -2
  8. package/src/agent-tools/subagent.mjs +7 -1
  9. package/src/agent-tools/timer.mjs +1 -1
  10. package/src/agent-tools/verify.mjs +1 -0
  11. package/src/agent-tools.mjs +1 -0
  12. package/src/agent.mjs +80 -21
  13. package/src/auto-think.mjs +23 -5
  14. package/src/cli/make-agent.mjs +20 -0
  15. package/src/config.mjs +1 -1
  16. package/src/mcp/transport-stdio.mjs +4 -3
  17. package/src/mcp.mjs +1 -1
  18. package/src/prompts/advisor-round1.md +23 -0
  19. package/src/prompts/advisor-round2.md +26 -0
  20. package/src/prompts/advisor-round3.md +24 -0
  21. package/src/prompts/coder.md +1 -0
  22. package/src/prompts/discipline.md +15 -1
  23. package/src/prompts/explore.md +2 -0
  24. package/src/prompts/plan.md +2 -0
  25. package/src/prompts/system.md +5 -1
  26. package/src/provider/anthropic.mjs +4 -4
  27. package/src/provider/core.mjs +6 -126
  28. package/src/provider/google.mjs +4 -2
  29. package/src/provider/sse.mjs +112 -0
  30. package/src/skills.mjs +67 -31
  31. package/src/tools/bash.md +8 -0
  32. package/src/tools/codemode.mjs +5 -16
  33. package/src/tools/edit.md +8 -0
  34. package/src/tools/git.mjs +9 -6
  35. package/src/tools/read.md +7 -0
  36. package/src/tools/shared.mjs +43 -2
  37. package/src/tools/system.mjs +14 -10
  38. package/src/tools/web.mjs +21 -16
  39. package/src/tui/agent-turn.mjs +130 -73
  40. package/src/tui/cmd-advisor.mjs +237 -41
  41. package/src/tui/cmd-auto.mjs +6 -8
  42. package/src/tui/cmd-mcp.mjs +4 -2
  43. package/src/tui/cmd-plan.mjs +6 -8
  44. package/src/tui/cmd-think.mjs +93 -70
  45. package/src/tui/index.mjs +9 -188
  46. package/src/tui/interaction.mjs +2 -1
  47. package/src/tui/key-handler.mjs +8 -4
  48. package/src/tui/layout.mjs +3 -2
  49. package/src/tui/render-conversation.mjs +92 -0
  50. package/src/tui/render-frame.mjs +94 -168
  51. 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(30, "试试加个日志?")` to give yourself a bounded thinking window.
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.
@@ -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:
@@ -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:
@@ -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
- if (spec.tempRange) {
46
- t = Math.min(spec.tempRange[1], Math.max(spec.tempRange[0], t))
47
- t = Math.round(t * 100) / 100
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
 
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * provider/core.mjs — LLM call core
3
- * chat / listModels / createProvider / requestWithRetry / readSSE
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")
@@ -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
- * Each skill: { name, path, description } name is the filename (without extension).
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
- for (const name of entries) {
26
- if (!/^[a-zA-Z0-9_-]+\.md$/.test(name)) continue // must match readSkill's name validation; prevents "listed but unreadable"
27
- const p = join(dir, name)
28
- try {
29
- const s = await stat(p)
30
- if (!s.isFile()) continue
31
- // Extract description (first non-empty, non-heading line in first 400 chars);
32
- // skip entire frontmatter block, otherwise frontmatter fields (e.g. "name: x") get mistaken for description
33
- const head = await readFile(p, "utf8")
34
- const body = head.slice(0, 400).split("\n")
35
- let desc = ""
36
- let inFrontmatter = false
37
- for (const line of body) {
38
- const t = line.trim()
39
- if (t === "---") { inFrontmatter = !inFrontmatter; continue }
40
- if (inFrontmatter) continue
41
- if (t && !t.startsWith("#")) {
42
- desc = t.slice(0, 120)
43
- break
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
- // Safety check: skill name must be alphanumeric + hyphens/underscores only
73
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) return null
74
- const p = join(cwd, ".thincoder", "skills", `${name}.md`)
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)
@@ -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
- // Block private/internal IPs
40
- const hostname = parsed.hostname.toLowerCase()
41
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" ||
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._\/~^][A-Za-z0-9._\/~^-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
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