Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/extension/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,5 @@ answers like a well-written engineering doc, not a terminal log:
> - 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`.
> `amicode/problems/` (problem-card frontmatter), `amicode/DEMOS.md`, and
> `amicode/memory/MEMORY.md`.
7 changes: 4 additions & 3 deletions packages/extension/DISTILLER.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,10 @@ New `-v<N+1>` ONLY when fidelity strictly improves on the card's

### `KNOWLEDGE.md` — FROZEN (read-only; superseded by the typed memory store)

`KNOWLEDGE.md` is **no longer written**. It stays readable for back-compat — the
session bootstrap still splices its existing lines as "Your recent problems" —
but the distiller does not append to it. Problem cards are still written to
`KNOWLEDGE.md` is **no longer written**. It is also no longer read: the session
bootstrap derives "Your recent problems" from the problem cards' frontmatter
(`problems/*.md`) directly, so the frozen index's lines reach no session —
but the distiller does not append to it either. Problem cards are still written to
`problems/` (the card frontmatter is the source of truth, Hard rule 7); only the
flat index is frozen. Durable *facts* now live in the typed memory store
(`memory/`, see below). Historical line shape, for reading only:
Expand Down
110 changes: 109 additions & 1 deletion packages/extension/opencode-plugin/stack_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,114 @@ function readIndexLines(vaultDir: string, file: string, cap: number): string[] {
.slice(0, cap);
}

// ── Problem-card frontmatter (the recent-problems source of truth) ───────────
//
// "Your recent problems" used to be spliced from the personal vault's
// amicode/KNOWLEDGE.md — a flat index the distiller FROZE (it no longer writes
// it), so its lines went stale the day they were written: the index claimed a
// problem was "solved 20×" while the card it links recorded 29 solves. The
// cards themselves (amicode/problems/*.md frontmatter) are the maintained
// source of truth — derive the bullets from them instead.

/** Scalar frontmatter fields of a problem card, parsed leniently: top-level
* `key: value` lines between `---` delimiters, quotes unquoted, inline
* comments stripped (outside quotes). Block scalars and lists are ignored —
* only the scalar fields the splice renders matter here. Returns undefined
* when the file has no well-formed frontmatter block; call sites skip such
* cards rather than rendering them. Deliberately hand-rolled: stack_state.ts
* stays node-builtin-only (the plugin's Bun runtime has no `yaml` package —
* same reason parseMarker above is a regex-lite TOML parse). */
function parseCardFrontmatter(text: string): Record<string, string> | undefined {
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!m) return undefined;
const fm: Record<string, string> = {};
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z_][\w-]*):(?:\s+(.*))?$/);
if (!kv) continue; // blank lines, list items, indented blocks (sys_params…)
let v = kv[2] ?? "";
const quoted = v.match(/^"((?:[^"\\]|\\.)*)"(?:\s+#.*)?$/) ?? v.match(/^'([^']*)'(?:\s+#.*)?$/);
if (quoted) v = quoted[1];
else v = v.replace(/\s+#.*$/, "").trim();
fm[kv[1]] = v;
}
return fm;
}

/** Frontmatter scalar absent per the distiller contract: missing, empty, or a
* literal YAML null (which parses here as the string "null"). */
function fmAbsent(v: string | undefined): boolean {
return v === undefined || v === "" || v === "null";
}

/** Render one problem card as the reader-facing bullet — the same line shape
* the frozen KNOWLEDGE.md index carried (DISTILLER.md's "historical line
* shape"), now derived from the card's own frontmatter so the numbers can
* never go stale: `- [slug](problems/slug.md) — platform kind target, status
* (+count), best F=…, pulse: …`. best_fidelity is rendered verbatim from the
* card — the splice never re-rounds a number. */
function renderProblemCardLine(slug: string, fm: Record<string, string>): string {
const n = Number(fm.solve_count);
const count = Number.isFinite(n) && !fmAbsent(fm.solve_count) ? Math.trunc(n) : undefined;
let statusText: string;
switch (fm.status) {
case "solved":
statusText = count !== undefined ? `solved ${count}×` : "solved";
break;
case "attempted":
statusText = count !== undefined ? `ATTEMPTED (${count} failed)` : "ATTEMPTED";
break;
case "failed":
statusText = count !== undefined ? `FAILED (${count} failed)` : "FAILED";
break;
default:
statusText = fmAbsent(fm.status) ? "" : (fm.status ?? "");
}
const desc = [fm.platform, fm.problem_kind, fm.target]
.filter((v) => !fmAbsent(v))
.join(" ");
const tail: string[] = [];
if (desc || statusText) tail.push([desc, statusText].filter(Boolean).join(", "));
if (!fmAbsent(fm.best_fidelity)) tail.push(`best F=${fm.best_fidelity}`);
if (!fmAbsent(fm.pulse_ref)) tail.push(`pulse: ${fm.pulse_ref.replace(/^pulses\//, "")}`);
else if (fm.status === "attempted" || fm.status === "failed") tail.push("no pulse yet");
let line = `- [${slug}](problems/${slug}.md)`;
if (tail.length > 0) line += ` — ${tail.join(", ")}`;
return line;
}

/** Recent-problem bullets from the personal vault's problem cards
* (amicode/problems/*.md frontmatter — the distiller-maintained source of
* truth), NOT the frozen KNOWLEDGE.md index. Sorted by last_seen, freshest
* first (missing last_seen sorts last; ties keep filename order), and capped
* at the same line budget the index read used — so the 50 most recently
* touched cards survive the cut. */
function readRecentProblemLines(vault: string, cap: number): string[] {
const dir = path.join(vault, "amicode", "problems");
let files: string[];
try {
files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
} catch {
return []; // no problems dir (or unreadable) → section omitted
}
const cards: { line: string; lastSeen: string }[] = [];
for (const file of files) {
try {
const text = fs.readFileSync(path.join(dir, file), "utf8");
const fm = parseCardFrontmatter(text);
if (!fm) continue; // no well-formed frontmatter block: skip, don't render
const slug = !fmAbsent(fm.slug) ? fm.slug : file.replace(/\.md$/, "");
cards.push({ line: renderProblemCardLine(slug, fm), lastSeen: fm.last_seen ?? "" });
} catch {
continue; // unreadable card: skip — never crash the splice on one bad file
}
}
// last_seen values are ISO dates: lexicographic order is chronological.
// Empty string (missing last_seen) sorts below any date. Array#sort is
// stable (ES2019+), so same-last_seen cards keep their filename order.
cards.sort((a, b) => b.lastSeen.localeCompare(a.lastSeen));
return cards.slice(0, cap).map((c) => c.line);
}

/** Section builders — text pinned by test/stack_state.test.ts (golden strings). */

function buildAboutUserSection(profileMd: string): string {
Expand Down Expand Up @@ -639,7 +747,7 @@ export function buildStackStateBlock(): string | null {
if (vault) {
const about = buildAboutUserSection(readProfileMd(vault));
if (about) parts.push(about);
const recent = buildRecentProblemsSection(readIndexLines(vault, "KNOWLEDGE.md", 50));
const recent = buildRecentProblemsSection(readRecentProblemLines(vault, 50));
if (recent) parts.push(recent);
const demos = buildReferenceDemosSection(readIndexLines(vault, "DEMOS.md", 30));
if (demos) parts.push(demos);
Expand Down
15 changes: 8 additions & 7 deletions packages/extension/src/substrate/vault_store.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/** 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. */
* routing). The user-memory section BUILDERS + index readers (problem-card
* frontmatter / 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";
Expand Down
Loading
Loading