groove-dev 0.26.32 → 0.26.33

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.
@@ -484,6 +484,9 @@ export function createApi(app, daemon) {
484
484
  const agent = daemon.registry.get(req.params.id);
485
485
  if (!agent) return res.status(404).json({ error: 'Agent not found' });
486
486
 
487
+ // Record user feedback so the journalist can include it in future agent context
488
+ if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, message.trim());
489
+
487
490
  // Agent loop path — send message directly to the running loop
488
491
  if (daemon.processes.hasAgentLoop(req.params.id)) {
489
492
  const sent = await daemon.processes.sendMessage(req.params.id, message.trim());
@@ -85,6 +85,19 @@ export class Introducer {
85
85
  lines.push(` GROOVE will automatically wake the target agent and deliver your request.`);
86
86
  lines.push(`- Check AGENTS_REGISTRY.md for the latest team state.`);
87
87
 
88
+ // User feedback from previous tasks — critical context about what the user
89
+ // observed and what needs to change. Prevents agents from repeating mistakes.
90
+ const feedback = this.daemon.journalist?.getUserFeedback() || [];
91
+ if (feedback.length > 0) {
92
+ lines.push('');
93
+ lines.push(`## User Feedback (from previous tasks)`);
94
+ lines.push('');
95
+ lines.push(`The user sent these messages about previous agents' work. Pay close attention — these indicate issues that previous agents missed:`);
96
+ for (const fb of feedback.slice(-10)) {
97
+ lines.push(`- **${fb.agentName}** (${fb.role}): "${fb.message}"`);
98
+ }
99
+ }
100
+
88
101
  // Project files section — tell the new agent what exists and what to read
89
102
  if (allTeamFiles.length > 0) {
90
103
  lines.push('');
@@ -57,9 +57,18 @@ export class Journalist {
57
57
  if (this.synthesizing) return; // Don't overlap
58
58
 
59
59
  const agents = this.daemon.registry.getAll();
60
- const activeAgents = agents.filter((a) => a.status === 'running');
60
+ const running = agents.filter((a) => a.status === 'running');
61
+
62
+ // Include recently completed agents (last 30 min) so their work persists
63
+ // in the project map instead of vanishing the moment they finish
64
+ const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
65
+ const recentlyCompleted = agents.filter((a) =>
66
+ a.status === 'completed' && a.lastActivity && a.lastActivity > thirtyMinAgo
67
+ && !running.some((r) => r.id === a.id)
68
+ );
69
+ const activeAgents = [...running, ...recentlyCompleted];
61
70
 
62
- // Skip if no active agents
71
+ // Skip if no agents to synthesize
63
72
  if (activeAgents.length === 0) return;
64
73
 
65
74
  // Smart scheduling: skip if no new log output since last cycle
@@ -149,33 +158,75 @@ export class Journalist {
149
158
  }
150
159
 
151
160
  filterLog(rawLog, agent) {
152
- // Parse stream-json lines and extract meaningful events
161
+ // Parse stream-json lines and extract meaningful events.
162
+ // Focus on PROGRESS (writes, edits, commands with results) not EXPLORATION (reads, greps).
153
163
  const entries = [];
154
164
  const lines = rawLog.split('\n');
165
+ const toolResults = new Map(); // tool_use_id -> result text
155
166
 
167
+ // First pass: collect tool results so we can attach them to tool calls
168
+ for (const line of lines) {
169
+ if (!line.trim() || line.startsWith('[')) continue;
170
+ try {
171
+ const data = JSON.parse(line);
172
+ if (data.type === 'user' && data.message?.content) {
173
+ const content = Array.isArray(data.message.content) ? data.message.content : [];
174
+ for (const block of content) {
175
+ if (block.type === 'tool_result' && block.tool_use_id) {
176
+ const text = typeof block.content === 'string' ? block.content
177
+ : Array.isArray(block.content) ? block.content.map((c) => c.text || '').join('').slice(0, 300)
178
+ : '';
179
+ if (text) toolResults.set(block.tool_use_id, text);
180
+ }
181
+ }
182
+ }
183
+ } catch { /* skip */ }
184
+ }
185
+
186
+ // Second pass: extract meaningful events
156
187
  for (const line of lines) {
157
- // Skip empty lines and GROOVE spawn headers
158
188
  if (!line.trim() || line.startsWith('[')) continue;
159
189
 
160
190
  try {
161
191
  const data = JSON.parse(line);
162
192
 
163
- // Tool use (file edits, commands)
193
+ // Tool use only keep WRITES, EDITS, and COMMANDS (progress, not exploration)
164
194
  if (data.type === 'assistant' && data.message?.content) {
165
195
  const content = data.message.content;
166
196
  if (Array.isArray(content)) {
167
197
  for (const block of content) {
168
198
  if (block.type === 'tool_use') {
169
- entries.push({
199
+ const tool = block.name;
200
+
201
+ // Skip exploration tools — reads/searches are noise for synthesis
202
+ if (tool === 'Read' || tool === 'Glob' || tool === 'Grep') continue;
203
+
204
+ const entry = {
170
205
  type: 'tool',
171
- tool: block.name,
172
- input: this.summarizeToolInput(block.name, block.input),
206
+ tool,
207
+ input: this.summarizeToolInput(tool, block.input),
173
208
  timestamp: data.timestamp,
174
- });
209
+ };
210
+
211
+ // Capture what was actually changed for Edit operations
212
+ if (tool === 'Edit' && block.input) {
213
+ const old = (block.input.old_string || '').slice(0, 150);
214
+ const nw = (block.input.new_string || '').slice(0, 150);
215
+ if (old && nw) entry.diff = `"${old}" → "${nw}"`;
216
+ }
217
+
218
+ // Capture Bash command output (exit code, first line of result)
219
+ if (tool === 'Bash' && block.id) {
220
+ const result = toolResults.get(block.id);
221
+ if (result) entry.output = result.slice(0, 200);
222
+ }
223
+
224
+ entries.push(entry);
175
225
  } else if (block.type === 'text' && block.text) {
176
- // Only keep substantial text (decisions, explanations)
226
+ // Only keep substantial reasoning (decisions, conclusions)
227
+ // Short fragments like "Let me check..." are noise
177
228
  const text = block.text.trim();
178
- if (text.length > 50) {
229
+ if (text.length > 200) {
179
230
  entries.push({ type: 'thinking', text: text.slice(0, 2000), timestamp: data.timestamp });
180
231
  }
181
232
  }
@@ -194,10 +245,8 @@ export class Journalist {
194
245
  });
195
246
  }
196
247
  } catch {
197
- // Not JSON — check for plain text signals
198
- if (line.includes('Error') || line.includes('error')) {
199
- entries.push({ type: 'error', text: line.trim().slice(0, 200) });
200
- }
248
+ // Skip non-JSON lines entirely transient stderr like ENOENT
249
+ // causes degradation when agents try to "fix" phantom errors
201
250
  }
202
251
  }
203
252
 
@@ -208,15 +257,11 @@ export class Journalist {
208
257
  if (!input) return '';
209
258
  switch (toolName) {
210
259
  case 'Write':
211
- case 'Edit':
212
260
  return input.file_path || input.path || '';
213
- case 'Read':
261
+ case 'Edit':
214
262
  return input.file_path || input.path || '';
215
263
  case 'Bash':
216
- return (input.command || '').slice(0, 100);
217
- case 'Glob':
218
- case 'Grep':
219
- return input.pattern || '';
264
+ return (input.command || '').slice(0, 150);
220
265
  default:
221
266
  return JSON.stringify(input).slice(0, 100);
222
267
  }
@@ -295,14 +340,16 @@ export class Journalist {
295
340
 
296
341
  formatEntry(entry) {
297
342
  switch (entry.type) {
298
- case 'tool':
299
- return `- [tool] ${entry.tool}: ${entry.input}`;
343
+ case 'tool': {
344
+ let line = `- [${entry.tool}] ${entry.input}`;
345
+ if (entry.diff) line += ` — changed: ${entry.diff}`;
346
+ if (entry.output) line += ` → ${entry.output.split('\n')[0]}`;
347
+ return line;
348
+ }
300
349
  case 'thinking':
301
- return `- [thought] ${entry.text.slice(0, 200)}`;
350
+ return `- [thought] ${entry.text.slice(0, 300)}`;
302
351
  case 'result':
303
- return `- [result] ${entry.text.slice(0, 200)} (${entry.turns} turns, ${entry.duration}ms)`;
304
- case 'error':
305
- return `- [error] ${entry.text}`;
352
+ return `- [result] ${entry.text.slice(0, 300)} (${entry.turns} turns, ${entry.duration}ms)`;
306
353
  default:
307
354
  return `- [${entry.type}] ${entry.text || ''}`;
308
355
  }
@@ -515,8 +562,25 @@ export class Journalist {
515
562
  existing = readFileSync(path, 'utf8').replace(/^# GROOVE Decisions Log[\s\S]*?\n\n/, '');
516
563
  }
517
564
 
565
+ // Deduplicate — skip if the new decisions are substantially similar to the most recent entry.
566
+ // Extract the last cycle's decisions text for comparison.
567
+ if (existing) {
568
+ const lastEntry = existing.match(/^## Cycle[\s\S]*?\n\n([\s\S]*?)(?=\n---|\n\n\*Auto|\n## Cycle|$)/);
569
+ if (lastEntry) {
570
+ const lastText = lastEntry[1].trim().toLowerCase().replace(/\s+/g, ' ');
571
+ const newText = decisions.trim().toLowerCase().replace(/\s+/g, ' ');
572
+ // If >60% of the new text appears in the last entry, skip (near-duplicate)
573
+ const newWords = newText.split(' ').filter((w) => w.length > 3);
574
+ const overlap = newWords.filter((w) => lastText.includes(w)).length;
575
+ if (newWords.length > 0 && overlap / newWords.length > 0.6) return;
576
+ }
577
+ }
578
+
518
579
  const entry = `## Cycle ${this.cycleCount} — ${new Date().toISOString()}\n\n${decisions}\n\n`;
519
- writeFileSync(path, header + entry + existing);
580
+
581
+ // Keep last 20 entries max to prevent unbounded growth
582
+ const entries = (entry + existing).split(/(?=^## Cycle)/m).slice(0, 20).join('');
583
+ writeFileSync(path, header + entries);
520
584
  }
521
585
 
522
586
  writeAgentSessionLogs(agents, filteredLogs) {
@@ -724,6 +788,60 @@ export class Journalist {
724
788
  }
725
789
  }
726
790
 
791
+ // --- User Feedback Tracking ---
792
+
793
+ /**
794
+ * Record user feedback/messages sent to agents.
795
+ * This gets included in the project map and handoff briefs so future
796
+ * agents know what the user said about previous work.
797
+ */
798
+ recordUserFeedback(agent, message) {
799
+ if (!this._userFeedback) this._loadFeedback();
800
+ if (!this._userFeedback[agent.id]) this._userFeedback[agent.id] = [];
801
+ this._userFeedback[agent.id].push({
802
+ agentName: agent.name,
803
+ role: agent.role,
804
+ message: message.slice(0, 500),
805
+ timestamp: new Date().toISOString(),
806
+ });
807
+ // Keep last 20 per agent
808
+ if (this._userFeedback[agent.id].length > 20) {
809
+ this._userFeedback[agent.id] = this._userFeedback[agent.id].slice(-20);
810
+ }
811
+ this._saveFeedback();
812
+ }
813
+
814
+ _loadFeedback() {
815
+ const p = resolve(this.daemon.grooveDir, 'user-feedback.json');
816
+ try {
817
+ if (existsSync(p)) this._userFeedback = JSON.parse(readFileSync(p, 'utf8'));
818
+ else this._userFeedback = {};
819
+ } catch { this._userFeedback = {}; }
820
+ }
821
+
822
+ _saveFeedback() {
823
+ try {
824
+ writeFileSync(resolve(this.daemon.grooveDir, 'user-feedback.json'), JSON.stringify(this._userFeedback, null, 2));
825
+ } catch { /* non-fatal */ }
826
+ }
827
+
828
+ /**
829
+ * Get user feedback for an agent (or all agents in a team).
830
+ * Used by the introducer to give agents context about previous attempts.
831
+ */
832
+ getUserFeedback(agentOrTeamId) {
833
+ if (!this._userFeedback) this._loadFeedback();
834
+ if (!this._userFeedback) return [];
835
+ // Check if it's an agent ID
836
+ if (this._userFeedback[agentOrTeamId]) return this._userFeedback[agentOrTeamId];
837
+ // Check by team — collect feedback for all agents in the team
838
+ const all = [];
839
+ for (const [, entries] of Object.entries(this._userFeedback)) {
840
+ all.push(...entries);
841
+ }
842
+ return all.sort((a, b) => a.timestamp.localeCompare(b.timestamp)).slice(-30);
843
+ }
844
+
727
845
  // --- Accessors ---
728
846
 
729
847
  getLastSynthesis() {
@@ -52,13 +52,13 @@ describe('Journalist', () => {
52
52
  assert.equal(entries[0].input, 'src/api/auth.js');
53
53
  });
54
54
 
55
- it('should extract errors from logs', () => {
55
+ it('should skip non-JSON lines (transient errors are noise)', () => {
56
56
  const { daemon } = createMockDaemon();
57
57
  const journalist = new Journalist(daemon);
58
58
 
59
+ // Non-JSON error lines are dropped to prevent context degradation
59
60
  const entries = journalist.filterLog('Error: something broke\nTypeError: undefined is not a function', {});
60
- assert.equal(entries.length, 2);
61
- assert.equal(entries[0].type, 'error');
61
+ assert.equal(entries.length, 0);
62
62
  });
63
63
 
64
64
  it('should extract result events', () => {