thincoder 0.12.24 → 0.12.26
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/package.json +1 -1
- package/src/acp/session.mjs +7 -1
- package/src/acp.mjs +67 -2
- package/src/advisor.mjs +9 -3
- package/src/agent/setup.mjs +9 -0
- package/src/config.mjs +17 -1
- package/src/git/checkpoint.mjs +46 -1
- package/src/provider/core.mjs +2 -1
- package/src/tools/bash.md +1 -1
package/package.json
CHANGED
package/src/acp/session.mjs
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* - `run` is injectable for tests (defaults to the real runAgent).
|
|
13
13
|
*/
|
|
14
14
|
import { runAgent } from "../agent.mjs"
|
|
15
|
+
import { saveSession } from "../session.mjs"
|
|
15
16
|
import { buildAcpCallbacks } from "./bridge.mjs"
|
|
16
17
|
|
|
17
|
-
export function createAcpSession({ id, agent, notify, request = async () => { throw new Error("no request channel") }, log = () => {}, run = runAgent }) {
|
|
18
|
+
export function createAcpSession({ id, agent, notify, request = async () => { throw new Error("no request channel") }, log = () => {}, run = runAgent, save = saveSession }) {
|
|
18
19
|
let controller = new AbortController()
|
|
19
20
|
const callbacks = buildAcpCallbacks({ sessionId: id, notify, request, log })
|
|
20
21
|
let queue = Promise.resolve()
|
|
@@ -33,6 +34,11 @@ export function createAcpSession({ id, agent, notify, request = async () => { th
|
|
|
33
34
|
busy = false
|
|
34
35
|
// Fresh controller per turn: cancel() only affects the in-flight turn.
|
|
35
36
|
controller = new AbortController()
|
|
37
|
+
// Persist the session archive at EVERY turn end (success/cancel/failure —
|
|
38
|
+
// finally semantics, desktop proposal ACP-SESSION-PERSISTENCE §2.1 US-E4):
|
|
39
|
+
// session/list / load / resume get a real data source; a save failure must
|
|
40
|
+
// never break the queue chain.
|
|
41
|
+
try { save(agent) } catch (e) { log(`[session] save failed: ${e?.message ?? e}`) }
|
|
36
42
|
}
|
|
37
43
|
})
|
|
38
44
|
// Keep the chain alive even when a turn rejects (the next prompt still runs).
|
package/src/acp.mjs
CHANGED
|
@@ -13,13 +13,15 @@
|
|
|
13
13
|
* `isConfigured` / `createSession` are injectable for tests.
|
|
14
14
|
*/
|
|
15
15
|
import { readFileSync } from "node:fs"
|
|
16
|
-
import { resolve } from "node:path"
|
|
17
|
-
import { loadConfig } from "./config.mjs"
|
|
16
|
+
import { resolve, join } from "node:path"
|
|
17
|
+
import { loadConfig, configDir } from "./config.mjs"
|
|
18
18
|
import { assembleAgent } from "./cli/make-agent.mjs"
|
|
19
19
|
import { createAcpServer, ACP_ERRORS } from "./acp/transport.mjs"
|
|
20
20
|
import { createAcpSession } from "./acp/session.mjs"
|
|
21
21
|
import { replayHistory } from "./acp/bridge.mjs"
|
|
22
22
|
import { listSlots, applySession, deleteSlot, sessionPath, normalizeCwd, isLegacyTransient } from "./session.mjs"
|
|
23
|
+
import { createCheckpoint, listCheckpoints, rewind, isGitRepo } from "./git/checkpoint.mjs"
|
|
24
|
+
import { createMemory, list as memList, remove as memRemove } from "./memory.mjs"
|
|
23
25
|
|
|
24
26
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
|
|
25
27
|
|
|
@@ -314,6 +316,56 @@ export function buildAcpHandlers({
|
|
|
314
316
|
})
|
|
315
317
|
return {}
|
|
316
318
|
},
|
|
319
|
+
|
|
320
|
+
// ─── M5-pull-forward: checkpoints (desktop proposal ②) + memory (③) ───
|
|
321
|
+
// Checkpoints are cwd-scoped (same store the TUI git tool uses); non-git cwds
|
|
322
|
+
// now snapshot by full-directory copy instead of returning null.
|
|
323
|
+
|
|
324
|
+
"checkpoint/create": async () => {
|
|
325
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
326
|
+
const cp = await createCheckpoint(getCwd())
|
|
327
|
+
if (!cp) return { error: { ...ACP_ERRORS.INTERNAL, message: "checkpoint creation failed" } }
|
|
328
|
+
return { checkpoint: { id: cp.id, time: cp.time, files: cp.files, git: isGitRepo(getCwd()) } }
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
"checkpoint/list": async () => {
|
|
332
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
333
|
+
const cps = await listCheckpoints(getCwd())
|
|
334
|
+
return { checkpoints: cps.map((c) => ({ id: c.id, time: c.time, files: (c.tracked?.length ?? 0) + (c.untracked?.length ?? 0), trackedCount: c.tracked?.length ?? 0, untrackedCount: c.untracked?.length ?? 0 })) }
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
"checkpoint/restore": async (params) => {
|
|
338
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
339
|
+
if (!params.checkpointId || !params.path) {
|
|
340
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "checkpoint/restore requires checkpointId and path (single-file restore; full rewind is disabled)" } }
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
await rewind(getCwd(), String(params.checkpointId), { path: String(params.path) })
|
|
344
|
+
return { restored: String(params.path) }
|
|
345
|
+
} catch (e) {
|
|
346
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: e?.message ?? String(e) } }
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
"memory/list": async (params) => {
|
|
351
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
352
|
+
const mem = ensureAcpMemory()
|
|
353
|
+
if (!mem) return { error: { ...ACP_ERRORS.INTERNAL, message: "memory unavailable" } }
|
|
354
|
+
const entries = await memList(mem, { type: params.type })
|
|
355
|
+
return { entries: entries.map((e) => ({ id: e.id, type: e.type, title: e.title, tags: e.tags ?? "", updatedAt: e.updatedAt ?? null })) }
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
"memory/remove": async (params) => {
|
|
359
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
360
|
+
const id = Number(params.id)
|
|
361
|
+
if (!Number.isInteger(id) || id < 1) {
|
|
362
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `memory/remove requires a numeric id (got ${params.id})` } }
|
|
363
|
+
}
|
|
364
|
+
const mem = ensureAcpMemory()
|
|
365
|
+
if (!mem) return { error: { ...ACP_ERRORS.INTERNAL, message: "memory unavailable" } }
|
|
366
|
+
const ok = await memRemove(mem, id)
|
|
367
|
+
return ok ? { removed: id } : { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `no memory entry #${id}` } }
|
|
368
|
+
},
|
|
317
369
|
},
|
|
318
370
|
sessions,
|
|
319
371
|
notifyRef,
|
|
@@ -321,6 +373,19 @@ export function buildAcpHandlers({
|
|
|
321
373
|
}
|
|
322
374
|
}
|
|
323
375
|
|
|
376
|
+
/** Shared memory handle for ACP handlers (same ~/.thincoder store the TUI uses).
|
|
377
|
+
* dbPath mirrors the TUI default (see cli/make-agent.mjs). */
|
|
378
|
+
let _acpMemory = null
|
|
379
|
+
function ensureAcpMemory() {
|
|
380
|
+
if (_acpMemory) return _acpMemory
|
|
381
|
+
try {
|
|
382
|
+
_acpMemory = createMemory({ dbPath: join(configDir, "memory.db") })
|
|
383
|
+
return _acpMemory
|
|
384
|
+
} catch {
|
|
385
|
+
return null // memory subsystem unavailable — handlers report it
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
324
389
|
/** `thincoder acp` — start the server and block until the client closes the pipe. */
|
|
325
390
|
export async function runAcpServer() {
|
|
326
391
|
const log = (...a) => process.stderr.write(a.join(" ") + "\n")
|
package/src/advisor.mjs
CHANGED
|
@@ -92,6 +92,12 @@ try { ADVISOR_DESIGN = readFileSync(join(__dirname, "prompts", "advisor-design.m
|
|
|
92
92
|
* @param {string} [reviewType] — "design" for design review, undefined/"code" for code review
|
|
93
93
|
* @returns {string} the system prompt
|
|
94
94
|
*/
|
|
95
|
+
/** Append local time so the reviewer knows "now" (same grounding as the agent loop). */
|
|
96
|
+
function withTime(prompt) {
|
|
97
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local"
|
|
98
|
+
return prompt + `\n\nCurrent time: ${new Date().toLocaleString("sv-SE")} (${timeZone}).`
|
|
99
|
+
}
|
|
100
|
+
|
|
95
101
|
export function buildAdvisorSystemPrompt(agent, prior, reviewType) {
|
|
96
102
|
// Round decision is DETERMINISTIC (decision 2026-08-08): _advisorRound > 0
|
|
97
103
|
// with a stored review output means convergence (round 2+); 0 means round 1.
|
|
@@ -215,7 +221,7 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
|
|
|
215
221
|
// after a failed design review). Fresh session.
|
|
216
222
|
if (reviewType === "design" && (agent._advisorRound || 0) === 0) {
|
|
217
223
|
return [
|
|
218
|
-
{ role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
|
|
224
|
+
{ role: "system", content: withTime(buildAdvisorSystemPrompt(agent, prior, reviewType)) },
|
|
219
225
|
{ role: "user", content: escapeLiteralEscapes(buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths)) },
|
|
220
226
|
]
|
|
221
227
|
}
|
|
@@ -243,7 +249,7 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
|
|
|
243
249
|
// Mutations exist → KEEP the round (cap keeps advancing through retries).
|
|
244
250
|
const user = buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths)
|
|
245
251
|
return [
|
|
246
|
-
{ role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
|
|
252
|
+
{ role: "system", content: withTime(buildAdvisorSystemPrompt(agent, prior, reviewType)) },
|
|
247
253
|
{
|
|
248
254
|
role: "user",
|
|
249
255
|
// NOTE (2026-08-06): the leading prefix is a PLAIN "System reminder:",
|
|
@@ -271,7 +277,7 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
|
|
|
271
277
|
// review surface.
|
|
272
278
|
const scopeFiles = resolveScopeFiles(agent, paths)
|
|
273
279
|
return [
|
|
274
|
-
{ role: "system", content: buildAdvisorSystemPrompt(agent, prior, reviewType) },
|
|
280
|
+
{ role: "system", content: withTime(buildAdvisorSystemPrompt(agent, prior, reviewType)) },
|
|
275
281
|
{ role: "user", content: escapeLiteralEscapes(buildAdvisorFollowUp(agent, prior, scopeFiles)) },
|
|
276
282
|
]
|
|
277
283
|
}
|
package/src/agent/setup.mjs
CHANGED
|
@@ -221,6 +221,15 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
221
221
|
? `${base}\n\n${mainOverlay}`
|
|
222
222
|
: base
|
|
223
223
|
|
|
224
|
+
// Local time + timezone on every agent (main, subagent, consult) — without it "today"/
|
|
225
|
+
// "just now"/"recent" in user messages and search freshness are ungrounded.
|
|
226
|
+
// MINUTE precision, deliberately: system prompts must stay byte-identical across runs
|
|
227
|
+
// within the same minute or provider prefix caches (DeepSeek cache_hit) never hit.
|
|
228
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local"
|
|
229
|
+
const now = new Date()
|
|
230
|
+
const mins = String(now.getMinutes()).padStart(2, "0")
|
|
231
|
+
systemPrompt += `\n\nCurrent time: ${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${mins} (${timeZone}).`
|
|
232
|
+
|
|
224
233
|
const projectRules = await loadProjectInstructions(agent.cwd)
|
|
225
234
|
if (projectRules) {
|
|
226
235
|
systemPrompt += `\n\nProject instructions (follow these as project conventions):\n<untrusted_project_instructions>\n${escapeXml(projectRules)}\n</untrusted_project_instructions>`
|
package/src/config.mjs
CHANGED
|
@@ -198,6 +198,20 @@ export function normalizeProxy(proxy) {
|
|
|
198
198
|
* THINCODER_ACTIVE_MODEL overrides the active model (wins over THINCODER_MODEL — see loadConfig)
|
|
199
199
|
* Provider-specific key fallbacks (when providers[] lacks a key): DEEPSEEK_API_KEY / OPENAI_API_KEY
|
|
200
200
|
*/
|
|
201
|
+
/** Keep only { header: "string value" } pairs from a provider's headers field — anything
|
|
202
|
+
* else (null, arrays, nested objects) is dropped so it can never reach a fetch call.
|
|
203
|
+
* Authorization is built-in and cannot be overridden from headers (core.mjs spreads first). */
|
|
204
|
+
function sanitizeProviderHeaders(p) {
|
|
205
|
+
if (p.headers == null || typeof p.headers !== "object" || Array.isArray(p.headers)) { delete p.headers; return p }
|
|
206
|
+
const clean = {}
|
|
207
|
+
for (const [k, v] of Object.entries(p.headers)) {
|
|
208
|
+
if (typeof v === "string" && k.toLowerCase() !== "authorization") clean[k] = v
|
|
209
|
+
}
|
|
210
|
+
if (Object.keys(clean).length > 0) p.headers = clean
|
|
211
|
+
else delete p.headers
|
|
212
|
+
return p
|
|
213
|
+
}
|
|
214
|
+
|
|
201
215
|
export function loadConfig() {
|
|
202
216
|
let config = {}
|
|
203
217
|
if (existsSync(configPath)) {
|
|
@@ -211,7 +225,9 @@ export function loadConfig() {
|
|
|
211
225
|
const merged = {
|
|
212
226
|
...DEFAULTS,
|
|
213
227
|
...config,
|
|
214
|
-
providers: Array.isArray(config.providers) && config.providers.length
|
|
228
|
+
providers: Array.isArray(config.providers) && config.providers.length
|
|
229
|
+
? config.providers.map((p) => sanitizeProviderHeaders({ ...p }))
|
|
230
|
+
: DEFAULTS.providers.map((p) => ({ ...p })),
|
|
215
231
|
activeProvider: config.activeProvider ?? DEFAULTS.activeProvider,
|
|
216
232
|
agent: { ...DEFAULTS.agent, ...config.agent },
|
|
217
233
|
memory: { ...DEFAULTS.memory, ...config.memory },
|
package/src/git/checkpoint.mjs
CHANGED
|
@@ -88,8 +88,53 @@ async function copyInto(dir, rel, src, skipped) {
|
|
|
88
88
|
* Per-file metadata ({ size, sha } per copied file) is stored in meta.json — this powers
|
|
89
89
|
* listFileVersions (per-file history across snapshots) without rescanning copies.
|
|
90
90
|
*/
|
|
91
|
+
/** Full-directory snapshot for NON-git cwds (desktop proposal ②: createCheckpoint used to
|
|
92
|
+
* return null here, silently disabling checkpoints for non-git projects). Every file is
|
|
93
|
+
* copied under files/ (v2 layout, same rewind code path); meta carries nongit:true so
|
|
94
|
+
* rewind knows to skip git plumbing. Respects .gitignore-like skips via SKIP set only. */
|
|
95
|
+
async function createNonGitCheckpoint(cwd) {
|
|
96
|
+
const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6)
|
|
97
|
+
const dir = join(checkpointRoot(cwd), id)
|
|
98
|
+
await mkdir(join(dir, "files"), { recursive: true })
|
|
99
|
+
await mkdir(join(dir, "untracked"), { recursive: true })
|
|
100
|
+
|
|
101
|
+
const skipped = []
|
|
102
|
+
const fileMeta = {}
|
|
103
|
+
const all = await collectFiles(cwd)
|
|
104
|
+
for (const rel of all) {
|
|
105
|
+
const m = await copyInto(join(dir, "files"), rel, join(cwd, rel), skipped)
|
|
106
|
+
if (m) fileMeta[rel] = m
|
|
107
|
+
}
|
|
108
|
+
await writeFile(join(dir, "patch.diff"), "", "utf8") // no patch in nongit mode (layout compat)
|
|
109
|
+
await writeFile(join(dir, "meta.json"), JSON.stringify({
|
|
110
|
+
version: META_VERSION, id, time: Date.now(), nongit: true,
|
|
111
|
+
untracked: [], tracked: all, trackedAll: all, skipped, head: "", fileMeta, untrackedMeta: {},
|
|
112
|
+
}, null, 2), "utf8")
|
|
113
|
+
await pruneCheckpoints(cwd)
|
|
114
|
+
return { id, time: Date.now(), files: all.length, tracked: all, untracked: [], skipped }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Walk a non-git cwd collecting every file (relative paths), skipping heavy/irrelevant dirs. */
|
|
118
|
+
async function collectFiles(cwd) {
|
|
119
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage", ".next", "target", "__pycache__", ".venv"])
|
|
120
|
+
const out = []
|
|
121
|
+
async function walk(rel) {
|
|
122
|
+
const abs = rel ? join(cwd, rel) : cwd
|
|
123
|
+
let entries
|
|
124
|
+
try { entries = await readdir(abs, { withFileTypes: true }) } catch { return }
|
|
125
|
+
for (const e of entries) {
|
|
126
|
+
if (e.name.startsWith(".thincoder")) continue
|
|
127
|
+
const child = rel ? `${rel}/${e.name}` : e.name
|
|
128
|
+
if (e.isDirectory()) { if (!SKIP.has(e.name)) await walk(child) }
|
|
129
|
+
else out.push(child)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
await walk("")
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
91
136
|
export async function createCheckpoint(cwd) {
|
|
92
|
-
if (!isGitRepo(cwd)) return
|
|
137
|
+
if (!isGitRepo(cwd)) return createNonGitCheckpoint(cwd)
|
|
93
138
|
|
|
94
139
|
// Random suffix: prevents id collisions for two snapshots in the same millisecond (sorting stays ordered by timestamp prefix)
|
|
95
140
|
const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6)
|
package/src/provider/core.mjs
CHANGED
|
@@ -267,7 +267,7 @@ export function normalizeToolPairing(messages) {
|
|
|
267
267
|
/** List available model IDs from the provider's /models endpoint */
|
|
268
268
|
export async function listModels(provider, { signal } = {}) {
|
|
269
269
|
const response = await fetch(`${provider.baseURL}/models`, {
|
|
270
|
-
headers: { Authorization: `Bearer ${provider.apiKey}` },
|
|
270
|
+
headers: { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey}` },
|
|
271
271
|
signal,
|
|
272
272
|
})
|
|
273
273
|
if (!response.ok) {
|
|
@@ -294,6 +294,7 @@ async function requestWithRetry(provider, body, signal, onWait) {
|
|
|
294
294
|
const opts = {
|
|
295
295
|
method: "POST",
|
|
296
296
|
headers: {
|
|
297
|
+
...(provider.headers ?? {}), // custom per-provider headers (desktop proposal ④: X-Device-Id etc.)
|
|
297
298
|
"Content-Type": "application/json",
|
|
298
299
|
Authorization: `Bearer ${provider.apiKey}`,
|
|
299
300
|
},
|
package/src/tools/bash.md
CHANGED
|
@@ -28,7 +28,7 @@ Notes:
|
|
|
28
28
|
- The environment sets GIT_PAGER=cat, PAGER=cat, EDITOR=true, TERM=dumb — but still always use non-interactive flags
|
|
29
29
|
- Output is capped at ~200K chars; if you need more, redirect to a file and read it. Truncated output ends with a `[... truncated: N chars omitted]` marker — the missing tail may contain errors.
|
|
30
30
|
- Check `[stderr]` for error messages, warnings, and diagnostic output — it is separated from `[stdout]` so you can quickly identify problems.
|
|
31
|
-
- On Windows
|
|
31
|
+
- On Windows the shell is **cmd.exe** (NOT Git Bash, NOT PowerShell): `&&`/`||` chaining works, use cmd built-ins (`del`, `dir`, `type`, `findstr`, `tasklist`) and `/dev/null`→`NUL`, `2>/dev/null`→`>nul 2>&1`. Bash-isms (`rm -rf`, `cp -r`, `head`, `$(...)`) FAIL. For complex logic prefer `node -e` over shell gymnastics.
|
|
32
32
|
- Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
|
|
33
33
|
- Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
|
|
34
34
|
- After commands that change files (git checkout, npm install, etc.), repo_outline and code_search may be stale — re-run them to get current results.
|