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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,47 @@ Only user and assistant messages are persisted, capped at the most recent messag

History written by a version before sessions existed is imported once into a `legacy` bucket, reachable from the `/resume` picker with <kbd>Ctrl</kbd>+<kbd>A</kbd>. Resume it and take a turn and it becomes that project's session; open it only to read and it stays put.

## How it is measured

Woopcode is benchmarked on [Harbor](https://github.com/laude-institute/harbor)'s
`terminal-bench-2` as an installed agent: the harness installs the published CLI
into the task container and runs `woopcode -p` once per task, so what is measured
is the thing users get rather than a bespoke harness build. `harbor_woopcode/`
holds the integration.

Context changes are measured before they ship, against ten recorded benchmark
trajectories rather than against intuition:

```bash
bun run replay:baseline
```

The harness replays each trajectory's prompt assembly and reports peak size and
what a given budget would have done — 932 iterations, no API calls, nothing spent.

**The measurements decide the defaults, including against the obvious answer.**
Tool history is the only part of the prompt that grows: across the corpus, peak
prompt size ran from 22,639 to 219,179 characters while the system prompt,
repository context and conversation stayed flat. Compacting it works by the
character count — 36–43% off peak prompts at matched depth, confirmed live — and
it is **off by default**, because the same benchmark run cost a task that had
been passing. Reading the provider's own token counts back out of both runs
explained why: implicit caching stopped entirely, 18.1M cached tokens of 23.2M
becoming 1.1M of 11.6M, because rewriting the older messages moves the cache
prefix on every request. At iteration 200, uncompacted, only 16k of a 96k prompt
was billed at full rate; compacted, all 29k was. Peak characters fell by two
thirds for roughly no saving.

The code, its tests and the measurements all stay — `WOOPCODE_TOOL_HISTORY_BUDGET`
enables it — but the default follows the billing, not the character count.
`runtime/compaction.ts` carries the full numbers and the two variants worth
trying next.

What the harness cannot tell you is stated where it runs: it reports cache rates
observed for the original recordings, and a modified prompt assembly cannot
inherit them. The fixtures reconstruct prompt *sizes* faithfully; they are not a
conversation that can be replayed against a live provider.

## Built-in tools

Woopcode ships with a fixed set of tools, grouped by what they touch.
Expand Down
5 changes: 2 additions & 3 deletions commands/agent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,10 @@ async function runInteractive(
modelOverride?: string,
session: InitializeOptions = { continueLatest: true },
) {
// Register slash commands
registerCommands();

// Ensure provider is configured (launches onboarding if needed)
// Launches onboarding when nothing is configured, so this may not return
// immediately on a first run.
const { provider, apiKey } = await ensureProviderConfigured();

const config = await getConfig();
Expand Down Expand Up @@ -375,7 +375,6 @@ async function runInteractive(
},

onDone() {
//console.log("onDone received");
store.finishAssistantMessage();
store.setStatus("Ready");
},
Expand Down
7 changes: 2 additions & 5 deletions commands/slash/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,6 @@ const loginCommand: SlashCommand = {
return unsupportedProviderMessage(provider);
}

// Validate API key
const { loginProvider } = await import("../../config/authProvider");
const isValid = await loginProvider(provider, apiKey);

Expand All @@ -358,7 +357,6 @@ const loginCommand: SlashCommand = {
return `Cannot change provider while the agent is running. Press Esc to cancel first.`;
}

// Save the API key
config.providers[provider].apiKey = apiKey;
config.defaultProvider = provider;

Expand Down Expand Up @@ -408,12 +406,11 @@ const logoutCommand: SlashCommand = {
return `Cannot log out of the active provider while the agent is running. Press Esc to cancel first.`;
}

// Remove API key
delete config.providers[provider].apiKey;

// If logging out from default provider, clear default
// Leaving a logged-out provider as the default would send the next turn at
// a provider with no key, so hand the default to one that still has one.
if (config.defaultProvider === provider) {
// Find another logged-in provider
const otherProvider = Object.entries(config.providers).find(
([name, details]: [string, any]) =>
name !== provider && details.apiKey && isProviderEnabled(name)
Expand Down
3 changes: 2 additions & 1 deletion commands/slash/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import type { ParsedCommand } from "./types";
export function parseInput(input: string): ParsedCommand {
const trimmed = input.trim();

//discovery mode
// A bare slash lists what is available rather than failing as an unknown
// command, which is what makes the commands discoverable at all.
if (trimmed === "/") {
return { type: "discovery", originalInput: input };
}
Expand Down
2 changes: 0 additions & 2 deletions commands/slash/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export class SlashCommandRegistry {
return this.getAll().filter((cmd) => cmd.category === category);
}

// Auto-generated help
generateHelp(): string {
const categories = {
session: "Session",
Expand Down Expand Up @@ -55,7 +54,6 @@ export class SlashCommandRegistry {
return output.trim();
}

// Discovery list
generateDiscoveryList(): string {
return this.getAll()
.map((cmd) => `/${cmd.name}`)
Expand Down
1 change: 0 additions & 1 deletion config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export function getConfigDir(): string {
configDir = join(xdgConfigHome, "woopcode");
}

// Ensure directory exists
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
Expand Down
70 changes: 51 additions & 19 deletions config/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,44 @@ async function writeIndex(slug: string, index: SessionIndex): Promise<void> {
await writeJsonAtomic(getSessionIndexPath(slug), index);
}

/**
* What a caller means when the index it wanted to change is not there.
*
* Not a detail to default: saving a record rebuilds from the files on disk so
* the new row joins the existing ones rather than replacing them; pruning
* starts from empty because it is about to write the rows it kept; and removing
* a session does nothing at all, since there is no row to take out and writing
* an index here would create the directory lazy creation exists to avoid.
*/
type MissingIndex = "rebuild" | "empty" | "skip";

/**
* Reads a project's index, applies `mutate`, writes the result back.
*
* Every caller wants those three steps and no caller wants two of them, but
* each spelled the sequence out itself — five copies of a read-modify-write
* that has already lost a row once. This does not close the window between the
* read and the write; two processes still interleave, which is why
* `summariesFor` compares the row count against the files on disk and rebuilds
* when they disagree. It puts the pattern in one place, so the next change to
* it is one edit rather than five.
*/
async function updateIndex(
slug: string,
onMissing: MissingIndex,
mutate: (index: SessionIndex) => SessionIndex,
): Promise<void> {
const existing = await readIndex(slug);
if (!existing && onMissing === "skip") return;

const index = existing ?? {
version: SESSION_VERSION,
sessions: onMissing === "rebuild" ? await rebuildIndex(slug) : [],
};

await writeIndex(slug, mutate(index));
}

/** Session files on disk for a project, ignoring the index entirely. */
function sessionFileCount(slug: string): number {
const directory = getProjectSessionsDir(slug);
Expand Down Expand Up @@ -481,16 +519,15 @@ async function writeSessionRecord(record: SessionRecord): Promise<SessionRecord>
mkdirSync(getProjectSessionsDir(slug), { recursive: true });
await writeJsonAtomic(getSessionPath(slug, trimmed.id), trimmed);

const index = (await readIndex(slug)) ?? {
version: SESSION_VERSION,
sessions: await rebuildIndex(slug),
};
const summary = summarize(trimmed, slug);
const sessions = [
summary,
...index.sessions.filter((session) => session.id !== trimmed.id),
].sort(byRecency);
await writeIndex(slug, { ...index, version: SESSION_VERSION, sessions });
await updateIndex(slug, "rebuild", (index) => ({
...index,
version: SESSION_VERSION,
sessions: [
summary,
...index.sessions.filter((session) => session.id !== trimmed.id),
].sort(byRecency),
}));

return trimmed;
}
Expand Down Expand Up @@ -540,13 +577,10 @@ async function removeFromProject(slug: string, id: string): Promise<void> {
const path = getSessionPath(slug, id);
if (existsSync(path)) rmSync(path, { force: true });

const index = await readIndex(slug);
if (!index) return;

await writeIndex(slug, {
await updateIndex(slug, "skip", (index) => ({
...index,
sessions: index.sessions.filter((session) => session.id !== id),
});
}));
} catch {
// See above: leaving it listed is the safer failure.
}
Expand Down Expand Up @@ -666,13 +700,12 @@ export async function pruneSessions(
}

if (removedHere > 0) {
const index = (await readIndex(slug)) ?? { version: SESSION_VERSION, sessions: [] };
await writeIndex(slug, {
await updateIndex(slug, "empty", (index) => ({
...index,
version: SESSION_VERSION,
lastPrunedAt: now,
sessions: kept,
});
}));
}
}

Expand Down Expand Up @@ -709,8 +742,7 @@ export async function pruneIfDue(

// Stamped even when nothing was removed, or a store with no expired sessions
// would rescan every launch.
const current = (await readIndex(slug)) ?? { version: SESSION_VERSION, sessions: [] };
await writeIndex(slug, { ...current, lastPrunedAt: now });
await updateIndex(slug, "empty", (index) => ({ ...index, lastPrunedAt: now }));

return removed;
}
Expand Down
1 change: 0 additions & 1 deletion onboarding/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ function runOnboarding(): Promise<void> {
}),
);

// Handle Ctrl+C gracefully
const handleExit = () => {
if (!hasCompleted) {
unmount();
Expand Down
1 change: 0 additions & 1 deletion onboarding/setupWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ export function SetupWizard({ onComplete, onError }: SetupWizardProps) {
return;
}

// Save configuration
const config = await getConfig();
config.defaultProvider = selectedProvider.id;
// A provider chosen in the wizard may have no entry yet.
Expand Down
52 changes: 52 additions & 0 deletions packages/tests/config/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,58 @@ describe("an index that has fallen behind the files", () => {
});
});

/**
* A missing index means something different to each caller that writes one, and
* the three answers are not interchangeable. Asserted against the file on disk
* rather than through listSessions, because listSessions heals a wrong index by
* comparing its row count to the files and rebuilding — which would hide every
* one of these.
*/
describe("writing an index that is not there", () => {
test("saving a session rebuilds the other rows instead of replacing them", async () => {
const first = await seed();
rmSync(join(projectDir(), "index.json"), { force: true });

const second = await seed();

const index = JSON.parse(await Bun.file(join(projectDir(), "index.json")).text());
const ids = index.sessions.map((entry: any) => entry.id);
expect(ids).toContain(second.id);
expect(ids).toContain(first.id);
});

test("moving a session out of a project does not create an index there", async () => {
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, "conversation.json"),
JSON.stringify([{ role: "user", content: "legacy work" }]),
);
resetSessionStoreForTests();
const imported = (await migrateLegacyConversation())!;

const legacyIndex = join(sessionsDir, LEGACY_SLUG, "index.json");
rmSync(legacyIndex, { force: true });

await adoptSession(imported);

// Nothing is left in that bucket to list, so an index recording zero rows
// is state the feature promises not to keep.
expect(existsSync(legacyIndex)).toBe(false);
});

test("stamping a prune keeps the sessions it did not remove", async () => {
const saved = await seed();
const indexPath = join(projectDir(), "index.json");
rmSync(indexPath, { force: true });

await pruneIfDue(30);

const index = JSON.parse(await Bun.file(indexPath).text());
expect(index.lastPrunedAt).toBeGreaterThan(0);
expect(index.sessions.map((entry: any) => entry.id)).toEqual([saved.id]);
});
});

describe("retention", () => {
test("removes sessions past the cutoff and keeps the rest", async () => {
const day = 24 * 60 * 60 * 1000;
Expand Down
6 changes: 2 additions & 4 deletions tools/editFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,10 @@ export const editFileTool: Tool = {
return `No changes needed for ${path}`;
}

// Generate unified diff
const diff = createTwoFilesPatch(path, path, content, updated, "", "", {
context: 3,
});

// Create pending edit
const pendingEdit: PendingEdit = {
id: crypto.randomUUID(),
filePath: path,
Expand All @@ -117,7 +115,6 @@ export const editFileTool: Tool = {
toolCallId: crypto.randomUUID(),
};

// Request approval from UI
let approved: boolean;
try {
approved = await store.setPendingEdit(pendingEdit);
Expand All @@ -133,7 +130,8 @@ export const editFileTool: Tool = {
return message;
}

// Write file after approval
// The only write in this tool, and it sits below both exits above. Nothing
// may move above them: the diff review is the product's whole guarantee.
await Bun.write(path, updated);

return outcome.replacements > 1
Expand Down
Loading