Skip to content

Commit 3e9c869

Browse files
authored
Merge pull request #79 from ownpilot/feat/memory-retention-default-schedule
feat(memory): always-on daily personal-memory retention schedule
2 parents a5c2e69 + ddcbb48 commit 3e9c869

4 files changed

Lines changed: 235 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Personal memory now has an always-on retention schedule.** Previously, personal-memory decay and cleanup ran only via manual REST calls or user-enabled triggers — there was no default daily enforcement (unlike the autonomous Claw runtime, which already trims its own tables daily). A new daily scheduler runs importance decay + dead-memory cleanup automatically. It is pure hygiene — no LLM calls, no conversation extraction, no semantic consolidation — and only removes already-dead entries (importance below 0.1, older than 90 days, and untouched for 90 days), so it runs by default while the LLM-driven `memory_extract` / `memory_consolidate` triggers stay opt-in. Cleanup (an idempotent delete) runs on startup and daily; the compounding decay step runs only on the daily tick, so it stays independent of how often the process restarts.
13+
1014
### Fixed
1115

1216
- **UI shipped unstyled when the Tailwind native scanner fell back to WASM.** Tailwind v4 scans sources with the native `@tailwindcss/oxide` addon; when its platform binary fails to load (a corrupt `@tailwindcss/oxide-win32-x64-msvc` install — missing `package.json` — on Windows), oxide silently falls back to its WASM build, which scans nothing and emits a utility-less ~13 KB stylesheet instead of ~250 KB, with no error. A new build-only `css-size-guard` Vite plugin now **fails the build** if total emitted CSS is under 80 KB, so this can never silently ship (including in Docker images) again.

packages/gateway/src/server.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,16 @@ async function main() {
878878
log.warn('Autonomy Engine failed to start', { error: String(error) });
879879
}
880880

881+
// Start always-on personal-memory retention (daily decay + cleanup). Pure
882+
// hygiene — no LLM, no conversation extraction — so it runs by default,
883+
// unlike the opt-in memory_extract / memory_consolidate triggers.
884+
try {
885+
const { startMemoryRetention } = await import('./services/memory/retention.js');
886+
startMemoryRetention('default');
887+
} catch (error) {
888+
log.warn('Memory retention scheduler failed to start', { error: String(error) });
889+
}
890+
881891
// Seed example plans (only creates if not already present)
882892
try {
883893
const planSeed = await seedExamplePlans('default');
@@ -1098,6 +1108,14 @@ async function main() {
10981108
log.warn('Heartbeat service reset error', { error: String(e) });
10991109
}
11001110

1111+
// 5.6c-mem. Stop the personal-memory retention scheduler
1112+
try {
1113+
const { stopMemoryRetention } = await import('./services/memory/retention.js');
1114+
stopMemoryRetention();
1115+
} catch (e) {
1116+
log.warn('Memory retention stop error', { error: String(e) });
1117+
}
1118+
11011119
// 5.6d. Reset memory service
11021120
try {
11031121
const { resetMemoryService } = await import('./services/memory-service.js');
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* Memory retention scheduler tests — mocks the MemoryService and the logger,
3+
* uses fake timers to assert the boot pass vs. daily-tick cadence.
4+
*/
5+
6+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7+
8+
const { mockService } = vi.hoisted(() => {
9+
const mockService = {
10+
decayMemories: vi.fn(async () => 0),
11+
cleanupMemories: vi.fn(async () => 0),
12+
};
13+
return { mockService };
14+
});
15+
16+
vi.mock('../memory-service.js', () => ({
17+
getMemoryService: () => mockService,
18+
}));
19+
20+
vi.mock('../log.js', () => ({
21+
getLog: () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }),
22+
}));
23+
24+
vi.mock('@ownpilot/core', () => ({
25+
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
26+
}));
27+
28+
const { runMemoryRetentionCleanup, startMemoryRetention, stopMemoryRetention } =
29+
await import('./retention.js');
30+
31+
const DAY_MS = 24 * 60 * 60 * 1000;
32+
33+
beforeEach(() => {
34+
vi.clearAllMocks();
35+
});
36+
37+
afterEach(() => {
38+
stopMemoryRetention();
39+
vi.useRealTimers();
40+
});
41+
42+
describe('runMemoryRetentionCleanup', () => {
43+
it('runs decay then cleanup by default', async () => {
44+
await runMemoryRetentionCleanup('default');
45+
expect(mockService.decayMemories).toHaveBeenCalledWith('default');
46+
expect(mockService.cleanupMemories).toHaveBeenCalledWith('default');
47+
});
48+
49+
it('skips decay when { decay: false }', async () => {
50+
await runMemoryRetentionCleanup('default', { decay: false });
51+
expect(mockService.decayMemories).not.toHaveBeenCalled();
52+
expect(mockService.cleanupMemories).toHaveBeenCalledTimes(1);
53+
});
54+
55+
it('still runs cleanup when decay throws', async () => {
56+
mockService.decayMemories.mockRejectedValueOnce(new Error('boom'));
57+
await runMemoryRetentionCleanup('default');
58+
expect(mockService.cleanupMemories).toHaveBeenCalledTimes(1);
59+
});
60+
61+
it('never throws when cleanup throws', async () => {
62+
mockService.cleanupMemories.mockRejectedValueOnce(new Error('boom'));
63+
await expect(runMemoryRetentionCleanup('default')).resolves.toBeUndefined();
64+
});
65+
});
66+
67+
describe('startMemoryRetention', () => {
68+
it('runs a cleanup-only boot pass immediately (no decay)', async () => {
69+
vi.useFakeTimers();
70+
startMemoryRetention('default');
71+
// Flush the microtask from the fire-and-forget boot pass.
72+
await vi.advanceTimersByTimeAsync(0);
73+
expect(mockService.cleanupMemories).toHaveBeenCalledTimes(1);
74+
expect(mockService.decayMemories).not.toHaveBeenCalled();
75+
});
76+
77+
it('runs a full decay + cleanup pass on the daily tick', async () => {
78+
vi.useFakeTimers();
79+
startMemoryRetention('default');
80+
await vi.advanceTimersByTimeAsync(0); // boot pass
81+
expect(mockService.decayMemories).not.toHaveBeenCalled();
82+
83+
await vi.advanceTimersByTimeAsync(DAY_MS);
84+
expect(mockService.decayMemories).toHaveBeenCalledTimes(1);
85+
// boot cleanup + daily cleanup
86+
expect(mockService.cleanupMemories).toHaveBeenCalledTimes(2);
87+
});
88+
89+
it('is idempotent — a second start does not schedule a second timer', async () => {
90+
vi.useFakeTimers();
91+
startMemoryRetention('default');
92+
startMemoryRetention('default');
93+
await vi.advanceTimersByTimeAsync(0);
94+
// Only one boot pass despite two start calls.
95+
expect(mockService.cleanupMemories).toHaveBeenCalledTimes(1);
96+
97+
await vi.advanceTimersByTimeAsync(DAY_MS);
98+
// Only one daily decay despite two start calls.
99+
expect(mockService.decayMemories).toHaveBeenCalledTimes(1);
100+
});
101+
});
102+
103+
describe('stopMemoryRetention', () => {
104+
it('stops the timer so no further passes run', async () => {
105+
vi.useFakeTimers();
106+
startMemoryRetention('default');
107+
await vi.advanceTimersByTimeAsync(0);
108+
stopMemoryRetention();
109+
110+
await vi.advanceTimersByTimeAsync(DAY_MS * 3);
111+
expect(mockService.decayMemories).not.toHaveBeenCalled();
112+
});
113+
114+
it('is idempotent', () => {
115+
expect(() => {
116+
stopMemoryRetention();
117+
stopMemoryRetention();
118+
}).not.toThrow();
119+
});
120+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Personal Memory Retention
3+
*
4+
* Always-on daily hygiene for personal memories, mirroring the Claw manager's
5+
* retention timer for its system tables (services/claw/manager-helpers.ts).
6+
*
7+
* A full pass runs importance *decay* (ages out stale, unaccessed memories)
8+
* followed by *cleanup* (deletes already-dead entries: importance < 0.1 AND
9+
* older than the cutoff AND not accessed within it). This is pure retention —
10+
* no LLM calls, no conversation extraction, no semantic consolidation — so it
11+
* is safe to run by default without the privacy/cost concerns that keep the
12+
* `memory_extract` / `memory_consolidate` triggers opt-in.
13+
*
14+
* Cadence note: `cleanup` is an idempotent DELETE (re-running deletes the same
15+
* dead set once), so it runs on boot AND on the daily tick — short-lived /
16+
* frequently-restarted processes still get their storage bounded. `decay` is a
17+
* *compounding* UPDATE (importance *= 0.9), so it runs ONLY on the daily
18+
* interval tick, never on the boot pass: this bounds decay to at most once per
19+
* 24h of continuous uptime and keeps it independent of restart frequency (a
20+
* dev process that restarts 20×/day must not decay a memory 20×).
21+
*/
22+
23+
import { getErrorMessage } from '@ownpilot/core';
24+
import { getMemoryService } from '../memory-service.js';
25+
import { getLog } from '../log.js';
26+
27+
const log = getLog('MemoryRetention');
28+
29+
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // once per day
30+
31+
/** Owner whose memories are maintained in single-tenant mode. */
32+
const RETENTION_OWNER_ID = 'default';
33+
34+
let retentionTimer: ReturnType<typeof setInterval> | null = null;
35+
36+
/**
37+
* Run one retention pass for the given owner. Never throws — each step is
38+
* isolated so a decay failure still lets cleanup run, and so the daily timer
39+
* keeps firing. Pass `{ decay: false }` for the boot pass to skip the
40+
* compounding decay step (see cadence note above).
41+
*/
42+
export async function runMemoryRetentionCleanup(
43+
userId: string = RETENTION_OWNER_ID,
44+
options: { decay?: boolean } = {}
45+
): Promise<void> {
46+
const { decay = true } = options;
47+
const service = getMemoryService();
48+
49+
if (decay) {
50+
try {
51+
const decayed = await service.decayMemories(userId);
52+
if (decayed > 0) log.info(`Decayed ${decayed} stale memories`);
53+
} catch (err) {
54+
log.warn(`Memory decay failed: ${getErrorMessage(err)}`);
55+
}
56+
}
57+
58+
try {
59+
const cleaned = await service.cleanupMemories(userId);
60+
if (cleaned > 0) log.info(`Cleaned up ${cleaned} dead memories`);
61+
} catch (err) {
62+
log.warn(`Memory cleanup failed: ${getErrorMessage(err)}`);
63+
}
64+
}
65+
66+
/**
67+
* Start the always-on daily retention timer. Runs an immediate cleanup-only
68+
* pass, then a full decay + cleanup pass once per day. Idempotent — a second
69+
* call while already running is a no-op.
70+
*/
71+
export function startMemoryRetention(userId: string = RETENTION_OWNER_ID): void {
72+
if (retentionTimer) return;
73+
74+
// Boot pass: cleanup only (idempotent), no compounding decay.
75+
void runMemoryRetentionCleanup(userId, { decay: false });
76+
77+
retentionTimer = setInterval(() => {
78+
void runMemoryRetentionCleanup(userId, { decay: true });
79+
}, CLEANUP_INTERVAL_MS);
80+
// Don't hold the process open just for this cleanup — Node should be free to
81+
// exit when nothing else keeps the event loop alive (matches ClawManager).
82+
retentionTimer.unref?.();
83+
84+
log.info('Memory retention scheduler started (daily decay + cleanup)');
85+
}
86+
87+
/** Stop the retention timer (graceful shutdown / test teardown). Idempotent. */
88+
export function stopMemoryRetention(): void {
89+
if (retentionTimer) {
90+
clearInterval(retentionTimer);
91+
retentionTimer = null;
92+
}
93+
}

0 commit comments

Comments
 (0)