@sfrangulov/shared-memory-mcp 1.0.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/github-memory-server.js +1360 -0
- package/lib/atomic-commit.js +126 -0
- package/lib/github-client.js +220 -0
- package/lib/root-parser.js +323 -0
- package/lib/slugify.js +77 -0
- package/lib/state-manager.js +153 -0
- package/package.json +33 -0
|
@@ -0,0 +1,1360 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Shared Memory — MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Main entry point. Creates the MCP server, initializes Octokit + github-client
|
|
6
|
+
* + state-manager, and registers all 12 tools.
|
|
7
|
+
*
|
|
8
|
+
* @module github-memory-server
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
import { createOctokit, createGitHubClient } from "./lib/github-client.js";
|
|
16
|
+
import {
|
|
17
|
+
parseRootMd,
|
|
18
|
+
addEntryToRoot,
|
|
19
|
+
updateEntryInRoot,
|
|
20
|
+
} from "./lib/root-parser.js";
|
|
21
|
+
import { slugify, ensureUnique } from "./lib/slugify.js";
|
|
22
|
+
import { atomicCommitWithRetry } from "./lib/atomic-commit.js";
|
|
23
|
+
import { createStateManager } from "./lib/state-manager.js";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Server initialization
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const server = new McpServer({ name: "shared-memory", version: "1.0.0" });
|
|
30
|
+
|
|
31
|
+
const token = process.env.GITHUB_TOKEN;
|
|
32
|
+
const repoString = process.env.GITHUB_REPO;
|
|
33
|
+
|
|
34
|
+
const octokit = createOctokit(token);
|
|
35
|
+
const client = createGitHubClient({ octokit, repo: repoString });
|
|
36
|
+
const [repoOwner, repoName] = (repoString || "").split("/");
|
|
37
|
+
const stateManager = createStateManager(process.cwd());
|
|
38
|
+
|
|
39
|
+
// Session state
|
|
40
|
+
let sessionAuthor = null;
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Helper functions — error / success wrappers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function errorResult(error_code, error, retry_possible, retry_after_ms) {
|
|
47
|
+
const result = { status: "error", error_code, error, retry_possible };
|
|
48
|
+
if (retry_after_ms !== undefined) result.retry_after_ms = retry_after_ms;
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function successResult(data) {
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
55
|
+
structuredContent: data,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function errorResponse(error_code, error, retry_possible, retry_after_ms) {
|
|
60
|
+
const result = errorResult(error_code, error, retry_possible, retry_after_ms);
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
63
|
+
structuredContent: result,
|
|
64
|
+
isError: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function withErrorHandling(fn) {
|
|
69
|
+
try {
|
|
70
|
+
return await fn();
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err.status === 401)
|
|
73
|
+
return errorResponse("auth_failed", "Invalid or expired token", false);
|
|
74
|
+
if (err.status === 404)
|
|
75
|
+
return errorResponse(
|
|
76
|
+
"not_found",
|
|
77
|
+
err.message || "Resource not found",
|
|
78
|
+
false
|
|
79
|
+
);
|
|
80
|
+
if (err.status === 429) {
|
|
81
|
+
const retryAfter = err.response?.headers?.["retry-after"];
|
|
82
|
+
const ms = retryAfter ? parseInt(retryAfter) * 1000 : 60000;
|
|
83
|
+
return errorResponse("rate_limit_rest", "Rate limit exceeded", true, ms);
|
|
84
|
+
}
|
|
85
|
+
if (err.status === 403 && err.message?.includes("rate limit")) {
|
|
86
|
+
return errorResponse(
|
|
87
|
+
"rate_limit_search",
|
|
88
|
+
"Search rate limit exceeded",
|
|
89
|
+
true,
|
|
90
|
+
60000
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return errorResponse(
|
|
94
|
+
"network_error",
|
|
95
|
+
err.message || "Unknown error",
|
|
96
|
+
true
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Entry content helpers
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function buildEntryContent({ title, date, author, tags, content, related }) {
|
|
106
|
+
let md = `# ${title}\n\n`;
|
|
107
|
+
md += `- **Date:** ${date}\n`;
|
|
108
|
+
md += `- **Author:** ${author}\n`;
|
|
109
|
+
md += `- **Tags:** ${tags.join(", ")}\n\n`;
|
|
110
|
+
md += content;
|
|
111
|
+
if (related && related.length > 0) {
|
|
112
|
+
md += `\n\n## Related\n\n`;
|
|
113
|
+
for (const r of related) {
|
|
114
|
+
md += `- [${r}](${r})\n`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return md;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseEntryMetadata(content) {
|
|
121
|
+
const lines = content.split("\n");
|
|
122
|
+
const result = {
|
|
123
|
+
title: "",
|
|
124
|
+
date: "",
|
|
125
|
+
author: "",
|
|
126
|
+
tags: [],
|
|
127
|
+
content: "",
|
|
128
|
+
related: [],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Title from first # heading
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
if (line.startsWith("# ")) {
|
|
134
|
+
result.title = line.slice(2).trim();
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Metadata fields
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
const dateMatch = line.match(/^\s*-\s*\*\*Date:\*\*\s*(.+)/);
|
|
142
|
+
if (dateMatch) result.date = dateMatch[1].trim();
|
|
143
|
+
|
|
144
|
+
const authorMatch = line.match(/^\s*-\s*\*\*Author:\*\*\s*(.+)/);
|
|
145
|
+
if (authorMatch) result.author = authorMatch[1].trim();
|
|
146
|
+
|
|
147
|
+
const tagsMatch = line.match(/^\s*-\s*\*\*Tags:\*\*\s*(.+)/);
|
|
148
|
+
if (tagsMatch)
|
|
149
|
+
result.tags = tagsMatch[1]
|
|
150
|
+
.split(",")
|
|
151
|
+
.map((t) => t.trim())
|
|
152
|
+
.filter(Boolean);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Related section
|
|
156
|
+
const relatedIdx = content.indexOf("## Related");
|
|
157
|
+
if (relatedIdx !== -1) {
|
|
158
|
+
const relatedSection = content.slice(relatedIdx);
|
|
159
|
+
const linkRe = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
160
|
+
let match;
|
|
161
|
+
while ((match = linkRe.exec(relatedSection)) !== null) {
|
|
162
|
+
result.related.push(match[2]);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Content: everything between metadata and Related section (or end)
|
|
167
|
+
const tagsIdx = content.indexOf("**Tags:**");
|
|
168
|
+
const contentStart = tagsIdx !== -1 ? content.indexOf("\n\n", tagsIdx) : -1;
|
|
169
|
+
const contentEnd = relatedIdx !== -1 ? relatedIdx : content.length;
|
|
170
|
+
if (contentStart !== -1) {
|
|
171
|
+
result.content = content.slice(contentStart, contentEnd).trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function findRelated(entries, tags, excludeFile) {
|
|
178
|
+
return entries
|
|
179
|
+
.filter((e) => e.file !== excludeFile)
|
|
180
|
+
.map((e) => {
|
|
181
|
+
const commonTags = e.tags.filter((t) => tags.includes(t));
|
|
182
|
+
return {
|
|
183
|
+
file: e.file,
|
|
184
|
+
common_tags: commonTags,
|
|
185
|
+
match_count: commonTags.length,
|
|
186
|
+
};
|
|
187
|
+
})
|
|
188
|
+
.filter((r) => r.match_count >= 1)
|
|
189
|
+
.sort((a, b) => b.match_count - a.match_count);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Constants
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
const DEFAULT_META = `# Shared Memory Repository
|
|
197
|
+
|
|
198
|
+
This repository is managed by the Claude Shared Memory plugin.
|
|
199
|
+
|
|
200
|
+
## Configuration
|
|
201
|
+
|
|
202
|
+
- **Created:** ${new Date().toISOString().split("T")[0]}
|
|
203
|
+
- **Format version:** 1
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
const DEFAULT_SHARED_ROOT = `# Shared Knowledge
|
|
207
|
+
|
|
208
|
+
Cross-project knowledge available to all team members.
|
|
209
|
+
|
|
210
|
+
| Entry | Description | Tags |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
`;
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Stopwords for keyword overlap in check_duplicate
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
const STOPWORDS = new Set([
|
|
219
|
+
"a",
|
|
220
|
+
"an",
|
|
221
|
+
"the",
|
|
222
|
+
"is",
|
|
223
|
+
"are",
|
|
224
|
+
"was",
|
|
225
|
+
"were",
|
|
226
|
+
"be",
|
|
227
|
+
"been",
|
|
228
|
+
"being",
|
|
229
|
+
"have",
|
|
230
|
+
"has",
|
|
231
|
+
"had",
|
|
232
|
+
"do",
|
|
233
|
+
"does",
|
|
234
|
+
"did",
|
|
235
|
+
"will",
|
|
236
|
+
"would",
|
|
237
|
+
"shall",
|
|
238
|
+
"should",
|
|
239
|
+
"may",
|
|
240
|
+
"might",
|
|
241
|
+
"must",
|
|
242
|
+
"can",
|
|
243
|
+
"could",
|
|
244
|
+
"of",
|
|
245
|
+
"in",
|
|
246
|
+
"to",
|
|
247
|
+
"for",
|
|
248
|
+
"with",
|
|
249
|
+
"on",
|
|
250
|
+
"at",
|
|
251
|
+
"from",
|
|
252
|
+
"by",
|
|
253
|
+
"about",
|
|
254
|
+
"as",
|
|
255
|
+
"into",
|
|
256
|
+
"through",
|
|
257
|
+
"during",
|
|
258
|
+
"before",
|
|
259
|
+
"after",
|
|
260
|
+
"above",
|
|
261
|
+
"below",
|
|
262
|
+
"and",
|
|
263
|
+
"but",
|
|
264
|
+
"or",
|
|
265
|
+
"nor",
|
|
266
|
+
"not",
|
|
267
|
+
"so",
|
|
268
|
+
"yet",
|
|
269
|
+
"both",
|
|
270
|
+
"either",
|
|
271
|
+
"neither",
|
|
272
|
+
"each",
|
|
273
|
+
"every",
|
|
274
|
+
"all",
|
|
275
|
+
"any",
|
|
276
|
+
"few",
|
|
277
|
+
"more",
|
|
278
|
+
"most",
|
|
279
|
+
"other",
|
|
280
|
+
"some",
|
|
281
|
+
"such",
|
|
282
|
+
"no",
|
|
283
|
+
"only",
|
|
284
|
+
"own",
|
|
285
|
+
"same",
|
|
286
|
+
"than",
|
|
287
|
+
"too",
|
|
288
|
+
"very",
|
|
289
|
+
"just",
|
|
290
|
+
"because",
|
|
291
|
+
"if",
|
|
292
|
+
"when",
|
|
293
|
+
"how",
|
|
294
|
+
"what",
|
|
295
|
+
"which",
|
|
296
|
+
"who",
|
|
297
|
+
"whom",
|
|
298
|
+
"this",
|
|
299
|
+
"that",
|
|
300
|
+
"these",
|
|
301
|
+
"those",
|
|
302
|
+
"it",
|
|
303
|
+
"its",
|
|
304
|
+
"we",
|
|
305
|
+
"our",
|
|
306
|
+
"they",
|
|
307
|
+
"their",
|
|
308
|
+
"he",
|
|
309
|
+
"she",
|
|
310
|
+
"his",
|
|
311
|
+
"her",
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
function extractKeywords(text) {
|
|
315
|
+
return text
|
|
316
|
+
.toLowerCase()
|
|
317
|
+
.split(/\s+/)
|
|
318
|
+
.filter((w) => w.length > 1 && !STOPWORDS.has(w));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Tool 1: connect_repo
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
server.registerTool(
|
|
326
|
+
"connect_repo",
|
|
327
|
+
{
|
|
328
|
+
title: "Connect Repository",
|
|
329
|
+
description: "Connect to the shared memory GitHub repository",
|
|
330
|
+
inputSchema: z.object({}),
|
|
331
|
+
annotations: { readOnlyHint: false, idempotentHint: true },
|
|
332
|
+
},
|
|
333
|
+
async () => {
|
|
334
|
+
return withErrorHandling(async () => {
|
|
335
|
+
// 1. Get user info, cache sessionAuthor
|
|
336
|
+
const userInfo = await client.getUserInfo();
|
|
337
|
+
sessionAuthor = userInfo.name;
|
|
338
|
+
|
|
339
|
+
// 2. Get root directory listing (may fail on empty repo)
|
|
340
|
+
let rootItems;
|
|
341
|
+
let isEmptyRepo = false;
|
|
342
|
+
try {
|
|
343
|
+
rootItems = await client.getRootDirectoryListing();
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (
|
|
346
|
+
err.message?.toLowerCase().includes("empty") ||
|
|
347
|
+
err.status === 409
|
|
348
|
+
) {
|
|
349
|
+
isEmptyRepo = true;
|
|
350
|
+
rootItems = [];
|
|
351
|
+
} else {
|
|
352
|
+
throw err;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const rootNames = rootItems.map((item) => item.name);
|
|
357
|
+
const hasMeta = rootNames.includes("_meta.md");
|
|
358
|
+
const hasShared = rootNames.includes("_shared");
|
|
359
|
+
|
|
360
|
+
// 3-4. Cold start or partial init
|
|
361
|
+
if (!hasMeta || !hasShared) {
|
|
362
|
+
if (isEmptyRepo) {
|
|
363
|
+
// Empty repo — use Contents API (works without existing commits)
|
|
364
|
+
if (!hasMeta) {
|
|
365
|
+
await octokit.rest.repos.createOrUpdateFileContents({
|
|
366
|
+
owner: repoOwner,
|
|
367
|
+
repo: repoName,
|
|
368
|
+
path: "_meta.md",
|
|
369
|
+
message: "[shared-memory] init: create _meta.md",
|
|
370
|
+
content: Buffer.from(DEFAULT_META).toString("base64"),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
if (!hasShared) {
|
|
374
|
+
await octokit.rest.repos.createOrUpdateFileContents({
|
|
375
|
+
owner: repoOwner,
|
|
376
|
+
repo: repoName,
|
|
377
|
+
path: "_shared/root.md",
|
|
378
|
+
message: "[shared-memory] init: create _shared/root.md",
|
|
379
|
+
content: Buffer.from(DEFAULT_SHARED_ROOT).toString("base64"),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
} else {
|
|
383
|
+
// Repo has commits but missing structure — use atomicCommit
|
|
384
|
+
const files = [];
|
|
385
|
+
if (!hasMeta) {
|
|
386
|
+
files.push({ path: "_meta.md", content: DEFAULT_META });
|
|
387
|
+
}
|
|
388
|
+
if (!hasShared) {
|
|
389
|
+
files.push({
|
|
390
|
+
path: "_shared/root.md",
|
|
391
|
+
content: DEFAULT_SHARED_ROOT,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
await atomicCommitWithRetry(client, {
|
|
395
|
+
files,
|
|
396
|
+
message: "[shared-memory] init: initialize repository structure",
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return successResult({
|
|
401
|
+
status: "initialized",
|
|
402
|
+
user: userInfo,
|
|
403
|
+
projects: [],
|
|
404
|
+
shared_entries_count: 0,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 5. Read _shared/root.md, count entries
|
|
409
|
+
const sharedRoot = await client.getFileContent("_shared/root.md");
|
|
410
|
+
let sharedEntriesCount = 0;
|
|
411
|
+
if (sharedRoot) {
|
|
412
|
+
const parsed = parseRootMd(sharedRoot.content);
|
|
413
|
+
sharedEntriesCount = parsed.entries.length;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// 6. List projects (dirs excluding _shared, _meta.md)
|
|
417
|
+
const projects = rootItems
|
|
418
|
+
.filter(
|
|
419
|
+
(item) =>
|
|
420
|
+
item.type === "dir" &&
|
|
421
|
+
item.name !== "_shared" &&
|
|
422
|
+
!item.name.startsWith(".")
|
|
423
|
+
)
|
|
424
|
+
.map((item) => item.name);
|
|
425
|
+
|
|
426
|
+
return successResult({
|
|
427
|
+
status: "connected",
|
|
428
|
+
user: userInfo,
|
|
429
|
+
projects,
|
|
430
|
+
shared_entries_count: sharedEntriesCount,
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Tool 2: read_root
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
server.registerTool(
|
|
441
|
+
"read_root",
|
|
442
|
+
{
|
|
443
|
+
title: "Read Root Index",
|
|
444
|
+
description: "Read root.md index for a project",
|
|
445
|
+
inputSchema: z.object({
|
|
446
|
+
project: z
|
|
447
|
+
.string()
|
|
448
|
+
.describe("Project folder name: '_shared', 'mobile-app', etc."),
|
|
449
|
+
}),
|
|
450
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
451
|
+
},
|
|
452
|
+
async ({ project }) => {
|
|
453
|
+
return withErrorHandling(async () => {
|
|
454
|
+
const file = await client.getFileContent(`${project}/root.md`);
|
|
455
|
+
if (!file) {
|
|
456
|
+
return errorResponse(
|
|
457
|
+
"not_found",
|
|
458
|
+
`root.md not found in project "${project}"`,
|
|
459
|
+
false
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const parsed = parseRootMd(file.content);
|
|
464
|
+
|
|
465
|
+
// If corrupted, fallback: list directory files
|
|
466
|
+
if (parsed.corrupted) {
|
|
467
|
+
const dirFiles = await client.getDirectoryListing(project);
|
|
468
|
+
const entries = dirFiles
|
|
469
|
+
.filter((f) => f !== "root.md")
|
|
470
|
+
.map((f) => ({ file: f }));
|
|
471
|
+
return successResult({
|
|
472
|
+
project,
|
|
473
|
+
description: "",
|
|
474
|
+
entries,
|
|
475
|
+
corrupted: true,
|
|
476
|
+
raw_markdown: file.content,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return successResult({
|
|
481
|
+
project,
|
|
482
|
+
description: parsed.description,
|
|
483
|
+
entries: parsed.entries,
|
|
484
|
+
raw_markdown: file.content,
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Tool 3: read_entry
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
server.registerTool(
|
|
495
|
+
"read_entry",
|
|
496
|
+
{
|
|
497
|
+
title: "Read Entry",
|
|
498
|
+
description: "Read a specific entry from shared memory",
|
|
499
|
+
inputSchema: z.object({
|
|
500
|
+
project: z.string().describe("Project folder name"),
|
|
501
|
+
file: z.string().describe("Entry filename (e.g. 'rive-vs-lottie.md')"),
|
|
502
|
+
}),
|
|
503
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
504
|
+
},
|
|
505
|
+
async ({ project, file }) => {
|
|
506
|
+
return withErrorHandling(async () => {
|
|
507
|
+
const result = await client.getFileContent(`${project}/${file}`);
|
|
508
|
+
if (!result) {
|
|
509
|
+
return errorResponse(
|
|
510
|
+
"not_found",
|
|
511
|
+
`Entry "${file}" not found in project "${project}"`,
|
|
512
|
+
false
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const meta = parseEntryMetadata(result.content);
|
|
517
|
+
return successResult({
|
|
518
|
+
title: meta.title,
|
|
519
|
+
date: meta.date,
|
|
520
|
+
author: meta.author,
|
|
521
|
+
tags: meta.tags,
|
|
522
|
+
content: meta.content,
|
|
523
|
+
sha: result.sha,
|
|
524
|
+
related: meta.related,
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// Tool 4: write_entry
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
server.registerTool(
|
|
535
|
+
"write_entry",
|
|
536
|
+
{
|
|
537
|
+
title: "Write Entry",
|
|
538
|
+
description: "Create a new entry in shared memory",
|
|
539
|
+
inputSchema: z.object({
|
|
540
|
+
project: z.string().describe("Project folder name"),
|
|
541
|
+
title: z.string().describe("Entry title"),
|
|
542
|
+
content: z.string().describe("Entry content (markdown)"),
|
|
543
|
+
tags: z.array(z.string()).describe("Tags for the entry"),
|
|
544
|
+
description: z
|
|
545
|
+
.string()
|
|
546
|
+
.max(80)
|
|
547
|
+
.describe("Brief description for root.md (max 80 chars)"),
|
|
548
|
+
auto_related: z
|
|
549
|
+
.boolean()
|
|
550
|
+
.optional()
|
|
551
|
+
.default(true)
|
|
552
|
+
.describe("Auto-discover Related links"),
|
|
553
|
+
related_override: z
|
|
554
|
+
.array(z.string())
|
|
555
|
+
.optional()
|
|
556
|
+
.describe("Manual override of Related list"),
|
|
557
|
+
}),
|
|
558
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
559
|
+
},
|
|
560
|
+
async ({
|
|
561
|
+
project,
|
|
562
|
+
title,
|
|
563
|
+
content,
|
|
564
|
+
tags,
|
|
565
|
+
description,
|
|
566
|
+
auto_related,
|
|
567
|
+
related_override,
|
|
568
|
+
}) => {
|
|
569
|
+
return withErrorHandling(async () => {
|
|
570
|
+
// 1. Read root.md
|
|
571
|
+
const rootFile = await client.getFileContent(`${project}/root.md`);
|
|
572
|
+
if (!rootFile) {
|
|
573
|
+
return errorResponse(
|
|
574
|
+
"not_found",
|
|
575
|
+
`root.md not found in project "${project}"`,
|
|
576
|
+
false
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// 2. Generate slug, ensure unique
|
|
581
|
+
const existingFiles = await client.getDirectoryListing(project);
|
|
582
|
+
const baseSlug = slugify(title);
|
|
583
|
+
const uniqueSlug = ensureUnique(baseSlug, existingFiles);
|
|
584
|
+
const fileName = `${uniqueSlug}.md`;
|
|
585
|
+
|
|
586
|
+
// 3. Determine related links
|
|
587
|
+
let relatedLinks = [];
|
|
588
|
+
let relatedCandidates = [];
|
|
589
|
+
|
|
590
|
+
if (related_override) {
|
|
591
|
+
relatedLinks = related_override;
|
|
592
|
+
} else if (auto_related) {
|
|
593
|
+
// Gather entries from project + _shared
|
|
594
|
+
const projectParsed = parseRootMd(rootFile.content);
|
|
595
|
+
let allEntries = projectParsed.entries.map((e) => ({
|
|
596
|
+
...e,
|
|
597
|
+
file: `${e.file}`,
|
|
598
|
+
project,
|
|
599
|
+
}));
|
|
600
|
+
|
|
601
|
+
// Also read _shared entries
|
|
602
|
+
if (project !== "_shared") {
|
|
603
|
+
const sharedRoot = await client.getFileContent("_shared/root.md");
|
|
604
|
+
if (sharedRoot) {
|
|
605
|
+
const sharedParsed = parseRootMd(sharedRoot.content);
|
|
606
|
+
const sharedEntries = sharedParsed.entries.map((e) => ({
|
|
607
|
+
...e,
|
|
608
|
+
file: `../_shared/${e.file}`,
|
|
609
|
+
project: "_shared",
|
|
610
|
+
}));
|
|
611
|
+
allEntries = allEntries.concat(sharedEntries);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const related = findRelated(allEntries, tags, fileName);
|
|
616
|
+
if (related.length <= 3) {
|
|
617
|
+
relatedLinks = related.map((r) => r.file);
|
|
618
|
+
} else {
|
|
619
|
+
// Add top 3, return rest as candidates
|
|
620
|
+
relatedLinks = related.slice(0, 3).map((r) => r.file);
|
|
621
|
+
relatedCandidates = related.slice(3).map((r) => ({
|
|
622
|
+
file: r.file,
|
|
623
|
+
common_tags: r.common_tags,
|
|
624
|
+
}));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// 4. Build entry content
|
|
629
|
+
const date = new Date().toISOString().split("T")[0];
|
|
630
|
+
const author = sessionAuthor || "Unknown";
|
|
631
|
+
const entryContent = buildEntryContent({
|
|
632
|
+
title,
|
|
633
|
+
date,
|
|
634
|
+
author,
|
|
635
|
+
tags,
|
|
636
|
+
content,
|
|
637
|
+
related: relatedLinks,
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// 5. Update root.md
|
|
641
|
+
const { updated_markdown } = addEntryToRoot(rootFile.content, {
|
|
642
|
+
file: fileName,
|
|
643
|
+
name: title,
|
|
644
|
+
description,
|
|
645
|
+
tags,
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// 6. Atomic commit
|
|
649
|
+
const commitResult = await atomicCommitWithRetry(client, {
|
|
650
|
+
files: [
|
|
651
|
+
{ path: `${project}/${fileName}`, content: entryContent },
|
|
652
|
+
{ path: `${project}/root.md`, content: updated_markdown },
|
|
653
|
+
],
|
|
654
|
+
message: `[shared-memory] create-entry: ${title}`,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
if (!commitResult.success) {
|
|
658
|
+
return errorResponse(
|
|
659
|
+
"sha_conflict",
|
|
660
|
+
"Failed to commit after retries",
|
|
661
|
+
true
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// 7. Invalidate author cache for this project
|
|
666
|
+
stateManager.invalidateAuthorCache(project);
|
|
667
|
+
|
|
668
|
+
const result = {
|
|
669
|
+
status: "created",
|
|
670
|
+
file: fileName,
|
|
671
|
+
commit_sha: commitResult.commitSHA,
|
|
672
|
+
related_added: relatedLinks,
|
|
673
|
+
};
|
|
674
|
+
if (relatedCandidates.length > 0) {
|
|
675
|
+
result.related_candidates = relatedCandidates;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return successResult(result);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
// Tool 5: update_entry
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
server.registerTool(
|
|
688
|
+
"update_entry",
|
|
689
|
+
{
|
|
690
|
+
title: "Update Entry",
|
|
691
|
+
description: "Update an existing entry in shared memory",
|
|
692
|
+
inputSchema: z.object({
|
|
693
|
+
project: z.string().describe("Project folder name"),
|
|
694
|
+
file: z.string().describe("Entry filename"),
|
|
695
|
+
previous_sha: z
|
|
696
|
+
.string()
|
|
697
|
+
.describe("SHA from read_entry (for conflict detection)"),
|
|
698
|
+
new_content: z.string().describe("Updated content"),
|
|
699
|
+
new_tags: z.array(z.string()).optional().describe("Updated tags"),
|
|
700
|
+
new_description: z
|
|
701
|
+
.string()
|
|
702
|
+
.optional()
|
|
703
|
+
.describe("Updated description for root.md"),
|
|
704
|
+
}),
|
|
705
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
706
|
+
},
|
|
707
|
+
async ({ project, file, previous_sha, new_content, new_tags, new_description }) => {
|
|
708
|
+
return withErrorHandling(async () => {
|
|
709
|
+
// 1. Re-read current file
|
|
710
|
+
const current = await client.getFileContent(`${project}/${file}`);
|
|
711
|
+
if (!current) {
|
|
712
|
+
return errorResponse(
|
|
713
|
+
"not_found",
|
|
714
|
+
`Entry "${file}" not found in project "${project}"`,
|
|
715
|
+
false
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// 2. Compare SHA
|
|
720
|
+
if (current.sha !== previous_sha) {
|
|
721
|
+
// Concurrent edit detected
|
|
722
|
+
const currentMeta = parseEntryMetadata(current.content);
|
|
723
|
+
const lastCommit = await client.getLastCommitForFile(
|
|
724
|
+
`${project}/${file}`
|
|
725
|
+
);
|
|
726
|
+
return successResult({
|
|
727
|
+
status: "concurrent_edit",
|
|
728
|
+
current_sha: current.sha,
|
|
729
|
+
previous_author: lastCommit?.author || "unknown",
|
|
730
|
+
previous_date: lastCommit?.date || null,
|
|
731
|
+
diff_summary: `Entry was modified since your last read. Current author: ${currentMeta.author}`,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// 3. Parse current entry, rebuild with new content
|
|
736
|
+
const currentMeta = parseEntryMetadata(current.content);
|
|
737
|
+
const updatedTags = new_tags || currentMeta.tags;
|
|
738
|
+
const author = currentMeta.author || sessionAuthor || "Unknown";
|
|
739
|
+
const date = currentMeta.date || new Date().toISOString().split("T")[0];
|
|
740
|
+
|
|
741
|
+
// Recalculate related if tags changed
|
|
742
|
+
let relatedLinks = currentMeta.related;
|
|
743
|
+
if (new_tags) {
|
|
744
|
+
// Re-discover related with new tags
|
|
745
|
+
const rootFile = await client.getFileContent(`${project}/root.md`);
|
|
746
|
+
if (rootFile) {
|
|
747
|
+
const projectParsed = parseRootMd(rootFile.content);
|
|
748
|
+
let allEntries = projectParsed.entries.map((e) => ({
|
|
749
|
+
...e,
|
|
750
|
+
file: `${e.file}`,
|
|
751
|
+
project,
|
|
752
|
+
}));
|
|
753
|
+
|
|
754
|
+
if (project !== "_shared") {
|
|
755
|
+
const sharedRoot = await client.getFileContent("_shared/root.md");
|
|
756
|
+
if (sharedRoot) {
|
|
757
|
+
const sharedParsed = parseRootMd(sharedRoot.content);
|
|
758
|
+
const sharedEntries = sharedParsed.entries.map((e) => ({
|
|
759
|
+
...e,
|
|
760
|
+
file: `../_shared/${e.file}`,
|
|
761
|
+
project: "_shared",
|
|
762
|
+
}));
|
|
763
|
+
allEntries = allEntries.concat(sharedEntries);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const related = findRelated(allEntries, new_tags, file);
|
|
768
|
+
relatedLinks = related.slice(0, 3).map((r) => r.file);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const updatedContent = buildEntryContent({
|
|
773
|
+
title: currentMeta.title,
|
|
774
|
+
date,
|
|
775
|
+
author,
|
|
776
|
+
tags: updatedTags,
|
|
777
|
+
content: new_content,
|
|
778
|
+
related: relatedLinks,
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// 4. Update root.md if tags/description changed
|
|
782
|
+
const filesToCommit = [
|
|
783
|
+
{ path: `${project}/${file}`, content: updatedContent },
|
|
784
|
+
];
|
|
785
|
+
|
|
786
|
+
if (new_tags || new_description) {
|
|
787
|
+
const rootFile = await client.getFileContent(`${project}/root.md`);
|
|
788
|
+
if (rootFile) {
|
|
789
|
+
const changes = {};
|
|
790
|
+
if (new_tags) changes.tags = new_tags;
|
|
791
|
+
if (new_description) changes.description = new_description;
|
|
792
|
+
const updatedRoot = updateEntryInRoot(
|
|
793
|
+
rootFile.content,
|
|
794
|
+
file,
|
|
795
|
+
changes
|
|
796
|
+
);
|
|
797
|
+
filesToCommit.push({
|
|
798
|
+
path: `${project}/root.md`,
|
|
799
|
+
content: updatedRoot,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// 5. Atomic commit
|
|
805
|
+
const commitResult = await atomicCommitWithRetry(client, {
|
|
806
|
+
files: filesToCommit,
|
|
807
|
+
message: `[shared-memory] update-entry: ${currentMeta.title}`,
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
if (!commitResult.success) {
|
|
811
|
+
return errorResponse(
|
|
812
|
+
"sha_conflict",
|
|
813
|
+
"Failed to commit after retries",
|
|
814
|
+
true
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// 6. Invalidate author cache
|
|
819
|
+
stateManager.invalidateAuthorCache(project);
|
|
820
|
+
|
|
821
|
+
return successResult({
|
|
822
|
+
status: "updated",
|
|
823
|
+
commit_sha: commitResult.commitSHA,
|
|
824
|
+
});
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
// ---------------------------------------------------------------------------
|
|
830
|
+
// Tool 6: search_tags
|
|
831
|
+
// ---------------------------------------------------------------------------
|
|
832
|
+
|
|
833
|
+
server.registerTool(
|
|
834
|
+
"search_tags",
|
|
835
|
+
{
|
|
836
|
+
title: "Search by Tags",
|
|
837
|
+
description: "Search entries by tags and description keywords",
|
|
838
|
+
inputSchema: z.object({
|
|
839
|
+
keywords: z
|
|
840
|
+
.array(z.string())
|
|
841
|
+
.describe("Keywords to match against tags and descriptions"),
|
|
842
|
+
active_project: z
|
|
843
|
+
.string()
|
|
844
|
+
.optional()
|
|
845
|
+
.describe("Active project for prioritization"),
|
|
846
|
+
}),
|
|
847
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
848
|
+
},
|
|
849
|
+
async ({ keywords, active_project }) => {
|
|
850
|
+
return withErrorHandling(async () => {
|
|
851
|
+
// 1. Get all projects
|
|
852
|
+
const rootItems = await client.getRootDirectoryListing();
|
|
853
|
+
const projectDirs = rootItems
|
|
854
|
+
.filter(
|
|
855
|
+
(item) =>
|
|
856
|
+
item.type === "dir" && !item.name.startsWith(".")
|
|
857
|
+
)
|
|
858
|
+
.map((item) => item.name);
|
|
859
|
+
|
|
860
|
+
// Ensure _shared is included
|
|
861
|
+
if (!projectDirs.includes("_shared")) {
|
|
862
|
+
projectDirs.push("_shared");
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// 2. Read all root.md files in parallel
|
|
866
|
+
const rootContents = await Promise.all(
|
|
867
|
+
projectDirs.map(async (proj) => {
|
|
868
|
+
const file = await client.getFileContent(`${proj}/root.md`);
|
|
869
|
+
if (!file) return { project: proj, entries: [] };
|
|
870
|
+
const parsed = parseRootMd(file.content);
|
|
871
|
+
return {
|
|
872
|
+
project: proj,
|
|
873
|
+
entries: parsed.entries,
|
|
874
|
+
};
|
|
875
|
+
})
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
// 3. Match keywords
|
|
879
|
+
const lowerKeywords = keywords.map((k) => k.toLowerCase());
|
|
880
|
+
const allResults = [];
|
|
881
|
+
|
|
882
|
+
for (const { project: proj, entries } of rootContents) {
|
|
883
|
+
for (const entry of entries) {
|
|
884
|
+
const matchDetails = [];
|
|
885
|
+
|
|
886
|
+
// Exact tag match
|
|
887
|
+
for (const kw of lowerKeywords) {
|
|
888
|
+
if (entry.tags.some((t) => t.toLowerCase() === kw)) {
|
|
889
|
+
matchDetails.push(`tag:${kw}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Substring match in description
|
|
894
|
+
const lowerDesc = entry.description.toLowerCase();
|
|
895
|
+
for (const kw of lowerKeywords) {
|
|
896
|
+
if (
|
|
897
|
+
lowerDesc.includes(kw) &&
|
|
898
|
+
!matchDetails.includes(`tag:${kw}`)
|
|
899
|
+
) {
|
|
900
|
+
matchDetails.push(`desc:${kw}`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (matchDetails.length > 0) {
|
|
905
|
+
allResults.push({
|
|
906
|
+
project: proj,
|
|
907
|
+
file: entry.file,
|
|
908
|
+
name: entry.name,
|
|
909
|
+
description: entry.description,
|
|
910
|
+
tags: entry.tags,
|
|
911
|
+
match_count: matchDetails.length,
|
|
912
|
+
match_details: matchDetails,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// 4. Rank: match_count DESC, then priority
|
|
919
|
+
allResults.sort((a, b) => {
|
|
920
|
+
if (b.match_count !== a.match_count)
|
|
921
|
+
return b.match_count - a.match_count;
|
|
922
|
+
|
|
923
|
+
// Priority: active > _shared > other active > archived
|
|
924
|
+
const priority = (proj) => {
|
|
925
|
+
if (proj === active_project) return 0;
|
|
926
|
+
if (proj === "_shared") return 1;
|
|
927
|
+
return 2;
|
|
928
|
+
};
|
|
929
|
+
return priority(a.project) - priority(b.project);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
// 5. Cap at 15
|
|
933
|
+
const total_count = allResults.length;
|
|
934
|
+
const was_truncated = total_count > 15;
|
|
935
|
+
const results = allResults.slice(0, 15);
|
|
936
|
+
|
|
937
|
+
return successResult({
|
|
938
|
+
results,
|
|
939
|
+
total_count,
|
|
940
|
+
was_truncated,
|
|
941
|
+
searched_projects: projectDirs,
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
);
|
|
946
|
+
|
|
947
|
+
// ---------------------------------------------------------------------------
|
|
948
|
+
// Tool 7: search_author
|
|
949
|
+
// ---------------------------------------------------------------------------
|
|
950
|
+
|
|
951
|
+
server.registerTool(
|
|
952
|
+
"search_author",
|
|
953
|
+
{
|
|
954
|
+
title: "Search by Author",
|
|
955
|
+
description: "Search entries by author name",
|
|
956
|
+
inputSchema: z.object({
|
|
957
|
+
author_query: z.string().describe("Author name to search for"),
|
|
958
|
+
project: z
|
|
959
|
+
.string()
|
|
960
|
+
.optional()
|
|
961
|
+
.describe("Search only in this project"),
|
|
962
|
+
}),
|
|
963
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
964
|
+
},
|
|
965
|
+
async ({ author_query, project }) => {
|
|
966
|
+
return withErrorHandling(async () => {
|
|
967
|
+
// Determine which projects to search
|
|
968
|
+
let projectsToSearch;
|
|
969
|
+
if (project) {
|
|
970
|
+
projectsToSearch = [project];
|
|
971
|
+
} else {
|
|
972
|
+
const rootItems = await client.getRootDirectoryListing();
|
|
973
|
+
projectsToSearch = rootItems
|
|
974
|
+
.filter(
|
|
975
|
+
(item) =>
|
|
976
|
+
item.type === "dir" && !item.name.startsWith(".")
|
|
977
|
+
)
|
|
978
|
+
.map((item) => item.name);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const results = [];
|
|
982
|
+
let usedCache = false;
|
|
983
|
+
|
|
984
|
+
for (const proj of projectsToSearch) {
|
|
985
|
+
// Check cache
|
|
986
|
+
let authorIndex = stateManager.getAuthorCache(proj);
|
|
987
|
+
|
|
988
|
+
if (!authorIndex) {
|
|
989
|
+
// Cache miss — read all entry metadata
|
|
990
|
+
authorIndex = {};
|
|
991
|
+
const rootFile = await client.getFileContent(`${proj}/root.md`);
|
|
992
|
+
if (!rootFile) continue;
|
|
993
|
+
|
|
994
|
+
const parsed = parseRootMd(rootFile.content);
|
|
995
|
+
|
|
996
|
+
// Read entry metadata in parallel
|
|
997
|
+
const metaResults = await Promise.all(
|
|
998
|
+
parsed.entries.map(async (entry) => {
|
|
999
|
+
if (!entry.file) return null;
|
|
1000
|
+
const fileContent = await client.getFileContent(
|
|
1001
|
+
`${proj}/${entry.file}`
|
|
1002
|
+
);
|
|
1003
|
+
if (!fileContent) return null;
|
|
1004
|
+
const meta = parseEntryMetadata(fileContent.content);
|
|
1005
|
+
return {
|
|
1006
|
+
file: entry.file,
|
|
1007
|
+
title: meta.title,
|
|
1008
|
+
author: meta.author,
|
|
1009
|
+
date: meta.date,
|
|
1010
|
+
};
|
|
1011
|
+
})
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
for (const m of metaResults) {
|
|
1015
|
+
if (m) {
|
|
1016
|
+
authorIndex[m.file] = {
|
|
1017
|
+
title: m.title,
|
|
1018
|
+
author: m.author,
|
|
1019
|
+
date: m.date,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Update cache
|
|
1025
|
+
stateManager.setAuthorCache(proj, authorIndex);
|
|
1026
|
+
} else {
|
|
1027
|
+
usedCache = true;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Substring match on author
|
|
1031
|
+
const lowerQuery = author_query.toLowerCase();
|
|
1032
|
+
for (const [fileName, meta] of Object.entries(authorIndex)) {
|
|
1033
|
+
if (meta.author.toLowerCase().includes(lowerQuery)) {
|
|
1034
|
+
results.push({
|
|
1035
|
+
project: proj,
|
|
1036
|
+
file: fileName,
|
|
1037
|
+
title: meta.title,
|
|
1038
|
+
author: meta.author,
|
|
1039
|
+
date: meta.date,
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
return successResult({
|
|
1046
|
+
results,
|
|
1047
|
+
total_count: results.length,
|
|
1048
|
+
cached: usedCache,
|
|
1049
|
+
warning:
|
|
1050
|
+
"Searching by author requires reading files and may take a few seconds",
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
// ---------------------------------------------------------------------------
|
|
1057
|
+
// Tool 8: search_deep
|
|
1058
|
+
// ---------------------------------------------------------------------------
|
|
1059
|
+
|
|
1060
|
+
server.registerTool(
|
|
1061
|
+
"search_deep",
|
|
1062
|
+
{
|
|
1063
|
+
title: "Deep Search",
|
|
1064
|
+
description: "Full-text search across all entries using GitHub Search API",
|
|
1065
|
+
inputSchema: z.object({
|
|
1066
|
+
query: z.string().describe("Full-text search query"),
|
|
1067
|
+
}),
|
|
1068
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
1069
|
+
},
|
|
1070
|
+
async ({ query }) => {
|
|
1071
|
+
return withErrorHandling(async () => {
|
|
1072
|
+
const items = await client.searchCode(query);
|
|
1073
|
+
|
|
1074
|
+
// Filter out root.md and _meta.md
|
|
1075
|
+
const filtered = items.filter((item) => {
|
|
1076
|
+
const name = item.name || "";
|
|
1077
|
+
return name !== "root.md" && name !== "_meta.md";
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
const results = filtered.map((item) => {
|
|
1081
|
+
// Parse project from path
|
|
1082
|
+
const pathParts = item.path.split("/");
|
|
1083
|
+
const projectName = pathParts.length > 1 ? pathParts[0] : "";
|
|
1084
|
+
const fileName =
|
|
1085
|
+
pathParts.length > 1 ? pathParts.slice(1).join("/") : item.path;
|
|
1086
|
+
|
|
1087
|
+
return {
|
|
1088
|
+
project: projectName,
|
|
1089
|
+
file: fileName,
|
|
1090
|
+
match_fragment: item.text_matches
|
|
1091
|
+
? item.text_matches.map((m) => m.fragment).join(" ... ")
|
|
1092
|
+
: "",
|
|
1093
|
+
};
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
return successResult({
|
|
1097
|
+
results,
|
|
1098
|
+
total_count: results.length,
|
|
1099
|
+
warning:
|
|
1100
|
+
"GitHub Search API has an indexing delay of 30-60 seconds. Very recent changes may not appear.",
|
|
1101
|
+
});
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
// ---------------------------------------------------------------------------
|
|
1107
|
+
// Tool 9: list_projects
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
|
|
1110
|
+
server.registerTool(
|
|
1111
|
+
"list_projects",
|
|
1112
|
+
{
|
|
1113
|
+
title: "List Projects",
|
|
1114
|
+
description: "List all projects in the shared memory repository",
|
|
1115
|
+
inputSchema: z.object({}),
|
|
1116
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
1117
|
+
},
|
|
1118
|
+
async () => {
|
|
1119
|
+
return withErrorHandling(async () => {
|
|
1120
|
+
// 1. Get root directory listing
|
|
1121
|
+
const rootItems = await client.getRootDirectoryListing();
|
|
1122
|
+
const projectDirs = rootItems
|
|
1123
|
+
.filter(
|
|
1124
|
+
(item) =>
|
|
1125
|
+
item.type === "dir" &&
|
|
1126
|
+
item.name !== "_shared" &&
|
|
1127
|
+
!item.name.startsWith(".")
|
|
1128
|
+
)
|
|
1129
|
+
.map((item) => item.name);
|
|
1130
|
+
|
|
1131
|
+
// 2. For each project, read root.md and count entries
|
|
1132
|
+
const projects = await Promise.all(
|
|
1133
|
+
projectDirs.map(async (name) => {
|
|
1134
|
+
const rootFile = await client.getFileContent(`${name}/root.md`);
|
|
1135
|
+
let entries_count = 0;
|
|
1136
|
+
if (rootFile) {
|
|
1137
|
+
const parsed = parseRootMd(rootFile.content);
|
|
1138
|
+
entries_count = parsed.entries.length;
|
|
1139
|
+
}
|
|
1140
|
+
return { name, entries_count };
|
|
1141
|
+
})
|
|
1142
|
+
);
|
|
1143
|
+
|
|
1144
|
+
// 3. Get state for active_project
|
|
1145
|
+
const state = await stateManager.readState();
|
|
1146
|
+
|
|
1147
|
+
return successResult({
|
|
1148
|
+
projects,
|
|
1149
|
+
archived: [],
|
|
1150
|
+
active_project: state.active_project,
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
);
|
|
1155
|
+
|
|
1156
|
+
// ---------------------------------------------------------------------------
|
|
1157
|
+
// Tool 10: switch_project
|
|
1158
|
+
// ---------------------------------------------------------------------------
|
|
1159
|
+
|
|
1160
|
+
server.registerTool(
|
|
1161
|
+
"switch_project",
|
|
1162
|
+
{
|
|
1163
|
+
title: "Switch Project",
|
|
1164
|
+
description: "Switch active project or create a new one",
|
|
1165
|
+
inputSchema: z.object({
|
|
1166
|
+
project: z.string().describe("Project name to switch to or create"),
|
|
1167
|
+
}),
|
|
1168
|
+
annotations: { readOnlyHint: false, idempotentHint: true },
|
|
1169
|
+
},
|
|
1170
|
+
async ({ project }) => {
|
|
1171
|
+
return withErrorHandling(async () => {
|
|
1172
|
+
// 1. Slugify project name
|
|
1173
|
+
const projectSlug = slugify(project);
|
|
1174
|
+
|
|
1175
|
+
// 2. Check if folder exists
|
|
1176
|
+
const rootFile = await client.getFileContent(
|
|
1177
|
+
`${projectSlug}/root.md`
|
|
1178
|
+
);
|
|
1179
|
+
|
|
1180
|
+
if (rootFile) {
|
|
1181
|
+
// 3. Exists — read root.md, compute summary, update state
|
|
1182
|
+
const parsed = parseRootMd(rootFile.content);
|
|
1183
|
+
const entries_count = parsed.entries.length;
|
|
1184
|
+
|
|
1185
|
+
// Determine last entry date
|
|
1186
|
+
let last_entry_date = null;
|
|
1187
|
+
if (entries_count > 0) {
|
|
1188
|
+
const lastEntry = parsed.entries[entries_count - 1];
|
|
1189
|
+
// Try to find date in description
|
|
1190
|
+
const dateMatch = lastEntry.description.match(
|
|
1191
|
+
/(\d{4}-\d{2}-\d{2})/
|
|
1192
|
+
);
|
|
1193
|
+
if (dateMatch) {
|
|
1194
|
+
last_entry_date = dateMatch[1];
|
|
1195
|
+
} else if (lastEntry.file) {
|
|
1196
|
+
// Fall back to git commit date
|
|
1197
|
+
const commitInfo = await client.getLastCommitForFile(
|
|
1198
|
+
`${projectSlug}/${lastEntry.file}`
|
|
1199
|
+
);
|
|
1200
|
+
if (commitInfo?.date) {
|
|
1201
|
+
last_entry_date = commitInfo.date.split("T")[0];
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Build summary
|
|
1207
|
+
let summary;
|
|
1208
|
+
if (entries_count === 0) {
|
|
1209
|
+
summary = `Project ${projectSlug}: empty for now. Create the first entry to get started`;
|
|
1210
|
+
} else if (last_entry_date) {
|
|
1211
|
+
summary = `Project ${projectSlug}: ${entries_count} entries, last — ${last_entry_date}`;
|
|
1212
|
+
} else {
|
|
1213
|
+
summary = `Project ${projectSlug}: ${entries_count} entries`;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Update state
|
|
1217
|
+
await stateManager.writeState({
|
|
1218
|
+
active_project: projectSlug,
|
|
1219
|
+
version: 1,
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
// Invalidate author cache (state change)
|
|
1223
|
+
stateManager.invalidateAuthorCache();
|
|
1224
|
+
|
|
1225
|
+
return successResult({
|
|
1226
|
+
status: "switched",
|
|
1227
|
+
project: projectSlug,
|
|
1228
|
+
entries_count,
|
|
1229
|
+
last_entry_date,
|
|
1230
|
+
summary,
|
|
1231
|
+
root_content: {
|
|
1232
|
+
description: parsed.description,
|
|
1233
|
+
entries: parsed.entries,
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// 4. Project does not exist — return not_found
|
|
1239
|
+
return successResult({
|
|
1240
|
+
status: "not_found",
|
|
1241
|
+
project: projectSlug,
|
|
1242
|
+
entries_count: 0,
|
|
1243
|
+
last_entry_date: null,
|
|
1244
|
+
summary: `Project "${projectSlug}" not found`,
|
|
1245
|
+
});
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
);
|
|
1249
|
+
|
|
1250
|
+
// ---------------------------------------------------------------------------
|
|
1251
|
+
// Tool 11: get_state
|
|
1252
|
+
// ---------------------------------------------------------------------------
|
|
1253
|
+
|
|
1254
|
+
server.registerTool(
|
|
1255
|
+
"get_state",
|
|
1256
|
+
{
|
|
1257
|
+
title: "Get State",
|
|
1258
|
+
description: "Get current session state",
|
|
1259
|
+
inputSchema: z.object({}),
|
|
1260
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
1261
|
+
},
|
|
1262
|
+
async () => {
|
|
1263
|
+
return withErrorHandling(async () => {
|
|
1264
|
+
const state = await stateManager.readState();
|
|
1265
|
+
return successResult(state);
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
);
|
|
1269
|
+
|
|
1270
|
+
// ---------------------------------------------------------------------------
|
|
1271
|
+
// Tool 12: check_duplicate
|
|
1272
|
+
// ---------------------------------------------------------------------------
|
|
1273
|
+
|
|
1274
|
+
server.registerTool(
|
|
1275
|
+
"check_duplicate",
|
|
1276
|
+
{
|
|
1277
|
+
title: "Check Duplicate",
|
|
1278
|
+
description: "Check if a similar entry already exists before creating",
|
|
1279
|
+
inputSchema: z.object({
|
|
1280
|
+
project: z.string().describe("Project folder name"),
|
|
1281
|
+
title: z.string().describe("Proposed entry title"),
|
|
1282
|
+
tags: z.array(z.string()).describe("Proposed tags"),
|
|
1283
|
+
description: z.string().describe("Proposed description"),
|
|
1284
|
+
}),
|
|
1285
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
1286
|
+
},
|
|
1287
|
+
async ({ project, title, tags, description }) => {
|
|
1288
|
+
return withErrorHandling(async () => {
|
|
1289
|
+
// 1. Read project root.md
|
|
1290
|
+
const rootFile = await client.getFileContent(`${project}/root.md`);
|
|
1291
|
+
if (!rootFile) {
|
|
1292
|
+
return errorResponse(
|
|
1293
|
+
"not_found",
|
|
1294
|
+
`root.md not found in project "${project}"`,
|
|
1295
|
+
false
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const parsed = parseRootMd(rootFile.content);
|
|
1300
|
+
const inputKeywords = extractKeywords(`${title} ${description}`);
|
|
1301
|
+
const lowerTags = tags.map((t) => t.toLowerCase());
|
|
1302
|
+
|
|
1303
|
+
const candidates = [];
|
|
1304
|
+
|
|
1305
|
+
// 2. For each entry: tag match + keyword overlap
|
|
1306
|
+
for (const entry of parsed.entries) {
|
|
1307
|
+
const entryLowerTags = entry.tags.map((t) => t.toLowerCase());
|
|
1308
|
+
|
|
1309
|
+
// Tag matching: exact match
|
|
1310
|
+
const commonTags = lowerTags.filter((t) =>
|
|
1311
|
+
entryLowerTags.includes(t)
|
|
1312
|
+
);
|
|
1313
|
+
|
|
1314
|
+
// Keyword overlap
|
|
1315
|
+
let keywordOverlap = 0;
|
|
1316
|
+
if (inputKeywords.length > 0) {
|
|
1317
|
+
const entryText =
|
|
1318
|
+
`${entry.name} ${entry.description}`.toLowerCase();
|
|
1319
|
+
const matched = inputKeywords.filter((kw) =>
|
|
1320
|
+
entryText.includes(kw)
|
|
1321
|
+
);
|
|
1322
|
+
keywordOverlap = Math.round(
|
|
1323
|
+
(matched.length / inputKeywords.length) * 100
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// 3. Threshold: >=2 common tags OR >=50% keyword overlap
|
|
1328
|
+
if (commonTags.length >= 2 || keywordOverlap >= 50) {
|
|
1329
|
+
let match_reason;
|
|
1330
|
+
if (commonTags.length >= 2) {
|
|
1331
|
+
match_reason = `${commonTags.length} common tags: [${commonTags.join(", ")}]`;
|
|
1332
|
+
} else {
|
|
1333
|
+
match_reason = `${keywordOverlap}% keyword overlap`;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
candidates.push({
|
|
1337
|
+
file: entry.file,
|
|
1338
|
+
name: entry.name,
|
|
1339
|
+
description: entry.description,
|
|
1340
|
+
common_tags: commonTags,
|
|
1341
|
+
keyword_overlap: keywordOverlap,
|
|
1342
|
+
match_reason,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
return successResult({
|
|
1348
|
+
has_duplicate: candidates.length > 0,
|
|
1349
|
+
candidates,
|
|
1350
|
+
});
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
);
|
|
1354
|
+
|
|
1355
|
+
// ---------------------------------------------------------------------------
|
|
1356
|
+
// Start server
|
|
1357
|
+
// ---------------------------------------------------------------------------
|
|
1358
|
+
|
|
1359
|
+
const transport = new StdioServerTransport();
|
|
1360
|
+
await server.connect(transport);
|