prism-mcp-server 20.21.20 → 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.
@@ -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";
@@ -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 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.21.20",
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",