@jungjaehoon/mama-core 1.0.2 → 1.1.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/package.json +33 -21
- package/src/config-loader.js +0 -218
- package/src/db-adapter/README.md +0 -110
- package/src/db-adapter/base-adapter.js +0 -91
- package/src/db-adapter/index.js +0 -31
- package/src/db-adapter/sqlite-adapter.js +0 -364
- package/src/db-adapter/statement.js +0 -127
- package/src/db-manager.js +0 -671
- package/src/debug-logger.js +0 -86
- package/src/decision-formatter.js +0 -1276
- package/src/decision-tracker.js +0 -621
- package/src/embedding-cache.js +0 -222
- package/src/embedding-client.js +0 -141
- package/src/embedding-server/index.js +0 -424
- package/src/embedding-server/mobile/auth.js +0 -160
- package/src/embedding-server/mobile/daemon.js +0 -313
- package/src/embedding-server/mobile/output-parser.js +0 -281
- package/src/embedding-server/mobile/session-api.js +0 -279
- package/src/embedding-server/mobile/session-manager.js +0 -377
- package/src/embedding-server/mobile/websocket-handler.js +0 -443
- package/src/embeddings.js +0 -305
- package/src/errors.js +0 -326
- package/src/index.js +0 -41
- package/src/mama-api.js +0 -2614
- package/src/memory-inject.js +0 -174
- package/src/memory-store.js +0 -89
- package/src/notification-manager.js +0 -3
- package/src/ollama-client.js +0 -391
- package/src/outcome-tracker.js +0 -351
- package/src/progress-indicator.js +0 -110
- package/src/query-intent.js +0 -237
- package/src/relevance-scorer.js +0 -286
- package/src/tier-validator.js +0 -269
- package/src/time-formatter.js +0 -98
package/src/mama-api.js
DELETED
|
@@ -1,2614 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MAMA (Memory-Augmented MCP Architecture) - Simple Public API
|
|
3
|
-
*
|
|
4
|
-
* Clean wrapper around MAMA's internal functions
|
|
5
|
-
* Follows Claude-First Design: Simple, Transparent, Non-Intrusive
|
|
6
|
-
*
|
|
7
|
-
* Core Principle: MAMA = Librarian, Claude = Researcher
|
|
8
|
-
* - MAMA stores (organize books), retrieves (find books), indexes (catalog)
|
|
9
|
-
* - Claude decides what to save and how to use recalled decisions
|
|
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
|
-
*
|
|
16
|
-
* @module mama-api
|
|
17
|
-
* @version 1.3
|
|
18
|
-
* @date 2025-11-26
|
|
19
|
-
*/
|
|
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
|
-
|
|
26
|
-
const { learnDecision } = require('./decision-tracker');
|
|
27
|
-
// eslint-disable-next-line no-unused-vars
|
|
28
|
-
const { injectDecisionContext } = require('./memory-inject');
|
|
29
|
-
// eslint-disable-next-line no-unused-vars
|
|
30
|
-
const { queryDecisionGraph, querySemanticEdges, getDB, getAdapter } = require('./memory-store');
|
|
31
|
-
const { formatRecall, formatList } = require('./decision-formatter');
|
|
32
|
-
const { logProgress, logComplete, logSearching } = require('./progress-indicator');
|
|
33
|
-
const os = require('os');
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Save a decision or insight to MAMA's memory
|
|
37
|
-
*
|
|
38
|
-
* Simple API for Claude to save insights without complex configuration
|
|
39
|
-
* AC #1: Simple API - no complex configuration required
|
|
40
|
-
*
|
|
41
|
-
* @param {Object} params - Decision parameters
|
|
42
|
-
* @param {string} params.topic - Decision topic (e.g., 'auth_strategy', 'date_format')
|
|
43
|
-
* @param {string} params.decision - The decision made (e.g., 'JWT', 'ISO 8601 + Unix')
|
|
44
|
-
* @param {string} params.reasoning - Why this decision was made
|
|
45
|
-
* @param {number} [params.confidence=0.5] - Confidence score 0.0-1.0 (optional)
|
|
46
|
-
* @param {string} [params.type='user_decision'] - 'user_decision' or 'assistant_insight' (optional)
|
|
47
|
-
* @param {string} [params.outcome='pending'] - 'pending', 'success', 'failure', 'partial', 'superseded' (optional)
|
|
48
|
-
* @param {string} [params.failure_reason] - Why this decision failed (optional, used with outcome='failure')
|
|
49
|
-
* @param {string} [params.limitation] - Known limitations of this decision (optional)
|
|
50
|
-
* @returns {Promise<{success: boolean, id: string, similar_decisions?: Array, warning?: string, collaboration_hint?: string, reasoning_graph?: Object}>} Save result with decision ID and metadata
|
|
51
|
-
*
|
|
52
|
-
* @example
|
|
53
|
-
* const decisionId = await mama.save({
|
|
54
|
-
* topic: 'date_calculation_format',
|
|
55
|
-
* decision: 'Support both ISO 8601 and Unix timestamp formats',
|
|
56
|
-
* reasoning: 'Bootstrap data stored as ISO 8601 causing NaN errors',
|
|
57
|
-
* confidence: 0.95,
|
|
58
|
-
* type: 'assistant_insight',
|
|
59
|
-
* outcome: 'success'
|
|
60
|
-
* });
|
|
61
|
-
*/
|
|
62
|
-
async function save({
|
|
63
|
-
topic,
|
|
64
|
-
decision,
|
|
65
|
-
reasoning,
|
|
66
|
-
confidence = 0.5,
|
|
67
|
-
type = 'user_decision',
|
|
68
|
-
outcome = 'pending',
|
|
69
|
-
failure_reason = null,
|
|
70
|
-
limitation = null,
|
|
71
|
-
trust_context = null,
|
|
72
|
-
}) {
|
|
73
|
-
// Validate required fields
|
|
74
|
-
if (!topic || typeof topic !== 'string') {
|
|
75
|
-
throw new Error('mama.save() requires topic (string)');
|
|
76
|
-
}
|
|
77
|
-
if (!decision || typeof decision !== 'string') {
|
|
78
|
-
throw new Error('mama.save() requires decision (string)');
|
|
79
|
-
}
|
|
80
|
-
if (!reasoning || typeof reasoning !== 'string') {
|
|
81
|
-
throw new Error('mama.save() requires reasoning (string)');
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Validate confidence range
|
|
85
|
-
if (typeof confidence !== 'number' || confidence < 0 || confidence > 1) {
|
|
86
|
-
throw new Error('mama.save() confidence must be a number between 0.0 and 1.0');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Validate type
|
|
90
|
-
if (type !== 'user_decision' && type !== 'assistant_insight') {
|
|
91
|
-
throw new Error('mama.save() type must be "user_decision" or "assistant_insight"');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Validate outcome
|
|
95
|
-
const validOutcomes = ['pending', 'success', 'failure', 'partial', 'superseded'];
|
|
96
|
-
if (outcome && !validOutcomes.includes(outcome)) {
|
|
97
|
-
throw new Error(
|
|
98
|
-
`mama.save() outcome must be one of: ${validOutcomes.join(', ')} (got: ${outcome})`
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Map type to user_involvement field
|
|
103
|
-
// Note: Current schema uses user_involvement ('requested', 'approved', 'rejected')
|
|
104
|
-
// Future: Will use decision_type column for proper distinction
|
|
105
|
-
// eslint-disable-next-line no-unused-vars
|
|
106
|
-
const userInvolvement = type === 'user_decision' ? 'approved' : null;
|
|
107
|
-
|
|
108
|
-
// Create detection object for learnDecision()
|
|
109
|
-
const detection = {
|
|
110
|
-
topic,
|
|
111
|
-
decision,
|
|
112
|
-
reasoning,
|
|
113
|
-
confidence,
|
|
114
|
-
outcome,
|
|
115
|
-
failure_reason,
|
|
116
|
-
limitation,
|
|
117
|
-
trust_context,
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
// Create tool execution context
|
|
121
|
-
// Use current timestamp and generate session ID
|
|
122
|
-
const sessionId = `mama_api_${Date.now()}`;
|
|
123
|
-
const toolExecution = {
|
|
124
|
-
tool_name: 'mama.save',
|
|
125
|
-
tool_input: { topic, decision },
|
|
126
|
-
exit_code: 0,
|
|
127
|
-
session_id: sessionId,
|
|
128
|
-
timestamp: Date.now(),
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
// Create session context
|
|
132
|
-
const sessionContext = {
|
|
133
|
-
session_id: sessionId,
|
|
134
|
-
latest_user_message: `Save ${type}: ${topic}`,
|
|
135
|
-
recent_exchange: `Claude: ${reasoning.substring(0, 100)}...`,
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
// Call internal learnDecision function
|
|
139
|
-
// Note: learnDecision returns { decisionId, notification }
|
|
140
|
-
logProgress(`Saving decision: ${topic.substring(0, 30)}...`);
|
|
141
|
-
const { decisionId } = await learnDecision(detection, toolExecution, sessionContext);
|
|
142
|
-
logComplete(`Decision saved: ${decisionId.substring(0, 20)}...`);
|
|
143
|
-
|
|
144
|
-
// Update user_involvement, outcome, failure_reason, limitation
|
|
145
|
-
// Note: learnDecision always sets 'requested', we need to override it
|
|
146
|
-
const adapter = getAdapter();
|
|
147
|
-
|
|
148
|
-
// Build UPDATE query dynamically based on what fields are provided
|
|
149
|
-
const updates = [];
|
|
150
|
-
const values = [];
|
|
151
|
-
|
|
152
|
-
// user_involvement based on type
|
|
153
|
-
if (type === 'assistant_insight') {
|
|
154
|
-
updates.push('user_involvement = NULL');
|
|
155
|
-
} else if (type === 'user_decision') {
|
|
156
|
-
updates.push('user_involvement = ?');
|
|
157
|
-
values.push('approved');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// outcome (always set, default is 'pending')
|
|
161
|
-
// Story M4.1 fix: Map to DB format (uppercase, pending → NULL)
|
|
162
|
-
if (outcome) {
|
|
163
|
-
const outcomeMap = {
|
|
164
|
-
pending: null,
|
|
165
|
-
success: 'SUCCESS',
|
|
166
|
-
failure: 'FAILED',
|
|
167
|
-
partial: 'PARTIAL',
|
|
168
|
-
superseded: null,
|
|
169
|
-
};
|
|
170
|
-
const dbOutcome = outcomeMap[outcome] !== undefined ? outcomeMap[outcome] : outcome;
|
|
171
|
-
|
|
172
|
-
updates.push('outcome = ?');
|
|
173
|
-
values.push(dbOutcome);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// failure_reason (optional)
|
|
177
|
-
if (failure_reason) {
|
|
178
|
-
updates.push('failure_reason = ?');
|
|
179
|
-
values.push(failure_reason);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// limitation (optional)
|
|
183
|
-
if (limitation) {
|
|
184
|
-
updates.push('limitation = ?');
|
|
185
|
-
values.push(limitation);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Execute UPDATE if we have any fields to update
|
|
189
|
-
if (updates.length > 0) {
|
|
190
|
-
values.push(decisionId); // WHERE id = ?
|
|
191
|
-
const stmt = adapter.prepare(`
|
|
192
|
-
UPDATE decisions
|
|
193
|
-
SET ${updates.join(', ')}
|
|
194
|
-
WHERE id = ?
|
|
195
|
-
`);
|
|
196
|
-
await stmt.run(...values);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// ════════════════════════════════════════════════════════════════════════════
|
|
200
|
-
// Story 1.1: Auto-Search on Save
|
|
201
|
-
// Story 1.2: Response Enhancement
|
|
202
|
-
// ════════════════════════════════════════════════════════════════════════════
|
|
203
|
-
let similar_decisions = [];
|
|
204
|
-
let warning = null;
|
|
205
|
-
let collaboration_hint = null;
|
|
206
|
-
let reasoning_graph = null;
|
|
207
|
-
|
|
208
|
-
// Only run auto-search for decisions (not checkpoints) with a topic
|
|
209
|
-
if (topic) {
|
|
210
|
-
try {
|
|
211
|
-
// Story 1.1: Auto-search using suggest()
|
|
212
|
-
logSearching('Searching for related decisions...');
|
|
213
|
-
const searchResults = await suggest(topic, {
|
|
214
|
-
limit: 3,
|
|
215
|
-
threshold: 0.7,
|
|
216
|
-
disableRecency: true, // Pure semantic similarity for comparison
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
if (searchResults && searchResults.results) {
|
|
220
|
-
// Filter out the decision we just saved
|
|
221
|
-
similar_decisions = searchResults.results
|
|
222
|
-
.filter((d) => d.id !== decisionId)
|
|
223
|
-
.map((d) => ({
|
|
224
|
-
id: d.id,
|
|
225
|
-
topic: d.topic,
|
|
226
|
-
decision: d.decision,
|
|
227
|
-
similarity: d.similarity,
|
|
228
|
-
created_at: d.created_at,
|
|
229
|
-
}));
|
|
230
|
-
|
|
231
|
-
if (similar_decisions.length > 0) {
|
|
232
|
-
logComplete(`Found ${similar_decisions.length} related decision(s)`);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Story 1.2: Warning logic (similarity >= 0.85)
|
|
236
|
-
const highSimilarity = similar_decisions.find((d) => d.similarity >= 0.85);
|
|
237
|
-
if (highSimilarity && !_isTopicInCooldown(topic)) {
|
|
238
|
-
warning = `High similarity (${(highSimilarity.similarity * 100).toFixed(0)}%) with existing decision "${highSimilarity.decision.substring(0, 50)}..."`;
|
|
239
|
-
_markTopicWarned(topic);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Story 1.2: Collaboration hint
|
|
243
|
-
if (similar_decisions.length > 0) {
|
|
244
|
-
collaboration_hint = _generateCollaborationHint(similar_decisions);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
} catch (error) {
|
|
248
|
-
// Story 1.1 AC3: Best-effort - save succeeds even if auto-search fails
|
|
249
|
-
console.error('Auto-search failed:', error.message);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Story 1.2: Reasoning graph info
|
|
253
|
-
try {
|
|
254
|
-
reasoning_graph = await _getReasoningGraphInfo(topic, decisionId);
|
|
255
|
-
} catch (error) {
|
|
256
|
-
console.error('Reasoning graph query failed:', error.message);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Story 2.2: Parse reasoning for relationship edges (builds_on, debates, synthesizes)
|
|
260
|
-
if (reasoning) {
|
|
261
|
-
try {
|
|
262
|
-
const { createEdgesFromReasoning } = require('./decision-tracker.js');
|
|
263
|
-
await createEdgesFromReasoning(decisionId, reasoning);
|
|
264
|
-
} catch (error) {
|
|
265
|
-
// Best-effort - save succeeds even if edge creation fails
|
|
266
|
-
console.error('Edge creation from reasoning failed:', error.message);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Story 1.2: Enhanced response (backward compatible)
|
|
272
|
-
return {
|
|
273
|
-
success: true,
|
|
274
|
-
id: decisionId,
|
|
275
|
-
...(similar_decisions.length > 0 && { similar_decisions }),
|
|
276
|
-
...(warning && { warning }),
|
|
277
|
-
...(collaboration_hint && { collaboration_hint }),
|
|
278
|
-
...(reasoning_graph && { reasoning_graph }),
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// ════════════════════════════════════════════════════════════════════════════
|
|
283
|
-
// Story 1.2: Helper functions for Response Enhancement
|
|
284
|
-
// ════════════════════════════════════════════════════════════════════════════
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Check if a topic is in warning cooldown
|
|
288
|
-
* @param {string} topic - Topic to check
|
|
289
|
-
* @returns {boolean} True if topic was warned recently
|
|
290
|
-
*/
|
|
291
|
-
function _isTopicInCooldown(topic) {
|
|
292
|
-
const lastWarned = warnedTopicsCache.get(topic);
|
|
293
|
-
if (!lastWarned) {
|
|
294
|
-
return false;
|
|
295
|
-
}
|
|
296
|
-
return Date.now() - lastWarned < WARNING_COOLDOWN_MS;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Mark a topic as warned (start cooldown)
|
|
301
|
-
* @param {string} topic - Topic to mark
|
|
302
|
-
*/
|
|
303
|
-
function _markTopicWarned(topic) {
|
|
304
|
-
warnedTopicsCache.set(topic, Date.now());
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Generate collaboration hint message
|
|
309
|
-
* @param {Array} similarDecisions - Similar decisions found
|
|
310
|
-
* @returns {string} Collaboration hint message
|
|
311
|
-
*/
|
|
312
|
-
function _generateCollaborationHint(similarDecisions) {
|
|
313
|
-
const count = similarDecisions.length;
|
|
314
|
-
if (count === 0) {
|
|
315
|
-
return null;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return `Found ${count} related decision(s). Consider:
|
|
319
|
-
- SUPERSEDE: Same topic replaces prior (automatic)
|
|
320
|
-
- BUILD-ON: Add "builds_on: <id>" in reasoning to extend
|
|
321
|
-
- DEBATE: Add "debates: <id>" in reasoning for alternative view
|
|
322
|
-
- SYNTHESIZE: Add "synthesizes: [id1, id2]" in reasoning to unify`;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Get reasoning graph info for a topic
|
|
327
|
-
* @param {string} topic - Topic to query
|
|
328
|
-
* @param {string} currentId - Current decision ID
|
|
329
|
-
* @returns {Object} Reasoning graph info
|
|
330
|
-
*/
|
|
331
|
-
async function _getReasoningGraphInfo(topic, currentId) {
|
|
332
|
-
try {
|
|
333
|
-
const { queryDecisionGraph } = require('./memory-store');
|
|
334
|
-
const chain = await queryDecisionGraph(topic);
|
|
335
|
-
|
|
336
|
-
if (!chain || chain.length === 0) {
|
|
337
|
-
return {
|
|
338
|
-
topic,
|
|
339
|
-
depth: 1,
|
|
340
|
-
latest: currentId,
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
return {
|
|
345
|
-
topic,
|
|
346
|
-
depth: chain.length,
|
|
347
|
-
latest: chain[0]?.id || currentId,
|
|
348
|
-
};
|
|
349
|
-
} catch (error) {
|
|
350
|
-
return {
|
|
351
|
-
topic,
|
|
352
|
-
depth: 1,
|
|
353
|
-
latest: currentId,
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Recall decisions by topic
|
|
360
|
-
*
|
|
361
|
-
* DEFAULT: Returns JSON object with decisions and edges (LLM-first design)
|
|
362
|
-
* OPTIONAL: Returns Markdown string if format='markdown' (for human display)
|
|
363
|
-
*
|
|
364
|
-
* @param {string} topic - Decision topic to recall
|
|
365
|
-
* @param {Object} [options] - Options
|
|
366
|
-
* @param {string} [options.format='json'] - Output format: 'json' (default) or 'markdown'
|
|
367
|
-
* @returns {Promise<Object|string>} Decision history as JSON or Markdown
|
|
368
|
-
*
|
|
369
|
-
* @example
|
|
370
|
-
* // LLM usage (default)
|
|
371
|
-
* const data = await mama.recall('auth_strategy');
|
|
372
|
-
* // → { topic, decisions: [...], edges: [...], meta: {...} }
|
|
373
|
-
*
|
|
374
|
-
* // Human display
|
|
375
|
-
* const markdown = await mama.recall('auth_strategy', { format: 'markdown' });
|
|
376
|
-
* // → "📋 Decision History: auth_strategy\n━━━━━━━━..."
|
|
377
|
-
*/
|
|
378
|
-
async function recall(topic, options = {}) {
|
|
379
|
-
if (!topic || typeof topic !== 'string') {
|
|
380
|
-
throw new Error('mama.recall() requires topic (string)');
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const { format = 'json' } = options;
|
|
384
|
-
|
|
385
|
-
try {
|
|
386
|
-
const decisions = await queryDecisionGraph(topic);
|
|
387
|
-
|
|
388
|
-
if (!decisions || decisions.length === 0) {
|
|
389
|
-
if (format === 'markdown') {
|
|
390
|
-
return `❌ No decisions found for topic: ${topic}`;
|
|
391
|
-
}
|
|
392
|
-
return {
|
|
393
|
-
topic,
|
|
394
|
-
supersedes_chain: [],
|
|
395
|
-
semantic_edges: { refines: [], refined_by: [], contradicts: [], contradicted_by: [] },
|
|
396
|
-
meta: { count: 0 },
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Query semantic edges for all decisions
|
|
401
|
-
const decisionIds = decisions.map((d) => d.id);
|
|
402
|
-
const rawEdges = (await querySemanticEdges(decisionIds)) || {};
|
|
403
|
-
const semanticEdges = {
|
|
404
|
-
refines: rawEdges.refines || [],
|
|
405
|
-
refined_by: rawEdges.refined_by || [],
|
|
406
|
-
contradicts: rawEdges.contradicts || [],
|
|
407
|
-
contradicted_by: rawEdges.contradicted_by || [],
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
// Markdown format (for human display)
|
|
411
|
-
if (format === 'markdown') {
|
|
412
|
-
// Pass semantic edges to formatter
|
|
413
|
-
return formatRecall(decisions, semanticEdges);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// JSON format (default - LLM-first)
|
|
417
|
-
// Separate supersedes chain from semantic edges
|
|
418
|
-
return {
|
|
419
|
-
topic,
|
|
420
|
-
supersedes_chain: decisions.map((d) => ({
|
|
421
|
-
id: d.id,
|
|
422
|
-
decision: d.decision,
|
|
423
|
-
reasoning: d.reasoning,
|
|
424
|
-
confidence: d.confidence,
|
|
425
|
-
outcome: d.outcome,
|
|
426
|
-
failure_reason: d.failure_reason,
|
|
427
|
-
limitation: d.limitation,
|
|
428
|
-
created_at: d.created_at,
|
|
429
|
-
updated_at: d.updated_at,
|
|
430
|
-
superseded_by: d.superseded_by,
|
|
431
|
-
supersedes: d.supersedes,
|
|
432
|
-
trust_context: d.trust_context,
|
|
433
|
-
})),
|
|
434
|
-
semantic_edges: {
|
|
435
|
-
refines: semanticEdges.refines.map((e) => ({
|
|
436
|
-
to_topic: e.topic,
|
|
437
|
-
to_decision: e.decision,
|
|
438
|
-
to_id: e.to_id,
|
|
439
|
-
reason: e.reason,
|
|
440
|
-
confidence: e.confidence,
|
|
441
|
-
created_at: e.created_at,
|
|
442
|
-
})),
|
|
443
|
-
refined_by: semanticEdges.refined_by.map((e) => ({
|
|
444
|
-
from_topic: e.topic,
|
|
445
|
-
from_decision: e.decision,
|
|
446
|
-
from_id: e.from_id,
|
|
447
|
-
reason: e.reason,
|
|
448
|
-
confidence: e.confidence,
|
|
449
|
-
created_at: e.created_at,
|
|
450
|
-
})),
|
|
451
|
-
contradicts: semanticEdges.contradicts.map((e) => ({
|
|
452
|
-
to_topic: e.topic,
|
|
453
|
-
to_decision: e.decision,
|
|
454
|
-
to_id: e.to_id,
|
|
455
|
-
reason: e.reason,
|
|
456
|
-
created_at: e.created_at,
|
|
457
|
-
})),
|
|
458
|
-
contradicted_by: semanticEdges.contradicted_by.map((e) => ({
|
|
459
|
-
from_topic: e.topic,
|
|
460
|
-
from_decision: e.decision,
|
|
461
|
-
from_id: e.from_id,
|
|
462
|
-
reason: e.reason,
|
|
463
|
-
created_at: e.created_at,
|
|
464
|
-
})),
|
|
465
|
-
},
|
|
466
|
-
meta: {
|
|
467
|
-
count: decisions.length,
|
|
468
|
-
latest_id: decisions[0]?.id,
|
|
469
|
-
has_supersedes_chain: decisions.some((d) => d.supersedes),
|
|
470
|
-
has_semantic_edges:
|
|
471
|
-
semanticEdges.refines.length > 0 ||
|
|
472
|
-
semanticEdges.refined_by.length > 0 ||
|
|
473
|
-
semanticEdges.contradicts.length > 0 ||
|
|
474
|
-
semanticEdges.contradicted_by.length > 0,
|
|
475
|
-
semantic_edges_count: {
|
|
476
|
-
refines: semanticEdges.refines.length,
|
|
477
|
-
refined_by: semanticEdges.refined_by.length,
|
|
478
|
-
contradicts: semanticEdges.contradicts.length,
|
|
479
|
-
contradicted_by: semanticEdges.contradicted_by.length,
|
|
480
|
-
},
|
|
481
|
-
},
|
|
482
|
-
};
|
|
483
|
-
} catch (error) {
|
|
484
|
-
throw new Error(`mama.recall() failed: ${error.message}`);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Update outcome of a decision
|
|
490
|
-
*
|
|
491
|
-
* Track whether a decision succeeded, failed, or partially worked
|
|
492
|
-
* AC: Evolutionary Decision Memory - Learn from outcomes
|
|
493
|
-
*
|
|
494
|
-
* @param {string} decisionId - Decision ID to update
|
|
495
|
-
* @param {Object} outcome - Outcome details
|
|
496
|
-
* @param {string} outcome.outcome - 'SUCCESS', 'FAILED', or 'PARTIAL'
|
|
497
|
-
* @param {string} [outcome.failure_reason] - Reason for failure (if FAILED)
|
|
498
|
-
* @param {string} [outcome.limitation] - Limitation description (if PARTIAL)
|
|
499
|
-
* @returns {Promise<void>}
|
|
500
|
-
*
|
|
501
|
-
* @example
|
|
502
|
-
* await mama.updateOutcome('decision_auth_strategy_123456_abc', {
|
|
503
|
-
* outcome: 'FAILED',
|
|
504
|
-
* failure_reason: 'Missing token expiration handling'
|
|
505
|
-
* });
|
|
506
|
-
*/
|
|
507
|
-
async function updateOutcome(decisionId, { outcome, failure_reason, limitation }) {
|
|
508
|
-
if (!decisionId || typeof decisionId !== 'string') {
|
|
509
|
-
throw new Error('mama.updateOutcome() requires decisionId (string)');
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// AX Improvement: Be forgiving with case sensitivity
|
|
513
|
-
const normalizedOutcome = outcome ? outcome.toUpperCase() : null;
|
|
514
|
-
|
|
515
|
-
if (!normalizedOutcome || !['SUCCESS', 'FAILED', 'PARTIAL'].includes(normalizedOutcome)) {
|
|
516
|
-
throw new Error('mama.updateOutcome() outcome must be "SUCCESS", "FAILED", or "PARTIAL"');
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
try {
|
|
520
|
-
const adapter = getAdapter();
|
|
521
|
-
|
|
522
|
-
// Update outcome and related fields
|
|
523
|
-
const stmt = adapter.prepare(
|
|
524
|
-
`
|
|
525
|
-
UPDATE decisions
|
|
526
|
-
SET
|
|
527
|
-
outcome = ?,
|
|
528
|
-
failure_reason = ?,
|
|
529
|
-
limitation = ?,
|
|
530
|
-
updated_at = ?
|
|
531
|
-
WHERE id = ?
|
|
532
|
-
`
|
|
533
|
-
);
|
|
534
|
-
const result = stmt.run(
|
|
535
|
-
normalizedOutcome,
|
|
536
|
-
failure_reason || null,
|
|
537
|
-
limitation || null,
|
|
538
|
-
Date.now(),
|
|
539
|
-
decisionId
|
|
540
|
-
);
|
|
541
|
-
|
|
542
|
-
// Check if decision was found and updated
|
|
543
|
-
if (result.changes === 0) {
|
|
544
|
-
throw new Error(`Decision not found: ${decisionId}`);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
return;
|
|
548
|
-
} catch (error) {
|
|
549
|
-
throw new Error(`mama.updateOutcome() failed: ${error.message}`);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* Expand search results with graph context (Phase 1 - Graph-Enhanced Retrieval)
|
|
555
|
-
*
|
|
556
|
-
* For each candidate decision:
|
|
557
|
-
* 1. Add supersedes chain (evolution history)
|
|
558
|
-
* 2. Add semantic edges (refines, contradicts)
|
|
559
|
-
* 3. Deduplicate by ID
|
|
560
|
-
* 4. Re-rank by relevance (primary candidates ranked higher)
|
|
561
|
-
*
|
|
562
|
-
* @param {Array} candidates - Initial search results from vector/keyword search
|
|
563
|
-
* @returns {Promise<Array>} Graph-enhanced results with evolution context
|
|
564
|
-
*/
|
|
565
|
-
async function expandWithGraph(candidates) {
|
|
566
|
-
const graphEnhanced = new Map(); // Use Map for deduplication by ID
|
|
567
|
-
const primaryIds = new Set(candidates.map((c) => c.id)); // Track primary candidates
|
|
568
|
-
|
|
569
|
-
// Process each candidate
|
|
570
|
-
for (const candidate of candidates) {
|
|
571
|
-
// Add primary candidate with higher rank
|
|
572
|
-
if (!graphEnhanced.has(candidate.id)) {
|
|
573
|
-
graphEnhanced.set(candidate.id, {
|
|
574
|
-
...candidate,
|
|
575
|
-
graph_source: 'primary', // Mark as primary result
|
|
576
|
-
graph_rank: 1.0, // Highest rank
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// 1. Add supersedes chain (evolution history)
|
|
581
|
-
try {
|
|
582
|
-
const chain = await queryDecisionGraph(candidate.topic);
|
|
583
|
-
for (const decision of chain) {
|
|
584
|
-
if (!graphEnhanced.has(decision.id)) {
|
|
585
|
-
graphEnhanced.set(decision.id, {
|
|
586
|
-
...decision,
|
|
587
|
-
graph_source: 'supersedes_chain',
|
|
588
|
-
graph_rank: 0.8, // Lower rank than primary
|
|
589
|
-
similarity: candidate.similarity * 0.9, // Inherit similarity, slightly reduced
|
|
590
|
-
related_to: candidate.id, // Track relationship
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
} catch (error) {
|
|
595
|
-
console.warn(`Failed to get supersedes chain for ${candidate.topic}: ${error.message}`);
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// 2. Add semantic edges (refines, contradicts, builds_on, debates, synthesizes)
|
|
599
|
-
try {
|
|
600
|
-
const rawEdges = (await querySemanticEdges([candidate.id])) || {};
|
|
601
|
-
const edges = {
|
|
602
|
-
refines: rawEdges.refines || [],
|
|
603
|
-
refined_by: rawEdges.refined_by || [],
|
|
604
|
-
contradicts: rawEdges.contradicts || [],
|
|
605
|
-
contradicted_by: rawEdges.contradicted_by || [],
|
|
606
|
-
builds_on: rawEdges.builds_on || [],
|
|
607
|
-
built_upon_by: rawEdges.built_upon_by || [],
|
|
608
|
-
debates: rawEdges.debates || [],
|
|
609
|
-
debated_by: rawEdges.debated_by || [],
|
|
610
|
-
synthesizes: rawEdges.synthesizes || [],
|
|
611
|
-
synthesized_by: rawEdges.synthesized_by || [],
|
|
612
|
-
};
|
|
613
|
-
|
|
614
|
-
// Helper to add edge to graph
|
|
615
|
-
const addEdge = (edge, idField, source, rank, simFactor) => {
|
|
616
|
-
const id = edge[idField];
|
|
617
|
-
if (!graphEnhanced.has(id)) {
|
|
618
|
-
graphEnhanced.set(id, {
|
|
619
|
-
id: id,
|
|
620
|
-
topic: edge.topic,
|
|
621
|
-
decision: edge.decision,
|
|
622
|
-
confidence: edge.confidence,
|
|
623
|
-
created_at: edge.created_at,
|
|
624
|
-
graph_source: source,
|
|
625
|
-
graph_rank: rank,
|
|
626
|
-
similarity: candidate.similarity * simFactor,
|
|
627
|
-
related_to: candidate.id,
|
|
628
|
-
edge_reason: edge.reason,
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
};
|
|
632
|
-
|
|
633
|
-
// Add refines edges
|
|
634
|
-
for (const edge of edges.refines) {
|
|
635
|
-
addEdge(edge, 'to_id', 'refines', 0.7, 0.85);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
// Add refined_by edges
|
|
639
|
-
for (const edge of edges.refined_by) {
|
|
640
|
-
addEdge(edge, 'from_id', 'refined_by', 0.7, 0.85);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// Add contradicts edges (lower rank, but still relevant)
|
|
644
|
-
for (const edge of edges.contradicts) {
|
|
645
|
-
addEdge(edge, 'to_id', 'contradicts', 0.6, 0.8);
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// Story 2.1: Add builds_on edges (high relevance - extending prior work)
|
|
649
|
-
for (const edge of edges.builds_on) {
|
|
650
|
-
addEdge(edge, 'to_id', 'builds_on', 0.75, 0.9);
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// Add built_upon_by edges (someone built on this decision)
|
|
654
|
-
for (const edge of edges.built_upon_by) {
|
|
655
|
-
addEdge(edge, 'from_id', 'built_upon_by', 0.75, 0.9);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// Add debates edges (alternative view)
|
|
659
|
-
for (const edge of edges.debates) {
|
|
660
|
-
addEdge(edge, 'to_id', 'debates', 0.65, 0.85);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// Add debated_by edges
|
|
664
|
-
for (const edge of edges.debated_by) {
|
|
665
|
-
addEdge(edge, 'from_id', 'debated_by', 0.65, 0.85);
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// Add synthesizes edges (unified approach)
|
|
669
|
-
for (const edge of edges.synthesizes) {
|
|
670
|
-
addEdge(edge, 'to_id', 'synthesizes', 0.7, 0.88);
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// Add synthesized_by edges
|
|
674
|
-
for (const edge of edges.synthesized_by) {
|
|
675
|
-
addEdge(edge, 'from_id', 'synthesized_by', 0.7, 0.88);
|
|
676
|
-
}
|
|
677
|
-
} catch (error) {
|
|
678
|
-
console.warn(`Failed to get semantic edges for ${candidate.id}: ${error.message}`);
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// 3. Convert Map to Array
|
|
683
|
-
const allResults = Array.from(graphEnhanced.values());
|
|
684
|
-
|
|
685
|
-
// 4. Sort: Interleave expanded results after their related primary
|
|
686
|
-
// This ensures edge-connected decisions appear near their source
|
|
687
|
-
const primaryResults = allResults
|
|
688
|
-
.filter((r) => primaryIds.has(r.id))
|
|
689
|
-
.sort((a, b) => {
|
|
690
|
-
const scoreA = a.final_score || a.similarity || 0;
|
|
691
|
-
const scoreB = b.final_score || b.similarity || 0;
|
|
692
|
-
return scoreB - scoreA;
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
const expandedResults = allResults.filter((r) => !primaryIds.has(r.id));
|
|
696
|
-
|
|
697
|
-
// Build final results: each primary followed by its related expanded results
|
|
698
|
-
const results = [];
|
|
699
|
-
for (const primary of primaryResults) {
|
|
700
|
-
results.push(primary);
|
|
701
|
-
|
|
702
|
-
// Find expanded results related to this primary
|
|
703
|
-
const relatedExpanded = expandedResults.filter((e) => e.related_to === primary.id);
|
|
704
|
-
|
|
705
|
-
// Sort related by graph_rank (higher first)
|
|
706
|
-
relatedExpanded.sort((a, b) => (b.graph_rank || 0) - (a.graph_rank || 0));
|
|
707
|
-
|
|
708
|
-
// Add related expanded results right after their primary
|
|
709
|
-
results.push(...relatedExpanded);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// Add any orphaned expanded results (shouldn't happen, but safety net)
|
|
713
|
-
const includedIds = new Set(results.map((r) => r.id));
|
|
714
|
-
const orphaned = expandedResults.filter((e) => !includedIds.has(e.id));
|
|
715
|
-
results.push(...orphaned);
|
|
716
|
-
|
|
717
|
-
return results;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
/**
|
|
721
|
-
* Apply Gaussian Decay recency boosting (Elasticsearch-style)
|
|
722
|
-
* Allows Claude to dynamically adjust search strategy based on results
|
|
723
|
-
*
|
|
724
|
-
* @param {Array} results - Search results with similarity scores
|
|
725
|
-
* @param {Object} options - Recency boosting options
|
|
726
|
-
* @returns {Array} Results with recency-boosted final scores
|
|
727
|
-
*/
|
|
728
|
-
function applyRecencyBoost(results, options = {}) {
|
|
729
|
-
const {
|
|
730
|
-
recencyWeight = 0.3,
|
|
731
|
-
recencyScale = 7,
|
|
732
|
-
recencyDecay = 0.5,
|
|
733
|
-
disableRecency = false,
|
|
734
|
-
} = options;
|
|
735
|
-
|
|
736
|
-
if (disableRecency || recencyWeight === 0) {
|
|
737
|
-
return results;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
const now = Date.now(); // Current timestamp in milliseconds
|
|
741
|
-
|
|
742
|
-
return results
|
|
743
|
-
.map((r) => {
|
|
744
|
-
// created_at is stored in milliseconds in the database
|
|
745
|
-
const ageInDays = (now - r.created_at) / (86400 * 1000);
|
|
746
|
-
|
|
747
|
-
// Gaussian Decay: exp(-((age / scale)^2) / (2 * ln(1 / decay)))
|
|
748
|
-
// At scale days: score = decay (e.g., 7 days = 50%)
|
|
749
|
-
const gaussianDecay = Math.exp(
|
|
750
|
-
-Math.pow(ageInDays / recencyScale, 2) / (2 * Math.log(1 / recencyDecay))
|
|
751
|
-
);
|
|
752
|
-
|
|
753
|
-
// Combine semantic similarity with recency
|
|
754
|
-
const finalScore = r.similarity * (1 - recencyWeight) + gaussianDecay * recencyWeight;
|
|
755
|
-
|
|
756
|
-
return {
|
|
757
|
-
...r,
|
|
758
|
-
recency_score: gaussianDecay,
|
|
759
|
-
recency_age_days: Math.round(ageInDays * 10) / 10,
|
|
760
|
-
final_score: finalScore,
|
|
761
|
-
};
|
|
762
|
-
})
|
|
763
|
-
.sort((a, b) => b.final_score - a.final_score);
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* Suggest relevant decisions based on user question
|
|
768
|
-
*
|
|
769
|
-
* DEFAULT: Returns JSON object with search results (LLM-first design)
|
|
770
|
-
* OPTIONAL: Returns Markdown string if format='markdown' (for human display)
|
|
771
|
-
*
|
|
772
|
-
* Simplified: Direct vector search without LLM intent analysis
|
|
773
|
-
* Works with short queries, long questions, Korean/English
|
|
774
|
-
*
|
|
775
|
-
* @param {string} userQuestion - User's question or intent
|
|
776
|
-
* @param {Object} options - Search options
|
|
777
|
-
* @param {string} [options.format='json'] - Output format: 'json' (default) or 'markdown'
|
|
778
|
-
* @param {number} [options.limit=5] - Max results to return
|
|
779
|
-
* @param {number} [options.threshold=0.6] - Minimum similarity (adaptive by query length)
|
|
780
|
-
* @param {boolean} [options.useReranking=false] - Use LLM re-ranking (optional, slower)
|
|
781
|
-
* @returns {Promise<Object|string|null>} Search results as JSON or Markdown, null if no results
|
|
782
|
-
*
|
|
783
|
-
* @example
|
|
784
|
-
* // LLM usage (default)
|
|
785
|
-
* const data = await mama.suggest('Why did we choose JWT?');
|
|
786
|
-
* // → { query, results: [...], meta: {...} }
|
|
787
|
-
*
|
|
788
|
-
* // Human display
|
|
789
|
-
* const markdown = await mama.suggest('mesh optimization', { format: 'markdown' });
|
|
790
|
-
* // → "💡 MAMA found 3 related topics:\n1. ..."
|
|
791
|
-
*/
|
|
792
|
-
async function suggest(userQuestion, options = {}) {
|
|
793
|
-
if (!userQuestion || typeof userQuestion !== 'string') {
|
|
794
|
-
throw new Error('mama.suggest() requires userQuestion (string)');
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
const {
|
|
798
|
-
format = 'json',
|
|
799
|
-
limit = 5,
|
|
800
|
-
threshold,
|
|
801
|
-
useReranking = false,
|
|
802
|
-
// Recency boosting parameters (Gaussian Decay - Elasticsearch style)
|
|
803
|
-
recencyWeight = 0.3, // 0-1: How much to weight recency (0.3 = 70% semantic, 30% recency)
|
|
804
|
-
recencyScale = 7, // Days until recency score drops to 50%
|
|
805
|
-
recencyDecay = 0.5, // Score at scale point (0.5 = 50%)
|
|
806
|
-
disableRecency = false, // Set true to disable recency boosting entirely
|
|
807
|
-
} = options;
|
|
808
|
-
|
|
809
|
-
try {
|
|
810
|
-
// 1. Try vector search first (if sqlite-vss is available)
|
|
811
|
-
// eslint-disable-next-line no-unused-vars
|
|
812
|
-
const { getPreparedStmt, getDB } = require('./memory-store');
|
|
813
|
-
let results = [];
|
|
814
|
-
let searchMethod = 'vector';
|
|
815
|
-
|
|
816
|
-
try {
|
|
817
|
-
// Check if vectorSearch prepared statement exists
|
|
818
|
-
getPreparedStmt('vectorSearch');
|
|
819
|
-
|
|
820
|
-
// Generate query embedding
|
|
821
|
-
const { generateEmbedding } = require('./embeddings');
|
|
822
|
-
const queryEmbedding = await generateEmbedding(userQuestion);
|
|
823
|
-
|
|
824
|
-
// Adaptive threshold (shorter queries need higher confidence)
|
|
825
|
-
const wordCount = userQuestion.split(/\s+/).length;
|
|
826
|
-
const adaptiveThreshold = threshold !== undefined ? threshold : wordCount < 3 ? 0.7 : 0.6;
|
|
827
|
-
|
|
828
|
-
// Vector search
|
|
829
|
-
const { vectorSearch } = require('./memory-store');
|
|
830
|
-
results = await vectorSearch(queryEmbedding, limit * 2, 0.5); // Get more candidates
|
|
831
|
-
|
|
832
|
-
// Filter by adaptive threshold
|
|
833
|
-
results = results.filter((r) => r.similarity >= adaptiveThreshold);
|
|
834
|
-
|
|
835
|
-
// Stage 1.5: Apply recency boosting (Gaussian Decay)
|
|
836
|
-
// Allows Claude to adjust search strategy (recent vs historical)
|
|
837
|
-
if (results.length > 0 && !disableRecency) {
|
|
838
|
-
results = applyRecencyBoost(results, {
|
|
839
|
-
recencyWeight,
|
|
840
|
-
recencyScale,
|
|
841
|
-
recencyDecay,
|
|
842
|
-
disableRecency,
|
|
843
|
-
});
|
|
844
|
-
searchMethod = 'vector+recency';
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
// Stage 2: Graph expansion (NEW - Phase 1)
|
|
848
|
-
// Expand candidates with supersedes chain and semantic edges
|
|
849
|
-
if (results.length > 0) {
|
|
850
|
-
const graphEnhanced = await expandWithGraph(results);
|
|
851
|
-
results = graphEnhanced;
|
|
852
|
-
searchMethod = disableRecency ? 'vector+graph' : 'vector+recency+graph';
|
|
853
|
-
}
|
|
854
|
-
} catch (vectorError) {
|
|
855
|
-
// Fallback to keyword search if vector search unavailable
|
|
856
|
-
console.warn(`Vector search failed: ${vectorError.message}, falling back to keyword search`);
|
|
857
|
-
searchMethod = 'keyword';
|
|
858
|
-
|
|
859
|
-
// Keyword search fallback
|
|
860
|
-
const adapter = getAdapter();
|
|
861
|
-
const keywords = userQuestion
|
|
862
|
-
.toLowerCase()
|
|
863
|
-
.split(/\s+/)
|
|
864
|
-
.filter((w) => w.length > 2); // Filter short words
|
|
865
|
-
|
|
866
|
-
if (keywords.length === 0) {
|
|
867
|
-
if (format === 'markdown') {
|
|
868
|
-
return `💡 Hint: Please be more specific.\nExample: "Railway Volume settings" or "mesh parameter optimization"`;
|
|
869
|
-
}
|
|
870
|
-
return null; // JSON mode returns null for empty/invalid queries
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
// Build LIKE query for each keyword
|
|
874
|
-
const likeConditions = keywords.map(() => '(topic LIKE ? OR decision LIKE ?)').join(' OR ');
|
|
875
|
-
const likeParams = keywords.flatMap((k) => [`%${k}%`, `%${k}%`]);
|
|
876
|
-
|
|
877
|
-
const stmt = adapter.prepare(`
|
|
878
|
-
SELECT * FROM decisions
|
|
879
|
-
WHERE ${likeConditions}
|
|
880
|
-
AND superseded_by IS NULL
|
|
881
|
-
ORDER BY created_at DESC
|
|
882
|
-
LIMIT ?
|
|
883
|
-
`);
|
|
884
|
-
|
|
885
|
-
const rows = await stmt.all(...likeParams, limit);
|
|
886
|
-
results = rows.map((row) => ({
|
|
887
|
-
...row,
|
|
888
|
-
similarity: 0.75, // Assign moderate similarity for keyword matches
|
|
889
|
-
}));
|
|
890
|
-
|
|
891
|
-
// Stage 2: Graph expansion for keyword results (Phase 1)
|
|
892
|
-
if (results.length > 0) {
|
|
893
|
-
const graphEnhanced = await expandWithGraph(results);
|
|
894
|
-
results = graphEnhanced;
|
|
895
|
-
searchMethod = 'keyword+graph';
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
if (results.length === 0) {
|
|
900
|
-
if (format === 'markdown') {
|
|
901
|
-
const wordCount = userQuestion.split(/\s+/).length;
|
|
902
|
-
if (wordCount < 3) {
|
|
903
|
-
return `💡 Hint: Please be more specific.\nExample: "Why did we choose COMPLEX mesh structure?" or "What parameters are used for large layers?"`;
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
return null;
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// 5. Optional: LLM re-ranking (only if requested)
|
|
910
|
-
if (useReranking) {
|
|
911
|
-
results = await rerankWithLLM(userQuestion, results);
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// Slice to limit
|
|
915
|
-
const finalResults = results.slice(0, limit);
|
|
916
|
-
|
|
917
|
-
// Markdown format (for human display)
|
|
918
|
-
if (format === 'markdown') {
|
|
919
|
-
const { formatContext } = require('./decision-formatter');
|
|
920
|
-
const context = formatContext(finalResults, { maxTokens: 500 });
|
|
921
|
-
|
|
922
|
-
// Add graph expansion summary if applicable
|
|
923
|
-
let graphSummary = '';
|
|
924
|
-
if (searchMethod.includes('graph')) {
|
|
925
|
-
const primaryCount = finalResults.filter((r) => r.graph_source === 'primary').length;
|
|
926
|
-
const expandedCount = finalResults.filter((r) => r.graph_source !== 'primary').length;
|
|
927
|
-
|
|
928
|
-
graphSummary = `\n📊 Graph expansion: ${primaryCount} primary + ${expandedCount} related (supersedes/refines/contradicts)\n`;
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
return `🔍 Search method: ${searchMethod}${graphSummary}\n${context}`;
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
// Calculate graph expansion stats
|
|
935
|
-
const graphStats = {
|
|
936
|
-
total_results: finalResults.length,
|
|
937
|
-
primary_count: finalResults.filter((r) => r.graph_source === 'primary').length,
|
|
938
|
-
expanded_count: finalResults.filter((r) => r.graph_source !== 'primary').length,
|
|
939
|
-
sources: {
|
|
940
|
-
primary: finalResults.filter((r) => r.graph_source === 'primary').length,
|
|
941
|
-
supersedes_chain: finalResults.filter((r) => r.graph_source === 'supersedes_chain').length,
|
|
942
|
-
refines: finalResults.filter((r) => r.graph_source === 'refines').length,
|
|
943
|
-
refined_by: finalResults.filter((r) => r.graph_source === 'refined_by').length,
|
|
944
|
-
contradicts: finalResults.filter((r) => r.graph_source === 'contradicts').length,
|
|
945
|
-
},
|
|
946
|
-
};
|
|
947
|
-
|
|
948
|
-
// JSON format (default - LLM-first)
|
|
949
|
-
return {
|
|
950
|
-
query: userQuestion,
|
|
951
|
-
results: finalResults.map((r) => ({
|
|
952
|
-
id: r.id,
|
|
953
|
-
topic: r.topic,
|
|
954
|
-
decision: r.decision,
|
|
955
|
-
reasoning: r.reasoning,
|
|
956
|
-
confidence: r.confidence,
|
|
957
|
-
similarity: r.similarity,
|
|
958
|
-
created_at: r.created_at,
|
|
959
|
-
// Recency metadata (NEW - Gaussian Decay)
|
|
960
|
-
recency_score: r.recency_score,
|
|
961
|
-
recency_age_days: r.recency_age_days,
|
|
962
|
-
final_score: r.final_score || r.similarity, // Falls back to similarity if no recency
|
|
963
|
-
// Graph metadata (NEW - Phase 1)
|
|
964
|
-
graph_source: r.graph_source || 'primary',
|
|
965
|
-
graph_rank: r.graph_rank || 1.0,
|
|
966
|
-
related_to: r.related_to || null,
|
|
967
|
-
edge_reason: r.edge_reason || null,
|
|
968
|
-
})),
|
|
969
|
-
meta: {
|
|
970
|
-
count: finalResults.length,
|
|
971
|
-
search_method: searchMethod,
|
|
972
|
-
threshold: threshold || 'adaptive',
|
|
973
|
-
// Recency boosting config (NEW - Gaussian Decay)
|
|
974
|
-
recency_boost: disableRecency
|
|
975
|
-
? null
|
|
976
|
-
: {
|
|
977
|
-
weight: recencyWeight,
|
|
978
|
-
scale: recencyScale,
|
|
979
|
-
decay: recencyDecay,
|
|
980
|
-
},
|
|
981
|
-
// Graph expansion stats (NEW - Phase 1)
|
|
982
|
-
graph_expansion: searchMethod.includes('graph') ? graphStats : null,
|
|
983
|
-
},
|
|
984
|
-
};
|
|
985
|
-
} catch (error) {
|
|
986
|
-
// Graceful degradation
|
|
987
|
-
console.warn(`mama.suggest() failed: ${error.message}`);
|
|
988
|
-
return null;
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
/**
|
|
993
|
-
* Re-rank search results using local LLM (optional enhancement)
|
|
994
|
-
*
|
|
995
|
-
* @param {string} userQuestion - User's question
|
|
996
|
-
* @param {Array} results - Vector search results
|
|
997
|
-
* @returns {Promise<Array>} Re-ranked results
|
|
998
|
-
*/
|
|
999
|
-
async function rerankWithLLM(userQuestion, results) {
|
|
1000
|
-
try {
|
|
1001
|
-
const { generate } = require('./ollama-client');
|
|
1002
|
-
|
|
1003
|
-
const prompt = `User asked: "${userQuestion}"
|
|
1004
|
-
|
|
1005
|
-
Found decisions (ranked by vector similarity):
|
|
1006
|
-
${results.map((r, i) => `${i + 1}. [${r.similarity.toFixed(3)}] ${r.topic}: ${r.decision.substring(0, 60)}...`).join('\n')}
|
|
1007
|
-
|
|
1008
|
-
Re-rank these by actual relevance to the user's intent (not just keyword similarity).
|
|
1009
|
-
Return JSON: { "ranking": [index1, index2, ...] } (0-based indices)
|
|
1010
|
-
|
|
1011
|
-
Example: { "ranking": [2, 0, 4, 1, 3] } means 3rd is most relevant, then 1st, then 5th...`;
|
|
1012
|
-
|
|
1013
|
-
const response = await generate(prompt, {
|
|
1014
|
-
format: 'json',
|
|
1015
|
-
temperature: 0.3,
|
|
1016
|
-
max_tokens: 100,
|
|
1017
|
-
timeout: 3000,
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
const parsed = typeof response === 'string' ? JSON.parse(response) : response;
|
|
1021
|
-
|
|
1022
|
-
// Reorder results based on LLM ranking
|
|
1023
|
-
return parsed.ranking.map((idx) => results[idx]).filter(Boolean);
|
|
1024
|
-
} catch (error) {
|
|
1025
|
-
console.warn(`Re-ranking failed: ${error.message}, using vector ranking`);
|
|
1026
|
-
return results; // Fallback to vector ranking
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
/**
|
|
1031
|
-
* List recent decisions (all topics, chronological)
|
|
1032
|
-
*
|
|
1033
|
-
* DEFAULT: Returns JSON array with recent decisions (LLM-first design)
|
|
1034
|
-
* OPTIONAL: Returns Markdown string if format='markdown' (for human display)
|
|
1035
|
-
*
|
|
1036
|
-
* @param {Object} [options] - Options
|
|
1037
|
-
* @param {number} [options.limit=10] - Max results
|
|
1038
|
-
* @param {string} [options.format='json'] - Output format
|
|
1039
|
-
* @returns {Promise<Array|string>} Recent decisions
|
|
1040
|
-
*/
|
|
1041
|
-
async function listDecisions(options = {}) {
|
|
1042
|
-
const { limit = 10, format = 'json' } = options;
|
|
1043
|
-
|
|
1044
|
-
try {
|
|
1045
|
-
const adapter = getAdapter();
|
|
1046
|
-
const stmt = adapter.prepare(`
|
|
1047
|
-
SELECT * FROM decisions
|
|
1048
|
-
WHERE superseded_by IS NULL
|
|
1049
|
-
ORDER BY created_at DESC
|
|
1050
|
-
LIMIT ?
|
|
1051
|
-
`);
|
|
1052
|
-
const decisions = await stmt.all(limit);
|
|
1053
|
-
|
|
1054
|
-
if (format === 'markdown') {
|
|
1055
|
-
return formatList(decisions);
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
return decisions;
|
|
1059
|
-
} catch (error) {
|
|
1060
|
-
throw new Error(`mama.listDecisions() failed: ${error.message}`);
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
/**
|
|
1065
|
-
* Save current session checkpoint (New Feature: Session Continuity)
|
|
1066
|
-
*
|
|
1067
|
-
* @param {string} summary - Summary of current session state
|
|
1068
|
-
* @param {Array<string>} openFiles - List of currently open files
|
|
1069
|
-
* @param {string} nextSteps - Next steps to be taken
|
|
1070
|
-
* @returns {Promise<number>} Checkpoint ID
|
|
1071
|
-
*/
|
|
1072
|
-
async function saveCheckpoint(summary, openFiles = [], nextSteps = '', recentConversation = []) {
|
|
1073
|
-
if (!summary) {
|
|
1074
|
-
throw new Error('Summary is required for checkpoint');
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
try {
|
|
1078
|
-
const adapter = getAdapter();
|
|
1079
|
-
const stmt = adapter.prepare(`
|
|
1080
|
-
INSERT INTO checkpoints (timestamp, summary, open_files, next_steps, recent_conversation, status)
|
|
1081
|
-
VALUES (?, ?, ?, ?, ?, 'active')
|
|
1082
|
-
`);
|
|
1083
|
-
|
|
1084
|
-
const result = stmt.run(
|
|
1085
|
-
Date.now(),
|
|
1086
|
-
summary,
|
|
1087
|
-
JSON.stringify(openFiles),
|
|
1088
|
-
nextSteps,
|
|
1089
|
-
JSON.stringify(recentConversation || [])
|
|
1090
|
-
);
|
|
1091
|
-
|
|
1092
|
-
return result.lastInsertRowid;
|
|
1093
|
-
} catch (error) {
|
|
1094
|
-
throw new Error(`Failed to save checkpoint: ${error.message}`);
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
/**
|
|
1099
|
-
* Load latest active checkpoint (New Feature: Session Continuity)
|
|
1100
|
-
*
|
|
1101
|
-
* @returns {Promise<Object|null>} Latest checkpoint or null
|
|
1102
|
-
*/
|
|
1103
|
-
async function loadCheckpoint() {
|
|
1104
|
-
try {
|
|
1105
|
-
const adapter = getAdapter();
|
|
1106
|
-
const stmt = adapter.prepare(`
|
|
1107
|
-
SELECT * FROM checkpoints
|
|
1108
|
-
WHERE status = 'active'
|
|
1109
|
-
ORDER BY timestamp DESC
|
|
1110
|
-
LIMIT 1
|
|
1111
|
-
`);
|
|
1112
|
-
|
|
1113
|
-
const checkpoint = stmt.get();
|
|
1114
|
-
|
|
1115
|
-
if (checkpoint) {
|
|
1116
|
-
try {
|
|
1117
|
-
checkpoint.open_files = JSON.parse(checkpoint.open_files);
|
|
1118
|
-
} catch (e) {
|
|
1119
|
-
checkpoint.open_files = [];
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
try {
|
|
1123
|
-
checkpoint.recent_conversation = JSON.parse(checkpoint.recent_conversation || '[]');
|
|
1124
|
-
} catch (e) {
|
|
1125
|
-
checkpoint.recent_conversation = [];
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
return checkpoint || null;
|
|
1130
|
-
} catch (error) {
|
|
1131
|
-
throw new Error(`Failed to load checkpoint: ${error.message}`);
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
/**
|
|
1136
|
-
* List recent checkpoints (New Feature: Session Continuity)
|
|
1137
|
-
*
|
|
1138
|
-
* @param {number} limit - Max number of checkpoints to return
|
|
1139
|
-
* @returns {Promise<Array>} Recent checkpoints
|
|
1140
|
-
*/
|
|
1141
|
-
async function listCheckpoints(limit = 10) {
|
|
1142
|
-
try {
|
|
1143
|
-
const adapter = getAdapter();
|
|
1144
|
-
const stmt = adapter.prepare(`
|
|
1145
|
-
SELECT * FROM checkpoints
|
|
1146
|
-
ORDER BY timestamp DESC
|
|
1147
|
-
LIMIT ?
|
|
1148
|
-
`);
|
|
1149
|
-
|
|
1150
|
-
const checkpoints = stmt.all(limit);
|
|
1151
|
-
|
|
1152
|
-
return checkpoints.map((c) => {
|
|
1153
|
-
try {
|
|
1154
|
-
c.open_files = JSON.parse(c.open_files);
|
|
1155
|
-
} catch (e) {
|
|
1156
|
-
c.open_files = [];
|
|
1157
|
-
}
|
|
1158
|
-
try {
|
|
1159
|
-
c.recent_conversation = JSON.parse(c.recent_conversation);
|
|
1160
|
-
} catch (e) {
|
|
1161
|
-
c.recent_conversation = [];
|
|
1162
|
-
}
|
|
1163
|
-
return c;
|
|
1164
|
-
});
|
|
1165
|
-
} catch (error) {
|
|
1166
|
-
throw new Error(`Failed to list checkpoints: ${error.message}`);
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
/**
|
|
1171
|
-
* Propose a new link between decisions (Epic 3 - Story 3.1)
|
|
1172
|
-
*
|
|
1173
|
-
* LLM proposes a link for user approval. Link is created but marked as pending.
|
|
1174
|
-
*
|
|
1175
|
-
* @param {Object} params - Link parameters
|
|
1176
|
-
* @param {string} params.from_id - Source decision ID
|
|
1177
|
-
* @param {string} params.to_id - Target decision ID
|
|
1178
|
-
* @param {string} params.relationship - 'refines' or 'contradicts'
|
|
1179
|
-
* @param {string} params.reason - Why this link should exist
|
|
1180
|
-
* @param {string} [params.decision_id] - Context decision where link was proposed
|
|
1181
|
-
* @param {string} [params.evidence] - Supporting evidence
|
|
1182
|
-
* @returns {Promise<void>}
|
|
1183
|
-
*/
|
|
1184
|
-
async function proposeLink({ from_id, to_id, relationship, reason, decision_id, evidence }) {
|
|
1185
|
-
if (!from_id || !to_id || !relationship || !reason) {
|
|
1186
|
-
throw new Error('proposeLink() requires from_id, to_id, relationship, and reason');
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
if (!['refines', 'contradicts'].includes(relationship)) {
|
|
1190
|
-
throw new Error('proposeLink() relationship must be "refines" or "contradicts"');
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
try {
|
|
1194
|
-
const adapter = getAdapter();
|
|
1195
|
-
|
|
1196
|
-
// Use transaction to ensure atomicity (link + audit log)
|
|
1197
|
-
adapter.transaction(() => {
|
|
1198
|
-
// Insert link with pending approval
|
|
1199
|
-
const stmt = adapter.prepare(`
|
|
1200
|
-
INSERT INTO decision_edges
|
|
1201
|
-
(from_id, to_id, relationship, reason, created_by, approved_by_user, decision_id, evidence, created_at)
|
|
1202
|
-
VALUES (?, ?, ?, ?, 'llm', 0, ?, ?, ?)
|
|
1203
|
-
`);
|
|
1204
|
-
stmt.run(
|
|
1205
|
-
from_id,
|
|
1206
|
-
to_id,
|
|
1207
|
-
relationship,
|
|
1208
|
-
reason,
|
|
1209
|
-
decision_id || null,
|
|
1210
|
-
evidence || null,
|
|
1211
|
-
Date.now()
|
|
1212
|
-
);
|
|
1213
|
-
|
|
1214
|
-
// Log to audit trail
|
|
1215
|
-
const auditStmt = adapter.prepare(`
|
|
1216
|
-
INSERT INTO link_audit_log (from_id, to_id, relationship, action, actor, reason, created_at)
|
|
1217
|
-
VALUES (?, ?, ?, 'proposed', 'llm', ?, ?)
|
|
1218
|
-
`);
|
|
1219
|
-
auditStmt.run(from_id, to_id, relationship, reason, Date.now());
|
|
1220
|
-
});
|
|
1221
|
-
|
|
1222
|
-
return;
|
|
1223
|
-
} catch (error) {
|
|
1224
|
-
throw new Error(`proposeLink() failed: ${error.message}`);
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
/**
|
|
1229
|
-
* Approve a proposed link (Epic 3 - Story 3.1)
|
|
1230
|
-
*
|
|
1231
|
-
* User approves a pending link, making it active.
|
|
1232
|
-
*
|
|
1233
|
-
* @param {string} from_id - Source decision ID
|
|
1234
|
-
* @param {string} to_id - Target decision ID
|
|
1235
|
-
* @param {string} relationship - Link relationship type
|
|
1236
|
-
* @returns {Promise<void>}
|
|
1237
|
-
*/
|
|
1238
|
-
async function approveLink(from_id, to_id, relationship) {
|
|
1239
|
-
if (!from_id || !to_id || !relationship) {
|
|
1240
|
-
throw new Error('approveLink() requires from_id, to_id, and relationship');
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
try {
|
|
1244
|
-
const adapter = getAdapter();
|
|
1245
|
-
|
|
1246
|
-
// Update link to approved with timestamp
|
|
1247
|
-
const stmt = adapter.prepare(`
|
|
1248
|
-
UPDATE decision_edges
|
|
1249
|
-
SET approved_by_user = 1, approved_at = ?
|
|
1250
|
-
WHERE from_id = ? AND to_id = ? AND relationship = ?
|
|
1251
|
-
`);
|
|
1252
|
-
stmt.run(Date.now(), from_id, to_id, relationship);
|
|
1253
|
-
|
|
1254
|
-
// Log approval
|
|
1255
|
-
const auditStmt = adapter.prepare(`
|
|
1256
|
-
INSERT INTO link_audit_log (from_id, to_id, relationship, action, actor, created_at)
|
|
1257
|
-
VALUES (?, ?, ?, 'approved', 'user', ?)
|
|
1258
|
-
`);
|
|
1259
|
-
auditStmt.run(from_id, to_id, relationship, Date.now());
|
|
1260
|
-
|
|
1261
|
-
return;
|
|
1262
|
-
} catch (error) {
|
|
1263
|
-
throw new Error(`approveLink() failed: ${error.message}`);
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
/**
|
|
1268
|
-
* Reject a proposed link (Epic 3 - Story 3.1)
|
|
1269
|
-
*
|
|
1270
|
-
* User rejects a pending link, removing it from the database.
|
|
1271
|
-
*
|
|
1272
|
-
* @param {string} from_id - Source decision ID
|
|
1273
|
-
* @param {string} to_id - Target decision ID
|
|
1274
|
-
* @param {string} relationship - Link relationship type
|
|
1275
|
-
* @param {string} [reason] - Optional reason for rejection
|
|
1276
|
-
* @returns {Promise<void>}
|
|
1277
|
-
*/
|
|
1278
|
-
async function rejectLink(from_id, to_id, relationship, reason) {
|
|
1279
|
-
if (!from_id || !to_id || !relationship) {
|
|
1280
|
-
throw new Error('rejectLink() requires from_id, to_id, and relationship');
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
try {
|
|
1284
|
-
const adapter = getAdapter();
|
|
1285
|
-
|
|
1286
|
-
// Log rejection before deletion
|
|
1287
|
-
const auditStmt = adapter.prepare(`
|
|
1288
|
-
INSERT INTO link_audit_log (from_id, to_id, relationship, action, actor, reason, created_at)
|
|
1289
|
-
VALUES (?, ?, ?, 'rejected', 'user', ?, ?)
|
|
1290
|
-
`);
|
|
1291
|
-
auditStmt.run(from_id, to_id, relationship, reason || 'User rejected', Date.now());
|
|
1292
|
-
|
|
1293
|
-
// Delete the link
|
|
1294
|
-
const stmt = adapter.prepare(`
|
|
1295
|
-
DELETE FROM decision_edges
|
|
1296
|
-
WHERE from_id = ? AND to_id = ? AND relationship = ?
|
|
1297
|
-
`);
|
|
1298
|
-
stmt.run(from_id, to_id, relationship);
|
|
1299
|
-
|
|
1300
|
-
return;
|
|
1301
|
-
} catch (error) {
|
|
1302
|
-
throw new Error(`rejectLink() failed: ${error.message}`);
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
/**
|
|
1307
|
-
* Get pending links awaiting approval (Epic 3 - Story 3.1)
|
|
1308
|
-
*
|
|
1309
|
-
* Returns all links that need user approval.
|
|
1310
|
-
*
|
|
1311
|
-
* @param {Object} [options] - Query options
|
|
1312
|
-
* @param {string} [options.from_id] - Filter by source decision
|
|
1313
|
-
* @param {string} [options.to_id] - Filter by target decision
|
|
1314
|
-
* @returns {Promise<Array>} Pending links with decision details
|
|
1315
|
-
*/
|
|
1316
|
-
async function getPendingLinks(options = {}) {
|
|
1317
|
-
try {
|
|
1318
|
-
const adapter = getAdapter();
|
|
1319
|
-
const { from_id, to_id } = options;
|
|
1320
|
-
|
|
1321
|
-
let query = `
|
|
1322
|
-
SELECT
|
|
1323
|
-
e.*,
|
|
1324
|
-
d_from.topic as from_topic,
|
|
1325
|
-
d_from.decision as from_decision,
|
|
1326
|
-
d_to.topic as to_topic,
|
|
1327
|
-
d_to.decision as to_decision
|
|
1328
|
-
FROM decision_edges e
|
|
1329
|
-
LEFT JOIN decisions d_from ON e.from_id = d_from.id
|
|
1330
|
-
LEFT JOIN decisions d_to ON e.to_id = d_to.id
|
|
1331
|
-
WHERE e.approved_by_user = 0
|
|
1332
|
-
`;
|
|
1333
|
-
|
|
1334
|
-
const params = [];
|
|
1335
|
-
if (from_id) {
|
|
1336
|
-
query += ' AND e.from_id = ?';
|
|
1337
|
-
params.push(from_id);
|
|
1338
|
-
}
|
|
1339
|
-
if (to_id) {
|
|
1340
|
-
query += ' AND e.to_id = ?';
|
|
1341
|
-
params.push(to_id);
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
query += ' ORDER BY e.created_at DESC';
|
|
1345
|
-
|
|
1346
|
-
const stmt = adapter.prepare(query);
|
|
1347
|
-
const links = await stmt.all(...params);
|
|
1348
|
-
|
|
1349
|
-
return links;
|
|
1350
|
-
} catch (error) {
|
|
1351
|
-
throw new Error(`getPendingLinks() failed: ${error.message}`);
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
/**
|
|
1356
|
-
* Deprecate auto-generated links (Epic 3 - Story 3.3)
|
|
1357
|
-
*
|
|
1358
|
-
* Identifies and removes v0 auto-generated links that lack explicit approval context.
|
|
1359
|
-
* Protected links (with decision_id or created_by='llm') are preserved.
|
|
1360
|
-
*
|
|
1361
|
-
* @param {Object} [options] - Deprecation options
|
|
1362
|
-
* @param {boolean} [options.dryRun=true] - If true, only report without deleting
|
|
1363
|
-
* @returns {Promise<Object>} Report with counts and deprecated links
|
|
1364
|
-
*/
|
|
1365
|
-
async function deprecateAutoLinks(options = {}) {
|
|
1366
|
-
const { dryRun = true } = options;
|
|
1367
|
-
|
|
1368
|
-
try {
|
|
1369
|
-
const adapter = getAdapter();
|
|
1370
|
-
|
|
1371
|
-
// Identify auto-generated links (v0 legacy)
|
|
1372
|
-
// Criteria: created_by='user' (default) AND decision_id IS NULL (no proposal context)
|
|
1373
|
-
// Protected: decision_id IS NOT NULL OR created_by='llm' (explicitly proposed)
|
|
1374
|
-
const identifyStmt = adapter.prepare(`
|
|
1375
|
-
SELECT * FROM decision_edges
|
|
1376
|
-
WHERE created_by = 'user' AND decision_id IS NULL
|
|
1377
|
-
`);
|
|
1378
|
-
const autoLinks = await identifyStmt.all();
|
|
1379
|
-
|
|
1380
|
-
// Identify protected links for comparison
|
|
1381
|
-
const protectedStmt = adapter.prepare(`
|
|
1382
|
-
SELECT COUNT(*) as count FROM decision_edges
|
|
1383
|
-
WHERE decision_id IS NOT NULL OR created_by = 'llm'
|
|
1384
|
-
`);
|
|
1385
|
-
const protectedResult = await protectedStmt.get();
|
|
1386
|
-
const protectedCount = protectedResult.count;
|
|
1387
|
-
|
|
1388
|
-
const totalLinks = autoLinks.length + protectedCount;
|
|
1389
|
-
const autoLinkRatio = totalLinks > 0 ? (autoLinks.length / totalLinks) * 100 : 0;
|
|
1390
|
-
|
|
1391
|
-
if (!dryRun && autoLinks.length > 0) {
|
|
1392
|
-
// Delete auto-generated links
|
|
1393
|
-
const deleteStmt = adapter.prepare(`
|
|
1394
|
-
DELETE FROM decision_edges
|
|
1395
|
-
WHERE created_by = 'user' AND decision_id IS NULL
|
|
1396
|
-
`);
|
|
1397
|
-
await deleteStmt.run();
|
|
1398
|
-
|
|
1399
|
-
// Log deprecation to audit trail
|
|
1400
|
-
const timestamp = Date.now();
|
|
1401
|
-
const auditStmt = adapter.prepare(`
|
|
1402
|
-
INSERT INTO link_audit_log (from_id, to_id, relationship, action, actor, reason, created_at)
|
|
1403
|
-
VALUES (?, ?, ?, 'deprecated', 'system', ?, ?)
|
|
1404
|
-
`);
|
|
1405
|
-
|
|
1406
|
-
for (const link of autoLinks) {
|
|
1407
|
-
await auditStmt.run(
|
|
1408
|
-
link.from_id,
|
|
1409
|
-
link.to_id,
|
|
1410
|
-
link.relationship,
|
|
1411
|
-
'v0 auto-generated link removed during governance migration',
|
|
1412
|
-
timestamp
|
|
1413
|
-
);
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
return {
|
|
1418
|
-
dryRun,
|
|
1419
|
-
deprecated: autoLinks.length,
|
|
1420
|
-
protected: protectedCount,
|
|
1421
|
-
total: totalLinks,
|
|
1422
|
-
autoLinkRatio: autoLinkRatio.toFixed(2) + '%',
|
|
1423
|
-
links: autoLinks.map((l) => ({
|
|
1424
|
-
from_id: l.from_id,
|
|
1425
|
-
to_id: l.to_id,
|
|
1426
|
-
relationship: l.relationship,
|
|
1427
|
-
reason: l.reason,
|
|
1428
|
-
created_at: l.created_at,
|
|
1429
|
-
})),
|
|
1430
|
-
};
|
|
1431
|
-
} catch (error) {
|
|
1432
|
-
throw new Error(`deprecateAutoLinks() failed: ${error.message}`);
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
/**
|
|
1437
|
-
* Scan and identify auto-generated links for cleanup (Epic 5 - Story 5.1)
|
|
1438
|
-
*
|
|
1439
|
-
* Identifies auto-generated links lacking proper approval metadata.
|
|
1440
|
-
* Separates deletion targets from protected links.
|
|
1441
|
-
*
|
|
1442
|
-
* Identification criteria:
|
|
1443
|
-
* - approved_by_user = 0 OR (created_by IS NULL AND decision_id IS NULL)
|
|
1444
|
-
*
|
|
1445
|
-
* Protected (excluded from deletion):
|
|
1446
|
-
* - approved_by_user = 1 AND (decision_id IS NOT NULL OR evidence IS NOT NULL)
|
|
1447
|
-
*
|
|
1448
|
-
* @returns {Object} Scan results with counts and link details
|
|
1449
|
-
*/
|
|
1450
|
-
function scanAutoLinks() {
|
|
1451
|
-
const adapter = getAdapter();
|
|
1452
|
-
const db = adapter.db;
|
|
1453
|
-
|
|
1454
|
-
// Total links
|
|
1455
|
-
const totalLinks = db.prepare(`SELECT COUNT(*) as count FROM decision_edges`).get().count;
|
|
1456
|
-
|
|
1457
|
-
// Auto-generated links (lacking proper metadata)
|
|
1458
|
-
const autoLinks = db
|
|
1459
|
-
.prepare(
|
|
1460
|
-
`
|
|
1461
|
-
SELECT * FROM decision_edges
|
|
1462
|
-
WHERE approved_by_user = 0
|
|
1463
|
-
OR (created_by IS NULL AND decision_id IS NULL)
|
|
1464
|
-
`
|
|
1465
|
-
)
|
|
1466
|
-
.all();
|
|
1467
|
-
|
|
1468
|
-
// Protected links (approved or has complete metadata)
|
|
1469
|
-
const protectedLinks = db
|
|
1470
|
-
.prepare(
|
|
1471
|
-
`
|
|
1472
|
-
SELECT COUNT(*) as count FROM decision_edges
|
|
1473
|
-
WHERE approved_by_user = 1
|
|
1474
|
-
OR (decision_id IS NOT NULL AND evidence IS NOT NULL)
|
|
1475
|
-
`
|
|
1476
|
-
)
|
|
1477
|
-
.get().count;
|
|
1478
|
-
|
|
1479
|
-
// Filter deletion targets (exclude protected links)
|
|
1480
|
-
const deletionTargets = autoLinks.filter((link) => {
|
|
1481
|
-
// Exclude protected links
|
|
1482
|
-
return !(link.approved_by_user === 1 || (link.decision_id && link.evidence));
|
|
1483
|
-
});
|
|
1484
|
-
|
|
1485
|
-
return {
|
|
1486
|
-
total_links: totalLinks,
|
|
1487
|
-
auto_links: autoLinks.length,
|
|
1488
|
-
protected_links: protectedLinks,
|
|
1489
|
-
deletion_targets: deletionTargets.length,
|
|
1490
|
-
deletion_target_list: deletionTargets,
|
|
1491
|
-
};
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
/**
|
|
1495
|
-
* Create backup of links before cleanup (Epic 5 - Story 5.1)
|
|
1496
|
-
*
|
|
1497
|
-
* Backs up deletion target links with full metadata to JSON file.
|
|
1498
|
-
* Generates SHA-256 checksum for data integrity verification.
|
|
1499
|
-
* Creates backup manifest with timestamp and metadata.
|
|
1500
|
-
*
|
|
1501
|
-
* @param {Array} targetLinks - Links to back up
|
|
1502
|
-
* @returns {Object} Backup result with file paths and checksum
|
|
1503
|
-
*/
|
|
1504
|
-
function createLinkBackup(targetLinks) {
|
|
1505
|
-
const fs = require('fs');
|
|
1506
|
-
const crypto = require('crypto');
|
|
1507
|
-
const path = require('path');
|
|
1508
|
-
|
|
1509
|
-
const backupDir = path.join(os.homedir(), '.claude', 'mama-backups');
|
|
1510
|
-
|
|
1511
|
-
// Create backup directory if not exists
|
|
1512
|
-
if (!fs.existsSync(backupDir)) {
|
|
1513
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1517
|
-
const backupFile = path.join(backupDir, `links-backup-${timestamp}.json`);
|
|
1518
|
-
|
|
1519
|
-
// Serialize target links with full metadata
|
|
1520
|
-
const backupData = {
|
|
1521
|
-
timestamp: new Date().toISOString(),
|
|
1522
|
-
link_count: targetLinks.length,
|
|
1523
|
-
links: targetLinks,
|
|
1524
|
-
};
|
|
1525
|
-
|
|
1526
|
-
const backupJson = JSON.stringify(backupData, null, 2);
|
|
1527
|
-
|
|
1528
|
-
// Calculate SHA-256 checksum
|
|
1529
|
-
const checksum = crypto.createHash('sha256').update(backupJson).digest('hex');
|
|
1530
|
-
|
|
1531
|
-
// Save backup file
|
|
1532
|
-
fs.writeFileSync(backupFile, backupJson, 'utf8');
|
|
1533
|
-
|
|
1534
|
-
// Save manifest
|
|
1535
|
-
const manifest = {
|
|
1536
|
-
timestamp: backupData.timestamp,
|
|
1537
|
-
backup_file: backupFile,
|
|
1538
|
-
checksum,
|
|
1539
|
-
link_count: targetLinks.length,
|
|
1540
|
-
};
|
|
1541
|
-
|
|
1542
|
-
const manifestFile = path.join(backupDir, `backup-manifest-${timestamp}.json`);
|
|
1543
|
-
fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2), 'utf8');
|
|
1544
|
-
|
|
1545
|
-
return {
|
|
1546
|
-
backup_file: backupFile,
|
|
1547
|
-
manifest_file: manifestFile,
|
|
1548
|
-
checksum,
|
|
1549
|
-
link_count: targetLinks.length,
|
|
1550
|
-
};
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
/**
|
|
1554
|
-
* Generate pre-cleanup report with risk assessment (Epic 5 - Story 5.1)
|
|
1555
|
-
*
|
|
1556
|
-
* Creates comprehensive report with statistics, risk level, and samples.
|
|
1557
|
-
* Risk assessment based on deletion ratio:
|
|
1558
|
-
* - HIGH: > 50% deletion
|
|
1559
|
-
* - MEDIUM: 30-50% deletion
|
|
1560
|
-
* - LOW: < 30% deletion
|
|
1561
|
-
*
|
|
1562
|
-
* @returns {Object} Report data with markdown output and file path
|
|
1563
|
-
*/
|
|
1564
|
-
function generatePreCleanupReport() {
|
|
1565
|
-
const scanResult = scanAutoLinks();
|
|
1566
|
-
|
|
1567
|
-
// Calculate risk level (guard against division by zero)
|
|
1568
|
-
const deletionRatio =
|
|
1569
|
-
scanResult.total_links > 0 ? scanResult.deletion_targets / scanResult.total_links : 0;
|
|
1570
|
-
let riskLevel;
|
|
1571
|
-
if (deletionRatio > 0.5) {
|
|
1572
|
-
riskLevel = 'HIGH';
|
|
1573
|
-
} else if (deletionRatio > 0.3) {
|
|
1574
|
-
riskLevel = 'MEDIUM';
|
|
1575
|
-
} else {
|
|
1576
|
-
riskLevel = 'LOW';
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
// Sample deletion targets (max 10)
|
|
1580
|
-
const samples = scanResult.deletion_target_list.slice(0, 10).map((link) => ({
|
|
1581
|
-
from_id: link.from_id,
|
|
1582
|
-
to_id: link.to_id,
|
|
1583
|
-
relationship: link.relationship,
|
|
1584
|
-
reason: link.reason,
|
|
1585
|
-
created_by: link.created_by,
|
|
1586
|
-
approved_by_user: link.approved_by_user,
|
|
1587
|
-
}));
|
|
1588
|
-
|
|
1589
|
-
const report = {
|
|
1590
|
-
generated_at: new Date().toISOString(),
|
|
1591
|
-
statistics: {
|
|
1592
|
-
total_links: scanResult.total_links,
|
|
1593
|
-
auto_links: scanResult.auto_links,
|
|
1594
|
-
protected_links: scanResult.protected_links,
|
|
1595
|
-
deletion_targets: scanResult.deletion_targets,
|
|
1596
|
-
deletion_ratio: `${(deletionRatio * 100).toFixed(1)}%`,
|
|
1597
|
-
},
|
|
1598
|
-
risk_assessment: {
|
|
1599
|
-
level: riskLevel,
|
|
1600
|
-
message:
|
|
1601
|
-
riskLevel === 'HIGH'
|
|
1602
|
-
? '⚠️ HIGH RISK: Deletion targets exceed 50%. Create backup before proceeding.'
|
|
1603
|
-
: riskLevel === 'MEDIUM'
|
|
1604
|
-
? '⚡ MEDIUM RISK: Deletion targets 30-50%. Verify backup recommended.'
|
|
1605
|
-
: '✅ LOW RISK: Deletion targets under 30%. Safe to proceed.',
|
|
1606
|
-
},
|
|
1607
|
-
deletion_target_samples: samples,
|
|
1608
|
-
};
|
|
1609
|
-
|
|
1610
|
-
// Generate markdown report
|
|
1611
|
-
const markdown = `# Pre-Cleanup Report
|
|
1612
|
-
|
|
1613
|
-
**Generated:** ${report.generated_at}
|
|
1614
|
-
|
|
1615
|
-
## Statistics
|
|
1616
|
-
|
|
1617
|
-
- **Total Links:** ${report.statistics.total_links}
|
|
1618
|
-
- **Auto Links:** ${report.statistics.auto_links}
|
|
1619
|
-
- **Protected Links:** ${report.statistics.protected_links}
|
|
1620
|
-
- **Deletion Targets:** ${report.statistics.deletion_targets} (${report.statistics.deletion_ratio})
|
|
1621
|
-
|
|
1622
|
-
## Risk Assessment
|
|
1623
|
-
|
|
1624
|
-
**Level:** ${report.risk_assessment.level}
|
|
1625
|
-
|
|
1626
|
-
${report.risk_assessment.message}
|
|
1627
|
-
|
|
1628
|
-
## Sample Deletion Targets (First 10)
|
|
1629
|
-
|
|
1630
|
-
${samples
|
|
1631
|
-
.map(
|
|
1632
|
-
(link, idx) => `
|
|
1633
|
-
### ${idx + 1}. ${link.from_id} → ${link.to_id}
|
|
1634
|
-
|
|
1635
|
-
- **Relationship:** ${link.relationship}
|
|
1636
|
-
- **Reason:** ${link.reason || 'N/A'}
|
|
1637
|
-
- **Created By:** ${link.created_by || 'N/A'}
|
|
1638
|
-
- **Approved:** ${link.approved_by_user ? 'Yes' : 'No'}
|
|
1639
|
-
`
|
|
1640
|
-
)
|
|
1641
|
-
.join('\n')}
|
|
1642
|
-
|
|
1643
|
-
---
|
|
1644
|
-
|
|
1645
|
-
**Next Steps:**
|
|
1646
|
-
|
|
1647
|
-
1. Review the deletion targets above
|
|
1648
|
-
2. Run \`create_link_backup\` to create a backup
|
|
1649
|
-
3. Proceed with cleanup using Story 5.2 tools
|
|
1650
|
-
4. If needed, restore from backup using \`restore_link_backup\`
|
|
1651
|
-
`;
|
|
1652
|
-
|
|
1653
|
-
const fs = require('fs');
|
|
1654
|
-
const path = require('path');
|
|
1655
|
-
const backupDir = path.join(os.homedir(), '.claude', 'mama-backups');
|
|
1656
|
-
|
|
1657
|
-
// Ensure backup directory exists
|
|
1658
|
-
if (!fs.existsSync(backupDir)) {
|
|
1659
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
1660
|
-
}
|
|
1661
|
-
|
|
1662
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1663
|
-
const reportFile = path.join(backupDir, `pre-cleanup-report-${timestamp}.md`);
|
|
1664
|
-
|
|
1665
|
-
fs.writeFileSync(reportFile, markdown, 'utf8');
|
|
1666
|
-
|
|
1667
|
-
return {
|
|
1668
|
-
report: report,
|
|
1669
|
-
report_file: reportFile,
|
|
1670
|
-
markdown,
|
|
1671
|
-
};
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
/**
|
|
1675
|
-
* Restore links from backup file (Epic 5 - Story 5.1)
|
|
1676
|
-
*
|
|
1677
|
-
* Restores previously backed-up links to the database.
|
|
1678
|
-
* Verifies checksum before restoration to ensure data integrity.
|
|
1679
|
-
* Reports number of restored and failed links.
|
|
1680
|
-
*
|
|
1681
|
-
* @param {string} backupFile - Path to backup file
|
|
1682
|
-
* @returns {Object} Restoration result with counts
|
|
1683
|
-
*/
|
|
1684
|
-
function restoreLinkBackup(backupFile) {
|
|
1685
|
-
const fs = require('fs');
|
|
1686
|
-
const crypto = require('crypto');
|
|
1687
|
-
|
|
1688
|
-
// Read backup file
|
|
1689
|
-
const backupJson = fs.readFileSync(backupFile, 'utf8');
|
|
1690
|
-
const backupData = JSON.parse(backupJson);
|
|
1691
|
-
|
|
1692
|
-
// Read manifest for checksum verification
|
|
1693
|
-
const manifestFile = backupFile.replace('links-backup', 'backup-manifest');
|
|
1694
|
-
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
1695
|
-
|
|
1696
|
-
// Verify checksum
|
|
1697
|
-
const calculatedChecksum = crypto.createHash('sha256').update(backupJson).digest('hex');
|
|
1698
|
-
if (calculatedChecksum !== manifest.checksum) {
|
|
1699
|
-
throw new Error('Backup file checksum mismatch. File may be corrupted.');
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
// Restore links to database
|
|
1703
|
-
const adapter = getAdapter();
|
|
1704
|
-
const db = adapter.db;
|
|
1705
|
-
|
|
1706
|
-
let restored = 0;
|
|
1707
|
-
let failed = 0;
|
|
1708
|
-
|
|
1709
|
-
const insertStmt = db.prepare(`
|
|
1710
|
-
INSERT OR REPLACE INTO decision_edges
|
|
1711
|
-
(from_id, to_id, relationship, reason, created_by, approved_by_user, decision_id, evidence, created_at)
|
|
1712
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1713
|
-
`);
|
|
1714
|
-
|
|
1715
|
-
for (const link of backupData.links) {
|
|
1716
|
-
try {
|
|
1717
|
-
insertStmt.run(
|
|
1718
|
-
link.from_id,
|
|
1719
|
-
link.to_id,
|
|
1720
|
-
link.relationship,
|
|
1721
|
-
link.reason,
|
|
1722
|
-
link.created_by,
|
|
1723
|
-
link.approved_by_user,
|
|
1724
|
-
link.decision_id,
|
|
1725
|
-
link.evidence,
|
|
1726
|
-
link.created_at
|
|
1727
|
-
);
|
|
1728
|
-
restored++;
|
|
1729
|
-
} catch (error) {
|
|
1730
|
-
console.error(`Failed to restore link ${link.from_id} -> ${link.to_id}:`, error);
|
|
1731
|
-
failed++;
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
return {
|
|
1736
|
-
total_links: backupData.link_count,
|
|
1737
|
-
restored,
|
|
1738
|
-
failed,
|
|
1739
|
-
backup_file: backupFile,
|
|
1740
|
-
};
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
/**
|
|
1744
|
-
* Verify backup file exists and is recent (Epic 5 - Story 5.2)
|
|
1745
|
-
*
|
|
1746
|
-
* Checks for backup files in backup directory and verifies they are recent enough.
|
|
1747
|
-
* Required as safety check before executing link deletion.
|
|
1748
|
-
*
|
|
1749
|
-
* @param {number} maxAgeHours - Maximum age of backup in hours (default: 24)
|
|
1750
|
-
* @returns {Object} Backup verification result with latest backup info
|
|
1751
|
-
*/
|
|
1752
|
-
function verifyBackupExists(maxAgeHours = 24) {
|
|
1753
|
-
const fs = require('fs');
|
|
1754
|
-
const path = require('path');
|
|
1755
|
-
const backupDir = path.join(os.homedir(), '.claude', 'mama-backups');
|
|
1756
|
-
|
|
1757
|
-
if (!fs.existsSync(backupDir)) {
|
|
1758
|
-
throw new Error(
|
|
1759
|
-
'Backup directory not found. Please create a backup first using create_link_backup.'
|
|
1760
|
-
);
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
const backupFiles = fs
|
|
1764
|
-
.readdirSync(backupDir)
|
|
1765
|
-
.filter((f) => f.startsWith('links-backup-'))
|
|
1766
|
-
.map((f) => ({
|
|
1767
|
-
name: f,
|
|
1768
|
-
path: path.join(backupDir, f),
|
|
1769
|
-
mtime: fs.statSync(path.join(backupDir, f)).mtime,
|
|
1770
|
-
}))
|
|
1771
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
1772
|
-
|
|
1773
|
-
if (backupFiles.length === 0) {
|
|
1774
|
-
throw new Error('No recent backup found. Please run create_link_backup first.');
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
const latestBackup = backupFiles[0];
|
|
1778
|
-
const backupAge = Date.now() - latestBackup.mtime.getTime();
|
|
1779
|
-
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
|
|
1780
|
-
|
|
1781
|
-
if (backupAge > maxAgeMs) {
|
|
1782
|
-
throw new Error(
|
|
1783
|
-
`Most recent backup is too old (${(backupAge / (60 * 60 * 1000)).toFixed(1)} hours). Max age: ${maxAgeHours} hours.`
|
|
1784
|
-
);
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
// Read backup metadata
|
|
1788
|
-
const backupJson = fs.readFileSync(latestBackup.path, 'utf8');
|
|
1789
|
-
const backupData = JSON.parse(backupJson);
|
|
1790
|
-
|
|
1791
|
-
return {
|
|
1792
|
-
backup_file: latestBackup.path,
|
|
1793
|
-
age_hours: backupAge / (60 * 60 * 1000),
|
|
1794
|
-
link_count: backupData.link_count || 0,
|
|
1795
|
-
};
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
|
-
/**
|
|
1799
|
-
* Delete auto-generated links with batch processing (Epic 5 - Story 5.2)
|
|
1800
|
-
*
|
|
1801
|
-
* Executes batch deletion of auto-generated links with transaction support.
|
|
1802
|
-
* Requires recent backup (within 24 hours) before execution.
|
|
1803
|
-
* Logs all deletions to audit trail.
|
|
1804
|
-
*
|
|
1805
|
-
* Safety features:
|
|
1806
|
-
* - Backup verification before deletion
|
|
1807
|
-
* - Batch processing with transaction support
|
|
1808
|
-
* - Dry-run mode for simulation
|
|
1809
|
-
* - Large deletion warning (> 1000 links)
|
|
1810
|
-
*
|
|
1811
|
-
* @param {number} batchSize - Number of links to delete per batch (default: 100)
|
|
1812
|
-
* @param {boolean} dryRun - If true, simulate deletion without actual changes (default: true)
|
|
1813
|
-
* @returns {Object} Deletion result with counts and backup info
|
|
1814
|
-
*/
|
|
1815
|
-
function deleteAutoLinks(batchSize = 100, dryRun = true) {
|
|
1816
|
-
const adapter = getAdapter();
|
|
1817
|
-
const db = adapter.db;
|
|
1818
|
-
|
|
1819
|
-
// Safety check: Verify recent backup exists
|
|
1820
|
-
const backupInfo = verifyBackupExists(24);
|
|
1821
|
-
|
|
1822
|
-
// Scan for deletion targets
|
|
1823
|
-
const scanResult = scanAutoLinks();
|
|
1824
|
-
const deletionTargets = scanResult.deletion_target_list;
|
|
1825
|
-
|
|
1826
|
-
if (deletionTargets.length === 0) {
|
|
1827
|
-
return {
|
|
1828
|
-
dry_run: dryRun,
|
|
1829
|
-
deleted: 0,
|
|
1830
|
-
failed: 0,
|
|
1831
|
-
total_targets: 0,
|
|
1832
|
-
backup_file: backupInfo.backup_file,
|
|
1833
|
-
message: 'No auto-generated links found. Nothing to delete.',
|
|
1834
|
-
};
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
|
-
// Large deletion warning
|
|
1838
|
-
const largeDelection = deletionTargets.length > 1000;
|
|
1839
|
-
if (largeDelection) {
|
|
1840
|
-
console.warn(
|
|
1841
|
-
`⚠️ LARGE DELETION: ${deletionTargets.length} links will be deleted. Consider running in dry-run mode first.`
|
|
1842
|
-
);
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
|
-
if (dryRun) {
|
|
1846
|
-
return {
|
|
1847
|
-
dry_run: true,
|
|
1848
|
-
would_delete: deletionTargets.length,
|
|
1849
|
-
deleted: 0,
|
|
1850
|
-
backup_file: backupInfo.backup_file,
|
|
1851
|
-
large_deletion_warning: largeDelection,
|
|
1852
|
-
warning_message: largeDelection
|
|
1853
|
-
? `Warning: More than 1000 links (${deletionTargets.length}) will be deleted.`
|
|
1854
|
-
: null,
|
|
1855
|
-
sample_links: deletionTargets.slice(0, 5).map((l) => ({
|
|
1856
|
-
from_id: l.from_id,
|
|
1857
|
-
to_id: l.to_id,
|
|
1858
|
-
relationship: l.relationship,
|
|
1859
|
-
})),
|
|
1860
|
-
message: 'Dry-run mode: No links were actually deleted.',
|
|
1861
|
-
};
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
// Execute batch deletion with transaction support
|
|
1865
|
-
let deleted = 0;
|
|
1866
|
-
let failed = 0;
|
|
1867
|
-
let batchesProcessed = 0;
|
|
1868
|
-
const errors = [];
|
|
1869
|
-
|
|
1870
|
-
const deleteStmt = db.prepare(`
|
|
1871
|
-
DELETE FROM decision_edges
|
|
1872
|
-
WHERE from_id = ? AND to_id = ? AND relationship = ?
|
|
1873
|
-
`);
|
|
1874
|
-
|
|
1875
|
-
const auditStmt = db.prepare(`
|
|
1876
|
-
INSERT INTO link_audit_log (from_id, to_id, relationship, action, actor, reason, created_at)
|
|
1877
|
-
VALUES (?, ?, ?, 'deprecated', 'system', ?, ?)
|
|
1878
|
-
`);
|
|
1879
|
-
|
|
1880
|
-
// Process in batches
|
|
1881
|
-
for (let i = 0; i < deletionTargets.length; i += batchSize) {
|
|
1882
|
-
const batch = deletionTargets.slice(i, i + batchSize);
|
|
1883
|
-
|
|
1884
|
-
try {
|
|
1885
|
-
db.transaction(() => {
|
|
1886
|
-
for (const link of batch) {
|
|
1887
|
-
try {
|
|
1888
|
-
deleteStmt.run(link.from_id, link.to_id, link.relationship);
|
|
1889
|
-
auditStmt.run(
|
|
1890
|
-
link.from_id,
|
|
1891
|
-
link.to_id,
|
|
1892
|
-
link.relationship,
|
|
1893
|
-
'Auto-link cleanup - v1.1 migration',
|
|
1894
|
-
Date.now()
|
|
1895
|
-
);
|
|
1896
|
-
deleted++;
|
|
1897
|
-
} catch (error) {
|
|
1898
|
-
failed++;
|
|
1899
|
-
errors.push({
|
|
1900
|
-
link: `${link.from_id}->${link.to_id}`,
|
|
1901
|
-
error: error.message,
|
|
1902
|
-
});
|
|
1903
|
-
}
|
|
1904
|
-
}
|
|
1905
|
-
})();
|
|
1906
|
-
batchesProcessed++;
|
|
1907
|
-
} catch (error) {
|
|
1908
|
-
console.error(`Batch deletion failed at index ${i}:`, error);
|
|
1909
|
-
failed += batch.length;
|
|
1910
|
-
errors.push({
|
|
1911
|
-
batch_index: i,
|
|
1912
|
-
batch_size: batch.length,
|
|
1913
|
-
error: error.message,
|
|
1914
|
-
});
|
|
1915
|
-
break; // Stop on batch failure
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
const successRate = deletionTargets.length > 0 ? (deleted / deletionTargets.length) * 100 : 0;
|
|
1920
|
-
|
|
1921
|
-
return {
|
|
1922
|
-
dry_run: false,
|
|
1923
|
-
deleted,
|
|
1924
|
-
failed,
|
|
1925
|
-
total_targets: deletionTargets.length,
|
|
1926
|
-
backup_file: backupInfo.backup_file,
|
|
1927
|
-
batches_processed: batchesProcessed,
|
|
1928
|
-
errors: errors.slice(0, 10), // Return first 10 errors
|
|
1929
|
-
success_rate: successRate,
|
|
1930
|
-
};
|
|
1931
|
-
}
|
|
1932
|
-
|
|
1933
|
-
/**
|
|
1934
|
-
* Validate cleanup result and generate post-cleanup report (Epic 5 - Story 5.2)
|
|
1935
|
-
*
|
|
1936
|
-
* Re-scans for remaining auto-generated links and evaluates cleanup success.
|
|
1937
|
-
* Generates comprehensive report with statistics and recommendations.
|
|
1938
|
-
*
|
|
1939
|
-
* Success criteria:
|
|
1940
|
-
* - SUCCESS: Remaining auto links < 5%
|
|
1941
|
-
* - PARTIAL: Remaining auto links 5-10%
|
|
1942
|
-
* - FAILED: Remaining auto links > 10%
|
|
1943
|
-
*
|
|
1944
|
-
* @returns {Object} Validation result with report and file path
|
|
1945
|
-
*/
|
|
1946
|
-
function validateCleanupResult() {
|
|
1947
|
-
// Re-scan for remaining auto links
|
|
1948
|
-
const scanResult = scanAutoLinks();
|
|
1949
|
-
|
|
1950
|
-
// Calculate remaining ratio
|
|
1951
|
-
const totalLinks = scanResult.total_links;
|
|
1952
|
-
const remainingAutoLinks = scanResult.auto_links;
|
|
1953
|
-
const remainingRatio = totalLinks > 0 ? remainingAutoLinks / totalLinks : 0;
|
|
1954
|
-
|
|
1955
|
-
// Evaluate cleanup success
|
|
1956
|
-
let status;
|
|
1957
|
-
let message;
|
|
1958
|
-
let recommendation;
|
|
1959
|
-
|
|
1960
|
-
if (remainingRatio < 0.05) {
|
|
1961
|
-
status = 'SUCCESS';
|
|
1962
|
-
message = '✅ SUCCESS: Remaining auto-links under 5%. Target achieved!';
|
|
1963
|
-
recommendation = 'Cleanup completed successfully. You can proceed with migration.';
|
|
1964
|
-
} else if (remainingRatio < 0.1) {
|
|
1965
|
-
status = 'PARTIAL';
|
|
1966
|
-
message = '⚡ PARTIAL: Remaining auto-links 5-10%. Additional cleanup recommended.';
|
|
1967
|
-
recommendation = 'Run execute_link_cleanup again to clean up more auto-links.';
|
|
1968
|
-
} else {
|
|
1969
|
-
status = 'FAILED';
|
|
1970
|
-
message = '⚠️ FAILED: Remaining auto-links exceed 10%. Rollback or re-run needed.';
|
|
1971
|
-
recommendation = 'Significantly missed target. Consider restoring from backup and retry.';
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
// Generate post-cleanup report
|
|
1975
|
-
const report = {
|
|
1976
|
-
validated_at: new Date().toISOString(),
|
|
1977
|
-
status,
|
|
1978
|
-
message,
|
|
1979
|
-
statistics: {
|
|
1980
|
-
total_links: totalLinks,
|
|
1981
|
-
remaining_auto_links: remainingAutoLinks,
|
|
1982
|
-
remaining_ratio: `${(remainingRatio * 100).toFixed(1)}%`,
|
|
1983
|
-
protected_links: scanResult.protected_links,
|
|
1984
|
-
deletion_targets: scanResult.deletion_targets,
|
|
1985
|
-
},
|
|
1986
|
-
recommendation,
|
|
1987
|
-
};
|
|
1988
|
-
|
|
1989
|
-
// Generate markdown report
|
|
1990
|
-
const markdown = generatePostCleanupReportMarkdown(report);
|
|
1991
|
-
|
|
1992
|
-
// Save report to file
|
|
1993
|
-
const fs = require('fs');
|
|
1994
|
-
const path = require('path');
|
|
1995
|
-
const backupDir = path.join(os.homedir(), '.claude', 'mama-backups');
|
|
1996
|
-
|
|
1997
|
-
if (!fs.existsSync(backupDir)) {
|
|
1998
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
1999
|
-
}
|
|
2000
|
-
|
|
2001
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
2002
|
-
const reportFile = path.join(backupDir, `post-cleanup-report-${timestamp}.md`);
|
|
2003
|
-
|
|
2004
|
-
fs.writeFileSync(reportFile, markdown, 'utf8');
|
|
2005
|
-
|
|
2006
|
-
return {
|
|
2007
|
-
status,
|
|
2008
|
-
total_links_before: totalLinks,
|
|
2009
|
-
auto_links_remaining: remainingAutoLinks,
|
|
2010
|
-
remaining_ratio: remainingRatio * 100,
|
|
2011
|
-
protected_links: scanResult.protected_links,
|
|
2012
|
-
report,
|
|
2013
|
-
report_file: reportFile,
|
|
2014
|
-
markdown,
|
|
2015
|
-
};
|
|
2016
|
-
}
|
|
2017
|
-
|
|
2018
|
-
/**
|
|
2019
|
-
* Generate post-cleanup report in Markdown format (Epic 5 - Story 5.2)
|
|
2020
|
-
*
|
|
2021
|
-
* @param {Object} report - Validation report data
|
|
2022
|
-
* @returns {string} Markdown formatted report
|
|
2023
|
-
*/
|
|
2024
|
-
function generatePostCleanupReportMarkdown(report) {
|
|
2025
|
-
let markdown = `# Post-Cleanup Validation Report
|
|
2026
|
-
|
|
2027
|
-
**Generated:** ${report.validated_at}
|
|
2028
|
-
**Status:** ${report.status}
|
|
2029
|
-
|
|
2030
|
-
${report.message}
|
|
2031
|
-
|
|
2032
|
-
## Statistics
|
|
2033
|
-
|
|
2034
|
-
- **Total Links:** ${report.statistics.total_links}
|
|
2035
|
-
- **Remaining Auto Links:** ${report.statistics.remaining_auto_links}
|
|
2036
|
-
- **Remaining Ratio:** ${report.statistics.remaining_ratio}
|
|
2037
|
-
- **Protected Links:** ${report.statistics.protected_links}
|
|
2038
|
-
- **Deletion Targets (if any):** ${report.statistics.deletion_targets}
|
|
2039
|
-
|
|
2040
|
-
## Recommendation
|
|
2041
|
-
|
|
2042
|
-
${report.recommendation}
|
|
2043
|
-
`;
|
|
2044
|
-
|
|
2045
|
-
// Add rollback instructions for non-SUCCESS statuses
|
|
2046
|
-
if (report.status !== 'SUCCESS') {
|
|
2047
|
-
markdown += `
|
|
2048
|
-
|
|
2049
|
-
---
|
|
2050
|
-
|
|
2051
|
-
## Rollback Instructions
|
|
2052
|
-
|
|
2053
|
-
If you need to restore the deleted links:
|
|
2054
|
-
|
|
2055
|
-
1. Find the latest backup file in \`~/.claude/mama-backups/\`
|
|
2056
|
-
2. Run: \`restore_link_backup <backup_file_path>\`
|
|
2057
|
-
3. Re-run validation: \`validate_cleanup_result\`
|
|
2058
|
-
|
|
2059
|
-
**Next Steps:**
|
|
2060
|
-
|
|
2061
|
-
- For PARTIAL status: Review remaining auto links and run cleanup again if needed
|
|
2062
|
-
- For FAILED status: Consider rollback and investigate why many links remain
|
|
2063
|
-
`;
|
|
2064
|
-
}
|
|
2065
|
-
|
|
2066
|
-
return markdown;
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
/**
|
|
2070
|
-
* Calculate coverage metrics (Epic 4 - Story 4.1)
|
|
2071
|
-
*
|
|
2072
|
-
* Measures narrative coverage (% of decisions with narrative fields)
|
|
2073
|
-
* and link coverage (% of decisions with at least one link).
|
|
2074
|
-
*
|
|
2075
|
-
* @returns {Object} Coverage metrics
|
|
2076
|
-
*/
|
|
2077
|
-
function calculateCoverage() {
|
|
2078
|
-
const adapter = getAdapter();
|
|
2079
|
-
const db = adapter.db;
|
|
2080
|
-
|
|
2081
|
-
// Total decisions
|
|
2082
|
-
const totalDecisions = db.prepare(`SELECT COUNT(*) as count FROM decisions`).get().count;
|
|
2083
|
-
|
|
2084
|
-
if (totalDecisions === 0) {
|
|
2085
|
-
return {
|
|
2086
|
-
narrativeCoverage: '0.0%',
|
|
2087
|
-
linkCoverage: '0.0%',
|
|
2088
|
-
totalDecisions: 0,
|
|
2089
|
-
completeNarratives: 0,
|
|
2090
|
-
decisionsWithLinks: 0,
|
|
2091
|
-
};
|
|
2092
|
-
}
|
|
2093
|
-
|
|
2094
|
-
// Narrative coverage: Decisions with evidence, alternatives, and risks filled
|
|
2095
|
-
// Note: Using existing schema fields (evidence, alternatives, risks) instead of
|
|
2096
|
-
// 5-layer fields (specificity, evidence, reasoning, tension, continuity) mentioned in story
|
|
2097
|
-
const completeNarratives = db
|
|
2098
|
-
.prepare(
|
|
2099
|
-
`
|
|
2100
|
-
SELECT COUNT(*) as count FROM decisions
|
|
2101
|
-
WHERE evidence IS NOT NULL AND evidence != ''
|
|
2102
|
-
AND alternatives IS NOT NULL AND alternatives != ''
|
|
2103
|
-
AND risks IS NOT NULL AND risks != ''
|
|
2104
|
-
`
|
|
2105
|
-
)
|
|
2106
|
-
.get().count;
|
|
2107
|
-
|
|
2108
|
-
const narrativeCoverage = (completeNarratives / totalDecisions) * 100;
|
|
2109
|
-
|
|
2110
|
-
// Link coverage: Decisions with at least one link
|
|
2111
|
-
const decisionsWithLinks = db
|
|
2112
|
-
.prepare(
|
|
2113
|
-
`
|
|
2114
|
-
SELECT COUNT(DISTINCT d.id) as count FROM decisions d
|
|
2115
|
-
WHERE EXISTS (
|
|
2116
|
-
SELECT 1 FROM decision_edges e
|
|
2117
|
-
WHERE e.from_id = d.id OR e.to_id = d.id
|
|
2118
|
-
)
|
|
2119
|
-
`
|
|
2120
|
-
)
|
|
2121
|
-
.get().count;
|
|
2122
|
-
|
|
2123
|
-
const linkCoverage = (decisionsWithLinks / totalDecisions) * 100;
|
|
2124
|
-
|
|
2125
|
-
return {
|
|
2126
|
-
narrativeCoverage: `${narrativeCoverage.toFixed(1)}%`,
|
|
2127
|
-
linkCoverage: `${linkCoverage.toFixed(1)}%`,
|
|
2128
|
-
totalDecisions,
|
|
2129
|
-
completeNarratives,
|
|
2130
|
-
decisionsWithLinks,
|
|
2131
|
-
};
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
/**
|
|
2135
|
-
* Log restart attempt (Epic 4 - Story 4.2)
|
|
2136
|
-
*
|
|
2137
|
-
* Records restart attempt with success/failure status, latency, and mode.
|
|
2138
|
-
* Replaces in-memory restart-metrics.js with SQLite-backed storage.
|
|
2139
|
-
*
|
|
2140
|
-
* @param {string} sessionId - Session identifier
|
|
2141
|
-
* @param {string} status - 'success' or 'failure'
|
|
2142
|
-
* @param {string|null} failureReason - 'NO_CHECKPOINT', 'LOAD_ERROR', 'CONTEXT_INCOMPLETE', or null
|
|
2143
|
-
* @param {number} latencyMs - Latency in milliseconds
|
|
2144
|
-
* @param {string} mode - 'full' (narrative+links) or 'summary' (summary only)
|
|
2145
|
-
* @returns {void}
|
|
2146
|
-
*/
|
|
2147
|
-
function logRestartAttempt(sessionId, status, failureReason, latencyMs, mode = 'full') {
|
|
2148
|
-
const adapter = getAdapter();
|
|
2149
|
-
const db = adapter.db;
|
|
2150
|
-
|
|
2151
|
-
const timestamp = new Date().toISOString();
|
|
2152
|
-
|
|
2153
|
-
db.prepare(
|
|
2154
|
-
`
|
|
2155
|
-
INSERT INTO restart_metrics (timestamp, session_id, status, failure_reason, latency_ms, mode)
|
|
2156
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
2157
|
-
`
|
|
2158
|
-
).run(timestamp, sessionId, status, failureReason, latencyMs, mode);
|
|
2159
|
-
|
|
2160
|
-
// Performance warning if exceeds threshold
|
|
2161
|
-
const threshold = mode === 'summary' ? 1000 : 2500;
|
|
2162
|
-
if (latencyMs > threshold) {
|
|
2163
|
-
console.warn(
|
|
2164
|
-
JSON.stringify({
|
|
2165
|
-
performance_warning: true,
|
|
2166
|
-
message: `Restart latency exceeded threshold: ${latencyMs}ms > ${threshold}ms`,
|
|
2167
|
-
session_id: sessionId,
|
|
2168
|
-
mode,
|
|
2169
|
-
latency_ms: latencyMs,
|
|
2170
|
-
threshold_ms: threshold,
|
|
2171
|
-
})
|
|
2172
|
-
);
|
|
2173
|
-
}
|
|
2174
|
-
}
|
|
2175
|
-
|
|
2176
|
-
/**
|
|
2177
|
-
* Calculate restart success rate (Epic 4 - Story 4.2)
|
|
2178
|
-
*
|
|
2179
|
-
* Calculates success rate over a given period (24h, 7d, 30d).
|
|
2180
|
-
*
|
|
2181
|
-
* @param {string} period - '24h', '7d', or '30d'
|
|
2182
|
-
* @returns {Object} Success rate metrics
|
|
2183
|
-
*/
|
|
2184
|
-
function calculateRestartSuccessRate(period = '7d') {
|
|
2185
|
-
const adapter = getAdapter();
|
|
2186
|
-
const db = adapter.db;
|
|
2187
|
-
|
|
2188
|
-
const periodMap = {
|
|
2189
|
-
'24h': 1,
|
|
2190
|
-
'7d': 7,
|
|
2191
|
-
'30d': 30,
|
|
2192
|
-
};
|
|
2193
|
-
|
|
2194
|
-
const days = periodMap[period] || 7;
|
|
2195
|
-
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
2196
|
-
|
|
2197
|
-
const stats = db
|
|
2198
|
-
.prepare(
|
|
2199
|
-
`
|
|
2200
|
-
SELECT
|
|
2201
|
-
COUNT(*) as total,
|
|
2202
|
-
COUNT(CASE WHEN status = 'success' THEN 1 END) as success,
|
|
2203
|
-
COUNT(CASE WHEN status = 'failure' THEN 1 END) as failure
|
|
2204
|
-
FROM restart_metrics
|
|
2205
|
-
WHERE timestamp >= ?
|
|
2206
|
-
`
|
|
2207
|
-
)
|
|
2208
|
-
.get(since);
|
|
2209
|
-
|
|
2210
|
-
const successRate = stats.total > 0 ? stats.success / stats.total : 0;
|
|
2211
|
-
|
|
2212
|
-
return {
|
|
2213
|
-
period,
|
|
2214
|
-
total: stats.total,
|
|
2215
|
-
success: stats.success,
|
|
2216
|
-
failure: stats.failure,
|
|
2217
|
-
successRate: `${(successRate * 100).toFixed(1)}%`,
|
|
2218
|
-
meetsTarget: successRate >= 0.95,
|
|
2219
|
-
};
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
/**
|
|
2223
|
-
* Calculate restart latency percentiles (Epic 4 - Story 4.2)
|
|
2224
|
-
*
|
|
2225
|
-
* Calculates p50, p95, p99 latencies for successful restarts.
|
|
2226
|
-
* Optionally filters by mode (full/summary).
|
|
2227
|
-
*
|
|
2228
|
-
* @param {string} period - '24h', '7d', or '30d'
|
|
2229
|
-
* @param {string|null} mode - 'full', 'summary', or null (all modes)
|
|
2230
|
-
* @returns {Object} Latency percentile metrics
|
|
2231
|
-
*/
|
|
2232
|
-
function calculateRestartLatency(period = '7d', mode = null) {
|
|
2233
|
-
const adapter = getAdapter();
|
|
2234
|
-
const db = adapter.db;
|
|
2235
|
-
|
|
2236
|
-
const periodMap = { '24h': 1, '7d': 7, '30d': 30 };
|
|
2237
|
-
const days = periodMap[period] || 7;
|
|
2238
|
-
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
2239
|
-
|
|
2240
|
-
let query = `
|
|
2241
|
-
SELECT latency_ms
|
|
2242
|
-
FROM restart_metrics
|
|
2243
|
-
WHERE timestamp >= ? AND status = 'success'
|
|
2244
|
-
`;
|
|
2245
|
-
|
|
2246
|
-
const params = [since];
|
|
2247
|
-
|
|
2248
|
-
if (mode) {
|
|
2249
|
-
query += ` AND mode = ?`;
|
|
2250
|
-
params.push(mode);
|
|
2251
|
-
}
|
|
2252
|
-
|
|
2253
|
-
query += ` ORDER BY latency_ms ASC`;
|
|
2254
|
-
|
|
2255
|
-
const rows = db.prepare(query).all(...params);
|
|
2256
|
-
const latencies = rows.map((r) => r.latency_ms);
|
|
2257
|
-
|
|
2258
|
-
if (latencies.length === 0) {
|
|
2259
|
-
return { p50: 0, p95: 0, p99: 0, count: 0, mode: mode || 'all' };
|
|
2260
|
-
}
|
|
2261
|
-
|
|
2262
|
-
const percentile = (arr, p) => {
|
|
2263
|
-
const index = Math.ceil((p / 100) * arr.length) - 1;
|
|
2264
|
-
return arr[Math.max(0, index)];
|
|
2265
|
-
};
|
|
2266
|
-
|
|
2267
|
-
return {
|
|
2268
|
-
p50: percentile(latencies, 50),
|
|
2269
|
-
p95: percentile(latencies, 95),
|
|
2270
|
-
p99: percentile(latencies, 99),
|
|
2271
|
-
count: latencies.length,
|
|
2272
|
-
mode: mode || 'all',
|
|
2273
|
-
};
|
|
2274
|
-
}
|
|
2275
|
-
|
|
2276
|
-
/**
|
|
2277
|
-
* Get restart metrics (Epic 4 - Story 4.2)
|
|
2278
|
-
*
|
|
2279
|
-
* Combines success rate and latency metrics for a given period.
|
|
2280
|
-
*
|
|
2281
|
-
* @param {string} period - '24h', '7d', or '30d'
|
|
2282
|
-
* @param {boolean} includeLatency - Whether to include latency percentiles
|
|
2283
|
-
* @returns {Object} Combined restart metrics
|
|
2284
|
-
*/
|
|
2285
|
-
function getRestartMetrics(period = '7d', includeLatency = true) {
|
|
2286
|
-
const successRate = calculateRestartSuccessRate(period);
|
|
2287
|
-
const result = { successRate };
|
|
2288
|
-
|
|
2289
|
-
if (includeLatency) {
|
|
2290
|
-
result.latency = {
|
|
2291
|
-
full: calculateRestartLatency(period, 'full'),
|
|
2292
|
-
summary: calculateRestartLatency(period, 'summary'),
|
|
2293
|
-
};
|
|
2294
|
-
}
|
|
2295
|
-
|
|
2296
|
-
return result;
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
/**
|
|
2300
|
-
* Calculate quality metrics (Epic 4 - Story 4.1)
|
|
2301
|
-
*
|
|
2302
|
-
* Measures narrative quality (field completeness per layer)
|
|
2303
|
-
* and link quality (rich reason ratio, approved link ratio).
|
|
2304
|
-
*
|
|
2305
|
-
* @returns {Object} Quality metrics
|
|
2306
|
-
*/
|
|
2307
|
-
function calculateQuality() {
|
|
2308
|
-
const adapter = getAdapter();
|
|
2309
|
-
const db = adapter.db;
|
|
2310
|
-
|
|
2311
|
-
// Narrative quality: Average completeness for each narrative field
|
|
2312
|
-
const narrativeQuality = db
|
|
2313
|
-
.prepare(
|
|
2314
|
-
`
|
|
2315
|
-
SELECT
|
|
2316
|
-
AVG(CASE WHEN evidence IS NOT NULL AND evidence != '' THEN 1 ELSE 0 END) as evidence,
|
|
2317
|
-
AVG(CASE WHEN alternatives IS NOT NULL AND alternatives != '' THEN 1 ELSE 0 END) as alternatives,
|
|
2318
|
-
AVG(CASE WHEN risks IS NOT NULL AND risks != '' THEN 1 ELSE 0 END) as risks
|
|
2319
|
-
FROM decisions
|
|
2320
|
-
`
|
|
2321
|
-
)
|
|
2322
|
-
.get();
|
|
2323
|
-
|
|
2324
|
-
// Link quality: "Rich" reason ratio (reason > 50 chars) and approved link ratio
|
|
2325
|
-
const linkStats = db
|
|
2326
|
-
.prepare(
|
|
2327
|
-
`
|
|
2328
|
-
SELECT
|
|
2329
|
-
COUNT(*) as total,
|
|
2330
|
-
COUNT(CASE WHEN reason IS NOT NULL AND LENGTH(reason) > 50 THEN 1 END) as rich,
|
|
2331
|
-
COUNT(CASE WHEN approved_by_user = 1 THEN 1 END) as approved
|
|
2332
|
-
FROM decision_edges
|
|
2333
|
-
`
|
|
2334
|
-
)
|
|
2335
|
-
.get();
|
|
2336
|
-
|
|
2337
|
-
const linkQuality =
|
|
2338
|
-
linkStats.total > 0 ? ((linkStats.rich / linkStats.total) * 100).toFixed(1) : '0.0';
|
|
2339
|
-
const approvedRatio =
|
|
2340
|
-
linkStats.total > 0 ? ((linkStats.approved / linkStats.total) * 100).toFixed(1) : '0.0';
|
|
2341
|
-
|
|
2342
|
-
return {
|
|
2343
|
-
narrativeQuality: {
|
|
2344
|
-
evidence: `${(narrativeQuality.evidence * 100).toFixed(1)}%`,
|
|
2345
|
-
alternatives: `${(narrativeQuality.alternatives * 100).toFixed(1)}%`,
|
|
2346
|
-
risks: `${(narrativeQuality.risks * 100).toFixed(1)}%`,
|
|
2347
|
-
},
|
|
2348
|
-
linkQuality: {
|
|
2349
|
-
richReasonRatio: `${linkQuality}%`,
|
|
2350
|
-
approvedRatio: `${approvedRatio}%`,
|
|
2351
|
-
totalLinks: linkStats.total,
|
|
2352
|
-
richLinks: linkStats.rich,
|
|
2353
|
-
approvedLinks: linkStats.approved,
|
|
2354
|
-
},
|
|
2355
|
-
};
|
|
2356
|
-
}
|
|
2357
|
-
|
|
2358
|
-
/**
|
|
2359
|
-
* Generate quality report with recommendations (Epic 4 - Story 4.1 + 4.2)
|
|
2360
|
-
*
|
|
2361
|
-
* Generates a comprehensive quality report with coverage, quality metrics,
|
|
2362
|
-
* restart metrics, and recommendations for improvement.
|
|
2363
|
-
*
|
|
2364
|
-
* @param {Object} options - Report options
|
|
2365
|
-
* @param {string} [options.format='json'] - Output format: 'json' or 'markdown'
|
|
2366
|
-
* @param {string} [options.period='7d'] - Period for restart metrics: '24h', '7d', or '30d'
|
|
2367
|
-
* @param {Object} [options.thresholds] - Custom thresholds
|
|
2368
|
-
* @param {number} [options.thresholds.narrativeCoverage=0.8] - Narrative coverage threshold (0-1)
|
|
2369
|
-
* @param {number} [options.thresholds.linkCoverage=0.7] - Link coverage threshold (0-1)
|
|
2370
|
-
* @param {number} [options.thresholds.richReasonRatio=0.7] - Rich reason ratio threshold (0-1)
|
|
2371
|
-
* @param {number} [options.thresholds.restartSuccessRate=0.95] - Restart success rate threshold (0-1)
|
|
2372
|
-
* @param {number} [options.thresholds.restartLatencyP95Full=2500] - Full mode p95 latency threshold (ms)
|
|
2373
|
-
* @param {number} [options.thresholds.restartLatencyP95Summary=1000] - Summary mode p95 latency threshold (ms)
|
|
2374
|
-
* @returns {Object|string} Quality report as JSON or Markdown
|
|
2375
|
-
*/
|
|
2376
|
-
function generateQualityReport(options = {}) {
|
|
2377
|
-
const { format = 'json', period = '7d', thresholds = {} } = options;
|
|
2378
|
-
|
|
2379
|
-
const defaultThresholds = {
|
|
2380
|
-
narrativeCoverage: 0.8,
|
|
2381
|
-
linkCoverage: 0.7,
|
|
2382
|
-
richReasonRatio: 0.7,
|
|
2383
|
-
restartSuccessRate: 0.95,
|
|
2384
|
-
restartLatencyP95Full: 2500,
|
|
2385
|
-
restartLatencyP95Summary: 1000,
|
|
2386
|
-
...thresholds,
|
|
2387
|
-
};
|
|
2388
|
-
|
|
2389
|
-
const coverage = calculateCoverage();
|
|
2390
|
-
const quality = calculateQuality();
|
|
2391
|
-
|
|
2392
|
-
// Story 4.2: Add restart metrics
|
|
2393
|
-
const restartMetrics = getRestartMetrics(period, true);
|
|
2394
|
-
|
|
2395
|
-
const recommendations = [];
|
|
2396
|
-
|
|
2397
|
-
// Check narrative coverage threshold
|
|
2398
|
-
const narrativeCoveragePct = parseFloat(coverage.narrativeCoverage);
|
|
2399
|
-
if (narrativeCoveragePct < defaultThresholds.narrativeCoverage * 100) {
|
|
2400
|
-
recommendations.push({
|
|
2401
|
-
type: 'narrative_coverage',
|
|
2402
|
-
message: `Narrative coverage below target (${defaultThresholds.narrativeCoverage * 100}%). Add narrative to decisions missing evidence, alternatives, or risks fields.`,
|
|
2403
|
-
target: `${defaultThresholds.narrativeCoverage * 100}%`,
|
|
2404
|
-
current: coverage.narrativeCoverage,
|
|
2405
|
-
});
|
|
2406
|
-
}
|
|
2407
|
-
|
|
2408
|
-
// Check link coverage threshold
|
|
2409
|
-
const linkCoveragePct = parseFloat(coverage.linkCoverage);
|
|
2410
|
-
if (linkCoveragePct < defaultThresholds.linkCoverage * 100) {
|
|
2411
|
-
recommendations.push({
|
|
2412
|
-
type: 'link_coverage',
|
|
2413
|
-
message: `Link coverage below target (${defaultThresholds.linkCoverage * 100}%). Add links between related decisions.`,
|
|
2414
|
-
target: `${defaultThresholds.linkCoverage * 100}%`,
|
|
2415
|
-
current: coverage.linkCoverage,
|
|
2416
|
-
});
|
|
2417
|
-
}
|
|
2418
|
-
|
|
2419
|
-
// Check link quality threshold
|
|
2420
|
-
const richReasonRatioPct = parseFloat(quality.linkQuality.richReasonRatio);
|
|
2421
|
-
if (richReasonRatioPct < defaultThresholds.richReasonRatio * 100) {
|
|
2422
|
-
recommendations.push({
|
|
2423
|
-
type: 'link_quality',
|
|
2424
|
-
message: `Link quality below target (${defaultThresholds.richReasonRatio * 100}%). Add specific causality and evidence to link reason fields.`,
|
|
2425
|
-
target: `${defaultThresholds.richReasonRatio * 100}%`,
|
|
2426
|
-
current: quality.linkQuality.richReasonRatio,
|
|
2427
|
-
});
|
|
2428
|
-
}
|
|
2429
|
-
|
|
2430
|
-
// Story 4.2: Check restart success rate threshold (only if there's data)
|
|
2431
|
-
if (restartMetrics.successRate.total > 0 && !restartMetrics.successRate.meetsTarget) {
|
|
2432
|
-
// eslint-disable-next-line no-unused-vars
|
|
2433
|
-
const successRatePct = parseFloat(restartMetrics.successRate.successRate);
|
|
2434
|
-
recommendations.push({
|
|
2435
|
-
type: 'restart_success_rate',
|
|
2436
|
-
message: `Restart success rate below target (95%). Analyze failure reasons and improve checkpoint quality.`,
|
|
2437
|
-
target: '95%',
|
|
2438
|
-
current: restartMetrics.successRate.successRate,
|
|
2439
|
-
});
|
|
2440
|
-
}
|
|
2441
|
-
|
|
2442
|
-
// Story 4.2: Check restart latency thresholds (only if there's data)
|
|
2443
|
-
if (restartMetrics.latency) {
|
|
2444
|
-
const fullP95 = restartMetrics.latency.full.p95;
|
|
2445
|
-
if (
|
|
2446
|
-
restartMetrics.latency.full.count > 0 &&
|
|
2447
|
-
fullP95 > defaultThresholds.restartLatencyP95Full
|
|
2448
|
-
) {
|
|
2449
|
-
recommendations.push({
|
|
2450
|
-
type: 'restart_latency_full',
|
|
2451
|
-
message: `Narrative+link expansion p95 latency exceeds target (2.5s). Consider limiting link expansion depth or adding caching.`,
|
|
2452
|
-
target: `${defaultThresholds.restartLatencyP95Full}ms`,
|
|
2453
|
-
current: `${fullP95}ms`,
|
|
2454
|
-
});
|
|
2455
|
-
}
|
|
2456
|
-
|
|
2457
|
-
const summaryP95 = restartMetrics.latency.summary.p95;
|
|
2458
|
-
if (
|
|
2459
|
-
restartMetrics.latency.summary.count > 0 &&
|
|
2460
|
-
summaryP95 > defaultThresholds.restartLatencyP95Summary
|
|
2461
|
-
) {
|
|
2462
|
-
recommendations.push({
|
|
2463
|
-
type: 'restart_latency_summary',
|
|
2464
|
-
message: `Summary mode p95 latency exceeds target (1.0s). Review query optimization or add indexes.`,
|
|
2465
|
-
target: `${defaultThresholds.restartLatencyP95Summary}ms`,
|
|
2466
|
-
current: `${summaryP95}ms`,
|
|
2467
|
-
});
|
|
2468
|
-
}
|
|
2469
|
-
}
|
|
2470
|
-
|
|
2471
|
-
const report = {
|
|
2472
|
-
generated_at: new Date().toISOString(),
|
|
2473
|
-
period,
|
|
2474
|
-
coverage,
|
|
2475
|
-
quality,
|
|
2476
|
-
restart: restartMetrics,
|
|
2477
|
-
thresholds: defaultThresholds,
|
|
2478
|
-
recommendations,
|
|
2479
|
-
format,
|
|
2480
|
-
};
|
|
2481
|
-
|
|
2482
|
-
if (format === 'markdown') {
|
|
2483
|
-
return formatQualityReportMarkdown(report);
|
|
2484
|
-
}
|
|
2485
|
-
|
|
2486
|
-
return report;
|
|
2487
|
-
}
|
|
2488
|
-
|
|
2489
|
-
/**
|
|
2490
|
-
* Format quality report as Markdown
|
|
2491
|
-
*
|
|
2492
|
-
* @param {Object} report - Quality report data
|
|
2493
|
-
* @returns {string} Markdown-formatted report
|
|
2494
|
-
*/
|
|
2495
|
-
function formatQualityReportMarkdown(report) {
|
|
2496
|
-
const { generated_at, period, coverage, quality, restart, thresholds, recommendations } = report;
|
|
2497
|
-
|
|
2498
|
-
let markdown = `# 📊 MAMA Quality Report\n\n`;
|
|
2499
|
-
markdown += `Generated: ${generated_at}\n`;
|
|
2500
|
-
markdown += `Period: ${period}\n\n`;
|
|
2501
|
-
|
|
2502
|
-
markdown += `## Coverage Metrics\n\n`;
|
|
2503
|
-
markdown += `- **Narrative Coverage**: ${coverage.narrativeCoverage} (${coverage.completeNarratives}/${coverage.totalDecisions} decisions)\n`;
|
|
2504
|
-
markdown += `- **Link Coverage**: ${coverage.linkCoverage} (${coverage.decisionsWithLinks}/${coverage.totalDecisions} decisions)\n\n`;
|
|
2505
|
-
|
|
2506
|
-
markdown += `## Quality Metrics\n\n`;
|
|
2507
|
-
markdown += `### Narrative Quality\n`;
|
|
2508
|
-
markdown += `- Evidence: ${quality.narrativeQuality.evidence}\n`;
|
|
2509
|
-
markdown += `- Alternatives: ${quality.narrativeQuality.alternatives}\n`;
|
|
2510
|
-
markdown += `- Risks: ${quality.narrativeQuality.risks}\n\n`;
|
|
2511
|
-
|
|
2512
|
-
markdown += `### Link Quality\n`;
|
|
2513
|
-
markdown += `- Rich Reason Ratio: ${quality.linkQuality.richReasonRatio} (${quality.linkQuality.richLinks}/${quality.linkQuality.totalLinks} links)\n`;
|
|
2514
|
-
markdown += `- Approved Link Ratio: ${quality.linkQuality.approvedRatio} (${quality.linkQuality.approvedLinks}/${quality.linkQuality.totalLinks} links)\n\n`;
|
|
2515
|
-
|
|
2516
|
-
// Story 4.2: Add restart metrics section
|
|
2517
|
-
if (restart) {
|
|
2518
|
-
markdown += `## Restart Metrics\n\n`;
|
|
2519
|
-
markdown += `### Success Rate\n`;
|
|
2520
|
-
markdown += `- **Success Rate**: ${restart.successRate.successRate} (${restart.successRate.success}/${restart.successRate.total} attempts)\n`;
|
|
2521
|
-
markdown += `- **Meets Target**: ${restart.successRate.meetsTarget ? '✅ Yes' : '❌ No'}\n`;
|
|
2522
|
-
markdown += `- Failures: ${restart.successRate.failure}\n\n`;
|
|
2523
|
-
|
|
2524
|
-
if (restart.latency) {
|
|
2525
|
-
markdown += `### Latency (Percentiles)\n\n`;
|
|
2526
|
-
markdown += `**Full Mode (Narrative + Links)**\n`;
|
|
2527
|
-
markdown += `- p50: ${restart.latency.full.p50}ms\n`;
|
|
2528
|
-
markdown += `- p95: ${restart.latency.full.p95}ms\n`;
|
|
2529
|
-
markdown += `- p99: ${restart.latency.full.p99}ms\n`;
|
|
2530
|
-
markdown += `- Count: ${restart.latency.full.count}\n\n`;
|
|
2531
|
-
|
|
2532
|
-
markdown += `**Summary Mode**\n`;
|
|
2533
|
-
markdown += `- p50: ${restart.latency.summary.p50}ms\n`;
|
|
2534
|
-
markdown += `- p95: ${restart.latency.summary.p95}ms\n`;
|
|
2535
|
-
markdown += `- p99: ${restart.latency.summary.p99}ms\n`;
|
|
2536
|
-
markdown += `- Count: ${restart.latency.summary.count}\n\n`;
|
|
2537
|
-
}
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
markdown += `## Thresholds\n\n`;
|
|
2541
|
-
markdown += `- Narrative Coverage: ≥ ${thresholds.narrativeCoverage * 100}%\n`;
|
|
2542
|
-
markdown += `- Link Coverage: ≥ ${thresholds.linkCoverage * 100}%\n`;
|
|
2543
|
-
markdown += `- Rich Reason Ratio: ≥ ${thresholds.richReasonRatio * 100}%\n`;
|
|
2544
|
-
markdown += `- Restart Success Rate: ≥ ${thresholds.restartSuccessRate * 100}%\n`;
|
|
2545
|
-
markdown += `- Restart Latency p95 (Full): ≤ ${thresholds.restartLatencyP95Full}ms\n`;
|
|
2546
|
-
markdown += `- Restart Latency p95 (Summary): ≤ ${thresholds.restartLatencyP95Summary}ms\n\n`;
|
|
2547
|
-
|
|
2548
|
-
if (recommendations.length > 0) {
|
|
2549
|
-
markdown += `## ⚠️ Recommendations\n\n`;
|
|
2550
|
-
recommendations.forEach((rec, idx) => {
|
|
2551
|
-
markdown += `${idx + 1}. **${rec.type}**: ${rec.message}\n`;
|
|
2552
|
-
markdown += ` - Target: ${rec.target}, Current: ${rec.current}\n\n`;
|
|
2553
|
-
});
|
|
2554
|
-
} else {
|
|
2555
|
-
markdown += `## ✅ All quality targets met!\n\n`;
|
|
2556
|
-
}
|
|
2557
|
-
|
|
2558
|
-
return markdown;
|
|
2559
|
-
}
|
|
2560
|
-
|
|
2561
|
-
/**
|
|
2562
|
-
* MAMA Public API
|
|
2563
|
-
*
|
|
2564
|
-
* Simple, clean interface for Claude to interact with MAMA
|
|
2565
|
-
* Hides complex implementation details (embeddings, vector search, graph queries)
|
|
2566
|
-
*
|
|
2567
|
-
* Key Principles:
|
|
2568
|
-
* 1. Simple API First - No complex configuration
|
|
2569
|
-
* 2. Transparent Process - Each step is visible
|
|
2570
|
-
* 3. Claude-First Design - Claude decides what to save
|
|
2571
|
-
* 4. Non-Intrusive - Silent failures for helpers (suggest)
|
|
2572
|
-
*/
|
|
2573
|
-
// ════════════════════════════════════════════════════════════════════════════
|
|
2574
|
-
// MAMA API - Simplified to 4 MCP tools (2025-11-25)
|
|
2575
|
-
//
|
|
2576
|
-
// Design: LLM can infer decision evolution from time-ordered search results
|
|
2577
|
-
// More tools = more constraints = less LLM flexibility
|
|
2578
|
-
//
|
|
2579
|
-
// Retained internal functions for future use, but MCP exposes only:
|
|
2580
|
-
// save, search, update, load_checkpoint
|
|
2581
|
-
// ════════════════════════════════════════════════════════════════════════════
|
|
2582
|
-
const mama = {
|
|
2583
|
-
// Core functions (used by 4 MCP tools)
|
|
2584
|
-
save,
|
|
2585
|
-
suggest,
|
|
2586
|
-
list: listDecisions,
|
|
2587
|
-
listCheckpoints,
|
|
2588
|
-
updateOutcome,
|
|
2589
|
-
saveCheckpoint,
|
|
2590
|
-
loadCheckpoint,
|
|
2591
|
-
// Legacy functions (retained for internal use, not exposed via MCP)
|
|
2592
|
-
recall,
|
|
2593
|
-
proposeLink,
|
|
2594
|
-
approveLink,
|
|
2595
|
-
rejectLink,
|
|
2596
|
-
getPendingLinks,
|
|
2597
|
-
deprecateAutoLinks,
|
|
2598
|
-
calculateCoverage,
|
|
2599
|
-
calculateQuality,
|
|
2600
|
-
generateQualityReport,
|
|
2601
|
-
logRestartAttempt,
|
|
2602
|
-
calculateRestartSuccessRate,
|
|
2603
|
-
calculateRestartLatency,
|
|
2604
|
-
getRestartMetrics,
|
|
2605
|
-
scanAutoLinks,
|
|
2606
|
-
createLinkBackup,
|
|
2607
|
-
generatePreCleanupReport,
|
|
2608
|
-
restoreLinkBackup,
|
|
2609
|
-
verifyBackupExists,
|
|
2610
|
-
deleteAutoLinks,
|
|
2611
|
-
validateCleanupResult,
|
|
2612
|
-
};
|
|
2613
|
-
|
|
2614
|
-
module.exports = mama;
|