@wrenautomation/raven 0.1.1 → 0.2.0

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.
package/README.md CHANGED
@@ -21,8 +21,9 @@ learns. Raven never loops; the runner takes the edge.
21
21
  ## CLI
22
22
 
23
23
  Needs Node 24+. The model is a command that reads the prompt on stdin and prints the answer.
24
- Default `claude -p`, so Claude Code is the model in any terminal; `RAVEN_MODEL` or `--model`
25
- changes it; `--no-model` plans from your own queries only.
24
+ Default `claude -p --output-format json`, so Claude Code is the model in any terminal and its
25
+ envelope gives the cost per call; `RAVEN_MODEL` or `--model` changes it (plain text out is fine);
26
+ `--no-model` plans from your own queries only.
26
27
 
27
28
  ```sh
28
29
  npm i @wrenautomation/raven
@@ -38,7 +39,10 @@ npx raven plans web > plans/web.json
38
39
  ```ts
39
40
  import { plan, triage, review, type Model } from "@wrenautomation/raven";
40
41
 
41
- const model: Model = { id: "claude-sonnet-5-5", ask: (prompt, shape) => yourClient.json(prompt, shape) };
42
+ const model: Model = {
43
+ id: "claude-sonnet-5-5",
44
+ ask: async (prompt, shape) => ({ value: await yourClient.json(prompt, shape), usage: { usd: 0.01 } }),
45
+ };
42
46
  const p = await plan({ ask: { text, kind: "web", known }, model, proposals: [byHand] });
43
47
  const reads = await triage(hits, p, model);
44
48
  const edge = await review(round, model);
package/dist/app/cli.js CHANGED
@@ -15,7 +15,7 @@ import { defaultPlanFile, PLAN_KINDS } from "../plans/index.js";
15
15
  import { review } from "../review.js";
16
16
  import { triage } from "../triage.js";
17
17
  import { AskKind, HitView, Plan, PlanFile, Round, YieldRow, z } from "../types.js";
18
- const DEFAULT_MODEL = "claude -p";
18
+ const DEFAULT_MODEL = "claude -p --output-format json";
19
19
  const readJson = (path, shape) => shape.parse(JSON.parse(readFileSync(path, "utf8")));
20
20
  const print = (x) => console.log(JSON.stringify(x, null, 2));
21
21
  const more = (v, prev) => [...prev, v];
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { commandModel, fill, type Model, parseAnswer, scriptedModel } from "./model.js";
1
+ export { type Answer, commandModel, fill, type Model, parseAnswer, readOutput, scriptedModel, type Usage, } from "./model.js";
2
2
  export { mergeProposals, type PlanInput, plan, propose, proposePrompt } from "./plan.js";
3
3
  export { defaultPlanFile, PLAN_KINDS } from "./plans/index.js";
4
4
  export { renderHits, renderYield } from "./render.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { commandModel, fill, parseAnswer, scriptedModel } from "./model.js";
1
+ export { commandModel, fill, parseAnswer, readOutput, scriptedModel, } from "./model.js";
2
2
  export { mergeProposals, plan, propose, proposePrompt } from "./plan.js";
3
3
  export { defaultPlanFile, PLAN_KINDS } from "./plans/index.js";
4
4
  export { renderHits, renderYield } from "./render.js";
package/dist/model.d.ts CHANGED
@@ -1,19 +1,36 @@
1
1
  import type { ZodType } from "zod";
2
+ /** What one model call cost, as far as the model says. All optional: a shell command may say nothing. */
3
+ export interface Usage {
4
+ /** Tokens in and out. */
5
+ input?: number;
6
+ output?: number;
7
+ usd?: number;
8
+ }
9
+ /** A parsed answer and what it cost: the ledger keeps both. */
10
+ export interface Answer<T> {
11
+ value: T;
12
+ usage?: Usage;
13
+ }
2
14
  export interface Model {
3
15
  /** What goes in `by` on the records this model writes. */
4
16
  id: string;
5
- ask<T>(prompt: string, shape: ZodType<T>): Promise<T>;
17
+ ask<T>(prompt: string, shape: ZodType<T>): Promise<Answer<T>>;
6
18
  }
7
19
  /** `{name}` marks filled in one pass; a `{word}` with no value stays as text. */
8
20
  export declare const fill: (template: string, values: Readonly<Record<string, string>>) => string;
9
21
  /**
10
- * The first JSON object in a model's text, parsed into the shape. Fences and prose around
11
- * it are dropped. Throws with the model's text when nothing parses.
22
+ * The first JSON object of the shape in a model's text, found by brace balance: fences, prose
23
+ * and stray braces around it are passed over. Throws with the model's text when nothing parses.
12
24
  */
13
25
  export declare function parseAnswer<T>(text: string, shape: ZodType<T>): T;
26
+ /** A command's stdout as an answer: the envelope's text and usage when it printed one, else the text. */
27
+ export declare function readOutput(stdout: string): {
28
+ text: string;
29
+ usage?: Usage;
30
+ };
14
31
  /**
15
- * A model that is a shell command: the prompt on stdin, the answer on stdout. `claude -p`
16
- * makes Claude Code the planner's model from any terminal.
32
+ * A model that is a shell command: the prompt on stdin, the answer on stdout. When stdout is
33
+ * Claude Code's JSON envelope (`claude -p --output-format json`) the usage comes with it.
17
34
  */
18
35
  export declare function commandModel(command: string, id?: string): Model;
19
36
  /** A model that answers from a fixed list, in order: tests and dry runs. */
package/dist/model.js CHANGED
@@ -1,33 +1,77 @@
1
1
  /**
2
2
  * The model seam. Raven never picks a model: a caller hands in one `Model`, a function from a
3
- * prompt and an answer shape to a parsed answer, and its id goes on every record it writes. In
4
- * a terminal the model is a command that reads the prompt on stdin and prints the answer
5
- * (`claude -p` by default); in wren it wraps the LLM client.
3
+ * prompt and an answer shape to a parsed answer with what it cost, and its id goes on every
4
+ * record it writes. In a terminal the model is a command that reads the prompt on stdin and
5
+ * prints the answer (`claude -p --output-format json` by default); in wren it wraps the LLM
6
+ * client.
6
7
  */
7
8
  import { spawn } from "node:child_process";
8
9
  /** `{name}` marks filled in one pass; a `{word}` with no value stays as text. */
9
10
  export const fill = (template, values) => template.replace(/\{(\w+)\}/g, (mark, name) => values[name] ?? mark);
11
+ /** Every balanced `{...}` in a text, by start position: an outer object comes before its inner ones. */
12
+ function* objects(text) {
13
+ for (let start = text.indexOf("{"); start >= 0; start = text.indexOf("{", start + 1)) {
14
+ let depth = 0;
15
+ let inString = false;
16
+ let escaped = false;
17
+ for (let i = start; i < text.length; i++) {
18
+ const c = text[i];
19
+ if (inString) {
20
+ if (escaped)
21
+ escaped = false;
22
+ else if (c === "\\")
23
+ escaped = true;
24
+ else if (c === '"')
25
+ inString = false;
26
+ }
27
+ else if (c === '"')
28
+ inString = true;
29
+ else if (c === "{")
30
+ depth++;
31
+ else if (c === "}" && --depth === 0) {
32
+ yield text.slice(start, i + 1);
33
+ break;
34
+ }
35
+ }
36
+ }
37
+ }
10
38
  /**
11
- * The first JSON object in a model's text, parsed into the shape. Fences and prose around
12
- * it are dropped. Throws with the model's text when nothing parses.
39
+ * The first JSON object of the shape in a model's text, found by brace balance: fences, prose
40
+ * and stray braces around it are passed over. Throws with the model's text when nothing parses.
13
41
  */
14
42
  export function parseAnswer(text, shape) {
15
- const start = text.indexOf("{");
16
- if (start >= 0) {
17
- for (let end = text.lastIndexOf("}"); end > start; end = text.lastIndexOf("}", end - 1)) {
18
- try {
19
- const out = shape.safeParse(JSON.parse(text.slice(start, end + 1)));
20
- if (out.success)
21
- return out.data;
22
- }
23
- catch { }
43
+ for (const candidate of objects(text)) {
44
+ try {
45
+ const out = shape.safeParse(JSON.parse(candidate));
46
+ if (out.success)
47
+ return out.data;
24
48
  }
49
+ catch { }
25
50
  }
26
51
  throw new Error(`no answer of the expected shape in: ${text.slice(0, 400)}`);
27
52
  }
53
+ const isEnvelope = (x) => typeof x === "object" && x !== null && typeof x.result === "string";
54
+ /** A command's stdout as an answer: the envelope's text and usage when it printed one, else the text. */
55
+ export function readOutput(stdout) {
56
+ try {
57
+ const parsed = JSON.parse(stdout);
58
+ if (isEnvelope(parsed)) {
59
+ const usage = {};
60
+ if (parsed.usage?.input_tokens !== undefined)
61
+ usage.input = parsed.usage.input_tokens;
62
+ if (parsed.usage?.output_tokens !== undefined)
63
+ usage.output = parsed.usage.output_tokens;
64
+ if (parsed.total_cost_usd !== undefined)
65
+ usage.usd = parsed.total_cost_usd;
66
+ return { text: parsed.result, usage };
67
+ }
68
+ }
69
+ catch { }
70
+ return { text: stdout };
71
+ }
28
72
  /**
29
- * A model that is a shell command: the prompt on stdin, the answer on stdout. `claude -p`
30
- * makes Claude Code the planner's model from any terminal.
73
+ * A model that is a shell command: the prompt on stdin, the answer on stdout. When stdout is
74
+ * Claude Code's JSON envelope (`claude -p --output-format json`) the usage comes with it.
31
75
  */
32
76
  export function commandModel(command, id = command) {
33
77
  return {
@@ -38,11 +82,12 @@ export function commandModel(command, id = command) {
38
82
  child.stdout.on("data", (b) => out.push(b));
39
83
  child.on("error", reject);
40
84
  child.on("close", (code) => {
41
- const text = Buffer.concat(out).toString("utf8");
85
+ const stdout = Buffer.concat(out).toString("utf8");
42
86
  if (code !== 0)
43
- return reject(new Error(`${command} exited ${code}: ${text.slice(0, 400)}`));
87
+ return reject(new Error(`${command} exited ${code}: ${stdout.slice(0, 400)}`));
44
88
  try {
45
- resolve(parseAnswer(text, shape));
89
+ const { text, usage } = readOutput(stdout);
90
+ resolve({ value: parseAnswer(text, shape), ...(usage ? { usage } : {}) });
46
91
  }
47
92
  catch (e) {
48
93
  reject(e);
@@ -61,7 +106,7 @@ export function scriptedModel(answers, id = "scripted") {
61
106
  const a = answers[i++];
62
107
  if (a === undefined)
63
108
  throw new Error(`scripted model has no answer ${i}`);
64
- return shape.parse(a);
109
+ return { value: shape.parse(a) };
65
110
  },
66
111
  };
67
112
  }
package/dist/plan.js CHANGED
@@ -22,7 +22,7 @@ export function proposePrompt(ask, planFile, yieldRows, now) {
22
22
  }
23
23
  /** The model's proposal for an ask under a plan file. */
24
24
  export async function propose(ask, planFile, model, yieldRows = [], now = new Date()) {
25
- const out = await model.ask(proposePrompt(ask, planFile, yieldRows, now), ProposalAnswer);
25
+ const { value: out } = await model.ask(proposePrompt(ask, planFile, yieldRows, now), ProposalAnswer);
26
26
  return {
27
27
  from: "ai",
28
28
  by: model.id,
@@ -9,7 +9,7 @@ const SHAPE_HITS = `Return ONLY a JSON object, no prose, exactly this shape:
9
9
  {"hits": [{"url": "...", "read": true, "why": "..."}]}`;
10
10
  const SHAPE_EDGE = `Return ONLY a JSON object, no prose, exactly this shape:
11
11
  {"next": "stop", "why": "..."}
12
- where "next" is "stop", "again" (a new round from plan) or "back" (rerun one step of this round, named in "to": "plan", "fetch", "read" or "extract").`;
12
+ where "next" is "stop", "again" (a new round from plan) or "back" (rerun one step of this round, named in "to": "plan", "fetch", "triage", "read" or "extract").`;
13
13
  const CONTEXT = `Ask: {ask}
14
14
  Known already (look past these):
15
15
  {known}
package/dist/review.js CHANGED
@@ -35,7 +35,7 @@ export async function review(round, model, options = {}) {
35
35
  };
36
36
  }
37
37
  const planFile = options.planFile ?? defaultPlanFile(round.ask.kind);
38
- const out = await model.ask(reviewPrompt(round, planFile), EdgeAnswer);
38
+ const { value: out } = await model.ask(reviewPrompt(round, planFile), EdgeAnswer);
39
39
  const edge = { next: out.next, why: out.why, from: "ai", by: model.id };
40
40
  if (out.next === "back")
41
41
  edge.to = out.to ?? "fetch";
package/dist/triage.d.ts CHANGED
@@ -4,12 +4,12 @@
4
4
  * caps it; a hit the model did not name is a skip, written down as one.
5
5
  */
6
6
  import { type Model } from "./model.js";
7
- import { type HitView, type Plan, type PlanFile, type Triage, type YieldRow } from "./types.js";
7
+ import { HitView, type HitViewIn, type Plan, type PlanFile, type Triage, type YieldRow } from "./types.js";
8
8
  /** One hit per url, sources merged, order of first sight. */
9
- export declare function dedupeHits(hits: readonly HitView[]): HitView[];
9
+ export declare function dedupeHits(hits: readonly HitViewIn[]): HitView[];
10
10
  export declare function triagePrompt(hits: readonly HitView[], plan: Plan, planFile: PlanFile, yieldRows: readonly YieldRow[]): string;
11
11
  /** Read or skip per hit, with why. Reads over the plan's budget flip to skips, in the model's order. */
12
- export declare function triage(hits: readonly HitView[], plan: Plan, model: Model, options?: {
12
+ export declare function triage(hits: readonly HitViewIn[], plan: Plan, model: Model, options?: {
13
13
  planFile?: PlanFile;
14
14
  yieldRows?: readonly YieldRow[];
15
15
  }): Promise<Triage[]>;
package/dist/triage.js CHANGED
@@ -6,14 +6,14 @@
6
6
  import { fill } from "./model.js";
7
7
  import { defaultPlanFile } from "./plans/index.js";
8
8
  import { lines, renderHits, renderYield } from "./render.js";
9
- import { z } from "./types.js";
9
+ import { HitView, z, } from "./types.js";
10
10
  const TriageAnswer = z.object({
11
11
  hits: z.array(z.object({ url: z.string(), read: z.boolean(), why: z.string() })),
12
12
  });
13
13
  /** One hit per url, sources merged, order of first sight. */
14
14
  export function dedupeHits(hits) {
15
15
  const byUrl = new Map();
16
- for (const h of hits) {
16
+ for (const h of hits.map((x) => HitView.parse(x))) {
17
17
  const url = h.url.trim();
18
18
  const have = byUrl.get(url);
19
19
  if (!have) {
@@ -46,7 +46,7 @@ export async function triage(hits, plan, model, options = {}) {
46
46
  if (!unique.length)
47
47
  return [];
48
48
  const planFile = options.planFile ?? defaultPlanFile(plan.ask.kind);
49
- const out = await model.ask(triagePrompt(unique, plan, planFile, options.yieldRows ?? []), TriageAnswer);
49
+ const { value: out } = await model.ask(triagePrompt(unique, plan, planFile, options.yieldRows ?? []), TriageAnswer);
50
50
  const said = new Map(out.hits.map((h) => [h.url.trim(), h]));
51
51
  let reads = 0;
52
52
  return unique.map((h) => {
package/dist/types.d.ts CHANGED
@@ -195,6 +195,8 @@ export declare const HitView: z.ZodObject<{
195
195
  sources: z.ZodDefault<z.ZodArray<z.ZodString>>;
196
196
  }, z.core.$strip>;
197
197
  export type HitView = z.infer<typeof HitView>;
198
+ /** A hit as a caller may pass it: `sources` optional. */
199
+ export type HitViewIn = z.input<typeof HitView>;
198
200
  export declare const Triage: z.ZodObject<{
199
201
  url: z.ZodString;
200
202
  read: z.ZodBoolean;
@@ -235,6 +237,7 @@ export declare const Round: z.ZodObject<{
235
237
  }, z.core.$strip>;
236
238
  export type Round = z.infer<typeof Round>;
237
239
  export declare const Step: z.ZodEnum<{
240
+ triage: "triage";
238
241
  read: "read";
239
242
  plan: "plan";
240
243
  fetch: "fetch";
@@ -249,6 +252,7 @@ export declare const Edge: z.ZodObject<{
249
252
  back: "back";
250
253
  }>;
251
254
  to: z.ZodOptional<z.ZodEnum<{
255
+ triage: "triage";
252
256
  read: "read";
253
257
  plan: "plan";
254
258
  fetch: "fetch";
package/dist/types.js CHANGED
@@ -121,7 +121,7 @@ export const Round = z.object({
121
121
  /** What went wrong or ran out, one line each. */
122
122
  notes: z.array(z.string()).default([]),
123
123
  });
124
- export const Step = z.enum(["plan", "fetch", "read", "extract"]);
124
+ export const Step = z.enum(["plan", "fetch", "triage", "read", "extract"]);
125
125
  /** The edge out of a round. `again` is a new round from `plan`; `back` reruns one step of this round. */
126
126
  export const Edge = z.object({
127
127
  next: z.enum(["stop", "again", "back"]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrenautomation/raven",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Ask in, plan out. The planner of Wren's research engine: typed queries with who proposed them and why, proposals merged into one plan under a plan file per ask kind, read-or-skip on hits, and the edge to take after a round. Pure functions and prompts; the model is injected.",
5
5
  "license": "PolyForm-Shield-1.0.0",
6
6
  "repository": {