From 81da05f0977e91f5b4ce677b20067e5efed425d9 Mon Sep 17 00:00:00 2001 From: Lorenz Hufe Date: Thu, 2 Jul 2026 11:32:52 +0200 Subject: [PATCH 01/45] docs: spec for save_output expert-authored output files (#14) --- ...26-07-02-save-output-expert-tool-design.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-save-output-expert-tool-design.md diff --git a/docs/superpowers/specs/2026-07-02-save-output-expert-tool-design.md b/docs/superpowers/specs/2026-07-02-save-output-expert-tool-design.md new file mode 100644 index 0000000..087704c --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-save-output-expert-tool-design.md @@ -0,0 +1,107 @@ +# Design: `save_output` — expert-authored output files + +**Issue:** #14 — *output_file should let the expert edit that file directly, not pipe its raw text* +**Date:** 2026-07-02 +**Branch:** feat/archive-support + +## Problem + +When `output_file` is passed to `task` or `task_batch`, we capture the expert's +**entire final assistant text** (`result.text`) and write it verbatim into a file +in the source's `data/` dir (`task-batch.ts:140-143`, `view-page.ts:139-148`). + +This "pipe the model's stdout into a file" approach is a poor fit for structured +output (JSON): + +- Prose, preamble/closing remarks, or ` ```json ` fences routinely leak into the + file and make it invalid JSON. +- The expert can't iterate on or self-correct the file — it emits one blob and we + write whatever came out. +- We over-constrain the prompt to beg for "only JSON, no fences", which is brittle. + +## Approved decisions + +1. **Trigger model — implicit, path-less tool.** When `output_file` is set the + expert is handed a dedicated tool that takes only `content` (no path). It can + only ever touch the one declared output file, so it is auditable by + construction and needs **no `grant` prompt**. +2. **Strict, no raw-text fallback.** The file is written *only* by a successful + `save_output` call. An expert that never calls it produces **no file** and is + flagged. The existing raw-text `writeFileSync` paths are removed. + +## Design + +### 1. New expert tool: `save_output` + +Offered **only when `output_file` is set** for that turn: + +``` +save_output({ content: string }) // writes to the one pre-resolved output path; no path param +``` + +- The caller (`task` / `task_batch`) resolves the concrete filename (`{page_id}` + already substituted) to an absolute path inside the source's `dataDir` and + passes it into the expert turn. The model never sees or names a path → scoped + by construction. +- **Last write wins.** The expert may call it repeatedly to revise. Success + returns `"Saved N chars."` so it knows to stop. +- **JSON self-correction.** If the target ends in `.json`, `JSON.parse` the + content *before* writing. On failure, **do not write**; return an `isError` + result (`"content is not valid JSON: ; call save_output again with + corrected content"`) so the expert fixes it inside its own bounded loop. No + fences/prose ever reach a `.json` file. + +### 2. Strict semantics (no raw-text fallback) + +- File written only by a successful `save_output`. +- Expert finishes without writing → no file, page flagged. +- The `writeFileSync(result.text ...)` blocks in `task-batch.ts` and + `view-page.ts` are removed. `runExpertTurn` returns `wroteOutput: boolean` + (and `outputBytes?`); callers report from that. + +### 3. Tool-budget interaction + +The loop disables tools after `MAX_EXPERT_TOOL_CALLS` to force a text answer. +When output mode is on and the budget is spent, keep **only** `save_output` +available (drop the exploratory tools) so the expert can always fulfill the +contract. (`MAX_EXPERT_TOOL_CALLS` was raised to 100, so stranding is unlikely, +but this rule stays as a cheap correctness guarantee.) + +### 4. Reporting + +- **`task_batch`**: `ExpertEntry` gains `wroteOutput`. The header counts a third + bucket, e.g. `12/13 succeeded, 1 produced no output`. Per-page line: + `task-7 ⇒ p.42 → entries_0042.json` or + `p.42: expert produced no output (no file written)`. +- **`task`**: `output_file` + no write → soft-failure message + (`"expert produced no output; no file written"`), no file. + +### 5. Abort semantics + +If `save_output` writes and the turn is then aborted, the file persists (the +model deliberately committed it) while the turn returns `ok:false`. Minor +divergence from today's "never write partial text" — acceptable because the write +was intentional, not salvaged stdout. Noted in a comment. + +### 6. Prompts + +- `page-expert-prompt.md`: document `save_output` (present only when applicable). +- `runExpertTurn` appends a directive to the turn prompt when output mode is on: + *"You MUST call `save_output` with your final result; your chat reply is for + reasoning only and is discarded."* +- `task.md` / `task-batch.md`: rewrite the `output_file` description — expert + writes via `save_output`; drop the brittle "only JSON, no fences" begging. + +## Files touched + +- `chronos/tools/expert-tools.ts` — new tool + write/JSON-validate logic + budget rule +- `chronos/tools/expert-turn.ts` — thread `outputPath` in, return `wroteOutput`, prompt directive, budget rule +- `chronos/tools/task-batch.ts` — pass path, drop raw write, new reporting +- `chronos/tools/view-page.ts` — pass path, drop raw write, new reporting +- `chronos/prompts/page-expert-prompt.md`, `task.md`, `task-batch.md` + +## Out of scope (YAGNI) + +- A separate `edit_output` tool — `save_output` overwrites; the expert has its own + content in context and can re-emit corrected content. +- JSON *schema* validation — we only check that `.json` targets parse. From 1832c6f844ba712e65d056e74a95c5afb20dab16 Mon Sep 17 00:00:00 2001 From: Lorenz Hufe Date: Mon, 6 Jul 2026 08:54:48 +0200 Subject: [PATCH 02/45] docs: spec for provider-agnostic image downscaling on expert upload --- chronos-vscode/scripts/skill-canary.mjs | 159 ++++++++++++++++++ .../2026-07-06-image-downscale-design.md | 103 ++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 chronos-vscode/scripts/skill-canary.mjs create mode 100644 docs/superpowers/specs/2026-07-06-image-downscale-design.md diff --git a/chronos-vscode/scripts/skill-canary.mjs b/chronos-vscode/scripts/skill-canary.mjs new file mode 100644 index 0000000..09425fb --- /dev/null +++ b/chronos-vscode/scripts/skill-canary.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +// Regression canary for the slash-command SKILLS path. +// +// Workspace skills live in /skills//SKILL.md and are bridged to +// pi via the workspace .pi/settings.json {"skills":["../skills"]}. BUT pi gates +// project settings behind project-trust, and in headless `--mode rpc` there is +// no UI to answer the trust prompt (defaultProjectTrust "ask" -> untrusted), so +// that bridge is silently discarded and workspace skills never reach the / menu. +// The extension works around this by spawning pi with `--skill /skills` (a +// CLI resource path, not project settings -> not trust-gated). This canary +// asserts that contract end-to-end against the real pi binary: +// 1. WITH --skill, a workspace SKILL.md surfaces in get_commands (source:skill) +// 2. WITHOUT --skill (plain rpc), it does NOT — i.e. the bridge alone is not +// enough in rpc mode, which is exactly why --skill is required. +// +// Usage: node scripts/skill-canary.mjs [path-to-pi] +// Run after upgrading the global pi (alongside rpc-spike.mjs). + +import { spawn } from "node:child_process"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const piBin = process.argv[2] ?? "pi"; +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", +); +const SKILL = `--- +name: range-finder +description: Find the page range covering a given record type. +--- + +# Range Finder + +Use list_pages, then narrow to the matching range. +`; + +function makeFixture() { + const dir = join(tmpdir(), `chronos-skill-canary-${process.pid}`); + mkdirSync(join(dir, "sources", "TestSource", "png"), { recursive: true }); + mkdirSync(join(dir, "sessions"), { recursive: true }); + mkdirSync(join(dir, "skills", "range-finder"), { recursive: true }); + // The .pi/settings.json bridge as written by `Chronos: Init Workspace` — present + // but deliberately ineffective in rpc mode (untrusted); proves --skill is what works. + mkdirSync(join(dir, ".pi"), { recursive: true }); + writeFileSync(join(dir, ".pi", "settings.json"), JSON.stringify({ skills: ["../skills"] }, null, 2) + "\n"); + writeFileSync(join(dir, "sources", "TestSource", "png", "page_0001.png"), PNG); + writeFileSync(join(dir, "skills", "range-finder", "SKILL.md"), SKILL); + return dir; +} + +// Returns the list of skill-source command names from a `pi --mode rpc [extra]` session. +function skillCommands(dir, extraArgs) { + return new Promise((resolve, reject) => { + const proc = spawn(piBin, ["--mode", "rpc", ...extraArgs], { + cwd: dir, + env: { ...process.env, CHRONOS_HTTP_PORT: "1" }, + stdio: ["pipe", "pipe", "pipe"], + }); + let buffer = ""; + let stderr = ""; + const handlers = []; + proc.stderr.on("data", (c) => (stderr += c.toString())); + proc.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + let idx; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + let msg; + try { + msg = JSON.parse(line); + } catch { + continue; + } + for (const h of [...handlers]) h(msg); + } + }); + const send = (o) => proc.stdin.write(JSON.stringify(o) + "\n"); + let reqId = 0; + const waitFor = (pred, ms) => + new Promise((res, rej) => { + const t = setTimeout(() => { + handlers.splice(handlers.indexOf(h), 1); + rej(new Error("timeout")); + }, ms); + const h = (m) => { + if (pred(m)) { + clearTimeout(t); + handlers.splice(handlers.indexOf(h), 1); + res(m); + } + }; + handlers.push(h); + }); + const request = (cmd, ms = 8000) => { + const id = `req_${++reqId}`; + const p = waitFor((m) => m.type === "response" && m.id === id, ms); + send({ ...cmd, id }); + return p; + }; + (async () => { + try { + let ready = false; + const deadline = Date.now() + 20000; + while (Date.now() < deadline && !ready) { + try { + const r = await request({ type: "get_state" }, 2000); + if (r.success) ready = true; + } catch { + if (proc.exitCode !== null) throw new Error(`pi exited early (${proc.exitCode}): ${stderr.slice(-400)}`); + } + } + if (!ready) throw new Error(`pi never became ready. stderr: ${stderr.slice(-400)}`); + const cmds = await request({ type: "get_commands" }); + const skills = (cmds.data?.commands ?? []).filter((c) => c.source === "skill").map((c) => c.name); + resolve(skills); + } catch (e) { + reject(e); + } finally { + proc.kill("SIGTERM"); + setTimeout(() => proc.kill("SIGKILL"), 1000).unref(); + } + })(); + }); +} + +let failed = false; +function check(name, ok, detail = "") { + console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); + if (!ok) failed = true; +} + +const fixture = makeFixture(); +try { + const withSkill = await skillCommands(fixture, ["--skill", join(fixture, "skills")]); + check( + "workspace skill surfaces with --skill", + withSkill.includes("skill:range-finder"), + `skill commands: [${withSkill.join(", ")}]`, + ); + + const plain = await skillCommands(fixture, []); + check( + "bridge alone is NOT enough in rpc mode (untrusted)", + !plain.includes("skill:range-finder"), + `skill commands: [${plain.join(", ")}]`, + ); + + console.log(failed ? "\nSKILL CANARY FAILED" : "\nSKILL CANARY OK"); +} catch (err) { + console.error("SKILL CANARY ERROR:", err.message); + failed = true; +} finally { + rmSync(fixture, { recursive: true, force: true }); + process.exit(failed ? 1 : 0); +} diff --git a/docs/superpowers/specs/2026-07-06-image-downscale-design.md b/docs/superpowers/specs/2026-07-06-image-downscale-design.md new file mode 100644 index 0000000..4cbba29 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-image-downscale-design.md @@ -0,0 +1,103 @@ +# Image downscaling on upload to expert models + +**Date:** 2026-07-06 +**Status:** Approved (design), pending implementation + +## Problem + +Expert subagents (`task` / `task_batch`) upload page imagery to vision models on +every turn — the full history, including the page PNG and all `view_region` +crops, is re-sent per LLM call. Scan PNGs commonly exceed 2 MB (e.g. the +Straubing source: ~2.1 MB/page), and every current provider resizes images past +a pixel cap anyway (Anthropic: 2576 px long edge on Opus 4.7+, 1568 px on older +models; Gemini/OpenAI tile at comparable sizes). Pixels beyond the provider cap +are pure wasted upload bandwidth. At batch concurrency this saturates the +user's uplink: on 2026-07-05 a 324-page batch at concurrency 50 stalled for +3.5 h with 241 pages failing on SDK "Request timed out" errors, with kernel +Send-Q backlogs confirming stalled uploads. + +Downscaling must be **provider-agnostic** (Chronos hardcodes no provider) and +**user-tunable** from VS Code settings. + +## Decision summary + +- One knob: **max long-edge pixels** (not bytes, not both). Every provider + documents a pixel cap, and bytes shrink quadratically with the dimension. +- **Default 2576 px** — Anthropic's current maximum; no fidelity loss on + Opus 4.7+/Sonnet 5, other providers resize down themselves. Users on slow + uplinks can lower it (1568 ≈ 4× smaller uploads on current scans). +- Applied **at upload time only**. Originals on disk are never modified. +- `0` disables downscaling entirely (exact current behavior). + +## Design + +### VS Code setting (chronos-vscode) + +`chronos.maxImageDimension` in `package.json` `contributes.configuration`: + +- type `integer`, default `2576`, minimum `0` +- Description: caps the long edge (in pixels) of every image sent to expert + vision models. Lower values shrink uploads quadratically at the cost of + full-page detail; experts can still zoom via `view_region`, which crops from + the full-resolution file on disk. `0` sends originals untouched. Notes the + provider context (Anthropic resizes past 2576 px regardless). + +Forwarded to the pi subprocess in `src/extension.ts` alongside the existing +limits (`agentEnv` block, ~line 758): + +```ts +CHRONOS_MAX_IMAGE_DIMENSION: String(chronosCfg.get("maxImageDimension", 2576)), +``` + +### Agent (chronos pi-package) + +New helper in `chronos/utils/crop-image.ts`: + +```ts +/** Downscale so the long edge is ≤ maxDim. Returns the input unchanged when + * already within the cap (no re-encode) or when maxDim is 0. */ +export async function downscaleToLimit(png: Buffer, maxDim: number): Promise +``` + +- Uses `sharp` (already a dependency): read metadata; if + `max(width, height) > maxDim`, `resize({ width|height: maxDim, fit: "inside", + withoutEnlargement: true })` on the long edge and re-encode PNG; else return + the original buffer. + +Applied inside `pageImageContent()` (`chronos/tools/expert-turn.ts:74`) — the +single funnel for all model-bound imagery: + +- full-page attachment (`task` / `task_batch` page context) +- `view_page` / `view_region` expert tools +- session-restore rehydration (`rehydrateToolResult`) + +Both branches are capped: the full-page `readFileSync` path and the +`cropImageToBase64` path (crop from full-res first, then cap — so an oversized +crop can't blow past the limit, while small zoom crops pass through untouched). + +Cap read via the existing env pattern: + +```ts +const MAX_IMAGE_DIMENSION = envInt("CHRONOS_MAX_IMAGE_DIMENSION", 2576, 0, 100_000); +``` + +`0` short-circuits `downscaleToLimit` to a no-op. + +### Non-goals + +- No byte-ceiling setting (encode-search loop; unpredictable pixel output). +- No PNG→JPEG/WebP re-encoding. +- No import-time downscaling — disk originals stay pristine. +- Not a fix for uplink saturation by itself: at the 2576 default, current scans + shrink only modestly. Relief for slow links comes from lowering the setting, + plus the separate retry/timeout work and concurrency limits. + +## Testing + +1. `cd chronos && npm run build`; typecheck both `chronos-vscode` tsconfigs. +2. Node one-liner: run `downscaleToLimit` on a >2576 px page PNG; assert output + long edge ≤ 2576 and output bytes < input bytes; assert a small crop buffer + is returned byte-identical (no re-encode). +3. Manual smoke in a dev workspace: run a `task` on a large page; confirm the + expert reads it and the viewer still renders; set the setting to `0` and + confirm originals pass through. From 92e0861afe92839abdf30b481e3870f3acbdf24b Mon Sep 17 00:00:00 2001 From: Lorenz Hufe Date: Mon, 6 Jul 2026 09:03:07 +0200 Subject: [PATCH 03/45] docs: spec for expert LLM call retry + timeout --- .../specs/2026-07-06-expert-retry-design.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-06-expert-retry-design.md diff --git a/docs/superpowers/specs/2026-07-06-expert-retry-design.md b/docs/superpowers/specs/2026-07-06-expert-retry-design.md new file mode 100644 index 0000000..8c5f774 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-expert-retry-design.md @@ -0,0 +1,110 @@ +# Retry + timeout for expert LLM calls + +**Date:** 2026-07-06 +**Status:** Approved (design), pending implementation +**Companion:** `2026-07-06-image-downscale-design.md` (same incident, upload-size half) + +## Problem + +Expert subagent LLM calls (`expert-turn.ts` → pi-ai `complete()`) run with the +Anthropic SDK's default 10-minute timeout and **zero retries** (pi-ai constructs +the client with `maxRetries: 0`; Chronos passes neither `timeoutMs` nor +`maxRetries`). One transient failure — "Request timed out.", a 429, a 529 +overload — permanently fails that page in a `task_batch`, wasting all tokens +already spent on the expert's turn. On 2026-07-05 this failed 241/324 pages in +one batch, and each stalled attempt held a concurrency slot for the full 10 +minutes. + +## Decision summary + +- **Chronos-side retry loop** around `complete()` — provider-agnostic (works + identically for Anthropic/Gemini/OpenAI-compat), and pi-ai surfaces failures + as a resolved response (`stopReason === "error"` + `errorMessage` string), + not a thrown exception, so the SDK's own retry knob is the wrong layer. + pi-ai's `maxRetries` stays at its default 0 so retries are controlled in + exactly one place. +- **Per-attempt timeout 300 s** via the existing `options.timeoutMs` + passthrough (`pi-ai` `types.d.ts:88`). Per pi-ai docs this bounds both the + HTTP request and stream idleness after connection — one knob covers stalled + uploads and dead streams. +- **Up to 3 retries** (4 attempts total) with exponential backoff. Worst case + ~21 min per LLM call, vs. 10 min today with guaranteed page loss. + +## Design + +### Retry wrapper (chronos pi-package) + +`completeWithRetry()` in `chronos/tools/expert-turn.ts` (private helper next to +the loop that calls it), replacing the bare `complete()` call at +`expert-turn.ts:259`: + +``` +attempts = 1 + EXPERT_RETRIES +for attempt in 1..attempts: + response = await complete(model, ctx, { apiKey, headers, signal, + timeoutMs: EXPERT_TIMEOUT_MS || undefined }) + if signal.aborted or stopReason == "aborted" -> return response (caller handles) + if stopReason != "error" -> return response (success) + if isPermanentError(errorMessage) -> return response (fail now) + if attempt < attempts -> abort-aware sleep(backoff[attempt]) +return response (retries exhausted) +``` + +- **Backoff:** ~2 s, 8 s, 30 s (capped) with ±25 % jitter, so a 50-expert batch + doesn't retry in lockstep. The sleep races the abort signal — cancel is + never delayed by a pending backoff. +- **Permanent-error classifier:** skip retries when `errorMessage` matches + auth/validation patterns (`/invalid|unauthorized|authentication|api key| + permission|not.found|billing/i`). Everything else — timeouts, 429, 5xx/529, + connection resets, unclassifiable messages — is retried. Misclassifying a + permanent error as transient costs at most 3 bounded extra attempts; the + reverse (dropping a recoverable page) is the failure mode we're fixing, so + the classifier errs toward retrying. +- **Error surfacing:** unchanged shape — after the final attempt the turn fails + exactly as today (`Expert model error (provider/model): …`), with + `" (after N attempts)"` appended so batch summaries distinguish exhausted + retries from immediate failures. +- The retried request is byte-identical (same messages/images), so + provider-side prompt caching can make retries cheaper than the first attempt. + +### Configuration + +Same pattern as the existing limits (`envInt` + `agentEnv` in +`extension.ts:758`): + +| VS Code setting | Default | Range | Env var | +|---|---|---|---| +| `chronos.expertRetries` | 3 | 0–10 (0 = no retries, current behavior) | `CHRONOS_EXPERT_RETRIES` | +| `chronos.expertRequestTimeout` (seconds) | 300 | 0–3600 (0 = provider/SDK default) | `CHRONOS_EXPERT_TIMEOUT` | + +Setting descriptions explain the trade-off: timeout bounds how long a stalled +upload or dead stream holds a batch concurrency slot; retries recover pages +from transient provider/network failures at the cost of extra attempts. + +### Scope + +Applies to the expert agentic loop only (`expert-turn.ts`). The orchestrator's +own model calls go through pi's main loop, which pi manages — out of scope. +Batch-level behavior is unchanged: a page that fails after all retries is +reported failed, and the orchestrator can re-batch the missing pages as it +does today. + +## Risks / notes + +- **Build-time vs runtime pi-ai drift:** `timeoutMs`/`maxRetries` exist in the + installed pi 0.79's pi-ai. Verify `chronos/node_modules`' pi-ai types include + them; if the peer dep is older, bump it — do not cast around missing types. +- Retries multiply worst-case latency (~21 min/call at defaults); the timeout + reduction (600 s → 300 s) offsets this for the common stall case. + +## Testing + +1. `cd chronos && npm run build` + both `chronos-vscode` typechecks. +2. Unit-style node script for the wrapper with a stubbed `complete`: + transient error → succeeds on attempt 2; permanent error (`"invalid x-api-key"`) + → single attempt; abort during backoff → returns promptly as aborted; + retries exhausted → error message carries attempt count. +3. Manual smoke: point `.chronos/.env` at a bogus key → task fails once, fast, + no retries (permanent). Then a real batch on a few pages → succeeds; check + a synthetic timeout (set `chronos.expertRequestTimeout` to ~5 s) produces + retry log/backoff then a clean failure. From 609589e1cddb6d0557f40cdf125da3aded6144fa Mon Sep 17 00:00:00 2001 From: Lorenz Hufe Date: Mon, 6 Jul 2026 09:11:55 +0200 Subject: [PATCH 04/45] docs: implementation plan for image downscale + expert retry --- ...-07-06-image-downscale-and-expert-retry.md | 625 ++++++++++++++++++ 1 file changed, 625 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-image-downscale-and-expert-retry.md diff --git a/docs/superpowers/plans/2026-07-06-image-downscale-and-expert-retry.md b/docs/superpowers/plans/2026-07-06-image-downscale-and-expert-retry.md new file mode 100644 index 0000000..1cd214a --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-image-downscale-and-expert-retry.md @@ -0,0 +1,625 @@ +# Image Downscaling + Expert Retry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Cap the pixel size of every image uploaded to expert vision models, and retry transient expert LLM failures instead of permanently failing the page. + +**Architecture:** All model-bound imagery funnels through `pageImageContent()` in `chronos/tools/expert-turn.ts` — a `sharp`-based `downscaleToLimit()` helper caps it there. All expert LLM calls go through the single `complete()` call in the same file — a generic `completeWithRetry()` wrapper (new `chronos/utils/expert-retry.ts`) adds bounded retries + per-attempt timeout. Three new VS Code settings forward as env vars via the existing `agentEnv` pattern. + +**Tech Stack:** TypeScript (chronos pi-package, compiled with `tsc` to `dist/`), `sharp` (already a dependency), VS Code extension settings (`chronos-vscode/package.json` + `src/extension.ts`). + +**Specs:** `docs/superpowers/specs/2026-07-06-image-downscale-design.md`, `docs/superpowers/specs/2026-07-06-expert-retry-design.md` + +## Global Constraints + +- **pi loads the agent from `dist/`** — after editing anything under `chronos/{tools,utils}/*.ts`, run `cd chronos && npm run build` or the change has no effect. +- **The working tree contains unrelated uncommitted changes** (archive-support work). `git add` ONLY the exact files listed in each commit step. Never `git add -A` / `git add .`. +- **Commits:** author `Lorenz Hufe `, NO `Co-Authored-By` trailers (repo convention). +- **Env var names (exact):** `CHRONOS_MAX_IMAGE_DIMENSION`, `CHRONOS_EXPERT_RETRIES`, `CHRONOS_EXPERT_TIMEOUT`. Defaults: 2576 px / 3 retries / 300 s. `0` disables (image cap, retries) or means provider default (timeout). +- **Setting names (exact):** `chronos.maxImageDimension`, `chronos.expertRetries`, `chronos.expertRequestTimeout`. +- `chronos-vscode` esbuild does not type-check; after editing it run `npx tsc --noEmit -p tsconfig.json` from `chronos-vscode/`. +- Canary test scripts live in `chronos/scripts/` (new directory, committed — mirrors `chronos-vscode/scripts/`). They import from `../dist/`, so build before running them. + +--- + +### Task 1: `downscaleToLimit()` helper + canary + +**Files:** +- Modify: `chronos/utils/crop-image.ts` (append function at end of file) +- Test: `chronos/scripts/downscale-canary.mjs` (create) + +**Interfaces:** +- Consumes: `sharp` (already imported at top of `crop-image.ts`). +- Produces: `export async function downscaleToLimit(png: Buffer, maxDim: number): Promise` — Task 2 imports this from `../utils/crop-image.js`. + +- [ ] **Step 1: Write the failing canary** + +Create `chronos/scripts/downscale-canary.mjs`: + +```js +// Canary for downscaleToLimit (utils/crop-image.ts). Run from chronos/ after +// `npm run build`: node scripts/downscale-canary.mjs +import sharp from "sharp"; +import { downscaleToLimit } from "../dist/utils/crop-image.js"; + +function assert(cond, msg) { + if (!cond) { + console.error("FAIL:", msg); + process.exit(1); + } +} + +const make = (width, height) => + sharp({ create: { width, height, channels: 3, background: { r: 200, g: 180, b: 150 } } }) + .png() + .toBuffer(); + +// Oversized image is capped on the long edge, aspect ratio preserved. +const big = await make(4000, 3000); +const capped = await downscaleToLimit(big, 2576); +const meta = await sharp(capped).metadata(); +assert(meta.width === 2576, `long edge capped to 2576, got ${meta.width}`); +assert(meta.height === 1932, `aspect preserved (3000*2576/4000=1932), got ${meta.height}`); + +// Portrait orientation: the LONG edge is capped, whichever axis it is. +const portrait = await make(1000, 4000); +const cappedPortrait = await downscaleToLimit(portrait, 2576); +const metaP = await sharp(cappedPortrait).metadata(); +assert(metaP.height === 2576, `portrait long edge capped, got ${metaP.height}`); + +// Under-cap image: returned byte-identical (no re-encode cost). +const small = await make(800, 600); +assert((await downscaleToLimit(small, 2576)) === small, "under-cap buffer returned unchanged"); + +// maxDim 0 disables entirely. +assert((await downscaleToLimit(big, 0)) === big, "maxDim 0 is a no-op"); + +console.log("downscale canary OK"); +``` + +- [ ] **Step 2: Run canary, verify it fails** + +Run: `cd chronos && npm run build && node scripts/downscale-canary.mjs` +Expected: FAIL — `SyntaxError: The requested module ... does not provide an export named 'downscaleToLimit'` + +- [ ] **Step 3: Implement `downscaleToLimit`** + +Append to `chronos/utils/crop-image.ts`: + +```ts +/** + * Downscale a PNG so its long edge is at most `maxDim` pixels. Returns the + * input buffer unchanged when it is already within the cap (no re-encode) or + * when maxDim is 0 (disabled). Aspect ratio is preserved. + */ +export async function downscaleToLimit(png: Buffer, maxDim: number): Promise { + if (maxDim <= 0) return png; + const img = sharp(png); + const { width, height } = await img.metadata(); + if (!width || !height || Math.max(width, height) <= maxDim) return png; + return img + .resize({ width: maxDim, height: maxDim, fit: "inside", withoutEnlargement: true }) + .png() + .toBuffer(); +} +``` + +- [ ] **Step 4: Run canary, verify it passes** + +Run: `cd chronos && npm run build && node scripts/downscale-canary.mjs` +Expected: `downscale canary OK` + +- [ ] **Step 5: Commit** + +```bash +cd /home/hufe/Documents/code/chronos +git add chronos/utils/crop-image.ts chronos/scripts/downscale-canary.mjs +git commit --author="Lorenz Hufe " -m "feat: downscaleToLimit helper caps image long edge with sharp" +``` + +--- + +### Task 2: Apply the cap in `pageImageContent()` + +**Files:** +- Modify: `chronos/tools/expert-turn.ts` (imports at ~line 17, module consts at ~line 36, `pageImageContent` at lines 74–81) + +**Interfaces:** +- Consumes: `downscaleToLimit(png, maxDim)` from Task 1; existing `cropImageToBuffer(imgPath, bbox)` from `chronos/utils/crop-image.ts`; existing `envInt(name, fallback, min, max)` from `chronos/utils/env-config.ts`. +- Produces: `pageImageContent()` behavior change only — signature unchanged, all four call paths (page attachment, `view_page`, `view_region`, `rehydrateToolResult`) get capped images automatically. + +- [ ] **Step 1: Change the crop-image import** + +In `chronos/tools/expert-turn.ts`, replace: + +```ts +import { cropImageToBase64, type Bbox } from "../utils/crop-image.js"; +``` + +with: + +```ts +import { cropImageToBuffer, downscaleToLimit, type Bbox } from "../utils/crop-image.js"; +``` + +(`cropImageToBase64` has no other consumers — verified 2026-07-06.) + +- [ ] **Step 2: Add the env-configured cap** + +Below the `HARD_TOOL_CALL_CEILING` const (~line 36), add: + +```ts +// Cap the long edge of every image sent to expert models. Providers resize +// past their own pixel caps anyway (Anthropic: 2576px on Opus 4.7+), so +// larger uploads are pure wasted bandwidth — at batch concurrency they can +// saturate the user's uplink. `chronos.maxImageDimension` setting, forwarded +// as CHRONOS_MAX_IMAGE_DIMENSION; 0 disables. view_region crops are cut from +// the full-resolution file first, so expert zooming keeps full detail. +const MAX_IMAGE_DIMENSION = envInt("CHRONOS_MAX_IMAGE_DIMENSION", 2576, 0, 100_000); +``` + +- [ ] **Step 3: Route both `pageImageContent` branches through the cap** + +Replace the body of `pageImageContent` (lines 74–81): + +```ts +export async function pageImageContent(sourceDir: string, pageId: number, bbox?: Bbox): Promise { + const imgPath = pageIdToPath(sourceDir, pageId); + if (!existsSync(imgPath)) { + throw new Error(`Page ${String(pageId).padStart(4, "0")} not found: ${imgPath}`); + } + const raw = bbox ? await cropImageToBuffer(imgPath, bbox) : readFileSync(imgPath); + const capped = await downscaleToLimit(raw, MAX_IMAGE_DIMENSION); + return { type: "image", data: capped.toString("base64"), mimeType: "image/png" }; +} +``` + +- [ ] **Step 4: Build and integration-check both branches** + +```bash +cd /home/hufe/Documents/code/chronos/chronos && npm run build +TMP_SRC=$(mktemp -d) && mkdir -p "$TMP_SRC/png" +TMP_SRC="$TMP_SRC" node --input-type=module -e ' +import sharp from "sharp"; +await sharp({ create: { width: 4000, height: 3000, channels: 3, background: { r: 200, g: 180, b: 150 } } }) + .png().toFile(process.env.TMP_SRC + "/png/page_0001.png"); +console.log("fixture ok"); +' +TMP_SRC="$TMP_SRC" CHRONOS_MAX_IMAGE_DIMENSION=1000 node --input-type=module -e ' +import sharp from "sharp"; +const { pageImageContent } = await import("./dist/tools/expert-turn.js"); +const tmp = process.env.TMP_SRC; +const full = await pageImageContent(tmp, 1); +const m1 = await sharp(Buffer.from(full.data, "base64")).metadata(); +if (m1.width !== 1000) { console.error("FAIL full-page cap, got", m1.width); process.exit(1); } +const crop = await pageImageContent(tmp, 1, { x: 0, y: 0, w: 0.1, h: 0.1 }); +const m2 = await sharp(Buffer.from(crop.data, "base64")).metadata(); +if (m2.width !== 400) { console.error("FAIL small crop should be untouched (400px), got", m2.width); process.exit(1); } +console.log("pageImageContent cap OK"); +' +``` + +Expected: `fixture ok`, then `pageImageContent cap OK` (full page capped 4000→1000; the 400 px crop passes through untouched). The env var is read at module load, so `CHRONOS_MAX_IMAGE_DIMENSION` must be set on the node invocation itself, as shown. + +- [ ] **Step 5: Commit** + +```bash +cd /home/hufe/Documents/code/chronos +git add chronos/tools/expert-turn.ts +git commit --author="Lorenz Hufe " -m "feat: cap expert image uploads at CHRONOS_MAX_IMAGE_DIMENSION (default 2576px)" +``` + +--- + +### Task 3: Retry utility + canary + +**Files:** +- Create: `chronos/utils/expert-retry.ts` +- Test: `chronos/scripts/retry-canary.mjs` (create) + +**Interfaces:** +- Consumes: nothing project-internal (self-contained; generic over the response shape so the canary needs no pi-ai stubs). +- Produces (Task 4 imports these from `../utils/expert-retry.js`): + - `interface CompleteLike { stopReason: string; errorMessage?: string }` + - `completeWithRetry(attempt: () => Promise, opts: { retries: number; delayMs?: (retryIndex: number) => number }, signal?: AbortSignal): Promise<{ response: T; attempts: number }>` + - `isPermanentExpertError(message: string | undefined): boolean` + - `backoffDelayMs(retryIndex: number, random?: () => number): number` + - `sleepWithAbort(ms: number, signal?: AbortSignal): Promise` + +- [ ] **Step 1: Write the failing canary** + +Create `chronos/scripts/retry-canary.mjs`: + +```js +// Canary for the expert LLM retry policy (utils/expert-retry.ts). Run from +// chronos/ after `npm run build`: node scripts/retry-canary.mjs +import { + backoffDelayMs, + completeWithRetry, + isPermanentExpertError, +} from "../dist/utils/expert-retry.js"; + +function assert(cond, msg) { + if (!cond) { + console.error("FAIL:", msg); + process.exit(1); + } +} +const tinyDelay = () => 5; + +// 1. Success on the first attempt: no retry. +{ + const { response, attempts } = await completeWithRetry( + async () => ({ stopReason: "stop" }), + { retries: 3, delayMs: tinyDelay }, + ); + assert(response.stopReason === "stop" && attempts === 1, "success needs one attempt"); +} + +// 2. Transient errors recover. +{ + let n = 0; + const { response, attempts } = await completeWithRetry( + async () => (++n < 3 ? { stopReason: "error", errorMessage: "Request timed out." } : { stopReason: "stop" }), + { retries: 3, delayMs: tinyDelay }, + ); + assert(response.stopReason === "stop" && attempts === 3, `transient recovers (attempts=${attempts})`); +} + +// 3. Permanent error: exactly one attempt. +{ + let n = 0; + const { response, attempts } = await completeWithRetry( + async () => { + n++; + return { stopReason: "error", errorMessage: "invalid x-api-key" }; + }, + { retries: 3, delayMs: tinyDelay }, + ); + assert(attempts === 1 && n === 1 && response.stopReason === "error", "permanent error not retried"); +} + +// 4. Retries exhausted: 1 + retries attempts, error returned. +{ + const { response, attempts } = await completeWithRetry( + async () => ({ stopReason: "error", errorMessage: "Overloaded" }), + { retries: 3, delayMs: tinyDelay }, + ); + assert(attempts === 4 && response.stopReason === "error", `exhausted after 4 attempts (got ${attempts})`); +} + +// 5. "aborted" responses are never retried. +{ + const { attempts } = await completeWithRetry( + async () => ({ stopReason: "aborted" }), + { retries: 3, delayMs: tinyDelay }, + ); + assert(attempts === 1, "aborted response returns immediately"); +} + +// 6. Abort during backoff cuts the wait short. +{ + const ctl = new AbortController(); + const start = Date.now(); + const pending = completeWithRetry( + async () => ({ stopReason: "error", errorMessage: "Request timed out." }), + { retries: 3, delayMs: () => 60_000 }, + ctl.signal, + ); + setTimeout(() => ctl.abort(), 20); + const { attempts } = await pending; + assert(Date.now() - start < 5_000, "abort resolves the backoff sleep promptly"); + assert(attempts === 1, "no further attempt after abort"); +} + +// 7. Classifier + backoff shape. +assert(isPermanentExpertError("401 Unauthorized"), "auth is permanent"); +assert(isPermanentExpertError("invalid_request_error: bad schema"), "validation is permanent"); +assert(!isPermanentExpertError("Request timed out."), "timeout is transient"); +assert(!isPermanentExpertError(undefined), "missing message is transient"); +assert(backoffDelayMs(0, () => 0.5) === 2000, "retry 0 midpoint 2s"); +assert(backoffDelayMs(1, () => 0.5) === 8000, "retry 1 midpoint 8s"); +assert(backoffDelayMs(9, () => 0.5) === 30000, "later retries capped at 30s"); + +console.log("retry canary OK"); +``` + +- [ ] **Step 2: Run canary, verify it fails** + +Run: `cd chronos && node scripts/retry-canary.mjs` +Expected: FAIL — `Cannot find module '.../dist/utils/expert-retry.js'` + +- [ ] **Step 3: Implement `chronos/utils/expert-retry.ts`** + +```ts +// Retry policy for expert LLM calls. +// See docs/superpowers/specs/2026-07-06-expert-retry-design.md. +// +// pi-ai's complete() reports failures as a *resolved* response with +// stopReason "error" (it does not throw), so the retry wraps that check. +// pi-ai's own maxRetries stays at its default 0 — this is the single retry +// layer; adding SDK-level retries on top would multiply attempts. + +/** Minimal shape of a pi-ai AssistantMessage that the retry loop inspects. */ +export interface CompleteLike { + stopReason: string; + errorMessage?: string; +} + +// Auth/validation failures fail identically on every attempt — skip retries +// for those. Everything else (timeouts, 429s, 5xx/529, connection resets, +// unclassifiable messages) is retried: a wasted bounded retry is cheaper than +// permanently losing a page to a transient error. +const PERMANENT_ERROR = /invalid|unauthorized|authentication|api key|permission|not.found|billing/i; + +export function isPermanentExpertError(message: string | undefined): boolean { + return message !== undefined && PERMANENT_ERROR.test(message); +} + +// ~2s / 8s / 30s, capped at 30s for later retries; ±25% jitter so a +// 50-expert batch doesn't retry in lockstep. +const BASE_DELAYS_MS = [2_000, 8_000, 30_000]; + +export function backoffDelayMs(retryIndex: number, random: () => number = Math.random): number { + const base = BASE_DELAYS_MS[Math.min(retryIndex, BASE_DELAYS_MS.length - 1)]; + return Math.round(base * (0.75 + random() * 0.5)); +} + +/** Sleep that resolves early (without throwing) when the signal aborts. */ +export function sleepWithAbort(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const timer = setTimeout(done, ms); + function done() { + clearTimeout(timer); + signal?.removeEventListener("abort", done); + resolve(); + } + signal?.addEventListener("abort", done); + }); +} + +export interface RetryResult { + response: T; + /** Total attempts made (1 = succeeded or failed without any retry). */ + attempts: number; +} + +/** + * Run `attempt` until it succeeds, fails permanently, is aborted, or retries + * are exhausted. `retries` counts *re*-attempts after the first try, so the + * loop makes at most `1 + retries` calls. The last response is returned + * as-is; the caller keeps its existing stopReason handling. + */ +export async function completeWithRetry( + attempt: () => Promise, + opts: { retries: number; delayMs?: (retryIndex: number) => number }, + signal?: AbortSignal, +): Promise> { + const delayMs = opts.delayMs ?? backoffDelayMs; + const maxAttempts = 1 + Math.max(0, opts.retries); + let response = await attempt(); + let attempts = 1; + while ( + attempts < maxAttempts && + response.stopReason === "error" && + !signal?.aborted && + !isPermanentExpertError(response.errorMessage) + ) { + await sleepWithAbort(delayMs(attempts - 1), signal); + if (signal?.aborted) break; + response = await attempt(); + attempts++; + } + return { response, attempts }; +} +``` + +- [ ] **Step 4: Run canary, verify it passes** + +Run: `cd chronos && npm run build && node scripts/retry-canary.mjs` +Expected: `retry canary OK` + +- [ ] **Step 5: Commit** + +```bash +cd /home/hufe/Documents/code/chronos +git add chronos/utils/expert-retry.ts chronos/scripts/retry-canary.mjs +git commit --author="Lorenz Hufe " -m "feat: retry policy for expert LLM calls (backoff, permanent-error classifier, abort-aware)" +``` + +--- + +### Task 4: Wire retry + timeout into the expert loop + +**Files:** +- Modify: `chronos/tools/expert-turn.ts` (imports; module consts; the `complete()` call at ~line 259 and the error return just below it) + +**Interfaces:** +- Consumes: `completeWithRetry`, `CompleteLike` semantics from Task 3; existing `envInt`; pi-ai `complete()` options `timeoutMs` (verified present in `chronos/node_modules/@earendil-works/pi-ai/dist/types.d.ts:77` — if the compiler rejects it, STOP and bump the pi-ai dep, do not cast). +- Produces: no new exports. Error strings gain an `" (after N attempts)"` suffix when retries happened — `task-batch.ts` passes these through unchanged. + +- [ ] **Step 1: Add import and env consts** + +Import (with the other util imports near the top): + +```ts +import { completeWithRetry } from "../utils/expert-retry.js"; +``` + +Below `MAX_IMAGE_DIMENSION` (added in Task 2): + +```ts +// Retry/timeout policy for expert LLM calls (`chronos.expertRetries` / +// `chronos.expertRequestTimeout` settings). The timeout bounds each attempt's +// HTTP request AND stream idleness (pi-ai forwards it to the provider SDK), +// so a stalled upload or dead stream can't hold a batch slot for the SDK's +// 10-minute default. 0 retries / 0 timeout restore the old behavior. +const EXPERT_RETRIES = envInt("CHRONOS_EXPERT_RETRIES", 3, 0, 10); +const EXPERT_TIMEOUT_S = envInt("CHRONOS_EXPERT_TIMEOUT", 300, 0, 3600); +``` + +- [ ] **Step 2: Wrap the `complete()` call** + +Replace (currently at ~lines 259–274): + +```ts + const response = await complete( + resolved.model, + { + systemPrompt: pageExpertPrompt, + messages: [...session.messages, ...turnMessages], + tools: toolsEnabled ? expertToolDefs : undefined, + }, + { apiKey: resolved.apiKey, headers: resolved.headers, signal: input.signal }, + ); + if (response.stopReason === "error") { + return { + ok: false, + taskId, + error: `Expert model error (${modelSpec(resolved.model)}): ${response.errorMessage ?? "unknown error"}`, + }; + } +``` + +with: + +```ts + const { response, attempts } = await completeWithRetry( + () => + complete( + resolved.model, + { + systemPrompt: pageExpertPrompt, + messages: [...session.messages, ...turnMessages], + tools: toolsEnabled ? expertToolDefs : undefined, + }, + { + apiKey: resolved.apiKey, + headers: resolved.headers, + signal: input.signal, + ...(EXPERT_TIMEOUT_S > 0 ? { timeoutMs: EXPERT_TIMEOUT_S * 1000 } : {}), + }, + ), + { retries: EXPERT_RETRIES }, + input.signal, + ); + if (response.stopReason === "error") { + const attemptNote = attempts > 1 ? ` (after ${attempts} attempts)` : ""; + return { + ok: false, + taskId, + error: `Expert model error (${modelSpec(resolved.model)}): ${response.errorMessage ?? "unknown error"}${attemptNote}`, + }; + } +``` + +Leave the abort check that follows (`if (input.signal?.aborted || response.stopReason === "aborted")`) exactly as it is — `completeWithRetry` never retries an `"aborted"` response, so the existing handling still applies. + +- [ ] **Step 3: Build (this is the type-level test)** + +Run: `cd chronos && npm run build` +Expected: clean compile. `response` keeps its full `AssistantMessage` type through the generic, so the downstream `turnMessages.push(response)` / `response.usage` / `response.content` code compiles unchanged. If `timeoutMs` is rejected: the build-time pi-ai is older than expected — bump `@earendil-works/pi-ai` in `chronos/package.json` and re-verify; do not cast. + +- [ ] **Step 4: Re-run both canaries (regression)** + +Run: `cd chronos && node scripts/downscale-canary.mjs && node scripts/retry-canary.mjs` +Expected: `downscale canary OK`, `retry canary OK` + +- [ ] **Step 5: Commit** + +```bash +cd /home/hufe/Documents/code/chronos +git add chronos/tools/expert-turn.ts +git commit --author="Lorenz Hufe " -m "feat: expert LLM calls retry transient failures with 300s per-attempt timeout" +``` + +--- + +### Task 5: VS Code settings + env forwarding + +**Files:** +- Modify: `chronos-vscode/package.json` (`contributes.configuration.properties`, directly after the `chronos.maxConcurrency` block at ~lines 92–98) +- Modify: `chronos-vscode/src/extension.ts` (`agentEnv` object, ~line 758) + +**Interfaces:** +- Consumes: env var names from Global Constraints; the settings must default to the SAME values as the `envInt` fallbacks in the agent (2576 / 3 / 300) so an unset setting and a missing env var agree. +- Produces: the three settings visible in VS Code Settings UI; env vars on the pi subprocess. + +- [ ] **Step 1: Add the three settings to `package.json`** + +Insert after the `chronos.maxConcurrency` property block: + +```json +"chronos.maxImageDimension": { + "type": "integer", + "default": 2576, + "minimum": 0, + "markdownDescription": "Maximum long-edge size in **pixels** for images sent to expert vision models (`task`/`task_batch` page context, `view_page`, `view_region`). Larger images are downscaled before upload — providers resize past their own caps anyway (Anthropic: 2576 px), so bigger uploads only waste bandwidth. Lower values shrink uploads roughly quadratically (1568 ≈ 4× smaller than a typical 3000 px scan) at the cost of full-page detail; experts can still zoom via `view_region`, which crops from the full-resolution file on disk. `0` sends originals untouched. Default: 2576." +}, +"chronos.expertRetries": { + "type": "integer", + "default": 3, + "minimum": 0, + "maximum": 10, + "markdownDescription": "How many times a failed expert LLM call is retried (exponential backoff ≈2 s/8 s/30 s with jitter) before the page is reported failed. Retries recover pages from transient provider/network failures — timeouts, rate limits, overloads; auth and validation errors are never retried. `0` disables retries. Default: 3." +}, +"chronos.expertRequestTimeout": { + "type": "integer", + "default": 300, + "minimum": 0, + "maximum": 3600, + "markdownDescription": "Per-attempt timeout in **seconds** for expert LLM calls. Bounds how long a stalled upload or an idle response stream can hold a `task_batch` concurrency slot. `0` uses the provider SDK default (typically 10 minutes). Default: 300." +} +``` + +- [ ] **Step 2: Forward them in `extension.ts`** + +In the `agentEnv` object (after the `CHRONOS_MAX_CONCURRENCY` line): + +```ts +CHRONOS_MAX_IMAGE_DIMENSION: String(chronosCfg.get("maxImageDimension", 2576)), +CHRONOS_EXPERT_RETRIES: String(chronosCfg.get("expertRetries", 3)), +CHRONOS_EXPERT_TIMEOUT: String(chronosCfg.get("expertRequestTimeout", 300)), +``` + +- [ ] **Step 3: Typecheck + build the extension** + +Run: +```bash +cd /home/hufe/Documents/code/chronos/chronos-vscode +npx tsc --noEmit -p tsconfig.json +npm run build +node -e 'const p = require("./package.json").contributes.configuration.properties; for (const k of ["chronos.maxImageDimension","chronos.expertRetries","chronos.expertRequestTimeout"]) { if (!p[k]) { console.error("missing", k); process.exit(1); } } console.log("settings present");' +``` +Expected: no type errors; build succeeds; `settings present`. + +- [ ] **Step 4: Commit** + +```bash +cd /home/hufe/Documents/code/chronos +git add chronos-vscode/package.json chronos-vscode/src/extension.ts +git commit --author="Lorenz Hufe " -m "feat: settings for image cap, expert retries, and request timeout" +``` + +--- + +### Task 6: Final verification sweep + +**Files:** none (verification only) + +- [ ] **Step 1: Full builds + canaries from clean state** + +```bash +cd /home/hufe/Documents/code/chronos/chronos && npm run build && node scripts/downscale-canary.mjs && node scripts/retry-canary.mjs +cd ../chronos-vscode && npx tsc --noEmit -p tsconfig.json && npx tsc --noEmit -p webview/tsconfig.json && npm run build +``` +Expected: both canaries OK, zero type errors, both builds green. + +- [ ] **Step 2: Confirm nothing unrelated was staged** + +Run: `cd /home/hufe/Documents/code/chronos && git log --oneline -6 && git status --short | head -30` +Expected: the 5 feature commits on top of the two spec commits; the pre-existing archive-support modifications still present and UNSTAGED. From b53e7a1e8a0c784b822180260c526e9f67e88062 Mon Sep 17 00:00:00 2001 From: Lorenz Hufe Date: Mon, 6 Jul 2026 09:13:50 +0200 Subject: [PATCH 05/45] feat: downscaleToLimit helper caps image long edge with sharp --- chronos/scripts/downscale-canary.mjs | 38 ++++++++++++++++++++++++++++ chronos/utils/crop-image.ts | 16 ++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 chronos/scripts/downscale-canary.mjs diff --git a/chronos/scripts/downscale-canary.mjs b/chronos/scripts/downscale-canary.mjs new file mode 100644 index 0000000..b8b5e4f --- /dev/null +++ b/chronos/scripts/downscale-canary.mjs @@ -0,0 +1,38 @@ +// Canary for downscaleToLimit (utils/crop-image.ts). Run from chronos/ after +// `npm run build`: node scripts/downscale-canary.mjs +import sharp from "sharp"; +import { downscaleToLimit } from "../dist/utils/crop-image.js"; + +function assert(cond, msg) { + if (!cond) { + console.error("FAIL:", msg); + process.exit(1); + } +} + +const make = (width, height) => + sharp({ create: { width, height, channels: 3, background: { r: 200, g: 180, b: 150 } } }) + .png() + .toBuffer(); + +// Oversized image is capped on the long edge, aspect ratio preserved. +const big = await make(4000, 3000); +const capped = await downscaleToLimit(big, 2576); +const meta = await sharp(capped).metadata(); +assert(meta.width === 2576, `long edge capped to 2576, got ${meta.width}`); +assert(meta.height === 1932, `aspect preserved (3000*2576/4000=1932), got ${meta.height}`); + +// Portrait orientation: the LONG edge is capped, whichever axis it is. +const portrait = await make(1000, 4000); +const cappedPortrait = await downscaleToLimit(portrait, 2576); +const metaP = await sharp(cappedPortrait).metadata(); +assert(metaP.height === 2576, `portrait long edge capped, got ${metaP.height}`); + +// Under-cap image: returned byte-identical (no re-encode cost). +const small = await make(800, 600); +assert((await downscaleToLimit(small, 2576)) === small, "under-cap buffer returned unchanged"); + +// maxDim 0 disables entirely. +assert((await downscaleToLimit(big, 0)) === big, "maxDim 0 is a no-op"); + +console.log("downscale canary OK"); diff --git a/chronos/utils/crop-image.ts b/chronos/utils/crop-image.ts index 6b84017..2b5fb72 100644 --- a/chronos/utils/crop-image.ts +++ b/chronos/utils/crop-image.ts @@ -45,3 +45,19 @@ export async function cropImageToBuffer(imgPath: string, bbox: Bbox): Promise { + if (maxDim <= 0) return png; + const img = sharp(png); + const { width, height } = await img.metadata(); + if (!width || !height || Math.max(width, height) <= maxDim) return png; + return img + .resize({ width: maxDim, height: maxDim, fit: "inside", withoutEnlargement: true }) + .png() + .toBuffer(); +} From 061582bfb4d8a9ce5cac7a8a8104cd3b41392b4c Mon Sep 17 00:00:00 2001 From: Lorenz Hufe Date: Mon, 6 Jul 2026 09:14:41 +0200 Subject: [PATCH 06/45] feat: cap expert image uploads at CHRONOS_MAX_IMAGE_DIMENSION (default 2576px) --- chronos/tools/expert-turn.ts | 97 +++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/chronos/tools/expert-turn.ts b/chronos/tools/expert-turn.ts index f7228f2..fb17635 100644 --- a/chronos/tools/expert-turn.ts +++ b/chronos/tools/expert-turn.ts @@ -11,20 +11,35 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { pageIdToPath } from "../utils/page-files.js"; import type { ExpertRegistry, ExpertSession } from "./expert-registry.js"; import { newTaskId } from "./expert-registry.js"; -import type { SourceContext } from "./source-context.js"; -import { requireSource } from "./source-context.js"; +import type { CollectionContext } from "./collection-context.js"; +import { resolveSource } from "./collection-context.js"; import { resolveExpertModel } from "../utils/resolve-model.js"; -import { cropImageToBase64, type Bbox } from "../utils/crop-image.js"; +import { cropImageToBuffer, downscaleToLimit, type Bbox } from "../utils/crop-image.js"; import { appendExpertTurn, type PersistedExpert, type PersistedStep } from "../utils/expert-store.js"; +import { envInt } from "../utils/env-config.js"; import { buildExpertTools, executeExpertTool, + outputOnlyTools, rehydrateToolResult, type ExpertCapability, } from "./expert-tools.js"; // Bound the per-turn agentic loop so a confused expert can't spin on tool calls. -const MAX_EXPERT_TOOL_CALLS = 8; +// User-configurable via the extension's `chronos.maxExpertToolCalls` setting, +// forwarded as CHRONOS_MAX_EXPERT_TOOL_CALLS; defaults to 100. +const MAX_EXPERT_TOOL_CALLS = envInt("CHRONOS_MAX_EXPERT_TOOL_CALLS", 100, 1, 1000); +// Absolute ceiling: past the exploratory budget an output-owing expert keeps only +// save_output (a few retries for invalid JSON), but this hard-stops all tools so +// the loop always terminates even if it keeps calling save_output. +const HARD_TOOL_CALL_CEILING = MAX_EXPERT_TOOL_CALLS + 3; +// Cap the long edge of every image sent to expert models. Providers resize +// past their own pixel caps anyway (Anthropic: 2576px on Opus 4.7+), so +// larger uploads are pure wasted bandwidth — at batch concurrency they can +// saturate the user's uplink. `chronos.maxImageDimension` setting, forwarded +// as CHRONOS_MAX_IMAGE_DIMENSION; 0 disables. view_region crops are cut from +// the full-resolution file first, so expert zooming keeps full detail. +const MAX_IMAGE_DIMENSION = envInt("CHRONOS_MAX_IMAGE_DIMENSION", 2576, 0, 100_000); const CAP_DESCRIPTION: Record = { bash: "run shell commands", @@ -68,11 +83,14 @@ export async function pageImageContent(sourceDir: string, pageId: number, bbox?: if (!existsSync(imgPath)) { throw new Error(`Page ${String(pageId).padStart(4, "0")} not found: ${imgPath}`); } - const data = bbox ? await cropImageToBase64(imgPath, bbox) : readFileSync(imgPath).toString("base64"); - return { type: "image", data, mimeType: "image/png" }; + const raw = bbox ? await cropImageToBuffer(imgPath, bbox) : readFileSync(imgPath); + const capped = await downscaleToLimit(raw, MAX_IMAGE_DIMENSION); + return { type: "image", data: capped.toString("base64"), mimeType: "image/png" }; } export interface ExpertTurnInput { + /** Collection member ref the expert works on. Always provided (the tools require it). */ + source: string; /** Continue an existing session; omit to spawn a new one. */ taskId?: string; prompt: string; @@ -89,6 +107,13 @@ export interface ExpertTurnInput { * the caller is responsible for getting the user's confirmation first. */ grantedCaps?: ExpertCapability[]; + /** + * Absolute path the expert must write its result to via `save_output` + * (pre-resolved by the caller from output_file). When set, the expert is given + * the save_output tool and told to use it; the turn no longer returns its raw + * text as the file's contents. Undefined for inline (return-text) tasks. + */ + outputPath?: string; } /** One tool the expert invoked during a turn — surfaced to the UI for oversight. */ @@ -111,6 +136,9 @@ export type ExpertTurnResult = pageId: number | null; /** view_region/view_page calls the expert made this turn (in order). */ toolUses: ExpertToolUse[]; + /** True when an output_file was owed and the expert wrote it via save_output. + * Always false for inline tasks (no output_file). */ + wroteOutput: boolean; } | { ok: false; error: string; taskId?: string }; @@ -127,7 +155,7 @@ function isToolCall(c: { type: string }): c is ToolCall { */ export async function runExpertTurn( registry: ExpertRegistry, - sourceCtx: SourceContext, + collectionCtx: CollectionContext, pageExpertPrompt: string, extCtx: ExtensionContext, input: ExpertTurnInput, @@ -136,6 +164,15 @@ export async function runExpertTurn( return { ok: false, error: "bbox requires page_id." }; } + // Resolve the source up-front — it scopes both the attached page image and the + // expert's own view_page/view_region tools. A source is always given now. + let sourceDir: string; + try { + sourceDir = resolveSource(collectionCtx, input.source).path; + } catch (e) { + return { ok: false, taskId: input.taskId, error: (e as Error).message }; + } + // Resolve the session first so a follow-up can default to its model. let session: ExpertSession | undefined; let taskId = input.taskId; @@ -151,23 +188,32 @@ export async function runExpertTurn( } // Build the user message; attach a page image only when page_id is given. + // The expert's tools always reach the resolved source, image or not. const content: (TextContent | ImageContent)[] = []; let pageId: number | null = null; - let turnSourceDir: string | undefined; + const turnSourceDir: string = sourceDir; if (input.pageId !== undefined) { - const sourceDir = requireSource(sourceCtx); pageId = Math.round(input.pageId); try { content.push(await pageImageContent(sourceDir, pageId, input.bbox)); } catch (e) { return { ok: false, taskId, error: (e as Error).message }; } - turnSourceDir = sourceDir; - } else if (sourceCtx.sourceDir) { - // No image attached, but a source is active — let the expert's tools reach it. - turnSourceDir = sourceCtx.sourceDir; } - content.push({ type: "text", text: input.prompt }); + // When an output_file is owed, direct the expert to write via save_output. The + // directive is appended to the sent message only; the persisted turn keeps the + // clean `input.prompt` (below) so restored history isn't cluttered with it. + let promptText = input.prompt; + if (input.outputPath) { + const jsonHint = input.outputPath.toLowerCase().endsWith(".json") + ? " The output file is JSON: pass a single valid JSON value (no code fences, no prose)." + : ""; + promptText += + "\n\n[Output file] You MUST call save_output with your final result — the complete content " + + "to write to the output file. Your chat reply is for reasoning only and is NOT saved." + + jsonHint; + } + content.push({ type: "text", text: promptText }); // Default to the session's model on follow-up, else the orchestrator's current // model (whatever the user has selected/authed in pi) — no provider is baked in. @@ -203,12 +249,15 @@ export async function runExpertTurn( // consume images; bash/write/edit only for capabilities the orchestrator // granted (and the user approved upstream). const granted = new Set(input.grantedCaps ?? []); - const expertToolDefs = buildExpertTools({ + const outputMode = !!input.outputPath; + let expertToolDefs = buildExpertTools({ vision: resolved.model.input.includes("image"), granted: [...granted], + output: outputMode, }); let toolsEnabled = expertToolDefs.length > 0; let totalCost = 0; + let wroteOutput = false; let finalResponse; for (;;) { @@ -254,13 +303,26 @@ export async function runExpertTurn( currentPageId, cwd: extCtx.cwd, granted, + outputPath: input.outputPath, }); turnMessages.push(outcome.message); steps.push({ kind: "toolResult", toolResult: outcome.persist }); if (outcome.viewedPageId !== undefined) currentPageId = outcome.viewedPageId; + if (outcome.wroteOutput) wroteOutput = true; + } + // Spent the budget — stop the exploratory tools so the next completion must + // answer. When an output_file is still owed, keep ONLY save_output (within a + // small grace window) so the expert can still fulfill its contract instead of + // being stranded; the hard ceiling then cuts everything off so the loop ends. + if (toolCallCount >= HARD_TOOL_CALL_CEILING) { + toolsEnabled = false; + } else if (toolCallCount >= MAX_EXPERT_TOOL_CALLS) { + if (outputMode) { + expertToolDefs = outputOnlyTools(); + } else { + toolsEnabled = false; + } } - // Spent the budget — drop tools so the next completion must answer in text. - if (toolCallCount >= MAX_EXPERT_TOOL_CALLS) toolsEnabled = false; } session.messages.push(...turnMessages); @@ -301,6 +363,7 @@ export async function runExpertTurn( cost: totalCost > 0 ? totalCost : undefined, pageId, toolUses, + wroteOutput, }; } From 4baa9d90e0ff19620d389da8d5069a894ae0110f Mon Sep 17 00:00:00 2001 From: Lorenz Hufe Date: Mon, 6 Jul 2026 09:15:50 +0200 Subject: [PATCH 07/45] feat: retry policy for expert LLM calls (backoff, permanent-error classifier, abort-aware) --- chronos/scripts/retry-canary.mjs | 91 ++++++++++++++++++++++++++++++++ chronos/utils/expert-retry.ts | 84 +++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 chronos/scripts/retry-canary.mjs create mode 100644 chronos/utils/expert-retry.ts diff --git a/chronos/scripts/retry-canary.mjs b/chronos/scripts/retry-canary.mjs new file mode 100644 index 0000000..7f3fc3b --- /dev/null +++ b/chronos/scripts/retry-canary.mjs @@ -0,0 +1,91 @@ +// Canary for the expert LLM retry policy (utils/expert-retry.ts). Run from +// chronos/ after `npm run build`: node scripts/retry-canary.mjs +import { + backoffDelayMs, + completeWithRetry, + isPermanentExpertError, +} from "../dist/utils/expert-retry.js"; + +function assert(cond, msg) { + if (!cond) { + console.error("FAIL:", msg); + process.exit(1); + } +} +const tinyDelay = () => 5; + +// 1. Success on the first attempt: no retry. +{ + const { response, attempts } = await completeWithRetry( + async () => ({ stopReason: "stop" }), + { retries: 3, delayMs: tinyDelay }, + ); + assert(response.stopReason === "stop" && attempts === 1, "success needs one attempt"); +} + +// 2. Transient errors recover. +{ + let n = 0; + const { response, attempts } = await completeWithRetry( + async () => (++n < 3 ? { stopReason: "error", errorMessage: "Request timed out." } : { stopReason: "stop" }), + { retries: 3, delayMs: tinyDelay }, + ); + assert(response.stopReason === "stop" && attempts === 3, `transient recovers (attempts=${attempts})`); +} + +// 3. Permanent error: exactly one attempt. +{ + let n = 0; + const { response, attempts } = await completeWithRetry( + async () => { + n++; + return { stopReason: "error", errorMessage: "invalid x-api-key" }; + }, + { retries: 3, delayMs: tinyDelay }, + ); + assert(attempts === 1 && n === 1 && response.stopReason === "error", "permanent error not retried"); +} + +// 4. Retries exhausted: 1 + retries attempts, error returned. +{ + const { response, attempts } = await completeWithRetry( + async () => ({ stopReason: "error", errorMessage: "Overloaded" }), + { retries: 3, delayMs: tinyDelay }, + ); + assert(attempts === 4 && response.stopReason === "error", `exhausted after 4 attempts (got ${attempts})`); +} + +// 5. "aborted" responses are never retried. +{ + const { attempts } = await completeWithRetry( + async () => ({ stopReason: "aborted" }), + { retries: 3, delayMs: tinyDelay }, + ); + assert(attempts === 1, "aborted response returns immediately"); +} + +// 6. Abort during backoff cuts the wait short. +{ + const ctl = new AbortController(); + const start = Date.now(); + const pending = completeWithRetry( + async () => ({ stopReason: "error", errorMessage: "Request timed out." }), + { retries: 3, delayMs: () => 60_000 }, + ctl.signal, + ); + setTimeout(() => ctl.abort(), 20); + const { attempts } = await pending; + assert(Date.now() - start < 5_000, "abort resolves the backoff sleep promptly"); + assert(attempts === 1, "no further attempt after abort"); +} + +// 7. Classifier + backoff shape. +assert(isPermanentExpertError("401 Unauthorized"), "auth is permanent"); +assert(isPermanentExpertError("invalid_request_error: bad schema"), "validation is permanent"); +assert(!isPermanentExpertError("Request timed out."), "timeout is transient"); +assert(!isPermanentExpertError(undefined), "missing message is transient"); +assert(backoffDelayMs(0, () => 0.5) === 2000, "retry 0 midpoint 2s"); +assert(backoffDelayMs(1, () => 0.5) === 8000, "retry 1 midpoint 8s"); +assert(backoffDelayMs(9, () => 0.5) === 30000, "later retries capped at 30s"); + +console.log("retry canary OK"); diff --git a/chronos/utils/expert-retry.ts b/chronos/utils/expert-retry.ts new file mode 100644 index 0000000..03a7486 --- /dev/null +++ b/chronos/utils/expert-retry.ts @@ -0,0 +1,84 @@ +// Retry policy for expert LLM calls. +// See docs/superpowers/specs/2026-07-06-expert-retry-design.md. +// +// pi-ai's complete() reports failures as a *resolved* response with +// stopReason "error" (it does not throw), so the retry wraps that check. +// pi-ai's own maxRetries stays at its default 0 — this is the single retry +// layer; adding SDK-level retries on top would multiply attempts. + +/** Minimal shape of a pi-ai AssistantMessage that the retry loop inspects. */ +export interface CompleteLike { + stopReason: string; + errorMessage?: string; +} + +// Auth/validation failures fail identically on every attempt — skip retries +// for those. Everything else (timeouts, 429s, 5xx/529, connection resets, +// unclassifiable messages) is retried: a wasted bounded retry is cheaper than +// permanently losing a page to a transient error. +const PERMANENT_ERROR = /invalid|unauthorized|authentication|api key|permission|not.found|billing/i; + +export function isPermanentExpertError(message: string | undefined): boolean { + return message !== undefined && PERMANENT_ERROR.test(message); +} + +// ~2s / 8s / 30s, capped at 30s for later retries; ±25% jitter so a +// 50-expert batch doesn't retry in lockstep. +const BASE_DELAYS_MS = [2_000, 8_000, 30_000]; + +export function backoffDelayMs(retryIndex: number, random: () => number = Math.random): number { + const base = BASE_DELAYS_MS[Math.min(retryIndex, BASE_DELAYS_MS.length - 1)]; + return Math.round(base * (0.75 + random() * 0.5)); +} + +/** Sleep that resolves early (without throwing) when the signal aborts. */ +export function sleepWithAbort(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const timer = setTimeout(done, ms); + function done() { + clearTimeout(timer); + signal?.removeEventListener("abort", done); + resolve(); + } + signal?.addEventListener("abort", done); + }); +} + +export interface RetryResult { + response: T; + /** Total attempts made (1 = succeeded or failed without any retry). */ + attempts: number; +} + +/** + * Run `attempt` until it succeeds, fails permanently, is aborted, or retries + * are exhausted. `retries` counts *re*-attempts after the first try, so the + * loop makes at most `1 + retries` calls. The last response is returned + * as-is; the caller keeps its existing stopReason handling. + */ +export async function completeWithRetry( + attempt: () => Promise, + opts: { retries: number; delayMs?: (retryIndex: number) => number }, + signal?: AbortSignal, +): Promise> { + const delayMs = opts.delayMs ?? backoffDelayMs; + const maxAttempts = 1 + Math.max(0, opts.retries); + let response = await attempt(); + let attempts = 1; + while ( + attempts < maxAttempts && + response.stopReason === "error" && + !signal?.aborted && + !isPermanentExpertError(response.errorMessage) + ) { + await sleepWithAbort(delayMs(attempts - 1), signal); + if (signal?.aborted) break; + response = await attempt(); + attempts++; + } + return { response, attempts }; +} From cf6c29e6d3234b849bcaf31e15415d797375b1ec Mon Sep 17 00:00:00 2001 From: Lorenz Hufe Date: Mon, 6 Jul 2026 09:16:31 +0200 Subject: [PATCH 08/45] feat: expert LLM calls retry transient failures with 300s per-attempt timeout --- chronos/tools/expert-turn.ts | 37 +++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/chronos/tools/expert-turn.ts b/chronos/tools/expert-turn.ts index fb17635..bdb316e 100644 --- a/chronos/tools/expert-turn.ts +++ b/chronos/tools/expert-turn.ts @@ -17,6 +17,7 @@ import { resolveExpertModel } from "../utils/resolve-model.js"; import { cropImageToBuffer, downscaleToLimit, type Bbox } from "../utils/crop-image.js"; import { appendExpertTurn, type PersistedExpert, type PersistedStep } from "../utils/expert-store.js"; import { envInt } from "../utils/env-config.js"; +import { completeWithRetry } from "../utils/expert-retry.js"; import { buildExpertTools, executeExpertTool, @@ -40,6 +41,13 @@ const HARD_TOOL_CALL_CEILING = MAX_EXPERT_TOOL_CALLS + 3; // as CHRONOS_MAX_IMAGE_DIMENSION; 0 disables. view_region crops are cut from // the full-resolution file first, so expert zooming keeps full detail. const MAX_IMAGE_DIMENSION = envInt("CHRONOS_MAX_IMAGE_DIMENSION", 2576, 0, 100_000); +// Retry/timeout policy for expert LLM calls (`chronos.expertRetries` / +// `chronos.expertRequestTimeout` settings). The timeout bounds each attempt's +// HTTP request AND stream idleness (pi-ai forwards it to the provider SDK), +// so a stalled upload or dead stream can't hold a batch slot for the SDK's +// 10-minute default. 0 retries / 0 timeout restore the old behavior. +const EXPERT_RETRIES = envInt("CHRONOS_EXPERT_RETRIES", 3, 0, 10); +const EXPERT_TIMEOUT_S = envInt("CHRONOS_EXPERT_TIMEOUT", 300, 0, 3600); const CAP_DESCRIPTION: Record = { bash: "run shell commands", @@ -264,20 +272,31 @@ export async function runExpertTurn( if (input.signal?.aborted) { return { ok: false, taskId, error: "Expert turn aborted." }; } - const response = await complete( - resolved.model, - { - systemPrompt: pageExpertPrompt, - messages: [...session.messages, ...turnMessages], - tools: toolsEnabled ? expertToolDefs : undefined, - }, - { apiKey: resolved.apiKey, headers: resolved.headers, signal: input.signal }, + const { response, attempts } = await completeWithRetry( + () => + complete( + resolved.model, + { + systemPrompt: pageExpertPrompt, + messages: [...session.messages, ...turnMessages], + tools: toolsEnabled ? expertToolDefs : undefined, + }, + { + apiKey: resolved.apiKey, + headers: resolved.headers, + signal: input.signal, + ...(EXPERT_TIMEOUT_S > 0 ? { timeoutMs: EXPERT_TIMEOUT_S * 1000 } : {}), + }, + ), + { retries: EXPERT_RETRIES }, + input.signal, ); if (response.stopReason === "error") { + const attemptNote = attempts > 1 ? ` (after ${attempts} attempts)` : ""; return { ok: false, taskId, - error: `Expert model error (${modelSpec(resolved.model)}): ${response.errorMessage ?? "unknown error"}`, + error: `Expert model error (${modelSpec(resolved.model)}): ${response.errorMessage ?? "unknown error"}${attemptNote}`, }; } // A cancel that lands while complete() is in flight resolves with an From 03d716e4f9356bb0dce4df1febe38743bafb23ca Mon Sep 17 00:00:00 2001 From: Lorenz Hufe Date: Mon, 6 Jul 2026 09:19:13 +0200 Subject: [PATCH 09/45] feat: settings for image cap, expert retries, and request timeout --- chronos-vscode/package.json | 40 +++++++++++++++++++++++++++++++++ chronos-vscode/src/extension.ts | 16 ++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/chronos-vscode/package.json b/chronos-vscode/package.json index 448ee43..5f9ac4a 100644 --- a/chronos-vscode/package.json +++ b/chronos-vscode/package.json @@ -75,6 +75,46 @@ "default": "", "scope": "machine", "markdownDescription": "**Dev override.** npm package the setup flow installs for the `pi` CLI when pi isn't found. Leave empty for the default `@earendil-works/pi-coding-agent` (the maintained package). Set to override it — e.g. a fork or a version-pinned tag like `@earendil-works/pi-coding-agent@0.79.10`." + }, + "chronos.piAgentDir": { + "type": "string", + "default": "", + "scope": "machine", + "markdownDescription": "**Dev override.** Relocates pi's agent home (package registration, `auth.json`, `models.json`, sessions) away from the default `~/.pi/agent`. Accepts an absolute path or a `~/`-prefixed one (e.g. `~/.pi-release/agent`). Set this in a dedicated VS Code profile to test the marketplace build against an isolated agent home without disturbing your local-dev registration in `~/.pi`. The extension both reads this location and passes it to the pi subprocess as `PI_CODING_AGENT_DIR`. Leave empty to use `PI_CODING_AGENT_DIR` from the environment, or the default `~/.pi/agent`." + }, + "chronos.maxExpertToolCalls": { + "type": "integer", + "default": 100, + "minimum": 1, + "maximum": 1000, + "markdownDescription": "Maximum number of tool calls a single vision-expert subagent may make while working on one page before it is forced to answer. Higher values let experts self-zoom and explore more (better on dense or damaged pages) at higher cost and latency; lower values keep them terse and cheap. Applies to the `task` / `task_batch` experts. Default: 100." + }, + "chronos.maxConcurrency": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 250, + "markdownDescription": "Maximum number of expert subagents `task_batch` runs in parallel. This is both the default when the agent doesn't specify a concurrency and a hard ceiling on what it may request. Higher values finish batches faster but consume more concurrent provider quota (and may hit rate limits). Default: 20." + }, + "chronos.maxImageDimension": { + "type": "integer", + "default": 2576, + "minimum": 0, + "markdownDescription": "Maximum long-edge size in **pixels** for images sent to expert vision models (`task`/`task_batch` page context, `view_page`, `view_region`). Larger images are downscaled before upload — providers resize past their own caps anyway (Anthropic: 2576 px), so bigger uploads only waste bandwidth. Lower values shrink uploads roughly quadratically (1568 ≈ 4× smaller than a typical 3000 px scan) at the cost of full-page detail; experts can still zoom via `view_region`, which crops from the full-resolution file on disk. `0` sends originals untouched. Default: 2576." + }, + "chronos.expertRetries": { + "type": "integer", + "default": 3, + "minimum": 0, + "maximum": 10, + "markdownDescription": "How many times a failed expert LLM call is retried (exponential backoff ≈2 s/8 s/30 s with jitter) before the page is reported failed. Retries recover pages from transient provider/network failures — timeouts, rate limits, overloads; auth and validation errors are never retried. `0` disables retries. Default: 3." + }, + "chronos.expertRequestTimeout": { + "type": "integer", + "default": 300, + "minimum": 0, + "maximum": 3600, + "markdownDescription": "Per-attempt timeout in **seconds** for expert LLM calls. Bounds how long a stalled upload or an idle response stream can hold a `task_batch` concurrency slot. `0` uses the provider SDK default (typically 10 minutes). Default: 300." } } }, diff --git a/chronos-vscode/src/extension.ts b/chronos-vscode/src/extension.ts index 1590d3e..d2a2722 100644 --- a/chronos-vscode/src/extension.ts +++ b/chronos-vscode/src/extension.ts @@ -20,6 +20,7 @@ import { countPartialPages, findIncompleteImports, } from "./import-status"; +import { TRACE_ENTITY_SKILL } from "./workspace-templates"; // pi's npm package. @mariozechner/pi-coding-agent is deprecated and frozen at // 0.73.1 ("use @earendil-works/pi-coding-agent going forward"); the renamed @@ -276,7 +277,7 @@ function writeIfMissing(filePath: string, content: string): void { async function initWorkspace(folder: string): Promise { // Create directory structure - for (const dir of ["sources", "memory", "skills", "sessions", ".chronos", ".pi"]) { + for (const dir of ["sources", "memory", "skills", "sessions", "collections", ".chronos", ".pi"]) { mkdirSync(join(folder, dir), { recursive: true }); } @@ -290,6 +291,10 @@ async function initWorkspace(folder: string): Promise { // Memory files writeIfMissing(join(folder, "memory", "MEMORY.MD"), ""); + // Seed the long-horizon "trace an entity" skill (non-destructive — edits are kept). + mkdirSync(join(folder, "skills", "trace-entity"), { recursive: true }); + writeIfMissing(join(folder, "skills", "trace-entity", "SKILL.md"), TRACE_ENTITY_SKILL); + // README writeIfMissing(join(folder, "README.md"), `# Chronos Workspace @@ -301,6 +306,7 @@ This folder is a Chronos workspace for digitizing historical documents. \`\`\` sources/ Place your source directories here (each with a png/ subfolder) data/ Per-source extraction results and outputs +collections/ Named collection manifests (.json grouping member sources) memory/ Agent memory files (MEMORY.MD, per-source notes) skills/ Skill definitions (markdown task instructions) sessions/ Agent session logs (auto-generated) @@ -743,8 +749,16 @@ export function activate(context: vscode.ExtensionContext): { // Bind the HTTP server (lazily) so the port is assigned before the agent starts. await httpServer.start(); + // User-tunable agent limits (contributed settings) are forwarded to the + // pi-package as env vars; it parses them defensively (see utils/env-config.ts). + const chronosCfg = vscode.workspace.getConfiguration("chronos"); const agentEnv = { CHRONOS_HTTP_PORT: String(httpServer.port), + CHRONOS_MAX_EXPERT_TOOL_CALLS: String(chronosCfg.get("maxExpertToolCalls", 100)), + CHRONOS_MAX_CONCURRENCY: String(chronosCfg.get("maxConcurrency", 20)), + CHRONOS_MAX_IMAGE_DIMENSION: String(chronosCfg.get("maxImageDimension", 2576)), + CHRONOS_EXPERT_RETRIES: String(chronosCfg.get("expertRetries", 3)), + CHRONOS_EXPERT_TIMEOUT: String(chronosCfg.get("expertRequestTimeout", 300)), // pi >= 0.7x dropped the session_directory extension hook; this env var // keeps session transcripts inside the workspace in both UI modes. PI_CODING_AGENT_SESSION_DIR: join(workspaceFolder, "sessions"), From e9b5c35b0fd760ebea671012ea4604bb53a2feb6 Mon Sep 17 00:00:00 2001 From: Lorenz Hufe Date: Thu, 16 Jul 2026 09:41:22 +0200 Subject: [PATCH 10/45] docs: spec for task subagent arbitrary image / plain-task modes --- .../2026-07-16-task-image-or-prompt-design.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-task-image-or-prompt-design.md diff --git a/docs/superpowers/specs/2026-07-16-task-image-or-prompt-design.md b/docs/superpowers/specs/2026-07-16-task-image-or-prompt-design.md new file mode 100644 index 0000000..8198743 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-task-image-or-prompt-design.md @@ -0,0 +1,160 @@ +# Task subagent: arbitrary image or plain task + +**Date:** 2026-07-16 +**Status:** Design — awaiting review + +## Problem + +The `task` / `task_batch` expert subagents are hard-wired to a *source*: `source` +is a required parameter, and the only way to give an expert an image is +`page_id` (a page **within that source**), optionally cropped by `bbox`. There is +no way to: + +- Hand an expert an **arbitrary image** (a screenshot, a diagram, a picture that + isn't a cataloged source page), or +- Run an expert on **just a prompt** without naming a valid `source`. + +Text-only turns already work internally (omit `page_id`), but a valid `source` +ref is still mandatory, and the expert's `view_page` / `view_region` / +`save_output` are all scoped to that source's directory. + +## Goal + +Let the `task` subagent be driven three ways: + +1. **Image** — attach an arbitrary image file by path. +2. **Plain task** — a prompt with no source and no image. +3. **Source + page** — today's behavior, unchanged. + +And extend `task_batch` to iterate over either page ids (as today) **or** a list +of arbitrary image paths, with `source` optional. + +## Approach + +Add an explicit `image` parameter (a file path) and make `source` optional, +rather than overloading `page_id` to also accept path strings (messy typing) or +introducing a separate tool (duplicate surface). The expert-tools execution +layer already accepts `sourceDir: string | undefined` and fails +`view_page`/`view_region` gracefully when no source is present, so the change is +concentrated in `runExpertTurn` and the two tool wrappers. + +## Design + +### `task` (single) — `chronos/tools/view-page.ts` + +Parameter changes to `taskParams`: + +- `source`: **optional** (was required). Description: required only when using + `page_id`, or when the expert should have source-scoped `view_page` / + `view_region`. +- `image`: **new**, optional `string`. Path to an image file to attach. + Resolved **inside the workspace only** (see Image path resolution). Mutually + exclusive with `page_id`. +- `page_id`: unchanged, optional — but now **requires `source`**. +- `bbox`: unchanged — requires `page_id`. +- `output_file`: with `source`, resolves in the source's data dir (today's + behavior). Without `source`, treated as a **workspace-relative** path (kept + inside the workspace). + +Validation (in the tool, returning a clear error and running nothing): + +- `image` and `page_id` are mutually exclusive. +- `page_id` (and therefore `bbox`) requires `source`. +- A missing / undecodable `image` file **fails the task** with a clear error. + +Three usable modes fall out: image-only, text-only (no source), source+page. + +### `task_batch` — `chronos/tools/task-batch.ts` + +The batch unit generalizes from "page" to "item": + +- `source`: **optional** (was required). +- `page_ids`: **optional** (was required); requires `source`. +- `images`: **new**, optional `string[]`; arbitrary image paths, source optional. +- **Exactly one** of `page_ids` / `images` must be provided. A sourceless, + imageless batch (N identical experts) is rejected. +- `output_file` template placeholders: + - page batch: `{page_id}` (today), zero-padded. + - image batch: `{index}` (1-based, zero-padded) and/or `{name}` (image + basename without extension); at least one must be present. + - Output base dir: page batch resolves the template in the source's data dir + (today). Image batch (no source) resolves the rendered filename as a + **workspace-relative** path, matching sourceless single-`task` output. + +Internal item model: each item carries a stable `key` and a display `label` +(the page id, or the image basename) instead of assuming a numeric `page_id`. +`LiveExpertEntry` / `ExpertEntry` gain `key: string` + `label: string`; the +existing `page_id` field is retained only on page-batch entries so the UI page +chips keep working. Sorting/reporting key off `key`/`label`. + +### Shared internals — `chronos/tools/expert-turn.ts` + +- `ExpertTurnInput.source`: `string | undefined`. +- New `ExpertTurnInput.imagePath?: string`. +- Resolve `sourceDir` only when `source` is given; otherwise `undefined` and + pass it through to `executeExpertTool` (already supported). +- Build the attached image from **exactly one** source: `imagePath` → + `imageContentFromPath()`; else `pageId` → `pageImageContent()` (requires + `sourceDir`). +- `buildExpertTools` gains a `hasSource: boolean` flag: `view_page` / + `view_region` are offered only when `vision && hasSource`. `read_file` / + `list_dir` / `grep` (workspace-scoped) and `save_output` are unaffected. +- The turn's `pageId` in the result is `null` for image / text-only turns; the + `[view p.N]` citation link is emitted only for page turns (image turns have no + source page to link to). + +### Image loading — `chronos/utils/crop-image.ts` + +New helper: + +``` +imageContentFromPath(path: string, maxDim: number): Promise +``` + +Read the file, normalize to PNG via `sharp`, downscale so the long edge ≤ `maxDim` +(0 = disabled), return `{ type: "image", data: , mimeType: "image/png" }`. +Always re-encodes to PNG so any `sharp`-readable format is accepted. Throws on a +missing / undecodable file (callers turn that into the task-level error). + +### Image path resolution + +Inside-workspace only: reject paths that escape the workspace root (`..` or an +absolute path outside it). Unlike `read_file`'s `resolveInWorkspace`, do **not** +apply the restricted-dir filter (`.`-dirs, `png/`, `dist/`, …): page images +legitimately live under `png/`, and the anti-secret-leak rationale for that +filter doesn't apply here because the target must decode as an image (a `.env` +won't). A small dedicated resolver in `expert-tools.ts` (or `crop-image.ts`) +handles this. + +### Persistence — `chronos/utils/expert-store.ts` + `restoreExpertSessions` + +- `PersistedTurn` gains `imagePath?: string`. +- `appendExpertTurn` call in `runExpertTurn` records `imagePath` when present. +- `restoreExpertSessions` rehydrates an image turn by re-reading `imagePath` + from disk via `imageContentFromPath()` (parallel to the existing page rehydrate + branch); if the file is gone, restore the turn text-only, matching the page + fallback. + +## Prompt / description updates + +- `chronos/prompts/task.md`, `chronos/prompts/task-batch.md`: document `image` / + `images`, the optional `source`, mutual exclusivity, and the output_file + placeholder rules. +- Parameter `description` strings in `view-page.ts` / `task-batch.ts` updated to + match. + +## Out of scope + +- Zooming into an arbitrary `image` (no `view_region` on non-source images) — the + image is sent as provided. +- Combining an arbitrary `image` with `page_id` in one turn (two images) — kept + mutually exclusive for a clean model. +- A fully text-only `task_batch` (no differentiator per item). + +## Testing + +- Typecheck: `cd chronos && npm run build`. +- Manual smoke (per `chronos-vscode/TESTING.md`): (a) `task` with `image` on a + workspace file; (b) `task` with prompt only, no source; (c) `task` source+page + still works; (d) `task_batch` with `images`; (e) restart session and follow up + on an image task via `task_id` (rehydration). From 2590722f99aaae2382bd3387c427ed51514469ab Mon Sep 17 00:00:00 2001 From: Lorenz Hufe Date: Thu, 16 Jul 2026 09:57:47 +0200 Subject: [PATCH 11/45] docs: implementation plan for task image / plain-task modes --- .../plans/2026-07-16-task-image-or-prompt.md | 944 ++++++++++++++++++ 1 file changed, 944 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-task-image-or-prompt.md diff --git a/docs/superpowers/plans/2026-07-16-task-image-or-prompt.md b/docs/superpowers/plans/2026-07-16-task-image-or-prompt.md new file mode 100644 index 0000000..0534910 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-task-image-or-prompt.md @@ -0,0 +1,944 @@ +# Task subagent: arbitrary image or plain task — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the `task` / `task_batch` expert subagents run with an arbitrary image file, with just a prompt (no source), or with a source page as today. + +**Architecture:** Add an explicit `image` (path) parameter and make `source` optional. Changes concentrate in `runExpertTurn` and the two tool wrappers; the expert-tools execution layer already tolerates a missing source. A new image loader normalizes any `sharp`-readable file to a downscaled PNG. Per-turn persistence records the image path so image tasks rehydrate across restarts. + +**Tech Stack:** TypeScript (Node ESM), `@earendil-works/pi-{ai,coding-agent}`, `@sinclair/typebox` (tool param schemas), `sharp` (image decode/resize), Lit (webview). + +## Global Constraints + +- **pi loads the agent from `dist/`, not `src`.** After editing anything under `chronos/{tools,utils,http,extensions}/*.ts`, run `cd chronos && npm run build` (= `tsc`) or the change has no effect and does not typecheck. Prompts (`chronos/prompts/*.md`) are read live — no rebuild needed. +- **Webview + host typecheck separately** (esbuild does not typecheck): + - `cd chronos-vscode && npx tsc --noEmit -p tsconfig.json` (host) + - `cd chronos-vscode && npx tsc --noEmit -p webview/tsconfig.json` (webview) +- **No unit-test runner in this repo.** Pure helpers are verified with a throwaway node script run against built `dist/`; wiring changes are verified by `tsc`; end-to-end by the manual checklist in `chronos-vscode/TESTING.md`. +- **Commit authorship:** commits are authored as `Lorenz Hufe `. Do NOT add any `Co-Authored-By: Claude` trailer. +- **Image long-edge cap:** every expert image is downscaled to `CHRONOS_MAX_IMAGE_DIMENSION` (`MAX_IMAGE_DIMENSION` const in `expert-turn.ts`, default 2576px, 0 = disabled). Arbitrary images obey the same cap. +- **Image path scope:** an `image` path resolves **inside the workspace root only** (reject `..`/absolute-outside escapes). Do NOT apply `read_file`'s restricted-dir filter — page images live under `png/` (a restricted dir), and the target must decode as an image anyway. + +--- + +## File Structure + +- `chronos/utils/crop-image.ts` — **modify.** Add `loadImageAsPng(imgPath, maxDim)`: read any file, normalize to a downscaled PNG buffer. Pure (only depends on `sharp` + `node:fs`). +- `chronos/tools/expert-tools.ts` — **modify.** Add `hasSource` to `buildExpertTools` (gate `view_page`/`view_region`). Add exported `resolveImagePath(workspaceRoot, p)` (inside-workspace resolver, no restricted-dir filter). +- `chronos/tools/expert-turn.ts` — **modify.** Add `imageFileContent(imgPath)` (mirror of `pageImageContent`). `ExpertTurnInput.source` → optional; new `imagePath?`. Resolve `sourceDir` only when source given; build attached image from `imagePath` else `pageId`; pass `hasSource` to `buildExpertTools`; suppress the `[view p.N]` link for non-page turns; persist/rehydrate `imagePath`. +- `chronos/utils/expert-store.ts` — **modify.** `PersistedTurn` gains `imagePath?: string`. +- `chronos/tools/view-page.ts` — **modify.** `task` tool: `source` optional, new `image` param, validation, sourceless `output_file` base, updated descriptions. +- `chronos/tools/task-batch.ts` — **modify.** `source` optional; `page_ids` optional; new `images` param; item model with `key`/`label`; `{index}`/`{name}` placeholders; sourceless output base. +- `chronos/prompts/task.md`, `chronos/prompts/task-batch.md` — **modify.** Document the new modes. +- `chronos-vscode/webview/components/chronos-chat.ts` — **modify.** `BatchExpertEntry` gains `label?`/`key?`, `page_id` optional; chip renders `label` with `p. N` fallback. + +--- + +## Task 1: `loadImageAsPng` image loader + +**Files:** +- Modify: `chronos/utils/crop-image.ts` +- Test (throwaway): `/tmp/claude-1342064982/-home-hufe-Documents-code-chronos/58d0c1ec-f28a-40f6-9384-e4b44dda70ef/scratchpad/test-load-image.mjs` + +**Interfaces:** +- Produces: `export async function loadImageAsPng(imgPath: string, maxDim: number): Promise` — returns a PNG-encoded buffer whose long edge is ≤ `maxDim` (0 disables the cap). Throws `Error("Image not found: ")` if the file is missing and lets `sharp` errors propagate for undecodable files. + +- [ ] **Step 1: Write the failing test** + +Create the throwaway test at the scratchpad path above: + +```js +import { strict as assert } from "node:assert"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import sharp from "sharp"; +import { loadImageAsPng } from "/home/hufe/Documents/code/chronos/chronos/dist/utils/crop-image.js"; + +const dir = mkdtempSync(join(tmpdir(), "loadimg-")); + +// A 100x40 JPEG — non-PNG input to prove format normalization. +const jpgPath = join(dir, "in.jpg"); +writeFileSync(jpgPath, await sharp({ create: { width: 100, height: 40, channels: 3, background: "#888" } }).jpeg().toBuffer()); + +// maxDim below the long edge → downscaled AND re-encoded to PNG. +const capped = await loadImageAsPng(jpgPath, 50); +const capMeta = await sharp(capped).metadata(); +assert.equal(capMeta.format, "png", "output must be PNG"); +assert.equal(capMeta.width, 50, "long edge downscaled to maxDim"); +assert.equal(capMeta.height, 20, "aspect ratio preserved"); + +// maxDim=0 disables the cap but still yields PNG. +const full = await loadImageAsPng(jpgPath, 0); +const fullMeta = await sharp(full).metadata(); +assert.equal(fullMeta.format, "png"); +assert.equal(fullMeta.width, 100, "no resize when maxDim=0"); + +// Missing file → clear error. +await assert.rejects(() => loadImageAsPng(join(dir, "nope.png"), 0), /Image not found/); + +console.log("OK"); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /home/hufe/Documents/code/chronos/chronos && npm run build && \ +node /tmp/claude-1342064982/-home-hufe-Documents-code-chronos/58d0c1ec-f28a-40f6-9384-e4b44dda70ef/scratchpad/test-load-image.mjs +``` +Expected: FAIL — build errors with `loadImageAsPng` not exported / import throws `SyntaxError: does not provide an export named 'loadImageAsPng'`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `chronos/utils/crop-image.ts` (after `downscaleToLimit`). Add `existsSync` to the `node:fs` import — currently the file imports only `sharp`, so add the fs import at the top: + +```ts +import { existsSync, readFileSync } from "node:fs"; +``` + +```ts +/** + * Load an arbitrary image file and return it as a PNG buffer whose long edge is + * at most `maxDim` px (0 = no cap). Always re-encodes to PNG, so any + * sharp-readable format is accepted. Throws a clear error for a missing file; + * sharp's own error propagates for an undecodable one. + */ +export async function loadImageAsPng(imgPath: string, maxDim: number): Promise { + if (!existsSync(imgPath)) throw new Error(`Image not found: ${imgPath}`); + let img = sharp(readFileSync(imgPath)); + if (maxDim > 0) { + img = img.resize({ width: maxDim, height: maxDim, fit: "inside", withoutEnlargement: true }); + } + return img.png().toBuffer(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd /home/hufe/Documents/code/chronos/chronos && npm run build && \ +node /tmp/claude-1342064982/-home-hufe-Documents-code-chronos/58d0c1ec-f28a-40f6-9384-e4b44dda70ef/scratchpad/test-load-image.mjs +``` +Expected: `OK` + +- [ ] **Step 5: Commit** + +```bash +cd /home/hufe/Documents/code/chronos +git add chronos/utils/crop-image.ts +git commit -m "feat: loadImageAsPng normalizes any image to a downscaled PNG" +``` +(`chronos/dist/` is gitignored — never stage it; only source `.ts` is committed. This holds for every task below.) + +--- + +## Task 2: `runExpertTurn` — optional source, arbitrary image, persistence + +**Files:** +- Modify: `chronos/tools/expert-tools.ts` (add `hasSource` to `buildExpertTools`; add `resolveImagePath`) +- Modify: `chronos/tools/expert-turn.ts` (add `imageFileContent`; input changes; wiring; persistence; restore) +- Modify: `chronos/utils/expert-store.ts` (`PersistedTurn.imagePath`) + +**Interfaces:** +- Consumes: `loadImageAsPng(imgPath, maxDim)` (Task 1). +- Produces: + - `buildExpertTools(opts: { vision: boolean; hasSource: boolean; granted: ExpertCapability[]; output: boolean }): Tool[]` — `view_page`/`view_region` offered only when `vision && hasSource`. + - `resolveImagePath(workspaceRoot: string, p: string): string` — absolute path inside the workspace; throws `Error("Image path is outside the workspace.")` on escape. + - `imageFileContent(imgPath: string): Promise` (in `expert-turn.ts`). + - `ExpertTurnInput`: `source?: string`; new `imagePath?: string`. `ExpertTurnResult.pageId` is `null` for image/text-only turns. + - `PersistedTurn.imagePath?: string`. + +- [ ] **Step 1: Add `hasSource` to `buildExpertTools` and the `resolveImagePath` resolver** + +In `chronos/tools/expert-tools.ts`, change the signature and body of `buildExpertTools`: + +```ts +export function buildExpertTools(opts: { + vision: boolean; + hasSource: boolean; + granted: ExpertCapability[]; + output: boolean; +}): Tool[] { + const tools: Tool[] = []; + if (opts.vision && opts.hasSource) tools.push(VIEW_REGION_TOOL, VIEW_PAGE_TOOL); + tools.push(READ_FILE_TOOL, LIST_DIR_TOOL, GREP_TOOL); + for (const cap of opts.granted) { + const tool = ELEVATED_TOOLS[cap]; + if (tool && !tools.includes(tool)) tools.push(tool); + } + if (opts.output) tools.push(SAVE_OUTPUT_TOOL); + return tools; +} +``` + +Add this exported resolver near `resolveInWorkspace` (reuse the existing `resolve`, `relative`, `isAbsolute` imports at the top of the file): + +```ts +/** + * Resolve an `image` path for a task/task_batch call. Like resolveInWorkspace it + * keeps the target inside the workspace, but deliberately does NOT apply the + * restricted-dir filter: page images legitimately live under png/, and the + * anti-secret-leak rationale doesn't apply here because the target must decode + * as an image (a .env won't). workspaceRoot is the pi cwd / workspace dir. + */ +export function resolveImagePath(workspaceRoot: string, p: string): string { + const abs = resolve(workspaceRoot, p); + const rel = relative(workspaceRoot, abs); + if (rel !== "" && (rel.startsWith("..") || isAbsolute(rel))) { + throw new Error("Image path is outside the workspace."); + } + return abs; +} +``` + +- [ ] **Step 2: Add `imageFileContent` and update `PersistedTurn`** + +In `chronos/tools/expert-turn.ts`, import the loader (extend the existing `crop-image.js` import): + +```ts +import { cropImageToBuffer, downscaleToLimit, loadImageAsPng, type Bbox } from "../utils/crop-image.js"; +``` + +Add, right after `pageImageContent`: + +```ts +/** + * Build the image content block for an arbitrary image file (not a source page), + * normalized to a downscaled PNG. Mirrors `pageImageContent`; shared by live + * turns and session restore so an image task rehydrates from disk. + */ +export async function imageFileContent(imgPath: string): Promise { + const png = await loadImageAsPng(imgPath, MAX_IMAGE_DIMENSION); + return { type: "image", data: png.toString("base64"), mimeType: "image/png" }; +} +``` + +In `chronos/utils/expert-store.ts`, add to `PersistedTurn` (after `sourceDir`): + +```ts + /** Absolute path of an arbitrary attached image (not a source page) — rehydrated from disk. */ + imagePath?: string; +``` + +- [ ] **Step 3: Make `source` optional and wire the image in `ExpertTurnInput` + `runExpertTurn`** + +In `chronos/tools/expert-turn.ts`, update `ExpertTurnInput`: + +```ts +export interface ExpertTurnInput { + /** Collection member ref the expert works on. Optional now — omit for a + * sourceless task (no source-scoped view/save tools). */ + source?: string; + /** Continue an existing session; omit to spawn a new one. */ + taskId?: string; + prompt: string; + model?: string; + /** Attach this source page's image. Requires `source`. */ + pageId?: number; + bbox?: Bbox; + /** Attach an arbitrary image by absolute path (pre-resolved by the caller). + * Mutually exclusive with pageId. */ + imagePath?: string; + signal?: AbortSignal; + grantedCaps?: ExpertCapability[]; + outputPath?: string; + onProgress?: (progress: ExpertProgress) => void; +} +``` + +Replace the source-resolution block near the top of `runExpertTurn` (currently unconditional) so a missing source is allowed unless a page needs it: + +```ts + if (input.bbox && input.pageId === undefined) { + return { ok: false, error: "bbox requires page_id." }; + } + if (input.pageId !== undefined && !input.source) { + return { ok: false, error: "page_id requires a source." }; + } + + // Resolve the source up-front only when one is given. It scopes the attached + // page image and the expert's own view_page/view_region tools. + let sourceDir: string | undefined; + if (input.source) { + try { + sourceDir = resolveSource(collectionCtx, input.source).path; + } catch (e) { + return { ok: false, taskId: input.taskId, error: (e as Error).message }; + } + } +``` + +Change the attached-image build block (the `if (input.pageId !== undefined) { … }` around line 236) to prefer `imagePath`: + +```ts + const content: (TextContent | ImageContent)[] = []; + let pageId: number | null = null; + if (input.imagePath) { + try { + content.push(await imageFileContent(input.imagePath)); + } catch (e) { + return { ok: false, taskId, error: (e as Error).message }; + } + } else if (input.pageId !== undefined && sourceDir) { + pageId = Math.round(input.pageId); + try { + content.push(await pageImageContent(sourceDir, pageId, input.bbox)); + } catch (e) { + return { ok: false, taskId, error: (e as Error).message }; + } + } +``` + +Remove the old `const turnSourceDir: string = sourceDir;` line and replace later uses of `turnSourceDir` with `sourceDir` (now `string | undefined`). The two references are: `executeExpertTool({ sourceDir: turnSourceDir, … })` and the `appendExpertTurn({ … sourceDir: turnSourceDir })` call — both accept `string | undefined` already (`ExpertToolContext.sourceDir` and `PersistedTurn.sourceDir` are optional). + +Update the `buildExpertTools` call to pass `hasSource`: + +```ts + let expertToolDefs = buildExpertTools({ + vision: resolved.model.input.includes("image"), + hasSource: !!sourceDir, + granted: [...granted], + output: outputMode, + }); +``` + +Update the model-resolution `hasImage` argument (4th arg of `resolveExpertModel`) so an arbitrary image also demands a vision model: + +```ts + const resolved = await resolveExpertModel(input.model, extCtx.modelRegistry, fallback, pageId !== null || !!input.imagePath); +``` + +- [ ] **Step 4: Persist and restore the image path** + +In `runExpertTurn`, add `imagePath` to the `appendExpertTurn` turn payload (alongside `pageId`/`bbox`/`sourceDir`): + +```ts + prompt: input.prompt, + pageId: pageId ?? undefined, + bbox: input.bbox, + imagePath: input.imagePath, + sourceDir, +``` + +In `restoreExpertSessions`, extend the per-turn content rebuild so an image turn rehydrates (place before the existing page branch, since they are mutually exclusive): + +```ts + const content: (TextContent | ImageContent)[] = []; + if (turn.imagePath) { + try { + content.push(await imageFileContent(turn.imagePath)); + } catch { + // image file no longer on disk — restore this turn text-only + } + } else if (turn.pageId !== undefined && turn.sourceDir) { + try { + content.push(await pageImageContent(turn.sourceDir, turn.pageId, turn.bbox)); + } catch { + // page/source no longer on disk — restore this turn text-only + } + } +``` + +- [ ] **Step 5: Typecheck** + +```bash +cd /home/hufe/Documents/code/chronos/chronos && npm run build +``` +Expected: no errors. (If `tsc` flags an unused `downscaleToLimit`/`cropImageToBuffer`, leave them — they are still used by `pageImageContent`.) + +- [ ] **Step 6: Smoke-test the source-optional/image wiring** + +Extend the Task 1 smoke script (or a new one) to exercise `buildExpertTools` gating and `resolveImagePath`, run against `dist/`: + +```js +import { strict as assert } from "node:assert"; +import { buildExpertTools, resolveImagePath } from "/home/hufe/Documents/code/chronos/chronos/dist/tools/expert-tools.js"; + +const names = (o) => buildExpertTools(o).map((t) => t.name); + +// No source → no view_page/view_region even with vision. +const sourceless = names({ vision: true, hasSource: false, granted: [], output: false }); +assert.ok(!sourceless.includes("view_page") && !sourceless.includes("view_region"), "sourceless has no view tools"); +assert.ok(sourceless.includes("read_file"), "read_file always present"); + +// Source + vision → view tools present. +const withSource = names({ vision: true, hasSource: true, granted: [], output: false }); +assert.ok(withSource.includes("view_page") && withSource.includes("view_region"), "sourced has view tools"); + +// Path resolver: inside ok, escape throws. +assert.equal(resolveImagePath("/ws", "sources/x/png/page_0001.png"), "/ws/sources/x/png/page_0001.png"); +assert.throws(() => resolveImagePath("/ws", "../secret.png"), /outside the workspace/); + +console.log("OK"); +``` + +```bash +node /tmp/claude-1342064982/-home-hufe-Documents-code-chronos/58d0c1ec-f28a-40f6-9384-e4b44dda70ef/scratchpad/test-expert-tools.mjs +``` +Expected: `OK` + +- [ ] **Step 7: Commit** + +```bash +cd /home/hufe/Documents/code/chronos +git add chronos/tools/expert-tools.ts chronos/tools/expert-turn.ts chronos/utils/expert-store.ts +git commit -m "feat: runExpertTurn supports optional source and arbitrary image" +``` + +--- + +## Task 3: `task` tool — `image` param, optional source, validation + +**Files:** +- Modify: `chronos/tools/view-page.ts` +- Modify: `chronos/prompts/task.md` + +**Interfaces:** +- Consumes: `runExpertTurn` (`ExpertTurnInput.source?`, `imagePath?`) and `resolveImagePath(workspaceRoot, p)` (Task 2). +- Produces: the `task` tool now accepting `image`, optional `source`. + +- [ ] **Step 1: Update `taskParams`** + +In `chronos/tools/view-page.ts`, make `source` optional and add `image`. Import `resolveImagePath`: + +```ts +import { resolveImagePath, type ExpertCapability } from "./expert-tools.js"; +``` + +Change the `source` field and add `image` after it: + +```ts + source: Type.Optional( + Type.String({ + description: + "Collection member ref the expert works on (see the catalog in the system prompt). " + + "Optional: required only when you pass page_id, or when the expert should have source-scoped " + + "view_page/view_region. Omit for a task on an arbitrary `image` or a plain (text-only) task.", + }), + ), + image: Type.Optional( + Type.String({ + description: + "Attach an arbitrary image file by path (workspace-relative, inside the workspace). Use this for " + + "a picture that is not a cataloged source page. Mutually exclusive with page_id. Any common image " + + "format is accepted (normalized to PNG, downscaled to the image cap). A missing/undecodable file " + + "fails the task.", + }), + ), +``` + +- [ ] **Step 2: Add validation + image resolution in `execute`** + +In `execute`, after the `grant` confirmation block and before the `output_file` resolution, add: + +```ts + if (params.image && params.page_id !== undefined) { + return { + content: [{ type: "text", text: "`image` and `page_id` are mutually exclusive — pass only one." }], + details: {}, + }; + } + if (params.page_id !== undefined && !params.source) { + return { + content: [{ type: "text", text: "`page_id` requires a `source`." }], + details: {}, + }; + } + let imagePath: string | undefined; + if (params.image) { + try { + imagePath = resolveImagePath(collectionCtx.workspaceDir, params.image); + } catch (e) { + return { content: [{ type: "text", text: (e as Error).message }], details: {} }; + } + } +``` + +- [ ] **Step 3: Resolve `output_file` with/without source** + +Replace the existing `output_file` resolution block so a sourceless task writes workspace-relative: + +```ts + let outputPath: string | undefined; + if (params.output_file) { + if (params.source) { + try { + outputPath = join(requireSourceDataDir(collectionCtx, params.source), params.output_file); + } catch { + outputPath = undefined; + } + } else { + // Sourceless: treat output_file as a workspace-relative path. + try { + outputPath = resolveImagePath(collectionCtx.workspaceDir, params.output_file); + } catch { + outputPath = undefined; + } + } + } +``` + +(Reusing `resolveImagePath` here is deliberate: it is a generic "resolve inside the workspace" check — not image-specific — so the sourceless output file is kept inside the workspace by the same rule.) + +- [ ] **Step 4: Pass `imagePath` into `runExpertTurn`** + +In the `runExpertTurn(...)` call, add `imagePath` and note `source`/`page_id` may be undefined (already the case): + +```ts + const result = await runExpertTurn(registry, collectionCtx, pageExpertPrompt, extCtx, { + source: params.source, + taskId: params.task_id, + prompt: params.prompt, + model: params.model, + pageId: params.page_id, + bbox: params.bbox, + imagePath, + signal, + grantedCaps: grant, + outputPath, + onProgress: /* unchanged */ onUpdate ? (p) => onUpdate({ /* unchanged */ +``` + +(Leave the `onProgress` body unchanged.) + +- [ ] **Step 5: Guard `sourceRel` for the sourceless case** + +The success-path `sourceRel` block calls `resolveSource(collectionCtx, params.source)`. With `source` now optional, guard it so a sourceless task doesn't throw: + +```ts + let sourceRel = ""; + if (params.source) { + try { + sourceRel = relative(collectionCtx.workspaceDir, resolveSource(collectionCtx, params.source).path); + } catch { + sourceRel = ""; + } + } +``` + +(`pageId === null` for image/text-only turns already suppresses the `[view p.N]` link, so no further change there.) + +- [ ] **Step 6: Typecheck** + +```bash +cd /home/hufe/Documents/code/chronos/chronos && npm run build +``` +Expected: no errors. + +- [ ] **Step 7: Update `task.md` description** + +In `chronos/prompts/task.md`, revise the opening so it documents the three modes. Replace the first two sentences with: + +``` +Talk to an expert model in a persistent conversation. Give it work in one of three ways: (1) pass `source` + `page_id` to attach a cataloged source page (scopes the expert's view_page/view_region to that source); (2) pass `image` to attach an arbitrary image file by path (workspace-relative), for a picture that is not a source page — mutually exclusive with page_id; (3) pass neither for a plain text-only task. `source` is optional and only needed for mode (1) or to give the expert source-scoped view tools. bbox requires page_id. If `output_file` is set without a source, it is written at that workspace-relative path; with a source it is written in the source's data dir (as before). +``` + +Leave the rest of the file (task_id, model, output_file, chronos_page guidance) intact. + +- [ ] **Step 8: Commit** + +```bash +cd /home/hufe/Documents/code/chronos +git add chronos/tools/view-page.ts chronos/prompts/task.md +git commit -m "feat: task tool accepts arbitrary image and optional source" +``` + +--- + +## Task 4: `task_batch` — image list, optional source, item model + +**Files:** +- Modify: `chronos/tools/task-batch.ts` +- Modify: `chronos/prompts/task-batch.md` + +**Interfaces:** +- Consumes: `runExpertTurn` (`imagePath?`, `source?`), `resolveImagePath` (Task 2). +- Produces: `ExpertEntry` / `LiveExpertEntry` gain `key: string` and `label: string`; `page_id` becomes optional (present only on page batches). The tool result `details.experts` entries carry `key`/`label`/optional `page_id`. + +- [ ] **Step 1: Update `taskBatchParams`** + +In `chronos/tools/task-batch.ts`, make `source`/`page_ids` optional and add `images`. Import `resolveImagePath`: + +```ts +import { resolveImagePath, type ExpertCapability } from "./expert-tools.js"; +``` + +```ts + source: Type.Optional( + Type.String({ + description: + "Collection member ref every expert in this batch works on. Required when using page_ids; " + + "optional (and unused) when using images.", + }), + ), + page_ids: Type.Optional( + Type.Array(Type.Number(), { + description: + "Spawn one expert per page id (file-system indices, not printed page numbers). Requires `source`. " + + "Provide EITHER page_ids OR images, not both.", + }), + ), + images: Type.Optional( + Type.Array(Type.String(), { + description: + "Spawn one expert per arbitrary image file path (workspace-relative). Source-independent. " + + "Provide EITHER page_ids OR images, not both.", + }), + ), +``` + +Update the `output_file` description to cover both placeholder schemes: + +```ts + output_file: Type.Optional( + Type.String({ + description: + "Filename template. For a page batch use {page_id} (zero-padded), written in the source data dir. " + + "For an image batch use {index} (1-based, zero-padded) and/or {name} (image basename without " + + "extension), written workspace-relative. Each expert writes its own result via save_output (JSON " + + "validated). If omitted, results are returned inline.", + }), + ), +``` + +- [ ] **Step 2: Build the item list and validate** + +Replace the top of `execute` (the `resolveSource` + `pageIds` derivation, roughly lines 116–131) with an item model. Each item has a `key` (stable, used for maps/sorting), a `label` (display), an optional `pageId`, and an optional pre-resolved `imagePath`: + +```ts + interface BatchItem { key: string; label: string; sortIndex: number; pageId?: number; imagePath?: string; } + + const usePages = Array.isArray(params.page_ids) && params.page_ids.length > 0; + const useImages = Array.isArray(params.images) && params.images.length > 0; + if (usePages === useImages) { + return { + content: [{ type: "text", text: "Provide exactly one of `page_ids` or `images` (non-empty)." }], + details: {}, + }; + } + if (usePages && !params.source) { + return { content: [{ type: "text", text: "`page_ids` requires a `source`." }], details: {} }; + } + + let member: import("./collection-context.js").CollectionMember | undefined; + let sourceRel: string | undefined; + if (params.source) { + try { + member = resolveSource(collectionCtx, params.source); + sourceRel = relative(collectionCtx.workspaceDir, member.path); + } catch (e) { + if (usePages) return { content: [{ type: "text", text: (e as Error).message }], details: {} }; + // image batch: a bad source ref is harmless (source unused) — ignore it. + } + } + + const outputFileTemplate = params.output_file; + const bbox = params.bbox as Bbox | undefined; + + // Validate the output template against the chosen mode. + if (outputFileTemplate) { + if (usePages && !outputFileTemplate.includes("{page_id}")) { + return { content: [{ type: "text", text: "output_file must contain {page_id} for a page batch." }], details: {} }; + } + if (useImages && !outputFileTemplate.includes("{index}") && !outputFileTemplate.includes("{name}")) { + return { content: [{ type: "text", text: "output_file must contain {index} and/or {name} for an image batch." }], details: {} }; + } + } + + const items: BatchItem[] = []; + if (usePages) { + params.page_ids!.forEach((raw, i) => { + const pageId = Math.round(raw); + items.push({ key: `p${pageId}`, label: `p. ${pageId}`, sortIndex: i, pageId }); + }); + } else { + for (let i = 0; i < params.images!.length; i++) { + let imagePath: string; + try { + imagePath = resolveImagePath(collectionCtx.workspaceDir, params.images![i]); + } catch (e) { + return { content: [{ type: "text", text: `${params.images![i]}: ${(e as Error).message}` }], details: {} }; + } + const base = params.images![i].replace(/\\/g, "/").split("/").pop() ?? params.images![i]; + items.push({ key: `img${i}`, label: base, sortIndex: i, imagePath }); + } + } +``` + +- [ ] **Step 3: Generalize `ExpertEntry` / `LiveExpertEntry`** + +Update the two interfaces at the top of the file. `page_id` becomes optional; add `key`/`label`: + +```ts +interface ExpertEntry { + key: string; + label: string; + taskId?: string; + page_id?: number; + status: "ok" | "error"; + response?: string; + file?: string; + noOutput?: boolean; + error?: string; + cost?: number; + toolUses?: ExpertToolUse[]; +} + +interface LiveExpertEntry extends Omit { + status: "queued" | "running" | "ok" | "error"; + activity?: string; +} +``` + +- [ ] **Step 4: Rework the live map, `runOne`, worker pool, and reporting to key off items** + +Replace the `live` map init (line ~157) to key by `item.key`: + +```ts + const live = new Map( + items.map((it) => [it.key, { key: it.key, label: it.label, page_id: it.pageId, status: "queued" }]), + ); +``` + +In `emitNow`, change the sort/derivation to use `label`/`sortIndex`. Since entries no longer sort by `page_id`, keep a `sortIndex` lookup: + +```ts + const order = new Map(items.map((it) => [it.key, it.sortIndex])); + const entries = [...live.values()] + .sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)) + .map((e) => ({ ...e })); +``` + +Rewrite `runOne` to take a `BatchItem`: + +```ts + const runOne = async (item: BatchItem): Promise => { + const filename = outputFileTemplate + ? outputFileTemplate + .replace("{page_id}", item.pageId !== undefined ? String(item.pageId).padStart(4, "0") : "") + .replace("{index}", String(item.sortIndex + 1).padStart(4, "0")) + .replace("{name}", item.label.replace(/\.[^.]+$/, "")) + : undefined; + const outputPath = filename + ? item.pageId !== undefined && member + ? join(member.dataDir, filename) + : join(collectionCtx.workspaceDir, filename) + : undefined; + const entry = live.get(item.key)!; + entry.status = "running"; + scheduleEmit(); + const input: ExpertTurnInput = { + source: params.source, + prompt: params.prompt, + model: params.model, + pageId: item.pageId, + bbox: item.pageId !== undefined ? bbox : undefined, + imagePath: item.imagePath, + signal, + grantedCaps: grant, + outputPath, + onProgress: (p) => { + if (p.taskId) entry.taskId = p.taskId; + entry.activity = + p.phase === "tool" + ? `${p.lastTool} · ${p.toolCalls} tool ${p.toolCalls === 1 ? "call" : "calls"}` + : p.toolCalls > 0 + ? `thinking · ${p.toolCalls} tool ${p.toolCalls === 1 ? "call" : "calls"}` + : "thinking"; + scheduleEmit(); + }, + }; + const result = await runExpertTurn(registry, collectionCtx, pageExpertPrompt, extCtx, input); + let final: ExpertEntry; + const skel = { key: item.key, label: item.label, page_id: item.pageId }; + if (!result.ok) { + final = { ...skel, status: "error", error: result.error }; + } else { + resolvedModel = result.model; + if (outputFileTemplate) { + final = result.wroteOutput + ? { ...skel, taskId: result.taskId, status: "ok", file: filename, cost: result.cost, toolUses: result.toolUses } + : { ...skel, taskId: result.taskId, status: "ok", noOutput: true, cost: result.cost, toolUses: result.toolUses }; + } else { + final = { ...skel, taskId: result.taskId, status: "ok", response: result.text || "(empty response)", cost: result.cost, toolUses: result.toolUses }; + } + } + live.set(item.key, { ...final }); + scheduleEmit(); + return final; + }; +``` + +Update the worker pool to iterate `items` instead of `pageIds`: + +```ts + const queue = [...items]; + if (onUpdate) emitNow(); + const workers: Promise[] = []; + for (let i = 0; i < Math.min(concurrency, queue.length); i++) { + workers.push( + (async () => { + while (queue.length > 0) { + if (signal?.aborted) return; + const item = queue.shift()!; + experts.push(await runOne(item)); + } + })(), + ); + } + await Promise.all(workers); + progressClosed = true; + if (emitTimer) clearTimeout(emitTimer); + const orderFinal = new Map(items.map((it) => [it.key, it.sortIndex])); + experts.sort((a, b) => (orderFinal.get(a.key) ?? 0) - (orderFinal.get(b.key) ?? 0)); +``` + +Also update the confirm-grant scope string and the two `pageIds.length` references to `items.length`, and the earlier "No page IDs" guard is now covered by the exactly-one check (remove the old `if (pageIds.length === 0)` block). The grant call becomes: + +```ts + if (grant.length > 0 && !(await confirmExpertGrant(extCtx, grant, `all ${items.length} experts in this batch`))) { +``` + +- [ ] **Step 5: Update the final summary + return details to use `label`** + +Replace `pageIds.length` with `items.length` in the summary line, and the per-expert report lines to use `label`: + +```ts + const lines = [ + `Batch complete: ${okCount}/${items.length} succeeded` + + (errCount > 0 ? `, ${errCount} failed` : "") + + (noOutputCount > 0 ? `, ${noOutputCount} produced no output` : "") + + (totalCost > 0 ? ` [total cost: $${totalCost.toFixed(4)}]` : ""), + "", + ...experts.map((e) => + e.status !== "ok" + ? `(failed) ${e.label}: ${e.error}` + : e.noOutput + ? `(no output) ${e.label}: expert never called save_output — no file written [${e.taskId}]` + : `${e.taskId} ⇒ ${e.label}${e.file ? ` → ${e.file}` : ""}`, + ), + "", + "Follow up on any item with task(task_id, prompt).", + ]; + + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { model: resolvedModel, prompt: params.prompt, bbox: bbox ?? null, source: sourceRel, experts }, + }; +``` + +Also update the two `emitNow` `details` objects' `source` to the now-optional `sourceRel` (already a variable) — no change needed if `sourceRel` is in scope; ensure the live `emitNow` `details.source` still reads `sourceRel` (it does). + +- [ ] **Step 6: Typecheck** + +```bash +cd /home/hufe/Documents/code/chronos/chronos && npm run build +``` +Expected: no errors. + +- [ ] **Step 7: Update `task-batch.md`** + +In `chronos/prompts/task-batch.md`, update the first paragraph to document that a batch runs over EITHER `page_ids` (requires `source`) OR `images` (arbitrary paths, source-independent), and the output_file placeholder rules (`{page_id}` for pages; `{index}`/`{name}` for images). Keep the entire "ABSOLUTE AND NON-NEGOTIABLE PROTOCOL" section verbatim. Replace only the first sentence group: + +``` +Spawn one expert per item in parallel — a batch version of the `task` tool. The batch runs over EITHER `page_ids` (one expert per source page; requires `source`) OR `images` (one expert per arbitrary image file path, workspace-relative; source-independent) — provide exactly one of the two. The same prompt is sent to every item; each becomes its own persistent expert session with its own `task_id` for follow-up via `task(task_id, …)`. Each expert self-directs and is READ-ONLY by default. With `output_file`, each expert writes its own result via save_output: use a {page_id} placeholder for a page batch (written in the source data dir) or {index} (1-based, zero-padded) and/or {name} (image basename) for an image batch (written workspace-relative). +``` + +- [ ] **Step 8: Commit** + +```bash +cd /home/hufe/Documents/code/chronos +git add chronos/tools/task-batch.ts chronos/prompts/task-batch.md +git commit -m "feat: task_batch runs over page_ids or arbitrary images, source optional" +``` + +--- + +## Task 5: Webview — render batch item labels + +**Files:** +- Modify: `chronos-vscode/webview/components/chronos-chat.ts` + +**Interfaces:** +- Consumes: `details.experts` entries now carry `key`/`label` and optional `page_id` (Task 4). +- Produces: batch chips + expert-turn cards display `label`, falling back to `p. N`. + +- [ ] **Step 1: Widen `BatchExpertEntry`** + +In `chronos-chat.ts` (interface at line ~150) make `page_id` optional and add `key`/`label`: + +```ts +interface BatchExpertEntry { + taskId?: string; + key?: string; + label?: string; + page_id?: number; + status: "queued" | "running" | "ok" | "error"; + response?: string; + file?: string; + error?: string; + toolUses?: ExpertToolUse[]; + activity?: string; +} +``` + +- [ ] **Step 2: Render the chip label with fallback** + +In `renderBatchChip` (line ~1170) replace the page span: + +```ts + ${e.label ?? (e.page_id != null ? `p. ${e.page_id}` : "—")} +``` + +- [ ] **Step 3: Typecheck the webview** + +```bash +cd /home/hufe/Documents/code/chronos/chronos-vscode && npx tsc --noEmit -p webview/tsconfig.json +``` +Expected: no errors. (The `entry.page_id` read at line ~903 stays valid — it is now `number | undefined`, and the `expertTurns` `pageId` field is already optional.) + +- [ ] **Step 4: Build the extension** + +```bash +cd /home/hufe/Documents/code/chronos/chronos-vscode && npm run build +``` +Expected: esbuild completes with no errors. + +- [ ] **Step 5: Commit** + +```bash +cd /home/hufe/Documents/code/chronos +git add chronos-vscode/webview/components/chronos-chat.ts +git commit -m "feat: batch chips show item label (page or image) in the webview" +``` + +--- + +## Task 6: Manual end-to-end smoke + +**Files:** none (verification only). + +- [ ] **Step 1: Rebuild both packages** + +```bash +cd /home/hufe/Documents/code/chronos/chronos && npm run build +cd /home/hufe/Documents/code/chronos/chronos-vscode && npm run build +``` + +- [ ] **Step 2: Confirm the local pi points at this working copy** + +Confirm `~/.pi/agent/settings.json` `packages` includes the absolute path to this repo's `chronos/` dir (per CLAUDE.md). Sessions snapshot at startup — restart the Chronos session/extension after building so the new tools load. + +- [ ] **Step 3: Exercise the five paths (manual, per `chronos-vscode/TESTING.md`)** + +Verify each and note the observed result: +1. `task` with `image` pointing at a workspace image file → expert answers about that image; no `[view p.N]` link; result has a `task_id`. +2. `task` with only a `prompt` (no `source`, no `image`) → expert answers; no source-scoped tools offered. +3. `task` with `source` + `page_id` (regression) → unchanged behavior, `[view p.N]` link present. +4. `task_batch` with `images: [...]` (2–3 workspace images) → one expert per image; chips show image basenames; follow-up via `task(task_id)` works. +5. Restart the session, then follow up on the image `task` from (1) via its `task_id` → the image rehydrates (expert still "sees" it) or degrades to text-only if the file was removed. + +- [ ] **Step 4: Final verification note** + +Record the outcomes in the PR/commit description. If any path fails, do NOT claim completion — file the discrepancy and fix before finishing. From f7de72585f3f681ea4006c8531854232cf1a2a5a Mon Sep 17 00:00:00 2001 From: Lorenz Hufe Date: Thu, 16 Jul 2026 10:32:27 +0200 Subject: [PATCH 12/45] wip: in-progress archive-support / collection-context work (checkpoint) --- DEV.md | 134 ++++++++ chronos-vscode/memory/MEMORY.MD | 0 chronos-vscode/src/panel/chronos-panel.ts | 93 +++++- chronos-vscode/src/panel/sessions.ts | 6 +- chronos-vscode/src/panel/sources.ts | 35 ++- chronos-vscode/src/panel/webview-protocol.ts | 8 + chronos-vscode/src/pi-env.ts | 23 ++ chronos-vscode/src/protocol.ts | 10 + chronos-vscode/src/rpc/pi-rpc-session.ts | 15 +- chronos-vscode/src/workspace-templates.ts | 90 ++++++ chronos-vscode/walkthroughs/setup.md | 1 + .../webview/components/chronos-app.ts | 30 ++ .../webview/components/chronos-chat.ts | 125 +++++--- chronos-vscode/webview/styles.css | 58 ++++ chronos/extensions/index.ts | 285 +++++++++++------- chronos/http/http-client.ts | 10 + chronos/prompts/change-source.md | 2 +- chronos/prompts/list-pages.md | 2 +- chronos/prompts/page-expert-prompt.md | 9 +- chronos/prompts/show-page.md | 2 +- chronos/prompts/show-text.md | 2 +- chronos/prompts/system-prompt.md | 149 ++++++--- chronos/prompts/task-batch.md | 4 +- chronos/prompts/task.md | 2 +- chronos/tools/change-source.ts | 75 ++--- chronos/tools/collection-context.ts | 145 +++++++++ chronos/tools/expert-tools.ts | 69 ++++- chronos/tools/expert-turn.ts | 56 +++- chronos/tools/list-pages.ts | 29 +- chronos/tools/show-page.ts | 29 +- chronos/tools/show-text.ts | 14 +- chronos/tools/source-context.ts | 45 --- chronos/tools/task-batch.ts | 180 +++++++++-- chronos/tools/view-page.ts | 97 ++++-- chronos/utils/collection-manifest.ts | 123 ++++++++ chronos/utils/env-config.ts | 12 + chronos/utils/session-collection-store.ts | 61 ++++ chronos/utils/source-discovery.ts | 2 +- chronos/utils/workspace.ts | 6 +- dev/pi-release | 13 + .../2026-07-06-live-expert-progress-design.md | 87 ++++++ memory/MEMORY.MD | 0 42 files changed, 1748 insertions(+), 390 deletions(-) create mode 100644 DEV.md create mode 100644 chronos-vscode/memory/MEMORY.MD create mode 100644 chronos-vscode/src/workspace-templates.ts create mode 100644 chronos/tools/collection-context.ts delete mode 100644 chronos/tools/source-context.ts create mode 100644 chronos/utils/collection-manifest.ts create mode 100644 chronos/utils/env-config.ts create mode 100644 chronos/utils/session-collection-store.ts create mode 100755 dev/pi-release create mode 100644 docs/superpowers/specs/2026-07-06-live-expert-progress-design.md create mode 100644 memory/MEMORY.MD diff --git a/DEV.md b/DEV.md new file mode 100644 index 0000000..e576a7f --- /dev/null +++ b/DEV.md @@ -0,0 +1,134 @@ +# Development setup + +How to work on Chronos **and** test the released/marketplace build without ever +uninstalling `pi` or the pi-package. The two modes are isolated, so they coexist. + +## Why the two modes used to fight + +Everything Chronos-related lives in one global slot per axis, so switching modes +meant mutating shared state (hence the uninstall/reinstall churn): + +| Axis | Where it lives | +|------|----------------| +| `pi` binary | `~/.npm-global/bin/pi` (one global npm install) | +| agent home: package registration, `auth.json`, `models.json`, sessions | `~/.pi/agent/` | +| VS Code extension | the editor's one extension slot | + +The fix isolates each axis so **local dev** and **release testing** never touch +each other's state. + +## Mode 1 — local development (default) + +Runs your working copy of both the extension and the agent. + +**One-time:** register the agent pi-package as a local checkout. It's already set +if `~/.pi/agent/settings.json` `packages` contains a path ending in `/chronos`: + +```jsonc +// ~/.pi/agent/settings.json +{ "packages": ["/abs/path/to/chronos/chronos"] } +``` + +The extension treats a local-checkout registration as sacred — its bootstrap +never reinstalls the pinned release on top of it (`extension.ts` → +`hasLocalChronosCheckout`). So this survives extension upgrades. + +**Run the extension from source** (never install a .vsix for dev): open +`chronos-vscode/` in VS Code and press **F5** ("Run Extension" — see +`.vscode/launch.json`). This launches an Extension Development Host running your +built `out/`. + +**Iterate:** + +```bash +# agent (chronos/) — pi loads from dist/, so you MUST build; then restart the session +cd chronos && npm run build + +# extension + webview (chronos-vscode/) — rebuild, then reload the dev-host window +cd chronos-vscode && npm run watch # or: npm run build +``` + +- Agent changes: `npm run build`, then **restart the pi session** (sessions + snapshot the agent at startup). Prompts/skills are read live — no build needed. +- Extension/webview changes: rebuild, then **Reload Window** in the dev host. + +Test the agent alone (no extension) in a terminal from a workspace: just `pi`. + +## Mode 2 — testing the released / marketplace build + +Runs the packaged `.vsix` and the GitHub-pinned agent against an **isolated agent +home**, so your dev registration in `~/.pi/agent` is untouched. + +**Set up a dedicated VS Code profile** (profiles isolate the installed extension +*and* settings): + +```bash +# build + package the extension (or grab the marketplace .vsix) +cd chronos-vscode && npm run package # -> chronos-.vsix + +# create the profile and install the .vsix into it +code --profile chronos-release --install-extension chronos-vscode/chronos-*.vsix +``` + +**Point that profile at an isolated agent home.** In the `chronos-release` +profile's user settings (`Preferences: Open User Settings (JSON)` while in that +profile): + +```jsonc +{ "chronos.piAgentDir": "~/.pi-release/agent" } +``` + +That's the whole trick. The extension both *reads* package registration / auth / +sessions from that dir and *passes it to the pi subprocess* as +`PI_CODING_AGENT_DIR`, so the two always agree. On first launch there, the +bootstrap sees no Chronos package in the isolated home and installs the release +pinned to the extension version (`v` tag) — leaving `~/.pi/agent` alone. + +A setting (not an env var) is used because VS Code doesn't reliably propagate a +launcher's environment to an already-running instance, whereas per-profile +settings always apply. + +### Terminal-only release testing + +To exercise the *released agent* from a terminal without the extension, use the +wrapper — it runs the global `pi` against the same isolated home: + +```bash +dev/pi-release install https://github.com/ai-historian/chronos@v0.2.2 # one-time +dev/pi-release # run a session +``` + +Override the location with `PI_RELEASE_AGENT_DIR`. + +## Other dev overrides (extension settings) + +Machine-scoped, so they don't travel with a committed workspace. Set per-profile. + +| Setting | Purpose | +|---------|---------| +| `chronos.piAgentDir` | Relocate the agent home (isolation — above). | +| `chronos.piPath` | Use a specific `pi` binary (e.g. a dev build / fork). | +| `chronos.piPackageSource` | Install the agent pkg from a local path or `@branch` instead of the pinned release. | +| `chronos.piNpmPackage` | Swap the npm package for the `pi` CLI itself (e.g. a fork or pinned version). | + +## Which mode am I in? + +```bash +# dev home +cat ~/.pi/agent/settings.json | grep -A3 packages +# release home +cat ~/.pi-release/agent/settings.json | grep -A3 packages +``` + +A path entry ⇒ local dev checkout; a `github.com/...@vX` entry ⇒ pinned release. + +## Typecheck / test reminders + +esbuild does **not** type-check. After editing extension/webview TS: + +```bash +cd chronos-vscode && npx tsc --noEmit -p tsconfig.json # host (src/) +cd chronos-vscode && npx tsc --noEmit -p webview/tsconfig.json # webview/ +``` + +See `chronos-vscode/TESTING.md` for the RPC canary and UI-boot tests. diff --git a/chronos-vscode/memory/MEMORY.MD b/chronos-vscode/memory/MEMORY.MD new file mode 100644 index 0000000..e69de29 diff --git a/chronos-vscode/src/panel/chronos-panel.ts b/chronos-vscode/src/panel/chronos-panel.ts index 6280c3b..0cfb74d 100644 --- a/chronos-vscode/src/panel/chronos-panel.ts +++ b/chronos-vscode/src/panel/chronos-panel.ts @@ -7,7 +7,7 @@ import { PiRpcSession } from "../rpc/pi-rpc-session"; import type { ModelInfo, RpcExtensionUIRequest, RpcSessionState, RpcSlashCommand } from "../rpc/rpc-types"; import type { AgentToExtensionMessage } from "../protocol"; import type { Bbox, ExtToWebview, WebviewToExt } from "./webview-protocol"; -import { discoverSources, countPages } from "./sources"; +import { discoverSources, countPages, discoverCollections } from "./sources"; import { listSessions, readSessionMessages } from "./sessions"; import { beginAnthropicLogin, @@ -167,6 +167,15 @@ export class ChronosPanel { // Last source we pushed a data-file list for, so navigation within a source // doesn't re-list on every page change. private lastDataSource: string | undefined; + // Active collection name (null = auto "all sources"), pushed by the agent over + // HTTP so the collection picker reflects the current selection. + private activeCollection: string | null = null; + // The active collection's output dir (data/_collections//) whose files — + // e.g. the entity index — are surfaced in the Data tab alongside source data. + private activeCollectionDataDir: string | undefined; + // filename → directory it was listed from, so a data/load reads the right dir + // when the list merges per-source and collection-level files. + private dataFileDirs = new Map(); // Blocking extension_ui_requests forwarded to the webview; answered with // cancelled on dispose so the agent never deadlocks. @@ -212,6 +221,7 @@ export class ChronosPanel { * adds a new source) so the header picker reflects it without a reload. */ refreshSources(): void { this.postSources(); + this.postCollections(); } // ── test seam (integration tests) ───────────────────────────────────────── @@ -311,6 +321,7 @@ export class ChronosPanel { this.post({ type: "state", state }); this.post({ type: "yolo", enabled: readChronosSettings(this.workspaceDir).yolo === true }); this.postSources(); + this.postCollections(); this.postSessions(); void this.postHistory(state); void this.rpc @@ -377,6 +388,14 @@ export class ChronosPanel { }); } + private postCollections(): void { + this.post({ + type: "collections", + collections: discoverCollections(this.workspaceDir), + active: this.activeCollection, + }); + } + private postSessions(): void { this.post({ type: "sessions", sessions: listSessions(this.workspaceDir) }); } @@ -571,6 +590,13 @@ export class ChronosPanel { sourceName: msg.sourceName, }); break; + case "collection": + this.activeCollection = msg.name; + this.activeCollectionDataDir = msg.dataDir; + this.postCollections(); + // The collection's entity index etc. may now be visible — refresh the tab. + this.postDataFiles(); + break; // text_delta/tool_start/tool_end/turn_end are RPC-event duplicates — dropped } } @@ -611,6 +637,19 @@ export class ChronosPanel { return this.panel.webview.asWebviewUri(vscode.Uri.file(pagePath)).toString(); } + // A cited source (from a [view …@path] link or a data row's chronos_source) to + // its absolute dir. Accepts an absolute path, a workspace-relative path + // (sources/Frankfurt_1864), or a bare ref (Frankfurt_1864) — trying the + // sources/ tree as a fallback so entity-index citations resolve either way. + private resolveCitedSource(sourcePath: string): string { + if (isAbsolute(sourcePath)) return sourcePath; + const direct = join(this.workspaceDir, sourcePath); + if (existsSync(join(direct, "png"))) return direct; + const underSources = join(this.workspaceDir, "sources", sourcePath); + if (existsSync(join(underSources, "png"))) return underSources; + return direct; + } + // Resolve a cited page's image for the Data tab's inline crop preview, WITHOUT // touching the page viewer or current source — the data and source viewers are // independent; only "Show full page" (openViewLink) crosses over. @@ -618,7 +657,7 @@ export class ChronosPanel { let sourceDir = this.currentSourceDir; let sourceName = this.currentSourceName; if (sourcePath) { - sourceDir = isAbsolute(sourcePath) ? sourcePath : join(this.workspaceDir, sourcePath); + sourceDir = this.resolveCitedSource(sourcePath); sourceName = basename(sourceDir); } if (!sourceDir) return; @@ -635,7 +674,7 @@ export class ChronosPanel { let sourceDir = this.currentSourceDir; let sourceName = this.currentSourceName; if (sourcePath) { - sourceDir = isAbsolute(sourcePath) ? sourcePath : join(this.workspaceDir, sourcePath); + sourceDir = this.resolveCitedSource(sourcePath); sourceName = basename(sourceDir); } if (!sourceDir || !sourceName) return; @@ -667,36 +706,52 @@ export class ChronosPanel { } // ── dataset viewer ───────────────────────────────────────────────────────── - // The agent writes extraction outputs to data// (sourceName is the - // source dir basename, matching change_source / select-source). We surface - // those files in the Data tab; row provenance reuses openViewLink. + // The agent writes extraction outputs to data// and sends that dataKey + // as the viewer event's `sourceName` (basename for flat sources, a slug for + // nested ones), so data/ here always matches the agent's output dir. + // We surface those files in the Data tab; row provenance reuses openViewLink. private dataDir(): string | undefined { return this.currentSourceName ? join(this.workspaceDir, "data", this.currentSourceName) : undefined; } - private listDataFiles(): string[] { - const dir = this.dataDir(); - if (!dir) return []; + private filesIn(dir: string): string[] { try { return readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isFile() && !e.name.startsWith(".")) - .map((e) => e.name) - .sort((a, b) => a.localeCompare(b)); + .map((e) => e.name); } catch { return []; } } + // The active source's files plus, when a collection is active, its + // collection-level files (entity index, cross-source summaries). Records each + // file's directory so data/load reads it back from the right place; the source + // dir wins on a name clash. + private listDataFiles(): string[] { + this.dataFileDirs.clear(); + const collectionDir = this.activeCollectionDataDir; + if (collectionDir) { + for (const name of this.filesIn(collectionDir)) this.dataFileDirs.set(name, collectionDir); + } + const sourceDir = this.dataDir(); + if (sourceDir) { + for (const name of this.filesIn(sourceDir)) this.dataFileDirs.set(name, sourceDir); + } + return [...this.dataFileDirs.keys()].sort((a, b) => a.localeCompare(b)); + } + private postDataFiles(): void { this.lastDataSource = this.currentSourceName; this.post({ type: "data/list", sourceName: this.currentSourceName ?? "", files: this.listDataFiles() }); } private postDataFile(filename: string): void { - const dir = this.dataDir(); // filenames come from listDataFiles (basenames); reject anything path-like. - if (!dir || filename.includes("/") || filename.includes("\\") || filename.includes("..")) return; + if (filename.includes("/") || filename.includes("\\") || filename.includes("..")) return; + const dir = this.dataFileDirs.get(filename) ?? this.dataDir(); + if (!dir) return; try { const content = readFileSync(join(dir, filename), "utf-8"); this.post({ type: "data/show", sourceName: this.currentSourceName ?? "", filename, content }); @@ -743,6 +798,11 @@ export class ChronosPanel { // Re-arm the Data-tab refresh guard so re-selecting the same source // (or restoring it on resume) repopulates the (now-cleared) Data tab. this.lastDataSource = undefined; + // New session starts on the auto collection; the agent re-emits the + // active collection on its session_start, which corrects this if needed. + this.activeCollection = null; + this.activeCollectionDataDir = undefined; + this.postCollections(); this.post({ type: "history", messages: [] }); // The new session has no source bound — clear the viewer + dropdown so // the display matches (don't leave the previous source showing). @@ -791,6 +851,13 @@ export class ChronosPanel { // (which arrives back here as an extension_ui_request). await this.rpc?.request({ type: "prompt", message: `/select-source ${msg.name}` }, 0); break; + case "selectCollection": { + // null → the auto "all sources" collection. "(all sources)" is the exact + // sentinel the pi-package's /select-collection maps back to null. + const arg = msg.name ?? "(all sources)"; + await this.rpc?.request({ type: "prompt", message: `/select-collection ${arg}` }, 0); + break; + } case "refreshSessions": this.postSessions(); break; diff --git a/chronos-vscode/src/panel/sessions.ts b/chronos-vscode/src/panel/sessions.ts index 85944c2..3ca36d1 100644 --- a/chronos-vscode/src/panel/sessions.ts +++ b/chronos-vscode/src/panel/sessions.ts @@ -1,6 +1,6 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; -import { homedir } from "node:os"; +import { piAgentDir } from "../pi-env"; import type { ChronosSessionInfo } from "./webview-protocol"; // Lightweight scan of pi session JSONL files in /sessions. @@ -124,12 +124,12 @@ function generatedName(entry: { name?: string } | string | undefined): string | return entry?.name || undefined; } -// pi's default per-project session location: ~/.pi/agent/sessions/---- +// pi's default per-project session location: /sessions/---- // (used by sessions created before the extension started pinning // PI_CODING_AGENT_SESSION_DIR to /sessions). function defaultPiSessionDir(workspaceDir: string): string { const encoded = `--${resolve(workspaceDir).replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - return join(homedir(), ".pi", "agent", "sessions", encoded); + return join(piAgentDir(), "sessions", encoded); } function scanDir( diff --git a/chronos-vscode/src/panel/sources.ts b/chronos-vscode/src/panel/sources.ts index 87d3909..e42d790 100644 --- a/chronos-vscode/src/panel/sources.ts +++ b/chronos-vscode/src/panel/sources.ts @@ -1,4 +1,4 @@ -import { readdirSync, statSync, existsSync } from "node:fs"; +import { readdirSync, statSync, existsSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; export interface SourceInfo { @@ -6,6 +6,39 @@ export interface SourceInfo { path: string; } +export interface CollectionInfo { + name: string; + description?: string; + memberCount: number; +} + +/** Named collections declared in collections/.json (mirrors the agent's + * listCollections). The picker lists these plus a synthetic "all sources". */ +export function discoverCollections(workspaceDir: string): CollectionInfo[] { + const dir = join(workspaceDir, "collections"); + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return []; + } + const out: CollectionInfo[] = []; + for (const f of entries) { + if (!f.endsWith(".json")) continue; + try { + const m = JSON.parse(readFileSync(join(dir, f), "utf-8")); + out.push({ + name: typeof m.name === "string" ? m.name : f.replace(/\.json$/, ""), + description: typeof m.description === "string" ? m.description : undefined, + memberCount: Array.isArray(m.members) ? m.members.length : 0, + }); + } catch { + // skip unparseable manifest + } + } + return out.sort((a, b) => a.name.localeCompare(b.name)); +} + export function countPages(sourceDir: string): number { const pngDir = join(sourceDir, "png"); try { diff --git a/chronos-vscode/src/panel/webview-protocol.ts b/chronos-vscode/src/panel/webview-protocol.ts index e821c2d..617087c 100644 --- a/chronos-vscode/src/panel/webview-protocol.ts +++ b/chronos-vscode/src/panel/webview-protocol.ts @@ -49,6 +49,12 @@ export type ExtToWebview = | { type: "yolo"; enabled: boolean } // workspace data | { type: "sources"; sources: { name: string; pageCount: number }[] } + | { + type: "collections"; + collections: { name: string; description?: string; memberCount: number }[]; + // Active collection name; null = the auto "all sources" collection. + active: string | null; + } | { type: "sessions"; sessions: ChronosSessionInfo[] } | { type: "resumeResult"; ok: boolean } // auth: no models are available until the user connects a provider @@ -94,6 +100,8 @@ export type WebviewToExt = | { type: "setModel"; provider: string; modelId: string } | { type: "setThinkingLevel"; level: ThinkingLevel } | { type: "selectSource"; name: string } + // pick the active collection; name null = the auto "all sources" collection + | { type: "selectCollection"; name: string | null } | { type: "refreshSessions" } | { type: "refreshSources" } | { type: "uiResponse"; response: RpcExtensionUIResponse } diff --git a/chronos-vscode/src/pi-env.ts b/chronos-vscode/src/pi-env.ts index 3098987..b78d076 100644 --- a/chronos-vscode/src/pi-env.ts +++ b/chronos-vscode/src/pi-env.ts @@ -32,6 +32,29 @@ export function resolvePiBin(): string { } } +// Single source of truth for the pi *agent home* (`~/.pi/agent` by default) — +// where package registration, auth.json, models.json and sessions live. pi +// relocates it via PI_CODING_AGENT_DIR (see its config.js getAgentDir); we +// resolve the same value here AND inject it into the pi subprocess env +// (extension.ts), so the two can never disagree. Precedence: the +// `chronos.piAgentDir` setting (profile-scoped, the robust way to isolate a +// release-testing VS Code profile) > the PI_CODING_AGENT_DIR env var > default. +// Config wins over env because VS Code doesn't reliably propagate a launcher's +// env to an already-running instance, whereas per-profile settings always apply. +// Tilde handling mirrors pi's normalizePath (`~` / `~/` only; `~user` untouched). +export function piAgentDir(): string { + const setting = vscode.workspace.getConfiguration("chronos").get("piAgentDir")?.trim(); + const configured = setting || process.env.PI_CODING_AGENT_DIR?.trim(); + if (configured) { + if (configured === "~") return homedir(); + if (configured.startsWith("~/") || (process.platform === "win32" && configured.startsWith("~\\"))) { + return join(homedir(), configured.slice(2)); + } + return configured; + } + return join(homedir(), ".pi", "agent"); +} + // True when pi is actually runnable, resolved exactly the way the agent launches // it — so "is pi present?" can never disagree with "how do we run pi?". export function hasPi(): boolean { diff --git a/chronos-vscode/src/protocol.ts b/chronos-vscode/src/protocol.ts index c4eab50..021fb9b 100644 --- a/chronos-vscode/src/protocol.ts +++ b/chronos-vscode/src/protocol.ts @@ -34,10 +34,20 @@ export interface ErrorMessage { message: string; } +// The active collection: its name (null = the auto "all sources" collection) for +// the picker, and its collection-level output dir so the Data tab can surface +// cross-source files (e.g. the entity index) alongside the current source's. +export interface CollectionMessage { + type: "collection"; + name: string | null; + dataDir: string; +} + export type AgentToExtensionMessage = | ShowPageMessage | PageListMessage | ShowTextMessage + | CollectionMessage | ErrorMessage; // Extension → Agent messages diff --git a/chronos-vscode/src/rpc/pi-rpc-session.ts b/chronos-vscode/src/rpc/pi-rpc-session.ts index a31dcb2..08a1ed3 100644 --- a/chronos-vscode/src/rpc/pi-rpc-session.ts +++ b/chronos-vscode/src/rpc/pi-rpc-session.ts @@ -1,4 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; import type { AgentEvent, RpcCommand, @@ -54,7 +56,18 @@ export class PiRpcSession { if (this.proc) throw new Error("PiRpcSession already started"); this.stopped = false; - this.proc = spawn(this.options.piBin, ["--mode", "rpc"], { + // Load the workspace skills/ dir explicitly via --skill. We can't rely on + // the workspace .pi/settings.json {skills:["../skills"]} bridge here: pi + // gates project settings behind project-trust, and in headless rpc mode + // there is no UI to answer the trust prompt (defaultProjectTrust "ask" -> + // untrusted), so the bridge is silently discarded and workspace skills never + // reach the slash-command menu. --skill is a CLI resource path, not project + // settings, so it loads regardless of trust state. + const args = ["--mode", "rpc"]; + const skillsDir = join(this.options.workspaceDir, "skills"); + if (existsSync(skillsDir)) args.push("--skill", skillsDir); + + this.proc = spawn(this.options.piBin, args, { cwd: this.options.workspaceDir, env: { ...process.env, ...this.options.env }, stdio: ["pipe", "pipe", "pipe"], diff --git a/chronos-vscode/src/workspace-templates.ts b/chronos-vscode/src/workspace-templates.ts new file mode 100644 index 0000000..3af1c56 --- /dev/null +++ b/chronos-vscode/src/workspace-templates.ts @@ -0,0 +1,90 @@ +// Curated default workspace content seeded by initWorkspace (writeIfMissing, so +// re-running "Init Workspace" adds new templates without touching edited files). + +// A long-horizon skill that orchestrates the collection + entity-index features: +// select a collection, sweep its sources for an entity, and accumulate a cited +// entity index plus narrative progress in collection memory. Read live by pi +// from the workspace skills/ dir — no rebuild needed to edit it. +export const TRACE_ENTITY_SKILL = `--- +name: trace-entity +description: Follow a person, family, property, business, or institution across every source in a collection and build a cited, cross-source entity index. +requires: +--- + +# Trace an entity across sources + +Use this when the user wants to follow one entity — a person, family, property, business, or +institution — across many sources over time (e.g. "trace the Vogt family through the Frankfurt +directories 1850–1900"). The goal is a **cited entity index** plus a narrative timeline, with +every claim traceable to a source page. + +This is long-horizon work: it spans many sources and often outlives a single context window, so +you **persist as you go** and design for resa resume. Never hold the whole trace in your head. + +## 0. Scope the task + +- Confirm the **active collection** (see the catalog in your system prompt). If the relevant + sources are not in the active collection, ask the user to run \`/select-collection\`. +- Pin down the entity and its bounds with the user if ambiguous: exact name(s) and spelling + variants, time range, place, and what counts as a match (same person vs. same household). +- Read **collection memory** (already injected) and, if it exists, the current entity index at + \`data/_collections//entities.json\` — you may be resuming an earlier run. + +## 1. Choose which sources to sweep + +- Use the catalog's \`meta\` (year, place, type) to pick the candidate sources — do **not** scan + every page of every source blindly. Order them (usually chronologically). +- For each candidate, if its **mem** column is ✓, \`read\` \`memory/.md\` first — it may already + record where the entity appears or the source's layout/section ranges. + +## 2. Sweep each source, one at a time + +For each chosen source \`S\`: + +1. \`list_pages(source: S)\` to learn its extent. +2. Narrow to likely pages using per-source memory, the source's structure (indices, + alphabetical sections), or a cheap first pass — don't batch the whole book if you can help it. +3. Extract candidate mentions with \`task\` (a few pages) or **\`task_batch\`** (many pages of the + SAME source — one batch targets one \`source\`; iterate sources with separate batches). Prompt + the expert to return, for each mention: the surface form of the name, surrounding attributes + (trade, address, dates, relations), the \`chronos_page\`, and a tight \`chronos_bbox\`. + - \`task_batch\` is high-cost and needs explicit user confirmation — follow the mandatory + confirmation protocol in your system prompt (propose → ask → stop). Do not call it until the + user approves. + +## 3. Reconcile into the entity index + +Maintain \`data/_collections//entities.json\` — a JSON array of entity rows. Each row +aggregates every place the entity appears, using the reserved keys as **index-aligned lists**: + +\`\`\`json +[ + { "entity": "Karl Vogt", "type": "person", "attributes": { "trade": "baker" }, + "chronos_source": ["sources/Frankfurt_1858", "sources/Frankfurt_1861"], + "chronos_page": [42, 51], + "chronos_bbox": [[0.10,0.32,0.80,0.05], [0.10,0.41,0.80,0.05]], + "notes": "1861 entry drops the 'jun.' suffix — likely the same man." } +] +\`\`\` + +- **Read the file, then \`edit\` to append** a new reference or a new entity — never blind-overwrite. +- **Do not over-merge.** If it's unclear whether two mentions are the same entity, keep them + distinct and record the doubt in \`notes\`, or add a \`candidate\` flag — flag uncertainty rather + than inventing continuity. Entity resolution is a judgement call; surface it, don't hide it. +- Every reference must have a \`chronos_page\`; add a \`chronos_bbox\` whenever you can so the + historian can verify at a glance. The Data tab renders each reference as its own "view source" + button. + +## 4. Checkpoint — every source, not just at the end + +After finishing each source (or every ~5–10 pages within a large one), write to **collection +memory**: which sources are done, what you found, spelling variants seen, and open questions. +This is what lets a compacted or resumed session continue instead of starting over. + +## 5. Synthesize + +When the sweep is done, give the user a chronological summary of the entity's trajectory. Cite +each claim with a source-qualified link — \`[view p.42@sources/Frankfurt_1858]\` — so they can +click through to the evidence. Point them at the entity index in the Data tab. State plainly +where the trail is uncertain or goes cold. +`; diff --git a/chronos-vscode/walkthroughs/setup.md b/chronos-vscode/walkthroughs/setup.md index e0a67dc..a0bac26 100644 --- a/chronos-vscode/walkthroughs/setup.md +++ b/chronos-vscode/walkthroughs/setup.md @@ -17,3 +17,4 @@ pi install https://github.com/ai-historian/chronos@v The checkmark appears once `pi` and the package are detected — a few seconds after the terminal finishes. +<<<< \ No newline at end of file diff --git a/chronos-vscode/webview/components/chronos-app.ts b/chronos-vscode/webview/components/chronos-app.ts index 7b677fb..b6d8fe7 100644 --- a/chronos-vscode/webview/components/chronos-app.ts +++ b/chronos-vscode/webview/components/chronos-app.ts @@ -34,6 +34,8 @@ export class ChronosApp extends LitElement { toasts: { state: true }, splitPct: { state: true }, currentSource: { state: true }, + collections: { state: true }, + activeCollection: { state: true }, yolo: { state: true }, contextTokens: { state: true }, sessionLoading: { state: true }, @@ -50,6 +52,8 @@ export class ChronosApp extends LitElement { declare toasts: Toast[]; declare splitPct: number; declare currentSource: string; + declare collections: { name: string; description?: string; memberCount: number }[]; + declare activeCollection: string | null; declare yolo: boolean; declare contextTokens: number; declare sessionLoading: { title?: string; name: string; sizeBytes?: number } | null; @@ -71,6 +75,8 @@ export class ChronosApp extends LitElement { this.toasts = []; this.splitPct = 52; this.currentSource = ""; + this.collections = []; + this.activeCollection = null; this.yolo = false; this.contextTokens = 0; this.sessionLoading = null; @@ -195,6 +201,10 @@ export class ChronosApp extends LitElement { case "sources": this.sources = msg.sources; break; + case "collections": + this.collections = msg.collections; + this.activeCollection = msg.active; + break; case "sessions": this.sessions = msg.sessions; break; @@ -375,6 +385,26 @@ export class ChronosApp extends LitElement { Chronos
+ ${this.collections.length > 0 + ? html`` + : nothing} `. Immediately **after** that closing `` and before the next `