@tritard/waterbrother 0.6.6 → 0.8.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/package.json +1 -1
- package/src/agent.js +2 -0
- package/src/cli.js +54 -0
- package/src/config.js +1 -0
- package/src/episodic.js +279 -0
- package/src/planner.js +128 -0
- package/src/workflow.js +40 -3
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -101,6 +101,8 @@ function buildSystemPrompt(profile, experienceMode = "standard", autonomyMode =
|
|
|
101
101
|
if (executionContext.chosenOption) ctxLines.push(`Chosen approach: ${executionContext.chosenOption}`);
|
|
102
102
|
if (executionContext.contractSummary) ctxLines.push(`Contract: ${executionContext.contractSummary}`);
|
|
103
103
|
if (executionContext.phase) ctxLines.push(`Phase: ${executionContext.phase}. Execute the chosen approach — do not re-decide.`);
|
|
104
|
+
if (executionContext.plan) ctxLines.push(`Execution plan:\n${executionContext.plan}`);
|
|
105
|
+
if (executionContext.reminders) ctxLines.push(`Scope reminders:\n${executionContext.reminders}`);
|
|
104
106
|
if (ctxLines.length > 0) base += `\n\nExecution context:\n${ctxLines.join("\n")}`;
|
|
105
107
|
}
|
|
106
108
|
if (!memoryBlock) return base;
|
package/src/cli.js
CHANGED
|
@@ -19,6 +19,8 @@ import { runDecisionPass, runInventPass, formatDecisionForDisplay, formatDecisio
|
|
|
19
19
|
import { runBuildWorkflow, startFeatureTask, runChallengeWorkflow } from "./workflow.js";
|
|
20
20
|
import { createPanelRenderer, buildPanelState } from "./panel.js";
|
|
21
21
|
import { deriveTaskNameFromPrompt, nextActionsForState, routeNaturalInput } from "./router.js";
|
|
22
|
+
import { compressEpisode, saveEpisode, loadRecentEpisodes, findRelevantEpisodes, buildEpisodicMemoryBlock, buildReminderBlock } from "./episodic.js";
|
|
23
|
+
import { formatPlanForDisplay } from "./planner.js";
|
|
22
24
|
|
|
23
25
|
const execFileAsync = promisify(execFile);
|
|
24
26
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -4033,6 +4035,21 @@ async function promptLoop(agent, session, context) {
|
|
|
4033
4035
|
if (!context.runtime.projectMemory) {
|
|
4034
4036
|
context.runtime.projectMemory = await readProjectMemory(context.cwd);
|
|
4035
4037
|
}
|
|
4038
|
+
|
|
4039
|
+
// Load episodic memory and combine with project memory
|
|
4040
|
+
try {
|
|
4041
|
+
const recentEpisodes = await loadRecentEpisodes({ cwd: context.cwd, limit: 5 });
|
|
4042
|
+
if (recentEpisodes.length > 0) {
|
|
4043
|
+
const episodicBlock = buildEpisodicMemoryBlock(recentEpisodes);
|
|
4044
|
+
context.runtime.episodicMemory = episodicBlock;
|
|
4045
|
+
const fullMemory = [
|
|
4046
|
+
context.runtime.projectMemory?.promptText || "",
|
|
4047
|
+
episodicBlock
|
|
4048
|
+
].filter(Boolean).join("\n\n");
|
|
4049
|
+
agent.setMemory(fullMemory);
|
|
4050
|
+
}
|
|
4051
|
+
} catch {}
|
|
4052
|
+
|
|
4036
4053
|
if (!Array.isArray(context.runtime.lastSearchResults)) {
|
|
4037
4054
|
context.runtime.lastSearchResults = [];
|
|
4038
4055
|
}
|
|
@@ -5002,6 +5019,13 @@ async function promptLoop(agent, session, context) {
|
|
|
5002
5019
|
continue;
|
|
5003
5020
|
}
|
|
5004
5021
|
try {
|
|
5022
|
+
// Save episodic memory before closing
|
|
5023
|
+
try {
|
|
5024
|
+
const receipt = context.runtime.lastReceipt || null;
|
|
5025
|
+
const episode = compressEpisode({ task, receipt });
|
|
5026
|
+
await saveEpisode({ cwd: context.cwd, episode });
|
|
5027
|
+
} catch {}
|
|
5028
|
+
|
|
5005
5029
|
await closeTask({ cwd: context.cwd, taskId: task.id });
|
|
5006
5030
|
clearTaskFromSession(currentSession);
|
|
5007
5031
|
agent.toolRuntime.setTaskContext(null);
|
|
@@ -5117,6 +5141,29 @@ async function promptLoop(agent, session, context) {
|
|
|
5117
5141
|
|
|
5118
5142
|
await maybeAutoCompactConversation({ agent, currentSession, context, pendingInput: buildPrompt });
|
|
5119
5143
|
|
|
5144
|
+
// Inject adaptive reminders from episodic memory
|
|
5145
|
+
try {
|
|
5146
|
+
const contractPaths = task.activeContract?.paths || [];
|
|
5147
|
+
const taskTags = [task.name, task.goal].filter(Boolean).join(" ").toLowerCase().split(/\s+/).filter((w) => w.length >= 3);
|
|
5148
|
+
const relevant = await findRelevantEpisodes({ cwd: context.cwd, filePatterns: contractPaths, tags: taskTags, limit: 3 });
|
|
5149
|
+
if (relevant.length > 0) {
|
|
5150
|
+
const reminders = buildReminderBlock({
|
|
5151
|
+
episodes: relevant,
|
|
5152
|
+
memoryText: context.runtime.projectMemory?.raw || "",
|
|
5153
|
+
contractPaths
|
|
5154
|
+
});
|
|
5155
|
+
if (reminders) {
|
|
5156
|
+
agent.setExecutionContext({
|
|
5157
|
+
taskName: task.name,
|
|
5158
|
+
chosenOption: task.chosenOption || null,
|
|
5159
|
+
contractSummary: task.activeContract?.summary || null,
|
|
5160
|
+
phase: "build",
|
|
5161
|
+
reminders
|
|
5162
|
+
});
|
|
5163
|
+
}
|
|
5164
|
+
}
|
|
5165
|
+
} catch {}
|
|
5166
|
+
|
|
5120
5167
|
const turnSummary = { startedAt: Date.now(), tools: [], events: [{ at: Date.now(), name: "thinking" }] };
|
|
5121
5168
|
const spinner = createProgressSpinner("building...");
|
|
5122
5169
|
let lastProgressAt = Date.now();
|
|
@@ -5174,6 +5221,13 @@ async function promptLoop(agent, session, context) {
|
|
|
5174
5221
|
markProgress();
|
|
5175
5222
|
currentSession.runState = { state, detail: "", updatedAt: new Date().toISOString() };
|
|
5176
5223
|
},
|
|
5224
|
+
onPlan(plan) {
|
|
5225
|
+
markProgress();
|
|
5226
|
+
spinner.stop();
|
|
5227
|
+
console.log(formatPlanForDisplay(plan));
|
|
5228
|
+
printRailTransition("executing");
|
|
5229
|
+
spinner.setLabel("executing plan...");
|
|
5230
|
+
},
|
|
5177
5231
|
onAssistantDelta() { markProgress(); },
|
|
5178
5232
|
onToolStart(toolCall) {
|
|
5179
5233
|
markProgress();
|
package/src/config.js
CHANGED
|
@@ -197,6 +197,7 @@ export function resolveRuntimeConfig(config, overrides = {}) {
|
|
|
197
197
|
? Boolean(config.panelEnabled)
|
|
198
198
|
: true,
|
|
199
199
|
decisionModel: overrides.decisionModel || config.decisionModel || "",
|
|
200
|
+
plannerModel: overrides.plannerModel || config.plannerModel || "",
|
|
200
201
|
taskDefaults: normalizeTaskDefaults(
|
|
201
202
|
overrides.taskDefaults !== undefined ? overrides.taskDefaults : config.taskDefaults
|
|
202
203
|
),
|
package/src/episodic.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
const STOP_WORDS = new Set(["the", "a", "an", "in", "to", "for", "of", "on", "at", "by", "and", "or", "is", "it", "this", "that", "with", "from", "as", "be", "was", "are"]);
|
|
6
|
+
const MAX_INDEX_ENTRIES = 200;
|
|
7
|
+
const MAX_EPISODIC_PROMPT_CHARS = 2000;
|
|
8
|
+
const MAX_REMINDER_CHARS = 1500;
|
|
9
|
+
const MAX_FILES_PER_EPISODE = 50;
|
|
10
|
+
|
|
11
|
+
function memoryDir(cwd) {
|
|
12
|
+
return path.join(cwd, ".waterbrother", "memory");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function indexPath(cwd) {
|
|
16
|
+
return path.join(memoryDir(cwd), "index.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function episodePath(cwd, id) {
|
|
20
|
+
return path.join(memoryDir(cwd), `${id}.json`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function slugify(name) {
|
|
24
|
+
return String(name || "")
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
27
|
+
.replace(/^-|-$/g, "")
|
|
28
|
+
.slice(0, 60);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeEpisodeId(taskName) {
|
|
32
|
+
const slug = slugify(taskName);
|
|
33
|
+
const rand = crypto.randomBytes(3).toString("hex");
|
|
34
|
+
return slug ? `ep_${slug}-${rand}` : `ep_${rand}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function deriveTags(text) {
|
|
38
|
+
return String(text || "")
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.split(/[\s/\\._\-:,;!?'"()[\]{}]+/)
|
|
41
|
+
.filter((w) => w.length >= 3 && !STOP_WORDS.has(w))
|
|
42
|
+
.filter((v, i, a) => a.indexOf(v) === i)
|
|
43
|
+
.slice(0, 20);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deriveFilePatterns(files) {
|
|
47
|
+
const dirs = new Set();
|
|
48
|
+
for (const f of files) {
|
|
49
|
+
const dir = path.dirname(f).replace(/\\/g, "/");
|
|
50
|
+
if (dir && dir !== ".") dirs.add(`${dir}/**`);
|
|
51
|
+
}
|
|
52
|
+
return [...dirs];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readIndex(cwd) {
|
|
56
|
+
try {
|
|
57
|
+
const raw = await fs.readFile(indexPath(cwd), "utf8");
|
|
58
|
+
const parsed = JSON.parse(raw);
|
|
59
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function writeIndex(cwd, index) {
|
|
66
|
+
await fs.mkdir(memoryDir(cwd), { recursive: true });
|
|
67
|
+
await fs.writeFile(indexPath(cwd), `${JSON.stringify(index, null, 2)}\n`, "utf8");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function compressEpisode({ task, receipt }) {
|
|
71
|
+
const id = makeEpisodeId(task.name || task.id);
|
|
72
|
+
const filesChanged = (receipt?.changedFiles || []).slice(0, MAX_FILES_PER_EPISODE);
|
|
73
|
+
const filePatterns = deriveFilePatterns(filesChanged);
|
|
74
|
+
|
|
75
|
+
// Key facts
|
|
76
|
+
const keyFacts = [];
|
|
77
|
+
if (task.goal) keyFacts.push(`Goal: ${task.goal}`);
|
|
78
|
+
if (task.chosenOption) {
|
|
79
|
+
const option = task.lastDecision?.options?.find((o) => o.id === task.chosenOption);
|
|
80
|
+
keyFacts.push(`Chose: ${task.chosenOption}${option?.title ? ` — ${option.title}` : ""}`);
|
|
81
|
+
}
|
|
82
|
+
if (receipt?.diffStat) {
|
|
83
|
+
const lastLine = receipt.diffStat.split("\n").pop()?.trim();
|
|
84
|
+
if (lastLine) keyFacts.push(`Diff: ${lastLine}`);
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(receipt?.verification)) {
|
|
87
|
+
for (const v of receipt.verification) {
|
|
88
|
+
keyFacts.push(`Verify ${v.command}: ${v.ok ? "passed" : "FAILED"}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Warnings and concerns
|
|
93
|
+
const warnings = [];
|
|
94
|
+
const sentinelConcerns = [];
|
|
95
|
+
if (receipt?.review?.concerns?.length) {
|
|
96
|
+
sentinelConcerns.push(...receipt.review.concerns);
|
|
97
|
+
}
|
|
98
|
+
if (receipt?.challenge?.concerns?.length) {
|
|
99
|
+
for (const c of receipt.challenge.concerns) {
|
|
100
|
+
if (!sentinelConcerns.includes(c)) sentinelConcerns.push(c);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (Array.isArray(receipt?.verification)) {
|
|
104
|
+
for (const v of receipt.verification) {
|
|
105
|
+
if (!v.ok) warnings.push(`Verification failed: ${v.command}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Outcome
|
|
110
|
+
let outcome = "closed-empty";
|
|
111
|
+
if (receipt?.mutated) {
|
|
112
|
+
outcome = task.accepted ? "accepted" : "closed-unaccepted";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Tags from task name, goal, and file paths
|
|
116
|
+
const tagSource = [task.name, task.goal, ...filesChanged].join(" ");
|
|
117
|
+
const tags = deriveTags(tagSource);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
id,
|
|
121
|
+
taskId: task.id,
|
|
122
|
+
taskName: task.name || "",
|
|
123
|
+
closedAt: new Date().toISOString(),
|
|
124
|
+
goal: task.goal || "",
|
|
125
|
+
chosenOption: task.chosenOption || null,
|
|
126
|
+
outcome,
|
|
127
|
+
filesChanged,
|
|
128
|
+
filePatterns,
|
|
129
|
+
keyFacts: keyFacts.slice(0, 8),
|
|
130
|
+
warnings: warnings.slice(0, 5),
|
|
131
|
+
sentinelConcerns: sentinelConcerns.slice(0, 5),
|
|
132
|
+
tags
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function saveEpisode({ cwd, episode }) {
|
|
137
|
+
await fs.mkdir(memoryDir(cwd), { recursive: true });
|
|
138
|
+
await fs.writeFile(episodePath(cwd, episode.id), `${JSON.stringify(episode, null, 2)}\n`, "utf8");
|
|
139
|
+
|
|
140
|
+
// Update index
|
|
141
|
+
const index = await readIndex(cwd);
|
|
142
|
+
const entry = {
|
|
143
|
+
id: episode.id,
|
|
144
|
+
taskName: episode.taskName,
|
|
145
|
+
closedAt: episode.closedAt,
|
|
146
|
+
tags: episode.tags,
|
|
147
|
+
filePatterns: episode.filePatterns,
|
|
148
|
+
outcome: episode.outcome
|
|
149
|
+
};
|
|
150
|
+
index.unshift(entry);
|
|
151
|
+
if (index.length > MAX_INDEX_ENTRIES) index.length = MAX_INDEX_ENTRIES;
|
|
152
|
+
await writeIndex(cwd, index);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function loadRecentEpisodes({ cwd, limit = 5 }) {
|
|
156
|
+
const index = await readIndex(cwd);
|
|
157
|
+
const recent = index.slice(0, limit);
|
|
158
|
+
const episodes = [];
|
|
159
|
+
for (const entry of recent) {
|
|
160
|
+
try {
|
|
161
|
+
const raw = await fs.readFile(episodePath(cwd, entry.id), "utf8");
|
|
162
|
+
episodes.push(JSON.parse(raw));
|
|
163
|
+
} catch {
|
|
164
|
+
// Skip missing/corrupt episodes
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return episodes;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function findRelevantEpisodes({ cwd, filePatterns = [], tags = [], limit = 5 }) {
|
|
171
|
+
const index = await readIndex(cwd);
|
|
172
|
+
if (index.length === 0) return [];
|
|
173
|
+
|
|
174
|
+
const queryDirs = filePatterns.map((p) => p.replace(/\/?\*\*$/, "").replace(/\\/g, "/"));
|
|
175
|
+
const queryTags = new Set(tags.map((t) => t.toLowerCase()));
|
|
176
|
+
|
|
177
|
+
const scored = [];
|
|
178
|
+
for (const entry of index) {
|
|
179
|
+
let score = 0;
|
|
180
|
+
|
|
181
|
+
// File pattern overlap
|
|
182
|
+
if (queryDirs.length > 0 && Array.isArray(entry.filePatterns)) {
|
|
183
|
+
for (const ep of entry.filePatterns) {
|
|
184
|
+
const epDir = ep.replace(/\/?\*\*$/, "").replace(/\\/g, "/");
|
|
185
|
+
for (const qd of queryDirs) {
|
|
186
|
+
if (epDir.startsWith(qd) || qd.startsWith(epDir)) {
|
|
187
|
+
score += 3;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Tag overlap
|
|
195
|
+
if (queryTags.size > 0 && Array.isArray(entry.tags)) {
|
|
196
|
+
for (const t of entry.tags) {
|
|
197
|
+
if (queryTags.has(t)) score += 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (score > 0) scored.push({ entry, score });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
scored.sort((a, b) => b.score - a.score || new Date(b.entry.closedAt) - new Date(a.entry.closedAt));
|
|
205
|
+
const top = scored.slice(0, limit);
|
|
206
|
+
|
|
207
|
+
const episodes = [];
|
|
208
|
+
for (const { entry } of top) {
|
|
209
|
+
try {
|
|
210
|
+
const raw = await fs.readFile(episodePath(cwd, entry.id), "utf8");
|
|
211
|
+
episodes.push(JSON.parse(raw));
|
|
212
|
+
} catch {}
|
|
213
|
+
}
|
|
214
|
+
return episodes;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function buildEpisodicMemoryBlock(episodes) {
|
|
218
|
+
if (!episodes || episodes.length === 0) return "";
|
|
219
|
+
const lines = ["Recent work in this project:"];
|
|
220
|
+
let chars = lines[0].length;
|
|
221
|
+
|
|
222
|
+
for (const ep of episodes) {
|
|
223
|
+
const date = ep.closedAt ? ep.closedAt.slice(0, 10) : "unknown";
|
|
224
|
+
const parts = [`[${date}] "${ep.taskName}" (${ep.outcome})`];
|
|
225
|
+
if (ep.keyFacts?.length > 0) parts.push(ep.keyFacts[0]);
|
|
226
|
+
if (ep.warnings?.length > 0) parts.push(`Warning: ${ep.warnings[0]}`);
|
|
227
|
+
if (ep.sentinelConcerns?.length > 0) parts.push(`Sentinel: ${ep.sentinelConcerns[0]}`);
|
|
228
|
+
const line = `- ${parts.join(". ")}`;
|
|
229
|
+
if (chars + line.length + 1 > MAX_EPISODIC_PROMPT_CHARS) break;
|
|
230
|
+
lines.push(line);
|
|
231
|
+
chars += line.length + 1;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return lines.join("\n");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function buildReminderBlock({ episodes = [], memoryText = "", contractPaths = [] }) {
|
|
238
|
+
const contractDirs = contractPaths.map((p) => p.replace(/\/?\*\*$/, "").replace(/\\/g, "/").toLowerCase());
|
|
239
|
+
const reminders = [];
|
|
240
|
+
let chars = 0;
|
|
241
|
+
|
|
242
|
+
// From episodes: warnings and concerns for overlapping scopes
|
|
243
|
+
for (const ep of episodes) {
|
|
244
|
+
const epDirs = (ep.filePatterns || []).map((p) => p.replace(/\/?\*\*$/, "").replace(/\\/g, "/").toLowerCase());
|
|
245
|
+
const overlaps = contractDirs.length === 0 || epDirs.some((ed) => contractDirs.some((cd) => ed.startsWith(cd) || cd.startsWith(ed)));
|
|
246
|
+
if (!overlaps) continue;
|
|
247
|
+
|
|
248
|
+
for (const w of (ep.warnings || [])) {
|
|
249
|
+
const line = `[from "${ep.taskName}"]: ${w}`;
|
|
250
|
+
if (chars + line.length > MAX_REMINDER_CHARS) break;
|
|
251
|
+
reminders.push(line);
|
|
252
|
+
chars += line.length;
|
|
253
|
+
}
|
|
254
|
+
for (const c of (ep.sentinelConcerns || [])) {
|
|
255
|
+
const line = `[from "${ep.taskName}"]: Sentinel flagged: ${c}`;
|
|
256
|
+
if (chars + line.length > MAX_REMINDER_CHARS) break;
|
|
257
|
+
reminders.push(line);
|
|
258
|
+
chars += line.length;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// From WATERBROTHER.md: lines mentioning paths in contract scope
|
|
263
|
+
if (memoryText && contractDirs.length > 0) {
|
|
264
|
+
const memLines = memoryText.split("\n");
|
|
265
|
+
for (const ml of memLines) {
|
|
266
|
+
const lower = ml.toLowerCase();
|
|
267
|
+
const relevant = contractDirs.some((cd) => lower.includes(cd.split("/").pop()));
|
|
268
|
+
if (relevant && ml.trim().length > 5) {
|
|
269
|
+
const line = `[from WATERBROTHER.md]: ${ml.trim()}`;
|
|
270
|
+
if (chars + line.length > MAX_REMINDER_CHARS) break;
|
|
271
|
+
reminders.push(line);
|
|
272
|
+
chars += line.length;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (reminders.length === 0) return "";
|
|
278
|
+
return `Reminders for this scope:\n${reminders.join("\n")}`;
|
|
279
|
+
}
|
package/src/planner.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createJsonCompletion } from "./grok-client.js";
|
|
2
|
+
|
|
3
|
+
const PLANNER_SCHEMA = `Respond with ONLY a JSON object matching this schema:
|
|
4
|
+
{
|
|
5
|
+
"summary": "one-line summary of what will be done",
|
|
6
|
+
"steps": [
|
|
7
|
+
{
|
|
8
|
+
"action": "read|edit|create|delete|run",
|
|
9
|
+
"target": "file path or shell command",
|
|
10
|
+
"description": "what this step does and why"
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"risks": ["potential issues to watch for"],
|
|
14
|
+
"estimate": "rough scope — e.g. ~30 lines, 2 files"
|
|
15
|
+
}`;
|
|
16
|
+
|
|
17
|
+
const PLANNER_SYSTEM_PROMPT = `You are a senior engineer creating an implementation plan. You do NOT write code — you plan.
|
|
18
|
+
|
|
19
|
+
Your job is to:
|
|
20
|
+
1. Decompose the task into concrete, ordered steps
|
|
21
|
+
2. Specify exactly which files to read, edit, create, or delete
|
|
22
|
+
3. Specify which shell commands to run (tests, builds, etc.)
|
|
23
|
+
4. Flag risks the executor should watch for
|
|
24
|
+
5. Keep the plan tight — no unnecessary steps
|
|
25
|
+
|
|
26
|
+
Rules:
|
|
27
|
+
- Each step must have an action (read, edit, create, delete, run), a target (file path or command), and a description
|
|
28
|
+
- Order matters — read before edit, edit before test
|
|
29
|
+
- Include verification steps (run tests, lint, etc.)
|
|
30
|
+
- Keep descriptions to one sentence
|
|
31
|
+
- Do not include markdown, code fences, or text outside the JSON
|
|
32
|
+
|
|
33
|
+
${PLANNER_SCHEMA}`;
|
|
34
|
+
|
|
35
|
+
function buildPlannerPrompt({ task, goal, contract, memory, episodicContext }) {
|
|
36
|
+
const parts = [];
|
|
37
|
+
if (task?.name) parts.push(`Task: ${task.name}`);
|
|
38
|
+
if (goal) parts.push(`Goal: ${goal}`);
|
|
39
|
+
if (task?.chosenOption) {
|
|
40
|
+
const option = task.lastDecision?.options?.find((o) => o.id === task.chosenOption);
|
|
41
|
+
if (option) parts.push(`Chosen approach: ${option.title} — ${option.summary}`);
|
|
42
|
+
}
|
|
43
|
+
if (contract?.paths?.length) parts.push(`Contract scope: ${contract.paths.join(", ")}`);
|
|
44
|
+
if (contract?.commands?.length) parts.push(`Verification commands: ${contract.commands.join(", ")}`);
|
|
45
|
+
if (memory) parts.push(`Project context:\n${memory}`);
|
|
46
|
+
if (episodicContext) parts.push(`Recent history:\n${episodicContext}`);
|
|
47
|
+
parts.push("Create a step-by-step implementation plan as JSON.");
|
|
48
|
+
return parts.join("\n\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizePlan(plan) {
|
|
52
|
+
if (!plan || typeof plan !== "object") return null;
|
|
53
|
+
const steps = Array.isArray(plan.steps) ? plan.steps : [];
|
|
54
|
+
return {
|
|
55
|
+
summary: String(plan.summary || "").trim(),
|
|
56
|
+
steps: steps.map((s, i) => ({
|
|
57
|
+
number: i + 1,
|
|
58
|
+
action: ["read", "edit", "create", "delete", "run"].includes(String(s.action || "").trim())
|
|
59
|
+
? String(s.action).trim()
|
|
60
|
+
: "read",
|
|
61
|
+
target: String(s.target || "").trim(),
|
|
62
|
+
description: String(s.description || "").trim()
|
|
63
|
+
})),
|
|
64
|
+
risks: Array.isArray(plan.risks) ? plan.risks.map(String) : [],
|
|
65
|
+
estimate: String(plan.estimate || "").trim()
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runPlannerPass({ apiKey, baseUrl, model, task, goal, contract, memory, episodicContext, signal }) {
|
|
70
|
+
if (!goal && !task?.name) throw new Error("goal or task name required for planner");
|
|
71
|
+
|
|
72
|
+
const messages = [
|
|
73
|
+
{ role: "system", content: PLANNER_SYSTEM_PROMPT },
|
|
74
|
+
{ role: "user", content: buildPlannerPrompt({ task, goal, contract, memory, episodicContext }) }
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const completion = await createJsonCompletion({
|
|
78
|
+
apiKey,
|
|
79
|
+
baseUrl,
|
|
80
|
+
model,
|
|
81
|
+
messages,
|
|
82
|
+
temperature: 0.2,
|
|
83
|
+
signal
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const plan = normalizePlan(completion.json);
|
|
87
|
+
if (!plan || plan.steps.length === 0) {
|
|
88
|
+
throw new Error("Planner returned no steps");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { plan, usage: completion.usage || null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function formatPlanForDisplay(plan) {
|
|
95
|
+
if (!plan) return "No plan available.";
|
|
96
|
+
const lines = [];
|
|
97
|
+
if (plan.summary) lines.push(plan.summary);
|
|
98
|
+
lines.push("");
|
|
99
|
+
for (const step of plan.steps) {
|
|
100
|
+
const icon = step.action === "read" ? "📖" : step.action === "edit" ? "✏️" : step.action === "create" ? "📄" : step.action === "delete" ? "🗑️" : step.action === "run" ? "▶" : "•";
|
|
101
|
+
lines.push(` ${step.number}. ${icon} ${step.action} ${step.target}`);
|
|
102
|
+
if (step.description) lines.push(` ${step.description}`);
|
|
103
|
+
}
|
|
104
|
+
if (plan.risks.length > 0) {
|
|
105
|
+
lines.push("");
|
|
106
|
+
for (const risk of plan.risks) {
|
|
107
|
+
lines.push(` ⚠ ${risk}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (plan.estimate) lines.push(`\n ${plan.estimate}`);
|
|
111
|
+
return lines.join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function formatPlanForExecutor(plan) {
|
|
115
|
+
if (!plan || !plan.steps?.length) return "";
|
|
116
|
+
const lines = ["Implementation plan — follow these steps in order:"];
|
|
117
|
+
for (const step of plan.steps) {
|
|
118
|
+
lines.push(`${step.number}. [${step.action}] ${step.target} — ${step.description}`);
|
|
119
|
+
}
|
|
120
|
+
if (plan.risks.length > 0) {
|
|
121
|
+
lines.push("\nRisks to watch for:");
|
|
122
|
+
for (const risk of plan.risks) {
|
|
123
|
+
lines.push(`- ${risk}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
lines.push("\nExecute each step. Do not skip steps or improvise beyond the plan.");
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
package/src/workflow.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createTask, findTaskByName, saveTask, slugify } from "./task-store.js";
|
|
2
2
|
import { computeImpactMap, summarizeImpactMap } from "./impact.js";
|
|
3
3
|
import { reviewTurn, challengeReceipt } from "./reviewer.js";
|
|
4
|
+
import { runPlannerPass, formatPlanForExecutor, formatPlanForDisplay } from "./planner.js";
|
|
4
5
|
|
|
5
6
|
export async function runBuildWorkflow({
|
|
6
7
|
agent,
|
|
@@ -12,13 +13,44 @@ export async function runBuildWorkflow({
|
|
|
12
13
|
if (!task) throw new Error("no active task");
|
|
13
14
|
if (!promptText) throw new Error("build requires a prompt");
|
|
14
15
|
|
|
16
|
+
// Planner/Executor split: if plannerModel is configured, run planner first
|
|
17
|
+
const plannerModel = context.runtime?.plannerModel;
|
|
18
|
+
let planBlock = "";
|
|
19
|
+
if (plannerModel) {
|
|
20
|
+
try {
|
|
21
|
+
if (handlers.onStateChange) handlers.onStateChange("planning");
|
|
22
|
+
const { plan } = await runPlannerPass({
|
|
23
|
+
apiKey: context.runtime.apiKey,
|
|
24
|
+
baseUrl: context.runtime.baseUrl,
|
|
25
|
+
model: plannerModel,
|
|
26
|
+
task,
|
|
27
|
+
goal: promptText,
|
|
28
|
+
contract: task.activeContract || null,
|
|
29
|
+
memory: context.runtime.projectMemory?.promptText || "",
|
|
30
|
+
episodicContext: context.runtime.episodicMemory || "",
|
|
31
|
+
signal: handlers.signal
|
|
32
|
+
});
|
|
33
|
+
task.lastPlan = plan;
|
|
34
|
+
planBlock = formatPlanForExecutor(plan);
|
|
35
|
+
// Show the plan to the user
|
|
36
|
+
if (handlers.onPlan) {
|
|
37
|
+
handlers.onPlan(plan);
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// Planner failure is non-fatal — fall back to unplanned execution
|
|
41
|
+
planBlock = "";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
// Inject execution context into agent system prompt
|
|
16
|
-
|
|
46
|
+
const executionCtx = {
|
|
17
47
|
taskName: task.name,
|
|
18
48
|
chosenOption: task.chosenOption || null,
|
|
19
49
|
contractSummary: task.activeContract?.summary || null,
|
|
20
50
|
phase: "build"
|
|
21
|
-
}
|
|
51
|
+
};
|
|
52
|
+
if (planBlock) executionCtx.plan = planBlock;
|
|
53
|
+
agent.setExecutionContext(executionCtx);
|
|
22
54
|
|
|
23
55
|
// Pre-seed contract if task has one
|
|
24
56
|
if (task.activeContract) {
|
|
@@ -35,8 +67,13 @@ export async function runBuildWorkflow({
|
|
|
35
67
|
});
|
|
36
68
|
}
|
|
37
69
|
|
|
70
|
+
// Build prompt: prepend plan if available
|
|
71
|
+
const executorPrompt = planBlock
|
|
72
|
+
? `${planBlock}\n\n---\n\nNow execute: ${promptText}`
|
|
73
|
+
: promptText;
|
|
74
|
+
|
|
38
75
|
// Run the turn
|
|
39
|
-
const response = await agent.runBuildTurn(
|
|
76
|
+
const response = await agent.runBuildTurn(executorPrompt, handlers);
|
|
40
77
|
|
|
41
78
|
// Complete turn and get receipt
|
|
42
79
|
const receipt = await agent.toolRuntime.completeTurn({ signal: handlers.signal });
|