@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.
Files changed (203) hide show
  1. package/abort-provenance.mjs +116 -0
  2. package/advisor/citations.mjs +139 -0
  3. package/advisor/compaction.mjs +174 -0
  4. package/advisor/convergence.mjs +80 -0
  5. package/advisor/history.mjs +77 -0
  6. package/advisor/loop.mjs +288 -0
  7. package/advisor/messages.mjs +299 -0
  8. package/advisor/notice.mjs +141 -0
  9. package/advisor/project-context.mjs +197 -0
  10. package/advisor/repos.mjs +150 -0
  11. package/advisor/run.mjs +190 -0
  12. package/advisor/truncate.mjs +57 -0
  13. package/advisor.mjs +281 -0
  14. package/agent/child-marks.mjs +24 -0
  15. package/agent/completion.mjs +145 -0
  16. package/agent/dispatch.mjs +493 -0
  17. package/agent/family-tools.mjs +174 -0
  18. package/agent/helpers.mjs +412 -0
  19. package/agent/post-turn.mjs +70 -0
  20. package/agent/record-results.mjs +174 -0
  21. package/agent/relay-prefix.mjs +39 -0
  22. package/agent/run-stages.mjs +244 -0
  23. package/agent/setup-reminders.mjs +199 -0
  24. package/agent/setup.mjs +234 -0
  25. package/agent/spawn-child.mjs +258 -0
  26. package/agent/suspension.mjs +240 -0
  27. package/agent/write-gate.mjs +87 -0
  28. package/agent-tools/advisor-async.mjs +481 -0
  29. package/agent-tools/advisor-settle.mjs +240 -0
  30. package/agent-tools/advisor.mjs +280 -0
  31. package/agent-tools/async-discard.mjs +143 -0
  32. package/agent-tools/async-settle.mjs +299 -0
  33. package/agent-tools/batch-segment.mjs +265 -0
  34. package/agent-tools/child-permission.mjs +45 -0
  35. package/agent-tools/consult.mjs +471 -0
  36. package/agent-tools/design-token.mjs +117 -0
  37. package/agent-tools/digest-budget.mjs +76 -0
  38. package/agent-tools/eng.mjs +102 -0
  39. package/agent-tools/escalate-async.mjs +302 -0
  40. package/agent-tools/goal.mjs +119 -0
  41. package/agent-tools/panel-blocks.mjs +24 -0
  42. package/agent-tools/parent-channel.mjs +231 -0
  43. package/agent-tools/plan.mjs +86 -0
  44. package/agent-tools/read-history.mjs +309 -0
  45. package/agent-tools/recent-changes.mjs +24 -0
  46. package/agent-tools/review-facts.mjs +31 -0
  47. package/agent-tools/settings.mjs +268 -0
  48. package/agent-tools/skill.mjs +63 -0
  49. package/agent-tools/spawn-gates.mjs +109 -0
  50. package/agent-tools/subagent-actions.mjs +496 -0
  51. package/agent-tools/subagent-async.mjs +456 -0
  52. package/agent-tools/subagent-panel.mjs +160 -0
  53. package/agent-tools/subagent-run.mjs +208 -0
  54. package/agent-tools/subagent-scheduler.mjs +446 -0
  55. package/agent-tools/subagent-spawn.mjs +478 -0
  56. package/agent-tools/subagent.mjs +419 -0
  57. package/agent-tools/task.mjs +87 -0
  58. package/agent-tools/timer.mjs +46 -0
  59. package/agent-tools/verify.mjs +295 -0
  60. package/agent-tools.mjs +23 -0
  61. package/agent.mjs +430 -0
  62. package/auto-think.mjs +115 -0
  63. package/compress-form.mjs +24 -0
  64. package/config-io.mjs +277 -0
  65. package/config-migrate.mjs +178 -0
  66. package/config-presets.mjs +49 -0
  67. package/config.mjs +419 -0
  68. package/context.mjs +495 -0
  69. package/conventions.mjs +223 -0
  70. package/embedding.mjs +120 -0
  71. package/escape.mjs +152 -0
  72. package/expand-home.mjs +16 -0
  73. package/explore-distill.mjs +152 -0
  74. package/generate-title.mjs +123 -0
  75. package/git/checkpoint.mjs +448 -0
  76. package/git/gitmem.mjs +100 -0
  77. package/history-window.mjs +179 -0
  78. package/hooks.mjs +108 -0
  79. package/i18n.mjs +106 -0
  80. package/index-bin.mjs +48 -0
  81. package/index-discover.mjs +176 -0
  82. package/ledger-cmd.mjs +209 -0
  83. package/ledger-db.mjs +86 -0
  84. package/ledger-surface.mjs +76 -0
  85. package/ledger.mjs +202 -0
  86. package/log.mjs +195 -0
  87. package/manifest.mjs +338 -0
  88. package/markdown.mjs +106 -0
  89. package/mcp/helpers.mjs +51 -0
  90. package/mcp/transport-http.mjs +248 -0
  91. package/mcp/transport-stdio.mjs +140 -0
  92. package/mcp/transport-ws.mjs +122 -0
  93. package/mcp.mjs +295 -0
  94. package/memory/code-index.mjs +219 -0
  95. package/memory/code-sync.mjs +427 -0
  96. package/memory/core.mjs +318 -0
  97. package/memory/delete.mjs +242 -0
  98. package/memory/docs.mjs +431 -0
  99. package/memory/file-walk.mjs +109 -0
  100. package/memory/origin.mjs +24 -0
  101. package/memory/scan.mjs +177 -0
  102. package/memory/schema.mjs +460 -0
  103. package/memory.mjs +21 -0
  104. package/model-ref.mjs +66 -0
  105. package/model-specs.mjs +277 -0
  106. package/package.json +32 -0
  107. package/peer-domains.mjs +265 -0
  108. package/peer-instances.mjs +178 -0
  109. package/permission.mjs +79 -0
  110. package/process-probe.mjs +315 -0
  111. package/prompt-files.mjs +113 -0
  112. package/prompt-overlays.mjs +78 -0
  113. package/prompts/advisor-design.md +43 -0
  114. package/prompts/advisor-round1.md +41 -0
  115. package/prompts/advisor-round2.md +46 -0
  116. package/prompts/advisor-round3.md +42 -0
  117. package/prompts/common.md +158 -0
  118. package/prompts/consult-base.md +19 -0
  119. package/prompts/discipline-engineering.md +123 -0
  120. package/prompts/discipline-normal.md +206 -0
  121. package/prompts/persona-coder.md +21 -0
  122. package/prompts/persona-eng-coder.md +41 -0
  123. package/prompts/persona-eng-designer.md +80 -0
  124. package/prompts/persona-engineering.md +160 -0
  125. package/prompts/persona-explore.md +15 -0
  126. package/prompts/persona-normal.md +35 -0
  127. package/prompts/persona-plan.md +27 -0
  128. package/provider/anthropic.mjs +225 -0
  129. package/provider/core.mjs +491 -0
  130. package/provider/errors.mjs +101 -0
  131. package/provider/google.mjs +257 -0
  132. package/provider/index.mjs +7 -0
  133. package/provider/list-models.mjs +163 -0
  134. package/provider/normalize.mjs +81 -0
  135. package/provider/rate.mjs +168 -0
  136. package/provider/responses.mjs +495 -0
  137. package/provider/retry.mjs +88 -0
  138. package/provider/sse.mjs +264 -0
  139. package/provider/wait-status.mjs +59 -0
  140. package/proxy.mjs +274 -0
  141. package/rules.mjs +53 -0
  142. package/session-gc.mjs +248 -0
  143. package/session-guard.mjs +59 -0
  144. package/session-lifecycle.mjs +305 -0
  145. package/session-migrate.mjs +48 -0
  146. package/session-rename.mjs +38 -0
  147. package/session-segments.mjs +99 -0
  148. package/session-slot-write.mjs +168 -0
  149. package/session-slots-manifest.mjs +264 -0
  150. package/session-slots.mjs +298 -0
  151. package/session-store.mjs +441 -0
  152. package/session.mjs +244 -0
  153. package/skills.mjs +234 -0
  154. package/text-budget.mjs +79 -0
  155. package/token-ttl.mjs +285 -0
  156. package/tool-docs/apply_patch.md +15 -0
  157. package/tool-docs/bash.md +38 -0
  158. package/tool-docs/delete.md +13 -0
  159. package/tool-docs/edit.md +30 -0
  160. package/tool-docs/execute.md +21 -0
  161. package/tool-docs/fetch.md +12 -0
  162. package/tool-docs/file_ops.md +17 -0
  163. package/tool-docs/get_current_time.md +8 -0
  164. package/tool-docs/git.md +54 -0
  165. package/tool-docs/glob.md +11 -0
  166. package/tool-docs/grep.md +19 -0
  167. package/tool-docs/hashline_edit.md +14 -0
  168. package/tool-docs/insert_after.md +15 -0
  169. package/tool-docs/lint.md +10 -0
  170. package/tool-docs/ls.md +12 -0
  171. package/tool-docs/lsp.md +10 -0
  172. package/tool-docs/process.md +10 -0
  173. package/tool-docs/question.md +16 -0
  174. package/tool-docs/read.md +20 -0
  175. package/tool-docs/read_image.md +8 -0
  176. package/tool-docs/tree.md +14 -0
  177. package/tool-docs/wait_for.md +22 -0
  178. package/tool-docs/websearch.md +16 -0
  179. package/tool-docs/write.md +11 -0
  180. package/tools/bash.mjs +276 -0
  181. package/tools/edit-batch.mjs +204 -0
  182. package/tools/edit-diff.mjs +388 -0
  183. package/tools/exec-run.mjs +43 -0
  184. package/tools/execute.mjs +243 -0
  185. package/tools/file.mjs +464 -0
  186. package/tools/git-checkpoint.mjs +143 -0
  187. package/tools/git-ext.mjs +173 -0
  188. package/tools/git.mjs +415 -0
  189. package/tools/glob-dialect.mjs +130 -0
  190. package/tools/index.mjs +76 -0
  191. package/tools/linter.mjs +120 -0
  192. package/tools/lsp.mjs +335 -0
  193. package/tools/ops.mjs +293 -0
  194. package/tools/patch.mjs +290 -0
  195. package/tools/question.mjs +26 -0
  196. package/tools/repomap.mjs +314 -0
  197. package/tools/search.mjs +248 -0
  198. package/tools/shared.mjs +467 -0
  199. package/tools/tree.mjs +81 -0
  200. package/tools/web.mjs +224 -0
  201. package/tools/write-path.mjs +191 -0
  202. package/traces/trace-store.mjs +303 -0
  203. package/undo-stack.mjs +47 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * generate-title.mjs — LLM-generated session titles
3
+ * Called after the first user message to auto-title the session.
4
+ *
5
+ * §2.5 #163 并入:按 `provider.format` 三格式分派(VSC 侧能力归位)——openai 兼容(缺省/
6
+ * 未知)/ anthropic / google;各格式的 thinking 禁用形态与响应提取各自适配(IK9UZ8:
7
+ * thinking 模型会把输出预算烧在 reasoning 上、content 为空——标题静默失败)。
8
+ * 定制头(`provider.headers`)与代理路径(`proxyUri`)为 CLI 侧口径,三格式共用。
9
+ */
10
+
11
+ import { proxyFetch } from "./proxy.mjs"
12
+
13
+ // Test seam (_-prefix, mirrors run.mjs seams): lets the proxy-branch regression
14
+ // test swap the proxy fetch. The branch it exercises used to carry a dynamic
15
+ // import("../proxy.mjs") that silently resolved to the REPO ROOT from src/ —
16
+ // the thrown ERR_MODULE_NOT_FOUND vanished into the catch, and proxy users
17
+ // lost session titles with zero test coverage (2026-08-30 review).
18
+ export const _deps = { proxyFetchImpl: proxyFetch }
19
+
20
+ const MAX_TITLE_TOKENS = 100
21
+
22
+ /** Generate a session title from the first user message using an LLM. Returns title string or null. */
23
+ export async function generateTitle(userContent, provider) {
24
+ // Extract text even from multimodal content (array of parts)
25
+ const userText = Array.isArray(userContent)
26
+ ? userContent.find((p) => p.type === "text")?.text || ""
27
+ : userContent
28
+ if (typeof userText !== "string" || userText.length < 10) return null
29
+ if (!provider?.apiKey || !provider?.baseURL || !provider?.model) return null
30
+
31
+ try {
32
+ const system = "Generate a concise title (max 40 chars, no quotes) for this conversation. Reply ONLY with the title."
33
+ const text = userText.slice(0, 200)
34
+ let url, body, extract
35
+ if (provider.format === "anthropic") {
36
+ url = `${provider.baseURL.replace(/\/+$/, "")}/messages`
37
+ body = JSON.stringify({
38
+ model: provider.model,
39
+ system,
40
+ messages: [{ role: "user", content: text }],
41
+ max_tokens: MAX_TITLE_TOKENS,
42
+ stream: false,
43
+ thinking: { type: "disabled" }, // IK9UZ8:不让 reasoning 吃掉输出预算
44
+ })
45
+ extract = (data) => data.content?.map((b) => b.text || "").join("")
46
+ } else if (provider.format === "google") {
47
+ url = `${provider.baseURL.replace(/\/+$/, "")}/models/${encodeURIComponent(provider.model)}:generateContent?key=${encodeURIComponent(provider.apiKey)}`
48
+ body = JSON.stringify({
49
+ systemInstruction: { parts: [{ text: system }] },
50
+ contents: [{ role: "user", parts: [{ text }] }],
51
+ generationConfig: { maxOutputTokens: MAX_TITLE_TOKENS, thinkingConfig: { thinkingLevel: "none" } }, // IK9UZ8
52
+ })
53
+ extract = (data) => data.candidates?.[0]?.content?.parts?.map((p) => p.text || "").join("")
54
+ } else {
55
+ const bodyObj = {
56
+ model: provider.model,
57
+ messages: [
58
+ { role: "system", content: system },
59
+ { role: "user", content: text },
60
+ ],
61
+ // Disable thinking so reasoning_content doesn't consume the whole output budget and
62
+ // leave content empty (IK9UZ8). Providers that don't accept the field ignore it
63
+ // (OpenAI-compatible convention). A 40-char title wants ~60–80 tokens, so 100 is
64
+ // ~2.5x headroom (design decision — docs/core/design/SESSION.md §6.7).
65
+ thinking: { type: "disabled" },
66
+ max_tokens: MAX_TITLE_TOKENS,
67
+ stream: false,
68
+ }
69
+ const chatPath = provider.chatPath ?? "/chat/completions"
70
+ url = `${provider.baseURL.replace(/\/+$/, "")}${chatPath}`
71
+ body = JSON.stringify(bodyObj)
72
+ extract = (data) => data.choices?.[0]?.message?.content
73
+ }
74
+ // 定制头展开(PROVIDER.md §6.17)——定制头在前、内置头在后:内置头胜出;三格式各自内置头。
75
+ const headers = { ...(provider.headers ?? {}), "Content-Type": "application/json" }
76
+ if (provider.format === "anthropic") {
77
+ headers["x-api-key"] = provider.apiKey
78
+ headers["anthropic-version"] = "2023-06-01"
79
+ } else if (provider.format !== "google") {
80
+ headers.Authorization = `Bearer ${provider.apiKey}` // google 走 URL key
81
+ }
82
+ const opts = {
83
+ method: "POST",
84
+ headers,
85
+ body,
86
+ signal: AbortSignal.timeout(10000),
87
+ }
88
+ const res = provider.proxyUri
89
+ ? await _deps.proxyFetchImpl(url, opts, provider.proxyUri)
90
+ : await fetch(url, opts)
91
+ if (!res.ok) return null
92
+ const data = await res.json()
93
+ const title = extract(data)?.trim().slice(0, 40)
94
+ return title || null
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ /** Derive + assign the session title from the first user message (once per session).
101
+ * Extracted from agent-turn.mjs's finally block (2026-08-30): the lookup + call +
102
+ * assign belongs beside generateTitle, not in the turn driver. Non-fatal on
103
+ * failure — title generation must never break the turn. Returns the title (or null). */
104
+ export async function ensureSessionTitle(agent) {
105
+ if (agent.title) return agent.title
106
+ try {
107
+ // TUI-OOM-ROOTCAUSE 批(SESSION.md §6.14):绑定态首条 user 消息在记录存储(段 1)——
108
+ // 内存窗口可能已滑过它(store.firstUserMessage 首扫一次并缓存);未绑定回退内存查找。
109
+ let firstUser = agent._recordStore?.firstUserMessage?.() ?? null
110
+ if (!firstUser) {
111
+ firstUser = (agent._fullHistory ?? agent.history).find(
112
+ (m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
113
+ )
114
+ }
115
+ if (firstUser) {
116
+ const title = await generateTitle(firstUser.content, agent.provider)
117
+ if (title) agent.title = title
118
+ }
119
+ } catch {
120
+ // Title generation failure is non-fatal
121
+ }
122
+ return agent.title ?? null
123
+ }
@@ -0,0 +1,448 @@
1
+ /**
2
+ * checkpoint.mjs — workspace snapshot and rollback (v2: full-file copies)
3
+ * Snapshot = full copies of changed tracked files + untracked file copies (respects .gitignore).
4
+ * v1 stored only a git diff patch — rewind depended on HEAD being unchanged (a commit after the
5
+ * snapshot made `git apply` fail and the failure recovery chain collapse). v2 copies files, so
6
+ * rewind works regardless of later commits.
7
+ * Only available inside git repos. Rewind creates a new snapshot first (rewind is reversible).
8
+ * rewind supports a path parameter for per-file restore.
9
+ */
10
+ import { execFileSync } from "node:child_process"
11
+ import { createHash } from "node:crypto"
12
+ import { existsSync, readFileSync, statSync } from "node:fs"
13
+ import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"
14
+ import { dirname, join } from "node:path"
15
+ import { configDir } from "../config.mjs"
16
+
17
+ const CWD_HASH_LEN = 12
18
+
19
+ const MAX_CHECKPOINTS = 100
20
+
21
+ /** Files larger than this are NOT copied (sqlite db, bundles…) — they are recorded as skipped. */
22
+ const MAX_FILE_BYTES = 5 * 1024 * 1024
23
+
24
+ /** meta.version 2 = full-copy snapshots; 1 = legacy patch-only snapshots */
25
+ const META_VERSION = 2
26
+
27
+ function git(cwd, args, { allowFail = false } = {}) {
28
+ try {
29
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
30
+ } catch (error) {
31
+ if (allowFail) return null
32
+ throw new Error(`git ${args.join(" ")} failed: ${error.stderr?.toString().trim() || error.message}`, { cause: error })
33
+ }
34
+ }
35
+
36
+ /** Normalize cwd for hashing: uppercase the Windows drive letter so the VS Code
37
+ * extension's uri.fsPath (lowercased) produces the SAME cwdHash12 as the CLI's
38
+ * process.cwd() — cross-end snapshot sharing (CHECKPOINT.md F5/T7) depends on this
39
+ * contract. Same normalization as session storage (session-slots.mjs normalizeCwd). */
40
+ function normalizeCwd(cwd) {
41
+ return cwd.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":")
42
+ }
43
+
44
+ function checkpointRoot(cwd) {
45
+ const hash = createHash("sha1").update(normalizeCwd(cwd)).digest("hex").slice(0, CWD_HASH_LEN)
46
+ return join(configDir, "checkpoints", hash)
47
+ }
48
+
49
+ /** Extract the list of tracked files changed from a unified diff */
50
+ function trackedFilesFromPatch(patch) {
51
+ if (!patch.trim()) return []
52
+ const files = new Set()
53
+ for (const m of patch.matchAll(/^--- a\/(.+)$/gm)) files.add(m[1])
54
+ return [...files].sort()
55
+ }
56
+
57
+ /** Extract a single file's patch hunks from a unified diff */
58
+ function extractFileHunks(patch, filePath) {
59
+ // Split by file: each file block starts with "diff --git"
60
+ const sections = patch.split(/(?=^diff --git )/m)
61
+ for (const sec of sections) {
62
+ if (!sec.trim()) continue
63
+ const m = sec.match(/^diff --git a\/(.+) b\/(.+)/m)
64
+ if (!m) continue
65
+ if (m[1] === filePath || m[2] === filePath) return sec.trim()
66
+ }
67
+ return ""
68
+ }
69
+
70
+ /** Whether the current directory is a git repo */
71
+ export function isGitRepo(cwd) {
72
+ return git(cwd, ["rev-parse", "--is-inside-work-tree"], { allowFail: true }) === "true"
73
+ }
74
+
75
+ /**
76
+ * Copy a file into a snapshot directory, skipping oversized files.
77
+ * Returns { size, sha } (sha = SHA256 hex, 12 chars) or null when skipped/failed.
78
+ */
79
+ async function copyInto(dir, rel, src, skipped) {
80
+ let size
81
+ try { size = statSync(src).size } catch { return null }
82
+ if (size > MAX_FILE_BYTES) {
83
+ skipped.push(rel)
84
+ return null
85
+ }
86
+ const buf = await readFile(src).catch((e) => { console.error(`[checkpoint] skipping ${rel}: ${e.message}`); return null })
87
+ if (!buf) return null
88
+ const dst = join(dir, rel)
89
+ await mkdir(dirname(dst), { recursive: true })
90
+ await writeFile(dst, buf)
91
+ return { size: buf.length, sha: createHash("sha256").update(buf).digest("hex").slice(0, 12) }
92
+ }
93
+
94
+ /**
95
+ * Create a snapshot. Returns { id, time, files, tracked, untracked, skipped } or null (non-git repo).
96
+ * Per-file metadata ({ size, sha } per copied file) is stored in meta.json — this powers
97
+ * listFileVersions (per-file history across snapshots) without rescanning copies.
98
+ */
99
+ /** Full-directory snapshot for NON-git cwds (desktop proposal ②: createCheckpoint used to
100
+ * return null here, silently disabling checkpoints for non-git projects). Every file is
101
+ * copied under files/ (v2 layout, same rewind code path); meta carries nongit:true so
102
+ * rewind knows to skip git plumbing. Respects .gitignore-like skips via SKIP set only. */
103
+ async function createNonGitCheckpoint(cwd) {
104
+ const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6)
105
+ const dir = join(checkpointRoot(cwd), id)
106
+ await mkdir(join(dir, "files"), { recursive: true })
107
+ await mkdir(join(dir, "untracked"), { recursive: true })
108
+
109
+ const skipped = []
110
+ const fileMeta = {}
111
+ const all = await collectFiles(cwd)
112
+ for (const rel of all) {
113
+ const m = await copyInto(join(dir, "files"), rel, join(cwd, rel), skipped)
114
+ if (m) fileMeta[rel] = m
115
+ }
116
+ await writeFile(join(dir, "patch.diff"), "", "utf8") // no patch in nongit mode (layout compat)
117
+ await writeFile(join(dir, "meta.json"), JSON.stringify({
118
+ version: META_VERSION, id, time: Date.now(), nongit: true,
119
+ untracked: [], tracked: all, trackedAll: all, skipped, head: "", fileMeta, untrackedMeta: {},
120
+ }, null, 2), "utf8")
121
+ await pruneCheckpoints(cwd)
122
+ return { id, time: Date.now(), files: all.length, tracked: all, untracked: [], skipped }
123
+ }
124
+
125
+ /** Walk a non-git cwd collecting every file (relative paths), skipping heavy/irrelevant dirs. */
126
+ async function collectFiles(cwd) {
127
+ const SKIP = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage", ".next", "target", "__pycache__", ".venv"])
128
+ const out = []
129
+ async function walk(rel) {
130
+ const abs = rel ? join(cwd, rel) : cwd
131
+ let entries
132
+ try { entries = await readdir(abs, { withFileTypes: true }) } catch { return }
133
+ for (const e of entries) {
134
+ if (e.name.startsWith(".thincoder")) continue
135
+ const child = rel ? `${rel}/${e.name}` : e.name
136
+ if (e.isDirectory()) { if (!SKIP.has(e.name)) await walk(child) }
137
+ else out.push(child)
138
+ }
139
+ }
140
+ await walk("")
141
+ return out
142
+ }
143
+
144
+ export async function createCheckpoint(cwd) {
145
+ if (!isGitRepo(cwd)) return createNonGitCheckpoint(cwd)
146
+
147
+ // Random suffix: prevents id collisions for two snapshots in the same millisecond (sorting stays ordered by timestamp prefix)
148
+ const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6)
149
+ const dir = join(checkpointRoot(cwd), id)
150
+ await mkdir(join(dir, "files"), { recursive: true })
151
+ await mkdir(join(dir, "untracked"), { recursive: true })
152
+
153
+ // v2: full copies of changed tracked files (git apply of a stale patch is the v1 failure mode).
154
+ // The patch is still written for legacy tooling/debugging, but rewind uses the copies.
155
+ const head = git(cwd, ["rev-parse", "HEAD"], { allowFail: true }) ?? ""
156
+ const changedRaw = git(cwd, ["diff", "HEAD", "--name-only", "-z"], { allowFail: true }) ?? ""
157
+ const changed = changedRaw ? changedRaw.split("\0").filter(Boolean) : []
158
+ const trackedAllRaw = git(cwd, ["ls-files", "-z"], { allowFail: true }) ?? ""
159
+ const trackedAll = trackedAllRaw ? trackedAllRaw.split("\0").filter(Boolean) : []
160
+ const patch = git(cwd, ["diff", "HEAD", "--binary"], { allowFail: true }) ?? ""
161
+ await writeFile(join(dir, "patch.diff"), patch, "utf8")
162
+
163
+ const skipped = []
164
+ const fileMeta = {}
165
+ for (const rel of changed) {
166
+ const m = await copyInto(join(dir, "files"), rel, join(cwd, rel), skipped)
167
+ if (m) fileMeta[rel] = m
168
+ }
169
+
170
+ // Untracked files (respects .gitignore) → copy as-is
171
+ const untrackedRaw = git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? ""
172
+ const untracked = untrackedRaw ? untrackedRaw.split("\n").filter(Boolean) : []
173
+ const untrackedMeta = {}
174
+ for (const rel of untracked) {
175
+ const m = await copyInto(join(dir, "untracked"), rel, join(cwd, rel), skipped)
176
+ if (m) untrackedMeta[rel] = m
177
+ }
178
+
179
+ await writeFile(join(dir, "meta.json"), JSON.stringify({
180
+ version: META_VERSION, id, time: Date.now(), untracked, tracked: changed, skipped,
181
+ head, trackedAll, fileMeta, untrackedMeta,
182
+ }, null, 2), "utf8")
183
+
184
+ await pruneCheckpoints(cwd)
185
+ return { id, time: Date.now(), files: changed.length + untracked.length, tracked: changed, untracked, skipped }
186
+ }
187
+
188
+ /** List checkpoints (newest→oldest), with file change summary */
189
+ export async function listCheckpoints(cwd) {
190
+ const root = checkpointRoot(cwd)
191
+ if (!existsSync(root)) return []
192
+ const ids = (await readdir(root)).sort().reverse()
193
+ const out = []
194
+ for (const id of ids) {
195
+ try {
196
+ const meta = JSON.parse(await readFile(join(root, id, "meta.json"), "utf8"))
197
+ // Compat with old checkpoints (no tracked field): extract from patch file
198
+ const tracked = meta.tracked ?? (() => {
199
+ try {
200
+ return trackedFilesFromPatch(readFileSync(join(root, id, "patch.diff"), "utf8"))
201
+ } catch { return [] }
202
+ })()
203
+ out.push({
204
+ id, time: meta.time,
205
+ untracked: meta.untracked ?? [],
206
+ skipped: meta.skipped ?? [],
207
+ tracked,
208
+ })
209
+ } catch {
210
+ // Corrupted checkpoint — skip
211
+ }
212
+ }
213
+ return out
214
+ }
215
+
216
+ // ---- restore core ----
217
+
218
+ /** v1 legacy fallback: reset file to HEAD then apply its patch hunks. */
219
+ async function partialRestoreTrackedViaPatch(cwd, root, patchContent, filePath) {
220
+ git(cwd, ["checkout", "HEAD", "--", filePath], { allowFail: true })
221
+ const hunks = extractFileHunks(patchContent, filePath)
222
+ if (!hunks) return false
223
+ const tmpFile = join(root, ".tmp_partial.patch")
224
+ await writeFile(tmpFile, hunks, "utf8")
225
+ try {
226
+ git(cwd, ["apply", "--whitespace=nowarn", tmpFile])
227
+ return true
228
+ } finally {
229
+ await rm(tmpFile, { force: true })
230
+ }
231
+ }
232
+
233
+ /** Restore a single file from a snapshot copy. Returns true when the copy existed. */
234
+ async function restoreFromCopy(snapshotDir, rel, cwd) {
235
+ const src = join(snapshotDir, rel)
236
+ if (!existsSync(src)) return false
237
+ await mkdir(dirname(join(cwd, rel)), { recursive: true })
238
+ await cp(src, join(cwd, rel), { force: true })
239
+ return true
240
+ }
241
+
242
+ // ---- restore (single-file only) ----
243
+
244
+ /**
245
+ * Restore ONE file from a specific snapshot (saves current state as a new snapshot first,
246
+ * making the restore reversible).
247
+ *
248
+ * FULL restore is deliberately DISABLED: rolling the whole working tree back to a snapshot
249
+ * is exactly as dangerous as `git checkout -- .` — it silently discards every change made
250
+ * after the snapshot. Restore files individually (rewind with path / restoreFile).
251
+ *
252
+ * Returns { path, type, restored }.
253
+ */
254
+ export async function rewind(cwd, id, { path } = {}) {
255
+ if (!path) {
256
+ throw new Error(
257
+ "Full rewind is disabled — it is as dangerous as `git checkout -- .` (silently discards all work after the snapshot). " +
258
+ "Restore files individually: rewind(cwd, id, { path }) or restoreFile(cwd, path, id)."
259
+ )
260
+ }
261
+
262
+ const root = checkpointRoot(cwd)
263
+ const dir = join(root, id)
264
+ if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
265
+ const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
266
+ const isV2 = meta.version === META_VERSION
267
+ const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
268
+
269
+ // Restore is reversible: snapshot current state first
270
+ await createCheckpoint(cwd)
271
+
272
+ const tracked = meta.tracked ?? []
273
+ const trackedAll = meta.trackedAll ?? []
274
+ const isTracked = tracked.includes(path) || trackedAll.includes(path) || extractFileHunks(patchContent, path) !== ""
275
+ const inUntracked = (meta.untracked ?? []).includes(path)
276
+
277
+ // Oversized files were never copied — nothing to restore, say so explicitly.
278
+ if ((meta.skipped ?? []).includes(path)) {
279
+ throw new Error(`file "${path}" was NOT snapshotted in checkpoint ${id} (oversized, >5MB) — no copy exists to restore`)
280
+ }
281
+
282
+ if (!isTracked && !inUntracked) {
283
+ throw new Error(`file "${path}" not found in checkpoint ${id} (tracked: ${tracked.join(", ") || "none"}, untracked: ${(meta.untracked ?? []).join(", ") || "none"})`)
284
+ }
285
+
286
+ let ok = false
287
+ if (inUntracked) {
288
+ ok = await restoreFromCopy(join(dir, "untracked"), path, cwd) || ok
289
+ }
290
+ if (isTracked) {
291
+ if (isV2 && existsSync(join(dir, "files", path))) {
292
+ ok = await restoreFromCopy(join(dir, "files"), path, cwd) || ok
293
+ } else if (isV2 && meta.head) {
294
+ // Untouched at snapshot time (content = snapshot HEAD) → checkout that commit's version.
295
+ // Works even if HEAD moved since — the commit object is immutable.
296
+ git(cwd, ["checkout", meta.head, "--", path], { allowFail: true })
297
+ ok = true
298
+ } else {
299
+ // v1 snapshot → patch fallback
300
+ ok = await partialRestoreTrackedViaPatch(cwd, root, patchContent, path) || ok
301
+ }
302
+ }
303
+
304
+ return { path, type: isTracked ? "tracked" : "untracked", restored: ok }
305
+ }
306
+
307
+ // ---- per-file history ----
308
+
309
+ /**
310
+ * List every historical version of a file across all snapshots (newest first).
311
+ * Distinguishing copies: each entry carries its snapshot id, time, byte size and
312
+ * content sha — the same file in different snapshots is a different version.
313
+ * @param {string} cwd
314
+ * @param {string} filePath — relative path as stored in snapshots
315
+ * @returns {Promise<Array<{snapshotId: string, time: number, size: number, sha: string, source: "tracked"|"untracked"}>>}
316
+ */
317
+ export async function listFileVersions(cwd, filePath) {
318
+ const root = checkpointRoot(cwd)
319
+ if (!existsSync(root)) return []
320
+ const ids = (await readdir(root)).sort().reverse() // newest → oldest
321
+ const out = []
322
+ for (const id of ids) {
323
+ let meta
324
+ try { meta = JSON.parse(await readFile(join(root, id, "meta.json"), "utf8")) } catch { continue }
325
+ const trackedMeta = meta.fileMeta ?? {}
326
+ const untrackedMeta = meta.untrackedMeta ?? {}
327
+ let entry = null
328
+ if (trackedMeta[filePath]) {
329
+ entry = { snapshotId: id, time: meta.time, ...trackedMeta[filePath], source: "tracked" }
330
+ } else if (untrackedMeta[filePath]) {
331
+ entry = { snapshotId: id, time: meta.time, ...untrackedMeta[filePath], source: "untracked" }
332
+ } else if ((meta.tracked ?? []).includes(filePath) || (meta.untracked ?? []).includes(filePath)) {
333
+ // Legacy snapshot (no per-file meta): fall back to stat-ing the copy
334
+ const src = join((meta.tracked ?? []).includes(filePath) ? join(root, id, "files") : join(root, id, "untracked"), filePath)
335
+ let size, sha
336
+ try {
337
+ const buf = await readFile(src)
338
+ size = buf.length
339
+ sha = createHash("sha256").update(buf).digest("hex").slice(0, 12)
340
+ } catch { continue }
341
+ entry = { snapshotId: id, time: meta.time, size, sha, source: (meta.tracked ?? []).includes(filePath) ? "tracked" : "untracked" }
342
+ }
343
+ if (entry) out.push(entry)
344
+ }
345
+ return out
346
+ }
347
+
348
+ /**
349
+ * Restore a single file's content from a specific snapshot (per-file restore —
350
+ * other files are left untouched). Thin wrapper over rewind path mode with clearer intent.
351
+ * @returns {Promise<{path: string, type: "tracked"|"untracked", restored: boolean}>}
352
+ */
353
+ export async function restoreFile(cwd, filePath, snapshotId) {
354
+ return rewind(cwd, snapshotId, { path: filePath })
355
+ }
356
+
357
+
358
+ // ---- view ----
359
+
360
+ /**
361
+ * View a file's content from a checkpoint (does not modify the working tree).
362
+ * v2: read the snapshot copy directly. Legacy snapshots fall back to the temporary-restore path.
363
+ * Returns the file content string.
364
+ */
365
+ export async function catFile(cwd, id, filePath) {
366
+ const root = checkpointRoot(cwd)
367
+ const dir = join(root, id)
368
+ if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
369
+ const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
370
+ const isV2 = meta.version === META_VERSION
371
+ const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
372
+
373
+ const tracked = meta.tracked ?? []
374
+ const isTracked = tracked.includes(filePath) || extractFileHunks(patchContent, filePath) !== ""
375
+ const inUntracked = (meta.untracked ?? []).includes(filePath)
376
+
377
+ if (!isTracked && !inUntracked) {
378
+ throw new Error(`file "${filePath}" not in checkpoint ${id}`)
379
+ }
380
+
381
+ // v2: read the snapshot copy directly
382
+ const copy = join(inUntracked ? join(dir, "untracked") : join(dir, "files"), filePath)
383
+ if (isV2 && existsSync(copy)) {
384
+ return await readFile(copy, "utf8")
385
+ }
386
+
387
+ // Untracked legacy: read the copy if present
388
+ if (inUntracked && !isTracked) {
389
+ const src = join(dir, "untracked", filePath)
390
+ if (!existsSync(src)) throw new Error(`untracked file "${filePath}" copy missing in checkpoint`)
391
+ return await readFile(src, "utf8")
392
+ }
393
+
394
+ // Tracked legacy: temporarily restore → read → restore working tree
395
+ const abs = join(cwd, filePath)
396
+ const existed = existsSync(abs)
397
+ let saved = null
398
+ if (existed) saved = await readFile(abs, "utf8")
399
+
400
+ try {
401
+ git(cwd, ["checkout", "HEAD", "--", filePath])
402
+ const hunks = extractFileHunks(patchContent, filePath)
403
+ if (hunks) {
404
+ const tmpPatch = join(root, ".tmp_cat.patch")
405
+ await writeFile(tmpPatch, hunks, "utf8")
406
+ try {
407
+ git(cwd, ["apply", "--whitespace=nowarn", tmpPatch])
408
+ } finally {
409
+ await rm(tmpPatch, { force: true })
410
+ }
411
+ }
412
+ return await readFile(abs, "utf8")
413
+ } finally {
414
+ // Restore original working tree state
415
+ if (existed) {
416
+ await writeFile(abs, saved, "utf8")
417
+ } else {
418
+ await rm(abs, { force: true })
419
+ }
420
+ }
421
+ }
422
+
423
+ /** F6: delete ALL checkpoints for a cwd — commit = new safety baseline. Best-effort:
424
+ * a missing dir is a no-op; deletion failures propagate so the git tool's commit case
425
+ * can report "(checkpoint cleanup skipped: …)" without blocking the commit result. */
426
+ export async function deleteCheckpointsForCwd(cwd) {
427
+ await rm(checkpointRoot(cwd), { recursive: true, force: true })
428
+ }
429
+
430
+ /** NF6: keep only the most recent `count` snapshots — oldest removed first (id sort =
431
+ * timestamp prefix, so ascending order IS oldest-first). Returns how many were deleted
432
+ * (0 when the cwd has no checkpoint dir). */
433
+ export async function deleteCheckpointsOlderThan(cwd, count) {
434
+ const root = checkpointRoot(cwd)
435
+ let ids
436
+ try { ids = (await readdir(root)).sort() } catch { return 0 }
437
+ let removed = 0
438
+ while (ids.length > count) {
439
+ await rm(join(root, ids.shift()), { recursive: true, force: true })
440
+ removed++
441
+ }
442
+ return removed
443
+ }
444
+
445
+ /** Keep only the most recent MAX_CHECKPOINTS (NF6 cap — runs at the end of every create) */
446
+ async function pruneCheckpoints(cwd) {
447
+ await deleteCheckpointsOlderThan(cwd, MAX_CHECKPOINTS)
448
+ }
package/git/gitmem.mjs ADDED
@@ -0,0 +1,100 @@
1
+ /**
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.
7
+ */
8
+
9
+ import { execFile } from "node:child_process"
10
+ import { existsSync } from "node:fs"
11
+ import { mkdir } from "node:fs/promises"
12
+ import { dirname, join } from "node:path"
13
+ import { promisify } from "node:util"
14
+
15
+ const execFileAsync = promisify(execFile)
16
+
17
+ /** Run git in dir, throw with stderr on failure */
18
+ async function git(dir, args) {
19
+ try {
20
+ const { stdout } = await execFileAsync("git", args, { cwd: dir, encoding: "utf8" })
21
+ return stdout.trim()
22
+ } catch (error) {
23
+ const detail = error.stderr?.trim() || error.message
24
+ const err = new Error(`git ${args.join(" ")} failed: ${detail}`)
25
+ err.gitError = true
26
+ err.stderr = detail
27
+ throw err
28
+ }
29
+ }
30
+
31
+ /** Clone team repo if it doesn't exist. Returns whether a clone happened */
32
+ export async function ensureClone({ repo, dir }) {
33
+ if (existsSync(join(dir, ".git"))) return false
34
+ await mkdir(dirname(dir), { recursive: true })
35
+ await git(dirname(dir), ["clone", repo, dir])
36
+ return true
37
+ }
38
+
39
+ /**
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)
43
+ */
44
+ export async function pullTeam(dir) {
45
+ // Empty remote repo: no branch to pull (ls-remote has no output)
46
+ const refs = await git(dir, ["ls-remote", "--heads", "origin"])
47
+ if (!refs) return false
48
+
49
+ try {
50
+ await git(dir, ["pull", "--rebase"])
51
+ return true
52
+ } catch (error) {
53
+ if (await hasConflict(dir)) {
54
+ let abortFailed = false
55
+ try { await git(dir, ["rebase", "--abort"]) } catch {
56
+ abortFailed = true
57
+ }
58
+ const conflictError = new Error(
59
+ `Team memory sync conflict: local and remote modified the same entry.\n` +
60
+ `Please resolve manually in ${dir} with \`git pull\`, then re-run \`thincoder sync\`.\n` +
61
+ (abortFailed
62
+ ? `(WARNING: git rebase --abort also failed — the repo may be in a conflicted state. ` +
63
+ `Run \`cd ${dir} && git rebase --abort\` manually to clean up.)`
64
+ : `(The local repo has been restored to its pre-sync state — nothing was lost.)`),
65
+ { cause: error },
66
+ )
67
+ throw conflictError
68
+ }
69
+ throw error
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Commit and push an entry file. On push rejection (remote has new commits),
75
+ * pull --rebase then retry once; rebase conflicts also abort and error out.
76
+ */
77
+ export async function commitAndPush(dir, filename, message) {
78
+ await git(dir, ["add", filename])
79
+ // Nothing to commit (content unchanged) — this is normal idempotent behavior, not an error
80
+ const dirty = await git(dir, ["status", "--porcelain", "--", filename])
81
+ if (dirty) await git(dir, ["commit", "-m", message])
82
+ try {
83
+ await git(dir, ["push"])
84
+ } catch {
85
+ await pullTeam(dir) // Conflicts will throw a guided error here
86
+ await git(dir, ["push"])
87
+ }
88
+ }
89
+
90
+ /** Whether currently in a rebase conflict state (unmerged paths exist) */
91
+ async function hasConflict(dir) {
92
+ try {
93
+ const out = await git(dir, ["status", "--porcelain"])
94
+ // There are 7 unmerged states: DD AU UD UA DU AA UU — only checking UU/AA/DD misses 4 states with U,
95
+ // which means the rebase is left mid-conflict (violating the "keep repo clean" promise)
96
+ return out.split("\n").some((l) => l[0] === "U" || l[1] === "U" || l.startsWith("AA") || l.startsWith("DD"))
97
+ } catch {
98
+ return false
99
+ }
100
+ }