From ddcbb4884c5864c9d93be87230f90412d5c607f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ersin=20KO=C3=87?= Date: Fri, 5 Jun 2026 16:56:26 +0300 Subject: [PATCH] feat(memory): always-on daily personal-memory retention schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Personal-memory decay and cleanup previously ran only via manual REST calls or user-enabled triggers — there was no default daily enforcement, unlike the Claw runtime which already trims its own tables daily. Add services/memory/retention.ts: a daily setInterval (mirroring the ClawManager retention pattern) that runs MemoryService.decayMemories + cleanupMemories for the default owner. Pure hygiene — no LLM, no conversation extraction, no semantic consolidation — so it runs by default while the LLM-driven memory_extract / memory_consolidate triggers stay opt-in. Cadence split: cleanup (idempotent DELETE of importance<0.1, >90d old, unaccessed 90d) runs on boot AND daily; the compounding decay step (importance *= 0.9) runs ONLY on the daily tick, so it stays independent of restart frequency. Timer is unref()'d so it never holds the process open. Wired into server.ts startup (after Autonomy Engine) and shutdown. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 + packages/gateway/src/server.ts | 18 +++ .../src/services/memory/retention.test.ts | 120 ++++++++++++++++++ .../gateway/src/services/memory/retention.ts | 93 ++++++++++++++ 4 files changed, 235 insertions(+) create mode 100644 packages/gateway/src/services/memory/retention.test.ts create mode 100644 packages/gateway/src/services/memory/retention.ts 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; + } +}