gnosys 5.12.0 → 5.12.3

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.
Files changed (78) hide show
  1. package/dist/cli.js +48 -7
  2. package/dist/index.js +199 -14
  3. package/dist/lib/addCommand.js +0 -1
  4. package/dist/lib/archive.js +10 -6
  5. package/dist/lib/askCommand.js +1 -1
  6. package/dist/lib/attachCommand.d.ts +17 -0
  7. package/dist/lib/attachCommand.js +66 -0
  8. package/dist/lib/attachments.d.ts +43 -2
  9. package/dist/lib/attachments.js +81 -2
  10. package/dist/lib/chat/choose.js +2 -2
  11. package/dist/lib/clientReadOverlay.js +3 -0
  12. package/dist/lib/config.d.ts +1 -48
  13. package/dist/lib/configCommand.js +2 -2
  14. package/dist/lib/db.d.ts +16 -1
  15. package/dist/lib/db.js +248 -133
  16. package/dist/lib/dbWrite.d.ts +1 -1
  17. package/dist/lib/dearchiveCommand.js +1 -1
  18. package/dist/lib/docxExtract.js +1 -1
  19. package/dist/lib/dream.d.ts +8 -0
  20. package/dist/lib/dream.js +35 -1
  21. package/dist/lib/dreamLogCommand.js +1 -1
  22. package/dist/lib/dreamRunLog.d.ts +1 -1
  23. package/dist/lib/dreamRunLog.js +26 -4
  24. package/dist/lib/embeddings.js +0 -3
  25. package/dist/lib/exportProject.d.ts +3 -2
  26. package/dist/lib/exportProject.js +2 -1
  27. package/dist/lib/federated.js +1 -1
  28. package/dist/lib/ftsQuery.d.ts +25 -0
  29. package/dist/lib/ftsQuery.js +42 -0
  30. package/dist/lib/hybridSearch.d.ts +9 -0
  31. package/dist/lib/hybridSearch.js +14 -0
  32. package/dist/lib/hybridSearchCommand.js +6 -1
  33. package/dist/lib/importProject.js +2 -1
  34. package/dist/lib/llm.js +1 -1
  35. package/dist/lib/lock.d.ts +1 -1
  36. package/dist/lib/lock.js +5 -3
  37. package/dist/lib/migrate.js +0 -1
  38. package/dist/lib/multimodalIngest.js +1 -1
  39. package/dist/lib/platform.d.ts +0 -6
  40. package/dist/lib/platform.js +0 -28
  41. package/dist/lib/readCommand.js +11 -10
  42. package/dist/lib/remoteWizard.d.ts +1 -1
  43. package/dist/lib/remoteWizard.js +4 -4
  44. package/dist/lib/resolver.d.ts +17 -0
  45. package/dist/lib/resolver.js +43 -17
  46. package/dist/lib/rulesGen.d.ts +8 -0
  47. package/dist/lib/rulesGen.js +16 -0
  48. package/dist/lib/search.d.ts +0 -2
  49. package/dist/lib/search.js +33 -27
  50. package/dist/lib/semanticSearchCommand.js +1 -1
  51. package/dist/lib/setup/sections/providers.js +56 -4
  52. package/dist/lib/setup/sections/routing.js +42 -5
  53. package/dist/lib/setup/sections/taskRoutingEditor.d.ts +1 -5
  54. package/dist/lib/setup/sections/taskRoutingEditor.js +0 -10
  55. package/dist/lib/setup/ui/header.js +0 -1
  56. package/dist/lib/setup/ui/status.d.ts +0 -1
  57. package/dist/lib/setup/ui/status.js +0 -2
  58. package/dist/lib/setup.d.ts +0 -15
  59. package/dist/lib/setup.js +13 -158
  60. package/dist/lib/staleCommand.js +2 -2
  61. package/dist/lib/syncClient.d.ts +0 -6
  62. package/dist/lib/syncClient.js +36 -14
  63. package/dist/lib/syncDoctorCommand.js +2 -2
  64. package/dist/lib/syncIngest.d.ts +11 -0
  65. package/dist/lib/syncIngest.js +24 -1
  66. package/dist/lib/syncIngestStartup.js +2 -2
  67. package/dist/lib/syncSnapshot.d.ts +2 -0
  68. package/dist/lib/syncSnapshot.js +4 -0
  69. package/dist/lib/syncStaging.d.ts +0 -2
  70. package/dist/lib/syncStaging.js +0 -2
  71. package/dist/lib/updateCommand.js +1 -1
  72. package/dist/lib/webBuildCommand.js +1 -1
  73. package/dist/lib/webIndex.js +0 -1
  74. package/dist/lib/webIngestCommand.js +1 -1
  75. package/dist/sandbox/client.js +1 -1
  76. package/dist/sandbox/manager.js +1 -14
  77. package/dist/sandbox/server.js +3 -5
  78. package/package.json +5 -2
package/dist/cli.js CHANGED
@@ -9,16 +9,16 @@ import fs from "fs/promises";
9
9
  import os from "os";
10
10
  import { fileURLToPath } from "url";
11
11
  import dotenv from "dotenv";
12
- import { readFileSync, existsSync } from "fs";
12
+ import { readFileSync, existsSync, } from "fs";
13
13
  // v5.8.0 (#4): only the lightweight modules are imported at top-level.
14
14
  // Anything that pulls @huggingface/transformers, mammoth/pdf-parse/turndown,
15
15
  // large file-walking machinery, or otherwise costs >100ms to load gets
16
16
  // `await import(...)` inside its own action handler. This keeps
17
17
  // `gnosys --help` and other lightweight commands fast.
18
18
  import { GnosysResolver } from "./lib/resolver.js";
19
- import { loadConfig, generateConfigTemplate } from "./lib/config.js";
19
+ import { loadConfig, generateConfigTemplate, } from "./lib/config.js";
20
20
  import { GnosysDB } from "./lib/db.js";
21
- import { createProjectIdentity, findProjectIdentity } from "./lib/projectIdentity.js";
21
+ import { createProjectIdentity, findProjectIdentity, } from "./lib/projectIdentity.js";
22
22
  // Lazy-loaded inside action handlers (each ~200ms-2.5s on cold cache):
23
23
  // - ./lib/embeddings.js (@huggingface/transformers — 80MB)
24
24
  // - ./lib/hybridSearch.js (depends on embeddings)
@@ -468,9 +468,9 @@ setupCmd
468
468
  }
469
469
  });
470
470
  // v5.4.2 removal: `gnosys models` (top-level shortcut) was removed in favor
471
- // of the canonical `gnosys setup models` form. The implementation function
472
- // runModelsCommand() in setup.ts is no longer wired but kept for now in case
473
- // we need to revive a top-level shortcut later.
471
+ // of the canonical `gnosys setup models` form. The unwired implementation
472
+ // (runModelsCommand in setup.ts) was deleted in v5.12.1 after eight minor
473
+ // versions without revival recover from git history if ever needed.
474
474
  // ─── gnosys init ─────────────────────────────────────────────────────────
475
475
  program
476
476
  .command("init")
@@ -685,6 +685,24 @@ program
685
685
  const { runIngestCommand } = await import("./lib/ingestCommand.js");
686
686
  await runIngestCommand(getResolver, fileOrGlob, opts);
687
687
  });
688
+ // ─── gnosys attach <file> --memory <id> ──────────────────────────────────
689
+ program
690
+ .command("attach <file>")
691
+ .description("Attach a small binary file (logo, diagram, screenshot) inline to a memory. Travels machine-to-machine over normal sync. Limit ~10MB — use 'gnosys ingest' for large media.")
692
+ .requiredOption("--memory <id>", "Memory ID to attach the file to")
693
+ .action(async (file, opts) => {
694
+ const { runAttachCommand } = await import("./lib/attachCommand.js");
695
+ await runAttachCommand(file, opts);
696
+ });
697
+ // ─── gnosys get-attachment <id> ───────────────────────────────────────────
698
+ program
699
+ .command("get-attachment <memoryId>")
700
+ .description("Retrieve the binary attachment stored on a memory. Writes to --out, or prints base64 to stdout.")
701
+ .option("--out <path>", "Write the attachment to this file path instead of printing base64")
702
+ .action(async (memoryId, opts) => {
703
+ const { runGetAttachmentCommand } = await import("./lib/attachCommand.js");
704
+ await runGetAttachmentCommand(memoryId, opts);
705
+ });
688
706
  // ─── gnosys tags-add ────────────────────────────────────────────────────
689
707
  program
690
708
  .command("tags-add")
@@ -970,7 +988,22 @@ program
970
988
  .description("Remove dead and temp-dir entries from the project registry")
971
989
  .option("--yes", "Non-interactive, remove without prompting")
972
990
  .option("--dry-run", "Show what would be removed without writing")
991
+ .option("--rules [dir]", "Remove the GNOSYS block from agent rules files (CLAUDE.md, .cursor, .codex) in the given directory (default: cwd)")
973
992
  .action(async (opts) => {
993
+ // v5.12.1: uninstall counterpart of `gnosys setup ides` rules generation.
994
+ if (opts.rules !== undefined) {
995
+ const { removeRulesFromProject } = await import("./lib/rulesGen.js");
996
+ const dir = typeof opts.rules === "string" ? path.resolve(opts.rules) : process.cwd();
997
+ const cleaned = await removeRulesFromProject(dir);
998
+ if (cleaned.length === 0) {
999
+ console.log(`No GNOSYS rules blocks found in ${dir}`);
1000
+ }
1001
+ else {
1002
+ for (const rel of cleaned)
1003
+ console.log(`Removed GNOSYS block: ${rel}`);
1004
+ }
1005
+ return;
1006
+ }
974
1007
  const { cleanupRegistry } = await import("./lib/cleanup.js");
975
1008
  const result = await cleanupRegistry({
976
1009
  interactive: !opts.yes && !opts.dryRun,
@@ -1852,4 +1885,12 @@ if (!isTestEnv()) {
1852
1885
  // non-critical — don't block CLI startup
1853
1886
  }
1854
1887
  }
1855
- program.parse();
1888
+ // v5.12.x observability: all 100+ command actions are async — with bare
1889
+ // program.parse() a thrown action error surfaced as a raw Node
1890
+ // UnhandledPromiseRejection (full engine stack, no gnosys framing).
1891
+ // parseAsync routes every action failure through one clean exit path.
1892
+ program.parseAsync().catch(async (err) => {
1893
+ const { logError } = await import("./lib/log.js");
1894
+ logError(err, { module: "cli" });
1895
+ process.exitCode = 1;
1896
+ });
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  // MCP stdio JSON protocol. parse() is a pure function with no side effects.
11
11
  import dotenv from "dotenv";
12
12
  import path from "path";
13
+ import { AsyncLocalStorage } from "async_hooks";
13
14
  import { readFileSync, realpathSync } from "fs";
14
15
  import { fileURLToPath } from "url";
15
16
  const home = process.env.HOME || process.env.USERPROFILE || "/tmp";
@@ -49,13 +50,14 @@ import { groupByPeriod, computeStats } from "./lib/timeline.js";
49
50
  import { buildLinkGraph, getBacklinks, getOutgoingLinks, formatGraphSummary } from "./lib/wikilinks.js";
50
51
  import { loadConfig, DEFAULT_CONFIG } from "./lib/config.js";
51
52
  import { getLLMProvider } from "./lib/llm.js";
52
- import { recall, formatRecall } from "./lib/recall.js";
53
+ import { recall, formatRecall, } from "./lib/recall.js";
53
54
  import { initAudit, readAuditLog, formatAuditTimeline } from "./lib/audit.js";
55
+ import { logError } from "./lib/log.js";
54
56
  import { GnosysDB } from "./lib/db.js";
55
57
  import { syncMemoryToDb, syncUpdateToDb, syncDearchiveToDb, syncReinforcementToDb, auditToDb } from "./lib/dbWrite.js";
56
- import { createProjectIdentity, readProjectIdentity } from "./lib/projectIdentity.js";
58
+ import { createProjectIdentity, readProjectIdentity, } from "./lib/projectIdentity.js";
57
59
  import { setPreference, getPreference, getAllPreferences, deletePreference, KNOWN_PREFERENCE_KEYS, suggestPreferenceKey } from "./lib/preferences.js";
58
- import { syncRules, generateRulesBlock } from "./lib/rulesGen.js";
60
+ import { syncRules, generateRulesBlock, } from "./lib/rulesGen.js";
59
61
  import { federatedSearch, detectAmbiguity, generateBriefing, generateAllBriefings, getWorkingSet, formatWorkingSet, detectCurrentProject } from "./lib/federated.js";
60
62
  import { generatePortfolio, formatPortfolioCompact, formatPortfolioMarkdown, generateStatusPrompt } from "./lib/portfolio.js";
61
63
  import { applyPendingOverlay, mergeOverlayDiscoverResults, mergeOverlaySearchResults, pendingAddToDbMemory, } from "./lib/clientReadOverlay.js";
@@ -71,9 +73,47 @@ const server = new McpServer({
71
73
  version: "2.0.0",
72
74
  });
73
75
  const _registrations = [];
76
+ // v5.12.1 reliability: every tool handler resolves a ToolContext whose
77
+ // clientRead (v13 sync) may own a DB handle. Historically only 8 of 52
78
+ // handlers released it, leaking handles on every early return. Enforce
79
+ // release centrally: resolveToolContext() registers each context in the
80
+ // per-call AsyncLocalStorage store, and withContextRelease() (wrapped around
81
+ // every tool handler at registration) releases them when the handler settles
82
+ // — every return path, every throw, every future tool, no per-handler code.
83
+ const activeToolContexts = new AsyncLocalStorage();
84
+ function withContextRelease(handler, toolName) {
85
+ return (...hargs) => {
86
+ const opened = [];
87
+ return activeToolContexts.run(opened, async () => {
88
+ try {
89
+ return await handler(...hargs);
90
+ }
91
+ catch (err) {
92
+ // v5.12.x observability: last-resort error envelope. 19 of 52 tools
93
+ // had no catch at all — a throw reached the SDK as a raw JSON-RPC
94
+ // error with no corruption-recovery guidance. Per-tool catches with
95
+ // more specific messages still take precedence; this only sees what
96
+ // they let through. Logged to stderr (stdout is JSON-RPC).
97
+ logError(err, { module: "mcp", op: toolName });
98
+ return {
99
+ content: [{ type: "text", text: formatMcpError(`in ${toolName}`, err) }],
100
+ isError: true,
101
+ };
102
+ }
103
+ finally {
104
+ for (const c of opened)
105
+ releaseClientReadFromContext(c);
106
+ }
107
+ });
108
+ };
109
+ }
74
110
  // Typed to the McpServer methods so call-site generic inference (Zod schema →
75
111
  // handler arg types) is preserved; the body just collects a replay thunk.
76
112
  const regTool = ((...args) => {
113
+ const last = args.length - 1;
114
+ if (typeof args[last] === "function") {
115
+ args[last] = withContextRelease(args[last], typeof args[0] === "string" ? args[0] : "tool");
116
+ }
77
117
  _registrations.push((s) => s.tool(...args));
78
118
  });
79
119
  const regPrompt = ((...args) => {
@@ -160,9 +200,25 @@ function applyClientReadToCentralDb(localDb) {
160
200
  const clientRead = openClientReadContext(localDb, masterPath, mc.machineId);
161
201
  return { centralDb: clientRead.db, clientRead };
162
202
  }
203
+ /** Idempotent: safe to call from both a handler's own finally and the central
204
+ * withContextRelease wrapper. */
163
205
  function releaseClientReadFromContext(ctx) {
164
- if (ctx.clientRead)
206
+ if (ctx.clientRead) {
165
207
  closeClientReadContext(ctx.clientRead);
208
+ ctx.clientRead = null;
209
+ }
210
+ // v5.12.x perf/leak: projectRoot-scoped contexts open their own
211
+ // GnosysSearch (SQLite handle to <store>/.config/search.db) per call.
212
+ if (ctx.ownsSearch && ctx.search) {
213
+ try {
214
+ ctx.search.close();
215
+ }
216
+ catch {
217
+ // already closed / never opened — fine
218
+ }
219
+ ctx.search = null;
220
+ ctx.ownsSearch = false;
221
+ }
166
222
  }
167
223
  async function resolveToolContext(projectRoot) {
168
224
  if (!projectRoot) {
@@ -176,7 +232,7 @@ async function resolveToolContext(projectRoot) {
176
232
  projectId = identity?.projectId || null;
177
233
  }
178
234
  const applied = applyClientReadToCentralDb(centralDb);
179
- return {
235
+ const ctx = {
180
236
  resolver,
181
237
  store: writeTarget?.store || null,
182
238
  storePath: writeTarget?.store.getStorePath() || "",
@@ -187,6 +243,8 @@ async function resolveToolContext(projectRoot) {
187
243
  projectId,
188
244
  clientRead: applied.clientRead,
189
245
  };
246
+ activeToolContexts.getStore()?.push(ctx);
247
+ return ctx;
190
248
  }
191
249
  // Scoped context — resolve for this specific project
192
250
  const scopedResolver = await GnosysResolver.resolveForProject(projectRoot);
@@ -215,7 +273,7 @@ async function resolveToolContext(projectRoot) {
215
273
  // gnosys.db in the project's .gnosys/ directory.
216
274
  }
217
275
  const applied = applyClientReadToCentralDb(centralDb);
218
- return {
276
+ const ctx = {
219
277
  resolver: scopedResolver,
220
278
  store: scopedWriteTarget?.store || null,
221
279
  storePath: scopedStorePath,
@@ -225,7 +283,10 @@ async function resolveToolContext(projectRoot) {
225
283
  centralDb: applied.centralDb,
226
284
  projectId,
227
285
  clientRead: applied.clientRead,
286
+ ownsSearch: scopedSearch !== null,
228
287
  };
288
+ activeToolContexts.getStore()?.push(ctx);
289
+ return ctx;
229
290
  }
230
291
  /**
231
292
  * v5.7.1 (#13): Resolve scope + projectId for a memory write.
@@ -1632,7 +1693,7 @@ regTool("gnosys_import", "Bulk import structured data (CSV, JSON, JSONL) into Gn
1632
1693
  const effectiveMode = mode || "structured";
1633
1694
  try {
1634
1695
  // v5.9.1 (#100): import.js pulls mammoth + pdf-parse + turndown.
1635
- const { performImport, formatImportSummary, estimateDuration } = await import("./lib/import.js");
1696
+ const { performImport, formatImportSummary } = await import("./lib/import.js");
1636
1697
  const result = await performImport(writeTarget.store, ingestion, {
1637
1698
  format: format,
1638
1699
  data,
@@ -1657,7 +1718,6 @@ regTool("gnosys_import", "Bulk import structured data (CSV, JSON, JSONL) into Gn
1657
1718
  // Smart threshold guidance
1658
1719
  if (effectiveMode === "llm" &&
1659
1720
  result.totalProcessed > 100) {
1660
- const estimate = estimateDuration(result.totalProcessed, "llm", concurrency || 5);
1661
1721
  response += `\n\n💡 Tip: For large LLM imports, the CLI offers progress tracking and resume:\n gnosys import ${data.length < 100 ? data : "<file>"} --format ${format} --mode llm --skip-existing`;
1662
1722
  }
1663
1723
  return { content: [{ type: "text", text: response }] };
@@ -1692,10 +1752,17 @@ regTool("gnosys_hybrid_search", "Search memories using hybrid keyword + semantic
1692
1752
  };
1693
1753
  }
1694
1754
  try {
1695
- const results = await hybridSearch.hybridSearch(query, limit || 15, mode || "hybrid");
1755
+ const requestedMode = mode || "hybrid";
1756
+ const results = await hybridSearch.hybridSearch(query, limit || 15, requestedMode);
1757
+ // v5.12.3: hybrid used to degrade to keyword-only silently when the
1758
+ // semantic leg can't run (embeddings are only built by gnosys_reindex,
1759
+ // and DB mode also needs the store-local query embedder). Say so loudly.
1760
+ const degradeWarning = requestedMode !== "keyword" && !hybridSearch.canRunSemantic()
1761
+ ? `⚠️ Semantic embeddings unavailable — ${requestedMode} search ran keyword-only. Run gnosys_reindex to build embeddings and enable semantic recall.\n\n`
1762
+ : "";
1696
1763
  if (results.length === 0) {
1697
1764
  return {
1698
- content: [{ type: "text", text: `No results for "${query}". Try gnosys_reindex to build embeddings, or different keywords.` }],
1765
+ content: [{ type: "text", text: `${degradeWarning}No results for "${query}". Try different keywords.` }],
1699
1766
  };
1700
1767
  }
1701
1768
  const formatted = results
@@ -1714,7 +1781,7 @@ regTool("gnosys_hybrid_search", "Search memories using hybrid keyword + semantic
1714
1781
  content: [
1715
1782
  {
1716
1783
  type: "text",
1717
- text: `Found ${results.length} results for "${query}" (${embCount} embeddings indexed):\n\n${formatted}`,
1784
+ text: `${degradeWarning}Found ${results.length} results for "${query}" (${embCount} embeddings indexed):\n\n${formatted}`,
1718
1785
  },
1719
1786
  ],
1720
1787
  };
@@ -1742,10 +1809,19 @@ regTool("gnosys_semantic_search", "Search memories using semantic similarity onl
1742
1809
  };
1743
1810
  }
1744
1811
  try {
1812
+ // v5.12.3: semantic search without a working semantic leg either
1813
+ // returns nothing or (in DB mode) keyword hits mislabeled as semantic —
1814
+ // refuse with the exact reason instead.
1815
+ if (!hybridSearch.canRunSemantic()) {
1816
+ return {
1817
+ content: [{ type: "text", text: `⚠️ Semantic embeddings unavailable — semantic search cannot run. Run gnosys_reindex to build embeddings, then retry.` }],
1818
+ isError: true,
1819
+ };
1820
+ }
1745
1821
  const results = await hybridSearch.hybridSearch(query, limit || 15, "semantic");
1746
1822
  if (results.length === 0) {
1747
1823
  return {
1748
- content: [{ type: "text", text: `No semantic results for "${query}". Run gnosys_reindex first to build embeddings.` }],
1824
+ content: [{ type: "text", text: `No semantic results for "${query}". Try a broader query.` }],
1749
1825
  };
1750
1826
  }
1751
1827
  const formatted = results
@@ -1967,7 +2043,7 @@ regTool("gnosys_dream", "Run a Dream Mode cycle — idle-time consolidation that
1967
2043
  }, async (params) => {
1968
2044
  try {
1969
2045
  const ctx = await resolveToolContext(params.projectRoot);
1970
- if (!ctx.centralDb || !ctx.centralDb.isAvailable() || !ctx.centralDb.isMigrated()) {
2046
+ if (!ctx.centralDb?.isAvailable() || !ctx.centralDb.isMigrated()) {
1971
2047
  return {
1972
2048
  content: [
1973
2049
  {
@@ -2047,7 +2123,7 @@ regTool("gnosys_export", "Export gnosys.db to Obsidian-compatible vault — atom
2047
2123
  }, async (params) => {
2048
2124
  try {
2049
2125
  const ctx = await resolveToolContext(params.projectRoot);
2050
- if (!ctx.centralDb || !ctx.centralDb.isAvailable() || !ctx.centralDb.isMigrated()) {
2126
+ if (!ctx.centralDb?.isAvailable() || !ctx.centralDb.isMigrated()) {
2051
2127
  return {
2052
2128
  content: [
2053
2129
  {
@@ -2720,6 +2796,106 @@ regTool("gnosys_remote_resolve", "Resolve a sync conflict by choosing which vers
2720
2796
  localDb.close();
2721
2797
  }
2722
2798
  });
2799
+ // ─── Tool: gnosys_attach ────────────────────────────────────────────────
2800
+ regTool("gnosys_attach", "Attach a small binary file (logo, diagram, screenshot, small PDF) directly to a memory. The bytes are stored inline in the memory row, so the attachment travels machine-to-machine over the normal sync and works with a remote/dockerized server (no shared filesystem). Limit ~10MB — use gnosys_ingest_file for large media.", {
2801
+ memoryId: z.string().describe("Memory ID to attach the file to (e.g., 'deci-052')"),
2802
+ filePath: z.string().describe("Absolute path to the file to attach"),
2803
+ projectRoot: projectRootParam,
2804
+ }, async ({ memoryId, filePath, projectRoot }) => {
2805
+ const ctx = await resolveToolContext(projectRoot);
2806
+ if (!ctx.centralDb?.isAvailable()) {
2807
+ return {
2808
+ content: [{ type: "text", text: "Database not available. Cannot attach file." }],
2809
+ isError: true,
2810
+ };
2811
+ }
2812
+ try {
2813
+ const { attachFileToMemory } = await import("./lib/attachments.js");
2814
+ const result = await attachFileToMemory(ctx.centralDb, memoryId, filePath);
2815
+ auditToDb(ctx.centralDb, "write", memoryId, {
2816
+ tool: "gnosys_attach",
2817
+ name: result.name,
2818
+ mime: result.mime,
2819
+ sizeBytes: result.sizeBytes,
2820
+ unchanged: result.unchanged,
2821
+ });
2822
+ const sizeKb = (result.sizeBytes / 1024).toFixed(1);
2823
+ const verb = result.unchanged ? "already attached (no change)" : "attached";
2824
+ return {
2825
+ content: [
2826
+ {
2827
+ type: "text",
2828
+ text: `File ${verb}: ${result.name} (${result.mime}, ${sizeKb} KB)\nMemory: ${memoryId}`,
2829
+ },
2830
+ ],
2831
+ };
2832
+ }
2833
+ catch (err) {
2834
+ return {
2835
+ content: [{ type: "text", text: formatMcpError("attaching file", err) }],
2836
+ isError: true,
2837
+ };
2838
+ }
2839
+ });
2840
+ // ─── Tool: gnosys_get_attachment ────────────────────────────────────────
2841
+ regTool("gnosys_get_attachment", "Retrieve the binary attachment stored on a memory. By default returns the bytes (base64, plus an inline image when the attachment is an image). Pass outputPath to write the file to disk instead.", {
2842
+ memoryId: z.string().describe("Memory ID that holds the attachment"),
2843
+ outputPath: z.string().optional().describe("If provided, write the attachment to this absolute path instead of returning bytes"),
2844
+ projectRoot: projectRootParam,
2845
+ }, async ({ memoryId, outputPath, projectRoot }) => {
2846
+ const ctx = await resolveToolContext(projectRoot);
2847
+ if (!ctx.centralDb?.isAvailable()) {
2848
+ return {
2849
+ content: [{ type: "text", text: "Database not available." }],
2850
+ isError: true,
2851
+ };
2852
+ }
2853
+ try {
2854
+ const { getMemoryAttachment } = await import("./lib/attachments.js");
2855
+ const att = getMemoryAttachment(ctx.centralDb, memoryId);
2856
+ if (!att) {
2857
+ return {
2858
+ content: [{ type: "text", text: `No attachment found on memory: ${memoryId}` }],
2859
+ isError: true,
2860
+ };
2861
+ }
2862
+ if (outputPath) {
2863
+ const { writeFile } = await import("fs/promises");
2864
+ await writeFile(outputPath, att.data);
2865
+ return {
2866
+ content: [
2867
+ {
2868
+ type: "text",
2869
+ text: `Wrote ${att.name} (${att.mime}, ${att.data.length} bytes) to ${outputPath}`,
2870
+ },
2871
+ ],
2872
+ };
2873
+ }
2874
+ const base64 = att.data.toString("base64");
2875
+ if (att.mime.startsWith("image/")) {
2876
+ return {
2877
+ content: [
2878
+ { type: "image", data: base64, mimeType: att.mime },
2879
+ { type: "text", text: `${att.name} (${att.mime}, ${att.data.length} bytes)` },
2880
+ ],
2881
+ };
2882
+ }
2883
+ return {
2884
+ content: [
2885
+ {
2886
+ type: "text",
2887
+ text: `${att.name} (${att.mime}, ${att.data.length} bytes)\n\nbase64:\n${base64}`,
2888
+ },
2889
+ ],
2890
+ };
2891
+ }
2892
+ catch (err) {
2893
+ return {
2894
+ content: [{ type: "text", text: formatMcpError("reading attachment", err) }],
2895
+ isError: true,
2896
+ };
2897
+ }
2898
+ });
2723
2899
  // ─── Tool: gnosys_update_status ─────────────────────────────────────────
2724
2900
  regTool("gnosys_update_status", "Get the prompt/template for writing a dashboard-compatible status memory for this project. Returns instructions for creating a landscape memory with the correct heading format so the portfolio dashboard can parse it. Run this, then follow the instructions to analyze and write the status.", {
2725
2901
  projectRoot: z.string().optional().describe("Project root for auto-detection"),
@@ -3080,6 +3256,15 @@ async function initHeavyDeps() {
3080
3256
  // ─── Start the server ────────────────────────────────────────────────────
3081
3257
  /** Start the MCP server (stdio or http). Called by `gnosys serve` and when invoked as `gnosys-mcp`. */
3082
3258
  export async function startMcpServer() {
3259
+ // v5.12.1 reliability: an escaped async error must not kill the server.
3260
+ // Log to stderr (stdout is JSON-RPC in stdio mode) and keep serving —
3261
+ // all persistent state is transactional SQLite, so survivors are safe.
3262
+ process.on("unhandledRejection", (reason) => {
3263
+ console.error(`Gnosys MCP: unhandled rejection — ${reason instanceof Error ? reason.stack || reason.message : String(reason)}`);
3264
+ });
3265
+ process.on("uncaughtException", (err) => {
3266
+ console.error(`Gnosys MCP: uncaught exception — ${err.stack || err.message}`);
3267
+ });
3083
3268
  // v5.7.1 (#15): start the upgrade-marker watcher BEFORE anything else.
3084
3269
  // If `gnosys upgrade` was run on this machine while the MCP was idle,
3085
3270
  // pick that up immediately instead of serving stale tool handlers.
@@ -54,7 +54,6 @@ export async function runAddCommand(getResolver, input, opts, resolveProjectId)
54
54
  centralDb = GnosysDB.openCentral();
55
55
  const projectId = await resolveProjectId();
56
56
  const id = centralDb.getNextId(result.category, projectId || undefined);
57
- const today = new Date().toISOString().split("T")[0];
58
57
  const now = new Date().toISOString();
59
58
  const content = `# ${result.title}\n\n${result.content}`;
60
59
  const tags = result.tags;
@@ -10,7 +10,6 @@
10
10
  * then dearchives used memories back to active
11
11
  */
12
12
  // Dynamic import — gracefully handles missing native module
13
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
13
  let Database = null;
15
14
  try {
16
15
  Database = (await import("better-sqlite3")).default;
@@ -24,9 +23,9 @@ import { statSync } from "fs";
24
23
  import { syncMemoryToDb, syncDearchiveToDb } from "./dbWrite.js";
25
24
  import { enableWAL } from "./lock.js";
26
25
  import { auditLog } from "./audit.js";
26
+ import { ftsTerms, ftsAndQuery, ftsOrQuery } from "./ftsQuery.js";
27
27
  // ─── Archive Manager ────────────────────────────────────────────────────
28
28
  export class GnosysArchive {
29
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
29
  db = null;
31
30
  storePath;
32
31
  available = false;
@@ -219,8 +218,8 @@ export class GnosysArchive {
219
218
  searchArchive(query, limit = 20) {
220
219
  if (!this.db)
221
220
  return [];
222
- const safeQuery = query.replace(/['"]/g, "").trim();
223
- if (!safeQuery)
221
+ const terms = ftsTerms(query);
222
+ if (terms.length === 0)
224
223
  return [];
225
224
  const stmt = this.db.prepare(`
226
225
  SELECT
@@ -237,7 +236,12 @@ export class GnosysArchive {
237
236
  LIMIT ?
238
237
  `);
239
238
  try {
240
- return stmt.all(safeQuery, limit);
239
+ // v5.12.3: AND first (precision), OR retry when AND finds nothing —
240
+ // multi-word queries previously required every term to match.
241
+ const results = stmt.all(ftsAndQuery(terms), limit);
242
+ if (results.length > 0 || terms.length === 1)
243
+ return results;
244
+ return stmt.all(ftsOrQuery(terms), limit);
241
245
  }
242
246
  catch {
243
247
  // FTS5 query syntax failed — try LIKE fallback
@@ -253,7 +257,7 @@ export class GnosysArchive {
253
257
  WHERE content LIKE ? OR title LIKE ? OR tags LIKE ?
254
258
  LIMIT ?
255
259
  `);
256
- const pattern = `%${safeQuery}%`;
260
+ const pattern = `%${terms.join(" ")}%`;
257
261
  return likeStmt.all(pattern, pattern, pattern, limit);
258
262
  }
259
263
  }
@@ -90,7 +90,7 @@ export async function runAskCommand(getResolver, question, opts) {
90
90
  const useStream = opts.stream !== false && !opts.json;
91
91
  try {
92
92
  const result = await ask.ask(question, {
93
- limit: parseInt(opts.limit),
93
+ limit: parseInt(opts.limit, 10),
94
94
  mode,
95
95
  stream: useStream,
96
96
  additionalContext: federatedContext,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * CLI handlers for inline DB-blob attachments (v5.12).
3
+ *
4
+ * gnosys attach <file> --memory <id> store bytes inline on a memory
5
+ * gnosys get-attachment <id> [--out path] retrieve the stored bytes
6
+ *
7
+ * Inline attachments live in the memory row, so they ride the normal sync
8
+ * rail to other machines and a remote/dockerized server.
9
+ */
10
+ export interface AttachCommandOptions {
11
+ memory: string;
12
+ }
13
+ export declare function runAttachCommand(filePath: string, opts: AttachCommandOptions): Promise<void>;
14
+ export interface GetAttachmentCommandOptions {
15
+ out?: string;
16
+ }
17
+ export declare function runGetAttachmentCommand(memoryId: string, opts: GetAttachmentCommandOptions): Promise<void>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * CLI handlers for inline DB-blob attachments (v5.12).
3
+ *
4
+ * gnosys attach <file> --memory <id> store bytes inline on a memory
5
+ * gnosys get-attachment <id> [--out path] retrieve the stored bytes
6
+ *
7
+ * Inline attachments live in the memory row, so they ride the normal sync
8
+ * rail to other machines and a remote/dockerized server.
9
+ */
10
+ import { GnosysDB } from "./db.js";
11
+ export async function runAttachCommand(filePath, opts) {
12
+ const db = GnosysDB.openCentral();
13
+ try {
14
+ if (!db.isAvailable()) {
15
+ console.error("Database not available.");
16
+ process.exitCode = 1;
17
+ return;
18
+ }
19
+ const { attachFileToMemory } = await import("./attachments.js");
20
+ const result = await attachFileToMemory(db, opts.memory, filePath);
21
+ const sizeKb = (result.sizeBytes / 1024).toFixed(1);
22
+ const verb = result.unchanged ? "Already attached (no change)" : "Attached";
23
+ console.log(`${verb}: ${result.name} (${result.mime}, ${sizeKb} KB) → ${opts.memory}`);
24
+ }
25
+ catch (err) {
26
+ console.error(`Error: ${err instanceof Error ? err.message : err}`);
27
+ process.exitCode = 1;
28
+ }
29
+ finally {
30
+ db.close();
31
+ }
32
+ }
33
+ export async function runGetAttachmentCommand(memoryId, opts) {
34
+ const db = GnosysDB.openCentral();
35
+ try {
36
+ if (!db.isAvailable()) {
37
+ console.error("Database not available.");
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+ const { getMemoryAttachment } = await import("./attachments.js");
42
+ const att = getMemoryAttachment(db, memoryId);
43
+ if (!att) {
44
+ console.error(`No attachment found on memory: ${memoryId}`);
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ if (opts.out) {
49
+ const { writeFile } = await import("fs/promises");
50
+ await writeFile(opts.out, att.data);
51
+ console.log(`Wrote ${att.name} (${att.mime}, ${att.data.length} bytes) to ${opts.out}`);
52
+ }
53
+ else {
54
+ // No output path: print metadata + base64 so it can be piped/redirected.
55
+ console.error(`${att.name} (${att.mime}, ${att.data.length} bytes)`);
56
+ console.log(att.data.toString("base64"));
57
+ }
58
+ }
59
+ catch (err) {
60
+ console.error(`Error: ${err instanceof Error ? err.message : err}`);
61
+ process.exitCode = 1;
62
+ }
63
+ finally {
64
+ db.close();
65
+ }
66
+ }
@@ -1,9 +1,15 @@
1
1
  /**
2
2
  * Gnosys Attachments — File attachment management for multimodal ingestion.
3
3
  *
4
- * Stores binary files in .gnosys/attachments/<uuid>.<ext> with a JSON manifest
5
- * at .gnosys/attachments/attachments.json for tracking metadata and memory links.
4
+ * Two storage modes:
5
+ * - Filesystem (legacy, large media): bytes copied to .gnosys/attachments/<uuid>.<ext>
6
+ * with a JSON manifest. Does NOT travel between machines.
7
+ * - Inline DB blob (v5.12, small assets): bytes stored in the memory row's
8
+ * attachment_data column. Travels over the same row-copy sync rail as
9
+ * embeddings, so it works single-machine, multi-machine, and with a future
10
+ * dockerized MCP without any shared volume.
6
11
  */
12
+ import type { GnosysDB } from "./db.js";
7
13
  export interface AttachmentRecord {
8
14
  uuid: string;
9
15
  originalName: string;
@@ -14,6 +20,8 @@ export interface AttachmentRecord {
14
20
  createdAt: string;
15
21
  memoryIds: string[];
16
22
  }
23
+ /** Infer a MIME type from a file path's extension. */
24
+ export declare function inferMimeType(filePath: string): string;
17
25
  /**
18
26
  * Initialize the attachments directory and manifest in a store.
19
27
  * Safe to call multiple times — creates only if missing.
@@ -40,3 +48,36 @@ export declare function getAttachmentPath(storePath: string, uuid: string, ext:
40
48
  * tracks which memories reference it.
41
49
  */
42
50
  export declare function linkMemoryToAttachment(storePath: string, uuid: string, memoryId: string): Promise<void>;
51
+ /**
52
+ * Maximum size for an inline DB-blob attachment. Larger files should use the
53
+ * filesystem path (`gnosys ingest`) so the synced central DB stays lean.
54
+ */
55
+ export declare const MAX_INLINE_ATTACHMENT_BYTES: number;
56
+ export interface InlineAttachment {
57
+ /** Raw file bytes. */
58
+ data: Buffer;
59
+ /** MIME type, e.g. "image/svg+xml". */
60
+ mime: string;
61
+ /** Original filename, e.g. "prospero-logo.svg". */
62
+ name: string;
63
+ /** Size in bytes. */
64
+ sizeBytes: number;
65
+ }
66
+ export interface AttachToMemoryResult {
67
+ memoryId: string;
68
+ name: string;
69
+ mime: string;
70
+ sizeBytes: number;
71
+ /** True when the file was identical to what was already attached (no write). */
72
+ unchanged: boolean;
73
+ }
74
+ /**
75
+ * Read a file and store its bytes inline on a memory row (attachment_data).
76
+ * Enforces the size cap and skips the write if the same bytes are already
77
+ * attached (content-hash dedup). Bumps `modified` so remote sync picks it up.
78
+ */
79
+ export declare function attachFileToMemory(db: GnosysDB, memoryId: string, filePath: string): Promise<AttachToMemoryResult>;
80
+ /** Return the inline attachment stored on a memory row, or null if none. */
81
+ export declare function getMemoryAttachment(db: GnosysDB, memoryId: string): InlineAttachment | null;
82
+ /** Remove an inline attachment from a memory row (keeps the memory itself). */
83
+ export declare function detachFromMemory(db: GnosysDB, memoryId: string): boolean;