thincoder 0.8.2 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/bin/thincoder.mjs +25 -25
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +17 -12
- package/src/agent/helpers.mjs +22 -11
- package/src/agent/setup.mjs +32 -10
- package/src/agent-tools/goal.mjs +7 -6
- package/src/agent-tools/plan.mjs +3 -3
- package/src/agent-tools/recent-changes.mjs +3 -3
- package/src/agent-tools/skill.mjs +6 -6
- package/src/agent-tools/subagent.mjs +19 -18
- package/src/agent-tools/task.mjs +4 -4
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +58 -37
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +9 -9
- package/src/cli/memory-command.mjs +1 -1
- package/src/cli/permission.mjs +3 -3
- package/src/cli/setup-wizard.mjs +15 -15
- package/src/config.mjs +55 -53
- package/src/context.mjs +59 -56
- package/src/distill.mjs +35 -35
- package/src/embedding.mjs +17 -17
- package/src/git/checkpoint.mjs +211 -38
- package/src/git/gitmem.mjs +21 -20
- package/src/markdown.mjs +13 -13
- package/src/mcp/helpers.mjs +6 -1
- package/src/mcp/transport-http.mjs +2 -1
- package/src/mcp/transport-stdio.mjs +3 -2
- package/src/mcp/transport-ws.mjs +3 -2
- package/src/mcp.mjs +5 -2
- package/src/memory/code-index.mjs +16 -14
- package/src/memory/code-sync.mjs +36 -26
- package/src/memory/core.mjs +42 -35
- package/src/memory/docs.mjs +24 -15
- package/src/memory/schema.mjs +28 -27
- package/src/memory.mjs +2 -2
- package/src/prompts/coder.md +1 -1
- package/src/prompts/discipline.md +3 -0
- package/src/prompts/main.md +1 -2
- package/src/prompts/system.md +21 -16
- package/src/provider/core.mjs +11 -6
- package/src/provider/index.mjs +2 -2
- package/src/provider/rate.mjs +11 -11
- package/src/session.mjs +73 -40
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +16 -12
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +1 -1
- package/src/tools/patch.mjs +20 -18
- package/src/tools/repomap-parse.mjs +17 -17
- package/src/tools/repomap.mjs +29 -29
- package/src/tools/shared.mjs +49 -28
- package/src/tools/system.mjs +163 -118
- package/src/tools/web.mjs +6 -6
- package/src/tui/agent-turn.mjs +82 -26
- package/src/tui/ansi.mjs +4 -2
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +1 -1
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +2 -2
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-goal.mjs +4 -4
- package/src/tui/cmd-help.mjs +1 -1
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +5 -5
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +1 -1
- package/src/tui/cmd-plan.mjs +1 -1
- package/src/tui/cmd-reindex.mjs +2 -2
- package/src/tui/cmd-restore.mjs +1 -1
- package/src/tui/cmd-session.mjs +1 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +2 -2
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +3 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +31 -23
- package/src/tui/layout.mjs +22 -17
- package/src/tui/pickers.mjs +19 -19
- package/src/tui/render-frame.mjs +37 -11
- package/src/tui/render.mjs +25 -24
- package/src/tui/slash-commands.mjs +14 -14
- package/src/tui/startup.mjs +15 -14
- package/src/tui/wizard.mjs +11 -11
- package/src/tui.mjs +2 -2
- package/bin/thincoder.js +0 -4
- package/src/tools/bash.mjs +0 -144
- package/src/tools/glob.mjs +0 -51
- package/src/tools/grep.mjs +0 -100
- package/src/tools/ls.mjs +0 -36
- package/src/tools.mjs +0 -2
- package/src/tui-render.mjs +0 -4
package/src/distill.mjs
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* distill.mjs —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* distill.mjs — extract knowledge candidates from sessions (the "automatic track" of the dual-track system)
|
|
3
|
+
* Principle (settled): manually triggered, LLM produces candidates, human confirms each one before writing.
|
|
4
|
+
* Absolutely no automatic storage at session end.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { chat } from "./provider/index.mjs"
|
|
8
8
|
import { put, putMarkdown } from "./memory.mjs"
|
|
9
9
|
import { commitAndPush } from "./git/gitmem.mjs"
|
|
10
10
|
|
|
11
|
-
const DISTILL_PROMPT =
|
|
11
|
+
const DISTILL_PROMPT = `You are a knowledge extractor. Read the following agent work session log and extract knowledge worth remembering across sessions.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Output a JSON array (nothing else):
|
|
14
14
|
[
|
|
15
15
|
{
|
|
16
16
|
"type": "rule | knowledge | decision | pattern",
|
|
17
|
-
"title": "
|
|
18
|
-
"content": "
|
|
17
|
+
"title": "Short title",
|
|
18
|
+
"content": "Full content, self-contained — understandable without session context",
|
|
19
19
|
"tags": ["tag1", "tag2"],
|
|
20
20
|
"scope": "personal | project"
|
|
21
21
|
}
|
|
22
22
|
]
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
- knowledge
|
|
26
|
-
- decision
|
|
27
|
-
- pattern
|
|
28
|
-
- rule
|
|
29
|
-
- scope
|
|
24
|
+
Extraction criteria:
|
|
25
|
+
- knowledge: factual project knowledge (architecture, deployment, conventions)
|
|
26
|
+
- decision: technical decisions made in the session and their rationale
|
|
27
|
+
- pattern: debugging experiences, solutions, reusable workflows
|
|
28
|
+
- rule: coding standards (caution! rules are usually best written manually; only extract rules explicitly established in the session)
|
|
29
|
+
- scope: use "project" for project-specific knowledge; use "personal" for general or personal preferences
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
31
|
+
Do NOT extract:
|
|
32
|
+
- one-off task details ("changed line X in file Y today")
|
|
33
|
+
- transient state mentioned in the session (current bugs, work-in-progress)
|
|
34
|
+
- pleasantries and obvious facts
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
If nothing is worth extracting, output []
|
|
37
|
+
If the session is long, prioritize conclusions that appeared last and are still in effect.
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Session log:
|
|
40
40
|
`
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
43
|
+
* Extract candidates from a session transcript. transcript: plain-text session record.
|
|
44
|
+
* Returns [{ type, title, content, tags, scope }], or [] on parse failure.
|
|
45
45
|
*/
|
|
46
46
|
export async function extractCandidates(provider, transcript) {
|
|
47
47
|
const res = await chat(provider, {
|
|
48
48
|
messages: [{ role: "user", content: DISTILL_PROMPT + transcript }],
|
|
49
49
|
})
|
|
50
|
-
//
|
|
50
|
+
// Non-greedy match first JSON array (greedy [\s\S]* would eat across multiple arrays including interstitial text)
|
|
51
51
|
const match = res.content.match(/\[[\s\S]*?\]/)
|
|
52
52
|
if (!match) return []
|
|
53
53
|
try {
|
|
@@ -60,37 +60,37 @@ export async function extractCandidates(provider, transcript) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
63
|
+
* Convert agent's OpenAI-format history to readable session transcript text.
|
|
64
64
|
*/
|
|
65
65
|
export function historyToTranscript(history, { maxChars = 30_000 } = {}) {
|
|
66
66
|
const lines = []
|
|
67
67
|
for (const m of history) {
|
|
68
68
|
if (m.role === "tool") {
|
|
69
|
-
lines.push(`[
|
|
69
|
+
lines.push(`[tool result] ${(m.content ?? "").slice(0, 500)}`)
|
|
70
70
|
} else if (m.tool_calls?.length) {
|
|
71
71
|
const calls = m.tool_calls.map((tc) => `${tc.function?.name ?? "?"}(${tc.function?.arguments?.slice(0, 200) ?? ""})`).join(", ")
|
|
72
|
-
lines.push(`[assistant] ${m.content ?? ""}\n[
|
|
72
|
+
lines.push(`[assistant] ${m.content ?? ""}\n[called tools] ${calls}`)
|
|
73
73
|
} else {
|
|
74
74
|
lines.push(`[${m.role}] ${m.content ?? ""}`)
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
const text = lines.join("\n\n")
|
|
78
|
-
//
|
|
78
|
+
// Overlong: keep head and tail (earliest requirements + latest conclusions are most important)
|
|
79
79
|
if (text.length <= maxChars) return text
|
|
80
80
|
const half = Math.floor(maxChars / 2)
|
|
81
|
-
return text.slice(0, half) + "\n\n...[
|
|
81
|
+
return text.slice(0, half) + "\n\n...[... middle portion omitted ...]...\n\n" + text.slice(-half)
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
*
|
|
85
|
+
* Write confirmed candidates to the specified layer.
|
|
86
86
|
* opts: { projectDir, team: { dir } | null, author }
|
|
87
|
-
* scope=team
|
|
88
|
-
*
|
|
87
|
+
* scope=team requires opts.team; project requires opts.projectDir.
|
|
88
|
+
* Returns write result description.
|
|
89
89
|
*/
|
|
90
90
|
export async function saveCandidate(memory, candidate, opts = {}) {
|
|
91
91
|
const scope = candidate.scope ?? "personal"
|
|
92
|
-
// tags
|
|
93
|
-
//
|
|
92
|
+
// tags come from LLM output (untrusted): if not an array, stringify then split by comma/whitespace —
|
|
93
|
+
// calling .split on a non-string would crash, and models often produce "a, b" comma strings
|
|
94
94
|
const tags = Array.isArray(candidate.tags)
|
|
95
95
|
? candidate.tags.map((t) => String(t)).filter(Boolean)
|
|
96
96
|
: String(candidate.tags ?? "").split(/[\s,]+/).filter(Boolean)
|
|
@@ -100,7 +100,7 @@ export async function saveCandidate(memory, candidate, opts = {}) {
|
|
|
100
100
|
return `personal#${id}`
|
|
101
101
|
}
|
|
102
102
|
if (scope === "project") {
|
|
103
|
-
if (!opts.projectDir) throw new Error("project scope unavailable")
|
|
103
|
+
if (!opts.projectDir) throw new Error("project scope unavailable — no project directory configured (set memory.projectDir in ~/.thincoder/config.json)")
|
|
104
104
|
const filename = await putMarkdown(memory, {
|
|
105
105
|
layer: "project", dir: opts.projectDir,
|
|
106
106
|
type: candidate.type, title: candidate.title, content: candidate.content,
|
|
@@ -109,7 +109,7 @@ export async function saveCandidate(memory, candidate, opts = {}) {
|
|
|
109
109
|
return `project:${filename}`
|
|
110
110
|
}
|
|
111
111
|
if (scope === "team") {
|
|
112
|
-
if (!opts.team?.dir) throw new Error("team scope not configured")
|
|
112
|
+
if (!opts.team?.dir) throw new Error("team scope not configured — configure memory.team in ~/.thincoder/config.json")
|
|
113
113
|
const filename = await putMarkdown(memory, {
|
|
114
114
|
layer: "team", dir: opts.team.dir,
|
|
115
115
|
type: candidate.type, title: candidate.title, content: candidate.content,
|
package/src/embedding.mjs
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* embedding.mjs —
|
|
3
|
-
* OpenAI
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* embedding.mjs — vector embeddings
|
|
3
|
+
* OpenAI-compatible /v1/embeddings (SiliconFlow bge-m3 / Ollama / OpenAI all supported),
|
|
4
|
+
* reuses provider.mjs fetch + retry pattern, zero dependencies.
|
|
5
|
+
* Vectors are normalized before storage; dot product then equals cosine similarity.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { RETRYABLE_STATUS } from "./provider/index.mjs"
|
|
9
9
|
const MAX_RETRIES = 3
|
|
10
|
-
const BATCH_SIZE = 32 //
|
|
10
|
+
const BATCH_SIZE = 32 // max texts per request (within SiliconFlow limits)
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/** Create an embedder. config: { baseURL, apiKey, model } */
|
|
13
13
|
export function createEmbedder(config) {
|
|
14
|
-
if (!config?.baseURL) throw new Error("embedding config: baseURL is required")
|
|
15
|
-
if (!config?.apiKey) throw new Error("embedding config: apiKey is required
|
|
16
|
-
if (!config?.model) throw new Error("embedding config: model is required")
|
|
14
|
+
if (!config?.baseURL) throw new Error("embedding config: baseURL is required — configure embedding.baseURL in ~/.thincoder/config.json")
|
|
15
|
+
if (!config?.apiKey) throw new Error("embedding config: apiKey is required — set SILICONFLOW_API_KEY env or configure embedding.apiKey in ~/.thincoder/config.json")
|
|
16
|
+
if (!config?.model) throw new Error("embedding config: model is required — configure embedding.model in ~/.thincoder/config.json")
|
|
17
17
|
return {
|
|
18
18
|
baseURL: config.baseURL.replace(/\/+$/, ""),
|
|
19
19
|
apiKey: config.apiKey,
|
|
@@ -22,8 +22,8 @@ export function createEmbedder(config) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* Batch embedding. texts: string[] → Float32Array[] (normalized)
|
|
26
|
+
* Auto-batches, retries on failure (exponential backoff).
|
|
27
27
|
*/
|
|
28
28
|
export async function embed(embedder, texts, { signal } = {}) {
|
|
29
29
|
if (texts.length === 0) return []
|
|
@@ -31,11 +31,11 @@ export async function embed(embedder, texts, { signal } = {}) {
|
|
|
31
31
|
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
|
|
32
32
|
const batch = texts.slice(i, i + BATCH_SIZE)
|
|
33
33
|
const data = await requestWithRetry(embedder, batch, signal)
|
|
34
|
-
//
|
|
34
|
+
// Mismatched count is a hard error — silently accepting would misalign vectors with texts, poisoning the entire index
|
|
35
35
|
if (!Array.isArray(data.data) || data.data.length !== batch.length) {
|
|
36
36
|
throw new Error(`Embedding API returned ${data.data?.length ?? 0} vectors for ${batch.length} inputs`)
|
|
37
37
|
}
|
|
38
|
-
//
|
|
38
|
+
// Spec says data[] order matches input, but sort by index field if present — don't bet on server implementation
|
|
39
39
|
const items = data.data.every((d) => typeof d.index === "number")
|
|
40
40
|
? [...data.data].sort((a, b) => a.index - b.index)
|
|
41
41
|
: data.data
|
|
@@ -46,7 +46,7 @@ export async function embed(embedder, texts, { signal } = {}) {
|
|
|
46
46
|
return vectors
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
49
|
+
/** Cosine similarity (inputs are normalized, dot product equals cosine) */
|
|
50
50
|
export function cosine(a, b) {
|
|
51
51
|
if (a.length !== b.length) return 0
|
|
52
52
|
let sum = 0
|
|
@@ -55,20 +55,20 @@ export function cosine(a, b) {
|
|
|
55
55
|
return sum
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/** Float32Array →
|
|
58
|
+
/** Float32Array → Buffer suitable for sqlite BLOB storage */
|
|
59
59
|
export function toBlob(vec) {
|
|
60
60
|
return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength)
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/** sqlite BLOB → Float32Array */
|
|
64
64
|
export function fromBlob(buf) {
|
|
65
|
-
// BLOB
|
|
65
|
+
// BLOB may come from Buffer pool where byteOffset isn't 4-aligned; creating a view directly would RangeError — copy to align first
|
|
66
66
|
if (buf.byteOffset % 4 !== 0) buf = new Uint8Array(buf)
|
|
67
67
|
if (buf.byteLength % 4 !== 0) return new Float32Array(0)
|
|
68
68
|
return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4)
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// ----------------------------------------------------------------
|
|
71
|
+
// ---------------------------------------------------------------- internal
|
|
72
72
|
|
|
73
73
|
async function requestWithRetry(embedder, input, signal) {
|
|
74
74
|
let lastError
|
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
|