@theglitchking/semantic-memory 1.0.0 → 1.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.
@@ -1,14 +1,19 @@
1
1
  import {
2
2
  FrontmatterManager,
3
3
  NoteCrud,
4
+ SessionManager,
4
5
  TagManager,
5
6
  Watcher,
6
7
  applyPatch,
7
8
  buildIngestChangeSet,
9
+ buildPromoteChangeSet,
8
10
  buildSynthesizeChangeSet,
11
+ deriveSessionDir,
12
+ inspectAgentsContract,
9
13
  regenDirectoryIndex,
10
- regenIndexesForPaths
11
- } from "../chunk-CXTRTCIK.js";
14
+ regenIndexesForPaths,
15
+ regenerateAgentsContract
16
+ } from "../chunk-XBAN7GRT.js";
12
17
  import {
13
18
  Embedder,
14
19
  GraphBuilder,
@@ -36,6 +41,217 @@ import {
36
41
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
37
42
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
38
43
 
44
+ // src/mcp/context.ts
45
+ import { mkdir, readFile, writeFile } from "fs/promises";
46
+ import { join } from "path";
47
+ import { existsSync } from "fs";
48
+ async function buildContext(notesPath, options = {}) {
49
+ const indexPath = join(notesPath, ".semantic-sidekick-index");
50
+ await mkdir(indexPath, { recursive: true });
51
+ const indexer = new Indexer(notesPath);
52
+ const embedder = new Embedder(options.model, options.workers, options.batchSize, options.quantized);
53
+ const graph = new GraphBuilder();
54
+ const textSearch = new TextSearch();
55
+ const crud = new NoteCrud(notesPath);
56
+ const frontmatterManager = new FrontmatterManager(notesPath);
57
+ const tagManager = new TagManager(notesPath);
58
+ const sessions = new SessionManager(deriveSessionDir(notesPath));
59
+ let documents = [];
60
+ let docByPath = /* @__PURE__ */ new Map();
61
+ let vectorIndex = null;
62
+ let indexState = "empty";
63
+ let indexProgress = { embedded: 0, total: 0 };
64
+ let indexingPromise = null;
65
+ async function tryLoadCachedIndex() {
66
+ try {
67
+ indexState = "loading";
68
+ await embedder.init();
69
+ const metaPath = join(indexPath, "meta.json");
70
+ let meta = null;
71
+ if (existsSync(metaPath)) {
72
+ try {
73
+ meta = JSON.parse(await readFile(metaPath, "utf-8"));
74
+ if (meta?.model && meta.model !== embedder.getModel()) {
75
+ process.stderr.write(`Model changed (${meta.model} \u2192 ${embedder.getModel()}), forcing reindex
76
+ `);
77
+ return false;
78
+ }
79
+ } catch {
80
+ meta = null;
81
+ }
82
+ }
83
+ const tempVector = new VectorIndex(embedder.getDimensions());
84
+ const vectorLoaded = await tempVector.load(indexPath);
85
+ if (!vectorLoaded) return false;
86
+ const savedEmbeddings = await embedder.loadEmbeddings(indexPath);
87
+ if (savedEmbeddings.size === 0) return false;
88
+ const graphLoaded = await graph.load(indexPath);
89
+ if (!graphLoaded) return false;
90
+ documents = await indexer.indexAll();
91
+ docByPath = new Map(documents.map((d) => [d.path, d]));
92
+ textSearch.setDocuments(documents);
93
+ vectorIndex = tempVector;
94
+ const currentChunks = documents.reduce((n, d) => n + d.chunks.length, 0);
95
+ const fresh = meta?.totalChunks !== void 0 && meta.totalChunks === currentChunks;
96
+ indexState = fresh ? "ready" : "stale";
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+ async function fullIndex() {
103
+ indexState = "indexing";
104
+ indexProgress = { embedded: 0, total: 0 };
105
+ await embedder.init();
106
+ const newDocs = await indexer.indexAll();
107
+ const totalChunks = newDocs.reduce((n, d) => n + d.chunks.length, 0);
108
+ indexProgress = { embedded: 0, total: totalChunks };
109
+ const newTextSearch = new TextSearch();
110
+ newTextSearch.setDocuments(newDocs);
111
+ const newGraph = new GraphBuilder();
112
+ newGraph.buildFromDocuments(newDocs);
113
+ const savedEmbeddings = await embedder.loadEmbeddings(indexPath);
114
+ const SAVE_INTERVAL = 100;
115
+ const allChunks = [];
116
+ const cumulativeEmbeddings = new Map(savedEmbeddings);
117
+ const pendingChunks = [];
118
+ for (const doc of newDocs) {
119
+ for (let i = 0; i < doc.chunks.length; i++) {
120
+ const key = `${doc.path}:${i}`;
121
+ const existing = cumulativeEmbeddings.get(key);
122
+ if (existing) {
123
+ allChunks.push({ embedding: existing, docPath: doc.path, chunkIndex: i, text: doc.chunks[i] });
124
+ } else {
125
+ pendingChunks.push({ text: doc.chunks[i], docPath: doc.path, chunkIndex: i });
126
+ }
127
+ }
128
+ }
129
+ indexProgress = { embedded: allChunks.length, total: totalChunks };
130
+ if (pendingChunks.length > 0) {
131
+ let chunksSinceLastSave = 0;
132
+ const alreadyEmbedded = allChunks.length;
133
+ await embedder.embedBatch(
134
+ pendingChunks.map((c) => c.text),
135
+ async (done, _total, embeddings) => {
136
+ if (!embeddings) return;
137
+ const batchStart = done - embeddings.length;
138
+ for (let i = 0; i < embeddings.length; i++) {
139
+ const { docPath, chunkIndex, text } = pendingChunks[batchStart + i];
140
+ const key = `${docPath}:${chunkIndex}`;
141
+ cumulativeEmbeddings.set(key, embeddings[i]);
142
+ allChunks.push({ embedding: embeddings[i], docPath, chunkIndex, text });
143
+ chunksSinceLastSave++;
144
+ }
145
+ const current = alreadyEmbedded + done;
146
+ indexProgress = { embedded: current, total: totalChunks };
147
+ options.onProgress?.(current, totalChunks);
148
+ if (chunksSinceLastSave >= SAVE_INTERVAL) {
149
+ await embedder.saveEmbeddings(cumulativeEmbeddings, indexPath);
150
+ chunksSinceLastSave = 0;
151
+ }
152
+ }
153
+ );
154
+ }
155
+ const newVector = new VectorIndex(embedder.getDimensions());
156
+ newVector.build(
157
+ allChunks.map((c) => c.embedding),
158
+ allChunks.map((c) => ({ docPath: c.docPath, chunkIndex: c.chunkIndex, text: c.text }))
159
+ );
160
+ documents = newDocs;
161
+ docByPath = new Map(documents.map((d) => [d.path, d]));
162
+ textSearch.setDocuments(newDocs);
163
+ graph.buildFromDocuments(newDocs);
164
+ vectorIndex = newVector;
165
+ indexState = "ready";
166
+ await Promise.all([
167
+ graph.save(indexPath),
168
+ newVector.save(indexPath),
169
+ embedder.saveEmbeddings(cumulativeEmbeddings, indexPath),
170
+ indexer.saveDocsCache(indexPath, newDocs),
171
+ writeFile(join(indexPath, "meta.json"), JSON.stringify({
172
+ model: embedder.getModel(),
173
+ dimensions: embedder.getDimensions(),
174
+ totalChunks: allChunks.length,
175
+ indexedAt: (/* @__PURE__ */ new Date()).toISOString()
176
+ }))
177
+ ]);
178
+ }
179
+ function backgroundIndex() {
180
+ if (indexingPromise) return;
181
+ indexingPromise = fullIndex().catch((err) => {
182
+ process.stderr.write(`Index error: ${err?.message ?? err}
183
+ `);
184
+ if (indexState === "indexing") indexState = documents.length > 0 ? "stale" : "empty";
185
+ }).finally(() => {
186
+ indexingPromise = null;
187
+ });
188
+ }
189
+ function textResponse(text) {
190
+ return { content: [{ type: "text", text }] };
191
+ }
192
+ function indexingMessage() {
193
+ if (indexProgress.total > 0) {
194
+ return `Indexing in progress (${indexProgress.embedded}/${indexProgress.total} chunks)... Try again shortly.`;
195
+ }
196
+ return "Indexing in progress... Try again shortly.";
197
+ }
198
+ function enrichResult(result) {
199
+ const doc = docByPath.get(result.path);
200
+ if (!doc) return result;
201
+ return {
202
+ ...result,
203
+ mtime: doc.mtime,
204
+ ...doc.loadPriority !== void 0 && { loadPriority: doc.loadPriority },
205
+ ...doc.status !== void 0 && { status: doc.status },
206
+ ...doc.tier !== void 0 && { tier: doc.tier },
207
+ ...doc.domains !== void 0 && { domains: doc.domains }
208
+ };
209
+ }
210
+ function applyPriorityBoost(score, path) {
211
+ const doc = docByPath.get(path);
212
+ if (doc?.loadPriority === void 0) return score;
213
+ return score * (1 + (doc.loadPriority - 5) * 0.04);
214
+ }
215
+ function applyDateFilter(results, modifiedAfter, modifiedBefore) {
216
+ if (!modifiedAfter && !modifiedBefore) return results;
217
+ const after = modifiedAfter ? new Date(modifiedAfter).getTime() : -Infinity;
218
+ const before = modifiedBefore ? new Date(modifiedBefore).getTime() : Infinity;
219
+ return results.filter((r) => {
220
+ const doc = docByPath.get(r.path);
221
+ if (!doc) return true;
222
+ const t = new Date(doc.mtime).getTime();
223
+ return t >= after && t <= before;
224
+ });
225
+ }
226
+ return {
227
+ notesPath,
228
+ indexPath,
229
+ options,
230
+ indexer,
231
+ embedder,
232
+ graph,
233
+ textSearch,
234
+ crud,
235
+ frontmatterManager,
236
+ tagManager,
237
+ sessions,
238
+ getDocuments: () => documents,
239
+ getDocByPath: () => docByPath,
240
+ getVectorIndex: () => vectorIndex,
241
+ getIndexState: () => indexState,
242
+ getIndexProgress: () => indexProgress,
243
+ tryLoadCachedIndex,
244
+ fullIndex,
245
+ backgroundIndex,
246
+ isIndexingInFlight: () => indexingPromise !== null,
247
+ textResponse,
248
+ indexingMessage,
249
+ enrichResult,
250
+ applyPriorityBoost,
251
+ applyDateFilter
252
+ };
253
+ }
254
+
39
255
  // node_modules/zod/v3/external.js
40
256
  var external_exports = {};
41
257
  __export(external_exports, {
@@ -4077,215 +4293,38 @@ var coerce = {
4077
4293
  };
4078
4294
  var NEVER = INVALID;
4079
4295
 
4080
- // src/mcp/server.ts
4081
- import { mkdir, readFile, writeFile } from "fs/promises";
4082
- import { join } from "path";
4083
- import { existsSync } from "fs";
4084
- async function createServer(notesPath, options = {}) {
4085
- const indexPath = join(notesPath, ".semantic-sidekick-index");
4086
- await mkdir(indexPath, { recursive: true });
4087
- const indexer = new Indexer(notesPath);
4088
- const embedder = new Embedder(options.model, options.workers, options.batchSize, options.quantized);
4089
- const graph = new GraphBuilder();
4090
- const textSearch = new TextSearch();
4091
- const crud = new NoteCrud(notesPath);
4092
- const frontmatterManager = new FrontmatterManager(notesPath);
4093
- const tagManager = new TagManager(notesPath);
4094
- let documents = [];
4095
- let docByPath = /* @__PURE__ */ new Map();
4096
- let vectorIndex = null;
4097
- let indexState = "empty";
4098
- let indexProgress = { embedded: 0, total: 0 };
4099
- let indexingPromise = null;
4100
- async function tryLoadCachedIndex() {
4101
- try {
4102
- indexState = "loading";
4103
- await embedder.init();
4104
- const metaPath = join(indexPath, "meta.json");
4105
- let meta = null;
4106
- if (existsSync(metaPath)) {
4107
- try {
4108
- meta = JSON.parse(await readFile(metaPath, "utf-8"));
4109
- if (meta?.model && meta.model !== embedder.getModel()) {
4110
- process.stderr.write(`Model changed (${meta.model} \u2192 ${embedder.getModel()}), forcing reindex
4111
- `);
4112
- return false;
4113
- }
4114
- } catch {
4115
- meta = null;
4116
- }
4296
+ // src/mcp/tools/search.ts
4297
+ function registerSearchTools(server, ctx) {
4298
+ server.tool(
4299
+ "search_semantic",
4300
+ "Vector similarity search \u2014 find notes similar to a query by meaning. Scores are boosted by load_priority when present.",
4301
+ {
4302
+ query: external_exports.string(),
4303
+ limit: external_exports.coerce.number().optional().default(10),
4304
+ modifiedAfter: external_exports.string().optional().describe("ISO date \u2014 only return notes modified after this date (e.g. '2026-01-01')"),
4305
+ modifiedBefore: external_exports.string().optional().describe("ISO date \u2014 only return notes modified before this date"),
4306
+ status: external_exports.string().optional().describe("Filter by frontmatter status (e.g. 'active', 'draft')"),
4307
+ tier: external_exports.string().optional().describe("Filter by frontmatter tier (e.g. 'guide', 'reference')"),
4308
+ domain: external_exports.string().optional().describe("Filter by frontmatter domain (e.g. 'api', 'security')")
4309
+ },
4310
+ async ({ query, limit, modifiedAfter, modifiedBefore, status, tier, domain }) => {
4311
+ const vectorIndex = ctx.getVectorIndex();
4312
+ if (!vectorIndex) {
4313
+ return ctx.textResponse(
4314
+ ctx.getIndexState() === "empty" ? ctx.indexingMessage() : "Index not built. Run reindex first."
4315
+ );
4117
4316
  }
4118
- const tempVector = new VectorIndex(embedder.getDimensions());
4119
- const vectorLoaded = await tempVector.load(indexPath);
4120
- if (!vectorLoaded) return false;
4121
- const savedEmbeddings = await embedder.loadEmbeddings(indexPath);
4122
- if (savedEmbeddings.size === 0) return false;
4123
- const graphLoaded = await graph.load(indexPath);
4124
- if (!graphLoaded) return false;
4125
- documents = await indexer.indexAll();
4126
- docByPath = new Map(documents.map((d) => [d.path, d]));
4127
- textSearch.setDocuments(documents);
4128
- vectorIndex = tempVector;
4129
- const currentChunks = documents.reduce((n, d) => n + d.chunks.length, 0);
4130
- const fresh = meta?.totalChunks !== void 0 && meta.totalChunks === currentChunks;
4131
- indexState = fresh ? "ready" : "stale";
4132
- return true;
4133
- } catch {
4134
- return false;
4135
- }
4136
- }
4137
- async function fullIndex() {
4138
- indexState = "indexing";
4139
- indexProgress = { embedded: 0, total: 0 };
4140
- await embedder.init();
4141
- const newDocs = await indexer.indexAll();
4142
- const totalChunks = newDocs.reduce((n, d) => n + d.chunks.length, 0);
4143
- indexProgress = { embedded: 0, total: totalChunks };
4144
- const newTextSearch = new TextSearch();
4145
- newTextSearch.setDocuments(newDocs);
4146
- const newGraph = new GraphBuilder();
4147
- newGraph.buildFromDocuments(newDocs);
4148
- const savedEmbeddings = await embedder.loadEmbeddings(indexPath);
4149
- const SAVE_INTERVAL = 100;
4150
- const allChunks = [];
4151
- const cumulativeEmbeddings = new Map(savedEmbeddings);
4152
- const pendingChunks = [];
4153
- for (const doc of newDocs) {
4154
- for (let i = 0; i < doc.chunks.length; i++) {
4155
- const key = `${doc.path}:${i}`;
4156
- const existing = cumulativeEmbeddings.get(key);
4157
- if (existing) {
4158
- allChunks.push({ embedding: existing, docPath: doc.path, chunkIndex: i, text: doc.chunks[i] });
4159
- } else {
4160
- pendingChunks.push({ text: doc.chunks[i], docPath: doc.path, chunkIndex: i });
4161
- }
4162
- }
4163
- }
4164
- indexProgress = { embedded: allChunks.length, total: totalChunks };
4165
- if (pendingChunks.length > 0) {
4166
- let chunksSinceLastSave = 0;
4167
- const alreadyEmbedded = allChunks.length;
4168
- await embedder.embedBatch(
4169
- pendingChunks.map((c) => c.text),
4170
- async (done, _total, embeddings) => {
4171
- if (!embeddings) return;
4172
- const batchStart = done - embeddings.length;
4173
- for (let i = 0; i < embeddings.length; i++) {
4174
- const { docPath, chunkIndex, text } = pendingChunks[batchStart + i];
4175
- const key = `${docPath}:${chunkIndex}`;
4176
- cumulativeEmbeddings.set(key, embeddings[i]);
4177
- allChunks.push({ embedding: embeddings[i], docPath, chunkIndex, text });
4178
- chunksSinceLastSave++;
4179
- }
4180
- const current = alreadyEmbedded + done;
4181
- indexProgress = { embedded: current, total: totalChunks };
4182
- options.onProgress?.(current, totalChunks);
4183
- if (chunksSinceLastSave >= SAVE_INTERVAL) {
4184
- await embedder.saveEmbeddings(cumulativeEmbeddings, indexPath);
4185
- chunksSinceLastSave = 0;
4186
- }
4187
- }
4188
- );
4189
- }
4190
- const newVector = new VectorIndex(embedder.getDimensions());
4191
- newVector.build(
4192
- allChunks.map((c) => c.embedding),
4193
- allChunks.map((c) => ({ docPath: c.docPath, chunkIndex: c.chunkIndex, text: c.text }))
4194
- );
4195
- documents = newDocs;
4196
- docByPath = new Map(documents.map((d) => [d.path, d]));
4197
- textSearch.setDocuments(newDocs);
4198
- graph.buildFromDocuments(newDocs);
4199
- vectorIndex = newVector;
4200
- indexState = "ready";
4201
- await Promise.all([
4202
- graph.save(indexPath),
4203
- newVector.save(indexPath),
4204
- embedder.saveEmbeddings(cumulativeEmbeddings, indexPath),
4205
- indexer.saveDocsCache(indexPath, newDocs),
4206
- writeFile(join(indexPath, "meta.json"), JSON.stringify({
4207
- model: embedder.getModel(),
4208
- dimensions: embedder.getDimensions(),
4209
- totalChunks: allChunks.length,
4210
- indexedAt: (/* @__PURE__ */ new Date()).toISOString()
4211
- }))
4212
- ]);
4213
- }
4214
- function backgroundIndex() {
4215
- if (indexingPromise) return;
4216
- indexingPromise = fullIndex().catch((err) => {
4217
- process.stderr.write(`Index error: ${err?.message ?? err}
4218
- `);
4219
- if (indexState === "indexing") indexState = documents.length > 0 ? "stale" : "empty";
4220
- }).finally(() => {
4221
- indexingPromise = null;
4222
- });
4223
- }
4224
- function textResponse(text) {
4225
- return { content: [{ type: "text", text }] };
4226
- }
4227
- function indexingMessage() {
4228
- if (indexProgress.total > 0) {
4229
- return `Indexing in progress (${indexProgress.embedded}/${indexProgress.total} chunks)... Try again shortly.`;
4230
- }
4231
- return "Indexing in progress... Try again shortly.";
4232
- }
4233
- function enrichResult(result) {
4234
- const doc = docByPath.get(result.path);
4235
- if (!doc) return result;
4236
- return {
4237
- ...result,
4238
- mtime: doc.mtime,
4239
- ...doc.loadPriority !== void 0 && { loadPriority: doc.loadPriority },
4240
- ...doc.status !== void 0 && { status: doc.status },
4241
- ...doc.tier !== void 0 && { tier: doc.tier },
4242
- ...doc.domains !== void 0 && { domains: doc.domains }
4243
- };
4244
- }
4245
- function applyPriorityBoost(score, path) {
4246
- const doc = docByPath.get(path);
4247
- if (doc?.loadPriority === void 0) return score;
4248
- return score * (1 + (doc.loadPriority - 5) * 0.04);
4249
- }
4250
- function applyDateFilter(results, modifiedAfter, modifiedBefore) {
4251
- if (!modifiedAfter && !modifiedBefore) return results;
4252
- const after = modifiedAfter ? new Date(modifiedAfter).getTime() : -Infinity;
4253
- const before = modifiedBefore ? new Date(modifiedBefore).getTime() : Infinity;
4254
- return results.filter((r) => {
4255
- const doc = docByPath.get(r.path);
4256
- if (!doc) return true;
4257
- const t = new Date(doc.mtime).getTime();
4258
- return t >= after && t <= before;
4259
- });
4260
- }
4261
- const server = new McpServer({
4262
- name: "semantic-memory",
4263
- version: "1.0.0"
4264
- });
4265
- server.tool(
4266
- "search_semantic",
4267
- "Vector similarity search \u2014 find notes similar to a query by meaning. Scores are boosted by load_priority when present.",
4268
- {
4269
- query: external_exports.string(),
4270
- limit: external_exports.coerce.number().optional().default(10),
4271
- modifiedAfter: external_exports.string().optional().describe("ISO date \u2014 only return notes modified after this date (e.g. '2026-01-01')"),
4272
- modifiedBefore: external_exports.string().optional().describe("ISO date \u2014 only return notes modified before this date"),
4273
- status: external_exports.string().optional().describe("Filter by frontmatter status (e.g. 'active', 'draft')"),
4274
- tier: external_exports.string().optional().describe("Filter by frontmatter tier (e.g. 'guide', 'reference')"),
4275
- domain: external_exports.string().optional().describe("Filter by frontmatter domain (e.g. 'api', 'security')")
4276
- },
4277
- async ({ query, limit, modifiedAfter, modifiedBefore, status, tier, domain }) => {
4278
- if (!vectorIndex) return textResponse(indexState === "empty" ? indexingMessage() : "Index not built. Run reindex first.");
4279
- const queryEmbed = await embedder.embed(query);
4280
- let results = vectorIndex.search(queryEmbed, limit * 3);
4281
- results = results.map((r) => ({ ...r, score: applyPriorityBoost(r.score, r.path) }));
4282
- results.sort((a, b) => b.score - a.score);
4283
- results = applyDateFilter(results, modifiedAfter, modifiedBefore);
4284
- if (status) results = results.filter((r) => docByPath.get(r.path)?.status === status);
4285
- if (tier) results = results.filter((r) => docByPath.get(r.path)?.tier === tier);
4286
- if (domain) results = results.filter((r) => docByPath.get(r.path)?.domains?.includes(domain));
4287
- const enriched = results.slice(0, limit).map(enrichResult);
4288
- return textResponse(JSON.stringify(enriched, null, 2));
4317
+ const queryEmbed = await ctx.embedder.embed(query);
4318
+ let results = vectorIndex.search(queryEmbed, limit * 3);
4319
+ results = results.map((r) => ({ ...r, score: ctx.applyPriorityBoost(r.score, r.path) }));
4320
+ results.sort((a, b) => b.score - a.score);
4321
+ results = ctx.applyDateFilter(results, modifiedAfter, modifiedBefore);
4322
+ const docByPath = ctx.getDocByPath();
4323
+ if (status) results = results.filter((r) => docByPath.get(r.path)?.status === status);
4324
+ if (tier) results = results.filter((r) => docByPath.get(r.path)?.tier === tier);
4325
+ if (domain) results = results.filter((r) => docByPath.get(r.path)?.domains?.includes(domain));
4326
+ const enriched = results.slice(0, limit).map((r) => ctx.enrichResult(r));
4327
+ return ctx.textResponse(JSON.stringify(enriched, null, 2));
4289
4328
  }
4290
4329
  );
4291
4330
  server.tool(
@@ -4305,14 +4344,16 @@ async function createServer(notesPath, options = {}) {
4305
4344
  domain: external_exports.string().optional().describe("Filter by frontmatter domain")
4306
4345
  },
4307
4346
  async ({ modifiedAfter, modifiedBefore, status, tier, domain, ...opts }) => {
4308
- if (documents.length === 0 && indexState !== "ready") return textResponse(indexingMessage());
4309
- let results = textSearch.search(opts);
4310
- results = applyDateFilter(results, modifiedAfter, modifiedBefore);
4347
+ const documents = ctx.getDocuments();
4348
+ if (documents.length === 0 && ctx.getIndexState() !== "ready") return ctx.textResponse(ctx.indexingMessage());
4349
+ let results = ctx.textSearch.search(opts);
4350
+ results = ctx.applyDateFilter(results, modifiedAfter, modifiedBefore);
4351
+ const docByPath = ctx.getDocByPath();
4311
4352
  if (status) results = results.filter((r) => docByPath.get(r.path)?.status === status);
4312
4353
  if (tier) results = results.filter((r) => docByPath.get(r.path)?.tier === tier);
4313
4354
  if (domain) results = results.filter((r) => docByPath.get(r.path)?.domains?.includes(domain));
4314
- const enriched = results.map(enrichResult);
4315
- return textResponse(JSON.stringify(enriched, null, 2));
4355
+ const enriched = results.map((r) => ctx.enrichResult(r));
4356
+ return ctx.textResponse(JSON.stringify(enriched, null, 2));
4316
4357
  }
4317
4358
  );
4318
4359
  server.tool(
@@ -4324,9 +4365,10 @@ async function createServer(notesPath, options = {}) {
4324
4365
  limit: external_exports.coerce.number().optional().default(20)
4325
4366
  },
4326
4367
  async ({ concept, maxDepth, limit }) => {
4327
- if (documents.length === 0 && indexState !== "ready") return textResponse(indexingMessage());
4328
- const results = graph.searchGraph(concept, maxDepth, limit);
4329
- return textResponse(JSON.stringify(results, null, 2));
4368
+ const documents = ctx.getDocuments();
4369
+ if (documents.length === 0 && ctx.getIndexState() !== "ready") return ctx.textResponse(ctx.indexingMessage());
4370
+ const results = ctx.graph.searchGraph(concept, maxDepth, limit);
4371
+ return ctx.textResponse(JSON.stringify(results, null, 2));
4330
4372
  }
4331
4373
  );
4332
4374
  server.tool(
@@ -4342,45 +4384,55 @@ async function createServer(notesPath, options = {}) {
4342
4384
  domain: external_exports.string().optional().describe("Filter by frontmatter domain")
4343
4385
  },
4344
4386
  async ({ query, limit, modifiedAfter, modifiedBefore, status, tier, domain }) => {
4345
- if (!vectorIndex) return textResponse(indexState === "empty" ? indexingMessage() : "Index not built. Run reindex first.");
4346
- const queryEmbed = await embedder.embed(query);
4387
+ const vectorIndex = ctx.getVectorIndex();
4388
+ if (!vectorIndex) {
4389
+ return ctx.textResponse(
4390
+ ctx.getIndexState() === "empty" ? ctx.indexingMessage() : "Index not built. Run reindex first."
4391
+ );
4392
+ }
4393
+ const queryEmbed = await ctx.embedder.embed(query);
4347
4394
  const semanticResults = vectorIndex.search(queryEmbed, limit * 3);
4348
- const graphResults = graph.searchGraph(query, 2);
4395
+ const graphResults = ctx.graph.searchGraph(query, 2);
4349
4396
  const graphPaths = new Set(graphResults.map((r) => r.path));
4350
4397
  let hybrid = semanticResults.map((r) => ({
4351
4398
  ...r,
4352
- score: applyPriorityBoost(
4399
+ score: ctx.applyPriorityBoost(
4353
4400
  graphPaths.has(r.path) ? r.score * 1.3 : r.score,
4354
4401
  r.path
4355
4402
  )
4356
4403
  }));
4357
4404
  hybrid.sort((a, b) => b.score - a.score);
4358
- hybrid = applyDateFilter(hybrid, modifiedAfter, modifiedBefore);
4405
+ hybrid = ctx.applyDateFilter(hybrid, modifiedAfter, modifiedBefore);
4406
+ const docByPath = ctx.getDocByPath();
4359
4407
  if (status) hybrid = hybrid.filter((r) => docByPath.get(r.path)?.status === status);
4360
4408
  if (tier) hybrid = hybrid.filter((r) => docByPath.get(r.path)?.tier === tier);
4361
4409
  if (domain) hybrid = hybrid.filter((r) => docByPath.get(r.path)?.domains?.includes(domain));
4362
- const enriched = hybrid.slice(0, limit).map(enrichResult);
4363
- return textResponse(JSON.stringify(enriched, null, 2));
4410
+ const enriched = hybrid.slice(0, limit).map((r) => ctx.enrichResult(r));
4411
+ return ctx.textResponse(JSON.stringify(enriched, null, 2));
4364
4412
  }
4365
4413
  );
4414
+ }
4415
+
4416
+ // src/mcp/tools/read.ts
4417
+ function registerReadTools(server, ctx) {
4366
4418
  server.tool(
4367
4419
  "read_note",
4368
4420
  "Read the full content of a specific note by path",
4369
4421
  { path: external_exports.string() },
4370
4422
  async ({ path }) => {
4371
- const content = await crud.read(path);
4372
- return textResponse(content);
4423
+ const content = await ctx.crud.read(path);
4424
+ return ctx.textResponse(content);
4373
4425
  }
4374
4426
  );
4375
4427
  server.tool(
4376
4428
  "read_multiple_notes",
4377
- "Batch read multiple notes in one call",
4429
+ "[DEPRECATED \u2014 removed in v2.0.0; use read_note in a loop or via batched MCP calls] Batch read multiple notes in one call",
4378
4430
  { paths: external_exports.array(external_exports.string()) },
4379
4431
  async ({ paths }) => {
4380
- const results = await crud.readMultiple(paths);
4432
+ const results = await ctx.crud.readMultiple(paths);
4381
4433
  const output = {};
4382
4434
  for (const [k, v] of results) output[k] = v;
4383
- return textResponse(JSON.stringify(output, null, 2));
4435
+ return ctx.textResponse(JSON.stringify(output, null, 2));
4384
4436
  }
4385
4437
  );
4386
4438
  server.tool(
@@ -4394,10 +4446,11 @@ async function createServer(notesPath, options = {}) {
4394
4446
  domain: external_exports.string().optional().describe("Filter by frontmatter domain (e.g. 'api', 'security')")
4395
4447
  },
4396
4448
  async ({ modifiedAfter, modifiedBefore, status, tier, domain }) => {
4397
- if (documents.length === 0 && indexState !== "ready") return textResponse(indexingMessage());
4449
+ const documents = ctx.getDocuments();
4450
+ if (documents.length === 0 && ctx.getIndexState() !== "ready") return ctx.textResponse(ctx.indexingMessage());
4398
4451
  const after = modifiedAfter ? new Date(modifiedAfter).getTime() : -Infinity;
4399
4452
  const before = modifiedBefore ? new Date(modifiedBefore).getTime() : Infinity;
4400
- let list = documents.filter((d) => {
4453
+ const list = documents.filter((d) => {
4401
4454
  const t = new Date(d.mtime).getTime();
4402
4455
  if (t < after || t > before) return false;
4403
4456
  if (status && d.status !== status) return false;
@@ -4417,288 +4470,447 @@ async function createServer(notesPath, options = {}) {
4417
4470
  ...d.domains !== void 0 && { domains: d.domains },
4418
4471
  ...d.purpose !== void 0 && { purpose: d.purpose }
4419
4472
  }));
4420
- return textResponse(JSON.stringify(list, null, 2));
4473
+ return ctx.textResponse(JSON.stringify(list, null, 2));
4421
4474
  }
4422
4475
  );
4423
- if (!options.readOnly) {
4424
- server.tool(
4425
- "create_note",
4426
- "Create a new markdown note",
4427
- {
4428
- path: external_exports.string(),
4429
- content: external_exports.string(),
4430
- frontmatter: external_exports.record(external_exports.unknown()).optional()
4431
- },
4432
- async ({ path, content, frontmatter }) => {
4433
- await crud.create(path, content, frontmatter);
4434
- return textResponse(`Created: ${path}`);
4476
+ }
4477
+
4478
+ // src/mcp/tools/write.ts
4479
+ function registerWriteTools(server, ctx) {
4480
+ if (ctx.options.readOnly) return;
4481
+ server.tool(
4482
+ "create_note",
4483
+ "Create a new markdown note",
4484
+ {
4485
+ path: external_exports.string(),
4486
+ content: external_exports.string(),
4487
+ frontmatter: external_exports.record(external_exports.unknown()).optional()
4488
+ },
4489
+ async ({ path, content, frontmatter }) => {
4490
+ await ctx.crud.create(path, content, frontmatter);
4491
+ return ctx.textResponse(`Created: ${path}`);
4492
+ }
4493
+ );
4494
+ server.tool(
4495
+ "update_note",
4496
+ "Edit note content \u2014 overwrite, append, prepend, or patch by heading",
4497
+ {
4498
+ path: external_exports.string(),
4499
+ content: external_exports.string(),
4500
+ mode: external_exports.enum(["overwrite", "append", "prepend", "patch-by-heading"]),
4501
+ heading: external_exports.string().optional()
4502
+ },
4503
+ async ({ path, content, mode, heading }) => {
4504
+ await ctx.crud.update(path, content, { mode, heading });
4505
+ return ctx.textResponse(`Updated: ${path} (${mode})`);
4506
+ }
4507
+ );
4508
+ server.tool(
4509
+ "delete_note",
4510
+ "Delete a note permanently",
4511
+ { path: external_exports.string(), confirm: external_exports.boolean().default(false) },
4512
+ async ({ path, confirm }) => {
4513
+ if (!confirm) return ctx.textResponse(`Set confirm=true to delete ${path}`);
4514
+ await ctx.crud.delete(path);
4515
+ return ctx.textResponse(`Deleted: ${path}`);
4516
+ }
4517
+ );
4518
+ server.tool(
4519
+ "move_note",
4520
+ "Move or rename a note \u2014 updates wikilinks across the vault",
4521
+ { from: external_exports.string(), to: external_exports.string() },
4522
+ async ({ from, to }) => {
4523
+ await ctx.crud.move(from, to);
4524
+ return ctx.textResponse(`Moved: ${from} \u2192 ${to}`);
4525
+ }
4526
+ );
4527
+ }
4528
+
4529
+ // src/mcp/tools/patch.ts
4530
+ import { readFile as readFile2 } from "fs/promises";
4531
+ import { join as join2 } from "path";
4532
+ import matter from "gray-matter";
4533
+ function registerPatchTools(server, ctx) {
4534
+ if (ctx.options.readOnly) return;
4535
+ server.tool(
4536
+ "apply_patch",
4537
+ "Atomic multi-note patch with rollback \u2014 apply a ChangeSet of creates/updates/deletes/moves. If any op fails, earlier ops are reverted. In dry-run, nothing is written; result describes the proposed state + schema findings.",
4538
+ {
4539
+ creates: external_exports.array(
4540
+ external_exports.object({
4541
+ path: external_exports.string(),
4542
+ content: external_exports.string(),
4543
+ frontmatter: external_exports.record(external_exports.unknown()).optional()
4544
+ })
4545
+ ).optional(),
4546
+ updates: external_exports.array(
4547
+ external_exports.object({
4548
+ path: external_exports.string(),
4549
+ content: external_exports.string(),
4550
+ mode: external_exports.enum(["overwrite", "append", "prepend", "patch-by-heading"]),
4551
+ heading: external_exports.string().optional()
4552
+ })
4553
+ ).optional(),
4554
+ deletes: external_exports.array(external_exports.object({ path: external_exports.string() })).optional(),
4555
+ moves: external_exports.array(external_exports.object({ from: external_exports.string(), to: external_exports.string() })).optional(),
4556
+ dry_run: external_exports.boolean().optional().default(false),
4557
+ validate: external_exports.boolean().optional().default(true),
4558
+ allow_lint_warnings: external_exports.boolean().optional().default(true)
4559
+ },
4560
+ async (args) => {
4561
+ const cs = {
4562
+ creates: args.creates,
4563
+ updates: args.updates,
4564
+ deletes: args.deletes,
4565
+ moves: args.moves
4566
+ };
4567
+ const result = await applyPatch(ctx.notesPath, cs, {
4568
+ dryRun: args.dry_run,
4569
+ validate: args.validate,
4570
+ allowLintWarnings: args.allow_lint_warnings
4571
+ });
4572
+ if (!result.ok && !args.dry_run) {
4573
+ await logEvent(ctx.notesPath, {
4574
+ kind: "error",
4575
+ summary: `apply_patch failed: ${result.errors[0] ?? "unknown"}`,
4576
+ payload: { tool: "apply_patch", errors: result.errors, rolled_back: result.rolledBack.length }
4577
+ }).catch(() => {
4578
+ });
4435
4579
  }
4436
- );
4437
- server.tool(
4438
- "update_note",
4439
- "Edit note content \u2014 overwrite, append, prepend, or patch by heading",
4440
- {
4441
- path: external_exports.string(),
4442
- content: external_exports.string(),
4443
- mode: external_exports.enum(["overwrite", "append", "prepend", "patch-by-heading"]),
4444
- heading: external_exports.string().optional()
4445
- },
4446
- async ({ path, content, mode, heading }) => {
4447
- await crud.update(path, content, { mode, heading });
4448
- return textResponse(`Updated: ${path} (${mode})`);
4580
+ if (result.ok && !args.dry_run) {
4581
+ const touched = result.applied.map((o) => o.path);
4582
+ await ctx.sessions.recordNotesTouched(touched).catch(() => {
4583
+ });
4449
4584
  }
4450
- );
4451
- server.tool(
4452
- "delete_note",
4453
- "Delete a note permanently",
4454
- { path: external_exports.string(), confirm: external_exports.boolean().default(false) },
4455
- async ({ path, confirm }) => {
4456
- if (!confirm) return textResponse(`Set confirm=true to delete ${path}`);
4457
- await crud.delete(path);
4458
- return textResponse(`Deleted: ${path}`);
4585
+ return ctx.textResponse(JSON.stringify(result, null, 2));
4586
+ }
4587
+ );
4588
+ server.tool(
4589
+ "synthesize_note",
4590
+ "Turn a researched answer + sources into a new filed note with provenance frontmatter and auto-wikilinks. Builds a ChangeSet and applies it (dry-run supported). The primary path from query-answer to durable vault artifact. Pass `proposal: true` to file the result under `proposals/<date>-<slug>.md` with `status: proposal` for review-first workflows; `synthesize_promote` then moves it to the canonical destination.",
4591
+ {
4592
+ topic: external_exports.string(),
4593
+ answer: external_exports.string(),
4594
+ suggested_path: external_exports.string().describe("Target path relative to vault root, e.g. 'decisions/auth-migration.md'. In proposal mode, this is recorded as proposed_target frontmatter and the actual write goes to proposals/."),
4595
+ type: external_exports.string().optional().default("note").describe("One of the schema types: note, decision, gotcha, source"),
4596
+ sources: external_exports.array(external_exports.string()).optional().describe("External URLs or paths \u2014 populate provenance.sources"),
4597
+ derived_from: external_exports.array(external_exports.string()).optional().describe("Wikilinks to other vault notes \u2014 populate provenance.derived_from"),
4598
+ related_notes: external_exports.array(external_exports.string()).optional().describe("Note names or paths to auto-link in the body + list under Related"),
4599
+ status: external_exports.string().optional().default("active"),
4600
+ confidence: external_exports.string().optional().default("medium"),
4601
+ decision_maker: external_exports.string().optional(),
4602
+ decided_on: external_exports.string().optional(),
4603
+ severity: external_exports.string().optional(),
4604
+ dry_run: external_exports.boolean().optional().default(false),
4605
+ proposal: external_exports.boolean().optional().default(false).describe("Write to proposals/<date>-<slug>.md with status:proposal instead of the canonical destination. Use for review-first workflows."),
4606
+ proposal_subdir: external_exports.string().optional().describe("Override the proposal subdirectory (default 'proposals'). Useful for source segregation, e.g. 'proposals/research'."),
4607
+ from_session: external_exports.boolean().optional().default(false).describe("When true, pull defaults from the active session: prepend a verification summary to `answer`, default `derived_from` to session.notes_touched, and append session id+task to `sources`. No-op when no session is active.")
4608
+ },
4609
+ async (args) => {
4610
+ let answer = args.answer;
4611
+ let derivedFrom = args.derived_from;
4612
+ let sources = args.sources;
4613
+ if (args.from_session) {
4614
+ const session = await ctx.sessions.readActive();
4615
+ if (session) {
4616
+ const verifLines = session.verifications.map(
4617
+ (v) => `- \`${v.cmd}\` \u2192 exit=${v.exit ?? "(killed)"} in ${v.duration_ms}ms`
4618
+ );
4619
+ if (verifLines.length > 0) {
4620
+ answer = `${answer}
4621
+
4622
+ ## Session verifications (${session.id})
4623
+
4624
+ ${verifLines.join("\n")}
4625
+ `;
4626
+ }
4627
+ if ((!derivedFrom || derivedFrom.length === 0) && session.notes_touched.length > 0) {
4628
+ derivedFrom = [...session.notes_touched];
4629
+ }
4630
+ const sessionTag = `session:${session.id} task:${session.task}`;
4631
+ sources = sources ? [...sources, sessionTag] : [sessionTag];
4632
+ }
4459
4633
  }
4460
- );
4461
- server.tool(
4462
- "move_note",
4463
- "Move or rename a note \u2014 updates wikilinks across the vault",
4464
- { from: external_exports.string(), to: external_exports.string() },
4465
- async ({ from, to }) => {
4466
- await crud.move(from, to);
4467
- return textResponse(`Moved: ${from} \u2192 ${to}`);
4634
+ const preview = buildSynthesizeChangeSet({
4635
+ topic: args.topic,
4636
+ answer,
4637
+ suggested_path: args.suggested_path,
4638
+ type: args.type,
4639
+ sources,
4640
+ derived_from: derivedFrom,
4641
+ related_notes: args.related_notes,
4642
+ status: args.status,
4643
+ confidence: args.confidence,
4644
+ decision_maker: args.decision_maker,
4645
+ decided_on: args.decided_on,
4646
+ severity: args.severity,
4647
+ proposal: args.proposal,
4648
+ proposal_subdir: args.proposal_subdir
4649
+ });
4650
+ const result = await applyPatch(ctx.notesPath, preview.changeset, {
4651
+ dryRun: args.dry_run
4652
+ });
4653
+ if (result.ok && !args.dry_run) {
4654
+ await logEvent(ctx.notesPath, {
4655
+ kind: "synthesis",
4656
+ summary: `synthesized ${preview.title}${preview.is_proposal ? " (proposal)" : ""}`,
4657
+ payload: {
4658
+ path: preview.path,
4659
+ type: args.type,
4660
+ sources: sources ?? [],
4661
+ derived_from: derivedFrom ?? [],
4662
+ ...preview.is_proposal && { is_proposal: true, proposed_target: preview.proposed_target }
4663
+ }
4664
+ }).catch(() => {
4665
+ });
4666
+ await ctx.sessions.recordNotesTouched([preview.path]).catch(() => {
4667
+ });
4668
+ } else if (!result.ok && !args.dry_run) {
4669
+ await logEvent(ctx.notesPath, {
4670
+ kind: "error",
4671
+ summary: `synthesize_note failed: ${result.errors[0] ?? "unknown"}`,
4672
+ payload: { tool: "synthesize_note", path: preview.path, errors: result.errors }
4673
+ }).catch(() => {
4674
+ });
4468
4675
  }
4469
- );
4470
- server.tool(
4471
- "apply_patch",
4472
- "Atomic multi-note patch with rollback \u2014 apply a ChangeSet of creates/updates/deletes/moves. If any op fails, earlier ops are reverted. In dry-run, nothing is written; result describes the proposed state + schema findings.",
4473
- {
4474
- creates: external_exports.array(
4475
- external_exports.object({
4476
- path: external_exports.string(),
4477
- content: external_exports.string(),
4478
- frontmatter: external_exports.record(external_exports.unknown()).optional()
4479
- })
4480
- ).optional(),
4481
- updates: external_exports.array(
4482
- external_exports.object({
4483
- path: external_exports.string(),
4484
- content: external_exports.string(),
4485
- mode: external_exports.enum(["overwrite", "append", "prepend", "patch-by-heading"]),
4486
- heading: external_exports.string().optional()
4487
- })
4488
- ).optional(),
4489
- deletes: external_exports.array(external_exports.object({ path: external_exports.string() })).optional(),
4490
- moves: external_exports.array(external_exports.object({ from: external_exports.string(), to: external_exports.string() })).optional(),
4491
- dry_run: external_exports.boolean().optional().default(false),
4492
- validate: external_exports.boolean().optional().default(true),
4493
- allow_lint_warnings: external_exports.boolean().optional().default(true)
4494
- },
4495
- async (args) => {
4496
- const cs = {
4497
- creates: args.creates,
4498
- updates: args.updates,
4499
- deletes: args.deletes,
4500
- moves: args.moves
4501
- };
4502
- const result = await applyPatch(notesPath, cs, {
4503
- dryRun: args.dry_run,
4504
- validate: args.validate,
4505
- allowLintWarnings: args.allow_lint_warnings
4676
+ return ctx.textResponse(
4677
+ JSON.stringify(
4678
+ {
4679
+ preview: {
4680
+ path: preview.path,
4681
+ title: preview.title,
4682
+ ...preview.is_proposal && { is_proposal: true, proposed_target: preview.proposed_target }
4683
+ },
4684
+ result
4685
+ },
4686
+ null,
4687
+ 2
4688
+ )
4689
+ );
4690
+ }
4691
+ );
4692
+ server.tool(
4693
+ "synthesize_promote",
4694
+ "Move a reviewed proposal note to its canonical destination. Reads the proposal's frontmatter (which must have `status: proposal` and `proposed_target`), strips proposal markers, restores canonical status, and atomically creates the target + deletes the proposal. Optional `target_path` overrides the proposal's recorded `proposed_target`.",
4695
+ {
4696
+ proposal_path: external_exports.string().describe("Path to the proposal note relative to vault root, e.g. 'proposals/2026-05-08-auth-flow.md'"),
4697
+ target_path: external_exports.string().optional().describe("Optional destination override. When omitted, uses the proposal's own `proposed_target` frontmatter."),
4698
+ dry_run: external_exports.boolean().optional().default(false)
4699
+ },
4700
+ async (args) => {
4701
+ const absoluteProposalPath = join2(ctx.notesPath, args.proposal_path);
4702
+ let raw;
4703
+ try {
4704
+ raw = await readFile2(absoluteProposalPath, "utf-8");
4705
+ } catch (err) {
4706
+ return ctx.textResponse(JSON.stringify({
4707
+ ok: false,
4708
+ error: `Proposal file not found: ${args.proposal_path}`
4709
+ }, null, 2));
4710
+ }
4711
+ const parsed = matter(raw);
4712
+ const fm = parsed.data;
4713
+ if (fm.status !== "proposal") {
4714
+ return ctx.textResponse(JSON.stringify({
4715
+ ok: false,
4716
+ error: `Note ${args.proposal_path} does not have status:proposal frontmatter (got status=${JSON.stringify(fm.status)}). Refusing to promote \u2014 only proposal-flagged notes are eligible.`
4717
+ }, null, 2));
4718
+ }
4719
+ let preview;
4720
+ try {
4721
+ preview = buildPromoteChangeSet({
4722
+ proposal_path: args.proposal_path,
4723
+ target_path: args.target_path,
4724
+ body: parsed.content,
4725
+ frontmatter: fm
4506
4726
  });
4507
- if (!result.ok && !args.dry_run) {
4508
- await logEvent(notesPath, {
4509
- kind: "error",
4510
- summary: `apply_patch failed: ${result.errors[0] ?? "unknown"}`,
4511
- payload: { tool: "apply_patch", errors: result.errors, rolled_back: result.rolledBack.length }
4512
- }).catch(() => {
4513
- });
4514
- }
4515
- return textResponse(JSON.stringify(result, null, 2));
4727
+ } catch (err) {
4728
+ return ctx.textResponse(JSON.stringify({ ok: false, error: err?.message ?? String(err) }, null, 2));
4516
4729
  }
4517
- );
4518
- server.tool(
4519
- "synthesize_note",
4520
- "Turn a researched answer + sources into a new filed note with provenance frontmatter and auto-wikilinks. Builds a ChangeSet and applies it (dry-run supported). The primary path from query-answer to durable vault artifact.",
4521
- {
4522
- topic: external_exports.string(),
4523
- answer: external_exports.string(),
4524
- suggested_path: external_exports.string().describe("Target path relative to vault root, e.g. 'decisions/auth-migration.md'"),
4525
- type: external_exports.string().optional().default("note").describe("One of the schema types: note, decision, gotcha, source"),
4526
- sources: external_exports.array(external_exports.string()).optional().describe("External URLs or paths \u2014 populate provenance.sources"),
4527
- derived_from: external_exports.array(external_exports.string()).optional().describe("Wikilinks to other vault notes \u2014 populate provenance.derived_from"),
4528
- related_notes: external_exports.array(external_exports.string()).optional().describe("Note names or paths to auto-link in the body + list under Related"),
4529
- status: external_exports.string().optional().default("active"),
4530
- confidence: external_exports.string().optional().default("medium"),
4531
- decision_maker: external_exports.string().optional(),
4532
- decided_on: external_exports.string().optional(),
4533
- severity: external_exports.string().optional(),
4534
- dry_run: external_exports.boolean().optional().default(false)
4535
- },
4536
- async (args) => {
4537
- const preview = buildSynthesizeChangeSet({
4538
- topic: args.topic,
4539
- answer: args.answer,
4540
- suggested_path: args.suggested_path,
4541
- type: args.type,
4542
- sources: args.sources,
4543
- derived_from: args.derived_from,
4544
- related_notes: args.related_notes,
4545
- status: args.status,
4546
- confidence: args.confidence,
4547
- decision_maker: args.decision_maker,
4548
- decided_on: args.decided_on,
4549
- severity: args.severity
4730
+ const result = await applyPatch(ctx.notesPath, preview.changeset, { dryRun: args.dry_run });
4731
+ if (result.ok && !args.dry_run) {
4732
+ await logEvent(ctx.notesPath, {
4733
+ kind: "synthesis",
4734
+ summary: `promoted proposal ${preview.from} \u2192 ${preview.to}`,
4735
+ payload: { tool: "synthesize_promote", from: preview.from, to: preview.to }
4736
+ }).catch(() => {
4550
4737
  });
4551
- const result = await applyPatch(notesPath, preview.changeset, {
4552
- dryRun: args.dry_run
4738
+ await ctx.sessions.recordNotesTouched([preview.to]).catch(() => {
4553
4739
  });
4554
- if (result.ok && !args.dry_run) {
4555
- await logEvent(notesPath, {
4556
- kind: "synthesis",
4557
- summary: `synthesized ${preview.title}`,
4558
- payload: { path: preview.path, type: args.type, sources: args.sources ?? [], derived_from: args.derived_from ?? [] }
4559
- }).catch(() => {
4560
- });
4561
- } else if (!result.ok && !args.dry_run) {
4562
- await logEvent(notesPath, {
4563
- kind: "error",
4564
- summary: `synthesize_note failed: ${result.errors[0] ?? "unknown"}`,
4565
- payload: { tool: "synthesize_note", path: preview.path, errors: result.errors }
4566
- }).catch(() => {
4567
- });
4568
- }
4569
- return textResponse(
4570
- JSON.stringify(
4571
- {
4572
- preview: { path: preview.path, title: preview.title },
4573
- result
4574
- },
4575
- null,
4576
- 2
4577
- )
4578
- );
4579
4740
  }
4580
- );
4581
- server.tool(
4582
- "ingest_source",
4583
- "Build + apply a ChangeSet that ingests a source (paper, doc, URL) and the notes extracted from it. Produces one sources/<slug>.md note (type=source) plus one create per extracted unit, each stamped with derived_from pointing at the source. Routes through apply_patch \u2014 atomic, validated, rollback-capable, dry-run supported.",
4584
- {
4585
- source: external_exports.object({
4586
- source_uri: external_exports.string(),
4587
- source_title: external_exports.string(),
4588
- source_type: external_exports.string().optional(),
4589
- source_path: external_exports.string().optional(),
4590
- source_summary: external_exports.string().optional(),
4591
- source_tags: external_exports.array(external_exports.string()).optional()
4592
- }),
4593
- units: external_exports.array(
4594
- external_exports.object({
4595
- path: external_exports.string(),
4596
- title: external_exports.string().optional(),
4597
- content: external_exports.string(),
4598
- type: external_exports.string().optional(),
4599
- extra_derived_from: external_exports.array(external_exports.string()).optional(),
4600
- extra_frontmatter: external_exports.record(external_exports.unknown()).optional(),
4601
- confidence: external_exports.string().optional()
4602
- })
4603
- ),
4604
- dry_run: external_exports.boolean().optional().default(false),
4605
- auto_apply: external_exports.boolean().optional().default(true).describe("If false, force dry-run regardless of dry_run flag \u2014 use for review-first workflows")
4606
- },
4607
- async (args) => {
4608
- const preview = buildIngestChangeSet({
4609
- source: args.source,
4610
- units: args.units
4741
+ return ctx.textResponse(JSON.stringify({ preview: { from: preview.from, to: preview.to }, result }, null, 2));
4742
+ }
4743
+ );
4744
+ server.tool(
4745
+ "ingest_source",
4746
+ "Build + apply a ChangeSet that ingests a source (paper, doc, URL) and the notes extracted from it. Produces one sources/<slug>.md note (type=source) plus one create per extracted unit, each stamped with derived_from pointing at the source. Routes through apply_patch \u2014 atomic, validated, rollback-capable, dry-run supported.",
4747
+ {
4748
+ source: external_exports.object({
4749
+ source_uri: external_exports.string(),
4750
+ source_title: external_exports.string(),
4751
+ source_type: external_exports.string().optional(),
4752
+ source_path: external_exports.string().optional(),
4753
+ source_summary: external_exports.string().optional(),
4754
+ source_tags: external_exports.array(external_exports.string()).optional()
4755
+ }),
4756
+ units: external_exports.array(
4757
+ external_exports.object({
4758
+ path: external_exports.string(),
4759
+ title: external_exports.string().optional(),
4760
+ content: external_exports.string(),
4761
+ type: external_exports.string().optional(),
4762
+ extra_derived_from: external_exports.array(external_exports.string()).optional(),
4763
+ extra_frontmatter: external_exports.record(external_exports.unknown()).optional(),
4764
+ confidence: external_exports.string().optional()
4765
+ })
4766
+ ),
4767
+ dry_run: external_exports.boolean().optional().default(false),
4768
+ auto_apply: external_exports.boolean().optional().default(true).describe("If false, force dry-run regardless of dry_run flag \u2014 use for review-first workflows")
4769
+ },
4770
+ async (args) => {
4771
+ const preview = buildIngestChangeSet({
4772
+ source: args.source,
4773
+ units: args.units
4774
+ });
4775
+ const dry = args.dry_run || args.auto_apply === false;
4776
+ const result = await applyPatch(ctx.notesPath, preview.changeset, { dryRun: dry });
4777
+ if (result.ok && !dry) {
4778
+ await logEvent(ctx.notesPath, {
4779
+ kind: "ingest",
4780
+ summary: `ingested ${preview.unitPaths.length} note(s) from ${args.source.source_title}`,
4781
+ payload: {
4782
+ source_path: preview.sourcePath,
4783
+ unit_paths: preview.unitPaths,
4784
+ source_uri: args.source.source_uri
4785
+ }
4786
+ }).catch(() => {
4611
4787
  });
4612
- const dry = args.dry_run || args.auto_apply === false;
4613
- const result = await applyPatch(notesPath, preview.changeset, { dryRun: dry });
4614
- if (result.ok && !dry) {
4615
- await logEvent(notesPath, {
4616
- kind: "ingest",
4617
- summary: `ingested ${preview.unitPaths.length} note(s) from ${args.source.source_title}`,
4618
- payload: {
4788
+ } else if (!result.ok && !dry) {
4789
+ await logEvent(ctx.notesPath, {
4790
+ kind: "error",
4791
+ summary: `ingest_source failed: ${result.errors[0] ?? "unknown"}`,
4792
+ payload: {
4793
+ tool: "ingest_source",
4794
+ source_uri: args.source.source_uri,
4795
+ source_title: args.source.source_title,
4796
+ errors: result.errors
4797
+ }
4798
+ }).catch(() => {
4799
+ });
4800
+ }
4801
+ return ctx.textResponse(
4802
+ JSON.stringify(
4803
+ {
4804
+ preview: {
4619
4805
  source_path: preview.sourcePath,
4620
- unit_paths: preview.unitPaths,
4621
- source_uri: args.source.source_uri
4622
- }
4623
- }).catch(() => {
4624
- });
4625
- } else if (!result.ok && !dry) {
4626
- await logEvent(notesPath, {
4627
- kind: "error",
4628
- summary: `ingest_source failed: ${result.errors[0] ?? "unknown"}`,
4629
- payload: {
4630
- tool: "ingest_source",
4631
- source_uri: args.source.source_uri,
4632
- source_title: args.source.source_title,
4633
- errors: result.errors
4634
- }
4635
- }).catch(() => {
4636
- });
4637
- }
4638
- return textResponse(
4639
- JSON.stringify(
4640
- {
4641
- preview: {
4642
- source_path: preview.sourcePath,
4643
- unit_paths: preview.unitPaths
4644
- },
4645
- result,
4646
- dry_run: dry
4806
+ unit_paths: preview.unitPaths
4647
4807
  },
4648
- null,
4649
- 2
4650
- )
4651
- );
4652
- }
4653
- );
4654
- server.tool(
4655
- "install_schema",
4656
- "Bootstrap vault.schema.yml in the vault root with the default schema (note/decision/gotcha/source types + provenance fields). Skipped if present unless force=true.",
4657
- { force: external_exports.boolean().optional().default(false) },
4658
- async ({ force }) => {
4659
- const r = await installDefaultSchema(notesPath, force);
4660
- return textResponse(
4661
- r.written ? `Installed default schema: ${r.path}` : `Schema already exists: ${r.path} (pass force=true to overwrite)`
4662
- );
4663
- }
4664
- );
4665
- }
4808
+ result,
4809
+ dry_run: dry
4810
+ },
4811
+ null,
4812
+ 2
4813
+ )
4814
+ );
4815
+ }
4816
+ );
4817
+ server.tool(
4818
+ "install_schema",
4819
+ "Bootstrap vault.schema.yml in the vault root with the default schema (note/decision/gotcha/source types + provenance fields). Skipped if present unless force=true.",
4820
+ { force: external_exports.boolean().optional().default(false) },
4821
+ async ({ force }) => {
4822
+ const r = await installDefaultSchema(ctx.notesPath, force);
4823
+ return ctx.textResponse(
4824
+ r.written ? `Installed default schema: ${r.path}` : `Schema already exists: ${r.path} (pass force=true to overwrite)`
4825
+ );
4826
+ }
4827
+ );
4828
+ }
4829
+
4830
+ // src/mcp/tools/lint.ts
4831
+ var ALL_CHECKS = ["schema", "provenance", "stale", "broken_links"];
4832
+ var RULE_KEY_FOR_CHECK = {
4833
+ schema: "schema_violations",
4834
+ provenance: "missing_provenance",
4835
+ stale: "stale",
4836
+ broken_links: "broken_links"
4837
+ };
4838
+ function registerLintTools(server, ctx) {
4666
4839
  server.tool(
4667
4840
  "find_schema_violations",
4668
- "Scan the vault for schema violations \u2014 missing required fields, unknown types, enum mismatches. Errors block apply_patch when validate=true.",
4841
+ "[DEPRECATED \u2014 removed in v2.0.0; use lint_vault({checks:['schema']})] Scan the vault for schema violations \u2014 missing required fields, unknown types, enum mismatches. Errors block apply_patch when validate=true.",
4669
4842
  { pathGlob: external_exports.string().optional() },
4670
4843
  async ({ pathGlob }) => {
4671
- const report = await lintVault(notesPath, { pathGlob });
4672
- return textResponse(JSON.stringify(report.byRule.schema_violations, null, 2));
4844
+ const report = await lintVault(ctx.notesPath, { pathGlob });
4845
+ return ctx.textResponse(JSON.stringify(report.byRule.schema_violations, null, 2));
4673
4846
  }
4674
4847
  );
4675
4848
  server.tool(
4676
4849
  "find_missing_provenance",
4677
- "Scan the vault for notes of types (note/decision/gotcha by default) that lack both sources and derived_from frontmatter. Warnings by default.",
4850
+ "[DEPRECATED \u2014 removed in v2.0.0; use lint_vault({checks:['provenance']})] Scan the vault for notes of types (note/decision/gotcha by default) that lack both sources and derived_from frontmatter. Warnings by default.",
4678
4851
  { pathGlob: external_exports.string().optional() },
4679
4852
  async ({ pathGlob }) => {
4680
- const report = await lintVault(notesPath, { pathGlob });
4681
- return textResponse(JSON.stringify(report.byRule.missing_provenance, null, 2));
4853
+ const report = await lintVault(ctx.notesPath, { pathGlob });
4854
+ return ctx.textResponse(JSON.stringify(report.byRule.missing_provenance, null, 2));
4682
4855
  }
4683
4856
  );
4684
4857
  server.tool(
4685
4858
  "find_stale",
4686
- "Scan the vault for notes whose last_verified date is older than the schema's stale.max_age_days (default 180).",
4859
+ "[DEPRECATED \u2014 removed in v2.0.0; use lint_vault({checks:['stale']})] Scan the vault for notes whose last_verified date is older than the schema's stale.max_age_days (default 180).",
4687
4860
  { pathGlob: external_exports.string().optional() },
4688
4861
  async ({ pathGlob }) => {
4689
- const report = await lintVault(notesPath, { pathGlob });
4690
- return textResponse(JSON.stringify(report.byRule.stale, null, 2));
4862
+ const report = await lintVault(ctx.notesPath, { pathGlob });
4863
+ return ctx.textResponse(JSON.stringify(report.byRule.stale, null, 2));
4691
4864
  }
4692
4865
  );
4693
4866
  server.tool(
4694
4867
  "find_broken_links",
4695
- "Scan the vault for [[wikilinks]] that point to non-existent notes. Heavier than the other lints (cross-note check) but still metadata-only.",
4868
+ "[DEPRECATED \u2014 removed in v2.0.0; use lint_vault({checks:['broken_links']})] Scan the vault for [[wikilinks]] that point to non-existent notes. Heavier than the other lints (cross-note check) but still metadata-only.",
4696
4869
  { pathGlob: external_exports.string().optional() },
4697
4870
  async ({ pathGlob }) => {
4698
- const report = await lintVault(notesPath, { pathGlob });
4699
- return textResponse(JSON.stringify(report.byRule.broken_links, null, 2));
4871
+ const report = await lintVault(ctx.notesPath, { pathGlob });
4872
+ return ctx.textResponse(JSON.stringify(report.byRule.broken_links, null, 2));
4873
+ }
4874
+ );
4875
+ server.tool(
4876
+ "lint_vault",
4877
+ "Run lint rules across the vault. Default returns the full LintReport with all findings grouped by rule. Pass `checks` to filter to specific rules (schema, provenance, stale, broken_links) \u2014 when filtered, byRule contains only the requested rules.",
4878
+ {
4879
+ pathGlob: external_exports.string().optional(),
4880
+ checks: external_exports.array(external_exports.enum(["schema", "provenance", "stale", "broken_links"])).optional().describe("Filter to specific lint rules. Omit for full report.")
4881
+ },
4882
+ async ({ pathGlob, checks }) => {
4883
+ const report = await lintVault(ctx.notesPath, { pathGlob });
4884
+ if (!checks || checks.length === 0) {
4885
+ return ctx.textResponse(JSON.stringify(report, null, 2));
4886
+ }
4887
+ const requested = new Set(checks.length > 0 ? checks : ALL_CHECKS);
4888
+ const filteredByRule = {};
4889
+ for (const check of requested) {
4890
+ const ruleKey = RULE_KEY_FOR_CHECK[check];
4891
+ filteredByRule[ruleKey] = report.byRule[ruleKey] ?? [];
4892
+ }
4893
+ const filteredFindings = report.findings.filter((f) => {
4894
+ const matchedCheck = Object.entries(RULE_KEY_FOR_CHECK).find(([, ruleKey]) => ruleKey === f.rule);
4895
+ return matchedCheck ? requested.has(matchedCheck[0]) : false;
4896
+ });
4897
+ return ctx.textResponse(
4898
+ JSON.stringify(
4899
+ {
4900
+ ...report,
4901
+ findings: filteredFindings,
4902
+ byRule: filteredByRule
4903
+ },
4904
+ null,
4905
+ 2
4906
+ )
4907
+ );
4700
4908
  }
4701
4909
  );
4910
+ }
4911
+
4912
+ // src/mcp/tools/log.ts
4913
+ function registerLogTools(server, ctx) {
4702
4914
  server.tool(
4703
4915
  "log_event",
4704
4916
  "Append a structured event to the vault's log.md. Each entry renders as a human line + a machine-readable YAML block for later querying.",
@@ -4708,8 +4920,8 @@ async function createServer(notesPath, options = {}) {
4708
4920
  payload: external_exports.record(external_exports.unknown()).optional().describe("Arbitrary structured data")
4709
4921
  },
4710
4922
  async (args) => {
4711
- const entry = await logEvent(notesPath, args);
4712
- return textResponse(JSON.stringify(entry, null, 2));
4923
+ const entry = await logEvent(ctx.notesPath, args);
4924
+ return ctx.textResponse(JSON.stringify(entry, null, 2));
4713
4925
  }
4714
4926
  );
4715
4927
  server.tool(
@@ -4722,11 +4934,11 @@ async function createServer(notesPath, options = {}) {
4722
4934
  limit: external_exports.coerce.number().optional()
4723
4935
  },
4724
4936
  async (args) => {
4725
- const entries = await logQuery(notesPath, args);
4726
- return textResponse(JSON.stringify(entries, null, 2));
4937
+ const entries = await logQuery(ctx.notesPath, args);
4938
+ return ctx.textResponse(JSON.stringify(entries, null, 2));
4727
4939
  }
4728
4940
  );
4729
- if (!options.readOnly) {
4941
+ if (!ctx.options.readOnly) {
4730
4942
  server.tool(
4731
4943
  "regenerate_index",
4732
4944
  "Force regeneration of INDEX.md for one directory (or all affected by a list of paths). Idempotent \u2014 skips write if unchanged.",
@@ -4737,89 +4949,94 @@ async function createServer(notesPath, options = {}) {
4737
4949
  async ({ directory, paths }) => {
4738
4950
  let results;
4739
4951
  if (directory) {
4740
- results = [await regenDirectoryIndex(notesPath, directory)];
4952
+ results = [await regenDirectoryIndex(ctx.notesPath, directory)];
4741
4953
  } else if (paths && paths.length > 0) {
4742
- results = await regenIndexesForPaths(notesPath, paths);
4954
+ results = await regenIndexesForPaths(ctx.notesPath, paths);
4743
4955
  } else {
4744
- return textResponse("Provide either `directory` or `paths`.");
4956
+ return ctx.textResponse("Provide either `directory` or `paths`.");
4745
4957
  }
4746
- return textResponse(JSON.stringify(results, null, 2));
4958
+ return ctx.textResponse(JSON.stringify(results, null, 2));
4747
4959
  }
4748
4960
  );
4749
4961
  }
4750
- server.tool(
4751
- "lint_vault",
4752
- "Run all lint rules at once \u2014 returns full LintReport with findings grouped by rule.",
4753
- { pathGlob: external_exports.string().optional() },
4754
- async ({ pathGlob }) => {
4755
- const report = await lintVault(notesPath, { pathGlob });
4756
- return textResponse(JSON.stringify(report, null, 2));
4757
- }
4758
- );
4962
+ }
4963
+
4964
+ // src/mcp/tools/metadata.ts
4965
+ function registerMetadataTools(server, ctx) {
4759
4966
  server.tool(
4760
4967
  "get_frontmatter",
4761
4968
  "Read parsed YAML frontmatter from a note as JSON",
4762
4969
  { path: external_exports.string() },
4763
4970
  async ({ path }) => {
4764
- const fm = await frontmatterManager.get(path);
4765
- return textResponse(JSON.stringify(fm, null, 2));
4971
+ const fm = await ctx.frontmatterManager.get(path);
4972
+ return ctx.textResponse(JSON.stringify(fm, null, 2));
4766
4973
  }
4767
4974
  );
4768
- if (!options.readOnly) {
4769
- server.tool(
4770
- "update_frontmatter",
4771
- "Set or delete YAML frontmatter keys \u2014 pass null to delete a key",
4772
- { path: external_exports.string(), fields: external_exports.record(external_exports.unknown()) },
4773
- async ({ path, fields }) => {
4774
- await frontmatterManager.update(path, fields);
4775
- return textResponse(`Frontmatter updated: ${path}`);
4776
- }
4777
- );
4778
- server.tool(
4779
- "manage_tags",
4780
- "Add, remove, or list tags on a note (frontmatter and inline)",
4781
- {
4782
- path: external_exports.string(),
4783
- action: external_exports.enum(["add", "remove", "list"]),
4784
- tags: external_exports.array(external_exports.string()).optional()
4785
- },
4786
- async ({ path, action, tags }) => {
4787
- switch (action) {
4788
- case "list": {
4789
- const result = await tagManager.list(path);
4790
- return textResponse(JSON.stringify(result));
4791
- }
4792
- case "add": {
4793
- if (!tags?.length) return textResponse("No tags provided");
4794
- await tagManager.add(path, tags);
4795
- return textResponse(`Added tags to ${path}: ${tags.join(", ")}`);
4796
- }
4797
- case "remove": {
4798
- if (!tags?.length) return textResponse("No tags provided");
4799
- await tagManager.remove(path, tags);
4800
- return textResponse(`Removed tags from ${path}: ${tags.join(", ")}`);
4801
- }
4975
+ if (ctx.options.readOnly) return;
4976
+ server.tool(
4977
+ "update_frontmatter",
4978
+ "Set or delete YAML frontmatter keys \u2014 pass null to delete a key",
4979
+ { path: external_exports.string(), fields: external_exports.record(external_exports.unknown()) },
4980
+ async ({ path, fields }) => {
4981
+ await ctx.frontmatterManager.update(path, fields);
4982
+ return ctx.textResponse(`Frontmatter updated: ${path}`);
4983
+ }
4984
+ );
4985
+ server.tool(
4986
+ "manage_tags",
4987
+ "Add, remove, list, or rename tags on a note (frontmatter and inline). Use action='rename' with `from` and `to` to rename a tag vault-wide; `path` is ignored in that mode.",
4988
+ {
4989
+ path: external_exports.string(),
4990
+ action: external_exports.enum(["add", "remove", "list", "rename"]),
4991
+ tags: external_exports.array(external_exports.string()).optional(),
4992
+ from: external_exports.string().optional().describe("For action='rename': the existing tag name (no leading #)"),
4993
+ to: external_exports.string().optional().describe("For action='rename': the new tag name (no leading #)")
4994
+ },
4995
+ async ({ path, action, tags, from, to }) => {
4996
+ switch (action) {
4997
+ case "list": {
4998
+ const result = await ctx.tagManager.list(path);
4999
+ return ctx.textResponse(JSON.stringify(result));
5000
+ }
5001
+ case "add": {
5002
+ if (!tags?.length) return ctx.textResponse("No tags provided");
5003
+ await ctx.tagManager.add(path, tags);
5004
+ return ctx.textResponse(`Added tags to ${path}: ${tags.join(", ")}`);
5005
+ }
5006
+ case "remove": {
5007
+ if (!tags?.length) return ctx.textResponse("No tags provided");
5008
+ await ctx.tagManager.remove(path, tags);
5009
+ return ctx.textResponse(`Removed tags from ${path}: ${tags.join(", ")}`);
5010
+ }
5011
+ case "rename": {
5012
+ if (!from || !to) return ctx.textResponse("action='rename' requires both `from` and `to`");
5013
+ const count = await ctx.tagManager.renameVaultWide(from, to);
5014
+ return ctx.textResponse(`Renamed #${from} \u2192 #${to} in ${count} files`);
4802
5015
  }
4803
5016
  }
4804
- );
4805
- server.tool(
4806
- "rename_tag",
4807
- "Rename a tag across all notes in the vault",
4808
- { oldTag: external_exports.string(), newTag: external_exports.string() },
4809
- async ({ oldTag, newTag }) => {
4810
- const count = await tagManager.renameVaultWide(oldTag, newTag);
4811
- return textResponse(`Renamed #${oldTag} \u2192 #${newTag} in ${count} files`);
4812
- }
4813
- );
4814
- }
5017
+ }
5018
+ );
5019
+ server.tool(
5020
+ "rename_tag",
5021
+ "[DEPRECATED \u2014 removed in v2.0.0; use manage_tags({action:'rename', from, to})] Rename a tag across all notes in the vault",
5022
+ { oldTag: external_exports.string(), newTag: external_exports.string() },
5023
+ async ({ oldTag, newTag }) => {
5024
+ const count = await ctx.tagManager.renameVaultWide(oldTag, newTag);
5025
+ return ctx.textResponse(`Renamed #${oldTag} \u2192 #${newTag} in ${count} files`);
5026
+ }
5027
+ );
5028
+ }
5029
+
5030
+ // src/mcp/tools/graph.ts
5031
+ function registerGraphTools(server, ctx) {
4815
5032
  server.tool(
4816
5033
  "backlinks",
4817
5034
  "Find all notes that link TO a given note",
4818
5035
  { path: external_exports.string(), limit: external_exports.coerce.number().optional().default(50) },
4819
5036
  async ({ path, limit }) => {
4820
- if (documents.length === 0 && indexState !== "ready") return textResponse(indexingMessage());
4821
- const results = graph.backlinks(path).slice(0, limit);
4822
- return textResponse(JSON.stringify(results, null, 2));
5037
+ if (ctx.getDocuments().length === 0 && ctx.getIndexState() !== "ready") return ctx.textResponse(ctx.indexingMessage());
5038
+ const results = ctx.graph.backlinks(path).slice(0, limit);
5039
+ return ctx.textResponse(JSON.stringify(results, null, 2));
4823
5040
  }
4824
5041
  );
4825
5042
  server.tool(
@@ -4827,9 +5044,9 @@ async function createServer(notesPath, options = {}) {
4827
5044
  "Find all notes linked FROM a given note",
4828
5045
  { path: external_exports.string(), limit: external_exports.coerce.number().optional().default(50) },
4829
5046
  async ({ path, limit }) => {
4830
- if (documents.length === 0 && indexState !== "ready") return textResponse(indexingMessage());
4831
- const results = graph.forwardlinks(path).slice(0, limit);
4832
- return textResponse(JSON.stringify(results, null, 2));
5047
+ if (ctx.getDocuments().length === 0 && ctx.getIndexState() !== "ready") return ctx.textResponse(ctx.indexingMessage());
5048
+ const results = ctx.graph.forwardlinks(path).slice(0, limit);
5049
+ return ctx.textResponse(JSON.stringify(results, null, 2));
4833
5050
  }
4834
5051
  );
4835
5052
  server.tool(
@@ -4837,10 +5054,10 @@ async function createServer(notesPath, options = {}) {
4837
5054
  "Find the shortest path between two notes in the knowledge graph",
4838
5055
  { from: external_exports.string(), to: external_exports.string() },
4839
5056
  async ({ from, to }) => {
4840
- if (documents.length === 0 && indexState !== "ready") return textResponse(indexingMessage());
4841
- const path = graph.findPath(from, to);
4842
- if (!path) return textResponse("No path found");
4843
- return textResponse(JSON.stringify(path));
5057
+ if (ctx.getDocuments().length === 0 && ctx.getIndexState() !== "ready") return ctx.textResponse(ctx.indexingMessage());
5058
+ const path = ctx.graph.findPath(from, to);
5059
+ if (!path) return ctx.textResponse("No path found");
5060
+ return ctx.textResponse(JSON.stringify(path));
4844
5061
  }
4845
5062
  );
4846
5063
  server.tool(
@@ -4848,24 +5065,32 @@ async function createServer(notesPath, options = {}) {
4848
5065
  "Knowledge graph stats \u2014 most connected nodes, orphans, density",
4849
5066
  {},
4850
5067
  async () => {
4851
- if (documents.length === 0 && indexState !== "ready") return textResponse(indexingMessage());
4852
- const stats = graph.statistics();
4853
- return textResponse(JSON.stringify(stats, null, 2));
5068
+ if (ctx.getDocuments().length === 0 && ctx.getIndexState() !== "ready") return ctx.textResponse(ctx.indexingMessage());
5069
+ const stats = ctx.graph.statistics();
5070
+ return ctx.textResponse(JSON.stringify(stats, null, 2));
4854
5071
  }
4855
5072
  );
5073
+ }
5074
+
5075
+ // src/mcp/tools/system.ts
5076
+ function registerSystemTools(server, ctx) {
4856
5077
  server.tool(
4857
5078
  "get_stats",
4858
5079
  "Vault and index statistics \u2014 note count, chunks, embeddings, graph density",
4859
5080
  {},
4860
5081
  async () => {
4861
- const graphStats = documents.length > 0 ? graph.statistics() : { totalNodes: 0, totalEdges: 0 };
5082
+ const documents = ctx.getDocuments();
5083
+ const vectorIndex = ctx.getVectorIndex();
5084
+ const indexState = ctx.getIndexState();
5085
+ const indexProgress = ctx.getIndexProgress();
5086
+ const graphStats = documents.length > 0 ? ctx.graph.statistics() : { totalNodes: 0, totalEdges: 0 };
4862
5087
  const stats = {
4863
5088
  totalNotes: documents.length,
4864
5089
  totalChunks: documents.reduce((n, d) => n + d.chunks.length, 0),
4865
5090
  totalEmbeddings: vectorIndex?.getChunkMeta().length ?? 0,
4866
- embeddingDimensions: embedder.getDimensions(),
4867
- embeddingModel: embedder.getModel(),
4868
- embeddingRuntime: embedder.getRuntime?.() ?? "unknown",
5091
+ embeddingDimensions: ctx.embedder.getDimensions(),
5092
+ embeddingModel: ctx.embedder.getModel(),
5093
+ embeddingRuntime: ctx.embedder.getRuntime?.() ?? "unknown",
4869
5094
  graphNodes: graphStats.totalNodes,
4870
5095
  graphEdges: graphStats.totalEdges,
4871
5096
  indexSize: 0,
@@ -4873,7 +5098,7 @@ async function createServer(notesPath, options = {}) {
4873
5098
  indexState,
4874
5099
  indexProgress: indexState === "indexing" ? indexProgress : void 0
4875
5100
  };
4876
- return textResponse(JSON.stringify(stats, null, 2));
5101
+ return ctx.textResponse(JSON.stringify(stats, null, 2));
4877
5102
  }
4878
5103
  );
4879
5104
  server.tool(
@@ -4881,29 +5106,244 @@ async function createServer(notesPath, options = {}) {
4881
5106
  "Force a full reindex of the vault",
4882
5107
  {},
4883
5108
  async () => {
4884
- if (indexingPromise) return textResponse("Reindex already in progress. " + indexingMessage());
4885
- await fullIndex();
4886
- return textResponse(
5109
+ if (ctx.isIndexingInFlight()) return ctx.textResponse("Reindex already in progress. " + ctx.indexingMessage());
5110
+ await ctx.fullIndex();
5111
+ const documents = ctx.getDocuments();
5112
+ return ctx.textResponse(
4887
5113
  `Reindexed: ${documents.length} notes, ${documents.reduce((n, d) => n + d.chunks.length, 0)} chunks`
4888
5114
  );
4889
5115
  }
4890
5116
  );
5117
+ }
5118
+
5119
+ // src/mcp/tools/inventory.ts
5120
+ var CURRENT_TOOL_INVENTORY = [
5121
+ // search
5122
+ { name: "search_semantic", description: "Vector similarity search \u2014 find notes similar to a query by meaning.", group: "search" },
5123
+ { name: "search_text", description: "Full-text keyword or regex search across all notes with optional filters.", group: "search" },
5124
+ { name: "search_graph", description: "Graph traversal \u2014 find notes connected to a concept via wikilinks, related_docs, and tags.", group: "search" },
5125
+ { name: "search_hybrid", description: "Combined semantic + graph search \u2014 vector results re-ranked by graph proximity and load_priority.", group: "search" },
5126
+ // read
5127
+ { name: "read_note", description: "Read the full content of a specific note by path.", group: "read" },
5128
+ { name: "read_multiple_notes", description: "Batch read multiple notes in one call.", group: "read", deprecated: true },
5129
+ { name: "list_notes", description: "List all indexed notes with metadata.", group: "read" },
5130
+ // write (CRUD)
5131
+ { name: "create_note", description: "Create a new markdown note.", group: "write" },
5132
+ { name: "update_note", description: "Edit note content \u2014 overwrite, append, prepend, or patch by heading.", group: "write" },
5133
+ { name: "delete_note", description: "Delete a note permanently.", group: "write" },
5134
+ { name: "move_note", description: "Move or rename a note \u2014 updates wikilinks across the vault.", group: "write" },
5135
+ // patch / synthesis
5136
+ { name: "apply_patch", description: "Atomic multi-note patch with rollback.", group: "patch" },
5137
+ { name: "synthesize_note", description: "Turn a researched answer + sources into a new filed note with provenance frontmatter. Pass proposal:true for review-first workflows.", group: "patch" },
5138
+ { name: "synthesize_promote", description: "Move a reviewed proposal note to its canonical destination, stripping proposal markers.", group: "patch" },
5139
+ { name: "ingest_source", description: "Build + apply a ChangeSet that ingests a source and the notes extracted from it.", group: "patch" },
5140
+ { name: "install_schema", description: "Bootstrap vault.schema.yml in the vault root with the default schema.", group: "patch" },
5141
+ // lint
5142
+ { name: "find_schema_violations", description: "Scan for schema violations.", group: "lint", deprecated: true },
5143
+ { name: "find_missing_provenance", description: "Scan for notes lacking sources/derived_from.", group: "lint", deprecated: true },
5144
+ { name: "find_stale", description: "Scan for notes whose last_verified is older than the threshold.", group: "lint", deprecated: true },
5145
+ { name: "find_broken_links", description: "Scan for [[wikilinks]] pointing to non-existent notes.", group: "lint", deprecated: true },
5146
+ { name: "lint_vault", description: "Run lint rules across the vault. Pass `checks` to filter to specific rules.", group: "lint" },
5147
+ // log
5148
+ { name: "log_event", description: "Append a structured event to the vault's log.md.", group: "log" },
5149
+ { name: "log_query", description: "Read structured log entries filtered by kind and/or date range.", group: "log" },
5150
+ { name: "regenerate_index", description: "Force regeneration of INDEX.md for one or more directories.", group: "log" },
5151
+ // metadata
5152
+ { name: "get_frontmatter", description: "Read parsed YAML frontmatter from a note as JSON.", group: "metadata" },
5153
+ { name: "update_frontmatter", description: "Set or delete YAML frontmatter keys.", group: "metadata" },
5154
+ { name: "manage_tags", description: "Add, remove, list, or rename tags on a note.", group: "metadata" },
5155
+ { name: "rename_tag", description: "Rename a tag across all notes.", group: "metadata", deprecated: true },
5156
+ // graph
5157
+ { name: "backlinks", description: "Find all notes that link TO a given note.", group: "graph" },
5158
+ { name: "forwardlinks", description: "Find all notes linked FROM a given note.", group: "graph" },
5159
+ { name: "graph_path", description: "Find the shortest path between two notes in the knowledge graph.", group: "graph" },
5160
+ { name: "graph_statistics", description: "Knowledge graph stats \u2014 most connected nodes, orphans, density.", group: "graph" },
5161
+ // system
5162
+ { name: "get_stats", description: "Vault and index statistics.", group: "system" },
5163
+ { name: "reindex", description: "Force a full reindex of the vault.", group: "system" },
5164
+ // contract (Phase 3)
5165
+ { name: "regenerate_contract", description: "Generate or refresh AGENTS.md at the project root with a managed-block contract.", group: "contract" },
5166
+ { name: "inspect_contract", description: "Read-only inspection of AGENTS.md state.", group: "contract" },
5167
+ // session (Phase 5)
5168
+ { name: "session_start", description: "Open a verification-gated session for a multi-step task.", group: "session" },
5169
+ { name: "session_run", description: "Run a verification command inside the active session, capture exit and tail.", group: "session" },
5170
+ { name: "session_finish", description: "Close the active session \u2014 hard-gated on verifications unless explicitly waived.", group: "session" },
5171
+ { name: "session_status", description: "Read-only inspection of the active session.", group: "session" }
5172
+ ];
5173
+
5174
+ // src/mcp/tools/contract.ts
5175
+ function registerContractTools(server, ctx) {
5176
+ if (ctx.options.readOnly) return;
5177
+ server.tool(
5178
+ "regenerate_contract",
5179
+ "Generate or refresh AGENTS.md at the project root with a managed-block contract. Preserves the 'Local Notes' tail across regenerations. Refuses to overwrite hand-edits inside the managed block unless force=true. Returns the path written and whether hand-edits were detected.",
5180
+ {
5181
+ projectRoot: external_exports.string().optional().describe("Override the project root path. Defaults to the parent of the vault directory if not provided."),
5182
+ force: external_exports.boolean().optional().default(false).describe("Overwrite hand-edited managed-block content.")
5183
+ },
5184
+ async ({ projectRoot, force }) => {
5185
+ const root = projectRoot ?? defaultProjectRoot(ctx.notesPath);
5186
+ const tools = CURRENT_TOOL_INVENTORY.map((t) => ({
5187
+ name: t.name,
5188
+ description: t.description,
5189
+ deprecated: t.deprecated
5190
+ }));
5191
+ const modes = [
5192
+ { name: "vault-first", description: "Project-scoped prose questions trigger vault consultation with cite-or-deflect.", isDefault: true },
5193
+ { name: "research", description: "Every source introduced is filed via ingest_source; mandatory synthesize_note on exit." },
5194
+ { name: "outage-silence", description: "No proactive vault search. Terse responses. Postmortem synthesize_note on exit." }
5195
+ ];
5196
+ const result = await regenerateAgentsContract({
5197
+ projectRoot: root,
5198
+ pluginVersion: "1.1.0",
5199
+ tools,
5200
+ modes,
5201
+ force
5202
+ });
5203
+ return ctx.textResponse(JSON.stringify(result, null, 2));
5204
+ }
5205
+ );
5206
+ server.tool(
5207
+ "inspect_contract",
5208
+ "Read-only inspection of AGENTS.md state \u2014 whether it exists, whether the managed-block markers are present, and how much Local Notes content is preserved.",
5209
+ {
5210
+ projectRoot: external_exports.string().optional().describe("Override the project root path. Defaults to the parent of the vault directory if not provided.")
5211
+ },
5212
+ async ({ projectRoot }) => {
5213
+ const root = projectRoot ?? defaultProjectRoot(ctx.notesPath);
5214
+ const result = await inspectAgentsContract(root);
5215
+ return ctx.textResponse(JSON.stringify(result, null, 2));
5216
+ }
5217
+ );
5218
+ }
5219
+ function defaultProjectRoot(notesPath) {
5220
+ if (notesPath.endsWith("/.claude/.vault") || notesPath.endsWith("\\.claude\\.vault")) {
5221
+ return notesPath.slice(0, -".claude/.vault".length);
5222
+ }
5223
+ return notesPath;
5224
+ }
5225
+
5226
+ // src/mcp/tools/session.ts
5227
+ function registerSessionTools(server, ctx) {
5228
+ if (ctx.options.readOnly) return;
5229
+ server.tool(
5230
+ "session_start",
5231
+ "Open a verification-gated session for a multi-step task. State persists at .claude/.semantic-memory/session.json. Refuses if a session is already open with a different task. Calling with the same task as an open session returns the existing id (idempotent).",
5232
+ {
5233
+ task: external_exports.string().describe("Short description of the task being worked on. Recorded in session state and surfaced at session_finish.")
5234
+ },
5235
+ async ({ task }) => {
5236
+ const result = await ctx.sessions.start(task);
5237
+ if (result.ok) {
5238
+ await logEvent(ctx.notesPath, {
5239
+ kind: "session",
5240
+ summary: result.reused ? `resumed session ${result.id}` : `started session ${result.id}`,
5241
+ payload: { tool: "session_start", id: result.id, task, reused: !!result.reused }
5242
+ }).catch(() => {
5243
+ });
5244
+ }
5245
+ return ctx.textResponse(JSON.stringify(result, null, 2));
5246
+ }
5247
+ );
5248
+ server.tool(
5249
+ "session_run",
5250
+ "Run a verification command inside the active session. Captures exit code, signal, duration, and a tail of stdout+stderr. Refused when no session is open. Default timeout: 5 minutes.",
5251
+ {
5252
+ cmd: external_exports.string().describe("Shell command to run, e.g. 'npm test' or 'go test ./...'."),
5253
+ timeout_ms: external_exports.coerce.number().int().positive().optional().describe("Override the default 5-minute timeout (in milliseconds).")
5254
+ },
5255
+ async ({ cmd, timeout_ms }) => {
5256
+ const result = await ctx.sessions.run(cmd, timeout_ms);
5257
+ if (result.ok) {
5258
+ await logEvent(ctx.notesPath, {
5259
+ kind: "session",
5260
+ summary: `ran ${cmd} \u2192 exit=${result.record.exit ?? "(killed)"}`,
5261
+ payload: {
5262
+ tool: "session_run",
5263
+ cmd,
5264
+ exit: result.record.exit,
5265
+ signal: result.record.signal,
5266
+ duration_ms: result.record.duration_ms
5267
+ }
5268
+ }).catch(() => {
5269
+ });
5270
+ }
5271
+ return ctx.textResponse(JSON.stringify(result, null, 2));
5272
+ }
5273
+ );
5274
+ server.tool(
5275
+ "session_finish",
5276
+ "Close the active session. HARD GATED: refused without recorded verifications unless verified=false is passed with a `reason` waiving verification (e.g. for prose-only edits). Deletes session state on success.",
5277
+ {
5278
+ summary: external_exports.string().describe("One-line description of what was accomplished. Appears in the closeout log entry."),
5279
+ verified: external_exports.boolean().optional().default(true).describe("Pass false to explicitly waive verification (requires `reason`)."),
5280
+ reason: external_exports.string().optional().describe("Required when verified=false. Why verification is not applicable to this task.")
5281
+ },
5282
+ async ({ summary, verified, reason }) => {
5283
+ const result = await ctx.sessions.finish({ summary, verified, reason });
5284
+ if (result.ok) {
5285
+ await logEvent(ctx.notesPath, {
5286
+ kind: "session",
5287
+ summary: `finished session ${result.closed.id}: ${summary}`,
5288
+ payload: {
5289
+ tool: "session_finish",
5290
+ id: result.closed.id,
5291
+ task: result.closed.task,
5292
+ verifications: result.closed.verifications.length,
5293
+ verified_waived: !!result.closed.closed_verified_waived,
5294
+ ...result.closed.closed_verified_waived && { waiver_reason: result.closed.closed_waiver_reason }
5295
+ }
5296
+ }).catch(() => {
5297
+ });
5298
+ }
5299
+ return ctx.textResponse(JSON.stringify(result, null, 2));
5300
+ }
5301
+ );
5302
+ server.tool(
5303
+ "session_status",
5304
+ "Read-only inspection of the active session \u2014 task, verifications, notes_touched, and a state label (active | stale | no_session). 'stale' indicates last_activity_at >24h ago.",
5305
+ {},
5306
+ async () => {
5307
+ const result = await ctx.sessions.status();
5308
+ return ctx.textResponse(JSON.stringify(result, null, 2));
5309
+ }
5310
+ );
5311
+ }
5312
+
5313
+ // src/mcp/server.ts
5314
+ async function createServer(notesPath, options = {}) {
5315
+ const ctx = await buildContext(notesPath, options);
5316
+ const server = new McpServer({
5317
+ name: "semantic-memory",
5318
+ version: "1.0.0"
5319
+ });
5320
+ registerSearchTools(server, ctx);
5321
+ registerReadTools(server, ctx);
5322
+ registerWriteTools(server, ctx);
5323
+ registerPatchTools(server, ctx);
5324
+ registerLintTools(server, ctx);
5325
+ registerLogTools(server, ctx);
5326
+ registerMetadataTools(server, ctx);
5327
+ registerGraphTools(server, ctx);
5328
+ registerSystemTools(server, ctx);
5329
+ registerContractTools(server, ctx);
5330
+ registerSessionTools(server, ctx);
4891
5331
  if (options.waitForReady) {
4892
- await tryLoadCachedIndex();
4893
- await fullIndex();
5332
+ await ctx.tryLoadCachedIndex();
5333
+ await ctx.fullIndex();
4894
5334
  } else {
4895
- tryLoadCachedIndex().then((cached) => {
4896
- if (!cached) backgroundIndex();
5335
+ ctx.tryLoadCachedIndex().then((cached) => {
5336
+ if (!cached) ctx.backgroundIndex();
4897
5337
  }).catch((err) => {
4898
5338
  process.stderr.write(`Startup error: ${err?.message ?? err}
4899
5339
  `);
4900
- backgroundIndex();
5340
+ ctx.backgroundIndex();
4901
5341
  });
4902
5342
  }
4903
5343
  if (options.watch !== false) {
4904
5344
  const watcher = new Watcher(notesPath);
4905
5345
  watcher.on("changed", () => {
4906
- backgroundIndex();
5346
+ ctx.backgroundIndex();
4907
5347
  });
4908
5348
  watcher.start();
4909
5349
  }