Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions packages/gateway/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
120 changes: 120 additions & 0 deletions packages/gateway/src/services/memory/retention.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
93 changes: 93 additions & 0 deletions packages/gateway/src/services/memory/retention.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | 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<void> {
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;
}
}
Loading