thincoder 0.12.25 → 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/agent/setup.mjs +5 -1
- package/src/config.mjs +17 -1
- package/src/git/checkpoint.mjs +46 -1
- package/src/provider/core.mjs +2 -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/agent/setup.mjs
CHANGED
|
@@ -223,8 +223,12 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
223
223
|
|
|
224
224
|
// Local time + timezone on every agent (main, subagent, consult) — without it "today"/
|
|
225
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.
|
|
226
228
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local"
|
|
227
|
-
|
|
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}).`
|
|
228
232
|
|
|
229
233
|
const projectRules = await loadProjectInstructions(agent.cwd)
|
|
230
234
|
if (projectRules) {
|
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
|
},
|