Repository navigation
feat(learn-from-fable): staged mining pipeline + ai-proxy transcripts/tiered billing + curated model registry #294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4d5b275
77ab25a
e5384a4
c7dfc58
38878b0
a140f23
778a56b
3a10508
60c21d9
1a50296
a070f27
b394151
5f7e3ae
2130688
b666438
f5569f4
8df30f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| /** | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧪 Tests | 🔵 Low · confidence 65/100 No test changes accompany 95 added lines in scripts/ai-proxy/structured-output.ts This PR adds 95 lines to 🧩 AnalysisGrep evidence: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧪 Tests | 🔵 Low · confidence 65/100 No test changes accompany 95 added lines in scripts/ai-proxy/structured-output.ts This PR adds 95 lines to 🧩 AnalysisGrep evidence: |
||
| * Probe: does the ai-proxy deliver schema-shaped output per provider? | ||
| * | ||
| * For each model (args, default: grok-4.5 + sonnet) runs the same tiny task in | ||
| * both schema modes ("response_format" native, "prompt" injected) and reports | ||
| * whether the reply parsed against the expectation, plus the usage the proxy | ||
| * returned AND the last matching record in usage/requests.jsonl (proving the | ||
| * proxy recorded metrics for the call). | ||
| * | ||
| * Usage: bun scripts/ai-proxy/structured-output.ts [model...] | ||
| */ | ||
| import { existsSync, readFileSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { AiProxyClient, type SchemaMode } from "@genesiscz/utils/ai/proxy/AiProxyClient"; | ||
| import { SafeJSON } from "@genesiscz/utils/json"; | ||
|
|
||
| const SCHEMA = { | ||
| name: "fruit_pick", | ||
| schema: { | ||
| type: "object", | ||
| additionalProperties: false, | ||
| required: ["fruit", "confidence", "reason"], | ||
| properties: { | ||
| fruit: { type: "string", enum: ["apple", "banana", "cherry"] }, | ||
| confidence: { type: "number", minimum: 0, maximum: 100 }, | ||
| reason: { type: "string" }, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| const PROMPT = "Pick the fruit that is a berry botanically-speaking from: apple, banana, cherry."; | ||
|
|
||
| function lastUsageRecord(model: string): unknown { | ||
| const path = join(homedir(), ".genesis-tools", "ai-proxy", "usage", "requests.jsonl"); | ||
| if (!existsSync(path)) { | ||
| return "NO requests.jsonl"; | ||
| } | ||
|
|
||
| const lines = readFileSync(path, "utf-8").trim().split("\n"); | ||
| for (let i = lines.length - 1; i >= 0; i--) { | ||
| try { | ||
| const row = SafeJSON.parse(lines[i], { strict: true }) as { proxyModel?: string }; | ||
| if (row.proxyModel === model || row.proxyModel?.endsWith(`/${model}`)) { | ||
| return row; | ||
| } | ||
| } catch { | ||
| // skip | ||
| } | ||
| } | ||
|
|
||
| return "NO record for model"; | ||
| } | ||
|
|
||
| const models = process.argv.slice(2).length ? process.argv.slice(2) : ["grok-4.5", "sonnet"]; | ||
| const client = new AiProxyClient(); | ||
| let failures = 0; | ||
|
|
||
| if (!(await client.health())) { | ||
| console.error("ai-proxy is DOWN (start: tools ai-proxy up)"); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| for (const model of models) { | ||
| for (const mode of ["response_format", "prompt"] as SchemaMode[]) { | ||
| const label = `${model} [${mode}]`; | ||
| try { | ||
| const res = await client.chat({ | ||
| model, | ||
| messages: [{ role: "user", content: PROMPT }], | ||
| jsonSchema: SCHEMA, | ||
| schemaMode: mode, | ||
| maxTokens: 2000, | ||
| timeoutMs: 120_000, | ||
| }); | ||
| const ok = res.parsed && typeof res.parsed === "object" && (res.parsed as { fruit?: string }).fruit; | ||
| console.log( | ||
| `${ok ? "✅" : "❌"} ${label} ${res.elapsedMs}ms usage=${SafeJSON.stringify(res.usage ?? null)} ` + | ||
| `parsed=${SafeJSON.stringify(res.parsed ?? null)} parseError=${res.parseError ?? "none"}` | ||
| ); | ||
|
|
||
| if (!ok) { | ||
| failures++; | ||
| console.log(` raw text: ${res.text.slice(0, 200)}`); | ||
| } | ||
| } catch (err) { | ||
| failures++; | ||
| console.log(`💥 ${label} ${err instanceof Error ? err.message.slice(0, 300) : err}`); | ||
| } | ||
| } | ||
|
|
||
| console.log(` usage record: ${SafeJSON.stringify(lastUsageRecord(model))}`); | ||
| } | ||
|
|
||
| process.exit(failures ? 1 : 0); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| /** | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧪 Tests | 🔵 Low · confidence 65/100 No test changes accompany 132 added lines in scripts/learn-from-fable/audit-spec.ts This PR adds 132 lines to 🧩 AnalysisGrep evidence: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧪 Tests | 🔵 Low · confidence 65/100 No test changes accompany 132 added lines in scripts/learn-from-fable/audit-spec.ts This PR adds 132 lines to 🧩 AnalysisGrep evidence: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧪 Tests | 🔵 Low · confidence 65/100 No test changes accompany 132 added lines in scripts/learn-from-fable/audit-spec.ts This PR adds 132 lines to 🧩 AnalysisGrep evidence: |
||
| * Is a FABLE-SPEC proposal ready to generate the skill from? | ||
| * | ||
| * The spec stage reports what IT did (passes, rejections, bullet counts). This reads | ||
| * the finished document the way the skill stage will and answers the separate | ||
| * question: is this still a list of principles? The v6 proposal passed every stage | ||
| * metric and was still unusable — 55 of its 78 bullets over 400 characters, the worst | ||
| * holding 4_363, because a line budget is satisfied by making lines longer. | ||
| * | ||
| * Usage: bun scripts/learn-from-fable/audit-spec.ts <spec.md> [baseline.md] | ||
| */ | ||
| import { readFileSync } from "node:fs"; | ||
|
|
||
| const CAP = 420; | ||
| /** Two bullets sharing this share of their vocabulary are saying the same thing twice. */ | ||
| const DUPLICATE_OVERLAP = 0.6; | ||
|
|
||
| interface Bullet { | ||
| line: number; | ||
| text: string; | ||
| section: string; | ||
| } | ||
|
|
||
| function readBullets(markdown: string): { bullets: Bullet[]; sections: string[] } { | ||
| const bullets: Bullet[] = []; | ||
| const sections: string[] = []; | ||
| let section = "(preamble)"; | ||
|
|
||
| markdown.split("\n").forEach((line, i) => { | ||
| const heading = line.match(/^##\s+(.*)$/); | ||
| if (heading) { | ||
| section = heading[1].trim(); | ||
| sections.push(section); | ||
| return; | ||
| } | ||
|
|
||
| if (/^\s*[-*]\s/.test(line)) { | ||
| bullets.push({ line: i + 1, text: line, section }); | ||
| } | ||
| }); | ||
|
|
||
| return { bullets, sections }; | ||
| } | ||
|
|
||
| function words(text: string): Set<string> { | ||
| return new Set( | ||
| text | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9\s]/g, " ") | ||
| .split(/\s+/) | ||
| .filter((w) => w.length > 3) | ||
| ); | ||
| } | ||
|
|
||
| function overlap(a: Set<string>, b: Set<string>): number { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Quality | 🔵 Low · confidence 98/100 overlap() divides by Math.min(a.size, b.size) which can be zero
🧩 AnalysisGrep evidence: Provenance: found by claude-sub/opus-5 · verified by codex/gpt-5.6-sol · peer score 38/100 |
||
| let shared = 0; | ||
| for (const w of a) { | ||
| if (b.has(w)) { | ||
| shared++; | ||
| } | ||
| } | ||
|
|
||
| return shared / Math.min(a.size, b.size); | ||
| } | ||
|
|
||
| function audit(path: string): void { | ||
| const markdown = readFileSync(path, "utf-8"); | ||
| const { bullets, sections } = readBullets(markdown); | ||
| const lengths = bullets.map((b) => b.text.length).sort((a, b) => a - b); | ||
| const over = bullets.filter((b) => b.text.length > CAP); | ||
| // the skill is generated per-principle, so a bullet with no source is a bullet | ||
| // nobody can trace back to a session when it turns out to be wrong | ||
| const uncited = bullets.filter((b) => !/\*\([0-9a-f]{8}/.test(b.text)); | ||
|
|
||
| const vocab = bullets.map((b) => words(b.text)); | ||
| const dupes: string[] = []; | ||
| for (let i = 0; i < bullets.length; i++) { | ||
| for (let j = i + 1; j < bullets.length; j++) { | ||
| if (overlap(vocab[i], vocab[j]) >= DUPLICATE_OVERLAP) { | ||
| dupes.push(` L${bullets[i].line} ~ L${bullets[j].line}: ${bullets[i].text.slice(2, 90)}…`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const total = lengths.reduce((a, b) => a + b, 0); | ||
| process.stdout.write( | ||
| [ | ||
| `# ${path}`, | ||
| `lines ${markdown.split("\n").length}`, | ||
| `sections ${sections.length} (${sections.join(" · ")})`, | ||
| `bullets ${bullets.length}`, | ||
| `bullet len avg ${Math.round(total / bullets.length)} median ${lengths[Math.floor(lengths.length / 2)]} max ${lengths.at(-1)}`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Quality | 🔵 Low · confidence 92/100 Empty specs produce NaN and undefined audit metrics When a document has no bullets, 🧩 AnalysisGrep evidence: Provenance: found by codex/gpt-5.6-sol · verified by claude-sub/opus-5 · peer score 33/100 |
||
| `over ${CAP} ${over.length} (${Math.round((over.length / bullets.length) * 100)}%)`, | ||
| `uncited ${uncited.length}`, | ||
| `near-dupes ${dupes.length}`, | ||
| "", | ||
| ...(over.length | ||
| ? [ | ||
| "## oversized", | ||
| ...over.slice(0, 8).map((b) => ` L${b.line} ${b.text.length}ch: ${b.text.slice(2, 90)}…`), | ||
| "", | ||
| ] | ||
| : []), | ||
| ...(dupes.length ? ["## near-duplicates", ...dupes.slice(0, 12), ""] : []), | ||
| ...(uncited.length | ||
| ? ["## uncited", ...uncited.slice(0, 8).map((b) => ` L${b.line}: ${b.text.slice(2, 90)}…`), ""] | ||
| : []), | ||
| "## bullets per section", | ||
| ...sections.map( | ||
| (s) => | ||
| ` ${bullets | ||
| .filter((b) => b.section === s) | ||
| .length.toString() | ||
| .padStart(4)} ${s}` | ||
| ), | ||
| "", | ||
| ].join("\n") | ||
| ); | ||
| } | ||
|
|
||
| const [path, baseline] = process.argv.slice(2); | ||
| if (!path) { | ||
| process.stdout.write("usage: bun scripts/learn-from-fable/audit-spec.ts <spec.md> [baseline.md]\n"); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| if (baseline) { | ||
| audit(baseline); | ||
| process.stdout.write(`${"=".repeat(72)}\n`); | ||
| } | ||
|
|
||
| audit(path); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| /** | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧪 Tests | 🔵 Low · confidence 65/100 No test changes accompany 71 added lines in scripts/learn-from-fable/probe-claude-sub-concurrency.ts This PR adds 71 lines to 🧩 AnalysisGrep evidence: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧪 Tests | 🔵 Low · confidence 65/100 No test changes accompany 71 added lines in scripts/learn-from-fable/probe-claude-sub-concurrency.ts This PR adds 71 lines to 🧩 AnalysisGrep evidence: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧪 Tests | 🔵 Low · confidence 65/100 No test changes accompany 71 added lines in scripts/learn-from-fable/probe-claude-sub-concurrency.ts This PR adds 71 lines to 🧩 AnalysisGrep evidence: |
||
| * Are claude-sub's silent stalls caused by CONCURRENCY, or independent of it? | ||
| * | ||
| * The filter runs ~18 calls in flight against one subscription account and about | ||
| * a quarter of them never emit a byte. If that is a concurrency ceiling, running | ||
| * wider makes it worse and running narrower fixes it; if the stall rate is flat | ||
| * across widths, it is upstream flakiness and the answer is retries, not tuning. | ||
| * | ||
| * Usage: bun scripts/learn-from-fable/probe-claude-sub-concurrency.ts [model] | ||
| */ | ||
| import { AiProxyClient } from "@genesiscz/utils/ai/proxy/AiProxyClient"; | ||
|
|
||
| const model = process.argv[2] ?? "olivia/claude-sub/claude-sonnet-5"; | ||
| const WIDTHS = (process.argv[3] ?? "4,12").split(",").map(Number); | ||
| const CALLS_PER_WIDTH = 12; | ||
| const BUDGET_MS = 45_000; | ||
|
|
||
| const client = new AiProxyClient(); | ||
|
|
||
| interface Outcome { | ||
| ok: boolean; | ||
| ms: number; | ||
| chars: number; | ||
| } | ||
|
|
||
| async function one(width: number, index: number): Promise<Outcome> { | ||
| const started = performance.now(); | ||
| const controller = new AbortController(); | ||
| const timer = setTimeout(() => controller.abort(new Error("budget")), BUDGET_MS); | ||
| try { | ||
| const r = await client.chatStream({ | ||
| model, | ||
| // varied so nothing is served from a cache | ||
| messages: [{ role: "user", content: `In one short sentence, why does step ${index} of a build matter?` }], | ||
| maxTokens: 120, | ||
| timeoutMs: BUDGET_MS, | ||
| signal: controller.signal, | ||
| tags: { session: "probe-cc", stage: "probe", label: `w${width}-${index}` }, | ||
| }); | ||
| const chars = r.text.trim().length; | ||
| return { ok: chars > 0, ms: Math.round(performance.now() - started), chars }; | ||
| } catch { | ||
| return { ok: false, ms: Math.round(performance.now() - started), chars: 0 }; | ||
| } finally { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
|
|
||
| for (const width of WIDTHS) { | ||
| const started = performance.now(); | ||
| const results: Outcome[] = []; | ||
| let next = 0; | ||
| const workers = Array.from({ length: width }, async () => { | ||
| while (next < CALLS_PER_WIDTH) { | ||
| const index = next++; | ||
| results.push(await one(width, index)); | ||
| } | ||
| }); | ||
| await Promise.all(workers); | ||
|
|
||
| const ok = results.filter((r) => r.ok); | ||
| const dead = results.filter((r) => !r.ok); | ||
| const lat = ok.map((r) => r.ms).sort((a, b) => a - b); | ||
| const median = lat.length ? lat[Math.floor(lat.length / 2)] : 0; | ||
| process.stdout.write( | ||
| `width=${String(width).padStart(2)} ok=${ok.length}/${results.length} ` + | ||
| `silent=${dead.length} medianOk=${median}ms wall=${Math.round(performance.now() - started)}ms\n` | ||
| ); | ||
| } | ||
|
|
||
| process.exit(0); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| /** | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧪 Tests | 🔵 Low · confidence 65/100 No test changes accompany 20 added lines in scripts/learn-from-fable/probe-episodes.ts This PR adds 20 lines to 🧩 AnalysisGrep evidence: |
||
| * Where the probes read mined episodes from when no path is passed on argv. | ||
| * Resolved through the fable config so no probe carries a machine-specific path. | ||
| */ | ||
| import { join } from "node:path"; | ||
| import { loadFableConfig, packPaths } from "../../src/learn-from-fable/lib/config"; | ||
|
|
||
| const DEFAULT_ARTIFACT = "episodes.ai-proxy-martin-grok-grok-4.5.raw.jsonl"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value Default artifact name still embeds a personal account slug. The header states no probe carries a machine-specific path, yet 🤖 Prompt for AI Agents |
||
|
|
||
| export function defaultEpisodesPath(artifact = DEFAULT_ARTIFACT): string { | ||
| const config = loadFableConfig(); | ||
|
|
||
| if (!config) { | ||
| throw new Error( | ||
| "No fable config found. Run `tools learn-from-fable bootstrap` or pass an episodes path as the last argument." | ||
| ); | ||
| } | ||
|
|
||
| return join(packPaths(config).episodesDir, artifact); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧪 Tests | 🔵 Low · confidence 65/100
No test changes accompany 95 added lines in scripts/ai-proxy/structured-output.ts
This PR adds 95 lines to
scripts/ai-proxy/structured-output.tswith no touching test change (no changed test namesstructured-outputand none underscripts/ai-proxy/). If the change alters behavior, add or extend a test that pins it (deterministic static check — ignore if the change is genuinely untestable or covered elsewhere).🧩 Analysis
Grep evidence:
structured-outputThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not acting on these. All three files are single-purpose diagnostic probes under scripts/ (
structured-output.ts,probe-extractor-latency.ts,probe-judge-batch.ts): they are run by hand against a live proxy and a real account to answer one question (does structured output round-trip, where does extractor latency go, does a judge batch of N hang), print timings, and exit. They export no behaviour, nothing imports them, and a test would have to mock the very network path the probe exists to observe. This matches the escape hatch in the finding itself ("ignore if the change is genuinely untestable"). The library code they exercise is what got tests this round: sse-keepalive.test.ts, transcripts.test.ts and calls.test.ts in ebde0daa8.