gitmem-mcp 1.5.1 → 1.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.
@@ -0,0 +1,200 @@
1
+ /**
2
+ * License Key Validation for GitMem Pro Tier
3
+ *
4
+ * Detection chain:
5
+ * 1. GITMEM_TIER env var (explicit override — testing/dev)
6
+ * 2. api_key in config.json or GITMEM_API_KEY env var:
7
+ * a. Check license-cache.json → if valid + not expired (72h) → return cached tier
8
+ * b. No cache → optimistic "pro" (validated async in runServer)
9
+ * 3. No key + no SUPABASE_URL + no config.supabase_url → free
10
+ * 4. No key + SUPABASE_URL set (env var) → pro (backward compat for us)
11
+ *
12
+ * Async validation (validateLicense()):
13
+ * - Called in runServer() startup (non-blocking)
14
+ * - POST to hardcoded validation endpoint with api_key + install_id
15
+ * - Success: cache to ~/.gitmem/license-cache.json (72h TTL)
16
+ * - Failure: downgrade _tier to free, log warning
17
+ * - Network error: honor existing cache if valid, else downgrade
18
+ */
19
+ import * as fs from "fs";
20
+ import * as path from "path";
21
+ import { getGitmemDir } from "./gitmem-dir.js";
22
+ // Hardcoded validation endpoint — calls RPC directly on our Supabase via PostgREST.
23
+ // Users never see or configure this URL.
24
+ const VALIDATION_URL = "https://cjptxyezuxdiinufgrrm.supabase.co/rest/v1/rpc/gitmem_validate_license";
25
+ // Anon key for our project (safe to embed — RPC is SECURITY DEFINER, RLS blocks direct table access)
26
+ const VALIDATION_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNqcHR4eWV6dXhkaWludWZncnJtIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjYxODY3MDMsImV4cCI6MjA4MTc2MjcwM30.L0oZy3LYCMikmZ15IUU5DnfJmucM37DJ14nUkM3AreY";
27
+ // Cache TTL: 72 hours
28
+ const CACHE_TTL_MS = 72 * 60 * 60 * 1000;
29
+ /**
30
+ * Get license key from env var or config.json
31
+ */
32
+ export function getLicenseKey() {
33
+ // Env var takes priority
34
+ const envKey = process.env.GITMEM_API_KEY;
35
+ if (envKey)
36
+ return envKey;
37
+ // Read from config.json
38
+ try {
39
+ const configPath = path.join(getGitmemDir(), "config.json");
40
+ if (fs.existsSync(configPath)) {
41
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
42
+ if (raw.api_key && typeof raw.api_key === "string") {
43
+ return raw.api_key;
44
+ }
45
+ }
46
+ }
47
+ catch {
48
+ // File doesn't exist or is invalid
49
+ }
50
+ return null;
51
+ }
52
+ /**
53
+ * Get Pro config (Supabase + OpenRouter credentials) from config.json
54
+ * Env vars override config.json values.
55
+ */
56
+ export function getProConfig() {
57
+ let supabaseUrl = process.env.SUPABASE_URL || "";
58
+ let supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_KEY || "";
59
+ let openrouterKey = process.env.OPENROUTER_API_KEY || "";
60
+ // Fall back to config.json
61
+ try {
62
+ const configPath = path.join(getGitmemDir(), "config.json");
63
+ if (fs.existsSync(configPath)) {
64
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
65
+ if (!supabaseUrl && raw.supabase_url)
66
+ supabaseUrl = raw.supabase_url;
67
+ if (!supabaseKey && raw.supabase_key)
68
+ supabaseKey = raw.supabase_key;
69
+ if (!openrouterKey && raw.openrouter_key)
70
+ openrouterKey = raw.openrouter_key;
71
+ }
72
+ }
73
+ catch {
74
+ // File doesn't exist or is invalid
75
+ }
76
+ return { supabaseUrl, supabaseKey, openrouterKey };
77
+ }
78
+ /**
79
+ * Read cached license validation result
80
+ */
81
+ function readLicenseCache() {
82
+ try {
83
+ const cachePath = path.join(getGitmemDir(), "license-cache.json");
84
+ if (!fs.existsSync(cachePath))
85
+ return null;
86
+ const raw = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
87
+ const validatedAt = new Date(raw.validated_at).getTime();
88
+ const now = Date.now();
89
+ // Check TTL
90
+ if (now - validatedAt > CACHE_TTL_MS) {
91
+ console.error("[gitmem:license] Cache expired");
92
+ return null;
93
+ }
94
+ return raw;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ /**
101
+ * Write license validation result to cache
102
+ */
103
+ function writeLicenseCache(result) {
104
+ try {
105
+ const gitmemDir = getGitmemDir();
106
+ if (!fs.existsSync(gitmemDir)) {
107
+ fs.mkdirSync(gitmemDir, { recursive: true });
108
+ }
109
+ const cachePath = path.join(gitmemDir, "license-cache.json");
110
+ fs.writeFileSync(cachePath, JSON.stringify(result, null, 2));
111
+ }
112
+ catch (err) {
113
+ console.error("[gitmem:license] Failed to write cache:", err);
114
+ }
115
+ }
116
+ /**
117
+ * Delete license cache (used by deactivate)
118
+ */
119
+ export function clearLicenseCache() {
120
+ try {
121
+ const cachePath = path.join(getGitmemDir(), "license-cache.json");
122
+ if (fs.existsSync(cachePath)) {
123
+ fs.unlinkSync(cachePath);
124
+ }
125
+ }
126
+ catch {
127
+ // Ignore
128
+ }
129
+ }
130
+ /**
131
+ * Check if license key has a valid cached result (non-async, for tier detection)
132
+ */
133
+ export function getCachedLicenseTier() {
134
+ const cache = readLicenseCache();
135
+ if (cache && cache.valid) {
136
+ return cache.tier;
137
+ }
138
+ return null;
139
+ }
140
+ /**
141
+ * Validate license key against GitMem's Supabase RPC endpoint.
142
+ * Calls gitmem_validate_license() via PostgREST using the anon key.
143
+ * Returns the validation result.
144
+ *
145
+ * This is async and should be called non-blocking during startup.
146
+ */
147
+ export async function validateLicense(apiKey, installId) {
148
+ try {
149
+ const controller = new AbortController();
150
+ const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
151
+ const response = await fetch(VALIDATION_URL, {
152
+ method: "POST",
153
+ headers: {
154
+ "Content-Type": "application/json",
155
+ apikey: VALIDATION_ANON_KEY,
156
+ Authorization: `Bearer ${VALIDATION_ANON_KEY}`,
157
+ },
158
+ body: JSON.stringify({ p_api_key: apiKey, p_install_id: installId }),
159
+ signal: controller.signal,
160
+ });
161
+ clearTimeout(timeout);
162
+ if (!response.ok) {
163
+ const text = await response.text().catch(() => "");
164
+ return { valid: false, tier: null, message: `HTTP ${response.status}: ${text}` };
165
+ }
166
+ // PostgREST RPC returns an array of rows
167
+ const rows = await response.json();
168
+ const data = Array.isArray(rows) ? rows[0] : rows;
169
+ if (!data) {
170
+ return { valid: false, tier: null, message: "Empty validation response" };
171
+ }
172
+ // Cache successful validation
173
+ if (data.valid && data.tier) {
174
+ writeLicenseCache({
175
+ valid: true,
176
+ tier: data.tier,
177
+ validated_at: new Date().toISOString(),
178
+ api_key_prefix: apiKey.substring(0, 16) + "...",
179
+ });
180
+ }
181
+ return data;
182
+ }
183
+ catch (err) {
184
+ const message = err instanceof Error ? err.message : "Unknown error";
185
+ // Network error: honor existing cache
186
+ const cache = readLicenseCache();
187
+ if (cache && cache.valid) {
188
+ console.error(`[gitmem:license] Network error, using cached validation: ${message}`);
189
+ return { valid: true, tier: cache.tier, message: "Using cached validation (offline)" };
190
+ }
191
+ return { valid: false, tier: null, message: `Network error: ${message}` };
192
+ }
193
+ }
194
+ /**
195
+ * Get the validation URL (for diagnostics/testing)
196
+ */
197
+ export function getValidationUrl() {
198
+ return VALIDATION_URL;
199
+ }
200
+ //# sourceMappingURL=license.js.map
@@ -37,6 +37,9 @@ export declare function getRecord<T = unknown>(table: string, id: string): Promi
37
37
  export declare function upsertRecord<T = unknown>(table: string, data: Record<string, unknown>): Promise<T>;
38
38
  /**
39
39
  * Semantic search across tables
40
+ *
41
+ * Generates an embedding for the query, then calls the gitmem_semantic_search
42
+ * RPC function directly via PostgREST.
40
43
  */
41
44
  export declare function semanticSearch<T = unknown>(options: SupabaseSearchOptions): Promise<T[]>;
42
45
  /**
@@ -115,6 +118,9 @@ export declare function directPatch<T = unknown>(table: string, filters: Record<
115
118
  export declare function loadScarsWithEmbeddings<T = unknown>(project?: string, limit?: number): Promise<T[]>;
116
119
  /**
117
120
  * Scar search with severity weighting
121
+ *
122
+ * Generates an embedding for the query, then calls the gitmem_scar_search
123
+ * RPC function directly via PostgREST. No Edge Function required.
118
124
  */
119
125
  export declare function scarSearch<T = unknown>(query: string, matchCount?: number, project?: Project): Promise<T[]>;
120
126
  /**
@@ -45,9 +45,11 @@ export function escapePostgRESTValue(value) {
45
45
  // PostgREST uses ( ) , as structural delimiters in or=() expressions
46
46
  return value.replace(/[(),]/g, "");
47
47
  }
48
- // Configuration from environment
49
- const SUPABASE_URL = process.env.SUPABASE_URL || "";
50
- const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_KEY || "";
48
+ // Configuration from environment, with config.json fallback for Pro tier
49
+ import { getProConfig } from "./license.js";
50
+ const _proConfig = getProConfig();
51
+ const SUPABASE_URL = _proConfig.supabaseUrl;
52
+ const SUPABASE_KEY = _proConfig.supabaseKey;
51
53
  // Direct REST API base URL
52
54
  const SUPABASE_REST_URL = SUPABASE_URL ? `${SUPABASE_URL}/rest/v1` : "";
53
55
  /**
@@ -141,21 +143,45 @@ export async function upsertRecord(table, data) {
141
143
  }
142
144
  /**
143
145
  * Semantic search across tables
146
+ *
147
+ * Generates an embedding for the query, then calls the gitmem_semantic_search
148
+ * RPC function directly via PostgREST.
144
149
  */
145
150
  export async function semanticSearch(options) {
146
- const { query, tables, match_count = 10, project } = options;
147
- const args = {
148
- query,
149
- match_count,
150
- };
151
- if (tables && tables.length > 0) {
152
- args.tables = tables;
151
+ if (!isConfigured()) {
152
+ throw new Error("Supabase not configured");
153
153
  }
154
- if (project) {
155
- args.project = project;
154
+ const { query, match_count = 10 } = options;
155
+ // Generate embedding for the query
156
+ const { embed } = await import("./embedding.js");
157
+ const embedding = await embed(query);
158
+ if (!embedding) {
159
+ console.error("[semantic-search] No embedding provider configured — cannot run semantic search");
160
+ return [];
161
+ }
162
+ // Call the RPC function directly via PostgREST
163
+ const rpcName = `${getTableName("").replace(/_$/, "")}_semantic_search`;
164
+ const url = `${SUPABASE_URL}/rest/v1/rpc/${rpcName}`;
165
+ const response = await fetch(url, {
166
+ method: "POST",
167
+ headers: {
168
+ "Content-Type": "application/json",
169
+ apikey: SUPABASE_KEY,
170
+ Authorization: `Bearer ${SUPABASE_KEY}`,
171
+ },
172
+ body: JSON.stringify({
173
+ query_embedding: `[${embedding.join(",")}]`,
174
+ match_count,
175
+ similarity_threshold: 0.0,
176
+ }),
177
+ signal: AbortSignal.timeout(15_000),
178
+ });
179
+ if (!response.ok) {
180
+ const text = await response.text();
181
+ throw new Error(`Supabase RPC error: ${response.status} - ${text.slice(0, 200)}`);
156
182
  }
157
- const result = await callMcp("semantic_search", args);
158
- return result.results || [];
183
+ const rows = (await response.json());
184
+ return rows || [];
159
185
  }
160
186
  // ============================================================================
161
187
  // DIRECT SUPABASE QUERIES (bypass ww-mcp for bulk operations)
@@ -408,17 +434,44 @@ export async function loadScarsWithEmbeddings(project, limit = 500) {
408
434
  }
409
435
  /**
410
436
  * Scar search with severity weighting
437
+ *
438
+ * Generates an embedding for the query, then calls the gitmem_scar_search
439
+ * RPC function directly via PostgREST. No Edge Function required.
411
440
  */
412
441
  export async function scarSearch(query, matchCount = 5, project) {
413
- const args = {
414
- query,
415
- match_count: matchCount,
416
- };
417
- if (project) {
418
- args.project = project;
442
+ if (!isConfigured()) {
443
+ throw new Error("Supabase not configured");
444
+ }
445
+ // Generate embedding for the query
446
+ const { embed } = await import("./embedding.js");
447
+ const embedding = await embed(query);
448
+ if (!embedding) {
449
+ console.error("[scar-search] No embedding provider configured — cannot run semantic search");
450
+ return [];
451
+ }
452
+ // Call the RPC function directly via PostgREST
453
+ const rpcName = `${getTableName("").replace(/_$/, "")}_scar_search`;
454
+ const url = `${SUPABASE_URL}/rest/v1/rpc/${rpcName}`;
455
+ const response = await fetch(url, {
456
+ method: "POST",
457
+ headers: {
458
+ "Content-Type": "application/json",
459
+ apikey: SUPABASE_KEY,
460
+ Authorization: `Bearer ${SUPABASE_KEY}`,
461
+ },
462
+ body: JSON.stringify({
463
+ query_embedding: `[${embedding.join(",")}]`,
464
+ match_count: matchCount,
465
+ similarity_threshold: 0.0,
466
+ }),
467
+ signal: AbortSignal.timeout(15_000),
468
+ });
469
+ if (!response.ok) {
470
+ const text = await response.text();
471
+ throw new Error(`Supabase RPC error: ${response.status} - ${text.slice(0, 200)}`);
419
472
  }
420
- const result = await callMcp("scar_search", args);
421
- return result.results || [];
473
+ const rows = (await response.json());
474
+ return rows || [];
422
475
  }
423
476
  /**
424
477
  * Scar search with caching
@@ -6,15 +6,23 @@
6
6
  * pro — Supabase + embeddings, semantic search, cloud persistence, variants
7
7
  * dev — Everything in pro + compliance, transcripts, metrics
8
8
  *
9
- * Detection:
10
- * GITMEM_TIER=free|pro|dev (explicit override)
11
- * Auto-detect: no SUPABASE_URL free, GITMEM_DEV=1 dev, else → pro
9
+ * Detection chain:
10
+ * 1. GITMEM_TIER env var (explicit override — testing/dev)
11
+ * 2. api_key in config.json or GITMEM_API_KEY env var:
12
+ * a. Check license-cache.json → if valid + not expired (72h) → return cached tier
13
+ * b. No cache → optimistic "pro" (validated async in runServer)
14
+ * 3. No key + no SUPABASE_URL + no config.supabase_url → free
15
+ * 4. No key + SUPABASE_URL set (env var) → pro (backward compat for us)
12
16
  */
13
17
  export type GitMemTier = "free" | "pro" | "dev";
14
18
  /**
15
19
  * Get the current tier (cached after first call)
16
20
  */
17
21
  export declare function getTier(): GitMemTier;
22
+ /**
23
+ * Force-set tier (used by license validation on failure)
24
+ */
25
+ export declare function setTier(tier: GitMemTier): void;
18
26
  /**
19
27
  * Reset tier detection (for testing)
20
28
  */
@@ -33,6 +41,8 @@ export declare function hasTranscripts(): boolean;
33
41
  export declare function hasBatchOperations(): boolean;
34
42
  /** Whether cache management tools are available (pro, dev) */
35
43
  export declare function hasCacheManagement(): boolean;
44
+ /** Whether Pro-tier insights (decay tags, analytics snippets, blindspots) are active (pro, dev) */
45
+ export declare function hasProInsights(): boolean;
36
46
  /** Whether detailed performance metrics recording is active (pro, dev — aligned with hasVariants) */
37
47
  export declare function hasMetrics(): boolean;
38
48
  /** Whether advanced agent detection (5-agent matrix) is active (dev only) */
@@ -6,23 +6,42 @@
6
6
  * pro — Supabase + embeddings, semantic search, cloud persistence, variants
7
7
  * dev — Everything in pro + compliance, transcripts, metrics
8
8
  *
9
- * Detection:
10
- * GITMEM_TIER=free|pro|dev (explicit override)
11
- * Auto-detect: no SUPABASE_URL free, GITMEM_DEV=1 dev, else → pro
9
+ * Detection chain:
10
+ * 1. GITMEM_TIER env var (explicit override — testing/dev)
11
+ * 2. api_key in config.json or GITMEM_API_KEY env var:
12
+ * a. Check license-cache.json → if valid + not expired (72h) → return cached tier
13
+ * b. No cache → optimistic "pro" (validated async in runServer)
14
+ * 3. No key + no SUPABASE_URL + no config.supabase_url → free
15
+ * 4. No key + SUPABASE_URL set (env var) → pro (backward compat for us)
12
16
  */
17
+ import { getLicenseKey, getCachedLicenseTier, getProConfig } from "./license.js";
13
18
  let _tier = null;
14
19
  /**
15
- * Detect tier from environment variables
20
+ * Detect tier from environment variables, license key, and config
16
21
  */
17
22
  function detectTier() {
23
+ // 1. Explicit override via env var (testing/dev)
18
24
  const explicit = process.env.GITMEM_TIER?.toLowerCase();
19
25
  if (explicit === "free" || explicit === "pro" || explicit === "dev") {
20
26
  return explicit;
21
27
  }
22
- const supabaseUrl = process.env.SUPABASE_URL;
28
+ // 2. License key present → check cache or optimistic pro
29
+ const apiKey = getLicenseKey();
30
+ if (apiKey) {
31
+ // 2a. Check cached validation
32
+ const cachedTier = getCachedLicenseTier();
33
+ if (cachedTier === "pro" || cachedTier === "dev") {
34
+ return cachedTier;
35
+ }
36
+ // 2b. Key present but no valid cache → optimistic pro
37
+ // (validated async in runServer, downgraded if invalid)
38
+ return "pro";
39
+ }
40
+ // 3. No key — check for Supabase URL (env var or config.json)
41
+ const supabaseUrl = process.env.SUPABASE_URL || getProConfig().supabaseUrl;
23
42
  if (!supabaseUrl)
24
43
  return "free";
25
- // Dev tier markers
44
+ // 4. Supabase URL set but no license key → backward compat (internal dev)
26
45
  if (process.env.GITMEM_DEV === "true" || process.env.GITMEM_DEV === "1") {
27
46
  return "dev";
28
47
  }
@@ -38,6 +57,13 @@ export function getTier() {
38
57
  }
39
58
  return _tier;
40
59
  }
60
+ /**
61
+ * Force-set tier (used by license validation on failure)
62
+ */
63
+ export function setTier(tier) {
64
+ _tier = tier;
65
+ console.error(`[gitmem] Tier updated: ${tier}`);
66
+ }
41
67
  /**
42
68
  * Reset tier detection (for testing)
43
69
  */
@@ -75,6 +101,10 @@ export function hasBatchOperations() {
75
101
  export function hasCacheManagement() {
76
102
  return getTier() !== "free";
77
103
  }
104
+ /** Whether Pro-tier insights (decay tags, analytics snippets, blindspots) are active (pro, dev) */
105
+ export function hasProInsights() {
106
+ return getTier() !== "free";
107
+ }
78
108
  /** Whether detailed performance metrics recording is active (pro, dev — aligned with hasVariants) */
79
109
  export function hasMetrics() {
80
110
  return getTier() !== "free";
@@ -96,7 +126,8 @@ export function hasEnforcementFields() {
96
126
  */
97
127
  export function getTablePrefix() {
98
128
  // Default prefix for all tiers. Override with GITMEM_TABLE_PREFIX env var.
99
- return process.env.GITMEM_TABLE_PREFIX || "orchestra_";
129
+ // User schema uses gitmem_ prefix. Orchestra infra uses orchestra_ (set via env var).
130
+ return process.env.GITMEM_TABLE_PREFIX || "gitmem_";
100
131
  }
101
132
  /**
102
133
  * Get the fully-qualified table name for a base table name
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import * as supabase from "../services/supabase-client.js";
16
16
  import { localScarSearch, isLocalSearchReady } from "../services/local-vector-search.js";
17
- import { hasSupabase, hasVariants, hasMetrics, getTableName } from "../services/tier.js";
17
+ import { hasSupabase, hasVariants, hasMetrics, hasProInsights, getTableName } from "../services/tier.js";
18
18
  import { getProject } from "../services/session-state.js";
19
19
  import { getStorage } from "../services/storage.js";
20
20
  import { Timer, recordMetrics, buildPerformanceData, buildComponentPerformance, calculateContextBytes, } from "../services/metrics.js";
@@ -82,12 +82,16 @@ No past lessons match this plan closely enough. Scars accumulate as you work —
82
82
  const starterTag = scar.is_starter ? ` ${dimText("[starter]")}` : "";
83
83
  // Confidence tier: marginal matches (< 0.55) get flagged — 66% N/A rate in this range
84
84
  const confidenceTag = scar.similarity < 0.55 ? ` ${dimText("[low confidence]")}` : "";
85
- lines.push(`${sev} **${scar.title}** (${scar.severity}, ${scar.similarity.toFixed(2)}) ${dimText(`id:${scar.id.slice(0, 8)}`)}${starterTag}${confidenceTag}`);
85
+ // Pro: decay tag for scars with reduced behavioral relevance
86
+ const decayTag = hasProInsights() && scar.decay_multiplier !== undefined && scar.decay_multiplier < 0.8
87
+ ? ` ${dimText(`[decay: ${Math.round(scar.decay_multiplier * 100)}%]`)}`
88
+ : "";
89
+ lines.push(`${sev} **${scar.title}** (${scar.severity}, ${scar.similarity.toFixed(2)}) ${dimText(`id:${scar.id.slice(0, 8)}`)}${starterTag}${confidenceTag}${decayTag}`);
86
90
  // Inline archival hint: scars with high dismiss rates get annotated
87
91
  if (dismissals) {
88
92
  const counts = dismissals.get(scar.id);
89
- if (counts && counts.surfaced >= 5 && (counts.dismissed / counts.surfaced) >= 0.7) {
90
- lines.push(` _[${counts.dismissed}x dismissedconsider archiving with gm-archive]_`);
93
+ if (counts && counts.surfaced >= 3 && (counts.dismissed / counts.surfaced) >= 0.6) {
94
+ lines.push(` _[dismissed ${counts.dismissed}/${counts.surfaced} timesre-evaluate whether this still applies]_`);
91
95
  }
92
96
  }
93
97
  // Use variant enforcement text if available (blind to variant name)
@@ -145,6 +149,14 @@ No past lessons match this plan closely enough. Scars accumulate as you work —
145
149
  lines.push("");
146
150
  }
147
151
  lines.push("**Acknowledge these lessons before proceeding.**");
152
+ // Pro: graph nudge when triples exist on any scar
153
+ if (hasProInsights() && scars.some(s => s.related_triples && s.related_triples.length > 0)) {
154
+ const firstScarWithTriples = scars.find(s => s.related_triples && s.related_triples.length > 0);
155
+ if (firstScarWithTriples) {
156
+ lines.push("");
157
+ lines.push(dimText(`Pro: Use graph_traverse(lens: 'connected_to', node: '${firstScarWithTriples.title}') to explore deeper connections.`));
158
+ }
159
+ }
148
160
  return lines.join("\n");
149
161
  }
150
162
  /**
@@ -10,7 +10,7 @@ import { v4 as uuidv4 } from "uuid";
10
10
  import { detectAgent } from "../services/agent-detection.js";
11
11
  import * as supabase from "../services/supabase-client.js";
12
12
  import { embed, isEmbeddingAvailable } from "../services/embedding.js";
13
- import { hasSupabase, getTableName } from "../services/tier.js";
13
+ import { hasSupabase, hasProInsights, getTableName } from "../services/tier.js";
14
14
  import { getStorage } from "../services/storage.js";
15
15
  import { clearCurrentSession, getSurfacedScars, getConfirmations, getReflections, getObservations, getChildren, getThreads, getSessionActivity, isRecallCalled } from "../services/session-state.js";
16
16
  import { normalizeThreads, mergeThreadStates, migrateStringThread, saveThreadsFile } from "../services/thread-manager.js"; //
@@ -20,6 +20,7 @@ import { validateSessionClose, buildCloseCompliance, } from "../services/complia
20
20
  import { normalizeReflectionKeys } from "../constants/closing-questions.js";
21
21
  import { Timer, recordMetrics, buildPerformanceData, updateRelevanceData, } from "../services/metrics.js";
22
22
  import { wrapDisplay, truncate, productLine, dimText, STATUS, ANSI } from "../services/display-protocol.js";
23
+ import { queryScarUsageByDateRange, enrichScarUsageTitles, formatBlindspotSnippet } from "../services/analytics.js";
23
24
  import { recordScarUsageBatch } from "./record-scar-usage-batch.js";
24
25
  import { getEffectTracker } from "../services/effect-tracker.js";
25
26
  import { saveTranscript } from "./save-transcript.js";
@@ -264,7 +265,7 @@ async function sessionCloseFree(params, timer) {
264
265
  };
265
266
  }
266
267
  }
267
- function formatCloseDisplay(sessionId, compliance, params, learningsCount, success, errors, transcriptStatus) {
268
+ function formatCloseDisplay(sessionId, compliance, params, learningsCount, success, errors, transcriptStatus, blindspotSnippet) {
268
269
  const lines = [];
269
270
  // Header: branded product line
270
271
  const status = success ? STATUS.complete : STATUS.failed;
@@ -320,6 +321,11 @@ function formatCloseDisplay(sessionId, compliance, params, learningsCount, succe
320
321
  lines.push(` ${indicator} ${truncate(s.reference_context || s.scar_identifier || "", 70)}`);
321
322
  }
322
323
  }
324
+ // Pro: blindspot section
325
+ if (blindspotSnippet) {
326
+ lines.push("");
327
+ lines.push(blindspotSnippet);
328
+ }
323
329
  // Transcript — only on failure
324
330
  if (transcriptStatus && !transcriptStatus.saved) {
325
331
  lines.push("");
@@ -1147,11 +1153,31 @@ export async function sessionClose(params) {
1147
1153
  params = { ...params, scars_to_record: bridgedScars };
1148
1154
  }
1149
1155
  }
1156
+ // Pro: fetch blindspot data in parallel with session persistence
1157
+ let blindspotSnippet = null;
1158
+ const blindspotPromise = (async () => {
1159
+ if (!hasProInsights())
1160
+ return;
1161
+ try {
1162
+ const endDate = new Date().toISOString();
1163
+ const startDate = new Date(Date.now() - 30 * 86400000).toISOString();
1164
+ const project = isRetroactive ? "default" : existingSession?.project || "default";
1165
+ const rawUsages = await queryScarUsageByDateRange(startDate, endDate, project);
1166
+ const usages = await enrichScarUsageTitles(rawUsages);
1167
+ blindspotSnippet = formatBlindspotSnippet(usages);
1168
+ }
1169
+ catch (error) {
1170
+ console.error("[session_close] Blindspot fetch failed (non-fatal):", error);
1171
+ }
1172
+ })();
1150
1173
  // 6. Persist to Supabase (direct REST API, bypasses ww-mcp)
1151
1174
  try {
1152
1175
  // Upsert session WITHOUT embedding (fast path)
1153
1176
  // Embedding + thread detection run fire-and-forget after
1154
- await supabase.directUpsert(getTableName("sessions"), sessionData);
1177
+ await Promise.all([
1178
+ supabase.directUpsert(getTableName("sessions"), sessionData),
1179
+ blindspotPromise,
1180
+ ]);
1155
1181
  // Tracked fire-and-forget embedding generation + session update + thread detection
1156
1182
  if (isEmbeddingAvailable()) {
1157
1183
  getEffectTracker().track("embedding", "session_close", async () => {
@@ -1262,7 +1288,7 @@ export async function sessionClose(params) {
1262
1288
  }
1263
1289
  catch { /* already gone */ }
1264
1290
  }
1265
- const display = formatCloseDisplay(sessionId, closeCompliance, params, learningsCount, true, validation.warnings.length > 0 ? validation.warnings : undefined, transcriptStatus);
1291
+ const display = formatCloseDisplay(sessionId, closeCompliance, params, learningsCount, true, validation.warnings.length > 0 ? validation.warnings : undefined, transcriptStatus, blindspotSnippet);
1266
1292
  return {
1267
1293
  success: true,
1268
1294
  session_id: sessionId,
@@ -1278,7 +1304,7 @@ export async function sessionClose(params) {
1278
1304
  const perfData = buildPerformanceData("session_close", latencyMs, 0);
1279
1305
  // Clear session state even on error (session is done either way)
1280
1306
  clearCurrentSession();
1281
- const errorDisplay = formatCloseDisplay(sessionId, closeCompliance, params, learningsCount, false, [`Failed to persist session: ${errorMessage}`], transcriptStatus);
1307
+ const errorDisplay = formatCloseDisplay(sessionId, closeCompliance, params, learningsCount, false, [`Failed to persist session: ${errorMessage}`], transcriptStatus, blindspotSnippet);
1282
1308
  return {
1283
1309
  success: false,
1284
1310
  session_id: sessionId,