feat: Electron desktop wrapper — LearnHarness.app (macOS arm64)
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.
This commit is contained in:
@@ -9,3 +9,4 @@ data/
|
||||
SCOPE.md
|
||||
# raw real-data import sources (user directive 2026-09-13: never commit)
|
||||
real-data-extraction/
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Hardened-runtime entitlements (applied once the app is signed).
|
||||
Laid now (2026-09-17) so the STT milestone doesn't rebuild packaging:
|
||||
- allow-jit / allow-unsigned-executable-memory: Electron's V8 needs
|
||||
both under hardened runtime (standard Electron entitlements);
|
||||
- audio-input: macOS mic for the STT milestone (the Developer-ID
|
||||
variant — com.apple.security.device.microphone is the App-Store
|
||||
sandbox variant and does NOT apply to Developer ID apps). -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -4,14 +4,33 @@ import { fileURLToPath } from "node:url";
|
||||
/** Repository root (this file is at <root>/config.ts). */
|
||||
export const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* THE data-location seam (the Electron contract, 2026-09-17): LH_DATA_DIR
|
||||
* relocates every MUTABLE record — chat sessions, graph ledgers, the
|
||||
* embedding model cache — when set. The desktop shell points it at
|
||||
* userData so nothing is written inside the read-only .app bundle
|
||||
* (macOS translocation guarantees the bundle is read-only anyway).
|
||||
* Unset (plain web dev / repo work) it resolves to the repo's data/,
|
||||
* byte-for-byte as before. Read-only assets (prompts) stay ROOT-anchored
|
||||
* on purpose: they ship inside the app bundle by design.
|
||||
*/
|
||||
const DATA_ROOT = process.env.LH_DATA_DIR
|
||||
? path.resolve(process.env.LH_DATA_DIR)
|
||||
: path.join(ROOT, "data");
|
||||
|
||||
export const PATHS = {
|
||||
root: ROOT,
|
||||
/** Teacher system prompt — the agent's instructions (src/agent/). */
|
||||
teacherPrompt: path.join(ROOT, "src", "agent", "system-prompt.md"),
|
||||
data: path.join(ROOT, "data"),
|
||||
data: DATA_ROOT,
|
||||
/** Chat sessions (AI SDK UIMessage transcripts, one JSON file per chat). */
|
||||
chats: path.join(ROOT, "data", "chats"),
|
||||
graph: path.join(ROOT, "data", "graph"),
|
||||
chats: path.join(DATA_ROOT, "chats"),
|
||||
graph: path.join(DATA_ROOT, "graph"),
|
||||
/** transformers.js model cache (server-side embeddings, ~300MB).
|
||||
* null in dev = keep the package default (the model is already
|
||||
* downloaded there); under LH_DATA_DIR the packaged app must never
|
||||
* try to cache inside its own bundle. */
|
||||
modelCache: process.env.LH_DATA_DIR ? path.join(DATA_ROOT, "models") : null,
|
||||
} as const;
|
||||
|
||||
// ─── Models ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# electron-builder config — LearnHarness desktop (macOS arm64 first).
|
||||
#
|
||||
# Shape (fact-checked 2026-09-17):
|
||||
# - the app's real server (.next/standalone, curated by
|
||||
# scripts/build-server.mjs) ships via extraResources — OUTSIDE the asar,
|
||||
# because native .node/dylib can't dlopen from a read-only archive and
|
||||
# server.js process.chdir()s into its own dir;
|
||||
# - the asar holds only the shell (electron/main.cjs + package.json);
|
||||
# - signing/notarization config is laid now but inert until a Developer
|
||||
# ID certificate exists (identity: null = unsigned local build, which
|
||||
# runs fine for personal use — Gatekeeper only gates quarantined apps).
|
||||
appId: com.learnharness.LearnHarness
|
||||
productName: LearnHarness
|
||||
directories:
|
||||
output: dist
|
||||
files:
|
||||
# Only the shell goes into the asar.
|
||||
- electron/**/*
|
||||
- package.json
|
||||
extraResources:
|
||||
# Copy from the PARENT dir (.next), not .next/standalone itself:
|
||||
# electron-builder hard-excludes a copy's root node_modules and the
|
||||
# default no-filter pattern is a literal path match (empirically both
|
||||
# node_modules/ and .next/ were silently dropped → server died on
|
||||
# "Cannot find module 'next'"). With `from: .next` the filter's
|
||||
# relative paths start with standalone/, dodging the root-node_modules
|
||||
# rule, and the explicit wildcard (minimatch dot:true) reaches
|
||||
# standalone/.next and standalone/node_modules.
|
||||
- from: .next
|
||||
to: server
|
||||
filter:
|
||||
- "standalone/**/*"
|
||||
mac:
|
||||
target:
|
||||
- target: dmg
|
||||
arch:
|
||||
- arm64
|
||||
category: public.app-category.education
|
||||
# Laid for the STT milestone (researcher-verified): the TCC mic prompt
|
||||
# reads this string; macOS asks regardless of Electron's own handlers.
|
||||
extendInfo:
|
||||
NSMicrophoneUsageDescription: "LearnHarness uses the microphone only for speech-to-text input into your sessions."
|
||||
hardenedRuntime: true
|
||||
entitlements: build/entitlements.mac.plist
|
||||
# Unsigned until a Developer ID certificate exists (research-confirmed:
|
||||
# local builds run fine; distribution needs signing + notarization).
|
||||
identity: null
|
||||
dmg:
|
||||
title: "${productName} ${version}"
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* LearnHarness — the Electron shell (wrapper milestone, 2026-09-17).
|
||||
*
|
||||
* What this process is and is not:
|
||||
* IS: a thin launcher + security guard around the app's real server —
|
||||
* `next build`'s standalone server.js (Next 16), spawned as a child
|
||||
* on 127.0.0.1 with the BYOK keys and the LH_DATA_DIR seam.
|
||||
* NOT: a host for any app logic. Everything — teacher turns, extraction,
|
||||
* the graph — runs in the Next server, unchanged.
|
||||
*
|
||||
* Pattern (fact-checked 2026-09-17 against current Electron 44 / Next 16
|
||||
* docs — see .memory release-strategy + electron-wrapper notes):
|
||||
* - spawn via process.execPath + ELECTRON_RUN_AS_NODE=1 (Electron's
|
||||
* bundled Node 24; the runAsNode fuse must stay enabled);
|
||||
* - the server dir ships UNPACKED via extraResources (asar archives are
|
||||
* read-only and native .node/dylib can't dlopen from one);
|
||||
* - all mutable state under userData (never inside the .app bundle —
|
||||
* translocated apps are read-only);
|
||||
* - keys ride the child's ENV, never argv; auth.json (BYOK) is a flat
|
||||
* {VAR: value} JSON map, plaintext, mode 0600, hand-edited until the
|
||||
* settings milestone builds the UI.
|
||||
*/
|
||||
|
||||
const { app, BrowserWindow, Menu, session, shell } = require("electron");
|
||||
const { spawn } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
|
||||
const PRODUCT_NAME = "LearnHarness";
|
||||
const AUTH_FILE = () => path.join(app.getPath("userData"), "auth.json");
|
||||
const DATA_DIR = () => path.join(app.getPath("userData"), "data");
|
||||
|
||||
/** The packaged standalone server (extraResources → Resources/server).
|
||||
* In dev LH_DEV_URL short-circuits everything: no spawn, no packaging —
|
||||
* the window points at the running `next dev` server (full HMR). */
|
||||
function serverEntry() {
|
||||
// extraResources ships the whole .next payload under server/standalone/
|
||||
// (copied from .next with an explicit filter — see electron-builder.yml).
|
||||
return path.join(process.resourcesPath, "server", "standalone", "server.js");
|
||||
}
|
||||
|
||||
// ─── Single instance: two instances would fight over the data dir ─────────
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
main();
|
||||
}
|
||||
|
||||
let serverProcess = null;
|
||||
|
||||
/** A free TCP port (listen(0) → the kernel assigns one). The close→spawn
|
||||
* race is theoretical; localhost ports are rarely contended, and a failed
|
||||
* spawn surfaces in the health check with the child's stderr in the log. */
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const { port } = srv.address();
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
srv.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** BYOK keys: auth.json in userData, flat {VAR: value} map, 0600.
|
||||
* Absent file = no keys injected — the app runs, model calls fail with the
|
||||
* engine's own clear error until the settings milestone adds the UI.
|
||||
* Real environment variables win over the file (process.env precedence is
|
||||
* also Next's own env loading order, so this stays consistent). */
|
||||
function authEnv() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(AUTH_FILE(), "utf-8"));
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw).filter(([, v]) => typeof v === "string").map(([k, v]) => [k, v]),
|
||||
);
|
||||
} catch {
|
||||
return {}; // absent or corrupt — the documented state is "no keys yet"
|
||||
}
|
||||
}
|
||||
|
||||
function log(line) {
|
||||
const stamp = new Date().toISOString();
|
||||
console.log(`[lh ${stamp}] ${line}`);
|
||||
// Belt and braces: also append to userData/logs so a misbehaving child
|
||||
// can be diagnosed after the window is gone. Best effort only.
|
||||
try {
|
||||
const dir = path.join(app.getPath("userData"), "logs");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(path.join(dir, "main.log"), `[${stamp}] ${line}\n`);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
function startServer(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[serverEntry()],
|
||||
{
|
||||
cwd: path.dirname(serverEntry()),
|
||||
env: {
|
||||
...process.env,
|
||||
...authEnv(),
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
NODE_ENV: "production",
|
||||
PORT: String(port),
|
||||
HOSTNAME: "127.0.0.1", // the server defaults to 0.0.0.0 — never
|
||||
LH_DATA_DIR: DATA_DIR(),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
serverProcess = child;
|
||||
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
|
||||
child.stdout.on("data", fwd);
|
||||
child.stderr.on("data", fwd);
|
||||
child.on("exit", (code, signal) => {
|
||||
log(`server exited (code=${code} signal=${signal})`);
|
||||
serverProcess = null;
|
||||
});
|
||||
resolve(child);
|
||||
});
|
||||
}
|
||||
|
||||
/** No "ready" event exists — poll until the server answers (docs-confirmed). */
|
||||
function waitUntilUp(url, tries = 150) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const attempt = (n) => {
|
||||
const req = http.get(url, (res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode !== undefined);
|
||||
});
|
||||
req.on("error", () => {
|
||||
if (n <= 0) reject(new Error(`server never answered on ${url}`));
|
||||
else setTimeout(() => attempt(n - 1), 200);
|
||||
});
|
||||
};
|
||||
attempt(tries);
|
||||
});
|
||||
}
|
||||
|
||||
function killServer() {
|
||||
if (!serverProcess) return;
|
||||
const child = serverProcess;
|
||||
serverProcess = null;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/** Security posture (all defaults asserted explicitly; the guards are the
|
||||
* checklist's own recommendations for localhost-app shells):
|
||||
* - deny-by-default permission handler allowing only our loopback origin
|
||||
* (groundwork for the mic/STT milestone — Electron auto-approves by
|
||||
* default, we do not);
|
||||
* - will-navigate pinned to the app origin; window.open always denied —
|
||||
* external links go to the real browser via shell.openExternal. */
|
||||
function createWindow(url) {
|
||||
const win = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 900,
|
||||
title: PRODUCT_NAME,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
const allowedOrigin = () => new URL(url).origin;
|
||||
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) from
|
||||
// our own loopback origin. Electron's default is auto-approve-everything
|
||||
// — we do not ship that.
|
||||
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback, details) => {
|
||||
const requestOrigin = details.requestingUrl ? new URL(details.requestingUrl).origin : null;
|
||||
callback(requestOrigin === allowedOrigin() && permission === "media");
|
||||
});
|
||||
session.defaultSession.setPermissionCheckHandler((_wc, permission, origin) => {
|
||||
return origin === allowedOrigin() && permission === "media";
|
||||
});
|
||||
|
||||
win.webContents.on("will-navigate", (event, target) => {
|
||||
if (new URL(target).origin !== allowedOrigin()) {
|
||||
event.preventDefault();
|
||||
void shell.openExternal(target);
|
||||
}
|
||||
});
|
||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||
void shell.openExternal(target);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
void win.loadURL(url);
|
||||
return win;
|
||||
}
|
||||
|
||||
/** macOS needs an application menu with the Edit roles — without one,
|
||||
* Cmd+C/V/A do nothing in the renderer. Everything else stays minimal. */
|
||||
function installMenu() {
|
||||
const template = [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
},
|
||||
{ label: "Edit", submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }] },
|
||||
{
|
||||
label: "View",
|
||||
submenu: [{ role: "reload" }, { role: "toggleDevTools" }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }],
|
||||
},
|
||||
{ role: "windowMenu" },
|
||||
];
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
app.on("window-all-closed", () => {
|
||||
// macOS convention: the app stays resident, reactivated from the Dock.
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
|
||||
// The child must not outlive us — cover every quit path.
|
||||
app.on("before-quit", killServer);
|
||||
process.on("exit", killServer);
|
||||
process.on("SIGHUP", killServer);
|
||||
|
||||
await app.whenReady();
|
||||
|
||||
// Dev mode: point at a running `next dev` server (LH_DEV_URL) — no
|
||||
// spawn, no packaging, full HMR. The rest of this shell still applies
|
||||
// (guards, menu, single instance).
|
||||
const devUrl = process.env.LH_DEV_URL;
|
||||
const url = devUrl ?? `http://127.0.0.1:${await freePort()}`;
|
||||
if (!devUrl) {
|
||||
log(`starting server: ${serverEntry()}`);
|
||||
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
|
||||
await startServer(new URL(url).port);
|
||||
await waitUntilUp(url);
|
||||
log(`server up: ${url}`);
|
||||
} else {
|
||||
log(`dev mode: ${devUrl}`);
|
||||
}
|
||||
|
||||
installMenu();
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow(url);
|
||||
});
|
||||
createWindow(url);
|
||||
log(`window open: ${url}`);
|
||||
}
|
||||
+3
-1
@@ -20,7 +20,9 @@ import importPlugin from "eslint-plugin-import";
|
||||
*/
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores([".next/**", "node_modules/**", "data/**", "next-env.d.ts", "scripts/**"]),
|
||||
// electron/** — the desktop shell: plain Node, no framework, outside the
|
||||
// import law (same rationale as scripts/).
|
||||
globalIgnores([".next/**", "node_modules/**", "data/**", "next-env.d.ts", "scripts/**", "electron/**", "dist/**"]),
|
||||
{
|
||||
files: ["**/*.{js,mjs,ts,tsx}"],
|
||||
languageOptions: {
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Desktop target (Electron wrapper, 2026-09-17): standalone output = a
|
||||
// self-contained .next/standalone/server.js the Electron shell spawns
|
||||
// on 127.0.0.1. Harmless for plain web deploys (extra build output).
|
||||
// The manual copies the docs require — public/ and .next/static — are
|
||||
// done by scripts/build-server.mjs.
|
||||
output: "standalone",
|
||||
// SAFETY (empirically verified 2026-09-17): without this, output file
|
||||
// tracing sweeps the repo's real data/ — chat records, graph ledgers —
|
||||
// INTO the standalone bundle. A shipped desktop app would carry the
|
||||
// developer's session data inside it.
|
||||
outputFileTracingExcludes: {
|
||||
"*": ["data/**/*"],
|
||||
},
|
||||
// Empirically verified gaps (2026-09-17 standalone smoke): the tracer
|
||||
// misses JSON loaded via `import ... with { type: "json" }` attributes —
|
||||
// pi-ai's provider catalog (39 JSON files) vanished from the trace and
|
||||
// the server 500s on module load. Include the whole data dirs of the
|
||||
// runtime-loaded engine packages.
|
||||
outputFileTracingIncludes: {
|
||||
"*": [
|
||||
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
|
||||
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
|
||||
// onnxruntime-node: the .node binding was traced, but its
|
||||
// sibling dylib (libonnxruntime.*.dylib, dlopen'd via @rpath)
|
||||
// was not — dlopen fails at module load.
|
||||
"node_modules/onnxruntime-node/bin/**/*",
|
||||
],
|
||||
},
|
||||
// Framework-free core lives in src/ (plain TS, imported by routes and CLI alike).
|
||||
// The pi engine must stay external: its providers are runtime-loaded Node
|
||||
// code (undici streams, catalog data) that breaks when bundled into routes.
|
||||
|
||||
Generated
+2781
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -4,13 +4,16 @@
|
||||
"description": "A standalone learning harness: a teacher agent (AI SDK) with quiz/ask interactions, session artifacts, and a derived knowledge graph of what the learner knows.",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "NODE_ENV=test tsx --test tests/*.test.ts tests/*.test.tsx",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"",
|
||||
"desktop:dist": "next build && node scripts/build-server.mjs && electron-builder --mac --arm64"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^4.0.79",
|
||||
@@ -66,6 +69,9 @@
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@typescript-eslint/parser": "^8.68.0",
|
||||
"concurrently": "^10.0.5",
|
||||
"electron": "^44.4.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* build-server.mjs — curate the Electron shell's server payload.
|
||||
*
|
||||
* `next build` (output: "standalone") produces .next/standalone/ with
|
||||
* server.js + the traced node_modules subset, but leaves two pieces
|
||||
* outside that the docs require us to copy: public/ and .next/static/.
|
||||
* This script finishes the job and sanity-checks the result — including
|
||||
* the exclusion that keeps the repo's real data/ OUT of the bundle
|
||||
* (see next.config.ts outputFileTracingExcludes).
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, "..");
|
||||
const STANDALONE = path.join(ROOT, ".next", "standalone");
|
||||
|
||||
const copyTree = (from, to) => {
|
||||
fs.cpSync(from, to, { recursive: true });
|
||||
};
|
||||
|
||||
const exists = (p) => fs.existsSync(p);
|
||||
|
||||
if (!exists(path.join(STANDALONE, "server.js"))) {
|
||||
console.error("[build-server] .next/standalone/server.js missing — run `next build` first (output: 'standalone').");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1) Static assets: public/ → standalone/public, .next/static → standalone/.next/static
|
||||
const staticFrom = path.join(ROOT, ".next", "static");
|
||||
const staticTo = path.join(STANDALONE, ".next", "static");
|
||||
if (!exists(staticFrom)) {
|
||||
console.error("[build-server] .next/static missing — the build looks incomplete.");
|
||||
process.exit(1);
|
||||
}
|
||||
copyTree(staticFrom, staticTo);
|
||||
if (exists(path.join(ROOT, "public"))) {
|
||||
copyTree(path.join(ROOT, "public"), path.join(STANDALONE, "public"));
|
||||
}
|
||||
|
||||
// 2) Safety: the repo's real data/ must NEVER ride along in the bundle.
|
||||
const leakedData = path.join(STANDALONE, "data");
|
||||
if (exists(leakedData)) {
|
||||
console.error("[build-server] REFUSING: standalone contains a data/ directory — repo session data would ship in the app.");
|
||||
console.error(" Check outputFileTracingExcludes in next.config.ts.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3) Sanity: the runtime-read prompt assets must be present (the teacher
|
||||
// agent reads them from disk at request time).
|
||||
for (const asset of [
|
||||
path.join("src", "agent", "system-prompt.md"),
|
||||
path.join("src", "agent", "prompts", "teacher", "20-teaching.md"),
|
||||
]) {
|
||||
if (!exists(path.join(STANDALONE, asset))) {
|
||||
console.error(`[build-server] prompt asset missing from the trace: ${asset}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[build-server] server payload ready:", STANDALONE);
|
||||
@@ -18,7 +18,9 @@
|
||||
* the whole ledger stale and this module re-embeds from scratch (minutes
|
||||
* at current scale).
|
||||
*/
|
||||
import { pipeline, type FeatureExtractionPipeline } from "@huggingface/transformers";
|
||||
import * as fs from "node:fs";
|
||||
import { env, pipeline, type FeatureExtractionPipeline } from "@huggingface/transformers";
|
||||
import { PATHS } from "../../config.ts";
|
||||
import type { EmbeddingsLedger, GraphNode } from "../core/contracts.ts";
|
||||
import { semanticClusters, SEMANTIC_SIM_THRESHOLD } from "../core/semantic-clusters.ts";
|
||||
|
||||
@@ -30,6 +32,15 @@ export const EMBEDDING_DIM = 512;
|
||||
* keeps the pipeline warm without thrashing. */
|
||||
const EMBED_BATCH = 32;
|
||||
|
||||
// Model cache routing (Electron seam, 2026-09-17): the default cacheDir
|
||||
// lives inside node_modules — read-only inside a packaged .app bundle.
|
||||
// When LH_DATA_DIR is set the cache follows the data seam (userData);
|
||||
// in dev it stays at the package default (the model is already there).
|
||||
if (PATHS.modelCache) {
|
||||
fs.mkdirSync(PATHS.modelCache, { recursive: true });
|
||||
env.cacheDir = PATHS.modelCache;
|
||||
}
|
||||
|
||||
let pipelinePromise: Promise<FeatureExtractionPipeline> | null = null;
|
||||
|
||||
function getPipeline(): Promise<FeatureExtractionPipeline> {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* The LH_DATA_DIR seam (Electron contract, 2026-09-17): when set, every
|
||||
* mutable record relocates under it; when unset, byte-for-byte the old
|
||||
* repo-data/ behavior. The desktop shell's whole data story rests on this,
|
||||
* so pin both directions.
|
||||
*
|
||||
* The seam is evaluated at MODULE LOAD, so the "set" case must set the env
|
||||
* BEFORE the first import of config.ts in this process (dynamic import,
|
||||
* below) and the "unset" case needs a fresh child process.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, "..");
|
||||
const TSX_LOADER = path.join(REPO_ROOT, "node_modules", "tsx", "dist", "loader.mjs");
|
||||
|
||||
describe("LH_DATA_DIR seam", () => {
|
||||
it("relocates data/chats/graph + modelCache under LH_DATA_DIR, keeps prompts ROOT-anchored", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-seam-"));
|
||||
process.env.LH_DATA_DIR = dir;
|
||||
try {
|
||||
const { PATHS, ROOT } = await import("../config.ts");
|
||||
assert.equal(PATHS.data, dir, "PATHS.data resolves the env value (resolved absolute)");
|
||||
assert.equal(PATHS.chats, path.join(dir, "chats"));
|
||||
assert.equal(PATHS.graph, path.join(dir, "graph"));
|
||||
assert.equal(PATHS.modelCache, path.join(dir, "models"), "the HF model cache follows the seam");
|
||||
assert.equal(
|
||||
PATHS.teacherPrompt,
|
||||
path.join(ROOT, "src", "agent", "system-prompt.md"),
|
||||
"read-only prompt assets stay bundle-anchored on purpose",
|
||||
);
|
||||
} finally {
|
||||
delete process.env.LH_DATA_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
it("unset = the repo data/ exactly as before (fresh child process, no env)", () => {
|
||||
const out = execFileSync(
|
||||
process.execPath,
|
||||
["--import", TSX_LOADER, "--eval", `const m = await import(${JSON.stringify(path.join(REPO_ROOT, "config.ts"))}); console.log(JSON.stringify({ data: m.PATHS.data, chats: m.PATHS.chats, modelCache: m.PATHS.modelCache }));`],
|
||||
{ cwd: REPO_ROOT, encoding: "utf-8" },
|
||||
);
|
||||
const parsed = JSON.parse(out) as { data: string; chats: string | null; modelCache: string | null };
|
||||
assert.equal(parsed.data, path.join(REPO_ROOT, "data"));
|
||||
assert.equal(parsed.chats, path.join(REPO_ROOT, "data", "chats"));
|
||||
assert.equal(parsed.modelCache, null, "dev keeps the transformers package default cache");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user