@akanjs/devkit 3.1.0-rc.8 → 3.1.0

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +382 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/akanConfig/akanConfig.test.ts +8 -4
  5. package/akanConfig/akanConfig.ts +2 -1
  6. package/applicationBuildRunner.test.ts +77 -2
  7. package/applicationBuildRunner.ts +28 -5
  8. package/artifact/implicitRootLayout.test.ts +11 -1
  9. package/artifact/implicitRootLayout.ts +32 -7
  10. package/biome.base.json +8 -3
  11. package/biomeBase.test.ts +78 -0
  12. package/biomeBase.ts +1 -1
  13. package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
  14. package/codeAgent/agent/AkanCodeServices.ts +2 -3
  15. package/codeAgent/agent/CodeAgent.ts +46 -2
  16. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  17. package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
  18. package/codeAgent/agent/codeAgent.test.ts +61 -3
  19. package/codeAgent/agent/codeAgentContract.test.ts +1 -0
  20. package/codeAgent/index.ts +1 -0
  21. package/codeAgent/skills/akan-code/SKILL.md +4 -0
  22. package/codeAgent/skills/akan-store/SKILL.md +4 -2
  23. package/codeAgent/tools/AkanCodePlugins.ts +3 -1
  24. package/codeAgent/tools/DeepSeekWeb.ts +127 -0
  25. package/codeAgent/tools/WebToolPack.ts +149 -25
  26. package/codeAgent/tools/akanCodePlugins.test.ts +191 -0
  27. package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
  28. package/commandDecorators/argMeta.ts +1 -0
  29. package/commandDecorators/command.ts +49 -10
  30. package/commandDecorators/commandBuilder.ts +6 -3
  31. package/commandDecorators/commandDecorators.test.ts +169 -6
  32. package/commandDecorators/helpFormatter.ts +2 -1
  33. package/commandDecorators/targetMeta.ts +10 -0
  34. package/executors.test.ts +53 -1
  35. package/executors.ts +10 -6
  36. package/frontendBuild/csrArtifactBuilder.test.ts +40 -0
  37. package/frontendBuild/csrArtifactBuilder.ts +24 -1
  38. package/frontendBuild/cssCandidateCache.test.ts +16 -0
  39. package/frontendBuild/cssCandidateCache.ts +8 -6
  40. package/frontendBuild/fontPruner.ts +1 -0
  41. package/frontendBuild/mcpAppViewBuilder.test.ts +170 -0
  42. package/frontendBuild/mcpAppViewBuilder.ts +245 -0
  43. package/incrementalBuilder/buildBatch.proc.ts +3 -3
  44. package/incrementalBuilder/buildBatchProtocol.ts +2 -2
  45. package/lint/__fixtures__/no-conditional-st-hook/bad.tsx +45 -0
  46. package/lint/__fixtures__/no-conditional-st-hook/good.tsx +33 -0
  47. package/lint/no-conditional-st-hook.grit +146 -0
  48. package/mobile/desktopServerStage.ts +1 -0
  49. package/package.json +21 -21
  50. package/piDependencyPins.test.ts +18 -19
  51. package/routeSourceValidator.test.ts +19 -4
  52. package/routeSourceValidator.ts +62 -14
  53. package/scanInfo.ts +36 -3
  54. package/subspace.test.ts +3 -2
  55. package/subspaceClone.ts +1 -12
  56. package/transforms/mcpAppPageTrimmer.test.ts +89 -0
  57. package/transforms/mcpAppPageTrimmer.ts +70 -0
  58. package/transforms/pureLazyMarker.test.ts +58 -0
  59. package/transforms/pureLazyMarker.ts +42 -0
  60. package/transforms/sideEffectImportOrder.test.ts +183 -0
  61. package/transforms/sideEffectImportOrder.ts +109 -0
@@ -18,6 +18,7 @@ import { Spinner } from "./spinner";
18
18
  import { createServerEnvPlugin } from "./transforms/serverEnvPlugin";
19
19
  import { ServerEnvProfile } from "./transforms/serverEnvProfile";
20
20
  import { ProjectTypecheck } from "./typecheck/projectTypecheck";
21
+ import type { BaseDevEnv } from "./types";
21
22
 
22
23
  export interface TypecheckOptions {
23
24
  clean?: boolean;
@@ -97,14 +98,34 @@ export class ApplicationBuildRunner {
97
98
  return path.join(this.#app.cwdPath, "env");
98
99
  }
99
100
 
101
+ //? Bundles bake AKAN_PUBLIC_* from process.env, where the shell outranks the .env that the server env and the
102
+ //? Dockerfile read: a page built otherwise drops the image's tokens as foreign or dials another serve domain.
103
+ #pinImageEnv(baseDevEnv: BaseDevEnv, environment: string) {
104
+ const imageEnv = {
105
+ AKAN_PUBLIC_ENV: environment,
106
+ AKAN_PUBLIC_REPO_NAME: baseDevEnv.repoName,
107
+ AKAN_PUBLIC_SERVE_DOMAIN: baseDevEnv.serveDomain,
108
+ };
109
+ const outranked = Object.entries(imageEnv).filter(([key, value]) => {
110
+ const given = process.env[key];
111
+ return !!given && given !== value && !(key === "AKAN_PUBLIC_ENV" && this.#environment);
112
+ });
113
+ if (outranked.length)
114
+ this.#app.logger.warn(
115
+ `The workspace .env outranks this process's environment: the bundles and the image take ${outranked
116
+ .map(([key, value]) => `${key}=${value} (not ${process.env[key]})`)
117
+ .join(", ")}.`,
118
+ );
119
+ Object.assign(process.env, imageEnv);
120
+ }
121
+
100
122
  async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
101
123
  // serial build is needed because of Bun.build is unstable for parallel build
102
124
  this.#spinner = spinner;
103
125
  const { web, assets, baseDevEnv } = await this.#app.getConfig();
104
126
  const environment = this.#environment ?? baseDevEnv.env;
105
127
  WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, "akan build");
106
- //? Bundles bake AKAN_PUBLIC_ENV from process.env, where the shell outranks the .env that picked the server env.
107
- process.env.AKAN_PUBLIC_ENV = environment;
128
+ this.#pinImageEnv(baseDevEnv, environment);
108
129
  //? Before any phase: a profile that would ship a secret stops the build, not the image after it.
109
130
  if (this.#profile)
110
131
  await ServerEnvProfile.assertShippable({ envDir: this.#envDir, environment, profile: this.#profile });
@@ -366,9 +387,11 @@ void run().catch((error) => {
366
387
 
367
388
  //? Opt-in and outside `dist/`: a metafile is the size of the module graph, and `dist/` is what the image copies.
368
389
  async #buildOrThrow(label: string, config: Bun.BuildConfig): Promise<Bun.BuildOutput> {
369
- const metafileDir = process.env.AKAN_BUILD_METAFILE
370
- ? path.join(this.#app.workspace.workspaceRoot, "local", "build-meta", path.basename(this.#app.cwdPath))
371
- : null;
390
+ const flag = process.env.AKAN_BUILD_METAFILE;
391
+ const metafileDir =
392
+ flag && flag !== "0" && flag !== "false"
393
+ ? path.join(this.#app.workspace.workspaceRoot, "local", "build-meta", path.basename(this.#app.cwdPath))
394
+ : null;
372
395
  const result = await Bun.build(metafileDir ? { ...config, metafile: true } : config);
373
396
  if (!result.success) throw new AggregateError(result.logs, `[${label}] Bun.build failed`);
374
397
  if (metafileDir && result.metafile) {
@@ -36,7 +36,9 @@ describe("resolveSsrPageEntries", () => {
36
36
  const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
37
37
  // Both user modules go through the route resolver, so a `rootLayout()` chain and the legacy exports read alike.
38
38
  expect(generatedSource).toContain('import * as inheritedModule from "../../../page/_layout.tsx";');
39
- expect(generatedSource).toContain("const inheritedLayout = resolveRouteModule(inheritedModule as never");
39
+ expect(generatedSource).toContain(
40
+ 'const inheritedLayout = assertGuardOnChain("page/_layout.tsx", resolveRouteModule(inheritedModule as never',
41
+ );
40
42
  expect(generatedSource).not.toContain("<System.Provider");
41
43
  expect(generatedSource).toContain("export async function generateHead(props: PageProps)");
42
44
  // The ancestor root is a level of the route tree, which merges its head; this boundary returns only its own.
@@ -48,6 +50,9 @@ describe("resolveSsrPageEntries", () => {
48
50
  expect(generatedSource).toContain("export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;");
49
51
  // The ancestor root is its own level of the stack, so inheriting its loading would nest it inside itself.
50
52
  expect(generatedSource).toContain("export const Loading = userLayout.Loading;");
53
+ // Its guard runs from that level too, ahead of this one, and an MCP view takes its providers from there.
54
+ expect(generatedSource).toContain("export const guard = userLayout.guard;");
55
+ expect(generatedSource).not.toContain("McpAppRoot");
51
56
  expect(generatedSource).toContain(
52
57
  "<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>",
53
58
  );
@@ -77,6 +82,11 @@ describe("resolveSsrPageEntries", () => {
77
82
  expect(generatedSource).toContain("export const viewport = userLayout.viewport ?? inheritedLayout.viewport;");
78
83
  expect(generatedSource).toContain("viewport={viewport}");
79
84
  expect(generatedSource).toContain("[inheritedLayout, userLayout].map((layout)");
85
+ expect(generatedSource).toContain(
86
+ "export const guard = RouteGuards.compose(inheritedLayout.guard, userLayout.guard);",
87
+ );
88
+ expect(generatedSource).toContain("export function McpAppRoot({ children, params, path }: McpAppRootProps) {");
89
+ expect(generatedSource).toContain("<System.App");
80
90
  expect(generatedSource).toContain('import { allDictionary } from "../dict/useDict.ts";');
81
91
  expect(generatedSource).toContain(
82
92
  'allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}',
@@ -16,8 +16,11 @@ const IMPLICIT_DICT_DIR = path.join(".akan", "generated", "dict");
16
16
  const IMPLICIT_OVERRIDES_DIR = path.join(".akan", "generated", "overrides");
17
17
  const OVERRIDES_KEY_RE = /^\.\/(.+\/)?_overrides\.(tsx|ts|jsx|js)$/;
18
18
 
19
+ const posixRelative = (fromDir: string, toAbsPath: string) =>
20
+ path.relative(fromDir, toAbsPath).split(path.sep).join("/");
21
+
19
22
  const importSpecifier = (fromAbsPath: string, toAbsPath: string) => {
20
- const rel = path.relative(path.dirname(fromAbsPath), toAbsPath).split(path.sep).join("/");
23
+ const rel = posixRelative(path.dirname(fromAbsPath), toAbsPath);
21
24
  return rel.startsWith(".") ? rel : `./${rel}`;
22
25
  };
23
26
 
@@ -162,14 +165,14 @@ async function writeGeneratedRootLayoutFile(opts: {
162
165
  ? `import { st } from "@apps/${opts.appName}/client";\nvoid st;\n`
163
166
  : `import "@apps/${opts.appName}/client";\n`;
164
167
  //? resolveRouteModule reads a rootLayout() chain and the legacy named exports alike.
165
- const inheritedLabel = inheritedSourceAbsPath ? path.relative(opts.appCwdPath, inheritedSourceAbsPath) : "";
168
+ const inheritedLabel = inheritedSourceAbsPath ? posixRelative(opts.appCwdPath, inheritedSourceAbsPath) : "";
166
169
  const inheritedImport = inheritedSourceSpecifier
167
- ? `import * as inheritedModule from ${JSON.stringify(inheritedSourceSpecifier)};\nconst inheritedLayout = resolveRouteModule(inheritedModule as never, ${JSON.stringify(inheritedLabel)}).module as LayoutModule;\n`
170
+ ? `import * as inheritedModule from ${JSON.stringify(inheritedSourceSpecifier)};\nconst inheritedLayout = assertGuardOnChain(${JSON.stringify(inheritedLabel)}, resolveRouteModule(inheritedModule as never, ${JSON.stringify(inheritedLabel)})).module as LayoutModule;\n`
168
171
  : "const inheritedLayout: LayoutModule = {};\n";
169
172
  const prefix = opts.boundary.segments.find((segment) => !/^\(.+\)$/.test(segment)) ?? null;
170
- const userLabel = opts.boundary.sourceAbsPath ? path.relative(opts.appCwdPath, opts.boundary.sourceAbsPath) : "";
173
+ const userLabel = opts.boundary.sourceAbsPath ? posixRelative(opts.appCwdPath, opts.boundary.sourceAbsPath) : "";
171
174
  const userImport = sourceSpecifier
172
- ? `import * as userModule from ${JSON.stringify(sourceSpecifier)};\nconst userLayout = resolveRouteModule(userModule as never, ${JSON.stringify(userLabel)}).module as LayoutModule;\nconst UserLayout = userLayout.default as (props: LayoutProps) => ReactNode | Promise<ReactNode>;\n`
175
+ ? `import * as userModule from ${JSON.stringify(sourceSpecifier)};\nconst userLayout = assertGuardOnChain(${JSON.stringify(userLabel)}, resolveRouteModule(userModule as never, ${JSON.stringify(userLabel)})).module as LayoutModule;\nconst UserLayout = userLayout.default as (props: LayoutProps) => ReactNode | Promise<ReactNode>;\n`
173
176
  : "const UserLayout = ({ children }: LayoutProps) => children;\nconst userLayout: LayoutModule = {};\n";
174
177
  const isAsyncUserLayout = opts.boundary.sourceAbsPath
175
178
  ? await AsyncDefaultExportDetector.detect(opts.boundary.sourceAbsPath)
@@ -191,6 +194,10 @@ async function writeGeneratedRootLayoutFile(opts: {
191
194
  const layoutReturn = isAsyncUserLayout ? "layout" : userLayoutElement;
192
195
  //? Under an ancestor root boundary the route tree merges that root's head itself; merging it here would repeat it.
193
196
  const headLayouts = opts.includeSystemProvider ? "[inheritedLayout, userLayout]" : "[userLayout]";
197
+ //? Likewise for the guard: an ancestor root boundary runs its own from its own layer.
198
+ const guardExport = opts.includeSystemProvider
199
+ ? "export const guard = RouteGuards.compose(inheritedLayout.guard, userLayout.guard);"
200
+ : "export const guard = userLayout.guard;";
194
201
  const layoutExports = `export async function generateHead(props: PageProps) {
195
202
  const heads = await Promise.all(
196
203
  ${headLayouts}.map((layout) => (layout.generateHead ? layout.generateHead(props) : layout.head)),
@@ -202,14 +209,16 @@ export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
202
209
  export const Error = userLayout.Error ?? inheritedLayout.Error;
203
210
  export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
204
211
  export const Loading = userLayout.Loading;
212
+ ${guardExport}
205
213
 
206
214
  ${layoutSignature}({ children, params, searchParams }: LayoutProps) {
207
215
  ${layoutBinding} return `;
208
216
  const source = opts.includeSystemProvider
209
217
  ? `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
210
- import { loadFonts, resolveRouteModule, RouteHead } from "akanjs/client";
218
+ import { assertGuardOnChain, loadFonts, RouteGuards, resolveRouteModule, RouteHead } from "akanjs/client";
211
219
  import type { ReactNode } from "react";
212
220
  import { System } from "akanjs/ui";
221
+ import type { McpAppRootProps } from "akanjs/webkit";
213
222
  import { env } from "@apps/${opts.appName}/env/env.client";
214
223
  import { allDictionary } from ${JSON.stringify(dictMacroSpecifier)};
215
224
  ${clientImport}${inheritedImport}${userImport}
@@ -242,9 +251,25 @@ ${layoutExports}(
242
251
  </System.Provider>
243
252
  );
244
253
  }
254
+
255
+ export function McpAppRoot({ children, params, path }: McpAppRootProps) {
256
+ return (
257
+ <System.App
258
+ params={params}
259
+ path={path}
260
+ env={env}
261
+ theme={theme}
262
+ viewport={viewport}
263
+ fonts={loadFonts(userFonts)}
264
+ className={defaultFontClassName}
265
+ >
266
+ {children}
267
+ </System.App>
268
+ );
269
+ }
245
270
  `
246
271
  : `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
247
- import { resolveRouteModule, RouteHead } from "akanjs/client";
272
+ import { assertGuardOnChain, resolveRouteModule, RouteHead } from "akanjs/client";
248
273
  import type { ReactNode } from "react";
249
274
  ${inheritedImport}${userImport}
250
275
  ${layoutExports}${layoutReturn};
package/biome.base.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.5.12/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
3
3
  "vcs": {
4
4
  "enabled": true,
5
5
  "clientKind": "git",
@@ -128,7 +128,8 @@
128
128
  "!**/*.constant.ts",
129
129
  "!**/common/**",
130
130
  "!**/env/**",
131
- "!{apps,libs}/*/native/**"
131
+ "!{apps,libs}/*/native/**",
132
+ "!apps/*/script/migration/**"
132
133
  ],
133
134
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-throw-raw-error.grit"]
134
135
  },
@@ -195,7 +196,11 @@
195
196
  },
196
197
  {
197
198
  "includes": ["{apps,libs}/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
198
- "plugins": ["./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit"]
199
+ "linter": { "rules": { "correctness": { "useHookAtTopLevel": "error" } } },
200
+ "plugins": [
201
+ "./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit",
202
+ "./node_modules/@akanjs/devkit/lint/no-conditional-st-hook.grit"
203
+ ]
199
204
  },
200
205
  {
201
206
  "includes": [
@@ -0,0 +1,78 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { spawn } from "node:child_process";
3
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { biomeBaseConfig } from "./biomeBase";
7
+ import { Linter, parseBiomeReport } from "./linter";
8
+
9
+ const devkitDir = import.meta.dir;
10
+ const biomeBin = path.resolve(devkitDir, "../../../node_modules/.bin", Linter.biomeBinName);
11
+
12
+ const hookProbe = `export const Early = ({ id }: { id?: string }) => {
13
+ if (!id) return null;
14
+ const [count] = useState(0);
15
+ return <p>{count}</p>;
16
+ };
17
+ export const Branch = ({ open }: { open: boolean }) => {
18
+ if (open) useEffect(() => {}, []);
19
+ return null;
20
+ };
21
+ `;
22
+
23
+ const tempDirs: string[] = [];
24
+
25
+ afterAll(async () => {
26
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
27
+ });
28
+
29
+ const runBiome = (args: string[], cwd: string) =>
30
+ new Promise<string>((resolve, reject) => {
31
+ const proc = spawn(biomeBin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
32
+ let stdout = "";
33
+ let stderr = "";
34
+ proc.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
35
+ proc.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString()));
36
+ proc.on("error", reject);
37
+ proc.on("close", () => resolve(stdout || stderr));
38
+ });
39
+
40
+ const lintWorkspace = async (files: string[]) => {
41
+ const dir = await mkdtemp(path.join(os.tmpdir(), "biome-base-"));
42
+ tempDirs.push(dir);
43
+ //? The base config names its plugins as `./node_modules/@akanjs/devkit/…`, resolved from the extending config's
44
+ //? folder. A junction needs no privilege on Windows, and the type is ignored elsewhere.
45
+ await mkdir(path.join(dir, "node_modules", "@akanjs"), { recursive: true });
46
+ await symlink(devkitDir, path.join(dir, "node_modules", "@akanjs", "devkit"), "junction");
47
+ await writeFile(path.join(dir, "biome.json"), JSON.stringify({ extends: [biomeBaseConfig] }));
48
+ for (const file of files) {
49
+ await mkdir(path.dirname(path.join(dir, file)), { recursive: true });
50
+ await writeFile(path.join(dir, file), hookProbe);
51
+ }
52
+ const output = await runBiome(["lint", "--reporter=json", "--max-diagnostics=none", ...files], dir);
53
+ return parseBiomeReport(output).diagnostics ?? [];
54
+ };
55
+
56
+ describe("biome.base.json", () => {
57
+ test("reports a conditional React hook in app and lib components, not in their tests or in packages", async () => {
58
+ const diagnostics = await lintWorkspace([
59
+ "apps/demo/ui/Probe.tsx",
60
+ "libs/demo/ui/Probe.tsx",
61
+ "apps/demo/ui/Probe.test.tsx",
62
+ "pkgs/demo/Probe.tsx",
63
+ ]);
64
+ const hookReports = diagnostics
65
+ .filter((diagnostic) => diagnostic.category === "lint/correctness/useHookAtTopLevel")
66
+ .map(
67
+ (diagnostic) =>
68
+ `${diagnostic.location?.path?.replaceAll("\\", "/")}:${diagnostic.location?.start?.line} ${diagnostic.severity}`,
69
+ )
70
+ .sort();
71
+ expect(hookReports).toEqual([
72
+ "apps/demo/ui/Probe.tsx:3 error",
73
+ "apps/demo/ui/Probe.tsx:7 error",
74
+ "libs/demo/ui/Probe.tsx:3 error",
75
+ "libs/demo/ui/Probe.tsx:7 error",
76
+ ]);
77
+ });
78
+ });
package/biomeBase.ts CHANGED
@@ -3,4 +3,4 @@ export const biomeBaseConfig = "@akanjs/devkit/biome.base.json";
3
3
 
4
4
  // Pinned: Biome moves rules between groups across minors and a stale position is a hard config error. Bump it with
5
5
  // `biome.base.json` in one commit, running `biome migrate --write` in the workspace root and in this package.
6
- export const biomeVersion = "2.5.12";
6
+ export const biomeVersion = "2.5.14";
@@ -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,8 @@ import {
25
25
  } from "akanjs/common";
26
26
  import type { Workspace } from "../../commandDecorators";
27
27
  import { AkanCodePlugins } from "../tools/AkanCodePlugins";
28
+ import { DeepSeekWeb } from "../tools/DeepSeekWeb";
29
+ import { AkanCodeCompaction } from "./AkanCodeCompaction";
28
30
  import { AkanCodeServices } from "./AkanCodeServices";
29
31
  import {
30
32
  akanCodeModel,
@@ -52,6 +54,8 @@ export interface CodeAgentOptions {
52
54
  cwd?: string;
53
55
  profile: CodeAgentProfile;
54
56
  model?: CodeAgentModelRef;
57
+ /** Percent of the context window that starts a compaction, in steps of 10. */
58
+ autoCompactAt?: number;
55
59
  /** Apps whose dev server and preview the turn-end feedback loop watches. */
56
60
  apps?: string[];
57
61
  extensions?: InlineExtension[];
@@ -85,6 +89,7 @@ export class CodeAgent {
85
89
  readonly #replay: CodeAgentEvent[] = [];
86
90
  #replayFrom = 0;
87
91
  #lastContextTokens: number | undefined;
92
+ #autoCompactAt: number | undefined;
88
93
 
89
94
  private constructor(profile: CodeAgentProfile) {
90
95
  this.#profile = profile;
@@ -95,8 +100,10 @@ export class CodeAgent {
95
100
  const workspaceRoot = options.workspace.workspaceRoot;
96
101
  const cwd = options.cwd ?? workspaceRoot;
97
102
  const agent = new CodeAgent(options.profile);
103
+ const autoCompactAt = AkanCodeCompaction.normalize(options.autoCompactAt);
98
104
  const settingsManager = AkanCodeServices.settings();
99
- const modelRuntime = await AkanCodeServices.runtime(workspaceRoot, CodeAgentProxy.fromEnv(options.profile));
105
+ const proxy = CodeAgentProxy.fromEnv(options.profile);
106
+ const modelRuntime = await AkanCodeServices.runtime(workspaceRoot, proxy);
100
107
  const model = await akanCodeModel(modelRuntime, options.model);
101
108
  const compaction = settingsManager.getCompactionSettings(model);
102
109
  const modelWarnings = akanCodeModelWarnings(model, compaction.reserveTokens + compaction.keepRecentTokens);
@@ -107,6 +114,7 @@ export class CodeAgent {
107
114
  profile: options.profile,
108
115
  apps: options.apps ?? [],
109
116
  canSeeImages: akanCodeModelSupportsImages(model),
117
+ deepseek: await DeepSeekWeb.fromRuntime(modelRuntime, !!proxy),
110
118
  depth: options.depth ?? 0,
111
119
  ...(options.subagentSpend ? { subagentSpend: options.subagentSpend } : {}),
112
120
  currentSessionId: () => agent.sessionId,
@@ -146,10 +154,12 @@ export class CodeAgent {
146
154
  agent.#workspaceRoot = workspaceRoot;
147
155
  if (options.profile.session.store !== "memory") agent.#sessionDir = akanCodePaths.sessionsDir(workspaceRoot);
148
156
  await agent.#attach(session, options.mode ?? "print");
157
+ const autoCompactWarning = agent.#fitAutoCompact(autoCompactAt);
149
158
  agent.#suspended = new CodeAgentSuspended(CodeAgentSuspended.fileOf(agent.#sessionDir, agent.sessionId));
150
159
  if (agent.#sessionDir && options.profile.session.store === "file")
151
160
  agent.#openMail(akanCodePaths.mailDir(workspaceRoot), cwd);
152
- for (const message of modelWarnings) agent.#emit({ type: "notice", level: "warning", message });
161
+ for (const message of [...modelWarnings, ...autoCompactWarning])
162
+ agent.#emit({ type: "notice", level: "warning", message });
153
163
  return agent;
154
164
  }
155
165
 
@@ -232,6 +242,7 @@ export class CodeAgent {
232
242
  contextTokens: model?.contextWindow,
233
243
  // Not `off`: that is a setting of a reasoning model, not the absence of reasoning.
234
244
  effort: session.supportsThinking() ? session.thinkingLevel : undefined,
245
+ autoCompactAt: this.#autoCompactAt,
235
246
  name: session.sessionName,
236
247
  interaction: this.#profile.interaction,
237
248
  };
@@ -396,9 +407,42 @@ export class CodeAgent {
396
407
  const model = await akanCodeModel(session.modelRuntime, ref);
397
408
  if (!model) throw new Error(`Unknown model: ${ref.provider}/${ref.id}`);
398
409
  await session.setModel(model);
410
+ for (const message of this.#fitAutoCompact(this.#autoCompactAt))
411
+ this.#emit({ type: "notice", level: "warning", message });
399
412
  this.#emit({ type: "session", info: this.info });
400
413
  }
401
414
 
415
+ setAutoCompactAt(percent: number | undefined) {
416
+ const wanted = AkanCodeCompaction.normalize(percent);
417
+ const problem = this.#autoCompactProblem(wanted);
418
+ if (problem) throw new Error(problem);
419
+ this.#autoCompactAt = wanted;
420
+ this.#applyAutoCompact();
421
+ this.#emit({ type: "session", info: this.info });
422
+ }
423
+
424
+ // A share that fit one window can fall under the kept turns of a smaller one, and would compact every turn.
425
+ #fitAutoCompact(percent: number | undefined) {
426
+ const problem = this.#autoCompactProblem(percent);
427
+ this.#autoCompactAt = problem ? undefined : percent;
428
+ this.#applyAutoCompact();
429
+ return problem ? [`${problem} Autocompact is back to the default.`] : [];
430
+ }
431
+
432
+ #autoCompactProblem(percent: number | undefined) {
433
+ const session = this.#require();
434
+ const model = session.model;
435
+ if (!model || !percent) return undefined;
436
+ const { keepRecentTokens } = session.settingsManager.getCompactionSettings(model);
437
+ return AkanCodeCompaction.problemOf(model, percent, keepRecentTokens);
438
+ }
439
+
440
+ // Never before `create` has built the session: `settingsManager.reload()` drops whatever `applyOverrides` set.
441
+ #applyAutoCompact() {
442
+ const session = this.#require();
443
+ if (session.model) AkanCodeCompaction.apply(session.settingsManager, session.model, this.#autoCompactAt);
444
+ }
445
+
402
446
  async waitForIdle() {
403
447
  await this.#require().waitForIdle();
404
448
  }
@@ -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