@akanjs/devkit 3.1.0-rc.2 → 3.1.0-rc.20
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.
- package/abstractDoc.ts +1 -1
- package/agentsIndex.test.ts +4 -1
- package/akanConfig/akanConfig.ts +2 -2
- package/akanContext.ts +4 -4
- package/akanMcpContract.ts +1 -1
- package/applicationBuildRunner.test.ts +24 -1
- package/applicationBuildRunner.ts +11 -1
- package/artifact/implicitRootLayout.test.ts +2 -0
- package/artifact/implicitRootLayout.ts +1 -0
- package/biome.base.json +1 -11
- package/biomeBase.ts +1 -1
- package/cloud/configLock.ts +2 -1
- package/cloud/globalConfig.ts +3 -2
- package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
- package/codeAgent/agent/AkanCodeServices.ts +2 -3
- package/codeAgent/agent/CodeAgent.ts +46 -2
- package/codeAgent/agent/CodeAgentProxy.ts +2 -2
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +3 -1
- package/codeAgent/agent/codeAgent.test.ts +61 -3
- package/codeAgent/agent/codeAgentContract.test.ts +1 -0
- package/codeAgent/index.ts +1 -0
- package/codeAgent/skills/akan-code/SKILL.md +4 -0
- package/codeAgent/skills/akan-store/SKILL.md +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +3 -1
- package/codeAgent/tools/DeepSeekWeb.ts +127 -0
- package/codeAgent/tools/WebToolPack.ts +128 -29
- package/codeAgent/tools/akanCodePlugins.test.ts +143 -16
- package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
- package/commandDecorators/command.ts +5 -4
- package/createTunnel.test.ts +14 -0
- package/createTunnel.ts +3 -1
- package/dependencyScanner.ts +1 -1
- package/devkitUtils.test.ts +1 -1
- package/executors.test.ts +85 -3
- package/executors.ts +30 -13
- package/frontendBuild/autoImportSync.test.ts +3 -1
- package/frontendBuild/clientEntryDiscovery.ts +1 -1
- package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
- package/frontendBuild/csrArtifactBuilder.ts +5 -3
- package/frontendBuild/csrDevPatcher.test.ts +4 -1
- package/frontendBuild/cssBlockParser.test.ts +123 -0
- package/frontendBuild/cssBlockParser.ts +132 -0
- package/frontendBuild/cssCandidateCache.test.ts +16 -0
- package/frontendBuild/cssCandidateCache.ts +8 -6
- package/frontendBuild/cssCompiler.test.ts +53 -0
- package/frontendBuild/cssCompiler.ts +8 -4
- package/frontendBuild/fontOptimizer.ts +2 -1
- package/frontendBuild/frontendBuild.test.ts +14 -0
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
- package/frontendBuild/pagesBundleBuilder.ts +64 -9
- package/frontendBuild/routeClientBuilder.ts +1 -1
- package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
- package/frontendBuild/themeValidator.test.ts +83 -2
- package/frontendBuild/themeValidator.ts +37 -15
- package/getRelatedCnsts.ts +2 -1
- package/integration/devStabilityHarness.ts +5 -3
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
- package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
- package/mobile/mobileTarget.ts +1 -2
- package/mobile/nativeApp.test.ts +1 -1
- package/mobile/nativeConfig.test.ts +3 -0
- package/mobile/nativeConfig.ts +3 -2
- package/mobile/nativeWebDir.ts +3 -2
- package/mobile/updateUploader.test.ts +1 -0
- package/mobile/updateUploader.ts +3 -3
- package/package.json +20 -20
- package/piDependencyPins.test.ts +18 -19
- package/platformTest/LinuxDockerTarget.ts +2 -1
- package/platformTest/PlatformTestTarget.ts +7 -5
- package/platformTest/WindowsSshTarget.ts +2 -1
- package/qualityScanner.ts +1 -1
- package/recipeScanner.test.ts +9 -3
- package/scanInfo.ts +1 -1
- package/subspaceClone.ts +5 -4
- package/testHelpers.ts +2 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/browserDirnamePlugin.test.ts +88 -0
- package/transforms/browserDirnamePlugin.ts +25 -0
- package/transforms/rscUseClientTransform.ts +2 -2
- package/transforms/transforms.test.ts +18 -2
- package/transforms/tsconfigPackageResolver.test.ts +9 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +1 -1
- package/workflow/rolloutGate.test.ts +5 -1
- package/workflow/source.ts +2 -2
package/abstractDoc.ts
CHANGED
|
@@ -26,7 +26,7 @@ export class AbstractDoc {
|
|
|
26
26
|
static async findAll(sys: SysExecutor, { module }: { module?: string | null } = {}) {
|
|
27
27
|
const filePaths = (await sys.getAllFiles(`${AbstractDoc.#facetRoots}/**/*${AbstractDoc.suffix}`))
|
|
28
28
|
.map((filePath) => filePath.split(path.sep).join("/"))
|
|
29
|
-
.sort();
|
|
29
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
30
30
|
const docs = await Promise.all(filePaths.map((filePath) => AbstractDoc.read(sys, filePath)));
|
|
31
31
|
if (!module) return docs;
|
|
32
32
|
return docs.filter((doc) => doc.moduleName === module || doc.folderName === module);
|
package/agentsIndex.test.ts
CHANGED
|
@@ -86,6 +86,9 @@ describe("collectScopeRecipeSources", () => {
|
|
|
86
86
|
await write("libs/shared/ui/Recipe/panel.ts", `export const panelRecipe = recipe(tv({ base: "y" }));`);
|
|
87
87
|
await write("pkgs/akanjs/ui/recipe/buttonRecipe.ts", `export const buttonRecipe = recipe(tv({ base: "z" }));`);
|
|
88
88
|
const sources = await collectScopeRecipeSources(root, { type: "app", name: "minimal" }, ["shared"]);
|
|
89
|
-
expect(sources.map((source) => source.importFrom).sort()).toEqual([
|
|
89
|
+
expect(sources.map((source) => source.importFrom).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
|
|
90
|
+
"@apps/minimal/ui",
|
|
91
|
+
"@libs/shared/ui",
|
|
92
|
+
]);
|
|
90
93
|
});
|
|
91
94
|
});
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import type { ServerPolicy } from "@akanjs/native/api";
|
|
5
5
|
import type { AkanPlugin } from "akanjs";
|
|
6
6
|
import { DatabaseModes, normalizeRoutePrefix } from "akanjs/base";
|
|
7
|
-
import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
|
|
7
|
+
import { type AkanI18nConfig, resolveAkanI18nConfig, trimSlashes } from "akanjs/common";
|
|
8
8
|
import type { AkanImageConfig } from "akanjs/server";
|
|
9
9
|
import type { App, Lib } from "../commandDecorators";
|
|
10
10
|
import { LibExecutor, WorkspaceExecutor } from "../executors";
|
|
@@ -567,7 +567,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
567
567
|
}
|
|
568
568
|
#applyRoutes(routes: AkanRouteConfig[] = []) {
|
|
569
569
|
for (const route of routes) {
|
|
570
|
-
const basePath = route.basePath ? route.basePath
|
|
570
|
+
const basePath = route.basePath ? trimSlashes(route.basePath) : null;
|
|
571
571
|
if (basePath !== null) this.basePaths.add(basePath);
|
|
572
572
|
const domains = basePath !== null ? this.subRoutes.getOrInsert(basePath, new Set()) : this.domains;
|
|
573
573
|
for (const branch of Object.keys(route.domains)) this.branches.add(branch);
|
package/akanContext.ts
CHANGED
|
@@ -209,7 +209,7 @@ export const upsertCodexMcpServerBlock = (
|
|
|
209
209
|
const match = existing.match(codexAkanTablePattern);
|
|
210
210
|
if (!match) {
|
|
211
211
|
if (!existing.trim()) return nextBlock;
|
|
212
|
-
return `${existing.
|
|
212
|
+
return `${existing.trimEnd()}\n\n${nextBlock}`;
|
|
213
213
|
}
|
|
214
214
|
if (match[0].trim() === nextBlock.trim()) return existing;
|
|
215
215
|
if (!force)
|
|
@@ -847,8 +847,8 @@ export class AkanContextAnalyzer {
|
|
|
847
847
|
const listed = listedByImport(sectionOf(agentsMd, "## Recipes")).get("akanjs/ui") ?? new Set<string>();
|
|
848
848
|
pushIndexDiagnostic(
|
|
849
849
|
"AGENTS.md",
|
|
850
|
-
[...frameworkDeclared].filter((name) => !listed.has(name)).sort(),
|
|
851
|
-
[...listed].filter((name) => !frameworkDeclared.has(name)).sort(),
|
|
850
|
+
[...frameworkDeclared].filter((name) => !listed.has(name)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
851
|
+
[...listed].filter((name) => !frameworkDeclared.has(name)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
852
852
|
"akan agent install agents-md",
|
|
853
853
|
);
|
|
854
854
|
}
|
|
@@ -859,7 +859,7 @@ export class AkanContextAnalyzer {
|
|
|
859
859
|
.catch(() => "");
|
|
860
860
|
const groups = listedByImport(sectionOf(scopeMd, "## Recipes In Scope"));
|
|
861
861
|
const ownListed = groups.get(`@${sys.path}/ui`) ?? new Set<string>();
|
|
862
|
-
const missing = [...own].filter((name) => !ownListed.has(name)).sort();
|
|
862
|
+
const missing = [...own].filter((name) => !ownListed.has(name)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
863
863
|
// Every listed name — the scope's own and its dependency libs' — must still exist at its owner.
|
|
864
864
|
const stale = [...groups.entries()]
|
|
865
865
|
.flatMap(([importFrom, names]) =>
|
package/akanMcpContract.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AkanAppConfig } from "./akanConfig";
|
|
@@ -110,4 +110,27 @@ console.info(new SampleService().name);
|
|
|
110
110
|
await rm(root, { recursive: true, force: true });
|
|
111
111
|
}
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
test("a build for a real environment stops before any phase when the serve domain is only the local default", async () => {
|
|
115
|
+
const { root, workspace } = await createTempApp("portal");
|
|
116
|
+
await writeFile(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=repo\n");
|
|
117
|
+
const shellDomain = process.env.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
118
|
+
delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
119
|
+
const app = AppExecutor.from(workspace, "portal");
|
|
120
|
+
let prepared = false;
|
|
121
|
+
app.prepareCommand = async () => {
|
|
122
|
+
prepared = true;
|
|
123
|
+
throw new Error("prepared");
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
await expect(new ApplicationBuildRunner(app, { environment: "main" }).build()).rejects.toThrow(
|
|
127
|
+
'akan build for the "main" environment needs AKAN_PUBLIC_SERVE_DOMAIN (would be "localhost") set',
|
|
128
|
+
);
|
|
129
|
+
expect(prepared).toBe(false);
|
|
130
|
+
await expect(new ApplicationBuildRunner(app, { environment: "local" }).build()).rejects.toThrow("prepared");
|
|
131
|
+
} finally {
|
|
132
|
+
if (shellDomain !== undefined) process.env.AKAN_PUBLIC_SERVE_DOMAIN = shellDomain;
|
|
133
|
+
await rm(root, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
113
136
|
});
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ApplicationBuildResult,
|
|
7
7
|
} from "./applicationBuildReporter";
|
|
8
8
|
import type { App } from "./commandDecorators";
|
|
9
|
+
import { WorkspaceExecutor } from "./executors";
|
|
9
10
|
import {
|
|
10
11
|
AllRoutesBuilder,
|
|
11
12
|
CsrArtifactBuilder,
|
|
@@ -101,6 +102,7 @@ export class ApplicationBuildRunner {
|
|
|
101
102
|
this.#spinner = spinner;
|
|
102
103
|
const { web, assets, baseDevEnv } = await this.#app.getConfig();
|
|
103
104
|
const environment = this.#environment ?? baseDevEnv.env;
|
|
105
|
+
WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, "akan build");
|
|
104
106
|
//? Bundles bake AKAN_PUBLIC_ENV from process.env, where the shell outranks the .env that picked the server env.
|
|
105
107
|
process.env.AKAN_PUBLIC_ENV = environment;
|
|
106
108
|
//? Before any phase: a profile that would ship a secret stops the build, not the image after it.
|
|
@@ -362,9 +364,17 @@ void run().catch((error) => {
|
|
|
362
364
|
return { base, allRoutes };
|
|
363
365
|
}
|
|
364
366
|
|
|
367
|
+
//? Opt-in and outside `dist/`: a metafile is the size of the module graph, and `dist/` is what the image copies.
|
|
365
368
|
async #buildOrThrow(label: string, config: Bun.BuildConfig): Promise<Bun.BuildOutput> {
|
|
366
|
-
const
|
|
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;
|
|
372
|
+
const result = await Bun.build(metafileDir ? { ...config, metafile: true } : config);
|
|
367
373
|
if (!result.success) throw new AggregateError(result.logs, `[${label}] Bun.build failed`);
|
|
374
|
+
if (metafileDir && result.metafile) {
|
|
375
|
+
const entry = path.parse(String(config.entrypoints[0] ?? label)).name;
|
|
376
|
+
await Bun.write(path.join(metafileDir, `${entry}.json`), JSON.stringify(result.metafile));
|
|
377
|
+
}
|
|
368
378
|
return result;
|
|
369
379
|
}
|
|
370
380
|
|
|
@@ -46,6 +46,8 @@ describe("resolveSsrPageEntries", () => {
|
|
|
46
46
|
expect(generatedSource).toContain("export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;");
|
|
47
47
|
expect(generatedSource).toContain("export const Error = userLayout.Error ?? inheritedLayout.Error;");
|
|
48
48
|
expect(generatedSource).toContain("export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;");
|
|
49
|
+
// The ancestor root is its own level of the stack, so inheriting its loading would nest it inside itself.
|
|
50
|
+
expect(generatedSource).toContain("export const Loading = userLayout.Loading;");
|
|
49
51
|
expect(generatedSource).toContain(
|
|
50
52
|
"<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>",
|
|
51
53
|
);
|
|
@@ -201,6 +201,7 @@ async function writeGeneratedRootLayoutFile(opts: {
|
|
|
201
201
|
export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
202
202
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
203
203
|
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
|
204
|
+
export const Loading = userLayout.Loading;
|
|
204
205
|
|
|
205
206
|
${layoutSignature}({ children, params, searchParams }: LayoutProps) {
|
|
206
207
|
${layoutBinding} return `;
|
package/biome.base.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$schema": "https://biomejs.dev/schemas/2.5.
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
|
|
3
3
|
"vcs": {
|
|
4
4
|
"enabled": true,
|
|
5
5
|
"clientKind": "git",
|
|
@@ -85,16 +85,6 @@
|
|
|
85
85
|
"overrides": [
|
|
86
86
|
{
|
|
87
87
|
"includes": [
|
|
88
|
-
"**/env.client.local.ts",
|
|
89
|
-
"**/env.client.debug.ts",
|
|
90
|
-
"**/env.client.develop.ts",
|
|
91
|
-
"**/env.client.main.ts",
|
|
92
|
-
"**/env.client.testing.ts",
|
|
93
|
-
"**/env.server.local.ts",
|
|
94
|
-
"**/env.server.develop.ts",
|
|
95
|
-
"**/env.server.main.ts",
|
|
96
|
-
"**/env.server.testing.ts",
|
|
97
|
-
"**/env.server.type.ts",
|
|
98
88
|
"apps/*/lib/cnst.ts",
|
|
99
89
|
"apps/*/lib/dict.ts",
|
|
100
90
|
"apps/*/lib/db.ts",
|
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.
|
|
6
|
+
export const biomeVersion = "2.5.14";
|
package/cloud/configLock.ts
CHANGED
|
@@ -36,7 +36,8 @@ export class ConfigLock {
|
|
|
36
36
|
|
|
37
37
|
static async #take(lockPath: string) {
|
|
38
38
|
const handle = await open(lockPath, "wx", 0o600).catch((error: NodeJS.ErrnoException) => {
|
|
39
|
-
|
|
39
|
+
// Windows answers EPERM, not EEXIST, while a released lock file is still pending delete behind an open handle.
|
|
40
|
+
if (error.code === "EEXIST" || (process.platform === "win32" && error.code === "EPERM")) return null;
|
|
40
41
|
throw error;
|
|
41
42
|
});
|
|
42
43
|
if (!handle) return false;
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { trimTrailingSlashes } from "akanjs/common";
|
|
2
3
|
import dayjs from "dayjs";
|
|
3
4
|
import { FileSys } from "../fileSys";
|
|
4
5
|
import { ConfigLock } from "./configLock";
|
|
@@ -60,8 +61,8 @@ export class GlobalConfig {
|
|
|
60
61
|
static #normalizeHost(host: string) {
|
|
61
62
|
const url = URL.canParse(host) ? new URL(host) : null;
|
|
62
63
|
return url && (url.protocol === "https:" || url.protocol === "http:")
|
|
63
|
-
? `${url.origin}${url.pathname
|
|
64
|
-
: host
|
|
64
|
+
? `${url.origin}${trimTrailingSlashes(url.pathname)}`
|
|
65
|
+
: trimTrailingSlashes(host);
|
|
65
66
|
}
|
|
66
67
|
static async getHostConfig(host = GlobalConfig.akanCloudHost): Promise<HostConfig> {
|
|
67
68
|
const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import type
|
|
3
|
+
import { type CodeAgentProfile, trimTrailingSlashes } from "akanjs/common";
|
|
4
4
|
import { akanCodeDefaultModel } from "./akanCodeModel";
|
|
5
5
|
|
|
6
6
|
interface PreparedRequest {
|
|
@@ -27,7 +27,7 @@ export class CodeAgentProxy {
|
|
|
27
27
|
readonly #token?: string;
|
|
28
28
|
|
|
29
29
|
constructor({ baseUrl, providers, tokenFile, token }: CodeAgentProxyOptions) {
|
|
30
|
-
this.baseUrl = baseUrl
|
|
30
|
+
this.baseUrl = trimTrailingSlashes(baseUrl);
|
|
31
31
|
this.providers = providers;
|
|
32
32
|
this.#tokenFile = tokenFile;
|
|
33
33
|
this.#token = token;
|
|
@@ -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
|
+
});
|
|
@@ -8,7 +8,7 @@ export interface CodeAgentModelRef {
|
|
|
8
8
|
id: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/** DeepSeek's million-token window is what makes
|
|
11
|
+
/** DeepSeek's million-token window is what makes the always-loaded `AGENTS.md` affordable on every turn. */
|
|
12
12
|
export const akanCodeDefaultModel: CodeAgentModelRef = { provider: "deepseek", id: "deepseek-flash" };
|
|
13
13
|
|
|
14
14
|
// Declared text-only by the catalogue, yet verified against the provider to see images.
|
|
@@ -73,7 +73,9 @@ describe("workspace context files", () => {
|
|
|
73
73
|
test("a rule with no frontmatter at all is unconditional, and so is .cursorrules", () => {
|
|
74
74
|
const root = workspace({ ".cursor/rules/plain.mdc": "just do it", ".cursorrules": "the legacy file" });
|
|
75
75
|
const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root });
|
|
76
|
-
expect(namesOf(files, root).sort(
|
|
76
|
+
expect(namesOf(files, root).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual(
|
|
77
|
+
[".cursorrules", ".cursor/rules/plain.mdc", "AGENTS.md"].sort(),
|
|
78
|
+
);
|
|
77
79
|
});
|
|
78
80
|
|
|
79
81
|
test("rules nested under the rules directory are found, in a stable order", () => {
|
|
@@ -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
|
-
|
|
35
|
-
|
|
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;
|
package/codeAgent/index.ts
CHANGED
|
@@ -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`, `
|
|
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>`.
|