thincoder 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/bin/thincoder.mjs +25 -25
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +17 -12
- package/src/agent/helpers.mjs +22 -11
- package/src/agent/setup.mjs +32 -10
- package/src/agent-tools/goal.mjs +12 -11
- package/src/agent-tools/plan.mjs +5 -6
- package/src/agent-tools/recent-changes.mjs +3 -3
- package/src/agent-tools/skill.mjs +6 -6
- package/src/agent-tools/subagent.mjs +19 -18
- package/src/agent-tools/task.mjs +7 -26
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +82 -92
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +9 -9
- package/src/cli/memory-command.mjs +1 -1
- package/src/cli/permission.mjs +3 -3
- package/src/cli/setup-wizard.mjs +15 -15
- package/src/config.mjs +56 -53
- package/src/context.mjs +59 -61
- package/src/distill.mjs +35 -35
- package/src/embedding.mjs +17 -17
- package/src/git/checkpoint.mjs +211 -38
- package/src/git/gitmem.mjs +21 -20
- package/src/markdown.mjs +13 -13
- package/src/mcp/helpers.mjs +6 -1
- package/src/mcp/transport-http.mjs +2 -1
- package/src/mcp/transport-stdio.mjs +3 -2
- package/src/mcp/transport-ws.mjs +3 -2
- package/src/mcp.mjs +5 -2
- package/src/memory/code-index.mjs +16 -14
- package/src/memory/code-sync.mjs +36 -26
- package/src/memory/core.mjs +42 -35
- package/src/memory/docs.mjs +24 -15
- package/src/memory/schema.mjs +28 -27
- package/src/memory.mjs +2 -2
- package/src/prompts/coder.md +0 -6
- package/src/prompts/discipline.md +9 -0
- package/src/prompts/main.md +24 -24
- package/src/prompts/system.md +21 -16
- package/src/provider/core.mjs +11 -6
- package/src/provider/index.mjs +2 -2
- package/src/provider/rate.mjs +11 -11
- package/src/session.mjs +73 -42
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +20 -14
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +1 -1
- package/src/tools/ls.md +1 -1
- package/src/tools/patch.mjs +20 -18
- package/src/tools/repomap-parse.mjs +17 -17
- package/src/tools/repomap.mjs +29 -29
- package/src/tools/shared.mjs +55 -28
- package/src/tools/system.mjs +163 -118
- package/src/tools/web.mjs +6 -6
- package/src/tui/agent-turn.mjs +82 -26
- package/src/tui/ansi.mjs +5 -3
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +3 -12
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +108 -23
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +11 -3
- package/src/tui/cmd-goal.mjs +3 -12
- package/src/tui/cmd-help.mjs +2 -2
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +38 -25
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +33 -13
- package/src/tui/cmd-plan.mjs +3 -12
- package/src/tui/cmd-reindex.mjs +2 -2
- package/src/tui/cmd-restore.mjs +1 -1
- package/src/tui/cmd-session.mjs +1 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +4 -11
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +4 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +34 -26
- package/src/tui/layout.mjs +22 -17
- package/src/tui/pickers.mjs +19 -19
- package/src/tui/render-frame.mjs +37 -11
- package/src/tui/render.mjs +25 -24
- package/src/tui/slash-commands.mjs +22 -22
- package/src/tui/startup.mjs +15 -14
- package/src/tui/wizard.mjs +11 -11
- package/src/tui.mjs +2 -2
- package/bin/thincoder.js +0 -4
- package/src/tools/bash.mjs +0 -144
- package/src/tools/glob.mjs +0 -51
- package/src/tools/grep.mjs +0 -100
- package/src/tools/ls.mjs +0 -36
- package/src/tools.mjs +0 -2
- package/src/tui-render.mjs +0 -4
package/src/git/checkpoint.mjs
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* checkpoint.mjs —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* checkpoint.mjs — workspace snapshot and rollback
|
|
3
|
+
* Snapshot = git diff HEAD patch + untracked file copies (respects .gitignore).
|
|
4
|
+
* Only available inside git repos. Rewind creates a new snapshot first (rewind is reversible).
|
|
5
|
+
* rewind supports a path parameter for per-file restore (restores only the specified file).
|
|
5
6
|
*/
|
|
6
|
-
|
|
7
7
|
import { execFileSync } from "node:child_process"
|
|
8
8
|
import { createHash } from "node:crypto"
|
|
9
|
-
import { existsSync } from "node:fs"
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
10
10
|
import { cp, mkdir, readFile, readdir, rm, writeFile, copyFile } from "node:fs/promises"
|
|
11
|
-
import { dirname, join
|
|
11
|
+
import { dirname, join } from "node:path"
|
|
12
12
|
import { configDir } from "../config.mjs"
|
|
13
13
|
|
|
14
|
+
const CWD_HASH_LEN = 12
|
|
15
|
+
|
|
14
16
|
const MAX_CHECKPOINTS = 20
|
|
15
17
|
|
|
16
18
|
function git(cwd, args, { allowFail = false } = {}) {
|
|
@@ -23,46 +25,71 @@ function git(cwd, args, { allowFail = false } = {}) {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function checkpointRoot(cwd) {
|
|
26
|
-
const hash = createHash("sha1").update(cwd).digest("hex").slice(0,
|
|
28
|
+
const hash = createHash("sha1").update(cwd).digest("hex").slice(0, CWD_HASH_LEN)
|
|
27
29
|
return join(configDir, "checkpoints", hash)
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
/**
|
|
32
|
+
/** Extract the list of tracked files changed from a unified diff */
|
|
33
|
+
function trackedFilesFromPatch(patch) {
|
|
34
|
+
if (!patch.trim()) return []
|
|
35
|
+
const files = new Set()
|
|
36
|
+
for (const m of patch.matchAll(/^--- a\/(.+)$/gm)) files.add(m[1])
|
|
37
|
+
return [...files].sort()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Extract a single file's patch hunks from a unified diff */
|
|
41
|
+
function extractFileHunks(patch, filePath) {
|
|
42
|
+
// Split by file: each file block starts with "diff --git"
|
|
43
|
+
const sections = patch.split(/(?=^diff --git )/m)
|
|
44
|
+
for (const sec of sections) {
|
|
45
|
+
if (!sec.trim()) continue
|
|
46
|
+
const m = sec.match(/^diff --git a\/(.+) b\/(.+)/m)
|
|
47
|
+
if (!m) continue
|
|
48
|
+
if (m[1] === filePath || m[2] === filePath) return sec.trim()
|
|
49
|
+
}
|
|
50
|
+
return ""
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether the current directory is a git repo */
|
|
31
54
|
export function isGitRepo(cwd) {
|
|
32
55
|
return git(cwd, ["rev-parse", "--is-inside-work-tree"], { allowFail: true }) === "true"
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
/**
|
|
36
|
-
*
|
|
59
|
+
* Create a snapshot. Returns { id, time, files } or null (non-git repo).
|
|
37
60
|
*/
|
|
38
61
|
export async function createCheckpoint(cwd) {
|
|
39
62
|
if (!isGitRepo(cwd)) return null
|
|
40
63
|
|
|
41
|
-
//
|
|
64
|
+
// Random suffix: prevents id collisions for two snapshots in the same millisecond (sorting stays ordered by timestamp prefix)
|
|
42
65
|
const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6)
|
|
43
66
|
const dir = join(checkpointRoot(cwd), id)
|
|
44
67
|
await mkdir(join(dir, "untracked"), { recursive: true })
|
|
45
68
|
|
|
46
|
-
//
|
|
69
|
+
// Tracked file changes → patch
|
|
47
70
|
const patch = git(cwd, ["diff", "HEAD", "--binary"], { allowFail: true }) ?? ""
|
|
48
71
|
await writeFile(join(dir, "patch.diff"), patch, "utf8")
|
|
72
|
+
const tracked = trackedFilesFromPatch(patch)
|
|
49
73
|
|
|
50
|
-
//
|
|
74
|
+
// Untracked files (respects .gitignore) → copy as-is
|
|
51
75
|
const untrackedRaw = git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? ""
|
|
52
76
|
const untracked = untrackedRaw ? untrackedRaw.split("\n").filter(Boolean) : []
|
|
53
77
|
for (const rel of untracked) {
|
|
54
78
|
const src = join(cwd, rel)
|
|
55
79
|
const dst = join(dir, "untracked", rel)
|
|
56
80
|
await mkdir(dirname(dst), { recursive: true })
|
|
57
|
-
await copyFile(src, dst).catch(() => {}) //
|
|
81
|
+
await copyFile(src, dst).catch(() => {}) // Copy failed (socket/device file etc.) — skip
|
|
58
82
|
}
|
|
59
|
-
|
|
83
|
+
|
|
84
|
+
await writeFile(join(dir, "meta.json"), JSON.stringify({
|
|
85
|
+
id, time: Date.now(), untracked, tracked,
|
|
86
|
+
}, null, 2), "utf8")
|
|
60
87
|
|
|
61
88
|
await pruneCheckpoints(cwd)
|
|
62
|
-
return { id, time: Date.now(), files: untracked.length +
|
|
89
|
+
return { id, time: Date.now(), files: untracked.length + tracked.length, tracked, untracked }
|
|
63
90
|
}
|
|
64
91
|
|
|
65
|
-
/**
|
|
92
|
+
/** List checkpoints (newest→oldest), with file change summary */
|
|
66
93
|
export async function listCheckpoints(cwd) {
|
|
67
94
|
const root = checkpointRoot(cwd)
|
|
68
95
|
if (!existsSync(root)) return []
|
|
@@ -71,37 +98,38 @@ export async function listCheckpoints(cwd) {
|
|
|
71
98
|
for (const id of ids) {
|
|
72
99
|
try {
|
|
73
100
|
const meta = JSON.parse(await readFile(join(root, id, "meta.json"), "utf8"))
|
|
74
|
-
|
|
101
|
+
// Compat with old checkpoints (no tracked field): extract from patch file
|
|
102
|
+
const tracked = meta.tracked ?? (() => {
|
|
103
|
+
try {
|
|
104
|
+
return trackedFilesFromPatch(readFileSync(join(root, id, "patch.diff"), "utf8"))
|
|
105
|
+
} catch { return [] }
|
|
106
|
+
})()
|
|
107
|
+
out.push({
|
|
108
|
+
id, time: meta.time,
|
|
109
|
+
untracked: meta.untracked ?? [],
|
|
110
|
+
tracked,
|
|
111
|
+
})
|
|
75
112
|
} catch {
|
|
76
|
-
//
|
|
113
|
+
// Corrupted checkpoint — skip
|
|
77
114
|
}
|
|
78
115
|
}
|
|
79
116
|
return out
|
|
80
117
|
}
|
|
81
118
|
|
|
82
|
-
|
|
83
|
-
* 回滚到指定快照(先把当前状态存成新快照,保证回滚可逆)。
|
|
84
|
-
* 返回恢复摘要。
|
|
85
|
-
*/
|
|
86
|
-
export async function rewind(cwd, id) {
|
|
87
|
-
const dir = join(checkpointRoot(cwd), id)
|
|
88
|
-
if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
|
|
89
|
-
const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
|
|
90
|
-
|
|
91
|
-
// 回滚也可逆:先给当前状态打快照
|
|
92
|
-
await createCheckpoint(cwd)
|
|
119
|
+
// ---- restore core ----
|
|
93
120
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
// 有 staged 改动时工作区留下的是 staged 版本,补丁(diff HEAD,含 staged 内容)会 apply 失败
|
|
121
|
+
/** Full restore of tracked files from a checkpoint: reset to HEAD → apply patch */
|
|
122
|
+
async function fullRestoreTracked(cwd, dir) {
|
|
97
123
|
git(cwd, ["restore", "--source=HEAD", "--staged", "--worktree", "."])
|
|
98
124
|
const patch = await readFile(join(dir, "patch.diff"), "utf8")
|
|
99
125
|
if (patch.trim()) {
|
|
100
|
-
|
|
101
|
-
git(cwd, ["apply", "--whitespace=nowarn", patchFile])
|
|
126
|
+
git(cwd, ["apply", "--whitespace=nowarn", join(dir, "patch.diff")])
|
|
102
127
|
}
|
|
128
|
+
return Boolean(patch.trim())
|
|
129
|
+
}
|
|
103
130
|
|
|
104
|
-
|
|
131
|
+
/** Full restore of untracked files from a checkpoint: delete new ones → restore from snapshot */
|
|
132
|
+
async function fullRestoreUntracked(cwd, dir, meta) {
|
|
105
133
|
const nowUntracked = (git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? "")
|
|
106
134
|
.split("\n")
|
|
107
135
|
.filter(Boolean)
|
|
@@ -114,7 +142,6 @@ export async function rewind(cwd, id) {
|
|
|
114
142
|
}
|
|
115
143
|
}
|
|
116
144
|
|
|
117
|
-
// 3. 快照时存在、现在被改/被删的未跟踪文件 → 还原
|
|
118
145
|
let restored = 0
|
|
119
146
|
for (const rel of meta.untracked) {
|
|
120
147
|
const src = join(dir, "untracked", rel)
|
|
@@ -124,11 +151,157 @@ export async function rewind(cwd, id) {
|
|
|
124
151
|
restored++
|
|
125
152
|
}
|
|
126
153
|
}
|
|
154
|
+
return { deleted, restored }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Restore a single tracked file: checkout HEAD → apply that file's patch hunks */
|
|
158
|
+
async function partialRestoreTracked(cwd, patchContent, filePath) {
|
|
159
|
+
// Reset the file to HEAD state first
|
|
160
|
+
git(cwd, ["checkout", "HEAD", "--", filePath], { allowFail: true })
|
|
161
|
+
const hunks = extractFileHunks(patchContent, filePath)
|
|
162
|
+
if (!hunks) return false
|
|
163
|
+
const tmpFile = join(checkpointRoot(cwd), ".tmp_partial.patch")
|
|
164
|
+
await writeFile(tmpFile, hunks, "utf8")
|
|
165
|
+
try {
|
|
166
|
+
git(cwd, ["apply", "--whitespace=nowarn", tmpFile])
|
|
167
|
+
return true
|
|
168
|
+
} finally {
|
|
169
|
+
await rm(tmpFile, { force: true })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Restore a single untracked file */
|
|
174
|
+
async function partialRestoreUntracked(cwd, dir, filePath) {
|
|
175
|
+
const src = join(dir, "untracked", filePath)
|
|
176
|
+
if (!existsSync(src)) return false
|
|
177
|
+
await mkdir(dirname(join(cwd, filePath)), { recursive: true })
|
|
178
|
+
await cp(src, join(cwd, filePath), { force: true })
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---- rewind ----
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Rewind to a specific snapshot (saves current state as a new snapshot first, making rewind reversible).
|
|
186
|
+
*
|
|
187
|
+
* Options:
|
|
188
|
+
* - path: restore only this single file (tracked or untracked); other files are left untouched.
|
|
189
|
+
* Omit for a full rewind (all changes → snapshot state).
|
|
190
|
+
*
|
|
191
|
+
* Returns summary { patchApplied, deleted?, restored? }; in path mode includes { file, type }.
|
|
192
|
+
*/
|
|
193
|
+
export async function rewind(cwd, id, { path } = {}) {
|
|
194
|
+
const root = checkpointRoot(cwd)
|
|
195
|
+
const dir = join(root, id)
|
|
196
|
+
if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
|
|
197
|
+
const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
|
|
198
|
+
const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
|
|
199
|
+
|
|
200
|
+
// Rewind is reversible: snapshot current state first
|
|
201
|
+
const preRewindCp = await createCheckpoint(cwd)
|
|
202
|
+
|
|
203
|
+
// ---- per-file restore ----
|
|
204
|
+
if (path) {
|
|
205
|
+
// Determine whether it's a tracked or untracked file
|
|
206
|
+
const isTracked = meta.tracked?.includes(path) ?? extractFileHunks(patchContent, path) !== ""
|
|
207
|
+
const inUntracked = (meta.untracked ?? []).includes(path)
|
|
208
|
+
|
|
209
|
+
if (!isTracked && !inUntracked) {
|
|
210
|
+
throw new Error(`file "${path}" not found in checkpoint ${id} (tracked: ${(meta.tracked ?? []).join(", ") || "none"}, untracked: ${(meta.untracked ?? []).join(", ") || "none"})`)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let ok = false
|
|
214
|
+
if (isTracked) {
|
|
215
|
+
ok = await partialRestoreTracked(cwd, patchContent, path)
|
|
216
|
+
}
|
|
217
|
+
if (inUntracked) {
|
|
218
|
+
ok = await partialRestoreUntracked(cwd, dir, path) || ok
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { path, type: isTracked ? "tracked" : "untracked", restored: ok, patchApplied: false }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- full rewind ----
|
|
225
|
+
try {
|
|
226
|
+
const patchApplied = await fullRestoreTracked(cwd, dir)
|
|
227
|
+
const { deleted, restored } = await fullRestoreUntracked(cwd, dir, meta)
|
|
228
|
+
return { deleted, restored, patchApplied }
|
|
229
|
+
} catch (e) {
|
|
230
|
+
// git apply failed: working tree may have been reset to HEAD by restore.
|
|
231
|
+
// Restore from pre-rewind snapshot to ensure no data is lost on rewind failure
|
|
232
|
+
const preDir = join(root, preRewindCp.id)
|
|
233
|
+
try {
|
|
234
|
+
await fullRestoreTracked(cwd, preDir)
|
|
235
|
+
await fullRestoreUntracked(cwd, preDir, JSON.parse(await readFile(join(preDir, "meta.json"), "utf8")))
|
|
236
|
+
} catch {
|
|
237
|
+
// Double failure: pre-rewind may also be corrupt, stop trying
|
|
238
|
+
}
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Rewind to ${id} failed: ${e.message}. ` +
|
|
241
|
+
`The pre-rewind state was restored from checkpoint ${preRewindCp.id} — no work was lost.`
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
127
245
|
|
|
128
|
-
|
|
246
|
+
// ---- view ----
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* View a file's content from a checkpoint (does not modify the working tree).
|
|
250
|
+
* Tracked files: temporarily restore via checkout HEAD + apply patch hunks, read, then restore original.
|
|
251
|
+
* Untracked files: read the copy directly from the checkpoint directory.
|
|
252
|
+
* Returns the file content string.
|
|
253
|
+
*/
|
|
254
|
+
export async function catFile(cwd, id, filePath) {
|
|
255
|
+
const root = checkpointRoot(cwd)
|
|
256
|
+
const dir = join(root, id)
|
|
257
|
+
if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
|
|
258
|
+
const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
|
|
259
|
+
const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
|
|
260
|
+
|
|
261
|
+
const isTracked = meta.tracked?.includes(filePath) ?? extractFileHunks(patchContent, filePath) !== ""
|
|
262
|
+
const inUntracked = (meta.untracked ?? []).includes(filePath)
|
|
263
|
+
|
|
264
|
+
if (!isTracked && !inUntracked) {
|
|
265
|
+
throw new Error(`file "${filePath}" not in checkpoint ${id}`)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Untracked file: read the copy directly
|
|
269
|
+
if (inUntracked && !isTracked) {
|
|
270
|
+
const src = join(dir, "untracked", filePath)
|
|
271
|
+
if (!existsSync(src)) throw new Error(`untracked file "${filePath}" copy missing in checkpoint`)
|
|
272
|
+
return await readFile(src, "utf8")
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Tracked file: temporarily restore → read → restore working tree
|
|
276
|
+
const abs = join(cwd, filePath)
|
|
277
|
+
const existed = existsSync(abs)
|
|
278
|
+
let saved = null
|
|
279
|
+
if (existed) saved = await readFile(abs, "utf8")
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
git(cwd, ["checkout", "HEAD", "--", filePath])
|
|
283
|
+
const hunks = extractFileHunks(patchContent, filePath)
|
|
284
|
+
if (hunks) {
|
|
285
|
+
const tmpPatch = join(root, ".tmp_cat.patch")
|
|
286
|
+
await writeFile(tmpPatch, hunks, "utf8")
|
|
287
|
+
try {
|
|
288
|
+
git(cwd, ["apply", "--whitespace=nowarn", tmpPatch])
|
|
289
|
+
} finally {
|
|
290
|
+
await rm(tmpPatch, { force: true })
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return await readFile(abs, "utf8")
|
|
294
|
+
} finally {
|
|
295
|
+
// Restore original working tree state
|
|
296
|
+
if (existed) {
|
|
297
|
+
await writeFile(abs, saved, "utf8")
|
|
298
|
+
} else {
|
|
299
|
+
await rm(abs, { force: true })
|
|
300
|
+
}
|
|
301
|
+
}
|
|
129
302
|
}
|
|
130
303
|
|
|
131
|
-
/**
|
|
304
|
+
/** Keep only the most recent MAX_CHECKPOINTS */
|
|
132
305
|
async function pruneCheckpoints(cwd) {
|
|
133
306
|
const root = checkpointRoot(cwd)
|
|
134
307
|
const ids = (await readdir(root)).sort()
|
package/src/git/gitmem.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* gitmem.mjs — Team
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* gitmem.mjs — Team-layer memory git sync
|
|
3
|
+
* All through child_process calling system git, zero dependencies.
|
|
4
|
+
* Conflict strategy (decided): different entries are naturally conflict-free;
|
|
5
|
+
* on real conflicts abort rebase to keep the repo clean,
|
|
6
|
+
* report an error with manual resolution guidance — no auto-merge.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { execFile } from "node:child_process"
|
|
@@ -13,7 +14,7 @@ import { promisify } from "node:util"
|
|
|
13
14
|
|
|
14
15
|
const execFileAsync = promisify(execFile)
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
+
/** Run git in dir, throw with stderr on failure */
|
|
17
18
|
async function git(dir, args) {
|
|
18
19
|
try {
|
|
19
20
|
const { stdout } = await execFileAsync("git", args, { cwd: dir, encoding: "utf8" })
|
|
@@ -27,7 +28,7 @@ async function git(dir, args) {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
+
/** Clone team repo if it doesn't exist. Returns whether a clone happened */
|
|
31
32
|
export async function ensureClone({ repo, dir }) {
|
|
32
33
|
if (existsSync(join(dir, ".git"))) return false
|
|
33
34
|
await mkdir(dirname(dir), { recursive: true })
|
|
@@ -36,12 +37,12 @@ export async function ensureClone({ repo, dir }) {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* Sync: pull --rebase. Skip if remote is still empty (first-time use).
|
|
41
|
+
* On conflict, abort rebase (keep repo clean) and throw a guided error.
|
|
42
|
+
* Returns true = pull succeeded (caller should then syncDir to rebuild index)
|
|
42
43
|
*/
|
|
43
44
|
export async function pullTeam(dir) {
|
|
44
|
-
//
|
|
45
|
+
// Empty remote repo: no branch to pull (ls-remote has no output)
|
|
45
46
|
const refs = await git(dir, ["ls-remote", "--heads", "origin"])
|
|
46
47
|
if (!refs) return false
|
|
47
48
|
|
|
@@ -52,9 +53,9 @@ export async function pullTeam(dir) {
|
|
|
52
53
|
if (await hasConflict(dir)) {
|
|
53
54
|
await git(dir, ["rebase", "--abort"]).catch(() => {})
|
|
54
55
|
throw new Error(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
`Team memory sync conflict: local and remote modified the same entry.\n` +
|
|
57
|
+
`Please resolve manually in ${dir} with \`git pull\`, then re-run \`thincoder sync\`.\n` +
|
|
58
|
+
`(The local repo has been restored to its pre-sync state — nothing was lost.)`,
|
|
58
59
|
)
|
|
59
60
|
}
|
|
60
61
|
throw error
|
|
@@ -62,28 +63,28 @@ export async function pullTeam(dir) {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
|
-
*
|
|
66
|
-
* rebase
|
|
66
|
+
* Commit and push an entry file. On push rejection (remote has new commits),
|
|
67
|
+
* pull --rebase then retry once; rebase conflicts also abort and error out.
|
|
67
68
|
*/
|
|
68
69
|
export async function commitAndPush(dir, filename, message) {
|
|
69
70
|
await git(dir, ["add", filename])
|
|
70
|
-
//
|
|
71
|
+
// Nothing to commit (content unchanged) — this is normal idempotent behavior, not an error
|
|
71
72
|
const dirty = await git(dir, ["status", "--porcelain", "--", filename])
|
|
72
73
|
if (dirty) await git(dir, ["commit", "-m", message])
|
|
73
74
|
try {
|
|
74
75
|
await git(dir, ["push"])
|
|
75
76
|
} catch {
|
|
76
|
-
await pullTeam(dir) //
|
|
77
|
+
await pullTeam(dir) // Conflicts will throw a guided error here
|
|
77
78
|
await git(dir, ["push"])
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
/**
|
|
82
|
+
/** Whether currently in a rebase conflict state (unmerged paths exist) */
|
|
82
83
|
async function hasConflict(dir) {
|
|
83
84
|
try {
|
|
84
85
|
const out = await git(dir, ["status", "--porcelain"])
|
|
85
|
-
//
|
|
86
|
-
//
|
|
86
|
+
// There are 7 unmerged states: DD AU UD UA DU AA UU — only checking UU/AA/DD misses 4 states with U,
|
|
87
|
+
// which means the rebase is left mid-conflict (violating the "keep repo clean" promise)
|
|
87
88
|
return out.split("\n").some((l) => l[0] === "U" || l[1] === "U" || l.startsWith("AA") || l.startsWith("DD"))
|
|
88
89
|
} catch {
|
|
89
90
|
return false
|
package/src/markdown.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* markdown.mjs —
|
|
3
|
-
*
|
|
2
|
+
* markdown.mjs — markdown + frontmatter format for memory entries
|
|
3
|
+
* Zero-dependency parsing/serialization. Entry format see ARCHITECTURE-v2.md.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const VALID_TYPES = new Set(["rule", "knowledge", "decision", "pattern"])
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* Parse a markdown entry.
|
|
10
10
|
* → { meta: { type, title, tags, author, created, embedding? }, content }
|
|
11
|
-
*
|
|
11
|
+
* Throws if frontmatter is missing or required fields absent.
|
|
12
12
|
*/
|
|
13
13
|
export function parseEntry(text) {
|
|
14
14
|
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
|
|
@@ -36,13 +36,13 @@ export function parseEntry(text) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
39
|
+
* Serialize to markdown entry text.
|
|
40
40
|
*/
|
|
41
41
|
export function serializeEntry(meta, content) {
|
|
42
42
|
if (!VALID_TYPES.has(meta.type)) throw new Error(`invalid type "${meta.type}"`)
|
|
43
43
|
if (!meta.title) throw new Error("meta.title is required")
|
|
44
|
-
// frontmatter
|
|
45
|
-
//
|
|
44
|
+
// frontmatter scalars must be single-line: newlines in title/author would inject fake frontmatter rows
|
|
45
|
+
// (e.g. title "x\ntype: rule" would override real type when parsed), same for tags with newlines/commas
|
|
46
46
|
const tags = (meta.tags ?? []).map((t) => oneLine(t).replaceAll(",", " ")).join(", ")
|
|
47
47
|
const lines = [
|
|
48
48
|
"---",
|
|
@@ -57,7 +57,7 @@ export function serializeEntry(meta, content) {
|
|
|
57
57
|
return lines.join("\n")
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
/**
|
|
60
|
+
/** Convert title to filename slug: keep alphanumeric + CJK, convert rest to hyphens */
|
|
61
61
|
export function slugify(title) {
|
|
62
62
|
return title
|
|
63
63
|
.trim()
|
|
@@ -67,23 +67,23 @@ export function slugify(title) {
|
|
|
67
67
|
.slice(0, 50) || "untitled"
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
70
|
+
/** Generate entry filename: YYYYMMDD-<slug>-<rand4>.md */
|
|
71
71
|
export function entryFilename(title, date = new Date()) {
|
|
72
72
|
const ymd = date.toISOString().slice(0, 10).replaceAll("-", "")
|
|
73
73
|
const rand = Math.random().toString(36).slice(2, 6)
|
|
74
74
|
return `${ymd}-${slugify(title)}-${rand}.md`
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// ----------------------------------------------------------------
|
|
77
|
+
// ---------------------------------------------------------------- internal
|
|
78
78
|
|
|
79
|
-
/**
|
|
79
|
+
/** Collapse to single line (for frontmatter scalars): fold newlines into spaces, prevent injecting fake field lines */
|
|
80
80
|
function oneLine(v) {
|
|
81
81
|
return String(v).replace(/\s*\r?\n\s*/g, " ").trim()
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
85
|
+
* Minimal YAML subset parser: only supports `key: value` and `key: [a, b, c]`.
|
|
86
|
+
* Our frontmatter is self-generated, no need for full YAML.
|
|
87
87
|
*/
|
|
88
88
|
function parseFrontmatter(text) {
|
|
89
89
|
const meta = {}
|
package/src/mcp/helpers.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* mcp/helpers.mjs — MCP
|
|
2
|
+
* mcp/helpers.mjs — MCP shared utility functions and constants
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export const INIT_TIMEOUT_MS = 30_000
|
|
@@ -7,10 +7,12 @@ export const CALL_TIMEOUT_MS = 120_000
|
|
|
7
7
|
export const ENDPOINT_WAIT_MS = 5_000
|
|
8
8
|
|
|
9
9
|
let nextRpcId = 0
|
|
10
|
+
/** Generate a unique incrementing RPC ID string */
|
|
10
11
|
export function rpcId() {
|
|
11
12
|
return String(++nextRpcId)
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/** Race a promise against a timeout, rejecting after ms milliseconds */
|
|
14
16
|
export function withTimeout(promise, ms) {
|
|
15
17
|
let timer
|
|
16
18
|
const timeout = new Promise((_, reject) => {
|
|
@@ -20,10 +22,12 @@ export function withTimeout(promise, ms) {
|
|
|
20
22
|
return Promise.race([promise.finally(() => clearTimeout(timer)), timeout])
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
/** Quote a shell argument: wrap in double-quotes if it contains whitespace or quotes */
|
|
23
26
|
export function quoteArg(s) {
|
|
24
27
|
return /[\s"]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
/** Append a Bearer token as a query parameter to a WebSocket URL */
|
|
27
31
|
export function withAuthToken(wsUrl, authorization) {
|
|
28
32
|
if (!authorization) return wsUrl
|
|
29
33
|
const token = authorization.replace(/^Bearer\s+/i, "")
|
|
@@ -32,6 +36,7 @@ export function withAuthToken(wsUrl, authorization) {
|
|
|
32
36
|
return u.href
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
/** Sanitize a tool name: replace non-alphanumeric chars with underscores, cap at 64 chars */
|
|
35
40
|
export function sanitizeToolName(name) {
|
|
36
41
|
return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
|
|
37
42
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { rpcId, CALL_TIMEOUT_MS, ENDPOINT_WAIT_MS, withTimeout } from "./helpers.mjs"
|
|
5
5
|
|
|
6
|
+
/** Create an MCP HTTP+SSE transport for Streamable HTTP servers */
|
|
6
7
|
export function httpTransport(baseURL, extraHeaders = {}) {
|
|
7
8
|
const url = baseURL.replace(/\/+$/, "")
|
|
8
9
|
let sessionId = null
|
|
@@ -82,7 +83,7 @@ export function httpTransport(baseURL, extraHeaders = {}) {
|
|
|
82
83
|
pending.delete(msg.id)
|
|
83
84
|
resolver(msg)
|
|
84
85
|
}
|
|
85
|
-
} catch { /*
|
|
86
|
+
} catch { /* not JSON, ignore */ }
|
|
86
87
|
}
|
|
87
88
|
} catch (error) {
|
|
88
89
|
if (!closed) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { spawn } from "node:child_process"
|
|
5
5
|
import { rpcId, CALL_TIMEOUT_MS, withTimeout, quoteArg } from "./helpers.mjs"
|
|
6
6
|
|
|
7
|
+
/** Create an MCP stdio transport over a spawned child process */
|
|
7
8
|
export function stdioTransport(command, args) {
|
|
8
9
|
const spawnOptions = { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, env: { ...process.env } }
|
|
9
10
|
const child =
|
|
@@ -39,7 +40,7 @@ export function stdioTransport(command, args) {
|
|
|
39
40
|
pending.delete(msg.id)
|
|
40
41
|
resolver(msg)
|
|
41
42
|
}
|
|
42
|
-
} catch { /*
|
|
43
|
+
} catch { /* non-JSON line, ignore */ }
|
|
43
44
|
}
|
|
44
45
|
})
|
|
45
46
|
|
|
@@ -77,7 +78,7 @@ export function stdioTransport(command, args) {
|
|
|
77
78
|
if (closed) return
|
|
78
79
|
try {
|
|
79
80
|
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n")
|
|
80
|
-
} catch { /*
|
|
81
|
+
} catch { /* ignore */ }
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
return { send, notify, close: () => { if (!closed) child.kill() } }
|
package/src/mcp/transport-ws.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { rpcId, INIT_TIMEOUT_MS, CALL_TIMEOUT_MS, withTimeout, withAuthToken } from "./helpers.mjs"
|
|
5
5
|
|
|
6
|
+
/** Create an MCP WebSocket transport */
|
|
6
7
|
export function wsTransport(wsUrl, extraHeaders = {}) {
|
|
7
8
|
const pending = new Map()
|
|
8
9
|
let closed = false
|
|
@@ -36,7 +37,7 @@ export function wsTransport(wsUrl, extraHeaders = {}) {
|
|
|
36
37
|
pending.delete(msg.id)
|
|
37
38
|
resolver(msg)
|
|
38
39
|
}
|
|
39
|
-
} catch { /*
|
|
40
|
+
} catch { /* not JSON, ignore */ }
|
|
40
41
|
})
|
|
41
42
|
|
|
42
43
|
ws.addEventListener("error", (event) => {
|
|
@@ -80,7 +81,7 @@ export function wsTransport(wsUrl, extraHeaders = {}) {
|
|
|
80
81
|
const close = () => {
|
|
81
82
|
closed = true
|
|
82
83
|
failAll("Connection closed")
|
|
83
|
-
try { ws?.close() } catch { /*
|
|
84
|
+
try { ws?.close() } catch { /* ignore */ }
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
return { send, notify, close, connect }
|
package/src/mcp.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mcp.mjs — MCP (Model Context Protocol) client
|
|
3
|
-
* config: { command, args?, name }
|
|
3
|
+
* config: { command, args?, name } or { url, name, headers? } or { wsUrl, name, headers? }
|
|
4
4
|
*/
|
|
5
5
|
import { INIT_TIMEOUT_MS, withTimeout, sanitizeToolName } from "./mcp/helpers.mjs"
|
|
6
6
|
import { stdioTransport } from "./mcp/transport-stdio.mjs"
|
|
@@ -46,6 +46,7 @@ async function doInitialize(transport, name) {
|
|
|
46
46
|
return toolsResp.result?.tools ?? []
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** Connect to an MCP server (stdio/http/ws), initialize, and return built tool wrappers */
|
|
49
50
|
export async function connectMcpServer(config) {
|
|
50
51
|
if (config.wsUrl) {
|
|
51
52
|
const transport = wsTransport(config.wsUrl, config.headers ?? {})
|
|
@@ -64,7 +65,7 @@ export async function connectMcpServer(config) {
|
|
|
64
65
|
try {
|
|
65
66
|
await transport.openSSE()
|
|
66
67
|
} catch {
|
|
67
|
-
//
|
|
68
|
+
// Server doesn't support GET (pure Streamable HTTP POST): degrade to no-SSE mode
|
|
68
69
|
}
|
|
69
70
|
try {
|
|
70
71
|
const mcpTools = await doInitialize(transport, config.name ?? config.url)
|
|
@@ -89,12 +90,14 @@ export async function connectMcpServer(config) {
|
|
|
89
90
|
throw new Error(`MCP server "${config.name}": needs either 'wsUrl' (websocket), 'command' (stdio), or 'url' (http)`)
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
/** Close all MCP transport connections on an agent's tools */
|
|
92
94
|
export function closeAllMcp(agent) {
|
|
93
95
|
for (const t of agent.tools) {
|
|
94
96
|
if (t._mcpTransport) t._mcpTransport.close()
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
/** Remove MCP tools belonging to a specific server from the agent's tool list */
|
|
98
101
|
export function removeMcpTools(agent, serverName) {
|
|
99
102
|
const keep = []
|
|
100
103
|
for (const t of agent.tools) {
|