@tritard/waterbrother 0.6.6 → 0.7.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 +1 -0
- package/src/cli.js +46 -0
- package/src/episodic.js +279 -0
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -101,6 +101,7 @@ 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.reminders) ctxLines.push(`Scope reminders:\n${executionContext.reminders}`);
|
|
104
105
|
if (ctxLines.length > 0) base += `\n\nExecution context:\n${ctxLines.join("\n")}`;
|
|
105
106
|
}
|
|
106
107
|
if (!memoryBlock) return base;
|
package/src/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ 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";
|
|
22
23
|
|
|
23
24
|
const execFileAsync = promisify(execFile);
|
|
24
25
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -4033,6 +4034,21 @@ async function promptLoop(agent, session, context) {
|
|
|
4033
4034
|
if (!context.runtime.projectMemory) {
|
|
4034
4035
|
context.runtime.projectMemory = await readProjectMemory(context.cwd);
|
|
4035
4036
|
}
|
|
4037
|
+
|
|
4038
|
+
// Load episodic memory and combine with project memory
|
|
4039
|
+
try {
|
|
4040
|
+
const recentEpisodes = await loadRecentEpisodes({ cwd: context.cwd, limit: 5 });
|
|
4041
|
+
if (recentEpisodes.length > 0) {
|
|
4042
|
+
const episodicBlock = buildEpisodicMemoryBlock(recentEpisodes);
|
|
4043
|
+
context.runtime.episodicMemory = episodicBlock;
|
|
4044
|
+
const fullMemory = [
|
|
4045
|
+
context.runtime.projectMemory?.promptText || "",
|
|
4046
|
+
episodicBlock
|
|
4047
|
+
].filter(Boolean).join("\n\n");
|
|
4048
|
+
agent.setMemory(fullMemory);
|
|
4049
|
+
}
|
|
4050
|
+
} catch {}
|
|
4051
|
+
|
|
4036
4052
|
if (!Array.isArray(context.runtime.lastSearchResults)) {
|
|
4037
4053
|
context.runtime.lastSearchResults = [];
|
|
4038
4054
|
}
|
|
@@ -5002,6 +5018,13 @@ async function promptLoop(agent, session, context) {
|
|
|
5002
5018
|
continue;
|
|
5003
5019
|
}
|
|
5004
5020
|
try {
|
|
5021
|
+
// Save episodic memory before closing
|
|
5022
|
+
try {
|
|
5023
|
+
const receipt = context.runtime.lastReceipt || null;
|
|
5024
|
+
const episode = compressEpisode({ task, receipt });
|
|
5025
|
+
await saveEpisode({ cwd: context.cwd, episode });
|
|
5026
|
+
} catch {}
|
|
5027
|
+
|
|
5005
5028
|
await closeTask({ cwd: context.cwd, taskId: task.id });
|
|
5006
5029
|
clearTaskFromSession(currentSession);
|
|
5007
5030
|
agent.toolRuntime.setTaskContext(null);
|
|
@@ -5117,6 +5140,29 @@ async function promptLoop(agent, session, context) {
|
|
|
5117
5140
|
|
|
5118
5141
|
await maybeAutoCompactConversation({ agent, currentSession, context, pendingInput: buildPrompt });
|
|
5119
5142
|
|
|
5143
|
+
// Inject adaptive reminders from episodic memory
|
|
5144
|
+
try {
|
|
5145
|
+
const contractPaths = task.activeContract?.paths || [];
|
|
5146
|
+
const taskTags = [task.name, task.goal].filter(Boolean).join(" ").toLowerCase().split(/\s+/).filter((w) => w.length >= 3);
|
|
5147
|
+
const relevant = await findRelevantEpisodes({ cwd: context.cwd, filePatterns: contractPaths, tags: taskTags, limit: 3 });
|
|
5148
|
+
if (relevant.length > 0) {
|
|
5149
|
+
const reminders = buildReminderBlock({
|
|
5150
|
+
episodes: relevant,
|
|
5151
|
+
memoryText: context.runtime.projectMemory?.raw || "",
|
|
5152
|
+
contractPaths
|
|
5153
|
+
});
|
|
5154
|
+
if (reminders) {
|
|
5155
|
+
agent.setExecutionContext({
|
|
5156
|
+
taskName: task.name,
|
|
5157
|
+
chosenOption: task.chosenOption || null,
|
|
5158
|
+
contractSummary: task.activeContract?.summary || null,
|
|
5159
|
+
phase: "build",
|
|
5160
|
+
reminders
|
|
5161
|
+
});
|
|
5162
|
+
}
|
|
5163
|
+
}
|
|
5164
|
+
} catch {}
|
|
5165
|
+
|
|
5120
5166
|
const turnSummary = { startedAt: Date.now(), tools: [], events: [{ at: Date.now(), name: "thinking" }] };
|
|
5121
5167
|
const spinner = createProgressSpinner("building...");
|
|
5122
5168
|
let lastProgressAt = Date.now();
|
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
|
+
}
|