prism-mcp-server 5.2.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -230,42 +230,51 @@ export class SqliteStorage {
230
230
  catch {
231
231
  // Column doesn't exist — do the table rebuild
232
232
  debugLog("[SqliteStorage] v3.0 migration: rebuilding session_handoffs with role column");
233
- // Step 1: Create new table with correct constraint
234
- await this.db.execute(`
235
- CREATE TABLE session_handoffs_v2 (
236
- id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
237
- project TEXT NOT NULL,
238
- user_id TEXT NOT NULL DEFAULT 'default',
239
- role TEXT NOT NULL DEFAULT 'global',
240
- last_summary TEXT DEFAULT NULL,
241
- pending_todo TEXT DEFAULT '[]',
242
- active_decisions TEXT DEFAULT '[]',
243
- keywords TEXT DEFAULT '[]',
244
- key_context TEXT DEFAULT NULL,
245
- active_branch TEXT DEFAULT NULL,
246
- version INTEGER NOT NULL DEFAULT 1,
247
- metadata TEXT DEFAULT '{}',
248
- created_at TEXT DEFAULT (datetime('now')),
249
- updated_at TEXT DEFAULT (datetime('now')),
250
- UNIQUE(project, user_id, role)
251
- )
252
- `);
253
- // Step 2: Copy data with explicit column names (Pro-Tip 2)
254
- await this.db.execute(`
255
- INSERT INTO session_handoffs_v2
256
- (id, project, user_id, role, last_summary, pending_todo,
257
- active_decisions, keywords, key_context, active_branch,
258
- version, metadata, created_at, updated_at)
259
- SELECT
260
- id, project, user_id, 'global', last_summary, pending_todo,
261
- active_decisions, keywords, key_context, active_branch,
262
- version, metadata, created_at, updated_at
263
- FROM session_handoffs
264
- `);
265
- // Step 3: Drop old and rename
266
- await this.db.execute(`DROP TABLE session_handoffs`);
267
- await this.db.execute(`ALTER TABLE session_handoffs_v2 RENAME TO session_handoffs`);
268
- debugLog("[SqliteStorage] v3.0 migration: session_handoffs rebuilt with UNIQUE(project, user_id, role)");
233
+ const tx = await this.db.transaction();
234
+ try {
235
+ // Step 1: Create new table with correct constraint
236
+ await tx.execute(`
237
+ CREATE TABLE session_handoffs_v2 (
238
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
239
+ project TEXT NOT NULL,
240
+ user_id TEXT NOT NULL DEFAULT 'default',
241
+ role TEXT NOT NULL DEFAULT 'global',
242
+ last_summary TEXT DEFAULT NULL,
243
+ pending_todo TEXT DEFAULT '[]',
244
+ active_decisions TEXT DEFAULT '[]',
245
+ keywords TEXT DEFAULT '[]',
246
+ key_context TEXT DEFAULT NULL,
247
+ active_branch TEXT DEFAULT NULL,
248
+ version INTEGER NOT NULL DEFAULT 1,
249
+ metadata TEXT DEFAULT '{}',
250
+ created_at TEXT DEFAULT (datetime('now')),
251
+ updated_at TEXT DEFAULT (datetime('now')),
252
+ UNIQUE(project, user_id, role)
253
+ )
254
+ `);
255
+ // Step 2: Copy data with explicit column names (Pro-Tip 2)
256
+ await tx.execute(`
257
+ INSERT INTO session_handoffs_v2
258
+ (id, project, user_id, role, last_summary, pending_todo,
259
+ active_decisions, keywords, key_context, active_branch,
260
+ version, metadata, created_at, updated_at)
261
+ SELECT
262
+ id, project, user_id, 'global', last_summary, pending_todo,
263
+ active_decisions, keywords, key_context, active_branch,
264
+ version, metadata, created_at, updated_at
265
+ FROM session_handoffs
266
+ `);
267
+ // Step 3: Drop old and rename
268
+ await tx.execute(`DROP TABLE session_handoffs`);
269
+ await tx.execute(`ALTER TABLE session_handoffs_v2 RENAME TO session_handoffs`);
270
+ await tx.commit();
271
+ debugLog("[SqliteStorage] v3.0 migration: session_handoffs rebuilt with UNIQUE(project, user_id, role)");
272
+ }
273
+ catch (txError) {
274
+ await tx.rollback();
275
+ console.error("[SqliteStorage] v3.0 migration: session_handoffs rebuild failed, rolled back", txError);
276
+ throw txError;
277
+ }
269
278
  }
270
279
  // agent_registry: new table for Hivemind coordination
271
280
  await this.db.execute(`
@@ -393,6 +402,49 @@ export class SqliteStorage {
393
402
  if (!e.message?.includes("duplicate column name"))
394
403
  throw e;
395
404
  }
405
+ // ── v5.3: Hivemind Watchdog columns on agent_registry ────────
406
+ // These enable the server-side health monitor to detect frozen agents,
407
+ // task overruns, and infinite loops. Safe no-op if columns already exist.
408
+ try {
409
+ await this.db.execute(`ALTER TABLE agent_registry ADD COLUMN task_start_time TEXT DEFAULT NULL`);
410
+ debugLog("[SqliteStorage] v5.3 migration: added task_start_time column");
411
+ }
412
+ catch (e) {
413
+ if (!e.message?.includes("duplicate column name"))
414
+ throw e;
415
+ }
416
+ try {
417
+ await this.db.execute(`ALTER TABLE agent_registry ADD COLUMN expected_duration_minutes INTEGER DEFAULT NULL`);
418
+ debugLog("[SqliteStorage] v5.3 migration: added expected_duration_minutes column");
419
+ }
420
+ catch (e) {
421
+ if (!e.message?.includes("duplicate column name"))
422
+ throw e;
423
+ }
424
+ try {
425
+ await this.db.execute(`ALTER TABLE agent_registry ADD COLUMN task_hash TEXT DEFAULT NULL`);
426
+ debugLog("[SqliteStorage] v5.3 migration: added task_hash column");
427
+ }
428
+ catch (e) {
429
+ if (!e.message?.includes("duplicate column name"))
430
+ throw e;
431
+ }
432
+ try {
433
+ await this.db.execute(`ALTER TABLE agent_registry ADD COLUMN loop_count INTEGER DEFAULT 0`);
434
+ debugLog("[SqliteStorage] v5.3 migration: added loop_count column");
435
+ }
436
+ catch (e) {
437
+ if (!e.message?.includes("duplicate column name"))
438
+ throw e;
439
+ }
440
+ // ─── v5.5 Migration: Superposed Distributed Memory (SDM) ───
441
+ await this.db.execute(`
442
+ CREATE TABLE IF NOT EXISTS sdm_state (
443
+ project TEXT PRIMARY KEY,
444
+ counters BLOB NOT NULL,
445
+ updated_at TEXT DEFAULT (datetime('now'))
446
+ )
447
+ `);
396
448
  }
397
449
  // ─── PostgREST Filter Parser ───────────────────────────────
398
450
  //
@@ -1184,7 +1236,35 @@ export class SqliteStorage {
1184
1236
  created_at: row.created_at,
1185
1237
  }));
1186
1238
  }
1187
- // ─── v2.0 Dashboard ─────────────────────────────────────────
1239
+ // ─── v2.0 Dashboard ─────────────────────────────────────────────
1240
+ // ─── v5.4: CRDT Base State Retrieval ───────────────────────
1241
+ //
1242
+ // Reads a historical handoff snapshot by version number.
1243
+ // Used by the CRDT merge engine to reconstruct the base state
1244
+ // that both concurrent agents originally read.
1245
+ //
1246
+ // This leverages the EXISTING session_handoffs_history table
1247
+ // (created by Time Travel v2.0) — no schema changes needed.
1248
+ async getHandoffAtVersion(project, version, userId = "default") {
1249
+ const result = await this.db.execute({
1250
+ sql: `SELECT snapshot FROM session_handoffs_history
1251
+ WHERE project = ? AND user_id = ? AND version = ?
1252
+ LIMIT 1`,
1253
+ args: [project, userId, version],
1254
+ });
1255
+ if (result.rows.length === 0 || !result.rows[0].snapshot)
1256
+ return null;
1257
+ try {
1258
+ const snapshot = result.rows[0].snapshot;
1259
+ if (typeof snapshot === "string")
1260
+ return JSON.parse(snapshot);
1261
+ return snapshot;
1262
+ }
1263
+ catch {
1264
+ console.error(`[SqliteStorage] Failed to parse history snapshot for ${project} v${version}`);
1265
+ return null;
1266
+ }
1267
+ }
1188
1268
  async listProjects() {
1189
1269
  const result = await this.db.execute("SELECT DISTINCT project FROM session_handoffs ORDER BY project ASC");
1190
1270
  return result.rows.map(row => row.project);
@@ -1296,6 +1376,15 @@ export class SqliteStorage {
1296
1376
  const totalActiveEntries = Number(totalsResult.rows[0]?.active ?? 0);
1297
1377
  const totalHandoffs = Number(totalsResult.rows[0]?.handoffs ?? 0);
1298
1378
  const totalRollups = Number(totalsResult.rows[0]?.rollups ?? 0);
1379
+ // ── v5.4: Aggregate CRDT merge counts from handoff metadata ───
1380
+ // Each successful CRDT merge increments metadata.crdt_merge_count.
1381
+ // We sum across all handoffs for the health report.
1382
+ const mergesResult = await this.db.execute({
1383
+ sql: `SELECT SUM(CAST(json_extract(metadata, '$.crdt_merge_count') AS INTEGER)) as total
1384
+ FROM session_handoffs WHERE user_id = ?`,
1385
+ args: [userId],
1386
+ });
1387
+ const totalCrdtMerges = Number(mergesResult.rows[0]?.total ?? 0);
1299
1388
  // ── Return the complete raw health stats ─────────────────────
1300
1389
  // healthCheck.ts engine will analyze this + produce HealthReport
1301
1390
  return {
@@ -1306,6 +1395,7 @@ export class SqliteStorage {
1306
1395
  totalActiveEntries, // grand total of active entries
1307
1396
  totalHandoffs, // grand total of handoff records
1308
1397
  totalRollups, // grand total of rollup entries
1398
+ totalCrdtMerges, // v5.4: total CRDT auto-merges
1309
1399
  };
1310
1400
  }
1311
1401
  // ─── v3.0: Agent Registry (Hivemind) ───────────────────────
@@ -1317,8 +1407,9 @@ export class SqliteStorage {
1317
1407
  // Try INSERT first
1318
1408
  await this.db.execute({
1319
1409
  sql: `INSERT INTO agent_registry
1320
- (id, project, user_id, role, agent_name, status, current_task)
1321
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
1410
+ (id, project, user_id, role, agent_name, status, current_task,
1411
+ task_start_time, expected_duration_minutes, task_hash, loop_count)
1412
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), NULL, NULL, 0)`,
1322
1413
  args: [
1323
1414
  id,
1324
1415
  entry.project,
@@ -1333,13 +1424,17 @@ export class SqliteStorage {
1333
1424
  return { ...entry, id, status };
1334
1425
  }
1335
1426
  catch (err) {
1336
- // UNIQUE constraint → update existing
1427
+ // UNIQUE constraint → update existing — reset watchdog fields on re-registration
1337
1428
  const msg = err instanceof Error ? err.message : String(err);
1338
1429
  if (msg.includes("UNIQUE") || msg.includes("constraint")) {
1339
1430
  await this.db.execute({
1340
1431
  sql: `UPDATE agent_registry
1341
1432
  SET agent_name = ?, status = ?, current_task = ?,
1342
- last_heartbeat = datetime('now')
1433
+ last_heartbeat = datetime('now'),
1434
+ task_start_time = datetime('now'),
1435
+ expected_duration_minutes = NULL,
1436
+ task_hash = NULL,
1437
+ loop_count = 0
1343
1438
  WHERE project = ? AND user_id = ? AND role = ?`,
1344
1439
  args: [
1345
1440
  entry.agent_name ?? null,
@@ -1356,13 +1451,52 @@ export class SqliteStorage {
1356
1451
  throw err;
1357
1452
  }
1358
1453
  }
1359
- async heartbeatAgent(project, userId, role, currentTask) {
1360
- const setClauses = ["last_heartbeat = datetime('now')"];
1361
- const args = [];
1454
+ async heartbeatAgent(project, userId, role, currentTask, expectedDurationMinutes) {
1455
+ // v5.3: Loop detection compute task hash and compare with stored value.
1456
+ // If the hash matches, increment loop_count. If different, reset counter.
1457
+ // This runs inline with the heartbeat UPDATE for zero additional queries.
1458
+ const newTaskHash = currentTask
1459
+ ? this._simpleHash(currentTask)
1460
+ : null;
1461
+ // Fetch current agent state for loop comparison (single SELECT)
1462
+ const current = await this.db.execute({
1463
+ sql: `SELECT task_hash, loop_count FROM agent_registry
1464
+ WHERE project = ? AND user_id = ? AND role = ?`,
1465
+ args: [project, userId, role],
1466
+ });
1467
+ const existingHash = current.rows[0]?.task_hash;
1468
+ const existingLoopCount = current.rows[0]?.loop_count || 0;
1469
+ // Determine if task changed
1470
+ const taskChanged = newTaskHash !== null && newTaskHash !== existingHash;
1471
+ const sameTask = newTaskHash !== null && newTaskHash === existingHash;
1472
+ const newLoopCount = sameTask
1473
+ ? existingLoopCount + 1
1474
+ : (taskChanged ? 0 : existingLoopCount);
1475
+ // Auto-detect LOOPING: if same task repeated >= 5 times, flag it
1476
+ const newStatus = newLoopCount >= 5 ? "looping" : "active";
1477
+ const setClauses = [
1478
+ "last_heartbeat = datetime('now')",
1479
+ "loop_count = ?",
1480
+ "status = ?",
1481
+ ];
1482
+ const args = [newLoopCount, newStatus];
1362
1483
  if (currentTask !== undefined) {
1363
1484
  setClauses.push("current_task = ?");
1364
1485
  args.push(currentTask);
1365
1486
  }
1487
+ if (newTaskHash !== null) {
1488
+ setClauses.push("task_hash = ?");
1489
+ args.push(newTaskHash);
1490
+ }
1491
+ // Task changed → reset task_start_time
1492
+ if (taskChanged) {
1493
+ setClauses.push("task_start_time = datetime('now')");
1494
+ }
1495
+ // Store expected duration if provided
1496
+ if (expectedDurationMinutes !== undefined) {
1497
+ setClauses.push("expected_duration_minutes = ?");
1498
+ args.push(expectedDurationMinutes);
1499
+ }
1366
1500
  args.push(project, userId, role);
1367
1501
  await this.db.execute({
1368
1502
  sql: `UPDATE agent_registry
@@ -1371,18 +1505,30 @@ export class SqliteStorage {
1371
1505
  args,
1372
1506
  });
1373
1507
  }
1508
+ /**
1509
+ * Simple string hash for loop detection.
1510
+ * Uses DJB2 — fast, deterministic, no crypto overhead.
1511
+ */
1512
+ _simpleHash(str) {
1513
+ let hash = 5381;
1514
+ for (let i = 0; i < str.length; i++) {
1515
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0xFFFFFFFF;
1516
+ }
1517
+ return hash.toString(16);
1518
+ }
1374
1519
  async listTeam(project, userId, staleMinutes = 30) {
1375
- // Auto-prune stale agents first
1520
+ // Auto-prune OFFLINE agents (>30min without heartbeat)
1376
1521
  await this.db.execute({
1377
1522
  sql: `DELETE FROM agent_registry
1378
1523
  WHERE project = ? AND user_id = ?
1379
1524
  AND last_heartbeat < datetime('now', '-' || ? || ' minutes')`,
1380
1525
  args: [project, userId, staleMinutes],
1381
1526
  });
1382
- // Fetch remaining active agents
1527
+ // Fetch remaining agents (including watchdog columns)
1383
1528
  const result = await this.db.execute({
1384
1529
  sql: `SELECT id, project, user_id, role, agent_name, status,
1385
- current_task, last_heartbeat, created_at
1530
+ current_task, last_heartbeat, created_at,
1531
+ task_start_time, expected_duration_minutes, task_hash, loop_count
1386
1532
  FROM agent_registry
1387
1533
  WHERE project = ? AND user_id = ?
1388
1534
  ORDER BY last_heartbeat DESC`,
@@ -1398,6 +1544,10 @@ export class SqliteStorage {
1398
1544
  current_task: r.current_task,
1399
1545
  last_heartbeat: r.last_heartbeat,
1400
1546
  created_at: r.created_at,
1547
+ task_start_time: r.task_start_time,
1548
+ expected_duration_minutes: r.expected_duration_minutes,
1549
+ task_hash: r.task_hash,
1550
+ loop_count: r.loop_count || 0,
1401
1551
  }));
1402
1552
  }
1403
1553
  async deregisterAgent(project, userId, role) {
@@ -1407,6 +1557,57 @@ export class SqliteStorage {
1407
1557
  });
1408
1558
  debugLog(`[SqliteStorage] Agent deregistered: ${project}/${role}`);
1409
1559
  }
1560
+ // ─── v5.3: Hivemind Watchdog Methods ───────────────────────
1561
+ async getAllAgents(userId) {
1562
+ const result = await this.db.execute({
1563
+ sql: `SELECT id, project, user_id, role, agent_name, status,
1564
+ current_task, last_heartbeat, created_at,
1565
+ task_start_time, expected_duration_minutes, task_hash, loop_count
1566
+ FROM agent_registry
1567
+ WHERE user_id = ?
1568
+ ORDER BY project, role`,
1569
+ args: [userId],
1570
+ });
1571
+ return result.rows.map(r => ({
1572
+ id: r.id,
1573
+ project: r.project,
1574
+ user_id: r.user_id,
1575
+ role: r.role,
1576
+ agent_name: r.agent_name,
1577
+ status: r.status,
1578
+ current_task: r.current_task,
1579
+ last_heartbeat: r.last_heartbeat,
1580
+ created_at: r.created_at,
1581
+ task_start_time: r.task_start_time,
1582
+ expected_duration_minutes: r.expected_duration_minutes,
1583
+ task_hash: r.task_hash,
1584
+ loop_count: r.loop_count || 0,
1585
+ }));
1586
+ }
1587
+ async updateAgentStatus(project, userId, role, status, additionalFields) {
1588
+ const setClauses = ["status = ?"];
1589
+ const args = [status];
1590
+ // Allow watchdog to set arbitrary safe fields (e.g., loop_count reset)
1591
+ const ALLOWED_FIELDS = new Set([
1592
+ "loop_count", "task_start_time", "expected_duration_minutes",
1593
+ "task_hash", "current_task",
1594
+ ]);
1595
+ if (additionalFields) {
1596
+ for (const [key, val] of Object.entries(additionalFields)) {
1597
+ if (ALLOWED_FIELDS.has(key)) {
1598
+ setClauses.push(`${key} = ?`);
1599
+ args.push(val);
1600
+ }
1601
+ }
1602
+ }
1603
+ args.push(project, userId, role);
1604
+ await this.db.execute({
1605
+ sql: `UPDATE agent_registry
1606
+ SET ${setClauses.join(", ")}
1607
+ WHERE project = ? AND user_id = ? AND role = ?`,
1608
+ args,
1609
+ });
1610
+ }
1410
1611
  // ─── System Settings (v3.0 Dashboard) — proxy to configStorage ───
1411
1612
  async getSetting(key) {
1412
1613
  const val = await cfgGet(key, "");
@@ -1714,4 +1915,54 @@ export class SqliteStorage {
1714
1915
  }
1715
1916
  return { purged: eligible, eligible, reclaimedBytes };
1716
1917
  }
1918
+ // ─── v5.5: SDM Persistence ───────────────────────────────────
1919
+ async loadSdmState(project) {
1920
+ const result = await this.db.execute({
1921
+ sql: `SELECT counters FROM sdm_state WHERE project = ?`,
1922
+ args: [project],
1923
+ });
1924
+ if (result.rows.length === 0) {
1925
+ return null;
1926
+ }
1927
+ const blob = result.rows[0].counters;
1928
+ // libSQL returns blobs as ArrayBuffer.
1929
+ // We instantiate a Float32Array directly over the buffer.
1930
+ if (blob instanceof ArrayBuffer) {
1931
+ return new Float32Array(blob);
1932
+ }
1933
+ else if (blob instanceof Uint8Array) {
1934
+ // In case it's returned as a Uint8Array view
1935
+ return new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);
1936
+ }
1937
+ else {
1938
+ throw new Error(`[SqliteStorage] Unexpected blob type returned for SDM state`);
1939
+ }
1940
+ }
1941
+ async saveSdmState(project, state) {
1942
+ // The state is a Float32Array. We need its underlying buffer for SQLite.
1943
+ // Wrap in Uint8Array to satisfy @libsql/client InValue typing which rejects SharedArrayBuffer
1944
+ const buffer = new Uint8Array(state.buffer, state.byteOffset, state.byteLength);
1945
+ // We do an UPSERT (INSERT ... ON CONFLICT REPLACE).
1946
+ await this.db.execute({
1947
+ sql: `INSERT INTO sdm_state (project, counters, updated_at)
1948
+ VALUES (?, ?, datetime('now'))
1949
+ ON CONFLICT(project) DO UPDATE SET
1950
+ counters = excluded.counters,
1951
+ updated_at = excluded.updated_at`,
1952
+ args: [project, buffer],
1953
+ });
1954
+ debugLog(`[SqliteStorage] Persisted SDM state to disk for project: ${project}`);
1955
+ }
1956
+ async getAllProjectEmbeddings(project) {
1957
+ const res = await this.db.execute({
1958
+ sql: `SELECT id, summary, embedding_compressed FROM session_ledger
1959
+ WHERE project = ? AND deleted_at IS NULL AND embedding_compressed IS NOT NULL`,
1960
+ args: [project]
1961
+ });
1962
+ return res.rows.map(r => ({
1963
+ id: r.id,
1964
+ summary: r.summary,
1965
+ embedding_compressed: r.embedding_compressed
1966
+ }));
1967
+ }
1717
1968
  }
@@ -186,6 +186,29 @@ export class SupabaseStorage {
186
186
  });
187
187
  return (Array.isArray(data) ? data : []);
188
188
  }
189
+ // ─── v5.4: CRDT Base State Retrieval ───────────────────────
190
+ //
191
+ // Reads a historical handoff snapshot by version number via
192
+ // Supabase REST API. Used by the CRDT merge engine.
193
+ async getHandoffAtVersion(project, version, userId = "default") {
194
+ try {
195
+ const data = await supabaseGet("session_handoffs_history", {
196
+ select: "snapshot",
197
+ project: `eq.${project}`,
198
+ user_id: `eq.${userId}`,
199
+ version: `eq.${version}`,
200
+ limit: "1",
201
+ });
202
+ const rows = Array.isArray(data) ? data : [];
203
+ if (rows.length === 0)
204
+ return null;
205
+ return rows[0].snapshot || null;
206
+ }
207
+ catch (err) {
208
+ console.error(`[SupabaseStorage] Failed to get handoff at version ${version}: ${err}`);
209
+ return null;
210
+ }
211
+ }
189
212
  // ─── v2.0 Dashboard ─────────────────────────────────────────
190
213
  async listProjects() {
191
214
  const data = await supabaseGet("session_handoffs", {
@@ -248,6 +271,13 @@ export class SupabaseStorage {
248
271
  const totalActiveEntries = activeLedgerSummaries.length;
249
272
  const totalHandoffs = handoffProjects.size;
250
273
  const totalRollups = rollups.length;
274
+ // v5.4: Aggregate CRDT merge counts from handoff metadata
275
+ const handoffFullData = await supabaseGet("session_handoffs", {
276
+ select: "metadata",
277
+ user_id: `eq.${userId}`,
278
+ });
279
+ const handoffRows = Array.isArray(handoffFullData) ? handoffFullData : [];
280
+ const totalCrdtMerges = handoffRows.reduce((sum, h) => sum + (h.metadata?.crdt_merge_count || 0), 0);
251
281
  return {
252
282
  missingEmbeddings,
253
283
  activeLedgerSummaries,
@@ -256,6 +286,7 @@ export class SupabaseStorage {
256
286
  totalActiveEntries,
257
287
  totalHandoffs,
258
288
  totalRollups,
289
+ totalCrdtMerges,
259
290
  };
260
291
  }
261
292
  // ─── v3.0: Agent Registry (Hivemind) ───────────────────────
@@ -268,18 +299,63 @@ export class SupabaseStorage {
268
299
  agent_name: entry.agent_name ?? null,
269
300
  status: entry.status || "active",
270
301
  current_task: entry.current_task ?? null,
302
+ // v5.3: Initialize watchdog fields
303
+ task_start_time: new Date().toISOString(),
304
+ expected_duration_minutes: null,
305
+ task_hash: null,
306
+ loop_count: 0,
271
307
  };
272
308
  const result = await supabasePost("agent_registry", record);
273
309
  const data = Array.isArray(result) ? result[0] : result;
274
310
  return { ...entry, id: data?.id, status: entry.status || "active" };
275
311
  }
276
- async heartbeatAgent(project, userId, role, currentTask) {
312
+ /**
313
+ * Simple string hash for loop detection (DJB2).
314
+ * Mirrors SqliteStorage._simpleHash().
315
+ */
316
+ _simpleHash(str) {
317
+ let hash = 5381;
318
+ for (let i = 0; i < str.length; i++) {
319
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0xFFFFFFFF;
320
+ }
321
+ return hash.toString(16);
322
+ }
323
+ async heartbeatAgent(project, userId, role, currentTask, expectedDurationMinutes) {
324
+ // v5.3: Loop detection — compute task hash and compare with stored value
325
+ const newTaskHash = currentTask ? this._simpleHash(currentTask) : null;
326
+ // Fetch current agent for loop comparison
327
+ const current = await supabaseGet("agent_registry", {
328
+ project: `eq.${project}`,
329
+ user_id: `eq.${userId}`,
330
+ role: `eq.${role}`,
331
+ select: "task_hash,loop_count",
332
+ });
333
+ const agentRow = Array.isArray(current) ? current[0] : current;
334
+ const existingHash = agentRow?.task_hash;
335
+ const existingLoopCount = agentRow?.loop_count || 0;
336
+ const taskChanged = newTaskHash !== null && newTaskHash !== existingHash;
337
+ const sameTask = newTaskHash !== null && newTaskHash === existingHash;
338
+ const newLoopCount = sameTask
339
+ ? existingLoopCount + 1
340
+ : (taskChanged ? 0 : existingLoopCount);
341
+ const newStatus = newLoopCount >= 5 ? "looping" : "active";
277
342
  const patchData = {
278
343
  last_heartbeat: new Date().toISOString(),
344
+ loop_count: newLoopCount,
345
+ status: newStatus,
279
346
  };
280
347
  if (currentTask !== undefined) {
281
348
  patchData.current_task = currentTask;
282
349
  }
350
+ if (newTaskHash !== null) {
351
+ patchData.task_hash = newTaskHash;
352
+ }
353
+ if (taskChanged) {
354
+ patchData.task_start_time = new Date().toISOString();
355
+ }
356
+ if (expectedDurationMinutes !== undefined) {
357
+ patchData.expected_duration_minutes = expectedDurationMinutes;
358
+ }
283
359
  await supabasePatch("agent_registry", patchData, {
284
360
  project: `eq.${project}`,
285
361
  user_id: `eq.${userId}`,
@@ -301,6 +377,33 @@ export class SupabaseStorage {
301
377
  role: `eq.${role}`,
302
378
  });
303
379
  }
380
+ // ─── v5.3: Hivemind Watchdog Methods ───────────────────────
381
+ async getAllAgents(userId) {
382
+ const data = await supabaseGet("agent_registry", {
383
+ user_id: `eq.${userId}`,
384
+ order: "project,role",
385
+ });
386
+ return (Array.isArray(data) ? data : []);
387
+ }
388
+ async updateAgentStatus(project, userId, role, status, additionalFields) {
389
+ const patchData = { status };
390
+ const ALLOWED_FIELDS = new Set([
391
+ "loop_count", "task_start_time", "expected_duration_minutes",
392
+ "task_hash", "current_task",
393
+ ]);
394
+ if (additionalFields) {
395
+ for (const [key, val] of Object.entries(additionalFields)) {
396
+ if (ALLOWED_FIELDS.has(key)) {
397
+ patchData[key] = val;
398
+ }
399
+ }
400
+ }
401
+ await supabasePatch("agent_registry", patchData, {
402
+ project: `eq.${project}`,
403
+ user_id: `eq.${userId}`,
404
+ role: `eq.${role}`,
405
+ });
406
+ }
304
407
  // ─── System Settings (v3.0 Dashboard) — proxy to configStorage ───
305
408
  async getSetting(key) {
306
409
  const val = await cfgGet(key, "");
@@ -500,4 +603,14 @@ export class SupabaseStorage {
500
603
  throw e;
501
604
  }
502
605
  }
606
+ // ─── SDM Operations ──────────────────────────────────────────
607
+ async loadSdmState(project) {
608
+ throw new Error("loading SDM state is not implemented for Supabase backend yet.");
609
+ }
610
+ async saveSdmState(project, state) {
611
+ throw new Error("saving SDM state is not implemented for Supabase backend yet.");
612
+ }
613
+ async getAllProjectEmbeddings(project) {
614
+ throw new Error("getting compressed embeddings for Hamming scan is not implemented for Supabase backend yet.");
615
+ }
503
616
  }
@@ -60,7 +60,8 @@ export const AGENT_HEARTBEAT_TOOL = {
60
60
  name: "agent_heartbeat",
61
61
  description: "Update your heartbeat and optionally your current task. " +
62
62
  "Call this periodically to stay visible to the team. " +
63
- "Agents that haven't sent a heartbeat in 30 minutes are auto-pruned.",
63
+ "The server-side Watchdog monitors health every 60 seconds agents that miss " +
64
+ "heartbeats transition through STALE → FROZEN → OFFLINE (auto-pruned).",
64
65
  inputSchema: {
65
66
  type: "object",
66
67
  properties: {
@@ -76,15 +77,21 @@ export const AGENT_HEARTBEAT_TOOL = {
76
77
  type: "string",
77
78
  description: "Optional updated description of your current task.",
78
79
  },
80
+ expected_duration_minutes: {
81
+ type: "number",
82
+ description: "Optional estimated duration for the current task in minutes. " +
83
+ "If the task exceeds this duration, the Watchdog flags the agent as OVERDUE " +
84
+ "and alerts teammates. Typical values: 5 for quick fixes, 15 for features, 30 for refactors.",
85
+ },
79
86
  },
80
87
  required: ["project", "role"],
81
88
  },
82
89
  };
83
90
  export const AGENT_LIST_TEAM_TOOL = {
84
91
  name: "agent_list_team",
85
- description: "List all active agents on a project. Shows role, status, current task, " +
86
- "and last heartbeat time. Automatically prunes agents that haven't " +
87
- "reported in 30+ minutes.",
92
+ description: "List all agents on a project with health status. Shows role, health state " +
93
+ "(🟢 ACTIVE / 🟡 STALE / 🔴 FROZEN / ⏰ OVERDUE / 🔄 LOOPING), current task, " +
94
+ "and last heartbeat time. The server-side Watchdog actively monitors agent health every 60 seconds.",
88
95
  inputSchema: {
89
96
  type: "object",
90
97
  properties: {