@geoql/mdr 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +152 -0
  3. package/USAGE.md +59 -0
  4. package/bin/detect-user.sh +53 -0
  5. package/bin/index-conversations.ts +34 -0
  6. package/bin/macrodata-daemon.ts +28 -0
  7. package/bin/macrodata-hook.sh +277 -0
  8. package/dist/bin/index-conversations.js +31 -0
  9. package/dist/bin/macrodata-daemon.js +30 -0
  10. package/dist/opencode/context.js +210 -0
  11. package/dist/opencode/conversations.js +367 -0
  12. package/dist/opencode/index.js +155 -0
  13. package/dist/opencode/journal.js +108 -0
  14. package/dist/opencode/logger.js +29 -0
  15. package/dist/opencode/search.js +210 -0
  16. package/dist/opencode/skills/macrodata-distill/SKILL.md +171 -0
  17. package/dist/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
  18. package/dist/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
  19. package/dist/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
  20. package/dist/opencode/tools.js +367 -0
  21. package/dist/src/config.js +55 -0
  22. package/dist/src/conversations.js +513 -0
  23. package/dist/src/daemon.js +582 -0
  24. package/dist/src/detect-user.js +73 -0
  25. package/dist/src/embeddings.js +190 -0
  26. package/dist/src/index.js +413 -0
  27. package/dist/src/indexer.js +286 -0
  28. package/opencode/context.ts +322 -0
  29. package/opencode/conversations.ts +467 -0
  30. package/opencode/index.ts +208 -0
  31. package/opencode/journal.ts +153 -0
  32. package/opencode/logger.ts +32 -0
  33. package/opencode/search.ts +288 -0
  34. package/opencode/skills/macrodata-distill/SKILL.md +171 -0
  35. package/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
  36. package/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
  37. package/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
  38. package/opencode/tools.ts +453 -0
  39. package/package.json +87 -0
  40. package/src/config.ts +66 -0
  41. package/src/conversations.ts +709 -0
  42. package/src/daemon.ts +785 -0
  43. package/src/detect-user.ts +97 -0
  44. package/src/embeddings.ts +262 -0
  45. package/src/index.ts +726 -0
  46. package/src/indexer.ts +394 -0
package/src/index.ts ADDED
@@ -0,0 +1,726 @@
1
+ /**
2
+ * Macrodata Local MCP Server
3
+ *
4
+ * Provides tools for local file-based memory:
5
+ * - log_journal: Append timestamped entries (with auto-indexing)
6
+ * - get_recent_journal: Get recent entries
7
+ * - search_memory: Semantic search using Transformers.js
8
+ * - manage_index: Rebuild or get stats for memory/conversation indexes
9
+ * - schedule: Create cron or one-shot reminders
10
+ * - list_reminders: List active schedules
11
+ * - remove_reminder: Delete a reminder
12
+ * - save_conversation_summary: Save session summaries
13
+ * - get_recent_summaries: Get past summaries
14
+ * - search_conversations: Search past Claude Code sessions
15
+ * - expand_conversation: Load full context from a session
16
+ */
17
+
18
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
+ import { z } from "zod";
21
+ import {
22
+ existsSync,
23
+ readFileSync,
24
+ writeFileSync,
25
+ appendFileSync,
26
+ mkdirSync,
27
+ readdirSync,
28
+ } from "fs";
29
+ import { join } from "path";
30
+ import {
31
+ searchMemory as doSearchMemory,
32
+ indexJournalEntry,
33
+ rebuildIndex,
34
+ getIndexStats,
35
+ type MemoryItemType,
36
+ } from "./indexer.js";
37
+ import {
38
+ searchConversations,
39
+ expandConversation,
40
+ rebuildConversationIndex,
41
+ updateConversationIndex,
42
+ getConversationIndexStats,
43
+ } from "./conversations.js";
44
+ import {
45
+ getStateRoot,
46
+ getStateDir,
47
+ getEntitiesDir,
48
+ getJournalDir,
49
+ getIndexDir,
50
+ getRemindersDir,
51
+ } from "./config.js";
52
+ import { unlinkSync } from "fs";
53
+
54
+ // Types
55
+ interface JournalEntry {
56
+ timestamp: string;
57
+ topic: string;
58
+ content: string;
59
+ metadata?: {
60
+ source?: string;
61
+ intent?: string;
62
+ };
63
+ }
64
+
65
+ interface Schedule {
66
+ id: string;
67
+ type: "cron" | "once";
68
+ expression: string;
69
+ description: string;
70
+ payload: string;
71
+ agent?: "opencode" | "claude"; // Which agent CLI to trigger
72
+ model?: string; // Optional model override (e.g., "anthropic/claude-opus-4-6")
73
+ createdAt: string;
74
+ }
75
+
76
+ // Helpers
77
+ function ensureDirectories() {
78
+ const entitiesDir = getEntitiesDir();
79
+ const dirs = [
80
+ getStateRoot(),
81
+ getStateDir(),
82
+ entitiesDir,
83
+ join(entitiesDir, "people"),
84
+ join(entitiesDir, "projects"),
85
+ getJournalDir(),
86
+ getIndexDir(),
87
+ ];
88
+ for (const dir of dirs) {
89
+ if (!existsSync(dir)) {
90
+ mkdirSync(dir, { recursive: true });
91
+ }
92
+ }
93
+ }
94
+
95
+ function loadAllSchedules(): Schedule[] {
96
+ const remindersDir = getRemindersDir();
97
+ const schedules: Schedule[] = [];
98
+
99
+ try {
100
+ if (!existsSync(remindersDir)) return schedules;
101
+
102
+ const files = readdirSync(remindersDir).filter((f) => f.endsWith(".json"));
103
+ for (const file of files) {
104
+ try {
105
+ const content = readFileSync(join(remindersDir, file), "utf-8");
106
+ schedules.push(JSON.parse(content));
107
+ } catch {
108
+ // Skip malformed files
109
+ }
110
+ }
111
+ } catch {
112
+ // Ignore
113
+ }
114
+
115
+ return schedules;
116
+ }
117
+
118
+ function saveSchedule(schedule: Schedule) {
119
+ const remindersDir = getRemindersDir();
120
+ if (!existsSync(remindersDir)) {
121
+ mkdirSync(remindersDir, { recursive: true });
122
+ }
123
+ const filePath = join(remindersDir, `${schedule.id}.json`);
124
+ writeFileSync(filePath, JSON.stringify(schedule, null, 2));
125
+ }
126
+
127
+ function deleteScheduleFile(id: string) {
128
+ const remindersDir = getRemindersDir();
129
+ const filePath = join(remindersDir, `${id}.json`);
130
+
131
+ try {
132
+ if (existsSync(filePath)) {
133
+ unlinkSync(filePath);
134
+ return true;
135
+ }
136
+ } catch {
137
+ // Ignore
138
+ }
139
+ return false;
140
+ }
141
+
142
+ function getTodayJournalPath(): string {
143
+ const today = new Date().toISOString().split("T")[0];
144
+ return join(getJournalDir(), `${today}.jsonl`);
145
+ }
146
+
147
+ function getRecentJournalEntries(count: number): JournalEntry[] {
148
+ const entries: JournalEntry[] = [];
149
+ const journalDir = getJournalDir();
150
+
151
+ // Get all journal files, sorted by name (date) descending
152
+ if (!existsSync(journalDir)) return entries;
153
+
154
+ const files = readdirSync(journalDir)
155
+ .filter((f: string) => f.endsWith(".jsonl"))
156
+ .sort()
157
+ .reverse();
158
+
159
+ for (const file of files) {
160
+ if (entries.length >= count) break;
161
+
162
+ const content = readFileSync(join(journalDir, file), "utf-8");
163
+ const lines = content.trim().split("\n").filter(Boolean);
164
+
165
+ for (const line of lines.reverse()) {
166
+ if (entries.length >= count) break;
167
+ try {
168
+ entries.push(JSON.parse(line));
169
+ } catch {
170
+ // Skip malformed lines
171
+ }
172
+ }
173
+ }
174
+
175
+ return entries;
176
+ }
177
+
178
+ // Create MCP server with every tool registered.
179
+ export function createServer(): McpServer {
180
+ const server = new McpServer({
181
+ name: "macrodata-local",
182
+ version: "0.1.0",
183
+ });
184
+
185
+ // Tool: log_journal
186
+ server.tool(
187
+ "log_journal",
188
+ "Append a timestamped entry to the journal",
189
+ {
190
+ topic: z.string().describe("Category or tag for this entry"),
191
+ content: z.string().describe("The actual note or observation"),
192
+ source: z.string().optional().describe("Where this came from (conversation, cron, etc.)"),
193
+ intent: z.string().optional().describe("What you were doing when logging this"),
194
+ },
195
+ async ({ topic, content, source, intent }) => {
196
+ ensureDirectories();
197
+
198
+ const entry: JournalEntry = {
199
+ timestamp: new Date().toISOString(),
200
+ topic,
201
+ content,
202
+ metadata: {
203
+ ...(source && { source }),
204
+ ...(intent && { intent }),
205
+ },
206
+ };
207
+
208
+ const journalPath = getTodayJournalPath();
209
+ appendFileSync(journalPath, JSON.stringify(entry) + "\n");
210
+
211
+ // Index the entry for semantic search
212
+ try {
213
+ await indexJournalEntry(entry);
214
+ } catch (err) {
215
+ console.error("[log_journal] Failed to index entry:", err);
216
+ }
217
+
218
+ return {
219
+ content: [
220
+ {
221
+ type: "text" as const,
222
+ text: `Logged to journal: ${topic}`,
223
+ },
224
+ ],
225
+ };
226
+ },
227
+ );
228
+
229
+ // Tool: get_recent_journal
230
+ server.tool(
231
+ "get_recent_journal",
232
+ "Get the N most recent journal entries, optionally filtered by topic",
233
+ {
234
+ count: z.number().default(10).describe("Number of entries to retrieve"),
235
+ topic: z.string().optional().describe("Filter by specific topic"),
236
+ },
237
+ async ({ count, topic }) => {
238
+ let entries = getRecentJournalEntries(Math.min(count * 2, 100)); // Get more to filter
239
+
240
+ if (topic) {
241
+ entries = entries.filter((e) => e.topic === topic);
242
+ }
243
+
244
+ entries = entries.slice(0, count);
245
+
246
+ return {
247
+ content: [
248
+ {
249
+ type: "text" as const,
250
+ text: JSON.stringify(entries, null, 2),
251
+ },
252
+ ],
253
+ };
254
+ },
255
+ );
256
+
257
+ // Tool: search_memory
258
+ server.tool(
259
+ "search_memory",
260
+ "Semantic search across journal entries and entity files. Returns ranked results.",
261
+ {
262
+ query: z.string().describe("Natural language search query"),
263
+ type: z
264
+ .enum(["journal", "person", "project", "all"])
265
+ .default("all")
266
+ .describe("Filter by content type"),
267
+ since: z.string().optional().describe("Only include items after this ISO date"),
268
+ limit: z.number().default(5).describe("Maximum results to return"),
269
+ },
270
+ async ({ query, type, since, limit }) => {
271
+ try {
272
+ const results = await doSearchMemory(query, {
273
+ limit,
274
+ type: type === "all" ? undefined : (type as MemoryItemType),
275
+ since,
276
+ });
277
+
278
+ if (results.length === 0) {
279
+ return {
280
+ content: [
281
+ {
282
+ type: "text" as const,
283
+ text: "(no matches found)",
284
+ },
285
+ ],
286
+ };
287
+ }
288
+
289
+ const formatted = results
290
+ .map((r, i) => {
291
+ const header = `[${i + 1}] ${r.type}${r.section ? ` / ${r.section}` : ""} (score: ${r.score.toFixed(3)})`;
292
+ const meta = r.timestamp ? ` Date: ${r.timestamp}` : "";
293
+ const source = ` Source: ${r.source}`;
294
+ const content = r.content.slice(0, 500) + (r.content.length > 500 ? "..." : "");
295
+ return [header, meta, source, "", content].filter(Boolean).join("\n");
296
+ })
297
+ .join("\n\n---\n\n");
298
+
299
+ return {
300
+ content: [
301
+ {
302
+ type: "text" as const,
303
+ text: formatted,
304
+ },
305
+ ],
306
+ };
307
+ } catch (err) {
308
+ return {
309
+ content: [
310
+ {
311
+ type: "text" as const,
312
+ text: `Search error: ${String(err)}`,
313
+ },
314
+ ],
315
+ };
316
+ }
317
+ },
318
+ );
319
+
320
+ // Tool: manage_index
321
+ server.tool(
322
+ "manage_index",
323
+ "Manage search indexes. Target 'memory' for journal/entities, 'conversations' for past Claude Code sessions.",
324
+ {
325
+ target: z.enum(["memory", "conversations"]).describe("Which index to manage"),
326
+ action: z
327
+ .enum(["rebuild", "update", "stats"])
328
+ .describe(
329
+ "'rebuild' to reindex from scratch, 'update' for incremental (conversations only), 'stats' to get counts",
330
+ ),
331
+ },
332
+ async ({ target, action }) => {
333
+ try {
334
+ if (target === "memory") {
335
+ if (action === "rebuild" || action === "update") {
336
+ const result = await rebuildIndex();
337
+ return {
338
+ content: [
339
+ {
340
+ type: "text" as const,
341
+ text: `Memory index rebuilt. Indexed ${result.itemCount} items.`,
342
+ },
343
+ ],
344
+ };
345
+ } else {
346
+ const stats = await getIndexStats();
347
+ return {
348
+ content: [
349
+ { type: "text" as const, text: `Memory index contains ${stats.itemCount} items.` },
350
+ ],
351
+ };
352
+ }
353
+ } else {
354
+ if (action === "rebuild") {
355
+ // Run in background - don't wait
356
+ rebuildConversationIndex()
357
+ .then((result) =>
358
+ console.log(
359
+ `[Macrodata] Conversation index rebuilt: ${result.exchangeCount} exchanges`,
360
+ ),
361
+ )
362
+ .catch((err) =>
363
+ console.error(`[Macrodata] Conversation index rebuild failed: ${err}`),
364
+ );
365
+ return {
366
+ content: [
367
+ {
368
+ type: "text" as const,
369
+ text: `Conversation index rebuild started in background.`,
370
+ },
371
+ ],
372
+ };
373
+ } else if (action === "update") {
374
+ // Incremental update - also background
375
+ updateConversationIndex()
376
+ .then((result) =>
377
+ console.log(
378
+ `[Macrodata] Conversation index updated: ${result.filesUpdated} files (${result.skipped} skipped, total: ${result.exchangeCount})`,
379
+ ),
380
+ )
381
+ .catch((err) =>
382
+ console.error(`[Macrodata] Conversation index update failed: ${err}`),
383
+ );
384
+ return {
385
+ content: [
386
+ { type: "text" as const, text: `Conversation index update started in background.` },
387
+ ],
388
+ };
389
+ } else {
390
+ const stats = await getConversationIndexStats();
391
+ return {
392
+ content: [
393
+ {
394
+ type: "text" as const,
395
+ text: `Conversation index contains ${stats.exchangeCount} exchanges.`,
396
+ },
397
+ ],
398
+ };
399
+ }
400
+ }
401
+ } catch (err) {
402
+ return {
403
+ content: [
404
+ { type: "text" as const, text: `Failed to ${action} ${target} index: ${String(err)}` },
405
+ ],
406
+ };
407
+ }
408
+ },
409
+ );
410
+
411
+ // Tool: schedule
412
+ server.tool(
413
+ "schedule",
414
+ "Create a reminder. Use type 'cron' for recurring (expression is cron syntax) or 'once' for one-shot (expression is ISO datetime).",
415
+ {
416
+ type: z.enum(["cron", "once"]).describe("'cron' for recurring, 'once' for one-shot"),
417
+ id: z.string().describe("Unique identifier for this reminder"),
418
+ expression: z
419
+ .string()
420
+ .describe(
421
+ "Cron expression (e.g., '0 9 * * *') or ISO datetime (e.g., '2026-01-31T10:00:00')",
422
+ ),
423
+ description: z.string().describe("What this reminder is for"),
424
+ payload: z.string().describe("Message to process when reminder fires"),
425
+ model: z
426
+ .string()
427
+ .optional()
428
+ .describe("Model to use (e.g., 'anthropic/claude-opus-4-6' for deep thinking tasks)"),
429
+ },
430
+ async ({ type, id, expression, description, payload, model }) => {
431
+ const schedule: Schedule = {
432
+ id,
433
+ type,
434
+ expression,
435
+ description,
436
+ payload,
437
+ agent: "claude",
438
+ model,
439
+ createdAt: new Date().toISOString(),
440
+ };
441
+
442
+ // Save to individual file (overwrites if exists)
443
+ saveSchedule(schedule);
444
+
445
+ const typeLabel = type === "cron" ? "recurring" : "one-shot";
446
+ return {
447
+ content: [
448
+ {
449
+ type: "text" as const,
450
+ text: `Created ${typeLabel} reminder: ${id} (${expression})${model ? ` with model ${model}` : ""}`,
451
+ },
452
+ ],
453
+ };
454
+ },
455
+ );
456
+
457
+ // Tool: list_reminders
458
+ server.tool("list_reminders", "List all active scheduled reminders", {}, async () => {
459
+ const schedules = loadAllSchedules();
460
+
461
+ return {
462
+ content: [
463
+ {
464
+ type: "text" as const,
465
+ text: JSON.stringify(schedules, null, 2),
466
+ },
467
+ ],
468
+ };
469
+ });
470
+
471
+ // Tool: remove_reminder
472
+ server.tool(
473
+ "remove_reminder",
474
+ "Remove a scheduled reminder",
475
+ {
476
+ id: z.string().describe("ID of the reminder to remove"),
477
+ },
478
+ async ({ id }) => {
479
+ const removed = deleteScheduleFile(id);
480
+
481
+ return {
482
+ content: [
483
+ {
484
+ type: "text" as const,
485
+ text: removed ? `Removed reminder: ${id}` : `Reminder not found: ${id}`,
486
+ },
487
+ ],
488
+ };
489
+ },
490
+ );
491
+
492
+ // Tool: save_conversation_summary
493
+ server.tool(
494
+ "save_conversation_summary",
495
+ "Save a summary of the current conversation for context recovery in future sessions",
496
+ {
497
+ summary: z.string().describe("Brief summary of what was discussed/accomplished"),
498
+ keyDecisions: z.array(z.string()).optional().describe("Important decisions made"),
499
+ openThreads: z.array(z.string()).optional().describe("Topics to follow up on"),
500
+ learnedPatterns: z
501
+ .array(z.string())
502
+ .optional()
503
+ .describe("New patterns learned about the user"),
504
+ notes: z.string().optional().describe("Freeform notes"),
505
+ },
506
+ async ({ summary, keyDecisions, openThreads, learnedPatterns, notes }) => {
507
+ ensureDirectories();
508
+
509
+ const parts = [summary];
510
+ if (keyDecisions?.length) parts.push(`Decisions: ${keyDecisions.join(", ")}`);
511
+ if (openThreads?.length) parts.push(`Open threads: ${openThreads.join(", ")}`);
512
+ if (learnedPatterns?.length) parts.push(`Learned: ${learnedPatterns.join(", ")}`);
513
+ if (notes) parts.push(`Notes: ${notes}`);
514
+
515
+ const entry: JournalEntry = {
516
+ timestamp: new Date().toISOString(),
517
+ topic: "conversation-summary",
518
+ content: parts.join("\n"),
519
+ metadata: { source: "conversation" },
520
+ };
521
+
522
+ const journalPath = getTodayJournalPath();
523
+ appendFileSync(journalPath, JSON.stringify(entry) + "\n");
524
+
525
+ // Index for semantic search
526
+ try {
527
+ await indexJournalEntry(entry);
528
+ } catch (err) {
529
+ console.error("[save_conversation_summary] Failed to index:", err);
530
+ }
531
+
532
+ return {
533
+ content: [
534
+ {
535
+ type: "text" as const,
536
+ text: "Conversation summary saved.",
537
+ },
538
+ ],
539
+ };
540
+ },
541
+ );
542
+
543
+ // Tool: get_recent_summaries
544
+ server.tool(
545
+ "get_recent_summaries",
546
+ "Get recent conversation summaries for context recovery",
547
+ {
548
+ count: z.number().default(7).describe("Number of summaries to retrieve"),
549
+ },
550
+ async ({ count }) => {
551
+ // Get recent journal entries filtered by topic
552
+ let entries = getRecentJournalEntries(count * 3);
553
+ entries = entries.filter((e) => e.topic === "conversation-summary").slice(0, count);
554
+
555
+ if (entries.length === 0) {
556
+ return {
557
+ content: [
558
+ {
559
+ type: "text" as const,
560
+ text: "No conversation summaries yet.",
561
+ },
562
+ ],
563
+ };
564
+ }
565
+
566
+ return {
567
+ content: [
568
+ {
569
+ type: "text" as const,
570
+ text: JSON.stringify(entries, null, 2),
571
+ },
572
+ ],
573
+ };
574
+ },
575
+ );
576
+
577
+ // Tool: search_conversations
578
+ server.tool(
579
+ "search_conversations",
580
+ "Search past Claude Code conversations for similar problems/solutions. By default searches current project first, with recent conversations weighted higher.",
581
+ {
582
+ query: z
583
+ .string()
584
+ .describe(
585
+ "What to search for (e.g., 'fixing TypeScript errors', 'performance optimization')",
586
+ ),
587
+ projectOnly: z
588
+ .boolean()
589
+ .default(false)
590
+ .describe("Only search current project (default: search all but boost current)"),
591
+ limit: z.number().default(5).describe("Maximum results to return"),
592
+ },
593
+ async ({ query, projectOnly, limit }) => {
594
+ try {
595
+ // Get current project from CWD environment (set by hook)
596
+ const currentProject = process.env.CLAUDE_PROJECT_DIR;
597
+
598
+ const results = await searchConversations(query, {
599
+ currentProject,
600
+ projectOnly,
601
+ limit,
602
+ });
603
+
604
+ if (results.length === 0) {
605
+ return {
606
+ content: [
607
+ {
608
+ type: "text" as const,
609
+ text: "No matching conversations found. Try manage_index(target: 'conversations', action: 'update').",
610
+ },
611
+ ],
612
+ };
613
+ }
614
+
615
+ // Format results: metadata + user prompt only (not full response)
616
+ const formatted = results
617
+ .map((r, i) => {
618
+ const date = new Date(r.exchange.timestamp).toLocaleDateString();
619
+ const branch = r.exchange.branch ? ` (${r.exchange.branch})` : "";
620
+ return `[${i + 1}] ${r.exchange.project}${branch} - ${date}
621
+ "${r.exchange.userPrompt.slice(0, 200)}${r.exchange.userPrompt.length > 200 ? "..." : ""}"
622
+ Session: ${r.exchange.sessionId}`;
623
+ })
624
+ .join("\n\n");
625
+
626
+ return {
627
+ content: [
628
+ {
629
+ type: "text" as const,
630
+ text: `Found ${results.length} relevant conversation(s):\n\n${formatted}\n\nUse expand_conversation to see full context.`,
631
+ },
632
+ ],
633
+ };
634
+ } catch (err) {
635
+ return {
636
+ content: [
637
+ {
638
+ type: "text" as const,
639
+ text: `Search error: ${String(err)}`,
640
+ },
641
+ ],
642
+ };
643
+ }
644
+ },
645
+ );
646
+
647
+ // Tool: expand_conversation
648
+ server.tool(
649
+ "expand_conversation",
650
+ "Load full context from a past conversation. Use after search_conversations to see the complete exchange.",
651
+ {
652
+ sessionPath: z.string().describe("Session file path from search results"),
653
+ messageUuid: z.string().optional().describe("Specific message UUID to center on"),
654
+ contextMessages: z.number().default(10).describe("Number of messages to include"),
655
+ },
656
+ async ({ sessionPath, messageUuid, contextMessages }) => {
657
+ try {
658
+ // Resolve session path if only ID given
659
+ let fullPath = sessionPath;
660
+ if (!sessionPath.startsWith("/")) {
661
+ // Assume it's a session ID, need to find the file
662
+ // For now, require full path
663
+ return {
664
+ content: [
665
+ {
666
+ type: "text" as const,
667
+ text: "Please provide the full session path from search results.",
668
+ },
669
+ ],
670
+ };
671
+ }
672
+
673
+ const result = await expandConversation(fullPath, messageUuid || "", contextMessages);
674
+
675
+ const formatted = result.messages
676
+ .map((m) => {
677
+ const prefix = m.role === "user" ? "User" : "Assistant";
678
+ return `**${prefix}**: ${m.content}`;
679
+ })
680
+ .join("\n\n---\n\n");
681
+
682
+ const header = `Project: ${result.project}${result.branch ? ` (${result.branch})` : ""}\n\n`;
683
+
684
+ return {
685
+ content: [
686
+ {
687
+ type: "text" as const,
688
+ text: header + formatted,
689
+ },
690
+ ],
691
+ };
692
+ } catch (err) {
693
+ return {
694
+ content: [
695
+ {
696
+ type: "text" as const,
697
+ text: `Failed to expand conversation: ${String(err)}`,
698
+ },
699
+ ],
700
+ };
701
+ }
702
+ },
703
+ );
704
+
705
+ return server;
706
+ }
707
+
708
+ // Start server
709
+ export async function main(): Promise<void> {
710
+ const server = createServer();
711
+ const transport = new StdioServerTransport();
712
+ await server.connect(transport);
713
+ }
714
+
715
+ // Auto-start only when run directly, so importing this module for tests does
716
+ // not open a stdio transport.
717
+ export function isRunAsMain(argv1: string | undefined, moduleUrl: string): boolean {
718
+ return Boolean(argv1) && moduleUrl === `file://${argv1}`;
719
+ }
720
+
721
+ /* v8 ignore next 3 -- entry-point glue: only runs when this file is the process
722
+ entry (node dist/src/index.js); that path is a subprocess vitest cannot
723
+ instrument. main() and isRunAsMain() are each covered directly above. */
724
+ if (isRunAsMain(process.argv[1], import.meta.url)) {
725
+ main().catch(console.error);
726
+ }