groove-dev 0.27.25 → 0.27.27

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.
@@ -31,6 +31,7 @@ export class Rotator extends EventEmitter {
31
31
  this.rotationHistory = [];
32
32
  this.rotating = new Set();
33
33
  this.lastRotationTime = new Map(); // agentId -> timestamp of last rotation
34
+ this._lastContextState = new Map(); // agentId -> { contextUsage, timestamp }
34
35
  this.enabled = false;
35
36
  this.liveScores = {};
36
37
  this.scoreHistory = {};
@@ -180,6 +181,25 @@ export class Rotator extends EventEmitter {
180
181
  continue;
181
182
  }
182
183
 
184
+ // Stale context fallback — safety net for providers (like Codex) that don't
185
+ // report intermediate contextUsage. If contextUsage hasn't changed in 120+
186
+ // seconds but tokens are being consumed, estimate from total tokens.
187
+ const knownCtx = this._lastContextState.get(agent.id);
188
+ if (!knownCtx || knownCtx.contextUsage !== agent.contextUsage) {
189
+ this._lastContextState.set(agent.id, { contextUsage: agent.contextUsage, timestamp: Date.now() });
190
+ } else if (agent.tokensUsed > 0 && (Date.now() - knownCtx.timestamp) >= 120_000) {
191
+ const providerClass = getProvider(agent.provider)?.constructor;
192
+ const models = providerClass?.models || [];
193
+ const model = models.find((m) => m.id === agent.model) || models[0];
194
+ const maxContext = model?.maxContext || 200000;
195
+ const estimatedContext = agent.tokensUsed / maxContext;
196
+ if (estimatedContext >= HARD_CEILING) {
197
+ console.log(` Rotator: ${agent.name} estimated context ${Math.round(estimatedContext * 100)}% (stale contextUsage fallback)`);
198
+ await this.rotate(agent.id, { reason: 'estimated_context_ceiling' });
199
+ continue;
200
+ }
201
+ }
202
+
183
203
  // Cooldown — skip threshold/quality rotation if recently rotated
184
204
  if (this._isOnCooldown(agent.id)) continue;
185
205
 
@@ -274,7 +294,7 @@ export class Rotator extends EventEmitter {
274
294
  oldTokens: agent.tokensUsed,
275
295
  contextUsage: agent.contextUsage,
276
296
  brief: brief.slice(0, 4000),
277
- }, agent.workingDir);
297
+ }, agent.workingDir, agent.teamId);
278
298
  }
279
299
 
280
300
  const record = {
@@ -312,6 +332,7 @@ export class Rotator extends EventEmitter {
312
332
  workingDir: agent.workingDir,
313
333
  name: agent.name,
314
334
  teamId: agent.teamId,
335
+ isRotation: true,
315
336
  });
316
337
  } catch (spawnErr) {
317
338
  // Spawn failed — re-add old agent so the user can see and retry.
@@ -499,6 +520,7 @@ export class Rotator extends EventEmitter {
499
520
  const naturalCompactions = this.rotationHistory.filter((r) => r.reason === 'natural_compaction').length;
500
521
  const hardCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'hard_ceiling').length;
501
522
  const tokenCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'token_ceiling').length;
523
+ const estimatedCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'estimated_context_ceiling').length;
502
524
  return {
503
525
  enabled: this.enabled,
504
526
  totalRotations,
@@ -508,6 +530,7 @@ export class Rotator extends EventEmitter {
508
530
  naturalCompactions,
509
531
  hardCeilingRotations,
510
532
  tokenCeilingRotations,
533
+ estimatedCeilingRotations,
511
534
  rotating: Array.from(this.rotating),
512
535
  liveScores: this.liveScores,
513
536
  scoreHistory: this.scoreHistory,
@@ -163,6 +163,69 @@ describe('Introducer', () => {
163
163
  });
164
164
  });
165
165
 
166
+ describe('hasTask gate — cross-team contamination fix', () => {
167
+ it('should NOT inject handoffs for agents spawned without a prompt', () => {
168
+ const grooveDir = join(tmpDir, '.groove');
169
+ mkdirSync(grooveDir, { recursive: true });
170
+ const memory = new MemoryStore(grooveDir);
171
+ memory.appendHandoffBrief('planner', {
172
+ brief: 'Old team planner work that should not leak',
173
+ reason: 'completed',
174
+ });
175
+ memory.addDiscovery({ role: 'planner', trigger: 'some error', fix: 'some fix', outcome: 'success' });
176
+ memory.addConstraint({ text: 'Always use ESM', category: 'pattern' });
177
+
178
+ const daemon = { registry, projectDir: tmpDir, grooveDir, memory };
179
+ const intro = new Introducer(daemon);
180
+
181
+ const agent = registry.add({ role: 'planner', scope: [] });
182
+ // hasTask=false, isRotation=false — simulates a fresh spawn with no prompt
183
+ const ctx = intro.generateContext(agent, { hasTask: false, isRotation: false });
184
+
185
+ // Constraints should ALWAYS be injected (project knowledge)
186
+ assert.ok(ctx.includes('Always use ESM'), 'constraints should always be injected');
187
+ // Handoffs and discoveries should NOT be injected
188
+ assert.ok(!ctx.includes('Old team planner work'), 'handoffs must not leak to promptless agents');
189
+ assert.ok(!ctx.includes('Known Fixes'), 'discoveries must not be injected for promptless agents');
190
+ });
191
+
192
+ it('should inject handoffs for agents spawned WITH a prompt (hasTask=true)', () => {
193
+ const grooveDir = join(tmpDir, '.groove');
194
+ mkdirSync(grooveDir, { recursive: true });
195
+ const memory = new MemoryStore(grooveDir);
196
+ memory.appendHandoffBrief('backend', {
197
+ brief: 'Previous backend handoff',
198
+ reason: 'completed',
199
+ });
200
+
201
+ const daemon = { registry, projectDir: tmpDir, grooveDir, memory };
202
+ const intro = new Introducer(daemon);
203
+
204
+ const agent = registry.add({ role: 'backend', scope: [] });
205
+ const ctx = intro.generateContext(agent, { hasTask: true });
206
+
207
+ assert.ok(ctx.includes('Recent Handoff History'), 'handoffs should be injected when hasTask=true');
208
+ });
209
+
210
+ it('should inject handoffs for rotation replacements (isRotation=true)', () => {
211
+ const grooveDir = join(tmpDir, '.groove');
212
+ mkdirSync(grooveDir, { recursive: true });
213
+ const memory = new MemoryStore(grooveDir);
214
+ memory.appendHandoffBrief('backend', {
215
+ brief: 'Rotation handoff content',
216
+ reason: 'context_threshold',
217
+ });
218
+
219
+ const daemon = { registry, projectDir: tmpDir, grooveDir, memory };
220
+ const intro = new Introducer(daemon);
221
+
222
+ const agent = registry.add({ role: 'backend', scope: [] });
223
+ const ctx = intro.generateContext(agent, { hasTask: false, isRotation: true });
224
+
225
+ assert.ok(ctx.includes('Recent Handoff History'), 'handoffs should be injected for rotations');
226
+ });
227
+ });
228
+
166
229
  describe('CLAUDE.md injection', () => {
167
230
  it('should inject GROOVE section into existing CLAUDE.md', () => {
168
231
  writeFileSync(join(tmpDir, 'CLAUDE.md'), '# My Project\n\nSome content.\n');
@@ -311,6 +311,112 @@ describe('Journalist', () => {
311
311
  });
312
312
  });
313
313
 
314
+ describe('requestSynthesis', () => {
315
+ it('should debounce multiple calls within 10s into a single cycle', async () => {
316
+ const { daemon } = createMockDaemon();
317
+ const journalist = new Journalist(daemon);
318
+
319
+ let cycleCalls = 0;
320
+ journalist.cycle = async () => { cycleCalls++; };
321
+
322
+ journalist.requestSynthesis('completion');
323
+ journalist.requestSynthesis('spawn');
324
+ journalist.requestSynthesis('rotation');
325
+
326
+ assert.equal(cycleCalls, 0, 'cycle should not fire immediately');
327
+
328
+ // Wait for debounce to fire
329
+ await new Promise((r) => setTimeout(r, 11_000));
330
+ assert.equal(cycleCalls, 1, 'only one cycle should fire after debounce');
331
+
332
+ journalist.stop();
333
+ });
334
+
335
+ it('should track the latest reason', () => {
336
+ const { daemon } = createMockDaemon();
337
+ const journalist = new Journalist(daemon);
338
+ journalist.cycle = async () => {};
339
+
340
+ journalist.requestSynthesis('completion');
341
+ journalist.requestSynthesis('rotation');
342
+
343
+ assert.equal(journalist._debounceReason, 'rotation');
344
+ journalist.stop();
345
+ });
346
+ });
347
+
348
+ describe('ensureFresh', () => {
349
+ it('should skip synthesis when lastCycleAt is recent', async () => {
350
+ const { daemon } = createMockDaemon();
351
+ const journalist = new Journalist(daemon);
352
+
353
+ let cycleCalled = false;
354
+ journalist.cycle = async () => { cycleCalled = true; };
355
+ journalist.lastCycleAt = Date.now() - 5000; // 5s ago
356
+
357
+ await journalist.ensureFresh(30000);
358
+ assert.equal(cycleCalled, false, 'should skip when recent');
359
+ });
360
+
361
+ it('should trigger synthesis when lastCycleAt is stale', async () => {
362
+ const { daemon } = createMockDaemon();
363
+ const journalist = new Journalist(daemon);
364
+
365
+ let cycleCalled = false;
366
+ journalist.cycle = async () => { cycleCalled = true; };
367
+ journalist.lastCycleAt = Date.now() - 60_000; // 60s ago
368
+
369
+ await journalist.ensureFresh(30000);
370
+ assert.equal(cycleCalled, true, 'should trigger when stale');
371
+ });
372
+
373
+ it('should trigger synthesis when lastCycleAt is null', async () => {
374
+ const { daemon } = createMockDaemon();
375
+ const journalist = new Journalist(daemon);
376
+
377
+ let cycleCalled = false;
378
+ journalist.cycle = async () => { cycleCalled = true; };
379
+
380
+ await journalist.ensureFresh(30000);
381
+ assert.equal(cycleCalled, true, 'should trigger when never synthesized');
382
+ });
383
+ });
384
+
385
+ describe('model tier selection', () => {
386
+ it('should prefer medium tier over light tier for synthesis', () => {
387
+ // Verify the callHeadless model selection logic by checking the source
388
+ // The constructor picks medium first, falling back to light
389
+ const { daemon } = createMockDaemon();
390
+ const journalist = new Journalist(daemon);
391
+
392
+ // Simulate provider model selection logic matching callHeadless
393
+ const models = [
394
+ { id: 'haiku', tier: 'light' },
395
+ { id: 'sonnet', tier: 'medium' },
396
+ { id: 'opus', tier: 'heavy' },
397
+ ];
398
+
399
+ const selected = models.find((m) => m.tier === 'medium')
400
+ || models.find((m) => m.tier === 'light')
401
+ || models[0];
402
+
403
+ assert.equal(selected.id, 'sonnet', 'should select medium tier (Sonnet)');
404
+ });
405
+
406
+ it('should fall back to light tier when medium is unavailable', () => {
407
+ const models = [
408
+ { id: 'haiku', tier: 'light' },
409
+ { id: 'opus', tier: 'heavy' },
410
+ ];
411
+
412
+ const selected = models.find((m) => m.tier === 'medium')
413
+ || models.find((m) => m.tier === 'light')
414
+ || models[0];
415
+
416
+ assert.equal(selected.id, 'haiku', 'should fall back to light tier');
417
+ });
418
+ });
419
+
314
420
  describe('_extractConstraints', () => {
315
421
  it('should no-op since auto-extraction is disabled', () => {
316
422
  const { daemon, grooveDir } = createMockDaemon();
@@ -181,6 +181,55 @@ describe('MemoryStore', () => {
181
181
  assert.ok(!rootRoles.includes('frontend'));
182
182
  assert.ok(wsRoles.includes('frontend'));
183
183
  });
184
+
185
+ it('scopes chains by teamId — different teams produce different files', () => {
186
+ memory.appendHandoffBrief('planner', { brief: 'team-A planner work' }, null, 'team-A');
187
+ memory.appendHandoffBrief('planner', { brief: 'team-B planner work' }, null, 'team-B');
188
+
189
+ const chainA = memory.getHandoffChain('planner', null, 'team-A');
190
+ const chainB = memory.getHandoffChain('planner', null, 'team-B');
191
+ assert.equal(chainA.length, 1);
192
+ assert.equal(chainB.length, 1);
193
+ assert.match(chainA[0].body, /team-A planner/);
194
+ assert.match(chainB[0].body, /team-B planner/);
195
+ });
196
+
197
+ it('team-scoped chains are independent from root chains', () => {
198
+ memory.appendHandoffBrief('planner', { brief: 'root planner work' });
199
+ memory.appendHandoffBrief('planner', { brief: 'scoped planner work' }, null, 'team-X');
200
+
201
+ const rootChain = memory.getHandoffChain('planner');
202
+ const teamChain = memory.getHandoffChain('planner', null, 'team-X');
203
+ assert.equal(rootChain.length, 1);
204
+ assert.equal(teamChain.length, 1);
205
+ assert.match(rootChain[0].body, /root planner/);
206
+ assert.match(teamChain[0].body, /scoped planner/);
207
+ });
208
+
209
+ it('teamId takes priority over workingDir for chain path', () => {
210
+ const ws = join(tmpDir, '..', 'some-workspace');
211
+ memory.appendHandoffBrief('backend', { brief: 'team-scoped' }, ws, 'team-Z');
212
+ memory.appendHandoffBrief('backend', { brief: 'ws-scoped' }, ws);
213
+
214
+ const teamChain = memory.getHandoffChain('backend', ws, 'team-Z');
215
+ const wsChain = memory.getHandoffChain('backend', ws);
216
+ assert.equal(teamChain.length, 1);
217
+ assert.equal(wsChain.length, 1);
218
+ assert.match(teamChain[0].body, /team-scoped/);
219
+ assert.match(wsChain[0].body, /ws-scoped/);
220
+ });
221
+
222
+ it('getRecentHandoffMarkdown respects teamId', () => {
223
+ memory.appendHandoffBrief('planner', { brief: 'team-1 brief' }, null, 'team-1');
224
+ memory.appendHandoffBrief('planner', { brief: 'team-2 brief' }, null, 'team-2');
225
+
226
+ const md1 = memory.getRecentHandoffMarkdown('planner', 3, 4000, null, 'team-1');
227
+ const md2 = memory.getRecentHandoffMarkdown('planner', 3, 4000, null, 'team-2');
228
+ assert.match(md1, /team-1 brief/);
229
+ assert.ok(!md1.includes('team-2 brief'));
230
+ assert.match(md2, /team-2 brief/);
231
+ assert.ok(!md2.includes('team-1 brief'));
232
+ });
184
233
  });
185
234
 
186
235
  describe('discoveries', () => {
@@ -335,6 +335,61 @@ describe('Rotator', () => {
335
335
  assert.equal(tokenCeilingRotations.length, 0);
336
336
  });
337
337
 
338
+ it('should trigger estimated_context_ceiling when contextUsage is stale', async () => {
339
+ // Simulates a Codex agent that never reports contextUsage updates.
340
+ // The agent has consumed 200K tokens (100% of maxContext) but contextUsage stayed at 0.
341
+ const agent = {
342
+ id: 'cx1', name: 'codex-stale', role: 'backend', status: 'running',
343
+ provider: 'codex', scope: [], model: 'gpt-5.4',
344
+ tokensUsed: 200_000, contextUsage: 0, workingDir: '/tmp',
345
+ lastActivity: new Date(Date.now() - 30_000).toISOString(),
346
+ spawnedAt: new Date(Date.now() - 300_000).toISOString(),
347
+ };
348
+ mockDaemon.registry.agents = [agent];
349
+
350
+ // First check — records the initial contextUsage state
351
+ await rotator.check();
352
+ assert.equal(rotator.getHistory().length, 0); // No rotation yet
353
+
354
+ // Simulate 120+ seconds of stale contextUsage by backdating the timestamp
355
+ const state = rotator._lastContextState.get('cx1');
356
+ state.timestamp = Date.now() - 130_000; // 130s ago
357
+
358
+ // Second check — stale context + high tokens should trigger estimated_context_ceiling
359
+ await rotator.check();
360
+
361
+ const history = rotator.getHistory();
362
+ assert.equal(history.length, 1);
363
+ assert.equal(history[0].reason, 'estimated_context_ceiling');
364
+ });
365
+
366
+ it('should NOT trigger estimated_context_ceiling when tokens are below HARD_CEILING', async () => {
367
+ const agent = {
368
+ id: 'cx2', name: 'codex-low', role: 'backend', status: 'running',
369
+ provider: 'codex', scope: [], model: 'gpt-5.4',
370
+ tokensUsed: 50_000, contextUsage: 0, workingDir: '/tmp',
371
+ lastActivity: new Date(Date.now() - 30_000).toISOString(),
372
+ spawnedAt: new Date(Date.now() - 300_000).toISOString(),
373
+ };
374
+ mockDaemon.registry.agents = [agent];
375
+
376
+ // First check to record state
377
+ await rotator.check();
378
+ const state = rotator._lastContextState.get('cx2');
379
+ state.timestamp = Date.now() - 130_000;
380
+
381
+ // Second check — 50K/200K = 25%, below 80% ceiling
382
+ await rotator.check();
383
+
384
+ const history = rotator.getHistory();
385
+ assert.equal(history.length, 0);
386
+ });
387
+
388
+ it('should include estimatedCeilingRotations in stats', () => {
389
+ const stats = rotator.getStats();
390
+ assert.equal(stats.estimatedCeilingRotations, 0);
391
+ });
392
+
338
393
  it('should record lastRotationTime after successful rotation', async () => {
339
394
  const agent = {
340
395
  id: 'a3', name: 'backend-3', role: 'backend',
@@ -352,4 +407,48 @@ describe('Rotator', () => {
352
407
  const elapsed = Date.now() - rotator.lastRotationTime.get(newAgent.id);
353
408
  assert.ok(elapsed < 1000); // Should be very recent
354
409
  });
410
+
411
+ it('should pass isRotation flag through spawn config', async () => {
412
+ let spawnConfig = null;
413
+ mockDaemon.processes.spawn = async (config) => {
414
+ spawnConfig = config;
415
+ return { id: 'new-' + config.role, name: config.name, ...config };
416
+ };
417
+
418
+ const agent = {
419
+ id: 'a4', name: 'backend-4', role: 'backend',
420
+ provider: 'claude-code', scope: [], model: null,
421
+ tokensUsed: 3000, contextUsage: 0.9, workingDir: '/tmp',
422
+ teamId: 'team-1',
423
+ };
424
+ mockDaemon.registry.agents = [agent];
425
+
426
+ await rotator.rotate('a4');
427
+
428
+ assert.ok(spawnConfig, 'spawn should have been called');
429
+ assert.equal(spawnConfig.isRotation, true, 'isRotation must be true for rotation spawns');
430
+ assert.equal(spawnConfig.teamId, 'team-1', 'teamId must be passed through');
431
+ });
432
+
433
+ it('should pass teamId to appendHandoffBrief', async () => {
434
+ let appendArgs = null;
435
+ mockDaemon.memory.appendHandoffBrief = (role, entry, workingDir, teamId) => {
436
+ appendArgs = { role, workingDir, teamId };
437
+ return true;
438
+ };
439
+
440
+ const agent = {
441
+ id: 'a5', name: 'backend-5', role: 'backend',
442
+ provider: 'claude-code', scope: [], model: null,
443
+ tokensUsed: 3000, contextUsage: 0.9, workingDir: '/tmp',
444
+ teamId: 'team-alpha',
445
+ };
446
+ mockDaemon.registry.agents = [agent];
447
+
448
+ await rotator.rotate('a5');
449
+
450
+ assert.ok(appendArgs, 'appendHandoffBrief should have been called');
451
+ assert.equal(appendArgs.teamId, 'team-alpha');
452
+ assert.equal(appendArgs.role, 'backend');
453
+ });
355
454
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.7",
3
+ "version": "0.27.27",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.25",
3
+ "version": "0.27.27",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.7",
4
- "description": "GROOVE CLI \u2014 manage AI coding agents from your terminal",
3
+ "version": "0.27.27",
4
+ "description": "GROOVE CLI manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
@@ -13,4 +13,4 @@
13
13
  "chalk": "^5.3.0"
14
14
  },
15
15
  "private": true
16
- }
16
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.7",
3
+ "version": "0.27.27",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -14,7 +14,7 @@ export class Introducer {
14
14
  }
15
15
 
16
16
  generateContext(newAgent, options = {}) {
17
- const { taskNegotiation, hasTask } = options;
17
+ const { taskNegotiation, hasTask, isRotation } = options;
18
18
  const agents = this.daemon.registry.getAll();
19
19
  // Only include ACTIVE agents — not completed/killed ones from previous sessions
20
20
  // Completed agents' work is captured in the journalist's project map, not here
@@ -353,13 +353,13 @@ export class Introducer {
353
353
  parts.push(`### Constraints (read carefully)\n${constraints}`);
354
354
  }
355
355
 
356
- if (hasTask) {
357
- const discoveries = this.daemon.memory.getDiscoveriesMarkdown(newAgent.role, 15, 1000);
356
+ if (hasTask || isRotation) {
357
+ const discoveries = this.daemon.memory.getDiscoveriesMarkdown(newAgent.role, 8, 600, newAgent.scope);
358
358
  if (discoveries) {
359
359
  parts.push(`### Known Fixes for ${newAgent.role} Role\n${discoveries}`);
360
360
  }
361
361
 
362
- const handoffs = this.daemon.memory.getRecentHandoffMarkdown(newAgent.role, 2, 1000, newAgent.workingDir);
362
+ const handoffs = this.daemon.memory.getRecentHandoffMarkdown(newAgent.role, 2, 1000, newAgent.workingDir, newAgent.teamId);
363
363
  if (handoffs) {
364
364
  parts.push(`### Recent Handoff History\n${handoffs}`);
365
365
  }
@@ -367,9 +367,9 @@ export class Introducer {
367
367
 
368
368
  if (parts.length > 0) {
369
369
  memorySection = `\n## Project Memory (auto-generated)\n\n${parts.join('\n\n')}\n`;
370
- // Hard budget: 4K chars total
371
- if (memorySection.length > 4000) {
372
- memorySection = memorySection.slice(0, 3997) + '...';
370
+ // Hard budget: 3K chars total
371
+ if (memorySection.length > 3000) {
372
+ memorySection = memorySection.slice(0, 2997) + '...';
373
373
  }
374
374
  }
375
375
  }
@@ -6,8 +6,9 @@ import { resolve } from 'path';
6
6
  import { execFile, spawn as cpSpawn } from 'child_process';
7
7
  import { getProvider, getInstalledProviders } from './providers/index.js';
8
8
 
9
- const DEFAULT_INTERVAL = 120_000; // 2 minutes
9
+ const DEFAULT_INTERVAL = 300_000; // 5 minutes (safety-net fallback; event-driven triggers handle the normal case)
10
10
  const MAX_LOG_CHARS = 100_000; // ~25k tokens budget for synthesis input (captures 80-90% of recent activity)
11
+ const DEBOUNCE_MS = 10_000; // requestSynthesis debounce window
11
12
 
12
13
  export class Journalist {
13
14
  constructor(daemon) {
@@ -19,6 +20,8 @@ export class Journalist {
19
20
  this.synthesizing = false;
20
21
  this.lastSynthesis = null; // last synthesis result text
21
22
  this.history = []; // recent synthesis summaries
23
+ this._debounceTimer = null;
24
+ this._debounceReason = null;
22
25
  }
23
26
 
24
27
  start(intervalMs = DEFAULT_INTERVAL) {
@@ -51,6 +54,33 @@ export class Journalist {
51
54
  clearInterval(this.interval);
52
55
  this.interval = null;
53
56
  }
57
+ if (this._debounceTimer) {
58
+ clearTimeout(this._debounceTimer);
59
+ this._debounceTimer = null;
60
+ }
61
+ }
62
+
63
+ requestSynthesis(reason = 'unknown') {
64
+ if (this._debounceTimer) {
65
+ this._debounceReason = reason;
66
+ return;
67
+ }
68
+ this._debounceReason = reason;
69
+ this._debounceTimer = setTimeout(() => {
70
+ const r = this._debounceReason;
71
+ this._debounceTimer = null;
72
+ this._debounceReason = null;
73
+ this.cycle().catch((err) => {
74
+ console.error(` Journalist requestSynthesis(${r}) failed:`, err.message);
75
+ });
76
+ }, DEBOUNCE_MS);
77
+ }
78
+
79
+ async ensureFresh(maxAgeMs = 30000) {
80
+ if (this.lastCycleAt && (Date.now() - this.lastCycleAt) < maxAgeMs) {
81
+ return;
82
+ }
83
+ await this.cycle();
54
84
  }
55
85
 
56
86
  async cycle() {
@@ -454,11 +484,11 @@ export class Journalist {
454
484
  }
455
485
  const provider = getProvider(providerId);
456
486
 
457
- // Pick the lightest model for synthesis (cheapest/fastest)
458
- const lightModel = provider.constructor.models?.find((m) => m.tier === 'light')
459
- || provider.constructor.models?.find((m) => m.tier === 'medium')
487
+ // Pick medium tier for higher-quality synthesis (fewer but better cycles)
488
+ const selectedModel = provider.constructor.models?.find((m) => m.tier === 'medium')
489
+ || provider.constructor.models?.find((m) => m.tier === 'light')
460
490
  || provider.constructor.models?.[0];
461
- const modelId = lightModel?.id || null;
491
+ const modelId = selectedModel?.id || null;
462
492
 
463
493
  const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
464
494
  const { command, args, env, stdin: stdinData } = headlessCmd;
@@ -749,7 +779,7 @@ export class Journalist {
749
779
  // Pull recent rotation history from persistent memory (Layer 7).
750
780
  // Gives the new agent causal continuity: what the last 3 agents struggled
751
781
  // with, decided, and solved — not just what the current session did.
752
- const recentChain = this.daemon.memory?.getRecentHandoffMarkdown(agent.role, 3, 3000, agent.workingDir) || '';
782
+ const recentChain = this.daemon.memory?.getRecentHandoffMarkdown(agent.role, 3, 3000, agent.workingDir, agent.teamId) || '';
753
783
 
754
784
  // Pull the user's recent messages scoped to this agent
755
785
  const agentFeedback = this.getUserFeedback(agent.id).slice(-5);
@@ -12,6 +12,7 @@
12
12
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, appendFileSync, statSync } from 'fs';
13
13
  import { resolve, relative } from 'path';
14
14
  import { createHash } from 'crypto';
15
+ import { minimatch } from 'minimatch';
15
16
 
16
17
  const MAX_CONSTRAINTS = 50;
17
18
  const MAX_HANDOFF_ROTATIONS = 25;
@@ -141,7 +142,12 @@ export class MemoryStore {
141
142
  return safeName(rel);
142
143
  }
143
144
 
144
- _chainPath(role, workingDir) {
145
+ _chainPath(role, workingDir, teamId) {
146
+ if (teamId) {
147
+ const dir = resolve(this.handoffDir, safeName(teamId));
148
+ mkdirSync(dir, { recursive: true });
149
+ return resolve(dir, `${safeName(role)}.md`);
150
+ }
145
151
  const slug = this._workspaceSlug(workingDir);
146
152
  if (slug) {
147
153
  const dir = resolve(this.handoffDir, slug);
@@ -151,8 +157,8 @@ export class MemoryStore {
151
157
  return resolve(this.handoffDir, `${safeName(role)}.md`);
152
158
  }
153
159
 
154
- getHandoffChain(role, workingDir) {
155
- const path = this._chainPath(role, workingDir);
160
+ getHandoffChain(role, workingDir, teamId) {
161
+ const path = this._chainPath(role, workingDir, teamId);
156
162
  if (!existsSync(path)) return [];
157
163
  try {
158
164
  const content = readFileSync(path, 'utf8');
@@ -173,9 +179,9 @@ export class MemoryStore {
173
179
  }
174
180
  }
175
181
 
176
- appendHandoffBrief(role, entry, workingDir) {
182
+ appendHandoffBrief(role, entry, workingDir, teamId) {
177
183
  if (!role || !entry) return false;
178
- const chain = this.getHandoffChain(role, workingDir);
184
+ const chain = this.getHandoffChain(role, workingDir, teamId);
179
185
  const nextN = (chain[0]?.rotationN || 0) + 1;
180
186
 
181
187
  const block = [
@@ -203,15 +209,15 @@ export class MemoryStore {
203
209
  }
204
210
 
205
211
  try {
206
- writeFileSync(this._chainPath(role, workingDir), lines.join('\n'));
212
+ writeFileSync(this._chainPath(role, workingDir, teamId), lines.join('\n'));
207
213
  return true;
208
214
  } catch {
209
215
  return false;
210
216
  }
211
217
  }
212
218
 
213
- getRecentHandoffMarkdown(role, count = 3, maxChars = 4000, workingDir) {
214
- const chain = this.getHandoffChain(role, workingDir);
219
+ getRecentHandoffMarkdown(role, count = 3, maxChars = 4000, workingDir, teamId) {
220
+ const chain = this.getHandoffChain(role, workingDir, teamId);
215
221
  if (chain.length === 0) return '';
216
222
  const recent = chain.slice(0, count);
217
223
  const out = recent.map((e) => e.body || '').join('\n\n---\n\n');
@@ -300,9 +306,22 @@ export class MemoryStore {
300
306
  } catch { /* best-effort */ }
301
307
  }
302
308
 
303
- getDiscoveriesMarkdown(role, limit = 20, maxChars = 4000) {
304
- const entries = this.listDiscoveries({ role, limit });
309
+ getDiscoveriesMarkdown(role, limit = 20, maxChars = 4000, scope) {
310
+ let entries = this.listDiscoveries({ role, limit: limit * 3 });
305
311
  if (entries.length === 0) return '';
312
+
313
+ if (scope && Array.isArray(scope) && scope.length > 0) {
314
+ const filtered = entries.filter((d) => {
315
+ const file = d.fix || '';
316
+ const rel = file.startsWith(this.projectDir + '/') ? file.slice(this.projectDir.length + 1) : file;
317
+ return scope.some((pattern) => minimatch(rel, pattern, { dot: true }));
318
+ });
319
+ if (filtered.length >= 3) {
320
+ entries = filtered;
321
+ }
322
+ }
323
+
324
+ entries = entries.slice(0, limit);
306
325
  const lines = entries.map((d) => `- When \`${d.trigger}\` → fix: ${d.fix}`);
307
326
  return truncate(lines.join('\n'), maxChars);
308
327
  }