@wrenautomation/raven 0.1.2 → 0.2.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.
- package/README.md +8 -3
- package/dist/app/cli.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/model.d.ts +25 -6
- package/dist/model.js +80 -22
- package/dist/plan.js +1 -1
- package/dist/plans/index.js +1 -1
- package/dist/review.js +1 -1
- package/dist/triage.js +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,8 +21,10 @@ 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
|
|
25
|
-
|
|
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. A command that says nothing for 3 minutes is
|
|
27
|
+
killed and the call fails with why.
|
|
26
28
|
|
|
27
29
|
```sh
|
|
28
30
|
npm i @wrenautomation/raven
|
|
@@ -38,7 +40,10 @@ npx raven plans web > plans/web.json
|
|
|
38
40
|
```ts
|
|
39
41
|
import { plan, triage, review, type Model } from "@wrenautomation/raven";
|
|
40
42
|
|
|
41
|
-
const model: Model = {
|
|
43
|
+
const model: Model = {
|
|
44
|
+
id: "claude-sonnet-5-5",
|
|
45
|
+
ask: async (prompt, shape) => ({ value: await yourClient.json(prompt, shape), usage: { usd: 0.01 } }),
|
|
46
|
+
};
|
|
42
47
|
const p = await plan({ ask: { text, kind: "web", known }, model, proposals: [byHand] });
|
|
43
48
|
const reads = await triage(hits, p, model);
|
|
44
49
|
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,20 +1,39 @@
|
|
|
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,
|
|
11
|
-
* it are
|
|
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.
|
|
16
|
-
*
|
|
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.
|
|
34
|
+
* A command that says nothing for `timeoutMs` (3 minutes) is killed and the ask fails with
|
|
35
|
+
* why, so a hung model never hangs the run.
|
|
17
36
|
*/
|
|
18
|
-
export declare function commandModel(command: string, id?: string): Model;
|
|
37
|
+
export declare function commandModel(command: string, id?: string, timeoutMs?: number): Model;
|
|
19
38
|
/** A model that answers from a fixed list, in order: tests and dry runs. */
|
|
20
39
|
export declare function scriptedModel(answers: unknown[], id?: string): Model;
|
package/dist/model.js
CHANGED
|
@@ -1,48 +1,106 @@
|
|
|
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
|
|
4
|
-
* a terminal the model is a command that reads the prompt on stdin and
|
|
5
|
-
* (`claude -p` by default); in wren it wraps the LLM
|
|
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,
|
|
12
|
-
* it are
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
30
|
-
*
|
|
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.
|
|
75
|
+
* A command that says nothing for `timeoutMs` (3 minutes) is killed and the ask fails with
|
|
76
|
+
* why, so a hung model never hangs the run.
|
|
31
77
|
*/
|
|
32
|
-
export function commandModel(command, id = command) {
|
|
78
|
+
export function commandModel(command, id = command, timeoutMs = 180_000) {
|
|
33
79
|
return {
|
|
34
80
|
id,
|
|
35
81
|
ask: (prompt, shape) => new Promise((resolve, reject) => {
|
|
36
82
|
const child = spawn(command, { shell: true, stdio: ["pipe", "pipe", "inherit"] });
|
|
37
83
|
const out = [];
|
|
84
|
+
let timedOut = false;
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
timedOut = true;
|
|
87
|
+
child.kill();
|
|
88
|
+
}, timeoutMs);
|
|
38
89
|
child.stdout.on("data", (b) => out.push(b));
|
|
39
|
-
child.on("error",
|
|
90
|
+
child.on("error", (e) => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
reject(e);
|
|
93
|
+
});
|
|
40
94
|
child.on("close", (code) => {
|
|
41
|
-
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
const stdout = Buffer.concat(out).toString("utf8");
|
|
97
|
+
if (timedOut)
|
|
98
|
+
return reject(new Error(`${command}: no answer in ${Math.round(timeoutMs / 1000)}s`));
|
|
42
99
|
if (code !== 0)
|
|
43
|
-
return reject(new Error(`${command} exited ${code}: ${
|
|
100
|
+
return reject(new Error(`${command} exited ${code}: ${stdout.slice(0, 400)}`));
|
|
44
101
|
try {
|
|
45
|
-
|
|
102
|
+
const { text, usage } = readOutput(stdout);
|
|
103
|
+
resolve({ value: parseAnswer(text, shape), ...(usage ? { usage } : {}) });
|
|
46
104
|
}
|
|
47
105
|
catch (e) {
|
|
48
106
|
reject(e);
|
|
@@ -61,7 +119,7 @@ export function scriptedModel(answers, id = "scripted") {
|
|
|
61
119
|
const a = answers[i++];
|
|
62
120
|
if (a === undefined)
|
|
63
121
|
throw new Error(`scripted model has no answer ${i}`);
|
|
64
|
-
return shape.parse(a);
|
|
122
|
+
return { value: shape.parse(a) };
|
|
65
123
|
},
|
|
66
124
|
};
|
|
67
125
|
}
|
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,
|
package/dist/plans/index.js
CHANGED
|
@@ -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.js
CHANGED
|
@@ -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
|
@@ -237,6 +237,7 @@ export declare const Round: z.ZodObject<{
|
|
|
237
237
|
}, z.core.$strip>;
|
|
238
238
|
export type Round = z.infer<typeof Round>;
|
|
239
239
|
export declare const Step: z.ZodEnum<{
|
|
240
|
+
triage: "triage";
|
|
240
241
|
read: "read";
|
|
241
242
|
plan: "plan";
|
|
242
243
|
fetch: "fetch";
|
|
@@ -251,6 +252,7 @@ export declare const Edge: z.ZodObject<{
|
|
|
251
252
|
back: "back";
|
|
252
253
|
}>;
|
|
253
254
|
to: z.ZodOptional<z.ZodEnum<{
|
|
255
|
+
triage: "triage";
|
|
254
256
|
read: "read";
|
|
255
257
|
plan: "plan";
|
|
256
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
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|