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
+45 -3
View File
@@ -17,7 +17,7 @@ import { Permissions, type PermissionConfig } from './permission';
import type { PluginHost } from './plugins';
import { costOf, formatUsd } from './pricing';
import { systemPrompt } from './prompt';
import { detachProviderItems, pruneToFit } from './prune';
import { detachProviderItems, droppedSpan, estimateTokens as pruneEstimateTokens, pruneToFit } from './prune';
import { createSkillTool, renderSkills, type Skill } from './skills';
import { disabledToolNames, onBashOutput, tools as builtinTools, type ToolSetName } from './tools';
@@ -94,7 +94,7 @@ export type SessionOptions = {
onNotebookChange?: (state: NotebookState) => void;
};
const estimateTokens = (messages: ModelMessage[]) => Math.round(JSON.stringify(messages).length / 4);
const estimateTokens = pruneEstimateTokens;
/** Estimated tokens at which the wire history is pruned. */
const DEFAULT_COMPACT_THRESHOLD = 120_000;
@@ -115,6 +115,30 @@ const isStaleItemError = (error: unknown): boolean =>
const STALE_ITEM_NOTICE =
'The provider no longer had part of this session stored. Re-sent the history inline and carried on.';
async function summarizeDiscarded(span: ModelMessage[], model: LanguageModel): Promise<string | undefined> {
if (span.length === 0) return undefined;
const excerpt = span
.map((m) => {
const c = typeof m.content === 'string' ? m.content : JSON.stringify(m.content).slice(0, 2000);
return `${m.role}: ${c}`;
})
.join('\n')
.slice(0, 6000);
if (!excerpt.trim()) return undefined;
try {
const { text } = await generateText({
model,
system: 'Summarize the dropped part of a long coding session so nothing important is lost. Keep: user goals, files touched with paths, decisions and why, tool results that matter, and what remains. One short handover note, 3-6 lines, no preamble.',
prompt: excerpt,
maxRetries: 1,
});
const t = text.trim();
return t.length > 0 ? t : undefined;
} catch {
return undefined;
}
}
type ApprovalContext = Pick<ApprovalRequest, 'matchedPattern' | 'suggestedPattern' | 'repeated'>;
export class Session {
@@ -279,11 +303,14 @@ export class Session {
}
private systemFor(): string {
const mem = this.opts.memory;
// Render project+global when available; fall back to project-only.
const memoryBlock = mem ? (typeof (mem as unknown as { renderWithGlobal?: (n?: number) => string }).renderWithGlobal === 'function' ? (mem as unknown as { renderWithGlobal: (n?: number) => string }).renderWithGlobal() || mem.render() : mem.render()) : '';
return systemPrompt({
cwd: this.opts.cwd ?? process.cwd(),
instructions: this.opts.instructions ?? [],
notebook: this.notebook.render(),
memory: this.opts.memory?.render() ?? '',
memory: memoryBlock,
skills: renderSkills(this.opts.skills ?? []),
agent: renderAgent(this.variant),
plugins: this.opts.plugins?.appendix ?? '',
@@ -439,6 +466,7 @@ export class Session {
// Each iteration is one model run. A run ends either finished, or suspended
// on tool approvals, in which case we collect decisions and run again.
let compactionReported = false;
let compactionSpan: ModelMessage[] | undefined;
while (true) {
const pending: ApprovalRequest[] = [];
const compactions: Extract<AgentEvent, { type: 'compacted' }>[] = [];
@@ -465,6 +493,10 @@ export class Session {
const instructions = this.systemFor();
if (estimateTokens(messages) <= threshold) return { instructions };
const pruned = pruneToFit({ messages, threshold, estimate: estimateTokens });
// Capture what was dropped by reference identity — the lossless note is built after the stream.
if (!compactionReported && pruned.length < messages.length) {
compactionSpan = droppedSpan(messages, pruned);
}
// prepareStep cannot yield, so queue the notice and drain it in the loop.
if (!compactionReported) {
compactions.push({ type: 'compacted', before: messages.length, after: pruned.length });
@@ -581,6 +613,16 @@ export class Session {
this.messages.push(...(await result.responseMessages));
this.opts.onChange?.(this.messages);
// Lossless compaction: summarize what the wire pruned so future turns keep it.
if (compactionSpan && compactionSpan.length > 0) {
const retained = await summarizeDiscarded(compactionSpan, this.model);
if (retained) {
this.messages.push({ role: 'user', content: `Note (retained from compacted history):\n${retained}` });
this.opts.onChange?.(this.messages);
}
compactionSpan = undefined;
}
if (pending.length === 0) {
const usage = await result.usage;
this.inputTokens += usage.inputTokens ?? 0;