@jungjaehoon/mama-server 1.2.4 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,17 +74,27 @@ Any MCP-compatible client can use MAMA with:
74
74
  npx -y @jungjaehoon/mama-server
75
75
  ```
76
76
 
77
- ## Available Tools
77
+ ## Available Tools (v1.3)
78
78
 
79
- The MCP server exposes these tools:
79
+ The MCP server exposes 4 core tools:
80
80
 
81
- - `save_decision` - Save decisions with reasoning and confidence
82
- - `recall_decision` - View full evolution history for a topic
83
- - `suggest_decision` - Semantic search across all decisions
84
- - `list_decisions` - Browse recent decisions chronologically
85
- - `update_outcome` - Update decision outcomes (success/failure/partial)
86
- - `save_checkpoint` - Save session state for later resumption
87
- - `load_checkpoint` - Restore previous session context
81
+ | Tool | Description |
82
+ | ----------------- | --------------------------------------------------------------------- |
83
+ | `save` | Save decision (`type='decision'`) or checkpoint (`type='checkpoint'`) |
84
+ | `search` | Semantic search (with `query`) or list recent items (without `query`) |
85
+ | `update` | Update decision outcome (case-insensitive: success/failed/partial) |
86
+ | `load_checkpoint` | Resume previous session |
87
+
88
+ ### Edge Types
89
+
90
+ Decisions connect through relationships. Include patterns in your reasoning:
91
+
92
+ | Edge Type | Pattern | Meaning |
93
+ | ------------- | -------------------------- | -------------------------- |
94
+ | `supersedes` | (automatic for same topic) | Newer replaces older |
95
+ | `builds_on` | `builds_on: decision_xxx` | Extends prior work |
96
+ | `debates` | `debates: decision_xxx` | Alternative view |
97
+ | `synthesizes` | `synthesizes: [id1, id2]` | Merges multiple approaches |
88
98
 
89
99
  ## Usage Example
90
100
 
@@ -197,4 +207,4 @@ MAMA was inspired by [mem0](https://github.com/mem0ai/mem0) (Apache 2.0). While
197
207
  ---
198
208
 
199
209
  **Author:** SpineLift Team
200
- **Version:** 1.1.0
210
+ **Version:** 1.3.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungjaehoon/mama-server",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "description": "MAMA MCP Server - Memory-Augmented MCP Assistant for Claude Code & Desktop",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -0,0 +1,56 @@
1
+ -- ══════════════════════════════════════════════════════════════
2
+ -- MAMA Migration 010: Extend Edge Types
3
+ -- ══════════════════════════════════════════════════════════════
4
+ -- Version: 1.3
5
+ -- Date: 2025-11-26
6
+ -- Purpose: Add builds_on, debates, synthesizes relationship types
7
+ -- Story: 2.1 - Edge Type Extension
8
+ -- ══════════════════════════════════════════════════════════════
9
+
10
+ -- SQLite doesn't support ALTER TABLE to modify CHECK constraints.
11
+ -- We need to recreate the table with expanded CHECK constraint.
12
+
13
+ -- Step 1: Create new table with extended edge types
14
+ CREATE TABLE IF NOT EXISTS decision_edges_new (
15
+ from_id TEXT NOT NULL,
16
+ to_id TEXT NOT NULL,
17
+ relationship TEXT NOT NULL,
18
+ reason TEXT,
19
+ weight REAL DEFAULT 1.0,
20
+ created_at INTEGER DEFAULT (unixepoch()),
21
+ created_by TEXT DEFAULT 'user' CHECK (created_by IN ('llm', 'user')),
22
+ approved_by_user INTEGER DEFAULT 1 CHECK (approved_by_user IN (0, 1)),
23
+ decision_id TEXT,
24
+ evidence TEXT,
25
+
26
+ PRIMARY KEY (from_id, to_id, relationship),
27
+ FOREIGN KEY (from_id) REFERENCES decisions(id),
28
+ FOREIGN KEY (to_id) REFERENCES decisions(id),
29
+
30
+ -- Extended CHECK constraint: original + v1.3 types
31
+ CHECK (relationship IN ('supersedes', 'refines', 'contradicts', 'builds_on', 'debates', 'synthesizes')),
32
+ CHECK (weight >= 0.0 AND weight <= 1.0)
33
+ );
34
+
35
+ -- Step 2: Copy existing data (explicit columns to handle schema variations)
36
+ INSERT OR IGNORE INTO decision_edges_new (from_id, to_id, relationship, reason, weight, created_at, created_by, approved_by_user, decision_id, evidence)
37
+ SELECT from_id, to_id, relationship, reason, weight, created_at, created_by, approved_by_user, decision_id, evidence FROM decision_edges;
38
+
39
+ -- Step 3: Drop old table
40
+ DROP TABLE IF EXISTS decision_edges;
41
+
42
+ -- Step 4: Rename new table
43
+ ALTER TABLE decision_edges_new RENAME TO decision_edges;
44
+
45
+ -- Step 5: Recreate indexes
46
+ CREATE INDEX IF NOT EXISTS idx_edges_from ON decision_edges(from_id);
47
+ CREATE INDEX IF NOT EXISTS idx_edges_to ON decision_edges(to_id);
48
+ CREATE INDEX IF NOT EXISTS idx_edges_relationship ON decision_edges(relationship);
49
+
50
+ -- Update schema version
51
+ INSERT OR REPLACE INTO schema_version (version, description, applied_at)
52
+ VALUES (10, 'Extend edge types: builds_on, debates, synthesizes', unixepoch());
53
+
54
+ -- ══════════════════════════════════════════════════════════════
55
+ -- End of Migration 010
56
+ -- ══════════════════════════════════════════════════════════════
@@ -184,6 +184,12 @@ async function startEmbeddingServer(port = DEFAULT_PORT) {
184
184
  );
185
185
  // Not a fatal error - another server instance may be running
186
186
  resolve(null);
187
+ } else if (error.code === 'EPERM' || error.code === 'EACCES') {
188
+ console.error(
189
+ `[EmbeddingHTTP] Permission denied opening port ${port}, skipping HTTP embedding server (sandboxed environment)`
190
+ );
191
+ // Some environments block listening on localhost; keep MCP server running without HTTP embeddings
192
+ resolve(null);
187
193
  } else {
188
194
  reject(error);
189
195
  }
@@ -386,30 +386,46 @@ async function queryDecisionGraph(topic) {
386
386
  * for refines and contradicts relationships
387
387
  *
388
388
  * @param {Array<string>} decisionIds - Decision IDs to query edges for
389
- * @returns {Promise<Object>} Categorized edges { refines, refined_by, contradicts, contradicted_by }
389
+ * @returns {Promise<Object>} Categorized edges { refines, refined_by, contradicts, contradicted_by, builds_on, built_on_by, debates, debated_by, synthesizes, synthesized_by }
390
390
  */
391
391
  async function querySemanticEdges(decisionIds) {
392
392
  const adapter = getAdapter();
393
393
 
394
394
  if (!decisionIds || decisionIds.length === 0) {
395
- return { refines: [], refined_by: [], contradicts: [], contradicted_by: [] };
395
+ return {
396
+ refines: [],
397
+ refined_by: [],
398
+ contradicts: [],
399
+ contradicted_by: [],
400
+ // Story 2.1: Extended edge types
401
+ builds_on: [],
402
+ built_on_by: [],
403
+ debates: [],
404
+ debated_by: [],
405
+ synthesizes: [],
406
+ synthesized_by: [],
407
+ };
396
408
  }
397
409
 
398
410
  try {
399
411
  // Build placeholders for IN clause
400
412
  const placeholders = decisionIds.map(() => '?').join(',');
401
413
 
414
+ // Story 2.1: Include new edge types in query
415
+ const edgeTypes = ['refines', 'contradicts', 'builds_on', 'debates', 'synthesizes'];
416
+ const edgeTypePlaceholders = edgeTypes.map(() => '?').join(',');
417
+
402
418
  // Query outgoing edges (from_id = decision)
403
419
  const outgoingStmt = adapter.prepare(`
404
420
  SELECT e.*, d.topic, d.decision, d.confidence, d.created_at
405
421
  FROM decision_edges e
406
422
  JOIN decisions d ON e.to_id = d.id
407
423
  WHERE e.from_id IN (${placeholders})
408
- AND e.relationship IN ('refines', 'contradicts')
424
+ AND e.relationship IN (${edgeTypePlaceholders})
409
425
  AND (e.approved_by_user = 1 OR e.approved_by_user IS NULL)
410
426
  ORDER BY e.created_at DESC
411
427
  `);
412
- const outgoingEdges = await outgoingStmt.all(...decisionIds);
428
+ const outgoingEdges = await outgoingStmt.all(...decisionIds, ...edgeTypes);
413
429
 
414
430
  // Query incoming edges (to_id = decision)
415
431
  const incomingStmt = adapter.prepare(`
@@ -417,23 +433,36 @@ async function querySemanticEdges(decisionIds) {
417
433
  FROM decision_edges e
418
434
  JOIN decisions d ON e.from_id = d.id
419
435
  WHERE e.to_id IN (${placeholders})
420
- AND e.relationship IN ('refines', 'contradicts')
436
+ AND e.relationship IN (${edgeTypePlaceholders})
421
437
  AND (e.approved_by_user = 1 OR e.approved_by_user IS NULL)
422
438
  ORDER BY e.created_at DESC
423
439
  `);
424
- const incomingEdges = await incomingStmt.all(...decisionIds);
440
+ const incomingEdges = await incomingStmt.all(...decisionIds, ...edgeTypes);
425
441
 
426
- // Categorize edges
442
+ // Categorize edges (original + v1.3 extended)
427
443
  const refines = outgoingEdges.filter((e) => e.relationship === 'refines');
428
444
  const refined_by = incomingEdges.filter((e) => e.relationship === 'refines');
429
445
  const contradicts = outgoingEdges.filter((e) => e.relationship === 'contradicts');
430
446
  const contradicted_by = incomingEdges.filter((e) => e.relationship === 'contradicts');
447
+ // Story 2.1: New edge type categories
448
+ const builds_on = outgoingEdges.filter((e) => e.relationship === 'builds_on');
449
+ const built_on_by = incomingEdges.filter((e) => e.relationship === 'builds_on');
450
+ const debates = outgoingEdges.filter((e) => e.relationship === 'debates');
451
+ const debated_by = incomingEdges.filter((e) => e.relationship === 'debates');
452
+ const synthesizes = outgoingEdges.filter((e) => e.relationship === 'synthesizes');
453
+ const synthesized_by = incomingEdges.filter((e) => e.relationship === 'synthesizes');
431
454
 
432
455
  return {
433
456
  refines,
434
457
  refined_by,
435
458
  contradicts,
436
459
  contradicted_by,
460
+ builds_on,
461
+ built_on_by,
462
+ debates,
463
+ debated_by,
464
+ synthesizes,
465
+ synthesized_by,
437
466
  };
438
467
  } catch (error) {
439
468
  throw new Error(`Semantic edges query failed: ${error.message}`);
@@ -24,6 +24,21 @@ const {
24
24
  getAdapter,
25
25
  } = require('./memory-store');
26
26
 
27
+ // ════════════════════════════════════════════════════════════════════════════
28
+ // Story 2.1: Extended Edge Types
29
+ // ════════════════════════════════════════════════════════════════════════════
30
+ // Valid relationship types for decision_edges
31
+ // Original: supersedes, refines, contradicts
32
+ // v1.3 Extension: builds_on, debates, synthesizes
33
+ const VALID_EDGE_TYPES = [
34
+ 'supersedes', // Original: New decision replaces old one
35
+ 'refines', // Original: Decision refines another
36
+ 'contradicts', // Original: Decision contradicts another
37
+ 'builds_on', // v1.3: Extends existing decision with new insights
38
+ 'debates', // v1.3: Presents counter-argument with evidence
39
+ 'synthesizes', // v1.3: Merges multiple decisions into unified approach
40
+ ];
41
+
27
42
  /**
28
43
  * Generate decision ID
29
44
  *
@@ -74,32 +89,68 @@ async function getPreviousDecision(topic) {
74
89
  }
75
90
 
76
91
  /**
77
- * Create supersedes edge
92
+ * Create a decision edge with specified relationship type
78
93
  *
79
- * Task 3.5: Create supersedes edge (INSERT INTO decision_edges)
80
- * AC #2: Supersedes relationship creation
94
+ * Story 2.1: Generic edge creation supporting all relationship types
81
95
  *
82
- * @param {string} fromId - New decision ID
83
- * @param {string} toId - Previous decision ID
84
- * @param {string} reason - Reason for superseding
96
+ * @param {string} fromId - Source decision ID
97
+ * @param {string} toId - Target decision ID
98
+ * @param {string} relationship - Edge type (supersedes, builds_on, debates, synthesizes, etc.)
99
+ * @param {string} reason - Reason for the relationship
100
+ * @returns {Promise<boolean>} Success status
85
101
  */
86
- async function createSupersedesEdge(fromId, toId, reason) {
102
+ async function createEdge(fromId, toId, relationship, reason) {
87
103
  const adapter = getAdapter();
88
104
 
105
+ // Story 2.1: Runtime validation of edge types
106
+ if (!VALID_EDGE_TYPES.includes(relationship)) {
107
+ throw new Error(
108
+ `Invalid edge type: "${relationship}". Valid types: ${VALID_EDGE_TYPES.join(', ')}`
109
+ );
110
+ }
111
+
89
112
  try {
90
- // Auto-generated links: created_by='llm', approved_by_user=0 (pending approval)
91
- // This ensures they appear in get_pending_links and require explicit approval
113
+ // Note: SQLite CHECK constraint only allows supersedes/refines/contradicts
114
+ // New types (builds_on, debates, synthesizes) bypass CHECK via runtime validation
115
+ // The INSERT will fail for new types due to CHECK constraint
116
+ // WORKAROUND: Use PRAGMA ignore_check_constraints or recreate table
117
+ // For now, we'll catch the error and handle gracefully
118
+
119
+ // Story 2.1: LLM auto-detected edges are approved by default (approved_by_user=1)
120
+ // This allows them to appear in search results via querySemanticEdges
92
121
  const stmt = adapter.prepare(`
93
- INSERT INTO decision_edges (from_id, to_id, relationship, reason, created_at, created_by, approved_by_user)
94
- VALUES (?, ?, 'supersedes', ?, ?, 'llm', 0)
122
+ INSERT OR REPLACE INTO decision_edges (from_id, to_id, relationship, reason, created_at, created_by, approved_by_user)
123
+ VALUES (?, ?, ?, ?, ?, 'llm', 1)
95
124
  `);
96
125
 
97
- await stmt.run(fromId, toId, reason, Date.now());
126
+ await stmt.run(fromId, toId, relationship, reason, Date.now());
127
+ return true;
98
128
  } catch (error) {
99
- throw new Error(`Failed to create supersedes edge: ${error.message}`);
129
+ // Handle CHECK constraint failure for new edge types
130
+ if (error.message.includes('CHECK constraint failed')) {
131
+ info(
132
+ `[decision-tracker] Edge type "${relationship}" not yet supported in schema, skipping edge creation`
133
+ );
134
+ return false;
135
+ }
136
+ throw new Error(`Failed to create ${relationship} edge: ${error.message}`);
100
137
  }
101
138
  }
102
139
 
140
+ /**
141
+ * Create supersedes edge
142
+ *
143
+ * Task 3.5: Create supersedes edge (INSERT INTO decision_edges)
144
+ * AC #2: Supersedes relationship creation
145
+ *
146
+ * @param {string} fromId - New decision ID
147
+ * @param {string} toId - Previous decision ID
148
+ * @param {string} reason - Reason for superseding
149
+ */
150
+ async function createSupersedesEdge(fromId, toId, reason) {
151
+ return createEdge(fromId, toId, 'supersedes', reason);
152
+ }
153
+
103
154
  /**
104
155
  * Update previous decision's superseded_by field
105
156
  *
@@ -175,6 +226,163 @@ function detectRefinement(_detection, _sessionContext) {
175
226
  return null;
176
227
  }
177
228
 
229
+ // ════════════════════════════════════════════════════════════════════════════
230
+ // Story 2.2: Reasoning Field Parsing
231
+ // ════════════════════════════════════════════════════════════════════════════
232
+
233
+ /**
234
+ * Parse reasoning field for relationship references
235
+ *
236
+ * Story 2.2: Detect patterns like:
237
+ * - builds_on: <decision_id>
238
+ * - debates: <decision_id>
239
+ * - synthesizes: [id1, id2]
240
+ *
241
+ * @param {string} reasoning - Decision reasoning text
242
+ * @returns {Array<{type: string, targetIds: string[]}>} Detected relationships
243
+ */
244
+ function parseReasoningForRelationships(reasoning) {
245
+ if (!reasoning || typeof reasoning !== 'string') {
246
+ return [];
247
+ }
248
+
249
+ const relationships = [];
250
+
251
+ // Pattern 1: builds_on: <id> or builds_on: decision_xxx
252
+ const buildsOnMatch = reasoning.match(/builds_on:\s*(decision_[a-z0-9_]+)/gi);
253
+ if (buildsOnMatch) {
254
+ buildsOnMatch.forEach((match) => {
255
+ const id = match.replace(/builds_on:\s*/i, '').trim();
256
+ if (id) {
257
+ relationships.push({ type: 'builds_on', targetIds: [id] });
258
+ }
259
+ });
260
+ }
261
+
262
+ // Pattern 2: debates: <id> or debates: decision_xxx
263
+ const debatesMatch = reasoning.match(/debates:\s*(decision_[a-z0-9_]+)/gi);
264
+ if (debatesMatch) {
265
+ debatesMatch.forEach((match) => {
266
+ const id = match.replace(/debates:\s*/i, '').trim();
267
+ if (id) {
268
+ relationships.push({ type: 'debates', targetIds: [id] });
269
+ }
270
+ });
271
+ }
272
+
273
+ // Pattern 3: synthesizes: [id1, id2] or synthesizes: decision_xxx, decision_yyy
274
+ const synthesizesMatch = reasoning.match(
275
+ /synthesizes:\s*\[?\s*(decision_[a-z0-9_]+(?:\s*,\s*decision_[a-z0-9_]+)*)\s*\]?/gi
276
+ );
277
+ if (synthesizesMatch) {
278
+ synthesizesMatch.forEach((match) => {
279
+ const idsStr = match.replace(/synthesizes:\s*\[?\s*/i, '').replace(/\s*\]?\s*$/, '');
280
+ const ids = idsStr.split(/\s*,\s*/).filter((id) => id.startsWith('decision_'));
281
+ if (ids.length > 0) {
282
+ relationships.push({ type: 'synthesizes', targetIds: ids });
283
+ }
284
+ });
285
+ }
286
+
287
+ return relationships;
288
+ }
289
+
290
+ /**
291
+ * Create edges from parsed reasoning relationships
292
+ *
293
+ * Story 2.2: Auto-create edges when reasoning references other decisions
294
+ *
295
+ * @param {string} fromId - Source decision ID
296
+ * @param {string} reasoning - Decision reasoning text
297
+ * @returns {Promise<{created: number, failed: number}>} Edge creation stats
298
+ */
299
+ async function createEdgesFromReasoning(fromId, reasoning) {
300
+ const relationships = parseReasoningForRelationships(reasoning);
301
+ let created = 0;
302
+ let failed = 0;
303
+
304
+ for (const rel of relationships) {
305
+ for (const targetId of rel.targetIds) {
306
+ try {
307
+ // Verify target decision exists
308
+ const adapter = getAdapter();
309
+ const stmt = adapter.prepare('SELECT id FROM decisions WHERE id = ?');
310
+ const target = await stmt.get(targetId);
311
+
312
+ if (!target) {
313
+ info(`[decision-tracker] Referenced decision not found: ${targetId}, skipping edge`);
314
+ failed++;
315
+ continue;
316
+ }
317
+
318
+ // Create the edge
319
+ const reason = `Auto-detected from reasoning: ${rel.type} reference`;
320
+ const success = await createEdge(fromId, targetId, rel.type, reason);
321
+
322
+ if (success) {
323
+ created++;
324
+ info(`[decision-tracker] Created ${rel.type} edge: ${fromId} -> ${targetId}`);
325
+ } else {
326
+ failed++;
327
+ }
328
+ } catch (error) {
329
+ info(`[decision-tracker] Failed to create edge to ${targetId}: ${error.message}`);
330
+ failed++;
331
+ }
332
+ }
333
+ }
334
+
335
+ return { created, failed };
336
+ }
337
+
338
+ /**
339
+ * Get supersedes chain depth for a topic
340
+ *
341
+ * Story 2.2: Calculate how many times a topic has been superseded
342
+ *
343
+ * @param {string} topic - Decision topic
344
+ * @returns {Promise<{depth: number, chain: string[]}>} Chain depth and decision IDs
345
+ */
346
+ async function getSupersededChainDepth(topic) {
347
+ const adapter = getAdapter();
348
+ const chain = [];
349
+
350
+ try {
351
+ // Start from the latest decision (superseded_by IS NULL)
352
+ let stmt = adapter.prepare(`
353
+ SELECT id, supersedes FROM decisions
354
+ WHERE topic = ? AND superseded_by IS NULL
355
+ ORDER BY created_at DESC
356
+ LIMIT 1
357
+ `);
358
+
359
+ let current = await stmt.get(topic);
360
+
361
+ if (!current) {
362
+ return { depth: 0, chain: [] };
363
+ }
364
+
365
+ chain.push(current.id);
366
+
367
+ // Walk back through supersedes chain
368
+ while (current && current.supersedes) {
369
+ stmt = adapter.prepare('SELECT id, supersedes FROM decisions WHERE id = ?');
370
+ current = await stmt.get(current.supersedes);
371
+
372
+ if (current) {
373
+ chain.push(current.id);
374
+ }
375
+ }
376
+
377
+ return {
378
+ depth: chain.length - 1, // depth = number of supersedes edges
379
+ chain: chain.reverse(), // oldest to newest
380
+ };
381
+ } catch (error) {
382
+ throw new Error(`Failed to get supersedes chain: ${error.message}`);
383
+ }
384
+ }
385
+
178
386
  // ════════════════════════════════════════════════════════════════════════════
179
387
  // NOTE: Auto-link functions REMOVED in v1.2.0
180
388
  //
@@ -387,7 +595,10 @@ function updateConfidence(prior, evidence) {
387
595
  // NOTE: Auto-link functions (createRefinesEdge, createContradictsEdge,
388
596
  // findRelatedDecisions, isConflicting, detectConflicts) removed from exports.
389
597
  // LLM infers relationships from search results instead.
598
+ //
599
+ // Story 2.1/2.2: Added new edge type support and reasoning parsing
390
600
  module.exports = {
601
+ // Core functions
391
602
  learnDecision,
392
603
  generateDecisionId,
393
604
  getPreviousDecision,
@@ -396,4 +607,11 @@ module.exports = {
396
607
  calculateCombinedConfidence,
397
608
  detectRefinement,
398
609
  updateConfidence,
610
+ // Story 2.1: Edge type extension
611
+ VALID_EDGE_TYPES,
612
+ createEdge,
613
+ // Story 2.2: Reasoning field parsing
614
+ parseReasoningForRelationships,
615
+ createEdgesFromReasoning,
616
+ getSupersededChainDepth,
399
617
  };
@@ -8,11 +8,21 @@
8
8
  * - MAMA stores (organize books), retrieves (find books), indexes (catalog)
9
9
  * - Claude decides what to save and how to use recalled decisions
10
10
  *
11
+ * v1.3 Update: Collaborative Reasoning Graph
12
+ * - Auto-search on save: Find similar decisions before saving
13
+ * - Collaborative invitation: Suggest build-on/debate/synthesize
14
+ * - AX-first: Soft warnings, not hard blocks
15
+ *
11
16
  * @module mama-api
12
- * @version 1.0
13
- * @date 2025-11-14
17
+ * @version 1.3
18
+ * @date 2025-11-26
14
19
  */
15
20
 
21
+ // Session-level warning cooldown cache (Story 1.1, 1.2)
22
+ // Prevents spam by tracking warned topics per session
23
+ const warnedTopicsCache = new Map();
24
+ const WARNING_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
25
+
16
26
  const { learnDecision } = require('./decision-tracker');
17
27
  // eslint-disable-next-line no-unused-vars
18
28
  const { injectDecisionContext } = require('./memory-inject');
@@ -182,7 +192,158 @@ async function save({
182
192
  await stmt.run(...values);
183
193
  }
184
194
 
185
- return decisionId;
195
+ // ════════════════════════════════════════════════════════════════════════════
196
+ // Story 1.1: Auto-Search on Save
197
+ // Story 1.2: Response Enhancement
198
+ // ════════════════════════════════════════════════════════════════════════════
199
+ let similar_decisions = [];
200
+ let warning = null;
201
+ let collaboration_hint = null;
202
+ let reasoning_graph = null;
203
+
204
+ // Only run auto-search for decisions (not checkpoints) with a topic
205
+ if (topic) {
206
+ try {
207
+ // Story 1.1: Auto-search using suggest()
208
+ const searchResults = await suggest(topic, {
209
+ limit: 3,
210
+ threshold: 0.7,
211
+ disableRecency: true, // Pure semantic similarity for comparison
212
+ });
213
+
214
+ if (searchResults && searchResults.results) {
215
+ // Filter out the decision we just saved
216
+ similar_decisions = searchResults.results
217
+ .filter((d) => d.id !== decisionId)
218
+ .map((d) => ({
219
+ id: d.id,
220
+ topic: d.topic,
221
+ decision: d.decision,
222
+ similarity: d.similarity,
223
+ created_at: d.created_at,
224
+ }));
225
+
226
+ // Story 1.2: Warning logic (similarity >= 0.85)
227
+ const highSimilarity = similar_decisions.find((d) => d.similarity >= 0.85);
228
+ if (highSimilarity && !_isTopicInCooldown(topic)) {
229
+ warning = `High similarity (${(highSimilarity.similarity * 100).toFixed(0)}%) with existing decision "${highSimilarity.decision.substring(0, 50)}..."`;
230
+ _markTopicWarned(topic);
231
+ }
232
+
233
+ // Story 1.2: Collaboration hint
234
+ if (similar_decisions.length > 0) {
235
+ collaboration_hint = _generateCollaborationHint(similar_decisions);
236
+ }
237
+ }
238
+ } catch (error) {
239
+ // Story 1.1 AC3: Best-effort - save succeeds even if auto-search fails
240
+ console.error('Auto-search failed:', error.message);
241
+ }
242
+
243
+ // Story 1.2: Reasoning graph info
244
+ try {
245
+ reasoning_graph = await _getReasoningGraphInfo(topic, decisionId);
246
+ } catch (error) {
247
+ console.error('Reasoning graph query failed:', error.message);
248
+ }
249
+
250
+ // Story 2.2: Parse reasoning for relationship edges (builds_on, debates, synthesizes)
251
+ if (reasoning) {
252
+ try {
253
+ const { createEdgesFromReasoning } = require('./decision-tracker.js');
254
+ await createEdgesFromReasoning(decisionId, reasoning);
255
+ } catch (error) {
256
+ // Best-effort - save succeeds even if edge creation fails
257
+ console.error('Edge creation from reasoning failed:', error.message);
258
+ }
259
+ }
260
+ }
261
+
262
+ // Story 1.2: Enhanced response (backward compatible)
263
+ return {
264
+ success: true,
265
+ id: decisionId,
266
+ ...(similar_decisions.length > 0 && { similar_decisions }),
267
+ ...(warning && { warning }),
268
+ ...(collaboration_hint && { collaboration_hint }),
269
+ ...(reasoning_graph && { reasoning_graph }),
270
+ };
271
+ }
272
+
273
+ // ════════════════════════════════════════════════════════════════════════════
274
+ // Story 1.2: Helper functions for Response Enhancement
275
+ // ════════════════════════════════════════════════════════════════════════════
276
+
277
+ /**
278
+ * Check if a topic is in warning cooldown
279
+ * @param {string} topic - Topic to check
280
+ * @returns {boolean} True if topic was warned recently
281
+ */
282
+ function _isTopicInCooldown(topic) {
283
+ const lastWarned = warnedTopicsCache.get(topic);
284
+ if (!lastWarned) {
285
+ return false;
286
+ }
287
+ return Date.now() - lastWarned < WARNING_COOLDOWN_MS;
288
+ }
289
+
290
+ /**
291
+ * Mark a topic as warned (start cooldown)
292
+ * @param {string} topic - Topic to mark
293
+ */
294
+ function _markTopicWarned(topic) {
295
+ warnedTopicsCache.set(topic, Date.now());
296
+ }
297
+
298
+ /**
299
+ * Generate collaboration hint message
300
+ * @param {Array} similarDecisions - Similar decisions found
301
+ * @returns {string} Collaboration hint message
302
+ */
303
+ function _generateCollaborationHint(similarDecisions) {
304
+ const count = similarDecisions.length;
305
+ if (count === 0) {
306
+ return null;
307
+ }
308
+
309
+ return `Found ${count} related decision(s). Consider:
310
+ - SUPERSEDE: Same topic replaces prior (automatic)
311
+ - BUILD-ON: Add "builds_on: <id>" in reasoning to extend
312
+ - DEBATE: Add "debates: <id>" in reasoning for alternative view
313
+ - SYNTHESIZE: Add "synthesizes: [id1, id2]" in reasoning to unify`;
314
+ }
315
+
316
+ /**
317
+ * Get reasoning graph info for a topic
318
+ * @param {string} topic - Topic to query
319
+ * @param {string} currentId - Current decision ID
320
+ * @returns {Object} Reasoning graph info
321
+ */
322
+ async function _getReasoningGraphInfo(topic, currentId) {
323
+ try {
324
+ const { queryDecisionGraph } = require('./memory-store');
325
+ const chain = await queryDecisionGraph(topic);
326
+
327
+ if (!chain || chain.length === 0) {
328
+ return {
329
+ topic,
330
+ depth: 1,
331
+ latest: currentId,
332
+ };
333
+ }
334
+
335
+ return {
336
+ topic,
337
+ depth: chain.length,
338
+ latest: chain[0]?.id || currentId,
339
+ };
340
+ } catch (error) {
341
+ return {
342
+ topic,
343
+ depth: 1,
344
+ latest: currentId,
345
+ };
346
+ }
186
347
  }
187
348
 
188
349
  /**
@@ -333,7 +494,10 @@ async function updateOutcome(decisionId, { outcome, failure_reason, limitation }
333
494
  throw new Error('mama.updateOutcome() requires decisionId (string)');
334
495
  }
335
496
 
336
- if (!outcome || !['SUCCESS', 'FAILED', 'PARTIAL'].includes(outcome)) {
497
+ // AX Improvement: Be forgiving with case sensitivity
498
+ const normalizedOutcome = outcome ? outcome.toUpperCase() : null;
499
+
500
+ if (!normalizedOutcome || !['SUCCESS', 'FAILED', 'PARTIAL'].includes(normalizedOutcome)) {
337
501
  throw new Error('mama.updateOutcome() outcome must be "SUCCESS", "FAILED", or "PARTIAL"');
338
502
  }
339
503
 
@@ -353,7 +517,7 @@ async function updateOutcome(decisionId, { outcome, failure_reason, limitation }
353
517
  `
354
518
  );
355
519
  const result = stmt.run(
356
- outcome,
520
+ normalizedOutcome,
357
521
  failure_reason || null,
358
522
  limitation || null,
359
523
  Date.now(),
@@ -416,91 +580,112 @@ async function expandWithGraph(candidates) {
416
580
  console.warn(`Failed to get supersedes chain for ${candidate.topic}: ${error.message}`);
417
581
  }
418
582
 
419
- // 2. Add semantic edges (refines, contradicts)
583
+ // 2. Add semantic edges (refines, contradicts, builds_on, debates, synthesizes)
420
584
  try {
421
585
  const edges = await querySemanticEdges([candidate.id]);
422
586
 
423
- // Add refines edges
424
- for (const edge of edges.refines) {
425
- if (!graphEnhanced.has(edge.to_id)) {
426
- graphEnhanced.set(edge.to_id, {
427
- id: edge.to_id,
587
+ // Helper to add edge to graph
588
+ const addEdge = (edge, idField, source, rank, simFactor) => {
589
+ const id = edge[idField];
590
+ if (!graphEnhanced.has(id)) {
591
+ graphEnhanced.set(id, {
592
+ id: id,
428
593
  topic: edge.topic,
429
594
  decision: edge.decision,
430
595
  confidence: edge.confidence,
431
596
  created_at: edge.created_at,
432
- graph_source: 'refines',
433
- graph_rank: 0.7,
434
- similarity: candidate.similarity * 0.85,
597
+ graph_source: source,
598
+ graph_rank: rank,
599
+ similarity: candidate.similarity * simFactor,
435
600
  related_to: candidate.id,
436
601
  edge_reason: edge.reason,
437
602
  });
438
603
  }
604
+ };
605
+
606
+ // Add refines edges
607
+ for (const edge of edges.refines) {
608
+ addEdge(edge, 'to_id', 'refines', 0.7, 0.85);
439
609
  }
440
610
 
441
611
  // Add refined_by edges
442
612
  for (const edge of edges.refined_by) {
443
- if (!graphEnhanced.has(edge.from_id)) {
444
- graphEnhanced.set(edge.from_id, {
445
- id: edge.from_id,
446
- topic: edge.topic,
447
- decision: edge.decision,
448
- confidence: edge.confidence,
449
- created_at: edge.created_at,
450
- graph_source: 'refined_by',
451
- graph_rank: 0.7,
452
- similarity: candidate.similarity * 0.85,
453
- related_to: candidate.id,
454
- edge_reason: edge.reason,
455
- });
456
- }
613
+ addEdge(edge, 'from_id', 'refined_by', 0.7, 0.85);
457
614
  }
458
615
 
459
616
  // Add contradicts edges (lower rank, but still relevant)
460
617
  for (const edge of edges.contradicts) {
461
- if (!graphEnhanced.has(edge.to_id)) {
462
- graphEnhanced.set(edge.to_id, {
463
- id: edge.to_id,
464
- topic: edge.topic,
465
- decision: edge.decision,
466
- confidence: edge.confidence,
467
- created_at: edge.created_at,
468
- graph_source: 'contradicts',
469
- graph_rank: 0.6,
470
- similarity: candidate.similarity * 0.8,
471
- related_to: candidate.id,
472
- edge_reason: edge.reason,
473
- });
474
- }
618
+ addEdge(edge, 'to_id', 'contradicts', 0.6, 0.8);
619
+ }
620
+
621
+ // Story 2.1: Add builds_on edges (high relevance - extending prior work)
622
+ for (const edge of edges.builds_on) {
623
+ addEdge(edge, 'to_id', 'builds_on', 0.75, 0.9);
624
+ }
625
+
626
+ // Add built_on_by edges (someone built on this decision)
627
+ for (const edge of edges.built_on_by) {
628
+ addEdge(edge, 'from_id', 'built_on_by', 0.75, 0.9);
629
+ }
630
+
631
+ // Add debates edges (alternative view)
632
+ for (const edge of edges.debates) {
633
+ addEdge(edge, 'to_id', 'debates', 0.65, 0.85);
634
+ }
635
+
636
+ // Add debated_by edges
637
+ for (const edge of edges.debated_by) {
638
+ addEdge(edge, 'from_id', 'debated_by', 0.65, 0.85);
639
+ }
640
+
641
+ // Add synthesizes edges (unified approach)
642
+ for (const edge of edges.synthesizes) {
643
+ addEdge(edge, 'to_id', 'synthesizes', 0.7, 0.88);
644
+ }
645
+
646
+ // Add synthesized_by edges
647
+ for (const edge of edges.synthesized_by) {
648
+ addEdge(edge, 'from_id', 'synthesized_by', 0.7, 0.88);
475
649
  }
476
650
  } catch (error) {
477
651
  console.warn(`Failed to get semantic edges for ${candidate.id}: ${error.message}`);
478
652
  }
479
653
  }
480
654
 
481
- // 3. Convert Map to Array and sort by graph_rank + similarity
482
- const results = Array.from(graphEnhanced.values());
655
+ // 3. Convert Map to Array
656
+ const allResults = Array.from(graphEnhanced.values());
657
+
658
+ // 4. Sort: Interleave expanded results after their related primary
659
+ // This ensures edge-connected decisions appear near their source
660
+ const primaryResults = allResults
661
+ .filter((r) => primaryIds.has(r.id))
662
+ .sort((a, b) => {
663
+ const scoreA = a.final_score || a.similarity || 0;
664
+ const scoreB = b.final_score || b.similarity || 0;
665
+ return scoreB - scoreA;
666
+ });
483
667
 
484
- // 4. Sort: Primary first, then by graph_rank, then by final_score (or similarity)
485
- results.sort((a, b) => {
486
- // Primary candidates always first
487
- if (primaryIds.has(a.id) && !primaryIds.has(b.id)) {
488
- return -1;
489
- }
490
- if (!primaryIds.has(a.id) && primaryIds.has(b.id)) {
491
- return 1;
492
- }
668
+ const expandedResults = allResults.filter((r) => !primaryIds.has(r.id));
493
669
 
494
- // Then by graph_rank
495
- if (a.graph_rank !== b.graph_rank) {
496
- return b.graph_rank - a.graph_rank;
497
- }
670
+ // Build final results: each primary followed by its related expanded results
671
+ const results = [];
672
+ for (const primary of primaryResults) {
673
+ results.push(primary);
498
674
 
499
- // Finally by final_score (recency-boosted) or similarity (fallback)
500
- const scoreA = a.final_score || a.similarity || 0;
501
- const scoreB = b.final_score || b.similarity || 0;
502
- return scoreB - scoreA;
503
- });
675
+ // Find expanded results related to this primary
676
+ const relatedExpanded = expandedResults.filter((e) => e.related_to === primary.id);
677
+
678
+ // Sort related by graph_rank (higher first)
679
+ relatedExpanded.sort((a, b) => (b.graph_rank || 0) - (a.graph_rank || 0));
680
+
681
+ // Add related expanded results right after their primary
682
+ results.push(...relatedExpanded);
683
+ }
684
+
685
+ // Add any orphaned expanded results (shouldn't happen, but safety net)
686
+ const includedIds = new Set(results.map((r) => r.id));
687
+ const orphaned = expandedResults.filter((e) => !includedIds.has(e.id));
688
+ results.push(...orphaned);
504
689
 
505
690
  return results;
506
691
  }
package/src/server.js CHANGED
@@ -241,8 +241,8 @@ class MAMAServer {
241
241
  },
242
242
  outcome: {
243
243
  type: 'string',
244
- enum: ['success', 'failure', 'partial'],
245
- description: 'New outcome status.',
244
+ description:
245
+ "New outcome status (case-insensitive): 'success' or 'SUCCESS', 'failed' or 'FAILED', 'partial' or 'PARTIAL'.",
246
246
  },
247
247
  reason: {
248
248
  type: 'string',
@@ -363,7 +363,8 @@ class MAMAServer {
363
363
  let decisions;
364
364
  if (query) {
365
365
  // suggest() returns { results: [...] } object or null
366
- const suggestResult = await mama.suggest(query, limit);
366
+ // Note: suggest() takes options object as second parameter
367
+ const suggestResult = await mama.suggest(query, { limit });
367
368
  decisions = suggestResult?.results || [];
368
369
  } else {
369
370
  decisions = await mama.list(limit);
@@ -406,6 +407,7 @@ class MAMAServer {
406
407
 
407
408
  /**
408
409
  * Handle update (decision outcome)
410
+ * Story 3.1: Case-insensitive outcome support
409
411
  */
410
412
  async handleUpdate(args) {
411
413
  const { id, outcome, reason } = args;
@@ -414,10 +416,20 @@ class MAMAServer {
414
416
  return { success: false, message: '❌ Update requires: id, outcome' };
415
417
  }
416
418
 
417
- await mama.updateOutcome(id, outcome.toUpperCase(), reason);
419
+ // Story 3.1: Normalize outcome - handle both 'failure' and 'failed' variants
420
+ let normalizedOutcome = outcome.toUpperCase();
421
+ if (normalizedOutcome === 'FAILURE') {
422
+ normalizedOutcome = 'FAILED';
423
+ }
424
+
425
+ await mama.updateOutcome(id, {
426
+ outcome: normalizedOutcome,
427
+ failure_reason: reason,
428
+ });
429
+
418
430
  return {
419
431
  success: true,
420
- message: `✅ Updated ${id} → ${outcome}`,
432
+ message: `✅ Updated ${id} → ${normalizedOutcome}`,
421
433
  };
422
434
  }
423
435
 
@@ -440,7 +452,7 @@ class MAMAServer {
440
452
  // Pre-warm model in background (don't block MCP startup)
441
453
  warmModel().catch((err) => console.error('[MAMA MCP] Model warmup error:', err.message));
442
454
  } else {
443
- console.error('[MAMA MCP] HTTP embedding server skipped (port in use)');
455
+ console.error('[MAMA MCP] HTTP embedding server skipped (port unavailable or blocked)');
444
456
  }
445
457
 
446
458
  // Start server with stdio transport
@@ -17,8 +17,37 @@ const mama = require('../mama/mama-api.js');
17
17
  */
18
18
  const createSaveDecisionTool = (mamaApi) => ({
19
19
  name: 'save_decision',
20
- description:
21
- "Save a decision or insight to MAMA's memory for future reference. Use this when the user explicitly wants to remember something important (e.g., architectural decisions, parameter choices, lessons learned). The decision will be stored with semantic embeddings for later retrieval.\n\n⚡ IMPORTANT - Graph Connectivity: Reuse the SAME topic name for related decisions to create decision graphs (supersedes/refines/contradicts edges). Example: Use 'auth_strategy' for all authentication decisions, not 'auth_strategy_v1', 'auth_strategy_v2'. This enables Learn/Unlearn/Relearn workflows.",
20
+ description: `Save your architectural decisions, lessons learned, or insights to MAMA's shared memory.
21
+
22
+ **WHY USE THIS:**
23
+ You are not just an AI assistant; you are a partner in this project. Your insights and reasoning are valuable assets. By saving them here, you allow future agents (including yourself in later sessions) to:
24
+ 1. Understand *WHY* a decision was made (Reasoning), not just *WHAT* was done.
25
+ 2. Avoid repeating the same mistakes (Lessons Learned).
26
+ 3. Build a connected graph of knowledge.
27
+
28
+ **WHEN TO USE (Be Proactive!):**
29
+ - **Decisions**: Whenever you make a significant choice (e.g., "Use SQLite instead of JSON"), save it. Don't wait for the user to ask. If you thought hard about it, it's worth saving.
30
+ - **Insights**: If you discover something new ("Ah, this library conflicts with that one"), save it.
31
+ - **Requests**: If the user says "Remember this" or "Note that", use this tool immediately.
32
+
33
+ **COLLABORATION MODES:**
34
+ When you find similar past decisions (returned in similar_decisions), choose your approach:
35
+ - **build_on**: Extend the existing decision with new insights. Use same topic to create supersedes edge.
36
+ - **debate**: Present a counter-argument with evidence. Explain why the prior decision may be wrong.
37
+ - **synthesize**: Merge multiple decisions into a new unified approach.
38
+
39
+ **5-LAYER REASONING (CoT Guide):**
40
+ Structure your reasoning with these layers for maximum value:
41
+ 1. **Context**: What problem/situation prompted this decision?
42
+ 2. **Evidence**: What proves this works? (tests, benchmarks, prior experience)
43
+ 3. **Alternatives**: What other options were considered and why rejected?
44
+ 4. **Risks**: Known limitations or failure modes
45
+ 5. **Rationale**: Final reasoning that ties it all together
46
+
47
+ **INSTRUCTIONS:**
48
+ 1. **Search First**: Before saving, try to search for related past decisions.
49
+ 2. **Link**: If you find a related decision, mention its ID or topic in the 'reasoning' field to create a mental link.
50
+ 3. **Reasoning**: Explain your logic clearly so future agents can "empathize" with your decision.`,
22
51
  inputSchema: {
23
52
  type: 'object',
24
53
  properties: {
@@ -104,24 +133,31 @@ const createSaveDecisionTool = (mamaApi) => ({
104
133
  }
105
134
 
106
135
  // Call MAMA API (mama.save will handle outcome mapping to DB format)
107
- const id = await mamaApi.save({
136
+ // Story 1.1/1.2: save() now returns enhanced response object
137
+ const result = await mamaApi.save({
108
138
  topic,
109
139
  decision,
110
140
  reasoning,
111
141
  confidence,
112
- type, // Assuming mama.saveDecision now expects 'type' directly
142
+ type,
113
143
  outcome,
114
144
  evidence,
115
145
  alternatives,
116
146
  risks,
117
147
  });
118
148
 
149
+ // Story 1.2: Return enhanced response with collaborative fields
119
150
  return {
120
- success: true,
121
- decision_id: id,
151
+ success: result.success,
152
+ decision_id: result.id,
122
153
  topic: topic,
123
- message: `✅ Decision saved successfully (ID: ${id})`,
154
+ message: `✅ Decision saved successfully (ID: ${result.id})`,
124
155
  recall_command: `To recall: mama.recall('${topic}')`,
156
+ // Story 1.1/1.2: Collaborative fields (optional)
157
+ ...(result.similar_decisions && { similar_decisions: result.similar_decisions }),
158
+ ...(result.warning && { warning: result.warning }),
159
+ ...(result.collaboration_hint && { collaboration_hint: result.collaboration_hint }),
160
+ ...(result.reasoning_graph && { reasoning_graph: result.reasoning_graph }),
125
161
  };
126
162
  } catch (error) {
127
163
  const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
@@ -12,13 +12,79 @@
12
12
 
13
13
  const mama = require('../mama/mama-api.js');
14
14
 
15
+ /**
16
+ * Valid outcome values (uppercase canonical form)
17
+ */
18
+ const VALID_OUTCOMES = ['SUCCESS', 'FAILED', 'PARTIAL'];
19
+
20
+ /**
21
+ * Suggest the closest valid outcome for typos/case errors
22
+ * @param {string} input - User's input
23
+ * @returns {string} Suggested outcome
24
+ */
25
+ function suggestOutcome(input) {
26
+ if (!input || typeof input !== 'string') {
27
+ return 'SUCCESS';
28
+ }
29
+
30
+ const normalized = input.toUpperCase().trim();
31
+
32
+ // Exact match after normalization
33
+ if (VALID_OUTCOMES.includes(normalized)) {
34
+ return normalized;
35
+ }
36
+
37
+ // Prefix matching (e.g., "suc" -> "SUCCESS", "fail" -> "FAILED")
38
+ const prefixMatch = VALID_OUTCOMES.find((o) => o.startsWith(normalized.slice(0, 3)));
39
+ if (prefixMatch) {
40
+ return prefixMatch;
41
+ }
42
+
43
+ // Common typos/variations
44
+ const typoMap = {
45
+ SUCCEED: 'SUCCESS',
46
+ SUCCEEDED: 'SUCCESS',
47
+ PASS: 'SUCCESS',
48
+ PASSED: 'SUCCESS',
49
+ OK: 'SUCCESS',
50
+ FAIL: 'FAILED',
51
+ FAILURE: 'FAILED',
52
+ ERROR: 'FAILED',
53
+ PART: 'PARTIAL',
54
+ PARTIALLY: 'PARTIAL',
55
+ };
56
+
57
+ return typoMap[normalized] || 'SUCCESS';
58
+ }
59
+
15
60
  /**
16
61
  * Update outcome tool definition
17
62
  */
18
63
  const updateOutcomeTool = {
19
64
  name: 'update_outcome',
20
- description:
21
- "Update a decision's outcome based on real-world results. Use this to mark decisions as SUCCESS, FAILED, or PARTIAL after implementation/validation. This enables tracking decision success rates and surfacing failures for improvement.\n\n⚡ OUTCOME TYPES:\n• SUCCESS: Decision worked as expected\n• FAILED: Decision caused problems (provide failure_reason)\n• PARTIAL: Decision partially worked (provide limitation)\n\n⚡ USE CASES:\n• After testing: Mark experimental decisions as SUCCESS/FAILED\n• After deployment: Update outcomes based on production metrics\n• After user feedback: Capture failure reasons from complaints",
65
+ description: `Update decision outcome after real-world validation.
66
+
67
+ **WHEN TO USE:**
68
+ • Days/weeks later when issues are discovered → mark FAILED with reason
69
+ • After production deployment confirms success → mark SUCCESS
70
+ • After partial success with known limitations → mark PARTIAL with limitation
71
+
72
+ **WHY IMPORTANT:**
73
+ Tracks decision evolution - failure outcomes help future LLMs avoid same mistakes.
74
+ TIP: If decision failed, save a new decision with same topic to supersede it.
75
+
76
+ **OUTCOME TYPES (case-insensitive):**
77
+ • SUCCESS / success: Decision worked as expected
78
+ • FAILED / failed: Decision caused problems (provide failure_reason)
79
+ • PARTIAL / partial: Decision partially worked (provide limitation)
80
+
81
+ **EVIDENCE TYPES:**
82
+ When providing failure_reason or limitation, consider including:
83
+ • url: Link to documentation, PR, or external resource
84
+ • file_path: Path to relevant code file
85
+ • log_snippet: Relevant log output or error message
86
+ • observation: Direct observation or user feedback
87
+ • reasoning_ref: Reference to another decision's reasoning`,
22
88
  inputSchema: {
23
89
  type: 'object',
24
90
  properties: {
@@ -29,9 +95,8 @@ const updateOutcomeTool = {
29
95
  },
30
96
  outcome: {
31
97
  type: 'string',
32
- enum: ['SUCCESS', 'FAILED', 'PARTIAL'],
33
98
  description:
34
- "Outcome status:\n• 'SUCCESS': Decision worked well in practice\n• 'FAILED': Decision caused problems (explain in failure_reason)\n• 'PARTIAL': Decision partially worked (explain in limitation)",
99
+ "Outcome status (case-insensitive):\n• 'SUCCESS' / 'success': Decision worked well in practice\n• 'FAILED' / 'failed': Decision caused problems (explain in failure_reason)\n• 'PARTIAL' / 'partial': Decision partially worked (explain in limitation)",
35
100
  },
36
101
  failure_reason: {
37
102
  type: 'string',
@@ -55,23 +120,33 @@ const updateOutcomeTool = {
55
120
  if (!decisionId || typeof decisionId !== 'string' || decisionId.trim() === '') {
56
121
  return {
57
122
  success: false,
58
- message: '❌ Validation error: decisionId must be a non-empty string',
123
+ message:
124
+ '❌ Validation error: decisionId must be a non-empty string\n' +
125
+ ' 💡 Use search tool to find valid decision IDs.',
59
126
  };
60
127
  }
61
128
 
62
- if (!outcome || !['SUCCESS', 'FAILED', 'PARTIAL'].includes(outcome)) {
129
+ // Story 3.1: Case-insensitive outcome normalization
130
+ const normalizedOutcome =
131
+ outcome && typeof outcome === 'string' ? outcome.toUpperCase().trim() : outcome;
132
+
133
+ if (!normalizedOutcome || !VALID_OUTCOMES.includes(normalizedOutcome)) {
134
+ const suggestion = suggestOutcome(outcome);
63
135
  return {
64
136
  success: false,
65
- message: '❌ Validation error: outcome must be "SUCCESS", "FAILED", or "PARTIAL"',
137
+ message:
138
+ '❌ Validation error: outcome must be "SUCCESS", "FAILED", or "PARTIAL"\n' +
139
+ ` 💡 Did you mean "${suggestion}"? (case-insensitive, e.g., "success" works too)`,
66
140
  };
67
141
  }
68
142
 
69
143
  // Validation: failure_reason required for FAILED
70
- if (outcome === 'FAILED' && (!failure_reason || failure_reason.trim() === '')) {
144
+ if (normalizedOutcome === 'FAILED' && (!failure_reason || failure_reason.trim() === '')) {
71
145
  return {
72
146
  success: false,
73
147
  message:
74
- '❌ Validation error: failure_reason is required when outcome="FAILED" (explain what went wrong)',
148
+ '❌ Validation error: failure_reason is required when outcome="FAILED"\n' +
149
+ ' 💡 Explain what went wrong so future agents can learn from this.',
75
150
  };
76
151
  }
77
152
 
@@ -90,9 +165,9 @@ const updateOutcomeTool = {
90
165
  };
91
166
  }
92
167
 
93
- // Call MAMA API
168
+ // Call MAMA API with normalized outcome
94
169
  await mama.updateOutcome(decisionId, {
95
- outcome,
170
+ outcome: normalizedOutcome,
96
171
  failure_reason,
97
172
  limitation,
98
173
  });
@@ -101,8 +176,8 @@ const updateOutcomeTool = {
101
176
  return {
102
177
  success: true,
103
178
  decision_id: decisionId,
104
- outcome,
105
- message: `✅ Decision outcome updated to ${outcome}${
179
+ outcome: normalizedOutcome,
180
+ message: `✅ Decision outcome updated to ${normalizedOutcome}${
106
181
  failure_reason
107
182
  ? `\n Reason: ${failure_reason.substring(0, 100)}${failure_reason.length > 100 ? '...' : ''}`
108
183
  : ''