thincoder 0.12.25 → 0.12.27

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.25",
3
+ "version": "0.12.27",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -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")
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * agent/setup.mjs — runAgent pre-flight setup: context injection, system prompt construction, tool injection
3
3
  */
4
+ /** Local time, second precision, for the per-run transient reminder. */
5
+ function timeNowLocal() {
6
+ return new Date().toLocaleString("sv-SE")
7
+ }
8
+
4
9
  import { search as memorySearch, docSearch } from "../memory.mjs"
5
10
  import { pushReal } from "../context.mjs"
6
11
  import { toOpenAISchema } from "../tools/index.mjs"
@@ -119,12 +124,14 @@ export async function prepareRun(agent, input, callbacks, {
119
124
  })
120
125
  }
121
126
  }
127
+ // Time grounding for EVERY agent depth (subagents are short-lived; they also need
128
+ // to know "now"). Transient — dropped on persist, fresh at every run start.
129
+ agent.history.push({
130
+ role: "user",
131
+ content: `[System reminder: current time is ${timeNowLocal()} (local; timezone ${Intl.DateTimeFormat().resolvedOptions().timeZone || "local"}).]`,
132
+ transient: true,
133
+ })
122
134
  if (depth === 0) {
123
- agent.history.push({
124
- role: "user",
125
- content: `[System reminder: current time is ${new Date().toISOString()}.]`,
126
- transient: true,
127
- })
128
135
  // Checklist injection: inject pending + in_progress items from .thincoder/checklist.md
129
136
  try {
130
137
  const { pendingItems } = await import("../tools/checklist.mjs")
@@ -221,10 +228,10 @@ export async function prepareRun(agent, input, callbacks, {
221
228
  ? `${base}\n\n${mainOverlay}`
222
229
  : base
223
230
 
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
- const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local"
227
- systemPrompt += `\n\nCurrent time: ${new Date().toLocaleString("sv-SE")} (${timeZone}).`
231
+ // Time injection deliberately does NOT live here: system prompts must be byte-identical
232
+ // across runs (provider prefix caches). The time rides a transient user reminder per turn
233
+ // (see the current-time injection below) variable content belongs in the history, not
234
+ // the cached prefix.
228
235
 
229
236
  const projectRules = await loadProjectInstructions(agent.cwd)
230
237
  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 ? config.providers.map((p) => ({ ...p })) : DEFAULTS.providers.map((p) => ({ ...p })),
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 },
@@ -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 null
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)
@@ -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
  },