@exaudeus/memory-mcp 0.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/LICENSE +21 -0
- package/README.md +264 -0
- package/dist/__tests__/clock-and-validators.test.d.ts +1 -0
- package/dist/__tests__/clock-and-validators.test.js +237 -0
- package/dist/__tests__/config-manager.test.d.ts +1 -0
- package/dist/__tests__/config-manager.test.js +142 -0
- package/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +236 -0
- package/dist/__tests__/crash-journal.test.d.ts +1 -0
- package/dist/__tests__/crash-journal.test.js +203 -0
- package/dist/__tests__/e2e.test.d.ts +1 -0
- package/dist/__tests__/e2e.test.js +788 -0
- package/dist/__tests__/ephemeral-benchmark.test.d.ts +1 -0
- package/dist/__tests__/ephemeral-benchmark.test.js +651 -0
- package/dist/__tests__/ephemeral.test.d.ts +1 -0
- package/dist/__tests__/ephemeral.test.js +435 -0
- package/dist/__tests__/git-service.test.d.ts +1 -0
- package/dist/__tests__/git-service.test.js +43 -0
- package/dist/__tests__/normalize.test.d.ts +1 -0
- package/dist/__tests__/normalize.test.js +161 -0
- package/dist/__tests__/store.test.d.ts +1 -0
- package/dist/__tests__/store.test.js +1153 -0
- package/dist/config-manager.d.ts +49 -0
- package/dist/config-manager.js +126 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +162 -0
- package/dist/crash-journal.d.ts +38 -0
- package/dist/crash-journal.js +198 -0
- package/dist/ephemeral-weights.json +1847 -0
- package/dist/ephemeral.d.ts +20 -0
- package/dist/ephemeral.js +516 -0
- package/dist/formatters.d.ts +10 -0
- package/dist/formatters.js +92 -0
- package/dist/git-service.d.ts +5 -0
- package/dist/git-service.js +39 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1197 -0
- package/dist/normalize.d.ts +2 -0
- package/dist/normalize.js +69 -0
- package/dist/store.d.ts +84 -0
- package/dist/store.js +813 -0
- package/dist/text-analyzer.d.ts +32 -0
- package/dist/text-analyzer.js +190 -0
- package/dist/thresholds.d.ts +39 -0
- package/dist/thresholds.js +75 -0
- package/dist/types.d.ts +186 -0
- package/dist/types.js +33 -0
- package/package.json +57 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { MemoryConfig } from './types.js';
|
|
2
|
+
import { type LoadedConfig, type ConfigOrigin } from './config.js';
|
|
3
|
+
import { MarkdownMemoryStore } from './store.js';
|
|
4
|
+
/** Health status for a lobe (matches existing LobeHealth in index.ts) */
|
|
5
|
+
export type LobeHealth = {
|
|
6
|
+
readonly status: 'healthy';
|
|
7
|
+
} | {
|
|
8
|
+
readonly status: 'degraded';
|
|
9
|
+
readonly error: string;
|
|
10
|
+
readonly since: string;
|
|
11
|
+
readonly recovery: string[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* ConfigManager: Hot-reload config on every tool call.
|
|
15
|
+
*
|
|
16
|
+
* Design:
|
|
17
|
+
* - Constructor takes configPath + initial LoadedConfig from startup
|
|
18
|
+
* - ensureFresh() stats the config file, reloads if mtime changed
|
|
19
|
+
* - Reload is atomic: both lobeConfigs + stores swap together or not at all
|
|
20
|
+
* - Graceful degradation: any error keeps old config, logs to stderr
|
|
21
|
+
* - Observability: all reloads logged with timestamp and lobe count
|
|
22
|
+
*/
|
|
23
|
+
export declare class ConfigManager {
|
|
24
|
+
private configPath;
|
|
25
|
+
private configOrigin;
|
|
26
|
+
private lobeConfigs;
|
|
27
|
+
private stores;
|
|
28
|
+
private lobeHealth;
|
|
29
|
+
private configMtime;
|
|
30
|
+
protected statFile(path: string): Promise<{
|
|
31
|
+
mtimeMs: number;
|
|
32
|
+
}>;
|
|
33
|
+
constructor(configPath: string, initial: LoadedConfig, initialStores: Map<string, MarkdownMemoryStore>, initialHealth: Map<string, LobeHealth>);
|
|
34
|
+
/**
|
|
35
|
+
* Ensure config is fresh. Call at the start of every tool handler.
|
|
36
|
+
* Stats config file, reloads if mtime changed. Graceful on all errors.
|
|
37
|
+
*/
|
|
38
|
+
ensureFresh(): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Reload config from file. Atomic: both lobeConfigs and stores swap together.
|
|
41
|
+
* Graceful: parse failure or store init failure keeps old config/stores.
|
|
42
|
+
*/
|
|
43
|
+
private reload;
|
|
44
|
+
getStore(lobe: string): MarkdownMemoryStore | undefined;
|
|
45
|
+
getLobeNames(): readonly string[];
|
|
46
|
+
getLobeHealth(lobe: string): LobeHealth | undefined;
|
|
47
|
+
getConfigOrigin(): ConfigOrigin;
|
|
48
|
+
getLobeConfig(lobe: string): MemoryConfig | undefined;
|
|
49
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// ConfigManager: Handles config hot-reload with stat-based freshness checking
|
|
2
|
+
//
|
|
3
|
+
// Encapsulates config state (lobeConfigs, configOrigin, stores, lobeHealth, mtime).
|
|
4
|
+
// Provides ensureFresh() method that stats the config file and reloads if changed.
|
|
5
|
+
// All tool handlers call ensureFresh() at entry to validate config is current.
|
|
6
|
+
import { stat } from 'fs/promises';
|
|
7
|
+
import { getLobeConfigs } from './config.js';
|
|
8
|
+
import { MarkdownMemoryStore } from './store.js';
|
|
9
|
+
/**
|
|
10
|
+
* ConfigManager: Hot-reload config on every tool call.
|
|
11
|
+
*
|
|
12
|
+
* Design:
|
|
13
|
+
* - Constructor takes configPath + initial LoadedConfig from startup
|
|
14
|
+
* - ensureFresh() stats the config file, reloads if mtime changed
|
|
15
|
+
* - Reload is atomic: both lobeConfigs + stores swap together or not at all
|
|
16
|
+
* - Graceful degradation: any error keeps old config, logs to stderr
|
|
17
|
+
* - Observability: all reloads logged with timestamp and lobe count
|
|
18
|
+
*/
|
|
19
|
+
export class ConfigManager {
|
|
20
|
+
// Dependency injection for testing: allow tests to override stat function
|
|
21
|
+
async statFile(path) {
|
|
22
|
+
return stat(path);
|
|
23
|
+
}
|
|
24
|
+
constructor(configPath, initial, initialStores, initialHealth) {
|
|
25
|
+
this.configPath = configPath;
|
|
26
|
+
this.configOrigin = initial.origin;
|
|
27
|
+
this.lobeConfigs = initial.configs;
|
|
28
|
+
this.stores = initialStores;
|
|
29
|
+
this.lobeHealth = initialHealth;
|
|
30
|
+
this.configMtime = Date.now(); // Initial mtime (will be updated on first stat)
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Ensure config is fresh. Call at the start of every tool handler.
|
|
34
|
+
* Stats config file, reloads if mtime changed. Graceful on all errors.
|
|
35
|
+
*/
|
|
36
|
+
async ensureFresh() {
|
|
37
|
+
// Only reload file-based configs (env-var configs can't change at runtime)
|
|
38
|
+
if (this.configOrigin.source !== 'file') {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const stats = await this.statFile(this.configPath);
|
|
43
|
+
if (stats.mtimeMs > this.configMtime) {
|
|
44
|
+
await this.reload(stats.mtimeMs);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// Any stat error (ENOENT, EACCES, EIO, etc.) → keep old config
|
|
49
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
50
|
+
process.stderr.write(`[memory-mcp] Config stat failed: ${message}. Keeping current config.\n`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reload config from file. Atomic: both lobeConfigs and stores swap together.
|
|
55
|
+
* Graceful: parse failure or store init failure keeps old config/stores.
|
|
56
|
+
*/
|
|
57
|
+
async reload(newMtime) {
|
|
58
|
+
try {
|
|
59
|
+
const newConfig = getLobeConfigs();
|
|
60
|
+
// If parse failed, getLobeConfigs falls through to env/default.
|
|
61
|
+
// For file-based reload, we want to detect parse failure and keep old.
|
|
62
|
+
// Check: if origin changed from 'file' to 'env'/'default', parse failed.
|
|
63
|
+
if (newConfig.origin.source !== 'file') {
|
|
64
|
+
process.stderr.write(`[memory-mcp] Config reload failed (parse error). Keeping current config.\n`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Initialize new stores
|
|
68
|
+
const newStores = new Map();
|
|
69
|
+
const newHealth = new Map();
|
|
70
|
+
for (const [name, config] of newConfig.configs) {
|
|
71
|
+
try {
|
|
72
|
+
const store = new MarkdownMemoryStore(config);
|
|
73
|
+
await store.init();
|
|
74
|
+
newStores.set(name, store);
|
|
75
|
+
newHealth.set(name, { status: 'healthy' });
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// Store init failed → mark as degraded, continue with others
|
|
79
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
80
|
+
process.stderr.write(`[memory-mcp] Lobe "${name}" init failed during reload: ${message}. Marked as degraded.\n`);
|
|
81
|
+
newHealth.set(name, {
|
|
82
|
+
status: 'degraded',
|
|
83
|
+
error: message,
|
|
84
|
+
since: new Date().toISOString(),
|
|
85
|
+
recovery: [
|
|
86
|
+
`Verify the repo root exists: ${config.repoRoot}`,
|
|
87
|
+
'Check file permissions on the memory directory.',
|
|
88
|
+
'If the repo was moved, update memory-config.json.',
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Atomic swap
|
|
94
|
+
this.configOrigin = newConfig.origin;
|
|
95
|
+
this.lobeConfigs = newConfig.configs;
|
|
96
|
+
this.stores = newStores;
|
|
97
|
+
this.lobeHealth = newHealth;
|
|
98
|
+
this.configMtime = newMtime;
|
|
99
|
+
const lobeCount = newConfig.configs.size;
|
|
100
|
+
const degradedCount = Array.from(newHealth.values()).filter(h => h.status === 'degraded').length;
|
|
101
|
+
const timestamp = new Date().toISOString();
|
|
102
|
+
process.stderr.write(`[memory-mcp] [${timestamp}] Config reloaded: ${lobeCount} lobe(s)${degradedCount > 0 ? `, ${degradedCount} degraded` : ''}\n`);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
// Reload failed entirely → keep old config
|
|
106
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
107
|
+
process.stderr.write(`[memory-mcp] Config reload failed: ${message}. Keeping current config.\n`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Accessors
|
|
111
|
+
getStore(lobe) {
|
|
112
|
+
return this.stores.get(lobe);
|
|
113
|
+
}
|
|
114
|
+
getLobeNames() {
|
|
115
|
+
return Array.from(this.lobeConfigs.keys());
|
|
116
|
+
}
|
|
117
|
+
getLobeHealth(lobe) {
|
|
118
|
+
return this.lobeHealth.get(lobe);
|
|
119
|
+
}
|
|
120
|
+
getConfigOrigin() {
|
|
121
|
+
return this.configOrigin;
|
|
122
|
+
}
|
|
123
|
+
getLobeConfig(lobe) {
|
|
124
|
+
return this.lobeConfigs.get(lobe);
|
|
125
|
+
}
|
|
126
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { MemoryConfig, BehaviorConfig } from './types.js';
|
|
2
|
+
/** How the config was loaded — discriminated union so configFilePath
|
|
3
|
+
* only exists when source is 'file' (illegal states unrepresentable) */
|
|
4
|
+
export type ConfigOrigin = {
|
|
5
|
+
readonly source: 'file';
|
|
6
|
+
readonly path: string;
|
|
7
|
+
} | {
|
|
8
|
+
readonly source: 'env';
|
|
9
|
+
} | {
|
|
10
|
+
readonly source: 'default';
|
|
11
|
+
};
|
|
12
|
+
/** Result of loading lobe configs */
|
|
13
|
+
export interface LoadedConfig {
|
|
14
|
+
readonly configs: ReadonlyMap<string, MemoryConfig>;
|
|
15
|
+
readonly origin: ConfigOrigin;
|
|
16
|
+
/** Resolved behavior config — present when a "behavior" block was found in memory-config.json */
|
|
17
|
+
readonly behavior?: BehaviorConfig;
|
|
18
|
+
}
|
|
19
|
+
interface MemoryConfigFileBehavior {
|
|
20
|
+
staleDaysStandard?: number;
|
|
21
|
+
staleDaysPreferences?: number;
|
|
22
|
+
maxStaleInBriefing?: number;
|
|
23
|
+
maxDedupSuggestions?: number;
|
|
24
|
+
maxConflictPairs?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Parse and validate a behavior config block, falling back to defaults for each field.
|
|
27
|
+
* Warns to stderr for unknown keys (likely typos) and out-of-range values.
|
|
28
|
+
* Exported for testing — validates and clamps all fields. */
|
|
29
|
+
export declare function parseBehaviorConfig(raw?: MemoryConfigFileBehavior): BehaviorConfig;
|
|
30
|
+
/** Load lobe configs with priority: memory-config.json -> env vars -> single-repo default */
|
|
31
|
+
export declare function getLobeConfigs(): LoadedConfig;
|
|
32
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Configuration loading for the memory MCP server.
|
|
2
|
+
//
|
|
3
|
+
// Priority: memory-config.json → env vars → single-repo default
|
|
4
|
+
// Graceful degradation: each source falls through to the next on failure.
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
6
|
+
import { execFileSync } from 'child_process';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import { DEFAULT_STORAGE_BUDGET_BYTES } from './types.js';
|
|
10
|
+
import { DEFAULT_STALE_DAYS_STANDARD, DEFAULT_STALE_DAYS_PREFERENCES, DEFAULT_MAX_STALE_IN_BRIEFING, DEFAULT_MAX_DEDUP_SUGGESTIONS, DEFAULT_MAX_CONFLICT_PAIRS, } from './thresholds.js';
|
|
11
|
+
/** Validate and clamp a numeric threshold to a given range.
|
|
12
|
+
* Returns the default if the value is missing, NaN, or out of range. */
|
|
13
|
+
function clampThreshold(value, defaultValue, min, max) {
|
|
14
|
+
if (value === undefined || value === null)
|
|
15
|
+
return defaultValue;
|
|
16
|
+
const n = Number(value);
|
|
17
|
+
if (isNaN(n) || n < min || n > max) {
|
|
18
|
+
process.stderr.write(`[memory-mcp] Behavior threshold out of range [${min}, ${max}]: ${value} — using default ${defaultValue}\n`);
|
|
19
|
+
return defaultValue;
|
|
20
|
+
}
|
|
21
|
+
return Math.round(n); // integer thresholds only
|
|
22
|
+
}
|
|
23
|
+
/** Known behavior config keys — used to warn on typos/unknown fields at startup. */
|
|
24
|
+
const KNOWN_BEHAVIOR_KEYS = new Set([
|
|
25
|
+
'staleDaysStandard', 'staleDaysPreferences',
|
|
26
|
+
'maxStaleInBriefing', 'maxDedupSuggestions', 'maxConflictPairs',
|
|
27
|
+
]);
|
|
28
|
+
/** Parse and validate a behavior config block, falling back to defaults for each field.
|
|
29
|
+
* Warns to stderr for unknown keys (likely typos) and out-of-range values.
|
|
30
|
+
* Exported for testing — validates and clamps all fields. */
|
|
31
|
+
export function parseBehaviorConfig(raw) {
|
|
32
|
+
if (!raw)
|
|
33
|
+
return {};
|
|
34
|
+
// Warn on unrecognized keys so typos don't silently produce defaults
|
|
35
|
+
for (const key of Object.keys(raw)) {
|
|
36
|
+
if (!KNOWN_BEHAVIOR_KEYS.has(key)) {
|
|
37
|
+
process.stderr.write(`[memory-mcp] Unknown behavior config key "${key}" — ignored. ` +
|
|
38
|
+
`Valid keys: ${Array.from(KNOWN_BEHAVIOR_KEYS).join(', ')}\n`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
staleDaysStandard: clampThreshold(raw.staleDaysStandard, DEFAULT_STALE_DAYS_STANDARD, 1, 365),
|
|
43
|
+
staleDaysPreferences: clampThreshold(raw.staleDaysPreferences, DEFAULT_STALE_DAYS_PREFERENCES, 1, 730),
|
|
44
|
+
maxStaleInBriefing: clampThreshold(raw.maxStaleInBriefing, DEFAULT_MAX_STALE_IN_BRIEFING, 1, 20),
|
|
45
|
+
maxDedupSuggestions: clampThreshold(raw.maxDedupSuggestions, DEFAULT_MAX_DEDUP_SUGGESTIONS, 1, 10),
|
|
46
|
+
maxConflictPairs: clampThreshold(raw.maxConflictPairs, DEFAULT_MAX_CONFLICT_PAIRS, 1, 5),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function resolveRoot(root) {
|
|
50
|
+
return root
|
|
51
|
+
.replace(/^\$HOME\b/, process.env.HOME ?? '')
|
|
52
|
+
.replace(/^~/, process.env.HOME ?? '');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the memory storage directory for a workspace.
|
|
56
|
+
*
|
|
57
|
+
* Priority:
|
|
58
|
+
* 1. Explicit memoryDir config -> use it (relative to repoRoot, or absolute)
|
|
59
|
+
* 2. Git repo detected -> `<git-common-dir>/memory/` — shared across all worktrees
|
|
60
|
+
* 3. Fallback -> `~/.memory-mcp/<workspaceName>/` (central, no git pollution)
|
|
61
|
+
*
|
|
62
|
+
* Uses `git rev-parse --git-common-dir` which always resolves to the main
|
|
63
|
+
* .git/ directory regardless of whether you're in a linked worktree or
|
|
64
|
+
* submodule. This ensures all worktrees of the same repo share one memory.
|
|
65
|
+
*/
|
|
66
|
+
function resolveMemoryPath(repoRoot, workspaceName, explicitMemoryDir) {
|
|
67
|
+
if (explicitMemoryDir) {
|
|
68
|
+
if (path.isAbsolute(explicitMemoryDir)) {
|
|
69
|
+
return explicitMemoryDir;
|
|
70
|
+
}
|
|
71
|
+
return path.join(repoRoot, explicitMemoryDir);
|
|
72
|
+
}
|
|
73
|
+
// Use git to find the common .git directory (shared across worktrees)
|
|
74
|
+
try {
|
|
75
|
+
const result = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: repoRoot, encoding: 'utf-8', timeout: 5000 }).trim();
|
|
76
|
+
const gitCommonDir = path.resolve(repoRoot, result);
|
|
77
|
+
return path.join(gitCommonDir, 'memory');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Not a git repo or git not available — fall through to central store
|
|
81
|
+
}
|
|
82
|
+
return path.join(os.homedir(), '.memory-mcp', workspaceName);
|
|
83
|
+
}
|
|
84
|
+
/** Load lobe configs with priority: memory-config.json -> env vars -> single-repo default */
|
|
85
|
+
export function getLobeConfigs() {
|
|
86
|
+
const configs = new Map();
|
|
87
|
+
// 1. Try loading from memory-config.json (highest priority)
|
|
88
|
+
const configPath = path.resolve(new URL('.', import.meta.url).pathname, '..', 'memory-config.json');
|
|
89
|
+
try {
|
|
90
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
91
|
+
const external = JSON.parse(raw);
|
|
92
|
+
if (!external.lobes || typeof external.lobes !== 'object') {
|
|
93
|
+
process.stderr.write(`[memory-mcp] Invalid memory-config.json: missing "lobes" object\n`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// Parse global behavior config once — applies to all lobes
|
|
97
|
+
const behavior = parseBehaviorConfig(external.behavior);
|
|
98
|
+
for (const [name, config] of Object.entries(external.lobes)) {
|
|
99
|
+
if (!config.root) {
|
|
100
|
+
process.stderr.write(`[memory-mcp] Skipping lobe "${name}": missing "root" field\n`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const repoRoot = resolveRoot(config.root);
|
|
104
|
+
configs.set(name, {
|
|
105
|
+
repoRoot,
|
|
106
|
+
memoryPath: resolveMemoryPath(repoRoot, name, config.memoryDir),
|
|
107
|
+
storageBudgetBytes: (config.budgetMB ?? 2) * 1024 * 1024,
|
|
108
|
+
behavior,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (configs.size > 0) {
|
|
112
|
+
process.stderr.write(`[memory-mcp] Loaded ${configs.size} lobe(s) from memory-config.json\n`);
|
|
113
|
+
// Pass resolved behavior at the top-level so diagnostics can surface active values
|
|
114
|
+
const resolvedBehavior = external.behavior ? parseBehaviorConfig(external.behavior) : undefined;
|
|
115
|
+
return { configs, origin: { source: 'file', path: configPath }, behavior: resolvedBehavior };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
// ENOENT = config file doesn't exist, which is expected — silently fall through
|
|
121
|
+
const isFileNotFound = error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
122
|
+
if (!isFileNotFound) {
|
|
123
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
124
|
+
process.stderr.write(`[memory-mcp] Failed to parse memory-config.json: ${message}\n`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// 2. Try env var multi-repo mode
|
|
128
|
+
const workspacesJson = process.env.MEMORY_MCP_WORKSPACES;
|
|
129
|
+
if (workspacesJson) {
|
|
130
|
+
const explicitDir = process.env.MEMORY_MCP_DIR;
|
|
131
|
+
const storageBudget = parseInt(process.env.MEMORY_MCP_BUDGET ?? '', 10) || DEFAULT_STORAGE_BUDGET_BYTES;
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(workspacesJson);
|
|
134
|
+
for (const [name, rawRoot] of Object.entries(parsed)) {
|
|
135
|
+
const repoRoot = resolveRoot(rawRoot);
|
|
136
|
+
configs.set(name, {
|
|
137
|
+
repoRoot,
|
|
138
|
+
memoryPath: resolveMemoryPath(repoRoot, name, explicitDir),
|
|
139
|
+
storageBudgetBytes: storageBudget,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (configs.size > 0) {
|
|
143
|
+
process.stderr.write(`[memory-mcp] Loaded ${configs.size} lobe(s) from MEMORY_MCP_WORKSPACES env var\n`);
|
|
144
|
+
return { configs, origin: { source: 'env' } };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
process.stderr.write(`[memory-mcp] Failed to parse MEMORY_MCP_WORKSPACES: ${e}\n`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// 3. Fall back to single-repo default
|
|
152
|
+
const repoRoot = process.env.MEMORY_MCP_REPO_ROOT ?? process.cwd();
|
|
153
|
+
const explicitDir = process.env.MEMORY_MCP_DIR;
|
|
154
|
+
const storageBudget = parseInt(process.env.MEMORY_MCP_BUDGET ?? '', 10) || DEFAULT_STORAGE_BUDGET_BYTES;
|
|
155
|
+
configs.set('default', {
|
|
156
|
+
repoRoot,
|
|
157
|
+
memoryPath: resolveMemoryPath(repoRoot, 'default', explicitDir),
|
|
158
|
+
storageBudgetBytes: storageBudget,
|
|
159
|
+
});
|
|
160
|
+
process.stderr.write(`[memory-mcp] Using single-lobe default mode (cwd: ${repoRoot})\n`);
|
|
161
|
+
return { configs, origin: { source: 'default' } };
|
|
162
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** A single crash report */
|
|
2
|
+
export interface CrashReport {
|
|
3
|
+
readonly timestamp: string;
|
|
4
|
+
readonly pid: number;
|
|
5
|
+
readonly error: string;
|
|
6
|
+
readonly stack?: string;
|
|
7
|
+
readonly type: CrashType;
|
|
8
|
+
readonly context: CrashContext;
|
|
9
|
+
readonly recovery: string[];
|
|
10
|
+
readonly serverUptime: number;
|
|
11
|
+
}
|
|
12
|
+
export type CrashType = 'uncaught-exception' | 'unhandled-rejection' | 'startup-failure' | 'lobe-init-failure' | 'transport-error' | 'unknown';
|
|
13
|
+
/** What the server was doing when it crashed */
|
|
14
|
+
export interface CrashContext {
|
|
15
|
+
readonly phase: 'startup' | 'running' | 'migration' | 'shutdown';
|
|
16
|
+
readonly lastToolCall?: string;
|
|
17
|
+
readonly activeLobe?: string;
|
|
18
|
+
readonly configSource?: string;
|
|
19
|
+
readonly lobeCount?: number;
|
|
20
|
+
}
|
|
21
|
+
/** Reset the start time (called on startup) */
|
|
22
|
+
export declare function markServerStarted(): void;
|
|
23
|
+
/** Write a crash report to disk. Synchronous — must work even in dying process. */
|
|
24
|
+
export declare function writeCrashReport(report: CrashReport): Promise<string>;
|
|
25
|
+
/** Synchronous version for use in process exit handlers where async isn't reliable */
|
|
26
|
+
export declare function writeCrashReportSync(report: CrashReport): string | null;
|
|
27
|
+
/** Build a CrashReport from an error */
|
|
28
|
+
export declare function buildCrashReport(error: unknown, type: CrashType, context: CrashContext): CrashReport;
|
|
29
|
+
/** Read the most recent crash report (if any) */
|
|
30
|
+
export declare function readLatestCrash(): Promise<CrashReport | null>;
|
|
31
|
+
/** Read all crash reports, newest first */
|
|
32
|
+
export declare function readCrashHistory(limit?: number): Promise<CrashReport[]>;
|
|
33
|
+
/** Clear the latest crash indicator (call after successfully showing it to user) */
|
|
34
|
+
export declare function clearLatestCrash(): Promise<void>;
|
|
35
|
+
/** Format a crash report for display in an MCP tool response */
|
|
36
|
+
export declare function formatCrashReport(report: CrashReport): string;
|
|
37
|
+
/** Format a short crash summary for briefing inclusion */
|
|
38
|
+
export declare function formatCrashSummary(report: CrashReport): string;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Crash journal: persistent, human-readable record of MCP server failures.
|
|
2
|
+
//
|
|
3
|
+
// Design principles:
|
|
4
|
+
// - Errors are data: crashes become structured records, not silent deaths
|
|
5
|
+
// - Observability as a constraint: every failure is visible on next startup
|
|
6
|
+
// - Fail fast with meaningful messages: journal then die, don't zombie
|
|
7
|
+
//
|
|
8
|
+
// Location: ~/.memory-mcp/crashes/
|
|
9
|
+
// crash-<timestamp>.json — one file per crash (no write conflicts)
|
|
10
|
+
// LATEST.json — symlink/copy of most recent crash (fast access)
|
|
11
|
+
import { promises as fs } from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
const CRASH_DIR = path.join(os.homedir(), '.memory-mcp', 'crashes');
|
|
15
|
+
const LATEST_FILE = path.join(CRASH_DIR, 'LATEST.json');
|
|
16
|
+
const MAX_CRASH_FILES = 20; // keep last 20 crash reports
|
|
17
|
+
let serverStartTime = Date.now();
|
|
18
|
+
/** Reset the start time (called on startup) */
|
|
19
|
+
export function markServerStarted() {
|
|
20
|
+
serverStartTime = Date.now();
|
|
21
|
+
}
|
|
22
|
+
/** Write a crash report to disk. Synchronous — must work even in dying process. */
|
|
23
|
+
export async function writeCrashReport(report) {
|
|
24
|
+
await fs.mkdir(CRASH_DIR, { recursive: true });
|
|
25
|
+
const filename = `crash-${report.timestamp.replace(/[:.]/g, '-')}.json`;
|
|
26
|
+
const filepath = path.join(CRASH_DIR, filename);
|
|
27
|
+
const content = JSON.stringify(report, null, 2);
|
|
28
|
+
await fs.writeFile(filepath, content, 'utf-8');
|
|
29
|
+
await fs.writeFile(LATEST_FILE, content, 'utf-8');
|
|
30
|
+
// Prune old crash files (keep newest MAX_CRASH_FILES)
|
|
31
|
+
try {
|
|
32
|
+
const files = (await fs.readdir(CRASH_DIR))
|
|
33
|
+
.filter(f => f.startsWith('crash-') && f.endsWith('.json'))
|
|
34
|
+
.sort()
|
|
35
|
+
.reverse();
|
|
36
|
+
for (const old of files.slice(MAX_CRASH_FILES)) {
|
|
37
|
+
await fs.unlink(path.join(CRASH_DIR, old)).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch { /* pruning is best-effort */ }
|
|
41
|
+
return filepath;
|
|
42
|
+
}
|
|
43
|
+
/** Synchronous version for use in process exit handlers where async isn't reliable */
|
|
44
|
+
export function writeCrashReportSync(report) {
|
|
45
|
+
const { mkdirSync, writeFileSync } = require('fs');
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(CRASH_DIR, { recursive: true });
|
|
48
|
+
const filename = `crash-${report.timestamp.replace(/[:.]/g, '-')}.json`;
|
|
49
|
+
const filepath = path.join(CRASH_DIR, filename);
|
|
50
|
+
const content = JSON.stringify(report, null, 2);
|
|
51
|
+
writeFileSync(filepath, content, 'utf-8');
|
|
52
|
+
writeFileSync(LATEST_FILE, content, 'utf-8');
|
|
53
|
+
return filepath;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Build a CrashReport from an error */
|
|
60
|
+
export function buildCrashReport(error, type, context) {
|
|
61
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
62
|
+
const stack = error instanceof Error ? error.stack : undefined;
|
|
63
|
+
const recovery = generateRecoverySteps(type, message, context);
|
|
64
|
+
return {
|
|
65
|
+
timestamp: new Date().toISOString(),
|
|
66
|
+
pid: process.pid,
|
|
67
|
+
error: message,
|
|
68
|
+
stack,
|
|
69
|
+
type,
|
|
70
|
+
context,
|
|
71
|
+
recovery,
|
|
72
|
+
serverUptime: Math.round((Date.now() - serverStartTime) / 1000),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Generate human-readable recovery instructions based on crash type */
|
|
76
|
+
function generateRecoverySteps(type, message, context) {
|
|
77
|
+
const steps = [];
|
|
78
|
+
// Common first step
|
|
79
|
+
steps.push('Toggle the memory MCP off and on in Firebender to restart the server.');
|
|
80
|
+
switch (type) {
|
|
81
|
+
case 'startup-failure':
|
|
82
|
+
if (message.includes('memory-config.json')) {
|
|
83
|
+
steps.push('Check memory-config.json for syntax errors (invalid JSON).');
|
|
84
|
+
steps.push('Verify all "root" paths in lobe definitions exist on disk.');
|
|
85
|
+
}
|
|
86
|
+
if (message.includes('ENOENT') || message.includes('not found')) {
|
|
87
|
+
steps.push(`Verify the path exists: check if the directory referenced in the error is accessible.`);
|
|
88
|
+
}
|
|
89
|
+
steps.push('Try running: node /path/to/memory-mcp/dist/index.js directly to see stderr output.');
|
|
90
|
+
break;
|
|
91
|
+
case 'lobe-init-failure':
|
|
92
|
+
steps.push(`The lobe "${context.activeLobe}" failed to initialize.`);
|
|
93
|
+
steps.push('Check that the repo root exists and is accessible.');
|
|
94
|
+
steps.push('Verify git is installed if the repo uses .git/memory/ storage.');
|
|
95
|
+
steps.push('Other lobes may still work — the server continues in degraded mode.');
|
|
96
|
+
break;
|
|
97
|
+
case 'uncaught-exception':
|
|
98
|
+
case 'unhandled-rejection':
|
|
99
|
+
steps.push('This is likely a bug in the memory MCP server.');
|
|
100
|
+
steps.push('If reproducible, note what tool call triggered it and report the issue.');
|
|
101
|
+
if (message.includes('ENOSPC')) {
|
|
102
|
+
steps.push('Disk is full — free space and restart.');
|
|
103
|
+
}
|
|
104
|
+
if (message.includes('EACCES') || message.includes('EPERM')) {
|
|
105
|
+
steps.push('Permission error — check file permissions on ~/.memory-mcp/ and the repo .git/memory/ directories.');
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
case 'transport-error':
|
|
109
|
+
steps.push('The communication channel between Firebender and the MCP broke.');
|
|
110
|
+
steps.push('This usually happens when the IDE restarts or the MCP is toggled.');
|
|
111
|
+
steps.push('Simply toggle the MCP off and on — this is expected behavior.');
|
|
112
|
+
break;
|
|
113
|
+
default:
|
|
114
|
+
steps.push('Check the stack trace for details.');
|
|
115
|
+
}
|
|
116
|
+
return steps;
|
|
117
|
+
}
|
|
118
|
+
/** Read the most recent crash report (if any) */
|
|
119
|
+
export async function readLatestCrash() {
|
|
120
|
+
try {
|
|
121
|
+
const content = await fs.readFile(LATEST_FILE, 'utf-8');
|
|
122
|
+
return JSON.parse(content);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Read all crash reports, newest first */
|
|
129
|
+
export async function readCrashHistory(limit = 10) {
|
|
130
|
+
try {
|
|
131
|
+
const files = (await fs.readdir(CRASH_DIR))
|
|
132
|
+
.filter(f => f.startsWith('crash-') && f.endsWith('.json'))
|
|
133
|
+
.sort()
|
|
134
|
+
.reverse()
|
|
135
|
+
.slice(0, limit);
|
|
136
|
+
const reports = [];
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
try {
|
|
139
|
+
const content = await fs.readFile(path.join(CRASH_DIR, file), 'utf-8');
|
|
140
|
+
reports.push(JSON.parse(content));
|
|
141
|
+
}
|
|
142
|
+
catch { /* skip corrupt files */ }
|
|
143
|
+
}
|
|
144
|
+
return reports;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Clear the latest crash indicator (call after successfully showing it to user) */
|
|
151
|
+
export async function clearLatestCrash() {
|
|
152
|
+
try {
|
|
153
|
+
await fs.unlink(LATEST_FILE);
|
|
154
|
+
}
|
|
155
|
+
catch { /* already gone */ }
|
|
156
|
+
}
|
|
157
|
+
/** Format a crash report for display in an MCP tool response */
|
|
158
|
+
export function formatCrashReport(report) {
|
|
159
|
+
const lines = [
|
|
160
|
+
`## ⚠ Memory MCP Crash Report`,
|
|
161
|
+
``,
|
|
162
|
+
`**When:** ${report.timestamp}`,
|
|
163
|
+
`**Type:** ${report.type}`,
|
|
164
|
+
`**Phase:** ${report.context.phase}`,
|
|
165
|
+
`**Uptime:** ${report.serverUptime}s before crash`,
|
|
166
|
+
`**Error:** ${report.error}`,
|
|
167
|
+
];
|
|
168
|
+
if (report.context.lastToolCall) {
|
|
169
|
+
lines.push(`**Last tool call:** ${report.context.lastToolCall}`);
|
|
170
|
+
}
|
|
171
|
+
if (report.context.activeLobe) {
|
|
172
|
+
lines.push(`**Affected lobe:** ${report.context.activeLobe}`);
|
|
173
|
+
}
|
|
174
|
+
lines.push('');
|
|
175
|
+
lines.push('### Recovery Steps');
|
|
176
|
+
for (const step of report.recovery) {
|
|
177
|
+
lines.push(`- ${step}`);
|
|
178
|
+
}
|
|
179
|
+
if (report.stack) {
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push('### Stack Trace');
|
|
182
|
+
lines.push('```');
|
|
183
|
+
// Truncate very long stacks
|
|
184
|
+
const stackLines = report.stack.split('\n').slice(0, 10);
|
|
185
|
+
lines.push(stackLines.join('\n'));
|
|
186
|
+
if (report.stack.split('\n').length > 10) {
|
|
187
|
+
lines.push('... (truncated)');
|
|
188
|
+
}
|
|
189
|
+
lines.push('```');
|
|
190
|
+
}
|
|
191
|
+
return lines.join('\n');
|
|
192
|
+
}
|
|
193
|
+
/** Format a short crash summary for briefing inclusion */
|
|
194
|
+
export function formatCrashSummary(report) {
|
|
195
|
+
const age = Math.round((Date.now() - new Date(report.timestamp).getTime()) / 1000 / 60);
|
|
196
|
+
const ageStr = age < 60 ? `${age}m ago` : `${Math.round(age / 60)}h ago`;
|
|
197
|
+
return `[!] Server crashed ${ageStr}: ${report.type} — ${report.error.substring(0, 100)}`;
|
|
198
|
+
}
|