UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

363 lines (335 loc) 20 kB
/** * # cli/commands/registry — `runs list`/`runs show`/`lineage`/`leaderboard` (HEAVY, lazy) * * Data fetch is unchanged from the old `session/cli.ts` (`listRuns`/`getRun`/`lineage`/`leaderboard`, * each wrapped in `openRegistry`/`finally db.close()`), but rendering is mode-branched via * {@link ../render/tables.ts}: today's glyph tables are the `human` branch; `text` = TSV; `json` = * `{schema, rows}`. The registry module (`bun:sqlite`) is dynamic-imported through {@link loadHeavy}, * so this file is off the load-time graph and faults loud (exit 4) under bun-less node. * * `runs show <id>` not found → exit 3 (NOT_FOUND). */ import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import type { OutputCtx } from "../context.ts"; import type { RunRow } from "../heavy.ts"; import { parseArgs } from "../args.ts"; import { CliError, EXIT } from "../errors.ts"; import { HOSTED_STORE_BASENAME, defaultHostedStorePath, readHostedRunsMerged, type HostedRunRow, } from "../../ledger/hosted.ts"; import { loadHeavy } from "./_heavy.ts"; import { renderRuns, renderRunShow, renderHostedRunShow, renderRunsCompare, renderLineage, renderLeaderboard } from "../render/tables.ts"; const DEFAULT_DB = "data/kestrel.db"; /** How the reader learns an empty list is a NORMAL fresh-store condition, not a fault, and where runs * get recorded. Hosted `sim`/`prove` runs go to the identity-bound HOME ledger (found from any cwd); * local `run`/`day` sessions record into the CWD-local sqlite ledger (kestrel-fq78). */ const NO_STORE_HINT = "no runs recorded yet — `kestrel sim` / `kestrel prove` record to your identity-bound home ledger (~/.kestrel/hosted-runs.jsonl, found from any directory), and `kestrel run` / `kestrel day` auto-record into ./data/kestrel.db (this folder)"; /** * Read the HOSTED lineage as a merged view: the identity-bound HOME store (found from ANY cwd, * kestrel-fq78) unioned with the legacy CWD-local receipt store beside the ledger `--db` (read-compat * for receipts an older CLI wrote into the originating folder). A corrupt store on either faults LOUDLY * inside {@link readHostedRunsMerged} (fail-closed) — never a silently-dropped receipt. */ function readHostedForDb(dbPath: string): HostedRunRow[] { return readHostedRunsMerged({ home: defaultHostedStorePath(), fallbacks: [join(dirname(dbPath), HOSTED_STORE_BASENAME)], }); } function usageErr(message: string): CliError { return new CliError({ code: "USAGE", exit: EXIT.USAGE, message }); } /** * Open the ledger for a READ verb, distinguishing an **absent** store (a cwd that has recorded no runs * yet — a NORMAL empty state) from a **present-but-unreadable** one (a real fault). Returns `db: null` * when the store file does not exist — a query NEVER mints a store (that is a recording verb's job), and * a missing store must not fault with a raw `unable to open database file`. A store that IS present but * fails to open (corrupt bytes, a locked/future-schema db) PROPAGATES — fail-closed, never silently empty. */ async function openRegForRead(dbPath: string) { const registry = await loadHeavy(() => import("../heavy.ts")); const db = existsSync(dbPath) ? registry.openRegistry(dbPath) : null; return { registry, db }; } /** On the human path, tell the reader the empty result is a normal fresh-store state (not a fault) and * how a store gets populated. Machine modes (`json`/`text`) stay a pure empty payload — no advisory noise * on the channel an agent parses. */ function emitNoStoreHint(ctx: OutputCtx): void { if (ctx.mode === "human") process.stdout.write(`hint: ${NO_STORE_HINT}\n`); } /** On the human path, tell the reader an empty FILTERED result means no run matched the given filter(s) * — NOT that the store is empty (kestrel-f0yr). Machine modes stay a pure empty payload (no advisory * noise on the channel an agent parses). */ function emitNoMatchHint(ctx: OutputCtx): void { if (ctx.mode === "human") process.stdout.write( "hint: no recorded run matched the given filter(s) — `kestrel runs list` (no filter) shows the full ledger; `--session-date <YYYY-MM-DD>`/`--lineage <plan|digest>` narrow it (hosted `sim` rows carry no fill model, so `--fill` matches only local `run`/`day` runs)\n", ); } /** The `runs list` query filters, as parsed from the flags. Absent keys mean "no filter on this axis". */ interface RunsListFilters { readonly sessionDate?: string; readonly fill?: string; readonly lineage?: string; } /** * Apply the documented `runs list` filters to a HOSTED receipt (kestrel-f0yr). The `--session-date` / * `--fill` / `--lineage` flags were built for the LOCAL sqlite ledger's columns; a hosted `sim` receipt * carries the same query dimensions under its own fields, so the flags must narrow it too instead of * being silently inert (a nonsense value was returning the FULL home ledger). The mapping: * * - **`--session-date <d>`** → the calendar day of the run's signed `issued_at` (its first 10 chars, * `YYYY-MM-DD`) — the server's proof timestamp, never a local wall clock. * - **`--lineage <n>`** → the plan lineage the run belongs to: its `strategy` LABEL (the `--plans` path * or bundled default's name) OR its `strategy_digest` CONTENT identity (exact, or a digest prefix). * - **`--fill <m>`** → a hosted `sim` receipt records NO fill model (the platform fixes it server-side; * it is not a row field), so a `--fill` filter can never MATCH a hosted row. Fail-CLOSED: it narrows * hosted rows to zero rather than returning the whole ledger. This is the honest "non-matching value → * zero" the bead asks for, not the inert full-ledger bug. * * A filter with a non-matching value excludes the row; an absent filter never excludes. Pure, no network. */ function hostedRowPassesFilters(h: HostedRunRow, f: RunsListFilters): boolean { // A hosted receipt has no fill model to match — any `--fill` value narrows it out (fail-closed). if (f.fill !== undefined) return false; if (f.sessionDate !== undefined && h.issued_at.slice(0, 10) !== f.sessionDate) return false; if (f.lineage !== undefined) { const inLineage = h.strategy === f.lineage || h.strategy_digest === f.lineage || (h.strategy_digest !== undefined && h.strategy_digest.startsWith(f.lineage)); if (!inLineage) return false; } return true; } /** `runs list [--session-date <d>] [--fill <m>] [--lineage <n>] [--hosted] [--db <p>]`. * * Merges the local `run`/`day` lineage (the `bun:sqlite` ledger) with the HOSTED `sim` lineage (the * sibling `hosted-runs.jsonl` receipt store, kestrel-pvlc). `--hosted` narrows to the hosted lineage * ALONE — a pure-node read that never touches `bun:sqlite`, so an agent on plain node can still * re-list its hosted attempts. Zero network on either side. */ export async function runsListCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> { const { flags, bools } = parseArgs(argv, new Set(["hosted"]), new Set(["session-date", "fill", "lineage", "db"])); const dbPath = flags.get("db") ?? DEFAULT_DB; const hostedOnly = bools.has("hosted"); const filters: RunsListFilters = { ...(flags.get("session-date") !== undefined ? { sessionDate: flags.get("session-date")! } : {}), ...(flags.get("fill") !== undefined ? { fill: flags.get("fill")! } : {}), ...(flags.get("lineage") !== undefined ? { lineage: flags.get("lineage")! } : {}), }; const hasFilter = filters.sessionDate !== undefined || filters.fill !== undefined || filters.lineage !== undefined; // The hosted receipts live in the identity-bound HOME ledger (found from any cwd), merged with the // legacy CWD-local store beside the db (kestrel-fq78). Read first — a corrupt store faults LOUDLY // (fail-closed), never a silently-dropped receipt. The SAME documented filters that narrow the local // ledger below narrow the hosted rows here (kestrel-f0yr) — a nonsense value returns zero, not the // full ledger, on the home lineage a returning user actually lives in. const hosted = readHostedForDb(dbPath).filter((h) => hostedRowPassesFilters(h, filters)); // `--hosted` skips the local sqlite query entirely (no `loadHeavy`, so it runs bun-less). let rows: readonly RunRow[] = []; if (!hostedOnly) { const { registry, db } = await openRegForRead(dbPath); if (db !== null) { try { rows = registry.listRuns(db, { ...(filters.sessionDate !== undefined ? { sessionDate: filters.sessionDate } : {}), ...(filters.fill !== undefined ? { fillModel: filters.fill } : {}), ...(filters.lineage !== undefined ? { lineage: filters.lineage } : {}), }); } finally { db.close(); } } } process.stdout.write(renderRuns(rows, ctx, hosted)); if (rows.length === 0 && hosted.length === 0) { // Distinguish a genuinely FRESH store (no filter, nothing recorded — a normal empty state) from a // filter that simply matched nothing. The "no runs recorded yet" hint is misleading in the latter, // so a filtered-empty result gets its own honest hint instead (kestrel-f0yr). if (hasFilter) emitNoMatchHint(ctx); else emitNoStoreHint(ctx); } return 0; } /** * Collapse a pasted proof URL (`https://kestrel.markets/proof/art_…`) to its bare `art_` id, so a * returning user can `runs show` the exact string they saved. Any non-URL input passes through * unchanged. Pure, no network. */ export function normalizeRunKey(raw: string): string { const m = raw.match(/\/proof\/([A-Za-z0-9_-]+)\/?$/); return m !== null ? m[1]! : raw; } /** Does a hosted receipt match the lookup key on EITHER of its two shareable handles — the * `operation_id` (op_…, the local-lookup id) OR the `grade_artifact_id` (art_…, the proof id the * product pushes everywhere and the user actually keeps, kestrel-qsbm)? Exact match or a prefix * of either. One receipt matches at most once regardless of which handle hit. */ function hostedRunMatches(h: { operation_id: string; grade_artifact_id: string }, key: string): boolean { return ( h.operation_id === key || h.operation_id.startsWith(key) || h.grade_artifact_id === key || h.grade_artifact_id.startsWith(key) ); } /** `runs show <id> [--db <p>]` — accepts a short (12-char) prefix, resolving against BOTH lineages: a * local `run`/`day` run (by `run_id`) OR a hosted `sim` receipt (by `operation_id` OR the `art_` proof id, * kestrel-pvlc + kestrel-qsbm). The `art_` id is the shareable handle the product pushes everywhere (the * proof URL the user saved), so it MUST be a valid lookup key alongside the `op_` id — a pasted proof URL * is collapsed to its `art_` id first. A hosted match re-prints the full receipt incl. the shareable proof * URL. The hosted lineage is read FIRST and is node-light: a fresh cwd that ran only `sim` has * `hosted-runs.jsonl` but no `bun:sqlite` store, so an agent on plain node can still `runs show` its hosted * operation without faulting exit 4 (the local `bun:sqlite` query is skipped entirely when the ledger db * file is absent). Not found → exit 3 (a typed hint naming BOTH id forms); an ambiguous prefix (across * either lineage) → exit 2. */ export async function runsShowCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> { const { positionals, flags } = parseArgs(argv, new Set(), new Set(["db"])); const rawId = positionals[0]; if (rawId === undefined) throw usageErr("runs show needs a <run_id>"); const runId = normalizeRunKey(rawId); const dbPath = flags.get("db") ?? DEFAULT_DB; // Hosted lineage first — a pure-node read of the identity-bound home ledger merged with the legacy // CWD-local store (never touches `bun:sqlite`), so a hosted-only cwd resolves without the heavy // runtime AND the same agent's run resolves from ANY directory (kestrel-fq78). A corrupt store faults // LOUDLY inside readHostedRunsMerged. const hostedMatch = readHostedForDb(dbPath).filter((h) => hostedRunMatches(h, runId)); // Local lineage — only opened when the ledger db FILE exists, so `runs show <hosted-op>` on plain node // never trips `loadHeavy`'s exit-4 (the db is absent in a hosted-only cwd). openRegForRead never mints. const opened = existsSync(dbPath) ? await openRegForRead(dbPath) : null; const db = opened?.db ?? null; try { const localMatch: RunRow[] = opened !== null && db !== null ? opened.registry.listRuns(db).filter((r) => r.run_id === runId || r.run_id.startsWith(runId)) : []; const total = localMatch.length + hostedMatch.length; if (total === 0) throw new CliError({ code: "NOT_FOUND", exit: EXIT.NOT_FOUND, message: `no run matching ${JSON.stringify(rawId)}`, hint: "the id may be an operation id (op_…), a local run id, OR the art_ proof id from your saved proof URL (…/proof/art_…) — `kestrel runs list` shows both", }); if (total > 1) throw usageErr(`ambiguous run prefix ${JSON.stringify(rawId)}${total} matches`); if (hostedMatch.length === 1) { process.stdout.write(renderHostedRunShow(hostedMatch[0]!, ctx)); } else { // A local match implies the db (and its registry) were opened above. const rec = opened!.registry.getRun(db!, localMatch[0]!.run_id)!; process.stdout.write(renderRunShow(rec, ctx)); } } finally { db?.close(); } return 0; } /** Resolve ONE hosted receipt using the EXACT id-acceptance `runs show` holds (kestrel-c535): a pasted * proof URL is collapsed to its `art_` id first ({@link normalizeRunKey}), then the key is matched on * EITHER shareable handle — the `operation_id` (op_…) OR the `grade_artifact_id` (art_…, the proof id the * product pushes everywhere and the user actually keeps) — exact or 12-char prefix (via * {@link hostedRunMatches}). Compare is the flagship "did-my-tweak-help?" verb, so it MUST accept the very * identifier every `sim` run tells the user to keep, not op_ ids alone. Zero match → NOT_FOUND (exit 3) * with a typed hint naming BOTH id forms (as `runs show` does — no bare NOT_FOUND); an ambiguous prefix → * USAGE (exit 2), never a silent first-match pick (mirrors `runsShowCommand`'s fail-closed ambiguity guard). */ function resolveHostedOne(hosted: readonly HostedRunRow[], rawId: string): HostedRunRow { const key = normalizeRunKey(rawId); const matches = hosted.filter((h) => hostedRunMatches(h, key)); if (matches.length === 0) throw new CliError({ code: "NOT_FOUND", exit: EXIT.NOT_FOUND, message: `no hosted run matching ${JSON.stringify(rawId)}`, hint: "the id may be an operation id (op_…) OR the art_ proof id from your saved proof URL (…/proof/art_…) — `kestrel runs list` shows both, and `runs show`/`verify`/`replay` accept the same forms", }); if (matches.length > 1) throw usageErr(`ambiguous run prefix ${JSON.stringify(rawId)}${matches.length} matches`); return matches[0]!; } /** `runs compare <runA> <runB> [--db <p>]` — the returning user's "did my tweak help?" verb (kestrel-etyu). * * Diffs two HOSTED `sim` receipts (resolved by `operation_id` / 12-char prefix from the sibling * `hosted-runs.jsonl`): which PLAN each used + the graded activity deltas (orders, fills, realized P&L), * `b − a` (B the tweak, A the baseline). NODE-LIGHT by construction — a pure-node read of the JSONL * receipt store, never touching `bun:sqlite`, so an agent iterating a strategy across sims on plain node * can compare its attempts. Deterministic (no wall clock, no RNG). A corrupt store faults LOUDLY inside * `readHostedRuns` (fail-closed); a missing/ambiguous id fails closed here (exit 3 / exit 2). * * Scope: the HOSTED lineage (the `sim` iteration loop this bead is about). Local `run`/`day` runs carry a * different metric shape and no plan field, so they are out of scope for this verb. */ export async function runsCompareCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> { const { positionals, flags } = parseArgs(argv, new Set(), new Set(["db"])); const [idA, idB] = positionals; if (idA === undefined || idB === undefined) throw usageErr("runs compare needs two run ids: `kestrel runs compare <runA> <runB>`"); const dbPath = flags.get("db") ?? DEFAULT_DB; // The hosted receipts live in the identity-bound HOME ledger merged with the legacy CWD-local store // (kestrel-fq78), so a returning agent compares runs recorded from ANY directory. A corrupt store // faults LOUDLY (fail-closed) inside readHostedRunsMerged — never a silently-dropped receipt. const hosted = readHostedForDb(dbPath); const rowA = resolveHostedOne(hosted, idA); const rowB = resolveHostedOne(hosted, idB); process.stdout.write(renderRunsCompare(rowA, rowB, ctx)); return 0; } /** What `lineage` DOES cover — named on an EMPTY result so silence is never the answer (kestrel-qozv). * `lineage` tracks only the LOCAL `run`/`day` sqlite ledger (plan-name keyed); hosted `sim` runs are a * separate receipt lineage with no plan-name index, so they are reachable via `runs list --hosted`, not here. */ const LINEAGE_COVERAGE = "lineage covers LOCAL `run`/`day` sessions (the plan-name ledger in ./data/kestrel.db) — hosted `sim` runs are NOT plan-lineage-tracked; find them with `kestrel runs list --hosted` (or `kestrel runs show <op_…|art_…>`)"; /** Emit the coverage notice on an empty lineage. human/json ride it INSIDE the payload (via * renderLineage — human appends it, json carries a `coverage` field); text keeps stdout a pure TSV * and mirrors the notice to stderr (the diagnostics channel), so no mode is silent. */ function emitLineageCoverage(ctx: OutputCtx): void { if (ctx.mode === "text") process.stderr.write(`notice: ${LINEAGE_COVERAGE}\n`); } /** `lineage <name> [--db <p>]`. An empty lineage renders the empty sentinel PLUS a typed coverage * notice naming what lineage tracks (kestrel-qozv) — an empty result is never silent. Exit 0. */ export async function lineageCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> { const { positionals, flags } = parseArgs(argv, new Set(), new Set(["db"])); const name = positionals[0]; if (name === undefined) throw usageErr("lineage needs a <name>"); const dbPath = flags.get("db") ?? DEFAULT_DB; const { registry, db } = await openRegForRead(dbPath); if (db === null) { process.stdout.write(renderLineage(name, [], ctx, LINEAGE_COVERAGE)); emitLineageCoverage(ctx); return 0; } try { const rows = registry.lineage(db, name); process.stdout.write(renderLineage(name, rows, ctx, rows.length === 0 ? LINEAGE_COVERAGE : undefined)); if (rows.length === 0) emitLineageCoverage(ctx); } finally { db.close(); } return 0; } /** `leaderboard [--since <ms>] [--mode <m>] [--db <p>]`. */ export async function leaderboardCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> { const { flags } = parseArgs(argv, new Set(), new Set(["since", "mode", "db"])); const dbPath = flags.get("db") ?? DEFAULT_DB; let since: number | undefined; const sinceRaw = flags.get("since"); if (sinceRaw !== undefined) { since = Number(sinceRaw); if (!Number.isFinite(since)) throw usageErr(`--since must be a number (ms), got ${JSON.stringify(sinceRaw)}`); } const { registry, db } = await openRegForRead(dbPath); if (db === null) { process.stdout.write(renderLeaderboard([], ctx)); emitNoStoreHint(ctx); return 0; } try { const rows = registry.leaderboard(db, { ...(flags.get("mode") !== undefined ? { mode: flags.get("mode")! } : {}), ...(since !== undefined ? { since } : {}), }); process.stdout.write(renderLeaderboard(rows, ctx)); } finally { db.close(); } return 0; }