Skip to content

Commit e730804

Browse files
AliceLJYclaude
andcommitted
feat: memory compaction — progressive summarization for stored memories
Adds a `MemoryCompactor` that periodically consolidates semantically similar old memories into single refined entries, inspired by the progressive summarization pattern (MemOS). Over time, related memory fragments are merged rather than accumulated, reducing retrieval noise and keeping the LanceDB index lean. Key additions: - `src/memory-compactor.ts`: pure, dependency-free compaction module with cosine-similarity clustering, greedy seed expansion, and rule-based merge (dedup lines, max importance, plurality category) - `store.ts`: new `fetchForCompaction()` method that fetches old entries with vectors (intentionally omitted from `list()` for performance) - `index.ts`: `memory_compact` management tool (requires `enableManagementTools: true`) + optional auto-compaction at `gateway_start` with configurable cooldown - `openclaw.plugin.json`: `memoryCompaction` config schema + uiHints - `test/memory-compactor.test.mjs`: 23 tests, 100% pass Config example: memoryCompaction: enabled: true # auto-run at gateway_start minAgeDays: 7 # only touch memories ≥ 7 days old similarityThreshold: 0.88 cooldownHours: 24 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8420d9b commit e730804

5 files changed

Lines changed: 956 additions & 0 deletions

File tree

index.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ import { registerAllMemoryTools } from "./src/tools.js";
2323
import { appendSelfImprovementEntry, ensureSelfImprovementLearningFiles } from "./src/self-improvement-files.js";
2424
import type { MdMirrorWriter } from "./src/tools.js";
2525
import { AccessTracker } from "./src/access-tracker.js";
26+
import {
27+
runCompaction,
28+
shouldRunCompaction,
29+
recordCompactionRun,
30+
type CompactionConfig,
31+
} from "./src/memory-compactor.js";
2632
import { runWithReflectionTransientRetryOnce } from "./src/reflection-retry.js";
2733
import { resolveReflectionSessionSearchDirs, stripResetSuffix } from "./src/session-recovery.js";
2834
import {
@@ -133,6 +139,14 @@ interface PluginConfig {
133139
};
134140
};
135141
mdMirror?: { enabled?: boolean; dir?: string };
142+
memoryCompaction?: {
143+
enabled?: boolean;
144+
minAgeDays?: number;
145+
similarityThreshold?: number;
146+
minClusterSize?: number;
147+
maxMemoriesToScan?: number;
148+
cooldownHours?: number;
149+
};
136150
}
137151

138152
type ReflectionThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
@@ -1613,6 +1627,128 @@ const memoryLanceDBProPlugin = {
16131627
}
16141628
);
16151629

1630+
// ========================================================================
1631+
// Memory Compaction (Progressive Summarization)
1632+
// ========================================================================
1633+
1634+
if (config.enableManagementTools) {
1635+
api.registerTool({
1636+
name: "memory_compact",
1637+
description:
1638+
"Consolidate semantically similar old memories into refined single entries " +
1639+
"(progressive summarization). Reduces noise and improves retrieval quality over time. " +
1640+
"Use dry_run:true first to preview the compaction plan without making changes.",
1641+
inputSchema: {
1642+
type: "object" as const,
1643+
properties: {
1644+
dry_run: {
1645+
type: "boolean",
1646+
description: "Preview clusters without writing changes. Default: false.",
1647+
},
1648+
min_age_days: {
1649+
type: "number",
1650+
description: "Only compact memories at least this many days old. Default: 7.",
1651+
},
1652+
similarity_threshold: {
1653+
type: "number",
1654+
description: "Cosine similarity threshold for clustering [0-1]. Default: 0.88.",
1655+
},
1656+
scopes: {
1657+
type: "array",
1658+
items: { type: "string" },
1659+
description: "Scope filter. Omit to compact all scopes.",
1660+
},
1661+
},
1662+
required: [],
1663+
},
1664+
execute: async (args: Record<string, unknown>) => {
1665+
const compactionCfg: CompactionConfig = {
1666+
enabled: true,
1667+
minAgeDays:
1668+
typeof args.min_age_days === "number"
1669+
? args.min_age_days
1670+
: (config.memoryCompaction?.minAgeDays ?? 7),
1671+
similarityThreshold:
1672+
typeof args.similarity_threshold === "number"
1673+
? Math.max(0, Math.min(1, args.similarity_threshold))
1674+
: (config.memoryCompaction?.similarityThreshold ?? 0.88),
1675+
minClusterSize: config.memoryCompaction?.minClusterSize ?? 2,
1676+
maxMemoriesToScan: config.memoryCompaction?.maxMemoriesToScan ?? 200,
1677+
dryRun: args.dry_run === true,
1678+
cooldownHours: config.memoryCompaction?.cooldownHours ?? 24,
1679+
};
1680+
const scopes =
1681+
Array.isArray(args.scopes) && args.scopes.length > 0
1682+
? (args.scopes as string[])
1683+
: undefined;
1684+
1685+
const result = await runCompaction(
1686+
store,
1687+
embedder,
1688+
compactionCfg,
1689+
scopes,
1690+
api.logger,
1691+
);
1692+
1693+
return {
1694+
content: [
1695+
{
1696+
type: "text",
1697+
text: JSON.stringify(
1698+
{
1699+
scanned: result.scanned,
1700+
clustersFound: result.clustersFound,
1701+
memoriesDeleted: result.memoriesDeleted,
1702+
memoriesCreated: result.memoriesCreated,
1703+
dryRun: result.dryRun,
1704+
summary: result.dryRun
1705+
? `Dry run: found ${result.clustersFound} cluster(s) in ${result.scanned} memories — no changes made.`
1706+
: `Compacted ${result.memoriesDeleted} memories into ${result.memoriesCreated} consolidated entries.`,
1707+
},
1708+
null,
1709+
2,
1710+
),
1711+
},
1712+
],
1713+
};
1714+
},
1715+
});
1716+
}
1717+
1718+
// Auto-compaction at gateway_start (if enabled, respects cooldown)
1719+
if (config.memoryCompaction?.enabled) {
1720+
api.on("gateway_start", () => {
1721+
const compactionStateFile = join(
1722+
dirname(resolvedDbPath),
1723+
".compaction-state.json",
1724+
);
1725+
const compactionCfg: CompactionConfig = {
1726+
enabled: true,
1727+
minAgeDays: config.memoryCompaction!.minAgeDays ?? 7,
1728+
similarityThreshold: config.memoryCompaction!.similarityThreshold ?? 0.88,
1729+
minClusterSize: config.memoryCompaction!.minClusterSize ?? 2,
1730+
maxMemoriesToScan: config.memoryCompaction!.maxMemoriesToScan ?? 200,
1731+
dryRun: false,
1732+
cooldownHours: config.memoryCompaction!.cooldownHours ?? 24,
1733+
};
1734+
1735+
shouldRunCompaction(compactionStateFile, compactionCfg.cooldownHours)
1736+
.then(async (should) => {
1737+
if (!should) return;
1738+
await recordCompactionRun(compactionStateFile);
1739+
const result = await runCompaction(store, embedder, compactionCfg, undefined, api.logger);
1740+
if (result.clustersFound > 0) {
1741+
api.logger.info(
1742+
`memory-compactor [auto]: compacted ${result.memoriesDeleted}${result.memoriesCreated} entries`,
1743+
);
1744+
}
1745+
})
1746+
.catch((err) => {
1747+
api.logger.warn(`memory-compactor [auto]: failed: ${String(err)}`);
1748+
});
1749+
});
1750+
}
1751+
16161752
// ========================================================================
16171753
// Register CLI Commands
16181754
// ========================================================================
@@ -2882,6 +3018,24 @@ export function parsePluginConfig(value: unknown): PluginConfig {
28823018
: undefined,
28833019
}
28843020
: undefined,
3021+
memoryCompaction: (() => {
3022+
const raw =
3023+
typeof cfg.memoryCompaction === "object" && cfg.memoryCompaction !== null
3024+
? (cfg.memoryCompaction as Record<string, unknown>)
3025+
: null;
3026+
if (!raw) return undefined;
3027+
return {
3028+
enabled: raw.enabled === true,
3029+
minAgeDays: parsePositiveInt(raw.minAgeDays) ?? 7,
3030+
similarityThreshold:
3031+
typeof raw.similarityThreshold === "number"
3032+
? Math.max(0, Math.min(1, raw.similarityThreshold))
3033+
: 0.88,
3034+
minClusterSize: parsePositiveInt(raw.minClusterSize) ?? 2,
3035+
maxMemoriesToScan: parsePositiveInt(raw.maxMemoriesToScan) ?? 200,
3036+
cooldownHours: parsePositiveInt(raw.cooldownHours) ?? 24,
3037+
};
3038+
})(),
28853039
};
28863040
}
28873041

openclaw.plugin.json

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,49 @@
489489
"description": "Fallback directory for Markdown mirror files when agent workspace is unknown"
490490
}
491491
}
492+
},
493+
"memoryCompaction": {
494+
"type": "object",
495+
"additionalProperties": false,
496+
"description": "Progressive summarization: periodically consolidate semantically similar old memories into refined single entries, reducing noise and improving retrieval quality over time.",
497+
"properties": {
498+
"enabled": {
499+
"type": "boolean",
500+
"default": false,
501+
"description": "Enable automatic compaction at gateway startup (respects cooldownHours)"
502+
},
503+
"minAgeDays": {
504+
"type": "integer",
505+
"default": 7,
506+
"minimum": 1,
507+
"description": "Only compact memories at least this many days old"
508+
},
509+
"similarityThreshold": {
510+
"type": "number",
511+
"default": 0.88,
512+
"minimum": 0,
513+
"maximum": 1,
514+
"description": "Cosine similarity threshold for clustering. Higher = more conservative merges."
515+
},
516+
"minClusterSize": {
517+
"type": "integer",
518+
"default": 2,
519+
"minimum": 2,
520+
"description": "Minimum cluster size required to trigger a merge"
521+
},
522+
"maxMemoriesToScan": {
523+
"type": "integer",
524+
"default": 200,
525+
"minimum": 1,
526+
"description": "Maximum number of memories to scan per compaction run"
527+
},
528+
"cooldownHours": {
529+
"type": "integer",
530+
"default": 24,
531+
"minimum": 1,
532+
"description": "Minimum hours between automatic compaction runs"
533+
}
534+
}
492535
}
493536
},
494537
"required": [
@@ -811,6 +854,25 @@
811854
"label": "Mirror Fallback Directory",
812855
"help": "Fallback directory when agent workspace mapping is unavailable",
813856
"advanced": true
857+
},
858+
"memoryCompaction.enabled": {
859+
"label": "Auto Compaction",
860+
"help": "Automatically consolidate similar old memories at gateway startup. Also available on-demand via the memory_compact tool (requires enableManagementTools)."
861+
},
862+
"memoryCompaction.minAgeDays": {
863+
"label": "Min Age (days)",
864+
"help": "Memories younger than this are never touched by compaction",
865+
"advanced": true
866+
},
867+
"memoryCompaction.similarityThreshold": {
868+
"label": "Similarity Threshold",
869+
"help": "How similar two memories must be to merge (0–1). 0.88 is a good starting point; raise to 0.92+ for conservative merges.",
870+
"advanced": true
871+
},
872+
"memoryCompaction.cooldownHours": {
873+
"label": "Cooldown (hours)",
874+
"help": "Minimum gap between automatic compaction runs",
875+
"advanced": true
814876
}
815877
}
816878
}

0 commit comments

Comments
 (0)