groove-dev 0.27.75 → 0.27.78
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/MOE_TRAINING_PIPELINE.md +216 -12
- package/moe-training/DEPLOY_CENTRAL_COMMAND.md +413 -0
- package/moe-training/client/consent.js +96 -0
- package/moe-training/client/envelope-builder.js +56 -0
- package/moe-training/client/index.js +10 -0
- package/moe-training/client/parsers/claude-code.js +110 -0
- package/moe-training/client/parsers/codex.js +80 -0
- package/moe-training/client/parsers/gemini.js +80 -0
- package/moe-training/client/parsers/grok.js +16 -0
- package/moe-training/client/parsers/index.js +20 -0
- package/moe-training/client/scrubber.js +126 -0
- package/moe-training/client/session-attestation.js +114 -0
- package/moe-training/client/step-classifier.js +51 -0
- package/moe-training/client/trajectory-capture.js +227 -0
- package/moe-training/client/transmission-queue.js +93 -0
- package/moe-training/package-lock.json +1266 -0
- package/moe-training/package.json +20 -0
- package/moe-training/server/enrichment.js +24 -0
- package/moe-training/server/index.js +119 -0
- package/moe-training/server/ledger.js +110 -0
- package/moe-training/server/routes/ingest.js +96 -0
- package/moe-training/server/routes/sessions.js +43 -0
- package/moe-training/server/routes/stats.js +31 -0
- package/moe-training/server/scoring.js +63 -0
- package/moe-training/server/session-registry.js +156 -0
- package/moe-training/server/stats.js +129 -0
- package/moe-training/server/stitcher.js +69 -0
- package/moe-training/server/storage.js +147 -0
- package/moe-training/server/verifier.js +102 -0
- package/moe-training/shared/constants.js +30 -0
- package/moe-training/shared/crypto.js +45 -0
- package/moe-training/shared/envelope-schema.js +220 -0
- package/moe-training/test/client/consent.test.js +121 -0
- package/moe-training/test/client/envelope-builder.test.js +107 -0
- package/moe-training/test/client/parsers/claude-code.test.js +119 -0
- package/moe-training/test/client/parsers/codex.test.js +83 -0
- package/moe-training/test/client/parsers/gemini.test.js +99 -0
- package/moe-training/test/client/scrubber.test.js +133 -0
- package/moe-training/test/client/session-attestation-security.test.js +95 -0
- package/moe-training/test/client/step-classifier.test.js +88 -0
- package/moe-training/test/integration/handshake.test.js +260 -0
- package/moe-training/test/server/ingest-security.test.js +166 -0
- package/moe-training/test/server/ledger.test.js +131 -0
- package/moe-training/test/server/scoring.test.js +242 -0
- package/moe-training/test/server/session-registry.test.js +125 -0
- package/moe-training/test/server/stitcher.test.js +157 -0
- package/moe-training/test/server/verifier.test.js +232 -0
- package/moe-training/test/shared/crypto.test.js +87 -0
- package/moe-training/test/shared/envelope-schema.test.js +351 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/agent-loop.js +48 -5
- package/node_modules/@groove-dev/daemon/src/api.js +77 -0
- package/node_modules/@groove-dev/daemon/src/index.js +61 -0
- package/node_modules/@groove-dev/daemon/src/journalist.js +64 -21
- package/node_modules/@groove-dev/daemon/src/preview.js +14 -0
- package/node_modules/@groove-dev/daemon/src/process.js +203 -1
- package/node_modules/@groove-dev/daemon/src/providers/grok.js +15 -0
- package/node_modules/@groove-dev/daemon/src/state.js +20 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-CAT9SCJi.js → index-BJgEJ9lZ.js} +1700 -1704
- package/node_modules/@groove-dev/gui/dist/assets/index-kbR5tOHu.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/app.css +12 -0
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +32 -27
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +26 -24
- package/node_modules/@groove-dev/gui/src/components/preview/preview-toolbar.jsx +34 -6
- package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +19 -4
- package/node_modules/@groove-dev/gui/src/components/preview/screenshot-overlay.jsx +91 -57
- package/node_modules/@groove-dev/gui/src/stores/groove.js +32 -0
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +167 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/agent-loop.js +48 -5
- package/packages/daemon/src/api.js +77 -0
- package/packages/daemon/src/index.js +61 -0
- package/packages/daemon/src/journalist.js +64 -21
- package/packages/daemon/src/preview.js +14 -0
- package/packages/daemon/src/process.js +203 -1
- package/packages/daemon/src/providers/grok.js +15 -0
- package/packages/daemon/src/state.js +20 -1
- package/packages/gui/dist/assets/{index-CAT9SCJi.js → index-BJgEJ9lZ.js} +1700 -1704
- package/packages/gui/dist/assets/index-kbR5tOHu.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/app.css +12 -0
- package/packages/gui/src/components/chat/chat-input.jsx +32 -27
- package/packages/gui/src/components/chat/chat-messages.jsx +26 -24
- package/packages/gui/src/components/preview/preview-toolbar.jsx +34 -6
- package/packages/gui/src/components/preview/preview-workspace.jsx +19 -4
- package/packages/gui/src/components/preview/screenshot-overlay.jsx +91 -57
- package/packages/gui/src/stores/groove.js +32 -0
- package/packages/gui/src/views/settings.jsx +167 -1
- package/welcome.png +0 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CVzz6zyb.css +0 -1
- package/packages/gui/dist/assets/index-CVzz6zyb.css +0 -1
|
@@ -15,6 +15,7 @@ import { listProviders, getProvider, clearInstallCache, getProviderMetadata, get
|
|
|
15
15
|
import { OllamaProvider } from './providers/ollama.js';
|
|
16
16
|
import { ClaudeCodeProvider } from './providers/claude-code.js';
|
|
17
17
|
import { supportsSignalFlag, compareSemver, parseSemver } from './providers/groove-network.js';
|
|
18
|
+
import { ConsentManager } from '../../../moe-training/client/index.js';
|
|
18
19
|
import { validateAgentConfig } from './validate.js';
|
|
19
20
|
import { ROLE_INTEGRATIONS, wrapWithRoleReminder } from './process.js';
|
|
20
21
|
|
|
@@ -4402,6 +4403,81 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4402
4403
|
res.json({ ok: true });
|
|
4403
4404
|
});
|
|
4404
4405
|
|
|
4406
|
+
// --- Training Data ---
|
|
4407
|
+
|
|
4408
|
+
app.get('/api/training/status', (req, res) => {
|
|
4409
|
+
let userId = null;
|
|
4410
|
+
try { userId = ConsentManager.isCaptureEnabled() ? ConsentManager.getOrCreateUserId() : null; } catch (e) { /* no db yet */ }
|
|
4411
|
+
res.json({
|
|
4412
|
+
optedIn: !!daemon.config.training_opt_in,
|
|
4413
|
+
userId: userId ? userId.substring(0, 8) + '...' : null,
|
|
4414
|
+
captureActive: !!daemon.trajectoryCapture,
|
|
4415
|
+
sessionsCaptured: daemon.state.get('training_sessions_captured') || 0,
|
|
4416
|
+
envelopesSent: daemon.state.get('training_envelopes_sent') || 0,
|
|
4417
|
+
});
|
|
4418
|
+
});
|
|
4419
|
+
|
|
4420
|
+
app.post('/api/training/opt-in', async (req, res) => {
|
|
4421
|
+
const { enabled } = req.body;
|
|
4422
|
+
if (typeof enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be boolean' });
|
|
4423
|
+
|
|
4424
|
+
daemon.config.training_opt_in = enabled;
|
|
4425
|
+
const { saveConfig } = await import('./firstrun.js');
|
|
4426
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
4427
|
+
|
|
4428
|
+
if (enabled) {
|
|
4429
|
+
const userId = ConsentManager.getOrCreateUserId();
|
|
4430
|
+
const consent = new ConsentManager();
|
|
4431
|
+
try {
|
|
4432
|
+
consent.recordConsent(userId, true, '1.0');
|
|
4433
|
+
} finally {
|
|
4434
|
+
consent.close();
|
|
4435
|
+
}
|
|
4436
|
+
await daemon._initTrajectoryCapture();
|
|
4437
|
+
daemon.state.set('training_enrolled_at', new Date().toISOString());
|
|
4438
|
+
} else {
|
|
4439
|
+
if (daemon.trajectoryCapture) {
|
|
4440
|
+
try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
|
|
4441
|
+
daemon.trajectoryCapture = null;
|
|
4442
|
+
}
|
|
4443
|
+
try {
|
|
4444
|
+
const userId = ConsentManager.getOrCreateUserId();
|
|
4445
|
+
const consent = new ConsentManager();
|
|
4446
|
+
try {
|
|
4447
|
+
consent.revokeConsent(userId);
|
|
4448
|
+
} finally {
|
|
4449
|
+
consent.close();
|
|
4450
|
+
}
|
|
4451
|
+
} catch (e) { /* no user_id yet */ }
|
|
4452
|
+
}
|
|
4453
|
+
|
|
4454
|
+
daemon.broadcast({ type: 'training:status', data: { optedIn: enabled, captureActive: !!daemon.trajectoryCapture } });
|
|
4455
|
+
if (daemon.audit) daemon.audit.log('training.consent', { opt_in: enabled });
|
|
4456
|
+
res.json({ ok: true, optedIn: enabled });
|
|
4457
|
+
});
|
|
4458
|
+
|
|
4459
|
+
app.post('/api/training/opt-in/delete', async (req, res) => {
|
|
4460
|
+
try {
|
|
4461
|
+
daemon.config.training_opt_in = false;
|
|
4462
|
+
const { saveConfig } = await import('./firstrun.js');
|
|
4463
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
4464
|
+
if (daemon.trajectoryCapture) {
|
|
4465
|
+
try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
|
|
4466
|
+
daemon.trajectoryCapture = null;
|
|
4467
|
+
}
|
|
4468
|
+
try {
|
|
4469
|
+
const userId = ConsentManager.getOrCreateUserId();
|
|
4470
|
+
const consent = new ConsentManager();
|
|
4471
|
+
try { consent.revokeConsent(userId); } finally { consent.close(); }
|
|
4472
|
+
} catch (e) { /* */ }
|
|
4473
|
+
daemon.broadcast({ type: 'training:status', data: { optedIn: false, captureActive: false } });
|
|
4474
|
+
if (daemon.audit) daemon.audit.log('training.delete', {});
|
|
4475
|
+
res.json({ ok: true, deleted: true });
|
|
4476
|
+
} catch (e) {
|
|
4477
|
+
res.status(500).json({ error: 'Failed to delete data' });
|
|
4478
|
+
}
|
|
4479
|
+
});
|
|
4480
|
+
|
|
4405
4481
|
// --- Config ---
|
|
4406
4482
|
|
|
4407
4483
|
app.get('/api/config', (req, res) => {
|
|
@@ -4419,6 +4495,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4419
4495
|
'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
|
|
4420
4496
|
'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
|
|
4421
4497
|
'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
|
|
4498
|
+
'training_opt_in',
|
|
4422
4499
|
];
|
|
4423
4500
|
for (const key of Object.keys(req.body)) {
|
|
4424
4501
|
if (!ALLOWED_KEYS.includes(key)) {
|
|
@@ -43,6 +43,7 @@ import { LlamaServerManager } from './llama-server.js';
|
|
|
43
43
|
import { RepoImporter } from './repo-import.js';
|
|
44
44
|
import { ConversationManager } from './conversations.js';
|
|
45
45
|
import { Toys } from './toys.js';
|
|
46
|
+
import { TrajectoryCapture, ConsentManager } from '../../../moe-training/client/index.js';
|
|
46
47
|
import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
|
|
47
48
|
import { bindDaemon as bindGrooveNetworkDaemon } from './providers/groove-network.js';
|
|
48
49
|
import { setProviderPaths } from './providers/index.js';
|
|
@@ -151,6 +152,17 @@ export class Daemon {
|
|
|
151
152
|
this.tunnelManager = new TunnelManager(this);
|
|
152
153
|
this.repoImporter = new RepoImporter(this);
|
|
153
154
|
this.toys = new Toys(this);
|
|
155
|
+
this.trajectoryCapture = null;
|
|
156
|
+
|
|
157
|
+
// Hook teams.delete to clean up agent-loop session files
|
|
158
|
+
const originalTeamDelete = this.teams.delete.bind(this.teams);
|
|
159
|
+
this.teams.delete = (id) => {
|
|
160
|
+
const agents = this.registry.getAll().filter(a => a.teamId === id);
|
|
161
|
+
const agentIds = agents.map(a => a.id);
|
|
162
|
+
const result = originalTeamDelete(id);
|
|
163
|
+
if (agentIds.length > 0) this.state.cleanupSessions(agentIds);
|
|
164
|
+
return result;
|
|
165
|
+
};
|
|
154
166
|
|
|
155
167
|
// Subscription state (populated by Electron IPC or direct auth)
|
|
156
168
|
this.authToken = null;
|
|
@@ -390,6 +402,20 @@ export class Daemon {
|
|
|
390
402
|
client.send(payload);
|
|
391
403
|
}
|
|
392
404
|
}
|
|
405
|
+
if (this.trajectoryCapture && message.type) {
|
|
406
|
+
try {
|
|
407
|
+
if (['approval:request', 'approval:resolved', 'conflict:detected', 'qc:activated'].includes(message.type)) {
|
|
408
|
+
const agentId = message.data?.agentId || message.agentId;
|
|
409
|
+
if (agentId) {
|
|
410
|
+
this.trajectoryCapture.onCoordinationEvent(agentId, {
|
|
411
|
+
type: message.type,
|
|
412
|
+
data: message.data,
|
|
413
|
+
timestamp: Date.now(),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
} catch (e) { /* fail silent */ }
|
|
418
|
+
}
|
|
393
419
|
}
|
|
394
420
|
|
|
395
421
|
async setAuthToken(token) {
|
|
@@ -527,6 +553,17 @@ export class Daemon {
|
|
|
527
553
|
const purged = this.locks.purgeOrphans(runningIds);
|
|
528
554
|
if (purged > 0) console.log(` Purged ${purged} orphaned lock(s) from previous session`);
|
|
529
555
|
|
|
556
|
+
// Mark agents with saved agent-loop sessions as resumable
|
|
557
|
+
const resumableIds = new Set(this.state.getResumableSessions());
|
|
558
|
+
if (resumableIds.size > 0) {
|
|
559
|
+
for (const agent of this.registry.getAll()) {
|
|
560
|
+
if (resumableIds.has(agent.id) && (agent.status === 'running' || agent.status === 'idle' || agent.status === 'completed')) {
|
|
561
|
+
this.registry.update(agent.id, { status: 'completed', hasSession: true, pid: null });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
console.log(` ${resumableIds.size} agent-loop session(s) marked as resumable`);
|
|
565
|
+
}
|
|
566
|
+
|
|
530
567
|
// Migrate old agents without teamId to default team
|
|
531
568
|
this.teams.migrateAgents();
|
|
532
569
|
|
|
@@ -542,6 +579,7 @@ export class Daemon {
|
|
|
542
579
|
printWelcome(this.port, this.host, this._firstRun);
|
|
543
580
|
|
|
544
581
|
// Start background services
|
|
582
|
+
this._initTrajectoryCapture().catch(() => {});
|
|
545
583
|
this.journalist.start();
|
|
546
584
|
this.rotator.start();
|
|
547
585
|
this.scheduler.start();
|
|
@@ -627,6 +665,23 @@ export class Daemon {
|
|
|
627
665
|
});
|
|
628
666
|
}
|
|
629
667
|
|
|
668
|
+
async _initTrajectoryCapture() {
|
|
669
|
+
if (!this.config.training_opt_in) return;
|
|
670
|
+
try {
|
|
671
|
+
if (ConsentManager.isCaptureEnabled()) {
|
|
672
|
+
const pkgPath = new URL('../package.json', import.meta.url);
|
|
673
|
+
const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
674
|
+
this.trajectoryCapture = new TrajectoryCapture({
|
|
675
|
+
centralCommandUrl: process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai',
|
|
676
|
+
grooveVersion: version,
|
|
677
|
+
});
|
|
678
|
+
this.trajectoryCapture.init();
|
|
679
|
+
}
|
|
680
|
+
} catch (e) {
|
|
681
|
+
// Training capture is never critical
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
630
685
|
_startGarbageCollector() {
|
|
631
686
|
// Run once on startup, then every 24 hours
|
|
632
687
|
this._gc();
|
|
@@ -737,6 +792,12 @@ export class Daemon {
|
|
|
737
792
|
// Disconnect all SSH tunnels
|
|
738
793
|
this.tunnelManager.shutdown();
|
|
739
794
|
|
|
795
|
+
// Shut down training capture
|
|
796
|
+
if (this.trajectoryCapture) {
|
|
797
|
+
try { await this.trajectoryCapture.shutdown(); } catch (e) { /* fail silent */ }
|
|
798
|
+
this.trajectoryCapture = null;
|
|
799
|
+
}
|
|
800
|
+
|
|
740
801
|
// Kill all agent processes, stop MCP servers, and stop inference servers
|
|
741
802
|
await this.processes.killAll();
|
|
742
803
|
if (this.preview) await this.preview.killAll();
|
|
@@ -540,27 +540,76 @@ export class Journalist {
|
|
|
540
540
|
}
|
|
541
541
|
|
|
542
542
|
async callHeadless(prompt, { trackAs = '__journalist__' } = {}) {
|
|
543
|
-
|
|
544
|
-
// Priority: claude-code (cheapest via Haiku) > gemini > codex > ollama
|
|
545
|
-
const priority = ['claude-code', 'gemini', 'codex', 'ollama'];
|
|
543
|
+
const priority = ['claude-code', 'gemini', 'codex', 'grok', 'ollama'];
|
|
546
544
|
const installed = getInstalledProviders();
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
545
|
+
|
|
546
|
+
for (const providerId of priority) {
|
|
547
|
+
if (!installed.some((i) => i.id === providerId)) continue;
|
|
548
|
+
|
|
549
|
+
const provider = getProvider(providerId);
|
|
550
|
+
if (!provider) continue;
|
|
551
|
+
|
|
552
|
+
const selectedModel = provider.constructor.models?.find((m) => m.tier === 'medium')
|
|
553
|
+
|| provider.constructor.models?.find((m) => m.tier === 'light')
|
|
554
|
+
|| provider.constructor.models?.[0];
|
|
555
|
+
const modelId = selectedModel?.id || null;
|
|
556
|
+
|
|
557
|
+
// Try CLI headless command first
|
|
558
|
+
const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
|
|
559
|
+
if (headlessCmd) {
|
|
560
|
+
try {
|
|
561
|
+
return await this._execHeadlessCmd(headlessCmd, trackAs, modelId);
|
|
562
|
+
} catch {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Fallback: streamChat for API-only providers (e.g. Grok)
|
|
568
|
+
if (typeof provider.streamChat === 'function') {
|
|
569
|
+
const apiKey = this.daemon.credentials?.getKey(providerId);
|
|
570
|
+
if (!apiKey) continue;
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
const text = await new Promise((resolve, reject) => {
|
|
574
|
+
let collected = '';
|
|
575
|
+
const ctrl = provider.streamChat(
|
|
576
|
+
[{ role: 'user', content: prompt }],
|
|
577
|
+
modelId,
|
|
578
|
+
apiKey,
|
|
579
|
+
(chunk) => { collected += chunk; },
|
|
580
|
+
() => resolve(collected),
|
|
581
|
+
(err) => reject(err),
|
|
582
|
+
);
|
|
583
|
+
if (!ctrl) reject(new Error('streamChat unavailable'));
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
if (text) {
|
|
587
|
+
const estimatedInputTokens = Math.ceil(prompt.length / 4);
|
|
588
|
+
const estimatedOutputTokens = Math.ceil(text.length / 4);
|
|
589
|
+
if (this.daemon?.tokens) {
|
|
590
|
+
this.daemon.tokens.record(trackAs, {
|
|
591
|
+
tokens: estimatedInputTokens + estimatedOutputTokens,
|
|
592
|
+
inputTokens: estimatedInputTokens,
|
|
593
|
+
outputTokens: estimatedOutputTokens,
|
|
594
|
+
model: modelId,
|
|
595
|
+
estimatedCostUsd: 0,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
return text;
|
|
599
|
+
}
|
|
600
|
+
} catch {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
550
604
|
}
|
|
551
|
-
const provider = getProvider(providerId);
|
|
552
605
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|| provider.constructor.models?.find((m) => m.tier === 'light')
|
|
556
|
-
|| provider.constructor.models?.[0];
|
|
557
|
-
const modelId = selectedModel?.id || null;
|
|
606
|
+
throw new Error('No provider available for synthesis');
|
|
607
|
+
}
|
|
558
608
|
|
|
559
|
-
|
|
609
|
+
_execHeadlessCmd(headlessCmd, trackAs, modelId) {
|
|
560
610
|
const { command, args, env, stdin: stdinData } = headlessCmd;
|
|
561
611
|
|
|
562
612
|
return new Promise((resolve, reject) => {
|
|
563
|
-
// Use spawn with stdin pipe if provider needs it (e.g., Ollama)
|
|
564
613
|
if (stdinData) {
|
|
565
614
|
let stdout = '';
|
|
566
615
|
const proc = cpSpawn(command, args, {
|
|
@@ -576,7 +625,6 @@ export class Journalist {
|
|
|
576
625
|
clearTimeout(timer);
|
|
577
626
|
if (code !== 0) return reject(new Error(`Headless exited with code ${code}`));
|
|
578
627
|
this._recordHeadlessUsage(stdout, trackAs, modelId);
|
|
579
|
-
// Process stdout same as execFile path below
|
|
580
628
|
const lines = stdout.split('\n');
|
|
581
629
|
for (const line of lines) {
|
|
582
630
|
try {
|
|
@@ -590,17 +638,14 @@ export class Journalist {
|
|
|
590
638
|
return;
|
|
591
639
|
}
|
|
592
640
|
|
|
593
|
-
|
|
641
|
+
execFile(command, args, {
|
|
594
642
|
env: { ...process.env, ...env },
|
|
595
643
|
cwd: this.daemon.projectDir,
|
|
596
644
|
maxBuffer: 1024 * 1024 * 5,
|
|
597
645
|
timeout: 60_000,
|
|
598
646
|
}, (err, stdout, stderr) => {
|
|
599
647
|
if (err) return reject(err);
|
|
600
|
-
|
|
601
648
|
this._recordHeadlessUsage(stdout, trackAs, modelId);
|
|
602
|
-
|
|
603
|
-
// Parse stream-json output to extract the result text
|
|
604
649
|
const lines = stdout.split('\n');
|
|
605
650
|
for (const line of lines) {
|
|
606
651
|
try {
|
|
@@ -610,8 +655,6 @@ export class Journalist {
|
|
|
610
655
|
}
|
|
611
656
|
} catch { /* skip */ }
|
|
612
657
|
}
|
|
613
|
-
|
|
614
|
-
// Fallback: return raw stdout
|
|
615
658
|
resolve(stdout);
|
|
616
659
|
});
|
|
617
660
|
});
|
|
@@ -168,6 +168,20 @@ export class PreviewService {
|
|
|
168
168
|
if (!command) {
|
|
169
169
|
return Promise.resolve({ launched: false, reason: 'no_command' });
|
|
170
170
|
}
|
|
171
|
+
// If command references an npm script, verify it exists in package.json
|
|
172
|
+
const npmRunMatch = command.match(/npm\s+run\s+(\S+)/);
|
|
173
|
+
if (npmRunMatch) {
|
|
174
|
+
const scriptName = npmRunMatch[1];
|
|
175
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
176
|
+
try {
|
|
177
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
178
|
+
if (!pkg.scripts || !pkg.scripts[scriptName]) {
|
|
179
|
+
return Promise.resolve({ launched: false, reason: 'no_dev_script' });
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
return Promise.resolve({ launched: false, reason: 'no_dev_script' });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
171
185
|
const urlPattern = preview.urlPattern
|
|
172
186
|
? new RegExp(preview.urlPattern)
|
|
173
187
|
: /https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0):\d+/;
|
|
@@ -114,6 +114,9 @@ CRITICAL — NEVER DO THESE:
|
|
|
114
114
|
- NEVER kill the daemon process. No "kill <pid>", "pkill groove", "killall node", etc.
|
|
115
115
|
- NEVER run "./promote.sh", "./promote-local.sh", or any publish/deploy script.
|
|
116
116
|
- NEVER start long-running dev servers (vite dev, npm start, next dev, etc.).
|
|
117
|
+
- NEVER use 'git add -f' or 'git add --force' to bypass .gitignore. If a file is gitignored, it should stay gitignored. Only stage files that git tracks normally. If .gitignore prevents staging, report it in your output — do NOT force-add.
|
|
118
|
+
- NEVER use 'git push --force' or 'git push -f'. Force-pushing can destroy shared history.
|
|
119
|
+
- NEVER modify .gitignore to include files that were previously excluded.
|
|
117
120
|
|
|
118
121
|
Restarting the daemon destroys ALL other agents currently running in other teams. Verification is done via "npm run build" and "npm test", which exit cleanly. If code changes require a daemon restart to take effect, report that in your output so the user can restart manually — do NOT do it yourself.
|
|
119
122
|
|
|
@@ -463,6 +466,15 @@ export class ProcessManager {
|
|
|
463
466
|
model: isAutoRouted ? null : config.model, // Set after routing
|
|
464
467
|
});
|
|
465
468
|
|
|
469
|
+
if (this.daemon.trajectoryCapture) {
|
|
470
|
+
try {
|
|
471
|
+
const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
|
|
472
|
+
this.daemon.trajectoryCapture.onAgentSpawn(
|
|
473
|
+
agent.id, providerName, config.model || null, config.role, teamSize
|
|
474
|
+
).catch(() => {});
|
|
475
|
+
} catch (e) { /* fail silent */ }
|
|
476
|
+
}
|
|
477
|
+
|
|
466
478
|
// Auto-route: let the router pick the model based on role/complexity
|
|
467
479
|
if (isAutoRouted) {
|
|
468
480
|
const { router } = this.daemon;
|
|
@@ -666,6 +678,12 @@ For normal file edits within your scope, proceed without review.
|
|
|
666
678
|
const logPath = resolve(logDir, `${sanitizeFilename(agent.name)}.log`);
|
|
667
679
|
const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
|
|
668
680
|
|
|
681
|
+
// Inject API key from credential store for agent-loop providers
|
|
682
|
+
if (provider.constructor.useAgentLoop && this.daemon.credentials) {
|
|
683
|
+
const storedKey = this.daemon.credentials.getKey(providerName);
|
|
684
|
+
if (storedKey) spawnConfig.apiKey = storedKey;
|
|
685
|
+
}
|
|
686
|
+
|
|
669
687
|
// ─── Agent Loop path (local models with built-in agentic runtime) ───
|
|
670
688
|
if (provider.constructor.useAgentLoop) {
|
|
671
689
|
const loopConfig = provider.getLoopConfig(spawnConfig);
|
|
@@ -875,6 +893,22 @@ For normal file edits within your scope, proceed without review.
|
|
|
875
893
|
});
|
|
876
894
|
}
|
|
877
895
|
|
|
896
|
+
if (this.daemon.trajectoryCapture) {
|
|
897
|
+
try {
|
|
898
|
+
if (finalStatus === 'completed') {
|
|
899
|
+
this.daemon.trajectoryCapture.onAgentComplete(agent.id, {
|
|
900
|
+
status: 'SUCCESS', exit_code: code, signal,
|
|
901
|
+
});
|
|
902
|
+
} else {
|
|
903
|
+
this.daemon.trajectoryCapture.onAgentCrash(agent.id,
|
|
904
|
+
signal ? 'Killed by signal ' + signal : 'Exit code ' + code
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
const count = (this.daemon.state.get('training_sessions_captured') || 0) + 1;
|
|
908
|
+
this.daemon.state.set('training_sessions_captured', count);
|
|
909
|
+
} catch (e) { /* fail silent */ }
|
|
910
|
+
}
|
|
911
|
+
|
|
878
912
|
this.daemon.broadcast({
|
|
879
913
|
type: 'agent:exit',
|
|
880
914
|
agentId: agent.id,
|
|
@@ -1022,6 +1056,12 @@ For normal file edits within your scope, proceed without review.
|
|
|
1022
1056
|
} catch (err) {
|
|
1023
1057
|
console.error(`[Groove] parseOutput error for ${agentId}: ${err.message}`);
|
|
1024
1058
|
}
|
|
1059
|
+
if (this.daemon.trajectoryCapture) {
|
|
1060
|
+
try {
|
|
1061
|
+
const parsed = JSON.parse(line);
|
|
1062
|
+
this.daemon.trajectoryCapture.onStdoutLine(agentId, parsed);
|
|
1063
|
+
} catch (e) { /* fail silent — non-JSON lines are expected */ }
|
|
1064
|
+
}
|
|
1025
1065
|
}
|
|
1026
1066
|
}
|
|
1027
1067
|
|
|
@@ -1161,7 +1201,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1161
1201
|
const workingDir = plan.workingDir;
|
|
1162
1202
|
preview.launch(teamId, workingDir, plan.preview).then((result) => {
|
|
1163
1203
|
if (!result.launched) {
|
|
1164
|
-
const intentionalSkips = new Set(['no_preview', 'cli', 'none']);
|
|
1204
|
+
const intentionalSkips = new Set(['no_preview', 'cli', 'none', 'no_command', 'no_dev_script']);
|
|
1165
1205
|
if (intentionalSkips.has(result.reason)) {
|
|
1166
1206
|
console.log(`[Groove] Preview for team ${teamId} intentionally skipped: ${result.reason}`);
|
|
1167
1207
|
return;
|
|
@@ -1497,6 +1537,15 @@ For normal file edits within your scope, proceed without review.
|
|
|
1497
1537
|
const agent = registry.get(agentId);
|
|
1498
1538
|
if (!agent) throw new Error(`Agent ${agentId} not found`);
|
|
1499
1539
|
|
|
1540
|
+
// Agent-loop providers: resume from saved session file
|
|
1541
|
+
const resumeProvider = getProvider(agent.provider || 'claude-code');
|
|
1542
|
+
if (resumeProvider?.constructor?.useAgentLoop) {
|
|
1543
|
+
const sessionPath = resolve(this.daemon.grooveDir, 'sessions', `${agentId}.json`);
|
|
1544
|
+
if (existsSync(sessionPath)) {
|
|
1545
|
+
return this._resumeAgentLoop(agentId, agent, message, resumeProvider);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1500
1549
|
// If no session ID, fall back to rotation (handoff brief)
|
|
1501
1550
|
if (!agent.sessionId) {
|
|
1502
1551
|
return this.daemon.rotator.rotate(agentId, { additionalPrompt: message });
|
|
@@ -1642,6 +1691,155 @@ For normal file edits within your scope, proceed without review.
|
|
|
1642
1691
|
return newAgent;
|
|
1643
1692
|
}
|
|
1644
1693
|
|
|
1694
|
+
async _resumeAgentLoop(agentId, agent, message, provider) {
|
|
1695
|
+
const { registry, locks } = this.daemon;
|
|
1696
|
+
const config = { ...agent };
|
|
1697
|
+
|
|
1698
|
+
if (this.handles.has(agentId)) {
|
|
1699
|
+
await this.kill(agentId);
|
|
1700
|
+
}
|
|
1701
|
+
registry.remove(agentId);
|
|
1702
|
+
locks.release(agentId);
|
|
1703
|
+
|
|
1704
|
+
const newAgent = registry.add({
|
|
1705
|
+
role: config.role,
|
|
1706
|
+
scope: config.scope,
|
|
1707
|
+
provider: config.provider,
|
|
1708
|
+
model: config.model,
|
|
1709
|
+
prompt: config.prompt,
|
|
1710
|
+
permission: config.permission,
|
|
1711
|
+
workingDir: config.workingDir || undefined,
|
|
1712
|
+
name: config.name,
|
|
1713
|
+
teamId: config.teamId,
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
if (config.tokensUsed > 0) {
|
|
1717
|
+
registry.update(newAgent.id, { tokensUsed: config.tokensUsed });
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
if (newAgent.scope?.length > 0) {
|
|
1721
|
+
locks.register(newAgent.id, newAgent.scope, newAgent.workingDir);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Move session file from old agent ID to new agent ID
|
|
1725
|
+
const sessionsDir = resolve(this.daemon.grooveDir, 'sessions');
|
|
1726
|
+
const oldSessionPath = resolve(sessionsDir, `${agentId}.json`);
|
|
1727
|
+
const newSessionPath = resolve(sessionsDir, `${newAgent.id}.json`);
|
|
1728
|
+
if (oldSessionPath !== newSessionPath && existsSync(oldSessionPath)) {
|
|
1729
|
+
try {
|
|
1730
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
1731
|
+
copyFileSync(oldSessionPath, newSessionPath);
|
|
1732
|
+
unlinkSync(oldSessionPath);
|
|
1733
|
+
} catch { /* AgentLoop will start fresh if file is missing */ }
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
const logDir = resolve(this.daemon.grooveDir, 'logs');
|
|
1737
|
+
mkdirSync(logDir, { recursive: true });
|
|
1738
|
+
const logPath = resolve(logDir, `${sanitizeFilename(newAgent.name)}.log`);
|
|
1739
|
+
const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
|
|
1740
|
+
logStream.write(`[${new Date().toISOString()}] GROOVE resuming agent-loop session\n`);
|
|
1741
|
+
|
|
1742
|
+
// Inject API key
|
|
1743
|
+
const spawnConfig = { ...newAgent, ...config };
|
|
1744
|
+
if (this.daemon.credentials) {
|
|
1745
|
+
const storedKey = this.daemon.credentials.getKey(newAgent.provider);
|
|
1746
|
+
if (storedKey) spawnConfig.apiKey = storedKey;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
const loopConfig = provider.getLoopConfig(spawnConfig);
|
|
1750
|
+
const loop = new AgentLoop({ daemon: this.daemon, agent: newAgent, loopConfig, logStream });
|
|
1751
|
+
|
|
1752
|
+
this.handles.set(newAgent.id, { loop, logStream });
|
|
1753
|
+
registry.update(newAgent.id, { status: 'running' });
|
|
1754
|
+
|
|
1755
|
+
if (this.daemon.timeline) {
|
|
1756
|
+
this.daemon.timeline.recordEvent('spawn', {
|
|
1757
|
+
agentId: newAgent.id, agentName: newAgent.name, role: newAgent.role,
|
|
1758
|
+
provider: newAgent.provider, model: loopConfig.model,
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
loop.on('output', (output) => {
|
|
1763
|
+
this._handleAgentOutput(newAgent.id, output);
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
loop.on('exit', ({ code, signal, status }) => {
|
|
1767
|
+
logStream.write(`[${new Date().toISOString()}] Agent loop exited: status=${status}\n`);
|
|
1768
|
+
logStream.end();
|
|
1769
|
+
this.handles.delete(newAgent.id);
|
|
1770
|
+
|
|
1771
|
+
const throttle = this._streamThrottle.get(newAgent.id);
|
|
1772
|
+
if (throttle?.timer) clearTimeout(throttle.timer);
|
|
1773
|
+
this._streamThrottle.delete(newAgent.id);
|
|
1774
|
+
this.peakContextUsage.delete(newAgent.id);
|
|
1775
|
+
this.pendingMessages.delete(newAgent.id);
|
|
1776
|
+
registry.update(newAgent.id, { status, pid: null });
|
|
1777
|
+
|
|
1778
|
+
const agentData = registry.get(newAgent.id);
|
|
1779
|
+
|
|
1780
|
+
if (this.daemon.timeline) {
|
|
1781
|
+
const evtType = status === 'completed' ? 'complete' : status === 'crashed' ? 'crash' : 'kill';
|
|
1782
|
+
this.daemon.timeline.recordEvent(evtType, {
|
|
1783
|
+
agentId: newAgent.id, agentName: newAgent.name, role: newAgent.role,
|
|
1784
|
+
finalTokens: agentData?.tokensUsed || 0, costUsd: agentData?.costUsd || 0,
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code: code || 0, signal, status });
|
|
1789
|
+
if (this.daemon.integrations) this.daemon.integrations.refreshMcpJson();
|
|
1790
|
+
|
|
1791
|
+
if (status === 'completed' && this.daemon.journalist) {
|
|
1792
|
+
const turns = agentData?.turns || 0;
|
|
1793
|
+
const tok = agentData?.tokensUsed || 0;
|
|
1794
|
+
if (turns > 1 || tok >= 100) this.daemon.journalist.requestSynthesis('completion');
|
|
1795
|
+
}
|
|
1796
|
+
this._checkPhase2(newAgent.id);
|
|
1797
|
+
|
|
1798
|
+
if (status === 'completed') {
|
|
1799
|
+
const files = this.daemon.journalist?.getAgentFiles(newAgent) || [];
|
|
1800
|
+
if (files.length > 0) this._triggerIdleQC(newAgent);
|
|
1801
|
+
this._processHandoffs(newAgent);
|
|
1802
|
+
if (this._rotatingAgents.has(newAgent.id)) {
|
|
1803
|
+
this._rotatingAgents.delete(newAgent.id);
|
|
1804
|
+
} else {
|
|
1805
|
+
this._writeCompletionHandoff(newAgent).catch(err =>
|
|
1806
|
+
console.error(`[Groove] Completion handoff failed for ${newAgent.name}:`, err.message));
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
});
|
|
1810
|
+
|
|
1811
|
+
loop.on('error', ({ message: errMsg }) => {
|
|
1812
|
+
this.daemon.broadcast({
|
|
1813
|
+
type: 'agent:output', agentId: newAgent.id,
|
|
1814
|
+
data: { type: 'activity', subtype: 'error', data: errMsg },
|
|
1815
|
+
});
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
loop.start();
|
|
1819
|
+
loop.sendMessage(message);
|
|
1820
|
+
|
|
1821
|
+
this.daemon.broadcast({
|
|
1822
|
+
type: 'rotation:complete',
|
|
1823
|
+
agentId: newAgent.id,
|
|
1824
|
+
agentName: newAgent.name,
|
|
1825
|
+
oldAgentId: agentId,
|
|
1826
|
+
reason: 'resume',
|
|
1827
|
+
tokensSaved: 0,
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
return newAgent;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
cleanupTeamSessions(teamId) {
|
|
1834
|
+
const { registry } = this.daemon;
|
|
1835
|
+
const teamAgents = registry.getAll().filter(a => a.teamId === teamId);
|
|
1836
|
+
const sessionsDir = resolve(this.daemon.grooveDir, 'sessions');
|
|
1837
|
+
for (const agent of teamAgents) {
|
|
1838
|
+
const sessionPath = resolve(sessionsDir, `${agent.id}.json`);
|
|
1839
|
+
try { if (existsSync(sessionPath)) unlinkSync(sessionPath); } catch { /* non-fatal */ }
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1645
1843
|
/**
|
|
1646
1844
|
* Stop the agent's current work without killing the agent.
|
|
1647
1845
|
* The process is terminated but the agent stays in the registry with its
|
|
@@ -1749,6 +1947,10 @@ For normal file edits within your scope, proceed without review.
|
|
|
1749
1947
|
const agent = this.daemon.registry.get(agentId);
|
|
1750
1948
|
const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
|
|
1751
1949
|
|
|
1950
|
+
if (this.daemon.trajectoryCapture) {
|
|
1951
|
+
try { this.daemon.trajectoryCapture.onUserMessage(agentId, message); } catch (e) { /* fail silent */ }
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1752
1954
|
loop.sendMessage(wrapped).catch(() => {});
|
|
1753
1955
|
return true;
|
|
1754
1956
|
}
|
|
@@ -37,6 +37,7 @@ export class GrokProvider extends Provider {
|
|
|
37
37
|
{ id: 'grok-3-mini', name: 'Grok 3 Mini', tier: 'light', maxContext: 131072, pricing: { input: 0.0003, output: 0.0005 } },
|
|
38
38
|
{ id: 'grok-imagine-image', name: 'Grok Imagine', tier: 'medium', type: 'image', pricing: { perImage: 0.07 } },
|
|
39
39
|
];
|
|
40
|
+
static useAgentLoop = true;
|
|
40
41
|
|
|
41
42
|
static isInstalled() {
|
|
42
43
|
return true; // API-only, no CLI needed
|
|
@@ -54,6 +55,20 @@ export class GrokProvider extends Provider {
|
|
|
54
55
|
return null; // No CLI
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
getLoopConfig(agent) {
|
|
59
|
+
return {
|
|
60
|
+
apiBase: 'https://api.x.ai/v1',
|
|
61
|
+
model: agent.model || 'grok-4-1-fast',
|
|
62
|
+
contextWindow: 131072,
|
|
63
|
+
temperature: 0.1,
|
|
64
|
+
maxResponseTokens: 16384,
|
|
65
|
+
stream: true,
|
|
66
|
+
apiKey: agent.apiKey,
|
|
67
|
+
headers: {},
|
|
68
|
+
introContext: agent.introContext || '',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
switchModel() {
|
|
58
73
|
return false;
|
|
59
74
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// GROOVE — State Persistence
|
|
2
2
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
3
|
|
|
4
|
-
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { readFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
|
|
5
5
|
import { writeFile } from 'node:fs/promises';
|
|
6
6
|
import { resolve } from 'path';
|
|
7
7
|
|
|
8
8
|
export class StateManager {
|
|
9
9
|
constructor(grooveDir) {
|
|
10
|
+
this.grooveDir = grooveDir;
|
|
10
11
|
this.path = resolve(grooveDir, 'state.json');
|
|
11
12
|
this.data = {};
|
|
12
13
|
}
|
|
@@ -32,4 +33,22 @@ export class StateManager {
|
|
|
32
33
|
set(key, value) {
|
|
33
34
|
this.data[key] = value;
|
|
34
35
|
}
|
|
36
|
+
|
|
37
|
+
getResumableSessions() {
|
|
38
|
+
const sessionsDir = resolve(this.grooveDir, 'sessions');
|
|
39
|
+
if (!existsSync(sessionsDir)) return [];
|
|
40
|
+
try {
|
|
41
|
+
return readdirSync(sessionsDir)
|
|
42
|
+
.filter(f => f.endsWith('.json'))
|
|
43
|
+
.map(f => f.replace('.json', ''));
|
|
44
|
+
} catch { return []; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
cleanupSessions(agentIds) {
|
|
48
|
+
const sessionsDir = resolve(this.grooveDir, 'sessions');
|
|
49
|
+
for (const id of agentIds) {
|
|
50
|
+
const sessionPath = resolve(sessionsDir, `${id}.json`);
|
|
51
|
+
try { if (existsSync(sessionPath)) unlinkSync(sessionPath); } catch { /* non-fatal */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
35
54
|
}
|