@thincoder/core 0.9.1
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/abort-provenance.mjs +116 -0
- package/advisor/citations.mjs +139 -0
- package/advisor/compaction.mjs +174 -0
- package/advisor/convergence.mjs +80 -0
- package/advisor/history.mjs +77 -0
- package/advisor/loop.mjs +288 -0
- package/advisor/messages.mjs +299 -0
- package/advisor/notice.mjs +141 -0
- package/advisor/project-context.mjs +197 -0
- package/advisor/repos.mjs +150 -0
- package/advisor/run.mjs +190 -0
- package/advisor/truncate.mjs +57 -0
- package/advisor.mjs +281 -0
- package/agent/child-marks.mjs +24 -0
- package/agent/completion.mjs +145 -0
- package/agent/dispatch.mjs +493 -0
- package/agent/family-tools.mjs +174 -0
- package/agent/helpers.mjs +412 -0
- package/agent/post-turn.mjs +70 -0
- package/agent/record-results.mjs +174 -0
- package/agent/relay-prefix.mjs +39 -0
- package/agent/run-stages.mjs +244 -0
- package/agent/setup-reminders.mjs +199 -0
- package/agent/setup.mjs +234 -0
- package/agent/spawn-child.mjs +258 -0
- package/agent/suspension.mjs +240 -0
- package/agent/write-gate.mjs +87 -0
- package/agent-tools/advisor-async.mjs +481 -0
- package/agent-tools/advisor-settle.mjs +240 -0
- package/agent-tools/advisor.mjs +280 -0
- package/agent-tools/async-discard.mjs +143 -0
- package/agent-tools/async-settle.mjs +299 -0
- package/agent-tools/batch-segment.mjs +265 -0
- package/agent-tools/child-permission.mjs +45 -0
- package/agent-tools/consult.mjs +471 -0
- package/agent-tools/design-token.mjs +117 -0
- package/agent-tools/digest-budget.mjs +76 -0
- package/agent-tools/eng.mjs +102 -0
- package/agent-tools/escalate-async.mjs +302 -0
- package/agent-tools/goal.mjs +119 -0
- package/agent-tools/panel-blocks.mjs +24 -0
- package/agent-tools/parent-channel.mjs +231 -0
- package/agent-tools/plan.mjs +86 -0
- package/agent-tools/read-history.mjs +309 -0
- package/agent-tools/recent-changes.mjs +24 -0
- package/agent-tools/review-facts.mjs +31 -0
- package/agent-tools/settings.mjs +268 -0
- package/agent-tools/skill.mjs +63 -0
- package/agent-tools/spawn-gates.mjs +109 -0
- package/agent-tools/subagent-actions.mjs +496 -0
- package/agent-tools/subagent-async.mjs +456 -0
- package/agent-tools/subagent-panel.mjs +160 -0
- package/agent-tools/subagent-run.mjs +208 -0
- package/agent-tools/subagent-scheduler.mjs +446 -0
- package/agent-tools/subagent-spawn.mjs +478 -0
- package/agent-tools/subagent.mjs +419 -0
- package/agent-tools/task.mjs +87 -0
- package/agent-tools/timer.mjs +46 -0
- package/agent-tools/verify.mjs +295 -0
- package/agent-tools.mjs +23 -0
- package/agent.mjs +430 -0
- package/auto-think.mjs +115 -0
- package/compress-form.mjs +24 -0
- package/config-io.mjs +277 -0
- package/config-migrate.mjs +178 -0
- package/config-presets.mjs +49 -0
- package/config.mjs +419 -0
- package/context.mjs +495 -0
- package/conventions.mjs +223 -0
- package/embedding.mjs +120 -0
- package/escape.mjs +152 -0
- package/expand-home.mjs +16 -0
- package/explore-distill.mjs +152 -0
- package/generate-title.mjs +123 -0
- package/git/checkpoint.mjs +448 -0
- package/git/gitmem.mjs +100 -0
- package/history-window.mjs +179 -0
- package/hooks.mjs +108 -0
- package/i18n.mjs +106 -0
- package/index-bin.mjs +48 -0
- package/index-discover.mjs +176 -0
- package/ledger-cmd.mjs +209 -0
- package/ledger-db.mjs +86 -0
- package/ledger-surface.mjs +76 -0
- package/ledger.mjs +202 -0
- package/log.mjs +195 -0
- package/manifest.mjs +338 -0
- package/markdown.mjs +106 -0
- package/mcp/helpers.mjs +51 -0
- package/mcp/transport-http.mjs +248 -0
- package/mcp/transport-stdio.mjs +140 -0
- package/mcp/transport-ws.mjs +122 -0
- package/mcp.mjs +295 -0
- package/memory/code-index.mjs +219 -0
- package/memory/code-sync.mjs +427 -0
- package/memory/core.mjs +318 -0
- package/memory/delete.mjs +242 -0
- package/memory/docs.mjs +431 -0
- package/memory/file-walk.mjs +109 -0
- package/memory/origin.mjs +24 -0
- package/memory/scan.mjs +177 -0
- package/memory/schema.mjs +460 -0
- package/memory.mjs +21 -0
- package/model-ref.mjs +66 -0
- package/model-specs.mjs +277 -0
- package/package.json +32 -0
- package/peer-domains.mjs +265 -0
- package/peer-instances.mjs +178 -0
- package/permission.mjs +79 -0
- package/process-probe.mjs +315 -0
- package/prompt-files.mjs +113 -0
- package/prompt-overlays.mjs +78 -0
- package/prompts/advisor-design.md +43 -0
- package/prompts/advisor-round1.md +41 -0
- package/prompts/advisor-round2.md +46 -0
- package/prompts/advisor-round3.md +42 -0
- package/prompts/common.md +158 -0
- package/prompts/consult-base.md +19 -0
- package/prompts/discipline-engineering.md +123 -0
- package/prompts/discipline-normal.md +206 -0
- package/prompts/persona-coder.md +21 -0
- package/prompts/persona-eng-coder.md +41 -0
- package/prompts/persona-eng-designer.md +80 -0
- package/prompts/persona-engineering.md +160 -0
- package/prompts/persona-explore.md +15 -0
- package/prompts/persona-normal.md +35 -0
- package/prompts/persona-plan.md +27 -0
- package/provider/anthropic.mjs +225 -0
- package/provider/core.mjs +491 -0
- package/provider/errors.mjs +101 -0
- package/provider/google.mjs +257 -0
- package/provider/index.mjs +7 -0
- package/provider/list-models.mjs +163 -0
- package/provider/normalize.mjs +81 -0
- package/provider/rate.mjs +168 -0
- package/provider/responses.mjs +495 -0
- package/provider/retry.mjs +88 -0
- package/provider/sse.mjs +264 -0
- package/provider/wait-status.mjs +59 -0
- package/proxy.mjs +274 -0
- package/rules.mjs +53 -0
- package/session-gc.mjs +248 -0
- package/session-guard.mjs +59 -0
- package/session-lifecycle.mjs +305 -0
- package/session-migrate.mjs +48 -0
- package/session-rename.mjs +38 -0
- package/session-segments.mjs +99 -0
- package/session-slot-write.mjs +168 -0
- package/session-slots-manifest.mjs +264 -0
- package/session-slots.mjs +298 -0
- package/session-store.mjs +441 -0
- package/session.mjs +244 -0
- package/skills.mjs +234 -0
- package/text-budget.mjs +79 -0
- package/token-ttl.mjs +285 -0
- package/tool-docs/apply_patch.md +15 -0
- package/tool-docs/bash.md +38 -0
- package/tool-docs/delete.md +13 -0
- package/tool-docs/edit.md +30 -0
- package/tool-docs/execute.md +21 -0
- package/tool-docs/fetch.md +12 -0
- package/tool-docs/file_ops.md +17 -0
- package/tool-docs/get_current_time.md +8 -0
- package/tool-docs/git.md +54 -0
- package/tool-docs/glob.md +11 -0
- package/tool-docs/grep.md +19 -0
- package/tool-docs/hashline_edit.md +14 -0
- package/tool-docs/insert_after.md +15 -0
- package/tool-docs/lint.md +10 -0
- package/tool-docs/ls.md +12 -0
- package/tool-docs/lsp.md +10 -0
- package/tool-docs/process.md +10 -0
- package/tool-docs/question.md +16 -0
- package/tool-docs/read.md +20 -0
- package/tool-docs/read_image.md +8 -0
- package/tool-docs/tree.md +14 -0
- package/tool-docs/wait_for.md +22 -0
- package/tool-docs/websearch.md +16 -0
- package/tool-docs/write.md +11 -0
- package/tools/bash.mjs +276 -0
- package/tools/edit-batch.mjs +204 -0
- package/tools/edit-diff.mjs +388 -0
- package/tools/exec-run.mjs +43 -0
- package/tools/execute.mjs +243 -0
- package/tools/file.mjs +464 -0
- package/tools/git-checkpoint.mjs +143 -0
- package/tools/git-ext.mjs +173 -0
- package/tools/git.mjs +415 -0
- package/tools/glob-dialect.mjs +130 -0
- package/tools/index.mjs +76 -0
- package/tools/linter.mjs +120 -0
- package/tools/lsp.mjs +335 -0
- package/tools/ops.mjs +293 -0
- package/tools/patch.mjs +290 -0
- package/tools/question.mjs +26 -0
- package/tools/repomap.mjs +314 -0
- package/tools/search.mjs +248 -0
- package/tools/shared.mjs +467 -0
- package/tools/tree.mjs +81 -0
- package/tools/web.mjs +224 -0
- package/tools/write-path.mjs +191 -0
- package/traces/trace-store.mjs +303 -0
- package/undo-stack.mjs +47 -0
package/tools/file.mjs
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DESC,
|
|
3
|
+
truncate,
|
|
4
|
+
MAX_READ_LINES,
|
|
5
|
+
gitDiffOne,
|
|
6
|
+
autoSyntaxCheck,
|
|
7
|
+
resolveInCwd,
|
|
8
|
+
resolveExternal,
|
|
9
|
+
normalizeEOL,
|
|
10
|
+
detectFileEol,
|
|
11
|
+
joinWithEol,
|
|
12
|
+
majorityEol,
|
|
13
|
+
FFFD_WARNING,
|
|
14
|
+
} from "./shared.mjs";
|
|
15
|
+
import { applyEditBatch } from "./edit-batch.mjs";
|
|
16
|
+
import { runSingleEdit } from "./edit-diff.mjs";
|
|
17
|
+
// 单一写路径点(编辑器编辑径注入缝——CORE-UNIFICATION §2.13.5):本档三个写点全走它,
|
|
18
|
+
// 记账面同源住该档(下面只做再导出——保既有 import 面稳定)。
|
|
19
|
+
import { writeThroughPath, clearDirty, clearLastWrite, lastWriteOf, isDirty } from "./write-path.mjs";
|
|
20
|
+
import { specForModel } from "../config.mjs";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { mkdir, readFile, stat, unlink } from "node:fs/promises";
|
|
23
|
+
import { join, relative, dirname } from "node:path";
|
|
24
|
+
|
|
25
|
+
const MAX_FILE_READ_BYTES = 10_000_000
|
|
26
|
+
const MAX_IMAGE_BYTES = 15_000_000
|
|
27
|
+
// DUAL-END-TRUNCATION (F-1, C 方案——2026-09-09):read 双端尾行数——窗口截断大文件时
|
|
28
|
+
// 返回 头 N 行(N = 请求窗口,默认 MAX_READ_LINES)+ 省略注 + 尾 M 行(本常量)。M 与
|
|
29
|
+
// 窗口合计(2500 行)为"整读上限"——K=0(尾区与窗口相接)时整印剩余、不产生假省略注。
|
|
30
|
+
export const READ_TAIL_LINES = 500
|
|
31
|
+
|
|
32
|
+
// ────────────────────────────────────────
|
|
33
|
+
// Dirty-file tracking (read-before-insert guard)
|
|
34
|
+
// ────────────────────────────────────────
|
|
35
|
+
// insert_after anchors on LINE NUMBERS — the most drift-prone addressing.
|
|
36
|
+
// Every write tool marks the file dirty; insert_after refuses to run on a
|
|
37
|
+
// dirty file until the agent reads it again (fresh line numbers).
|
|
38
|
+
// 2026-09-14(CORE-UNIFICATION §2.13.5):记账面(`dirtyPaths` / `lastWrites` +
|
|
39
|
+
// `markDirty` / `clearDirty` / `isDirty` / `recordWrite` / `lastWriteOf` /
|
|
40
|
+
// `clearLastWrite`)已**同源迁至单一写路径点** `tools/write-path.mjs`——写盘只此一处 ⇒
|
|
41
|
+
// 记账只此一处,调用方不再自行记账(**不重复记账**)。本档只做再导出(**兼容面**——
|
|
42
|
+
// 核内消费方现直取 `write-path.mjs`,此处保留给未改档 / 端侧装配的既有 import 面)。
|
|
43
|
+
export { markDirty, clearDirty, isDirty, recordWrite, lastWriteOf, clearLastWrite } from "./write-path.mjs"
|
|
44
|
+
|
|
45
|
+
/** 2026-08-31 工具顺手度(用户批准"可以啊"):写入工具返回带上下文窗口——
|
|
46
|
+
* 模型拿到的不只是"inserted at L395",而是"L395 这行是什么内容"——下次再操作时
|
|
47
|
+
* 能自检"我的行号 vs 实际内容"是否匹配,匹配不上 = 行号漂了,先 read——
|
|
48
|
+
* 死循环就断了(根因:模型对行号锚点的"新鲜度"没有感知——数字本身不携带语义)。
|
|
49
|
+
* write 全文重写跳过(无行号锚点——模型刚写的知道内容)。
|
|
50
|
+
* edit-batch.mjs(数组形态)复用——导出仅供内部模块,非公共 API。 */
|
|
51
|
+
export async function appendWriteContext(abs, writeLine, baseResult) {
|
|
52
|
+
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
53
|
+
const lines = content.split("\n")
|
|
54
|
+
const start = Math.max(1, writeLine - 3)
|
|
55
|
+
const end = Math.min(lines.length, writeLine + 3)
|
|
56
|
+
const ctxLines = []
|
|
57
|
+
for (let i = start; i <= end; i++) {
|
|
58
|
+
const marker = i === writeLine ? "→" : " "
|
|
59
|
+
ctxLines.push(`${marker} L${i}\t${lines[i - 1]}`)
|
|
60
|
+
}
|
|
61
|
+
return `${baseResult}\ncontext (L${start}-L${end}):\n${ctxLines.join("\n")}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const readTool = {
|
|
65
|
+
name: "read",
|
|
66
|
+
description: DESC("read"),
|
|
67
|
+
parameters: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
path: { type: "string", description: "File path (relative to cwd or absolute)" },
|
|
71
|
+
filePath: { type: "string", description: "Alias for path (supported for API compatibility)" },
|
|
72
|
+
offset: { type: "number", description: "1-based line number to start from" },
|
|
73
|
+
limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
|
|
74
|
+
allowExternal: { type: "boolean", description: "No-op retained for API compatibility — path resolution no longer asserts a working-directory boundary (all paths resolve relative to cwd)." },
|
|
75
|
+
hashes: { type: "boolean", description: "Include SHA256 line hashes for hash-based editing (default false). Use when you plan to edit the file with hashline_edit." },
|
|
76
|
+
},
|
|
77
|
+
required: ["path"],
|
|
78
|
+
},
|
|
79
|
+
readonly: true,
|
|
80
|
+
async execute(args, ctx) {
|
|
81
|
+
const p = args.path ?? args.filePath
|
|
82
|
+
const abs = args.allowExternal ? resolveExternal(ctx, p) : resolveInCwd(ctx, p)
|
|
83
|
+
// Large file guard: check size first, reject reading entire file if >10MB (offset/limit only affect the returned slice, not buffering)
|
|
84
|
+
const st = await stat(abs).catch(() => null)
|
|
85
|
+
if (st && st.size > MAX_FILE_READ_BYTES) throw new Error(`File too large (${Math.round(st.size / 1_000_000)}MB > 10MB limit). Use bash with head/tail or grep for targeted extraction.`)
|
|
86
|
+
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
87
|
+
// A read refreshes the agent's view — line numbers are fresh again.
|
|
88
|
+
clearDirty(abs)
|
|
89
|
+
clearLastWrite(abs) // 2026-08-31:read 同时清写入快照(新视图以 read 为准)
|
|
90
|
+
const lines = content.split("\n")
|
|
91
|
+
const offset = Math.max(1, args.offset ?? 1)
|
|
92
|
+
const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
|
93
|
+
const windowEnd = offset - 1 + limit // index just past the requested window
|
|
94
|
+
const render = (slice, startLn) => slice.map((l, i) => {
|
|
95
|
+
const ln = startLn + i
|
|
96
|
+
if (args.hashes) return `${ln}\t[${hashLine(l)}] ${l}`
|
|
97
|
+
return `${ln}\t${l}`
|
|
98
|
+
}).join("\n")
|
|
99
|
+
// DUAL-END-TRUNCATION (F-1, C 方案——DUAL-END-TRUNCATION.md 2026-09-09):
|
|
100
|
+
// 大文件(> MAX_READ_LINES 行)窗口截断时返回 头(请求窗口)+ 中段省略注 +
|
|
101
|
+
// 文件真实尾部(末 READ_TAIL_LINES 行——现尾注升级为真实尾行内容——offload 产物
|
|
102
|
+
// 的尾端结论可见)。≤ 阈值文件任何窗口走旧头向路径(字节零变化);窗口覆盖全文件
|
|
103
|
+
// 也走旧路径。与 hashes 模式照常交互(头尾行 hash 照算)。
|
|
104
|
+
if (windowEnd >= lines.length || lines.length <= MAX_READ_LINES) {
|
|
105
|
+
const suffix = windowEnd < lines.length ? `\n... (${lines.length} lines total, use offset to continue)` : ""
|
|
106
|
+
return truncate(render(lines.slice(offset - 1, windowEnd), offset) + suffix)
|
|
107
|
+
}
|
|
108
|
+
const head = render(lines.slice(offset - 1, windowEnd), offset)
|
|
109
|
+
// 尾区 = 文件末 READ_TAIL_LINES 行;尾区起点落在窗口内(重叠)→ 尾区从窗口后开始
|
|
110
|
+
// —— 任何行不打印两次;剩余全被覆盖(K=0)→ 整印余段且无假省略注。
|
|
111
|
+
const tailStart = Math.max(windowEnd, lines.length - READ_TAIL_LINES)
|
|
112
|
+
const middle = tailStart - windowEnd
|
|
113
|
+
const tail = render(lines.slice(tailStart), tailStart + 1)
|
|
114
|
+
return middle > 0
|
|
115
|
+
? `${head}\n…(truncated: ${middle} lines in middle, use offset to continue)\n${tail}`
|
|
116
|
+
: `${head}\n${tail}`
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------- read_image
|
|
121
|
+
|
|
122
|
+
// Raster formats only — every mainstream vision API (Kimi, Anthropic, OpenAI, Gemini)
|
|
123
|
+
// rejects svg/bmp. svg is served as text source below; bmp is refused with a hint.
|
|
124
|
+
const IMAGE_EXTENSIONS = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" }
|
|
125
|
+
const MAX_SVG_CHARS = 100_000
|
|
126
|
+
|
|
127
|
+
export const readImageTool = {
|
|
128
|
+
name: "read_image",
|
|
129
|
+
description: DESC("read_image"),
|
|
130
|
+
parameters: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
path: { type: "string", description: "Path to image file (relative to cwd or absolute). Supports png, jpg, gif, webp. svg files are returned as text source (no vision API accepts svg)." },
|
|
134
|
+
},
|
|
135
|
+
required: ["path"],
|
|
136
|
+
},
|
|
137
|
+
readonly: true,
|
|
138
|
+
multimodal: true, // returns JSON { text, images } — agent loop converts to multimodal user message
|
|
139
|
+
/** Returns JSON: { text, images }, for the agent layer to convert into multimodal user messages */
|
|
140
|
+
async execute(args, ctx) {
|
|
141
|
+
const abs = resolveInCwd(ctx, args.path)
|
|
142
|
+
const ext = abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()
|
|
143
|
+
|
|
144
|
+
// SVG is text markup — return the source directly. Works with text-only models too
|
|
145
|
+
// (no vision gate), and never poisons history with an image part the API will 400 on.
|
|
146
|
+
if (ext === "svg") {
|
|
147
|
+
const st = await stat(abs).catch(() => null)
|
|
148
|
+
if (st && st.size > MAX_IMAGE_BYTES) throw new Error(`Image too large: ${Math.round(st.size / 1_000_000)}MB (max 15MB)`)
|
|
149
|
+
const src = normalizeEOL(await readFile(abs, "utf8"))
|
|
150
|
+
return `[read_image: ${args.path} (svg source, ${src.length} chars — no vision API accepts image/svg+xml, showing markup instead)]\n` +
|
|
151
|
+
truncate(src, MAX_SVG_CHARS)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Vision capability gate: injecting an image into a text-only model's history poisons the whole
|
|
155
|
+
// conversation (every subsequent request 400s on the image part). Refuse before reading the file.
|
|
156
|
+
// F-3(IMAGE-DOWNGRADE-VISION——2026-09-09):软引导句逐字锚(设计档照抄——CLI 无 extension
|
|
157
|
+
// 式自动降级——模型收到错误后自选 spawn 视觉渠道子代理路径)。
|
|
158
|
+
const model = ctx.agent?.provider?.model
|
|
159
|
+
if (model && !specForModel(model).multimodal) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Model "${model}" does not support image input — read_image is unavailable with this provider. ` +
|
|
162
|
+
`Verify visual output programmatically (file size, dimensions, pixel checks via code) or ask the user to switch to a vision-capable provider.\n` +
|
|
163
|
+
`模型不支持图像——可 spawn 一个视觉模型子代理(subagent model 参数指视觉渠道)用 read_image 读图`
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
const mime = IMAGE_EXTENSIONS[ext]
|
|
167
|
+
if (!mime) {
|
|
168
|
+
const hint = ext === "bmp" ? " Convert it to PNG first (no mainstream vision API accepts BMP)." : ""
|
|
169
|
+
throw new Error(`Unsupported image format: .${ext}. Supported: ${Object.keys(IMAGE_EXTENSIONS).join(", ")}, svg (as text source).${hint}`)
|
|
170
|
+
}
|
|
171
|
+
// Check size before reading — prevent huge images from blowing up memory (20MB base64 ≈ 15MB raw)
|
|
172
|
+
const imgStat = await stat(abs).catch(() => null)
|
|
173
|
+
if (imgStat && imgStat.size > MAX_IMAGE_BYTES) throw new Error(`Image too large: ${Math.round(imgStat.size / 1_000_000)}MB (max 15MB)`)
|
|
174
|
+
const buf = await readFile(abs) // raw buffer, no encoding
|
|
175
|
+
const b64 = buf.toString("base64")
|
|
176
|
+
const bytes = buf.length
|
|
177
|
+
const result = JSON.stringify({
|
|
178
|
+
text: `[read_image: ${args.path} (${mime}, ${bytes} bytes)]`,
|
|
179
|
+
images: [{ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } }],
|
|
180
|
+
})
|
|
181
|
+
// Paste-created temp files: delete after use, no litter
|
|
182
|
+
const basename = abs.includes("/") ? abs.slice(abs.lastIndexOf("/") + 1) : abs.slice(abs.lastIndexOf("\\") + 1)
|
|
183
|
+
if (basename.startsWith(".thincoder-paste-")) {
|
|
184
|
+
try { await unlink(abs) } catch { /* can't delete, so be it */ }
|
|
185
|
+
}
|
|
186
|
+
return result
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------- write
|
|
191
|
+
|
|
192
|
+
export const writeTool = {
|
|
193
|
+
name: "write",
|
|
194
|
+
description: DESC("write"),
|
|
195
|
+
parameters: {
|
|
196
|
+
type: "object",
|
|
197
|
+
properties: {
|
|
198
|
+
path: { type: "string", description: "File path (relative to cwd or absolute)" },
|
|
199
|
+
content: { type: "string", description: "Full content to write" },
|
|
200
|
+
},
|
|
201
|
+
required: ["path", "content"],
|
|
202
|
+
},
|
|
203
|
+
readonly: false,
|
|
204
|
+
touchedPaths(args) { return args.path ? [args.path] : [] },
|
|
205
|
+
async execute(args, ctx) {
|
|
206
|
+
const abs = resolveInCwd(ctx, args.path)
|
|
207
|
+
await mkdir(dirname(abs), { recursive: true })
|
|
208
|
+
const st = await stat(abs).catch(() => null)
|
|
209
|
+
if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
|
|
210
|
+
// EOL semantics: overwriting an existing file restores ITS original EOL style (F1);
|
|
211
|
+
// a new file follows the directory's majority style, defaulting to LF (F2).
|
|
212
|
+
const prev = st ? await readFile(abs, "utf8").catch(() => null) : null
|
|
213
|
+
const eol = prev != null ? detectFileEol(prev) : majorityEol(dirname(abs))
|
|
214
|
+
const content = eol === "\r\n" ? normalizeEOL(args.content).replace(/\n/g, "\r\n") : args.content
|
|
215
|
+
// 单一写路径点:默认 = writeFile + 记账;端侧注入 ⇒ 编辑器径(§2.13.5)。
|
|
216
|
+
await writeThroughPath(abs, content, { op: "write", record: { type: "write", startLine: 1, shift: 0 } }) // 全文重写——全文件受影响
|
|
217
|
+
const diff = gitDiffOne(ctx.cwd, abs)
|
|
218
|
+
return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------- edit
|
|
223
|
+
|
|
224
|
+
export const editTool = {
|
|
225
|
+
name: "edit",
|
|
226
|
+
description: DESC("edit"),
|
|
227
|
+
parameters: {
|
|
228
|
+
type: "object",
|
|
229
|
+
properties: {
|
|
230
|
+
path: { type: "string", description: "File path — single form: required; with the edits array: optional top-level default for entries without their own path" },
|
|
231
|
+
old_string: { type: "string", description: "Exact text to replace (tolerant matching: exact → whitespace-only variant → fuzzy match at ≥90% line equality after whitespace/indent/quote normalization). Mutually exclusive with line/startLine/endLine" },
|
|
232
|
+
new_string: { type: "string", description: "Replacement text — content-based edits require it (empty is an explicit error); with line-based targeting (line/startLine/endLine) give it to replace the line/range, or OMIT it to delete (an explicit empty string is an error — omission is the delete signal)" },
|
|
233
|
+
line: { type: "integer", description: "1-based line number — replace that single line with new_string, or OMIT new_string to DELETE the line; no old_string needed (mutually exclusive with old_string and startLine/endLine)" },
|
|
234
|
+
startLine: { type: "integer", description: "1-based first line of the range to replace with new_string (inclusive — requires endLine; mutually exclusive with old_string); OMIT new_string to DELETE the range" },
|
|
235
|
+
endLine: { type: "integer", description: "1-based last line of the range to replace with new_string (inclusive — requires startLine; mutually exclusive with old_string); OMIT new_string to DELETE the range" },
|
|
236
|
+
replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
|
|
237
|
+
edits: {
|
|
238
|
+
type: "array",
|
|
239
|
+
description: "Batch form — multiple edits in ONE call, atomic (any failure writes nothing; same-file entries apply serially, each based on the previous result). Use it for multiple changes to the same file AND for independent changes across multiple files — prefer one batched call over N single edits. A top-level path is allowed — it defaults entries without their own path (entry paths win). Mutually exclusive with top-level old_string/new_string/line/startLine/endLine — provide each change's targeting (old_string, or line / startLine+endLine) inside its edits entry; new_string replaces when given, and line-targeted entries may omit it to DELETE the line/range.",
|
|
240
|
+
items: {
|
|
241
|
+
type: "object",
|
|
242
|
+
properties: {
|
|
243
|
+
path: { type: "string" },
|
|
244
|
+
old_string: { type: "string" },
|
|
245
|
+
new_string: { type: "string" },
|
|
246
|
+
line: { type: "integer" },
|
|
247
|
+
startLine: { type: "integer" },
|
|
248
|
+
endLine: { type: "integer" },
|
|
249
|
+
replace_all: { type: "boolean" },
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
required: [],
|
|
255
|
+
},
|
|
256
|
+
readonly: false,
|
|
257
|
+
touchedPaths(args) {
|
|
258
|
+
// 2026-09-05 用户裁定:顶层 path = 批默认——仅当有条目缺 path 时计入(条目全带 path
|
|
259
|
+
// 时顶层 path 不实际使用——不虚报进 _touchedFiles)
|
|
260
|
+
if (args.edits) {
|
|
261
|
+
const out = args.edits.map((e) => e.path).filter(Boolean)
|
|
262
|
+
if (args.path && args.edits.some((e) => !e.path)) out.push(args.path)
|
|
263
|
+
return out
|
|
264
|
+
}
|
|
265
|
+
return args.path ? [args.path] : []
|
|
266
|
+
},
|
|
267
|
+
async execute(args, ctx) {
|
|
268
|
+
// 2026-08-31 工具顺手度(用户批准):数组形态——一次多文件原子替换
|
|
269
|
+
// (应用逻辑在 edit-batch.mjs——2026-09-01 拆出,500 行硬限,先例 git-ext.mjs)
|
|
270
|
+
if (args.edits) return applyEditBatch(args, ctx)
|
|
271
|
+
|
|
272
|
+
// 单文件(现状路径)——执行体整段迁出至 edit-diff.mjs(EDIT.md §6——
|
|
273
|
+
// 行级 LCS 判定:零重叠→替换即删 / 一般 diff→LCS / 空 new→显式报错)
|
|
274
|
+
return runSingleEdit(args, ctx)
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------- insert_after
|
|
279
|
+
|
|
280
|
+
export const insertAfterTool = {
|
|
281
|
+
name: "insert_after",
|
|
282
|
+
description: DESC("insert_after"),
|
|
283
|
+
parameters: {
|
|
284
|
+
type: "object",
|
|
285
|
+
properties: {
|
|
286
|
+
path: { type: "string", description: "File path" },
|
|
287
|
+
after_line: { type: "number", description: "Line number to insert after (1-based). Takes priority over after_regex." },
|
|
288
|
+
after_regex: { type: "string", description: "JavaScript regex to find the line to insert after (must match exactly one line)" },
|
|
289
|
+
content: { type: "string", description: "Text to insert (with leading newline if you need a blank line)" },
|
|
290
|
+
},
|
|
291
|
+
required: ["path", "content"],
|
|
292
|
+
},
|
|
293
|
+
readonly: false,
|
|
294
|
+
touchedPaths(args) { return args.path ? [args.path] : [] },
|
|
295
|
+
async execute(args, ctx) {
|
|
296
|
+
const abs = resolveInCwd(ctx, args.path)
|
|
297
|
+
// Read-before-insert guard: after_line anchors are line numbers, and any
|
|
298
|
+
// write since the last read made them stale. Refuse instead of silently
|
|
299
|
+
// inserting at a drifted position (the failure mode that corrupted test
|
|
300
|
+
// structure repeatedly). after_regex callers get the same gate — a stale
|
|
301
|
+
// target line is just as wrong, and the rule is simpler to reason about.
|
|
302
|
+
// 2026-08-31 工具顺手度(用户批准):判定精确化——本 session 写入工具记录受影响区
|
|
303
|
+
// (lastWrite),after_line 在未受影响区(<= startLine)→ 行号未漂移 → 允许
|
|
304
|
+
// (消掉"我写的文件被当外部修改"的摩擦);受影响区内/write 全文重写 → 拒绝。
|
|
305
|
+
const lw = lastWriteOf(abs)
|
|
306
|
+
if (lw && args.after_line != null) {
|
|
307
|
+
if (lw.type === "write") {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`${args.path} 刚被 write 全文重写(was modified since your last read)——任何行号都可能漂移,必须重 read。`
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
if (args.after_line > lw.startLine) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`${args.path} 的 after_line ${args.after_line} 在上次写入(L${lw.startLine})之后——` +
|
|
315
|
+
`行号已漂移 ${lw.shift >= 0 ? "+" : ""}${lw.shift},请用新行号或先 read。`
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
// after_line <= startLine → 行号未漂移 → 允许
|
|
319
|
+
} else if (isDirty(abs)) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`${args.path} was modified since your last read — line numbers may be stale.\n` +
|
|
322
|
+
`Read the file again (read tool) to refresh line numbers, then retry insert_after.`
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
const raw = await readFile(abs, "utf8") // original bytes — EOL detection needs the file's real line endings
|
|
326
|
+
const text = normalizeEOL(raw)
|
|
327
|
+
const lines = text.split("\n")
|
|
328
|
+
|
|
329
|
+
let targetLine
|
|
330
|
+
if (args.after_line != null) {
|
|
331
|
+
targetLine = args.after_line
|
|
332
|
+
if (!Number.isInteger(targetLine)) {
|
|
333
|
+
throw new Error(`after_line must be an integer, got: ${args.after_line}`)
|
|
334
|
+
}
|
|
335
|
+
if (targetLine < 1 || targetLine > lines.length) {
|
|
336
|
+
throw new Error(`after_line ${targetLine} out of range (file has ${lines.length} lines)`)
|
|
337
|
+
}
|
|
338
|
+
} else if (args.after_regex) {
|
|
339
|
+
let regex
|
|
340
|
+
try {
|
|
341
|
+
regex = new RegExp(args.after_regex)
|
|
342
|
+
} catch (e) {
|
|
343
|
+
throw new Error(`after_regex /${args.after_regex}/ is not a valid JavaScript regex: ${e.message}`, { cause: e })
|
|
344
|
+
}
|
|
345
|
+
const matches = []
|
|
346
|
+
for (let i = 0; i < lines.length; i++) {
|
|
347
|
+
if (regex.test(lines[i])) matches.push(i + 1)
|
|
348
|
+
}
|
|
349
|
+
if (matches.length === 0) throw new Error(`after_regex /${args.after_regex}/ matched no lines in ${args.path}`)
|
|
350
|
+
if (matches.length > 1) throw new Error(`after_regex /${args.after_regex}/ matched ${matches.length} lines (${matches.slice(0, 5).join(", ")}${matches.length > 5 ? "…" : ""}); use a more specific pattern or after_line instead`)
|
|
351
|
+
targetLine = matches[0]
|
|
352
|
+
} else {
|
|
353
|
+
throw new Error("Either after_line or after_regex is required")
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
lines.splice(targetLine, 0, normalizeEOL(args.content))
|
|
357
|
+
// Write back in the file's ORIGINAL EOL style (review R9#2: same bug class as
|
|
358
|
+
// edit — a CRLF file must not silently become LF here either).
|
|
359
|
+
const updated = joinWithEol(lines, raw)
|
|
360
|
+
await writeThroughPath(abs, updated, {
|
|
361
|
+
op: "write",
|
|
362
|
+
record: { type: "insert", startLine: targetLine, shift: normalizeEOL(args.content).split("\n").length },
|
|
363
|
+
})
|
|
364
|
+
const diff = gitDiffOne(ctx.cwd, abs)
|
|
365
|
+
const baseResult = `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
366
|
+
return await appendWriteContext(abs, targetLine + 1, baseResult)
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------- hashline_edit
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Compute a 12-char hex SHA256 hash for a line (exact content, no trimming).
|
|
374
|
+
* Used by both read (hashes=true) and hashline_edit for hash-based matching.
|
|
375
|
+
*/
|
|
376
|
+
export function hashLine(content) {
|
|
377
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 12)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export const hashlineEditTool = {
|
|
381
|
+
name: "hashline_edit",
|
|
382
|
+
description: DESC("hashline_edit"),
|
|
383
|
+
parameters: {
|
|
384
|
+
type: "object",
|
|
385
|
+
properties: {
|
|
386
|
+
path: { type: "string", description: "File path" },
|
|
387
|
+
old_hashes: { type: "array", items: { type: "string" }, description: "SHA256 hashes (12 chars) of the lines to replace. Read the file with hashes=true first to obtain these hashes. Single line: pass 1 hash; multiple lines: pass the exact sequence of hashes." },
|
|
388
|
+
new_content: { type: "string", description: "Replacement text (can span multiple lines)" },
|
|
389
|
+
},
|
|
390
|
+
required: ["path", "old_hashes", "new_content"],
|
|
391
|
+
},
|
|
392
|
+
readonly: false,
|
|
393
|
+
touchedPaths(args) { return args.path ? [args.path] : [] },
|
|
394
|
+
async execute(args, ctx) {
|
|
395
|
+
const abs = resolveInCwd(ctx, args.path)
|
|
396
|
+
if (!args.old_hashes?.length) throw new Error("old_hashes must not be empty — read the file with hashes=true to get line hashes")
|
|
397
|
+
const raw = await readFile(abs, "utf8")
|
|
398
|
+
const content = normalizeEOL(raw)
|
|
399
|
+
// Encoding-corruption probe: U+FFFD means the file is not clean UTF-8 — hash
|
|
400
|
+
// addressing may be unreliable. Warn (never block).
|
|
401
|
+
const corrupted = content.includes("\uFFFD")
|
|
402
|
+
const lines = content.split("\n")
|
|
403
|
+
const fileHashes = lines.map((l) => hashLine(l))
|
|
404
|
+
const target = args.old_hashes
|
|
405
|
+
|
|
406
|
+
// Sliding-window match: find all occurrences of the hash sequence.
|
|
407
|
+
// When multiple matches are found (e.g. empty lines), report positions so the
|
|
408
|
+
// model can include more context lines (adjacent lines with unique hashes).
|
|
409
|
+
const matches = []
|
|
410
|
+
for (let i = 0; i <= fileHashes.length - target.length; i++) {
|
|
411
|
+
let match = true
|
|
412
|
+
for (let j = 0; j < target.length; j++) {
|
|
413
|
+
if (fileHashes[i + j] !== target[j]) { match = false; break }
|
|
414
|
+
}
|
|
415
|
+
if (match) matches.push(i)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (matches.length === 0) {
|
|
419
|
+
// Help the model recover: show the current file hashes for context
|
|
420
|
+
const maxShow = Math.min(fileHashes.length, 50)
|
|
421
|
+
const hashDump = fileHashes.slice(0, maxShow).map((h, i) => `${h} L${i + 1}: ${lines[i].slice(0, 80)}`).join("\n")
|
|
422
|
+
const preview = target.join(" ")
|
|
423
|
+
throw new Error(
|
|
424
|
+
`Hash sequence not found in ${args.path}: ${preview} — for fresh hashes, re-read the file with hashes=true\n` +
|
|
425
|
+
`The file may have been modified since you last read it. Current hashes (first ${maxShow} lines):\n${hashDump}` +
|
|
426
|
+
(corrupted ? `\n${FFFD_WARNING}` : "")
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (matches.length > 1) {
|
|
431
|
+
const ctx = 2 // lines of surrounding context
|
|
432
|
+
const detail = matches.map((m) => {
|
|
433
|
+
const start = Math.max(0, m - ctx)
|
|
434
|
+
const end = Math.min(lines.length, m + target.length + ctx)
|
|
435
|
+
const preview = lines.slice(start, end).map((l, i) => {
|
|
436
|
+
const ln = start + i + 1
|
|
437
|
+
const marker = m <= ln - 1 && ln - 1 < m + target.length ? ">" : " "
|
|
438
|
+
return `${marker} L${ln}: ${l.slice(0, 80)}`
|
|
439
|
+
}).join("\n")
|
|
440
|
+
return ` Match at line ${m + 1} (${target.length} line(s)):\n${preview}`
|
|
441
|
+
}).join("\n\n")
|
|
442
|
+
throw new Error(
|
|
443
|
+
`Hash sequence matches ${matches.length} positions in ${args.path} — ambiguous.\n` +
|
|
444
|
+
`Include more surrounding lines (unique-hash lines before/after the target) to disambiguate.\n\n` +
|
|
445
|
+
`All matches with surrounding context:\n\n${detail}`
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const pos = matches[0]
|
|
450
|
+
// Replace: remove old lines, insert new lines at the same position
|
|
451
|
+
const newLines = normalizeEOL(args.new_content).split("\n") // normalize: CRLF in new_content would join into \r\r\n
|
|
452
|
+
lines.splice(pos, target.length, ...newLines)
|
|
453
|
+
// Write back in the file's original EOL style (same rule as edit / apply_patch).
|
|
454
|
+
const updated = joinWithEol(lines, raw)
|
|
455
|
+
await writeThroughPath(abs, updated, {
|
|
456
|
+
op: "write",
|
|
457
|
+
record: { type: "edit", startLine: pos + 1, shift: newLines.length - target.length },
|
|
458
|
+
})
|
|
459
|
+
const diff = gitDiffOne(ctx.cwd, abs)
|
|
460
|
+
const baseResult = `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}${corrupted ? `\n${FFFD_WARNING}` : ""}`
|
|
461
|
+
return await appendWriteContext(abs, pos + 1, baseResult)
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-checkpoint.mjs — git 工具 checkpoint action 子系统(CHECKPOINT.md F2/F6)。
|
|
3
|
+
* git.mjs 的 checkpoint case 委托到这里:list/create/rewind/cat/versions + F6 懒清理 +
|
|
4
|
+
* F2/D7 提示行 + 文件树格式化。CLI 与 VS Code 两端同构(镜像,修改须两端同批)。
|
|
5
|
+
*/
|
|
6
|
+
import { runGit } from "./shared.mjs"
|
|
7
|
+
import { escapeXml } from "../agent/helpers.mjs"
|
|
8
|
+
import {
|
|
9
|
+
createCheckpoint,
|
|
10
|
+
listCheckpoints,
|
|
11
|
+
rewind,
|
|
12
|
+
listFileVersions,
|
|
13
|
+
catFile,
|
|
14
|
+
isGitRepo,
|
|
15
|
+
deleteCheckpointsForCwd,
|
|
16
|
+
} from "../git/checkpoint.mjs"
|
|
17
|
+
|
|
18
|
+
/** F6 lazy fallback (CHECKPOINT.md D3): an EXTERNAL git commit (via bash / IDE — not the git
|
|
19
|
+
* tool) leaves this cwd's checkpoints as pre-commit state. Compare HEAD commit time
|
|
20
|
+
* (epoch SECONDS from %ct) against the newest snapshot's meta.time (ms): `%ct × 1000` aligns
|
|
21
|
+
* both to ms. HEAD newer → every snapshot predates the commit → clear all. All-or-nothing:
|
|
22
|
+
* any snapshot NEWER than HEAD (e.g. a manual create after the external commit) skips the
|
|
23
|
+
* whole clear. Best-effort — never blocks the checkpoint op. */
|
|
24
|
+
export async function lazyClearIfCommitted(cwd) {
|
|
25
|
+
try {
|
|
26
|
+
const cps = await listCheckpoints(cwd)
|
|
27
|
+
if (cps.length === 0) return
|
|
28
|
+
const headSec = runGit(cwd, ["log", "-1", "--format=%ct"])
|
|
29
|
+
const headMs = Number.parseInt(headSec, 10) * 1000
|
|
30
|
+
if (!Number.isFinite(headMs) || headMs <= 0) return
|
|
31
|
+
const newest = cps[0] // listCheckpoints returns newest → oldest
|
|
32
|
+
if (headMs > newest.time) await deleteCheckpointsForCwd(cwd)
|
|
33
|
+
} catch {
|
|
34
|
+
// best-effort (NF7 philosophy) — a lazy-clear failure must not break list/create
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** checkpoint case 主入口(git 工具 checkpoint action 的全部子动作)。 */
|
|
39
|
+
export async function executeCheckpointAction(args, ctx) {
|
|
40
|
+
const { checkpointAction: sub, checkpointId: id, path } = args
|
|
41
|
+
if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
|
|
42
|
+
|
|
43
|
+
if (!sub) return "checkpoint: missing checkpointAction — use: list | create | rewind | cat | versions"
|
|
44
|
+
|
|
45
|
+
// F6 lazy fallback (list/create entry): an EXTERNAL git commit (HEAD time newer
|
|
46
|
+
// than the newest snapshot) means every snapshot predates a safety baseline —
|
|
47
|
+
// clear them. All-or-nothing: if any snapshot is newer than HEAD, skip entirely.
|
|
48
|
+
if (sub === "list" || sub === "create") await lazyClearIfCommitted(ctx.cwd)
|
|
49
|
+
|
|
50
|
+
if (sub === "create") {
|
|
51
|
+
const cp = await createCheckpoint(ctx.cwd)
|
|
52
|
+
return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
|
|
53
|
+
}
|
|
54
|
+
if (sub === "versions") {
|
|
55
|
+
if (!path) throw new Error("path is required for versions — the file whose history you want")
|
|
56
|
+
const versions = await listFileVersions(ctx.cwd, path)
|
|
57
|
+
if (versions.length === 0) return `No snapshot copies of "${path}" found (it was never part of an auto/protection snapshot).`
|
|
58
|
+
return (
|
|
59
|
+
`Historical versions of "${path}" (${versions.length}, newest first):\n` +
|
|
60
|
+
versions.map((v) =>
|
|
61
|
+
` ${v.snapshotId} ${new Date(v.time).toISOString()} ${v.size}B sha:${v.sha} (${v.source})` +
|
|
62
|
+
(v.sha === versions[versions.indexOf(v) - 1]?.sha ? " ← same content as previous" : "")
|
|
63
|
+
).join("\n") +
|
|
64
|
+
`\nRestore a version: checkpointAction=rewind checkpointId=<snapshotId> path="${path}"`
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
if (sub === "rewind") {
|
|
68
|
+
if (!id) throw new Error("checkpointId is required for rewind — use checkpointAction=list to see snapshot ids")
|
|
69
|
+
if (!path) throw new Error("path is required for rewind — full restore is disabled (as dangerous as `git checkout -- .`). Restore files individually. Use checkpointAction=versions path=<file> to list a file's historical versions.")
|
|
70
|
+
const s = await rewind(ctx.cwd, id, { path })
|
|
71
|
+
return `Restored "${path}" (${s.type}) from checkpoint ${id}.\n(The pre-restore state was snapshotted first — you can restore again to go back.)`
|
|
72
|
+
}
|
|
73
|
+
if (sub === "cat") {
|
|
74
|
+
if (!id) throw new Error("checkpointId is required for cat — use checkpointAction=list to see snapshot ids")
|
|
75
|
+
if (!path) throw new Error("path is required for cat — specify which file to read")
|
|
76
|
+
return await catFile(ctx.cwd, id, path)
|
|
77
|
+
}
|
|
78
|
+
if (sub === "list") {
|
|
79
|
+
const cps = await listCheckpoints(ctx.cwd)
|
|
80
|
+
if (cps.length === 0) return "(no checkpoints yet)"
|
|
81
|
+
|
|
82
|
+
// Specific id: show the file tree within that snapshot
|
|
83
|
+
if (id) {
|
|
84
|
+
const cp = cps.find((c) => c.id === id)
|
|
85
|
+
if (!cp) throw new Error(`checkpoint ${id} not found`)
|
|
86
|
+
return formatFileTree(cp)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Overview: list of all snapshots (file names are XML-escaped: they are
|
|
90
|
+
// untrusted input that flows back into the model's context). F2/D7: fixed
|
|
91
|
+
// hint line at the tail — the recovery entry for a snapshot after an accident.
|
|
92
|
+
return cps.map((c) => {
|
|
93
|
+
const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
|
|
94
|
+
if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.map(escapeXml).join(", ")}`)
|
|
95
|
+
if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.map(escapeXml).join(", ")}`)
|
|
96
|
+
return parts.join(" ")
|
|
97
|
+
}).join("\n") + "\n(意外丢弃改动?checkpointAction=rewind 可恢复操作前状态)"
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat | versions`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Format a checkpoint's file list as a directory tree (directories first, indented display) */
|
|
103
|
+
function formatFileTree(cp) {
|
|
104
|
+
// File names are XML-escaped: untrusted input that flows back into the model's context
|
|
105
|
+
const all = [
|
|
106
|
+
...(cp.tracked ?? []).map((f) => ({ path: escapeXml(f), type: "" })),
|
|
107
|
+
...(cp.untracked ?? []).map((f) => ({ path: escapeXml(f), type: " (untracked)" })),
|
|
108
|
+
]
|
|
109
|
+
if (all.length === 0) return "(empty checkpoint)"
|
|
110
|
+
|
|
111
|
+
all.sort((a, b) => a.path.localeCompare(b.path))
|
|
112
|
+
|
|
113
|
+
const tree = new Map()
|
|
114
|
+
for (const { path, type } of all) {
|
|
115
|
+
const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "."
|
|
116
|
+
if (!tree.has(dir)) tree.set(dir, [])
|
|
117
|
+
tree.get(dir).push({ name: path.slice(dir === "." ? 0 : dir.length + 1), type })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const lines = []
|
|
121
|
+
const dirs = [...tree.keys()].sort()
|
|
122
|
+
for (const dir of dirs) {
|
|
123
|
+
if (dir !== "." && !lines.includes(dir + "/")) {
|
|
124
|
+
const parts = dir.split("/")
|
|
125
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
126
|
+
const prefix = parts.slice(0, i).join("/") + "/"
|
|
127
|
+
if (!lines.includes(prefix)) lines.push(prefix)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const dir of dirs) {
|
|
132
|
+
if (dir !== ".") {
|
|
133
|
+
for (const { name, type } of tree.get(dir)) {
|
|
134
|
+
lines.push(` ${dir}/${name}${type}`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const { name, type } of tree.get(".") ?? []) {
|
|
139
|
+
lines.push(name + type)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return lines.join("\n")
|
|
143
|
+
}
|