diff --git a/CHANGELOG.md b/CHANGELOG.md index defd9a24..2bce40e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **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. + ### Fixed - **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. diff --git a/packages/gateway/src/server.ts b/packages/gateway/src/server.ts index dd71ac47..6dba8574 100644 --- a/packages/gateway/src/server.ts +++ b/packages/gateway/src/server.ts @@ -878,6 +878,16 @@ async function main() { log.warn('Autonomy Engine failed to start', { error: String(error) }); } + // Start always-on personal-memory retention (daily decay + cleanup). Pure + // hygiene — no LLM, no conversation extraction — so it runs by default, + // unlike the opt-in memory_extract / memory_consolidate triggers. + try { + const { startMemoryRetention } = await import('./services/memory/retention.js'); + startMemoryRetention('default'); + } catch (error) { + log.warn('Memory retention scheduler failed to start', { error: String(error) }); + } + // Seed example plans (only creates if not already present) try { const planSeed = await seedExamplePlans('default'); @@ -1098,6 +1108,14 @@ async function main() { log.warn('Heartbeat service reset error', { error: String(e) }); } + // 5.6c-mem. Stop the personal-memory retention scheduler + try { + const { stopMemoryRetention } = await import('./services/memory/retention.js'); + stopMemoryRetention(); + } catch (e) { + log.warn('Memory retention stop error', { error: String(e) }); + } + // 5.6d. Reset memory service try { const { resetMemoryService } = await import('./services/memory-service.js'); diff --git a/packages/gateway/src/services/memory/retention.test.ts b/packages/gateway/src/services/memory/retention.test.ts new file mode 100644 index 00000000..0f05e9e6 --- /dev/null +++ b/packages/gateway/src/services/memory/retention.test.ts @@ -0,0 +1,120 @@ +/** + * Memory retention scheduler tests — mocks the MemoryService and the logger, + * uses fake timers to assert the boot pass vs. daily-tick cadence. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { mockService } = vi.hoisted(() => { + const mockService = { + decayMemories: vi.fn(async () => 0), + cleanupMemories: vi.fn(async () => 0), + }; + return { mockService }; +}); + +vi.mock('../memory-service.js', () => ({ + getMemoryService: () => mockService, +})); + +vi.mock('../log.js', () => ({ + getLog: () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }), +})); + +vi.mock('@ownpilot/core', () => ({ + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), +})); + +const { runMemoryRetentionCleanup, startMemoryRetention, stopMemoryRetention } = + await import('./retention.js'); + +const DAY_MS = 24 * 60 * 60 * 1000; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + stopMemoryRetention(); + vi.useRealTimers(); +}); + +describe('runMemoryRetentionCleanup', () => { + it('runs decay then cleanup by default', async () => { + await runMemoryRetentionCleanup('default'); + expect(mockService.decayMemories).toHaveBeenCalledWith('default'); + expect(mockService.cleanupMemories).toHaveBeenCalledWith('default'); + }); + + it('skips decay when { decay: false }', async () => { + await runMemoryRetentionCleanup('default', { decay: false }); + expect(mockService.decayMemories).not.toHaveBeenCalled(); + expect(mockService.cleanupMemories).toHaveBeenCalledTimes(1); + }); + + it('still runs cleanup when decay throws', async () => { + mockService.decayMemories.mockRejectedValueOnce(new Error('boom')); + await runMemoryRetentionCleanup('default'); + expect(mockService.cleanupMemories).toHaveBeenCalledTimes(1); + }); + + it('never throws when cleanup throws', async () => { + mockService.cleanupMemories.mockRejectedValueOnce(new Error('boom')); + await expect(runMemoryRetentionCleanup('default')).resolves.toBeUndefined(); + }); +}); + +describe('startMemoryRetention', () => { + it('runs a cleanup-only boot pass immediately (no decay)', async () => { + vi.useFakeTimers(); + startMemoryRetention('default'); + // Flush the microtask from the fire-and-forget boot pass. + await vi.advanceTimersByTimeAsync(0); + expect(mockService.cleanupMemories).toHaveBeenCalledTimes(1); + expect(mockService.decayMemories).not.toHaveBeenCalled(); + }); + + it('runs a full decay + cleanup pass on the daily tick', async () => { + vi.useFakeTimers(); + startMemoryRetention('default'); + await vi.advanceTimersByTimeAsync(0); // boot pass + expect(mockService.decayMemories).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(DAY_MS); + expect(mockService.decayMemories).toHaveBeenCalledTimes(1); + // boot cleanup + daily cleanup + expect(mockService.cleanupMemories).toHaveBeenCalledTimes(2); + }); + + it('is idempotent — a second start does not schedule a second timer', async () => { + vi.useFakeTimers(); + startMemoryRetention('default'); + startMemoryRetention('default'); + await vi.advanceTimersByTimeAsync(0); + // Only one boot pass despite two start calls. + expect(mockService.cleanupMemories).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(DAY_MS); + // Only one daily decay despite two start calls. + expect(mockService.decayMemories).toHaveBeenCalledTimes(1); + }); +}); + +describe('stopMemoryRetention', () => { + it('stops the timer so no further passes run', async () => { + vi.useFakeTimers(); + startMemoryRetention('default'); + await vi.advanceTimersByTimeAsync(0); + stopMemoryRetention(); + + await vi.advanceTimersByTimeAsync(DAY_MS * 3); + expect(mockService.decayMemories).not.toHaveBeenCalled(); + }); + + it('is idempotent', () => { + expect(() => { + stopMemoryRetention(); + stopMemoryRetention(); + }).not.toThrow(); + }); +}); diff --git a/packages/gateway/src/services/memory/retention.ts b/packages/gateway/src/services/memory/retention.ts new file mode 100644 index 00000000..1b54b15f --- /dev/null +++ b/packages/gateway/src/services/memory/retention.ts @@ -0,0 +1,93 @@ +/** + * Personal Memory Retention + * + * Always-on daily hygiene for personal memories, mirroring the Claw manager's + * retention timer for its system tables (services/claw/manager-helpers.ts). + * + * A full pass runs importance *decay* (ages out stale, unaccessed memories) + * followed by *cleanup* (deletes already-dead entries: importance < 0.1 AND + * older than the cutoff AND not accessed within it). This is pure retention — + * no LLM calls, no conversation extraction, no semantic consolidation — so it + * is safe to run by default without the privacy/cost concerns that keep the + * `memory_extract` / `memory_consolidate` triggers opt-in. + * + * Cadence note: `cleanup` is an idempotent DELETE (re-running deletes the same + * dead set once), so it runs on boot AND on the daily tick — short-lived / + * frequently-restarted processes still get their storage bounded. `decay` is a + * *compounding* UPDATE (importance *= 0.9), so it runs ONLY on the daily + * interval tick, never on the boot pass: this bounds decay to at most once per + * 24h of continuous uptime and keeps it independent of restart frequency (a + * dev process that restarts 20×/day must not decay a memory 20×). + */ + +import { getErrorMessage } from '@ownpilot/core'; +import { getMemoryService } from '../memory-service.js'; +import { getLog } from '../log.js'; + +const log = getLog('MemoryRetention'); + +const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // once per day + +/** Owner whose memories are maintained in single-tenant mode. */ +const RETENTION_OWNER_ID = 'default'; + +let retentionTimer: ReturnType | null = null; + +/** + * Run one retention pass for the given owner. Never throws — each step is + * isolated so a decay failure still lets cleanup run, and so the daily timer + * keeps firing. Pass `{ decay: false }` for the boot pass to skip the + * compounding decay step (see cadence note above). + */ +export async function runMemoryRetentionCleanup( + userId: string = RETENTION_OWNER_ID, + options: { decay?: boolean } = {} +): Promise { + const { decay = true } = options; + const service = getMemoryService(); + + if (decay) { + try { + const decayed = await service.decayMemories(userId); + if (decayed > 0) log.info(`Decayed ${decayed} stale memories`); + } catch (err) { + log.warn(`Memory decay failed: ${getErrorMessage(err)}`); + } + } + + try { + const cleaned = await service.cleanupMemories(userId); + if (cleaned > 0) log.info(`Cleaned up ${cleaned} dead memories`); + } catch (err) { + log.warn(`Memory cleanup failed: ${getErrorMessage(err)}`); + } +} + +/** + * Start the always-on daily retention timer. Runs an immediate cleanup-only + * pass, then a full decay + cleanup pass once per day. Idempotent — a second + * call while already running is a no-op. + */ +export function startMemoryRetention(userId: string = RETENTION_OWNER_ID): void { + if (retentionTimer) return; + + // Boot pass: cleanup only (idempotent), no compounding decay. + void runMemoryRetentionCleanup(userId, { decay: false }); + + retentionTimer = setInterval(() => { + void runMemoryRetentionCleanup(userId, { decay: true }); + }, CLEANUP_INTERVAL_MS); + // Don't hold the process open just for this cleanup — Node should be free to + // exit when nothing else keeps the event loop alive (matches ClawManager). + retentionTimer.unref?.(); + + log.info('Memory retention scheduler started (daily decay + cleanup)'); +} + +/** Stop the retention timer (graceful shutdown / test teardown). Idempotent. */ +export function stopMemoryRetention(): void { + if (retentionTimer) { + clearInterval(retentionTimer); + retentionTimer = null; + } +}