@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.6.6",
3
+ "version": "0.7.0",
4
4
  "description": "Waterbrother: Grok-powered coding CLI with local tools, sessions, operator modes, and approval controls",
5
5
  "type": "module",
6
6
  "bin": {
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();
@@ -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
+ }