@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.
@@ -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);