wyrm-mcp 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,6 +46,36 @@ AI: *checks Wyrm* "We went with JWT + refresh tokens, stored in httpOnly cookie
46
46
 
47
47
  ---
48
48
 
49
+ ## Installation
50
+
51
+ ### npm (recommended)
52
+ ```bash
53
+ npm install -g wyrm-mcp
54
+ ```
55
+
56
+ ### Homebrew (macOS / Linux)
57
+ ```bash
58
+ brew tap ghosts-lk/wyrm
59
+ brew install wyrm-mcp
60
+ ```
61
+
62
+ ### Docker
63
+ ```bash
64
+ docker run -d -p 3000:3000 -v wyrm-data:/data ghcr.io/ghosts-lk/wyrm-mcp:latest
65
+ ```
66
+ Or with Docker Compose:
67
+ ```bash
68
+ curl -O https://raw.githubusercontent.com/ghosts-lk/Wyrm/main/docker-compose.yml
69
+ docker compose up -d
70
+ ```
71
+
72
+ ### npx (no install)
73
+ ```bash
74
+ npx wyrm-mcp
75
+ ```
76
+
77
+ ---
78
+
49
79
  ## Quick Start
50
80
 
51
81
  ### 1. Install
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;GAeG;AAwBH,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;GAeG;AA4BH,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -23,7 +23,10 @@ import { WyrmSync } from "./sync.js";
23
23
  import { createContextBundle } from "./summarizer.js";
24
24
  import { autoConfigureAll, removeFromAll, getStatusSummary, findWyrmServerPath, getDefaultDbPath } from "./autoconfig.js";
25
25
  import { cache, estimateTokens } from "./performance.js";
26
- import { createHash } from "crypto";
26
+ import { createHash, randomBytes, pbkdf2Sync, createCipheriv, createDecipheriv } from "crypto";
27
+ import { readFileSync, writeFileSync, copyFileSync, chmodSync, unlinkSync, existsSync as fsExistsSync } from "fs";
28
+ import { homedir } from "os";
29
+ import { join as pathJoin } from "path";
27
30
  import { initializeOrchestrator, getDefaultConfig } from "./auto-orchestrator.js";
28
31
  import { sanitizeFtsQuery, sanitizeString, validateBatchSize } from "./security.js";
29
32
  import { getCrypto, initializeCrypto } from "./crypto.js";
@@ -31,7 +34,7 @@ import { createVectorStore } from "./vectors.js";
31
34
  import { IndexingPipeline } from "./indexer.js";
32
35
  import { KnowledgeGraph } from "./knowledge-graph.js";
33
36
  import { MemoryArtifacts } from "./memory-artifacts.js";
34
- import { GroundTruths, ReasoningScaffolds } from "./intelligence.js";
37
+ import { GroundTruths, ReasoningScaffolds, computeStaleness } from "./intelligence.js";
35
38
  import { classifyCapture } from "./capture.js";
36
39
  import { injectSystemPrompt } from "./autoconfig.js";
37
40
  export { classifyCapture } from "./capture.js";
@@ -222,7 +225,6 @@ const READ_ONLY_TOOLS = new Set([
222
225
  "wyrm_entity_search",
223
226
  "wyrm_entity_graph",
224
227
  "wyrm_entity_path",
225
- "wyrm_recall",
226
228
  "wyrm_context_build",
227
229
  "wyrm_truth_get",
228
230
  "wyrm_scaffold_get",
@@ -251,6 +253,7 @@ const WRITE_TOOLS = new Set([
251
253
  "wyrm_entity_link",
252
254
  "wyrm_entity_merge",
253
255
  "wyrm_remember",
256
+ "wyrm_recall",
254
257
  "wyrm_feedback",
255
258
  "wyrm_truth_set",
256
259
  "wyrm_scaffold_save",
@@ -261,10 +264,13 @@ const WRITE_TOOLS = new Set([
261
264
  "wyrm_import_pr",
262
265
  "wyrm_import_rules",
263
266
  "wyrm_inject_prompt",
267
+ "wyrm_prune",
268
+ "wyrm_sync_export",
269
+ "wyrm_sync_import",
264
270
  ]);
265
271
  const server = new Server({
266
272
  name: "wyrm",
267
- version: "3.4.0",
273
+ version: "3.5.0",
268
274
  }, {
269
275
  capabilities: {
270
276
  tools: {},
@@ -905,6 +911,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
905
911
  rationale: { type: "string", description: "Why this is true / how it was determined" },
906
912
  source: { type: "string", description: "Origin: 'user', 'derived', 'observed', 'confirmed'" },
907
913
  confidence: { type: "number", description: "Confidence 0-1 (default: 1.0 for user-asserted truths)" },
914
+ ttl_days: { type: "number", description: "Time-to-live in days — truth becomes stale after this many days since last_verified_at" },
908
915
  },
909
916
  required: ["projectPath", "category", "key", "value"],
910
917
  },
@@ -1020,6 +1027,45 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1020
1027
  },
1021
1028
  },
1022
1029
  },
1030
+ {
1031
+ name: "wyrm_prune",
1032
+ description: "Prune low-confidence, stale memory artifacts. Dry-run by default — use dry_run:false with confirm_ids to actually delete. Never deletes artifacts in the review queue.",
1033
+ inputSchema: {
1034
+ type: "object",
1035
+ properties: {
1036
+ project_id: { type: "number", description: "Project ID (optional, all projects if omitted)" },
1037
+ min_confidence: { type: "number", description: "Prune artifacts below this confidence (default: 0.3)" },
1038
+ older_than_days: { type: "number", description: "Last accessed more than N days ago (default: 90)" },
1039
+ types: { type: "array", items: { type: "string" }, description: "Filter by artifact_type (optional)" },
1040
+ dry_run: { type: "boolean", description: "If true (default), return candidates without deleting. Set false to delete." },
1041
+ confirm_ids: { type: "array", items: { type: "number" }, description: "Required when dry_run:false — only IDs in this list will be deleted" },
1042
+ },
1043
+ },
1044
+ },
1045
+ {
1046
+ name: "wyrm_sync_export",
1047
+ description: "Export an AES-256-GCM encrypted snapshot of the Wyrm database for cross-device sync. Passphrase from WYRM_SYNC_PASSPHRASE env var.",
1048
+ inputSchema: {
1049
+ type: "object",
1050
+ properties: {
1051
+ output_path: { type: "string", description: "File path to write the encrypted snapshot" },
1052
+ description: { type: "string", description: "Optional description of this export" },
1053
+ },
1054
+ required: ["output_path"],
1055
+ },
1056
+ },
1057
+ {
1058
+ name: "wyrm_sync_import",
1059
+ description: "Import an encrypted Wyrm snapshot. Use restore_mode:'preview' to inspect without touching the DB; 'restore' to replace current DB (backs up first).",
1060
+ inputSchema: {
1061
+ type: "object",
1062
+ properties: {
1063
+ input_path: { type: "string", description: "Path to the encrypted snapshot file" },
1064
+ restore_mode: { type: "string", enum: ["preview", "restore"], description: "preview = show stats only; restore = replace DB (backs up first)" },
1065
+ },
1066
+ required: ["input_path", "restore_mode"],
1067
+ },
1068
+ },
1023
1069
  {
1024
1070
  name: "wyrm_import_git",
1025
1071
  description: "Import a list of commits into the Wyrm memory review queue. Useful for onboarding a project's history or capturing recent work.",
@@ -1919,6 +1965,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1919
1965
  limit: rcLimit ?? 10,
1920
1966
  minConfidence: rcMinConf,
1921
1967
  });
1968
+ // Track access for memory decay
1969
+ if (results.length > 0) {
1970
+ const rawDbRecall = db.getDatabase();
1971
+ const ids = results.map(r => r.artifact.id);
1972
+ const placeholders = ids.map(() => '?').join(',');
1973
+ rawDbRecall.prepare(`UPDATE memory_artifacts SET last_accessed_at = datetime('now'), access_count = access_count + 1 WHERE id IN (${placeholders})`).run(...ids);
1974
+ }
1922
1975
  if (results.length === 0) {
1923
1976
  return { content: [{ type: "text", text: `🐉 No relevant memory found for: "${rcQuery}"\n\nUse \`wyrm_remember\` to store knowledge as you work.` }] };
1924
1977
  }
@@ -1942,10 +1995,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1942
1995
  text += `_Confidence: ${(a.confidence * 100).toFixed(0)}% | Used ${a.reuse_count}× | Success rate: ${a.reuse_count > 0 ? ((a.reuse_success_count / a.reuse_count) * 100).toFixed(0) + '%' : 'not yet used'}_\n\n`;
1943
1996
  }
1944
1997
  text += `_Use \`wyrm_feedback\` to record whether these were helpful._`;
1945
- const response = cachedResponse(text);
1946
- if (cacheKey)
1947
- cache.set(cacheKey, response, 15000);
1948
- return response;
1998
+ return cachedResponse(text);
1949
1999
  }
1950
2000
  case "wyrm_feedback": {
1951
2001
  const { artifactId, success } = args;
@@ -2004,7 +2054,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2004
2054
  return response;
2005
2055
  }
2006
2056
  case "wyrm_truth_set": {
2007
- const { projectPath: tPath, category: tCat, key: tKey, value: tVal, rationale: tRat, source: tSrc, confidence: tConf } = args;
2057
+ const { projectPath: tPath, category: tCat, key: tKey, value: tVal, rationale: tRat, source: tSrc, confidence: tConf, ttl_days: tTtl } = args;
2008
2058
  const tProject = db.getProject(tPath);
2009
2059
  if (!tProject)
2010
2060
  return { content: [{ type: "text", text: `Project not found: ${tPath}` }], isError: true };
@@ -2015,10 +2065,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2015
2065
  rationale: tRat,
2016
2066
  source: tSrc,
2017
2067
  confidence: tConf ?? 1.0,
2068
+ ttl_days: tTtl,
2018
2069
  });
2019
2070
  cache.invalidate('wyrm_truth_get');
2020
2071
  cache.invalidate('wyrm_context_build');
2021
- return { content: [{ type: "text", text: `🐉 **Ground Truth Set** ✅\n\n- **Category:** ${tCat}\n- **Key:** ${tKey}\n- **Value:** ${tVal}${tRat ? `\n- **Rationale:** ${tRat}` : ''}\n- **ID:** ${truth.id}\n\nThis truth will be injected at the top of every \`wyrm_context_build\` response for this project.` }] };
2072
+ return { content: [{ type: "text", text: `🐉 **Ground Truth Set** ✅\n\n- **Category:** ${tCat}\n- **Key:** ${tKey}\n- **Value:** ${tVal}${tRat ? `\n- **Rationale:** ${tRat}` : ''}${tTtl ? `\n- **TTL:** ${tTtl} days` : ''}\n- **ID:** ${truth.id}\n\nThis truth will be injected at the top of every \`wyrm_context_build\` response for this project.` }] };
2022
2073
  }
2023
2074
  case "wyrm_truth_get": {
2024
2075
  const { projectPath: tgPath, category: tgCat } = args;
@@ -2042,11 +2093,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2042
2093
  for (const [cat, items] of byCategory) {
2043
2094
  text += `### ${cat}\n`;
2044
2095
  for (const t of items) {
2045
- text += `- **${t.key}:** ${t.value}`;
2096
+ const staleness = computeStaleness(t);
2097
+ const stale = staleness !== null && staleness > 0.7;
2098
+ const stalePrefix = stale ? '[⚠️ STALE] ' : '';
2099
+ text += `- ${stalePrefix}**${t.key}:** ${t.value}`;
2046
2100
  if (t.confidence < 1)
2047
2101
  text += ` _(confidence: ${(t.confidence * 100).toFixed(0)}%)_`;
2048
2102
  if (t.rationale)
2049
2103
  text += `\n _${t.rationale}_`;
2104
+ if (staleness !== null)
2105
+ text += `\n _staleness: ${(staleness * 100).toFixed(0)}% (TTL: ${t.ttl_days}d)_`;
2050
2106
  text += '\n';
2051
2107
  }
2052
2108
  text += '\n';
@@ -2591,6 +2647,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2591
2647
  if (effectiveProjId === null) {
2592
2648
  return { content: [{ type: "text", text: `🧠 Cannot capture truth without project_id. Please provide project_id.` }], isError: true };
2593
2649
  }
2650
+ // Structural contradiction check: same project + same category
2651
+ if (capConf >= 80) {
2652
+ const capCategory = capSubtype ?? 'other';
2653
+ const existingTruths = groundTruths.getCurrent(effectiveProjId);
2654
+ const sameCategory = existingTruths.filter(t => t.category === capCategory);
2655
+ if (sameCategory.length > 0) {
2656
+ // Route to review queue — possible supersession
2657
+ const conflictArtifact = memory.add(effectiveProjId, {
2658
+ kind: 'pattern',
2659
+ problem: `Potential conflict with ${sameCategory.length} existing truth(s) in category "${capCategory}"`,
2660
+ validatedFix: capContent,
2661
+ whyItWorked: 'Pending review — possible supersession',
2662
+ tags: capTags ?? [],
2663
+ confidence: capConf / 100,
2664
+ needsReview: 1,
2665
+ });
2666
+ return {
2667
+ content: [{
2668
+ type: "text",
2669
+ text: JSON.stringify({
2670
+ status: 'queued_for_review',
2671
+ reason: 'conflict_check',
2672
+ artifact_id: conflictArtifact.id,
2673
+ conflicts_with: sameCategory.map(t => ({ id: t.id, content: t.value.slice(0, 80) })),
2674
+ }),
2675
+ }],
2676
+ };
2677
+ }
2678
+ }
2594
2679
  if (capMode !== 'truth' && capConf < 100) {
2595
2680
  // Store as artifact pending review
2596
2681
  const artifact = memory.add(effectiveProjId, {
@@ -2638,6 +2723,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2638
2723
  if (!shouldAutoCreate) {
2639
2724
  needsReviewNote = `\n⚠️ Stored for review — run \`wyrm_review\` to activate`;
2640
2725
  }
2726
+ // Advisory conflict check for heuristics (non-blocking)
2727
+ let advisoryConflicts;
2728
+ if (capSubtype === 'heuristic' && capConf >= 75 && effectiveProjId !== null) {
2729
+ try {
2730
+ const rawDbCap = db.getDatabase();
2731
+ const sanitizedCap = capContent.replace(/['"*]/g, ' ').trim().split(/\s+/).slice(0, 5).join(' ');
2732
+ const heuristicConflicts = rawDbCap.prepare(`
2733
+ SELECT a.id, a.problem FROM memory_artifacts a
2734
+ JOIN memory_artifacts_fts fts ON a.id = fts.rowid
2735
+ WHERE fts MATCH ? AND a.project_id = ? AND a.kind = 'heuristic' AND a.needs_review = 0
2736
+ LIMIT 3
2737
+ `).all(sanitizedCap, effectiveProjId);
2738
+ if (heuristicConflicts.length > 0) {
2739
+ advisoryConflicts = heuristicConflicts.map(h => ({ id: h.id, content: h.problem.slice(0, 80) }));
2740
+ }
2741
+ }
2742
+ catch {
2743
+ // FTS may fail on short queries — advisory only, ignore
2744
+ }
2745
+ }
2746
+ if (advisoryConflicts && advisoryConflicts.length > 0) {
2747
+ return {
2748
+ content: [{
2749
+ type: "text",
2750
+ text: `🧠 Captured as ${capType}: ${capSubtype}\nConfidence: ${capConf}% | ${capReasoning}\nID: ${typeShort}:${storedId}${needsReviewNote}\n\n⚠️ Advisory: ${advisoryConflicts.length} similar heuristic(s) found:\n${advisoryConflicts.map(c => ` - [${c.id}] ${c.content}`).join('\n')}`,
2751
+ }],
2752
+ };
2753
+ }
2641
2754
  }
2642
2755
  return {
2643
2756
  content: [{
@@ -2674,10 +2787,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2674
2787
  return { content: [{ type: "text", text: `🐉 Project not found. Available projects:\n${candidates}` }] };
2675
2788
  }
2676
2789
  const sections = [];
2677
- // Section 1 — Ground Truths (up to 1200 chars)
2678
- const truthsText = groundTruths.formatForContext(spProject.id, 1200);
2679
- if (truthsText)
2680
- sections.push(truthsText);
2790
+ // Section 1 — Ground Truths (up to 1200 chars), prefix stale truths
2791
+ const spCurrentTruths = groundTruths.getCurrent(spProject.id);
2792
+ if (spCurrentTruths.length > 0) {
2793
+ const CATEGORY_LABELS_SP = {
2794
+ tech_stack: '🛠️ Tech Stack', architecture: '🏗️ Architecture',
2795
+ constraint: '🚧 Constraints', decision: '📋 Decisions',
2796
+ contact: '👤 Contacts', other: '📌 Other Facts',
2797
+ };
2798
+ let spTruthsText = '### 📚 Project Ground Truths _(treat as authoritative — do not infer or contradict)_\n';
2799
+ const spByCategory = new Map();
2800
+ for (const t of spCurrentTruths) {
2801
+ if (!spByCategory.has(t.category))
2802
+ spByCategory.set(t.category, []);
2803
+ spByCategory.get(t.category).push(t);
2804
+ }
2805
+ for (const [cat, items] of spByCategory) {
2806
+ spTruthsText += `**${CATEGORY_LABELS_SP[cat] ?? cat}:**\n`;
2807
+ for (const t of items) {
2808
+ const staleness = computeStaleness(t);
2809
+ const stale = staleness !== null && staleness > 0.7;
2810
+ const stalePrefix = stale ? '[⚠️ STALE] ' : '';
2811
+ spTruthsText += `- ${stalePrefix}**${t.key}:** ${t.value}${t.rationale ? ` _(${t.rationale})_` : ''}\n`;
2812
+ }
2813
+ }
2814
+ sections.push(spTruthsText.slice(0, 1200));
2815
+ }
2681
2816
  // Section 2 — Reasoning Scaffold (up to 1500 chars)
2682
2817
  if (spTask) {
2683
2818
  const scaffoldMatchSP = scaffoldLib.findBest(spTask, spProject.id, 0.3);
@@ -2869,7 +3004,249 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2869
3004
  }
2870
3005
  return { content: [{ type: "text", text: injText }] };
2871
3006
  }
2872
- default:
3007
+ // ==================== v3.5.0 TOOLS ====================
3008
+ case "wyrm_prune": {
3009
+ const { project_id: pruneProjectId, min_confidence: pruneMinConf = 0.3, older_than_days: pruneOlderDays = 90, types: pruneTypes, dry_run: pruneDryRun = true, confirm_ids: pruneConfirmIds, } = args;
3010
+ const rawDbPrune = db.getDatabase();
3011
+ const projectClausePrune = pruneProjectId !== undefined ? `AND project_id = ${pruneProjectId}` : '';
3012
+ const typeClausePrune = pruneTypes && pruneTypes.length > 0
3013
+ ? `AND kind IN (${pruneTypes.map(() => '?').join(',')})`
3014
+ : '';
3015
+ const typeParamsPrune = pruneTypes ?? [];
3016
+ const candidateRows = rawDbPrune.prepare(`
3017
+ SELECT id, project_id, kind, problem, confidence, last_accessed_at, access_count
3018
+ FROM memory_artifacts
3019
+ WHERE confidence < ?
3020
+ AND (last_accessed_at IS NULL OR last_accessed_at < datetime('now', ?))
3021
+ AND needs_review = 0
3022
+ AND supersedes_id IS NULL
3023
+ ${projectClausePrune}
3024
+ ${typeClausePrune}
3025
+ ORDER BY confidence ASC, last_accessed_at ASC
3026
+ LIMIT 500
3027
+ `).all(pruneMinConf, `-${pruneOlderDays} days`, ...typeParamsPrune);
3028
+ if (pruneDryRun) {
3029
+ return {
3030
+ content: [{
3031
+ type: "text",
3032
+ text: JSON.stringify({
3033
+ dry_run: true,
3034
+ count: candidateRows.length,
3035
+ candidates: candidateRows.map(r => ({
3036
+ id: r.id,
3037
+ kind: r.kind,
3038
+ problem: r.problem.slice(0, 100),
3039
+ confidence: r.confidence,
3040
+ last_accessed_at: r.last_accessed_at,
3041
+ })),
3042
+ }),
3043
+ }],
3044
+ };
3045
+ }
3046
+ // dry_run: false — require confirm_ids
3047
+ if (!pruneConfirmIds || pruneConfirmIds.length === 0) {
3048
+ return {
3049
+ content: [{ type: "text", text: `🐉 **Prune**: dry_run:false requires confirm_ids — provide the IDs from a dry-run to confirm deletion.` }],
3050
+ isError: true,
3051
+ };
3052
+ }
3053
+ const candidateIds = new Set(candidateRows.map(r => r.id));
3054
+ const toDelete = pruneConfirmIds.filter(id => candidateIds.has(id));
3055
+ if (toDelete.length === 0) {
3056
+ return {
3057
+ content: [{ type: "text", text: `🐉 **Prune**: No matching IDs to delete (confirm_ids not in candidate set).` }],
3058
+ };
3059
+ }
3060
+ const placeholdersPrune = toDelete.map(() => '?').join(',');
3061
+ const deleteResult = rawDbPrune.prepare(`DELETE FROM memory_artifacts WHERE id IN (${placeholdersPrune}) AND needs_review = 0`).run(...toDelete);
3062
+ return {
3063
+ content: [{
3064
+ type: "text",
3065
+ text: JSON.stringify({ deleted: deleteResult.changes, ids: toDelete }),
3066
+ }],
3067
+ };
3068
+ }
3069
+ case "wyrm_sync_export": {
3070
+ const { output_path: exportPath, description: exportDesc } = args;
3071
+ const passphrase = process.env.WYRM_SYNC_PASSPHRASE;
3072
+ if (!passphrase) {
3073
+ return {
3074
+ content: [{ type: "text", text: `🐉 **Sync Export**: Set WYRM_SYNC_PASSPHRASE env var to enable encrypted export.` }],
3075
+ isError: true,
3076
+ };
3077
+ }
3078
+ const dbPath = db.getDatabasePath();
3079
+ const wyrmDir = pathJoin(homedir(), '.wyrm');
3080
+ const tempDbPath = pathJoin(wyrmDir, 'wyrm_sync_export_temp.db');
3081
+ try {
3082
+ // WAL-safe snapshot via VACUUM INTO
3083
+ const rawDbExport = db.getDatabase();
3084
+ if (fsExistsSync(tempDbPath))
3085
+ unlinkSync(tempDbPath);
3086
+ rawDbExport.prepare(`VACUUM INTO ?`).run(tempDbPath);
3087
+ // Read snapshot
3088
+ const plaintext = readFileSync(tempDbPath);
3089
+ // Derive key
3090
+ const salt = randomBytes(32);
3091
+ const iv = randomBytes(16);
3092
+ const key = pbkdf2Sync(passphrase, salt, 600000, 32, 'sha256');
3093
+ // Encrypt
3094
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
3095
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
3096
+ const authTag = cipher.getAuthTag();
3097
+ // Binary format: WYRM(4) + version(1) + salt(32) + iv(16) + authTag(16) + data
3098
+ const magic = Buffer.from('WYRM');
3099
+ const version = Buffer.alloc(1);
3100
+ version.writeUInt8(1, 0);
3101
+ const output = Buffer.concat([magic, version, salt, iv, authTag, encrypted]);
3102
+ writeFileSync(exportPath, output);
3103
+ try {
3104
+ chmodSync(exportPath, 0o600);
3105
+ }
3106
+ catch { /* non-fatal */ }
3107
+ // Clean up temp
3108
+ try {
3109
+ unlinkSync(tempDbPath);
3110
+ }
3111
+ catch { /* non-fatal */ }
3112
+ const sizeMb = (output.length / (1024 * 1024)).toFixed(2);
3113
+ return {
3114
+ content: [{
3115
+ type: "text",
3116
+ text: JSON.stringify({
3117
+ success: true,
3118
+ path: exportPath,
3119
+ size_mb: parseFloat(sizeMb),
3120
+ exported_at: new Date().toISOString(),
3121
+ description: exportDesc ?? null,
3122
+ }),
3123
+ }],
3124
+ };
3125
+ }
3126
+ catch (err) {
3127
+ try {
3128
+ if (fsExistsSync(tempDbPath))
3129
+ unlinkSync(tempDbPath);
3130
+ }
3131
+ catch { /* clean up */ }
3132
+ return {
3133
+ content: [{ type: "text", text: `🐉 **Sync Export** failed: ${err}` }],
3134
+ isError: true,
3135
+ };
3136
+ }
3137
+ }
3138
+ case "wyrm_sync_import":
3139
+ {
3140
+ const { input_path: importPath, restore_mode: restoreMode } = args;
3141
+ const passphrase = process.env.WYRM_SYNC_PASSPHRASE;
3142
+ if (!passphrase) {
3143
+ return {
3144
+ content: [{ type: "text", text: `🐉 **Sync Import**: Set WYRM_SYNC_PASSPHRASE env var to enable encrypted import.` }],
3145
+ isError: true,
3146
+ };
3147
+ }
3148
+ const wyrmDirImport = pathJoin(homedir(), '.wyrm');
3149
+ const tempImportPath = pathJoin(wyrmDirImport, 'wyrm_sync_import_temp.db');
3150
+ try {
3151
+ const fileData = readFileSync(importPath);
3152
+ // Validate magic bytes
3153
+ const magic = fileData.subarray(0, 4).toString('ascii');
3154
+ if (magic !== 'WYRM') {
3155
+ return { content: [{ type: "text", text: `🐉 Invalid Wyrm snapshot file (bad magic bytes).` }], isError: true };
3156
+ }
3157
+ const version = fileData.readUInt8(4);
3158
+ if (version !== 1) {
3159
+ return { content: [{ type: "text", text: `🐉 Unsupported snapshot version: ${version}` }], isError: true };
3160
+ }
3161
+ const salt = fileData.subarray(5, 37);
3162
+ const iv = fileData.subarray(37, 53);
3163
+ const authTag = fileData.subarray(53, 69);
3164
+ const encrypted = fileData.subarray(69);
3165
+ // Derive key & decrypt
3166
+ const key = pbkdf2Sync(passphrase, salt, 600000, 32, 'sha256');
3167
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
3168
+ decipher.setAuthTag(authTag);
3169
+ let decrypted;
3170
+ try {
3171
+ decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
3172
+ }
3173
+ catch {
3174
+ return { content: [{ type: "text", text: `🐉 Decryption failed — wrong passphrase or corrupted snapshot.` }], isError: true };
3175
+ }
3176
+ if (restoreMode === 'preview') {
3177
+ // Open temp DB and return stats
3178
+ if (fsExistsSync(tempImportPath))
3179
+ unlinkSync(tempImportPath);
3180
+ writeFileSync(tempImportPath, decrypted);
3181
+ let previewStats = {};
3182
+ try {
3183
+ const BetterSQLite = (await import('better-sqlite3')).default;
3184
+ const previewDb = new BetterSQLite(tempImportPath, { readonly: true });
3185
+ const tables = ['projects', 'sessions', 'ground_truths', 'memory_artifacts', 'quests'];
3186
+ for (const t of tables) {
3187
+ try {
3188
+ const row = previewDb.prepare(`SELECT COUNT(*) as n FROM ${t}`).get();
3189
+ previewStats[t] = row.n;
3190
+ }
3191
+ catch {
3192
+ previewStats[t] = 0;
3193
+ }
3194
+ }
3195
+ previewDb.close();
3196
+ }
3197
+ catch { /* stats optional */ }
3198
+ try {
3199
+ unlinkSync(tempImportPath);
3200
+ }
3201
+ catch { /* clean up */ }
3202
+ return {
3203
+ content: [{
3204
+ type: "text",
3205
+ text: JSON.stringify({ preview: true, stats: previewStats }),
3206
+ }],
3207
+ };
3208
+ }
3209
+ // restore mode
3210
+ const dbPathImport = db.getDatabasePath();
3211
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
3212
+ const backupPath = `${dbPathImport}.backup.${timestamp}`;
3213
+ // Backup current DB
3214
+ copyFileSync(dbPathImport, backupPath);
3215
+ // Write decrypted to temp
3216
+ if (fsExistsSync(tempImportPath))
3217
+ unlinkSync(tempImportPath);
3218
+ writeFileSync(tempImportPath, decrypted);
3219
+ // Close current DB connection, replace file, reopen
3220
+ const rawDbImport = db.getDatabase();
3221
+ rawDbImport.close();
3222
+ copyFileSync(tempImportPath, dbPathImport);
3223
+ try {
3224
+ unlinkSync(tempImportPath);
3225
+ }
3226
+ catch { /* clean up */ }
3227
+ return {
3228
+ content: [{
3229
+ type: "text",
3230
+ text: JSON.stringify({
3231
+ success: true,
3232
+ restored_from: importPath,
3233
+ backup_at: backupPath,
3234
+ }),
3235
+ }],
3236
+ };
3237
+ }
3238
+ catch (err) {
3239
+ try {
3240
+ if (fsExistsSync(tempImportPath))
3241
+ unlinkSync(tempImportPath);
3242
+ }
3243
+ catch { /* clean up */ }
3244
+ return {
3245
+ content: [{ type: "text", text: `🐉 **Sync Import** failed: ${err}` }],
3246
+ isError: true,
3247
+ };
3248
+ }
3249
+ }
2873
3250
  return {
2874
3251
  content: [{ type: "text", text: `Unknown tool: ${name}` }],
2875
3252
  isError: true,