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.
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +927 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1092 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/bootstrap.d.ts +56 -0
- package/dist/lib/bootstrap.d.ts.map +1 -0
- package/dist/lib/bootstrap.js +163 -0
- package/dist/lib/bootstrap.js.map +1 -0
- package/dist/lib/history.d.ts +39 -0
- package/dist/lib/history.d.ts.map +1 -0
- package/dist/lib/history.js +86 -0
- package/dist/lib/history.js.map +1 -0
- package/dist/lib/ingest.d.ts +47 -0
- package/dist/lib/ingest.d.ts.map +1 -0
- package/dist/lib/ingest.js +134 -0
- package/dist/lib/ingest.js.map +1 -0
- package/dist/lib/lensing.d.ts +35 -0
- package/dist/lib/lensing.d.ts.map +1 -0
- package/dist/lib/lensing.js +85 -0
- package/dist/lib/lensing.js.map +1 -0
- package/dist/lib/resolver.d.ts +84 -0
- package/dist/lib/resolver.d.ts.map +1 -0
- package/dist/lib/resolver.js +269 -0
- package/dist/lib/resolver.js.map +1 -0
- package/dist/lib/search.d.ts +50 -0
- package/dist/lib/search.d.ts.map +1 -0
- package/dist/lib/search.js +166 -0
- package/dist/lib/search.js.map +1 -0
- package/dist/lib/store.d.ts +67 -0
- package/dist/lib/store.d.ts.map +1 -0
- package/dist/lib/store.js +168 -0
- package/dist/lib/store.js.map +1 -0
- package/dist/lib/tags.d.ts +26 -0
- package/dist/lib/tags.d.ts.map +1 -0
- package/dist/lib/tags.js +109 -0
- package/dist/lib/tags.js.map +1 -0
- package/dist/lib/timeline.d.ts +34 -0
- package/dist/lib/timeline.d.ts.map +1 -0
- package/dist/lib/timeline.js +116 -0
- package/dist/lib/timeline.js.map +1 -0
- package/dist/lib/wikilinks.d.ts +63 -0
- package/dist/lib/wikilinks.d.ts.map +1 -0
- package/dist/lib/wikilinks.js +146 -0
- package/dist/lib/wikilinks.js.map +1 -0
- package/dist/test/bootstrap.test.d.ts +2 -0
- package/dist/test/bootstrap.test.d.ts.map +1 -0
- package/dist/test/bootstrap.test.js +208 -0
- package/dist/test/bootstrap.test.js.map +1 -0
- package/dist/test/history.test.d.ts +2 -0
- package/dist/test/history.test.d.ts.map +1 -0
- package/dist/test/history.test.js +141 -0
- package/dist/test/history.test.js.map +1 -0
- package/dist/test/ingest.test.d.ts +2 -0
- package/dist/test/ingest.test.d.ts.map +1 -0
- package/dist/test/ingest.test.js +132 -0
- package/dist/test/ingest.test.js.map +1 -0
- package/dist/test/lensing.test.d.ts +2 -0
- package/dist/test/lensing.test.d.ts.map +1 -0
- package/dist/test/lensing.test.js +154 -0
- package/dist/test/lensing.test.js.map +1 -0
- package/dist/test/search.test.d.ts +2 -0
- package/dist/test/search.test.d.ts.map +1 -0
- package/dist/test/search.test.js +129 -0
- package/dist/test/search.test.js.map +1 -0
- package/dist/test/store.test.d.ts +2 -0
- package/dist/test/store.test.d.ts.map +1 -0
- package/dist/test/store.test.js +152 -0
- package/dist/test/store.test.js.map +1 -0
- package/dist/test/tags.test.d.ts +2 -0
- package/dist/test/tags.test.d.ts.map +1 -0
- package/dist/test/tags.test.js +81 -0
- package/dist/test/tags.test.js.map +1 -0
- package/dist/test/timeline.test.d.ts +2 -0
- package/dist/test/timeline.test.d.ts.map +1 -0
- package/dist/test/timeline.test.js +127 -0
- package/dist/test/timeline.test.js.map +1 -0
- package/dist/test/wikilinks.test.d.ts +2 -0
- package/dist/test/wikilinks.test.d.ts.map +1 -0
- package/dist/test/wikilinks.test.js +178 -0
- package/dist/test/wikilinks.test.js.map +1 -0
- 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
|