repomemory 1.0.4 → 1.1.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 (56) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/README.md +93 -34
  3. package/dist/commands/analyze.d.ts.map +1 -1
  4. package/dist/commands/analyze.js +20 -0
  5. package/dist/commands/analyze.js.map +1 -1
  6. package/dist/commands/dashboard.d.ts.map +1 -1
  7. package/dist/commands/dashboard.js +305 -44
  8. package/dist/commands/dashboard.js.map +1 -1
  9. package/dist/commands/go.d.ts +6 -0
  10. package/dist/commands/go.d.ts.map +1 -0
  11. package/dist/commands/go.js +132 -0
  12. package/dist/commands/go.js.map +1 -0
  13. package/dist/commands/hook.d.ts.map +1 -1
  14. package/dist/commands/hook.js +19 -4
  15. package/dist/commands/hook.js.map +1 -1
  16. package/dist/commands/init.d.ts +3 -1
  17. package/dist/commands/init.d.ts.map +1 -1
  18. package/dist/commands/init.js +51 -38
  19. package/dist/commands/init.js.map +1 -1
  20. package/dist/commands/serve.d.ts.map +1 -1
  21. package/dist/commands/serve.js +5 -2
  22. package/dist/commands/serve.js.map +1 -1
  23. package/dist/commands/setup.js +7 -1
  24. package/dist/commands/setup.js.map +1 -1
  25. package/dist/commands/status.d.ts.map +1 -1
  26. package/dist/commands/status.js +3 -0
  27. package/dist/commands/status.js.map +1 -1
  28. package/dist/commands/wizard.d.ts.map +1 -1
  29. package/dist/commands/wizard.js +11 -3
  30. package/dist/commands/wizard.js.map +1 -1
  31. package/dist/index.js +8 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/lib/config.d.ts +9 -0
  34. package/dist/lib/config.d.ts.map +1 -1
  35. package/dist/lib/config.js +8 -1
  36. package/dist/lib/config.js.map +1 -1
  37. package/dist/lib/context-store.d.ts.map +1 -1
  38. package/dist/lib/context-store.js +18 -3
  39. package/dist/lib/context-store.js.map +1 -1
  40. package/dist/lib/embeddings.d.ts +27 -0
  41. package/dist/lib/embeddings.d.ts.map +1 -0
  42. package/dist/lib/embeddings.js +121 -0
  43. package/dist/lib/embeddings.js.map +1 -0
  44. package/dist/lib/search.d.ts +9 -2
  45. package/dist/lib/search.d.ts.map +1 -1
  46. package/dist/lib/search.js +259 -21
  47. package/dist/lib/search.js.map +1 -1
  48. package/dist/mcp/server.d.ts +26 -0
  49. package/dist/mcp/server.d.ts.map +1 -1
  50. package/dist/mcp/server.js +333 -43
  51. package/dist/mcp/server.js.map +1 -1
  52. package/package.json +1 -1
  53. package/server.json +12 -8
  54. package/skills/repomemory/SKILL.md +7 -4
  55. package/skills/session-end/SKILL.md +19 -0
  56. package/skills/session-start/SKILL.md +14 -0
@@ -3,22 +3,100 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { ContextStore } from "../lib/context-store.js";
5
5
  import { SearchIndex } from "../lib/search.js";
6
- const VALID_CATEGORIES = ["facts", "decisions", "regressions", "sessions", "changelog"];
6
+ import { createEmbeddingProvider } from "../lib/embeddings.js";
7
+ import { createRequire } from "module";
8
+ const require = createRequire(import.meta.url);
9
+ const { version: PKG_VERSION } = require("../../package.json");
10
+ const VALID_CATEGORIES = ["facts", "decisions", "regressions", "sessions", "changelog", "preferences"];
11
+ function createSessionTracker() {
12
+ return {
13
+ startTime: new Date(),
14
+ toolCalls: [],
15
+ searchQueries: [],
16
+ entriesRead: [],
17
+ entriesWritten: [],
18
+ entriesDeleted: [],
19
+ writeCallMade: false,
20
+ readCallCount: 0,
21
+ };
22
+ }
23
+ export function buildSessionSummary(session, durationSeconds) {
24
+ const date = new Date().toISOString().split("T")[0];
25
+ const mins = Math.round(durationSeconds / 60);
26
+ const parts = [];
27
+ parts.push(`## Auto-captured session ${date} (${mins}min)\n`);
28
+ if (session.searchQueries.length > 0) {
29
+ parts.push(`**Searched:** ${[...new Set(session.searchQueries)].join(", ")}`);
30
+ }
31
+ if (session.entriesRead.length > 0) {
32
+ parts.push(`**Read:** ${[...new Set(session.entriesRead)].join(", ")}`);
33
+ }
34
+ if (session.entriesWritten.length > 0) {
35
+ parts.push(`**Written:** ${[...new Set(session.entriesWritten)].join(", ")}`);
36
+ }
37
+ if (session.entriesDeleted.length > 0) {
38
+ parts.push(`**Deleted:** ${[...new Set(session.entriesDeleted)].join(", ")}`);
39
+ }
40
+ parts.push(`**Total tool calls:** ${session.toolCalls.length}`);
41
+ return parts.join("\n");
42
+ }
43
+ // --- Intelligent Category Routing ---
44
+ /**
45
+ * Detect the most likely category for a search query using keyword heuristics.
46
+ * Returns undefined if no category can be confidently inferred.
47
+ *
48
+ * Precedence order is intentional: decisions > regressions > preferences > sessions > facts.
49
+ * For ambiguous queries (e.g., "why did the login crash"), decisions wins because
50
+ * understanding the "why" is usually more actionable. The caller retries without
51
+ * category filter if the routed search returns 0 results.
52
+ */
53
+ export function detectQueryCategory(query) {
54
+ const q = query.toLowerCase();
55
+ // Decision-related queries — "why" is the strongest signal
56
+ if (/\b(why\b|chose|decision|alternatives?|trade.?off|instead of|reason\b)/.test(q))
57
+ return "decisions";
58
+ // Regression/bug queries
59
+ if (/\b(bug|broke|regression|crash|error\b|fail|fix\b|issues?\b|problem|broken)/.test(q))
60
+ return "regressions";
61
+ // Preference/style queries — require coding/style context to avoid false positives
62
+ if (/\b(prefer(?:red|ence|s)?|coding style|naming convention|indent(?:ation)?|lint(?:ing)?|tab(?:s|\s+vs|\s+or)|code format(?:ting)?)/.test(q))
63
+ return "preferences";
64
+ // Session queries
65
+ if (/\b(last session|previous session|yesterday|worked on|accomplished)/.test(q))
66
+ return "sessions";
67
+ // Architecture/fact queries
68
+ if (/\b(how does|architecture|schema|database|api|endpoint|flow|structure)/.test(q))
69
+ return "facts";
70
+ return undefined; // search all categories
71
+ }
7
72
  export async function startMcpServer(repoRoot, config) {
8
73
  const store = new ContextStore(repoRoot, config);
9
74
  let searchIndex = null;
75
+ // Initialize embedding provider (optional — falls back to keyword search)
76
+ let embeddingProvider = null;
77
+ try {
78
+ embeddingProvider = await createEmbeddingProvider({
79
+ provider: config.embeddingProvider,
80
+ model: config.embeddingModel,
81
+ apiKey: config.embeddingApiKey,
82
+ });
83
+ }
84
+ catch {
85
+ // No embeddings available, will use keyword-only search
86
+ }
10
87
  if (store.exists()) {
11
88
  try {
12
- searchIndex = new SearchIndex(store.path, store);
89
+ searchIndex = new SearchIndex(store.path, store, embeddingProvider, config.hybridAlpha);
13
90
  await searchIndex.rebuild();
14
91
  }
15
92
  catch (e) {
16
93
  console.error("Warning: Could not initialize search index:", e);
17
94
  }
18
95
  }
96
+ const session = createSessionTracker();
19
97
  const server = new Server({
20
98
  name: "repomemory",
21
- version: "1.0.0",
99
+ version: PKG_VERSION,
22
100
  }, {
23
101
  capabilities: {
24
102
  tools: {},
@@ -42,7 +120,7 @@ export async function startMcpServer(repoRoot, config) {
42
120
  },
43
121
  {
44
122
  name: "end-session",
45
- description: "Record what you accomplished and discovered during this session.",
123
+ description: "Record what you accomplished and discovered during this session. Routes conclusions to the right categories.",
46
124
  arguments: [
47
125
  {
48
126
  name: "summary",
@@ -64,7 +142,7 @@ export async function startMcpServer(repoRoot, config) {
64
142
  role: "user",
65
143
  content: {
66
144
  type: "text",
67
- text: `I'm about to work on: ${task}\n\nPlease search the repository's persistent knowledge base for any relevant context architecture docs, past decisions, known regressions, or session notes that would help me be productive immediately. Use the context_search tool with relevant queries.`,
145
+ text: `I'm about to work on: ${task}\n\nYou MUST search the repository's persistent knowledge base for relevant context before starting. Use context_search with relevant keywords, and call context_auto_orient if this is a new session. Do NOT skip this step.`,
68
146
  },
69
147
  },
70
148
  ],
@@ -79,7 +157,7 @@ export async function startMcpServer(repoRoot, config) {
79
157
  role: "user",
80
158
  content: {
81
159
  type: "text",
82
- text: `Session summary: ${summary}\n\nPlease record this in the repository's persistent memory using context_write with category "sessions" so future AI sessions can benefit from what we learned today.`,
160
+ text: `Session summary: ${summary}\n\nPlease record this session's work. IMPORTANT: Route knowledge to the RIGHT categories:\n- New architectural facts \u2192 context_write(category="facts", ...)\n- Decisions made \u2192 context_write(category="decisions", ...)\n- Bugs/regressions found \u2192 context_write(category="regressions", ...)\n- Coding style preferences \u2192 context_write(category="preferences", ...)\n- The session overview itself \u2192 context_write(category="sessions", ...)\n\nDo NOT dump everything into sessions/. Parse your conclusions and write each piece to the appropriate category.`,
83
161
  },
84
162
  },
85
163
  ],
@@ -110,6 +188,11 @@ export async function startMcpServer(repoRoot, config) {
110
188
  type: "number",
111
189
  description: "Max results to return (default: 5)",
112
190
  },
191
+ detail: {
192
+ type: "string",
193
+ enum: ["compact", "full"],
194
+ description: "Level of detail. 'compact' (default) returns one-line summaries (~50 tokens each). 'full' returns longer snippets.",
195
+ },
113
196
  },
114
197
  required: ["query"],
115
198
  },
@@ -123,19 +206,14 @@ export async function startMcpServer(repoRoot, config) {
123
206
  },
124
207
  {
125
208
  name: "context_write",
126
- description: "Write a new piece of knowledge to the repository's persistent memory. Use this to record: discoveries you made during this session, architectural decisions, bug patterns, or any insight that would help a future AI session. This persists across sessions write anything you'd want a future version of yourself to know.",
209
+ description: "Write a new piece of knowledge to the repository's persistent memory. Use this to record: discoveries you made during this session, architectural decisions, bug patterns, or any insight that would help a future AI session. This persists across sessions \u2014 write anything you'd want a future version of yourself to know.",
127
210
  inputSchema: {
128
211
  type: "object",
129
212
  properties: {
130
213
  category: {
131
214
  type: "string",
132
215
  enum: VALID_CATEGORIES,
133
- description: `Category for the knowledge:
134
- - facts: Architecture, patterns, how things work
135
- - decisions: Why something was chosen (include alternatives considered)
136
- - regressions: Bug patterns, things that broke, gotchas
137
- - sessions: What you worked on and discovered this session
138
- - changelog: Notable changes`,
216
+ description: `Category for the knowledge:\n- facts: Architecture, patterns, how things work\n- decisions: Why something was chosen (include alternatives considered)\n- regressions: Bug patterns, things that broke, gotchas\n- sessions: What you worked on and discovered this session\n- changelog: Notable changes\n- preferences: Coding style, preferred patterns, tool configs, formatting rules \u2014 personal developer knowledge`,
139
217
  },
140
218
  filename: {
141
219
  type: "string",
@@ -149,6 +227,10 @@ export async function startMcpServer(repoRoot, config) {
149
227
  type: "boolean",
150
228
  description: "If true, append to existing file instead of overwriting. Useful for session logs.",
151
229
  },
230
+ supersedes: {
231
+ type: "string",
232
+ description: "Filename of an existing entry in the same category that this replaces. The old entry will be auto-deleted.",
233
+ },
152
234
  },
153
235
  required: ["category", "filename", "content"],
154
236
  },
@@ -162,7 +244,7 @@ export async function startMcpServer(repoRoot, config) {
162
244
  },
163
245
  {
164
246
  name: "context_delete",
165
- description: "Delete a knowledge entry from the repository context. Use this to remove stale or incorrect information. Knowledge quality matters more than quantity prune aggressively.",
247
+ description: "Delete a knowledge entry from the repository context. Use this to remove stale or incorrect information. Knowledge quality matters more than quantity \u2014 prune aggressively.",
166
248
  inputSchema: {
167
249
  type: "object",
168
250
  properties: {
@@ -197,6 +279,10 @@ export async function startMcpServer(repoRoot, config) {
197
279
  enum: VALID_CATEGORIES,
198
280
  description: "Optional: filter to a specific category.",
199
281
  },
282
+ compact: {
283
+ type: "boolean",
284
+ description: "If true (default), returns one-line summaries. If false, includes file sizes.",
285
+ },
200
286
  },
201
287
  },
202
288
  annotations: {
@@ -215,7 +301,7 @@ export async function startMcpServer(repoRoot, config) {
215
301
  properties: {
216
302
  category: {
217
303
  type: "string",
218
- description: "The category (facts, decisions, regressions, sessions, changelog)",
304
+ description: "The category (facts, decisions, regressions, sessions, changelog, preferences)",
219
305
  },
220
306
  filename: {
221
307
  type: "string",
@@ -232,14 +318,40 @@ export async function startMcpServer(repoRoot, config) {
232
318
  openWorldHint: false,
233
319
  },
234
320
  },
321
+ {
322
+ name: "context_auto_orient",
323
+ description: "Get a comprehensive project orientation in a single call. Returns the project index, recent session summaries, and recently modified entries. Use this at the START of every new coding session to immediately understand the project. This replaces the need to make 3-4 separate tool calls.",
324
+ inputSchema: {
325
+ type: "object",
326
+ properties: {},
327
+ },
328
+ annotations: {
329
+ title: "Auto Orient",
330
+ readOnlyHint: true,
331
+ destructiveHint: false,
332
+ idempotentHint: true,
333
+ openWorldHint: false,
334
+ },
335
+ },
235
336
  ],
236
337
  };
237
338
  });
238
339
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
239
340
  const { name, arguments: args } = request.params;
341
+ // Track every tool call for session capture
342
+ session.toolCalls.push({ tool: name, timestamp: new Date() });
343
+ // Build write-nudge suffix for non-writing sessions
344
+ const getWriteNudge = () => {
345
+ if (!session.writeCallMade && session.readCallCount >= 3) {
346
+ return "\n\n> Tip: Use `context_write` to record any discoveries or decisions from this session.";
347
+ }
348
+ return "";
349
+ };
240
350
  switch (name) {
241
351
  case "context_search": {
242
- const { query, category, limit = 5 } = args;
352
+ const { query, category, limit = 5, detail = "compact" } = args;
353
+ session.searchQueries.push(query);
354
+ session.readCallCount++;
243
355
  // Validate category if provided
244
356
  if (category && !VALID_CATEGORIES.includes(category)) {
245
357
  return {
@@ -254,7 +366,7 @@ export async function startMcpServer(repoRoot, config) {
254
366
  return {
255
367
  content: [{
256
368
  type: "text",
257
- text: "No .context/ directory found. Run `repomemory init && repomemory analyze` to set up.",
369
+ text: "No .context/ directory found. Tell the user to run:\n\n npx repomemory go\n\nThis will set up persistent memory for this project.",
258
370
  }],
259
371
  };
260
372
  }
@@ -262,9 +374,24 @@ export async function startMcpServer(repoRoot, config) {
262
374
  searchIndex = new SearchIndex(store.path, store);
263
375
  await searchIndex.rebuild();
264
376
  }
265
- const results = await searchIndex.search(query, category, limit);
377
+ // Intelligent category routing: auto-detect if not explicitly provided
378
+ let effectiveCategory = category;
379
+ let routingNote = "";
380
+ if (!category) {
381
+ const detected = detectQueryCategory(query);
382
+ if (detected) {
383
+ effectiveCategory = detected;
384
+ routingNote = `(auto-routed to ${detected}/) `;
385
+ }
386
+ }
387
+ let results = await searchIndex.search(query, effectiveCategory, limit);
388
+ // If routing returned 0 results, retry without category filter
389
+ if (results.length === 0 && effectiveCategory && !category) {
390
+ results = await searchIndex.search(query, undefined, limit);
391
+ routingNote = "";
392
+ }
266
393
  if (results.length === 0) {
267
- // Fallback to simple text search
394
+ // Fallback to simple text search (use explicit category, not auto-routed)
268
395
  const entries = store.listEntries(category);
269
396
  const queryLower = query.toLowerCase();
270
397
  const matched = entries
@@ -275,22 +402,42 @@ export async function startMcpServer(repoRoot, config) {
275
402
  return {
276
403
  content: [{
277
404
  type: "text",
278
- text: `No results found for "${query}"${category ? ` in ${category}` : ""}. Try a different query or browse with context_list.`,
405
+ text: `No results found for "${query}"${category ? ` in ${category}` : ""}. Try a different query or browse with context_list.${getWriteNudge()}`,
279
406
  }],
280
407
  };
281
408
  }
282
- const text = matched
283
- .map((e) => `## ${e.category}/${e.filename}\n**${e.title}**\n\n${e.content.slice(0, 800)}\n`)
409
+ // Format fallback results respecting detail level
410
+ let text;
411
+ if (detail === "compact") {
412
+ text = routingNote + matched
413
+ .map((e) => `- **${e.title}** [${e.category}/${e.filename}] \u2014 ${e.content.slice(0, 150).replace(/\n/g, " ")}...`)
414
+ .join("\n");
415
+ }
416
+ else {
417
+ text = routingNote + matched
418
+ .map((e) => `## ${e.category}/${e.filename}\n**${e.title}**\n\n${e.content.slice(0, 800)}\n`)
419
+ .join("\n---\n\n");
420
+ }
421
+ return { content: [{ type: "text", text: text + getWriteNudge() }] };
422
+ }
423
+ // Format search results based on detail level
424
+ let text;
425
+ if (detail === "compact") {
426
+ text = routingNote + results
427
+ .map((r) => `- **${r.title}** [${r.category}/${r.filename}] (score: ${r.score.toFixed(2)}) \u2014 ${r.snippet.slice(0, 150).replace(/\n/g, " ")}...`)
428
+ .join("\n");
429
+ }
430
+ else {
431
+ text = routingNote + results
432
+ .map((r) => `## ${r.category}/${r.filename} (relevance: ${r.score.toFixed(2)})\n**${r.title}**\n\n${r.snippet}\n`)
284
433
  .join("\n---\n\n");
285
- return { content: [{ type: "text", text }] };
286
434
  }
287
- const text = results
288
- .map((r) => `## ${r.category}/${r.filename} (relevance: ${r.score.toFixed(2)})\n**${r.title}**\n\n${r.snippet}\n`)
289
- .join("\n---\n\n");
290
- return { content: [{ type: "text", text }] };
435
+ return { content: [{ type: "text", text: text + getWriteNudge() }] };
291
436
  }
292
437
  case "context_write": {
293
- const { category, filename, content, append = false } = args;
438
+ const { category, filename, content, append = false, supersedes } = args;
439
+ session.writeCallMade = true;
440
+ session.entriesWritten.push(`${category}/${filename}`);
294
441
  // Validate category
295
442
  if (!VALID_CATEGORIES.includes(category)) {
296
443
  return {
@@ -304,6 +451,32 @@ export async function startMcpServer(repoRoot, config) {
304
451
  if (!store.exists()) {
305
452
  store.scaffold();
306
453
  }
454
+ // Auto-purge: handle explicit supersedes
455
+ let supersedesDeleted = false;
456
+ if (supersedes) {
457
+ const supersedeFname = supersedes.endsWith(".md") ? supersedes : supersedes + ".md";
458
+ supersedesDeleted = store.deleteEntry(category, supersedeFname);
459
+ if (supersedesDeleted && searchIndex) {
460
+ await searchIndex.removeEntry(category, supersedeFname);
461
+ }
462
+ }
463
+ // Auto-purge: detect potentially overlapping entries
464
+ let supersededList = [];
465
+ if (!append && searchIndex) {
466
+ try {
467
+ const searchTerms = filename.replace(/-/g, " ");
468
+ const existing = await searchIndex.search(searchTerms, category, 3);
469
+ supersededList = existing
470
+ .filter((r) => r.category === category &&
471
+ r.filename !== filename + ".md" &&
472
+ r.filename !== filename &&
473
+ r.score > 2.0)
474
+ .map((d) => `${d.category}/${d.filename} (score: ${d.score.toFixed(1)})`);
475
+ }
476
+ catch {
477
+ // Best-effort overlap detection
478
+ }
479
+ }
307
480
  let relativePath;
308
481
  if (append) {
309
482
  relativePath = store.appendEntry(category, filename, content);
@@ -319,15 +492,26 @@ export async function startMcpServer(repoRoot, config) {
319
492
  await searchIndex.indexEntry(entry);
320
493
  }
321
494
  }
495
+ let responseText = `\u2713 Written to ${relativePath}${append ? " (appended)" : ""}.`;
496
+ if (supersedes && supersedesDeleted) {
497
+ responseText += `\n\u2713 Superseded and deleted: ${category}/${supersedes}`;
498
+ }
499
+ else if (supersedes && !supersedesDeleted) {
500
+ responseText += `\n\u26a0 Could not find ${category}/${supersedes} to supersede (file not found).`;
501
+ }
502
+ if (supersededList.length > 0) {
503
+ responseText += `\n\u26a0 Potentially supersedes: ${supersededList.join(", ")}\n Consider deleting old entries with context_delete if they're now outdated.`;
504
+ }
322
505
  return {
323
506
  content: [{
324
507
  type: "text",
325
- text: `\u2713 Written to ${relativePath}${append ? " (appended)" : ""}. This knowledge will persist across sessions.`,
508
+ text: responseText,
326
509
  }],
327
510
  };
328
511
  }
329
512
  case "context_delete": {
330
513
  const { category, filename } = args;
514
+ session.entriesDeleted.push(`${category}/${filename}`);
331
515
  if (!VALID_CATEGORIES.includes(category)) {
332
516
  return {
333
517
  content: [{
@@ -359,7 +543,8 @@ export async function startMcpServer(repoRoot, config) {
359
543
  };
360
544
  }
361
545
  case "context_list": {
362
- const { category } = (args || {});
546
+ const { category, compact = true } = (args || {});
547
+ session.readCallCount++;
363
548
  if (category && !VALID_CATEGORIES.includes(category)) {
364
549
  return {
365
550
  content: [{
@@ -373,7 +558,7 @@ export async function startMcpServer(repoRoot, config) {
373
558
  return {
374
559
  content: [{
375
560
  type: "text",
376
- text: "No .context/ directory found. Run `repomemory init` first.",
561
+ text: "No .context/ directory found. Run `npx repomemory go` to set up.",
377
562
  }],
378
563
  };
379
564
  }
@@ -382,7 +567,7 @@ export async function startMcpServer(repoRoot, config) {
382
567
  return {
383
568
  content: [{
384
569
  type: "text",
385
- text: `No entries found${category ? ` in ${category}` : ""}. Run \`repomemory analyze\` to populate, or use context_write to add entries.`,
570
+ text: `No entries found${category ? ` in ${category}` : ""}. Run \`npx repomemory analyze\` to populate, or use context_write to add entries.`,
386
571
  }],
387
572
  };
388
573
  }
@@ -392,20 +577,43 @@ export async function startMcpServer(repoRoot, config) {
392
577
  grouped[entry.category] = [];
393
578
  grouped[entry.category].push(entry);
394
579
  }
395
- let text = "# Repository Context\n\n";
396
- for (const [cat, catEntries] of Object.entries(grouped)) {
397
- text += `## ${cat}/\n`;
398
- for (const entry of catEntries) {
399
- const sizeKb = (entry.sizeBytes / 1024).toFixed(1);
400
- const age = getRelativeTime(entry.lastModified);
401
- text += `- **${entry.filename}** \u2014 ${entry.title} (${sizeKb}KB, ${age})\n`;
580
+ let text = "";
581
+ if (compact) {
582
+ for (const [cat, catEntries] of Object.entries(grouped)) {
583
+ text += `**${cat}/** (${catEntries.length})\n`;
584
+ for (const entry of catEntries) {
585
+ const age = getRelativeTime(entry.lastModified);
586
+ text += `- ${entry.filename} \u2014 ${entry.title} (${age})\n`;
587
+ }
402
588
  }
403
- text += "\n";
404
589
  }
405
- return { content: [{ type: "text", text }] };
590
+ else {
591
+ text = "# Repository Context\n\n";
592
+ for (const [cat, catEntries] of Object.entries(grouped)) {
593
+ text += `## ${cat}/\n`;
594
+ for (const entry of catEntries) {
595
+ const sizeKb = (entry.sizeBytes / 1024).toFixed(1);
596
+ const age = getRelativeTime(entry.lastModified);
597
+ text += `- **${entry.filename}** \u2014 ${entry.title} (${sizeKb}KB, ${age})\n`;
598
+ }
599
+ text += "\n";
600
+ }
601
+ }
602
+ return { content: [{ type: "text", text: text.trimEnd() + getWriteNudge() }] };
406
603
  }
407
604
  case "context_read": {
408
605
  const { category, filename } = args;
606
+ if (category && !VALID_CATEGORIES.includes(category)) {
607
+ return {
608
+ content: [{
609
+ type: "text",
610
+ text: `Invalid category: ${category}. Valid categories: ${VALID_CATEGORIES.join(", ")}`,
611
+ }],
612
+ isError: true,
613
+ };
614
+ }
615
+ session.entriesRead.push(`${category}/${filename}`);
616
+ session.readCallCount++;
409
617
  const fname = filename.endsWith(".md") ? filename : filename + ".md";
410
618
  const content = store.readEntry(category, fname);
411
619
  if (!content) {
@@ -423,6 +631,67 @@ export async function startMcpServer(repoRoot, config) {
423
631
  }],
424
632
  };
425
633
  }
634
+ case "context_auto_orient": {
635
+ if (!store.exists()) {
636
+ return {
637
+ content: [{
638
+ type: "text",
639
+ text: "No .context/ directory found. The user needs to run:\n\n npx repomemory go\n\nThis will set up persistent memory for this project.",
640
+ }],
641
+ };
642
+ }
643
+ const parts = [];
644
+ // 1. Index.md content
645
+ const indexContent = store.readIndex();
646
+ if (indexContent && indexContent.trim().length > 0) {
647
+ parts.push("# Project Overview\n\n" + indexContent);
648
+ }
649
+ else {
650
+ parts.push("# Project Overview\n\n*No index.md found. Run `npx repomemory analyze` to generate.*");
651
+ }
652
+ // 2. Developer preferences
653
+ const prefEntries = store.listEntries("preferences");
654
+ if (prefEntries.length > 0) {
655
+ parts.push("\n# Developer Preferences\n");
656
+ for (const p of prefEntries) {
657
+ parts.push(`**${p.title}**\n${p.content.slice(0, 300)}\n`);
658
+ }
659
+ }
660
+ // 3. Recent session summaries (last 3)
661
+ const sessionEntries = store.listEntries("sessions");
662
+ const recentSessions = sessionEntries
663
+ .sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime())
664
+ .slice(0, 3);
665
+ if (recentSessions.length > 0) {
666
+ parts.push("\n# Recent Sessions\n");
667
+ for (const s of recentSessions) {
668
+ const age = getRelativeTime(s.lastModified);
669
+ parts.push(`- **${s.title}** (${age}) \u2014 ${s.content.slice(0, 200).replace(/\n/g, " ")}...`);
670
+ }
671
+ }
672
+ // 4. Recently modified entries (last 7 days, excluding sessions/changelog)
673
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
674
+ const allEntries = store.listEntries();
675
+ const recentEntries = allEntries
676
+ .filter((e) => e.category !== "sessions" &&
677
+ e.category !== "changelog" &&
678
+ e.category !== "root" &&
679
+ e.lastModified.getTime() > sevenDaysAgo)
680
+ .sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime())
681
+ .slice(0, 10);
682
+ if (recentEntries.length > 0) {
683
+ parts.push("\n# Recently Updated\n");
684
+ for (const e of recentEntries) {
685
+ parts.push(`- ${e.category}/${e.filename}: ${e.title} (${getRelativeTime(e.lastModified)})`);
686
+ }
687
+ }
688
+ // 5. Empty state warning
689
+ const stats = store.getStats();
690
+ if (stats.totalFiles === 0 || (stats.categories["facts"] || 0) === 0) {
691
+ parts.push("\n> **Note**: Context is mostly empty. Ask the user to run `npx repomemory analyze` to populate with architecture knowledge.");
692
+ }
693
+ return { content: [{ type: "text", text: parts.join("\n") }] };
694
+ }
426
695
  default:
427
696
  return {
428
697
  content: [{
@@ -455,6 +724,10 @@ export async function startMcpServer(repoRoot, config) {
455
724
  throw new Error(`Invalid URI: ${uri}`);
456
725
  }
457
726
  const [, category, filename] = match;
727
+ // Validate category to prevent path traversal
728
+ if (!VALID_CATEGORIES.includes(category)) {
729
+ throw new Error(`Invalid category in URI: ${category}`);
730
+ }
458
731
  const content = store.readEntry(category, filename);
459
732
  if (!content) {
460
733
  throw new Error(`Resource not found: ${uri}`);
@@ -467,8 +740,25 @@ export async function startMcpServer(repoRoot, config) {
467
740
  }],
468
741
  };
469
742
  });
470
- // Graceful shutdown
743
+ // --- Graceful shutdown with auto-session capture ---
744
+ let cleanupDone = false;
471
745
  const cleanup = () => {
746
+ if (cleanupDone)
747
+ return;
748
+ cleanupDone = true;
749
+ // Auto-write session summary if there was meaningful activity
750
+ const duration = Math.round((Date.now() - session.startTime.getTime()) / 1000);
751
+ const hasActivity = session.toolCalls.length > 2;
752
+ if (hasActivity && store.exists()) {
753
+ try {
754
+ const date = new Date().toISOString().split("T")[0];
755
+ const summary = buildSessionSummary(session, duration);
756
+ store.appendEntry("sessions", `auto-${date}`, summary);
757
+ }
758
+ catch {
759
+ // Best-effort, don't fail shutdown
760
+ }
761
+ }
472
762
  if (searchIndex) {
473
763
  searchIndex.close();
474
764
  searchIndex = null;
@@ -480,7 +770,7 @@ export async function startMcpServer(repoRoot, config) {
480
770
  const transport = new StdioServerTransport();
481
771
  await server.connect(transport);
482
772
  }
483
- function getRelativeTime(date) {
773
+ export function getRelativeTime(date) {
484
774
  const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
485
775
  if (seconds < 60)
486
776
  return "just now";