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.
- package/README.md +308 -218
- package/dist/backgroundScheduler.js +327 -0
- package/dist/config.js +29 -0
- package/dist/dashboard/server.js +246 -0
- package/dist/dashboard/ui.js +216 -6
- package/dist/hivemindWatchdog.js +206 -0
- package/dist/lifecycle.js +59 -4
- package/dist/scholar/freeSearch.js +78 -0
- package/dist/scholar/webScholar.js +258 -0
- package/dist/sdm/sdmDecoder.js +75 -0
- package/dist/sdm/sdmEngine.js +158 -0
- package/dist/server.js +173 -11
- package/dist/storage/sqlite.js +298 -47
- package/dist/storage/supabase.js +114 -1
- package/dist/tools/agentRegistryDefinitions.js +11 -4
- package/dist/tools/agentRegistryHandlers.js +23 -5
- package/dist/tools/index.js +2 -2
- package/dist/tools/sessionMemoryDefinitions.js +46 -1
- package/dist/tools/sessionMemoryHandlers.js +210 -38
- package/dist/utils/briefing.js +1 -1
- package/dist/utils/crdtMerge.js +152 -0
- package/dist/utils/healthCheck.js +15 -0
- package/dist/utils/llm/adapters/gemini.js +3 -3
- package/package.json +9 -2
package/dist/storage/sqlite.js
CHANGED
|
@@ -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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1361
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/storage/supabase.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"
|
|
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
|
|
86
|
-
"
|
|
87
|
-
"
|
|
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: {
|