diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 9eebb8c2..7688eea9 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -462,3 +462,16 @@ answers like a well-written engineering doc, not a terminal log: reader's next move, not by compressing the prose. - **LaTeX for all math.** $\hat H$, $\Omega_{\max}$, $F = 0.9982$ — inline or display — never ASCII approximations. + +> **Live context — solver mode + routing, fleet, profile, recent problems, reference +> demos, mount stack, memory index — is injected into every session by the +> amicode_context plugin.** If you do not see a `## Stack state (live)`, `## Fleet +> (live)`, `## About this user`, or `## Memory index` block anywhere in this prompt, +> read the state directly before acting: +> - solver mode + routing: `~/.amico/amicode/solver-mode.json` and +> `~/.amico/connections.json` — especially before setting `tier` or `executor` +> in a solvespec. +> - fleet: `~/.amico/ops/fleet/fleet.json` and `~/.amico/ops/fleet-status.json`. +> - profile, problems, demos, mounts, memory: the personal Armonia mount (first +> `kind = "personal"` dir under `~/.amico/vaults/`) — its `amicode/PROFILE.md`, +> `amicode/KNOWLEDGE.md`, `amicode/DEMOS.md`, and `amicode/memory/MEMORY.md`. diff --git a/packages/extension/opencode-plugin/amicode_context.ts b/packages/extension/opencode-plugin/amicode_context.ts new file mode 100644 index 00000000..788d3d13 --- /dev/null +++ b/packages/extension/opencode-plugin/amicode_context.ts @@ -0,0 +1,36 @@ +// ============================================================================ +// amicode_context — an opencode plugin that injects live stack-state context +// (solver mode, routing, active problem, live runs) into every system prompt +// via the `experimental.chat.system.transform` hook. +// +// RUNTIME: same constraints as amicode_tools.ts — executes inside opencode's +// embedded Bun runtime, registered by absolute path via OPENCODE_CONFIG_CONTENT +// `plugin: [""]`. Exactly ONE export (the legacy-plugin scan constraint). +// All imports are sibling modules using node: builtins only. +// +// This is a SECOND plugin file alongside amicode_tools.ts; it is registered as +// a separate entry in the `plugin` array and operates independently from the +// tool pack. The split keeps the tested tool pack untouched and respects the +// single-export constraint. +// ============================================================================ + +import { buildStackStateBlock } from "./stack_state"; + +console.error("[amicode-context] loaded — stack-state injection plugin (experimental.chat.system.transform)"); + +export const AmicodeContext = async () => ({ + "experimental.chat.system.transform": ( + _input: { sessionID?: string; model?: string }, + output: { system: string[] }, + ): void => { + try { + const block = buildStackStateBlock(); + if (block) { + output.system.push(block); + } + } catch (e) { + console.error(`[amicode-context] buildStackStateBlock failed: ${e instanceof Error ? e.message : String(e)}`); + // Never throw — a failing hook must not break the prompt build. + } + }, +}); diff --git a/packages/extension/opencode-plugin/stack_state.ts b/packages/extension/opencode-plugin/stack_state.ts new file mode 100644 index 00000000..777767d9 --- /dev/null +++ b/packages/extension/opencode-plugin/stack_state.ts @@ -0,0 +1,608 @@ +// ============================================================================ +// Stack-state readers and builders — shared between the amicode_context.ts +// plugin and (eventually) the extension. Must use only node: builtins (fs, +// path, os) — imported by the Bun-runtime plugin via relative sibling import. +// +// Standalone copies of the reader logic from solver_mode.ts and routing.ts, +// minus the smol-toml dependency (not available in the plugin's Bun runtime), +// PLUS the live fleet line and the user-memory sections (profile, recent +// problems, reference demos, mount stack, memory index) — previously boot-time +// file splices, now read from the personal vault on every prompt build so a +// distiller write in the morning reaches the next message without a restart. +// Section text is pinned byte-for-byte by test/stack_state.test.ts (golden +// strings carried over from the retired substrate/user_splice.ts). +// ============================================================================ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +// ── Solver mode ────────────────────────────────────────────────────────────── + +function solverModeFile(): string { + const opsDir = process.env.AMICODE_OPS_DIR; + if (opsDir && opsDir.trim() !== "") return path.join(opsDir.trim(), "solver-mode.json"); + return path.join(os.homedir(), ".amico", "amicode", "solver-mode.json"); +} + +function readSolverModeState(): { mode: "piccolo" | "hp"; status: "ready" | "switching" } { + try { + const parsed = JSON.parse(fs.readFileSync(solverModeFile(), "utf8")) as Record; + return { + mode: parsed.mode === "hp" ? "hp" : "piccolo", + status: parsed.status === "switching" ? "switching" : "ready", + }; + } catch { + return { mode: "piccolo", status: "ready" }; + } +} + +// ── Cloud connection status ────────────────────────────────────────────────── + +function connectionsStatusFile(): string { + const env = process.env.AMICODE_CONNECTIONS_FILE; + if (env && env.trim() !== "") return env; + return path.join(os.homedir(), ".amico", "connections.json"); +} + +function readCompanyComputeStatus(): { connected: boolean; identity?: string } { + try { + const raw = JSON.parse(fs.readFileSync(connectionsStatusFile(), "utf8")) as unknown; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { connected: false }; + const obj = raw as Record; + let entry: Record | undefined; + if (Array.isArray(obj.connections)) { + entry = obj.connections.find( + (c): c is Record => + typeof c === "object" && c !== null && (c as Record).id === "company-compute", + ); + } else if (typeof obj["company-compute"] === "object" && obj["company-compute"] !== null) { + entry = obj["company-compute"] as Record; + } + if (!entry) return { connected: false }; + const identity = typeof entry.identity === "string" && entry.identity !== "" ? entry.identity : undefined; + return { connected: entry.state === "connected", ...(identity ? { identity } : {}) }; + } catch { + return { connected: false }; + } +} + +// ── Active problem ─────────────────────────────────────────────────────────── + +function problemsRoot(): string { + const env = process.env.AMICODE_PROBLEMS_DIR; + if (env && env.trim() !== "") return env; + return path.join(os.homedir(), ".amico", "problems"); +} + +function activeProblemSlug(): string | undefined { + try { + const activeFile = path.join(problemsRoot(), "active"); + if (!fs.existsSync(activeFile)) return undefined; + const slug = fs.readFileSync(activeFile, "utf8").trim(); + if (!slug) return undefined; + const dir = path.join(problemsRoot(), slug); + return fs.existsSync(dir) ? slug : undefined; + } catch { + return undefined; + } +} + +function readEntityJson(slug: string, kind: string): T | undefined { + try { + const file = path.join(problemsRoot(), slug, "entities", `${kind}.json`); + if (!fs.existsSync(file)) return undefined; + return JSON.parse(fs.readFileSync(file, "utf8")) as T; + } catch { + return undefined; + } +} + +// ── Live runs ──────────────────────────────────────────────────────────────── + +function runsRoot(): string { + const env = process.env.AMICODE_RUNS_DIR; + if (env && env.trim() !== "") return env; + return path.join(os.homedir(), ".amico", "runs"); +} + +// ── Section builders ───────────────────────────────────────────────────────── + +/** Full solver-mode guidance section (Altissimo gotchas, import-both warning, + * routing sub-text). Mirrors opencode_config.ts solverModeSection() text. */ +function buildSolverModeSection(): string { + const state = readSolverModeState(); + if (state.mode !== "hp") return ""; + + const status = readCompanyComputeStatus(); + const connected = status.connected; + const routing = connected + ? "Harmoniqs Cloud is CONNECTED, and EVERY solve on this solver runs there — this tier has no local " + + "mode, so never ask the user where a solve should run. Author it as: " + + '`tier="hpc"`, `executor="remote"`, `env.kind="provisioned"` (via `amico-run --spec ' + + " --executor remote`). The runner image has Piccolissimo/Altissimo pre-baked, so there " + + "is NO local precompile and NO sandbox — never author a sandbox env for HP. A local launch is " + + "REFUSED by amico-run while this solver is selected (exit 64), so attempting one only wastes a turn. " + + "Live iteration frames stream to the Inspector; note that per-iteration AMICODE_ITER stats + the " + + "cooperative Stop are not yet available on the cloud bundle, and re-rollout verification is skipped " + + "for cloud runs (say so). Only claim cloud execution when the launch actually used `--executor remote`." + : "Harmoniqs Cloud is NOT connected (no API key). Piccolissimo + Altissimo is a PAID cloud tier and " + + "CANNOT run locally — do NOT attempt a local Piccolissimo solve (it will fail three ways: amico-run " + + "refuses a local launch in this mode, the private package can't be instantiated in a sandbox, and " + + "the gate rejects a local hpc run). Instead, STOP and tell the user: " + + '"Piccolissimo + Altissimo needs a Harmoniqs Cloud connection — click **Piccolissimo + Altissimo** ' + + "in the model · solver control on the dashboard and connect your API key there (or run **Amico: " + + 'Connect Cloud**, which opens the same flow)." Offer to switch back to the free local Piccolo solver ' + + "if they'd rather not connect now."; + + return ( + "## Solver mode\n" + + "**HIGH-PERFORMANCE + CLOUD (Piccolissimo + Altissimo).** The user selected the paid " + + '"High-Performance + Cloud" solver. Author solves with the **Piccolissimo** stack ' + + "(SplinePulseProblem, free-phase paths, `using Piccolissimo`) rather than plain Piccolo, falling back " + + "to Piccolo only when Piccolissimo cannot express the problem (say so when you do). " + + "**Import BOTH: `using Piccolo` AND `using Piccolissimo`.** Piccolissimo does NOT re-export Piccolo's " + + "symbols, so a script with only `using Piccolissimo` dies on the first `GATES[:X]`, `TransmonSystem`, " + + "`EmbeddedOperator`, `UnitaryTrajectory` — every problem-setup name comes from Piccolo. The failure is " + + "an UndefVarError at load time, before any solve starts, and on a cloud run you pay the full queue and " + + "instance-boot wait before seeing it. " + + "**Solver backend:** the default remains IPOPT (`IpoptOptions`), which is what streams per-iteration " + + "telemetry — its `intermediate_callback` produces the Inspector's frames and the `AMICODE_ITER` lines. " + + "If the researcher asks for the **Altissimo** backend (the augmented-Lagrangian GPU solver, " + + "`AltissimoOptions`), switch it by setting **`SOLVER = :altissimo`** in the template's FILL-IN block — that " + + "one line is the whole change. Do NOT hand-roll the solve call: the template already re-hangs BOTH telemetry " + + "channels onto Altissimo's `(x, info)` hook (the frames come off `IpoptOptions.intermediate_callback`, which " + + "`AltissimoOptions` does not have, so a hand-written call loses the Inspector's frames as well as its " + + "numbers), passes the budget as `AltissimoOptions(max_outer_iter = max_iter)` (a `max_iter` given to " + + "`solve!` is silently DROPPED on that path — the solve would quietly run 20 outer iterations), and derives " + + "`inf_pr`/`inf_du` on older Altissimo builds. " + + "Also TELL THEM that live iterations depend on the INSTALLED version. " + + "Current Piccolissimo main accepts a `callback` on `solve!(::AltissimoOptions)` and forwards it to " + + "`Altissimo.optimize!`, which fires it every outer iteration; older builds swallow `kwargs...` and forward " + + "nothing, so an Altissimo run there emits NO AMICODE_ITER lines and the Run Inspector stays dark until the " + + "solve finishes. Do not promise live iterations you have not seen: run it, and if no AMICODE_ITER line " + + "appears in the first iterations, say so plainly rather than implying the solve is stuck. Never switch to " + + "Altissimo silently. " + + routing + ); +} + +/** Full routing guidance section. Mirrors routing.ts buildRoutingSection text. */ +function buildRoutingSection(): string { + const state = readSolverModeState(); + const status = readCompanyComputeStatus(); + + if (!status.connected || state.mode !== "hp") return ""; + + const who = status.identity ? ` (connected as ${status.identity})` : ""; + return ( + "## Routing (where THIS solve runs)\n" + + `Harmoniqs Cloud is connected${who} and the selected solver is **Piccolissimo + Altissimo**, ` + + "which is a CLOUD-ONLY tier. Every solve on this solver runs in the cloud: there is no " + + "local-vs-cloud choice to make here, so do NOT ask the researcher where it should run.\n" + + "- **Author it as High-Performance + Cloud.** Set `tier=\"hpc\"`, `executor=\"remote\"`, and " + + '`env.kind="provisioned"` on solvespec.json, then launch with `amico-run --spec ' + + " --executor remote`.\n" + + "- **Never dispatch this solver locally.** The runner image has Piccolissimo/Altissimo " + + "pre-baked; a laptop would precompile the HP stack from scratch. amico-run REFUSES a local " + + "launch while this solver is selected (exit 64), so a local attempt only wastes a turn.\n" + + "- **`amico-run estimate` is still worth running** to report size and cost to the " + + "researcher, but it no longer decides anything: an estimate that fits in local RAM does " + + "not make an HP solve local.\n" + + "- **A local solve means switching solvers.** If the researcher wants to run locally, they " + + "switch the solver to Piccolo (the model · solver control) — that is a user action, not " + + "something you can do for them by setting `executor: \"local\"`. " + ); +} + +/** Compact active-problem block: one line showing slug + entity presence. */ +function buildActiveProblemBlock(): string { + const slug = activeProblemSlug(); + if (!slug) return ""; + + const sys = readEntityJson>(slug, "system"); + const form = readEntityJson>(slug, "formulation"); + const hasSys = !!sys; + const hasForm = !!form; + + if (!hasSys && !hasForm) return `active problem: **${slug}** (no entities recorded yet)`; + + let desc = `active problem: **${slug}** —`; + if (hasSys) desc += " system ✓"; + if (hasForm) { + const f = form as Record; + const target = f.target ?? "?"; + const traj = f.trajectory_type ?? "?"; + const parts = [target, traj]; + if (f.time_mode === "min_time") parts.push("min-time"); + if (f.free_phase) parts.push("free-phase"); + desc += `, formulation ✓ (${parts.join(" · ")})`; + } + return desc; +} + +/** Compact live-runs block: one-line-per-run with status. */ +function buildLiveRunsBlock(): string { + const root = runsRoot(); + const lines: string[] = []; + + try { + if (!fs.existsSync(root)) return ""; + + const labs = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()); + for (const lab of labs) { + const labDir = path.join(root, lab.name); + const runs = fs.readdirSync(labDir, { withFileTypes: true }).filter((d) => d.isDirectory()); + for (const run of runs) { + const runDir = path.join(labDir, run.name); + const solved = !fs.existsSync(path.join(runDir, "FINISHED")); + let fidelity: string | undefined; + const resultPath = path.join(runDir, "result.toml"); + if (fs.existsSync(resultPath)) { + try { + const content = fs.readFileSync(resultPath, "utf8"); + const m = content.match(/fidelity\s*=\s*([\d.eE+-]+)/); + if (m) fidelity = parseFloat(m[1]).toFixed(6); + } catch { /* skip */ } + } + const status = solved ? "solving" : fidelity ? `done (F=${fidelity})` : "done"; + lines.push(`- ${run.name} @ ${lab.name}: ${status}`); + } + } + } catch { + return ""; // optional — silent on error + } + + return lines.length > 0 ? "**live runs**\n" + lines.join("\n") : ""; +} + +// ── Fleet state ────────────────────────────────────────────────────────────── + +function fleetConfigFile(override?: string): string { + if (override) return override; + const env = process.env.AMICO_FLEET_CONFIG; + if (env && env.trim() !== "") return env.trim(); + return path.join(os.homedir(), ".amico", "ops", "fleet", "fleet.json"); +} + +function fleetStatusFile(override?: string): string { + if (override) return override; + const env = process.env.AMICO_FLEET_STATUS; + if (env && env.trim() !== "") return env.trim(); + return path.join(os.homedir(), ".amico", "ops", "fleet-status.json"); +} + +/** Fleet role from fleet.json — "server" | "client" | "standalone". + * No file = null (a standalone machine has no fleet to report). */ +function readFleetRole(configPath?: string): string | null { + try { + const parsed = JSON.parse(fs.readFileSync(fleetConfigFile(configPath), "utf8")) as Record; + return typeof parsed.role === "string" && parsed.role !== "" ? parsed.role : null; + } catch { + return null; + } +} + +interface FleetStatusSummary { + total: number; + up: number; + names: string[]; + /** Minutes since collected_at, when parseable. */ + ageMin?: number; +} + +function readFleetStatus(statusPath?: string): FleetStatusSummary | undefined { + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(fleetStatusFile(statusPath), "utf8")); + } catch { + return undefined; + } + if (typeof raw !== "object" || raw === null) return undefined; + const obj = raw as Record; + if (!Array.isArray(obj.devices)) return undefined; + const devices = obj.devices.filter( + (d): d is Record => typeof d === "object" && d !== null, + ); + let ageMin: number | undefined; + if (typeof obj.collected_at === "string") { + const t = Date.parse(obj.collected_at); + if (!Number.isNaN(t)) ageMin = Math.max(0, Math.round((Date.now() - t) / 60000)); + } + return { + total: devices.length, + up: devices.filter((d) => d.reachable === true).length, + names: devices + .map((d) => (typeof d.name === "string" ? d.name : "")) + .filter((n) => n !== ""), + ageMin, + }; +} + +/** Lean fleet line + on-demand pointers (the reader's choice: detail loads + * from fleet-status.json / the fleet skill only when relevant). Absent + * fleet.json (standalone or no fleet tooling) → "" — nothing to say. */ +function buildFleetSection(opts: { configPath?: string; statusPath?: string } = {}): string { + const role = readFleetRole(opts.configPath); + if (role === null) return ""; + + const roleText = + role === "server" + ? "**server** — this machine is the canonical Amicode server" + : role === "client" + ? "**client** — rides the tunnel to the canonical server" + : `**${role}**`; + const lines = [`## Fleet (live)`, `Role: ${roleText} (\`~/.amico/ops/fleet/fleet.json\`).`]; + + const status = readFleetStatus(opts.statusPath); + if (status) { + const who = status.names.length > 0 ? ` (${status.names.join(", ")})` : ""; + const age = status.ageMin !== undefined ? ` — refreshed ${status.ageMin} min ago` : ""; + lines.push( + `Devices: ${status.up}/${status.total} reachable${who}${age} (launchd, 5-min cadence).`, + ); + } else { + lines.push("Devices: status unknown (`~/.amico/ops/fleet-status.json` unreadable)."); + } + lines.push( + "Full status on demand: `~/.amico/ops/fleet-status.json` (devices, chat-db health,", + "server guard, repo sync). The `fleet` skill is the playbook for the sync/lock", + "rituals; code repos sync by `wip-sync.sh` leave/arrive — never file-sync a live `.git`.", + ); + return lines.join("\n"); +} + +// ── Armonia mount stack + user memory (live) ───────────────────────────────── +// +// Marker-only port of the extension's mount_store.ts discovery (same kind +// ranks, same skip rules) MINUS the mounts.toml manifest — smol-toml is not +// available in the plugin's Bun runtime. No manifest exists in practice today +// (kind-rank ordering is byte-equivalent); if one ever appears, the extension's +// full resolver at prep time remains canonical and this section degrades to a +// slightly stale ordering. Section text pinned by test/stack_state.test.ts. + +interface LiveMount { + name: string; + kind: string; + path: string; + writable: boolean; +} + +function vaultsRoot(override?: string): string { + if (override) return override; + const env = process.env.AMICO_VAULTS_ROOT; + if (env && env.trim() !== "") return env.trim(); + return path.join(os.homedir(), ".amico", "vaults"); +} + +/** Kind ranks + writable-by-default posture (vault-CLI spec-20260703-053956). */ +function kindRank(kind: string): number { + switch (kind) { + case "personal": + return 0; + case "engagement": + return 1; + case "project": + return 2; + case "restricted": + return 3; + case "team": + return 4; + case "public": + return 5; + default: + return 6; + } +} + +function writableByKind(kind: string): boolean { + return kind === "personal" || kind === "project" || kind === "engagement"; +} + +/** Regex-lite marker parse — the markers carry only `kind` and `name`. */ +function parseMarker(text: string): { kind?: string; name?: string } { + const kind = text.match(/^\s*kind\s*=\s*"([^"]+)"\s*$/m); + const name = text.match(/^\s*name\s*=\s*"([^"]+)"\s*$/m); + return { kind: kind?.[1], name: name?.[1] }; +} + +function discoverMounts(root?: string): { mounts: LiveMount[]; warnings: string[] } { + const warnings: string[] = []; + let entries: string[]; + try { + entries = fs.readdirSync(vaultsRoot(root)).sort(); + } catch { + return { mounts: [], warnings }; + } + const discovered: LiveMount[] = []; + const seen = new Set(); + for (const base of entries) { + const dir = path.join(vaultsRoot(root), base); + let markerText: string; + try { + markerText = fs.readFileSync(path.join(dir, ".amico-vault.toml"), "utf8"); + } catch { + continue; // marker-less dir: not a mount + } + const m = parseMarker(markerText); + const kind = m.kind ?? ""; + const name = m.name && m.name !== "" ? m.name : base; + if (kind === "") { + warnings.push(`skipped '${base}': marker missing 'kind'`); + continue; + } + if (seen.has(name)) { + warnings.push(`skipped '${base}': duplicate id '${name}'`); + continue; + } + seen.add(name); + discovered.push({ name, kind, path: dir, writable: writableByKind(kind) }); + } + discovered.sort((a, b) => { + const ra = kindRank(a.kind); + const rb = kindRank(b.kind); + if (ra !== rb) return ra - rb; + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + }); + return { mounts: discovered, warnings }; +} + +/** The personal mount (first kind === "personal" in stack order), or undefined. */ +function personalVaultDir(mounts: LiveMount[]): string | undefined { + return mounts.find((m) => m.kind === "personal")?.path; +} + +/** Non-empty PROFILE.md content, or "" (whitespace-only counts as absent). */ +function readProfileMd(vaultDir: string): string { + try { + const text = fs.readFileSync(path.join(vaultDir, "amicode", "PROFILE.md"), "utf8"); + return text.trim() === "" ? "" : text; + } catch { + return ""; + } +} + +/** List-item lines from an amicode index file, capped. */ +function readIndexLines(vaultDir: string, file: string, cap: number): string[] { + let text: string; + try { + text = fs.readFileSync(path.join(vaultDir, "amicode", file), "utf8"); + } catch { + return []; + } + return text + .split("\n") + .filter((l) => l.startsWith("- ")) + .slice(0, cap); +} + +/** Section builders — text pinned by test/stack_state.test.ts (golden strings). */ + +function buildAboutUserSection(profileMd: string): string { + if (!profileMd) return ""; + return [ + "## About this user", + "", + profileMd.trim(), + "", + "Greet and recommend with this context. Anchor the hardware stage on the", + "user's environment card (read it from the vault path above when you reach", + "that stage). Never re-ask what the profile already answers.", + ].join("\n"); +} + +function buildRecentProblemsSection(knowledgeLines: string[]): string { + if (knowledgeLines.length === 0) return ""; + return [ + "## Your recent problems", + "", + ...knowledgeLines, + "", + "Before recommending parameters, check whether the user's target matches one", + "of these cards (read the card file on demand for details). If a pulse exists", + "in the bank, offer a warm start from its `pulse.jld2` path. If a prior", + "attempt failed, surface its lesson before re-authoring.", + ].join("\n"); +} + +function buildReferenceDemosSection(demoLines: string[]): string { + if (demoLines.length === 0) return ""; + return [ + "## Reference demos", + "", + ...demoLines, + "", + "Curated demos we've built — use them as PRECEDENT (medium confidence) when", + "the user's target matches one and there's no own-precedent card. Read the", + "demo card on demand for its params, and cite it in your recommendation.", + ].join("\n"); +} + +function buildMountStackSection(mounts: LiveMount[], warnings: string[]): string { + if (mounts.length === 0) return ""; + const mountLines = mounts.map( + (m) => `- ${m.name} · kind=${m.kind} · ${m.writable ? "rw" : "ro"} · ${m.path}`, + ); + const warnLines = warnings.map((w) => `- ⚠ ${w}`); + return [ + "## Mount stack (Armonia — read precedence top→bottom)", + "", + ...mountLines, + ...warnLines, + "", + "Resolution & write-routing (condensed from the amico-vault skill):", + "- Reads union across all mounts; on the same relative path the first hit", + " top→bottom wins (higher-precedence mount shadows lower).", + "- Writes route by intent to the first WRITABLE mount of that kind:", + " personal→personal, engagement→engagement, project→project,", + " restricted/team/public→their own kind.", + "- If the target mount is absent or read-only, write to the personal mount", + " and stamp `route_intent: ` in the note frontmatter — never silently", + " drop a write, never write a ro mount.", + "- Ambiguous intent: ask once, else default to personal.", + ].join("\n"); +} + +function buildMemoryIndexSection(memoryIndexLines: string[]): string { + if (memoryIndexLines.length === 0) return ""; + return [ + "## Memory index", + "", + ...memoryIndexLines, + "", + "These are one-line pointers. The full typed-memory cards (user / feedback /", + "project / reference) load on demand from the granted vault path under", + "`amicode/memory/` — read a card only when its hook is relevant to the turn.", + ].join("\n"); +} + +// ── Public: compose the full per-session block ─────────────────────────────── + +/** Read the current stack state (solver mode, routing, active problem, live + * runs, fleet, and the personal-vault user-memory sections) and compose a + * markdown block to inject into the agent's system prompt. Returns null + * when nothing to report (no HP mode, no active problem, no runs, no fleet, + * no vault content). */ +export function buildStackStateBlock(): string | null { + const parts: string[] = []; + + const solver = buildSolverModeSection(); + if (solver) parts.push(solver); + + const routing = buildRoutingSection(); + if (routing) parts.push(routing); + + const active = buildActiveProblemBlock(); + const runs = buildLiveRunsBlock(); + if (active || runs) { + const lines = [active, runs].filter(Boolean).join("\n"); + if (lines) parts.push("## Stack state (live)\n" + lines); + } + + const fleet = buildFleetSection(); + if (fleet) parts.push(fleet); + + // User-memory sections (live reads from the personal vault — splice order + // parity with the retired boot-time file splice: about → recent → demos → + // mount stack → memory index). + const { mounts, warnings } = discoverMounts(); + const vault = personalVaultDir(mounts); + if (vault) { + const about = buildAboutUserSection(readProfileMd(vault)); + if (about) parts.push(about); + const recent = buildRecentProblemsSection(readIndexLines(vault, "KNOWLEDGE.md", 50)); + if (recent) parts.push(recent); + const demos = buildReferenceDemosSection(readIndexLines(vault, "DEMOS.md", 30)); + if (demos) parts.push(demos); + } + const mountSection = buildMountStackSection(mounts, warnings); + if (mountSection) parts.push(mountSection); + if (vault) { + const memory = buildMemoryIndexSection( + readIndexLines(vault, path.join("memory", "MEMORY.md"), 50), + ); + if (memory) parts.push(memory); + } + + return parts.length > 0 ? parts.join("\n\n") : null; +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 8a63c5b7..1c9a76a6 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -727,6 +727,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Telemetry gate → experimental.openTelemetry (span generation), coupled // to the exporter env this same spawnEnv resolves. telemetryOpen(), + // Context plugin: injects live stack state (solver mode, routing, + // active problem, live runs) per system-prompt build. + [path.resolve(ctx.extensionPath, "opencode-plugin", "amicode_context.ts")], ), }), channel: opencodeChannel, @@ -765,6 +768,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { skillRoots: cfgArr("skillRoots"), skillLibraryRoots: cfgLibraryRoots(), vaultDir: vscode.workspace.getConfiguration("amicode").get("vaultDir", "") || undefined, + projectDir: path.join((ctx.storageUri ?? ctx.globalStorageUri).fsPath, "opencode-project"), }); ChatPanel.setBugReportAvailable(bugReportSkillStaged(project2.skillPaths)); // #250 AC5 await serverManager?.stop(); @@ -789,6 +793,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Same pin rule as boot: only an explicit amicode.defaultModel pins. vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), telemetryOpen(), // gate → experimental.openTelemetry (span generation) + // Context plugin: injects live stack state per system-prompt build. + [path.resolve(ctx.extensionPath, "opencode-plugin", "amicode_context.ts")], ), }), channel: opencodeChannel, diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 8a111445..e12528d0 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -21,20 +21,10 @@ import { studioPathsOrLegacy } from "@amicode/schema"; import { buildRoutingSection, readRoutingContext } from "./routing"; import { readProfileMd, - readKnowledgeLines, - readDemoLines, - readMemoryIndexLines, hasOnboardingCompleted, onboardingDir, } from "./substrate/vault_store"; import { resolveMountStack, personalMount, type Mount, type MountStack } from "./substrate/mount_store"; -import { - buildAboutUserSection, - buildRecentProblemsSection, - buildReferenceDemosSection, - buildMountStackSection, - buildMemoryIndexSection, -} from "./substrate/user_splice"; // ============================================================================ // Prepare a per-session opencode project directory. @@ -410,6 +400,11 @@ export function buildOpencodeConfigContent( * When false we OMIT the key entirely (rather than force it false) so a user's * own global `experimental.openTelemetry` is never clobbered by the deep-merge. */ telemetryOpen: boolean = false, + /** Additional plugin paths to register alongside pluginPath. Each entry is an + * absolute path to a .ts plugin file. Used to register the amicode_context + * plugin (experimental.chat.system.transform hook) without touching the + * single-export amicode_tools pack. */ + extraPluginPaths: string[] = [], ): string { const templatesDir = path.dirname(templatePath); // Least-privilege read grants for the skill index (spec §3): each indexed @@ -434,7 +429,7 @@ export function buildOpencodeConfigContent( default_agent: "plan", ...(modelPin ? { model: modelPin } : {}), instructions: [agentsPath], - plugin: [pluginPath], + plugin: [pluginPath, ...extraPluginPaths], ...(skills ? { skills } : {}), // Enable AI-SDK span generation ONLY behind the telemetry gate — deep-merges // into cfg.experimental alongside any user keys (see telemetryOpen above). @@ -669,7 +664,11 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro console.warn(`amicode: skill index failed (session continues without it): ${e}`); } - fs.writeFileSync(agentsPath, finalContent + solverModeSection() + routingSection(), "utf8"); + // Solver mode, routing, fleet, and the user-memory sections are injected + // per-prompt by the amicode_context plugin (see the recovery pointer the + // template carries at its tail). Early safe write in case a later step + // throws — the second write below is the authoritative one. + fs.writeFileSync(agentsPath, finalContent, "utf8"); // spec C: write the authoring.json seam amico-run reads (allowlist resolved // from the same entitlements the score filter used + the bundled asset paths). @@ -690,42 +689,16 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro fs.rmSync(path.join(projectDir, "skills"), { recursive: true, force: true }); const skillsStageDir = stageOpencodeSkills(path.join(projectDir, "skills"), skillEntries); - // User-memory splice (spec-20260705-002847 §6): About-this-user + Your-recent- - // problems, appended to the compiled content — own try/catch, personalization - // trouble must never brick the boot. `vaultDir` was resolved up front (above). - // When we routed to the overture (no profile yet) these read empty and add - // nothing — correct: there is no memory to splice on the very first session. - if (vaultDir) { - try { - const about = buildAboutUserSection(readProfileMd(vaultDir)); - const recent = buildRecentProblemsSection(readKnowledgeLines(vaultDir)); - const demos = buildReferenceDemosSection(readDemoLines(vaultDir)); // L1 §3 - for (const section of [about, recent, demos]) { - if (section) finalContent = finalContent + "\n\n" + section; - } - } catch (e) { - console.warn(`amicode: user-memory splice failed (session continues unpersonalized): ${e}`); - } - } - - // Mount-stack + memory-index splice (spec-20260707-002846 C3/C4 read side): - // its OWN try/catch — mount-parity trouble must never brick the boot. The - // mount-stack section renders whenever the stack has mounts (mounts can exist - // without a personal vault — e.g. a team-only stack); the typed-memory index - // is read from the personal mount, so it is gated on vaultDir. Empty stack - // ("" vaultDir) → both builders return "" → nothing is spliced. - try { - const mountSection = buildMountStackSection(stack); - if (mountSection) finalContent = finalContent + "\n\n" + mountSection; - if (vaultDir) { - const memorySection = buildMemoryIndexSection(readMemoryIndexLines(vaultDir)); - if (memorySection) finalContent = finalContent + "\n\n" + memorySection; - } - } catch (e) { - console.warn(`amicode: mount-stack/memory-index splice failed (session continues): ${e}`); - } - - fs.writeFileSync(agentsPath, finalContent + solverModeSection() + routingSection(), "utf8"); + // Solver mode, routing, fleet, and the user-memory sections (profile, + // recent problems, reference demos, mount stack, memory index) are injected + // per-prompt by the amicode_context plugin (experimental.chat.system.transform + // hook), read LIVE from disk — a distiller write or a solver switch reaches + // the next message without a re-prep or restart (the boot-time file splices + // these replaced went stale between sessions; spec-20260705-002847 §6 and + // spec-20260707-002846 C3/C4 read side moved to the live hook). The + // recovery pointer the template carries at its tail tells the agent where + // to read the state directly if the hook ever fails silently. + fs.writeFileSync(agentsPath, finalContent, "utf8"); // The agent reads the template from its bundled absolute path (the session // cwd is the workspace, not this temp dir — so no copy is made here). diff --git a/packages/extension/src/substrate/user_splice.ts b/packages/extension/src/substrate/user_splice.ts deleted file mode 100644 index a38484ca..00000000 --- a/packages/extension/src/substrate/user_splice.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** The personalized splice (spec-20260705-002847 §6): two lean sections built - * from the vault's user-memory files. Both are ≤~3 KB by construction (profile - * capped at ~30 lines by convention, knowledge lines capped at 50 by the - * reader); the agent reads full cards on demand from the granted vault path. - * - * The mount-stack + memory-index sections (spec-20260707-002846 C3/C4 read - * side) live here too — same "build a lean section, splice on demand" shape. */ -import type { MountStack } from "./mount_store"; - -export function buildAboutUserSection(profileMd: string): string { - if (!profileMd) return ""; - return [ - "## About this user", - "", - profileMd.trim(), - "", - "Greet and recommend with this context. Anchor the hardware stage on the", - "user's environment card (read it from the vault path above when you reach", - "that stage). Never re-ask what the profile already answers.", - ].join("\n"); -} - -export function buildReferenceDemosSection(demoLines: string[]): string { - if (demoLines.length === 0) return ""; - return [ - "## Reference demos", - "", - ...demoLines, - "", - "Curated demos we've built — use them as PRECEDENT (medium confidence) when", - "the user's target matches one and there's no own-precedent card. Read the", - "demo card on demand for its params, and cite it in your recommendation.", - ].join("\n"); -} - -/** The Armonia mount stack, top→bottom in read precedence, plus a condensed - * static block mirroring the amico-vault skill's "Mounts & resolution" (so the - * agent knows how reads union and how writes route without loading the skill). - * Empty stack → "" (no mounts discovered ⇒ nothing to say). Parity oracle: the - * session-start hook's rendered "Mount stack" block. */ -export function buildMountStackSection(stack: MountStack): string { - if (stack.mounts.length === 0) return ""; - const mountLines = stack.mounts.map( - (m) => `- ${m.name} · kind=${m.kind} · ${m.writable ? "rw" : "ro"} · ${m.path}`, - ); - const warnLines = stack.warnings.map((w) => `- ⚠ ${w}`); - return [ - "## Mount stack (Armonia — read precedence top→bottom)", - "", - ...mountLines, - ...warnLines, - "", - "Resolution & write-routing (condensed from the amico-vault skill):", - "- Reads union across all mounts; on the same relative path the first hit", - " top→bottom wins (higher-precedence mount shadows lower).", - "- Writes route by intent to the first WRITABLE mount of that kind:", - " personal→personal, engagement→engagement, project→project,", - " restricted/team/public→their own kind.", - "- If the target mount is absent or read-only, write to the personal mount", - " and stamp `route_intent: ` in the note frontmatter — never silently", - " drop a write, never write a ro mount.", - "- Ambiguous intent: ask once, else default to personal.", - ].join("\n"); -} - -/** The typed-memory index (spec-20260707-002846 C4 read side): the one-line - * pointers from `amicode/memory/MEMORY.md`. Only the index is spliced; the full - * typed cards load on demand from the granted vault path. No lines → "". */ -export function buildMemoryIndexSection(memoryIndexLines: string[]): string { - if (memoryIndexLines.length === 0) return ""; - return [ - "## Memory index", - "", - ...memoryIndexLines, - "", - "These are one-line pointers. The full typed-memory cards (user / feedback /", - "project / reference) load on demand from the granted vault path under", - "`amicode/memory/` — read a card only when its hook is relevant to the turn.", - ].join("\n"); -} - -export function buildRecentProblemsSection(knowledgeLines: string[]): string { - if (knowledgeLines.length === 0) return ""; - return [ - "## Your recent problems", - "", - ...knowledgeLines, - "", - "Before recommending parameters, check whether the user's target matches one", - "of these cards (read the card file on demand for details). If a pulse exists", - "in the bank, offer a warm start from its `pulse.jld2` path. If a prior", - "attempt failed, surface its lesson before re-authoring.", - ].join("\n"); -} diff --git a/packages/extension/src/substrate/vault_store.ts b/packages/extension/src/substrate/vault_store.ts index a9a02ef2..f845b109 100644 --- a/packages/extension/src/substrate/vault_store.ts +++ b/packages/extension/src/substrate/vault_store.ts @@ -1,16 +1,15 @@ -/** Vault resolution + user-memory readers (spec-20260705-002847 §2, §3 routing). - * - * The personal vault is the first mount under the vaults root whose - * `.amico-vault.toml` marker declares `kind = "personal"`. Everything here is - * read-only and failure-tolerant: a missing vault, file, or stream simply +/** Vault resolution + onboarding-stream readers (spec-20260705-002847 §2, §3 + * routing). The user-memory section BUILDERS + index readers (KNOWLEDGE / + * DEMOS / memory index) moved to the amicode_context plugin's live + * stack_state.ts (injected per-prompt); what remains here is what prep-time + * code still needs — the personal-vault resolver, PROFILE.md presence (the + * onboarding routing predicate), and the onboarding-stream marker. Everything + * is read-only and failure-tolerant: a missing vault, file, or stream simply * yields the empty value and the session proceeds unpersonalized. */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -export const KNOWLEDGE_LINE_CAP = 50; -export const MEMORY_INDEX_LINE_CAP = 50; - export function defaultVaultsRoot(): string { return path.join(os.homedir(), ".amico", "vaults"); } @@ -55,39 +54,6 @@ export function readProfileMd(vaultDir: string): string { } } -/** List-item lines from an amicode index file, capped. */ -function readIndexLines(vaultDir: string, file: string, cap: number): string[] { - let text: string; - try { - text = fs.readFileSync(path.join(vaultDir, "amicode", file), "utf8"); - } catch { - return []; - } - return text - .split("\n") - .filter((l) => l.startsWith("- ")) - .slice(0, cap); -} - -/** KNOWLEDGE.md list-item lines, capped (§2.3). */ -export function readKnowledgeLines(vaultDir: string, cap: number = KNOWLEDGE_LINE_CAP): string[] { - return readIndexLines(vaultDir, "KNOWLEDGE.md", cap); -} - -/** DEMOS.md list-item lines (L1 §3) — separate index so reference demos never - * age against KNOWLEDGE.md's problem cap. Capped tighter (splice budget ≤~2KB). */ -export function readDemoLines(vaultDir: string, cap = 30): string[] { - return readIndexLines(vaultDir, "DEMOS.md", cap); -} - -/** Typed-memory index list lines (spec-20260707-002846 C4). The distiller writes - * durable facts as typed cards under `/amicode/memory/` and maintains a - * one-line index at `memory/MEMORY.md`; only that index is spliced (the cards - * load on demand). Subdir-capable reuse of the readIndexLines pattern. */ -export function readMemoryIndexLines(vaultDir: string, cap: number = MEMORY_INDEX_LINE_CAP): string[] { - return readIndexLines(vaultDir, path.join("memory", "MEMORY.md"), cap); -} - /** Second disjunct of the routing predicate (§3): completed marker in the * onboarding stream. Malformed lines are skipped. */ export function hasOnboardingCompleted(onboardingStreamDir: string): boolean { diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index 49b67645..a7f8ddb4 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -55,6 +55,8 @@ const REQUIRED = [ // amicode_* plugin (Bun-transpiled .ts, loaded by absolute path) — every sibling // is load-bearing: a dropped file silently reverts the session to vanilla opencode. "extension/opencode-plugin/amicode_tools.ts", + "extension/opencode-plugin/amicode_context.ts", // live stack-state injection plugin (system.transform hook) + "extension/opencode-plugin/stack_state.ts", // its sibling readers/builders (imported by amicode_context) "extension/opencode-plugin/entities.ts", "extension/opencode-plugin/problems.ts", "extension/opencode-plugin/hashes.ts", diff --git a/packages/extension/test/scores/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts index 7f344b3c..de43076d 100644 --- a/packages/extension/test/scores/prep_integration.test.ts +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -184,16 +184,17 @@ describe("buildOpencodeConfigContent × scores", () => { }); describe("prepareOpencodeProject × Armonia mount stack (spec-20260707-002846 C1–C4, three-state vaultDir)", () => { - it('vaultDir "" → personalization disabled: empty mount stack, no mount/memory splice (regression guard)', () => { + it('vaultDir "" → personalization disabled: empty mount stack, no mount/memory content, recovery pointer only', () => { const proj = prep({ vaultDir: "" }); expect(proj.mounts).toEqual([]); expect(proj.vaultDir).toBe(""); const agents = fs.readFileSync(proj.agentsPath, "utf8"); - expect(agents).not.toContain("## Mount stack (Armonia"); - expect(agents).not.toContain("## Memory index"); + expect(agents).not.toMatch(/^## Mount stack \(Armonia/m); // only prose mentions survive + expect(agents).not.toMatch(/^## Memory index/m); + expect(agents).toContain("amicode_context plugin"); // live-injection recovery pointer }); - it("vaultDir path → single forced personal mount at that path; returns mounts + splices the mount stack", () => { + it("vaultDir path → single forced personal mount at that path; returns mounts; sections live-injected, not spliced", () => { const vault = fs.mkdtempSync(path.join(os.tmpdir(), "forced-vault-")); fs.mkdirSync(path.join(vault, "amicode", "memory"), { recursive: true }); fs.writeFileSync( @@ -205,11 +206,14 @@ describe("prepareOpencodeProject × Armonia mount stack (spec-20260707-002846 C1 expect(proj.mounts[0]).toMatchObject({ kind: "personal", path: vault, writable: true }); expect(proj.vaultDir).toBe(vault); // vaultDir === personalMount path const agents = fs.readFileSync(proj.agentsPath, "utf8"); - expect(agents).toContain("## Mount stack (Armonia — read precedence top→bottom)"); - expect(agents).toContain(`kind=personal · rw · ${vault}`); - // memory index reads from the personal mount: - expect(agents).toContain("## Memory index"); - expect(agents).toContain("- [user-role](user_role.md) — Aaron is CEO"); + // The mount stack + memory index are injected per-prompt by the + // amicode_context plugin (their live builders are pinned in + // test/stack_state.test.ts) — the prepared file must NOT carry them as + // sections (only the recovery pointer's prose mentions may appear). + expect(agents).not.toMatch(/^## Mount stack \(Armonia/m); + expect(agents).not.toMatch(/^## Memory index/m); + expect(agents).not.toContain("- [user-role](user_role.md) — Aaron is CEO"); + expect(agents).toContain("amicode_context plugin"); // recovery pointer present }); it("vaultDir undefined → auto-resolves the full stack from ~/.amico/vaults; vaultDir === personal mount", () => { @@ -234,9 +238,10 @@ describe("prepareOpencodeProject × Armonia mount stack (spec-20260707-002846 C1 expect(proj.mounts.map((m) => m.name)).toEqual(["armonia-me", "armonissima"]); // kind-rank: personal(0) < team(4) expect(proj.vaultDir).toBe(personal); const agents = fs.readFileSync(proj.agentsPath, "utf8"); - expect(agents).toContain("## Mount stack (Armonia — read precedence top→bottom)"); - expect(agents).toContain(`- armonia-me · kind=personal · rw · ${personal}`); - expect(agents).toContain(`- armonissima · kind=team · ro · ${team}`); + // Sections live-injected (see test/stack_state.test.ts), not spliced: + expect(agents).not.toMatch(/^## Mount stack \(Armonia/m); + expect(agents).not.toContain(`- armonia-me · kind=personal · rw · ${personal}`); + expect(agents).toContain("amicode_context plugin"); // recovery pointer present } finally { if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; diff --git a/packages/extension/test/stack_state.test.ts b/packages/extension/test/stack_state.test.ts new file mode 100644 index 00000000..4b659753 --- /dev/null +++ b/packages/extension/test/stack_state.test.ts @@ -0,0 +1,376 @@ +// Tests for the amicode_context plugin's stack_state.ts (live stack-state +// injection: fleet line + Armonia mount discovery + the user-memory sections +// formerly boot-time file splices). +// +// stack_state.ts is deliberately dependency-free (node: builtins only — it is +// imported by the opencode plugin, which executes inside opencode's embedded +// Bun runtime, NOT in the extension bundle) — so these tests exercise it as +// plain functions. The section-builder text is pinned byte-for-byte here +// (golden strings carried over from the retired substrate/user_splice.ts, the +// parity oracle for the splice these tests replace). +// +// buildStackStateBlock() reads env seams (the plugin has no params); the +// composition test stubs ALL seven seams to fixtures so it is hermetic on any +// machine (no reads of the real ~/.amico tree). +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { buildStackStateBlock } from "../opencode-plugin/stack_state"; + +function mkTmp(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function mkVault(root: string, name: string, kind: string): string { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, ".amico-vault.toml"), `kind = "${kind}"\nname = "${name}"\n`); + return dir; +} + +function mkFixtureVault(root: string): string { + const v = mkVault(root, "armonia-fixture", "personal"); + fs.mkdirSync(path.join(v, "amicode", "memory"), { recursive: true }); + fs.writeFileSync(path.join(v, "amicode", "PROFILE.md"), "# Profile — Fixture\n- Role: researcher\n"); + fs.writeFileSync(path.join(v, "amicode", "KNOWLEDGE.md"), "- [p1](problems/p1.md) — thing one\n"); + fs.writeFileSync(path.join(v, "amicode", "DEMOS.md"), "- [d1](demos/d1.md) — demo one\n"); + fs.writeFileSync(path.join(v, "amicode", "memory", "MEMORY.md"), "- [m1](m1.md) — fact one\n"); + return v; +} + +// ── Fleet section ──────────────────────────────────────────────────────────── + +describe("buildFleetSection (lean fleet line + pointers)", () => { + it("no fleet.json (standalone machine) → no section", () => { + const dir = mkTmp("fleet-"); + const s = fleetSectionWith({ configPath: path.join(dir, "absent.json") }); + expect(s).toBe(""); + }); + it("server role with live status renders role, devices, freshness", () => { + const dir = mkTmp("fleet-"); + const cfg = path.join(dir, "fleet.json"); + fs.writeFileSync(cfg, JSON.stringify({ role: "server", canonical: { host: "127.0.0.1", port: 4096 } })); + const status = path.join(dir, "fleet-status.json"); + fs.writeFileSync( + status, + JSON.stringify({ + collected_at: new Date().toISOString(), + devices: [ + { name: "mini", reachable: true }, + { name: "macbook", reachable: true }, + { name: "erlich", reachable: false }, + ], + }), + ); + const s = fleetSectionWith({ configPath: cfg, statusPath: status }); + expect(s).toContain("## Fleet (live)"); + expect(s).toContain("**server** — this machine is the canonical Amicode server"); + expect(s).toContain("Devices: 2/3 reachable (mini, macbook, erlich)"); + expect(s).toContain("refreshed 0 min ago"); + expect(s).toContain("fleet-status.json"); + expect(s).toContain("`fleet` skill"); + }); + it("unreadable status degrades to 'status unknown', not an error", () => { + const dir = mkTmp("fleet-"); + const cfg = path.join(dir, "fleet.json"); + fs.writeFileSync(cfg, JSON.stringify({ role: "client" })); + const s = fleetSectionWith({ configPath: cfg, statusPath: path.join(dir, "nope.json") }); + expect(s).toContain("**client** — rides the tunnel to the canonical server"); + expect(s).toContain("status unknown"); + }); +}); + +// buildFleetSection is module-private; reach it through buildStackStateBlock's +// seams for these unit cases (config + status stubbed, everything else empty). +function fleetSectionWith(opts: { configPath?: string; statusPath?: string }): string { + const stubs = stubAllSeams({ fleetConfig: opts.configPath, fleetStatus: opts.statusPath }); + try { + const block = buildStackStateBlock() ?? ""; + const m = block.match(/## Fleet \(live\)[\s\S]*?(?=\n\n## |\n*$)/); + return m ? m[0] : ""; + } finally { + restoreSeams(stubs); + } +} + +// ── Mount discovery ────────────────────────────────────────────────────────── + +describe("mount discovery (marker-only port of mount_store semantics)", () => { + it("personal before team regardless of directory name; marker-less dirs skipped", () => { + const root = mkTmp("vaults-"); + const team = mkVault(root, "aaa-team", "team"); + const personal = mkVault(root, "zzz-personal", "personal"); + fs.mkdirSync(path.join(root, "marker-less")); + const { mounts } = discoverWith(root); + expect(mounts.map((m) => m.kind)).toEqual(["personal", "team"]); + expect(mounts[0].path).toBe(personal); + expect(mounts[1].path).toBe(team); + }); + it("duplicate resolved id: later discovery skipped with a warning", () => { + const root = mkTmp("vaults-"); + mkVault(root, "one", "personal"); + const two = path.join(root, "two"); + fs.mkdirSync(two, { recursive: true }); + fs.writeFileSync(path.join(two, ".amico-vault.toml"), 'kind = "personal"\nname = "one"\n'); + const { mounts, warnings } = discoverWith(root); + expect(mounts.length).toBe(1); + expect(warnings.some((w) => w.includes("duplicate id 'one'"))).toBe(true); + }); + it("marker missing kind → skipped with warning; missing root → empty", () => { + const root = mkTmp("vaults-"); + const noKind = path.join(root, "no-kind"); + fs.mkdirSync(noKind, { recursive: true }); + fs.writeFileSync(path.join(noKind, ".amico-vault.toml"), 'name = "no-kind"\n'); + // A valid mount must coexist, else the section (and its warnings) doesn't render. + mkVault(root, "valid", "team"); + const { mounts, warnings } = discoverWith(root); + expect(mounts.length).toBe(1); + expect(mounts[0].name).toBe("valid"); + expect(warnings.some((w) => w.includes("missing 'kind'"))).toBe(true); + expect(discoverWith(path.join(root, "absent")).mounts).toEqual([]); + }); +}); + +function discoverWith(root: string) { + const stubs = stubAllSeams({ vaultsRoot: root }); + try { + // Re-discover through the public block: the mount-stack section lines + // encode name · kind · rw/ro · path, ordered. + const block = buildStackStateBlock() ?? ""; + const lines = block + .split("\n") + .filter((l) => l.startsWith("- ") && l.includes(" · kind=")) + .map((l) => l.slice(2)); + const mounts = lines.map((l) => { + const [name, kind, rw, p] = l.split(" · "); + return { name, kind: kind.replace("kind=", ""), writable: rw === "rw", path: p }; + }); + const warnings = block + .split("\n") + .filter((l) => l.startsWith("- ⚠ ")) + .map((l) => l.slice(4)); + return { mounts, warnings }; + } finally { + restoreSeams(stubs); + } +} + +// ── User-memory sections (golden text parity with the retired file splice) ── + +describe("user-memory section text (parity oracle vs the retired user_splice.ts)", () => { + it("## About this user wraps the profile with the anchor guidance", () => { + const stubs = stubAllSeams({ vault: "profile" }); + try { + const block = buildStackStateBlock() ?? ""; + expect(block).toContain( + [ + "## About this user", + "", + "# Profile — Fixture", + "- Role: researcher", + "", + "Greet and recommend with this context. Anchor the hardware stage on the", + "user's environment card (read it from the vault path above when you reach", + "that stage). Never re-ask what the profile already answers.", + ].join("\n"), + ); + } finally { + restoreSeams(stubs); + } + }); + it("## Your recent problems carries the warm-start guidance", () => { + const stubs = stubAllSeams({ vault: "knowledge" }); + try { + const block = buildStackStateBlock() ?? ""; + expect(block).toContain( + [ + "## Your recent problems", + "", + "- [p1](problems/p1.md) — thing one", + "", + "Before recommending parameters, check whether the user's target matches one", + "of these cards (read the card file on demand for details). If a pulse exists", + "in the bank, offer a warm start from its `pulse.jld2` path. If a prior", + "attempt failed, surface its lesson before re-authoring.", + ].join("\n"), + ); + } finally { + restoreSeams(stubs); + } + }); + it("## Reference demos carries the precedent guidance", () => { + const stubs = stubAllSeams({ vault: "demos" }); + try { + const block = buildStackStateBlock() ?? ""; + expect(block).toContain( + [ + "## Reference demos", + "", + "- [d1](demos/d1.md) — demo one", + "", + "Curated demos we've built — use them as PRECEDENT (medium confidence) when", + "the user's target matches one and there's no own-precedent card. Read the", + "demo card on demand for its params, and cite it in your recommendation.", + ].join("\n"), + ); + } finally { + restoreSeams(stubs); + } + }); + it("## Memory index carries the typed-card pointer guidance", () => { + const stubs = stubAllSeams({ vault: "memory" }); + try { + const block = buildStackStateBlock() ?? ""; + expect(block).toContain( + [ + "## Memory index", + "", + "- [m1](m1.md) — fact one", + "", + "These are one-line pointers. The full typed-memory cards (user / feedback /", + "project / reference) load on demand from the granted vault path under", + "`amicode/memory/` — read a card only when its hook is relevant to the turn.", + ].join("\n"), + ); + } finally { + restoreSeams(stubs); + } + }); + it("## Mount stack renders the resolution & write-routing block", () => { + const stubs = stubAllSeams({}); + try { + const block = buildStackStateBlock() ?? ""; + expect(block).toContain("## Mount stack (Armonia — read precedence top→bottom)"); + expect(block).toContain("- armonia-fixture · kind=personal · rw · "); + expect(block).toContain("Resolution & write-routing (condensed from the amico-vault skill):"); + } finally { + restoreSeams(stubs); + } + }); +}); + +// ── Caps + composition ─────────────────────────────────────────────────────── + +describe("caps + composition", () => { + it("KNOWLEDGE/DEMOS/MEMORY list lines are capped (50/30/50)", () => { + const root = mkTmp("vaults-"); + const v = mkVault(root, "capped", "personal"); + fs.mkdirSync(path.join(v, "amicode", "memory"), { recursive: true }); + const mk = (n: number, t: (i: number) => string) => Array.from({ length: n }, (_, i) => t(i)).join("\n"); + fs.writeFileSync(path.join(v, "amicode", "KNOWLEDGE.md"), mk(60, (i) => `- k${i}`)); + fs.writeFileSync(path.join(v, "amicode", "DEMOS.md"), mk(40, (i) => `- d${i}`)); + fs.writeFileSync(path.join(v, "amicode", "memory", "MEMORY.md"), mk(60, (i) => `- m${i}`)); + const stubs = stubAllSeams({ vaultsRoot: root }); + try { + const block = buildStackStateBlock() ?? ""; + const count = (re: RegExp) => (block.match(re) ?? []).length; + expect(count(/^- k\d+$/gm)).toBe(50); + expect(count(/^- d\d+$/gm)).toBe(30); + expect(count(/^- m\d+$/gm)).toBe(50); + } finally { + restoreSeams(stubs); + } + }); + it("composition: sections in splice-parity order (about → recent → demos → mounts → memory)", () => { + const stubs = stubAllSeams({}); + try { + const block = buildStackStateBlock(); + expect(block).toBeTruthy(); + const order = [ + "## About this user", + "## Your recent problems", + "## Reference demos", + "## Mount stack (Armonia", + "## Memory index", + ].map((h) => (block as string).indexOf(h)); + expect(order.every((i) => i >= 0)).toBe(true); + expect([...order].sort((a, b) => a - b)).toEqual(order); + } finally { + restoreSeams(stubs); + } + }); + it("empty vaults root + no fleet + no ops state → null (nothing to inject)", () => { + const stubs = stubAllSeams({ vaultsRoot: path.join(mkTmp("empty-"), "vaults") }); + try { + expect(buildStackStateBlock()).toBeNull(); + } finally { + restoreSeams(stubs); + } + }); +}); + +// ── Env-seam plumbing ──────────────────────────────────────────────────────── + +interface SeamOpts { + vaultsRoot?: string; + fleetConfig?: string; + fleetStatus?: string; + /** Prebuilt fixture vault flavor for the golden-text cases. */ + vault?: "profile" | "knowledge" | "demos" | "memory"; +} + +const SEAM_KEYS = [ + "AMICO_VAULTS_ROOT", + "AMICO_FLEET_CONFIG", + "AMICO_FLEET_STATUS", + "AMICODE_OPS_DIR", + "AMICODE_CONNECTIONS_FILE", + "AMICODE_PROBLEMS_DIR", + "AMICODE_RUNS_DIR", +] as const; + +let fixtureRoot: string | undefined; + +function stubAllSeams(opts: SeamOpts): Record { + const saved: Record = {}; + for (const k of SEAM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + if (!fixtureRoot) fixtureRoot = mkTmp("stackstate-fixture-"); + const root = opts.vaultsRoot ?? fixtureRoot; + if (opts.vault) { + // Rebuild the full fixture vault; only the requested file is populated + // (the flavor switch keeps the golden-text cases independent). + fs.rmSync(root, { recursive: true, force: true }); + const v = mkVault(root, "armonia-fixture", "personal"); + fs.mkdirSync(path.join(v, "amicode", "memory"), { recursive: true }); + if (opts.vault === "profile") { + fs.writeFileSync(path.join(v, "amicode", "PROFILE.md"), "# Profile — Fixture\n- Role: researcher\n"); + } + if (opts.vault === "knowledge") { + fs.writeFileSync(path.join(v, "amicode", "KNOWLEDGE.md"), "- [p1](problems/p1.md) — thing one\n"); + } + if (opts.vault === "demos") { + fs.writeFileSync(path.join(v, "amicode", "DEMOS.md"), "- [d1](demos/d1.md) — demo one\n"); + } + if (opts.vault === "memory") { + fs.writeFileSync(path.join(v, "amicode", "memory", "MEMORY.md"), "- [m1](m1.md) — fact one\n"); + } + } else if (!opts.vaultsRoot) { + // Default root: the full fixture vault with every file present. + fs.rmSync(root, { recursive: true, force: true }); + mkFixtureVault(root); + } + const ops = mkTmp("ops-"); + const conn = mkTmp("conn-"); + const problems = path.join(mkTmp("problems-"), "none"); + const runs = path.join(mkTmp("runs-"), "none"); + const fleetDir = mkTmp("fleetdir-"); + process.env.AMICO_VAULTS_ROOT = root; + process.env.AMICO_FLEET_CONFIG = opts.fleetConfig ?? path.join(fleetDir, "absent-fleet.json"); + process.env.AMICO_FLEET_STATUS = opts.fleetStatus ?? path.join(fleetDir, "absent-status.json"); + process.env.AMICODE_OPS_DIR = ops; // no solver-mode.json → piccolo/ready → no section + process.env.AMICODE_CONNECTIONS_FILE = path.join(conn, "absent.json"); // not connected + process.env.AMICODE_PROBLEMS_DIR = problems; // no active problem + process.env.AMICODE_RUNS_DIR = runs; // no runs + return saved; +} + +function restoreSeams(saved: Record): void { + for (const k of SEAM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +} diff --git a/packages/extension/test/substrate/user_splice.test.ts b/packages/extension/test/substrate/user_splice.test.ts deleted file mode 100644 index 705695c1..00000000 --- a/packages/extension/test/substrate/user_splice.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, it, expect } from "vitest"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { - buildAboutUserSection, - buildRecentProblemsSection, - buildReferenceDemosSection, - buildMountStackSection, - buildMemoryIndexSection, -} from "../../src/substrate/user_splice"; -import type { MountStack } from "../../src/substrate/mount_store"; -import { buildOpencodeConfigContent, prepareOpencodeProject } from "../../src/opencode_config"; - -describe("buildAboutUserSection (spec §6)", () => { - it("empty profile → empty string (no section)", () => { - expect(buildAboutUserSection("")).toBe(""); - }); - it("carries the profile verbatim + the greet/anchor/never-re-ask instruction", () => { - const s = buildAboutUserSection("# Profile — Aaron\n- Role: CEO\n"); - expect(s).toContain("## About this user"); - expect(s).toContain("Role: CEO"); - expect(s).toMatch(/never re-ask/i); - expect(s).toMatch(/environment/i); // anchor the hardware stage on the environment card - }); -}); - -describe("buildRecentProblemsSection (spec §6)", () => { - it("no lines → empty string", () => { - expect(buildRecentProblemsSection([])).toBe(""); - }); - it("carries the knowledge lines + warm-start/lesson instruction", () => { - const s = buildRecentProblemsSection([ - "- [x-gate-transmon](problems/x-gate-transmon.md) — solved 8×, pulse: x-gate-transmon-v1", - ]); - expect(s).toContain("## Your recent problems"); - expect(s).toContain("x-gate-transmon-v1"); - expect(s).toMatch(/warm start/i); - expect(s).toMatch(/lesson/i); - }); -}); - -describe("vault wiring (grant + splice + return)", () => { - it("buildOpencodeConfigContent grants /amicode/** only when a vault dir is passed", () => { - const withVault = JSON.parse( - buildOpencodeConfigContent("/a.md", "/t/tmpl.jl", "/runs", undefined, undefined, [], "", "/my/vault"), - ); - expect(withVault.permission.external_directory["/my/vault/amicode/**"]).toBe("allow"); - const without = JSON.parse(buildOpencodeConfigContent("/a.md", "/t/tmpl.jl", "/runs")); - const keys = Object.keys(without.permission.external_directory).join("\n"); - expect(keys).not.toContain("amicode/**"); - }); - it("prepareOpencodeProject splices both sections when the vault has profile+knowledge, and returns vaultDir", () => { - const vault = fs.mkdtempSync(path.join(os.tmpdir(), "vault-")); - fs.mkdirSync(path.join(vault, "amicode"), { recursive: true }); - fs.writeFileSync(path.join(vault, "amicode", "PROFILE.md"), "# Profile — T\n- Role: tester\n"); - fs.writeFileSync( - path.join(vault, "amicode", "KNOWLEDGE.md"), - "- [x-gate](problems/x-gate.md) — solved, pulse: x-gate-v1\n", - ); - const proj = prepareOpencodeProject({ - agentsSrc: "/nonexistent-agents.md", - templateSrc: "/tmp/none.jl", - juliaProject: "/tmp/jp", - vaultDir: vault, - }); - expect(proj.vaultDir).toBe(vault); - const agents = fs.readFileSync(proj.agentsPath, "utf8"); - expect(agents).toContain("## About this user"); - expect(agents).toContain("Role: tester"); - expect(agents).toContain("## Your recent problems"); - expect(agents).toContain("x-gate-v1"); - }); - it("explicit empty vaultDir disables personalization (no spliced sections, no throw)", () => { - const proj = prepareOpencodeProject({ - agentsSrc: "/nonexistent-agents.md", - templateSrc: "/tmp/none.jl", - juliaProject: "/tmp/jp", - vaultDir: "", - }); - const agents = fs.readFileSync(proj.agentsPath, "utf8"); - // Key on splice-UNIQUE sentinels, not the section headings — the pulse-designer - // SCORE.md prose legitimately references "## About this user" when instructing - // the agent to anchor on it. The actual spliced sections carry these lines: - expect(agents).not.toContain("Greet and recommend with this context"); - expect(agents).not.toContain("Before recommending parameters, check whether"); - }); -}); - -describe("buildMountStackSection (spec §3 C3 read side)", () => { - const stack = (mounts: MountStack["mounts"], warnings: string[] = []): MountStack => ({ mounts, warnings }); - - it("empty stack → empty string (no section)", () => { - expect(buildMountStackSection(stack([]))).toBe(""); - // warnings alone (nothing discovered) still render nothing — parity with the - // "Empty stack → ''" contract. - expect(buildMountStackSection(stack([], ["skipped 'x': no marker"]))).toBe(""); - }); - - it("renders the header + one precedence line per mount with rw/ro + path", () => { - const s = buildMountStackSection( - stack([ - { name: "armonia-aaron", kind: "personal", path: "/v/armonia-aaron", writable: true }, - { name: "armonissima", kind: "team", path: "/v/armonissima", writable: false }, - ]), - ); - expect(s).toContain("## Mount stack (Armonia — read precedence top→bottom)"); - expect(s).toContain("- armonia-aaron · kind=personal · rw · /v/armonia-aaron"); - expect(s).toContain("- armonissima · kind=team · ro · /v/armonissima"); - // top→bottom = read precedence: the personal line precedes the team line. - expect(s.indexOf("armonia-aaron")).toBeLessThan(s.indexOf("armonissima")); - }); - - it("renders warning lines beneath the mounts", () => { - const s = buildMountStackSection( - stack( - [{ name: "p", kind: "personal", path: "/v/p", writable: true }], - ["skipped 'junk': marker missing 'kind'"], - ), - ); - expect(s).toContain("skipped 'junk': marker missing 'kind'"); - }); - - it("appends the condensed routing-rules block (union/first-hit, intent routing, route_intent, ask-once)", () => { - const s = buildMountStackSection(stack([{ name: "p", kind: "personal", path: "/v/p", writable: true }])); - expect(s).toMatch(/union/i); // union reads across mounts - expect(s).toMatch(/first hit/i); // first-hit precedence - expect(s).toMatch(/route_intent/); // the fallback stamp - expect(s).toMatch(/writable/i); // routes to first WRITABLE mount of that kind - expect(s).toMatch(/ask once/i); // ambiguous → ask once - expect(s).toMatch(/default(?:s)?(?: to)? personal/i); // else default (to) personal - }); -}); - -describe("buildMemoryIndexSection (spec §3 C4 read side)", () => { - it("no lines → empty string", () => { - expect(buildMemoryIndexSection([])).toBe(""); - }); - it("renders the heading + index lines + a load-on-demand instruction", () => { - const s = buildMemoryIndexSection([ - "- [user-role](user_role.md) — Aaron is CEO of Harmoniqs", - "- [feedback-latex](feedback_latex.md) — use LaTeX in chat", - ]); - expect(s).toContain("## Memory index"); - expect(s).toContain("- [user-role](user_role.md) — Aaron is CEO of Harmoniqs"); - expect(s).toContain("- [feedback-latex](feedback_latex.md) — use LaTeX in chat"); - expect(s).toMatch(/load on demand from the granted vault path/i); - }); -}); - -describe("buildReferenceDemosSection (L1 §3)", () => { - it("empty → ''", () => { - expect(buildReferenceDemosSection([])).toBe(""); - }); - it("renders demo lines + the precedent/medium-confidence instruction", () => { - const s = buildReferenceDemosSection([ - "- [stanford-bosonics-cat](demos/stanford-bosonics-cat.md) — cavity cat, N_fock=20", - ]); - expect(s).toContain("## Reference demos"); - expect(s).toContain("N_fock=20"); - expect(s).toMatch(/precedent/i); - expect(s).toMatch(/medium confidence/i); - }); -}); diff --git a/packages/extension/test/substrate/vault_store.test.ts b/packages/extension/test/substrate/vault_store.test.ts index a1f0a062..90027e6f 100644 --- a/packages/extension/test/substrate/vault_store.test.ts +++ b/packages/extension/test/substrate/vault_store.test.ts @@ -5,8 +5,6 @@ import * as path from "node:path"; import { resolvePersonalVault, readProfileMd, - readKnowledgeLines, - readMemoryIndexLines, hasOnboardingCompleted, } from "../../src/substrate/vault_store"; @@ -65,44 +63,6 @@ describe("readProfileMd (spec §3 routing predicate: non-empty check)", () => { }); }); -describe("readKnowledgeLines (spec §2.3: list lines, cap 50)", () => { - it("missing → []", () => { - expect(readKnowledgeLines(mkTmp("vault-"))).toEqual([]); - }); - it("returns only list-item lines, capped", () => { - const v = mkTmp("vault-"); - fs.mkdirSync(path.join(v, "amicode"), { recursive: true }); - const items = Array.from({ length: 60 }, (_, i) => `- [p${i}](problems/p${i}.md) — thing ${i}`); - fs.writeFileSync( - path.join(v, "amicode", "KNOWLEDGE.md"), - "# heading ignored\n" + items.join("\n") + "\nprose ignored\n", - ); - const lines = readKnowledgeLines(v); - expect(lines.length).toBe(50); - expect(lines[0]).toContain("p0"); - expect(lines.every((l) => l.startsWith("- "))).toBe(true); - }); -}); - -describe("readMemoryIndexLines (spec §3 C4: memory/MEMORY.md list lines, cap 50)", () => { - it("missing memory index → []", () => { - expect(readMemoryIndexLines(mkTmp("vault-"))).toEqual([]); - }); - it("reads list lines from the amicode/memory subdir, capped", () => { - const v = mkTmp("vault-"); - fs.mkdirSync(path.join(v, "amicode", "memory"), { recursive: true }); - const items = Array.from({ length: 60 }, (_, i) => `- [m${i}](m${i}.md) — fact ${i}`); - fs.writeFileSync( - path.join(v, "amicode", "memory", "MEMORY.md"), - "# Memory index\n" + items.join("\n") + "\nprose ignored\n", - ); - const lines = readMemoryIndexLines(v); - expect(lines.length).toBe(50); - expect(lines[0]).toContain("m0"); - expect(lines.every((l) => l.startsWith("- "))).toBe(true); - }); -}); - describe("hasOnboardingCompleted (spec §3 routing predicate, second disjunct)", () => { it("missing stream → false", () => { expect(hasOnboardingCompleted(path.join(mkTmp("ops-"), "onboarding"))).toBe(false);