Enhance memory management and compaction features

- Implement global memory layer for cross-project patterns.
- Improve memory entry scoring with recency and tokenization.
- Adjust MAX_TEXT limit from 400 to 800 for better context retention.
- Add TTL pruning for stale memory entries.
- Capture and summarize dropped content during compaction.
- Update tests to reflect changes in memory behavior and compaction logic.
This commit is contained in:
asepharyana
2026-09-08 21:27:47 +07:00
parent b1ff00d840
commit 7db69f19da
8 changed files with 522 additions and 36 deletions
+64 -9
View File
@@ -173,8 +173,37 @@ export function prunePreservingItems(options: PruneOptions): ModelMessage[] {
* One agent step is two messages — the assistant's tool call and the tool message
* answering it — so 64 is about 32 steps of memory.
*/
export function droppedSpan(before: ModelMessage[], after: ModelMessage[]): ModelMessage[] {
const norm = (m: ModelMessage) => {
const c = (m as { content?: unknown }).content;
if (typeof c === 'string') return `${m.role}:${c}`;
try {
// Ignore reasoning parts and all providerOptions: a kept-but-detached
// message (reasoning stripped, itemId removed) is not considered dropped.
const filtered = Array.isArray(c)
? c.filter((p: unknown) => (p as { type?: string }).type !== 'reasoning')
: c;
const stripped = JSON.stringify(filtered, (k, v) => (k === 'providerOptions' ? undefined : k === 'itemId' ? undefined : v));
return `${m.role}:${stripped}`;
} catch {
return `${m.role}:${String(c)}`;
}
};
const keptNorm = new Set(after.map(norm));
return before.filter((m) => !keptNorm.has(norm(m)));
}
const KEEP_LADDER = [64, 32, 16, 8, 4] as const;
/**
* Token estimate used by the session harness. `len/4` undercounts tool envelopes
* (role + toolCallId + providerOptions); `len/3.6 + 8*msgs` tracks cl100k closer
* without pulling a tokenizer. Exported so session and tests share it.
*/
export function estimateTokens(messages: ModelMessage[]): number {
return Math.round(JSON.stringify(messages).length / 3.6 + messages.length * 8);
}
export type FitOptions = {
messages: ModelMessage[];
/** Estimated tokens the wire history must come in under. */
@@ -202,21 +231,47 @@ export type FitOptions = {
* not, the narrowest is returned, because sending something is better than sending a
* request that will be rejected for size.
*/
/** Head that must never be pruned: the initial user goal and first assistant ack. */
function headOf(messages: ModelMessage[]): ModelMessage[] {
if (messages.length === 0) return [];
const firstUser = messages.find((m) => m.role === 'user');
if (!firstUser) return [];
// Keep first user message; if an assistant immediately follows, keep it too (goal ack).
const idx = messages.indexOf(firstUser);
const next = messages[idx + 1];
if (next && next.role === 'assistant' && idx === 0) return messages.slice(0, 2);
return [firstUser];
}
function withHeadPreserved(all: ModelMessage[], pruned: ModelMessage[]): ModelMessage[] {
const head = headOf(all);
if (head.length === 0) return pruned;
// If head already in pruned (by identity of content's first 80 chars), leave it.
const headText = JSON.stringify(head[0]!.content).slice(0, 80);
if (pruned.some((m) => JSON.stringify(m.content).slice(0, 80) === headText)) return pruned;
// Prepend head; dedupe if head was partially kept.
return [...head, ...pruned];
}
export function pruneToFit({ messages, threshold, estimate }: FitOptions): ModelMessage[] {
const withoutReasoning = detachProviderItems(
prunePreservingItems({ messages, reasoning: 'all', emptyMessages: 'remove' }),
const withoutReasoning = withHeadPreserved(
messages,
detachProviderItems(prunePreservingItems({ messages, reasoning: 'all', emptyMessages: 'remove' })),
);
if (estimate(withoutReasoning) <= threshold) return withoutReasoning;
let narrowest = withoutReasoning;
for (const keep of KEEP_LADDER) {
narrowest = detachProviderItems(
prunePreservingItems({
messages,
reasoning: 'all',
toolCalls: `before-last-${keep}-messages`,
emptyMessages: 'remove',
}),
narrowest = withHeadPreserved(
messages,
detachProviderItems(
prunePreservingItems({
messages,
reasoning: 'all',
toolCalls: `before-last-${keep}-messages`,
emptyMessages: 'remove',
}),
),
);
if (estimate(narrowest) <= threshold) return narrowest;
}