gnosys-mcp 0.1.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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -0
  3. package/dist/cli.d.ts +7 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +927 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/index.d.ts +8 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +1092 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/lib/bootstrap.d.ts +56 -0
  12. package/dist/lib/bootstrap.d.ts.map +1 -0
  13. package/dist/lib/bootstrap.js +163 -0
  14. package/dist/lib/bootstrap.js.map +1 -0
  15. package/dist/lib/history.d.ts +39 -0
  16. package/dist/lib/history.d.ts.map +1 -0
  17. package/dist/lib/history.js +86 -0
  18. package/dist/lib/history.js.map +1 -0
  19. package/dist/lib/ingest.d.ts +47 -0
  20. package/dist/lib/ingest.d.ts.map +1 -0
  21. package/dist/lib/ingest.js +134 -0
  22. package/dist/lib/ingest.js.map +1 -0
  23. package/dist/lib/lensing.d.ts +35 -0
  24. package/dist/lib/lensing.d.ts.map +1 -0
  25. package/dist/lib/lensing.js +85 -0
  26. package/dist/lib/lensing.js.map +1 -0
  27. package/dist/lib/resolver.d.ts +84 -0
  28. package/dist/lib/resolver.d.ts.map +1 -0
  29. package/dist/lib/resolver.js +269 -0
  30. package/dist/lib/resolver.js.map +1 -0
  31. package/dist/lib/search.d.ts +50 -0
  32. package/dist/lib/search.d.ts.map +1 -0
  33. package/dist/lib/search.js +166 -0
  34. package/dist/lib/search.js.map +1 -0
  35. package/dist/lib/store.d.ts +67 -0
  36. package/dist/lib/store.d.ts.map +1 -0
  37. package/dist/lib/store.js +168 -0
  38. package/dist/lib/store.js.map +1 -0
  39. package/dist/lib/tags.d.ts +26 -0
  40. package/dist/lib/tags.d.ts.map +1 -0
  41. package/dist/lib/tags.js +109 -0
  42. package/dist/lib/tags.js.map +1 -0
  43. package/dist/lib/timeline.d.ts +34 -0
  44. package/dist/lib/timeline.d.ts.map +1 -0
  45. package/dist/lib/timeline.js +116 -0
  46. package/dist/lib/timeline.js.map +1 -0
  47. package/dist/lib/wikilinks.d.ts +63 -0
  48. package/dist/lib/wikilinks.d.ts.map +1 -0
  49. package/dist/lib/wikilinks.js +146 -0
  50. package/dist/lib/wikilinks.js.map +1 -0
  51. package/dist/test/bootstrap.test.d.ts +2 -0
  52. package/dist/test/bootstrap.test.d.ts.map +1 -0
  53. package/dist/test/bootstrap.test.js +208 -0
  54. package/dist/test/bootstrap.test.js.map +1 -0
  55. package/dist/test/history.test.d.ts +2 -0
  56. package/dist/test/history.test.d.ts.map +1 -0
  57. package/dist/test/history.test.js +141 -0
  58. package/dist/test/history.test.js.map +1 -0
  59. package/dist/test/ingest.test.d.ts +2 -0
  60. package/dist/test/ingest.test.d.ts.map +1 -0
  61. package/dist/test/ingest.test.js +132 -0
  62. package/dist/test/ingest.test.js.map +1 -0
  63. package/dist/test/lensing.test.d.ts +2 -0
  64. package/dist/test/lensing.test.d.ts.map +1 -0
  65. package/dist/test/lensing.test.js +154 -0
  66. package/dist/test/lensing.test.js.map +1 -0
  67. package/dist/test/search.test.d.ts +2 -0
  68. package/dist/test/search.test.d.ts.map +1 -0
  69. package/dist/test/search.test.js +129 -0
  70. package/dist/test/search.test.js.map +1 -0
  71. package/dist/test/store.test.d.ts +2 -0
  72. package/dist/test/store.test.d.ts.map +1 -0
  73. package/dist/test/store.test.js +152 -0
  74. package/dist/test/store.test.js.map +1 -0
  75. package/dist/test/tags.test.d.ts +2 -0
  76. package/dist/test/tags.test.d.ts.map +1 -0
  77. package/dist/test/tags.test.js +81 -0
  78. package/dist/test/tags.test.js.map +1 -0
  79. package/dist/test/timeline.test.d.ts +2 -0
  80. package/dist/test/timeline.test.d.ts.map +1 -0
  81. package/dist/test/timeline.test.js +127 -0
  82. package/dist/test/timeline.test.js.map +1 -0
  83. package/dist/test/wikilinks.test.d.ts +2 -0
  84. package/dist/test/wikilinks.test.d.ts.map +1 -0
  85. package/dist/test/wikilinks.test.js +178 -0
  86. package/dist/test/wikilinks.test.js.map +1 -0
  87. package/package.json +56 -0
package/dist/index.js ADDED
@@ -0,0 +1,1092 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gnosys MCP Server — The core of Gnosys.
4
+ * Exposes memory operations as MCP tools that any agent can call.
5
+ * Supports layered stores: project (auto-discovered), personal, global, optional.
6
+ */
7
+ // Load API keys from ~/.config/gnosys/.env before anything else
8
+ import dotenv from "dotenv";
9
+ import path from "path";
10
+ const home = process.env.HOME || process.env.USERPROFILE || "/tmp";
11
+ dotenv.config({ path: path.join(home, ".config", "gnosys", ".env") });
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import { z } from "zod";
15
+ import fs from "fs/promises";
16
+ import { GnosysSearch } from "./lib/search.js";
17
+ import { GnosysTagRegistry } from "./lib/tags.js";
18
+ import { GnosysIngestion } from "./lib/ingest.js";
19
+ import { GnosysResolver } from "./lib/resolver.js";
20
+ import { applyLens } from "./lib/lensing.js";
21
+ import { getFileHistory, rollbackToCommit, hasGitHistory } from "./lib/history.js";
22
+ import { groupByPeriod, computeStats } from "./lib/timeline.js";
23
+ import { buildLinkGraph, getBacklinks, getOutgoingLinks, formatGraphSummary } from "./lib/wikilinks.js";
24
+ import { bootstrap } from "./lib/bootstrap.js";
25
+ // Initialize resolver (discovers all layered stores)
26
+ const resolver = new GnosysResolver();
27
+ // Create MCP server
28
+ const server = new McpServer({
29
+ name: "gnosys",
30
+ version: "0.1.0",
31
+ });
32
+ // These are initialized in main() after resolver runs
33
+ let search = null;
34
+ let tagRegistry = null;
35
+ let ingestion = null;
36
+ // ─── Tool: gnosys_discover ──────────────────────────────────────────────
37
+ server.tool("gnosys_discover", "Discover relevant memories by describing what you're working on. Searches relevance keyword clouds across all stores. Returns lightweight metadata (title, path, relevance keywords) — NO file contents. Use gnosys_read to load specific memories you need. Call this FIRST when starting a task to find what Gnosys knows.", {
38
+ query: z
39
+ .string()
40
+ .describe("Describe what you're working on or looking for. Use keywords, not sentences. Example: 'auth JWT session tokens' or 'deployment CI/CD pipeline'"),
41
+ limit: z.number().optional().describe("Max results (default 20)"),
42
+ }, async ({ query, limit }) => {
43
+ if (!search) {
44
+ return {
45
+ content: [{ type: "text", text: "Search index not initialized." }],
46
+ isError: true,
47
+ };
48
+ }
49
+ const results = search.discover(query, limit || 20);
50
+ if (results.length === 0) {
51
+ return {
52
+ content: [
53
+ {
54
+ type: "text",
55
+ text: `No memories found for "${query}". Try different keywords or use gnosys_search for full-text search.`,
56
+ },
57
+ ],
58
+ };
59
+ }
60
+ const formatted = results
61
+ .map((r) => `**${r.title}**\n Path: ${r.relative_path}${r.relevance ? `\n Relevance: ${r.relevance}` : ""}`)
62
+ .join("\n\n");
63
+ return {
64
+ content: [
65
+ {
66
+ type: "text",
67
+ text: `Found ${results.length} relevant memories for "${query}":\n\n${formatted}\n\nUse gnosys_read to load any of these.`,
68
+ },
69
+ ],
70
+ };
71
+ });
72
+ // ─── Tool: gnosys_read ───────────────────────────────────────────────────
73
+ server.tool("gnosys_read", "Read a specific memory file. Use layer-prefixed paths (e.g., 'project:decisions/why-not-rag.md'). Without a prefix, searches all stores in precedence order.", { path: z.string().describe("Path to memory, optionally prefixed with store layer") }, async ({ path: memPath }) => {
74
+ const memory = await resolver.readMemory(memPath);
75
+ if (!memory) {
76
+ return {
77
+ content: [{ type: "text", text: `Memory not found: ${memPath}` }],
78
+ isError: true,
79
+ };
80
+ }
81
+ const raw = await fs.readFile(memory.filePath, "utf-8");
82
+ return {
83
+ content: [
84
+ {
85
+ type: "text",
86
+ text: `[Source: ${memory.sourceLabel}]\n\n${raw}`,
87
+ },
88
+ ],
89
+ };
90
+ });
91
+ // ─── Tool: gnosys_search ─────────────────────────────────────────────────
92
+ server.tool("gnosys_search", "Search memories by keyword across all stores. Returns matching file paths with relevance snippets.", {
93
+ query: z.string().describe("Search query (keywords)"),
94
+ limit: z.number().optional().describe("Max results (default 20)"),
95
+ }, async ({ query, limit }) => {
96
+ if (!search) {
97
+ return {
98
+ content: [{ type: "text", text: "Search index not initialized." }],
99
+ isError: true,
100
+ };
101
+ }
102
+ const results = search.search(query, limit || 20);
103
+ if (results.length === 0) {
104
+ return {
105
+ content: [
106
+ {
107
+ type: "text",
108
+ text: `No results for "${query}". Try different keywords or use gnosys_discover.`,
109
+ },
110
+ ],
111
+ };
112
+ }
113
+ const formatted = results
114
+ .map((r) => `**${r.title}** (${r.relative_path})\n${r.snippet.replace(/>>>/g, "**").replace(/<<</g, "**")}`)
115
+ .join("\n\n");
116
+ return {
117
+ content: [
118
+ {
119
+ type: "text",
120
+ text: `Found ${results.length} results for "${query}":\n\n${formatted}`,
121
+ },
122
+ ],
123
+ };
124
+ });
125
+ // ─── Tool: gnosys_list ───────────────────────────────────────────────────
126
+ server.tool("gnosys_list", "List memories across all stores, optionally filtered by category, tag, or store layer.", {
127
+ category: z.string().optional().describe("Filter by category"),
128
+ tag: z.string().optional().describe("Filter by tag"),
129
+ store: z.string().optional().describe("Filter by store layer (project/personal/global/optional)"),
130
+ status: z.string().optional().describe("Filter by status (active/archived/superseded)"),
131
+ }, async ({ category, tag, store: storeFilter, status }) => {
132
+ let memories = await resolver.getAllMemories();
133
+ if (storeFilter) {
134
+ memories = memories.filter((m) => m.sourceLayer === storeFilter || m.sourceLabel === storeFilter);
135
+ }
136
+ if (category) {
137
+ memories = memories.filter((m) => m.frontmatter.category === category);
138
+ }
139
+ if (tag) {
140
+ memories = memories.filter((m) => {
141
+ const tags = Array.isArray(m.frontmatter.tags)
142
+ ? m.frontmatter.tags
143
+ : Object.values(m.frontmatter.tags).flat();
144
+ return tags.includes(tag);
145
+ });
146
+ }
147
+ if (status) {
148
+ memories = memories.filter((m) => m.frontmatter.status === status);
149
+ }
150
+ const lines = memories.map((m) => `- [${m.sourceLabel}] **${m.frontmatter.title}** (${m.relativePath}) [${m.frontmatter.status}]`);
151
+ return {
152
+ content: [
153
+ {
154
+ type: "text",
155
+ text: lines.length > 0
156
+ ? `${lines.length} memories:\n\n${lines.join("\n")}`
157
+ : "No memories match the filter.",
158
+ },
159
+ ],
160
+ };
161
+ });
162
+ // ─── Tool: gnosys_add ────────────────────────────────────────────────────
163
+ server.tool("gnosys_add", "Add a new memory. Accepts raw text — an LLM structures it into an atomic memory. Writes to the project store by default. Use store='personal' for cross-project knowledge, or store='global' to explicitly write to shared org knowledge.", {
164
+ input: z
165
+ .string()
166
+ .describe("Raw text input. Can be a decision, concept, fact, observation, or any knowledge."),
167
+ store: z
168
+ .enum(["project", "personal", "global"])
169
+ .optional()
170
+ .describe("Which store to write to (default: project). Global requires explicit intent."),
171
+ author: z
172
+ .enum(["human", "ai", "human+ai"])
173
+ .optional()
174
+ .describe("Who is adding this memory"),
175
+ authority: z
176
+ .enum(["declared", "observed", "imported", "inferred"])
177
+ .optional()
178
+ .describe("Epistemic trust level"),
179
+ }, async ({ input, store: targetStore, author, authority }) => {
180
+ const writeTarget = resolver.getWriteTarget(targetStore || undefined);
181
+ if (!writeTarget) {
182
+ return {
183
+ content: [
184
+ {
185
+ type: "text",
186
+ text: "No writable store found. Create a .gnosys/ directory in your project root or set GNOSYS_PERSONAL.",
187
+ },
188
+ ],
189
+ isError: true,
190
+ };
191
+ }
192
+ if (!ingestion) {
193
+ return {
194
+ content: [
195
+ { type: "text", text: "Ingestion module not initialized." },
196
+ ],
197
+ isError: true,
198
+ };
199
+ }
200
+ try {
201
+ const result = await ingestion.ingest(input);
202
+ const id = await writeTarget.store.generateId(result.category);
203
+ const today = new Date().toISOString().split("T")[0];
204
+ const frontmatter = {
205
+ id,
206
+ title: result.title,
207
+ category: result.category,
208
+ tags: result.tags,
209
+ relevance: result.relevance,
210
+ author: author || "ai",
211
+ authority: authority || "observed",
212
+ confidence: result.confidence,
213
+ created: today,
214
+ modified: today,
215
+ last_reviewed: today,
216
+ status: "active",
217
+ supersedes: null,
218
+ };
219
+ const filename = `${result.filename}.md`;
220
+ const content = `# ${result.title}\n\n${result.content}`;
221
+ const relativePath = await writeTarget.store.writeMemory(result.category, filename, frontmatter, content);
222
+ // Rebuild search index across all stores
223
+ if (search) {
224
+ await reindexAllStores();
225
+ }
226
+ let response = `Memory added to [${writeTarget.label}]: **${result.title}**\nPath: ${writeTarget.label}:${relativePath}\nCategory: ${result.category}\nConfidence: ${result.confidence}`;
227
+ if (result.proposedNewTags && result.proposedNewTags.length > 0) {
228
+ const proposed = result.proposedNewTags
229
+ .map((t) => `${t.category}:${t.tag}`)
230
+ .join(", ");
231
+ response += `\n\nProposed new tags (not yet in registry): ${proposed}\nUse gnosys_tags_add to approve them.`;
232
+ }
233
+ // Contradiction / overlap detection: search for closely related memories
234
+ if (search && result.relevance) {
235
+ const related = search.discover(result.relevance.split(" ").slice(0, 5).join(" "), 5);
236
+ // Filter out the memory we just added
237
+ const overlaps = related.filter((r) => !r.relative_path.endsWith(filename));
238
+ if (overlaps.length > 0) {
239
+ response += `\n\n⚠️ Potential overlaps detected — review these for contradictions:`;
240
+ for (const o of overlaps.slice(0, 3)) {
241
+ response += `\n - ${o.title} (${o.relative_path})`;
242
+ }
243
+ response += `\nUse gnosys_read to compare, then gnosys_update with supersedes/superseded_by if needed.`;
244
+ }
245
+ }
246
+ return { content: [{ type: "text", text: response }] };
247
+ }
248
+ catch (err) {
249
+ return {
250
+ content: [
251
+ {
252
+ type: "text",
253
+ text: `Error adding memory: ${err instanceof Error ? err.message : String(err)}`,
254
+ },
255
+ ],
256
+ isError: true,
257
+ };
258
+ }
259
+ });
260
+ // ─── Tool: gnosys_add_structured ─────────────────────────────────────────
261
+ server.tool("gnosys_add_structured", "Add a memory with structured input (no LLM needed). Writes to the project store by default. Use store='global' to explicitly write to shared org knowledge.", {
262
+ title: z.string().describe("Memory title"),
263
+ category: z.string().describe("Category directory name"),
264
+ tags: z
265
+ .record(z.string(), z.array(z.string()))
266
+ .describe("Tags object, e.g. { domain: ['auth'], type: ['decision'] }"),
267
+ relevance: z
268
+ .string()
269
+ .optional()
270
+ .describe("Keyword cloud for discovery search. Space-separated terms describing contexts where this memory is useful."),
271
+ content: z.string().describe("Memory content as markdown"),
272
+ store: z.enum(["project", "personal", "global"]).optional().describe("Target store (default: project). Global requires explicit intent."),
273
+ author: z.enum(["human", "ai", "human+ai"]).optional(),
274
+ authority: z
275
+ .enum(["declared", "observed", "imported", "inferred"])
276
+ .optional(),
277
+ confidence: z.number().min(0).max(1).optional(),
278
+ }, async ({ title, category, tags, relevance, content, store: targetStore, author, authority, confidence }) => {
279
+ const writeTarget = resolver.getWriteTarget(targetStore || undefined);
280
+ if (!writeTarget) {
281
+ return {
282
+ content: [{ type: "text", text: "No writable store found." }],
283
+ isError: true,
284
+ };
285
+ }
286
+ const id = await writeTarget.store.generateId(category);
287
+ const slug = title
288
+ .toLowerCase()
289
+ .replace(/[^a-z0-9]+/g, "-")
290
+ .replace(/^-|-$/g, "")
291
+ .substring(0, 60);
292
+ const today = new Date().toISOString().split("T")[0];
293
+ const frontmatter = {
294
+ id,
295
+ title,
296
+ category,
297
+ tags: tags,
298
+ relevance: relevance || "",
299
+ author: author || "ai",
300
+ authority: authority || "observed",
301
+ confidence: confidence || 0.8,
302
+ created: today,
303
+ modified: today,
304
+ last_reviewed: today,
305
+ status: "active",
306
+ supersedes: null,
307
+ };
308
+ const fullContent = `# ${title}\n\n${content}`;
309
+ const relativePath = await writeTarget.store.writeMemory(category, `${slug}.md`, frontmatter, fullContent);
310
+ if (search)
311
+ await reindexAllStores();
312
+ return {
313
+ content: [
314
+ {
315
+ type: "text",
316
+ text: `Memory added to [${writeTarget.label}]: **${title}**\nPath: ${writeTarget.label}:${relativePath}`,
317
+ },
318
+ ],
319
+ };
320
+ });
321
+ // ─── Tool: gnosys_tags ───────────────────────────────────────────────────
322
+ server.tool("gnosys_tags", "List all tags in the registry, grouped by category.", {}, async () => {
323
+ if (!tagRegistry) {
324
+ return { content: [{ type: "text", text: "Tag registry not loaded." }], isError: true };
325
+ }
326
+ const registry = tagRegistry.getRegistry();
327
+ const lines = ["# Gnosys Tag Registry\n"];
328
+ for (const [category, tags] of Object.entries(registry)) {
329
+ lines.push(`## ${category}`);
330
+ lines.push(tags.sort().join(", "));
331
+ lines.push("");
332
+ }
333
+ return { content: [{ type: "text", text: lines.join("\n") }] };
334
+ });
335
+ // ─── Tool: gnosys_tags_add ───────────────────────────────────────────────
336
+ server.tool("gnosys_tags_add", "Add a new tag to the registry.", {
337
+ category: z.string().describe("Tag category (domain, type, concern, status_tag)"),
338
+ tag: z.string().describe("The new tag to add"),
339
+ }, async ({ category, tag }) => {
340
+ if (!tagRegistry) {
341
+ return { content: [{ type: "text", text: "Tag registry not loaded." }], isError: true };
342
+ }
343
+ const added = await tagRegistry.addTag(category, tag);
344
+ if (added) {
345
+ return {
346
+ content: [{ type: "text", text: `Tag '${tag}' added to category '${category}'.` }],
347
+ };
348
+ }
349
+ return {
350
+ content: [{ type: "text", text: `Tag '${tag}' already exists in '${category}'.` }],
351
+ };
352
+ });
353
+ // ─── Tool: gnosys_reinforce ──────────────────────────────────────────────
354
+ server.tool("gnosys_reinforce", "Signal whether a memory was useful. 'useful' reinforces it (resets decay). 'not_relevant' means routing was wrong, not the memory (memory unchanged). 'outdated' flags for review.", {
355
+ memory_id: z.string().describe("The memory ID (from frontmatter)"),
356
+ signal: z
357
+ .enum(["useful", "not_relevant", "outdated"])
358
+ .describe("The reinforcement signal"),
359
+ context: z.string().optional().describe("Why this signal was given"),
360
+ }, async ({ memory_id, signal, context }) => {
361
+ // Log to the first writable store's .gnosys directory
362
+ const writeTarget = resolver.getWriteTarget();
363
+ if (writeTarget) {
364
+ const logPath = path.join(writeTarget.store.getStorePath(), ".gnosys", "reinforcement.log");
365
+ const entry = JSON.stringify({
366
+ memory_id,
367
+ signal,
368
+ context,
369
+ timestamp: new Date().toISOString(),
370
+ });
371
+ await fs.appendFile(logPath, entry + "\n", "utf-8");
372
+ }
373
+ // If 'useful', find the memory across all stores and update if writable
374
+ if (signal === "useful") {
375
+ const allMemories = await resolver.getAllMemories();
376
+ const memory = allMemories.find((m) => m.frontmatter.id === memory_id);
377
+ if (memory) {
378
+ const sourceStore = resolver
379
+ .getStores()
380
+ .find((s) => s.label === memory.sourceLabel);
381
+ if (sourceStore?.writable) {
382
+ await sourceStore.store.updateMemory(memory.relativePath, {
383
+ modified: new Date().toISOString().split("T")[0],
384
+ });
385
+ }
386
+ }
387
+ }
388
+ const messages = {
389
+ useful: `Memory ${memory_id} reinforced. Decay clock reset.`,
390
+ not_relevant: `Routing feedback logged for ${memory_id}. Memory unchanged — consider reviewing its relevance keywords or tags.`,
391
+ outdated: `Memory ${memory_id} flagged for review as outdated.`,
392
+ };
393
+ return { content: [{ type: "text", text: messages[signal] }] };
394
+ });
395
+ // ─── Tool: gnosys_init ───────────────────────────────────────────────────
396
+ server.tool("gnosys_init", "Initialize a new .gnosys store in the given directory. Creates the directory structure, default tag registry, and git repo. You MUST pass the 'directory' parameter with the full absolute path to the project root.", {
397
+ directory: z
398
+ .string()
399
+ .describe("Absolute path to the project directory to create .gnosys/ in. Required."),
400
+ }, async ({ directory }) => {
401
+ const targetDir = path.resolve(directory);
402
+ const storePath = path.join(targetDir, ".gnosys");
403
+ // Check if already exists
404
+ try {
405
+ await fs.stat(storePath);
406
+ return {
407
+ content: [
408
+ {
409
+ type: "text",
410
+ text: `A .gnosys store already exists at ${storePath}.`,
411
+ },
412
+ ],
413
+ isError: true,
414
+ };
415
+ }
416
+ catch {
417
+ // Good — doesn't exist yet
418
+ }
419
+ // Create directory structure
420
+ await fs.mkdir(storePath, { recursive: true });
421
+ await fs.mkdir(path.join(storePath, ".gnosys"), { recursive: true });
422
+ // Seed default tag registry
423
+ const defaultRegistry = {
424
+ domain: [
425
+ "architecture",
426
+ "api",
427
+ "auth",
428
+ "database",
429
+ "devops",
430
+ "frontend",
431
+ "backend",
432
+ "testing",
433
+ "security",
434
+ "performance",
435
+ ],
436
+ type: [
437
+ "decision",
438
+ "concept",
439
+ "convention",
440
+ "requirement",
441
+ "observation",
442
+ "fact",
443
+ "question",
444
+ ],
445
+ concern: ["dx", "scalability", "maintainability", "reliability"],
446
+ status_tag: ["draft", "stable", "deprecated", "experimental"],
447
+ };
448
+ await fs.writeFile(path.join(storePath, "tags.json"), JSON.stringify(defaultRegistry, null, 2), "utf-8");
449
+ // Seed changelog
450
+ const changelog = `# Gnosys Changelog\n\n## ${new Date().toISOString().split("T")[0]}\n\n- Store initialized\n`;
451
+ await fs.writeFile(path.join(storePath, "CHANGELOG.md"), changelog, "utf-8");
452
+ // Init git
453
+ try {
454
+ const { execSync } = await import("child_process");
455
+ execSync("git init", { cwd: storePath, stdio: "pipe" });
456
+ execSync("git add -A", { cwd: storePath, stdio: "pipe" });
457
+ execSync('git commit -m "Initialize Gnosys store"', {
458
+ cwd: storePath,
459
+ stdio: "pipe",
460
+ });
461
+ }
462
+ catch {
463
+ // Git not available — that's fine
464
+ }
465
+ // Register this project so the resolver finds it on future restarts
466
+ // (MCP server cwd may not match the editor's project directory).
467
+ await resolver.registerProject(targetDir);
468
+ // Directly add the new store to the resolver (no re-resolve from cwd needed)
469
+ await resolver.addProjectStore(storePath);
470
+ // Initialize search, tags, and ingestion if this is the first store
471
+ const writeTarget = resolver.getWriteTarget();
472
+ if (writeTarget && !search) {
473
+ search = new GnosysSearch(writeTarget.store.getStorePath());
474
+ tagRegistry = new GnosysTagRegistry(writeTarget.store.getStorePath());
475
+ await tagRegistry.load();
476
+ ingestion = new GnosysIngestion(writeTarget.store, tagRegistry);
477
+ await reindexAllStores();
478
+ }
479
+ return {
480
+ content: [
481
+ {
482
+ type: "text",
483
+ text: `Gnosys store initialized at ${storePath}\n\nCreated:\n- .gnosys/ (internal config)\n- tags.json (tag registry)\n- CHANGELOG.md\n- git repo initialized\n\nThe store is ready. Use gnosys_discover to find existing memories or gnosys_add to create new ones.`,
484
+ },
485
+ ],
486
+ };
487
+ });
488
+ // ─── Tool: gnosys_update ─────────────────────────────────────────────────
489
+ server.tool("gnosys_update", "Update an existing memory's frontmatter and/or content. Specify the memory path and the fields to change.", {
490
+ path: z
491
+ .string()
492
+ .describe("Path to memory, optionally prefixed with store layer (e.g., 'project:decisions/auth.md')"),
493
+ title: z.string().optional().describe("New title"),
494
+ tags: z
495
+ .record(z.string(), z.array(z.string()))
496
+ .optional()
497
+ .describe("New tags object"),
498
+ status: z
499
+ .enum(["active", "archived", "superseded"])
500
+ .optional()
501
+ .describe("New status"),
502
+ confidence: z.number().min(0).max(1).optional().describe("New confidence"),
503
+ supersedes: z
504
+ .string()
505
+ .optional()
506
+ .describe("ID of memory this supersedes"),
507
+ relevance: z
508
+ .string()
509
+ .optional()
510
+ .describe("Updated relevance keyword cloud for discovery"),
511
+ superseded_by: z
512
+ .string()
513
+ .optional()
514
+ .describe("ID of memory that supersedes this one"),
515
+ content: z
516
+ .string()
517
+ .optional()
518
+ .describe("New markdown content (replaces existing body)"),
519
+ }, async ({ path: memPath, title, tags, status, confidence, relevance, supersedes, superseded_by, content: newContent, }) => {
520
+ const memory = await resolver.readMemory(memPath);
521
+ if (!memory) {
522
+ return {
523
+ content: [{ type: "text", text: `Memory not found: ${memPath}` }],
524
+ isError: true,
525
+ };
526
+ }
527
+ // Find the source store and check if writable
528
+ const sourceStore = resolver
529
+ .getStores()
530
+ .find((s) => s.label === memory.sourceLabel);
531
+ if (!sourceStore?.writable) {
532
+ return {
533
+ content: [
534
+ {
535
+ type: "text",
536
+ text: `Cannot update: store [${memory.sourceLabel}] is read-only.`,
537
+ },
538
+ ],
539
+ isError: true,
540
+ };
541
+ }
542
+ // Build updates object — only include defined fields
543
+ const updates = {};
544
+ if (title !== undefined)
545
+ updates.title = title;
546
+ if (tags !== undefined)
547
+ updates.tags = tags;
548
+ if (status !== undefined)
549
+ updates.status = status;
550
+ if (confidence !== undefined)
551
+ updates.confidence = confidence;
552
+ if (relevance !== undefined)
553
+ updates.relevance = relevance;
554
+ if (supersedes !== undefined)
555
+ updates.supersedes = supersedes;
556
+ if (superseded_by !== undefined)
557
+ updates.superseded_by = superseded_by;
558
+ const fullContent = newContent ? `# ${title || memory.frontmatter.title}\n\n${newContent}` : undefined;
559
+ const updated = await sourceStore.store.updateMemory(memory.relativePath, updates, fullContent);
560
+ if (!updated) {
561
+ return {
562
+ content: [{ type: "text", text: `Failed to update: ${memPath}` }],
563
+ isError: true,
564
+ };
565
+ }
566
+ // Supersession cross-linking: if A supersedes B, mark B as superseded_by A
567
+ if (supersedes && updated.frontmatter.id) {
568
+ const allMemories = await resolver.getAllMemories();
569
+ const supersededMemory = allMemories.find((m) => m.frontmatter.id === supersedes);
570
+ if (supersededMemory) {
571
+ const supersededStore = resolver
572
+ .getStores()
573
+ .find((s) => s.label === supersededMemory.sourceLabel);
574
+ if (supersededStore?.writable) {
575
+ await supersededStore.store.updateMemory(supersededMemory.relativePath, {
576
+ superseded_by: updated.frontmatter.id,
577
+ status: "superseded",
578
+ });
579
+ }
580
+ }
581
+ }
582
+ // Rebuild search index
583
+ if (search)
584
+ await reindexAllStores();
585
+ const changedFields = Object.keys(updates);
586
+ if (newContent)
587
+ changedFields.push("content");
588
+ return {
589
+ content: [
590
+ {
591
+ type: "text",
592
+ text: `Memory updated: **${updated.frontmatter.title}**\nPath: ${memory.sourceLabel}:${memory.relativePath}\nChanged: ${changedFields.join(", ")}`,
593
+ },
594
+ ],
595
+ };
596
+ });
597
+ // ─── Tool: gnosys_stale ─────────────────────────────────────────────────
598
+ server.tool("gnosys_stale", "Find memories that haven't been modified or reviewed within a given number of days. Useful for identifying knowledge that may be outdated.", {
599
+ days: z
600
+ .number()
601
+ .optional()
602
+ .describe("Number of days since last modification to consider stale (default: 90)"),
603
+ limit: z.number().optional().describe("Max results (default 20)"),
604
+ }, async ({ days, limit }) => {
605
+ const threshold = days || 90;
606
+ const maxResults = limit || 20;
607
+ const cutoff = new Date();
608
+ cutoff.setDate(cutoff.getDate() - threshold);
609
+ const cutoffStr = cutoff.toISOString().split("T")[0];
610
+ const allMemories = await resolver.getAllMemories();
611
+ const stale = allMemories
612
+ .filter((m) => {
613
+ const lastTouched = m.frontmatter.last_reviewed || m.frontmatter.modified;
614
+ return lastTouched && lastTouched < cutoffStr;
615
+ })
616
+ .sort((a, b) => {
617
+ const aDate = a.frontmatter.last_reviewed || a.frontmatter.modified;
618
+ const bDate = b.frontmatter.last_reviewed || b.frontmatter.modified;
619
+ return (aDate || "").localeCompare(bDate || "");
620
+ })
621
+ .slice(0, maxResults);
622
+ if (stale.length === 0) {
623
+ return {
624
+ content: [
625
+ {
626
+ type: "text",
627
+ text: `No memories older than ${threshold} days found. Everything is fresh.`,
628
+ },
629
+ ],
630
+ };
631
+ }
632
+ const lines = stale.map((m) => `- **${m.frontmatter.title}** (${m.sourceLabel}:${m.relativePath})\n Last modified: ${m.frontmatter.modified}${m.frontmatter.last_reviewed ? `, Last reviewed: ${m.frontmatter.last_reviewed}` : ""}`);
633
+ return {
634
+ content: [
635
+ {
636
+ type: "text",
637
+ text: `Found ${stale.length} memories not touched in ${threshold}+ days:\n\n${lines.join("\n\n")}\n\nUse gnosys_read to review, then gnosys_update or gnosys_reinforce as needed.`,
638
+ },
639
+ ],
640
+ };
641
+ });
642
+ // ─── Tool: gnosys_commit_context ────────────────────────────────────────
643
+ server.tool("gnosys_commit_context", "Pre-compaction memory sweep. Call this before context is lost (e.g., before a long conversation compacts). Extracts important decisions, facts, and insights from the conversation and commits novel ones to memory. Checks existing memories to avoid duplicates — only adds what's genuinely new or augments what's changed.", {
644
+ context: z
645
+ .string()
646
+ .describe("Summary of the conversation or context to extract memories from. Include key decisions, facts, insights, and observations."),
647
+ dry_run: z
648
+ .boolean()
649
+ .optional()
650
+ .describe("If true, show what would be committed without actually writing. Default: false."),
651
+ }, async ({ context, dry_run }) => {
652
+ if (!ingestion || !ingestion.isLLMAvailable) {
653
+ return {
654
+ content: [
655
+ {
656
+ type: "text",
657
+ text: "Commit context requires LLM (ANTHROPIC_API_KEY). Set the key and restart.",
658
+ },
659
+ ],
660
+ isError: true,
661
+ };
662
+ }
663
+ const writeTarget = resolver.getWriteTarget();
664
+ if (!writeTarget) {
665
+ return {
666
+ content: [{ type: "text", text: "No writable store found." }],
667
+ isError: true,
668
+ };
669
+ }
670
+ // Step 1: Use LLM to extract candidate memories from the context
671
+ const Anthropic = (await import("@anthropic-ai/sdk")).default;
672
+ const apiKey = process.env.ANTHROPIC_API_KEY;
673
+ if (!apiKey) {
674
+ return {
675
+ content: [{ type: "text", text: "ANTHROPIC_API_KEY not set." }],
676
+ isError: true,
677
+ };
678
+ }
679
+ const client = new Anthropic({ apiKey });
680
+ const extractResponse = await client.messages.create({
681
+ model: "claude-haiku-4-5-20251001",
682
+ max_tokens: 4000,
683
+ system: `You extract atomic knowledge items from conversations. Each item should be ONE decision, fact, insight, or observation — not compound.
684
+
685
+ Output a JSON array of objects, each with:
686
+ - summary: One-sentence description of the knowledge
687
+ - type: "decision" | "insight" | "fact" | "observation" | "requirement"
688
+ - search_terms: 3-5 keywords someone would search for to find if this already exists
689
+
690
+ Be selective. Only extract things worth remembering long-term. Skip small talk, debugging steps, and transient details. Focus on decisions made, architecture choices, requirements established, and insights gained.
691
+
692
+ Output ONLY the JSON array, no markdown fences.`,
693
+ messages: [
694
+ {
695
+ role: "user",
696
+ content: `Extract atomic knowledge items from this context:\n\n${context}`,
697
+ },
698
+ ],
699
+ });
700
+ const extractText = extractResponse.content[0].type === "text"
701
+ ? extractResponse.content[0].text
702
+ : "[]";
703
+ let candidates;
704
+ try {
705
+ const jsonMatch = extractText.match(/```json\s*([\s\S]*?)```/) ||
706
+ extractText.match(/```\s*([\s\S]*?)```/) || [null, extractText];
707
+ candidates = JSON.parse(jsonMatch[1] || extractText);
708
+ }
709
+ catch {
710
+ return {
711
+ content: [
712
+ {
713
+ type: "text",
714
+ text: `Failed to extract candidates from context. LLM output was not valid JSON.`,
715
+ },
716
+ ],
717
+ isError: true,
718
+ };
719
+ }
720
+ if (!Array.isArray(candidates) || candidates.length === 0) {
721
+ return {
722
+ content: [
723
+ {
724
+ type: "text",
725
+ text: "No extractable knowledge found in the provided context.",
726
+ },
727
+ ],
728
+ };
729
+ }
730
+ // Step 2: For each candidate, check if it's novel by searching existing memories
731
+ const results = [];
732
+ let added = 0;
733
+ let skipped = 0;
734
+ for (const candidate of candidates) {
735
+ const searchTerms = candidate.search_terms.join(" ");
736
+ // Check existing memories via discover
737
+ const existing = search
738
+ ? search.discover(searchTerms, 3)
739
+ : [];
740
+ const hasOverlap = existing.length > 0;
741
+ if (hasOverlap) {
742
+ const topMatch = existing[0];
743
+ results.push(`⏭ SKIP: "${candidate.summary}"\n Overlaps with: ${topMatch.title} (${topMatch.relative_path})`);
744
+ skipped++;
745
+ }
746
+ else {
747
+ if (dry_run) {
748
+ results.push(`➕ WOULD ADD: "${candidate.summary}" [${candidate.type}]`);
749
+ added++;
750
+ }
751
+ else {
752
+ // Actually add via ingestion
753
+ try {
754
+ const result = await ingestion.ingest(candidate.summary);
755
+ const id = await writeTarget.store.generateId(result.category);
756
+ const today = new Date().toISOString().split("T")[0];
757
+ const frontmatter = {
758
+ id,
759
+ title: result.title,
760
+ category: result.category,
761
+ tags: result.tags,
762
+ relevance: result.relevance,
763
+ author: "ai",
764
+ authority: "observed",
765
+ confidence: result.confidence,
766
+ created: today,
767
+ modified: today,
768
+ last_reviewed: today,
769
+ status: "active",
770
+ supersedes: null,
771
+ };
772
+ const filename = `${result.filename}.md`;
773
+ const content = `# ${result.title}\n\n${result.content}`;
774
+ const relPath = await writeTarget.store.writeMemory(result.category, filename, frontmatter, content);
775
+ results.push(`➕ ADDED: "${result.title}"\n Path: ${writeTarget.label}:${relPath}`);
776
+ added++;
777
+ }
778
+ catch (err) {
779
+ results.push(`❌ FAILED: "${candidate.summary}": ${err instanceof Error ? err.message : String(err)}`);
780
+ }
781
+ }
782
+ }
783
+ }
784
+ // Rebuild search index after all writes
785
+ if (!dry_run && search && added > 0) {
786
+ await reindexAllStores();
787
+ }
788
+ const header = dry_run
789
+ ? `DRY RUN — ${candidates.length} candidates extracted, ${added} would be added, ${skipped} duplicates skipped:`
790
+ : `Context committed — ${candidates.length} candidates extracted, ${added} added, ${skipped} duplicates skipped:`;
791
+ return {
792
+ content: [
793
+ {
794
+ type: "text",
795
+ text: `${header}\n\n${results.join("\n\n")}`,
796
+ },
797
+ ],
798
+ };
799
+ });
800
+ // ─── Tool: gnosys_history ────────────────────────────────────────────────
801
+ server.tool("gnosys_history", "View version history for a memory. Shows what changed and when. Every memory write/update creates a git commit, so the full evolution is available.", {
802
+ path: z.string().describe("Path to memory, optionally layer-prefixed"),
803
+ limit: z.number().optional().describe("Max history entries (default 20)"),
804
+ }, async ({ path: memPath, limit }) => {
805
+ const memory = await resolver.readMemory(memPath);
806
+ if (!memory) {
807
+ return { content: [{ type: "text", text: `Memory not found: ${memPath}` }], isError: true };
808
+ }
809
+ const sourceStore = resolver.getStores().find((s) => s.label === memory.sourceLabel);
810
+ if (!sourceStore || !hasGitHistory(sourceStore.path)) {
811
+ return { content: [{ type: "text", text: "No git history available for this store." }], isError: true };
812
+ }
813
+ const history = getFileHistory(sourceStore.path, memory.relativePath, limit || 20);
814
+ if (history.length === 0) {
815
+ return { content: [{ type: "text", text: "No history found for this memory." }] };
816
+ }
817
+ const lines = history.map((e) => `- \`${e.commitHash.substring(0, 7)}\` ${e.date} — ${e.message}`);
818
+ return {
819
+ content: [{
820
+ type: "text",
821
+ text: `History for **${memory.frontmatter.title}** (${history.length} entries):\n\n${lines.join("\n")}\n\nUse gnosys_rollback with a commit hash to revert to a prior version.`,
822
+ }],
823
+ };
824
+ });
825
+ // ─── Tool: gnosys_rollback ──────────────────────────────────────────────
826
+ server.tool("gnosys_rollback", "Rollback a memory to its state at a specific commit. Non-destructive: creates a new commit with the reverted content. Use gnosys_history first to find the target commit hash.", {
827
+ path: z.string().describe("Path to memory, optionally layer-prefixed"),
828
+ commitHash: z.string().describe("Git commit hash to revert to (full or abbreviated)"),
829
+ }, async ({ path: memPath, commitHash }) => {
830
+ const memory = await resolver.readMemory(memPath);
831
+ if (!memory) {
832
+ return { content: [{ type: "text", text: `Memory not found: ${memPath}` }], isError: true };
833
+ }
834
+ const sourceStore = resolver.getStores().find((s) => s.label === memory.sourceLabel);
835
+ if (!sourceStore?.writable) {
836
+ return { content: [{ type: "text", text: "Cannot rollback: store is read-only." }], isError: true };
837
+ }
838
+ const success = rollbackToCommit(sourceStore.path, memory.relativePath, commitHash);
839
+ if (!success) {
840
+ return { content: [{ type: "text", text: `Rollback failed. Verify the commit hash with gnosys_history.` }], isError: true };
841
+ }
842
+ // Reindex after rollback
843
+ if (search)
844
+ await reindexAllStores();
845
+ // Read the reverted memory
846
+ const reverted = await resolver.readMemory(memPath);
847
+ return {
848
+ content: [{
849
+ type: "text",
850
+ text: `Rolled back **${memory.frontmatter.title}** to commit ${commitHash.substring(0, 7)}.\n\nCurrent state: ${reverted?.frontmatter.title} [${reverted?.frontmatter.status}] (confidence: ${reverted?.frontmatter.confidence})`,
851
+ }],
852
+ };
853
+ });
854
+ // ─── Tool: gnosys_lens ──────────────────────────────────────────────────
855
+ server.tool("gnosys_lens", "Filtered view of memories. Combine criteria to focus on specific subsets — e.g., 'active decisions about auth with confidence > 0.8'. Use AND (default) to require all criteria, or OR to match any.", {
856
+ category: z.string().optional().describe("Filter by category"),
857
+ tags: z.array(z.string()).optional().describe("Filter by tags"),
858
+ tagMatchMode: z.enum(["any", "all"]).optional().describe("'any' = has any listed tag (default), 'all' = must have every listed tag"),
859
+ status: z.array(z.enum(["active", "archived", "superseded"])).optional().describe("Filter by status"),
860
+ author: z.array(z.enum(["human", "ai", "human+ai"])).optional().describe("Filter by author"),
861
+ authority: z.array(z.enum(["declared", "observed", "imported", "inferred"])).optional().describe("Filter by authority"),
862
+ minConfidence: z.number().min(0).max(1).optional().describe("Minimum confidence"),
863
+ maxConfidence: z.number().min(0).max(1).optional().describe("Maximum confidence"),
864
+ createdAfter: z.string().optional().describe("Created after ISO date"),
865
+ createdBefore: z.string().optional().describe("Created before ISO date"),
866
+ modifiedAfter: z.string().optional().describe("Modified after ISO date"),
867
+ modifiedBefore: z.string().optional().describe("Modified before ISO date"),
868
+ operator: z.enum(["AND", "OR"]).optional().describe("Compound operator when multiple filter groups are provided (default: AND)"),
869
+ }, async ({ category, tags, tagMatchMode, status, author, authority, minConfidence, maxConfidence, createdAfter, createdBefore, modifiedAfter, modifiedBefore }) => {
870
+ const allMemories = await resolver.getAllMemories();
871
+ const lens = {};
872
+ if (category)
873
+ lens.category = category;
874
+ if (tags) {
875
+ lens.tags = tags;
876
+ lens.tagMatchMode = tagMatchMode || "any";
877
+ }
878
+ if (status)
879
+ lens.status = status;
880
+ if (author)
881
+ lens.author = author;
882
+ if (authority)
883
+ lens.authority = authority;
884
+ if (minConfidence !== undefined)
885
+ lens.minConfidence = minConfidence;
886
+ if (maxConfidence !== undefined)
887
+ lens.maxConfidence = maxConfidence;
888
+ if (createdAfter)
889
+ lens.createdAfter = createdAfter;
890
+ if (createdBefore)
891
+ lens.createdBefore = createdBefore;
892
+ if (modifiedAfter)
893
+ lens.modifiedAfter = modifiedAfter;
894
+ if (modifiedBefore)
895
+ lens.modifiedBefore = modifiedBefore;
896
+ const result = applyLens(allMemories, lens);
897
+ if (result.length === 0) {
898
+ return { content: [{ type: "text", text: "No memories match the lens filter." }] };
899
+ }
900
+ const lines = result.map((m) => `- **${m.frontmatter.title}** [${m.frontmatter.status}] (${m.frontmatter.confidence})\n ${m.sourceLabel ? m.sourceLabel + ":" : ""}${m.relativePath}`);
901
+ return {
902
+ content: [{ type: "text", text: `${result.length} memories match:\n\n${lines.join("\n\n")}` }],
903
+ };
904
+ });
905
+ // ─── Tool: gnosys_timeline ───────────────────────────────────────────────
906
+ server.tool("gnosys_timeline", "View memory creation and modification activity over time. Shows how knowledge evolves by grouping memories into time periods.", {
907
+ period: z.enum(["day", "week", "month", "year"]).optional().describe("Grouping period (default: month)"),
908
+ }, async ({ period }) => {
909
+ const allMemories = await resolver.getAllMemories();
910
+ const entries = groupByPeriod(allMemories, period || "month");
911
+ if (entries.length === 0) {
912
+ return { content: [{ type: "text", text: "No memories found for timeline." }] };
913
+ }
914
+ const lines = entries.map((e) => `**${e.period}** — ${e.created} created, ${e.modified} modified\n ${e.titles.slice(0, 5).join(", ")}${e.titles.length > 5 ? ` (+${e.titles.length - 5} more)` : ""}`);
915
+ return {
916
+ content: [{ type: "text", text: `Knowledge Timeline (by ${period || "month"}):\n\n${lines.join("\n\n")}` }],
917
+ };
918
+ });
919
+ // ─── Tool: gnosys_stats ─────────────────────────────────────────────────
920
+ server.tool("gnosys_stats", "Summary statistics across all memories — totals by category, status, author, authority, average confidence, and date ranges.", {}, async () => {
921
+ const allMemories = await resolver.getAllMemories();
922
+ const stats = computeStats(allMemories);
923
+ if (stats.totalCount === 0) {
924
+ return { content: [{ type: "text", text: "No memories found." }] };
925
+ }
926
+ const catLines = Object.entries(stats.byCategory).map(([k, v]) => ` ${k}: ${v}`).join("\n");
927
+ const statusLines = Object.entries(stats.byStatus).map(([k, v]) => ` ${k}: ${v}`).join("\n");
928
+ const authorLines = Object.entries(stats.byAuthor).map(([k, v]) => ` ${k}: ${v}`).join("\n");
929
+ const authLines = Object.entries(stats.byAuthority).map(([k, v]) => ` ${k}: ${v}`).join("\n");
930
+ const text = `Gnosys Memory Statistics
931
+ Total: ${stats.totalCount} memories
932
+
933
+ By Category:
934
+ ${catLines}
935
+
936
+ By Status:
937
+ ${statusLines}
938
+
939
+ By Author:
940
+ ${authorLines}
941
+
942
+ By Authority:
943
+ ${authLines}
944
+
945
+ Average Confidence: ${stats.averageConfidence.toFixed(2)}
946
+ Oldest: ${stats.oldestCreated || "—"}
947
+ Newest: ${stats.newestCreated || "—"}
948
+ Last Modified: ${stats.lastModified || "—"}`;
949
+ return { content: [{ type: "text", text }] };
950
+ });
951
+ // ─── Tool: gnosys_links ─────────────────────────────────────────────────
952
+ server.tool("gnosys_links", "Show wikilinks for a specific memory — outgoing [[links]] and backlinks from other memories. Obsidian-compatible [[Title]] and [[path|display]] syntax.", {
953
+ path: z.string().describe("Path to memory, optionally layer-prefixed"),
954
+ }, async ({ path: memPath }) => {
955
+ const memory = await resolver.readMemory(memPath);
956
+ if (!memory) {
957
+ return { content: [{ type: "text", text: `Memory not found: ${memPath}` }], isError: true };
958
+ }
959
+ const allMemories = await resolver.getAllMemories();
960
+ const outgoing = getOutgoingLinks(allMemories, memory.relativePath);
961
+ const backlinks = getBacklinks(allMemories, memory.relativePath);
962
+ const parts = [`Links for **${memory.frontmatter.title}**:\n`];
963
+ if (outgoing.length > 0) {
964
+ parts.push(`Outgoing (${outgoing.length}):`);
965
+ for (const link of outgoing) {
966
+ const display = link.displayText ? ` (${link.displayText})` : "";
967
+ parts.push(` → [[${link.target}]]${display}`);
968
+ }
969
+ }
970
+ else {
971
+ parts.push("No outgoing links.");
972
+ }
973
+ parts.push("");
974
+ if (backlinks.length > 0) {
975
+ parts.push(`Backlinks (${backlinks.length}):`);
976
+ for (const link of backlinks) {
977
+ parts.push(` ← ${link.sourceTitle} (${link.sourcePath})`);
978
+ }
979
+ }
980
+ else {
981
+ parts.push("No backlinks.");
982
+ }
983
+ return { content: [{ type: "text", text: parts.join("\n") }] };
984
+ });
985
+ // ─── Tool: gnosys_graph ─────────────────────────────────────────────────
986
+ server.tool("gnosys_graph", "Show the full cross-reference graph across all memories. Reveals clusters, orphaned links, and the most-connected memories.", {}, async () => {
987
+ const allMemories = await resolver.getAllMemories();
988
+ if (allMemories.length === 0) {
989
+ return { content: [{ type: "text", text: "No memories found." }] };
990
+ }
991
+ const graph = buildLinkGraph(allMemories);
992
+ return { content: [{ type: "text", text: formatGraphSummary(graph) }] };
993
+ });
994
+ // ─── Tool: gnosys_bootstrap ─────────────────────────────────────────────
995
+ server.tool("gnosys_bootstrap", "Batch-import existing documents from a directory into the memory store. Scans for markdown files and creates memories. Use dry_run=true to preview.", {
996
+ sourceDir: z.string().describe("Absolute path to directory containing documents to import"),
997
+ patterns: z.array(z.string()).optional().describe("File glob patterns (default: ['**/*.md'])"),
998
+ skipExisting: z.boolean().optional().describe("Skip files whose titles already exist (default: false)"),
999
+ defaultCategory: z.string().optional().describe("Default category for imported files (default: imported)"),
1000
+ preserveFrontmatter: z.boolean().optional().describe("Preserve existing YAML frontmatter if present (default: false)"),
1001
+ dryRun: z.boolean().optional().describe("Preview what would be imported without writing (default: false)"),
1002
+ store: z.enum(["project", "personal", "global"]).optional().describe("Target store"),
1003
+ }, async ({ sourceDir, patterns, skipExisting, defaultCategory, preserveFrontmatter, dryRun, store: targetStore }) => {
1004
+ const writeTarget = resolver.getWriteTarget(targetStore || undefined);
1005
+ if (!writeTarget) {
1006
+ return { content: [{ type: "text", text: "No writable store found." }], isError: true };
1007
+ }
1008
+ try {
1009
+ const result = await bootstrap(writeTarget.store, {
1010
+ sourceDir,
1011
+ patterns,
1012
+ skipExisting,
1013
+ defaultCategory,
1014
+ preserveFrontmatter,
1015
+ dryRun,
1016
+ });
1017
+ const mode = dryRun ? "DRY RUN" : "COMPLETE";
1018
+ const parts = [
1019
+ `Bootstrap ${mode}: ${result.totalScanned} scanned, ${result.imported.length} ${dryRun ? "would be" : ""} imported, ${result.skipped.length} skipped, ${result.failed.length} failed`,
1020
+ ];
1021
+ if (result.imported.length > 0) {
1022
+ parts.push(`\n${dryRun ? "Would import" : "Imported"}:`);
1023
+ for (const f of result.imported.slice(0, 20)) {
1024
+ parts.push(` + ${f}`);
1025
+ }
1026
+ if (result.imported.length > 20) {
1027
+ parts.push(` ... and ${result.imported.length - 20} more`);
1028
+ }
1029
+ }
1030
+ if (result.failed.length > 0) {
1031
+ parts.push("\nFailed:");
1032
+ for (const f of result.failed.slice(0, 10)) {
1033
+ parts.push(` ✗ ${f.path}: ${f.error}`);
1034
+ }
1035
+ }
1036
+ // Reindex after import
1037
+ if (!dryRun && result.imported.length > 0 && search) {
1038
+ await reindexAllStores();
1039
+ }
1040
+ return { content: [{ type: "text", text: parts.join("\n") }] };
1041
+ }
1042
+ catch (err) {
1043
+ return {
1044
+ content: [{ type: "text", text: `Bootstrap failed: ${err instanceof Error ? err.message : String(err)}` }],
1045
+ isError: true,
1046
+ };
1047
+ }
1048
+ });
1049
+ // ─── Tool: gnosys_stores ─────────────────────────────────────────────────
1050
+ server.tool("gnosys_stores", "Show all active Gnosys stores — their layers, paths, and write permissions.", {}, async () => {
1051
+ const summary = resolver.getSummary();
1052
+ return { content: [{ type: "text", text: summary }] };
1053
+ });
1054
+ // ─── Helper: reindex search across all stores ────────────────────────────
1055
+ async function reindexAllStores() {
1056
+ if (!search)
1057
+ return;
1058
+ search.clearIndex();
1059
+ const allStores = resolver.getStores();
1060
+ for (const s of allStores) {
1061
+ await search.addStoreMemories(s.store, s.label);
1062
+ }
1063
+ }
1064
+ // ─── Start the server ────────────────────────────────────────────────────
1065
+ async function main() {
1066
+ // Discover and initialize all layered stores
1067
+ const stores = await resolver.resolve();
1068
+ if (stores.length === 0) {
1069
+ console.error("Warning: No Gnosys stores found. Create a .gnosys/ directory or set GNOSYS_PERSONAL / GNOSYS_GLOBAL.");
1070
+ }
1071
+ console.error("Gnosys MCP server starting.");
1072
+ console.error("Active stores:");
1073
+ console.error(resolver.getSummary());
1074
+ // Initialize search from the first writable store
1075
+ const writeTarget = resolver.getWriteTarget();
1076
+ if (writeTarget) {
1077
+ search = new GnosysSearch(writeTarget.store.getStorePath());
1078
+ tagRegistry = new GnosysTagRegistry(writeTarget.store.getStorePath());
1079
+ await tagRegistry.load();
1080
+ ingestion = new GnosysIngestion(writeTarget.store, tagRegistry);
1081
+ // Build search index across all stores
1082
+ await reindexAllStores();
1083
+ console.error(`LLM ingestion: ${ingestion.isLLMAvailable ? "enabled" : "disabled (set ANTHROPIC_API_KEY)"}`);
1084
+ }
1085
+ const transport = new StdioServerTransport();
1086
+ await server.connect(transport);
1087
+ }
1088
+ main().catch((err) => {
1089
+ console.error("Fatal error:", err);
1090
+ process.exit(1);
1091
+ });
1092
+ //# sourceMappingURL=index.js.map