prism-mcp-server 4.3.0 → 4.6.1

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.
@@ -20,7 +20,7 @@
20
20
  * helps the AI understand how much token budget was saved.
21
21
  */
22
22
  import { performWebSearch, performWebSearchRaw, performLocalSearch, performLocalSearchRaw, performBraveAnswers } from "../utils/braveApi.js";
23
- import { analyzePaperWithGemini } from "../utils/googleAi.js";
23
+ import { getLLMProvider } from "../utils/llm/factory.js";
24
24
  import { isBraveWebSearchArgs, isBraveLocalSearchArgs, isBraveAnswersArgs, isGeminiResearchPaperAnalysisArgs, isBraveWebSearchCodeModeArgs, isBraveLocalSearchCodeModeArgs, isCodeModeTransformArgs } from "./definitions.js";
25
25
  import { runInSandbox } from "../utils/executor.js";
26
26
  import { CODE_MODE_TEMPLATES, getTemplateNames } from "../templates/codeMode.js";
@@ -216,8 +216,31 @@ export async function researchPaperAnalysisHandler(args) {
216
216
  };
217
217
  }
218
218
  try {
219
- debugLog(`Analyzing research paper with Gemini (${analysisType} analysis)...`);
220
- const analysis = await analyzePaperWithGemini(paperContent, analysisType, additionalContext);
219
+ debugLog(`Analyzing research paper (${analysisType} analysis)...`);
220
+ // Build the analysis prompt (mirrors the original analyzePaperWithGemini logic)
221
+ let prompt = `I need you to perform a detailed ${analysisType} analysis of the following research paper.\n\n`;
222
+ if (additionalContext) {
223
+ prompt += `Additional context: ${additionalContext}\n\n`;
224
+ }
225
+ prompt += `Research paper content:\n${paperContent}\n\n`;
226
+ switch (analysisType.toLowerCase()) {
227
+ case "summary":
228
+ prompt += "Provide a comprehensive summary including the research question, methodology, key findings, and conclusions.";
229
+ break;
230
+ case "critique":
231
+ prompt += "Provide a critical evaluation of the research methodology, validity of findings, limitations, and suggestions for improvement.";
232
+ break;
233
+ case "literature review":
234
+ prompt += "Analyze how this paper fits into the broader research landscape, identifying key related works and research gaps.";
235
+ break;
236
+ case "key findings":
237
+ prompt += "Extract and explain the most significant findings and their implications.";
238
+ break;
239
+ default:
240
+ prompt += "Perform a comprehensive analysis including summary, methodology assessment, key findings, limitations, and significance.";
241
+ }
242
+ const llm = getLLMProvider();
243
+ const analysis = await llm.generateText(prompt);
221
244
  return {
222
245
  content: [{ type: "text", text: analysis }],
223
246
  isError: false,
@@ -26,8 +26,8 @@ export { webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, br
26
26
  // This file always exports them — server.ts decides whether to include them in the tool list.
27
27
  //
28
28
  // v0.4.0: Added SESSION_COMPACT_LEDGER_TOOL and SESSION_SEARCH_MEMORY_TOOL
29
- export { SESSION_SAVE_LEDGER_TOOL, SESSION_SAVE_HANDOFF_TOOL, SESSION_LOAD_CONTEXT_TOOL, KNOWLEDGE_SEARCH_TOOL, KNOWLEDGE_FORGET_TOOL, SESSION_COMPACT_LEDGER_TOOL, SESSION_SEARCH_MEMORY_TOOL, MEMORY_HISTORY_TOOL, MEMORY_CHECKOUT_TOOL, SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL, SESSION_HEALTH_CHECK_TOOL, SESSION_FORGET_MEMORY_TOOL, KNOWLEDGE_SET_RETENTION_TOOL, SESSION_SAVE_EXPERIENCE_TOOL, KNOWLEDGE_UPVOTE_TOOL, KNOWLEDGE_DOWNVOTE_TOOL, KNOWLEDGE_SYNC_RULES_TOOL } from "./sessionMemoryDefinitions.js";
30
- export { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, knowledgeSearchHandler, knowledgeForgetHandler, sessionSearchMemoryHandler, backfillEmbeddingsHandler, memoryHistoryHandler, memoryCheckoutHandler, sessionSaveImageHandler, sessionViewImageHandler, sessionHealthCheckHandler, sessionForgetMemoryHandler, knowledgeSetRetentionHandler, sessionSaveExperienceHandler, knowledgeUpvoteHandler, knowledgeDownvoteHandler, knowledgeSyncRulesHandler } from "./sessionMemoryHandlers.js";
29
+ export { SESSION_SAVE_LEDGER_TOOL, SESSION_SAVE_HANDOFF_TOOL, SESSION_LOAD_CONTEXT_TOOL, KNOWLEDGE_SEARCH_TOOL, KNOWLEDGE_FORGET_TOOL, SESSION_COMPACT_LEDGER_TOOL, SESSION_SEARCH_MEMORY_TOOL, MEMORY_HISTORY_TOOL, MEMORY_CHECKOUT_TOOL, SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL, SESSION_HEALTH_CHECK_TOOL, SESSION_FORGET_MEMORY_TOOL, SESSION_EXPORT_MEMORY_TOOL, KNOWLEDGE_SET_RETENTION_TOOL, SESSION_SAVE_EXPERIENCE_TOOL, KNOWLEDGE_UPVOTE_TOOL, KNOWLEDGE_DOWNVOTE_TOOL, KNOWLEDGE_SYNC_RULES_TOOL } from "./sessionMemoryDefinitions.js";
30
+ export { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, knowledgeSearchHandler, knowledgeForgetHandler, sessionSearchMemoryHandler, backfillEmbeddingsHandler, memoryHistoryHandler, memoryCheckoutHandler, sessionSaveImageHandler, sessionViewImageHandler, sessionHealthCheckHandler, sessionForgetMemoryHandler, knowledgeSetRetentionHandler, sessionSaveExperienceHandler, knowledgeUpvoteHandler, knowledgeDownvoteHandler, knowledgeSyncRulesHandler, sessionExportMemoryHandler } from "./sessionMemoryHandlers.js";
31
31
  // ── Compaction Handler (v0.4.0 — Enhancement #2) ──
32
32
  // The compaction handler is in a separate file because it's significantly
33
33
  // more complex than the other session memory handlers (chunked Gemini
@@ -605,6 +605,59 @@ export function isSessionForgetMemoryArgs(args) {
605
605
  "memory_id" in args &&
606
606
  typeof args.memory_id === "string");
607
607
  }
608
+ // ─── Phase 2: GDPR Export Tool ─────────────────────────────────────────
609
+ //
610
+ // Complements session_forget_memory (surgical deletion) with a full data
611
+ // portability export. Fulfills GDPR Article 20 (Right to Data Portability).
612
+ // API keys are always redacted from the exported settings object.
613
+ export const SESSION_EXPORT_MEMORY_TOOL = {
614
+ name: "session_export_memory",
615
+ description: "Export all of a project's memory to a local file. " +
616
+ "Fulfills GDPR Article 20 (Right to Data Portability) and the " +
617
+ "'local-first' portability promise.\n\n" +
618
+ "**What is exported:**\n" +
619
+ "- All session ledger entries (summaries, decisions, TODOs, file changes)\n" +
620
+ "- Current handoff state (live project context)\n" +
621
+ "- System settings (API keys are \"**REDACTED**\" for security)\n" +
622
+ "- Visual memory index (descriptions, captions, timestamps; not the raw files)\n\n" +
623
+ "**Formats:**\n" +
624
+ "- `json` — machine-readable, suitable for import into another Prism instance\n" +
625
+ "- `markdown` — human-readable, ideal for Obsidian, Notion, or archiving\n\n" +
626
+ "⚠️ Output directory must exist and be writable. " +
627
+ "Filenames are auto-generated: `prism-export-<project>-<date>.(json|md)`",
628
+ inputSchema: {
629
+ type: "object",
630
+ properties: {
631
+ project: {
632
+ type: "string",
633
+ description: "Project to export. If omitted, exports ALL projects into separate files.",
634
+ },
635
+ format: {
636
+ type: "string",
637
+ enum: ["json", "markdown"],
638
+ description: "Export format: 'json' (machine-readable) or 'markdown' (human-readable). Default: json.",
639
+ default: "json",
640
+ },
641
+ output_dir: {
642
+ type: "string",
643
+ description: "Absolute path to the directory where the export file(s) will be written. " +
644
+ "Must exist and be writable. Example: '/Users/admin/Desktop'.",
645
+ },
646
+ },
647
+ required: ["output_dir"],
648
+ },
649
+ };
650
+ /**
651
+ * Type guard for session_export_memory arguments.
652
+ * output_dir is required (must be an absolute path).
653
+ * project and format are optional.
654
+ */
655
+ export function isSessionExportMemoryArgs(args) {
656
+ return (typeof args === "object" &&
657
+ args !== null &&
658
+ "output_dir" in args &&
659
+ typeof args.output_dir === "string");
660
+ }
608
661
  // ─── v3.1: Knowledge Set Retention (TTL) ─────────────────────
609
662
  export const KNOWLEDGE_SET_RETENTION_TOOL = {
610
663
  name: "knowledge_set_retention",
@@ -18,9 +18,9 @@
18
18
  import { debugLog } from "../utils/logger.js";
19
19
  import { getStorage } from "../storage/index.js";
20
20
  import { toKeywordArray } from "../utils/keywordExtractor.js";
21
- import { generateEmbedding } from "../utils/embeddingApi.js";
21
+ import { getLLMProvider } from "../utils/llm/factory.js";
22
22
  import { getCurrentGitState, getGitDrift } from "../utils/git.js";
23
- import { getSetting } from "../storage/configStorage.js";
23
+ import { getSetting, getAllSettings } from "../storage/configStorage.js";
24
24
  // ─── Phase 1: Explainability & Memory Lineage ────────────────
25
25
  // These utilities provide structured tracing metadata for search operations.
26
26
  // When `enable_trace: true` is passed to session_search_memory or knowledge_search,
@@ -30,6 +30,7 @@ import { getSetting } from "../storage/configStorage.js";
30
30
  import { createMemoryTrace, traceToContentBlock } from "../utils/tracing.js";
31
31
  import { GOOGLE_API_KEY, PRISM_USER_ID, PRISM_AUTO_CAPTURE, PRISM_CAPTURE_PORTS } from "../config.js";
32
32
  import { captureLocalEnvironment } from "../utils/autoCapture.js";
33
+ import { fireCaptionAsync } from "../utils/imageCaptioner.js";
33
34
  import { isSessionSaveLedgerArgs, isSessionSaveHandoffArgs, isSessionLoadContextArgs, isKnowledgeSearchArgs, isKnowledgeForgetArgs, isSessionSearchMemoryArgs, isBackfillEmbeddingsArgs, isMemoryHistoryArgs, isMemoryCheckoutArgs, isSessionHealthCheckArgs, // v2.2.0: health check type guard
34
35
  isSessionForgetMemoryArgs, // Phase 2: GDPR-compliant memory deletion type guard
35
36
  isKnowledgeSetRetentionArgs, // v3.1: TTL retention policy type guard
@@ -40,7 +41,7 @@ isKnowledgeSyncRulesArgs, } from "./sessionMemoryDefinitions.js";
40
41
  // v4.2: File system access for knowledge_sync_rules
41
42
  import { readFile, writeFile, mkdir } from "node:fs/promises";
42
43
  import { existsSync } from "node:fs";
43
- import { join, dirname } from "node:path";
44
+ import { join, dirname, resolve, isAbsolute, sep } from "node:path";
44
45
  // v3.1: In-memory debounce lock for auto-compaction.
45
46
  // Prevents multiple concurrent Gemini compaction tasks for the same project
46
47
  // when many agents call session_save_ledger at the same time.
@@ -102,7 +103,7 @@ export async function sessionSaveLedgerHandler(args) {
102
103
  const savedEntry = Array.isArray(result) ? result[0] : result;
103
104
  const entryId = savedEntry?.id;
104
105
  if (entryId) {
105
- generateEmbedding(embeddingText)
106
+ getLLMProvider().generateEmbedding(embeddingText)
106
107
  .then(async (embedding) => {
107
108
  await storage.patchLedger(entryId, {
108
109
  embedding: JSON.stringify(embedding),
@@ -144,6 +145,13 @@ export async function sessionSaveLedgerHandler(args) {
144
145
  activeCompactions.delete(project);
145
146
  }
146
147
  }).catch(() => { });
148
+ // ─── Fire-and-forget importance decay (v4.3) ──────────────
149
+ // Decays stale behavioral insights (>30d old) by -1 importance.
150
+ // Matches SQLite's automatic decay behavior on every save.
151
+ // Non-fatal: errors are logged but never surfaced to the caller.
152
+ storage.decayImportance(project, PRISM_USER_ID, 30).catch((err) => {
153
+ debugLog(`[session_save_ledger] Background decay failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
154
+ });
147
155
  return {
148
156
  content: [{
149
157
  type: "text",
@@ -824,7 +832,7 @@ export async function sessionSearchMemoryHandler(args) {
824
832
  // This is the most variable component: 50ms on a good day, 2000ms under load.
825
833
  const embeddingStart = performance.now();
826
834
  try {
827
- queryEmbedding = await generateEmbedding(query);
835
+ queryEmbedding = await getLLMProvider().generateEmbedding(query);
828
836
  }
829
837
  catch (err) {
830
838
  return {
@@ -1013,7 +1021,7 @@ export async function backfillEmbeddingsHandler(args) {
1013
1021
  failed++;
1014
1022
  continue;
1015
1023
  }
1016
- const embedding = await generateEmbedding(textToEmbed);
1024
+ const embedding = await getLLMProvider().generateEmbedding(textToEmbed);
1017
1025
  await storage.patchLedger(e.id, {
1018
1026
  embedding: JSON.stringify(embedding),
1019
1027
  });
@@ -1244,6 +1252,8 @@ export async function sessionSaveImageHandler(args) {
1244
1252
  const fileSize = fs.statSync(vaultPath).size;
1245
1253
  const sizeKB = (fileSize / 1024).toFixed(1);
1246
1254
  debugLog(`[Visual Memory] Saved image [${imageId}] for "${project}" (${sizeKB}KB, ${ext})`);
1255
+ // Fire-and-forget VLM captioning (2-5s — don’t block the MCP response)
1256
+ fireCaptionAsync(project, imageId, vaultPath, description);
1247
1257
  return {
1248
1258
  content: [{
1249
1259
  type: "text",
@@ -1251,7 +1261,8 @@ export async function sessionSaveImageHandler(args) {
1251
1261
  `• ID: \`${imageId}\`\n` +
1252
1262
  `• Description: ${description}\n` +
1253
1263
  `• Format: ${ext} (${sizeKB}KB)\n` +
1254
- `• Vault: ${vaultPath}\n\n` +
1264
+ `• Vault: ${vaultPath}\n` +
1265
+ `• Captioning: ⏳ queued (will be searchable in ~5s)\n\n` +
1255
1266
  `Use \`session_view_image("${project}", "${imageId}")\` to retrieve it later.`,
1256
1267
  }],
1257
1268
  isError: false,
@@ -1321,7 +1332,8 @@ export async function sessionViewImageHandler(args) {
1321
1332
  type: "text",
1322
1333
  text: `🖼️ Visual Memory [${image_id}]: ${imgMeta.description}\n` +
1323
1334
  `Saved: ${imgMeta.timestamp?.split("T")[0] || "unknown"}\n` +
1324
- `Format: ${ext.replace(".", "").toUpperCase()} (${(fileSize / 1024).toFixed(1)}KB)`,
1335
+ `Format: ${ext.replace(".", "").toUpperCase()} (${(fileSize / 1024).toFixed(1)}KB)` +
1336
+ (imgMeta.caption ? `\n\n🤖 VLM Caption:\n${imgMeta.caption}` : "\n\n⏳ Caption: generating..."),
1325
1337
  },
1326
1338
  {
1327
1339
  type: "image",
@@ -1645,7 +1657,7 @@ export async function sessionSaveExperienceHandler(args) {
1645
1657
  const savedEntry = Array.isArray(result) ? result[0] : result;
1646
1658
  const entryId = savedEntry?.id;
1647
1659
  if (entryId) {
1648
- generateEmbedding(embeddingText)
1660
+ getLLMProvider().generateEmbedding(embeddingText)
1649
1661
  .then(async (embedding) => {
1650
1662
  await storage.patchLedger(entryId, {
1651
1663
  embedding: JSON.stringify(embedding),
@@ -1679,15 +1691,21 @@ export async function knowledgeUpvoteHandler(args) {
1679
1691
  throw new Error("Invalid arguments for knowledge_upvote");
1680
1692
  }
1681
1693
  const storage = await getStorage();
1682
- await storage.adjustImportance(args.id, 1, PRISM_USER_ID);
1683
- debugLog(`[knowledge_upvote] Upvoted entry ${args.id}`);
1684
- return {
1685
- content: [{
1686
- type: "text",
1687
- text: `👍 Entry ${args.id} upvoted (+1 importance).`,
1688
- }],
1689
- isError: false,
1690
- };
1694
+ try {
1695
+ await storage.adjustImportance(args.id, 1, PRISM_USER_ID);
1696
+ debugLog(`[knowledge_upvote] Upvoted entry ${args.id}`);
1697
+ return {
1698
+ content: [{ type: "text", text: `👍 Entry ${args.id} upvoted (+1 importance).` }],
1699
+ isError: false,
1700
+ };
1701
+ }
1702
+ catch (err) {
1703
+ const msg = err instanceof Error ? err.message : String(err);
1704
+ return {
1705
+ content: [{ type: "text", text: `❌ Failed to upvote entry ${args.id}: ${msg}` }],
1706
+ isError: true,
1707
+ };
1708
+ }
1691
1709
  }
1692
1710
  // ─── v4.0: Knowledge Downvote Handler ────────────────────────
1693
1711
  /**
@@ -1699,15 +1717,21 @@ export async function knowledgeDownvoteHandler(args) {
1699
1717
  throw new Error("Invalid arguments for knowledge_downvote");
1700
1718
  }
1701
1719
  const storage = await getStorage();
1702
- await storage.adjustImportance(args.id, -1, PRISM_USER_ID);
1703
- debugLog(`[knowledge_downvote] Downvoted entry ${args.id}`);
1704
- return {
1705
- content: [{
1706
- type: "text",
1707
- text: `👎 Entry ${args.id} downvoted (-1 importance).`,
1708
- }],
1709
- isError: false,
1710
- };
1720
+ try {
1721
+ await storage.adjustImportance(args.id, -1, PRISM_USER_ID);
1722
+ debugLog(`[knowledge_downvote] Downvoted entry ${args.id}`);
1723
+ return {
1724
+ content: [{ type: "text", text: `👎 Entry ${args.id} downvoted (-1 importance).` }],
1725
+ isError: false,
1726
+ };
1727
+ }
1728
+ catch (err) {
1729
+ const msg = err instanceof Error ? err.message : String(err);
1730
+ return {
1731
+ content: [{ type: "text", text: `❌ Failed to downvote entry ${args.id}: ${msg}` }],
1732
+ isError: true,
1733
+ };
1734
+ }
1711
1735
  }
1712
1736
  // ─── v4.2: Knowledge Sync Rules Handler ─────────────────────
1713
1737
  //
@@ -1729,7 +1753,7 @@ const SENTINEL_END = "<!-- PRISM:AUTO-RULES:END -->";
1729
1753
  */
1730
1754
  function formatRulesBlock(insights, project) {
1731
1755
  const header = `## Prism Graduated Insights (auto-synced)\n\n` +
1732
- `> These rules were automatically generated by [Prism MCP](https://github.com/fdarcy/prism) ` +
1756
+ `> These rules were automatically generated by [Prism MCP](https://github.com/dcostenco/prism-mcp) ` +
1733
1757
  `from behavioral memory for project \"${project}\".\n` +
1734
1758
  `> Last synced: ${new Date().toISOString().split("T")[0]}\n\n`;
1735
1759
  const rules = insights.map(i => {
@@ -1804,8 +1828,32 @@ export async function knowledgeSyncRulesHandler(args) {
1804
1828
  isError: false,
1805
1829
  };
1806
1830
  }
1807
- // 5. Idempotent file write
1808
- const targetPath = join(normalizedRepoPath, target_file);
1831
+ // 5. Idempotent file write — with path traversal protection
1832
+ // Reject absolute paths (e.g. "/etc/hosts")
1833
+ if (isAbsolute(target_file)) {
1834
+ return {
1835
+ content: [{
1836
+ type: "text",
1837
+ text: `❌ Security Error: target_file cannot be an absolute path. Got: "${target_file}"`,
1838
+ }],
1839
+ isError: true,
1840
+ };
1841
+ }
1842
+ // Resolve both paths to their canonical forms, then assert containment
1843
+ const resolvedRepo = resolve(normalizedRepoPath);
1844
+ const targetPath = resolve(resolvedRepo, target_file);
1845
+ // Ensure the resolved target is strictly inside the repo root
1846
+ // (handles "../../../etc/hosts" style traversal)
1847
+ if (!targetPath.startsWith(resolvedRepo + sep)) {
1848
+ return {
1849
+ content: [{
1850
+ type: "text",
1851
+ text: `❌ Security Error: Path traversal blocked.\n` +
1852
+ `"${target_file}" resolves outside the repo root "${resolvedRepo}".`,
1853
+ }],
1854
+ isError: true,
1855
+ };
1856
+ }
1809
1857
  // Ensure directory exists (handles nested target_file like ".config/rules.md")
1810
1858
  const targetDir = dirname(targetPath);
1811
1859
  if (!existsSync(targetDir)) {
@@ -1833,3 +1881,188 @@ export async function knowledgeSyncRulesHandler(args) {
1833
1881
  isError: false,
1834
1882
  };
1835
1883
  }
1884
+ // ────────────────────────────────────────────────────────
1885
+ // GDPR Export Handler (v4.5.1)
1886
+ // Implements session_export_memory.
1887
+ // Article 20: Right to Data Portability — fully local, no network calls.
1888
+ // ────────────────────────────────────────────────────────
1889
+ import { isSessionExportMemoryArgs, } from "./sessionMemoryDefinitions.js";
1890
+ // Keys whose values must be redacted from the export.
1891
+ // Matches any setting key ending with "_api_key" or "_secret".
1892
+ const REDACT_PATTERNS = [/_api_key$/i, /_secret$/i, /^password$/i];
1893
+ function redactSettings(settings) {
1894
+ const redacted = {};
1895
+ for (const [k, v] of Object.entries(settings)) {
1896
+ redacted[k] = REDACT_PATTERNS.some(p => p.test(k)) ? "**REDACTED**" : v;
1897
+ }
1898
+ return redacted;
1899
+ }
1900
+ function toMarkdown(exportData) {
1901
+ const data = exportData;
1902
+ const d = data.prism_export;
1903
+ const lines = [];
1904
+ lines.push(`# Prism Memory Export: \`${d.project}\``);
1905
+ lines.push(``);
1906
+ lines.push(`> Exported: ${d.exported_at} | Version: ${d.version}`);
1907
+ lines.push(``);
1908
+ // ── Settings
1909
+ lines.push(`## ⚙️ Settings`);
1910
+ lines.push(``);
1911
+ lines.push(`| Key | Value |`);
1912
+ lines.push(`|-----|-------|`);
1913
+ for (const [k, v] of Object.entries(d.settings)) {
1914
+ lines.push(`| \`${k}\` | ${v} |`);
1915
+ }
1916
+ lines.push(``);
1917
+ // ── Handoff State
1918
+ lines.push(`## 🎯 Live Project State (Handoff)`);
1919
+ lines.push(``);
1920
+ lines.push(`\`\`\`json`);
1921
+ lines.push(JSON.stringify(d.handoff, null, 2));
1922
+ lines.push(`\`\`\``);
1923
+ lines.push(``);
1924
+ // ── Visual Memory
1925
+ if (Array.isArray(d.visual_memory) && d.visual_memory.length > 0) {
1926
+ lines.push(`## 🖼️ Visual Memory (${d.visual_memory.length} images)`);
1927
+ lines.push(``);
1928
+ for (const img of d.visual_memory) {
1929
+ lines.push(`### ${img.id ?? "??"}`);
1930
+ lines.push(`- **Description:** ${img.description ?? "-"}`);
1931
+ lines.push(`- **Saved:** ${String(img.timestamp ?? "-").split("T")[0]}`);
1932
+ if (img.caption)
1933
+ lines.push(`- **VLM Caption:** ${img.caption}`);
1934
+ }
1935
+ lines.push(``);
1936
+ }
1937
+ // ── Ledger
1938
+ lines.push(`## 📚 Session Ledger (${d.ledger.length} entries)`);
1939
+ lines.push(``);
1940
+ for (const entry of d.ledger) {
1941
+ const date = entry.created_at?.split("T")[0] ?? "unknown";
1942
+ const type = entry.event_type ?? "session";
1943
+ lines.push(`---`);
1944
+ lines.push(``);
1945
+ lines.push(`### ${date} \u00b7 \`${type}\` ${entry.id ? `\`${entry.id.slice(0, 8)}\`` : ""}`);
1946
+ lines.push(``);
1947
+ lines.push(entry.summary);
1948
+ if (entry.decisions?.length) {
1949
+ lines.push(``);
1950
+ lines.push(`**Decisions:**`);
1951
+ entry.decisions.forEach(d => lines.push(`- ${d}`));
1952
+ }
1953
+ if (entry.todos?.length) {
1954
+ lines.push(``);
1955
+ lines.push(`**TODOs:**`);
1956
+ entry.todos.forEach(t => lines.push(`- [ ] ${t}`));
1957
+ }
1958
+ if (entry.files_changed?.length) {
1959
+ lines.push(``);
1960
+ lines.push(`**Files:** ${entry.files_changed.join(", ")}`);
1961
+ }
1962
+ lines.push(``);
1963
+ }
1964
+ return lines.join("\n");
1965
+ }
1966
+ /**
1967
+ * Export a project's full memory (ledger + handoff + settings + visual memory)
1968
+ * to a local file. No network calls. API keys always redacted.
1969
+ */
1970
+ export async function sessionExportMemoryHandler(args) {
1971
+ if (!isSessionExportMemoryArgs(args)) {
1972
+ return {
1973
+ content: [{ type: "text", text: "Error: output_dir (string) is required." }],
1974
+ isError: true,
1975
+ };
1976
+ }
1977
+ const { output_dir, format = "json" } = args;
1978
+ const requestedProject = args.project;
1979
+ // Validate output directory
1980
+ if (!existsSync(output_dir)) {
1981
+ return {
1982
+ content: [{
1983
+ type: "text",
1984
+ text: `Error: output_dir does not exist: "${output_dir}". Please create it first.`,
1985
+ }],
1986
+ isError: true,
1987
+ };
1988
+ }
1989
+ const storage = await getStorage();
1990
+ const exportedFiles = [];
1991
+ try {
1992
+ // Determine which projects to export
1993
+ let projects;
1994
+ if (requestedProject) {
1995
+ projects = [requestedProject];
1996
+ }
1997
+ else {
1998
+ projects = await storage.listProjects();
1999
+ if (projects.length === 0) {
2000
+ return {
2001
+ content: [{ type: "text", text: "No projects found in memory — nothing to export." }],
2002
+ isError: false,
2003
+ };
2004
+ }
2005
+ }
2006
+ // Fetch settings once (shared across all projects)
2007
+ const rawSettings = await getAllSettings();
2008
+ const safeSettings = redactSettings(rawSettings);
2009
+ const exportedAt = new Date().toISOString();
2010
+ const dateSuffix = exportedAt.split("T")[0]; // YYYY-MM-DD
2011
+ for (const project of projects) {
2012
+ debugLog(`[session_export_memory] Exporting project "${project}" as ${format}`);
2013
+ // Fetch handoff (live context)
2014
+ const ctx = await storage.loadContext(project, "deep", PRISM_USER_ID);
2015
+ // Fetch full ledger (all non-deleted entries)
2016
+ const ledger = await storage.getLedgerEntries({ project });
2017
+ // Strip raw embedding vectors from the export (large binary data)
2018
+ const cleanLedger = ledger.map(({ embedding: _emb, ...rest }) => rest);
2019
+ const visualMemory = ctx?.metadata?.visual_memory ?? [];
2020
+ const exportPayload = {
2021
+ prism_export: {
2022
+ version: "4.5",
2023
+ exported_at: exportedAt,
2024
+ project,
2025
+ settings: safeSettings,
2026
+ handoff: ctx ?? null,
2027
+ visual_memory: visualMemory,
2028
+ ledger: cleanLedger,
2029
+ },
2030
+ };
2031
+ // Serialize
2032
+ const ext = format === "markdown" ? "md" : "json";
2033
+ const filename = `prism-export-${project}-${dateSuffix}.${ext}`;
2034
+ const outputPath = join(output_dir, filename);
2035
+ let content;
2036
+ if (format === "markdown") {
2037
+ content = toMarkdown(exportPayload);
2038
+ }
2039
+ else {
2040
+ content = JSON.stringify(exportPayload, null, 2);
2041
+ }
2042
+ await writeFile(outputPath, content, "utf-8");
2043
+ exportedFiles.push(outputPath);
2044
+ debugLog(`[session_export_memory] Wrote ${content.length} bytes to ${outputPath}`);
2045
+ }
2046
+ const plural = exportedFiles.length > 1 ? "files" : "file";
2047
+ return {
2048
+ content: [{
2049
+ type: "text",
2050
+ text: `✅ Memory exported successfully (${format.toUpperCase()})\n\n` +
2051
+ `**Project(s):** ${projects.join(", ")}\n` +
2052
+ `**${exportedFiles.length} ${plural} written:**\n` +
2053
+ exportedFiles.map(f => ` \u2022 \`${f}\``).join("\n") +
2054
+ `\n\n⚠️ API keys have been redacted. Vault image files are NOT included — ` +
2055
+ `only metadata and captions. Re-run \`session_save_image\` to re-attach images.`,
2056
+ }],
2057
+ isError: false,
2058
+ };
2059
+ }
2060
+ catch (err) {
2061
+ const msg = err instanceof Error ? err.message : String(err);
2062
+ console.error(`[session_export_memory] Error: ${msg}`);
2063
+ return {
2064
+ content: [{ type: "text", text: `Export failed: ${msg}` }],
2065
+ isError: true,
2066
+ };
2067
+ }
2068
+ }
@@ -12,8 +12,7 @@
12
12
  * - Reuses GOOGLE_API_KEY from config.ts (same key as embeddings)
13
13
  * ═══════════════════════════════════════════════════════════════════
14
14
  */
15
- import { GoogleGenerativeAI } from "@google/generative-ai";
16
- import { GOOGLE_API_KEY } from "../config.js";
15
+ import { getLLMProvider } from "./llm/factory.js";
17
16
  import { debugLog } from "./logger.js";
18
17
  /**
19
18
  * Generates a 3-bullet Morning Briefing using Gemini.
@@ -23,12 +22,13 @@ import { debugLog } from "./logger.js";
23
22
  * @returns Formatted briefing text, or a graceful fallback string
24
23
  */
25
24
  export async function generateMorningBriefing(context, recentEntries) {
26
- if (!GOOGLE_API_KEY) {
27
- return "☀️ Good morning! (Morning Briefing unavailable — no GOOGLE_API_KEY configured)";
25
+ let llm;
26
+ try {
27
+ llm = getLLMProvider();
28
+ }
29
+ catch {
30
+ return "☀️ Good morning! (Morning Briefing unavailable — LLM provider not configured)";
28
31
  }
29
- const genAI = new GoogleGenerativeAI(GOOGLE_API_KEY);
30
- // 2.5-flash for speed — briefings should take ≤3s
31
- const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
32
32
  const todosBlock = context.pendingTodos?.length
33
33
  ? `Pending TODOs:\n${context.pendingTodos.map(t => ` • ${t}`).join("\n")}`
34
34
  : "No pending TODOs.";
@@ -57,13 +57,12 @@ Rules:
57
57
  - No preamble, no closing remarks — just the 3 bullets.
58
58
  - Each bullet starts with a relevant emoji.`;
59
59
  try {
60
- const result = await model.generateContent(prompt);
61
- const text = result.response.text().trim();
60
+ const text = (await llm.generateText(prompt)).trim();
62
61
  debugLog(`[Morning Briefing] Generated for "${context.project}" (${text.length} chars)`);
63
62
  return text;
64
63
  }
65
64
  catch (error) {
66
- console.error(`[Morning Briefing] Gemini call failed: ${error instanceof Error ? error.message : String(error)}`);
65
+ console.error(`[Morning Briefing] LLM call failed: ${error instanceof Error ? error.message : String(error)}`);
67
66
  return "☀️ Good morning! Ready to continue — check your TODOs above to pick up where you left off.";
68
67
  }
69
68
  }
@@ -33,8 +33,7 @@
33
33
  * - Uses gemini-2.5-flash for speed (~2-3s per merge)
34
34
  * ═══════════════════════════════════════════════════════════════════
35
35
  */
36
- import { GoogleGenerativeAI } from "@google/generative-ai"; // Gemini SDK for LLM calls
37
- import { GOOGLE_API_KEY } from "../config.js"; // API key from environment
36
+ import { getLLMProvider } from "./llm/factory.js";
38
37
  import { debugLog } from "./logger.js";
39
38
  /**
40
39
  * Merge old and new key_context using Gemini to resolve contradictions.
@@ -56,9 +55,13 @@ import { debugLog } from "./logger.js";
56
55
  * // Result: "We use MySQL for the main DB"
57
56
  */
58
57
  export async function consolidateFacts(oldContext, newContext) {
59
- // Guard: need API key to call Gemini
60
- if (!GOOGLE_API_KEY) {
61
- debugLog("[FactMerger] Skipped — no GOOGLE_API_KEY configured");
58
+ // Guard: need LLM provider factory throws if no API key
59
+ let llm;
60
+ try {
61
+ llm = getLLMProvider();
62
+ }
63
+ catch {
64
+ debugLog("[FactMerger] Skipped — LLM provider unavailable (no API key configured)");
62
65
  return newContext; // fallback: just use the new context as-is
63
66
  }
64
67
  // Guard: if either context is empty, no merging needed
@@ -73,13 +76,7 @@ export async function consolidateFacts(oldContext, newContext) {
73
76
  debugLog("[FactMerger] Old and new context are identical — skipping merge");
74
77
  return newContext; // no changes needed
75
78
  }
76
- // Initialize Gemini with the configured API key
77
- const genAI = new GoogleGenerativeAI(GOOGLE_API_KEY);
78
- // Use gemini-2.5-flash for speed — merges should complete in ~2-3s
79
- const model = genAI.getGenerativeModel({
80
- model: "gemini-2.5-flash",
81
- });
82
- // Build the merge prompt — instructs Gemini to resolve contradictions
79
+ // Build the merge prompt instructs LLM to resolve contradictions
83
80
  // and deduplicate while keeping the NEW UPDATE as source of truth
84
81
  const prompt = "You are a memory consolidation engine for an AI agent.\n\n" +
85
82
  "OLD MEMORY:\n" + oldContext + "\n\n" +
@@ -92,10 +89,8 @@ export async function consolidateFacts(oldContext, newContext) {
92
89
  "4. Preserve unique facts from both old and new that don't conflict.\n" +
93
90
  "5. Return ONLY the consolidated raw text. No markdown, no preamble, " +
94
91
  "no explanation — just the merged facts.";
95
- // Call Gemini to perform the intelligent merge
96
- const result = await model.generateContent(prompt);
97
- // Extract and trim the merged text from Gemini's response
98
- const mergedText = result.response.text().trim();
92
+ // Call LLM to perform the intelligent merge
93
+ const mergedText = (await llm.generateText(prompt)).trim();
99
94
  // Log the merge result for debugging (to stderr, not stdout)
100
95
  debugLog("[FactMerger] Merged context (" +
101
96
  oldContext.length + " chars old + " +