@exaudeus/memory-mcp 1.8.0 → 1.9.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.
@@ -1,4 +1,4 @@
1
- import type { MemoryStats, StaleEntry, ConflictPair, BehaviorConfig } from './types.js';
1
+ import type { MemoryStats, StaleEntry, ConflictPair, BehaviorConfig, RelatedEntry } from './types.js';
2
2
  import { type FilterGroup } from './text-analyzer.js';
3
3
  import type { MarkdownMemoryStore } from './store.js';
4
4
  /** Format the search mode indicator for context/recall responses.
@@ -6,6 +6,10 @@ import type { MarkdownMemoryStore } from './store.js';
6
6
  *
7
7
  * Shows whether semantic search is active and vector coverage. */
8
8
  export declare function formatSearchMode(embedderAvailable: boolean, vectorCount: number, totalCount: number): string;
9
+ /** Format the loot-drop section — related entries shown after a storage operation.
10
+ * Transforms storage from "chore for the future" into "immediate value exchange."
11
+ * Pure function. */
12
+ export declare function formatLootDrop(related: readonly RelatedEntry[]): string;
9
13
  /** Format the stale entries section for briefing/context responses */
10
14
  export declare function formatStaleSection(staleDetails: readonly StaleEntry[]): string;
11
15
  /** Format the conflict detection warning for query/context responses */
@@ -20,6 +20,15 @@ export function formatSearchMode(embedderAvailable, vectorCount, totalCount) {
20
20
  }
21
21
  return `*Search: semantic + keyword (${vectorCount}/${totalCount} entries vectorized)*`;
22
22
  }
23
+ /** Format the loot-drop section — related entries shown after a storage operation.
24
+ * Transforms storage from "chore for the future" into "immediate value exchange."
25
+ * Pure function. */
26
+ export function formatLootDrop(related) {
27
+ if (related.length === 0)
28
+ return '';
29
+ const lines = related.map(r => `- [${r.id}] "${r.title}" (confidence: ${r.confidence.toFixed(2)})`);
30
+ return `\n**Related knowledge:**\n${lines.join('\n')}`;
31
+ }
23
32
  /** Format the stale entries section for briefing/context responses */
24
33
  export function formatStaleSection(staleDetails) {
25
34
  const lines = [
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // Supports multiple workspaces simultaneously
5
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
- import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
7
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
8
8
  import { z } from 'zod';
9
9
  import path from 'path';
10
10
  import { readFile, writeFile } from 'fs/promises';
@@ -14,8 +14,8 @@ import { getLobeConfigs } from './config.js';
14
14
  import { ConfigManager } from './config-manager.js';
15
15
  import { normalizeArgs } from './normalize.js';
16
16
  import { buildCrashReport, writeCrashReport, writeCrashReportSync, readLatestCrash, readCrashHistory, clearLatestCrash, formatCrashReport, formatCrashSummary, markServerStarted, } from './crash-journal.js';
17
- import { formatStaleSection, formatConflictWarning, formatStats, formatBehaviorConfigSection, mergeTagFrequencies, buildQueryFooter, buildBriefingTagPrimerSections, formatSearchMode } from './formatters.js';
18
- import { parseFilter } from './text-analyzer.js';
17
+ import { formatStaleSection, formatConflictWarning, formatStats, formatBehaviorConfigSection, mergeTagFrequencies, buildQueryFooter, buildBriefingTagPrimerSections, formatSearchMode, formatLootDrop } from './formatters.js';
18
+ import { parseFilter, extractTitle } from './text-analyzer.js';
19
19
  import { VOCABULARY_ECHO_LIMIT, WARN_SEPARATOR } from './thresholds.js';
20
20
  import { matchRootsToLobeNames, buildLobeResolution } from './lobe-resolution.js';
21
21
  let serverMode = { kind: 'running' };
@@ -143,7 +143,7 @@ function inferLobeFromPaths(paths) {
143
143
  // Only return if unambiguous — exactly one lobe matched
144
144
  return matchedLobes.size === 1 ? matchedLobes.values().next().value : undefined;
145
145
  }
146
- const server = new Server({ name: 'memory-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
146
+ const server = new Server({ name: 'memory-mcp', version: '0.1.0' }, { capabilities: { tools: {}, resources: {} } });
147
147
  // --- Lobe resolution for read operations ---
148
148
  // When the agent doesn't specify a lobe, we determine which lobe(s) to search
149
149
  // via a degradation ladder (see lobe-resolution.ts for the pure logic):
@@ -208,170 +208,180 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
208
208
  const currentLobeNames = configManager.getLobeNames();
209
209
  const lobeProperty = buildLobeProperty(currentLobeNames);
210
210
  return { tools: [
211
- // memory_list_lobes is hidden lobe info is surfaced in memory_context() hints
212
- // and memory_stats. The handler still works if called directly.
211
+ // ─── New v2 tool surface ─────────────────────────────────────────────
212
+ // 4 retrieval tools + 4 storage tools + 1 maintenance tool.
213
+ // Old tools (memory_store, memory_query, memory_correct, memory_context) are hidden
214
+ // from listing but their handlers remain active for backward compatibility.
215
+ // --- Retrieval ---
213
216
  {
214
- name: 'memory_store',
215
- // Example comes first agents form their call shape from the first concrete pattern they see.
216
- // "entries" (not "content") signals a collection; fighting the "content = string" prior
217
- // is an architectural fix rather than patching the description after the fact.
218
- description: 'memory_store(topic: "gotchas", entries: [{title: "Build cache", fact: "Must clean build after Tuist changes"}, {title: "Tuist version", fact: "Project requires Tuist 4.x"}], tags: ["build"]). Stores enduring knowledge — (1) Codebase facts (architecture, conventions, gotchas, modules): what IS true now, not past actions. Wrong: "Completed migration to StateFlow." Right: "All ViewModels use StateFlow." (2) User knowledge (user, preferences): who the person is, how they work. "user" and "preferences" are stored in the alwaysInclude lobe (shared across projects). One insight per object; use multiple objects instead of bundling. If content looks likely-ephemeral, the tool returns a review-required response; re-run with durabilityDecision: "store-anyway" only when deliberate.',
217
+ name: 'brief',
218
+ description: `Session start for a project. Returns your preferences (global), gotchas, and recent work for this project and branch. Also surfaces stale entries that need review. Call once at the beginning of a conversation. Lobe names: ${currentLobeNames.join(', ') || 'none configured'}. Example: brief(lobe: "${currentLobeNames[0] ?? 'my-project'}")`,
219
219
  inputSchema: {
220
220
  type: 'object',
221
221
  properties: {
222
222
  lobe: lobeProperty,
223
- topic: {
223
+ },
224
+ required: [],
225
+ },
226
+ },
227
+ {
228
+ name: 'recall',
229
+ description: `Search stored knowledge by relevance within a project. BEFORE starting a task, describe what you are about to do in natural language and get relevant knowledge back (semantic + keyword search). Example: recall(lobe: "${currentLobeNames[0] ?? 'my-project'}", context: "how does auth token refresh work") or recall(lobe: "${currentLobeNames[0] ?? 'my-project'}", context: "refactoring the payment webhook handler")`,
230
+ inputSchema: {
231
+ type: 'object',
232
+ properties: {
233
+ lobe: lobeProperty,
234
+ context: {
224
235
  type: 'string',
225
- // modules/<name> is intentionally excluded from the enum so the MCP schema
226
- // doesn't restrict it — agents can pass any "modules/foo" value and it works.
227
- // The description makes this explicit.
228
- description: 'Predefined: user | preferences | architecture | conventions | gotchas | recent-work. Custom namespace: modules/<name> (e.g. modules/brainstorm, modules/game-design, modules/api-notes). Use modules/<name> for any domain that doesn\'t fit the built-in topics.',
229
- enum: ['user', 'preferences', 'architecture', 'conventions', 'gotchas', 'recent-work'],
230
- },
231
- entries: {
232
- type: 'array',
233
- // Type annotation first — agents trained on code read type signatures before prose.
234
- description: 'Array<{title: string, fact: string}> — not a string. One object per insight. title: short label (2-5 words). fact: codebase topics → present-tense state ("X uses Y", not "Completed X"); user/preferences → what the person expressed. Wrong: one object bundling two facts. Right: two objects.',
235
- items: {
236
- type: 'object',
237
- properties: {
238
- title: {
239
- type: 'string',
240
- description: 'Short label for this insight (2-5 words)',
241
- },
242
- fact: {
243
- type: 'string',
244
- description: 'The insight itself — one focused fact or observation',
245
- },
246
- },
247
- required: ['title', 'fact'],
248
- },
249
- minItems: 1,
236
+ description: 'Describe what you are about to do, in natural language.',
250
237
  },
251
- sources: {
252
- type: 'array',
253
- items: { type: 'string' },
254
- description: 'File paths that informed this (provenance, for freshness tracking)',
255
- default: [],
238
+ maxResults: {
239
+ type: 'number',
240
+ description: 'Max results (default: 10).',
241
+ default: 10,
256
242
  },
257
- references: {
258
- type: 'array',
259
- items: { type: 'string' },
260
- description: 'Files, classes, or symbols this knowledge is about (semantic pointers). Example: ["features/messaging/impl/MessagingReducer.kt"]',
261
- default: [],
243
+ },
244
+ required: ['context'],
245
+ },
246
+ },
247
+ {
248
+ name: 'gotchas',
249
+ description: `Pitfalls in a project, optionally scoped to an area. Check BEFORE making changes to avoid known traps. Example: gotchas(lobe: "${currentLobeNames[0] ?? 'my-project'}", area: "auth") or gotchas(lobe: "${currentLobeNames[0] ?? 'my-project'}") for all gotchas.`,
250
+ inputSchema: {
251
+ type: 'object',
252
+ properties: {
253
+ lobe: lobeProperty,
254
+ area: {
255
+ type: 'string',
256
+ description: 'Optional keyword filter (e.g. "auth", "build", "navigation").',
262
257
  },
263
- trust: {
258
+ },
259
+ required: [],
260
+ },
261
+ },
262
+ {
263
+ name: 'conventions',
264
+ description: `Coding patterns and standards in a project, optionally scoped to an area. Check BEFORE writing new code to follow established patterns. Example: conventions(lobe: "${currentLobeNames[0] ?? 'my-project'}", area: "testing") or conventions(lobe: "${currentLobeNames[0] ?? 'my-project'}") for all conventions.`,
265
+ inputSchema: {
266
+ type: 'object',
267
+ properties: {
268
+ lobe: lobeProperty,
269
+ area: {
264
270
  type: 'string',
265
- enum: ['user', 'agent-confirmed', 'agent-inferred'],
266
- description: 'user (from human) > agent-confirmed > agent-inferred',
267
- default: 'agent-inferred',
271
+ description: 'Optional keyword filter (e.g. "testing", "naming", "architecture").',
268
272
  },
269
- tags: {
270
- type: 'array',
271
- items: { type: 'string' },
272
- description: 'Category labels for exact-match retrieval (lowercase slugs). Query with filter: "#tag". Example: ["auth", "critical-path", "mite-combat"]',
273
- default: [],
273
+ },
274
+ required: [],
275
+ },
276
+ },
277
+ // --- Storage ---
278
+ {
279
+ name: 'gotcha',
280
+ description: `Flag a pitfall you hit — what you expected vs what actually happened. Gets priority in brief and recall results. Returns related knowledge you might want to review. Example: gotcha(lobe: "${currentLobeNames[0] ?? 'my-project'}", observation: "Gradle cache must be cleaned after Tuist changes or builds silently use stale artifacts")`,
281
+ inputSchema: {
282
+ type: 'object',
283
+ properties: {
284
+ lobe: lobeProperty,
285
+ observation: {
286
+ type: 'string',
287
+ description: 'The gotcha. Write naturally — first sentence becomes the title.',
274
288
  },
275
289
  durabilityDecision: {
276
290
  type: 'string',
277
291
  enum: ['default', 'store-anyway'],
278
- description: 'Write intent for content that may require review. Use "default" normally. Use "store-anyway" only when intentionally persisting content after a review-required response.',
292
+ description: 'Use "store-anyway" only when re-storing after a review-required response.',
279
293
  default: 'default',
280
294
  },
281
295
  },
282
- required: ['topic', 'entries'],
296
+ required: ['lobe', 'observation'],
283
297
  },
284
298
  },
285
299
  {
286
- name: 'memory_query',
287
- description: 'Search stored knowledge. Searches all lobes when lobe is omitted. Filter supports: keywords (stemmed), #tag (exact tag match), =term (exact keyword, no stemming), -term (NOT). Example: memory_query(scope: "*", filter: "#auth reducer", detail: "full")',
300
+ name: 'convention',
301
+ description: `Record a codebase pattern you observed how the code is structured, naming rules, architectural decisions. For personal rules or preferences, use prefer() instead. Returns related knowledge. Example: convention(lobe: "${currentLobeNames[0] ?? 'my-project'}", observation: "All ViewModels use StateFlow for UI state. LiveData is banned.")`,
288
302
  inputSchema: {
289
303
  type: 'object',
290
304
  properties: {
291
305
  lobe: lobeProperty,
292
- scope: {
293
- type: 'string',
294
- description: 'Optional. Defaults to "*" (all topics). Options: * | user | preferences | architecture | conventions | gotchas | recent-work | modules/<name>',
295
- },
296
- detail: {
306
+ observation: {
297
307
  type: 'string',
298
- enum: ['brief', 'standard', 'full'],
299
- description: 'brief = titles only, standard = summaries, full = complete content + metadata',
300
- default: 'brief',
308
+ description: 'The convention. Write naturally — first sentence becomes the title.',
301
309
  },
302
- filter: {
303
- type: 'string',
304
- description: 'Search terms. "A B" = AND, "A|B" = OR, "-A" = NOT, "#tag" = exact tag, "=term" = exact keyword (no stemming). Example: "#auth reducer -deprecated"',
305
- },
306
- branch: {
310
+ durabilityDecision: {
307
311
  type: 'string',
308
- description: 'Branch for recent-work. Omit = current branch, "*" = all branches.',
309
- },
310
- isFirstMemoryToolCall: {
311
- type: 'boolean',
312
- description: 'Set true on first memory call in a conversation to include identity/preferences from alwaysInclude lobes. Set false on subsequent calls to skip redundant global knowledge.',
313
- default: true,
312
+ enum: ['default', 'store-anyway'],
313
+ description: 'Use "store-anyway" only when re-storing after a review-required response.',
314
+ default: 'default',
314
315
  },
315
316
  },
316
- required: [],
317
+ required: ['lobe', 'observation'],
317
318
  },
318
319
  },
319
320
  {
320
- name: 'memory_correct',
321
- description: 'Fix or delete an entry. Example: memory_correct(id: "arch-3f7a", action: "replace", correction: "updated content")',
321
+ name: 'learn',
322
+ description: `Store architecture, dependencies, decisions, progress, or any knowledge not covered by gotcha() or convention(). Use this as the catch-all for insights worth remembering. Returns related knowledge. Example: learn(lobe: "${currentLobeNames[0] ?? 'my-project'}", observation: "Payments module depends on auth for tokens only — no other cross-module dependency")`,
322
323
  inputSchema: {
323
324
  type: 'object',
324
325
  properties: {
325
326
  lobe: lobeProperty,
326
- id: {
327
+ observation: {
327
328
  type: 'string',
328
- description: 'Entry ID (e.g. arch-3f7a, pref-5c9b)',
329
+ description: 'The observation. Write naturally — first sentence becomes the title.',
329
330
  },
330
- correction: {
331
+ durabilityDecision: {
331
332
  type: 'string',
332
- description: 'New text (for append/replace). Not needed for delete.',
333
+ enum: ['default', 'store-anyway'],
334
+ description: 'Use "store-anyway" only when re-storing after a review-required response.',
335
+ default: 'default',
333
336
  },
334
- action: {
337
+ },
338
+ required: ['lobe', 'observation'],
339
+ },
340
+ },
341
+ {
342
+ name: 'prefer',
343
+ description: `When your user corrects you or states a working-style rule, record it here. Persists globally by default and is surfaced in every brief(). Optionally scope to a specific project. Example: prefer(rule: "Never use !! operator") or prefer(rule: "Use Anvil for DI", lobe: "${currentLobeNames[0] ?? 'my-project'}")`,
344
+ inputSchema: {
345
+ type: 'object',
346
+ properties: {
347
+ rule: {
335
348
  type: 'string',
336
- enum: ['append', 'replace', 'delete'],
337
- description: 'append | replace | delete',
349
+ description: 'The preference or rule. Write naturally.',
350
+ },
351
+ lobe: {
352
+ ...lobeProperty,
353
+ description: `Optional. Scope this preference to a specific lobe. Omit for global. Available: ${currentLobeNames.join(', ')}`,
338
354
  },
339
355
  },
340
- required: ['id', 'action'],
356
+ required: ['rule'],
341
357
  },
342
358
  },
359
+ // --- Maintenance ---
343
360
  {
344
- name: 'memory_context',
345
- description: 'Session start AND pre-task lookup. Call with no args at session start to get user identity, preferences, and stale entries. Call with context to get task-specific knowledge. When lobe is omitted, uses client workspace roots to select the matching lobe; falls back to global-only if roots are unavailable. Example: memory_context() or memory_context(context: "writing a Kotlin reducer")',
361
+ name: 'fix',
362
+ description: `Correct or delete a memory entry. IDs appear in brief(), recall(), gotchas(), and conventions() results. Pass correction to update content; omit correction to delete the entry entirely. Example: fix(id: "gotcha-a1b2c3d4", correction: "Fixed in v2.3 cache is auto-cleaned now") or fix(id: "gotcha-a1b2c3d4") to delete.`,
346
363
  inputSchema: {
347
364
  type: 'object',
348
365
  properties: {
349
- lobe: lobeProperty,
350
- context: {
366
+ id: {
351
367
  type: 'string',
352
- description: 'Optional. What you are about to do, in natural language. Omit for session-start briefing (user + preferences + stale entries).',
353
- },
354
- maxResults: {
355
- type: 'number',
356
- description: 'Max results (default: 10)',
357
- default: 10,
368
+ description: 'Entry ID (e.g. gotcha-3f7a, conv-5c9b, gen-a2d1, pref-8e4f).',
358
369
  },
359
- minMatch: {
360
- type: 'number',
361
- description: 'Min keyword match ratio 0-1 (default: 0.2). Higher = stricter.',
362
- default: 0.2,
370
+ correction: {
371
+ type: 'string',
372
+ description: 'New text to replace the entry content. Omit entirely to delete the entry.',
363
373
  },
364
- isFirstMemoryToolCall: {
365
- type: 'boolean',
366
- description: 'Set true on first memory call in a conversation to include identity/preferences from alwaysInclude lobes. Set false on subsequent calls to skip redundant global knowledge.',
367
- default: true,
374
+ lobe: {
375
+ ...lobeProperty,
376
+ description: `Optional. Searches all lobes if omitted. Available: ${currentLobeNames.join(', ')}`,
368
377
  },
369
378
  },
370
- required: [],
379
+ required: ['id'],
371
380
  },
372
381
  },
373
- // memory_stats is hidden agents rarely need it proactively. Mentioned in
374
- // hints when storage is running low. The handler still works if called directly.
382
+ // --- Legacy tools (still listed for backward compatibility) ---
383
+ // memory_store, memory_query, memory_correct, memory_context handlers remain active
384
+ // but are hidden from tool discovery. Agents use the new v2 tools above.
375
385
  {
376
386
  name: 'memory_bootstrap',
377
387
  description: 'First-time setup: scan repo structure, README, and build system to seed initial knowledge. Run once per new codebase. If the lobe does not exist yet, provide "root" to auto-add it to memory-config.json and proceed without a manual restart.',
@@ -380,7 +390,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
380
390
  properties: {
381
391
  lobe: {
382
392
  type: 'string',
383
- // No enum restriction: agents pass new lobe names not yet in the config
384
393
  description: `Memory lobe name. If the lobe doesn't exist yet, also pass "root" to auto-create it. Available lobes: ${currentLobeNames.join(', ')}`,
385
394
  },
386
395
  root: {
@@ -395,13 +404,38 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
395
404
  required: [],
396
405
  },
397
406
  },
398
- // memory_diagnose is intentionally hidden from the tool list — it clutters
399
- // agent tool discovery and should only be called when directed by error messages
400
- // or crash reports. The handler still works if called directly.
401
- // memory_reembed is hidden — utility for generating/regenerating embeddings.
402
- // Surfaced via hint in memory_context when >50% of entries lack vectors.
407
+ // Hidden tools handlers still active:
408
+ // memory_stats, memory_diagnose, memory_reembed, memory_list_lobes,
409
+ // memory_store, memory_query, memory_correct, memory_context
403
410
  ] };
404
411
  });
412
+ // --- MCP Resource: memory://lobes ---
413
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
414
+ await configManager.ensureFresh();
415
+ return {
416
+ resources: [{
417
+ uri: 'memory://lobes',
418
+ name: 'Available memory lobes',
419
+ description: 'Lists all configured memory lobes with their health status and entry counts.',
420
+ mimeType: 'application/json',
421
+ }],
422
+ };
423
+ });
424
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
425
+ const uri = request.params.uri;
426
+ if (uri === 'memory://lobes') {
427
+ await configManager.ensureFresh();
428
+ const lobeInfo = await buildLobeInfo();
429
+ return {
430
+ contents: [{
431
+ uri: 'memory://lobes',
432
+ mimeType: 'application/json',
433
+ text: JSON.stringify({ lobes: lobeInfo }, null, 2),
434
+ }],
435
+ };
436
+ }
437
+ throw new Error(`Unknown resource: ${uri}`);
438
+ });
405
439
  // --- Tool handlers ---
406
440
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
407
441
  const { name, arguments: rawArgs } = request.params;
@@ -449,6 +483,340 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
449
483
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
450
484
  };
451
485
  }
486
+ // ─── New v2 tool handlers ────────────────────────────────────────────
487
+ // Shared helper for gotcha/convention/learn — identical store logic, different topic.
488
+ case 'gotcha':
489
+ case 'convention':
490
+ case 'learn': {
491
+ const topicMap = {
492
+ gotcha: 'gotchas', convention: 'conventions', learn: 'general',
493
+ };
494
+ const topic = topicMap[name];
495
+ const { lobe: rawLobe, observation, durabilityDecision } = z.object({
496
+ lobe: z.string().min(1),
497
+ observation: z.string().min(1),
498
+ durabilityDecision: z.enum(['default', 'store-anyway']).default('default'),
499
+ }).parse(args);
500
+ process.stderr.write(`[memory-mcp] tool=${name} lobe=${rawLobe}\n`);
501
+ const ctx = resolveToolContext(rawLobe);
502
+ if (!ctx.ok)
503
+ return contextError(ctx);
504
+ const { title, content } = extractTitle(observation);
505
+ const result = await ctx.store.store(topic, title, content, [], 'agent-inferred', [], [], durabilityDecision);
506
+ if (result.kind === 'review-required') {
507
+ return {
508
+ content: [{ type: 'text', text: `[${ctx.label}] Review required: ${result.warning}\n\nIf intentional, re-run: ${name}(lobe: "${rawLobe}", observation: "...", durabilityDecision: "store-anyway")` }],
509
+ isError: true,
510
+ };
511
+ }
512
+ if (result.kind === 'rejected') {
513
+ return {
514
+ content: [{ type: 'text', text: `[${ctx.label}] Failed: ${result.warning}` }],
515
+ isError: true,
516
+ };
517
+ }
518
+ const lootDrop = result.relatedEntries ? formatLootDrop(result.relatedEntries) : '';
519
+ const label = name === 'learn' ? '' : `${name} `;
520
+ return {
521
+ content: [{ type: 'text', text: `[${ctx.label}] Stored ${label}${result.id}: "${title}" (confidence: ${result.confidence})${lootDrop}` }],
522
+ };
523
+ }
524
+ case 'brief': {
525
+ // Session-start briefing — user identity, preferences, gotchas overview, stale entries.
526
+ // Delegates to the same briefing logic as memory_context(context: undefined).
527
+ const { lobe: rawLobe } = z.object({
528
+ lobe: z.string().optional(),
529
+ }).parse(args ?? {});
530
+ process.stderr.write(`[memory-mcp] tool=brief lobe=${rawLobe ?? 'auto'}\n`);
531
+ // Surface previous crash report
532
+ const previousCrash = await readLatestCrash();
533
+ const crashSection = previousCrash
534
+ ? `## Previous Crash Detected\n${formatCrashSummary(previousCrash)}\nRun **memory_diagnose** for full details.\n`
535
+ : '';
536
+ if (previousCrash)
537
+ await clearLatestCrash();
538
+ // Surface degraded lobes warning
539
+ const allBriefingLobes = rawLobe
540
+ ? [rawLobe, ...configManager.getAlwaysIncludeLobes().filter(n => n !== rawLobe)]
541
+ : configManager.getLobeNames();
542
+ const degradedLobeNames = allBriefingLobes.filter(n => configManager.getLobeHealth(n)?.status === 'degraded');
543
+ const degradedSection = degradedLobeNames.length > 0
544
+ ? `## Degraded Lobes: ${degradedLobeNames.join(', ')}\nRun **memory_diagnose** for details.`
545
+ : '';
546
+ const sections = [];
547
+ if (crashSection)
548
+ sections.push(crashSection);
549
+ if (degradedSection)
550
+ sections.push(degradedSection);
551
+ // Collect briefing across all lobes (or specified lobe + alwaysInclude)
552
+ const briefingLobeNames = allBriefingLobes;
553
+ const allStale = [];
554
+ let totalEntries = 0;
555
+ const alwaysIncludeSet = new Set(configManager.getAlwaysIncludeLobes());
556
+ for (const lobeName of briefingLobeNames) {
557
+ const health = configManager.getLobeHealth(lobeName);
558
+ if (health?.status === 'degraded')
559
+ continue;
560
+ const store = configManager.getStore(lobeName);
561
+ if (!store)
562
+ continue;
563
+ const budget = alwaysIncludeSet.has(lobeName) ? 300 : 100;
564
+ const lobeBriefing = await store.briefing(budget);
565
+ if (lobeBriefing.entryCount > 0)
566
+ sections.push(lobeBriefing.briefing);
567
+ if (lobeBriefing.staleDetails)
568
+ allStale.push(...lobeBriefing.staleDetails);
569
+ totalEntries += lobeBriefing.entryCount;
570
+ }
571
+ if (allStale.length > 0)
572
+ sections.push(formatStaleSection(allStale));
573
+ if (sections.length === 0) {
574
+ sections.push('No knowledge stored yet. As you work, use **gotcha**, **convention**, or **learn** to store observations. Try **memory_bootstrap** to seed initial knowledge.');
575
+ }
576
+ const briefLobes = briefingLobeNames.filter(n => configManager.getLobeHealth(n)?.status !== 'degraded');
577
+ sections.push(`---\n*${totalEntries} entries across ${briefLobes.length} ${briefLobes.length === 1 ? 'lobe' : 'lobes'}. Use **recall(context: "what you are doing")** for task-specific knowledge.*`);
578
+ return { content: [{ type: 'text', text: sections.join('\n\n---\n\n') }] };
579
+ }
580
+ case 'recall': {
581
+ // Pre-task lookup — semantic + keyword search on the target lobe.
582
+ // Pure task-relevant search — no global preferences (that's what brief is for).
583
+ const { lobe: rawLobe, context, maxResults } = z.object({
584
+ lobe: z.string().optional(),
585
+ context: z.string().min(1),
586
+ maxResults: z.number().optional(),
587
+ }).parse(args);
588
+ process.stderr.write(`[memory-mcp] tool=recall lobe=${rawLobe ?? 'auto'} context="${context.slice(0, 50)}"\n`);
589
+ const max = maxResults ?? 10;
590
+ const allLobeResults = [];
591
+ const ctxEntryLobeMap = new Map();
592
+ let label;
593
+ let primaryStore;
594
+ if (rawLobe) {
595
+ const ctx = resolveToolContext(rawLobe);
596
+ if (!ctx.ok)
597
+ return contextError(ctx);
598
+ label = ctx.label;
599
+ primaryStore = ctx.store;
600
+ const lobeResults = await ctx.store.contextSearch(context, max);
601
+ allLobeResults.push(...lobeResults);
602
+ }
603
+ else {
604
+ // Search all non-global lobes (recall is task-specific, not identity)
605
+ const resolution = await resolveLobesForRead(false);
606
+ switch (resolution.kind) {
607
+ case 'resolved': {
608
+ label = resolution.label;
609
+ for (const lobeName of resolution.lobes) {
610
+ const store = configManager.getStore(lobeName);
611
+ if (!store)
612
+ continue;
613
+ if (!primaryStore)
614
+ primaryStore = store;
615
+ const lobeResults = await store.contextSearch(context, max);
616
+ if (resolution.lobes.length > 1) {
617
+ for (const r of lobeResults)
618
+ ctxEntryLobeMap.set(r.entry.id, lobeName);
619
+ }
620
+ allLobeResults.push(...lobeResults);
621
+ }
622
+ break;
623
+ }
624
+ case 'global-only': {
625
+ // No project lobes matched — ask agent to specify
626
+ return {
627
+ content: [{ type: 'text', text: `Cannot determine which lobe to search. Specify the lobe: recall(lobe: "...", context: "${context}")\nAvailable: ${configManager.getLobeNames().join(', ')}` }],
628
+ isError: true,
629
+ };
630
+ }
631
+ }
632
+ }
633
+ // Dedupe, sort, slice
634
+ const seenIds = new Set();
635
+ const results = allLobeResults
636
+ .sort((a, b) => b.score - a.score)
637
+ .filter(r => {
638
+ if (seenIds.has(r.entry.id))
639
+ return false;
640
+ seenIds.add(r.entry.id);
641
+ return true;
642
+ })
643
+ .slice(0, max);
644
+ if (results.length === 0) {
645
+ const modeHint = primaryStore
646
+ ? `\n${formatSearchMode(primaryStore.hasEmbedder, primaryStore.vectorCount, primaryStore.entryCount)}`
647
+ : '';
648
+ return {
649
+ content: [{
650
+ type: 'text',
651
+ text: `No relevant knowledge found for: "${context}"\n\nProceed without prior context. Use **gotcha**, **convention**, or **learn** to store observations as you work.${modeHint}`,
652
+ }],
653
+ };
654
+ }
655
+ // Format results grouped by topic
656
+ const sections = [`## Recall: "${context}"\n`];
657
+ const byTopic = new Map();
658
+ for (const r of results) {
659
+ const list = byTopic.get(r.entry.topic) ?? [];
660
+ list.push(r);
661
+ byTopic.set(r.entry.topic, list);
662
+ }
663
+ const topicOrder = ['gotchas', 'conventions', 'architecture', 'general'];
664
+ const orderedTopics = [
665
+ ...topicOrder.filter(t => byTopic.has(t)),
666
+ ...Array.from(byTopic.keys()).filter(t => !topicOrder.includes(t)).sort(),
667
+ ];
668
+ const showLobeLabels = ctxEntryLobeMap.size > 0;
669
+ for (const topic of orderedTopics) {
670
+ const topicResults = byTopic.get(topic);
671
+ const heading = topic === 'gotchas' ? 'Gotchas'
672
+ : topic === 'conventions' ? 'Conventions'
673
+ : topic === 'general' ? 'General'
674
+ : topic.startsWith('modules/') ? `Module: ${topic.split('/')[1]}`
675
+ : topic.charAt(0).toUpperCase() + topic.slice(1);
676
+ sections.push(`### ${heading}`);
677
+ for (const r of topicResults) {
678
+ const marker = topic === 'gotchas' ? '[!] ' : '';
679
+ const lobeLabel = showLobeLabels ? ` [${ctxEntryLobeMap.get(r.entry.id) ?? '?'}]` : '';
680
+ sections.push(`- **${marker}${r.entry.title}**${lobeLabel}: ${r.entry.content}`);
681
+ }
682
+ sections.push('');
683
+ }
684
+ // Mode indicator
685
+ if (primaryStore) {
686
+ sections.push(formatSearchMode(primaryStore.hasEmbedder, primaryStore.vectorCount, primaryStore.entryCount));
687
+ }
688
+ return { content: [{ type: 'text', text: sections.join('\n') }] };
689
+ }
690
+ case 'gotchas': {
691
+ // Topic-filtered query for gotchas.
692
+ const { lobe: rawLobe, area } = z.object({
693
+ lobe: z.string().optional(),
694
+ area: z.string().optional(),
695
+ }).parse(args ?? {});
696
+ process.stderr.write(`[memory-mcp] tool=gotchas lobe=${rawLobe ?? 'auto'} area=${area ?? '*'}\n`);
697
+ const ctx = resolveToolContext(rawLobe);
698
+ if (!ctx.ok)
699
+ return contextError(ctx);
700
+ const result = await ctx.store.query('gotchas', 'standard', area);
701
+ if (result.entries.length === 0) {
702
+ return {
703
+ content: [{ type: 'text', text: `[${ctx.label}] No gotchas found${area ? ` for "${area}"` : ''}. Use **gotcha(lobe, observation)** to store one.` }],
704
+ };
705
+ }
706
+ const lines = result.entries.map(e => `- **[!] ${e.title}** (${e.id}): ${e.content}`);
707
+ return {
708
+ content: [{ type: 'text', text: `## [${ctx.label}] Gotchas${area ? ` — ${area}` : ''}\n\n${lines.join('\n')}` }],
709
+ };
710
+ }
711
+ case 'conventions': {
712
+ // Topic-filtered query for conventions.
713
+ const { lobe: rawLobe, area } = z.object({
714
+ lobe: z.string().optional(),
715
+ area: z.string().optional(),
716
+ }).parse(args ?? {});
717
+ process.stderr.write(`[memory-mcp] tool=conventions lobe=${rawLobe ?? 'auto'} area=${area ?? '*'}\n`);
718
+ const ctx = resolveToolContext(rawLobe);
719
+ if (!ctx.ok)
720
+ return contextError(ctx);
721
+ const result = await ctx.store.query('conventions', 'standard', area);
722
+ if (result.entries.length === 0) {
723
+ return {
724
+ content: [{ type: 'text', text: `[${ctx.label}] No conventions found${area ? ` for "${area}"` : ''}. Use **convention(lobe, observation)** to store one.` }],
725
+ };
726
+ }
727
+ const lines = result.entries.map(e => `- **${e.title}** (${e.id}): ${e.content}`);
728
+ return {
729
+ content: [{ type: 'text', text: `## [${ctx.label}] Conventions${area ? ` — ${area}` : ''}\n\n${lines.join('\n')}` }],
730
+ };
731
+ }
732
+ case 'prefer': {
733
+ // Store a user preference. Routes to alwaysInclude lobe (or specified lobe).
734
+ const { rule, lobe: rawLobe } = z.object({
735
+ rule: z.string().min(1),
736
+ lobe: z.string().optional(),
737
+ }).parse(args);
738
+ process.stderr.write(`[memory-mcp] tool=prefer lobe=${rawLobe ?? 'global'}\n`);
739
+ // Route to alwaysInclude lobe when no lobe specified
740
+ let effectiveLobe = rawLobe;
741
+ if (!effectiveLobe) {
742
+ const alwaysIncludeLobes = configManager.getAlwaysIncludeLobes();
743
+ effectiveLobe = alwaysIncludeLobes.length > 0 ? alwaysIncludeLobes[0] : undefined;
744
+ }
745
+ const ctx = resolveToolContext(effectiveLobe);
746
+ if (!ctx.ok) {
747
+ return {
748
+ content: [{ type: 'text', text: `No global lobe configured for preferences. Specify a lobe: prefer(rule: "...", lobe: "...").\nAvailable: ${configManager.getLobeNames().join(', ')}` }],
749
+ isError: true,
750
+ };
751
+ }
752
+ const { title, content } = extractTitle(rule);
753
+ const result = await ctx.store.store('preferences', title, content, [], 'user');
754
+ if (result.kind !== 'stored') {
755
+ return {
756
+ content: [{ type: 'text', text: `[${ctx.label}] Failed to store preference: ${result.kind === 'review-required' ? result.warning : result.kind === 'rejected' ? result.warning : 'unknown error'}` }],
757
+ isError: true,
758
+ };
759
+ }
760
+ return {
761
+ content: [{ type: 'text', text: `[${ctx.label}] Stored preference ${result.id}: "${title}" (trust: user)` }],
762
+ };
763
+ }
764
+ case 'fix': {
765
+ // Fix or delete an entry. Search all stores if no lobe specified.
766
+ const { id, correction, lobe: rawLobe } = z.object({
767
+ id: z.string().min(1),
768
+ correction: z.string().optional(),
769
+ lobe: z.string().optional(),
770
+ }).parse(args);
771
+ process.stderr.write(`[memory-mcp] tool=fix id=${id} action=${correction ? 'replace' : 'delete'}\n`);
772
+ const action = correction !== undefined && correction.length > 0 ? 'replace' : 'delete';
773
+ // Resolve lobe — search all stores if not specified
774
+ let effectiveFixLobe = rawLobe;
775
+ let foundInLobe;
776
+ if (!effectiveFixLobe) {
777
+ for (const lobeName of configManager.getLobeNames()) {
778
+ const store = configManager.getStore(lobeName);
779
+ if (!store)
780
+ continue;
781
+ try {
782
+ if (await store.hasEntry(id)) {
783
+ effectiveFixLobe = lobeName;
784
+ foundInLobe = lobeName;
785
+ break;
786
+ }
787
+ }
788
+ catch {
789
+ // Skip degraded lobes
790
+ }
791
+ }
792
+ }
793
+ if (!effectiveFixLobe) {
794
+ return {
795
+ content: [{ type: 'text', text: `Entry "${id}" not found in any lobe. Available: ${configManager.getLobeNames().join(', ')}` }],
796
+ isError: true,
797
+ };
798
+ }
799
+ const ctx = resolveToolContext(effectiveFixLobe);
800
+ if (!ctx.ok)
801
+ return contextError(ctx);
802
+ const result = await ctx.store.correct(id, correction ?? '', action);
803
+ if (!result.corrected) {
804
+ return {
805
+ content: [{ type: 'text', text: `[${ctx.label}] Failed: ${result.error}` }],
806
+ isError: true,
807
+ };
808
+ }
809
+ const lobeNote = foundInLobe && !rawLobe ? ` (found in lobe: ${foundInLobe})` : '';
810
+ if (action === 'delete') {
811
+ return {
812
+ content: [{ type: 'text', text: `Deleted entry ${id}${lobeNote}.` }],
813
+ };
814
+ }
815
+ return {
816
+ content: [{ type: 'text', text: `Fixed entry ${id}${lobeNote} (confidence: ${result.newConfidence}, trust: ${result.trust}).` }],
817
+ };
818
+ }
819
+ // ─── Legacy tool handlers (hidden from listing) ──────────────────────
452
820
  case 'memory_store': {
453
821
  const { lobe: rawLobe, topic: rawTopic, entries: rawEntries, sources, references, trust: rawTrust, tags: rawTags, durabilityDecision } = z.object({
454
822
  lobe: z.string().optional(),
@@ -470,7 +838,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
470
838
  const topic = parseTopicScope(rawTopic);
471
839
  if (!topic) {
472
840
  return {
473
- content: [{ type: 'text', text: `Invalid topic: "${rawTopic}". Valid: user | preferences | architecture | conventions | gotchas | recent-work | modules/<name>` }],
841
+ content: [{ type: 'text', text: `Invalid topic: "${rawTopic}". Valid: user | preferences | architecture | conventions | gotchas | general | recent-work | modules/<name>` }],
474
842
  isError: true,
475
843
  };
476
844
  }
@@ -1179,20 +1547,50 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1179
1547
  }
1180
1548
  catch (error) {
1181
1549
  const message = error instanceof Error ? error.message : String(error);
1182
- // Provide helpful hints for common Zod validation errors
1550
+ // Provide helpful, human-readable hints for Zod validation errors.
1551
+ // Raw Zod JSON is cryptic for agents — translate to actionable guidance.
1552
+ const lobeNames = configManager.getLobeNames();
1553
+ const lobeList = lobeNames.join(', ');
1554
+ let friendlyMessage = message;
1555
+ // Detect Zod validation errors (they contain path arrays in JSON)
1556
+ if (message.includes('"path"') && message.includes('"message"')) {
1557
+ // Build a friendlyMessage from the Zod issues
1558
+ try {
1559
+ const issues = JSON.parse(message);
1560
+ const fields = issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', ');
1561
+ friendlyMessage = `Invalid arguments — ${fields}`;
1562
+ }
1563
+ catch {
1564
+ // If parsing fails, keep original message
1565
+ }
1566
+ }
1567
+ // Tool-specific hints based on which field failed
1183
1568
  let hint = '';
1184
- if (message.includes('"lobe"') && message.includes('Required')) {
1185
- const lobeNames = configManager.getLobeNames();
1186
- hint = `\n\nHint: lobe is required. Use memory_list_lobes to see available lobes. Available: ${lobeNames.join(', ')}`;
1569
+ const v2ToolHints = {
1570
+ brief: `brief takes an optional lobe. Example: {"lobe": "${lobeNames[0] ?? 'default'}"}. Available lobes: ${lobeList}`,
1571
+ recall: `recall requires "context" (describe what you are working on). Example: {"context": "implementing auth flow", "lobe": "${lobeNames[0] ?? 'default'}"}. Available lobes: ${lobeList}`,
1572
+ gotcha: `gotcha requires "lobe" and "observation". Example: {"lobe": "${lobeNames[0] ?? 'default'}", "observation": "Gradle cache breaks after Tuist changes"}`,
1573
+ convention: `convention requires "lobe" and "observation". Example: {"lobe": "${lobeNames[0] ?? 'default'}", "observation": "All ViewModels use StateFlow"}`,
1574
+ learn: `learn requires "lobe" and "observation". Example: {"lobe": "${lobeNames[0] ?? 'default'}", "observation": "Messaging uses MVVM with FlowCoordinator"}`,
1575
+ prefer: `prefer requires "rule". Example: {"rule": "Always suggest the simplest solution first"}`,
1576
+ fix: `fix requires "id". Optional: "correction" (new text; omit to delete). Example: {"id": "gotcha-3f7a", "correction": "updated text"}`,
1577
+ gotchas: `gotchas takes optional "lobe" and "area". Example: {"lobe": "${lobeNames[0] ?? 'default'}", "area": "auth"}. Available lobes: ${lobeList}`,
1578
+ conventions: `conventions takes optional "lobe" and "area". Example: {"lobe": "${lobeNames[0] ?? 'default'}", "area": "testing"}. Available lobes: ${lobeList}`,
1579
+ };
1580
+ if (name in v2ToolHints) {
1581
+ hint = `\n\nUsage: ${v2ToolHints[name]}`;
1582
+ }
1583
+ else if (friendlyMessage.includes('"lobe"') || friendlyMessage.includes('lobe:')) {
1584
+ hint = `\n\nHint: lobe is required. Available: ${lobeList}`;
1187
1585
  }
1188
1586
  else if (message.includes('"topic"') || message.includes('"entries"')) {
1189
- hint = '\n\nHint: memory_store requires: topic (architecture|conventions|gotchas|recent-work|modules/<name>) and entries (Array<{title, fact}>). Example: entries: [{title: "Build cache", fact: "Must clean build after Tuist changes"}]. Use modules/<name> for custom namespaces.';
1587
+ hint = '\n\nHint: memory_store requires: topic (architecture|conventions|gotchas|general|recent-work|modules/<name>) and entries (Array<{title, fact}>).';
1190
1588
  }
1191
1589
  else if (message.includes('"scope"')) {
1192
- hint = '\n\nHint: memory_query requires: lobe, scope (architecture|conventions|gotchas|recent-work|modules/<name>|* for all)';
1590
+ hint = '\n\nHint: memory_query requires: scope (architecture|conventions|gotchas|*). Example: memory_query(scope: "*")';
1193
1591
  }
1194
1592
  return {
1195
- content: [{ type: 'text', text: `Error: ${message}${hint}` }],
1593
+ content: [{ type: 'text', text: `${friendlyMessage}${hint}` }],
1196
1594
  isError: true,
1197
1595
  };
1198
1596
  }
package/dist/normalize.js CHANGED
@@ -3,17 +3,10 @@
3
3
  // Agents frequently guess wrong param names. This module resolves common aliases
4
4
  // and applies defaults to avoid wasted round-trips from validation errors.
5
5
  // Pure functions — no side effects, no state.
6
- /** Canonical param name aliases — maps guessed names to their correct form */
7
- const PARAM_ALIASES = {
6
+ /** Global param aliases — apply to all tools regardless of name */
7
+ const GLOBAL_ALIASES = {
8
8
  // memory_store aliases
9
9
  refs: 'references',
10
- // memory_query aliases
11
- query: 'filter',
12
- search: 'filter',
13
- keyword: 'filter',
14
- // memory_context aliases
15
- description: 'context',
16
- task: 'context',
17
10
  // tag aliases
18
11
  tag: 'tags',
19
12
  labels: 'tags',
@@ -22,6 +15,28 @@ const PARAM_ALIASES = {
22
15
  workspace: 'lobe',
23
16
  repo: 'lobe',
24
17
  };
18
+ /** Tool-specific param aliases — only apply when the tool name matches.
19
+ * Prevents `query` → `filter` rewriting from breaking `recall(query: "...")`. */
20
+ const TOOL_ALIASES = {
21
+ // Legacy tools: "query" means "filter"
22
+ memory_query: { query: 'filter', search: 'filter', keyword: 'filter' },
23
+ memory_store: { scope: 'topic' },
24
+ // Legacy tools: "description"/"task" mean "context"
25
+ memory_context: { description: 'context', task: 'context' },
26
+ // v2 retrieval tools: "query"/"search"/"description"/"task" mean "context"
27
+ recall: { query: 'context', search: 'context', description: 'context', task: 'context' },
28
+ // v2 storage tools: "content"/"note"/"fact" mean "observation"
29
+ gotcha: { content: 'observation', note: 'observation', fact: 'observation', message: 'observation' },
30
+ convention: { content: 'observation', note: 'observation', fact: 'observation', message: 'observation' },
31
+ learn: { content: 'observation', note: 'observation', fact: 'observation', message: 'observation' },
32
+ // v2 prefer: "preference"/"pref" mean "rule"
33
+ prefer: { preference: 'rule', pref: 'rule' },
34
+ // v2 fix: "text"/"content"/"replacement" mean "correction"
35
+ fix: { text: 'correction', content: 'correction', replacement: 'correction' },
36
+ // v2 retrieval: "filter"/"keyword" mean "area"
37
+ gotchas: { filter: 'area', keyword: 'area', query: 'area', search: 'area' },
38
+ conventions: { filter: 'area', keyword: 'area', query: 'area', search: 'area' },
39
+ };
25
40
  /** Wildcard scope aliases — agents guess many variations instead of "*" */
26
41
  const SCOPE_WILDCARDS = new Set([
27
42
  'all', 'everything', 'any', '*', 'global', 'project', 'repo',
@@ -30,17 +45,22 @@ const SCOPE_WILDCARDS = new Set([
30
45
  /** Normalize args before Zod validation: resolve aliases, default workspace, fix wildcards */
31
46
  export function normalizeArgs(toolName, raw, lobeNames) {
32
47
  const args = { ...(raw ?? {}) };
33
- // 1. Resolve param aliases (move aliased keys to canonical names)
34
- for (const [alias, canonical] of Object.entries(PARAM_ALIASES)) {
48
+ // 1. Resolve global param aliases
49
+ for (const [alias, canonical] of Object.entries(GLOBAL_ALIASES)) {
35
50
  if (alias in args && !(canonical in args)) {
36
51
  args[canonical] = args[alias];
37
52
  delete args[alias];
38
53
  }
39
54
  }
40
- // 2. For memory_store: accept "scope" as alias for "topic"
41
- if (toolName === 'memory_store' && 'scope' in args && !('topic' in args)) {
42
- args['topic'] = args['scope'];
43
- delete args['scope'];
55
+ // 2. Resolve tool-specific param aliases
56
+ const toolSpecific = TOOL_ALIASES[toolName];
57
+ if (toolSpecific) {
58
+ for (const [alias, canonical] of Object.entries(toolSpecific)) {
59
+ if (alias in args && !(canonical in args)) {
60
+ args[canonical] = args[alias];
61
+ delete args[alias];
62
+ }
63
+ }
44
64
  }
45
65
  // 3. Default lobe to the only available one when omitted
46
66
  if (!('lobe' in args) || args['lobe'] === undefined || args['lobe'] === '') {
@@ -60,3 +60,15 @@ export declare function matchesFilter(allKeywords: Set<string>, filter: string,
60
60
  * Title matches get 2x weight over content-only matches.
61
61
  * Tag and exact matches count as full-weight hits (same as title). */
62
62
  export declare function computeRelevanceScore(titleKeywords: Set<string>, contentKeywords: Set<string>, confidence: number, filter: string, tags?: readonly string[]): number;
63
+ /** Extract a title and content from a single observation string.
64
+ * Title: first sentence (terminated by . ! ? or newline), capped at MAX_TITLE_LENGTH.
65
+ * Content: the full observation text (title is a derived label, not subtracted).
66
+ *
67
+ * Abbreviation-safe: requires 2+ word characters before the period to avoid
68
+ * splitting on "e.g.", "U.S.", "i.e.", etc.
69
+ *
70
+ * Pure function — no I/O, no side effects. */
71
+ export declare function extractTitle(observation: string): {
72
+ readonly title: string;
73
+ readonly content: string;
74
+ };
@@ -275,3 +275,31 @@ export function computeRelevanceScore(titleKeywords, contentKeywords, confidence
275
275
  }
276
276
  return bestScore * confidence;
277
277
  }
278
+ /** Maximum title length before truncation */
279
+ const MAX_TITLE_LENGTH = 80;
280
+ /** Extract a title and content from a single observation string.
281
+ * Title: first sentence (terminated by . ! ? or newline), capped at MAX_TITLE_LENGTH.
282
+ * Content: the full observation text (title is a derived label, not subtracted).
283
+ *
284
+ * Abbreviation-safe: requires 2+ word characters before the period to avoid
285
+ * splitting on "e.g.", "U.S.", "i.e.", etc.
286
+ *
287
+ * Pure function — no I/O, no side effects. */
288
+ export function extractTitle(observation) {
289
+ const trimmed = observation.trim();
290
+ if (trimmed.length === 0)
291
+ return { title: '', content: '' };
292
+ // Find first sentence boundary.
293
+ // The lookbehind (?<=\w{2}) ensures we don't split on abbreviations like "e.g." or "U.S."
294
+ // where a period follows a single character. Newlines always end a sentence.
295
+ const sentenceMatch = trimmed.match(/(?<=\w{2})[.!?](?:\s|$)|\n/);
296
+ const sentenceEnd = sentenceMatch?.index ?? -1;
297
+ const firstSentence = sentenceEnd >= 0
298
+ ? trimmed.slice(0, sentenceEnd + 1).trim()
299
+ : trimmed;
300
+ // Truncate if too long
301
+ const title = firstSentence.length <= MAX_TITLE_LENGTH
302
+ ? firstSentence
303
+ : firstSentence.slice(0, MAX_TITLE_LENGTH - 3).trimEnd() + '...';
304
+ return { title, content: trimmed };
305
+ }
package/dist/types.d.ts CHANGED
@@ -7,7 +7,7 @@ export type EphemeralSeverity = 'high' | 'medium' | 'low';
7
7
  /** Parse a raw string into a TrustLevel, returning null for invalid input */
8
8
  export declare function parseTrustLevel(raw: string): TrustLevel | null;
9
9
  /** Predefined topic scopes for organizing knowledge */
10
- export type TopicScope = 'user' | 'preferences' | 'architecture' | 'conventions' | 'gotchas' | 'recent-work' | `modules/${string}`;
10
+ export type TopicScope = 'user' | 'preferences' | 'architecture' | 'conventions' | 'gotchas' | 'general' | 'recent-work' | `modules/${string}`;
11
11
  /** Validated tag: lowercase alphanumeric slug (letters, digits, hyphens).
12
12
  * Branded type prevents accidentally passing raw strings where validated tags are expected. */
13
13
  export type Tag = string & {
package/dist/types.js CHANGED
@@ -9,7 +9,7 @@ const TRUST_LEVELS = ['user', 'agent-confirmed', 'agent-inferred'];
9
9
  export function parseTrustLevel(raw) {
10
10
  return TRUST_LEVELS.includes(raw) ? raw : null;
11
11
  }
12
- const FIXED_TOPICS = ['user', 'preferences', 'architecture', 'conventions', 'gotchas', 'recent-work'];
12
+ const FIXED_TOPICS = ['user', 'preferences', 'architecture', 'conventions', 'gotchas', 'general', 'recent-work'];
13
13
  /** Construct an EmbeddingVector from a Float32Array. Boundary validation only —
14
14
  * callers (OllamaEmbedder, FakeEmbedder, vector deserialization) validate dimensions. */
15
15
  export function asEmbeddingVector(raw) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exaudeus/memory-mcp",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "Codebase memory MCP server - persistent, evolving knowledge for AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",