The hand-packed icns was pixel-clean (verified by unpacking and scanning
every size), but macOS 26 composites legacy .icns icons into the Liquid
Glass system at render time; stacked on Icon Composer's baked PNG-export
lighting, that clips the rim highlights into the near-corner specks and
abrupt highlight ends (owner report 2026-10-13). The fix is the real
layered icon: mac.icon now points at the Icon Composer document
(build/icon.icon, copied from ~/Documents/Affinity/lh-logo) and
electron-builder compiles it via actool into Assets.car — macOS 26+
renders the true Liquid Glass design (dark/tinted variants included).
actool's backwards-compat icns caps at 256px, so scripts/after-pack.cjs
swaps in the existing full-res build/icon.icns before signing: pre-Tahoe
macOS keeps today's quality, Tahoe gets the layered icon.
Windows/Linux get the owner's flat design (rounded squircle with baked
transparency): scripts/make-icons.mjs renders build/icon-flat.svg via
resvg into build/icon.ico (16→256 PNG entries) + build/icon.png (512),
and electron-builder.yml pins win.icon/linux.icon.
desktop:dist injects DEVELOPER_DIR (actool needs Xcode; the machine
selects CommandLineTools by default, CI's setup-xcode takes precedence
via the :- default). The release workflow's mac job selects Xcode 26+
explicitly. Verified locally: mac --dir build (Assets.car + checksummed
full-res fallback + CFBundleIconName/IconFile) and the win-unpacked exe
carries our ico bytes; NSIS/AppImage assembly is CI's (cross-build
tooling can't run on arm64 macOS). Runbook updated. 1610/1610.
Visual-maker SVGs never execute script, so a browser added nothing but a
process. resvg rasterizes in-process now (white background and fitTo
width mirror the old Chrome-page semantics; svgViewport unchanged,
quirks included for parity). The old Chrome path stays verbatim as the
fallback for exotic-SVG parse failures - it was the only path before,
so nothing regresses.
The resvg binary (4MB per platform, one per build) rides the standalone
via the same tracing pattern. Gates 1519/1519; native path pinned
against real SVGs (intrinsic size, viewBox-only 900 default, opts
override, system-font text).
electron-updater in the shell (packaged builds only; dev never updates).
Checks at startup + every 24h, downloads silently, and the renderer shows
a Restart banner once the update is staged (it installs on quit either
way). Manual "Check for Updates..." under the app menu answers via dialog.
Every updater event routes through the shell log and is caught - the
updater can never take the app down (verified live: a feed failure logs
three lines and the app runs on).
LH_UPDATE_FEED overrides the baked GitHub feed with a generic local
server - the pre-release drill: serve latest-mac.yml + zip + blockmap
from localhost and a real installed copy updates itself, no release, no
public repo.
The asar allow-list re-includes electron-updater + its 16-package runtime
tree explicitly (gitignore last-match-wins; asar verified at 2.1 MB).
The banner rides the preload bridge (onUpdateStatus/installUpdate) and
renders nothing in the browser. window.lh typing centralized in
sidebar.tsx's declaration.
- LICENSE: AGPL-3.0-only (holder Amos Blomqvist), package.json gains "license".
- Product/package name unified: package.json "name" is now "learnharness"
(matches the GitHub repo); electron-builder productName/appId already
were LearnHarness/com.learnharness.LearnHarness.
- Deterministic userData: app.setName("LearnHarness") early in main.cjs
— Application Support dir is exactly "LearnHarness" in dev and
packaged runs (packaged Electron would otherwise derive it from the
package name). Dev shell becomes "LearnHarness-dev". Pre-release
rename: zero external users, no migration code; the local test dir
was moved by hand.
- docs/ diet: nine vision-*.md splinters, the completed pi-ai migration
plan, and research/ notes leave the tree (retained privately in the
git-ignored memory; full history stays in git). Public docs remain:
architecture, vision, plan, probe-teach-split, pi-engine-swap,
keyboard, teaching-methodology (+ release-0.3 until the ship).
Gates: 1489/1489, typecheck, lint.
The 2026-10-08 flip, verified end-to-end (notarized build accepted,
stapled, spctl clean): Developer ID auto-detect, LH_NOTARY keychain
profile, zip + blockmap beside the DMG for Squirrel.Mac, publish block
pointing at GitHub Releases (repo goes public before 0.3.0; builds never
auto-upload — publishing is a release-time act).
- desktop:dist cleans dist/ + .next/standalone before next build: the
trace sweep was shipping the PREVIOUS build's DMG inside every new one
(standalone/dist = 8.0GB inside the app; measured 7.4GB DMG → 144MB).
- the same sweep shipped the repo-root auth.json (dev BYOK keys,
byte-identical) in every DMG ever built — excluded in the trace and
REFUSED by a new build-server.mjs guard (.env* belt-and-braces).
- WARNING (next.config.ts): dir-style trace excludes (dist/** etc.,
"./"-anchored or not) strip server-external packages to bare
package.jsons — pi-ai/pi-agent-core lost their dist/, the packaged
server 500'd on ERR_MODULE_NOT_FOUND. Only the three verified-safe
entries stay.
- new scripts/smoke-server.mjs gates desktop:dist between payload
curation and packaging: boots the standalone exactly as the Electron
shell does (temp LH_DATA_DIR, empty auth) and requires 200s on / +
graph/models/settings. Caught both defects above on its first day.
- entitlements: allow-unsigned-executable-memory dropped (2019-era
relic per current electron-builder docs; weakens hardened runtime).
- pi-ai + pi-agent-core pinned exact 1.0.2 — persisted chat records
carry their wire shapes; post-release a bump is a migration event.
Gates: typecheck, lint, 1459/1459 tests, full desktop:dist + smoke PASS.
Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data
lines + [DONE]); only app-level events are the loop-level error and
turn-settled terminal; onError carries raw provider text (owner ruling).
Client: useChat + the AI-SDK transport replaced by components/session-core.ts
(SessionModel: 409 identity-retry, optimistic c_ appends, absorbed()
adoption, needsContinuation, shared consume for POST + reattach) and the
session.ts hook; the golden-master grouping canon moved client-side into
core/view.ts (projectView); scanners read the native record (phaseAt3Lh,
danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction).
Coverage: the native message is the unit (fork 3) with the call+result
inseparability bond rule; view-line-less messages (markers, error
results) are invisible to coverage and never checkpointed.
Deletes: chat-transport, projection, ui-message, their tests (ported to
tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies —
the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten
onto the AgentEvent dialect (eventsOf parser).
Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke
on PORT=3004 (verbatim wire → native record).
- src/agent/lh-tool.ts deleted: the zod-wrapped LhTool + toPiTool bridge
(z.toJSONSchema per call, clientQuestion flag, hand-rolled result mapping)
is gone. Tools are pi-agent-core native AgentTools now.
- New src/agent/tool-execute.ts owns what the wrapper added: the abort guard
(runWithAbortGuard: run signal + 30s per-call timeout + 250ms grace,
'Operation aborted' / timeout wording preserved), ToolInputError (the
retryable '- problem at path' format), result mapping (string verbatim /
object JSON / image parts, details rides the record's tool part output),
and questionResult() — placeholder + terminate:true (owner Option A:
belt of terminate + slim makeQuestionStopper with the clean-question
rule; maxTurns deleted from the teaching turn entirely per owner).
- All 11 tool files + tools.ts registries converted to TypeBox parameters
(typebox@1.3.7, pinned to pi-agent-core's copy). zod refinements
(quizInputProblems, askOptionProblems, live-nodeId) moved from schemas
into execute — the model sees the same retryable error shape.
- question-core + contracts/probe TypeBox-authored (single source shared
with the UI); tool-parts parse seam runs the boundary's full chain
(Convert → Check → Clean → refinement), fixing the live-smoke lockout:
pi's boundary Value.Converts correctAnswer: 17 → '17' but the record
keeps raw args, so a parse without Convert rejected a posed quiz
(invisible pending question, turn ended). Pinned with the real smoke
emission shape.
- Subagents converted: demo-builder/researcher AgentTool-native,
visual-maker/pdf-reader declaration-only pi-ai Tools with local execute
fns, extraction submit schemas re-authored in TypeBox (owner option 2:
zod defaults → Type.Optional + fill at the submit seam).
- Accepted delta, flagged: TypeBox-native schemas skip pi's JSON-Schema
primitive coercion fast path... (boundary still Value.Converts), and
prober-quiz unknown keys ride through (Value.Clean strips at display).
- Tests: 988 passing (was 967 at the recon baseline); tool-abort and
read-tool rewritten against the native seams; question-core/question-
tools/tool-parts/demos/web-search/graph-tools/plan-tree/probe-done/
tool-toggles converted to Value.Check/execute pins.
Live smoke (PORT=3004, real model): native ls turn streams end-to-end; a
real quiz pose (first attempt missing correctAnswer → tool-input-error →
model retry → posed) terminates the turn cleanly and persists pending.
The owner needs exact new-user simulation as a one-command launch
(repeatable first-run BYOK testing). Option A, owner-approved:
env-hiding switch + launcher script.
Fix 1 — LH_IGNORE_ENV_KEYS=1 in the keys seam: when set, every env key
is invisible app-wide; only auth.json counts. The mechanism is boot-time
DELETION, not seam-local filtering: pi-ai resolves provider keys from
process.env NATIVELY at every request (verified — auth/context.js's
defaultProviderAuthContext reads live env, no boot snapshot), so
deleting the managed vars from process.env in initAuthEnv is the ONE
mechanism that hides keys from every consumer — model engines included,
plus the three direct process.env readers (visual-maker, pdf-reader,
openRouterApiKey). realEnv stays EMPTY under the switch (nothing counts
as env), and the fill-gap still reflects auth.json keys so a fresh user
can add keys mid-session and engines see them with no restart.
keyValue reads file-only under the switch as belt-and-suspenders (a
post-boot env reintroduction stays invisible to the seam). Unset
switch = today's behavior, byte-for-byte (server-keys.test.ts pins it).
Fix 2 — scripts/dev-fresh.sh (npm run dev:fresh): fresh throwaway
LH_DATA_DIR (.fresh-data/, wiped each launch, --keep preserves), and —
the subtle bit — LH_AUTH_FILE redirected INTO the fresh dir: dev's
AUTH_FILE default is the repo-root auth.json, which may symlink the
real keys; the fresh instance must never see it. Sets the switch,
dedicated port (default 3005, --port overrideable, standing assignments
3000-3004 refused), prints URL + data dir, safe to rerun. .fresh-data/
gitignored.
Test: tests/fresh-env-keys.test.ts (own process — the switch is
boot-time): the engine-path deletion asserted directly, the env/file
collision resolved to the file, status rows report file sources only,
mid-session saveKey/deleteKey round-trip, idempotent re-init, and the
unset-switch env-wins read seam live-checked.
Live-verified on 3005 via the launcher: zero federated models, all
providers keyPresent false, no present keys, no auth.json/settings.json
in the fresh dir; then mid-session ZAI save → 7 zai models federate
instantly, source 'file' — while .env.local's real keys stayed
invisible throughout.
Gates: typecheck, lint, 863/863, build.
Learner source files, on the teacher's own terms (nothing auto-injects):
- read tool (src/agent/tools/read.ts): pi-read port — path/offset/limit,
2000-line/50KB truncation with use-offset continuation notices, image
files (jpg/png/gif/webp/bmp) returned as native image content parts
(new LhToolImageResult escape hatch in lh-tool.ts). Always-on in both
the prober and teacher registries (user-decided 2026-09).
- pdf-reader sub-agent (JobKind 'pdf', toggleable, ack-and-steer): triage
via pdf_info (pdfjs text layer: per-page text/image/math-density),
targeted pdf_extract, regex pdf_search (hits count as consultation),
and pdf_render — pages painted by pdfjs onto a canvas shim in headless
Chrome (the pdfium viewer doesn't paint headless), screenshot fed back
image-in-the-loop (visual-maker idiom, providerSafePng applied). The
unread-finish gate bounces a zero-consultation brief once. Teacher gets
a text brief with page citations only; renders never leave the loop.
- MODELS.pdfReader = qwen/qwen3.8-flash (user override 2026-09-19: the
look-at-renders workload wants the vision-proven model; catalog listed
glm-5.3-flash as image-capable but qwen is the known-good daily driver).
- UI: PdfCompleteBadge + PdfBriefCard (steer-card, job-card), dispatch
card + activity-dock kind entries (type-forced, flagged for UI review).
- Prompts: 'Learner source files' section in teacher/30-jobs.md; read
line in prober/10-probe-doctrine.md. Doctrine rides tool descriptions
per the self-contained-tools principle.
The web app becomes a desktop app without changing shape: the Electron
shell spawns the Next standalone server on a loopback port and points a
sandboxed window at it. All app logic stays in the Next server.
Bands:
1. LH_DATA_DIR seam (config.ts): PATHS.data/chats/graph derive from
process.env.LH_DATA_DIR (else repo data/, unchanged in dev). New
PATHS.modelCache routes the transformers.js embedding-model cache
(~300MB) to <data>/models when the seam is set — the default cache
inside node_modules is read-only in a packaged bundle. All existing
fs writes flow through PATHS; teacherPrompt stays ROOT-anchored
(read-only asset, ships inside the bundle by design).
tests/data-dir-seam.test.ts covers set/unset via child process.
2. Standalone build (next.config.ts + scripts/build-server.mjs):
output 'standalone'; outputFileTracingExcludes data/**/* keeps the
repo's real session data out of the bundle (verified: an unexcluded
scratch build traced 34 files / 716K of real chats + ledgers).
Two empirically-verified tracer gaps required outputFileTracingIncludes:
pi-ai's provider catalog (JSON import attributes — 39 files, 500s on
/api/models without them) and onnxruntime-node's sibling dylib
(libonnxruntime.dlopen via @rpath). build-server.mjs copies public/
and .next/static per docs, refuses a leaked data/ dir, and asserts
the runtime-read prompt .md assets are traced.
3. Electron shell (electron/main.cjs, no preload):
- single-instance lock (two instances would fight over the data dir);
- free port via listen(0); spawn via process.execPath +
ELECTRON_RUN_AS_NODE=1 with PORT/HOSTNAME=127.0.0.1/LH_DATA_DIR;
- BYOK: userData/auth.json, flat {VAR: value} JSON, mode 0600,
merged into the child env only when absent from the real env
(auth UI is the settings milestone; file is hand-editable);
- poll until the server answers (no ready event), then open a
contextIsolation+sandbox window; deny-by-default permission
handler (media allowed only from the loopback origin — mic/STT
groundwork); will-navigate pinned to origin, window.open denied
with external links to the system browser; macOS Edit-menu roles;
- quit path SIGTERM→3s→SIGKILL + exit/SIGHUP nets; child stdout
mirrored to userData/logs/main.log;
- dev mode via LH_DEV_URL: no spawn, points at next dev (HMR).
4. Packaging (electron-builder.yml + build/entitlements.mac.plist):
files = electron shell only; the whole .next payload ships via
extraResources from .next (filter standalone/**/*) — NOT from
.next/standalone directly: electron-builder hard-excludes a copy's
root node_modules and the default pattern is a literal match, so
node_modules/ and .next/ were silently dropped and the server died
with 'Cannot find module next'. Asar holds no native code.
Unsigned (identity: null) until a Developer ID cert exists; hardened
runtime + audio-input entitlements laid now for the STT milestone.
desktop:dev / desktop:dist scripts (electron 44, electron-builder 26).
Live smoke of the packaged .app: server up on a random loopback port,
all API routes 200, a real GLM chat turn answered end-to-end via
auth.json BYOK, chat persisted under userData/data, second instance
exited via the lock, no orphaned server child after quit.
Gates: typecheck, eslint, 500/500 tests (+2 data-dir-seam), build.
The old lines were our hand-rolled shortcut: every arrow from the
source's bottom-center to the target's top-center as a single S-bezier.
Dagre actually routes each edge as border-intersected waypoints that
leave/enter nodes on whatever side the route needs — mermaid's
behavior. We now keep that routing (owner: adopt it).
blockLayout also returns edgePts — dagre's per-edge waypoint lists.
edgePathD draws them through d3's curveBasis, the exact recipe
dagre-d3's own renderer uses (and mermaid's default flowchart curve):
endpoints repeat so the line starts at the source border and the END
pulls back by the tip overhang so the arrowhead's tip lands on the
target border with its mouth at the path's end. Pinned in tests.
Dangling/unroutable edges draw nothing. d3-shape added as a direct
dependency (^3.2.0, already in the tree via dagre-d3-es — lane touch,
flagged to master with dagre-d3-es).
The owner chose: adopt the same layout engine regular mermaid uses, keep
our SVG skin. That engine is dagre (Sugiyama-style layered layout) — and
mermaid 11 ships dagre-d3-es 7.0.14, so we import exactly that fork, via
the same deep subpath mermaid itself uses. No new code shipped; pinned
verbatim in package.json (lane touch — flagged to master).
blockLayout keeps its signature ({ x, y, nodeH, width, height }, all
consumers unchanged): it feeds dagre our geometry (NODE_W boxes, GAP_X/
GAP_Y separations, zero margin, per-node grown heights from round 7),
runs the layout (deterministic, synchronous, DOM-free — SSR and render
tests stay exact), and converts dagre's node centers back to top-lefts.
Dangling edges are ignored defensively (a ghost endpoint would spawn an
unmeasured node and break the measurer).
topoFor stays exported for order/depth semantics (tests); placement no
longer calls it. dagre's network-simplex ranker + crossing minimization
now handle multi-parent DAGs, so future plan shapes route like mermaid
diagrams do. On the existing tree fixtures dagre's numbers agree exactly
with the old hand-rolled layout — every render pin survives verbatim.
Louvain only ever found the reasoning chains (the graph is disconnected
by nature — every connected component became its own cluster) and
edge-orphans could never join anything. Clusters are now RELATED TOPICS:
propositions grouped by meaning.
- Embedder (src/server/embeddings.ts): in-process transformers.js v4
running EmbeddingGemma-300M q8 (MTEB 68.4, ~300MB one-time download),
MRL-truncated to 512d, "title: none | text:" prefix on 100% of inputs,
L2-normalized so cosine = dot. Model/dim mismatch voids the ledger
(full re-embed). Injected at the composition root — the agent layer
never imports the server layer (architecture law).
- Ledger (data/graph/embeddings.json): model + dim + vectors + the
persisted cluster partition. Reclustering runs at the END of every
embedding batch (post-merge hook, both front doors) — never per read
(the agglomerative pass is ~1.5s at 338 nodes).
- Clustering (src/core/semantic-clusters.ts): hand-rolled average-linkage
agglomerative over cosine (npm HDBSCAN is an abandoned alpha). Stragglers
stay singletons by default; attachStragglers is the all-in toggle.
Threshold 0.60 swept on the real graph: 26 topics, 74/90 edge-orphans
land in real topics, 10 stragglers.
- Consumers (snapshot, settle, namer, replay) read the persisted
partition — one cluster concept everywhere; core-survival label
matching unchanged (20/26 topics kept labels).
- Louvain retired: detectCommunities + graphology* deps deleted
(graphology-louvain had leaked into the client bundle since the
clusters wave); scripts/bench-graph.ts (dead FA2 bench) removed.
- scripts/embed-graph.ts: backfill/cold-start (338 nodes in 150s
including the one-time model download).
One engine owns the equilibrium: the merge settle and (next commit)
the live drag both run createGraphSimulation — d3-force link springs,
1/d charge, weak centroid gravity — so a drag's rest state IS the
server's rest state. The FA2-era anchor mechanism survives as a
post-tick POSITION-SPACE re-blend (retention 0.85): d3's forceLink is
a Hooke spring (strength*(d-L)*alpha), which overpowers any force-space
anchor at long range — the re-blend is alpha-independent and always
wins as the sim anneals (the 675-unit drift bug). Gravity retuned for
d3's 1/d many-body falloff (bootstrap 0.2 / incremental 0.12: cluster
spacing lands ~400 world units, the FA2-era density). Rim orphans seed
(and anchor) at 1.7x the CONNECTED core's mean radius — a max-span-based
rim fed back on itself and ran the layout away to 6000 units. Replay:
338 positions in 1.6s, old-node shift within a batch 4.8 (FA2: 23).
Implements Option B from docs/research/graph-rendering.md. sigma.js v3
(WebGL) is replaced by one canvas, one rAF loop with a dirty flag, and
d3-zoom for the camera — eliminating the entire bug class from the
sigma-era bolt-on architecture:
- Hull/node desync during pan (two independent render loops) is now
structurally impossible: hull fills, dashed hull strokes, edges with
arrowheads, node discs, region labels, node labels, and hover glow
are all drawn in ONE frame under ONE camera.
- The settings-change canvas-sizing race (everything shifting up-left)
is gone: settings changes only redraw; the canvas and camera persist
(verified live: camera identical to 15 decimals across a slider drag).
- The GPU floor is gone: idle schedules NO frames at all (verified via
pixel-identity over 600ms idle); pan redraws exactly once per frame.
components/graph-canvas.ts: GraphCanvasRenderer class plus pure helpers
(zoomRatioOf, labelAlpha, fitTransform, arrowGeometry, pickNearest).
The sigma camera-ratio convention (1 = fit, smaller = zoomed in) is kept
verbatim so persisted labelZoom settings survive the swap; node labels
and cluster region labels cross-fade over the same LABELS_FADE_SPAN
(0.5, unchanged). Picking is a nearest-node distance check with the
10px pad that replaces the old padded-picking vertex shader. Hull math
stays in components/cluster-geometry.ts, reused as-is. No ctx.filter,
ever.
graph-view.tsx keeps every feature: fetch/refetch on runs-empty, FA2
run-then-freeze layout, localStorage render settings + sanitizers,
search filtering with edge drops and card-close, detail card with
flipped anchoring and per-source provenance, settings panel, import
button, pipeline status chips. Only the renderer plumbing changed.
tests/graph-canvas.test.ts pins the pure math: ratio direction, label
gate + inverse complement (alphas sum to 1), fit transform padding,
arrowhead tip-on-disc-edge and symmetry, picking pad/ties/null cases.
Live-verified end-to-end on the real 338-node graph (dev server
port 3002): single canvas, paint at fit, hover cursor+glow+fade, click
card open/anchored/close, stage click, search filter to zero with hint,
label gate opens via slider and zoom, cluster-labels toggle, zoom-to-
cursor 0.56px anchor drift, camera persistence across settings, zero
idle repaints. sigma dependency removed from package.json; d3-zoom and
d3-selection added.
Note: the settings feed effect needed isEmpty in its deps — without it
the renderer mounted with settings=null and every draw early-returned
(blank canvas); caught live, not by typecheck.