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
text/typescript
/**
* # 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;
}