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
51 changes: 47 additions & 4 deletions apps/core/src/agent/loop.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,9 @@ import {
invalidateProjectContext,
} from "../context/tree-cache.js";
import { MemoryService, renderPromptMemoryContext } from "../compaction/index.js";
import { createLlmSummarizer } from "../compaction/llm-summarizer.js";
import type { CompactOptions } from "../compaction/service.js";
import { getModelContextLimit } from "../models-dev.js";
import { getProvider } from "../providers/index.js";
import { createHookRuntime, type HookRuntime } from "../hooks/runtime.js";
import type { HookResult } from "../agent/types.js";
Expand DownExpand Up@@ -392,6 +395,40 @@ export class AgentLoop {
}
}

// Resolve the model's real context window from models.dev so compaction
// fires against the actual limit (200K/1M) rather than a conservative
// fallback. Returns undefined on lookup failure (offline / unknown model),
// which makes shouldCompact fall back to the local model table.
private async resolveContextLimit(
provider: string,
model: string | undefined,
): Promise<number | undefined> {
if (!model) return undefined;
try {
const limit = await getModelContextLimit(provider, model);
return limit > 0 ? limit : undefined;
} catch {
return undefined;
}
}

// Build compaction options that summarize via the active provider/model.
// Cancellable through the loop's AbortController. On provider-lookup failure
// returns {}, so MemoryService.compact falls back to the heuristic summary.
private compactOptions(
provider: string,
model: string | undefined,
): CompactOptions {
try {
const aiProvider = getProvider(provider as any);
return {
llmSummarize: createLlmSummarizer(aiProvider, model, this.abort.signal),
};
} catch {
return {};
}
}

// ===========================================================================
// PRIVATE: executeTurn()
// One iteration: build prompt → send to provider → normalize → parse → execute
Expand DownExpand Up@@ -511,9 +548,12 @@ export class AgentLoop {
if (toolCalls.length === 0) {
this.memory.addMessage("assistant", providerResult.content);
await this.appendAssistantMessage(providerResult.content);
if (this.memory.shouldCompact(model ?? provider)) {
const contextLimit = await this.resolveContextLimit(provider, model);
if (this.memory.shouldCompact(model ?? provider, contextLimit)) {
// PreCompact/PostCompact hooks run inside MemoryService.compact()
const result = await this.memory.compact();
const result = await this.memory.compact(
this.compactOptions(provider, model),
);
if (result.success && result.summary) {
this.recorder.recordCompactOccurred(
result.tokenCountBefore,
Expand DownExpand Up@@ -576,9 +616,12 @@ export class AgentLoop {
providerResult.content || `[Executed ${toolCalls.length} tools]`,
);

if (this.memory.shouldCompact(model ?? provider)) {
const contextLimit = await this.resolveContextLimit(provider, model);
if (this.memory.shouldCompact(model ?? provider, contextLimit)) {
// PreCompact/PostCompact hooks run inside MemoryService.compact()
const result = await this.memory.compact();
const result = await this.memory.compact(
this.compactOptions(provider, model),
);
if (result.success && result.summary) {
this.recorder.recordCompactOccurred(
result.tokenCountBefore,
Expand Down
1 change: 1 addition & 0 deletions apps/core/src/compaction/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,5 +2,6 @@ export * from "./types.js";
export * from "./tokens.js";
export * from "./selector.js";
export * from "./summarizer.js";
export * from "./llm-summarizer.js";
export * from "./storage.js";
export * from "./service.js";
61 changes: 61 additions & 0 deletions apps/core/src/compaction/llm-summarizer.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
import type { AIProvider } from "../providers/types.js";
import type { MemoryMessage } from "./types.js";
import type { SummarizeInput } from "./summarizer.js";

/**
* Produces the markdown body of a compaction summary. Throws on failure so the
* caller can fall back to the heuristic summarizer.
*/
export type LlmSummarize = (input: SummarizeInput) => Promise<string>;

const SYSTEM_PROMPT = `You compress a coding-assistant session into a concise handoff summary so work can continue after older messages are dropped.

Output GitHub-flavored markdown using exactly these section headers, in this order:
## Goal
## Done
## In Progress
## Blocked
## Decisions
## Relevant Files
## Next Steps

Rules:
- Be factual and terse. Do not invent progress that isn't in the transcript.
- Preserve exact file paths, identifiers, commands, and unresolved errors.
- Under "Blocked", list only real, still-open blockers.
- If a section has nothing, write "- (none)".`;

function renderTranscript(messages: MemoryMessage[]): string {
return messages.map((m) => `${m.role}: ${m.content}`).join("\n\n");
}

export function createLlmSummarizer(
provider: AIProvider,
model: string | undefined,
abortSignal?: AbortSignal,
): LlmSummarize {
return async (input) => {
const parts: string[] = [];
if (input.previousSummary) {
parts.push(
`Previous summary (carry forward and update, don't repeat verbatim):\n\n${input.previousSummary}`,
);
}
parts.push(
`Session transcript to summarize:\n\n${renderTranscript(input.messages)}`,
);

const result = await provider.execute({
system: SYSTEM_PROMPT,
prompt: parts.join("\n\n---\n\n"),
model,
temperature: 0,
maxTokens: 1_024,
abortSignal,
});

const content = result.content?.trim();
if (!content) throw new Error("provider returned an empty summary");
return content;
};
}
33 changes: 28 additions & 5 deletions apps/core/src/compaction/service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,9 +11,17 @@ import {
} from "./types.js";
import { estimateTokenCount, shouldCompact } from "./tokens.js";
import { selectForCompaction } from "./selector.js";
import { summarizeMessages } from "./summarizer.js";
import { makeSummary, summarizeMessages } from "./summarizer.js";
import type { LlmSummarize } from "./llm-summarizer.js";
import { FileMemoryStorage, type MemoryStorage } from "./storage.js";

export interface CompactOptions {
// When provided, used to generate the summary; on failure the heuristic
// summarizer is used instead. The agent loop supplies this with the current
// provider/model so summaries are real LLM output rather than keyword bullets.
llmSummarize?: LlmSummarize;
}

interface MemoryServiceOptions {
config?: Partial<CompactionConfig>;
storage?: MemoryStorage;
Expand DownExpand Up@@ -68,7 +76,9 @@ export class MemoryService {
return message;
}

shouldCompact(model: string): boolean {
// `contextLimit` comes from models.dev (getModelContextLimit) when available;
// omit it to fall back to the local model table in tokens.ts.
shouldCompact(model: string, contextLimit?: number): boolean {
if (
this.blockedAtTokenCount !== undefined &&
this.state.tokenCount <
Expand All@@ -80,6 +90,7 @@ export class MemoryService {
this.state.tokenCount,
model,
this.config.autoCompactBufferTokens,
contextLimit,
);
}

Expand All@@ -91,7 +102,7 @@ export class MemoryService {
};
}

async compact(): Promise<CompactionResult> {
async compact(options: CompactOptions = {}): Promise<CompactionResult> {
const selected = selectForCompaction(this.state.messages, this.config);
if (selected.summarize.length === 0) {
return {
Expand DownExpand Up@@ -121,11 +132,23 @@ export class MemoryService {
}

const previousSummary = this.state.summaries.at(-1)?.content;
const summary = summarizeMessages({
const summarizeInput = {
sessionId: this.state.sessionId,
previousSummary,
messages: selected.summarize,
});
};
let summary = summarizeMessages(summarizeInput);
if (options.llmSummarize) {
try {
const content = await options.llmSummarize(summarizeInput);
summary = makeSummary(summarizeInput, content);
} catch (err) {
// Fall back to the heuristic summary already computed above.
console.warn(
`[MemoryService] LLM summarization failed, using heuristic: ${err instanceof Error ? err.message : String(err)}`,
);
}
}

const tokenCountAfter =
summary.summaryTokenCount + selected.preserveTokenCount;
Expand Down
30 changes: 19 additions & 11 deletions apps/core/src/compaction/storage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,14 +3,20 @@ import {
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { MemoryState } from "./types.js";

const SESSION_DIR = ".freecode/sessions";
// Home-rooted like every other ~/.freecode subsystem (sessions, rollout,
// state, config). Previously this defaulted to process.cwd(), which scattered
// compaction memory into whatever directory core was launched from (e.g. the
// repo root) and orphaned it from the session it belongs to.
const MEMORY_ROOT = join(homedir(), ".freecode", "memory");

export interface MemoryStorage {
save(state: MemoryState): void;
Expand All@@ -20,10 +26,10 @@ export interface MemoryStorage {
}

export class FileMemoryStorage implements MemoryStorage {
constructor(private readonly basePath = process.cwd()) {}
constructor(private readonly basePath = MEMORY_ROOT) {}

private sessionDir(sessionId: string): string {
return join(this.basePath, SESSION_DIR, sessionId);
return join(this.basePath, sessionId);
}

private memoryPath(sessionId: string): string {
Expand All@@ -32,10 +38,13 @@ export class FileMemoryStorage implements MemoryStorage {

save(state: MemoryState): void {
mkdirSync(this.sessionDir(state.sessionId), { recursive: true });
writeFileSync(
this.memoryPath(state.sessionId),
JSON.stringify(state, null, 2),
);
// Atomic write: a crash mid-write must not corrupt memory.json (load()
// treats a corrupt file as "no memory" and discards the whole session's
// state). Write to a temp file, then rename — rename is atomic on POSIX.
const dest = this.memoryPath(state.sessionId);
const tmp = `${dest}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(state, null, 2));
renameSync(tmp, dest);
}

load(sessionId: string): MemoryState | undefined {
Expand All@@ -50,10 +59,9 @@ export class FileMemoryStorage implements MemoryStorage {
}

listSessions(): string[] {
const dir = join(this.basePath, SESSION_DIR);
if (!existsSync(dir)) return [];
return readdirSync(dir).filter((entry) =>
statSync(join(dir, entry)).isDirectory(),
if (!existsSync(this.basePath)) return [];
return readdirSync(this.basePath).filter((entry) =>
statSync(join(this.basePath, entry)).isDirectory(),
);
}

Expand Down
53 changes: 33 additions & 20 deletions apps/core/src/compaction/summarizer.ts
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
import type { CompactionSummary, MemoryMessage } from "./types.js";
import { estimateTokenCount } from "./tokens.js";

interface SummarizeInput {
export interface SummarizeInput {
sessionId: string;
previousSummary?: string;
messages: MemoryMessage[];
}

/**
* Wrap summary content (from either the heuristic or an LLM) into a
* CompactionSummary, computing the token bookkeeping consistently.
*/
export function makeSummary(
input: SummarizeInput,
content: string,
): CompactionSummary {
const originalTokenCount = input.messages.reduce(
(sum, message) => sum + message.tokenCount,
0,
);
return {
id: `summary-${input.sessionId}-${Date.now()}`,
createdAt: Date.now(),
originalMessageCount: input.messages.length,
originalTokenCount,
summaryTokenCount: estimateTokenCount(content),
content,
};
}

function clip(text: string, maxChars: number): string {
return text.length <= maxChars ? text : `${text.slice(0, maxChars)}...`;
}
Expand DownExpand Up@@ -47,11 +69,15 @@ function extractWorkStatus(messages: MemoryMessage[]): {
const content = msg.content.toLowerCase();
const summary = clip(msg.content, 120);

if (
content.includes("blocked") ||
content.includes("waiting") ||
content.includes("error")
) {
// Word-boundary match so "no errors"/"error handling" don't count as a
// blocker, and negations ("no error", "without errors") are excluded.
const isBlocked =
/\b(blocked|waiting|errors?|failed|failing)\b/.test(content) &&
!/\b(no|without|zero|resolved|fixed)\s+(errors?|blockers?|issues?)\b/.test(
content,
);

if (isBlocked) {
if (!blocked.includes(summary)) blocked.push(summary);
} else if (
content.includes("working on") ||
Expand DownExpand Up@@ -187,18 +213,5 @@ export function summarizeMessages(input: SummarizeInput): CompactionSummary {
lines.push("- Continue from where the session left off");
}

const content = lines.join("\n");
const originalTokenCount = input.messages.reduce(
(sum, message) => sum + message.tokenCount,
0,
);

return {
id: `summary-${input.sessionId}-${Date.now()}`,
createdAt: Date.now(),
originalMessageCount: input.messages.length,
originalTokenCount,
summaryTokenCount: estimateTokenCount(content),
content,
};
return makeSummary(input, lines.join("\n"));
}
17 changes: 12 additions & 5 deletions apps/core/src/compaction/tokens.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,15 +11,22 @@ test("estimateTokenCount uses a conservative char estimate", () => {
assert.equal(estimateTokenCount("Hello World"), 3);
});

test("getContextLimit falls back for unknown models", () => {
test("getContextLimit returns the offline fallback floor", () => {
assert.equal(getContextLimit("unknown-model"), 100_000);
assert.equal(getContextLimit("gpt-4o"), 100_000);
});

test("getAutoCompactThreshold reserves compaction buffer", () => {
assert.equal(getAutoCompactThreshold("gpt-4o", 13_000), 115_000);
assert.equal(getAutoCompactThreshold("gpt-4o", 13_000), 87_000);
});

test("shouldCompact is only true at or above threshold", () => {
assert.equal(shouldCompact(114_999, "gpt-4o", 13_000), false);
assert.equal(shouldCompact(115_000, "gpt-4o", 13_000), true);
test("shouldCompact uses the fallback limit when no explicit limit is given", () => {
assert.equal(shouldCompact(86_999, "gpt-4o", 13_000), false);
assert.equal(shouldCompact(87_000, "gpt-4o", 13_000), true);
});

test("shouldCompact prefers an explicit (models.dev) context limit", () => {
// 200K model with a 13K buffer → threshold 187K, not the 87K fallback.
assert.equal(shouldCompact(150_000, "gpt-4o", 13_000, 200_000), false);
assert.equal(shouldCompact(187_000, "gpt-4o", 13_000, 200_000), true);
});
Loading