@akanjs/devkit 3.1.0-rc.11 → 3.1.0-rc.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ import type { SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import type { CodeAgentModel } from "./akanCodeModel";
3
+
4
+ type CompactedModel = Pick<CodeAgentModel, "provider" | "id" | "contextWindow">;
5
+
6
+ // The engine compacts above `contextWindow - reserveTokens`, so a share of the window becomes a reserve per model.
7
+ export class AkanCodeCompaction {
8
+ // Twice the engine's 16,384: at 16k a reasoning model stops mid-thought at the compaction threshold,
9
+ // and the recovery re-sends a near-full window to retry the turn.
10
+ static readonly reserveFloor = 32_768;
11
+ static readonly steps = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] as const;
12
+
13
+ static isStep(percent: number) {
14
+ return AkanCodeCompaction.steps.some((step) => step === percent);
15
+ }
16
+
17
+ /** `100` is the default itself, so it is stored as no choice at all. */
18
+ static normalize(percent: number | undefined) {
19
+ if (percent === undefined) return undefined;
20
+ if (!AkanCodeCompaction.isStep(percent))
21
+ throw new Error(`Autocompact takes a percent in steps of 10: ${AkanCodeCompaction.steps.join(", ")}.`);
22
+ return percent === 100 ? undefined : percent;
23
+ }
24
+
25
+ static thresholdOf(contextWindow: number, percent: number | undefined) {
26
+ const share = percent ? Math.floor((contextWindow * percent) / 100) : contextWindow;
27
+ return Math.min(share, contextWindow - AkanCodeCompaction.reserveFloor);
28
+ }
29
+
30
+ static apply(settings: SettingsManager, model: CompactedModel, percent: number | undefined) {
31
+ const threshold = AkanCodeCompaction.thresholdOf(model.contextWindow, percent);
32
+ // Keyed by model, so the next model is never checked against a reserve sized for this one's window.
33
+ settings.applyOverrides({
34
+ compaction: {
35
+ modelOverrides: { [`${model.provider}/${model.id}`]: { reserveTokens: model.contextWindow - threshold } },
36
+ },
37
+ });
38
+ }
39
+
40
+ /** Why `percent` cannot work on `model`, or undefined when it can. */
41
+ static problemOf(model: CompactedModel, percent: number, keepRecentTokens: number) {
42
+ const name = `${model.provider}/${model.id}`;
43
+ if (model.contextWindow <= 0) return `${name} declares no context window, so it has no share to compact at.`;
44
+ const threshold = AkanCodeCompaction.thresholdOf(model.contextWindow, percent);
45
+ if (threshold >= keepRecentTokens) return undefined;
46
+ const k = (tokens: number) => `${Math.round(tokens / 1000)}k`;
47
+ return `Compacting at ${percent}% of the ${k(model.contextWindow)} window of ${name} starts at ${k(threshold)}, under the ${k(keepRecentTokens)} of recent turns every compaction keeps, so every turn would compact again.`;
48
+ }
49
+ }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { type InlineExtension, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
5
5
  import type { CodeAgentProfile } from "akanjs/common";
6
6
  import { AkanEnvKeys } from "../tools/AkanEnvKeys";
7
+ import { AkanCodeCompaction } from "./AkanCodeCompaction";
7
8
  import { type AkanContextFile, AkanContextFiles } from "./AkanContextFiles";
8
9
  import { akanCodePaths } from "./akanCodePaths";
9
10
  import { akanSystemPrompt } from "./akanSystemPrompt";
@@ -27,9 +28,7 @@ export class AkanCodeServices {
27
28
  enableInstallTelemetry: false,
28
29
  enableAnalytics: false,
29
30
  quietStartup: true,
30
- // Twice the engine's 16,384: at 16k a reasoning model stops mid-thought at the compaction threshold,
31
- // and the recovery re-sends a near-full window to retry the turn.
32
- compaction: { reserveTokens: 32_768 },
31
+ compaction: { reserveTokens: AkanCodeCompaction.reserveFloor },
33
32
  });
34
33
  }
35
34
 
@@ -25,6 +25,7 @@ import {
25
25
  } from "akanjs/common";
26
26
  import type { Workspace } from "../../commandDecorators";
27
27
  import { AkanCodePlugins } from "../tools/AkanCodePlugins";
28
+ import { AkanCodeCompaction } from "./AkanCodeCompaction";
28
29
  import { AkanCodeServices } from "./AkanCodeServices";
29
30
  import {
30
31
  akanCodeModel,
@@ -52,6 +53,8 @@ export interface CodeAgentOptions {
52
53
  cwd?: string;
53
54
  profile: CodeAgentProfile;
54
55
  model?: CodeAgentModelRef;
56
+ /** Percent of the context window that starts a compaction, in steps of 10. */
57
+ autoCompactAt?: number;
55
58
  /** Apps whose dev server and preview the turn-end feedback loop watches. */
56
59
  apps?: string[];
57
60
  extensions?: InlineExtension[];
@@ -85,6 +88,7 @@ export class CodeAgent {
85
88
  readonly #replay: CodeAgentEvent[] = [];
86
89
  #replayFrom = 0;
87
90
  #lastContextTokens: number | undefined;
91
+ #autoCompactAt: number | undefined;
88
92
 
89
93
  private constructor(profile: CodeAgentProfile) {
90
94
  this.#profile = profile;
@@ -95,6 +99,7 @@ export class CodeAgent {
95
99
  const workspaceRoot = options.workspace.workspaceRoot;
96
100
  const cwd = options.cwd ?? workspaceRoot;
97
101
  const agent = new CodeAgent(options.profile);
102
+ const autoCompactAt = AkanCodeCompaction.normalize(options.autoCompactAt);
98
103
  const settingsManager = AkanCodeServices.settings();
99
104
  const modelRuntime = await AkanCodeServices.runtime(workspaceRoot, CodeAgentProxy.fromEnv(options.profile));
100
105
  const model = await akanCodeModel(modelRuntime, options.model);
@@ -146,10 +151,12 @@ export class CodeAgent {
146
151
  agent.#workspaceRoot = workspaceRoot;
147
152
  if (options.profile.session.store !== "memory") agent.#sessionDir = akanCodePaths.sessionsDir(workspaceRoot);
148
153
  await agent.#attach(session, options.mode ?? "print");
154
+ const autoCompactWarning = agent.#fitAutoCompact(autoCompactAt);
149
155
  agent.#suspended = new CodeAgentSuspended(CodeAgentSuspended.fileOf(agent.#sessionDir, agent.sessionId));
150
156
  if (agent.#sessionDir && options.profile.session.store === "file")
151
157
  agent.#openMail(akanCodePaths.mailDir(workspaceRoot), cwd);
152
- for (const message of modelWarnings) agent.#emit({ type: "notice", level: "warning", message });
158
+ for (const message of [...modelWarnings, ...autoCompactWarning])
159
+ agent.#emit({ type: "notice", level: "warning", message });
153
160
  return agent;
154
161
  }
155
162
 
@@ -232,6 +239,7 @@ export class CodeAgent {
232
239
  contextTokens: model?.contextWindow,
233
240
  // Not `off`: that is a setting of a reasoning model, not the absence of reasoning.
234
241
  effort: session.supportsThinking() ? session.thinkingLevel : undefined,
242
+ autoCompactAt: this.#autoCompactAt,
235
243
  name: session.sessionName,
236
244
  interaction: this.#profile.interaction,
237
245
  };
@@ -396,9 +404,42 @@ export class CodeAgent {
396
404
  const model = await akanCodeModel(session.modelRuntime, ref);
397
405
  if (!model) throw new Error(`Unknown model: ${ref.provider}/${ref.id}`);
398
406
  await session.setModel(model);
407
+ for (const message of this.#fitAutoCompact(this.#autoCompactAt))
408
+ this.#emit({ type: "notice", level: "warning", message });
399
409
  this.#emit({ type: "session", info: this.info });
400
410
  }
401
411
 
412
+ setAutoCompactAt(percent: number | undefined) {
413
+ const wanted = AkanCodeCompaction.normalize(percent);
414
+ const problem = this.#autoCompactProblem(wanted);
415
+ if (problem) throw new Error(problem);
416
+ this.#autoCompactAt = wanted;
417
+ this.#applyAutoCompact();
418
+ this.#emit({ type: "session", info: this.info });
419
+ }
420
+
421
+ // A share that fit one window can fall under the kept turns of a smaller one, and would compact every turn.
422
+ #fitAutoCompact(percent: number | undefined) {
423
+ const problem = this.#autoCompactProblem(percent);
424
+ this.#autoCompactAt = problem ? undefined : percent;
425
+ this.#applyAutoCompact();
426
+ return problem ? [`${problem} Autocompact is back to the default.`] : [];
427
+ }
428
+
429
+ #autoCompactProblem(percent: number | undefined) {
430
+ const session = this.#require();
431
+ const model = session.model;
432
+ if (!model || !percent) return undefined;
433
+ const { keepRecentTokens } = session.settingsManager.getCompactionSettings(model);
434
+ return AkanCodeCompaction.problemOf(model, percent, keepRecentTokens);
435
+ }
436
+
437
+ // Never before `create` has built the session: `settingsManager.reload()` drops whatever `applyOverrides` set.
438
+ #applyAutoCompact() {
439
+ const session = this.#require();
440
+ if (session.model) AkanCodeCompaction.apply(session.settingsManager, session.model, this.#autoCompactAt);
441
+ }
442
+
402
443
  async waitForIdle() {
403
444
  await this.#require().waitForIdle();
404
445
  }
@@ -83,6 +83,9 @@ export class CodeAgentRpcHost {
83
83
  case "set_model":
84
84
  await this.#agent.setModel({ provider: command.provider, id: command.modelId });
85
85
  return await this.#agent.state();
86
+ case "set_auto_compact":
87
+ this.#agent.setAutoCompactAt(command.percent);
88
+ return await this.#agent.state();
86
89
  case "get_state":
87
90
  return await this.#agent.state(command.sinceSeq === undefined ? {} : { sinceSeq: command.sinceSeq });
88
91
  case "shutdown":
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { AkanCodeCompaction } from "./AkanCodeCompaction";
3
+ import { AkanCodeServices } from "./AkanCodeServices";
4
+
5
+ const big = { provider: "deepseek", id: "deepseek-flash", contextWindow: 1_000_000 };
6
+ const small = { provider: "openai", id: "small", contextWindow: 128_000 };
7
+
8
+ describe("AkanCodeCompaction", () => {
9
+ test("a percent is a share of the window, never closer to the edge than the reply's floor", () => {
10
+ expect(AkanCodeCompaction.thresholdOf(1_000_000, 60)).toBe(600_000);
11
+ expect(AkanCodeCompaction.thresholdOf(128_000, 90)).toBe(128_000 - AkanCodeCompaction.reserveFloor);
12
+ expect(AkanCodeCompaction.thresholdOf(1_000_000, undefined)).toBe(1_000_000 - AkanCodeCompaction.reserveFloor);
13
+ });
14
+
15
+ test("only steps of 10 are taken, and 100 is the default rather than a choice", () => {
16
+ expect(AkanCodeCompaction.normalize(60)).toBe(60);
17
+ expect(AkanCodeCompaction.normalize(100)).toBeUndefined();
18
+ expect(AkanCodeCompaction.normalize(undefined)).toBeUndefined();
19
+ for (const wrong of [0, 55, 110, Number.NaN]) expect(() => AkanCodeCompaction.normalize(wrong)).toThrow();
20
+ });
21
+
22
+ test("the engine reads the share back as this model's reserve and leaves other models at the floor", () => {
23
+ const settings = AkanCodeServices.settings();
24
+ AkanCodeCompaction.apply(settings, big, 60);
25
+ expect(settings.getCompactionSettings(big).reserveTokens).toBe(400_000);
26
+ expect(settings.getCompactionSettings(small).reserveTokens).toBe(AkanCodeCompaction.reserveFloor);
27
+ AkanCodeCompaction.apply(settings, big, undefined);
28
+ expect(settings.getCompactionSettings(big).reserveTokens).toBe(AkanCodeCompaction.reserveFloor);
29
+ });
30
+
31
+ test("a share under the turns every compaction keeps is refused, since each turn would compact again", () => {
32
+ expect(AkanCodeCompaction.problemOf(big, 10, 20_000)).toBeUndefined();
33
+ expect(AkanCodeCompaction.problemOf(small, 10, 20_000)).toContain("every turn would compact again");
34
+ expect(AkanCodeCompaction.problemOf({ ...small, contextWindow: 0 }, 50, 20_000)).toContain("no context window");
35
+ });
36
+ });
@@ -24,16 +24,31 @@ const chunk = (delta: object, finish: string | null = null) =>
24
24
  choices: [{ index: 0, delta, finish_reason: finish }],
25
25
  })}\n\n`;
26
26
 
27
+ const usageChunk = (promptTokens: number) =>
28
+ `data: ${JSON.stringify({
29
+ id: "c1",
30
+ object: "chat.completion.chunk",
31
+ created: 0,
32
+ model: akanCodeDefaultModel.id,
33
+ choices: [],
34
+ usage: { prompt_tokens: promptTokens, completion_tokens: 1, total_tokens: promptTokens + 1 },
35
+ })}\n\n`;
36
+
27
37
  let llm: ReturnType<typeof Bun.serve>;
28
38
  let llmCalls = 0;
39
+ let reportedTokens = 0;
29
40
  beforeAll(() => {
30
41
  llm = Bun.serve({
31
42
  port: 0,
32
43
  fetch: () => {
33
44
  llmCalls += 1;
34
- return new Response(`${chunk({ role: "assistant", content: "ok" })}${chunk({}, "stop")}data: [DONE]\n\n`, {
35
- headers: { "content-type": "text/event-stream" },
36
- });
45
+ const usage = reportedTokens ? usageChunk(reportedTokens) : "";
46
+ return new Response(
47
+ `${chunk({ role: "assistant", content: "ok" })}${chunk({}, "stop")}${usage}data: [DONE]\n\n`,
48
+ {
49
+ headers: { "content-type": "text/event-stream" },
50
+ },
51
+ );
37
52
  },
38
53
  });
39
54
  });
@@ -84,6 +99,49 @@ describe("CodeAgent", () => {
84
99
  }
85
100
  });
86
101
 
102
+ test("autocompact is carried from creation, announced on the session frame, and refuses a step it lacks", async () => {
103
+ const root = await makeTempRoot();
104
+ const agent = await createAgent(root, { autoCompactAt: 60 });
105
+ try {
106
+ const events: CodeAgentEvent[] = [];
107
+ agent.on((event) => events.push(event));
108
+ expect(agent.info.autoCompactAt).toBe(60);
109
+ expect(() => agent.setAutoCompactAt(55)).toThrow("steps of 10");
110
+ expect(agent.info.autoCompactAt).toBe(60);
111
+ agent.setAutoCompactAt(100);
112
+ expect(agent.info.autoCompactAt).toBeUndefined();
113
+ expect(events.filter((event) => event.type === "session")).toHaveLength(1);
114
+ } finally {
115
+ agent.dispose();
116
+ }
117
+ });
118
+
119
+ test("a chosen share reaches the engine: a reply reported past it compacts, and the default lets it pass", async () => {
120
+ const compactions = async (options: Partial<CodeAgentOptions>) => {
121
+ const agent = await createAgent(await makeTempRoot(), options);
122
+ const reasons: string[] = [];
123
+ agent.on((event) => {
124
+ if (event.type === "compaction" && event.phase === "start") reasons.push(event.reason);
125
+ });
126
+ try {
127
+ // The 20k most recent tokens are kept, so a summary needs an older turn behind the filler.
128
+ for (const text of ["hello", "filler ".repeat(20_000)]) {
129
+ await agent.prompt(text);
130
+ await agent.waitForIdle();
131
+ }
132
+ reportedTokens = 650_000;
133
+ await agent.prompt("next");
134
+ await agent.waitForIdle();
135
+ } finally {
136
+ reportedTokens = 0;
137
+ agent.dispose();
138
+ }
139
+ return reasons;
140
+ };
141
+ expect(await compactions({})).toEqual([]);
142
+ expect(await compactions({ autoCompactAt: 60 })).toEqual(["threshold"]);
143
+ });
144
+
87
145
  test("a sub-agent hands its plugins the token tally of the tree that opened it", async () => {
88
146
  const root = await makeTempRoot();
89
147
  const build = AkanCodePlugins.build;
@@ -349,6 +349,7 @@ describe("codeAgentEventLabel", () => {
349
349
  tools: [],
350
350
  contextTokens: undefined,
351
351
  effort: undefined,
352
+ autoCompactAt: undefined,
352
353
  name: undefined,
353
354
  interaction: { question: "await", approval: "await" },
354
355
  },
@@ -1,5 +1,6 @@
1
1
  // Importing this barrel loads the engine (~122MiB resident); a host needing cheap pieces imports the owning module.
2
2
  // agent/consoleToStderr is deliberately absent: it takes over globalThis.console, so an RPC entry imports it first.
3
+ export * from "./agent/AkanCodeCompaction";
3
4
  export * from "./agent/AkanCodeServices";
4
5
  export * from "./agent/akanCodeModel";
5
6
  export * from "./agent/akanCodePaths";
@@ -78,6 +78,9 @@ Stored under `.akan/code/sessions` — project history, so a second checkout of
78
78
  - `/peers` lists other `akan code` sessions running in this workspace; `/msg <peer> <text>` hands one a
79
79
  message.
80
80
  - `/clear` wipes the screen and keeps the conversation; `/compact [notes]` shortens what the model carries.
81
+ - `/autocompact <percent>` compacts on its own once the window is that full, in steps of 10 (`/autocompact 60`);
82
+ `100` is the default, which waits until 32k remain for the reply. `akan code --compact-at 60` (`-c`) starts
83
+ with it, and a share under the 20k every compaction keeps is refused.
81
84
 
82
85
  ## Model, effort, keys
83
86
 
@@ -119,6 +122,7 @@ akan code -i "<prompt>" open the session with the prompt already sent
119
122
  --app <name> narrow the agent to one app
120
123
  --profile local|pod|review|web what it may do (default local)
121
124
  --model <provider>/<id> --thinking, --json, --rpc, --resume <id>
125
+ --compact-at <percent> compact once the window is this full (10, 20, … 100)
122
126
  ```
123
127
 
124
128
  `review` reads and never writes, loads no project context files, and keeps no session on disk. `pod` has
@@ -26,7 +26,8 @@ For a model `comment` (`<Slice>` is a signal slice suffix, e.g. `InPublic`):
26
26
 
27
27
  ## Generated state — `st.use.*`
28
28
 
29
- `comment`, `commentLoading`, `commentForm`, `commentFormLoading`, `commentSubmit`, `commentModal`,
29
+ `comment`, `commentLoading`, `commentForm`, `commentFormLoading`, `commentFormUploading` (uploads in flight, per
30
+ `File` field — non-empty means do not submit), `commentSubmit`, `commentModal`,
30
31
  `commentViewAt`, `commentOperation`; per slice `commentList<Slice>`, `commentListLoading<Slice>`,
31
32
  `commentInitList<Slice>`, `commentSelection<Slice>`, `commentInsight<Slice>`, `pageOfComment<Slice>`,
32
33
  `lastPageOfComment<Slice>`, `hasMoreOfComment<Slice>`, `limitOfComment<Slice>`, `sortOfComment<Slice>`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.0-rc.11",
3
+ "version": "3.1.0-rc.12",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@earendil-works/pi-tui": "1.3.0",
49
49
  "@inquirer/prompts": "^8.7.3",
50
50
  "@tailwindcss/node": "^4.3.3",
51
- "akanjs": "3.1.0-rc.11",
51
+ "akanjs": "3.1.0-rc.12",
52
52
  "chalk": "^6.0.1",
53
53
  "commander": "^15.0.0",
54
54
  "dayjs": "^1.11.23",