prism-mcp-server 4.2.0 → 4.6.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/dist/server.js CHANGED
@@ -70,6 +70,8 @@ import { acquireLock, registerShutdownHandlers } from "./lifecycle.js";
70
70
  // correct backend (Supabase or SQLite) with proper error handling.
71
71
  import { getStorage } from "./storage/index.js";
72
72
  import { getSettingSync, initConfigStorage } from "./storage/configStorage.js";
73
+ import { getTracer, initTelemetry } from "./utils/telemetry.js";
74
+ import { context as otelContext, trace, SpanStatusCode } from "@opentelemetry/api";
73
75
  // ─── Import Tool Definitions (schemas) and Handlers (implementations) ─────
74
76
  import { WEB_SEARCH_TOOL, BRAVE_WEB_SEARCH_CODE_MODE_TOOL, LOCAL_SEARCH_TOOL, BRAVE_LOCAL_SEARCH_CODE_MODE_TOOL, CODE_MODE_TRANSFORM_TOOL, BRAVE_ANSWERS_TOOL, RESEARCH_PAPER_ANALYSIS_TOOL, webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, braveLocalSearchCodeModeHandler, codeModeTransformHandler, braveAnswersHandler, researchPaperAnalysisHandler, } from "./tools/index.js";
75
77
  // Session memory tools — only used if Supabase is configured
@@ -84,6 +86,8 @@ SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL,
84
86
  SESSION_HEALTH_CHECK_TOOL,
85
87
  // ─── Phase 2: GDPR Memory Deletion tool definition ───
86
88
  SESSION_FORGET_MEMORY_TOOL,
89
+ // ─── Phase 2: GDPR Export tool definition ───
90
+ SESSION_EXPORT_MEMORY_TOOL,
87
91
  // ─── v3.1: TTL Retention tool ───
88
92
  KNOWLEDGE_SET_RETENTION_TOOL,
89
93
  // v4.0: Active Behavioral Memory tools
@@ -98,10 +102,14 @@ sessionSaveImageHandler, sessionViewImageHandler,
98
102
  sessionHealthCheckHandler,
99
103
  // ─── Phase 2: GDPR Memory Deletion handler ───
100
104
  sessionForgetMemoryHandler,
105
+ // ─── Phase 2: GDPR Export handler ───
106
+ sessionExportMemoryHandler,
101
107
  // ─── v3.1: TTL Retention handler ───
102
108
  knowledgeSetRetentionHandler,
103
109
  // v4.0: Active Behavioral Memory handlers
104
110
  sessionSaveExperienceHandler, knowledgeUpvoteHandler, knowledgeDownvoteHandler,
111
+ // v4.2: Knowledge Sync Rules
112
+ KNOWLEDGE_SYNC_RULES_TOOL, knowledgeSyncRulesHandler,
105
113
  // ─── v3.0: Agent Hivemind tools ───
106
114
  AGENT_REGISTRY_TOOLS, agentRegisterHandler, agentHeartbeatHandler, agentListTeamHandler, } from "./tools/index.js";
107
115
  // ─── Dynamic Tool Registration ───────────────────────────────────
@@ -159,6 +167,10 @@ function buildSessionMemoryTools(autoloadList) {
159
167
  SESSION_SAVE_EXPERIENCE_TOOL, // session_save_experience — record typed experience events
160
168
  KNOWLEDGE_UPVOTE_TOOL, // knowledge_upvote — increase entry importance
161
169
  KNOWLEDGE_DOWNVOTE_TOOL, // knowledge_downvote — decrease entry importance
170
+ // ─── v4.2: Knowledge Sync Rules tool ───
171
+ KNOWLEDGE_SYNC_RULES_TOOL, // knowledge_sync_rules — sync graduated insights to IDE rules files
172
+ // ─── Phase 2: GDPR Export tool ───
173
+ SESSION_EXPORT_MEMORY_TOOL, // session_export_memory — full portability export (Article 20)
162
174
  ];
163
175
  }
164
176
  // ─── v0.4.0: Resource Subscription Tracking ──────────────────────
@@ -534,148 +546,221 @@ export function createServer() {
534
546
  // - session_search_memory (Enhancement #4)
535
547
  // The server reference is passed to sessionSaveHandoffHandler so it
536
548
  // can trigger resource update notifications on successful saves.
549
+ //
550
+ // v4.6.0: Every tool call is wrapped in a root OTel span (mcp.call_tool).
551
+ // The span is parented via AsyncLocalStorage context propagation — all
552
+ // child spans from LLM adapters and background workers are automatically
553
+ // nested under this root span in Jaeger/Zipkin without explicit ref-passing.
554
+ // When otel_enabled=false, getTracer() returns a no-op tracer — zero overhead.
537
555
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
538
- try {
539
- const { name, arguments: args } = request.params;
540
- if (!args) {
541
- throw new Error("No arguments provided");
556
+ const { name, arguments: args } = request.params;
557
+ // Start the root span for this MCP tool invocation.
558
+ // All child spans (llm.generate_text, worker.vlm_caption, etc.) are
559
+ // automatically parented to this span via the propagated context.
560
+ const rootSpan = getTracer().startSpan("mcp.call_tool", {
561
+ attributes: {
562
+ "tool.name": name,
563
+ // Capture the project attribute if present (most memory tools have it)
564
+ "project": args?.project ?? "unknown",
565
+ },
566
+ });
567
+ // context.with() sets the root span as the active span for the duration
568
+ // of this async operation. AsyncLocalStorage ensures the context flows
569
+ // through await chains — including fire-and-forget workers launched
570
+ // within the handler body (e.g. imageCaptioner, embeddings backfill).
571
+ return otelContext.with(trace.setSpan(otelContext.active(), rootSpan), async () => {
572
+ try {
573
+ if (!args) {
574
+ throw new Error("No arguments provided");
575
+ }
576
+ let result;
577
+ switch (name) {
578
+ // ── Search & Analysis Tools (always available) ──
579
+ case "brave_web_search":
580
+ result = await webSearchHandler(args);
581
+ break;
582
+ case "brave_web_search_code_mode":
583
+ result = await braveWebSearchCodeModeHandler(args);
584
+ break;
585
+ case "brave_local_search":
586
+ result = await localSearchHandler(args);
587
+ break;
588
+ case "brave_local_search_code_mode":
589
+ result = await braveLocalSearchCodeModeHandler(args);
590
+ break;
591
+ case "code_mode_transform":
592
+ result = await codeModeTransformHandler(args);
593
+ break;
594
+ case "brave_answers":
595
+ result = await braveAnswersHandler(args);
596
+ break;
597
+ case "gemini_research_paper_analysis":
598
+ result = await researchPaperAnalysisHandler(args);
599
+ break;
600
+ // ── Session Memory Tools (only callable when Supabase is configured) ──
601
+ // REVIEWER NOTE: Even though these tools won't appear in the
602
+ // tool list without Supabase, we still guard each handler call
603
+ // in case of direct invocation.
604
+ case "session_save_ledger":
605
+ if (!SESSION_MEMORY_ENABLED)
606
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
607
+ result = await sessionSaveLedgerHandler(args);
608
+ break;
609
+ case "session_save_handoff":
610
+ if (!SESSION_MEMORY_ENABLED)
611
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
612
+ // REVIEWER NOTE: v0.4.0 passes the server reference so the
613
+ // handler can trigger resource update notifications after
614
+ // a successful save. See notifyResourceUpdate() above.
615
+ result = await sessionSaveHandoffHandler(args, server);
616
+ break;
617
+ case "session_load_context":
618
+ if (!SESSION_MEMORY_ENABLED)
619
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
620
+ result = await sessionLoadContextHandler(args);
621
+ break;
622
+ case "knowledge_search":
623
+ if (!SESSION_MEMORY_ENABLED)
624
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
625
+ result = await knowledgeSearchHandler(args);
626
+ break;
627
+ case "knowledge_forget":
628
+ if (!SESSION_MEMORY_ENABLED)
629
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
630
+ result = await knowledgeForgetHandler(args);
631
+ break;
632
+ // ─── v0.4.0: New Session Memory Tools ───
633
+ case "session_compact_ledger":
634
+ if (!SESSION_MEMORY_ENABLED)
635
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
636
+ result = await compactLedgerHandler(args);
637
+ break;
638
+ case "session_search_memory":
639
+ if (!SESSION_MEMORY_ENABLED)
640
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
641
+ result = await sessionSearchMemoryHandler(args);
642
+ break;
643
+ // ─── v2.0: Time Travel Tools ───
644
+ case "memory_history":
645
+ if (!SESSION_MEMORY_ENABLED)
646
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
647
+ result = await memoryHistoryHandler(args);
648
+ break;
649
+ case "memory_checkout":
650
+ if (!SESSION_MEMORY_ENABLED)
651
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
652
+ result = await memoryCheckoutHandler(args);
653
+ break;
654
+ // ─── v2.0: Visual Memory Tools ───
655
+ case "session_save_image":
656
+ if (!SESSION_MEMORY_ENABLED)
657
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
658
+ result = await sessionSaveImageHandler(args);
659
+ break;
660
+ case "session_view_image":
661
+ if (!SESSION_MEMORY_ENABLED)
662
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
663
+ result = await sessionViewImageHandler(args);
664
+ break;
665
+ // ─── v2.2.0: Health Check Tool ───
666
+ case "session_health_check":
667
+ if (!SESSION_MEMORY_ENABLED)
668
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
669
+ result = await sessionHealthCheckHandler(args);
670
+ break;
671
+ // ─── Phase 2: GDPR Memory Deletion Tool ───
672
+ case "session_forget_memory":
673
+ if (!SESSION_MEMORY_ENABLED)
674
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
675
+ result = await sessionForgetMemoryHandler(args);
676
+ break;
677
+ // ─── Phase 2: GDPR Export Tool ───
678
+ case "session_export_memory":
679
+ if (!SESSION_MEMORY_ENABLED)
680
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
681
+ result = await sessionExportMemoryHandler(args);
682
+ break;
683
+ case "knowledge_set_retention":
684
+ if (!SESSION_MEMORY_ENABLED)
685
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
686
+ result = await knowledgeSetRetentionHandler(args);
687
+ break;
688
+ // ─── v4.0: Active Behavioral Memory Tools ───
689
+ case "session_save_experience":
690
+ if (!SESSION_MEMORY_ENABLED)
691
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
692
+ result = await sessionSaveExperienceHandler(args);
693
+ break;
694
+ case "knowledge_upvote":
695
+ if (!SESSION_MEMORY_ENABLED)
696
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
697
+ result = await knowledgeUpvoteHandler(args);
698
+ break;
699
+ case "knowledge_downvote":
700
+ if (!SESSION_MEMORY_ENABLED)
701
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
702
+ result = await knowledgeDownvoteHandler(args);
703
+ break;
704
+ // ─── v4.2: Knowledge Sync Rules Tool ───
705
+ case "knowledge_sync_rules":
706
+ if (!SESSION_MEMORY_ENABLED)
707
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
708
+ result = await knowledgeSyncRulesHandler(args);
709
+ break;
710
+ // ─── v3.0: Agent Hivemind Tools ───
711
+ case "agent_register":
712
+ if (!SESSION_MEMORY_ENABLED)
713
+ throw new Error("Session memory not configured.");
714
+ if (!PRISM_ENABLE_HIVEMIND)
715
+ throw new Error("Hivemind not enabled. Set PRISM_ENABLE_HIVEMIND=true.");
716
+ result = await agentRegisterHandler(args);
717
+ break;
718
+ case "agent_heartbeat":
719
+ if (!SESSION_MEMORY_ENABLED)
720
+ throw new Error("Session memory not configured.");
721
+ if (!PRISM_ENABLE_HIVEMIND)
722
+ throw new Error("Hivemind not enabled. Set PRISM_ENABLE_HIVEMIND=true.");
723
+ result = await agentHeartbeatHandler(args);
724
+ break;
725
+ case "agent_list_team":
726
+ if (!SESSION_MEMORY_ENABLED)
727
+ throw new Error("Session memory not configured.");
728
+ if (!PRISM_ENABLE_HIVEMIND)
729
+ throw new Error("Hivemind not enabled. Set PRISM_ENABLE_HIVEMIND=true.");
730
+ result = await agentListTeamHandler(args);
731
+ break;
732
+ default:
733
+ result = {
734
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
735
+ isError: true,
736
+ };
737
+ }
738
+ rootSpan.setStatus({ code: SpanStatusCode.OK });
739
+ return result;
542
740
  }
543
- switch (name) {
544
- // ── Search & Analysis Tools (always available) ──
545
- case "brave_web_search":
546
- return await webSearchHandler(args);
547
- case "brave_web_search_code_mode":
548
- return await braveWebSearchCodeModeHandler(args);
549
- case "brave_local_search":
550
- return await localSearchHandler(args);
551
- case "brave_local_search_code_mode":
552
- return await braveLocalSearchCodeModeHandler(args);
553
- case "code_mode_transform":
554
- return await codeModeTransformHandler(args);
555
- case "brave_answers":
556
- return await braveAnswersHandler(args);
557
- case "gemini_research_paper_analysis":
558
- return await researchPaperAnalysisHandler(args);
559
- // ── Session Memory Tools (only callable when Supabase is configured) ──
560
- // REVIEWER NOTE: Even though these tools won't appear in the
561
- // tool list without Supabase, we still guard each handler call
562
- // in case of direct invocation.
563
- case "session_save_ledger":
564
- if (!SESSION_MEMORY_ENABLED)
565
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
566
- return await sessionSaveLedgerHandler(args);
567
- case "session_save_handoff":
568
- if (!SESSION_MEMORY_ENABLED)
569
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
570
- // REVIEWER NOTE: v0.4.0 passes the server reference so the
571
- // handler can trigger resource update notifications after
572
- // a successful save. See notifyResourceUpdate() above.
573
- return await sessionSaveHandoffHandler(args, server);
574
- case "session_load_context":
575
- if (!SESSION_MEMORY_ENABLED)
576
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
577
- return await sessionLoadContextHandler(args);
578
- case "knowledge_search":
579
- if (!SESSION_MEMORY_ENABLED)
580
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
581
- return await knowledgeSearchHandler(args);
582
- case "knowledge_forget":
583
- if (!SESSION_MEMORY_ENABLED)
584
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
585
- return await knowledgeForgetHandler(args);
586
- // ─── v0.4.0: New Session Memory Tools ───
587
- case "session_compact_ledger":
588
- if (!SESSION_MEMORY_ENABLED)
589
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
590
- return await compactLedgerHandler(args);
591
- case "session_search_memory":
592
- if (!SESSION_MEMORY_ENABLED)
593
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
594
- return await sessionSearchMemoryHandler(args);
595
- // ─── v2.0: Time Travel Tools ───
596
- case "memory_history":
597
- if (!SESSION_MEMORY_ENABLED)
598
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
599
- return await memoryHistoryHandler(args);
600
- case "memory_checkout":
601
- if (!SESSION_MEMORY_ENABLED)
602
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
603
- return await memoryCheckoutHandler(args);
604
- // ─── v2.0: Visual Memory Tools ───
605
- case "session_save_image":
606
- if (!SESSION_MEMORY_ENABLED)
607
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
608
- return await sessionSaveImageHandler(args);
609
- case "session_view_image":
610
- if (!SESSION_MEMORY_ENABLED)
611
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
612
- return await sessionViewImageHandler(args);
613
- // ─── v2.2.0: Health Check Tool ───
614
- case "session_health_check":
615
- if (!SESSION_MEMORY_ENABLED)
616
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
617
- return await sessionHealthCheckHandler(args);
618
- // ─── Phase 2: GDPR Memory Deletion Tool ───
619
- case "session_forget_memory":
620
- if (!SESSION_MEMORY_ENABLED)
621
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
622
- return await sessionForgetMemoryHandler(args);
623
- // ─── v3.1: TTL Retention Tool ───
624
- case "knowledge_set_retention":
625
- if (!SESSION_MEMORY_ENABLED)
626
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
627
- return await knowledgeSetRetentionHandler(args);
628
- // ─── v4.0: Active Behavioral Memory Tools ───
629
- case "session_save_experience":
630
- if (!SESSION_MEMORY_ENABLED)
631
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
632
- return await sessionSaveExperienceHandler(args);
633
- case "knowledge_upvote":
634
- if (!SESSION_MEMORY_ENABLED)
635
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
636
- return await knowledgeUpvoteHandler(args);
637
- case "knowledge_downvote":
638
- if (!SESSION_MEMORY_ENABLED)
639
- throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
640
- return await knowledgeDownvoteHandler(args);
641
- // ─── v3.0: Agent Hivemind Tools ───
642
- case "agent_register":
643
- if (!SESSION_MEMORY_ENABLED)
644
- throw new Error("Session memory not configured.");
645
- if (!PRISM_ENABLE_HIVEMIND)
646
- throw new Error("Hivemind not enabled. Set PRISM_ENABLE_HIVEMIND=true.");
647
- return await agentRegisterHandler(args);
648
- case "agent_heartbeat":
649
- if (!SESSION_MEMORY_ENABLED)
650
- throw new Error("Session memory not configured.");
651
- if (!PRISM_ENABLE_HIVEMIND)
652
- throw new Error("Hivemind not enabled. Set PRISM_ENABLE_HIVEMIND=true.");
653
- return await agentHeartbeatHandler(args);
654
- case "agent_list_team":
655
- if (!SESSION_MEMORY_ENABLED)
656
- throw new Error("Session memory not configured.");
657
- if (!PRISM_ENABLE_HIVEMIND)
658
- throw new Error("Hivemind not enabled. Set PRISM_ENABLE_HIVEMIND=true.");
659
- return await agentListTeamHandler(args);
660
- default:
661
- return {
662
- content: [{ type: "text", text: `Unknown tool: ${name}` }],
663
- isError: true,
664
- };
741
+ catch (error) {
742
+ console.error(`Error in tool handler: ${error instanceof Error ? error.message : String(error)}`);
743
+ rootSpan.recordException(error instanceof Error ? error : new Error(String(error)));
744
+ rootSpan.setStatus({
745
+ code: SpanStatusCode.ERROR,
746
+ message: error instanceof Error ? error.message : String(error),
747
+ });
748
+ return {
749
+ content: [
750
+ {
751
+ type: "text",
752
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
753
+ },
754
+ ],
755
+ isError: true,
756
+ };
665
757
  }
666
- }
667
- catch (error) {
668
- console.error(`Error in tool handler: ${error instanceof Error ? error.message : String(error)}`);
669
- return {
670
- content: [
671
- {
672
- type: "text",
673
- text: `Error: ${error instanceof Error ? error.message : String(error)}`,
674
- },
675
- ],
676
- isError: true,
677
- };
678
- }
758
+ finally {
759
+ // Always end the root span — even on error — to avoid span leaks
760
+ // in the BatchSpanProcessor's in-memory queue.
761
+ rootSpan.end();
762
+ }
763
+ });
679
764
  });
680
765
  return server;
681
766
  }
@@ -754,6 +839,11 @@ export async function startServer() {
754
839
  // during the Initialize handshake — zero extra latency for resource reads.
755
840
  // initConfigStorage() is local SQLite only (~5ms), safe to await.
756
841
  await initConfigStorage();
842
+ // v4.6.0: Initialize OTel AFTER the settings cache is warm so that
843
+ // initTelemetry() can read otel_enabled/otel_endpoint from getSettingSync()
844
+ // synchronously. This is a synchronous call — no await needed.
845
+ // No-op when otel_enabled=false (the default).
846
+ initTelemetry();
757
847
  const server = createServer();
758
848
  const transport = new StdioServerTransport();
759
849
  await server.connect(transport);
@@ -1391,4 +1391,56 @@ export class SqliteStorage {
1391
1391
  });
1392
1392
  debugLog(`[SqliteStorage] Adjusted importance for ${id} by ${delta > 0 ? "+" : ""}${delta}`);
1393
1393
  }
1394
+ // ─── v4.2: Graduated Insights Query ──────────────────────────
1395
+ //
1396
+ // Returns ledger entries that have "graduated" — i.e., their
1397
+ // importance score has reached the threshold (default 7).
1398
+ // Used by knowledge_sync_rules to physically write insights
1399
+ // into .cursorrules / .clauderules files at the project repo path.
1400
+ async getGraduatedInsights(project, userId, minImportance = 7) {
1401
+ const result = await this.db.execute({
1402
+ sql: `SELECT id, project, user_id, role, summary, importance,
1403
+ event_type, decisions, created_at
1404
+ FROM session_ledger
1405
+ WHERE project = ? AND user_id = ?
1406
+ AND importance >= ?
1407
+ AND deleted_at IS NULL
1408
+ AND archived_at IS NULL
1409
+ ORDER BY importance DESC, created_at DESC`,
1410
+ args: [project, userId, minImportance],
1411
+ });
1412
+ return result.rows.map(row => ({
1413
+ id: row.id,
1414
+ project: row.project,
1415
+ user_id: row.user_id,
1416
+ role: row.role || "global",
1417
+ summary: row.summary,
1418
+ importance: Number(row.importance),
1419
+ event_type: row.event_type || "session",
1420
+ decisions: this.parseJsonColumn(row.decisions),
1421
+ created_at: row.created_at,
1422
+ conversation_id: "",
1423
+ }));
1424
+ }
1425
+ // ─── v4.3: Standalone Importance Decay ────────────────────
1426
+ //
1427
+ // Extracted from expireByTTL so decay can be triggered independently
1428
+ // (e.g. fire-and-forget from session_save_ledger) without needing
1429
+ // an active TTL retention policy.
1430
+ async decayImportance(project, userId, decayDays) {
1431
+ const result = await this.db.execute({
1432
+ sql: `UPDATE session_ledger
1433
+ SET importance = MAX(0, importance - 1)
1434
+ WHERE project = ? AND user_id = ?
1435
+ AND importance > 0
1436
+ AND event_type != 'session'
1437
+ AND created_at < datetime('now', '-' || ? || ' days')
1438
+ AND deleted_at IS NULL`,
1439
+ args: [project, userId, decayDays],
1440
+ });
1441
+ const decayed = result.rowsAffected || 0;
1442
+ if (decayed > 0) {
1443
+ debugLog(`[SqliteStorage] decayImportance: reduced ${decayed} entries for "${project}" (>${decayDays}d old)`);
1444
+ }
1445
+ }
1394
1446
  }
@@ -356,31 +356,90 @@ export class SupabaseStorage {
356
356
  catch (e) {
357
357
  debugLog("[SupabaseStorage] expireByTTL failed: " + (e instanceof Error ? e.message : String(e)));
358
358
  }
359
+ // Fix #5: Decay importance parity with SQLite.
360
+ // NOTE: Unlike SQLite (which decays on every session_save_ledger health sweep),
361
+ // Supabase decay is TTL-gated — it only runs when knowledge_set_retention has
362
+ // been configured for this project. Projects without a TTL policy will not
363
+ // experience importance decay. Future improvement: fire this from
364
+ // sessionSaveLedgerHandler (fire-and-forget) to achieve full parity.
365
+ try {
366
+ await supabaseRpc("prism_decay_importance", {
367
+ p_project: project,
368
+ p_user_id: userId,
369
+ p_days: 30,
370
+ });
371
+ debugLog(`[SupabaseStorage] Importance decay sweep completed for "${project}"`);
372
+ }
373
+ catch (e) {
374
+ // Non-fatal: decay is a best-effort background operation
375
+ debugLog("[SupabaseStorage] prism_decay_importance failed (non-fatal): " + (e instanceof Error ? e.message : String(e)));
376
+ }
359
377
  // Supabase PATCH doesn't return rowsAffected — return 0 (UI doesn't need exact count)
360
378
  debugLog(`[SupabaseStorage] TTL sweep completed for "${project}" (cutoff: ${cutoffStr})`);
361
379
  return { expired: 0 };
362
380
  }
363
381
  // ─── v4.0: Insight Graduation ──────────────────────────────────
364
382
  async adjustImportance(id, delta, userId) {
365
- // Supabase PATCH can't do MAX(0, importance + delta) directly.
366
- // Fetch current value first, compute new, then patch.
383
+ // Fix #4: Use atomic RPC instead of read-then-write.
384
+ // prism_adjust_importance computes MAX(0, importance + delta) in one
385
+ // SQL UPDATE, eliminating the race condition in the old pattern.
367
386
  try {
368
- const data = await supabaseGet("session_ledger", {
369
- id: `eq.${id}`,
370
- user_id: `eq.${userId}`,
371
- select: "importance",
387
+ await supabaseRpc("prism_adjust_importance", {
388
+ p_id: id,
389
+ p_user_id: userId,
390
+ p_delta: delta,
372
391
  });
373
- const rows = Array.isArray(data) ? data : [];
374
- const current = rows[0]?.importance ?? 0;
375
- const newVal = Math.max(0, current + delta);
376
- await supabasePatch("session_ledger", { importance: newVal }, {
377
- id: `eq.${id}`,
378
- user_id: `eq.${userId}`,
392
+ debugLog(`[SupabaseStorage] Adjusted importance for ${id} by ${delta > 0 ? "+" : ""}${delta} via RPC`);
393
+ }
394
+ catch (e) {
395
+ const msg = e instanceof Error ? e.message : String(e);
396
+ debugLog("[SupabaseStorage] adjustImportance failed: " + msg);
397
+ throw e; // Fix #3: rethrow so handlers can surface isError:true
398
+ }
399
+ }
400
+ // ─── v4.2: Graduated Insights Query ──────────────────────────
401
+ async getGraduatedInsights(project, userId, minImportance = 7) {
402
+ const data = await supabaseGet("session_ledger", {
403
+ project: `eq.${project}`,
404
+ user_id: `eq.${userId}`,
405
+ importance: `gte.${minImportance}`,
406
+ deleted_at: "is.null",
407
+ archived_at: "is.null",
408
+ select: "id,project,user_id,role,summary,importance,event_type,decisions,created_at",
409
+ order: "importance.desc,created_at.desc",
410
+ });
411
+ const rows = Array.isArray(data) ? data : [];
412
+ return rows.map((r) => ({
413
+ id: r.id,
414
+ project: r.project,
415
+ user_id: r.user_id,
416
+ role: r.role || "global",
417
+ summary: r.summary,
418
+ importance: r.importance || 0,
419
+ event_type: r.event_type || "session",
420
+ decisions: Array.isArray(r.decisions) ? r.decisions : [],
421
+ created_at: r.created_at,
422
+ conversation_id: "",
423
+ }));
424
+ }
425
+ // ─── v4.3: Standalone Importance Decay ─────────────────────
426
+ //
427
+ // Calls the prism_decay_importance RPC (migration 028) directly,
428
+ // decoupled from expireByTTL so it can be triggered fire-and-forget
429
+ // from session_save_ledger without a TTL policy being active.
430
+ async decayImportance(project, userId, decayDays) {
431
+ try {
432
+ await supabaseRpc("prism_decay_importance", {
433
+ p_project: project,
434
+ p_user_id: userId,
435
+ p_days: decayDays,
379
436
  });
380
- debugLog(`[SupabaseStorage] Adjusted importance for ${id} by ${delta > 0 ? "+" : ""}${delta} (${current} → ${newVal})`);
437
+ debugLog(`[SupabaseStorage] decayImportance: sweep completed for "${project}" (>${decayDays}d old)`);
381
438
  }
382
439
  catch (e) {
383
- debugLog("[SupabaseStorage] adjustImportance failed: " + (e instanceof Error ? e.message : String(e)));
440
+ // Non-fatal: decay is best-effort log and rethrow so fire-and-forget caller can see it
441
+ debugLog("[SupabaseStorage] decayImportance failed: " + (e instanceof Error ? e.message : String(e)));
442
+ throw e;
384
443
  }
385
444
  }
386
445
  }
@@ -56,7 +56,48 @@ export const MIGRATIONS = [
56
56
  AND deleted_at IS NULL AND archived_at IS NULL;
57
57
  `,
58
58
  },
59
- // Future migrations go here (version 28+)
59
+ {
60
+ version: 28,
61
+ name: "importance_rpcs",
62
+ sql: `
63
+ -- Fix #4: Atomic importance adjustment — eliminates read-then-write race condition
64
+ CREATE OR REPLACE FUNCTION prism_adjust_importance(
65
+ p_id TEXT, p_user_id TEXT, p_delta INTEGER
66
+ )
67
+ RETURNS void
68
+ LANGUAGE plpgsql
69
+ SECURITY DEFINER
70
+ AS $$
71
+ BEGIN
72
+ UPDATE session_ledger
73
+ SET importance = GREATEST(0, importance + p_delta)
74
+ WHERE id = p_id AND user_id = p_user_id;
75
+ END;
76
+ $$;
77
+
78
+ -- Fix #5: Importance decay — parity with SQLite backend
79
+ CREATE OR REPLACE FUNCTION prism_decay_importance(
80
+ p_project TEXT, p_user_id TEXT, p_days INTEGER
81
+ )
82
+ RETURNS void
83
+ LANGUAGE plpgsql
84
+ SECURITY DEFINER
85
+ AS $$
86
+ BEGIN
87
+ UPDATE session_ledger
88
+ SET importance = GREATEST(0, importance - 1)
89
+ WHERE project = p_project
90
+ AND user_id = p_user_id
91
+ AND importance > 0
92
+ AND event_type <> 'session'
93
+ AND created_at < now() - (p_days || ' days')::interval
94
+ AND deleted_at IS NULL
95
+ AND archived_at IS NULL;
96
+ END;
97
+ $$;
98
+ `,
99
+ },
100
+ // Future migrations go here (version 29+)
60
101
  ];
61
102
  /**
62
103
  * Current schema version — derived from the MIGRATIONS array.