Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4599aed
feat(console): give Sources a way into the files behind it
siracusa5 Aug 7, 2026
17c69d6
feat(console): make the file list a tree that scales to a real vault
siracusa5 Aug 7, 2026
abd8ab3
feat(core): let a folder-backed source be repointed, not re-added
siracusa5 Aug 7, 2026
c995760
feat(desktop): reveal a source's files in Finder, resolved in the mai…
siracusa5 Aug 7, 2026
e504691
feat(console): walk between a file and the concept it resolves to
siracusa5 Aug 7, 2026
46c0cd7
feat(console): edit a source's folder instead of removing and re-addi…
siracusa5 Aug 7, 2026
4ba2308
feat(console): let the Web Demo browse the files behind its sources
siracusa5 Aug 7, 2026
089f627
docs: document the source navigator
siracusa5 Aug 7, 2026
6949b47
fix(console): refetch the file listing when a source moves, not when …
siracusa5 Aug 7, 2026
aa26de8
fix(console): stop deep nesting from indenting a tree row's name to n…
siracusa5 Aug 7, 2026
b840722
fix(console): keep the build machine's home directory out of the Web …
siracusa5 Aug 7, 2026
e413a7b
fix(core): type the patched source path before coercing it
siracusa5 Aug 7, 2026
bff562d
fix(console): guard Back on the document it opens, and put the whole …
siracusa5 Aug 7, 2026
eeb8944
fix(desktop): let one broken source stop breaking Reveal in Finder fo…
siracusa5 Aug 7, 2026
353e27f
fix(console): stop two tree rows from sharing one id
siracusa5 Aug 7, 2026
c4012d4
fix(console): put the focused tree row back in DOM order
siracusa5 Aug 7, 2026
f791557
fix(console): keep the tree's keyboard alive while a search is running
siracusa5 Aug 7, 2026
7d4a35c
docs: say what the navigator actually promises about focus and filters
siracusa5 Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ Key files:
- **`indexing` means "no answer yet", not "working".** A source with a snapshot that is being re-read stays `status: "ready"` and raises the additive `indexing.refreshing` flag, so a background refresh never flips a usable source (or the console's spinner) back to unready. `awaitIndexes` (`?wait=`) waits on the running pass, not on `status`. A pass dirtied mid-flight owes exactly one follow-up, and that follow-up waits out a quiet period as long as the last pass took (1–15s) — without it, sustained editing chained a full re-walk per edit forever.
- **Poll `/api/status`, not `/api/graph`.** `/api/status` is O(sources) — index progress and per-source state, no resolve, no tokenize — and carries a `generation` counter that moves whenever the *content* of the graph payload would differ. Progress-only fields (`indexing.elapsedMs`, `passes`) are deliberately outside it — a counter that moved every millisecond through an index would defeat the poll. `generation` moving is a necessary condition for a refetch, not a sufficient one; the console additionally compares a per-source content signature. `/api/graph`'s expensive half (concept rows + `resolvedTokens`) is memoized on the identity of the snapshots it read, and per-concept token counts come from the index rather than a per-request BPE encode; that cache is keyed by live state on every request rather than cleared by an invalidation event, precisely so there is no trigger to forget. Never reintroduce a per-request `countTokens` over the corpus — it cost 14.5s per call on a 139MB vault the console was polling every 900ms.
- **The indexing limits are user settings, not env-only** (`settings.mjs`, `GET`/`PATCH /api/settings`, Settings → Indexing in the console). Precedence is manifest > env > default — the manifest has to win or the settings UI would silently do nothing. Env vars (`CONTEXTCAKE_MAX_DOC_FILES`, `CONTEXTCAKE_MAX_SCAN_ENTRIES`, `CONTEXTCAKE_SOURCE_BUDGET_MS`) remain the headless/CI fallback.
- **`PATCH /api/sources` can repoint a folder-backed source** (`path`, for the `local`/`files` kinds) — the same cheap `probeFolder` the add path uses, so don't put a full walk on it either. It is refused for `github`/`github-rest`/`mcp` and for a clone-backed layer, whose folder belongs to Sync (`gitCloneOrPull` writes `CACHE_DIR/<slug>`, never `layer.path`). A new folder is a new *content identity*, so `adoptIndexes` finds nothing to carry and the source re-indexes from zero — measured on a 3,000-note vault: `status: "indexing"`, `conceptCount: 0`, ~16s, and the old folder's concepts stop resolving immediately. That is correct, not a gap in adoption: the snapshot it would have carried indexes a folder this source no longer reads. The response says `reindexing: true` so the client can name the cause before the row goes blue.
- **Add-source validation is deliberately cheap**: only "folder is missing" and "that's a file" fail the form. A too-big folder is a normal thing to add — it becomes a visible source error after indexing, with a pointer at Settings. Don't reintroduce a full walk on the add path. MCP sources still probe (`tools/list`) at add time, which is bounded and catches a wrong command.
- **Retrieval is measured, not asserted.** `npm test` runs the eval; a ranking change that loses recall fails the build with `RETRIEVAL REGRESSED`. If a change is a deliberate trade, re-record with `--record --label "<why>"` — the superseded numbers stay in `baseline.json`'s history so the trade is visible later. Do not tune the stemmer or the field boosts against the golden questions: the set is small enough to overfit in an afternoon, and a scorer fitted to its own eval measures nothing. Add questions first, then tune.
- **Layer file APIs live in the engine, not the playground** (`layer-files.mjs`), so the desktop app can browse and edit context files. They cover `files`-kind layers too — the playground's old copy only mapped `okf-local` roots, which made markdown folders invisible in the editor.
- **Layer file APIs live in the engine, not the playground** (`layer-files.mjs`), so the desktop app can browse and edit context files. They cover `files`-kind layers too — the playground's old copy only mapped `okf-local` roots, which made markdown folders invisible in the editor. The console's Web Demo browses the same tree read-only: `apps/console/scripts/build-demo-data.mjs` calls `listFilesApi`/`readFileApi` over `apps/playground/demo-layers/` at build time. Like the resolved concepts beside it, that fixture is generated from real engine output — never hand-authored — and it captures only the two GET answers, so the demo has no write path to fake.
39 changes: 31 additions & 8 deletions apps/console/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ release builds and deploys the matching public Web Demo from the same commit.

The gates are `npm run typecheck` (strict, `noUnusedLocals`/`noUnusedParameters`)
and `npm test`; CI runs both. dev/build/typecheck/test all regenerate
`src/generated/demo-cascade.json` (gitignored) via their pre-hooks.
`src/generated/demo-cascade.json` + `src/generated/demo-files.json` (gitignored)
via their pre-hooks.

## Architecture

Expand All @@ -47,12 +48,24 @@ and `npm test`; CI runs both. dev/build/typecheck/test all regenerate
- **Views** — `src/views/` (Canvas, Overview, Sources, Triage, Conflicts,
Concepts, Files). `App.tsx` is the shell: topbar + subbar + routed view, plus
the Triage S/R/D keyboard handler. The canvas view stays full-height inside
the chrome. Files is live-mode only: it browses and edits the real files
behind each layer through the engine's `/api/files` + `/api/file`, with a
rendered/raw toggle for Markdown. Sources manages the layers themselves —
rename + re-level (PATCH `/api/sources`), remove with confirm (DELETE),
Sync-now for github kinds (POST `/api/sources/sync`) — read-only in demo
mode; `live: true` layers get a capture warning on rename/remove.
the chrome. Files browses and edits the real files behind each layer through
the engine's `/api/files` + `/api/file`, with a rendered/raw toggle for
Markdown. It renders in demo mode too, read-only, over the generated
`demo-files.json` snapshot (see **Data**): same tree, same documents, same
cross-links, no Save — `canEdit = live && file.editable` gates the save
button, the ⌘S binding and the textarea, and the raw-preview fetch is skipped
because a snapshot carries text, not bytes. Sources manages the layers themselves —
rename + re-level + repoint a folder-backed source (PATCH `/api/sources`),
remove with confirm (DELETE), Sync-now for github kinds (POST
`/api/sources/sync`) — read-only in demo mode; `live: true` layers get a
capture warning on rename/remove.
- **Files ⇄ Concepts** — the two ends of one thing, and walkable both ways. An
open document names the concept it resolves to (`conceptForFile`: the file's
`rel` minus its document extension, matched against a loaded concept id —
verified 3,000/3,000 against a real `files` layer); each contributor in
`ConceptDetail` gets an "Open file" link, but only where `/api/files` lists a
file for that (source, concept id) pair, so an MCP or REST-read contributor
gets no affordance rather than one that opens on an error.
- **Setup wizard** — `src/components/SetupWizard.tsx` has two shapes from one
component: the first-run guided narrative (personal → optional team →
optional company MCP → review) and a one-step add-a-source mode (four-kind
Expand All @@ -71,7 +84,11 @@ and `npm test`; CI runs both. dev/build/typecheck/test all regenerate
- **Data** — `src/api.ts` is the single seam: demo mode imports a bundle
generated at build time by shelling out to the real `packages/core/src/resolver.mjs`
(`scripts/build-demo-data.mjs`), live mode fetches the same-origin playground
API (`/api/status`, `/api/graph`, `/api/resolve-all`). Adapters map wire types (`types.ts`)
API (`/api/status`, `/api/graph`, `/api/resolve-all`). `src/layer-files.ts` is
the same seam for files: the demo half of `demo-files.json` is one
`listFilesApi` listing plus a `readFileApi` answer per path, produced by
calling the engine's own file APIs — never hand-authored, and read-only
because only the two GET answers are captured. Adapters map wire types (`types.ts`)
onto the view model in `src/data.ts`, deriving provenance from contributor
levels. `src/data.ts` keeps only lane semantics and the demo-only
triage/activity fixtures. Live errors are typed (`LiveDataError`) and
Expand Down Expand Up @@ -122,6 +139,12 @@ Key files: `src/store.tsx` (state), `src/theme.ts` (`css()` + tokens),
`synced` — "synced · 0 concepts" over a still-reading vault is the exact lie
this pass exists to remove. `indexing.refreshing` is the opposite case:
serving good data while re-reading, so it gets a note, never a spinner.
- **The file listing revalidates on `filesRevalidation()`, never on
`sources.length`.** Three views read `/api/files` (Sources, Files,
ConceptDetail) and all three must key the refetch the same way. A rename and a
repoint both leave the source count untouched, so a count-keyed effect went on
answering for the old layer name and the old root until something remounted —
a renamed 3,000-file source rendering "None on this machine".
- **`warnings` is the true count; `warningMessages` is capped at 10.** Render
the count from `warnings`.
- **`src/markdown.ts` parses to typed data and has no dependencies.** It never
Expand Down
18 changes: 16 additions & 2 deletions apps/console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
The React front end for inspecting and resolving a ContextCake cascade. It runs in
three environments from the same codebase:

- **demo** — bundled sample data for the public site;
- **demo** — bundled sample data for the public site, including a snapshot of
the files behind each layer so the navigator works read-only;
- **live browser** — reads the local engine through `/api/status` (the cheap
poll), `/api/graph`, `/api/resolve*`, `/api/conflict-resolutions`, and the
source-management endpoints;
Expand Down Expand Up @@ -52,7 +53,16 @@ playground/service command documented in the repository instructions.
`.contextcake/conflict-resolutions.ndjson` beside the manifest. History can
be reopened to choose a different saved answer later. The service refuses
the whole change if a source is remote, missing, or changed since review.
- **Concepts** shows the effective concept with per-section provenance.
- **Concepts** shows the effective concept with per-section provenance, and each
contributor links to the file it came from.
- **Files** is the source navigator: a keyboard tree per source, scoping to one
source, deep links (`#/files/<source>/<path>`), a rendered/raw view of each
document, and editing with re-resolve on save. Demo mode renders the same
navigator read-only. Sources whose content is remote — a GitHub repository
read over the API, an MCP graph — keep no files here and say so.
- **Sources** manages the layers themselves: rename, re-level, repoint a
folder-backed source, remove, and sync. Read-only in demo mode, where the way
into the navigator is still offered.
- **Ask ContextCake** uses the resolved cascade when a compatible
`window.claude.complete` harness bridge is present. Otherwise it returns a
visibly labeled sample answer; Electron does not currently provide that
Expand Down Expand Up @@ -86,6 +96,7 @@ metadata; each Mac requires its own local setup.
```text
src/
api.ts demo/live adapters and authenticated desktop fetch
layer-files.ts the same seam for /api/files and /api/file
store.tsx application state and live reload/actions
theme.ts CSS-variable references and style helpers
theme-mode.tsx local theme plus optional desktop sync
Expand All @@ -95,6 +106,7 @@ src/
SettingsView.tsx full-window General and Account settings
AccountPanel.tsx desktop auth and settings-sync controls
SetupWizard.tsx first-run source configuration
FileTree.tsx windowed ARIA tree behind the Files navigator
ConnectAgentDialog.tsx
ChatPanel.tsx
views/
Expand All @@ -103,6 +115,8 @@ src/
Triage.tsx
Conflicts.tsx
Concepts.tsx
Files.tsx
Sources.tsx
styles.css
```

Expand Down
46 changes: 38 additions & 8 deletions apps/console/scripts/build-demo-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,30 @@
//
// Enumerate every concept in the demo bundle and resolve it through the REAL
// engine, then assemble a graph summary shaped exactly like the playground
// server's GET /api/graph. Emits one JSON file the console imports at build time:
// server's GET /api/graph. Emits two JSON files the console imports at build time:
//
// apps/console/src/generated/demo-cascade.json → { graph, concepts }
// apps/console/src/generated/demo-files.json → { layers, files }
//
// so DemoSource and LiveSource return identical shapes (types.ts). The directory
// is gitignored: generated, never committed, never hand-edited. Wired as the
// console `predev` / `prebuild` / `pretypecheck` npm script.
// so DemoSource and LiveSource return identical shapes (types.ts), and the Files
// navigator has the same tree in the public Web Demo that it has over a real
// folder. The directory is gitignored: generated, never committed, never
// hand-edited. Wired as the console `predev` / `prebuild` / `pretypecheck` npm
// script.
//
// Engine use is READ-ONLY — we shell out to `resolver.mjs` exactly as the docs
// show (`node resolver.mjs --manifest … --concept …`). No engine file is
// imported or modified; this can never affect `npm test`.
// Engine use is READ-ONLY, and both halves come from the engine itself: the
// cascade by shelling out to `resolver.mjs` exactly as the docs show
// (`node resolver.mjs --manifest … --concept …`), the file tree by calling the
// very functions service.mjs mounts at GET /api/files and GET /api/file. Those
// have no CLI, and reimplementing the walk here would mean the demo tree could
// drift from the live one without anything failing. Nothing is written back and
// no engine file is modified, so this can never affect `npm test`.

import { execFileSync } from 'node:child_process'
import { readdirSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { layerRootMap, listFilesApi, readFileApi } from '../../../packages/core/src/layer-files.mjs'

const scriptDir = dirname(fileURLToPath(import.meta.url)) // apps/console/scripts
const consoleRoot = resolve(scriptDir, '..') // apps/console/
Expand All @@ -28,6 +36,7 @@ const manifestDir = dirname(manifestPath)
const resolverPath = join(repoRoot, 'packages', 'core', 'src', 'resolver.mjs')
const outDir = join(consoleRoot, 'src', 'generated')
const outFile = join(outDir, 'demo-cascade.json')
const filesOutFile = join(outDir, 'demo-files.json')

/** Recursively collect every `*.md` under `dir` (Node ≥ 18, no deps). */
function walkMarkdown(dir) {
Expand Down Expand Up @@ -114,16 +123,37 @@ const sources = layers.map((l) => ({
}))

const graph = {
manifest: { path: manifestPath },
// Repo-relative for the same reason `layer.root` is below: this bundle is
// inlined into the public Web Demo's JS, and the absolute path is the build
// machine's — a developer's home directory and repo layout, shipped.
manifest: { path: relative(repoRoot, manifestPath) },
tokenizer: 'demo',
totals: { sourceTokens: 0, resolvedTokens: 0, concepts: concepts.length, sources: sources.length },
sources,
concepts: graphConcepts,
}

// The file tree behind those layers, from the engine's own file APIs. The demo
// serves it read-only: the listing and every document's text, no write route.
const roots = layerRootMap(manifest, manifestDir)
const listing = await listFilesApi(roots)
const files = {}
for (const layer of listing.layers) {
for (const entry of layer.files) files[entry.path] = await readFileApi(entry.path, roots)
// The absolute root is the build machine's, and this bundle ships to the
// public Web Demo. The repo-relative path is the same folder said honestly,
// without a stranger's home directory in it.
layer.root = relative(repoRoot, layer.root)
}

mkdirSync(outDir, { recursive: true })
writeFileSync(outFile, JSON.stringify({ graph, concepts }, null, 2) + '\n')
const filesJson = JSON.stringify({ layers: listing.layers, files }, null, 2) + '\n'
writeFileSync(filesOutFile, filesJson)

console.log(
`[console build-demo-data] wrote ${concepts.length} concept(s), ${sources.length} source(s) → ${relative(repoRoot, outFile)}`,
)
console.log(
`[console build-demo-data] wrote ${Object.keys(files).length} file(s), ${Math.round(filesJson.length / 1024)} KB → ${relative(repoRoot, filesOutFile)}`,
)
Loading