Skip to content

Commit 3ff7cbd

Browse files
committed
memory decay + pruning (auto+man)
1 parent 4eaaf27 commit 3ff7cbd

5 files changed

Lines changed: 99 additions & 0 deletions

File tree

apps/code/src/main/services/memory/service.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,33 @@ import { logger } from "../../utils/logger";
88

99
const log = logger.scope("memory");
1010

11+
const MAINTENANCE_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
12+
const DECAY_RATE = 0.02;
13+
const DECAY_MIN_AGE_DAYS = 1;
14+
const PRUNE_THRESHOLD = 0.1;
15+
const PRUNE_MIN_AGE_DAYS = 30;
16+
1117
function getDataDir(): string {
1218
return join(app.getPath("userData"), "memory");
1319
}
1420

1521
@injectable()
1622
export class MemoryService {
1723
private svc: AgentMemoryService | null = null;
24+
private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
1825

1926
@postConstruct()
2027
initialize(): void {
2128
const dataDir = getDataDir();
2229
log.info("Initializing memory service", { dataDir });
2330
this.svc = new AgentMemoryService({ dataDir });
2431
log.info("Memory service ready", { count: this.svc.count() });
32+
33+
this.runMaintenance();
34+
this.maintenanceTimer = setInterval(
35+
() => this.runMaintenance(),
36+
MAINTENANCE_INTERVAL_MS,
37+
);
2538
}
2639

2740
get service(): AgentMemoryService {
@@ -81,8 +94,28 @@ export class MemoryService {
8194
return this.service.getAssociations(memoryId);
8295
}
8396

97+
runMaintenance(): { decayed: number; pruned: number } {
98+
try {
99+
const decayed = this.service.decayImportance(
100+
DECAY_RATE,
101+
DECAY_MIN_AGE_DAYS,
102+
);
103+
const pruned = this.service.prune(PRUNE_THRESHOLD, PRUNE_MIN_AGE_DAYS);
104+
const total = this.service.count();
105+
log.info("Memory maintenance complete", { decayed, pruned, total });
106+
return { decayed, pruned };
107+
} catch (error) {
108+
log.error("Memory maintenance failed", { error });
109+
return { decayed: 0, pruned: 0 };
110+
}
111+
}
112+
84113
@preDestroy()
85114
close(): void {
115+
if (this.maintenanceTimer) {
116+
clearInterval(this.maintenanceTimer);
117+
this.maintenanceTimer = null;
118+
}
86119
if (this.svc) {
87120
log.info("Closing memory service");
88121
this.svc.close();

apps/code/src/main/trpc/routers/memory.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export const memoryRouter = router({
4949
.output(z.array(associationSchema))
5050
.query(({ input }) => getService().getAssociations(input.memoryId)),
5151

52+
maintenance: publicProcedure
53+
.output(z.object({ decayed: z.number(), pruned: z.number() }))
54+
.mutation(() => getService().runMaintenance()),
55+
5256
seed: publicProcedure.output(z.number()).mutation(() => getService().seed()),
5357

5458
reset: publicProcedure.mutation(() => {

apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,23 @@ export function AdvancedSettings() {
242242
}),
243243
);
244244

245+
const maintenanceMutation = useMutation(
246+
trpc.memory.maintenance.mutationOptions({
247+
onSuccess: ({ decayed, pruned }) => {
248+
toast.success(`Maintenance: decayed ${decayed}, pruned ${pruned}`);
249+
queryClient.invalidateQueries({
250+
queryKey: trpc.memory.count.queryKey(),
251+
});
252+
queryClient.invalidateQueries({
253+
queryKey: trpc.memory.list.queryKey(),
254+
});
255+
},
256+
onError: () => {
257+
toast.error("Failed to run memory maintenance");
258+
},
259+
}),
260+
);
261+
245262
return (
246263
<Flex direction="column">
247264
<SettingRow
@@ -312,6 +329,19 @@ export function AdvancedSettings() {
312329
{resetMutation.isPending ? "Resetting..." : "Reset"}
313330
</Button>
314331
</SettingRow>
332+
<SettingRow
333+
label="Run maintenance"
334+
description="Decay old memory importance and prune low-value memories"
335+
>
336+
<Button
337+
variant="soft"
338+
size="1"
339+
disabled={maintenanceMutation.isPending}
340+
onClick={() => maintenanceMutation.mutate()}
341+
>
342+
{maintenanceMutation.isPending ? "Running..." : "Maintain"}
343+
</Button>
344+
</SettingRow>
315345
<SettingRow
316346
label="Browse memory data"
317347
description="Inspect raw memory records and associations"

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,16 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
375375
if (message.subtype === "compact_boundary") {
376376
lastAssistantTotalUsage = 0;
377377
promptReplayed = true;
378+
379+
// Flush memory buffer before context is compacted
380+
if (this.options?.memoryService) {
381+
this.logger.info("Compaction detected, flushing memory buffer");
382+
this.options.memoryService.distill().catch((err: unknown) => {
383+
this.logger.error("Pre-compaction distillation failed", {
384+
error: err,
385+
});
386+
});
387+
}
378388
}
379389
if (message.subtype === "local_command_output") {
380390
promptReplayed = true;

packages/agent/src/memory/agent-memory.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ import {
2727

2828
const DEFAULT_DISTILL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
2929
const DEFAULT_DISTILL_MIN_CHUNK = 2000; // chars
30+
const EAGER_DISTILL_THRESHOLD = 8000; // chars — trigger distill mid-conversation
3031
const DEFAULT_RECALL_TOKEN_BUDGET = 1500;
3132
const DEFAULT_EXTRACTION_MODEL = "claude-sonnet-4-20250514";
33+
const DECAY_RATE = 0.02;
34+
const DECAY_MIN_AGE_DAYS = 1;
3235

3336
// ── Scoring ─────────────────────────────────────────────────────────────────
3437

@@ -115,6 +118,7 @@ export class AgentMemoryManager {
115118
// Periodic distillation timer
116119
private distillTimer: ReturnType<typeof setInterval> | null = null;
117120
private distilling = false;
121+
private hasRunMaintenance = false;
118122

119123
constructor(config: MemoryServiceConfig) {
120124
this.config = config;
@@ -157,6 +161,12 @@ export class AgentMemoryManager {
157161
query: context.slice(0, 80),
158162
});
159163

164+
if (!this.hasRunMaintenance) {
165+
const decayed = this.svc.decayImportance(DECAY_RATE, DECAY_MIN_AGE_DAYS);
166+
this.hasRunMaintenance = true;
167+
this.logger.info("Session maintenance: decayed memories", { decayed });
168+
}
169+
160170
const scored = this.searchScored(context, options);
161171
const selected = this.selectWithinBudget(scored, tokenBudget);
162172

@@ -201,6 +211,18 @@ export class AgentMemoryManager {
201211
bufferSize: this.bufferCharCount,
202212
bufferEntries: this.buffer.length,
203213
});
214+
215+
if (this.bufferCharCount >= EAGER_DISTILL_THRESHOLD) {
216+
this.logger.info(
217+
"Buffer threshold reached, triggering eager distillation",
218+
{
219+
bufferSize: this.bufferCharCount,
220+
},
221+
);
222+
this.distill().catch((err) => {
223+
this.logger.error("Eager distillation error", { error: err });
224+
});
225+
}
204226
}
205227

206228
/**

0 commit comments

Comments
 (0)