diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8a23ef..5b4486c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: chronos/package-lock.json chronos-vscode/package-lock.json - # chronos pi-package — npm ci resolves the @mariozechner peer deps; tsc emits dist/ + # chronos pi-package — npm ci resolves the pi peer deps; tsc emits dist/ - name: Install chronos working-directory: chronos run: npm ci @@ -28,6 +28,14 @@ jobs: working-directory: chronos run: npm run build + # The agent's canaries (image downscaling, expert retry, timeout-as-abort, + # collection context / refs / data keys / session sidecars). They import the + # BUILD OUTPUT, so they run after the build above — `npm test` rebuilds anyway. + # Nothing in CI exercised these before, so ~140 assertions were local-only. + - name: Agent canaries + working-directory: chronos + run: npm test + # chronos-vscode — esbuild does NOT type-check, so run both tsconfigs explicitly - name: Install chronos-vscode working-directory: chronos-vscode @@ -64,7 +72,19 @@ jobs: with: node-version: "24" cache: npm - cache-dependency-path: chronos-vscode/package-lock.json + cache-dependency-path: | + chronos/package-lock.json + chronos-vscode/package-lock.json + + # The agent package's OWN devDeps (typescript, @types/node) are required here, + # not just chronos-vscode's: `npm test` below builds chronos/ first, because + # test/suite.js and test/data-key-equivalence-test.mjs derive their expected + # nested-source data keys FROM chronos/dist rather than hardcoding them — a + # missing dist fails opaquely inside the extension host, and a stale one makes + # those tests agree with themselves. + - name: Install chronos (the agent package the tests derive expectations from) + working-directory: chronos + run: npm ci - name: Install chronos-vscode working-directory: chronos-vscode @@ -73,8 +93,9 @@ jobs: - name: Install xvfb run: sudo apt-get update && sudo apt-get install -y xvfb - # npm test = esbuild bundle + run-ui-test.mjs. The test drives a deterministic - # mock pi (no real agent / API keys) and downloads VS Code into .vscode-test/. + # npm test = build chronos/ + esbuild bundle + host tests + run-ui-test.mjs. + # The UI test drives a deterministic mock pi (no real agent / API keys) and + # downloads VS Code into .vscode-test/. - name: Run UI test working-directory: chronos-vscode run: xvfb-run -a npm test diff --git a/.gitignore b/.gitignore index 5174dad..ebad9c2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,20 @@ dist/ *.js.map chronos-vscode/node_modules/ chronos-vscode/out/ -chronos-vscode/.vscode-test/ +# VS Code test-electron download (appears wherever the UI test is run from) +.vscode-test/ *.tgz *.vsix *.html paper.md win-vm.md soduco/ -assets/originals/ \ No newline at end of file +assets/originals/ +# Workspace artifacts from dev-running the agent inside the repo +/memory/ +/chronos-vscode/memory/ +# Implementation plans are working notes for a single change, not product docs — +# they went stale the moment their branch landed and one of them was the largest +# file in its own PR. Kept on disk, out of git. Specs (docs/superpowers/specs/) +# stay tracked: they record WHY a design was chosen. +docs/superpowers/plans/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3a96c87..7992f33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,13 +30,35 @@ cd chronos-vscode && npx tsc --noEmit -p tsconfig.json # host (src/) cd chronos-vscode && npx tsc --noEmit -p webview/tsconfig.json # webview/ ``` -Tests live in `chronos-vscode/` (no unit-test runner; two node scripts): +There is no unit-test runner — tests are plain node scripts, split across both packages. Each +package's `npm test` runs its own set: ```bash -node scripts/rpc-spike.mjs [path-to-pi] # canary: asserts the pi --mode rpc JSONL contract. Run after upgrading the global pi. -node test/run-ui-test.mjs # launches VS Code against a fixture workspace; asserts panel + webview + RPC boot +cd chronos && npm test # builds dist/, then all four agent canaries +cd chronos-vscode && npm test # builds the agent + bundle, then host tests + the UI test ``` +Individually: + +```bash +# chronos/ — canaries import the BUILD OUTPUT (dist/), so they need `npm run build` first +node scripts/downscale-canary.mjs # image long-edge cap +node scripts/retry-canary.mjs # expert retry/backoff policy +node scripts/timeout-canary.mjs # per-attempt timeout enforced by abort, not pi-ai timeoutMs +node scripts/collection-canary.mjs # collection context, ids, session sidecar, source precedence + +# chronos-vscode/ +node scripts/rpc-spike.mjs [path-to-pi] # asserts the pi --mode rpc JSONL contract. Run after upgrading the global pi. +node test/collection-id-test.mjs # host reads collection id vs display name +node test/data-key-equivalence-test.mjs # host's data-key mirror vs the agent's real derivation (needs chronos/dist) +node test/run-ui-test.mjs # launches VS Code against a fixture workspace; panel + webview + RPC boot + nested sources +``` + +`test/suite.js` and `test/data-key-equivalence-test.mjs` import `chronos/dist/` to derive their +expected values instead of hardcoding them, so **a stale `dist` makes them agree with themselves**. +`npm test` builds the agent first for that reason; if you invoke the scripts directly, build it +yourself. Note `chronos/dist` is gitignored and survives branch switches. + See `chronos-vscode/TESTING.md` for the manual smoke checklist. ## Architecture — the big picture @@ -69,7 +91,7 @@ Key tools: `task`/`task_batch` (spawn persistent vision-expert subagents per pag ### Workspace layout (user-facing, created by `Chronos: Init Workspace`) -A Chronos *workspace* (separate from this repo) contains `sources//png/page_NNNN.png`, `data/` (outputs), `memory/` (`MEMORY.MD` + per-source `.md`, injected into the system prompt), `skills//SKILL.md`, `sessions/`, and `.chronos/` (`.env` with provider API keys e.g. `ANTHROPIC_API_KEY`/`GEMINI_API_KEY`, written by the panel's "Log in" flow; `settings.json` for the `yolo` flag; `session-sources.json` mapping session id → selected source for resume; `session-names.json` caching auto-generated session titles). The workspace `skills/` dir is bridged into pi via `.pi/settings.json` (`{ "skills": ["../skills"] }`). +A Chronos *workspace* (separate from this repo) contains `sources//png/page_NNNN.png`, `data/` (outputs), `memory/` (`MEMORY.MD` + per-source `.md`, injected into the system prompt), `skills//SKILL.md`, `sessions/`, and `.chronos/` (`.env` with provider API keys e.g. `ANTHROPIC_API_KEY`/`GEMINI_API_KEY`, written by the panel's "Log in" flow; `settings.json` for the `yolo` flag; `session-collections.json` mapping session id → `{ name?, extraMembers? }` — the selected collection plus any sources added mid-session via `change_source`, replayed on resume; `session-names.json` caching auto-generated session titles). The workspace `skills/` dir is bridged into pi via `.pi/settings.json` (`{ "skills": ["../skills"] }`). ## Reference 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/TESTING.md b/chronos-vscode/TESTING.md index 0216815..6bfa49e 100644 --- a/chronos-vscode/TESTING.md +++ b/chronos-vscode/TESTING.md @@ -14,13 +14,43 @@ fact that slash-command `prompt` responses arrive only after the handler finishes). Run this after upgrading the global `pi` install to detect protocol drift. Last verified: pi 0.79.1 (2026-06-11). +## Agent canaries (`chronos/`) + +``` +cd ../chronos && npm test +``` + +Builds `dist/` then runs all four canaries — image downscaling, expert +retry/backoff, per-attempt timeout-as-abort, and collection context (ids, +session sidecar, source precedence). They import the **build output**, so the +build step is not optional. + +## Host tests + +``` +node test/collection-id-test.mjs # collection id vs display name +node test/data-key-equivalence-test.mjs # host data-key mirror vs the agent's real derivation +``` + +`data-key-equivalence-test.mjs` imports the agent's compiled `deriveRef`/ +`dataKeyForRef` from `chronos/dist` and compares them against the host's +`src/panel/data-key.ts` mirror over a case table, with no expected-value literal +— so the unavoidable duplication across the two packages is self-policing. +It needs `chronos/dist` built (`npm run build:agent`). + ## VS Code integration test ``` -npm test # builds, then runs test/run-ui-test.mjs +npm test # builds the agent + bundle, then host tests, then the UI test node test/run-ui-test.mjs ``` +`npm test` builds `chronos/` first because `test/suite.js` derives its expected +nested-source data keys from `chronos/dist` rather than hardcoding them — a +**stale or missing `dist` makes the test agree with itself** (or fail with an +opaque module error). `chronos/dist` is gitignored and survives branch switches, +so it can silently hold another branch's build. + Launches VS Code (local binary) with the dev extension against a fixture workspace and drives it against **`test/mock-pi.mjs`** — a stub that speaks the `pi --mode rpc` JSONL contract, so no real pi binary or API keys are needed. The @@ -39,6 +69,11 @@ via a test-only `__test/invoke` / `__test/dump` message pair (see `webview-protocol.ts`); the mock varies behavior by prompt prefix (`select:` / `tool:` / anything → echo). +**Known flake:** this test failed roughly 1 run in 4 on unchanged code when last +measured (2026-07-28) — it drives a real VS Code instance with `waitFor` polling, +so it is timing-sensitive. Treat a single red run as inconclusive: re-run before +concluding a change broke it, and do not treat one green run as a release gate. + ## Manual smoke checklist (combined viewer + chat UI) Chat is the only UI. The automated test above covers prompt/assistant rendering, diff --git a/chronos-vscode/package.json b/chronos-vscode/package.json index 05ac599..4ac3490 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." } } }, @@ -138,7 +178,8 @@ "build": "node esbuild.mjs", "watch": "node esbuild.mjs --watch", "package": "vsce package", - "test": "node esbuild.mjs && node test/run-ui-test.mjs" + "build:agent": "npm --prefix ../chronos run build", + "test": "npm run build:agent && node esbuild.mjs && node test/collection-id-test.mjs && node test/data-key-equivalence-test.mjs && node test/run-ui-test.mjs" }, "dependencies": { "dompurify": "^3.4.9", 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/chronos-vscode/src/extension.ts b/chronos-vscode/src/extension.ts index 1bd6cf5..1302999 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 @@ -288,7 +289,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 }); } @@ -302,6 +303,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 @@ -313,6 +318,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) @@ -755,8 +761,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"), diff --git a/chronos-vscode/src/panel/chronos-panel.ts b/chronos-vscode/src/panel/chronos-panel.ts index 6280c3b..130a312 100644 --- a/chronos-vscode/src/panel/chronos-panel.ts +++ b/chronos-vscode/src/panel/chronos-panel.ts @@ -7,7 +7,8 @@ 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 { deriveDataKeyFallback } from "./data-key"; import { listSessions, readSessionMessages } from "./sessions"; import { beginAnthropicLogin, @@ -162,11 +163,39 @@ export class ChronosPanel { // source resolve against the current source. private currentSourceDir: string | undefined; private currentSourceName: string | undefined; + + // The agent owns the data-dir key: flat sources use basename(path), nested + // refs are slugged (city/X -> city--X). The cache of what the agent has + // already told us wins when present (cheapest, and exactly what the agent + // is using); `deriveDataKeyFallback` (./data-key.ts) covers a cold cache — + // e.g. resuming a session and citing a nested source the agent hasn't sent + // a viewer message about yet this run — by mirroring the agent's own + // derivation instead of a bare `basename`, which is wrong for nested + // sources (see data-key.ts for why the duplication is unavoidable). + private dataKeyBySourceDir = new Map(); + + private rememberDataKey(sourceDir: string, dataKey: string): void { + if (sourceDir && dataKey) this.dataKeyBySourceDir.set(sourceDir, dataKey); + } + + private dataKeyForSourceDir(sourceDir: string): string { + return this.dataKeyBySourceDir.get(sourceDir) ?? deriveDataKeyFallback(this.workspaceDir, sourceDir); + } + private firstPage = 1; private lastPage = 1; // 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 id (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 +241,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 +341,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 @@ -373,7 +404,17 @@ export class ChronosPanel { const sources = existsSync(sourcesDir) ? discoverSources(sourcesDir) : []; this.post({ type: "sources", - sources: sources.map((s) => ({ name: s.name, pageCount: countPages(s.path) })), + // dataKey lets the webview match the dropdown option against currentSource + // (also a data key) without re-deriving it — see dataKeyForSourceDir above. + sources: sources.map((s) => ({ name: s.name, pageCount: countPages(s.path), dataKey: this.dataKeyForSourceDir(s.path) })), + }); + } + + private postCollections(): void { + this.post({ + type: "collections", + collections: discoverCollections(this.workspaceDir), + active: this.activeCollection, }); } @@ -554,14 +595,19 @@ export class ChronosPanel { handleHttpMessage(msg: AgentToExtensionMessage): void { switch (msg.type) { case "show_page": + this.rememberDataKey(msg.sourceDir, msg.sourceName); this.showPage(msg.sourceDir, msg.sourceName, msg.pageId, msg.bbox, msg.totalPages); break; case "page_list": + this.rememberDataKey(msg.sourceDir, msg.sourceName); this.firstPage = msg.firstPage; this.lastPage = msg.lastPage; this.post({ type: "viewer/updateRange", firstPage: msg.firstPage, lastPage: msg.lastPage }); break; case "show_text": + // ShowTextMessage carries only sourceName, not sourceDir (see http-client.ts) + // — nothing to key the cache on here; currentSourceName is already correct + // as sent, so this is a no-op w.r.t. the bug this cache fixes. this.currentSourceName = msg.sourceName; this.post({ type: "viewer/showText", @@ -571,6 +617,13 @@ export class ChronosPanel { sourceName: msg.sourceName, }); break; + case "collection": + this.activeCollection = msg.id; + 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 +664,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,8 +684,8 @@ export class ChronosPanel { let sourceDir = this.currentSourceDir; let sourceName = this.currentSourceName; if (sourcePath) { - sourceDir = isAbsolute(sourcePath) ? sourcePath : join(this.workspaceDir, sourcePath); - sourceName = basename(sourceDir); + sourceDir = this.resolveCitedSource(sourcePath); + sourceName = this.dataKeyForSourceDir(sourceDir); } if (!sourceDir) return; this.post({ @@ -635,8 +701,8 @@ export class ChronosPanel { let sourceDir = this.currentSourceDir; let sourceName = this.currentSourceName; if (sourcePath) { - sourceDir = isAbsolute(sourcePath) ? sourcePath : join(this.workspaceDir, sourcePath); - sourceName = basename(sourceDir); + sourceDir = this.resolveCitedSource(sourcePath); + sourceName = this.dataKeyForSourceDir(sourceDir); } if (!sourceDir || !sourceName) return; const totalPages = sourceDir === this.currentSourceDir ? undefined : countPages(sourceDir); @@ -667,36 +733,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 +825,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 +878,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.id ?? "(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/data-key.ts b/chronos-vscode/src/panel/data-key.ts new file mode 100644 index 0000000..5b9f276 --- /dev/null +++ b/chronos-vscode/src/panel/data-key.ts @@ -0,0 +1,50 @@ +/** + * Fallback derivation of a source's `data//` directory name, for use only + * when the agent hasn't yet told the host what key it's using for a given + * source directory this session (a cold `dataKeyBySourceDir` cache entry — see + * `ChronosPanel.dataKeyForSourceDir` in `chronos-panel.ts`). + * + * This deliberately mirrors — line for line — the agent's own derivation: + * - `toSlug` in `chronos/utils/source-discovery.ts` + * - `deriveRef` and `dataKeyForRef` in `chronos/tools/collection-context.ts` + * + * `chronos-vscode` cannot import those modules directly (this package's + * `tsconfig.json` sets `rootDir: "src"`, and the two packages are built and + * published independently — see CLAUDE.md), so the trivial slug transform is + * duplicated across the package boundary on purpose. If the agent's logic in + * the files above ever changes, update this file to match — that drift is + * exactly what `test/data-key-equivalence-test.mjs` checks for. + * + * Kept dependency-free (no `vscode` import) so it can be compiled and run + * standalone by that test. + */ +import { basename, isAbsolute, join, relative, sep } from "node:path"; + +/** Mirrors chronos/utils/source-discovery.ts's toSlug. */ +export function toSlug(rel: string): string { + return rel.replace(/[\\/]/g, "--").replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +/** Mirrors chronos/tools/collection-context.ts's refFromRelative. Translates the + * PLATFORM separator only — on POSIX a backslash is a legal filename character, + * and folding it would collide a dir named `city\Nested` with `city/Nested`. */ +export function refFromRelative(rel: string, pathSep: string = sep): string { + return rel.split(pathSep).join("/"); +} + +/** Mirrors chronos/tools/collection-context.ts's deriveRef. */ +export function deriveRef(workspaceDir: string, sourcePath: string): string { + const rel = relative(join(workspaceDir, "sources"), sourcePath); + return rel && !rel.startsWith("..") && !isAbsolute(rel) ? refFromRelative(rel) : basename(sourcePath); +} + +/** Mirrors chronos/tools/collection-context.ts's dataKeyForRef. */ +export function dataKeyForRef(ref: string, sourcePath: string): string { + return ref.includes("/") ? toSlug(ref) : basename(sourcePath); +} + +/** The full derivation the agent uses for a source's data dir name, applied to + * a bare source directory (no pre-computed ref available on the host side). */ +export function deriveDataKeyFallback(workspaceDir: string, sourceDir: string): string { + return dataKeyForRef(deriveRef(workspaceDir, sourceDir), sourceDir); +} 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..23205d2 100644 --- a/chronos-vscode/src/panel/sources.ts +++ b/chronos-vscode/src/panel/sources.ts @@ -1,11 +1,49 @@ -import { readdirSync, statSync, existsSync } from "node:fs"; +import { readdirSync, statSync, existsSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; +import { refFromRelative } from "./data-key.js"; export interface SourceInfo { name: string; path: string; } +export interface CollectionInfo { + /** The filename stem — the collection's stable identity; `name` is display-only. */ + id: string; + 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")); + const id = f.replace(/\.json$/, ""); + out.push({ + id, + name: typeof m.name === "string" ? m.name : id, + 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 { @@ -29,7 +67,16 @@ export function discoverSources(rootDir: string): SourceInfo[] { } if (existsSync(join(dir, "png")) && statSync(join(dir, "png")).isDirectory()) { - sources.push({ name: relative(rootDir, dir), path: dir }); + // Normalize to forward slashes so this matches the agent's ref exactly. + // relative() is platform-native (backslash-joined on win32); the agent's + // collectionCtx member ref is always normalized (see collection-context.ts's + // deriveRef), and /select-source compares `name` against that ref verbatim + // (chronos-panel.ts sends `msg.name` straight through as the ref argument). + // Without this, a nested source's name would never match its ref on win32. + // Uses the shared helper rather than a third copy of the transform, and + // translates only the platform separator — a POSIX dir named `city\Nested` + // is ONE component and must keep its backslash. + sources.push({ name: refFromRelative(relative(rootDir, dir)), path: dir }); return; } diff --git a/chronos-vscode/src/panel/webview-protocol.ts b/chronos-vscode/src/panel/webview-protocol.ts index e821c2d..81ab4bc 100644 --- a/chronos-vscode/src/panel/webview-protocol.ts +++ b/chronos-vscode/src/panel/webview-protocol.ts @@ -48,7 +48,16 @@ export type ExtToWebview = | { type: "permissionRequest"; id: string; command: string; suggestedPrefix: string } | { type: "yolo"; enabled: boolean } // workspace data - | { type: "sources"; sources: { name: string; pageCount: number }[] } + // dataKey is the agent's data-dir key for this source (basename for a flat + // source, a slug for a nested one) — the same identity space as currentSource, + // so the webview can match the dropdown selection on it directly. + | { type: "sources"; sources: { name: string; pageCount: number; dataKey: string }[] } + | { + type: "collections"; + collections: { id: string; name: string; description?: string; memberCount: number }[]; + // Active collection id; 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 +103,8 @@ export type WebviewToExt = | { type: "setModel"; provider: string; modelId: string } | { type: "setThinkingLevel"; level: ThinkingLevel } | { type: "selectSource"; name: string } + // pick the active collection; id null = the auto "all sources" collection + | { type: "selectCollection"; id: 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..44887c8 100644 --- a/chronos-vscode/src/protocol.ts +++ b/chronos-vscode/src/protocol.ts @@ -34,10 +34,22 @@ export interface ErrorMessage { message: string; } +// The active collection: its id (the stable identity the picker matches +// against; null = the auto "all sources" collection), its display name, 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"; + id: string | null; + 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..b8a7bab --- /dev/null +++ b/chronos-vscode/src/workspace-templates.ts @@ -0,0 +1,92 @@ +// 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 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 + \`/entities.json\` — you may be resuming an earlier run. Your system prompt + names the exact directory; do not construct the path yourself. + +## 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 \`entities.json\` in the collection data dir named in your system prompt (do not construct +the path yourself) — 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/test/collection-id-test.mjs b/chronos-vscode/test/collection-id-test.mjs new file mode 100644 index 0000000..431e631 --- /dev/null +++ b/chronos-vscode/test/collection-id-test.mjs @@ -0,0 +1,39 @@ +// The host duplicates the agent's collection discovery (see sources.ts), so it +// needs the same id/name split or the picker sends a value the agent cannot +// resolve. +import { build } from "esbuild"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +let failures = 0; +const check = (name, cond, detail = "") => { + if (cond) console.log(`PASS ${name}`); + else { console.log(`FAIL ${name}${detail ? " — " + detail : ""}`); failures++; } +}; + +const outfile = join(mkdtempSync(join(tmpdir(), "ch-src-")), "sources.mjs"); +await build({ + entryPoints: [join(here, "../src/panel/sources.ts")], + outfile, bundle: true, format: "esm", platform: "node", +}); +const { discoverCollections } = await import(outfile); + +const ws = mkdtempSync(join(tmpdir(), "ch-ws-")); +mkdirSync(join(ws, "collections"), { recursive: true }); +writeFileSync(join(ws, "collections", "frankfurt.json"), + JSON.stringify({ name: "Frankfurt Directories", members: [{ path: "sources/x" }] })); +writeFileSync(join(ws, "collections", "mainz.json"), + JSON.stringify({ members: [{ path: "sources/y" }] })); + +const list = discoverCollections(ws); +const fr = list.find((c) => c.name === "Frankfurt Directories"); +check("host exposes the filename stem as id", fr?.id === "frankfurt", JSON.stringify(fr)); +check("host keeps the display name", fr?.name === "Frankfurt Directories", JSON.stringify(fr)); +const mz = list.find((c) => c.id === "mainz"); +check("nameless manifest -> name falls back to the stem", mz?.name === "mainz", JSON.stringify(mz)); + +console.log(failures === 0 ? "\ncollection id test OK" : `\n${failures} FAILURE(S)`); +process.exit(failures === 0 ? 0 : 1); diff --git a/chronos-vscode/test/data-key-equivalence-test.mjs b/chronos-vscode/test/data-key-equivalence-test.mjs new file mode 100644 index 0000000..143cc03 --- /dev/null +++ b/chronos-vscode/test/data-key-equivalence-test.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env node +// Equivalence test for the source data-dir key (Task 7, finding F3/duplication +// risk): chronos-vscode cannot import chronos/'s TS sources directly (this +// package's tsconfig sets rootDir: "src", and the two packages are built and +// published independently — see CLAUDE.md), so +// chronos-vscode/src/panel/data-key.ts duplicates the trivial slug transform +// the agent uses to name a source's data// directory. That duplication +// is only safe as long as the two implementations agree. +// +// This test proves they agree — for a table of cases, not a single hardcoded +// string — by importing BOTH sides of the boundary and comparing their output +// directly, with no expected-value literal anywhere: +// - the agent's real, compiled `deriveRef`/`dataKeyForRef` from +// chronos/dist/tools/collection-context.js (run `cd chronos && npm run +// build` first — this test reads the build output, same as +// chronos/scripts/collection-canary.mjs and test/suite.js do) +// - the host's `deriveDataKeyFallback` from +// chronos-vscode/src/panel/data-key.ts, compiled on the fly with esbuild +// (already a devDependency here) since it's plain TypeScript, not part of +// the extension bundle +// +// If chronos/'s derivation ever changes without a matching update to +// data-key.ts, this test fails instead of the two silently drifting apart. +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, sep, dirname } from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import * as esbuild from "esbuild"; + +const here = dirname(fileURLToPath(import.meta.url)); +const chronosDist = join(here, "..", "..", "chronos", "dist"); +const hostSrc = join(here, "..", "src", "panel", "data-key.ts"); +const hostSourcesSrc = join(here, "..", "src", "panel", "sources.ts"); + +let failures = 0; +const check = (name, cond, detail = "") => { + if (cond) console.log(`PASS ${name}`); + else { + console.log(`FAIL ${name}${detail ? " — " + detail : ""}`); + failures++; + } +}; + +// ── load the agent's real functions from the build output ────────────────── +let agent; +try { + agent = await import(pathToFileURL(join(chronosDist, "tools", "collection-context.js")).href); +} catch (err) { + console.log( + `FAIL could not import chronos/dist/tools/collection-context.js — run "cd chronos && npm run build" first (${err.message})`, + ); + process.exit(1); +} +const { deriveRef: agentDeriveRef, dataKeyForRef: agentDataKeyForRef } = agent; + +// ── compile the host's mirror on the fly (it's plain TS, not bundled) ────── +const built = await esbuild.build({ + entryPoints: [hostSrc], + bundle: false, + write: false, + format: "esm", + platform: "node", + target: "node18", +}); +const hostModuleSource = built.outputFiles[0].text; +const hostModule = await import( + `data:text/javascript;base64,${Buffer.from(hostModuleSource).toString("base64")}` +); +const { deriveDataKeyFallback: hostDeriveDataKeyFallback } = hostModule; + +// ── compile the host's real discoverSources (sources.ts), same technique ─── +// bundle: true, unlike data-key.ts above: sources.ts imports refFromRelative from +// ./data-key.js (deliberately — one copy of the separator normalization, not a +// third), and a bare compile leaves that relative specifier unresolvable from the +// data: URL this is imported through. Bundling inlines it; node builtins stay +// external under platform: "node". +const builtSources = await esbuild.build({ + entryPoints: [hostSourcesSrc], + bundle: true, + write: false, + format: "esm", + platform: "node", + target: "node18", +}); +const hostSourcesModule = await import( + `data:text/javascript;base64,${Buffer.from(builtSources.outputFiles[0].text).toString("base64")}` +); +const { discoverSources: hostDiscoverSources } = hostSourcesModule; + +// ── table of cases: every branch dataKeyForRef(deriveRef(...)) distinguishes ─ +const tmpDirs = []; +function mktemp() { + const dir = mkdtempSync(join(tmpdir(), "ch-datakey-eq-")); + tmpDirs.push(dir); + return dir; +} +const ws = mktemp(); + +function agentDataKey(workspaceDir, sourceDir) { + return agentDataKeyForRef(agentDeriveRef(workspaceDir, sourceDir), sourceDir); +} + +const cases = [ + { + label: "in-tree flat source", + sourceDir: join(ws, "sources", "Frankfurt_1864"), + }, + { + label: "in-tree nested source (one level)", + sourceDir: join(ws, "sources", "city", "Nested_1900"), + }, + { + label: "in-tree nested source (two levels)", + sourceDir: join(ws, "sources", "region", "city", "Deep_1850"), + }, + { + label: "in-tree nested source (three levels, different basename collision)", + sourceDir: join(ws, "sources", "a", "b", "c", "Frankfurt_1864"), + }, + { + label: "out-of-tree source (absolute path outside the workspace, added via change_source)", + sourceDir: join(mktemp(), "elsewhere", "Imported_Archive_1900"), + }, +]; + +for (const { label, sourceDir } of cases) { + const expected = agentDataKey(ws, sourceDir); + const actual = hostDeriveDataKeyFallback(ws, sourceDir); + check(`${label}: host matches agent`, actual === expected, `agent=${expected} host=${actual} sourceDir=${sourceDir}`); +} + +// Sanity: the table actually exercises both the slugged and basename branches +// (otherwise every case above could pass vacuously if both sides always fell +// back to the same branch by coincidence). +const nestedCase = agentDataKey(ws, join(ws, "sources", "city", "Nested_1900")); +const flatCase = agentDataKey(ws, join(ws, "sources", "Frankfurt_1864")); +check( + "table exercises both a slugged (nested) and a bare-basename (flat) case", + nestedCase.includes("--") && !flatCase.includes("--"), + `nested=${nestedCase} flat=${flatCase}`, +); + +// Sanity: no path-separator assumption leaked in either implementation — the +// derived key never contains the raw OS separator. +for (const { label, sourceDir } of cases) { + const key = hostDeriveDataKeyFallback(ws, sourceDir); + check(`${label}: data key has no raw path separator`, !key.includes(sep), key); +} + +// ── F1: a case whose sourceDir comes from ACTUAL discoverSources output, not +// a hand-built path. Every case above hand-joins its own sourceDir and calls +// the normalizing agentDeriveRef directly — which is exactly why they can't +// see a bug in discoverSources' own `name` derivation (chronos-vscode's +// discoverSources normalizes `relative()`'s output to forward slashes so it +// matches the agent's ref verbatim; before that fix, a nested source's `name` +// would be platform-native and diverge from the agent's ref on win32). This +// exercises the real (compiled) host discoverSources against real nested +// fixture directories, then checks BOTH that the derived data keys still +// agree and that the discovered `name` itself agrees with the agent's ref — +// the exact string /select-source round-trips through the RPC. +{ + const realWs = mktemp(); + mkdirSync(join(realWs, "sources", "city", "Nested_1900", "png"), { recursive: true }); + writeFileSync(join(realWs, "sources", "city", "Nested_1900", "png", "page_0001.png"), Buffer.alloc(8)); + mkdirSync(join(realWs, "sources", "Flat_1864", "png"), { recursive: true }); + writeFileSync(join(realWs, "sources", "Flat_1864", "png", "page_0001.png"), Buffer.alloc(8)); + + const discovered = hostDiscoverSources(join(realWs, "sources")); + const nested = discovered.find((s) => s.path.endsWith(join("city", "Nested_1900"))); + const flat = discovered.find((s) => s.path.endsWith("Flat_1864")); + check("real discoverSources found the nested fixture", !!nested, JSON.stringify(discovered)); + check("real discoverSources found the flat fixture", !!flat, JSON.stringify(discovered)); + + for (const s of [nested, flat]) { + if (!s) continue; + const expected = agentDataKey(realWs, s.path); + const actual = hostDeriveDataKeyFallback(realWs, s.path); + check(`real discoverSources output "${s.name}": host data key matches agent`, + actual === expected, `agent=${expected} host=${actual}`); + } + + // The discovered `name` itself — not just the derived data key — must equal + // the agent's ref exactly, since chronos-panel.ts sends it verbatim as the + // /select-source argument and the agent matches it against member.ref. + check('discovered nested "name" matches the agent\'s ref (what /select-source compares against)', + nested && nested.name === agentDeriveRef(realWs, nested.path), + `name=${nested?.name} agentRef=${nested ? agentDeriveRef(realWs, nested.path) : undefined}`); + check("discovered name has no raw path separator character other than the canonical forward slash", + !nested || !nested.name.includes("\\"), nested?.name); +} + +// ── the win32 separator branch, which a POSIX host cannot reach via the FS ── +// This is the half of the nested-source fix that lives on the HOST, and it had +// NO coverage: on Linux `relative()` never emits a backslash, so deleting the +// host's normalization left every check above green. Both packages now expose +// refFromRelative with an injectable separator precisely so the win32 branch is +// executable here — and so the two are compared on it, not just on POSIX input. +{ + const { refFromRelative: agentRefFromRelative } = agent; + const { refFromRelative: hostRefFromRelative } = hostModule; + + check("host exposes refFromRelative (the shared separator normalization)", + typeof hostRefFromRelative === "function", typeof hostRefFromRelative); + check("agent exposes refFromRelative", + typeof agentRefFromRelative === "function", typeof agentRefFromRelative); + + if (typeof hostRefFromRelative === "function" && typeof agentRefFromRelative === "function") { + const cases = [ + ["city\\Nested_1900", "\\", "city/Nested_1900", "win32 nested source"], + ["a\\b\\c\\D_1900", "\\", "a/b/c/D_1900", "win32 deeply nested"], + ["Flat_1864", "\\", "Flat_1864", "win32 flat source"], + ["city/Nested_1900", "/", "city/Nested_1900", "posix nested source"], + // The regression guard: on POSIX a backslash is a legal FILENAME character, + // so it must survive — folding it collided a dir named `city\Nested` with a + // genuinely nested `city/Nested` and silently dropped one of them. + ["city\\Nested_1900", "/", "city\\Nested_1900", "posix literal backslash is preserved"], + ]; + for (const [rel, pathSep, expected, label] of cases) { + const a = agentRefFromRelative(rel, pathSep); + const h = hostRefFromRelative(rel, pathSep); + check(`${label}: agent and host agree`, a === h, `agent=${a} host=${h}`); + check(`${label}: result is ${JSON.stringify(expected)}`, a === expected, a); + } + + // The property that actually matters: a nested source gets ONE ref regardless + // of which platform derived it. + check("a nested source's ref is platform-independent (win32 sep == posix sep)", + hostRefFromRelative("city\\Nested_1900", "\\") === hostRefFromRelative("city/Nested_1900", "/")); + } +} + +for (const dir of tmpDirs) rmSync(dir, { recursive: true, force: true }); + +console.log(failures === 0 ? "\ndata-key equivalence OK" : `\n${failures} check(s) FAILED`); +process.exit(failures === 0 ? 0 : 1); diff --git a/chronos-vscode/test/mock-pi.mjs b/chronos-vscode/test/mock-pi.mjs index c487603..ed11f8d 100755 --- a/chronos-vscode/test/mock-pi.mjs +++ b/chronos-vscode/test/mock-pi.mjs @@ -5,8 +5,12 @@ // mock covers several scenarios. Never writes non-JSON to stdout. // // Scenarios (by message prefix): -// "select: …" → POST a show_page over HTTP (establishes the active source), -// then reply with assistant text "source selected". +// "select: …" → POST a show_page over HTTP (establishes the active +// source), then reply with assistant text "source selected". +// "select-nested: …" → same, but for the nested fixture source +// (sources/city/Nested_1900), sending the sourceName the +// real agent would send for it: the slugged data key +// "city--Nested_1900" (basename(dataDir), not basename(path)). // "tool: …" → emit a tool_execution_start/end (list_pages), then "done". // anything → emit assistant text "echo: ". import { createInterface } from "node:readline"; @@ -99,7 +103,17 @@ function emitToolThenAssistant(toolName, text) { function handlePrompt(message) { const m = (message || "").trim(); - if (m.startsWith("select:")) { + if (m.startsWith("select-nested:")) { + httpPost({ + type: "show_page", + pageId: 1, + totalPages: 1, + sourceDir: join(process.cwd(), "sources", "city", "Nested_1900"), + sourceName: "city--Nested_1900", + bbox: null, + }); + emitAssistant("nested source selected"); + } else if (m.startsWith("select:")) { httpPost({ type: "show_page", pageId: 1, diff --git a/chronos-vscode/test/run-ui-test.mjs b/chronos-vscode/test/run-ui-test.mjs index e47a91b..b5235ea 100644 --- a/chronos-vscode/test/run-ui-test.mjs +++ b/chronos-vscode/test/run-ui-test.mjs @@ -19,18 +19,26 @@ const extensionRoot = dirname(dirname(fileURLToPath(import.meta.url))); const mockPi = join(extensionRoot, "test", "mock-pi.mjs"); chmodSync(mockPi, 0o755); -// Fixture workspace: one source; point Chronos at the mock pi. +// Fixture workspace: one flat source, one nested source (sources/city/Nested_1900 — +// the agent slugs this to the data key "city--Nested_1900"; a flat basename() read +// of the directory would wrongly yield "Nested_1900"), plus a second nested source +// (sources/city/Nested_1875) that the mock pi never sends any viewer message about — +// suite.js uses it to exercise the COLD dataKeyBySourceDir cache path (citing a +// nested source the host has never been told the data key for), point Chronos at +// the mock pi. const fixture = join(tmpdir(), `chronos-ui-test-${process.pid}`); mkdirSync(join(fixture, "sources", "TestSource", "png"), { recursive: true }); +mkdirSync(join(fixture, "sources", "city", "Nested_1900", "png"), { recursive: true }); +mkdirSync(join(fixture, "sources", "city", "Nested_1875", "png"), { recursive: true }); mkdirSync(join(fixture, ".vscode"), { recursive: true }); mkdirSync(join(fixture, ".chronos"), { recursive: true }); -writeFileSync( - join(fixture, "sources", "TestSource", "png", "page_0001.png"), - Buffer.from( - "iVBORw0KGgoAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - "base64", - ), +const tinyPng = Buffer.from( + "iVBORw0KGgoAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", ); +writeFileSync(join(fixture, "sources", "TestSource", "png", "page_0001.png"), tinyPng); +writeFileSync(join(fixture, "sources", "city", "Nested_1900", "png", "page_0001.png"), tinyPng); +writeFileSync(join(fixture, "sources", "city", "Nested_1875", "png", "page_0001.png"), tinyPng); writeFileSync(join(fixture, ".vscode", "settings.json"), JSON.stringify({ "chronos.piPath": mockPi }, null, 2)); writeFileSync(join(fixture, ".chronos", ".env"), ""); diff --git a/chronos-vscode/test/suite.js b/chronos-vscode/test/suite.js index 851b4bd..2a7386d 100644 --- a/chronos-vscode/test/suite.js +++ b/chronos-vscode/test/suite.js @@ -4,6 +4,7 @@ const vscode = require("vscode"); const { mkdirSync, writeFileSync } = require("node:fs"); const { join } = require("node:path"); +const { pathToFileURL } = require("node:url"); function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); @@ -206,6 +207,103 @@ exports.run = async function run() { }); check("expert drawer surfaces tool-use viewer links + flagged elevated actions", true); + // The expected data-dir key for each nested fixture source is computed here + // from the agent's OWN derivation (chronos/tools/collection-context.js, + // built to chronos/dist by `cd chronos && npm run build`) rather than + // hardcoded — a literal like "city--Nested_1900" would just be restating + // what the mock happens to send, not proving the host derives it correctly. + // `npm test` builds chronos/ first, so this import is fresh. Invoked directly + // (node test/run-ui-test.mjs) on a checkout that has never built the agent, + // chronos/dist does not exist — dist is gitignored — and a bare dynamic + // import would surface as an opaque ERR_MODULE_NOT_FOUND from inside the + // extension host. Say what to run instead. + const collectionContext = join(__dirname, "..", "..", "chronos", "dist", "tools", "collection-context.js"); + let deriveRef, dataKeyForRef; + try { + ({ deriveRef, dataKeyForRef } = await import(pathToFileURL(collectionContext).href)); + } catch (err) { + throw new Error( + `could not import ${collectionContext} — the agent package is not built. ` + + `Run "npm test" (which builds it) or "cd chronos && npm run build" first. Cause: ${err.message}`, + ); + } + const expectedDataKeyFor = (relSourcePath) => { + const sourceDir = join(ws, "sources", ...relSourcePath.split("/")); + return dataKeyForRef(deriveRef(ws, sourceDir), sourceDir); + }; + + // 10. Nested sources (blocker 4): the agent slugs a nested source's data key + // (sources/city/Nested_1900 -> data/city--Nested_1900), but the host used + // to re-derive sourceName via basename(sourceDir) whenever a citation + // carried an explicit chronos_source — even one naming the already-active + // source — clobbering currentSource with the raw directory basename. + const expectedNestedKey = expectedDataKeyFor("city/Nested_1900"); + api.chronosTest.invoke("sendPrompt", "select-nested: city/Nested_1900"); + await waitFor("nested source active in viewer", async () => (await dump())?.currentSource === expectedNestedKey); + check("agent-initiated show_page resolves the nested source to its slug", true); + + const nestedDataDir = join(ws, "data", expectedNestedKey); + mkdirSync(nestedDataDir, { recursive: true }); + writeFileSync( + join(nestedDataDir, "citation.json"), + JSON.stringify( + [{ name: "nested row", chronos_page: 1, chronos_bbox: [0.1, 0.1, 0.4, 0.1], chronos_source: "city/Nested_1900" }], + null, + 2, + ), + ); + api.chronosTest.invoke("openDataTab"); + await waitFor("nested data file listed", async () => (await dump())?.data?.files?.includes("citation.json")); + api.chronosTest.invoke("selectDataFile", "citation.json"); + await waitFor("nested data file selected", async () => (await dump())?.data?.selected === "citation.json"); + + // The row cites its own (already-active) source explicitly via chronos_source + // — this is what drives previewSource/openViewLink down the sourcePath branch + // even though the cited source is already current. This exercises the WARM + // cache path only: dataKeyBySourceDir already has an entry for this exact + // directory from the show_page above. + api.chronosTest.invoke("viewFirstRow"); + api.chronosTest.invoke("showFullPage"); + await waitFor("citation click round-trips to the source viewer", async () => (await dump())?.viewerTab === "page"); + const afterCitation = await dump(); + check( + "citation click for a nested source resolves currentSource to the agent's data-dir slug, not basename(sourceDir)", + afterCitation?.currentSource === expectedNestedKey, + `currentSource=${afterCitation?.currentSource}, expected=${expectedNestedKey}`, + ); + + // 11. Cold cache (F2 regression): the check above only proves the WARM path + // (citing a source right after its own show_page primed the cache). + // Resuming a session, or reloading VS Code, then clicking a citation for + // a nested source the agent hasn't mentioned yet THIS session is the + // ordinary case that hit the bug — dataKeyBySourceDir has no entry for + // sources/city/Nested_1875 at all here; nothing above ever sent a + // show_page/page_list naming it. The host must fall back to deriving + // the key (data-key.ts), not basename(sourceDir). + const expectedColdKey = expectedDataKeyFor("city/Nested_1875"); + writeFileSync( + join(nestedDataDir, "cold-citation.json"), + JSON.stringify( + [{ name: "cold row", chronos_page: 1, chronos_bbox: [0.1, 0.1, 0.4, 0.1], chronos_source: "city/Nested_1875" }], + null, + 2, + ), + ); + api.chronosTest.invoke("openDataTab"); + await waitFor("cold-cache data file listed", async () => (await dump())?.data?.files?.includes("cold-citation.json")); + api.chronosTest.invoke("selectDataFile", "cold-citation.json"); + await waitFor("cold-cache data file selected", async () => (await dump())?.data?.selected === "cold-citation.json"); + + api.chronosTest.invoke("viewFirstRow"); + api.chronosTest.invoke("showFullPage"); + await waitFor("cold-cache citation click round-trips to the source viewer", async () => (await dump())?.viewerTab === "page"); + const afterColdCitation = await dump(); + check( + "citation click for a NEVER-VISITED nested source resolves to the agent's data-dir slug (cold cache), not basename(sourceDir)", + afterColdCitation?.currentSource === expectedColdKey, + `currentSource=${afterColdCitation?.currentSource}, expected=${expectedColdKey}`, + ); + // Make sure the subprocess stayed alive throughout const after = api.getChronosStatus(); check("pi subprocess still alive", after?.agentStatus === "ready", after?.lastError); diff --git a/chronos-vscode/webview/components/chronos-app.ts b/chronos-vscode/webview/components/chronos-app.ts index 7b677fb..3175a21 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 }, @@ -43,13 +45,16 @@ export class ChronosApp extends LitElement { declare state: RpcSessionState | null; declare models: ModelInfo[]; - declare sources: { name: string; pageCount: number }[]; + declare sources: { name: string; pageCount: number; dataKey: string }[]; declare sessions: ChronosSessionInfo[]; declare drawerOpen: boolean; declare uiRequest: RpcExtensionUIRequest | null; declare toasts: Toast[]; declare splitPct: number; declare currentSource: string; + declare collections: { id: string; name: string; description?: string; memberCount: number }[]; + /** The active collection's id (null = the auto "all sources" collection). */ + declare activeCollection: string | null; declare yolo: boolean; declare contextTokens: number; declare sessionLoading: { title?: string; name: string; sizeBytes?: number } | null; @@ -71,6 +76,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 +202,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 +386,26 @@ export class ChronosApp extends LitElement { Chronos
+ ${this.collections.length > 0 + ? html`` + : nothing} `. Immediately **after** that closing `` and before the next `