@itmetsam/samurai-framework 0.22.4 → 0.22.6

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.
Files changed (75) hide show
  1. package/dist/agent/generate-kind.d.ts +5 -0
  2. package/dist/agent/generate-kind.js +45 -0
  3. package/dist/agent/index.d.ts +13 -3
  4. package/dist/agent/index.js +15 -2
  5. package/dist/agent/plan/front-matter.d.ts +9 -0
  6. package/dist/agent/plan/front-matter.js +18 -0
  7. package/dist/agent/plan/guard.d.ts +7 -0
  8. package/dist/agent/plan/guard.js +32 -0
  9. package/dist/agent/plan/plan-file.d.ts +67 -0
  10. package/dist/agent/plan/plan-file.js +229 -0
  11. package/dist/agent/plan/plan-kind.d.ts +5 -0
  12. package/dist/agent/plan/plan-kind.js +28 -0
  13. package/dist/agent/plan/plan-path.d.ts +8 -0
  14. package/dist/agent/plan/plan-path.js +46 -0
  15. package/dist/agent/plan/prompt.d.ts +3 -0
  16. package/dist/agent/plan/prompt.js +37 -0
  17. package/dist/agent/plan/questions.d.ts +5 -0
  18. package/dist/agent/plan/questions.js +22 -0
  19. package/dist/agent/plan/read-plan.d.ts +5 -0
  20. package/dist/agent/plan/read-plan.js +29 -0
  21. package/dist/agent/plan/recorder.d.ts +30 -0
  22. package/dist/agent/plan/recorder.js +59 -0
  23. package/dist/agent/plan/run.d.ts +8 -0
  24. package/dist/agent/plan/run.js +10 -0
  25. package/dist/agent/plan/tools.d.ts +4 -0
  26. package/dist/agent/plan/tools.js +95 -0
  27. package/dist/agent/plan-link.d.ts +8 -0
  28. package/dist/agent/plan-link.js +25 -0
  29. package/dist/agent/prompt.d.ts +6 -0
  30. package/dist/agent/prompt.js +20 -6
  31. package/dist/agent/recording.d.ts +6 -0
  32. package/dist/agent/recording.js +14 -0
  33. package/dist/agent/run-kind.d.ts +48 -0
  34. package/dist/agent/run-kind.js +1 -0
  35. package/dist/agent/run.d.ts +7 -3
  36. package/dist/agent/run.js +19 -27
  37. package/dist/agent/sdk-tools.js +10 -2
  38. package/dist/agent/tools/context.d.ts +12 -5
  39. package/dist/agent/tools/index.js +2 -0
  40. package/dist/agent/tools/undo.d.ts +1 -0
  41. package/dist/agent/tools/undo.js +23 -0
  42. package/dist/agent/types.d.ts +36 -7
  43. package/dist/cli/agent-run.d.ts +43 -0
  44. package/dist/cli/agent-run.js +132 -0
  45. package/dist/cli/args.d.ts +3 -0
  46. package/dist/cli/args.js +3 -0
  47. package/dist/cli/generate.d.ts +4 -8
  48. package/dist/cli/generate.js +18 -109
  49. package/dist/cli/main.js +3 -0
  50. package/dist/cli/plan.d.ts +6 -0
  51. package/dist/cli/plan.js +31 -0
  52. package/dist/cli/usage.d.ts +1 -1
  53. package/dist/cli/usage.js +9 -1
  54. package/dist/config/config.js +2 -0
  55. package/dist/config/defaults.d.ts +2 -0
  56. package/dist/config/defaults.js +2 -0
  57. package/dist/config/plans-dir.d.ts +3 -0
  58. package/dist/config/plans-dir.js +11 -0
  59. package/dist/config/resolved-config.d.ts +1 -0
  60. package/dist/config/resolved-config.js +2 -1
  61. package/dist/recorder/spec-source.d.ts +2 -0
  62. package/dist/recorder/spec-source.js +9 -0
  63. package/dist/runner/registry.js +6 -2
  64. package/dist/runner/test-info.js +1 -0
  65. package/dist/runner/test-io.d.ts +5 -1
  66. package/dist/runner/test-io.js +21 -0
  67. package/dist/steps/edit.d.ts +3 -1
  68. package/dist/steps/edit.js +1 -1
  69. package/dist/steps/model.d.ts +3 -1
  70. package/dist/steps/parse.js +2 -1
  71. package/dist/steps/test-options.d.ts +5 -3
  72. package/dist/steps/test-options.js +49 -0
  73. package/dist/types/config.d.ts +6 -0
  74. package/dist/types/test.d.ts +4 -0
  75. package/package.json +1 -1
@@ -0,0 +1,5 @@
1
+ import { StepRecorder } from "./recording.js";
2
+ import type { RunKind } from "./run-kind.js";
3
+ import type { AgentOptions } from "./types.js";
4
+ /** The run of `runAgent`: typed steps go into a spec file */
5
+ export declare const generateKind: RunKind<AgentOptions, StepRecorder>;
@@ -0,0 +1,45 @@
1
+ import { resultSecrets } from "../recorder/session-secrets.js";
2
+ import { buildSystemPrompt } from "./prompt.js";
3
+ import { questionsSection } from "./plan/questions.js";
4
+ import { StepRecorder } from "./recording.js";
5
+ import { personRecording } from "./take-over.js";
6
+ import { createTools } from "./tools/index.js";
7
+ /** The run of `runAgent`: typed steps go into a spec file */
8
+ export const generateKind = {
9
+ file: (options) => options.file,
10
+ sink: (options, { baseURL, emit }) => new StepRecorder({
11
+ file: options.file,
12
+ create: options.create,
13
+ test: options.test,
14
+ baseURL,
15
+ planFile: options.planFile,
16
+ onStep: (event, code, source) => emit({
17
+ type: "step",
18
+ event,
19
+ ...(code !== undefined && { code }),
20
+ source,
21
+ }),
22
+ }),
23
+ opened: (steps, url) => {
24
+ if (steps.count === 0)
25
+ steps.observe({ type: "goto", url });
26
+ },
27
+ tools: (context) => createTools(context),
28
+ prompt: (input, options) => buildSystemPrompt({
29
+ ...input,
30
+ ...(options.plan !== undefined && { hasPlan: true }),
31
+ }),
32
+ opening: (options, first) => {
33
+ if (options.plan === undefined)
34
+ return `Goal: ${options.goal}\n\n${first}`;
35
+ const questions = questionsSection(options.plan);
36
+ return `Goal: ${options.goal}\n\nPlan (written earlier by another agent; follow it, but check each step on the live page):\n${options.plan.trim()}\n\n${questions === "" ? "" : `${questions}\n\n`}${first}`;
37
+ },
38
+ secrets: (steps) => resultSecrets(steps.events, []),
39
+ person: ({ page, sink, snapshots, baseURL }) => personRecording({
40
+ page,
41
+ steps: sink,
42
+ snapshots,
43
+ ...(baseURL !== undefined && { baseURL }),
44
+ }),
45
+ };
@@ -1,11 +1,21 @@
1
+ import type { AgentOptions, AgentSession, PlanOptions, PlanSession } from "./types.js";
1
2
  export { DEFAULT_LIMITS, resolveLimits, type AgentLimits } from "./limits.js";
2
3
  export { assertModelObject, type AgentModel } from "./model.js";
4
+ export { parsePlan, type ParsedPlan } from "./plan/plan-file.js";
5
+ export { defaultPlanFile } from "./plan/plan-path.js";
6
+ export { readPlan } from "./plan/read-plan.js";
3
7
  export { defaultModelSpec, ModelSetupError, modelFromSpec, } from "./providers.js";
4
- export type { AgentEndReason, AgentEvent, AgentOptions, AgentResult, AgentSession, } from "./types.js";
5
- import type { AgentOptions, AgentSession } from "./types.js";
8
+ export type { AgentEndReason, AgentEvent, AgentOptions, AgentResult, AgentSession, PlanOptions, PlanResult, PlanSession, } from "./types.js";
6
9
  /**
7
10
  * Starts the agent on a goal. The model is an AI SDK model object; the run opens a browser, writes typed steps
8
11
  * into the spec file as they succeed, and ends with an `ended` event whatever happens. Throws at once for a model
9
- * that is a string. Needs the optional packages `ai` and `zod`.
12
+ * that is a string. Needs the optional packages `ai` and `zod`. With `plan` (the text of a plan from `runPlan`)
13
+ * the agent follows that plan, checking each step on the live page.
10
14
  */
11
15
  export declare function runAgent(options: AgentOptions): AgentSession;
16
+ /**
17
+ * Starts the planning agent on a goal: it explores the page and writes a Markdown plan into `out`, a step at a
18
+ * time, for `runAgent`'s `plan` option. The page is read-only: every request that is not a GET is stopped. Same
19
+ * events and `ended` event as `runAgent`, plus `plan-step`. Needs the optional packages `ai` and `zod`.
20
+ */
21
+ export declare function runPlan(options: PlanOptions): PlanSession;
@@ -1,12 +1,25 @@
1
+ import { startPlan } from "./plan/run.js";
2
+ import { startAgent } from "./run.js";
1
3
  export { DEFAULT_LIMITS, resolveLimits } from "./limits.js";
2
4
  export { assertModelObject } from "./model.js";
5
+ export { parsePlan } from "./plan/plan-file.js";
6
+ export { defaultPlanFile } from "./plan/plan-path.js";
7
+ export { readPlan } from "./plan/read-plan.js";
3
8
  export { defaultModelSpec, ModelSetupError, modelFromSpec, } from "./providers.js";
4
- import { startAgent } from "./run.js";
5
9
  /**
6
10
  * Starts the agent on a goal. The model is an AI SDK model object; the run opens a browser, writes typed steps
7
11
  * into the spec file as they succeed, and ends with an `ended` event whatever happens. Throws at once for a model
8
- * that is a string. Needs the optional packages `ai` and `zod`.
12
+ * that is a string. Needs the optional packages `ai` and `zod`. With `plan` (the text of a plan from `runPlan`)
13
+ * the agent follows that plan, checking each step on the live page.
9
14
  */
10
15
  export function runAgent(options) {
11
16
  return startAgent(options);
12
17
  }
18
+ /**
19
+ * Starts the planning agent on a goal: it explores the page and writes a Markdown plan into `out`, a step at a
20
+ * time, for `runAgent`'s `plan` option. The page is read-only: every request that is not a GET is stopped. Same
21
+ * events and `ended` event as `runAgent`, plus `plan-step`. Needs the optional packages `ai` and `zod`.
22
+ */
23
+ export function runPlan(options) {
24
+ return startPlan(options);
25
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Splits the YAML front matter off a plan: a first line of `---` up to the next line of `---`. Nothing inside is
3
+ * parsed (the app keeps its own metadata there, and any key is fine). A plan without it is returned as it is; an
4
+ * opening fence with no closing one throws.
5
+ */
6
+ export declare function splitFrontMatter(text: string): {
7
+ front: string;
8
+ body: string;
9
+ };
@@ -0,0 +1,18 @@
1
+ const FENCE = "---";
2
+ /**
3
+ * Splits the YAML front matter off a plan: a first line of `---` up to the next line of `---`. Nothing inside is
4
+ * parsed (the app keeps its own metadata there, and any key is fine). A plan without it is returned as it is; an
5
+ * opening fence with no closing one throws.
6
+ */
7
+ export function splitFrontMatter(text) {
8
+ const lines = text.replace(/^/, "").split("\n");
9
+ if (lines[0]?.trimEnd() !== FENCE)
10
+ return { front: "", body: text };
11
+ const end = lines.findIndex((line, i) => i > 0 && line.trimEnd() === FENCE);
12
+ if (end < 0)
13
+ throw new Error(`the front matter block opened with "---" on the first line has no closing "---" line`);
14
+ return {
15
+ front: `${lines.slice(0, end + 1).join("\n")}\n`,
16
+ body: lines.slice(end + 1).join("\n"),
17
+ };
18
+ }
@@ -0,0 +1,7 @@
1
+ import type Page from "../../browser/page.js";
2
+ /**
3
+ * Makes the page read-only for the planner: every request that is not a `GET`, `HEAD` or `OPTIONS` is aborted
4
+ * before it leaves the browser (form submits, `fetch`, XHR, beacons), and `onBlocked` hears what it was. A `GET`
5
+ * that changes data and a WebSocket message are not covered. Other origins are the origin policy's business.
6
+ */
7
+ export declare function installPlanGuard(page: Pick<Page, "route">, onBlocked: (what: string) => void): Promise<void>;
@@ -0,0 +1,32 @@
1
+ /** Methods that read: they pass. `OPTIONS` is the CORS preflight, which carries no data of the page */
2
+ const READS = new Set(["GET", "HEAD", "OPTIONS"]);
3
+ /** "POST /api/feedback": the method and the path, without the query (it can hold values) */
4
+ function describeRequest(method, url) {
5
+ try {
6
+ return `${method} ${new URL(url).pathname}`;
7
+ }
8
+ catch {
9
+ return method;
10
+ }
11
+ }
12
+ /**
13
+ * Makes the page read-only for the planner: every request that is not a `GET`, `HEAD` or `OPTIONS` is aborted
14
+ * before it leaves the browser (form submits, `fetch`, XHR, beacons), and `onBlocked` hears what it was. A `GET`
15
+ * that changes data and a WebSocket message are not covered. Other origins are the origin policy's business.
16
+ */
17
+ export async function installPlanGuard(page, onBlocked) {
18
+ await page.route("**/*", async (route) => {
19
+ const request = route.request();
20
+ const { method, url } = request;
21
+ if (READS.has(method.toUpperCase())) {
22
+ await route.continue();
23
+ return;
24
+ }
25
+ onBlocked(describeRequest(method.toUpperCase(), url));
26
+ // An aborted form submit leaves Chrome on its error page; an empty 204 answer to a navigation keeps the page
27
+ if (request.resourceType === "document" || request.navigation !== null)
28
+ await route.fulfill({ status: 204 });
29
+ else
30
+ await route.abort();
31
+ });
32
+ }
@@ -0,0 +1,67 @@
1
+ /** A plan file may not be larger than this (bytes), for the planner and for `generate --plan` */
2
+ export declare const MAX_PLAN_BYTES: number;
3
+ interface PlanStep {
4
+ text: string;
5
+ expect?: string;
6
+ }
7
+ /** A plan as `parsePlan` reads it; every part but the steps may be missing in a hand-written file */
8
+ interface ParsedPlan {
9
+ title: string;
10
+ goal: string;
11
+ start: string;
12
+ steps: PlanStep[];
13
+ seen: string[];
14
+ notDone: string[];
15
+ /** Every open question, answered or not */
16
+ questions: string[];
17
+ /** The questions that have an `Answer:` line, with the answer; a question with an empty answer is not here */
18
+ answers: {
19
+ question: string;
20
+ answer: string;
21
+ }[];
22
+ }
23
+ /**
24
+ * A plan file being written. The whole file is written again after every call, so it is valid Markdown with its
25
+ * four headings whenever the run stops. Texts are masked and made one line long.
26
+ */
27
+ declare class PlanFile {
28
+ readonly file: string;
29
+ private readonly goal;
30
+ private start;
31
+ private readonly steps;
32
+ private readonly seenItems;
33
+ private readonly notDoneItems;
34
+ private readonly questionItems;
35
+ constructor(file: string, goal: string, start: string);
36
+ /** The title, which is also what the run reports as the name of the test */
37
+ get title(): string;
38
+ get count(): number;
39
+ /** The front matter the file has now (the app may change it while the plan is written); kept as it is */
40
+ private frontMatter;
41
+ private render;
42
+ /** Writes the file; refuses, and keeps the file as it was, when it would pass the size limit */
43
+ private save;
44
+ private add;
45
+ /** Appends a step and returns its number */
46
+ addStep(text: string, expect?: string): number;
47
+ /** Names the page the plan starts on, once the browser is there */
48
+ setStart(url: string): void;
49
+ seen(text: string): void;
50
+ notDone(text: string): void;
51
+ question(text: string): void;
52
+ /** Writes the first version of the file */
53
+ create(): void;
54
+ }
55
+ export type { PlanFile, ParsedPlan };
56
+ /** Starts a plan file: the headings are written at once. An existing file is an error unless `force` is set */
57
+ export declare function createPlan(input: {
58
+ goal: string;
59
+ start: string;
60
+ out: string;
61
+ force?: boolean;
62
+ }): PlanFile;
63
+ /**
64
+ * Reads a plan, the one `createPlan` wrote or one a person edited. A leading front matter block is ignored. Blank lines, a missing title, missing sections
65
+ * and `None.` placeholders are fine. Only the `## Steps` heading is needed.
66
+ */
67
+ export declare function parsePlan(text: string): ParsedPlan;
@@ -0,0 +1,229 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { projectDir } from "../../config/config.js";
4
+ import { maskText } from "../../config/mask.js";
5
+ import { splitFrontMatter } from "./front-matter.js";
6
+ /** A plan file may not be larger than this (bytes), for the planner and for `generate --plan` */
7
+ export const MAX_PLAN_BYTES = 32 * 1024;
8
+ const NONE = "None.";
9
+ const TITLE_LENGTH = 80;
10
+ /** One line, masked: a new line in a text must not be able to start a heading or a step */
11
+ function oneLine(text) {
12
+ return maskText(text).replace(/\s+/g, " ").trim();
13
+ }
14
+ function bullets(items) {
15
+ return items.length > 0
16
+ ? items.map((item) => `- ${item}`).join("\n")
17
+ : `- ${NONE}`;
18
+ }
19
+ /**
20
+ * A plan file being written. The whole file is written again after every call, so it is valid Markdown with its
21
+ * four headings whenever the run stops. Texts are masked and made one line long.
22
+ */
23
+ class PlanFile {
24
+ constructor(file, goal, start) {
25
+ this.file = file;
26
+ this.goal = goal;
27
+ this.start = start;
28
+ this.steps = [];
29
+ this.seenItems = [];
30
+ this.notDoneItems = [];
31
+ this.questionItems = [];
32
+ }
33
+ /** The title, which is also what the run reports as the name of the test */
34
+ get title() {
35
+ const first = this.goal.slice(0, TITLE_LENGTH).trim();
36
+ return first.length < this.goal.length ? `${first}…` : first;
37
+ }
38
+ get count() {
39
+ return this.steps.length;
40
+ }
41
+ /** The front matter the file has now (the app may change it while the plan is written); kept as it is */
42
+ frontMatter() {
43
+ try {
44
+ return splitFrontMatter(readFileSync(this.file, "utf8")).front;
45
+ }
46
+ catch {
47
+ return "";
48
+ }
49
+ }
50
+ render(front) {
51
+ const steps = this.steps.map((step, i) => `${i + 1}. ${step.text}${step.expect !== undefined ? `\n Expect: ${step.expect}` : ""}`);
52
+ return [
53
+ ...(front === "" ? [] : [front.replace(/\n$/, "")]),
54
+ `# Plan: ${this.title}`,
55
+ "",
56
+ `Goal: ${this.goal}`,
57
+ `Start: ${this.start}`,
58
+ "",
59
+ "## Steps",
60
+ "",
61
+ steps.length > 0 ? steps.join("\n") : `- ${NONE}`,
62
+ "",
63
+ "## Seen on the page",
64
+ "",
65
+ bullets(this.seenItems),
66
+ "",
67
+ "## Not done",
68
+ "",
69
+ bullets(this.notDoneItems),
70
+ "",
71
+ "## Open questions",
72
+ "",
73
+ bullets(this.questionItems),
74
+ "",
75
+ ].join("\n");
76
+ }
77
+ /** Writes the file; refuses, and keeps the file as it was, when it would pass the size limit */
78
+ save() {
79
+ const text = this.render(this.frontMatter());
80
+ if (Buffer.byteLength(text) > MAX_PLAN_BYTES)
81
+ throw new Error(`the plan file is full (${MAX_PLAN_BYTES / 1024} KB); finish with what it holds, or shorten the next entry`);
82
+ mkdirSync(path.dirname(this.file), { recursive: true });
83
+ writeFileSync(this.file, text);
84
+ }
85
+ add(list, text) {
86
+ list.push(oneLine(text));
87
+ try {
88
+ this.save();
89
+ }
90
+ catch (error) {
91
+ list.pop();
92
+ throw error;
93
+ }
94
+ }
95
+ /** Appends a step and returns its number */
96
+ addStep(text, expect) {
97
+ const step = { text: oneLine(text) };
98
+ const wanted = expect === undefined ? "" : oneLine(expect);
99
+ if (wanted !== "")
100
+ step.expect = wanted;
101
+ this.steps.push(step);
102
+ try {
103
+ this.save();
104
+ }
105
+ catch (error) {
106
+ this.steps.pop();
107
+ throw error;
108
+ }
109
+ return this.steps.length;
110
+ }
111
+ /** Names the page the plan starts on, once the browser is there */
112
+ setStart(url) {
113
+ this.start = oneLine(url);
114
+ this.save();
115
+ }
116
+ seen(text) {
117
+ this.add(this.seenItems, text);
118
+ }
119
+ notDone(text) {
120
+ this.add(this.notDoneItems, text);
121
+ }
122
+ question(text) {
123
+ this.add(this.questionItems, text);
124
+ }
125
+ /** Writes the first version of the file */
126
+ create() {
127
+ this.save();
128
+ }
129
+ }
130
+ /** Starts a plan file: the headings are written at once. An existing file is an error unless `force` is set */
131
+ export function createPlan(input) {
132
+ const file = path.resolve(projectDir(), input.out);
133
+ if (existsSync(file) && input.force !== true)
134
+ throw new Error(`createPlan(): ${file} already exists; pass force (--force) to replace it`);
135
+ const plan = new PlanFile(file, oneLine(input.goal), oneLine(input.start));
136
+ plan.create();
137
+ return plan;
138
+ }
139
+ const HEADING = /^##\s+(.*?)\s*$/;
140
+ const STEP = /^\s*\d+\.\s+(.*)$/;
141
+ const BULLET = /^\s*[-*]\s+(.*)$/;
142
+ const EXPECT = /^\s+Expect:\s*(.*)$/i;
143
+ const ANSWER = /^\s+Answer:\s*(.*)$/i;
144
+ const FIELD = /^(Goal|Start):\s*(.*)$/i;
145
+ function sectionOf(heading) {
146
+ const name = heading.toLowerCase();
147
+ if (name === "steps")
148
+ return "steps";
149
+ if (name.startsWith("seen"))
150
+ return "seen";
151
+ if (name === "not done")
152
+ return "notDone";
153
+ if (name.startsWith("open question"))
154
+ return "questions";
155
+ return "other";
156
+ }
157
+ /**
158
+ * Reads a plan, the one `createPlan` wrote or one a person edited. A leading front matter block is ignored. Blank lines, a missing title, missing sections
159
+ * and `None.` placeholders are fine. Only the `## Steps` heading is needed.
160
+ */
161
+ export function parsePlan(text) {
162
+ const plan = {
163
+ title: "",
164
+ goal: "",
165
+ start: "",
166
+ steps: [],
167
+ seen: [],
168
+ notDone: [],
169
+ questions: [],
170
+ answers: [],
171
+ };
172
+ let section;
173
+ let hasSteps = false;
174
+ let body;
175
+ try {
176
+ body = splitFrontMatter(text).body;
177
+ }
178
+ catch (error) {
179
+ throw new Error(`parsePlan(): ${error.message}`);
180
+ }
181
+ for (const line of body.split(/\r?\n/)) {
182
+ const heading = HEADING.exec(line);
183
+ if (heading) {
184
+ section = sectionOf(heading[1]);
185
+ if (section === "steps")
186
+ hasSteps = true;
187
+ continue;
188
+ }
189
+ if (line.trim() === "")
190
+ continue;
191
+ if (section === undefined) {
192
+ const title = /^#\s+(?:Plan:\s*)?(.*)$/i.exec(line);
193
+ const field = FIELD.exec(line);
194
+ if (title && plan.title === "")
195
+ plan.title = title[1].trim();
196
+ else if (field)
197
+ plan[field[1].toLowerCase()] = field[2].trim();
198
+ continue;
199
+ }
200
+ if (section === "other")
201
+ continue;
202
+ if (section === "steps") {
203
+ const last = plan.steps[plan.steps.length - 1];
204
+ const step = STEP.exec(line);
205
+ const expect = EXPECT.exec(line);
206
+ if (step)
207
+ plan.steps.push({ text: step[1].trim() });
208
+ else if (last && expect)
209
+ last.expect = expect[1].trim();
210
+ else if (last && !BULLET.test(line) && /^\s/.test(line))
211
+ last.text = `${last.text} ${line.trim()}`;
212
+ continue;
213
+ }
214
+ const answer = section === "questions" ? ANSWER.exec(line) : null;
215
+ const asked = plan.questions[plan.questions.length - 1];
216
+ if (answer) {
217
+ const text = answer[1].trim();
218
+ if (asked !== undefined && text !== "")
219
+ plan.answers.push({ question: asked, answer: text });
220
+ continue;
221
+ }
222
+ const item = BULLET.exec(line)?.[1]?.trim();
223
+ if (item !== undefined && item !== NONE)
224
+ plan[section].push(item);
225
+ }
226
+ if (!hasSteps)
227
+ throw new Error('parsePlan(): the plan has no "## Steps" heading');
228
+ return plan;
229
+ }
@@ -0,0 +1,5 @@
1
+ import type { RunKind } from "../run-kind.js";
2
+ import type { PlanOptions } from "../types.js";
3
+ import { PlanRecorder } from "./recorder.js";
4
+ /** The run of `runPlan`: steps go into a Markdown plan, and the page cannot change data */
5
+ export declare const planKind: RunKind<PlanOptions, PlanRecorder>;
@@ -0,0 +1,28 @@
1
+ import { maskText } from "../../config/mask.js";
2
+ import { installPlanGuard } from "./guard.js";
3
+ import { createPlan } from "./plan-file.js";
4
+ import { buildPlanPrompt } from "./prompt.js";
5
+ import { PlanRecorder } from "./recorder.js";
6
+ import { createPlanTools } from "./tools.js";
7
+ /** The run of `runPlan`: steps go into a Markdown plan, and the page cannot change data */
8
+ export const planKind = {
9
+ file: (options) => options.out,
10
+ sink: (options, { baseURL, emit }) => new PlanRecorder(createPlan({
11
+ goal: options.goal,
12
+ start: options.url ?? baseURL ?? "",
13
+ out: options.out,
14
+ ...(options.force !== undefined && { force: options.force }),
15
+ }), (n, text) => emit({ type: "plan-step", n, text })),
16
+ opened: (plan, url) => plan.setStart(url),
17
+ tools: (context, plan) => createPlanTools(context, plan),
18
+ prompt: (input) => buildPlanPrompt(input),
19
+ opening: (options, first) => `Goal: ${options.goal}\n\n${first}`,
20
+ secrets: () => [],
21
+ guard: (page, plan) => installPlanGuard(page, (what) => plan.block(what)),
22
+ person: ({ snapshots }) => ({
23
+ // The person's clicks write nothing: only the agent writes the plan. It is told the page may have changed
24
+ begin: () => Promise.resolve(),
25
+ end: async () => `The person used the browser while you were stopped. The page may have changed; this is the page now:\n\n${maskText((await snapshots.take()).text)}\n\nYour last call was not run. Call it again only if it is still needed.`,
26
+ dispose: () => Promise.resolve(),
27
+ }),
28
+ };
@@ -0,0 +1,8 @@
1
+ /** Lower-case ascii words of a goal joined by `-`, at most 60 characters; `plan` when nothing is left */
2
+ export declare function slugOf(goal: string): string;
3
+ /**
4
+ * The file `samurai plan` writes when no `--out` is given: `<plansDir>/<slug-of-goal>.plan.md` below the project
5
+ * folder, with `-2`, `-3`, ... added until the name is free, so an earlier plan is never replaced. The folder is
6
+ * created. Returns the path relative to the project folder, with `/` separators.
7
+ */
8
+ export declare function defaultPlanFile(goal: string): Promise<string>;
@@ -0,0 +1,46 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { loadRawConfig, projectDir } from "../../config/config.js";
4
+ import { DEFAULT_PLANS_DIR } from "../../config/defaults.js";
5
+ const SLUG_LENGTH = 60;
6
+ /** Lower-case ascii words of a goal joined by `-`, at most 60 characters; `plan` when nothing is left */
7
+ export function slugOf(goal) {
8
+ const slug = goal
9
+ .normalize("NFKD")
10
+ .replace(/[̀-ͯ]/g, "")
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, "-")
13
+ .slice(0, SLUG_LENGTH)
14
+ .replace(/^-+|-+$/g, "");
15
+ return slug === "" ? "plan" : slug;
16
+ }
17
+ /** The config's `plansDir`; a project without a `samurai.config.ts` (and no config given) uses the default */
18
+ async function plansDirOf() {
19
+ try {
20
+ return (await loadRawConfig()).plansDir ?? DEFAULT_PLANS_DIR;
21
+ }
22
+ catch (error) {
23
+ if (existsSync(path.join(projectDir(), "samurai.config.ts")))
24
+ throw error;
25
+ return DEFAULT_PLANS_DIR;
26
+ }
27
+ }
28
+ /**
29
+ * The file `samurai plan` writes when no `--out` is given: `<plansDir>/<slug-of-goal>.plan.md` below the project
30
+ * folder, with `-2`, `-3`, ... added until the name is free, so an earlier plan is never replaced. The folder is
31
+ * created. Returns the path relative to the project folder, with `/` separators.
32
+ */
33
+ export async function defaultPlanFile(goal) {
34
+ const dir = await plansDirOf();
35
+ const folder = path.resolve(projectDir(), dir);
36
+ mkdirSync(folder, { recursive: true });
37
+ const slug = slugOf(goal);
38
+ for (let n = 1;; n++) {
39
+ const name = `${n === 1 ? slug : `${slug}-${n}`}.plan.md`;
40
+ if (!existsSync(path.join(folder, name)))
41
+ return path
42
+ .relative(projectDir(), path.join(folder, name))
43
+ .split(path.sep)
44
+ .join("/");
45
+ }
46
+ }
@@ -0,0 +1,3 @@
1
+ import { type PromptInput } from "../prompt.js";
2
+ /** The planner's system prompt: explore and describe, never submit, write steps another agent can follow */
3
+ export declare function buildPlanPrompt(input: PromptInput): string;
@@ -0,0 +1,37 @@
1
+ import { DATA_RULES, instructionsSection, } from "../prompt.js";
2
+ /** The planner's system prompt: explore and describe, never submit, write steps another agent can follow */
3
+ export function buildPlanPrompt(input) {
4
+ const secrets = input.secrets.length > 0 ? input.secrets.join(", ") : "none are available";
5
+ const sections = [
6
+ `You plan browser tests for SAMURAI, a test framework over WebDriver BiDi. You drive a real browser with the tools below to find out how a page works, and you write a plan: numbered steps, in plain language, that another agent can follow without the page in front of it. That agent writes the test from your plan; you write no test code.`,
7
+ `## How to work
8
+ - State a short plan in one or two sentences first, then act.
9
+ - Call snapshot to read the page. It lists elements as [e12] role "name" with their state; use those references (e12) as the target of act. References are valid until the next snapshot, so take a new one after the page changes.
10
+ - Do one thing at a time: one tool call at a time, and wait for its answer before the next call.
11
+ - After you did something with act or navigate and saw that it worked, write it with step(text, expect?). Name elements the way snapshot shows them: role and accessible name, for example: Click "Create account" (button). Never use internal ids or references (e12) in a step; they change. In expect, say what the page showed afterwards.
12
+ - A step is written only after you did it on the live page. Do not write steps for things you did not try.
13
+ - Use seen for what a test writer needs to know that is not a step (the headings, the form fields, the validation messages), and notDone for everything you did not do or could not see.
14
+ - Never invent credentials, addresses or data. If you need something you do not have, call question with a short question (and options when there are a few); the answer is for your steps.
15
+ - When the plan covers the goal, call finish with a short summary. If you cannot go on, call finish and say why.`,
16
+ `## You never submit
17
+ - The page you drive is read-only: the framework blocks every request that is not a GET, so a form submit, a save, a delete or a payment does nothing, and the tool answer says blocked. This is so that planning cannot change real data.
18
+ - Fill forms and open pages to see what they do, but do not try to get around the block. Write the click that would submit as "Not done" (notDone) and say what you expect it to do, if the page tells you.
19
+ - A link or button that only reads is fine. Nothing here stops a page that changes data with a plain GET, so do not open pages whose names say they delete, log out or buy.`,
20
+ `## Tools
21
+ - snapshot: the page as text.
22
+ - navigate(url): open a URL or a path. Allowed without asking: ${input.origins.length > 0 ? input.origins.join(", ") : "none"}. Any other site asks the person and may be refused.
23
+ - act(kind, target, value?): click, fill, select, check, uncheck, hover, dblclick, clear, press. It writes nothing to the plan.
24
+ - step(text, expect?): write a step you did. Refused when every act or navigate so far is already a step.
25
+ - seen(text), notDone(text): notes for the test writer.
26
+ - question(question, options?): ask the person; it is kept in the plan.
27
+ - finish(summary).`,
28
+ `## Secrets and data
29
+ - You see the names of secrets, never their values: ${secrets}. Password fields show •••• in snapshots. Write a secret in a step as secrets.NAME (for example secrets.PASSWORD), never a value. You cannot type a secret: describe the step and write it as secrets.NAME.
30
+ - Environment variables are env.NAME. Never put a value you were told or saw for a password, token or key in the plan.`,
31
+ DATA_RULES,
32
+ ];
33
+ const project = instructionsSection(input.instructions);
34
+ if (project)
35
+ sections.push(project);
36
+ return sections.join("\n\n");
37
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The plan's open questions as a prompt section: the answered ones with their answers, then the unanswered ones,
3
+ * which the agent may `ask` the person. Empty when the plan has none or cannot be read.
4
+ */
5
+ export declare function questionsSection(planText: string): string;
@@ -0,0 +1,22 @@
1
+ import { parsePlan } from "./plan-file.js";
2
+ /**
3
+ * The plan's open questions as a prompt section: the answered ones with their answers, then the unanswered ones,
4
+ * which the agent may `ask` the person. Empty when the plan has none or cannot be read.
5
+ */
6
+ export function questionsSection(planText) {
7
+ let plan;
8
+ try {
9
+ plan = parsePlan(planText);
10
+ }
11
+ catch {
12
+ return "";
13
+ }
14
+ const answered = new Set(plan.answers.map((entry) => entry.question));
15
+ const open = plan.questions.filter((question) => !answered.has(question));
16
+ const parts = [];
17
+ if (plan.answers.length > 0)
18
+ parts.push(`Questions the person has answered (use the answers, do not ask again):\n${plan.answers.map((entry) => `- ${entry.question}\n Answer: ${entry.answer}`).join("\n")}`);
19
+ if (open.length > 0)
20
+ parts.push(`Questions still open (call ask if you need the answer, do not guess):\n${open.map((question) => `- ${question}`).join("\n")}`);
21
+ return parts.join("\n\n");
22
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Reads a plan for `generate --plan`: the text goes to the agent as it is, without a front matter block. A missing file, a file over 32 KB and a
3
+ * file without a `## Steps` heading are errors that name the file.
4
+ */
5
+ export declare function readPlan(file: string): string;