prism-mcp-server 20.21.19 → 20.21.21

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
@@ -764,14 +764,8 @@ export function createServer() {
764
764
  if (!SESSION_MEMORY_ENABLED)
765
765
  throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
766
766
  result = await sessionSaveLedgerHandler(args);
767
- // GATE 5: Reset drift timer — save_ledger counts as a drift checkpoint
768
- {
769
- const cid = args?.conversation_id;
770
- if (cid) {
771
- const { noteDriftCheck } = await import("./session/sessionContext.js");
772
- noteDriftCheck(cid);
773
- }
774
- }
767
+ // GATE 5: the handler resets the drift timer itself, and only when
768
+ // it wrote an entry (not on a refusal or the greeting-only skip).
775
769
  // Savings sync (paid, opt-in, fail-soft): session end is the natural
776
770
  // push point. pushSavings gates itself — disabled/free/no-jwt all
777
771
  // return without a request, and nothing here awaits it.
@@ -17,6 +17,29 @@
17
17
  * 3. Add a schema-contract test in synalux-portal-contract.test.ts.
18
18
  */
19
19
  import { z } from "zod";
20
+ import { ANALYTICS_DATE_PATTERN } from "../dashboard/readMessages.js";
21
+ const AnalyticsCountSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
22
+ const UTC_DAY_MS = 24 * 60 * 60 * 1000;
23
+ export const ProjectAnalyticsSchema = z.object({
24
+ totalEntries: AnalyticsCountSchema,
25
+ totalRollups: AnalyticsCountSchema,
26
+ rollupSavings: AnalyticsCountSchema,
27
+ avgSummaryLength: z.number().nonnegative().finite(),
28
+ sessionsByDay: z.array(z.object({
29
+ date: z.string().regex(ANALYTICS_DATE_PATTERN), count: AnalyticsCountSchema,
30
+ })).length(14),
31
+ }).refine(value => value.totalRollups <= value.totalEntries
32
+ && value.sessionsByDay.reduce((sum, day) => sum + day.count, 0) <= value.totalEntries - value.totalRollups
33
+ && value.sessionsByDay.every((day, index, days) => {
34
+ const time = Date.parse(day.date);
35
+ return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === day.date
36
+ && (index === 0 || time - Date.parse(days[index - 1].date) === UTC_DAY_MS);
37
+ }));
38
+ export const ProjectAnalyticsResponseSchema = z.object({
39
+ status: z.literal('success'),
40
+ project: z.string().min(1).max(100),
41
+ analytics: ProjectAnalyticsSchema,
42
+ });
20
43
  // ─── knowledge_search ────────────────────────────────────────────
21
44
  export const KnowledgeSearchRequestSchema = z.object({
22
45
  action: z.literal("knowledge_search"),
@@ -1824,6 +1824,7 @@ export class SqliteStorage {
1824
1824
  FROM session_ledger
1825
1825
  WHERE user_id = ?
1826
1826
  AND archived_at IS NULL
1827
+ AND deleted_at IS NULL
1827
1828
  AND embedding IS NULL
1828
1829
  `,
1829
1830
  args: [userId], // bind user_id to the ? placeholder
@@ -1883,11 +1884,13 @@ export class SqliteStorage {
1883
1884
  FROM session_ledger r
1884
1885
  LEFT JOIN session_ledger a
1885
1886
  ON a.archived_at IS NOT NULL
1887
+ AND a.deleted_at IS NULL
1886
1888
  AND a.project = r.project
1887
1889
  AND a.user_id = r.user_id
1888
1890
  WHERE r.user_id = ?
1889
1891
  AND r.is_rollup = 1
1890
1892
  AND r.archived_at IS NULL
1893
+ AND r.deleted_at IS NULL
1891
1894
  GROUP BY r.id
1892
1895
  HAVING COUNT(a.id) = 0
1893
1896
  `,
@@ -1901,12 +1904,12 @@ export class SqliteStorage {
1901
1904
  sql: `
1902
1905
  SELECT
1903
1906
  (SELECT COUNT(*) FROM session_ledger
1904
- WHERE user_id = ? AND archived_at IS NULL) as active,
1907
+ WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL) as active,
1905
1908
  (SELECT COUNT(*) FROM session_handoffs
1906
1909
  WHERE user_id = ?) as handoffs,
1907
1910
  (SELECT COUNT(*) FROM session_ledger
1908
1911
  WHERE user_id = ? AND is_rollup = 1
1909
- AND archived_at IS NULL) as rollups
1912
+ AND archived_at IS NULL AND deleted_at IS NULL) as rollups
1910
1913
  `,
1911
1914
  args: [userId, userId, userId], // bind user_id 3x (one per subquery)
1912
1915
  });
@@ -2177,7 +2180,7 @@ export class SqliteStorage {
2177
2180
  COUNT(*) AS total_entries,
2178
2181
  SUM(CASE WHEN is_rollup = 1 THEN 1 ELSE 0 END) AS total_rollups,
2179
2182
  -- rollup_count tracks how many raw entries each rollup replaced,
2180
- -- so we can show "X entries saved by compaction" in the dashboard.
2183
+ -- so the dashboard reports how many originals were consolidated.
2181
2184
  SUM(CASE WHEN is_rollup = 1 THEN COALESCE(rollup_count, 0) ELSE 0 END) AS rollup_savings,
2182
2185
  COALESCE(AVG(LENGTH(summary)), 0) AS avg_summary_length
2183
2186
  FROM session_ledger
@@ -2213,7 +2216,7 @@ export class SqliteStorage {
2213
2216
  const sessionsByDay = [];
2214
2217
  for (let i = 13; i >= 0; i--) {
2215
2218
  const date = new Date();
2216
- date.setDate(date.getDate() - i);
2219
+ date.setUTCDate(date.getUTCDate() - i);
2217
2220
  const dateStr = date.toISOString().slice(0, 10);
2218
2221
  sessionsByDay.push({ date: dateStr, count: sparkMap.get(dateStr) || 0 });
2219
2222
  }
@@ -15,6 +15,8 @@
15
15
  import { supabasePost, supabaseGet, supabaseRpc, supabasePatch, supabaseDelete, } from "../utils/supabaseApi.js";
16
16
  import { gzipSync, gunzipSync } from "node:zlib";
17
17
  import { debugLog } from "../utils/logger.js";
18
+ import { ProjectAnalyticsSchema } from './portalContracts.js';
19
+ import { DASHBOARD_READ_MESSAGES } from "../dashboard/readMessages.js";
18
20
  import { PRISM_USER_ID } from "../config.js";
19
21
  import { getSetting as cfgGet, setSetting as cfgSet, getAllSettings as cfgGetAll } from "./configStorage.js";
20
22
  import { runAutoMigrations } from "./supabaseMigrations.js";
@@ -154,11 +156,11 @@ export class SupabaseStorage {
154
156
  };
155
157
  }
156
158
  catch (e) {
157
- debugLog("[SupabaseStorage] saveHandoff RPC failed: " + (e instanceof Error ? e.message : String(e)));
158
- return {
159
- status: "updated",
160
- version: handoff.version ?? 1,
161
- };
159
+ // The write failed. Reporting "updated" here told the agent its handoff
160
+ // was saved when nothing was; the caller turns this into an error reply.
161
+ const message = e instanceof Error ? e.message : String(e);
162
+ debugLog("[SupabaseStorage] saveHandoff RPC failed: " + message);
163
+ throw new Error(`Handoff for project "${handoff.project}" was not saved: ${message}`);
162
164
  }
163
165
  }
164
166
  async patchHandoff(project, userId, data) {
@@ -441,12 +443,14 @@ export class SupabaseStorage {
441
443
  user_id: `eq.${userId}`,
442
444
  archived_at: "is.null",
443
445
  embedding: "is.null",
446
+ deleted_at: "is.null",
444
447
  });
445
448
  const missingEmbeddings = Array.isArray(missingData) ? missingData.length : 0;
446
449
  const summData = await supabaseGet("session_ledger", {
447
450
  select: "id,project,summary",
448
451
  user_id: `eq.${userId}`,
449
452
  archived_at: "is.null",
453
+ deleted_at: "is.null",
450
454
  });
451
455
  const activeLedgerSummaries = (Array.isArray(summData) ? summData : []).map((r) => ({
452
456
  id: r.id,
@@ -463,6 +467,7 @@ export class SupabaseStorage {
463
467
  select: "project",
464
468
  user_id: `eq.${userId}`,
465
469
  archived_at: "is.null",
470
+ deleted_at: "is.null",
466
471
  });
467
472
  const ledgerProjects = new Set((Array.isArray(ledgerData) ? ledgerData : [])
468
473
  .map((r) => r.project));
@@ -474,11 +479,13 @@ export class SupabaseStorage {
474
479
  user_id: `eq.${userId}`,
475
480
  is_rollup: "eq.true",
476
481
  archived_at: "is.null",
482
+ deleted_at: "is.null",
477
483
  });
478
484
  const archivedData = await supabaseGet("session_ledger", {
479
485
  select: "project",
480
486
  user_id: `eq.${userId}`,
481
487
  "archived_at": "not.is.null",
488
+ deleted_at: "is.null",
482
489
  });
483
490
  const archivedProjects = new Set((Array.isArray(archivedData) ? archivedData : [])
484
491
  .map((r) => r.project));
@@ -633,32 +640,33 @@ export class SupabaseStorage {
633
640
  }
634
641
  // ─── v3.1: Memory Analytics ──────────────────────────────────
635
642
  async getAnalytics(project, userId) {
636
- // Attempt to call a Supabase RPC. Falls back to zeroed struct if the RPC
637
- // doesn't exist yet (avoids breaking users who haven't run the migration).
638
- try {
639
- const result = await supabaseRpc("get_project_analytics", {
640
- p_project: project,
641
- p_user_id: userId,
642
- });
643
- const data = Array.isArray(result) ? result[0] : result;
644
- if (data) {
645
- return {
646
- totalEntries: data.total_entries || 0,
647
- totalRollups: data.total_rollups || 0,
648
- rollupSavings: data.rollup_savings || 0,
649
- avgSummaryLength: data.avg_summary_length || 0,
650
- sessionsByDay: data.sessions_by_day || [],
651
- };
652
- }
653
- }
654
- catch {
655
- debugLog("[SupabaseStorage] getAnalytics RPC unavailable — returning zeroed struct");
643
+ // An unavailable read must not look like a genuinely empty project.
644
+ const result = await supabaseRpc("get_project_analytics", {
645
+ p_project: project,
646
+ p_user_id: userId,
647
+ });
648
+ const data = Array.isArray(result) ? result[0] : result;
649
+ if (!data) {
650
+ throw new Error(DASHBOARD_READ_MESSAGES.analyticsUnavailable);
656
651
  }
657
- // Graceful degradation: return zeroed struct so dashboard doesn't crash
658
- return {
659
- totalEntries: 0, totalRollups: 0, rollupSavings: 0,
660
- avgSummaryLength: 0, sessionsByDay: [],
652
+ const analytics = {
653
+ totalEntries: data.total_entries, totalRollups: data.total_rollups,
654
+ rollupSavings: data.rollup_savings, avgSummaryLength: data.avg_summary_length,
655
+ sessionsByDay: data.sessions_by_day,
661
656
  };
657
+ if (analytics.totalEntries === 0 && analytics.totalRollups === 0 && analytics.rollupSavings === 0
658
+ && analytics.avgSummaryLength === 0 && Array.isArray(analytics.sessionsByDay) && analytics.sessionsByDay.length === 0) {
659
+ const now = new Date();
660
+ analytics.sessionsByDay = Array.from({ length: 14 }, (_, index) => {
661
+ const date = new Date(now);
662
+ date.setUTCDate(now.getUTCDate() - (13 - index));
663
+ return { date: date.toISOString().slice(0, 10), count: 0 };
664
+ });
665
+ }
666
+ const parsed = ProjectAnalyticsSchema.safeParse(analytics);
667
+ if (!parsed.success)
668
+ throw new Error(DASHBOARD_READ_MESSAGES.analyticsUnavailable);
669
+ return parsed.data;
662
670
  }
663
671
  // ─── v3.1: TTL / Automated Data Retention ────────────────────
664
672
  async expireByTTL(project, ttlDays, userId) {
@@ -30,6 +30,7 @@
30
30
  * - patchLedger → POST /api/v1/prism/memory action=save_embedding
31
31
  * - getEntriesMissingEmbeddings → POST /api/v1/prism/memory action=list_missing_embeddings
32
32
  * - listProjects → POST /api/v1/prism/memory action=list_projects
33
+ * - getAnalytics → GET /api/v1/prism/analytics view=project
33
34
  * - exportLedger → POST /api/v1/prism/memory action=export_memory (paginated)
34
35
  *
35
36
  * Methods still falling through to SupabaseStorage (Phase 3 Tier B+):
@@ -51,7 +52,7 @@ import { SupabaseStorage } from "./supabase.js";
51
52
  import { debugLog } from "../utils/logger.js";
52
53
  import { PRISM_SYNALUX_BASE_URL, PRISM_SYNALUX_API_KEY } from "../config.js";
53
54
  import { isSynaluxSignedOut } from "../utils/synaluxCredentialState.js";
54
- import { KnowledgeSearchRequestSchema, KnowledgeSearchResponseSchema } from "./portalContracts.js";
55
+ import { KnowledgeSearchRequestSchema, KnowledgeSearchResponseSchema, ProjectAnalyticsResponseSchema } from "./portalContracts.js";
55
56
  function resolveKnowledgeScope(callerScope) {
56
57
  if (callerScope === "user" || callerScope === "workspace") {
57
58
  return callerScope;
@@ -758,6 +759,14 @@ export class SynaluxStorage extends SupabaseStorage {
758
759
  }));
759
760
  }
760
761
  // ─── Health Check ─────────────────────────────────────────────
762
+ async getAnalytics(project, _userId) {
763
+ const query = new URLSearchParams({ view: 'project', project });
764
+ const result = ProjectAnalyticsResponseSchema.safeParse(await this.portalGet('/api/v1/prism/analytics?' + query));
765
+ if (!result.success || result.data.project !== project) {
766
+ throw new Error('Project analytics unavailable');
767
+ }
768
+ return result.data.analytics;
769
+ }
761
770
  // Phase 3 Tier B: route health_check through portal. The portal
762
771
  // returns summary counts only (no per-entry duplicate scan), so
763
772
  // activeLedgerSummaries is returned empty — the hygiene handler
@@ -769,6 +778,10 @@ export class SynaluxStorage extends SupabaseStorage {
769
778
  project: "prism-mcp",
770
779
  });
771
780
  const inventory = result.inventory;
781
+ if (!inventory || !['ledger_entries', 'active_projects', 'ledger_missing_embeddings'].every(field => Number.isSafeInteger(inventory[field]) && inventory[field] >= 0)
782
+ || inventory.ledger_missing_embeddings > inventory.ledger_entries) {
783
+ throw new Error('Health inventory unavailable');
784
+ }
772
785
  const totalActiveEntries = typeof inventory?.ledger_entries === "number" ? inventory.ledger_entries : 0;
773
786
  const totalHandoffs = typeof inventory?.active_projects === "number" ? inventory.active_projects : 0;
774
787
  // Hardcoding 0 here certified a 100%-missing-embeddings outage as
@@ -16,6 +16,10 @@
16
16
  * ═══════════════════════════════════════════════════════════════════
17
17
  */
18
18
  import { debugLog } from "../utils/logger.js";
19
+ const BACKFILL_SCOPE_MESSAGES = {
20
+ empty: '✅ No eligible entries with missing embeddings found in the requested scope.',
21
+ complete: 'All scanned entries now have embeddings for semantic search.',
22
+ };
19
23
  import { activeStorageBackend, getStorage } from "../storage/index.js";
20
24
  import { getEmbeddingProvider } from "../utils/llm/factory.js";
21
25
  import { PRISM_USER_ID } from "../config.js";
@@ -78,6 +82,7 @@ export async function backfillEmbeddingsHandler(args) {
78
82
  const params = {
79
83
  "embedding": "is.null",
80
84
  "archived_at": "is.null",
85
+ "deleted_at": "is.null",
81
86
  user_id: `eq.${PRISM_USER_ID}`,
82
87
  order: "id.asc",
83
88
  limit: String(safeLimit),
@@ -95,7 +100,7 @@ export async function backfillEmbeddingsHandler(args) {
95
100
  return {
96
101
  content: [{
97
102
  type: "text",
98
- text: "✅ No entries with missing embeddings found. All ledger entries have embeddings.",
103
+ text: BACKFILL_SCOPE_MESSAGES.empty,
99
104
  }],
100
105
  isError: false,
101
106
  };
@@ -165,7 +170,7 @@ export async function backfillEmbeddingsHandler(args) {
165
170
  `• Total scanned: ${entries.length}\n\n` +
166
171
  (failed > 0
167
172
  ? `⚠️ ${failed} entries could not be repaired. Check server logs for details.`
168
- : `All entries now have embeddings for semantic search.`),
173
+ : BACKFILL_SCOPE_MESSAGES.complete),
169
174
  }],
170
175
  isError: false,
171
176
  _stats: { repaired, failed, last_id: entries[entries.length - 1]?.id },
@@ -810,6 +810,22 @@ export async function sessionSaveLedgerHandler(args) {
810
810
  keywords,
811
811
  role: effectiveRole, // v3.0: Hivemind role scoping (dashboard fallback)
812
812
  });
813
+ // SQLite skips an identical (project, conversation_id, summary) saved in the
814
+ // last 5 minutes and returns { id, deduplicated: true } without writing.
815
+ // Nothing new was saved, so this is not a drift checkpoint and must not read
816
+ // as one: no timer reset, no "saved" line, no handoff nudge, and no second
817
+ // embedding or auto-link pass over the existing row.
818
+ if (result?.deduplicated === true) {
819
+ return {
820
+ content: [{
821
+ type: "text",
822
+ text: (_saveLedgerGateWarning ? `⚠️ ${_saveLedgerGateWarning}\n\n` : "") +
823
+ `ℹ️ An identical ledger entry for project "${project}" was already saved in the last 5 minutes; nothing new was written.` +
824
+ resolverNote,
825
+ }],
826
+ isError: false,
827
+ };
828
+ }
813
829
  // ─── Fire-and-forget embedding generation ───
814
830
  let embeddingQueued = false;
815
831
  if (result) {
@@ -908,6 +924,14 @@ export async function sessionSaveLedgerHandler(args) {
908
924
  debugLog(`[session_save_ledger] Background decay failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
909
925
  });
910
926
  const metricsBlock = formatInferenceMetrics();
927
+ // GATE 5: a ledger entry that was WRITTEN is a drift checkpoint. Reset the
928
+ // timer here, on the write path only. It used to be reset by the server
929
+ // after every returned result, so a context_not_loaded refusal or the
930
+ // greeting-only skip silenced the hourly reminder without saving anything.
931
+ {
932
+ const { noteDriftCheck } = await import("../session/sessionContext.js");
933
+ noteDriftCheck(conversation_id);
934
+ }
911
935
  return {
912
936
  content: [{
913
937
  type: "text",
@@ -920,6 +944,9 @@ export async function sessionSaveLedgerHandler(args) {
920
944
  (embeddingQueued
921
945
  ? `📊 Embedding generation queued for semantic search.`
922
946
  : `📊 Primary history saved; optional semantic indexing was not queued.`) +
947
+ // The ledger logs what happened; the next session starts from the
948
+ // handoff. Only a written entry asks, never the skip or a refusal.
949
+ `\n💾 If this checkpoint ends a unit of work, also save the handoff with session_save_handoff.` +
923
950
  metricsBlock +
924
951
  resolverNote,
925
952
  }],
@@ -99,11 +99,13 @@ size alone. Emulate the device instead:
99
99
  `viewport-fit=cover`; without it an iPhone 17 reports 0. Chromium applies
100
100
  them either way, so check that your page sets it.
101
101
 
102
- Flags given explicitly override the preset, so a phone turned sideways is
103
- `--device iphone-17 --viewport 874x402 --safe-area 0,62,21,62`. Landscape
104
- insets are not part of the presets. The engine stays Chromium, and the user
105
- agent stays the desktop one, so pages that branch on the user-agent string
106
- still take their desktop path.
102
+ Flags given explicitly override the preset, so
103
+ `--device iphone-17 --viewport 874x402 --safe-area T,R,B,L` is the same phone
104
+ turned sideways. Landscape insets were not measured and are not part of the
105
+ presets: put the values your device reports in place of `T,R,B,L`, because
106
+ without `--safe-area` the portrait insets still apply. The engine stays
107
+ Chromium, and the user agent stays the desktop one, so pages that branch on the
108
+ user-agent string still take their desktop path.
107
109
 
108
110
  ```bash
109
111
  printf '%s\n' \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.21.19",
3
+ "version": "20.21.21",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",
@@ -83,7 +83,7 @@
83
83
  "hono": "^4.13.5",
84
84
  "ip-address": "^10.2.0",
85
85
  "protobufjs": "^7.6.5",
86
- "sharp": "^0.35.4",
86
+ "sharp": "^0.35.5",
87
87
  "tar": "^7.5.19",
88
88
  "nanoid": "^3.3.17"
89
89
  },