groove-dev 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/CLAUDE.md +197 -0
- package/LICENSE +40 -0
- package/README.md +115 -0
- package/docs/GUI_DESIGN_SPEC.md +402 -0
- package/favicon.png +0 -0
- package/groove-logo-short.png +0 -0
- package/groove-logo.png +0 -0
- package/package.json +70 -0
- package/packages/cli/bin/groove.js +98 -0
- package/packages/cli/package.json +15 -0
- package/packages/cli/src/client.js +25 -0
- package/packages/cli/src/commands/agents.js +38 -0
- package/packages/cli/src/commands/approve.js +50 -0
- package/packages/cli/src/commands/config.js +35 -0
- package/packages/cli/src/commands/kill.js +15 -0
- package/packages/cli/src/commands/nuke.js +19 -0
- package/packages/cli/src/commands/providers.js +40 -0
- package/packages/cli/src/commands/rotate.js +16 -0
- package/packages/cli/src/commands/spawn.js +91 -0
- package/packages/cli/src/commands/start.js +31 -0
- package/packages/cli/src/commands/status.js +38 -0
- package/packages/cli/src/commands/stop.js +15 -0
- package/packages/cli/src/commands/team.js +77 -0
- package/packages/daemon/package.json +18 -0
- package/packages/daemon/src/adaptive.js +237 -0
- package/packages/daemon/src/api.js +533 -0
- package/packages/daemon/src/classifier.js +126 -0
- package/packages/daemon/src/credentials.js +121 -0
- package/packages/daemon/src/firstrun.js +93 -0
- package/packages/daemon/src/index.js +208 -0
- package/packages/daemon/src/introducer.js +238 -0
- package/packages/daemon/src/journalist.js +600 -0
- package/packages/daemon/src/lockmanager.js +58 -0
- package/packages/daemon/src/pm.js +108 -0
- package/packages/daemon/src/process.js +361 -0
- package/packages/daemon/src/providers/aider.js +72 -0
- package/packages/daemon/src/providers/base.js +38 -0
- package/packages/daemon/src/providers/claude-code.js +167 -0
- package/packages/daemon/src/providers/codex.js +68 -0
- package/packages/daemon/src/providers/gemini.js +62 -0
- package/packages/daemon/src/providers/index.js +38 -0
- package/packages/daemon/src/providers/ollama.js +94 -0
- package/packages/daemon/src/registry.js +89 -0
- package/packages/daemon/src/rotator.js +185 -0
- package/packages/daemon/src/router.js +132 -0
- package/packages/daemon/src/state.js +34 -0
- package/packages/daemon/src/supervisor.js +178 -0
- package/packages/daemon/src/teams.js +203 -0
- package/packages/daemon/src/terminal/base.js +27 -0
- package/packages/daemon/src/terminal/generic.js +27 -0
- package/packages/daemon/src/terminal/tmux.js +64 -0
- package/packages/daemon/src/tokentracker.js +124 -0
- package/packages/daemon/src/validate.js +122 -0
- package/packages/daemon/templates/api-builder.json +18 -0
- package/packages/daemon/templates/fullstack.json +18 -0
- package/packages/daemon/templates/monorepo.json +24 -0
- package/packages/gui/dist/assets/index-BO95Rm1F.js +73 -0
- package/packages/gui/dist/assets/index-CPzm9ZE9.css +1 -0
- package/packages/gui/dist/favicon.png +0 -0
- package/packages/gui/dist/groove-logo-short.png +0 -0
- package/packages/gui/dist/groove-logo.png +0 -0
- package/packages/gui/dist/index.html +13 -0
- package/packages/gui/index.html +12 -0
- package/packages/gui/package.json +22 -0
- package/packages/gui/public/favicon.png +0 -0
- package/packages/gui/public/groove-logo-short.png +0 -0
- package/packages/gui/public/groove-logo.png +0 -0
- package/packages/gui/src/App.jsx +215 -0
- package/packages/gui/src/components/AgentActions.jsx +347 -0
- package/packages/gui/src/components/AgentChat.jsx +479 -0
- package/packages/gui/src/components/AgentNode.jsx +117 -0
- package/packages/gui/src/components/AgentPanel.jsx +115 -0
- package/packages/gui/src/components/AgentStats.jsx +333 -0
- package/packages/gui/src/components/ApprovalQueue.jsx +156 -0
- package/packages/gui/src/components/EmptyState.jsx +100 -0
- package/packages/gui/src/components/SpawnPanel.jsx +515 -0
- package/packages/gui/src/components/TeamSelector.jsx +162 -0
- package/packages/gui/src/main.jsx +9 -0
- package/packages/gui/src/stores/groove.js +247 -0
- package/packages/gui/src/theme.css +67 -0
- package/packages/gui/src/views/AgentTree.jsx +148 -0
- package/packages/gui/src/views/CommandCenter.jsx +620 -0
- package/packages/gui/src/views/JournalistFeed.jsx +149 -0
- package/packages/gui/vite.config.js +19 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
// GROOVE — The Journalist (Context Synthesis Engine)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { execFile } from 'child_process';
|
|
7
|
+
import { getProvider } from './providers/index.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_INTERVAL = 120_000; // 2 minutes
|
|
10
|
+
const MAX_LOG_CHARS = 40_000; // ~10k tokens budget for synthesis input
|
|
11
|
+
|
|
12
|
+
export class Journalist {
|
|
13
|
+
constructor(daemon) {
|
|
14
|
+
this.daemon = daemon;
|
|
15
|
+
this.interval = null;
|
|
16
|
+
this.cycleCount = 0;
|
|
17
|
+
this.lastCycleAt = null;
|
|
18
|
+
this.lastLogSizes = {}; // agentId -> last known log byte size
|
|
19
|
+
this.synthesizing = false;
|
|
20
|
+
this.lastSynthesis = null; // last synthesis result text
|
|
21
|
+
this.history = []; // recent synthesis summaries
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
start(intervalMs = DEFAULT_INTERVAL) {
|
|
25
|
+
if (this.interval) return;
|
|
26
|
+
|
|
27
|
+
// Run first cycle immediately, then on interval
|
|
28
|
+
this.cycle();
|
|
29
|
+
this.interval = setInterval(() => this.cycle(), intervalMs);
|
|
30
|
+
console.log(` Journalist started (every ${intervalMs / 1000}s)`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
stop() {
|
|
34
|
+
if (this.interval) {
|
|
35
|
+
clearInterval(this.interval);
|
|
36
|
+
this.interval = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async cycle() {
|
|
41
|
+
if (this.synthesizing) return; // Don't overlap
|
|
42
|
+
|
|
43
|
+
const agents = this.daemon.registry.getAll();
|
|
44
|
+
const activeAgents = agents.filter((a) => a.status === 'running');
|
|
45
|
+
|
|
46
|
+
// Skip if no active agents
|
|
47
|
+
if (activeAgents.length === 0) return;
|
|
48
|
+
|
|
49
|
+
// Smart scheduling: skip if no new log output since last cycle
|
|
50
|
+
if (this.lastCycleAt && !this.hasNewActivity(activeAgents)) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.synthesizing = true;
|
|
55
|
+
this.cycleCount++;
|
|
56
|
+
this.lastCycleAt = Date.now();
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// 1. Collect and filter recent logs
|
|
60
|
+
const filteredLogs = this.collectFilteredLogs(activeAgents);
|
|
61
|
+
|
|
62
|
+
// 2. Run AI synthesis (or fallback to structural summary)
|
|
63
|
+
const synthesis = await this.synthesize(activeAgents, filteredLogs);
|
|
64
|
+
|
|
65
|
+
// 3. Write outputs
|
|
66
|
+
this.writeProjectMap(synthesis.projectMap);
|
|
67
|
+
this.writeDecisionsLog(synthesis.decisions);
|
|
68
|
+
this.writeAgentSessionLogs(activeAgents, filteredLogs);
|
|
69
|
+
|
|
70
|
+
this.lastSynthesis = synthesis;
|
|
71
|
+
this.history.push({
|
|
72
|
+
cycle: this.cycleCount,
|
|
73
|
+
timestamp: new Date().toISOString(),
|
|
74
|
+
agentCount: activeAgents.length,
|
|
75
|
+
summary: synthesis.summary,
|
|
76
|
+
});
|
|
77
|
+
// Keep last 50 entries
|
|
78
|
+
if (this.history.length > 50) this.history = this.history.slice(-50);
|
|
79
|
+
|
|
80
|
+
this.daemon.broadcast({
|
|
81
|
+
type: 'journalist:cycle',
|
|
82
|
+
data: {
|
|
83
|
+
cycle: this.cycleCount,
|
|
84
|
+
agentCount: activeAgents.length,
|
|
85
|
+
summary: synthesis.summary,
|
|
86
|
+
projectMap: synthesis.projectMap,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error(' Journalist cycle failed:', err.message);
|
|
91
|
+
} finally {
|
|
92
|
+
this.synthesizing = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Log collection & filtering ---
|
|
97
|
+
|
|
98
|
+
hasNewActivity(agents) {
|
|
99
|
+
for (const agent of agents) {
|
|
100
|
+
const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
|
|
101
|
+
if (!existsSync(logPath)) continue;
|
|
102
|
+
try {
|
|
103
|
+
const size = statSync(logPath).size;
|
|
104
|
+
if (size !== (this.lastLogSizes[agent.id] || 0)) return true;
|
|
105
|
+
} catch { /* ignore */ }
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
collectFilteredLogs(agents) {
|
|
111
|
+
const result = {};
|
|
112
|
+
|
|
113
|
+
for (const agent of agents) {
|
|
114
|
+
const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
|
|
115
|
+
if (!existsSync(logPath)) {
|
|
116
|
+
result[agent.id] = { agent, entries: [] };
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const content = readFileSync(logPath, 'utf8');
|
|
122
|
+
const size = Buffer.byteLength(content);
|
|
123
|
+
this.lastLogSizes[agent.id] = size;
|
|
124
|
+
|
|
125
|
+
const entries = this.filterLog(content, agent);
|
|
126
|
+
result[agent.id] = { agent, entries };
|
|
127
|
+
} catch {
|
|
128
|
+
result[agent.id] = { agent, entries: [] };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
filterLog(rawLog, agent) {
|
|
136
|
+
// Parse stream-json lines and extract meaningful events
|
|
137
|
+
const entries = [];
|
|
138
|
+
const lines = rawLog.split('\n');
|
|
139
|
+
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
// Skip empty lines and GROOVE spawn headers
|
|
142
|
+
if (!line.trim() || line.startsWith('[')) continue;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const data = JSON.parse(line);
|
|
146
|
+
|
|
147
|
+
// Tool use (file edits, commands)
|
|
148
|
+
if (data.type === 'assistant' && data.message?.content) {
|
|
149
|
+
const content = data.message.content;
|
|
150
|
+
if (Array.isArray(content)) {
|
|
151
|
+
for (const block of content) {
|
|
152
|
+
if (block.type === 'tool_use') {
|
|
153
|
+
entries.push({
|
|
154
|
+
type: 'tool',
|
|
155
|
+
tool: block.name,
|
|
156
|
+
input: this.summarizeToolInput(block.name, block.input),
|
|
157
|
+
timestamp: data.timestamp,
|
|
158
|
+
});
|
|
159
|
+
} else if (block.type === 'text' && block.text) {
|
|
160
|
+
// Only keep substantial text (decisions, explanations)
|
|
161
|
+
const text = block.text.trim();
|
|
162
|
+
if (text.length > 50) {
|
|
163
|
+
entries.push({ type: 'thinking', text: text.slice(0, 2000), timestamp: data.timestamp });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Result / completion
|
|
171
|
+
if (data.type === 'result') {
|
|
172
|
+
entries.push({
|
|
173
|
+
type: 'result',
|
|
174
|
+
text: typeof data.result === 'string' ? data.result.slice(0, 3000) : '',
|
|
175
|
+
cost: data.total_cost_usd,
|
|
176
|
+
turns: data.num_turns,
|
|
177
|
+
duration: data.duration_ms,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// Not JSON — check for plain text signals
|
|
182
|
+
if (line.includes('Error') || line.includes('error')) {
|
|
183
|
+
entries.push({ type: 'error', text: line.trim().slice(0, 200) });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return entries;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
summarizeToolInput(toolName, input) {
|
|
192
|
+
if (!input) return '';
|
|
193
|
+
switch (toolName) {
|
|
194
|
+
case 'Write':
|
|
195
|
+
case 'Edit':
|
|
196
|
+
return input.file_path || input.path || '';
|
|
197
|
+
case 'Read':
|
|
198
|
+
return input.file_path || input.path || '';
|
|
199
|
+
case 'Bash':
|
|
200
|
+
return (input.command || '').slice(0, 100);
|
|
201
|
+
case 'Glob':
|
|
202
|
+
case 'Grep':
|
|
203
|
+
return input.pattern || '';
|
|
204
|
+
default:
|
|
205
|
+
return JSON.stringify(input).slice(0, 100);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- AI Synthesis ---
|
|
210
|
+
|
|
211
|
+
async synthesize(agents, filteredLogs) {
|
|
212
|
+
// Build synthesis prompt from filtered logs
|
|
213
|
+
const prompt = this.buildSynthesisPrompt(agents, filteredLogs);
|
|
214
|
+
|
|
215
|
+
// Try headless AI call for synthesis
|
|
216
|
+
try {
|
|
217
|
+
const result = await this.callHeadless(prompt);
|
|
218
|
+
return this.parseSynthesisResult(result, agents);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
// Fallback to structural summary (no AI call)
|
|
221
|
+
console.error(' Journalist AI synthesis unavailable:', err.message);
|
|
222
|
+
return this.buildStructuralSummary(agents, filteredLogs);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
buildSynthesisPrompt(agents, filteredLogs) {
|
|
227
|
+
const parts = [
|
|
228
|
+
'You are The Journalist for GROOVE, an agent orchestration system.',
|
|
229
|
+
'Analyze the following agent activity logs and produce a synthesis.',
|
|
230
|
+
'',
|
|
231
|
+
'Output EXACTLY this format (use these exact headers):',
|
|
232
|
+
'',
|
|
233
|
+
'## Project Map',
|
|
234
|
+
'(What has been built/changed, organized by area. Be specific about files and functions.)',
|
|
235
|
+
'',
|
|
236
|
+
'## Decisions',
|
|
237
|
+
'(Key architectural and implementation decisions agents made, with reasoning.)',
|
|
238
|
+
'',
|
|
239
|
+
'## Summary',
|
|
240
|
+
'(2-3 sentence overview of current progress.)',
|
|
241
|
+
'',
|
|
242
|
+
'---',
|
|
243
|
+
'',
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
// Add agent logs, budget-trimmed
|
|
247
|
+
let totalChars = 0;
|
|
248
|
+
for (const [agentId, { agent, entries }] of Object.entries(filteredLogs)) {
|
|
249
|
+
if (entries.length === 0) continue;
|
|
250
|
+
|
|
251
|
+
parts.push(`### Agent: ${agent.name} (${agent.role}, scope: ${agent.scope?.join(', ') || 'unrestricted'})`);
|
|
252
|
+
|
|
253
|
+
for (const entry of entries.slice(-100)) { // Last 100 events per agent
|
|
254
|
+
const line = this.formatEntry(entry);
|
|
255
|
+
if (totalChars + line.length > MAX_LOG_CHARS) break;
|
|
256
|
+
parts.push(line);
|
|
257
|
+
totalChars += line.length;
|
|
258
|
+
}
|
|
259
|
+
parts.push('');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return parts.join('\n');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
formatEntry(entry) {
|
|
266
|
+
switch (entry.type) {
|
|
267
|
+
case 'tool':
|
|
268
|
+
return `- [tool] ${entry.tool}: ${entry.input}`;
|
|
269
|
+
case 'thinking':
|
|
270
|
+
return `- [thought] ${entry.text.slice(0, 200)}`;
|
|
271
|
+
case 'result':
|
|
272
|
+
return `- [result] ${entry.text.slice(0, 200)} (${entry.turns} turns, ${entry.duration}ms)`;
|
|
273
|
+
case 'error':
|
|
274
|
+
return `- [error] ${entry.text}`;
|
|
275
|
+
default:
|
|
276
|
+
return `- [${entry.type}] ${entry.text || ''}`;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async callHeadless(prompt) {
|
|
281
|
+
const provider = getProvider('claude-code');
|
|
282
|
+
if (!provider || !provider.constructor.isInstalled()) {
|
|
283
|
+
throw new Error('No provider available for synthesis');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Use headless mode with Haiku — cheapest model, good enough for synthesis
|
|
287
|
+
const { command, args, env } = provider.buildHeadlessCommand(prompt, 'claude-haiku-4-5-20251001');
|
|
288
|
+
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
const proc = execFile(command, args, {
|
|
291
|
+
env: { ...process.env, ...env },
|
|
292
|
+
cwd: this.daemon.projectDir,
|
|
293
|
+
maxBuffer: 1024 * 1024 * 5,
|
|
294
|
+
timeout: 60_000,
|
|
295
|
+
}, (err, stdout, stderr) => {
|
|
296
|
+
if (err) return reject(err);
|
|
297
|
+
|
|
298
|
+
// Parse stream-json output to extract the result text
|
|
299
|
+
const lines = stdout.split('\n');
|
|
300
|
+
for (const line of lines) {
|
|
301
|
+
try {
|
|
302
|
+
const data = JSON.parse(line);
|
|
303
|
+
if (data.type === 'result' && data.result) {
|
|
304
|
+
return resolve(data.result);
|
|
305
|
+
}
|
|
306
|
+
} catch { /* skip */ }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Fallback: return raw stdout
|
|
310
|
+
resolve(stdout);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
parseSynthesisResult(text, agents) {
|
|
316
|
+
// Parse the structured output from AI
|
|
317
|
+
const sections = {
|
|
318
|
+
projectMap: '',
|
|
319
|
+
decisions: '',
|
|
320
|
+
summary: '',
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const mapMatch = text.match(/## Project Map\n([\s\S]*?)(?=## Decisions|## Summary|$)/);
|
|
324
|
+
const decMatch = text.match(/## Decisions\n([\s\S]*?)(?=## Project Map|## Summary|$)/);
|
|
325
|
+
const sumMatch = text.match(/## Summary\n([\s\S]*?)(?=## Project Map|## Decisions|$)/);
|
|
326
|
+
|
|
327
|
+
sections.projectMap = this.buildProjectMapMd(
|
|
328
|
+
agents,
|
|
329
|
+
mapMatch ? mapMatch[1].trim() : 'No changes detected.'
|
|
330
|
+
);
|
|
331
|
+
sections.decisions = decMatch ? decMatch[1].trim() : '';
|
|
332
|
+
sections.summary = sumMatch ? sumMatch[1].trim() : 'Synthesis cycle complete.';
|
|
333
|
+
|
|
334
|
+
return sections;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- Fallback structural summary (no AI) ---
|
|
338
|
+
|
|
339
|
+
buildStructuralSummary(agents, filteredLogs) {
|
|
340
|
+
const mapParts = [];
|
|
341
|
+
const decisions = [];
|
|
342
|
+
const summaryParts = [];
|
|
343
|
+
|
|
344
|
+
for (const [agentId, { agent, entries }] of Object.entries(filteredLogs)) {
|
|
345
|
+
const tools = entries.filter((e) => e.type === 'tool');
|
|
346
|
+
const errors = entries.filter((e) => e.type === 'error');
|
|
347
|
+
const writes = tools.filter((t) => t.tool === 'Write' || t.tool === 'Edit');
|
|
348
|
+
const reads = tools.filter((t) => t.tool === 'Read');
|
|
349
|
+
|
|
350
|
+
mapParts.push(`### ${agent.name} (${agent.role})`);
|
|
351
|
+
if (writes.length > 0) {
|
|
352
|
+
mapParts.push(`Files modified:`);
|
|
353
|
+
const files = [...new Set(writes.map((w) => w.input).filter(Boolean))];
|
|
354
|
+
files.forEach((f) => mapParts.push(`- ${f}`));
|
|
355
|
+
}
|
|
356
|
+
if (reads.length > 0) {
|
|
357
|
+
const files = [...new Set(reads.map((r) => r.input).filter(Boolean))];
|
|
358
|
+
mapParts.push(`Files read: ${files.slice(0, 10).join(', ')}`);
|
|
359
|
+
}
|
|
360
|
+
if (errors.length > 0) {
|
|
361
|
+
mapParts.push(`Errors: ${errors.length}`);
|
|
362
|
+
}
|
|
363
|
+
mapParts.push(`Activity: ${entries.length} events, ${tools.length} tool calls`);
|
|
364
|
+
mapParts.push('');
|
|
365
|
+
|
|
366
|
+
summaryParts.push(`${agent.name}: ${tools.length} tool calls, ${writes.length} writes`);
|
|
367
|
+
|
|
368
|
+
// Extract decisions from thinking entries
|
|
369
|
+
const thoughts = entries.filter((e) => e.type === 'thinking');
|
|
370
|
+
for (const t of thoughts.slice(-3)) {
|
|
371
|
+
if (t.text.length > 100) {
|
|
372
|
+
decisions.push(`- **${agent.name}**: ${t.text.slice(0, 200)}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
projectMap: this.buildProjectMapMd(agents, mapParts.join('\n')),
|
|
379
|
+
decisions: decisions.join('\n'),
|
|
380
|
+
summary: summaryParts.join('. ') || 'No activity.',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
buildProjectMapMd(agents, content) {
|
|
385
|
+
return [
|
|
386
|
+
`# GROOVE Project Map`,
|
|
387
|
+
``,
|
|
388
|
+
`*Auto-generated by The Journalist. Cycle ${this.cycleCount}. Updated: ${new Date().toISOString()}*`,
|
|
389
|
+
``,
|
|
390
|
+
`## Active Agents`,
|
|
391
|
+
``,
|
|
392
|
+
...agents.map((a) =>
|
|
393
|
+
`- **${a.name}** (${a.role}) — ${a.provider} — ${a.scope?.join(', ') || 'unrestricted'} — tokens: ${a.tokensUsed}`
|
|
394
|
+
),
|
|
395
|
+
``,
|
|
396
|
+
`## Activity`,
|
|
397
|
+
``,
|
|
398
|
+
content,
|
|
399
|
+
].join('\n');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// --- File outputs ---
|
|
403
|
+
|
|
404
|
+
writeProjectMap(content) {
|
|
405
|
+
writeFileSync(resolve(this.daemon.projectDir, 'GROOVE_PROJECT_MAP.md'), content);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
writeDecisionsLog(decisions) {
|
|
409
|
+
if (!decisions) return;
|
|
410
|
+
|
|
411
|
+
const path = resolve(this.daemon.projectDir, 'GROOVE_DECISIONS.md');
|
|
412
|
+
const header = `# GROOVE Decisions Log\n\n*Auto-generated by The Journalist.*\n\n`;
|
|
413
|
+
|
|
414
|
+
let existing = '';
|
|
415
|
+
if (existsSync(path)) {
|
|
416
|
+
existing = readFileSync(path, 'utf8').replace(/^# GROOVE Decisions Log[\s\S]*?\n\n/, '');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const entry = `## Cycle ${this.cycleCount} — ${new Date().toISOString()}\n\n${decisions}\n\n`;
|
|
420
|
+
writeFileSync(path, header + entry + existing);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
writeAgentSessionLogs(agents, filteredLogs) {
|
|
424
|
+
const logsDir = resolve(this.daemon.projectDir, 'GROOVE_AGENT_LOGS');
|
|
425
|
+
mkdirSync(logsDir, { recursive: true });
|
|
426
|
+
|
|
427
|
+
const dateStr = new Date().toISOString().split('T')[0];
|
|
428
|
+
|
|
429
|
+
for (const [agentId, { agent, entries }] of Object.entries(filteredLogs)) {
|
|
430
|
+
if (entries.length === 0) continue;
|
|
431
|
+
|
|
432
|
+
const agentDir = resolve(logsDir, agent.name);
|
|
433
|
+
mkdirSync(agentDir, { recursive: true });
|
|
434
|
+
|
|
435
|
+
const logPath = resolve(agentDir, `${dateStr}-session.md`);
|
|
436
|
+
const content = [
|
|
437
|
+
`# ${agent.name} — Session Log`,
|
|
438
|
+
``,
|
|
439
|
+
`*Updated: ${new Date().toISOString()}*`,
|
|
440
|
+
``,
|
|
441
|
+
`- Role: ${agent.role}`,
|
|
442
|
+
`- Provider: ${agent.provider}`,
|
|
443
|
+
`- Scope: ${agent.scope?.join(', ') || 'unrestricted'}`,
|
|
444
|
+
`- Tokens: ${agent.tokensUsed}`,
|
|
445
|
+
``,
|
|
446
|
+
`## Activity`,
|
|
447
|
+
``,
|
|
448
|
+
...entries.slice(-100).map((e) => this.formatEntry(e)),
|
|
449
|
+
].join('\n');
|
|
450
|
+
|
|
451
|
+
writeFileSync(logPath, content);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// --- Handoff Brief for Context Rotation ---
|
|
456
|
+
|
|
457
|
+
async generateHandoffBrief(agent) {
|
|
458
|
+
const filteredLogs = this.collectFilteredLogs([agent]);
|
|
459
|
+
const agentLog = filteredLogs[agent.id];
|
|
460
|
+
const entries = agentLog?.entries || [];
|
|
461
|
+
|
|
462
|
+
// Get current project map for context
|
|
463
|
+
const mapPath = resolve(this.daemon.projectDir, 'GROOVE_PROJECT_MAP.md');
|
|
464
|
+
const projectMap = existsSync(mapPath) ? readFileSync(mapPath, 'utf8') : '';
|
|
465
|
+
|
|
466
|
+
// Build a focused handoff brief
|
|
467
|
+
const toolSummary = entries
|
|
468
|
+
.filter((e) => e.type === 'tool')
|
|
469
|
+
.map((e) => `- ${e.tool}: ${e.input}`)
|
|
470
|
+
.slice(-30)
|
|
471
|
+
.join('\n');
|
|
472
|
+
|
|
473
|
+
const errorSummary = entries
|
|
474
|
+
.filter((e) => e.type === 'error')
|
|
475
|
+
.map((e) => `- ${e.text}`)
|
|
476
|
+
.slice(-10)
|
|
477
|
+
.join('\n');
|
|
478
|
+
|
|
479
|
+
const resultSummary = entries
|
|
480
|
+
.filter((e) => e.type === 'result')
|
|
481
|
+
.map((e) => e.text)
|
|
482
|
+
.slice(-3)
|
|
483
|
+
.join('\n');
|
|
484
|
+
|
|
485
|
+
return [
|
|
486
|
+
`# Agent Handoff Brief`,
|
|
487
|
+
``,
|
|
488
|
+
`You are continuing the work of **${agent.name}** (role: ${agent.role}).`,
|
|
489
|
+
`This is a context rotation — the previous session is being replaced to keep context fresh.`,
|
|
490
|
+
``,
|
|
491
|
+
`## Your Identity`,
|
|
492
|
+
`- Role: ${agent.role}`,
|
|
493
|
+
`- Scope: ${agent.scope?.join(', ') || 'unrestricted'}`,
|
|
494
|
+
`- Provider: ${agent.provider}`,
|
|
495
|
+
``,
|
|
496
|
+
`## Previous Session`,
|
|
497
|
+
`- Tokens used: ${agent.tokensUsed}`,
|
|
498
|
+
`- Tool calls: ${entries.filter((e) => e.type === 'tool').length}`,
|
|
499
|
+
``,
|
|
500
|
+
toolSummary ? `### Recent tool calls\n${toolSummary}\n` : '',
|
|
501
|
+
resultSummary ? `### Last results\n${resultSummary}\n` : '',
|
|
502
|
+
errorSummary ? `### Unresolved errors\n${errorSummary}\n` : '',
|
|
503
|
+
`## Current Project State`,
|
|
504
|
+
``,
|
|
505
|
+
projectMap ? projectMap.slice(0, 10000) : 'No project map available yet.',
|
|
506
|
+
``,
|
|
507
|
+
`## Instructions`,
|
|
508
|
+
``,
|
|
509
|
+
`Continue the work from where the previous session left off.`,
|
|
510
|
+
`Review AGENTS_REGISTRY.md for team awareness.`,
|
|
511
|
+
agent.prompt ? `\nOriginal task: ${agent.prompt}` : '',
|
|
512
|
+
].filter(Boolean).join('\n');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// --- Agent File Tracking ---
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Extract files created/modified by an agent from its raw logs.
|
|
519
|
+
* Used by the Introducer to tell new agents what their teammates built.
|
|
520
|
+
*/
|
|
521
|
+
getAgentFiles(agent) {
|
|
522
|
+
const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
|
|
523
|
+
if (!existsSync(logPath)) return [];
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
const content = readFileSync(logPath, 'utf8');
|
|
527
|
+
const files = new Set();
|
|
528
|
+
|
|
529
|
+
for (const line of content.split('\n')) {
|
|
530
|
+
if (!line.trim() || line.startsWith('[')) continue;
|
|
531
|
+
try {
|
|
532
|
+
const data = JSON.parse(line);
|
|
533
|
+
if (data.type === 'assistant' && Array.isArray(data.message?.content)) {
|
|
534
|
+
for (const block of data.message.content) {
|
|
535
|
+
if (block.type === 'tool_use' && (block.name === 'Write' || block.name === 'Edit')) {
|
|
536
|
+
const fp = block.input?.file_path || block.input?.path;
|
|
537
|
+
if (fp) {
|
|
538
|
+
// Store relative to project dir for readability
|
|
539
|
+
const projDir = this.daemon.projectDir;
|
|
540
|
+
const rel = fp.startsWith(projDir) ? fp.slice(projDir.length + 1) : fp;
|
|
541
|
+
files.add(rel);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
} catch { /* skip non-JSON */ }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return [...files];
|
|
550
|
+
} catch {
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Get the final result/output text from an agent's logs.
|
|
557
|
+
* Used to capture planner conclusions, build summaries, etc.
|
|
558
|
+
*/
|
|
559
|
+
getAgentResult(agent) {
|
|
560
|
+
const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
|
|
561
|
+
if (!existsSync(logPath)) return '';
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
const content = readFileSync(logPath, 'utf8');
|
|
565
|
+
let lastResult = '';
|
|
566
|
+
|
|
567
|
+
for (const line of content.split('\n')) {
|
|
568
|
+
try {
|
|
569
|
+
const data = JSON.parse(line);
|
|
570
|
+
if (data.type === 'result' && typeof data.result === 'string') {
|
|
571
|
+
lastResult = data.result;
|
|
572
|
+
}
|
|
573
|
+
} catch { /* skip */ }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return lastResult.slice(0, 5000);
|
|
577
|
+
} catch {
|
|
578
|
+
return '';
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// --- Accessors ---
|
|
583
|
+
|
|
584
|
+
getLastSynthesis() {
|
|
585
|
+
return this.lastSynthesis;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
getHistory() {
|
|
589
|
+
return this.history;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
getStatus() {
|
|
593
|
+
return {
|
|
594
|
+
running: !!this.interval,
|
|
595
|
+
cycleCount: this.cycleCount,
|
|
596
|
+
lastCycleAt: this.lastCycleAt,
|
|
597
|
+
synthesizing: this.synthesizing,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// GROOVE — File Lock Manager
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { minimatch } from 'minimatch';
|
|
7
|
+
|
|
8
|
+
export class LockManager {
|
|
9
|
+
constructor(grooveDir) {
|
|
10
|
+
this.path = resolve(grooveDir, 'locks.json');
|
|
11
|
+
this.locks = new Map(); // agentId -> glob patterns[]
|
|
12
|
+
this.load();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
load() {
|
|
16
|
+
if (existsSync(this.path)) {
|
|
17
|
+
try {
|
|
18
|
+
const data = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
19
|
+
for (const [id, patterns] of Object.entries(data)) {
|
|
20
|
+
this.locks.set(id, patterns);
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// Start fresh
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
save() {
|
|
29
|
+
const obj = Object.fromEntries(this.locks);
|
|
30
|
+
writeFileSync(this.path, JSON.stringify(obj, null, 2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
register(agentId, patterns) {
|
|
34
|
+
this.locks.set(agentId, patterns);
|
|
35
|
+
this.save();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
release(agentId) {
|
|
39
|
+
this.locks.delete(agentId);
|
|
40
|
+
this.save();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
check(agentId, filePath) {
|
|
44
|
+
for (const [ownerId, patterns] of this.locks) {
|
|
45
|
+
if (ownerId === agentId) continue;
|
|
46
|
+
for (const pattern of patterns) {
|
|
47
|
+
if (minimatch(filePath, pattern)) {
|
|
48
|
+
return { conflict: true, owner: ownerId, pattern };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { conflict: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getAll() {
|
|
56
|
+
return Object.fromEntries(this.locks);
|
|
57
|
+
}
|
|
58
|
+
}
|