Skip to content

Commit 0d13c23

Browse files
author
StackMemory Bot (CLI)
committed
feat(daemon): add memory monitor service with auto-capture/clear cycle
- DaemonMemoryService monitors RAM/heap at 30s intervals, triggers capture/clear when >90% threshold, writes signal file for hook - memory-guard.sh hook notifies user on next prompt submit - Wired into UnifiedDaemon lifecycle + CLI status display Also fixes pre-existing bugs: - fix(capture): use test:run instead of test to avoid vitest watch hang - fix(clear): add getStack() shim to RefactoredFrameManager - fix(clear): align saveLedger/restoreFromLedger with actual return types - fix(clear): add missing dbManager stubs (getRecentTraces, etc.) 0.8.0
1 parent 8b95fc8 commit 0d13c23

File tree

10 files changed

+736
-17
lines changed

10 files changed

+736
-17
lines changed

.claude/hooks/memory-guard.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/bash
2+
# Memory Guard Hook
3+
# Checks for memory-clear signal file written by the daemon memory service.
4+
# Registered as a UserPromptSubmit hook in ~/.claude/hooks.json.
5+
6+
SIGNAL_FILE="${PROJECT_ROOT:-.}/.stackmemory/.memory-clear-signal"
7+
8+
if [ -f "$SIGNAL_FILE" ]; then
9+
REASON=$(grep -o '"reason" *: *"[^"]*"' "$SIGNAL_FILE" | head -1 | sed 's/"reason" *: *"//;s/"$//')
10+
RAM=$(grep -o '"ramPercent" *: *[0-9]*' "$SIGNAL_FILE" | head -1 | sed 's/"ramPercent" *: *//')
11+
rm -f "$SIGNAL_FILE"
12+
echo "MEMORY CRITICAL: ${REASON:-RAM/heap exceeded threshold}. Context has been captured."
13+
echo "RAM: ${RAM:-?}% | Run /clear now, then stackmemory restore to continue."
14+
fi

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stackmemoryai/stackmemory",
3-
"version": "0.7.0",
3+
"version": "0.8.0",
44
"description": "Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.",
55
"engines": {
66
"node": ">=20.0.0",

src/cli/commands/clear.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,13 @@ const clearCommand = new Command('clear')
5656
generateHandoff: () => Promise.resolve('Mock handoff'),
5757
getHandoffPath: () => 'mock.md',
5858
};
59-
// Create a mock DatabaseManager for ClearSurvival
59+
// Create a stub DatabaseManager for ClearSurvival
6060
const dbManager = {
6161
getCurrentSessionId: () => Promise.resolve(session.id),
6262
getSession: () => Promise.resolve(session),
63+
getRecentTraces: () => Promise.resolve([]),
64+
getRecentFrames: () => Promise.resolve([]),
65+
addAnchor: () => Promise.resolve(),
6366
} as any;
6467
const clearSurvival = new ClearSurvival(
6568
frameManager,
@@ -147,19 +150,15 @@ async function saveLedger(
147150
): Promise<void> {
148151
spinner.start('Saving continuity ledger...');
149152

150-
const ledgerPath = await clearSurvival.saveContinuityLedger();
153+
const ledger = await clearSurvival.saveContinuityLedger();
151154

152155
spinner.succeed(chalk.green('Continuity ledger saved'));
153-
console.log(chalk.cyan(`Location: ${ledgerPath}`));
154156

155157
// Show what was saved
156-
const ledger = JSON.parse(await fs.readFile(ledgerPath, 'utf-8'));
157158
console.log('\nSaved:');
158-
console.log(` • ${ledger.activeFrames.length} active frames`);
159-
console.log(` • ${ledger.decisions.length} key decisions`);
160-
console.log(
161-
` • ${ledger.context.importantTasks?.length || 0} important tasks`
162-
);
159+
console.log(` • ${ledger.active_frame_stack?.length || 0} active frames`);
160+
console.log(` • ${ledger.key_decisions?.length || 0} key decisions`);
161+
console.log(` • ${ledger.active_tasks?.length || 0} active tasks`);
163162
}
164163

165164
async function restoreFromLedger(
@@ -168,16 +167,12 @@ async function restoreFromLedger(
168167
): Promise<void> {
169168
spinner.start('Restoring from continuity ledger...');
170169

171-
const result = await clearSurvival.restoreFromLedger();
170+
const success = await clearSurvival.restoreFromLedger();
172171

173-
if (result.success) {
172+
if (success) {
174173
spinner.succeed(chalk.green('Context restored from ledger'));
175-
console.log('\nRestored:');
176-
console.log(` • ${result.restoredFrames} frames`);
177-
console.log(` • ${result.restoredDecisions} decisions`);
178174
} else {
179175
spinner.fail(chalk.red('Failed to restore from ledger'));
180-
console.log(chalk.yellow(result.message));
181176
}
182177
}
183178

src/cli/commands/daemon.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ The daemon provides:
140140
if (newStatus.services.linear.enabled) services.push('linear');
141141
if (newStatus.services.maintenance?.enabled)
142142
services.push('maintenance');
143+
if (newStatus.services.memory?.enabled) services.push('memory');
143144
if (newStatus.services.fileWatch.enabled) services.push('file-watch');
144145
if (services.length > 0) {
145146
console.log(chalk.gray(`Services: ${services.join(', ')}`));
@@ -327,6 +328,38 @@ The daemon provides:
327328
}
328329
}
329330

331+
// Memory service
332+
const mem = status.services.memory;
333+
if (mem) {
334+
console.log(
335+
` Memory: ${mem.enabled ? chalk.green('Enabled') : chalk.gray('Disabled')}`
336+
);
337+
if (mem.enabled) {
338+
console.log(
339+
chalk.gray(` Interval: ${config.memory.interval} min`)
340+
);
341+
console.log(
342+
chalk.gray(
343+
` Thresholds: RAM ${config.memory.ramThreshold * 100}% / Heap ${config.memory.heapThreshold * 100}%`
344+
)
345+
);
346+
if (mem.currentRamPercent !== undefined) {
347+
console.log(
348+
chalk.gray(
349+
` Current RAM: ${Math.round(mem.currentRamPercent * 100)}%`
350+
)
351+
);
352+
}
353+
if (mem.triggerCount) {
354+
console.log(chalk.gray(` Triggers: ${mem.triggerCount}`));
355+
}
356+
if (mem.lastTrigger) {
357+
const ago = Math.round((Date.now() - mem.lastTrigger) / 1000 / 60);
358+
console.log(chalk.gray(` Last trigger: ${ago} min ago`));
359+
}
360+
}
361+
}
362+
330363
// File watch
331364
const fw = status.services.fileWatch;
332365
console.log(

src/core/context/refactored-frame-manager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,14 @@ export class RefactoredFrameManager {
484484
return this.frameStack.getCurrentFrameId();
485485
}
486486

487+
/**
488+
* Get stack as { frames } — compat shim for ClearSurvival
489+
*/
490+
getStack(): { frames: Frame[] } {
491+
const frames = this.frameDb.getFramesByProject(this.projectId);
492+
return { frames };
493+
}
494+
487495
/**
488496
* Get stack depth
489497
*/

src/core/session/enhanced-handoff.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ export class EnhancedHandoffGenerator {
499499

500500
// Check for failing tests
501501
try {
502-
const testResult = execSync('npm test 2>&1 || true', {
502+
const testResult = execSync('npm run test:run 2>&1 || true', {
503503
encoding: 'utf-8',
504504
cwd: this.projectRoot,
505505
timeout: 30000,

src/daemon/daemon-config.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ export interface MaintenanceServiceConfig extends DaemonServiceConfig {
4646
coldTierRehydrateCacheMinutes?: number; // default: 30
4747
}
4848

49+
export interface MemoryServiceConfig extends DaemonServiceConfig {
50+
ramThreshold: number; // 0.9 = 90% system RAM
51+
heapThreshold: number; // 0.9 = 90% Node.js heap
52+
cooldownMinutes: number; // avoid repeated triggers
53+
}
54+
4955
export interface FileWatchConfig extends DaemonServiceConfig {
5056
paths: string[];
5157
extensions: string[];
@@ -58,6 +64,7 @@ export interface DaemonConfig {
5864
context: ContextServiceConfig;
5965
linear: LinearServiceConfig;
6066
maintenance: MaintenanceServiceConfig;
67+
memory: MemoryServiceConfig;
6168
fileWatch: FileWatchConfig;
6269
heartbeatInterval: number; // seconds
6370
inactivityTimeout: number; // minutes, 0 = disabled
@@ -86,6 +93,13 @@ export const DEFAULT_DAEMON_CONFIG: DaemonConfig = {
8693
embeddingBatchSize: 50,
8794
vacuumInterval: 168, // weekly
8895
},
96+
memory: {
97+
enabled: true,
98+
interval: 0.5, // 30 seconds
99+
ramThreshold: 0.9,
100+
heapThreshold: 0.9,
101+
cooldownMinutes: 10,
102+
},
89103
fileWatch: {
90104
enabled: false, // Disabled by default
91105
interval: 0, // Not interval-based
@@ -118,6 +132,12 @@ export interface DaemonStatus {
118132
framesGarbageCollected?: number;
119133
lastGcRun?: number;
120134
};
135+
memory: {
136+
enabled: boolean;
137+
lastTrigger?: number;
138+
triggerCount?: number;
139+
currentRamPercent?: number;
140+
};
121141
fileWatch: { enabled: boolean; eventsProcessed?: number };
122142
};
123143
errors: string[];
@@ -181,6 +201,7 @@ export function loadDaemonConfig(): DaemonConfig {
181201
...DEFAULT_DAEMON_CONFIG.maintenance,
182202
...config.maintenance,
183203
},
204+
memory: { ...DEFAULT_DAEMON_CONFIG.memory, ...config.memory },
184205
fileWatch: { ...DEFAULT_DAEMON_CONFIG.fileWatch, ...config.fileWatch },
185206
};
186207
} catch {
@@ -200,6 +221,7 @@ export function saveDaemonConfig(config: Partial<DaemonConfig>): void {
200221
context: { ...currentConfig.context, ...config.context },
201222
linear: { ...currentConfig.linear, ...config.linear },
202223
maintenance: { ...currentConfig.maintenance, ...config.maintenance },
224+
memory: { ...currentConfig.memory, ...config.memory },
203225
fileWatch: { ...currentConfig.fileWatch, ...config.fileWatch },
204226
};
205227
writeFileSync(configFile, JSON.stringify(newConfig, null, 2));
@@ -217,6 +239,7 @@ export function readDaemonStatus(): DaemonStatus {
217239
context: { enabled: false },
218240
linear: { enabled: false },
219241
maintenance: { enabled: false },
242+
memory: { enabled: false },
220243
fileWatch: { enabled: false },
221244
},
222245
errors: [],

0 commit comments

Comments
 (0)