groove-dev 0.27.77 → 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.
Files changed (79) hide show
  1. package/CLAUDE.md +0 -7
  2. package/MOE_TRAINING_PIPELINE.md +216 -12
  3. package/moe-training/DEPLOY_CENTRAL_COMMAND.md +413 -0
  4. package/moe-training/client/consent.js +96 -0
  5. package/moe-training/client/envelope-builder.js +56 -0
  6. package/moe-training/client/index.js +10 -0
  7. package/moe-training/client/parsers/claude-code.js +110 -0
  8. package/moe-training/client/parsers/codex.js +80 -0
  9. package/moe-training/client/parsers/gemini.js +80 -0
  10. package/moe-training/client/parsers/grok.js +16 -0
  11. package/moe-training/client/parsers/index.js +20 -0
  12. package/moe-training/client/scrubber.js +126 -0
  13. package/moe-training/client/session-attestation.js +114 -0
  14. package/moe-training/client/step-classifier.js +51 -0
  15. package/moe-training/client/trajectory-capture.js +227 -0
  16. package/moe-training/client/transmission-queue.js +93 -0
  17. package/moe-training/package-lock.json +1266 -0
  18. package/moe-training/package.json +20 -0
  19. package/moe-training/server/enrichment.js +24 -0
  20. package/moe-training/server/index.js +119 -0
  21. package/moe-training/server/ledger.js +110 -0
  22. package/moe-training/server/routes/ingest.js +96 -0
  23. package/moe-training/server/routes/sessions.js +43 -0
  24. package/moe-training/server/routes/stats.js +31 -0
  25. package/moe-training/server/scoring.js +63 -0
  26. package/moe-training/server/session-registry.js +156 -0
  27. package/moe-training/server/stats.js +129 -0
  28. package/moe-training/server/stitcher.js +69 -0
  29. package/moe-training/server/storage.js +147 -0
  30. package/moe-training/server/verifier.js +102 -0
  31. package/moe-training/shared/constants.js +30 -0
  32. package/moe-training/shared/crypto.js +45 -0
  33. package/moe-training/shared/envelope-schema.js +220 -0
  34. package/moe-training/test/client/consent.test.js +121 -0
  35. package/moe-training/test/client/envelope-builder.test.js +107 -0
  36. package/moe-training/test/client/parsers/claude-code.test.js +119 -0
  37. package/moe-training/test/client/parsers/codex.test.js +83 -0
  38. package/moe-training/test/client/parsers/gemini.test.js +99 -0
  39. package/moe-training/test/client/scrubber.test.js +133 -0
  40. package/moe-training/test/client/session-attestation-security.test.js +95 -0
  41. package/moe-training/test/client/step-classifier.test.js +88 -0
  42. package/moe-training/test/integration/handshake.test.js +260 -0
  43. package/moe-training/test/server/ingest-security.test.js +166 -0
  44. package/moe-training/test/server/ledger.test.js +131 -0
  45. package/moe-training/test/server/scoring.test.js +242 -0
  46. package/moe-training/test/server/session-registry.test.js +125 -0
  47. package/moe-training/test/server/stitcher.test.js +157 -0
  48. package/moe-training/test/server/verifier.test.js +232 -0
  49. package/moe-training/test/shared/crypto.test.js +87 -0
  50. package/moe-training/test/shared/envelope-schema.test.js +351 -0
  51. package/node_modules/@groove-dev/cli/package.json +1 -1
  52. package/node_modules/@groove-dev/daemon/package.json +1 -1
  53. package/node_modules/@groove-dev/daemon/src/agent-loop.js +48 -5
  54. package/node_modules/@groove-dev/daemon/src/api.js +77 -0
  55. package/node_modules/@groove-dev/daemon/src/index.js +61 -0
  56. package/node_modules/@groove-dev/daemon/src/journalist.js +64 -21
  57. package/node_modules/@groove-dev/daemon/src/process.js +199 -0
  58. package/node_modules/@groove-dev/daemon/src/providers/grok.js +15 -0
  59. package/node_modules/@groove-dev/daemon/src/state.js +20 -1
  60. package/node_modules/@groove-dev/gui/dist/assets/{index-BbmPDhuW.js → index-BJgEJ9lZ.js} +1677 -1677
  61. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  62. package/node_modules/@groove-dev/gui/package.json +1 -1
  63. package/node_modules/@groove-dev/gui/src/stores/groove.js +32 -0
  64. package/node_modules/@groove-dev/gui/src/views/settings.jsx +167 -1
  65. package/package.json +1 -1
  66. package/packages/cli/package.json +1 -1
  67. package/packages/daemon/package.json +1 -1
  68. package/packages/daemon/src/agent-loop.js +48 -5
  69. package/packages/daemon/src/api.js +77 -0
  70. package/packages/daemon/src/index.js +61 -0
  71. package/packages/daemon/src/journalist.js +64 -21
  72. package/packages/daemon/src/process.js +199 -0
  73. package/packages/daemon/src/providers/grok.js +15 -0
  74. package/packages/daemon/src/state.js +20 -1
  75. package/packages/gui/dist/assets/{index-BbmPDhuW.js → index-BJgEJ9lZ.js} +1677 -1677
  76. package/packages/gui/dist/index.html +1 -1
  77. package/packages/gui/package.json +1 -1
  78. package/packages/gui/src/stores/groove.js +32 -0
  79. package/packages/gui/src/views/settings.jsx +167 -1
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@groove-dev/moe-training",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "MoE TrajectoryCapture client — captures agent session data into structured Trajectory Envelopes",
6
+ "license": "FSL-1.1-Apache-2.0",
7
+ "exports": {
8
+ ".": "./client/index.js",
9
+ "./shared/*": "./shared/*.js"
10
+ },
11
+ "scripts": {
12
+ "test": "node --test test/**/*.test.js",
13
+ "start:server": "node server/index.js"
14
+ },
15
+ "dependencies": {
16
+ "better-sqlite3": "^11.0.0",
17
+ "uuid": "^9.0.0",
18
+ "express": "^4.18.0"
19
+ }
20
+ }
@@ -0,0 +1,24 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ // TODO: LLM-as-a-Judge — classify each step's cognitive target
4
+ // (syntactic/semantic/strategic/corrective/coordinative)
5
+ // using a small model call after stitching
6
+
7
+ // TODO: Model fingerprint verification — confirm the claimed model_engine
8
+ // matches the output style using an LLM classifier
9
+
10
+ // TODO: Quality assessment — score trajectory coherence, tool usage
11
+ // efficiency, and error recovery patterns
12
+
13
+ export class EnrichmentPipeline {
14
+ async enrich(stitchedTrajectory) {
15
+ return {
16
+ ...stitchedTrajectory,
17
+ enrichment: {
18
+ cognitive_target: 'pending',
19
+ model_verified: 'pending',
20
+ quality_assessment: 'pending',
21
+ },
22
+ };
23
+ }
24
+ }
@@ -0,0 +1,119 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import express from 'express';
4
+ import { SessionRegistry } from './session-registry.js';
5
+ import { EnvelopeVerifier } from './verifier.js';
6
+ import { EnvelopeStorage } from './storage.js';
7
+ import { TrajectoryStitcher } from './stitcher.js';
8
+ import { TrajectoryScorer } from './scoring.js';
9
+ import { ContributorLedger } from './ledger.js';
10
+ import { EnrichmentPipeline } from './enrichment.js';
11
+ import { CentralStats } from './stats.js';
12
+ import { createSessionRoutes } from './routes/sessions.js';
13
+ import { createIngestRoutes } from './routes/ingest.js';
14
+ import { createStatsRoutes } from './routes/stats.js';
15
+ import { MODEL_TIERS, QUALITY_MULTIPLIERS } from '../shared/constants.js';
16
+
17
+ const PORT = parseInt(process.env.GROOVE_CENTRAL_PORT, 10) || 8443;
18
+
19
+ const sessionRegistry = new SessionRegistry();
20
+ const storage = new EnvelopeStorage();
21
+ const ledger = new ContributorLedger();
22
+ const verifier = new EnvelopeVerifier(sessionRegistry);
23
+ const stitcher = new TrajectoryStitcher(storage);
24
+ const scorer = new TrajectoryScorer({ MODEL_TIERS, QUALITY_MULTIPLIERS });
25
+ const enrichment = new EnrichmentPipeline();
26
+ const centralStats = new CentralStats(storage, ledger, sessionRegistry);
27
+
28
+ const app = express();
29
+
30
+ app.use((_req, res, next) => {
31
+ res.header('Access-Control-Allow-Origin', '*');
32
+ res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
33
+ res.header('Access-Control-Allow-Headers', 'Content-Type');
34
+ if (_req.method === 'OPTIONS') return res.sendStatus(204);
35
+ next();
36
+ });
37
+
38
+ app.use(express.json({ limit: '5mb' }));
39
+
40
+ // Per-IP rate limiting
41
+ const ipWindows = new Map();
42
+ const RATE_LIMIT_PER_MINUTE = 100;
43
+ const RATE_LIMIT_PER_HOUR = 1000;
44
+
45
+ setInterval(() => {
46
+ const now = Date.now();
47
+ for (const [ip, entry] of ipWindows) {
48
+ if (now - entry.minuteStart > 120_000 && now - entry.hourStart > 7200_000) {
49
+ ipWindows.delete(ip);
50
+ }
51
+ }
52
+ }, 300_000);
53
+
54
+ app.use((req, res, next) => {
55
+ const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
56
+ const now = Date.now();
57
+
58
+ let entry = ipWindows.get(ip);
59
+ if (!entry) {
60
+ entry = { minuteCount: 0, minuteStart: now, hourCount: 0, hourStart: now };
61
+ ipWindows.set(ip, entry);
62
+ }
63
+
64
+ if (now - entry.minuteStart > 60_000) {
65
+ entry.minuteCount = 0;
66
+ entry.minuteStart = now;
67
+ }
68
+ if (now - entry.hourStart > 3600_000) {
69
+ entry.hourCount = 0;
70
+ entry.hourStart = now;
71
+ }
72
+
73
+ entry.minuteCount++;
74
+ entry.hourCount++;
75
+
76
+ if (entry.minuteCount > RATE_LIMIT_PER_MINUTE) {
77
+ return res.status(429).json({ error: 'Too Many Requests', retryAfter: Math.ceil((entry.minuteStart + 60_000 - now) / 1000) });
78
+ }
79
+ if (entry.hourCount > RATE_LIMIT_PER_HOUR) {
80
+ return res.status(429).json({ error: 'Too Many Requests', retryAfter: Math.ceil((entry.hourStart + 3600_000 - now) / 1000) });
81
+ }
82
+
83
+ next();
84
+ });
85
+
86
+ app.use((req, res, next) => {
87
+ const start = Date.now();
88
+ res.on('finish', () => {
89
+ const duration = Date.now() - start;
90
+ console.log(`[${new Date().toISOString()}] ${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
91
+ });
92
+ next();
93
+ });
94
+
95
+ app.get('/health', (_req, res) => res.json({ status: 'ok', uptime: process.uptime() }));
96
+
97
+ app.use(createSessionRoutes(sessionRegistry));
98
+ app.use(createIngestRoutes(verifier, storage, stitcher, scorer, enrichment, ledger, sessionRegistry));
99
+ app.use(createStatsRoutes(centralStats));
100
+
101
+ const server = app.listen(PORT, () => {
102
+ console.log(`[central-command] listening on port ${PORT}`);
103
+ });
104
+
105
+ function shutdown() {
106
+ console.log('[central-command] shutting down...');
107
+ server.close(() => {
108
+ sessionRegistry.close();
109
+ ledger.close();
110
+ console.log('[central-command] shut down complete');
111
+ process.exit(0);
112
+ });
113
+ setTimeout(() => process.exit(1), 5000);
114
+ }
115
+
116
+ process.on('SIGTERM', shutdown);
117
+ process.on('SIGINT', shutdown);
118
+
119
+ export { app, server };
@@ -0,0 +1,110 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import Database from 'better-sqlite3';
4
+ import { mkdirSync, existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ export class ContributorLedger {
8
+ constructor(dbPath = './data/ledger.db') {
9
+ const dir = join(dbPath, '..');
10
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
11
+ this.db = new Database(dbPath);
12
+ this.db.pragma('journal_mode = WAL');
13
+ this._createTables();
14
+ }
15
+
16
+ _createTables() {
17
+ this.db.exec(`
18
+ CREATE TABLE IF NOT EXISTS credits (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ contributor_id TEXT NOT NULL,
21
+ session_id TEXT NOT NULL,
22
+ points REAL NOT NULL,
23
+ base_points INTEGER NOT NULL,
24
+ multiplier_breakdown TEXT NOT NULL,
25
+ created_at TEXT NOT NULL
26
+ )
27
+ `);
28
+ this.db.exec(`
29
+ CREATE TABLE IF NOT EXISTS balances (
30
+ contributor_id TEXT PRIMARY KEY,
31
+ total_points REAL NOT NULL DEFAULT 0,
32
+ total_sessions INTEGER NOT NULL DEFAULT 0,
33
+ last_credit_at TEXT,
34
+ trust_score REAL NOT NULL DEFAULT 1.0
35
+ )
36
+ `);
37
+ }
38
+
39
+ credit(contributorId, sessionId, scoreResult) {
40
+ const now = new Date().toISOString();
41
+ const breakdown = JSON.stringify({
42
+ modelMultiplier: scoreResult.modelMultiplier,
43
+ correctionBonus: scoreResult.correctionBonus,
44
+ coordinationBonus: scoreResult.coordinationBonus,
45
+ errorRecoveryBonus: scoreResult.errorRecoveryBonus,
46
+ complexityBonus: scoreResult.complexityBonus,
47
+ qualityBonus: scoreResult.qualityBonus,
48
+ });
49
+
50
+ const txn = this.db.transaction(() => {
51
+ this.db.prepare(`
52
+ INSERT INTO credits (contributor_id, session_id, points, base_points, multiplier_breakdown, created_at)
53
+ VALUES (?, ?, ?, ?, ?, ?)
54
+ `).run(contributorId, sessionId, scoreResult.totalPoints, scoreResult.basePoints, breakdown, now);
55
+
56
+ const existing = this.db.prepare('SELECT * FROM balances WHERE contributor_id = ?').get(contributorId);
57
+ if (existing) {
58
+ this.db.prepare(`
59
+ UPDATE balances SET total_points = total_points + ?, total_sessions = total_sessions + 1, last_credit_at = ?
60
+ WHERE contributor_id = ?
61
+ `).run(scoreResult.totalPoints, now, contributorId);
62
+ } else {
63
+ this.db.prepare(`
64
+ INSERT INTO balances (contributor_id, total_points, total_sessions, last_credit_at, trust_score)
65
+ VALUES (?, ?, 1, ?, 1.0)
66
+ `).run(contributorId, scoreResult.totalPoints, now);
67
+ }
68
+ });
69
+ txn();
70
+ }
71
+
72
+ getBalance(contributorId) {
73
+ return this.db.prepare('SELECT * FROM balances WHERE contributor_id = ?').get(contributorId) || null;
74
+ }
75
+
76
+ getLeaderboard(limit = 50) {
77
+ return this.db.prepare('SELECT * FROM balances ORDER BY total_points DESC LIMIT ?').all(limit);
78
+ }
79
+
80
+ getCreditsForContributor(contributorId, limit = 100) {
81
+ return this.db.prepare(
82
+ 'SELECT * FROM credits WHERE contributor_id = ? ORDER BY created_at DESC LIMIT ?'
83
+ ).all(contributorId, limit);
84
+ }
85
+
86
+ getDailyCredits(days = 7) {
87
+ const cutoff = new Date();
88
+ cutoff.setDate(cutoff.getDate() - days);
89
+
90
+ return this.db.prepare(`
91
+ SELECT DATE(created_at) as date, SUM(points) as totalPoints, COUNT(*) as totalSessions
92
+ FROM credits WHERE created_at > ? GROUP BY DATE(created_at) ORDER BY date
93
+ `).all(cutoff.toISOString());
94
+ }
95
+
96
+ adjustTrustScore(contributorId, delta) {
97
+ this.db.prepare(`
98
+ UPDATE balances SET trust_score = MAX(0, MIN(10, trust_score + ?)) WHERE contributor_id = ?
99
+ `).run(delta, contributorId);
100
+ }
101
+
102
+ getTotalPointsAwarded() {
103
+ const row = this.db.prepare('SELECT COALESCE(SUM(total_points), 0) as total FROM balances').get();
104
+ return row.total;
105
+ }
106
+
107
+ close() {
108
+ this.db.close();
109
+ }
110
+ }
@@ -0,0 +1,96 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { Router } from 'express';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+
6
+ const MAX_ENVELOPES_PER_SESSION = 200;
7
+
8
+ export function createIngestRoutes(verifier, storage, stitcher, scorer, enrichment, ledger, sessionRegistry) {
9
+ const router = Router();
10
+
11
+ router.post('/v1/training/ingest', async (req, res) => {
12
+ const envelope = req.body;
13
+
14
+ if (!envelope || !envelope.session_id) {
15
+ return res.status(400).json({ accepted: false, reason: 'malformed request: missing envelope or session_id' });
16
+ }
17
+
18
+ if (envelope.type === 'SESSION_CLOSE') {
19
+ const closeResult = verifier.verifyClose(envelope);
20
+ if (!closeResult.valid) {
21
+ return res.json({ accepted: false, reason: closeResult.reason });
22
+ }
23
+
24
+ // Server-generated envelope_id
25
+ const envelopeId = `env_${uuidv4()}`;
26
+ envelope.envelope_id = envelopeId;
27
+
28
+ // Dedup check
29
+ if (sessionRegistry.isEnvelopeProcessed(envelopeId)) {
30
+ return res.json({ accepted: false, reason: 'duplicate envelope' });
31
+ }
32
+
33
+ try {
34
+ storage.store(envelope);
35
+ } catch (err) {
36
+ if (err.message === 'STORAGE_QUOTA_EXCEEDED') {
37
+ return res.status(507).json({ accepted: false, reason: 'storage quota exceeded' });
38
+ }
39
+ throw err;
40
+ }
41
+
42
+ sessionRegistry.recordProcessedEnvelope(envelopeId, envelope.session_id);
43
+
44
+ try {
45
+ const stitched = stitcher.stitch(envelope.session_id);
46
+ if (stitched) {
47
+ const enriched = await enrichment.enrich(stitched);
48
+ const scoreResult = scorer.score(enriched);
49
+ const contributorId = stitched.contributor_id;
50
+ if (contributorId) {
51
+ ledger.credit(contributorId, envelope.session_id, scoreResult);
52
+ }
53
+ }
54
+ } catch (err) {
55
+ console.error(`[ingest] stitching/scoring error for session ${envelope.session_id}:`, err.message);
56
+ }
57
+
58
+ return res.json({ accepted: true, envelope_id: envelopeId });
59
+ }
60
+
61
+ // Per-session envelope limit
62
+ if (!sessionRegistry.checkEnvelopeCount(envelope.session_id, MAX_ENVELOPES_PER_SESSION)) {
63
+ return res.status(429).json({ accepted: false, reason: `session envelope limit exceeded (max ${MAX_ENVELOPES_PER_SESSION})` });
64
+ }
65
+
66
+ const result = verifier.verify(envelope);
67
+ if (!result.valid) {
68
+ return res.json({ accepted: false, reason: result.reason });
69
+ }
70
+
71
+ // Server-generated envelope_id
72
+ const envelopeId = `env_${uuidv4()}`;
73
+ envelope.envelope_id = envelopeId;
74
+
75
+ // Dedup check
76
+ if (sessionRegistry.isEnvelopeProcessed(envelopeId)) {
77
+ return res.json({ accepted: false, reason: 'duplicate envelope' });
78
+ }
79
+
80
+ try {
81
+ storage.store(envelope);
82
+ } catch (err) {
83
+ if (err.message === 'STORAGE_QUOTA_EXCEEDED') {
84
+ return res.status(507).json({ accepted: false, reason: 'storage quota exceeded' });
85
+ }
86
+ throw err;
87
+ }
88
+
89
+ sessionRegistry.recordProcessedEnvelope(envelopeId, envelope.session_id);
90
+ sessionRegistry.incrementEnvelopeCount(envelope.session_id);
91
+
92
+ res.json({ accepted: true, envelope_id: envelopeId });
93
+ });
94
+
95
+ return router;
96
+ }
@@ -0,0 +1,43 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { Router } from 'express';
4
+
5
+ export function createSessionRoutes(sessionRegistry) {
6
+ const router = Router();
7
+
8
+ router.post('/v1/sessions/open', (req, res) => {
9
+ const { session_id, public_key, provider, model, machine_fingerprint, app_version_hash, groove_version } = req.body;
10
+
11
+ if (!session_id || !public_key || !provider || !model || !machine_fingerprint || !app_version_hash || !groove_version) {
12
+ return res.status(400).json({ error: 'missing required fields', required: ['session_id', 'public_key', 'provider', 'model', 'machine_fingerprint', 'app_version_hash', 'groove_version'] });
13
+ }
14
+
15
+ const result = sessionRegistry.openSession(session_id, public_key, provider, model, machine_fingerprint, app_version_hash, groove_version);
16
+
17
+ if (result.rateLimited) {
18
+ return res.status(429).json({ error: 'rate limited: too many sessions from this machine' });
19
+ }
20
+
21
+ res.json({ server_public_key: result.serverPublicKey });
22
+ });
23
+
24
+ router.post('/v1/sessions/close', (req, res) => {
25
+ const { session_id } = req.body;
26
+ if (!session_id) {
27
+ return res.status(400).json({ error: 'missing session_id' });
28
+ }
29
+
30
+ const session = sessionRegistry.getSession(session_id);
31
+ if (!session) {
32
+ return res.status(404).json({ error: 'unknown session_id' });
33
+ }
34
+ if (session.status === 'closed') {
35
+ return res.json({ closed: true, already_closed: true });
36
+ }
37
+
38
+ sessionRegistry.closeSession(session_id);
39
+ res.json({ closed: true });
40
+ });
41
+
42
+ return router;
43
+ }
@@ -0,0 +1,31 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { Router } from 'express';
4
+
5
+ export function createStatsRoutes(centralStats) {
6
+ const router = Router();
7
+
8
+ router.get('/v1/stats/summary', (_req, res) => {
9
+ res.json(centralStats.summary());
10
+ });
11
+
12
+ router.get('/v1/stats/daily', (req, res) => {
13
+ const days = parseInt(req.query.days, 10) || 7;
14
+ res.json(centralStats.dailyGrowth(days));
15
+ });
16
+
17
+ router.get('/v1/stats/models', (_req, res) => {
18
+ res.json(centralStats.modelBreakdown());
19
+ });
20
+
21
+ router.get('/v1/stats/providers', (_req, res) => {
22
+ res.json(centralStats.providerBreakdown());
23
+ });
24
+
25
+ router.get('/v1/stats/leaderboard', (req, res) => {
26
+ const limit = parseInt(req.query.limit, 10) || 10;
27
+ res.json(centralStats.topContributors(limit));
28
+ });
29
+
30
+ return router;
31
+ }
@@ -0,0 +1,63 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ export class TrajectoryScorer {
4
+ constructor(constants) {
5
+ this.modelTiers = constants.MODEL_TIERS || {};
6
+ this.multipliers = constants.QUALITY_MULTIPLIERS || {};
7
+ }
8
+
9
+ score(stitchedTrajectory) {
10
+ const steps = stitchedTrajectory.trajectory_log || [];
11
+ const metadata = stitchedTrajectory.metadata || {};
12
+
13
+ // DERIVE from actual steps — never trust client-reported outcome
14
+ const correctionSteps = steps.filter(s => s.type === 'correction').length;
15
+ const coordinationSteps = steps.filter(s => s.type === 'coordination').length;
16
+ const errorSteps = steps.filter(s => s.type === 'error').length;
17
+ const resolutionSteps = steps.filter(s => s.type === 'resolution').length;
18
+ const errorsRecovered = Math.min(errorSteps, resolutionSteps);
19
+
20
+ const basePoints = Math.min(steps.length, 5000);
21
+
22
+ const modelEngine = metadata.model_engine || '';
23
+ const modelMultiplier = this.modelTiers[modelEngine] || 1;
24
+
25
+ // Correction bonus: 10x on ACTUAL correction steps, capped at 30% of trajectory
26
+ const maxCorrectionSteps = Math.floor(steps.length * 0.3);
27
+ const cappedCorrectionSteps = Math.min(correctionSteps, maxCorrectionSteps);
28
+ const correctionBonus = cappedCorrectionSteps * (this.multipliers.correction || 10);
29
+
30
+ // Coordination bonus: 5x on ACTUAL coordination steps, capped at 20% of trajectory
31
+ const maxCoordSteps = Math.floor(steps.length * 0.2);
32
+ const cappedCoordSteps = Math.min(coordinationSteps, maxCoordSteps);
33
+ const coordinationBonus = cappedCoordSteps * (this.multipliers.coordination || 5);
34
+
35
+ // Error recovery: 3x if errors AND resolutions exist in the trajectory
36
+ const errorRecoveryBonus = errorsRecovered > 0 ? Math.min(errorsRecovered, errorSteps) * (this.multipliers.errorRecovery || 3) : 0;
37
+
38
+ // Complexity bonus: only if validated value
39
+ const validComplexity = ['light', 'medium', 'heavy'];
40
+ const complexityBonus = validComplexity.includes(metadata.task_complexity) && metadata.task_complexity === 'heavy'
41
+ ? basePoints * 1
42
+ : 0;
43
+
44
+ // Quality bonus: server-derived from trajectory completeness
45
+ const hasResolution = resolutionSteps > 0;
46
+ const reasonableLength = steps.length >= 5 && steps.length <= 5000;
47
+ const subtotal = (basePoints * modelMultiplier) + correctionBonus + coordinationBonus + errorRecoveryBonus + complexityBonus;
48
+ const qualityBonus = (hasResolution && reasonableLength) ? Math.floor(subtotal * 0.1) : 0;
49
+
50
+ const totalPoints = subtotal + qualityBonus;
51
+
52
+ return {
53
+ basePoints,
54
+ modelMultiplier,
55
+ correctionBonus,
56
+ coordinationBonus,
57
+ errorRecoveryBonus,
58
+ complexityBonus,
59
+ qualityBonus,
60
+ totalPoints,
61
+ };
62
+ }
63
+ }
@@ -0,0 +1,156 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import Database from 'better-sqlite3';
4
+ import { mkdirSync, existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { generateECDHKeypair, deriveSharedSecret } from '../shared/crypto.js';
7
+
8
+ export class SessionRegistry {
9
+ constructor(dbPath = './data/sessions.db') {
10
+ const dir = join(dbPath, '..');
11
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
12
+ this.db = new Database(dbPath);
13
+ this.db.pragma('journal_mode = WAL');
14
+ this.db.pragma('foreign_keys = ON');
15
+ this._createTables();
16
+ }
17
+
18
+ _createTables() {
19
+ this.db.exec(`
20
+ CREATE TABLE IF NOT EXISTS sessions (
21
+ session_id TEXT PRIMARY KEY,
22
+ server_private_key TEXT NOT NULL,
23
+ server_public_key TEXT NOT NULL,
24
+ shared_secret TEXT NOT NULL,
25
+ client_public_key TEXT NOT NULL,
26
+ provider TEXT NOT NULL,
27
+ model TEXT NOT NULL,
28
+ machine_fingerprint TEXT NOT NULL,
29
+ app_version_hash TEXT NOT NULL,
30
+ groove_version TEXT NOT NULL,
31
+ expected_sequence INTEGER DEFAULT 0,
32
+ envelope_count INTEGER DEFAULT 0,
33
+ status TEXT DEFAULT 'active',
34
+ created_at TEXT NOT NULL,
35
+ closed_at TEXT
36
+ )
37
+ `);
38
+
39
+ this.db.exec(`
40
+ CREATE TABLE IF NOT EXISTS processed_envelopes (
41
+ envelope_id TEXT PRIMARY KEY,
42
+ session_id TEXT NOT NULL,
43
+ processed_at TEXT NOT NULL
44
+ )
45
+ `);
46
+
47
+ // Migration: add envelope_count if table exists but column doesn't
48
+ try {
49
+ this.db.prepare('SELECT envelope_count FROM sessions LIMIT 1').get();
50
+ } catch {
51
+ try { this.db.exec('ALTER TABLE sessions ADD COLUMN envelope_count INTEGER DEFAULT 0'); } catch { /* already exists */ }
52
+ }
53
+ }
54
+
55
+ openSession(sessionId, clientPublicKey, provider, model, machineFingerprint, appVersionHash, grooveVersion) {
56
+ if (this.rateLimitCheck(machineFingerprint)) {
57
+ return { rateLimited: true };
58
+ }
59
+
60
+ const keypair = generateECDHKeypair();
61
+ const sharedSecret = deriveSharedSecret(keypair.privateKey, clientPublicKey);
62
+
63
+ this.db.prepare(`
64
+ INSERT INTO sessions (session_id, server_private_key, server_public_key, shared_secret,
65
+ client_public_key, provider, model, machine_fingerprint, app_version_hash,
66
+ groove_version, expected_sequence, envelope_count, status, created_at)
67
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 'active', ?)
68
+ `).run(
69
+ sessionId, keypair.privateKey, keypair.publicKey, sharedSecret,
70
+ clientPublicKey, provider, model, machineFingerprint, appVersionHash,
71
+ grooveVersion, new Date().toISOString()
72
+ );
73
+
74
+ return { serverPublicKey: keypair.publicKey };
75
+ }
76
+
77
+ getSession(sessionId) {
78
+ return this.db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(sessionId) || null;
79
+ }
80
+
81
+ checkAndIncrementSequence(sessionId, expectedSequence) {
82
+ const txn = this.db.transaction(() => {
83
+ const row = this.db.prepare(
84
+ "SELECT expected_sequence FROM sessions WHERE session_id = ? AND status = 'active'"
85
+ ).get(sessionId);
86
+
87
+ if (!row) {
88
+ return { valid: false, reason: 'session not found or not active' };
89
+ }
90
+
91
+ if (row.expected_sequence !== expectedSequence) {
92
+ return { valid: false, reason: `sequence mismatch: expected ${row.expected_sequence}, got ${expectedSequence}` };
93
+ }
94
+
95
+ this.db.prepare(
96
+ 'UPDATE sessions SET expected_sequence = expected_sequence + 1 WHERE session_id = ?'
97
+ ).run(sessionId);
98
+
99
+ return { valid: true };
100
+ });
101
+
102
+ return txn.immediate();
103
+ }
104
+
105
+ incrementSequence(sessionId) {
106
+ const result = this.db.prepare(`
107
+ UPDATE sessions SET expected_sequence = expected_sequence + 1
108
+ WHERE session_id = ? RETURNING expected_sequence
109
+ `).get(sessionId);
110
+ return result ? result.expected_sequence : null;
111
+ }
112
+
113
+ checkEnvelopeCount(sessionId, max = 200) {
114
+ const row = this.db.prepare('SELECT envelope_count FROM sessions WHERE session_id = ?').get(sessionId);
115
+ if (!row) return false;
116
+ return row.envelope_count < max;
117
+ }
118
+
119
+ incrementEnvelopeCount(sessionId) {
120
+ this.db.prepare('UPDATE sessions SET envelope_count = envelope_count + 1 WHERE session_id = ?').run(sessionId);
121
+ }
122
+
123
+ isEnvelopeProcessed(envelopeId) {
124
+ const row = this.db.prepare('SELECT 1 FROM processed_envelopes WHERE envelope_id = ?').get(envelopeId);
125
+ return !!row;
126
+ }
127
+
128
+ recordProcessedEnvelope(envelopeId, sessionId) {
129
+ this.db.prepare(
130
+ 'INSERT OR IGNORE INTO processed_envelopes (envelope_id, session_id, processed_at) VALUES (?, ?, ?)'
131
+ ).run(envelopeId, sessionId, new Date().toISOString());
132
+ }
133
+
134
+ closeSession(sessionId) {
135
+ this.db.prepare(`
136
+ UPDATE sessions SET status = 'closed', closed_at = ? WHERE session_id = ?
137
+ `).run(new Date().toISOString(), sessionId);
138
+ }
139
+
140
+ getActiveSessions() {
141
+ return this.db.prepare("SELECT * FROM sessions WHERE status = 'active'").all();
142
+ }
143
+
144
+ rateLimitCheck(machineFingerprint) {
145
+ const oneHourAgo = new Date(Date.now() - 3600_000).toISOString();
146
+ const row = this.db.prepare(`
147
+ SELECT COUNT(*) as cnt FROM sessions
148
+ WHERE machine_fingerprint = ? AND created_at > ?
149
+ `).get(machineFingerprint, oneHourAgo);
150
+ return row.cnt >= 20;
151
+ }
152
+
153
+ close() {
154
+ this.db.close();
155
+ }
156
+ }