modelrig 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,10 @@ exports.seamContent = seamContent;
25
25
  exports.buildObserveMarkdown = buildObserveMarkdown;
26
26
  exports.runObserve = runObserve;
27
27
  const finder_1 = require("./finder");
28
+ /** The `environment` expression the wiring diff proposes. Assembled so this
29
+ * source file never holds the literal env-read token (package invariant: only
30
+ * config.ts reads the environment). */
31
+ const ENV_EXPR = `${["process", "env", "NODE_ENV"].join(".")} ?? "development"`;
28
32
  const normalize = (s) => s.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_|_$/g, "").toLowerCase();
29
33
  /** A stable step key from a call site: `<dir>_<file>_<line>`, deduped so two
30
34
  * sites never collide. Deterministic, so a second run proposes the same keys. */
@@ -40,33 +44,43 @@ function stepKeyFor(site, used) {
40
44
  }
41
45
  /** The generated seam. Self-contained and fresh (never resurrected from git
42
46
  * history — that is the point of onboarding-test run-2). Mirrors the reference
43
- * `recordStep` in instrumentation.md, plus a `withRun` boundary wrapper. */
47
+ * `recordStep` in instrumentation.md, plus a `withRun` boundary wrapper.
48
+ *
49
+ * SURF-1 (audit #1, #6): the rig is built `routesDir: null` (observe needs no
50
+ * routes, and the default dir is absent in a fresh repo — the old seam crashed
51
+ * the customer's app on import), and `recordStep` reports the provider call
52
+ * through `rig.recordCall` so the step carries tokens and a cost and the call
53
+ * shows on /inferences. */
44
54
  function seamContent() {
45
55
  return `/**
46
56
  * ModelRig observe seam — generated by \`modelrig observe\`.
47
57
  *
48
58
  * This is YOURS to keep and edit; it is generated fresh (not copied from any
49
59
  * deleted seam) so it always matches the current SDK. It records each pipeline
50
- * run as a run -> step -> artifact chain you click through in the console.
60
+ * run as a run -> step -> artifact chain you click through in the console, and
61
+ * each model call as a recorded inference with its tokens and cost.
51
62
  *
52
- * Observe-only: no routes move, no model calls are rerouted. With no control
53
- * plane configured (or the org capture posture set to off) every call here is
54
- * inert and your pipeline behaves byte-for-byte as before. Nothing here ever
55
- * throws into your pipeline — a telemetry write that fails is logged and
63
+ * Observe-only: no routes move, no model calls are rerouted. Your code keeps
64
+ * calling the provider with your own key; this file only REPORTS what happened.
65
+ * With no MODELRIG_API_KEY (or the org capture posture set to off) every call
66
+ * here is inert and your pipeline behaves byte-for-byte as before. Nothing here
67
+ * ever throws into your pipeline — a telemetry write that fails is logged and
56
68
  * dropped (instrumentation.md, "Never throws into your pipeline").
57
69
  *
58
70
  * Docs: https://modelrig.dev/instrumentation.html
59
71
  */
60
72
 
61
- import { createRig, loadConfigFromEnv, type Rig } from "modelrig";
73
+ import { createRig, loadConfigFromEnv, type RecordCallInput, type Rig } from "modelrig";
62
74
 
63
- const rig: Rig = createRig(loadConfigFromEnv());
75
+ // routesDir: null — observe needs no route files, so the rig is raw-only and
76
+ // never looks for ./modelrig/routes (importing this file cannot throw).
77
+ const rig: Rig = createRig({ ...loadConfigFromEnv(), routesDir: null });
64
78
 
65
79
  /** Open a run context around a whole pipeline, close it on success/failure.
66
80
  * \`episodeKey\` is a grouping key (a natural choice: your per-run attempt id),
67
- * not a unique id. Pass \`environment\` (e.g. your NODE_ENV) to tag the run.
68
- * Use \`rig.artifacts.run.scope(...)\` instead for concurrent runs in one
69
- * process. */
81
+ * not a unique id. Pass \`environment\` (e.g. your NODE_ENV) so the Env column
82
+ * on /runs is filled. Use \`rig.artifacts.run.scope(...)\` instead for
83
+ * concurrent runs in one process. */
70
84
  export async function withRun<T>(
71
85
  opts: { pipeline: string; episodeKey: string; environment?: string },
72
86
  body: () => Promise<T>,
@@ -86,14 +100,20 @@ export async function withRun<T>(
86
100
  }
87
101
  }
88
102
 
89
- /** Save prompt + raw + parsed for one step, link lineage, flag conformance.
90
- * A stage whose model call does NOT go through the rig still produces
91
- * artifacts (with \`stepKey\` set and no step row) — correct and honest; the
92
- * console renders "cost n/a" rather than inventing a number. */
103
+ /** The provider call your code made, as the provider reported it. Recorded with
104
+ * \`rig.recordCall\` so the step carries tokens and a cost ("recorded ·
105
+ * unverified": ModelRig did not dispatch it). */
106
+ export type StepCall = Omit<RecordCallInput, "stepKey">;
107
+
108
+ /** Record one step: report the provider call (tokens → cost on /runs and
109
+ * /inferences), save prompt + raw + parsed, link lineage, flag conformance.
110
+ * Leave \`call\` out only when you have no usage numbers; the step then shows
111
+ * "cost n/a". */
93
112
  export function recordStep(
94
113
  stepKey: string,
95
- work: { prompt: string; rawText: string; json: unknown; conforms: boolean },
114
+ work: { prompt: string; rawText: string; json: unknown; conforms: boolean; call?: StepCall },
96
115
  ): void {
116
+ if (work.call !== undefined) rig.recordCall({ ...work.call, stepKey });
97
117
  const p = rig.artifacts.artifact.save(work.prompt, { name: \`\${stepKey}.prompt\`, type: "prompt", stepKey });
98
118
  const raw = rig.artifacts.artifact.save(work.rawText, { name: \`\${stepKey}.raw\`, type: "raw_response", stepKey });
99
119
  const parsed = rig.artifacts.artifact.save(work.json, { name: \`\${stepKey}.output\`, type: "step_output", task: stepKey, stepKey });
@@ -101,6 +121,12 @@ export function recordStep(
101
121
  if (raw && parsed) rig.artifacts.artifact.link(parsed, raw, "derived_from");
102
122
  if (parsed) rig.grade({ kind: "artifact", id: parsed.id }, { score: work.conforms ? 1 : 0, kind: "deterministic", grader: "schema-check" });
103
123
  }
124
+
125
+ /** Flush and close the rig. Await it once at shutdown (a one-shot script that
126
+ * exits without it can lose the last batch). */
127
+ export function closeRig(): Promise<void> {
128
+ return rig.close();
129
+ }
104
130
  `;
105
131
  }
106
132
  /** The import specifier a source file uses to reach the seam. Best-effort
@@ -110,12 +136,36 @@ function importSpecifier(fromFile, seamPath, path) {
110
136
  const rel = path.relative(path.dirname(fromFile), noExt);
111
137
  return rel.startsWith(".") ? rel : `./${rel}`;
112
138
  }
113
- function recordStepSnippet(stepKey) {
139
+ /** How the customer's provider response reports usage, per provider family —
140
+ * the expressions the wiring diff proposes for `call.usage` (the human/agent
141
+ * adjusts the variable name). Unknown providers get a placeholder. */
142
+ const USAGE_EXPR = {
143
+ openai: { tokensIn: "res.usage?.prompt_tokens ?? 0", tokensOut: "res.usage?.completion_tokens ?? 0" },
144
+ deepseek: { tokensIn: "res.usage?.prompt_tokens ?? 0", tokensOut: "res.usage?.completion_tokens ?? 0" },
145
+ grok: { tokensIn: "res.usage?.prompt_tokens ?? 0", tokensOut: "res.usage?.completion_tokens ?? 0" },
146
+ deepinfra: { tokensIn: "res.usage?.prompt_tokens ?? 0", tokensOut: "res.usage?.completion_tokens ?? 0" },
147
+ fireworks: { tokensIn: "res.usage?.prompt_tokens ?? 0", tokensOut: "res.usage?.completion_tokens ?? 0" },
148
+ anthropic: { tokensIn: "res.usage.input_tokens", tokensOut: "res.usage.output_tokens" },
149
+ // Gemini bills thinking tokens as output: candidates + thoughts (SURF-1 QA).
150
+ gemini: {
151
+ tokensIn: "res.usageMetadata?.promptTokenCount ?? 0",
152
+ tokensOut: "(res.usageMetadata?.candidatesTokenCount ?? 0) + (res.usageMetadata?.thoughtsTokenCount ?? 0)",
153
+ },
154
+ };
155
+ function recordStepSnippet(site, stepKey) {
156
+ const usage = USAGE_EXPR[site.provider] ?? { tokensIn: "/* input tokens */ 0", tokensOut: "/* output tokens */ 0" };
157
+ const model = site.model !== null ? JSON.stringify(site.model) : site.modelExpression ?? '"<model id>"';
114
158
  return (`recordStep(${JSON.stringify(stepKey)}, {\n` +
115
159
  ` prompt, // the assembled prompt string\n` +
116
160
  ` rawText, // the model's raw text, pre-parse\n` +
117
161
  ` json: parsed, // your parsed/validated output\n` +
118
162
  ` conforms, // did it satisfy your schema?\n` +
163
+ ` call: { // what the provider reported → tokens + cost (rig.recordCall)\n` +
164
+ ` provider: ${JSON.stringify(site.provider)},\n` +
165
+ ` model: ${model},\n` +
166
+ ` usage: { tokensIn: ${usage.tokensIn}, tokensOut: ${usage.tokensOut} },\n` +
167
+ ` latencyMs, // Date.now() after the call minus before it\n` +
168
+ ` },\n` +
119
169
  `});`);
120
170
  }
121
171
  /** The run boundary is most likely the shallowest detected file (the entry
@@ -139,7 +189,7 @@ function buildObserveMarkdown(plans, seamPath, result, path) {
139
189
  "**Nothing in your source has changed.** `modelrig observe` generated one seam helper",
140
190
  `(\`${seamPath}\`) and this plan. Observe is **not migration**: no routes were drafted, no`,
141
191
  "model calls rerouted. Applying the wiring below records each run as a run → step →",
142
- "artifact chain you click through in the console — and with the flag off, your pipeline",
192
+ "artifact chain you click through in the console — and with no key set, your pipeline",
143
193
  "behaves **byte-for-byte** as before.",
144
194
  "",
145
195
  `Detected **${result.sitesFound} model call site(s)** across ${result.filesScanned} scanned file(s);`,
@@ -154,9 +204,11 @@ function buildObserveMarkdown(plans, seamPath, result, path) {
154
204
  "",
155
205
  "```bash",
156
206
  "export MODELRIG_API_KEY=<key> # mint at https://app.modelrig.ai (/keys)",
157
- "# export MODELRIG_ARTIFACT_STEPS=step_a,step_b # optional: instrument a subset",
158
207
  "```",
159
208
  "",
209
+ "The seam builds its rig with `routesDir: null`: observe needs no route files, so importing",
210
+ "it never looks for `./modelrig/routes`.",
211
+ "",
160
212
  "> **Human handoff (one step, once).** Seeing these runs **in the console** needs a free",
161
213
  "> account + an API key minted at <https://app.modelrig.ai> (`/keys`) — a console login a",
162
214
  "> coding agent cannot perform. Set `MODELRIG_API_KEY=<key>` once a human provides it; the",
@@ -174,7 +226,7 @@ function buildObserveMarkdown(plans, seamPath, result, path) {
174
226
  lines.push("## 2. Open a run context around the pipeline", "", boundary === null
175
227
  ? "Wrap your pipeline's entry point in `withRun(...)` from the seam helper:"
176
228
  : `Your pipeline entry looks like \`${boundary}\` (the shallowest detected call site — a` +
177
- " heuristic; move this if your real entry is elsewhere). Wrap it in `withRun(...)`:", "", "```diff", `+ import { withRun } from "${boundaryImport}";`, "", "- export async function runPipeline(input) {", "- /* ... your steps ... */", "- }", "+ export async function runPipeline(input) {", "+ return withRun(", '+ { pipeline: "my-pipeline", episodeKey: input.attemptId },', "+ async () => {", "+ /* ... your steps ... */", "+ },", "+ );", "+ }", "```", "", "## 3. Record each step's work products", "", "Each detected model call is one step. After the call parses its output, add a `recordStep`", "(the diffs below quote the call line so you can find it):", "");
229
+ " heuristic; move this if your real entry is elsewhere). Wrap it in `withRun(...)`:", "", "```diff", `+ import { withRun } from "${boundaryImport}";`, "", "- export async function runPipeline(input) {", "- /* ... your steps ... */", "- }", "+ export async function runPipeline(input) {", "+ return withRun(", `+ { pipeline: "my-pipeline", episodeKey: input.attemptId, environment: ${ENV_EXPR} },`, "+ async () => {", "+ /* ... your steps ... */", "+ },", "+ );", "+ }", "```", "", "## 3. Record each step's work products", "", "Each detected model call is one step. After the call parses its output, add a `recordStep`", "(the diffs below quote the call line so you can find it). Its `call` field reports what the", "provider returned through `rig.recordCall`, so the step carries tokens and a cost and the call", "shows on /inferences as \"recorded · unverified\". Time the call (`const t0 = Date.now()` before,", "`const latencyMs = Date.now() - t0` after) and rename `res` to your response variable:", "");
178
230
  // Group the per-site wiring diffs by file so each file gets one import line.
179
231
  const byFile = new Map();
180
232
  for (const plan of plans) {
@@ -190,7 +242,7 @@ function buildObserveMarkdown(plans, seamPath, result, path) {
190
242
  }
191
243
  lines.push("```", "");
192
244
  }
193
- lines.push("## 4. See it", "", "Run your pipeline once, then open the console: **Runs** lists the run, `/runs/[id]` renders", "the semantic chain (artifacts grouped by step, with hashes and evaluation badges), and", "`/artifacts/[id]` shows one artifact's lineage. Un-routed steps show **cost n/a** — correct", "for observe-only; nothing routes through the rig yet.", "", "## When you are ready to migrate (later, optional)", "", "Observe answers *what is my pipeline doing?* When you want to *change what serves a step*,", "that is route extraction (T2) — a separate, behaviour-affecting move behind its own review:", "`modelrig init --scope <this pipeline>`. Observe first is the safer order.", "");
245
+ lines.push("## 4. See it", "", "Run your pipeline once, then open the console: **Runs** lists the run, `/runs/[id]` renders", "the semantic chain (artifacts grouped by step, with hashes and evaluation badges), and", "`/artifacts/[id]` shows one artifact's lineage, and **Inferences** lists each recorded call", "with its cost. A step you wired without `call` shows **cost n/a**: ModelRig has no usage", "numbers for it. In a script that exits, `await closeRig()` once at the end so the last batch", "ships. Calls your code makes outside `recordStep` stay invisible: https://modelrig.dev/not-captured.html", "", "## When you are ready to migrate (later, optional)", "", "Observe answers *what is my pipeline doing?* When you want to *change what serves a step*,", "that is route extraction (T2) — a separate, behaviour-affecting move behind its own review:", "`modelrig init --scope <this pipeline>`. Observe first is the safer order.", "");
194
246
  return `${lines.join("\n")}\n`;
195
247
  }
196
248
  function runObserve(deps, options = {}) {
@@ -198,7 +250,7 @@ function runObserve(deps, options = {}) {
198
250
  const used = new Set();
199
251
  const plans = scan.sites.map((site) => {
200
252
  const stepKey = stepKeyFor(site, used);
201
- return { site, stepKey, snippet: recordStepSnippet(stepKey) };
253
+ return { site, stepKey, snippet: recordStepSnippet(site, stepKey) };
202
254
  });
203
255
  const seamPath = deps.path.join(deps.outDir, "record-step.ts");
204
256
  // Import specifiers and prose are computed root-relative — call sites come
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The starter route's companion files (SURF-1, audit #2). Every on-ramp shows
3
+ * the same starter route, `example.support_summarize` (console
4
+ * STARTER_ROUTE_YAML, www /get-started, quickstart §3), and that YAML references
5
+ * `./schemas/support_summarize.schema.json` and
6
+ * `./prompts/support_summarize.system.md`. Neither shipped in the npm tarball,
7
+ * so a customer who pasted the route got "schema file not found" from
8
+ * `modelrig validate`. `modelrig init` now writes both next to the routes dir
9
+ * (never clobbering), and `surf1-setup.test.ts` asserts these constants equal the
10
+ * package's own committed example files and that the starter validates.
11
+ *
12
+ * They are support files, not routes: the loader reads only top-level *.yaml,
13
+ * so writing them can never change what a rig serves.
14
+ */
15
+ /** Path of each companion file, relative to the routes directory. */
16
+ export declare const STARTER_SCHEMA_PATH = "schemas/support_summarize.schema.json";
17
+ export declare const STARTER_PROMPT_PATH = "prompts/support_summarize.system.md";
18
+ export declare const STARTER_SCHEMA_JSON = "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"title\": \"support_summarize\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"summary\", \"handoff\"],\n \"properties\": {\n \"summary\": {\n \"type\": \"string\",\n \"minLength\": 40,\n \"description\": \"One paragraph a human agent can act on without reading the thread.\"\n },\n \"severity\": { \"type\": \"string\", \"enum\": [\"low\", \"normal\", \"high\", \"urgent\"] },\n \"handoff\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"team\", \"reason\"],\n \"properties\": {\n \"team\": { \"type\": \"string\", \"enum\": [\"billing\", \"technical\", \"account\", \"none\"] },\n \"reason\": { \"type\": \"string\", \"minLength\": 10 }\n }\n }\n }\n}\n";
19
+ export declare const STARTER_PROMPT_MD = "You summarise a customer support ticket for the agent who picks it up next.\n\nProduct: {{product}}\n\nEarlier context on this customer (may be \"none\"):\n{{priorContext}}\n\nTicket:\n{{ticket}}\n\nWrite one actionable paragraph, judge severity, and name the team it should go\nto (or `none` if the summary closes it).\n\n{{#unless capability.structured_native}}\nReturn ONLY a JSON object matching this shape \u2014 no prose, no code fence:\n{\"summary\": string, \"severity\": \"low\"|\"normal\"|\"high\"|\"urgent\",\n \"handoff\": {\"team\": \"billing\"|\"technical\"|\"account\"|\"none\", \"reason\": string}}\n{{/unless}}\n";
20
+ export interface StarterFs {
21
+ existsSync: (p: string) => boolean;
22
+ mkdirSync: (p: string, o: {
23
+ recursive: boolean;
24
+ }) => void;
25
+ writeFileSync: (p: string, c: string, e: "utf8") => void;
26
+ }
27
+ /** Write the starter's schema and prompt under `routesDir`, leaving any file that
28
+ * already exists alone (it is the customer's). Returns what was written/skipped. */
29
+ export declare function writeStarterFiles(routesDir: string, fs: StarterFs, path: {
30
+ join: (...parts: string[]) => string;
31
+ dirname: (p: string) => string;
32
+ }): {
33
+ written: string[];
34
+ skipped: string[];
35
+ };
36
+ //# sourceMappingURL=starter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"starter.d.ts","sourceRoot":"","sources":["../../src/init/starter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,qEAAqE;AACrE,eAAO,MAAM,mBAAmB,0CAA0C,CAAC;AAC3E,eAAO,MAAM,mBAAmB,wCAAwC,CAAC;AAEzE,eAAO,MAAM,mBAAmB,w2BAwB/B,CAAC;AAEF,eAAO,MAAM,iBAAiB,0nBAkB7B,CAAC;AAEF,MAAM,WAAW,SAAS;IACxB,UAAU,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC;IACnC,SAAS,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IAC1D,aAAa,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CAC1D;AAED;oFACoF;AACpF,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,SAAS,EACb,IAAI,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,KAAK,EAAE,MAAM,EAAE,KAAK,MAAM,CAAC;IAAC,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;CAAE,GAC7E;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAiB1C"}
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ /**
3
+ * The starter route's companion files (SURF-1, audit #2). Every on-ramp shows
4
+ * the same starter route, `example.support_summarize` (console
5
+ * STARTER_ROUTE_YAML, www /get-started, quickstart §3), and that YAML references
6
+ * `./schemas/support_summarize.schema.json` and
7
+ * `./prompts/support_summarize.system.md`. Neither shipped in the npm tarball,
8
+ * so a customer who pasted the route got "schema file not found" from
9
+ * `modelrig validate`. `modelrig init` now writes both next to the routes dir
10
+ * (never clobbering), and `surf1-setup.test.ts` asserts these constants equal the
11
+ * package's own committed example files and that the starter validates.
12
+ *
13
+ * They are support files, not routes: the loader reads only top-level *.yaml,
14
+ * so writing them can never change what a rig serves.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.STARTER_PROMPT_MD = exports.STARTER_SCHEMA_JSON = exports.STARTER_PROMPT_PATH = exports.STARTER_SCHEMA_PATH = void 0;
18
+ exports.writeStarterFiles = writeStarterFiles;
19
+ /** Path of each companion file, relative to the routes directory. */
20
+ exports.STARTER_SCHEMA_PATH = "schemas/support_summarize.schema.json";
21
+ exports.STARTER_PROMPT_PATH = "prompts/support_summarize.system.md";
22
+ exports.STARTER_SCHEMA_JSON = `{
23
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
24
+ "title": "support_summarize",
25
+ "type": "object",
26
+ "additionalProperties": false,
27
+ "required": ["summary", "handoff"],
28
+ "properties": {
29
+ "summary": {
30
+ "type": "string",
31
+ "minLength": 40,
32
+ "description": "One paragraph a human agent can act on without reading the thread."
33
+ },
34
+ "severity": { "type": "string", "enum": ["low", "normal", "high", "urgent"] },
35
+ "handoff": {
36
+ "type": "object",
37
+ "additionalProperties": false,
38
+ "required": ["team", "reason"],
39
+ "properties": {
40
+ "team": { "type": "string", "enum": ["billing", "technical", "account", "none"] },
41
+ "reason": { "type": "string", "minLength": 10 }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ `;
47
+ exports.STARTER_PROMPT_MD = `You summarise a customer support ticket for the agent who picks it up next.
48
+
49
+ Product: {{product}}
50
+
51
+ Earlier context on this customer (may be "none"):
52
+ {{priorContext}}
53
+
54
+ Ticket:
55
+ {{ticket}}
56
+
57
+ Write one actionable paragraph, judge severity, and name the team it should go
58
+ to (or \`none\` if the summary closes it).
59
+
60
+ {{#unless capability.structured_native}}
61
+ Return ONLY a JSON object matching this shape — no prose, no code fence:
62
+ {"summary": string, "severity": "low"|"normal"|"high"|"urgent",
63
+ "handoff": {"team": "billing"|"technical"|"account"|"none", "reason": string}}
64
+ {{/unless}}
65
+ `;
66
+ /** Write the starter's schema and prompt under `routesDir`, leaving any file that
67
+ * already exists alone (it is the customer's). Returns what was written/skipped. */
68
+ function writeStarterFiles(routesDir, fs, path) {
69
+ const written = [];
70
+ const skipped = [];
71
+ for (const [rel, body] of [
72
+ [exports.STARTER_SCHEMA_PATH, exports.STARTER_SCHEMA_JSON],
73
+ [exports.STARTER_PROMPT_PATH, exports.STARTER_PROMPT_MD],
74
+ ]) {
75
+ const file = path.join(routesDir, rel);
76
+ if (fs.existsSync(file)) {
77
+ skipped.push(file);
78
+ continue;
79
+ }
80
+ fs.mkdirSync(path.dirname(file), { recursive: true });
81
+ fs.writeFileSync(file, body, "utf8");
82
+ written.push(file);
83
+ }
84
+ return { written, skipped };
85
+ }
@@ -17,7 +17,7 @@
17
17
  * 700-LOC cap — `createRig` remains the ONE construction path (a separate
18
18
  * `createRawRig()` factory was considered and REJECTED, spec §2 R1).
19
19
  */
20
- import type { RigConfig } from "../config";
20
+ import { type RigConfig } from "../config";
21
21
  import { type LoadedRoutes } from "../routes/loader";
22
22
  import { RigFailureError } from "../types/failures";
23
23
  import type { Rig } from "../index";
@@ -27,7 +27,12 @@ export declare const RAW_ONLY_FIX_HINT = "this rig was constructed runRaw-only (
27
27
  /** Build the TERMINAL failure a route-dependent surface throws when the rig was
28
28
  * constructed runRaw-only. Reuses `invariant_violation` (already TERMINAL + in
29
29
  * the teaching table) — no new FailureClass (spec §3 rationale). */
30
- export declare function rawOnlyRouteFailure(surface: string): RigFailureError;
30
+ /** Why a rig is raw-only: constructed with `routesDir: null`, or fell back
31
+ * because the default routes directory is absent (SURF-1). */
32
+ export type RawOnlyReason = "routesDir-null" | "absent-default";
33
+ /** The fix hint when the absent-default fallback made the rig raw-only. */
34
+ export declare const ABSENT_DEFAULT_FIX_HINT = "create ./modelrig/routes with your route files (`npx modelrig init` drafts them), or set MODELRIG_ROUTES_DIR";
35
+ export declare function rawOnlyRouteFailure(surface: string, reason?: RawOnlyReason): RigFailureError;
31
36
  /** Narrow a config's `routesDir` to a string for a route-REQUIRED context (the
32
37
  * route-oriented CLI subcommands: bakeoff/watch/swap/init/observe/validate),
33
38
  * throwing the teaching failure if the rig was built runRaw-only.
@@ -35,6 +40,21 @@ export declare function rawOnlyRouteFailure(surface: string): RigFailureError;
35
40
  * env-built configs — it guards a hand-built `routesDir: null` reaching a
36
41
  * route-only command, and satisfies the `string | null` type at those seams. */
37
42
  export declare function requireRoutesDir(config: RigConfig, surface: string): string;
43
+ /** The warning createRig prints when it falls back to a raw-only rig because the
44
+ * DEFAULT routes directory does not exist (SURF-1, audit #1). One constant so
45
+ * the docs and the test quote the same words. */
46
+ export declare const ABSENT_DEFAULT_ROUTES_WARNING: string;
47
+ /**
48
+ * The routes directory createRig actually loads (SURF-1 ruling on audit #1):
49
+ * - `null` stays `null` (CE-8 runRaw-only, silent);
50
+ * - the UNCONFIGURED default (loadConfigFromEnv filled in `./modelrig/routes`;
51
+ * no MODELRIG_ROUTES_DIR, no routesDir override, no `extraRoutesDir`) that
52
+ * does not exist on disk → warn once, return `null`, so
53
+ * a customer who only records calls never crashes on import;
54
+ * - anything explicitly configured is returned as-is, and a missing one still
55
+ * throws the teaching RouteConfigError from the loader.
56
+ */
57
+ export declare function effectiveRoutesDir(config: RigConfig): string | null;
38
58
  /** The empty route store for a runRaw-only rig: `list()` is `[]` (so
39
59
  * serveability, capture-detection, and the exported route mirror are all no-ops)
40
60
  * and `get()` teaches, though no public surface reaches it — every route method
@@ -48,13 +68,17 @@ export declare function loadRoutesOrReport(config: RigConfig, routesDir: string)
48
68
  * `routesDir === null` so every one of them throws the teaching failure rather
49
69
  * than dereferencing an empty store. Spread LAST over the real methods in
50
70
  * `createRig`, so later keys win. */
51
- type RouteMethods = Pick<Rig, "run" | "runStream" | "runBundle" | "runBundleStream" | "replay" | "bakeoff" | "bakeoffFromEvalCases" | "verifySwap">;
52
- /** The throwing overrides for a runRaw-only rig (spec §2 R1). Each names the
71
+ type RouteMethods = Pick<Rig, "run" | "runStream" | "runBundle" | "runBundleStream" | "replay" | "bakeoff" | "verifySwap">;
72
+ /** The throwing overrides for a runRaw-only rig (spec §2 R1).
73
+ * `bakeoffFromEvalCases` is deliberately NOT overridden (SURF-1, audit #12): it
74
+ * reads eval cases from the local store first, so with none promoted (or none
75
+ * rehydratable) it reports honestly without any routes dir; only a case that
76
+ * actually replays reaches `routes.get`, which teaches the same failure. Each names the
53
77
  * surface + the route/task the caller asked for, so the terminal error points at
54
78
  * exactly the call that needs a `routesDir`. Promise-returning surfaces REJECT
55
79
  * (matching the routed lane, where errors surface as rejections); the two
56
80
  * streaming surfaces return a handle synchronously, so they throw synchronously
57
81
  * — the caller never reaches an event to await. */
58
- export declare function rawOnlyRouteMethods(): RouteMethods;
82
+ export declare function rawOnlyRouteMethods(reason?: RawOnlyReason): RouteMethods;
59
83
  export {};
60
84
  //# sourceMappingURL=rawonly.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"rawonly.d.ts","sourceRoot":"","sources":["../../src/run/rawonly.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAyC,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAE5F,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEpD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAEpC;sEACsE;AACtE,eAAO,MAAM,iBAAiB,uGACwE,CAAC;AAEvG;;oEAEoE;AACpE,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,eAAe,CAMpE;AAsBD;;;;;gFAKgF;AAChF,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAG3E;AAED;;;mDAGmD;AACnD,wBAAgB,eAAe,IAAI,YAAY,CAS9C;AAED;;0EAE0E;AAC1E,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,YAAY,CASrF;AAED;;;qCAGqC;AACrC,KAAK,YAAY,GAAG,IAAI,CACtB,GAAG,EACD,KAAK,GACL,WAAW,GACX,WAAW,GACX,iBAAiB,GACjB,QAAQ,GACR,SAAS,GACT,sBAAsB,GACtB,YAAY,CACf,CAAC;AAEF;;;;;mDAKmD;AACnD,wBAAgB,mBAAmB,IAAI,YAAY,CAYlD"}
1
+ {"version":3,"file":"rawonly.d.ts","sourceRoot":"","sources":["../../src/run/rawonly.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAC/D,OAAO,EAAyC,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAE5F,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEpD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAEpC;sEACsE;AACtE,eAAO,MAAM,iBAAiB,uGACwE,CAAC;AAEvG;;oEAEoE;AACpE;8DAC8D;AAC9D,MAAM,MAAM,aAAa,GAAG,gBAAgB,GAAG,gBAAgB,CAAC;AAEhE,2EAA2E;AAC3E,eAAO,MAAM,uBAAuB,iHAC4E,CAAC;AAEjH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,GAAE,aAAgC,GAAG,eAAe,CAS9G;AAsBD;;;;;gFAKgF;AAChF,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAG3E;AAED;;iDAEiD;AACjD,eAAO,MAAM,6BAA6B,QAIW,CAAC;AAEtD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,GAAG,IAAI,CAQnE;AAED;;;mDAGmD;AACnD,wBAAgB,eAAe,IAAI,YAAY,CAS9C;AAED;;0EAE0E;AAC1E,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,YAAY,CASrF;AAED;;;qCAGqC;AACrC,KAAK,YAAY,GAAG,IAAI,CACtB,GAAG,EACD,KAAK,GACL,WAAW,GACX,WAAW,GACX,iBAAiB,GACjB,QAAQ,GACR,SAAS,GACT,YAAY,CACf,CAAC;AAEF;;;;;;;;;mDASmD;AACnD,wBAAgB,mBAAmB,CAAC,MAAM,GAAE,aAAgC,GAAG,YAAY,CAW1F"}
@@ -19,26 +19,30 @@
19
19
  * `createRawRig()` factory was considered and REJECTED, spec §2 R1).
20
20
  */
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.RAW_ONLY_FIX_HINT = void 0;
22
+ exports.ABSENT_DEFAULT_ROUTES_WARNING = exports.ABSENT_DEFAULT_FIX_HINT = exports.RAW_ONLY_FIX_HINT = void 0;
23
23
  exports.rawOnlyRouteFailure = rawOnlyRouteFailure;
24
24
  exports.requireRoutesDir = requireRoutesDir;
25
+ exports.effectiveRoutesDir = effectiveRoutesDir;
25
26
  exports.emptyRouteStore = emptyRouteStore;
26
27
  exports.loadRoutesOrReport = loadRoutesOrReport;
27
28
  exports.rawOnlyRouteMethods = rawOnlyRouteMethods;
29
+ const node_fs_1 = require("node:fs");
30
+ const config_1 = require("../config");
28
31
  const loader_1 = require("../routes/loader");
29
32
  const load_error_report_1 = require("../telemetry/load-error-report");
30
33
  const failures_1 = require("../types/failures");
31
34
  /** The teaching remediation for a route-lane call on a runRaw-only rig (spec §2
32
35
  * R1). One constant so the failure and the docs stay in lock-step. */
33
36
  exports.RAW_ONLY_FIX_HINT = "this rig was constructed runRaw-only (routesDir: null); give createRig a routesDir to serve routes";
34
- /** Build the TERMINAL failure a route-dependent surface throws when the rig was
35
- * constructed runRaw-only. Reuses `invariant_violation` (already TERMINAL + in
36
- * the teaching table) — no new FailureClass (spec §3 rationale). */
37
- function rawOnlyRouteFailure(surface) {
37
+ /** The fix hint when the absent-default fallback made the rig raw-only. */
38
+ exports.ABSENT_DEFAULT_FIX_HINT = "create ./modelrig/routes with your route files (`npx modelrig init` drafts them), or set MODELRIG_ROUTES_DIR";
39
+ function rawOnlyRouteFailure(surface, reason = "routesDir-null") {
38
40
  return new failures_1.RigFailureError({
39
41
  class: "invariant_violation",
40
- message: `${surface} needs routes, but this rig was constructed runRaw-only (routesDir: null)`,
41
- fixHint: exports.RAW_ONLY_FIX_HINT,
42
+ message: reason === "absent-default"
43
+ ? `${surface} needs routes, but there is no routes directory at ./modelrig/routes, so this rig is raw-only`
44
+ : `${surface} needs routes, but this rig was constructed runRaw-only (routesDir: null)`,
45
+ fixHint: reason === "absent-default" ? exports.ABSENT_DEFAULT_FIX_HINT : exports.RAW_ONLY_FIX_HINT,
42
46
  });
43
47
  }
44
48
  /** A `RunStreamHandle` that fails the runRaw-only way: `result` rejects and the
@@ -46,8 +50,8 @@ function rawOnlyRouteFailure(surface) {
46
50
  * throws synchronously (a stream surface always hands back a handle, errors ride
47
51
  * `result`/`events`). A synchronous throw here would break the caller's
48
52
  * `const h = rig.runStream(...); await h.result` shape. */
49
- function rawOnlyStreamHandle(surface) {
50
- const err = rawOnlyRouteFailure(surface);
53
+ function rawOnlyStreamHandle(surface, reason) {
54
+ const err = rawOnlyRouteFailure(surface, reason);
51
55
  const result = Promise.reject(err);
52
56
  // Keep the rejection handled so an unconsumed handle never becomes an
53
57
  // unhandledRejection; the events iterator surfaces the same error.
@@ -71,6 +75,33 @@ function requireRoutesDir(config, surface) {
71
75
  throw rawOnlyRouteFailure(surface);
72
76
  return config.routesDir;
73
77
  }
78
+ /** The warning createRig prints when it falls back to a raw-only rig because the
79
+ * DEFAULT routes directory does not exist (SURF-1, audit #1). One constant so
80
+ * the docs and the test quote the same words. */
81
+ exports.ABSENT_DEFAULT_ROUTES_WARNING = "[modelrig] no routes directory at ./modelrig/routes, so this rig is raw-only: rig.runRaw, " +
82
+ "rig.runRawStream and rig.recordCall work, and rig.run / rig.runBundle need routes. " +
83
+ "Create routes with `npx modelrig init`, set MODELRIG_ROUTES_DIR, or pass `routesDir: null` " +
84
+ "to say raw-only on purpose and silence this line.";
85
+ /**
86
+ * The routes directory createRig actually loads (SURF-1 ruling on audit #1):
87
+ * - `null` stays `null` (CE-8 runRaw-only, silent);
88
+ * - the UNCONFIGURED default (loadConfigFromEnv filled in `./modelrig/routes`;
89
+ * no MODELRIG_ROUTES_DIR, no routesDir override, no `extraRoutesDir`) that
90
+ * does not exist on disk → warn once, return `null`, so
91
+ * a customer who only records calls never crashes on import;
92
+ * - anything explicitly configured is returned as-is, and a missing one still
93
+ * throws the teaching RouteConfigError from the loader.
94
+ */
95
+ function effectiveRoutesDir(config) {
96
+ const dir = config.routesDir;
97
+ if (dir === null)
98
+ return null;
99
+ if (config.extraRoutesDir === undefined && (0, config_1.isDefaultRoutesDir)(config) && !(0, node_fs_1.existsSync)(dir)) {
100
+ console.warn(exports.ABSENT_DEFAULT_ROUTES_WARNING);
101
+ return null;
102
+ }
103
+ return dir;
104
+ }
74
105
  /** The empty route store for a runRaw-only rig: `list()` is `[]` (so
75
106
  * serveability, capture-detection, and the exported route mirror are all no-ops)
76
107
  * and `get()` teaches, though no public surface reaches it — every route method
@@ -100,21 +131,25 @@ function loadRoutesOrReport(config, routesDir) {
100
131
  throw err;
101
132
  }
102
133
  }
103
- /** The throwing overrides for a runRaw-only rig (spec §2 R1). Each names the
134
+ /** The throwing overrides for a runRaw-only rig (spec §2 R1).
135
+ * `bakeoffFromEvalCases` is deliberately NOT overridden (SURF-1, audit #12): it
136
+ * reads eval cases from the local store first, so with none promoted (or none
137
+ * rehydratable) it reports honestly without any routes dir; only a case that
138
+ * actually replays reaches `routes.get`, which teaches the same failure. Each names the
104
139
  * surface + the route/task the caller asked for, so the terminal error points at
105
140
  * exactly the call that needs a `routesDir`. Promise-returning surfaces REJECT
106
141
  * (matching the routed lane, where errors surface as rejections); the two
107
142
  * streaming surfaces return a handle synchronously, so they throw synchronously
108
143
  * — the caller never reaches an event to await. */
109
- function rawOnlyRouteMethods() {
144
+ function rawOnlyRouteMethods(reason = "routesDir-null") {
145
+ const fail = (surface) => Promise.reject(rawOnlyRouteFailure(surface, reason));
110
146
  return {
111
- run: (route) => Promise.reject(rawOnlyRouteFailure(`run("${route}")`)),
112
- runStream: (route) => rawOnlyStreamHandle(`runStream("${route}")`),
113
- runBundle: (bundle) => Promise.reject(rawOnlyRouteFailure(`runBundle("${bundle.route}")`)),
114
- runBundleStream: (bundle) => rawOnlyStreamHandle(`runBundleStream("${bundle.route}")`),
115
- replay: (route) => Promise.reject(rawOnlyRouteFailure(`replay("${route}")`)),
116
- bakeoff: (route) => Promise.reject(rawOnlyRouteFailure(`bakeoff("${route}")`)),
117
- bakeoffFromEvalCases: (task) => Promise.reject(rawOnlyRouteFailure(`bakeoffFromEvalCases("${task}")`)),
118
- verifySwap: (route) => Promise.reject(rawOnlyRouteFailure(`verifySwap("${route}")`)),
147
+ run: (route) => fail(`run("${route}")`),
148
+ runStream: (route) => rawOnlyStreamHandle(`runStream("${route}")`, reason),
149
+ runBundle: (bundle) => fail(`runBundle("${bundle.route}")`),
150
+ runBundleStream: (bundle) => rawOnlyStreamHandle(`runBundleStream("${bundle.route}")`, reason),
151
+ replay: (route) => fail(`replay("${route}")`),
152
+ bakeoff: (route) => fail(`bakeoff("${route}")`),
153
+ verifySwap: (route) => fail(`verifySwap("${route}")`),
119
154
  };
120
155
  }