Merge upstream v0.4.0 into Gitea fork
This commit is contained in:
+25
-15
@@ -1,6 +1,6 @@
|
||||
# Learning Harness — Architecture
|
||||
|
||||
Status: **the canonical architecture spec** — describes the as-built system, verified against the code by a full audit on 2026-09-18 (every file under `src/`, `app/`, `components/`, `electron/`, `scripts/`, the lint config, and the test suite; `lint` and `typecheck` clean on the audited tree). The pre-audit spec is archived at [`archive/architecture-2026-09-08.md`](archive/architecture-2026-09-08.md) — history only. Product reasoning lives in [`docs/vision.md`](vision.md); file-by-file layout lives in the README.
|
||||
Status: **the canonical architecture spec** — describes the as-built system, verified against the code by a full audit on 2026-09-18 (every file under `src/`, `app/`, `components/`, `electron/`, `scripts/`, the lint config, and the test suite; `lint` and `typecheck` clean on the audited tree). The pre-audit spec is archived at [`docs/archive/architecture-2026-09-08.md`](archive/architecture-2026-09-08.md) — history only. Product reasoning lives in [`docs/vision.md`](vision.md); file-by-file layout lives in the README.
|
||||
|
||||
**What this is.** A standalone learning app (web + Electron desktop): the learner's knowledge accumulates into one persistent, cross-session knowledge graph, built from tutoring sessions in which a teacher agent authors a formal, visible dependency tree (the plan tree) and verifies understanding with structured questions. The graph is the home page; the session is the classroom; everything else serves those two.
|
||||
|
||||
@@ -307,26 +307,34 @@ sequenceDiagram
|
||||
- **The prober→teacher handoff**: the first turn(s) run the prober (floor/ceiling via quiz, goal via ask); `probe_done` (server-executed) files the assessment and queues a handoff steer; from the first `tool-probe_done` part in the transcript, the handler builds the teacher agent for every later turn. Phases (`core/phases.ts`) fold strictly: probe → plan → teach.
|
||||
- **The formal plan tree is author-once**: the teacher authors the session's dependency map with a single schema-validated `plan_init` tool (a mermaid-subset graph, parsed deterministically with line-numbered errors, validated as a DAG). Progress is never mutated — statuses (`current`/`visited`/upcoming) are **derived** from tagged quiz calls in the transcript; the tree panel and the plan blocks render that derivation.
|
||||
- **Job tools are server-executed, ack-and-steer**: `research`/`demo`/`visual` resolve immediately mid-turn with a fire-and-forget ack (the teacher keeps teaching); `spawn(job, input)` fires the background runner.
|
||||
- Each turn service is a named, error-contained seam taking explicit deps; failures log one `[chat <id>]` line and never touch the response. Swapping any of them touches nothing else.
|
||||
- Turn services are named seams taking explicit dependencies. Extraction contains its errors, but **canonical persistence must not fail silently**: a failed input write refuses acceptance without starting an agent; `persistTurn` throws on a failed reply write. The runner reports settlement failure before `[DONE]` and releases liveness even when normal settlement fails. Steer reconciliation uses the durable record, so a later bookkeeping failure cannot replay already-saved learner input.
|
||||
- **Late attachment must still see settlement failure.** A small, memory-only notice per session precedes the settled stream terminal when the failing turn has already left the live registry. The next accepted turn, successful session deletion, or server exit clears it. Reopening seeds the existing banner from that notice and waits for ordinary learner input; dismissing the banner is not permission to automatically retry the model. No synthetic failure entry is added to the saved transcript.
|
||||
- **Recovery never blindly resends an answer.** Submitted question drafts survive optimistic unmount in memory; the existing Submit action reconciles uncertain acceptance against the canonical record and running-turn state before sending again. EOF or a rejected stream read without a terminal triggers one read-only reattachment; another transport failure shows the existing error banner rather than claiming completion. Explicit completion, cancellation, and the literal-final-entry saved-error rule remain unchanged.
|
||||
|
||||
### 5.1 The wake pipeline (Workbench → Classroom)
|
||||
|
||||
On sub-agent completion the runner builds a **steer message** — a user-role `UIMessage` whose text part is the model-facing result and whose `data-subagent-artifact` part carries the full result for the client (provider conversion drops data parts) — and hands it to the wake pipeline:
|
||||
**Worker completion and session delivery are separate lifecycles.** Dispatch persists the tool input on the job before spawning. On completion, the runner saves its result, terminal worker status (`done`/`error`), and `delivery: {kind: "result", status: "pending"}` in one job-record update. It then queues the native custom **steer message** without awaiting delivery. Its content is model-facing text; details carry the UI artifact. Worker resources are released and the Background agents row disappears even if an unanswered question holds the result indefinitely. Dispatch cards say “finished,” not “delivered.” Only computing workers can be aborted; abort saves an independent pending abort notice.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
R["job runner finishes sub-agent"] -->|"deliver(steer) — injectable seam"| ENQ["enqueue steer in per-chat queue (globalThis)"]
|
||||
ENQ --> PUMP["pump(chatId) — one loop per chat (pumping set serializes)"]
|
||||
PUMP --> GATE{"turn-free AND no pending question? (400ms poll)"}
|
||||
GATE -->|no| PUMP
|
||||
GATE -->|yes| REFRESH["re-fetch fresh record, re-check steerDelivered (claim race)"]
|
||||
REFRESH --> RUN["turnRunner(chatId, record + steer) — the SAME handleChatRequest path"]
|
||||
RUN -->|"200"| FIN["finalizeSteer → finalizeJob (done/error)<br/>— status flips only after the wake turn persists"]
|
||||
RUN -->|"409"| BACKOFF["backoff 500→1000→2000→5000ms → retry"]
|
||||
CRASH["crash recovery: listJobs → recoverStaleJobs (stale >15min)<br/>re-deliver results / respawn via setJobRespawn"] --> PUMP
|
||||
R["save worker outcome + pending delivery; release worker"] --> ENQ["one registry entry per steer ID, including wake cargo"]
|
||||
ENQ --> LIVE{"live agent and background consent?"}
|
||||
LIVE -->|yes| STEER["pi steering at native boundaries; answer run takes parked results"]
|
||||
LIVE -->|no| GATE["wait for turn-free, question-free, unpaused, consented session"]
|
||||
GATE --> RUN["idle wake through the normal turn composition"]
|
||||
RUN -->|"409"| BACKOFF["existing backoff 500→1000→2000→5000ms"]
|
||||
BACKOFF --> GATE
|
||||
RUN -->|"403"| HOLD["retain ownership until consent permits delivery"]
|
||||
RUN -->|"other failed start"| QUIET["quiet pending; next learner run or restart recovery"]
|
||||
RUN --> SAVE["canonical marker persistence, not merely HTTP 200"]
|
||||
STEER --> SAVE
|
||||
SAVE --> FIN["acknowledge delivery; worker outcome unchanged"]
|
||||
CRASH["lazy recovery + live-agent registration restore known pending jobs"] --> ENQ
|
||||
```
|
||||
|
||||
Job status flips done/error only after the wake turn persists. Delivery is **exactly-once**: deterministic steer ids (`steer_<jobId>`, `steer_probe_<chatId>`), `steerDelivered` checks at every claim point, and `claimSteers` finalizing in-transcript steers instead of re-appending. The client adopts the wake turn through the same `turnInFlight` machinery as returning mid-stream (busy shimmer, composer gate, append-only `mergeAdopted` adoption) and renders steers as artifacts — never a bubble.
|
||||
The per-chat registry retains ownership across live handoff, idle-wake flight, and 409/403 return; repeated submissions share one entry/promise. A deterministic marker ID in the **saved conversation** completes delivery. Streaming visibility alone does not. A failed bookkeeping acknowledgement cannot replay a saved marker, and later teaching/model failure does not reopen delivery or retry the accepted turn. Failed starts leave results durably pending without a new error UI or retry timer; the in-memory entry stays parked until a learner run or process restart. Normal question/dismissal waits and consent deferrals are not failures.
|
||||
|
||||
Recovery reads saved job input before legacy transcript lookup. Missing research questions fail honestly without invoking the researcher. Legacy running jobs with saved results become finished workers with pending delivery; legacy terminal jobs without delivery metadata remain untouched, rather than guessing that old outcomes should be replayed. Pending result and abort notices are restored before a newly registered agent's first steering poll, even without a preceding jobs-feed read. Deleting a session discards its pending delivery obligations; only live turns and computing workers block deletion. These guarantees use the existing JSON storage and single-process delivery registry, not a transactional queue or a promise of exactly-once model execution.
|
||||
|
||||
Sub-agents **verify, never one-shot**: the demo-builder headlessly executes its HTML in system Chrome before delivery; the visual-maker renders each SVG and looks at it (needs a vision-capable model — the `vision` slot in settings).
|
||||
|
||||
@@ -423,9 +431,11 @@ The Electron main is a launcher + credential/security guard, never a host for te
|
||||
|
||||
### 8.1 Subscription connection boundary
|
||||
|
||||
`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved.
|
||||
`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. In packaged desktop mode Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved.
|
||||
|
||||
The renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them.
|
||||
The packaged desktop renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them.
|
||||
|
||||
For ordinary Next web/dev, `src/server/web-subscriptions.ts` composes these same plain-Node controllers into one lazy process-global credential owner beside configured `AUTH_FILE`. The server injects the private transport through `src/agent/subscription-runtime.ts`; the agent layer never imports the server layer. Existing scoped leases, authority revisions and foreground/background abort listeners work through that transport, without exposing an HTTP token/lease operation. `src/server/web-subscription-http.ts` exposes only same-origin status/control operations. Generic `subscriptions-changed` notifications use the existing SSE notify→fetch channel; opaque one-use browser actions redirect to provider pages without putting authorization URLs in app JSON. Browser adapters preserve the UI bridge contracts; Electron dev uses the web owner with a narrow system-browser opener. Desktop markers fail closed rather than falling back to web credentials. This is the existing shared developer-server trust model, not user authentication or DNS-rebinding protection; LAN behavior is unchanged. Only one server process may own an auth directory. See [development](development.md#subscription-connections-in-webdev) for storage, browser locality and access limitations.
|
||||
|
||||
`subscription-request-cycle.ts` is the shared admission/automatic-input recovery state machine; the old ChatGPT module is its compatibility export. Background consent is independent per provider, default ON, with foreground-preserving cancellation and queue ownership retained on no-dispatch. Native usage rejected before dispatch is transiently excluded from helper usage collectors, without adding persisted provenance fields. Catalogs and read-time pricing preserve the selected connection: subscription use displays compact provider labels, API dollars remain dollars, and mixed usage joins both without estimating subscription dollars or claiming detected tiers/allowances.
|
||||
|
||||
|
||||
@@ -24,6 +24,17 @@ prompt, plan continuity, persistence, threshold, scheduling, and authorization
|
||||
lifecycle. It does not adopt Pi's coding summary or separate split-turn summary
|
||||
calls. A cut that drops no active history does not launch a summarizer.
|
||||
|
||||
When the active cutoff is applied, conversation requests carry the latest summary
|
||||
once, followed by the retained transcript without older compaction markers. When
|
||||
a subsequent compaction supplies that prior summary separately, it includes it
|
||||
once, plus the newly superseded transcript without summary markers. All markers
|
||||
and original messages remain stored. The existing full-history fallback for an
|
||||
unresolved cutoff is unchanged, including its summary text.
|
||||
|
||||
A provider response stopped at its output limit (`length`) is an incomplete
|
||||
summary, not a successful checkpoint. Compaction fails without changing the
|
||||
existing history or prior summary; a later attempt can retry normally.
|
||||
|
||||
## Conversation meter
|
||||
|
||||
The numerator is the latest qualifying completed conversation response's native
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Custom and local models
|
||||
|
||||
LearnHarness supports **one custom OpenAI-compatible endpoint with multiple models**.
|
||||
The setup and Settings UI are based on PR #62 by Daniel Buckner and Abhishek Thakur.
|
||||
Pi owns inference, message conversion, tools, streaming, and reasoning-effort handling.
|
||||
|
||||
## Configure an endpoint
|
||||
|
||||
1. Open **Settings → Providers → + Add a provider → Custom endpoint**. The same
|
||||
editor is available from the first-run provider picker.
|
||||
2. Enter the server's complete API base URL, for example
|
||||
`http://localhost:11434/v1`. LearnHarness does **not** append `/v1`.
|
||||
3. Choose **Keyless** or **API key**:
|
||||
- **Keyless** never uses a saved or environment custom-provider key for
|
||||
authentication. Pi's OpenAI client receives an internal dummy credential.
|
||||
- **API key** uses the effective `CUSTOM_OPENAI_API_KEY`. A real environment
|
||||
value wins over a file-saved value; the editor identifies environment ownership.
|
||||
Any nonempty trimmed custom key is accepted—there is no arbitrary length floor.
|
||||
- Changing the URL requires a new effective key or explicit consent to reuse
|
||||
the existing effective key. A key is never silently forwarded to a new URL.
|
||||
Clearing authentication selects Keyless.
|
||||
4. Enter optional **model IDs**, separated by commas or whitespace. This supports
|
||||
servers whose chat endpoint works but whose `/models` endpoint is absent.
|
||||
5. Click **Connect**, then choose models for Strong, Cheap, and Vision.
|
||||
|
||||
HTTP and HTTPS, including localhost/private-network servers, are supported. URLs
|
||||
with embedded credentials, query strings, or fragments are rejected. Put a key in
|
||||
the authentication field, not the URL. Discovery redirects are rejected.
|
||||
|
||||
Connect requests `GET <base>/models`. Discovery metadata wins over duplicate manually
|
||||
entered IDs. A failed probe can fall back to entered IDs; a failed probe for the
|
||||
same URL preserves its previous model snapshot and pins. A different URL starts
|
||||
with its own models. **401/403 always fails**, even with entered IDs. Response-size,
|
||||
model-count, and redirect violations also fail explicitly rather than silently
|
||||
truncating or bypassing a discovery limit.
|
||||
|
||||
Configuration is not a continuous health check. An endpoint may be configured while
|
||||
offline, and model capability or output quality is not proven by listing its ID.
|
||||
No backend/model installation, downloads, or OAuth flows are performed.
|
||||
|
||||
## Model budgets and reasoning support
|
||||
|
||||
Settings exposes offline-editable pins per model:
|
||||
|
||||
- **Context budget**: user pin → reported context → **8192 fallback**.
|
||||
- **Maximum output**: user pin → reported output limit → **4096 fallback**.
|
||||
- **Reasoning support**: user declaration → reported support.
|
||||
|
||||
Every positive integer token pin is accepted, including a 512-token output limit.
|
||||
Blank fields remove that pin. The editor identifies reported, fallback, and user-pin
|
||||
values. **These are request/compaction budgets; changing them does not resize the
|
||||
backend's actual context.** Configure the server's active context separately and
|
||||
enter budgets that match it. Reconnecting prunes pins only to the resulting model
|
||||
IDs; pin saves do not contact the server.
|
||||
|
||||
Reasoning support is a **capability declaration, not an execution toggle**. The
|
||||
existing Pi-backed effort picker remains available. In particular, **Off may not
|
||||
disable a custom backend's default thinking**: an absent effort parameter is not a
|
||||
universal disable command. There is no compatibility/thinking-map editor, Ollama
|
||||
preset, or model-name inference. Backends requiring additional nonstandard headers
|
||||
or compatibility configuration are outside this release's custom-endpoint UI.
|
||||
|
||||
## Images and Vision
|
||||
|
||||
All custom models can be selected for Vision; models that report image support
|
||||
appear first. The endpoint owner is responsible for choosing an appropriate model.
|
||||
Built-in model filtering is unchanged.
|
||||
|
||||
Missing discovery metadata does not silently remove images: **every custom-engine
|
||||
image-bearing request attempts delivery**, including teaching `read` results,
|
||||
rendered PDF pages, and visual-maker screenshots. A request-local model clone permits
|
||||
Pi's native image encoding; registered/catalog metadata is not promoted to claim
|
||||
that every model understands images. A text-only backend may reject the request;
|
||||
that error is surfaced, with no silent text-only fallback. This does not establish
|
||||
real-model image-understanding quality.
|
||||
|
||||
## Credentials, reloads, and concurrent editing
|
||||
|
||||
Nonsecret configuration is stored in the existing `settings.json.customProvider`;
|
||||
file-saved credentials use the existing `auth.json` key seam. A reserved internal
|
||||
string entry, `__learnharness_custom_provider_binding`, binds authorization to the
|
||||
endpoint, mode, and a random nonsecret `credentialBindingId` in custom settings.
|
||||
The file key and its binding are published in one existing atomic auth-map write.
|
||||
The internal entry is not an environment variable or an editable/displayed key.
|
||||
Neither keys nor raw upstream errors are returned by the custom-provider API.
|
||||
|
||||
Every startup and upstream operation requires matching binding IDs, URL, and mode
|
||||
before using credentials. A crash between auth/settings writes, or a failed
|
||||
settings write whose auth rollback also fails, leaves the custom provider disabled
|
||||
rather than sending a new key to an old endpoint. Reload and explicitly reconnect
|
||||
to repair authorization. A shadowed file key retains its own authorized URL: if an
|
||||
environment key is reused for another URL and later disappears, the old file key
|
||||
is not silently forwarded there. Keyless also requires its nonsecret binding, but
|
||||
never resolves a stored/environment key. Disconnect leaves an internal disabled
|
||||
marker so a latent environment key cannot revive an interrupted disconnect.
|
||||
|
||||
Use the custom endpoint editor to change custom credentials; generic
|
||||
`/api/keys` PUT/DELETE cannot bypass binding. No credential-derived fingerprint is
|
||||
stored in settings or a public revision. These guards cover process crashes and
|
||||
restart using the existing atomic rename; they are not a power-loss/fsync
|
||||
transaction system. Missing auth storage can initialize normally; unreadable or
|
||||
malformed private storage fails closed without overwriting its contents.
|
||||
|
||||
Each upstream model operation resolves the current custom model, endpoint, effective
|
||||
credential, limits, and original saved effort preference against Pi's current model
|
||||
metadata. Already-started requests keep their setup. A removed model or disconnected
|
||||
provider fails rather than falling back; saved model selections and history are not
|
||||
rewritten.
|
||||
|
||||
Edits use an opaque nonsecret ETag/If-Match revision. A stale edit returns **409** with
|
||||
reload/retry guidance. A connect also checks configuration and effective credentials
|
||||
after discovery, and merges only the custom section into the latest settings record,
|
||||
preserving intervening unrelated edits. Failed mutations never claim success.
|
||||
|
||||
Desktop requests use the existing desktop capability. Browser mutations must be
|
||||
same-origin JSON requests. Limits are **15 seconds** for discovery, **8 MiB** for its
|
||||
response, **10,000** listed models, and **64 KiB** for configuration-write bodies.
|
||||
These are protections for the new endpoint, not a multiuser authentication system.
|
||||
|
||||
## Pricing and previous development configuration
|
||||
|
||||
Custom model pricing is **unknown/unpriced**, not $0—even for an endpoint that may
|
||||
be locally hosted. Pi-required internal zero-rate placeholders are not presented as
|
||||
free inference. There is no rate editor in this release.
|
||||
|
||||
The earlier, unshipped app-data `models.json` feature is retired and is **not read**.
|
||||
No user file is deleted or renamed, and there is no importer, coexistence mode,
|
||||
profile reset, history rewrite, or silent model remapping. Existing development
|
||||
profiles need manual endpoint configuration and model reselection. Unbound early
|
||||
GUI development configurations also require explicit reconnect/new-key entry or
|
||||
the existing reuse-consent checkbox, even at the same URL; they are never silently
|
||||
trusted or migrated. This release
|
||||
supports one OpenAI-compatible custom endpoint, not multiple arbitrary protocols.
|
||||
+20
-9
@@ -2,13 +2,13 @@
|
||||
|
||||
The developer README (split out of the storefront README, 2026-10-10). Everything below is for people working ON LearnHarness, not using it.
|
||||
|
||||
A standalone learning app (web + Electron desktop): a **teacher agent** (Next.js; the Vercel AI SDK's `UIMessage` protocol end-to-end, LLM engine [pi-ai](docs/pi-engine-swap.md) via OpenRouter), probe→teach lessons with graded quiz/ask interactions, a formal **session plan tree** the teacher authors once (`plan_init`) and whose progress is derived from tagged quiz calls, and a persistent cross-session **knowledge graph** of what the learner knows — the graph is the app's home page.
|
||||
A standalone learning app (web + Electron desktop): a **teacher agent** (Next.js; the Vercel AI SDK's `UIMessage` protocol end-to-end, LLM engine [pi-ai](pi-engine-swap.md) via OpenRouter), probe→teach lessons with graded quiz/ask interactions, a formal **session plan tree** the teacher authors once (`plan_init`) and whose progress is derived from tagged quiz calls, and a persistent cross-session **knowledge graph** of what the learner knows — the graph is the app's home page.
|
||||
|
||||
Not a chat app with a graph bolted on: sessions are probe→teach lessons, the plan tree is a structured, schema-validated object, sessions accumulate into the graph through the extraction pipeline (propositions + reasoning chains — the stored truth; the canvas renders a derived image), and the teacher reaches back into the graph through read-only prober tools on its own judgment so probing compounds across sessions.
|
||||
|
||||
- [`docs/architecture.md`](docs/architecture.md) is the **canonical architecture spec** — layers, the import law, the five systems, the invariants, the seam directory. (The pre-audit spec is archived under `docs/archive/` — history only.)
|
||||
- Living design doc (all decisions): [`docs/vision.md`](docs/vision.md)
|
||||
- Build plan + wave/gate results: [`docs/plan.md`](docs/plan.md)
|
||||
- [`docs/architecture.md`](architecture.md) is the **canonical architecture spec** — layers, the import law, the five systems, the invariants, the seam directory. (The pre-audit spec is archived under `docs/archive/` — history only.)
|
||||
- Living design doc (all decisions): [`docs/vision.md`](vision.md)
|
||||
- Build plan + wave/gate results: [`docs/plan.md`](plan.md)
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -22,6 +22,7 @@ npm start # serve the production build
|
||||
npm test # the full suite (node:test via tsx; deterministic, no LLM, no network)
|
||||
npm run lint # eslint — includes the import law (architecture §2)
|
||||
npm run typecheck # tsc --noEmit
|
||||
node scripts/smoke-web-subscriptions.mjs # after build: offline Next start/dev + reload gate
|
||||
|
||||
npm run desktop:dev # next dev + Electron shell pointed at it
|
||||
npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY keychain)
|
||||
@@ -32,7 +33,7 @@ npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extrac
|
||||
npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers
|
||||
```
|
||||
|
||||
For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
|
||||
For Windows/Linux packages and the three-OS CI/release flow, see [`docs/desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
|
||||
|
||||
## Environment (`.env.local`)
|
||||
|
||||
@@ -47,9 +48,19 @@ Secrets and machine-specific paths only. Model choice and thinking budgets are t
|
||||
|
||||
The packaged desktop app takes keys from `userData/auth.json` (BYOK, mode 0600, read/written directly by the server via `LH_AUTH_FILE`). PDFs render natively with pdfjs/Skia; SVGs use resvg.
|
||||
|
||||
### Subscription connections in web/dev
|
||||
|
||||
Ordinary `npm run dev` and `npm start` support the existing **ChatGPT, GitHub Copilot and Grok** connections in Settings → Providers. No separate launcher or change to Next's listening/LAN behavior is required. `npm run desktop:dev` uses these same dev-server connections; the packaged desktop app keeps its separate main-process credentials and private IPC transport.
|
||||
|
||||
Web/dev saves `chatgpt-credentials.json`, `github-copilot-credentials.json` and `xai-credentials.json` **beside the configured API-key `auth.json`**: the project directory by default, or the directory containing `LH_AUTH_FILE`. These are owner-only plaintext files, not files under `data/`, desktop Application Support, or a global Pi profile. Git and standalone-build exclusions cover both the files and atomic-write temporary files. Run only one server process per credential directory: refresh serialization is process-local, not a cross-process lock.
|
||||
|
||||
Sign-in opens a new tab in the browser that initiated it; permit that tab if your browser blocks popups. **ChatGPT sign-in must be completed in a browser on the server's machine**, because its callback listener remains `127.0.0.1` on that machine. Copilot/Grok use device codes. In the Electron dev shell, provider pages open in the system browser instead. Once connected, credentials and refresh remain server-owned; other browsers using that server share its connections and background-use consent, just as they share its API keys. Disconnect, storage-failure handling, and provider-specific background consent retain the existing behavior; subscription routes never fall back to API-key billing.
|
||||
|
||||
This remains a **trusted developer/shared-server mode**, not multi-user hosting. Subscription controls require same-origin JSON/custom-header requests; browser-opening actions are short-lived, origin-bound and one-use. These checks prevent ordinary cross-origin browser controls, **not user authentication or DNS-rebinding protection**. Do not expose an unprotected dev/server instance to untrusted users or the public Internet; a public deployment needs an independent access/host gate. Remote ChatGPT callback routing, per-user accounts and credential import are not implemented.
|
||||
|
||||
## Architecture
|
||||
|
||||
Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: [`docs/architecture.md`](docs/architecture.md).
|
||||
Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: [`docs/architecture.md`](architecture.md).
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -165,7 +176,7 @@ pricing-generation step: Pi AI's catalog and public calculator are the authority
|
||||
|
||||
**Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
|
||||
|
||||
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server.
|
||||
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → Providers → Dictation → Language (below the Deepgram key) offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; the confirmed preference is cached across page navigation and reopening Settings, with no polling or per-recording read. Explicit external dictation-settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker uses the model dropdown's shared `SearchMenuList` and `MenuShell`: the same fuzzy search, arrow navigation, Enter selection, row styling and scrolling. Its compact mono `SettingsMenuTrigger` is shared with Agent and Appearance; a muted setup line below the key and language rows links the Deepgram Console and states the Member-or-higher key-role requirement.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -179,10 +190,10 @@ All deterministic, no LLM, no network (engines are injected — pi-ai's faux pro
|
||||
|
||||
## Lineage
|
||||
|
||||
The teaching methodology comes from an Obsidian-based POC (its `knowledge graph design.md` was the original epistemic-model source, since amended — see the amendments table in `docs/vision.md`). The harness was originally built on the pi agent SDK, pivoted to standalone Next.js + the Vercel AI SDK, and later swapped its LLM engine back to pi-ai while keeping the AI SDK's `UIMessage` as the client protocol ([`docs/pi-engine-swap.md`](docs/pi-engine-swap.md)).
|
||||
The teaching methodology comes from an Obsidian-based POC (its `knowledge graph design.md` was the original epistemic-model source, since amended — see the amendments table in `docs/vision.md`). The harness was originally built on the pi agent SDK, pivoted to standalone Next.js + the Vercel AI SDK, and later swapped its LLM engine back to pi-ai while keeping the AI SDK's `UIMessage` as the client protocol ([`docs/pi-engine-swap.md`](pi-engine-swap.md)).
|
||||
|
||||
## Data
|
||||
|
||||
Nothing under `data/` is ever tracked by git. Chats, graph ledgers, jobs, positions, and the settings records are plain files (JSON/JSONL) — inspect them freely; they are the canonical records. The desktop app relocates the whole directory via `LH_DATA_DIR`.
|
||||
|
||||
**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand.
|
||||
**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand.
|
||||
|
||||
@@ -21,7 +21,8 @@ philosophy so they share a view of the whole process).
|
||||
field it must not use. No plan tools, no jobs, no demos.
|
||||
- **The teacher** runs everything from planning onward: full tool set
|
||||
(teach quiz with nodeId, plan_init, jobs, demos). Constructed the moment the
|
||||
transcript carries a `probe_done`.
|
||||
transcript carries a concluded `probe_done`: a non-failed/non-aborted
|
||||
assistant call followed by a matching successful tool result.
|
||||
- **The swap happens between probing and planning** — the planning phase
|
||||
needs full knowledge of how teaching is done, so the teacher agent starts
|
||||
at planning.
|
||||
@@ -43,6 +44,12 @@ The prober concludes explicitly:
|
||||
- **Refuses while a question is open** ("resolve the open question first") —
|
||||
same doctrine as nodeId validation.
|
||||
- **Refuses a second conclusion** (idempotent: the probe concludes once).
|
||||
Conclusion requires a later `probe_done` result matching the call's ID
|
||||
and tool name. The calling generation must not have failed or aborted;
|
||||
the result must not be `isError` or explicitly contain `details.ok:false`.
|
||||
Clean acknowledgments without an explicit `ok:true` remain valid. Bare,
|
||||
unmatched, errored, or refused calls keep probing open; saved attempts
|
||||
are never deleted or replay-executed by this detection rule.
|
||||
- The assessment is the tool call's input, so it streams to the learner live
|
||||
and is durable in the transcript by the tool result itself. A dedicated
|
||||
assessment card/viewer comes later (UI).
|
||||
@@ -53,7 +60,7 @@ The prober concludes explicitly:
|
||||
- **Side effect: it queues the handoff.** When the prober's turn ends, the
|
||||
wake pipeline (the same machinery that delivers sub-agent results and
|
||||
abort steers) fires a server-side turn with a handoff steer — the teacher
|
||||
is constructed (probe_done is in the transcript) and authors the plan.
|
||||
is constructed (probe_done has successfully concluded) and authors the plan.
|
||||
The learner experiences assessment → plan as one continuous flow. If the
|
||||
wake is lost (crash window), the next client message still constructs the
|
||||
teacher — the transcript is the source of truth.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Tool-content presentation recovery
|
||||
|
||||
## Contract
|
||||
|
||||
Model-authored tool content is parsed against its schema before presentation. Math or document recovery must not change persisted tool arguments, option identities, learner answers, shuffling, grading, or future answer snapshots. Those use the source-derived, schema-parsed content.
|
||||
|
||||
`ToolContentResponse` projects text through `repairMathForDisplay`, then uses the ordinary Markdown renderer. Teaching/probing questions, asks, recalls/explanations, and practice statements/solutions share this path. Ordinary assistant prose does not receive tool-content recovery.
|
||||
|
||||
There are three separate responsibilities:
|
||||
|
||||
1. **Argument/shape parsing:** decode encoded argument shapes, then schema conversion, validation and cleaning. No LaTeX recovery here.
|
||||
2. **Content projection:** distinguish literal code, prose/document structure and math; recover only supported escaping defects.
|
||||
3. **Presentation:** Markdown/KaTeX rendering, including paragraph layout. In labeled Why/answer reveals, only the first direct paragraph is inline with its label; subsequent paragraphs retain their normal layout.
|
||||
|
||||
## Recovery rules
|
||||
|
||||
### Literal code
|
||||
|
||||
Code and inline-code source ranges come from the rendering stack's CommonMark parser with its math extensions. This covers fences, indentation, quotes and lists without maintaining separate container heuristics. The projection copies those source ranges verbatim; it does not serialize an AST back into Markdown.
|
||||
|
||||
### Document escapes
|
||||
|
||||
A literal two-character `\n` is different from a physical newline. Document-layout recovery requires evidence such as repeated document-escape candidates or a candidate alongside over-escaped known TeX commands. Complete known n-prefixed TeX words, including `\nu`, `\nabla`, `\neq` and declared n-prefixed macros, take precedence over interpreting their prefix as document layout. Code, genuine TeX comments and recognized path tokens are protected.
|
||||
|
||||
Recovered double-dollar display framing puts the formula and closing fence on their own lines. Decoding the opening line alone can otherwise turn closing dollars into erroneous TeX content.
|
||||
|
||||
### JSON-swallowed command prefixes
|
||||
|
||||
Inside math, a control character can be the result of JSON consuming the start of a command: TAB + `ext` instead of `\text`, for example. Recovery reconstructs a command only if the control character plus the **complete following alphabetic word** names a known KaTeX command. All supported occurrences recover in one projection.
|
||||
|
||||
The owner-approved typical-model-authoring policy is:
|
||||
|
||||
- **Inline math:** recover recognized prefixes, including physical LF + `u` to `\nu`, and TAB + `o` to `\to`. There is no word-length cutoff.
|
||||
- **Display math:** keep physical LF and CRLF as source formatting; recover recognized non-newline prefixes.
|
||||
- **Correctly written commands:** remain commands in both modes. `\nu` is never decoded into LF + `u`.
|
||||
- **Unrecognized whitespace:** remains unchanged. `x<TAB>+<TAB>y` does not become invented `\t`/`\ty` commands.
|
||||
|
||||
The control-character case is inherently ambiguous: whitespace followed by a variable may happen to spell a command. This policy targets typical model-authored LaTeX, not every syntactically possible human whitespace arrangement. TeX parse success alone cannot distinguish the two meanings.
|
||||
|
||||
### Extra command escaping, percentages and rows
|
||||
|
||||
Recovery examines the original TeX context before changing backslash runs:
|
||||
|
||||
- Complete native environment pairs and native `\substack` arguments preserve genuine row separators.
|
||||
- Consistently extra-escaped environment pairs establish an escaping factor for commands **and rows**. For example, an encoded four-backslash row becomes two backslashes, not one.
|
||||
- Outside protected native row/comment contexts, extra prefixes on known commands can recover. Unknown command words are not invented.
|
||||
- Doubled percent escapes in ordinary numeric expressions can recover to `\%`, including numeric percentages at the end of a display formula. Genuine following comment text or equation rows remain literal. This is not a blanket `%` addition to an even-backslash rule.
|
||||
- As an additional guard, partial command recovery does not turn parseable source into a TeX parse failure. Parse success is not the primary correctness test: both a corrupted matrix and a lost percentage can parse successfully.
|
||||
|
||||
Unbalanced, unsupported or ambiguous content is not generally corrected into new mathematics. This is an escaping recovery projection, not a general TeX authoring repair system. There are no language-specific decoders.
|
||||
|
||||
## Historical answers
|
||||
|
||||
`answerForPresentation` joins model-authored display fields to the original tool call without changing recorded answer facts. Option labels bind only through unambiguous selected values, exact/source-recovered labels, or one uniquely remaining pair in a complete bijection. Ambiguous rows keep their saved snapshots. Saved order, indices, selected values, verdicts, confidence and learner text are retained. Dismissed answers are unchanged. No migration or record rewrite is performed.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/content-recovery.test.ts`: recovery/preservation rules and adversarial row/comment/code examples.
|
||||
- `tests/tool-content-presentation.test.tsx`: provenance-labeled synthetic corpus regressions, historical joins and ambiguity.
|
||||
- `tests/tool-content-interaction.test.tsx`: select/submit/persist/reveal flow and paragraph structure.
|
||||
- Existing Markdown, card, practice, tool-shape and grading suites remain applicable.
|
||||
|
||||
The local frozen corpus can be replayed offline with `scripts/replay-tool-rendering-corpus.ts`. Replay verifies source hashes and preserves baseline findings; it reports glyph loss, matrix rows, rendering errors, code bytes, schema regressions, answer facts and projection convergence. Corpus collection and larger diagnostic batches are separate from product recovery and are not prerequisites for running these unit tests.
|
||||
Reference in New Issue
Block a user