@akanjs/devkit 3.0.0-beta.16 → 3.0.0-beta.18
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 -3
- package/applicationBuildRunner.ts +4 -3
- package/artifact/implicitRootLayout.test.ts +6 -11
- package/artifact/implicitRootLayout.ts +0 -15
- package/biome.base.json +6 -1
- package/capacitorApp.test.ts +26 -0
- package/capacitorApp.ts +25 -4
- package/codeAgent/agent/AkanCodeServices.ts +43 -27
- package/codeAgent/agent/AkanContextFiles.ts +117 -0
- package/codeAgent/agent/CodeAgent.ts +87 -19
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -0
- package/codeAgent/agent/SubagentPool.ts +55 -4
- package/codeAgent/agent/akanCodeModel.ts +8 -8
- package/codeAgent/agent/akanCodePaths.ts +23 -3
- package/codeAgent/agent/akanContextFiles.test.ts +115 -0
- package/codeAgent/agent/akanSystemPrompt.ts +5 -0
- package/codeAgent/index.ts +0 -1
- package/codeAgent/skills/akan-code/SKILL.md +122 -0
- package/codeAgent/skills/akan-module/SKILL.md +2 -2
- package/codeAgent/skills/akan-store/SKILL.md +6 -2
- package/codeAgent/tools/AkanCodePlugins.ts +26 -3
- package/codeAgent/tools/AkanEnvKeys.ts +5 -3
- package/codeAgent/tools/AskToolPack.ts +111 -0
- package/codeAgent/tools/McpClient.ts +51 -5
- package/codeAgent/tools/McpOAuth.ts +268 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
- package/codeAgent/tools/McpServerConfig.ts +148 -0
- package/codeAgent/tools/McpSignIn.ts +161 -0
- package/codeAgent/tools/McpTokenStore.ts +92 -0
- package/codeAgent/tools/McpToolPack.ts +42 -49
- package/codeAgent/tools/akanCodePlugins.test.ts +36 -3
- package/codeAgent/tools/askToolPack.test.ts +119 -0
- package/codeAgent/tools/mcpOAuth.test.ts +320 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +205 -0
- package/commandDecorators/command.ts +2 -0
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/helpFormatter.ts +2 -1
- package/executors.test.ts +10 -46
- package/index.ts +0 -1
- package/package.json +8 -6
- package/piDependencyPins.test.ts +4 -2
- package/prompter.ts +0 -7
- package/qualityScanner.test.ts +3 -2
- package/qualityScanner.ts +1 -1
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +0 -12
- package/scanInfo.ts +5 -2
- package/workflow/executor.ts +52 -0
- package/workflow/source.ts +19 -13
- package/codeAgent/agent/AkanAuthStorageBackend.ts +0 -105
- package/guideline.ts +0 -19
package/abstractDoc.ts
CHANGED
|
@@ -5,10 +5,8 @@ export type AbstractKind = "domain" | "service" | "scalar" | "other";
|
|
|
5
5
|
/** One `*.abstract.md` file: the agent-facing summary a module carries next to its source. */
|
|
6
6
|
export class AbstractDoc {
|
|
7
7
|
static readonly suffix = ".abstract.md";
|
|
8
|
-
/** `akan quality` warns above this
|
|
8
|
+
/** `akan quality` warns above this. */
|
|
9
9
|
static readonly maxLines = 300;
|
|
10
|
-
/** `akan compact` floor, and the line budget it compacts down to, so a compacted file is never a candidate again. */
|
|
11
|
-
static readonly compactMinLines = 40;
|
|
12
10
|
// Abstracts only live in facet folders; globbing the sys root instead would walk node_modules.
|
|
13
11
|
static readonly #facetRoots = "{lib,ui,webkit,srvkit,common,plugin}";
|
|
14
12
|
|
|
@@ -246,11 +246,12 @@ export class ApplicationBuildRunner {
|
|
|
246
246
|
async #writeConsoleShim() {
|
|
247
247
|
await Bun.write(
|
|
248
248
|
path.join(this.#app.dist.cwdPath, "console.js"),
|
|
249
|
-
`
|
|
250
|
-
|
|
249
|
+
`process.env.AKAN_COMMAND_TYPE = "console";
|
|
250
|
+
const { cnst, db, dict, option, server, sig, srv } = await import("./server.js");
|
|
251
|
+
const { assertAkanConsoleAllowed, startAkanConsole } = await import("./console-runtime.js");
|
|
251
252
|
|
|
252
253
|
const run = async () => {
|
|
253
|
-
assertAkanConsoleAllowed(
|
|
254
|
+
assertAkanConsoleAllowed();
|
|
254
255
|
await server.start({ listen: false, web: false });
|
|
255
256
|
try {
|
|
256
257
|
await startAkanConsole(server, { globals: { cnst, db, dict, option, sig, srv } });
|
|
@@ -31,11 +31,11 @@ describe("resolveSsrPageEntries", () => {
|
|
|
31
31
|
await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
|
|
32
32
|
await write(
|
|
33
33
|
rootLayoutPath,
|
|
34
|
-
'export const theme = "dark";\nexport const fonts = [{ name: "pretendard" }];\nexport const
|
|
34
|
+
'export const theme = "dark";\nexport const fonts = [{ name: "pretendard" }];\nexport const head = null;\n',
|
|
35
35
|
);
|
|
36
36
|
await write(
|
|
37
37
|
groupedLayoutPath,
|
|
38
|
-
|
|
38
|
+
"export function generateHead() { return null; }\nexport default function Layout({ children }) { return children; }\n",
|
|
39
39
|
);
|
|
40
40
|
|
|
41
41
|
const entries = await resolveSsrPageEntries({
|
|
@@ -54,15 +54,10 @@ describe("resolveSsrPageEntries", () => {
|
|
|
54
54
|
expect(generatedSource).toContain('import * as inheritedModule from "../../../page/_layout.tsx";');
|
|
55
55
|
expect(generatedSource).toContain("const inheritedLayout = resolveRouteModule(inheritedModule as never");
|
|
56
56
|
expect(generatedSource).not.toContain("<System.Provider");
|
|
57
|
-
expect(generatedSource).toContain("export async function
|
|
58
|
-
expect(generatedSource).toContain("if (userLayout.
|
|
59
|
-
expect(generatedSource).toContain("
|
|
60
|
-
expect(generatedSource).toContain(
|
|
61
|
-
"if (inheritedLayout.generateMetadata) return inheritedLayout.generateMetadata(props);",
|
|
62
|
-
);
|
|
63
|
-
expect(generatedSource).toContain("return inheritedLayout.metadata;");
|
|
64
|
-
expect(generatedSource).not.toContain("Object.keys(userLayout.metadata)");
|
|
65
|
-
expect(generatedSource).not.toContain("Object.keys(inheritedLayout.metadata)");
|
|
57
|
+
expect(generatedSource).toContain("export async function generateHead(props: PageProps)");
|
|
58
|
+
expect(generatedSource).toContain("if (userLayout.generateHead) return userLayout.generateHead(props);");
|
|
59
|
+
expect(generatedSource).toContain("return inheritedLayout.head;");
|
|
60
|
+
expect(generatedSource).not.toContain("generateMetadata");
|
|
66
61
|
expect(generatedSource).toContain("export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;");
|
|
67
62
|
expect(generatedSource).toContain("export const Error = userLayout.Error ?? inheritedLayout.Error;");
|
|
68
63
|
expect(generatedSource).toContain("export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;");
|
|
@@ -236,13 +236,6 @@ export async function generateHead(props: PageProps) {
|
|
|
236
236
|
return inheritedLayout.head;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
export async function generateMetadata(props: PageProps) {
|
|
240
|
-
if (userLayout.generateMetadata) return userLayout.generateMetadata(props);
|
|
241
|
-
if (userLayout.metadata !== undefined) return userLayout.metadata;
|
|
242
|
-
if (inheritedLayout.generateMetadata) return inheritedLayout.generateMetadata(props);
|
|
243
|
-
return inheritedLayout.metadata;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
239
|
export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
247
240
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
248
241
|
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
|
@@ -258,7 +251,6 @@ ${layoutBinding} return (
|
|
|
258
251
|
theme={userLayout.theme ?? inheritedLayout.theme}
|
|
259
252
|
fonts={loadFonts(userFonts)}
|
|
260
253
|
className={defaultFontClassName}
|
|
261
|
-
gaTrackingId={userLayout.gaTrackingId ?? inheritedLayout.gaTrackingId}
|
|
262
254
|
layoutStyle={userLayout.layoutStyle ?? inheritedLayout.layoutStyle}
|
|
263
255
|
reconnect={userLayout.reconnect ?? inheritedLayout.reconnect ?? false}
|
|
264
256
|
wsConnect={userLayout.wsConnect ?? inheritedLayout.wsConnect ?? true}
|
|
@@ -280,13 +272,6 @@ export async function generateHead(props: PageProps) {
|
|
|
280
272
|
return inheritedLayout.head;
|
|
281
273
|
}
|
|
282
274
|
|
|
283
|
-
export async function generateMetadata(props: PageProps) {
|
|
284
|
-
if (userLayout.generateMetadata) return userLayout.generateMetadata(props);
|
|
285
|
-
if (userLayout.metadata !== undefined) return userLayout.metadata;
|
|
286
|
-
if (inheritedLayout.generateMetadata) return inheritedLayout.generateMetadata(props);
|
|
287
|
-
return inheritedLayout.metadata;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
275
|
export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
291
276
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
292
277
|
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
package/biome.base.json
CHANGED
|
@@ -154,7 +154,12 @@
|
|
|
154
154
|
]
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
|
-
"includes": [
|
|
157
|
+
"includes": [
|
|
158
|
+
"{apps,libs}/**/page/**/*.ts",
|
|
159
|
+
"{apps,libs}/**/page/**/*.tsx",
|
|
160
|
+
"{apps,libs}/**/*.Unit.tsx",
|
|
161
|
+
"{apps,libs}/**/*.View.tsx"
|
|
162
|
+
],
|
|
158
163
|
"plugins": ["./node_modules/@akanjs/devkit/lint/non-scalar-props-restricted.grit"]
|
|
159
164
|
},
|
|
160
165
|
{
|
package/capacitorApp.test.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
sanitizeIosNativeRunEnv,
|
|
25
25
|
selectLocalDevHost,
|
|
26
26
|
sortIosRunTargets,
|
|
27
|
+
toIosInfoPlistUsageDescriptions,
|
|
27
28
|
writeRootCapacitorConfig,
|
|
28
29
|
} from "./capacitorApp";
|
|
29
30
|
|
|
@@ -370,3 +371,28 @@ describe("raiseGradleMinSdkVersion", () => {
|
|
|
370
371
|
expect(raiseGradleMinSdkVersion("minSdkVersion = 28", 26)).toBeNull();
|
|
371
372
|
});
|
|
372
373
|
});
|
|
374
|
+
|
|
375
|
+
describe("toIosInfoPlistUsageDescriptions", () => {
|
|
376
|
+
test("writes Apple's real Info.plist keys, not NS + the description name", () => {
|
|
377
|
+
expect(
|
|
378
|
+
toIosInfoPlistUsageDescriptions({
|
|
379
|
+
cameraUsageDescription: "camera",
|
|
380
|
+
photoAddUsageDescription: "add",
|
|
381
|
+
photoUsageDescription: "read",
|
|
382
|
+
locationAlwaysUsageDescription: "always",
|
|
383
|
+
}),
|
|
384
|
+
).toEqual({
|
|
385
|
+
NSCameraUsageDescription: "camera",
|
|
386
|
+
NSPhotoLibraryAddUsageDescription: "add",
|
|
387
|
+
NSPhotoLibraryUsageDescription: "read",
|
|
388
|
+
NSLocationAlwaysAndWhenInUseUsageDescription: "always",
|
|
389
|
+
NSLocationAlwaysUsageDescription: "always",
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("keeps the NS prefix convention for a key it does not know", () => {
|
|
394
|
+
expect(toIosInfoPlistUsageDescriptions({ bluetoothAlwaysUsageDescription: "ble" })).toEqual({
|
|
395
|
+
NSBluetoothAlwaysUsageDescription: "ble",
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
});
|
package/capacitorApp.ts
CHANGED
|
@@ -489,7 +489,7 @@ export function classifyIosRunFailure(log: string): IosRunFailureClassification
|
|
|
489
489
|
kind: "unknown",
|
|
490
490
|
title: "iOS native run failed.",
|
|
491
491
|
detail:
|
|
492
|
-
"Review the xcodebuild/devicectl output above. You can retry with --
|
|
492
|
+
"Review the xcodebuild/devicectl output above. You can retry with --no-allow-provisioning-updates to use the conservative path.",
|
|
493
493
|
};
|
|
494
494
|
}
|
|
495
495
|
|
|
@@ -597,6 +597,29 @@ export function raiseGradleMinSdkVersion(content: string, floor: number = ANDROI
|
|
|
597
597
|
return content.replace(/(minSdkVersion\s*=\s*)\d+/, `$1${floor}`);
|
|
598
598
|
}
|
|
599
599
|
|
|
600
|
+
//* Apple's Info.plist keys do not follow the description names (photo → NSPhotoLibrary…), so each one is spelled out.
|
|
601
|
+
const iosUsageDescriptionKeys = {
|
|
602
|
+
cameraUsageDescription: ["NSCameraUsageDescription"],
|
|
603
|
+
photoAddUsageDescription: ["NSPhotoLibraryAddUsageDescription"],
|
|
604
|
+
photoUsageDescription: ["NSPhotoLibraryUsageDescription"],
|
|
605
|
+
contactsUsageDescription: ["NSContactsUsageDescription"],
|
|
606
|
+
//? iOS 11+ reads the AlwaysAndWhenInUse key; the Always key only serves iOS 10 and below.
|
|
607
|
+
locationAlwaysUsageDescription: ["NSLocationAlwaysAndWhenInUseUsageDescription", "NSLocationAlwaysUsageDescription"],
|
|
608
|
+
locationWhenInUseUsageDescription: ["NSLocationWhenInUseUsageDescription"],
|
|
609
|
+
microphoneUsageDescription: ["NSMicrophoneUsageDescription"],
|
|
610
|
+
speechRecognitionUsageDescription: ["NSSpeechRecognitionUsageDescription"],
|
|
611
|
+
} as const;
|
|
612
|
+
|
|
613
|
+
export const toIosInfoPlistUsageDescriptions = (descriptions: { [key: string]: string }) =>
|
|
614
|
+
Object.fromEntries(
|
|
615
|
+
Object.entries(descriptions).flatMap(([key, value]) => {
|
|
616
|
+
const plistKeys: readonly string[] = Object.hasOwn(iosUsageDescriptionKeys, key)
|
|
617
|
+
? iosUsageDescriptionKeys[key as keyof typeof iosUsageDescriptionKeys]
|
|
618
|
+
: [`NS${capitalize(key)}`];
|
|
619
|
+
return plistKeys.map((plistKey) => [plistKey, value]);
|
|
620
|
+
}),
|
|
621
|
+
);
|
|
622
|
+
|
|
600
623
|
const mergeAllowNavigation = (configured: unknown, localIp: string | undefined) => {
|
|
601
624
|
const values = Array.isArray(configured)
|
|
602
625
|
? configured.filter((value): value is string => typeof value === "string")
|
|
@@ -1469,9 +1492,7 @@ export class CapacitorApp {
|
|
|
1469
1492
|
if (next !== content) await editor.setContent(next).save();
|
|
1470
1493
|
}
|
|
1471
1494
|
async #setPermissionInIos(permissions: { [key: string]: string }) {
|
|
1472
|
-
const updateNs =
|
|
1473
|
-
Object.entries(permissions).map(([key, value]) => [`NS${capitalize(key)}`, value]),
|
|
1474
|
-
);
|
|
1495
|
+
const updateNs = toIosInfoPlistUsageDescriptions(permissions);
|
|
1475
1496
|
await Promise.all([
|
|
1476
1497
|
this.project.ios.updateInfoPlist(this.iosTargetName, "Debug", updateNs),
|
|
1477
1498
|
this.project.ios.updateInfoPlist(this.iosTargetName, "Release", updateNs),
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
AuthStorage,
|
|
4
|
-
type InlineExtension,
|
|
5
|
-
ModelRegistry,
|
|
6
|
-
SessionManager,
|
|
7
|
-
SettingsManager,
|
|
8
|
-
} from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type InlineExtension, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
9
3
|
import type { CodeAgentProfile } from "akanjs/common";
|
|
10
4
|
import { AkanEnvKeys } from "../tools/AkanEnvKeys";
|
|
11
|
-
import {
|
|
5
|
+
import { type AkanContextFile, AkanContextFiles } from "./AkanContextFiles";
|
|
12
6
|
import { akanCodePaths } from "./akanCodePaths";
|
|
13
7
|
import { akanSystemPrompt } from "./akanSystemPrompt";
|
|
14
8
|
import { CodeSessionIndex } from "./CodeSessionIndex";
|
|
@@ -24,8 +18,8 @@ export interface AkanCodeServicesOptions {
|
|
|
24
18
|
/**
|
|
25
19
|
* Assembles the engine's injectable services from akan's own storage.
|
|
26
20
|
*
|
|
27
|
-
* Every one of the
|
|
28
|
-
* measured: with all
|
|
21
|
+
* Every one of the four is passed explicitly. Leave any of them out and the engine falls back to `~/.pi/` —
|
|
22
|
+
* measured: with all four injected it writes nothing outside the paths named here.
|
|
29
23
|
*/
|
|
30
24
|
export class AkanCodeServices {
|
|
31
25
|
static settings() {
|
|
@@ -41,15 +35,21 @@ export class AkanCodeServices {
|
|
|
41
35
|
});
|
|
42
36
|
}
|
|
43
37
|
|
|
44
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The model catalogue and the credentials behind it, both under `~/.akan/`.
|
|
40
|
+
*
|
|
41
|
+
* `authPath` is the whole of what akan's own credential file used to need a storage backend for: the engine
|
|
42
|
+
* locks and rewrites that file itself, so a second `akan code` refreshing an OAuth token at the same moment
|
|
43
|
+
* cannot clobber the first. Nothing lands in `~/.pi/`, and a user who removes akan takes their keys with them.
|
|
44
|
+
*/
|
|
45
|
+
static async runtime(workspaceRoot: string) {
|
|
45
46
|
mkdirSync(akanCodePaths.globalDir(), { recursive: true, mode: 0o700 });
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return ModelRegistry.create(authStorage, akanCodePaths.modelsFile());
|
|
47
|
+
const runtime = await ModelRuntime.create({
|
|
48
|
+
authPath: akanCodePaths.authFile(),
|
|
49
|
+
modelsPath: akanCodePaths.modelsFile(),
|
|
50
|
+
});
|
|
51
|
+
await AkanEnvKeys.apply(runtime, workspaceRoot);
|
|
52
|
+
return runtime;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
static sessions(workspaceRoot: string, cwd: string, profile: CodeAgentProfile, resume?: string) {
|
|
@@ -66,22 +66,29 @@ export class AkanCodeServices {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* The akan skill set, then the
|
|
69
|
+
* The akan skill set, then the person's own, then the workspace's.
|
|
70
|
+
*
|
|
71
|
+
* `~/.akan/code/skills` is read because a skill somebody wrote for themselves is theirs, not one checkout's,
|
|
72
|
+
* and having to copy it into every repo is the same friction that makes people stop writing them. It is
|
|
73
|
+
* text the model may read, never code that runs — which is why extensions stay workspace-only below.
|
|
70
74
|
*
|
|
71
|
-
* A path that does not exist is a reported load error rather than a skip, so
|
|
75
|
+
* A path that does not exist is a reported load error rather than a skip, so each is checked first.
|
|
72
76
|
*/
|
|
73
77
|
static #skillPaths(profile: CodeAgentProfile, workspaceRoot: string) {
|
|
74
78
|
if (!profile.context.skills) return [];
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
(
|
|
78
|
-
|
|
79
|
+
const dirs = [
|
|
80
|
+
akanCodePaths.builtinSkillsDir(),
|
|
81
|
+
akanCodePaths.globalSkillsDir(),
|
|
82
|
+
akanCodePaths.skillsDir(workspaceRoot),
|
|
83
|
+
];
|
|
84
|
+
return dirs.filter((dir): dir is string => !!dir && existsSync(dir));
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
/**
|
|
82
|
-
* What the engine's resource loader may discover. Extensions
|
|
83
|
-
*
|
|
84
|
-
*
|
|
88
|
+
* What the engine's resource loader may discover. Extensions come from the workspace and nowhere else: a
|
|
89
|
+
* globally installed extension is code that runs in every checkout, so an agent would behave differently in
|
|
90
|
+
* two clones of the same repo for reasons nothing in the repo explains. Skills are text and are read from
|
|
91
|
+
* the home directory too — see `#skillPaths`.
|
|
85
92
|
*/
|
|
86
93
|
static resourceOptions(options: AkanCodeServicesOptions) {
|
|
87
94
|
// A path that does not exist is reported as a load error rather than skipped, and these two are optional.
|
|
@@ -95,6 +102,15 @@ export class AkanCodeServices {
|
|
|
95
102
|
noPromptTemplates: true,
|
|
96
103
|
noThemes: true,
|
|
97
104
|
noContextFiles: !options.profile.context.projectFiles,
|
|
105
|
+
// The engine takes one context file per directory and looks at no editor's own rules, so a repo whose
|
|
106
|
+
// conventions live in a `CLAUDE.md` or under `.cursor/rules` hands this agent less than it hands the
|
|
107
|
+
// editor beside it — see {@link AkanContextFiles}. Gated on the same flag, so a profile that wants no
|
|
108
|
+
// project context still gets none.
|
|
109
|
+
agentsFilesOverride: ({ agentsFiles }: { agentsFiles: AkanContextFile[] }) => ({
|
|
110
|
+
agentsFiles: options.profile.context.projectFiles
|
|
111
|
+
? AkanContextFiles.extend(agentsFiles, { workspaceRoot: options.workspaceRoot, cwd: options.cwd })
|
|
112
|
+
: agentsFiles,
|
|
113
|
+
}),
|
|
98
114
|
appendSystemPrompt: [akanSystemPrompt(options.profile)],
|
|
99
115
|
additionalExtensionPaths: present(akanCodePaths.extensionsDir(options.workspaceRoot)),
|
|
100
116
|
additionalSkillPaths: AkanCodeServices.#skillPaths(options.profile, options.workspaceRoot),
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface AkanContextFile {
|
|
5
|
+
path: string;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AkanContextFilesOptions {
|
|
10
|
+
workspaceRoot: string;
|
|
11
|
+
cwd: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The workspace-authored rules the engine's own discovery leaves behind.
|
|
16
|
+
*
|
|
17
|
+
* It walks the ancestors of `cwd` and takes **one** file per directory, first match of
|
|
18
|
+
* `AGENTS.override.md · AGENTS.md · AGENTS.MD · CLAUDE.md · CLAUDE.MD` — so a repo carrying both an `AGENTS.md`
|
|
19
|
+
* and a `CLAUDE.md` has the second one silently dropped, and a `.cursor/rules` tree is never looked at. Both
|
|
20
|
+
* are files a developer wrote expecting an agent to obey them, and an agent that reads one editor's copy and
|
|
21
|
+
* not another's follows rules that disagree with the repo it is in.
|
|
22
|
+
*
|
|
23
|
+
* Added rather than replaced: the base list keeps its order and its precedence, and these land after it.
|
|
24
|
+
*/
|
|
25
|
+
export class AkanContextFiles {
|
|
26
|
+
/** Cursor's frontmatter block, which is metadata for its own rule picker and not instruction for a model. */
|
|
27
|
+
static readonly #frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
28
|
+
|
|
29
|
+
static extend(base: AkanContextFile[], options: AkanContextFilesOptions): AkanContextFile[] {
|
|
30
|
+
const seen = new Set(base.map((file) => file.path));
|
|
31
|
+
// Keyed by content as well as by path, because the usual way to carry both names is to make one a symlink
|
|
32
|
+
// to the other or a one-line pointer at it — and the same guide twice is the same guide twice.
|
|
33
|
+
const contents = new Set(base.map((file) => file.content.trim()));
|
|
34
|
+
const added: AkanContextFile[] = [];
|
|
35
|
+
for (const file of [...AkanContextFiles.#claude(base), ...AkanContextFiles.#cursor(options)]) {
|
|
36
|
+
if (seen.has(file.path) || contents.has(file.content.trim()) || !file.content.trim()) continue;
|
|
37
|
+
seen.add(file.path);
|
|
38
|
+
contents.add(file.content.trim());
|
|
39
|
+
added.push(file);
|
|
40
|
+
}
|
|
41
|
+
return [...base, ...added];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The `CLAUDE.md` beside an `AGENTS.md` the engine already took.
|
|
46
|
+
*
|
|
47
|
+
* Not beside an `AGENTS.override.md`: that name means "ignore what this directory would otherwise say", so
|
|
48
|
+
* reinstating the file it was written to displace is the one case where loading both is wrong.
|
|
49
|
+
*/
|
|
50
|
+
static #claude(base: AkanContextFile[]) {
|
|
51
|
+
const files: AkanContextFile[] = [];
|
|
52
|
+
for (const file of base) {
|
|
53
|
+
if (!/^AGENTS\.(md|MD)$/.test(path.basename(file.path))) continue;
|
|
54
|
+
for (const name of ["CLAUDE.md", "CLAUDE.MD"]) {
|
|
55
|
+
const candidate = path.join(path.dirname(file.path), name);
|
|
56
|
+
const content = AkanContextFiles.#read(candidate);
|
|
57
|
+
if (content !== undefined) files.push({ path: candidate, content });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return files;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Cursor's always-on rules, from the workspace root and from the session's own directory.
|
|
65
|
+
*
|
|
66
|
+
* Only `alwaysApply: true` is taken. Cursor's other three kinds are conditional — `globs` attaches a rule to
|
|
67
|
+
* the files it names, a bare `description` offers it for the model to ask for, and neither means the rule is
|
|
68
|
+
* loaded manually — so putting them in a system prompt applies rules their author scoped away. A file with
|
|
69
|
+
* no frontmatter at all is the `.cursorrules` shape, which was never conditional.
|
|
70
|
+
*/
|
|
71
|
+
static #cursor(options: AkanContextFilesOptions) {
|
|
72
|
+
const files: AkanContextFile[] = [];
|
|
73
|
+
const roots = [...new Set([options.workspaceRoot, options.cwd])];
|
|
74
|
+
for (const root of roots) {
|
|
75
|
+
const legacy = AkanContextFiles.#read(path.join(root, ".cursorrules"));
|
|
76
|
+
if (legacy !== undefined) files.push({ path: path.join(root, ".cursorrules"), content: legacy });
|
|
77
|
+
for (const file of AkanContextFiles.#mdcFiles(path.join(root, ".cursor", "rules"))) {
|
|
78
|
+
const raw = AkanContextFiles.#read(file);
|
|
79
|
+
if (raw === undefined) continue;
|
|
80
|
+
const body = AkanContextFiles.#applied(raw);
|
|
81
|
+
if (body !== undefined) files.push({ path: file, content: body });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return files;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The rule body when the rule applies unconditionally, and nothing when its author scoped it. */
|
|
88
|
+
static #applied(raw: string) {
|
|
89
|
+
const match = AkanContextFiles.#frontmatter.exec(raw);
|
|
90
|
+
if (!match) return raw;
|
|
91
|
+
if (!/^\s*alwaysApply\s*:\s*true\s*$/m.test(match[1] ?? "")) return undefined;
|
|
92
|
+
return raw.slice(match[0].length);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Sorted, because the order rules reach the model in is otherwise the order a directory happens to list. */
|
|
96
|
+
static #mdcFiles(dir: string) {
|
|
97
|
+
if (!existsSync(dir)) return [];
|
|
98
|
+
try {
|
|
99
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
100
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".mdc"))
|
|
101
|
+
.map((entry) => path.join(entry.parentPath ?? dir, entry.name))
|
|
102
|
+
.sort();
|
|
103
|
+
} catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static #read(file: string) {
|
|
109
|
+
try {
|
|
110
|
+
if (!existsSync(file) || !statSync(file).isFile()) return undefined;
|
|
111
|
+
return readFileSync(file, "utf8").replace(/^/, "");
|
|
112
|
+
} catch {
|
|
113
|
+
// A rule file that cannot be read costs its own rules. It must not cost the session its start.
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -13,7 +13,9 @@ import {
|
|
|
13
13
|
type CodeAgentEvent,
|
|
14
14
|
type CodeAgentEventBody,
|
|
15
15
|
type CodeAgentImage,
|
|
16
|
+
type CodeAgentMcpStatus,
|
|
16
17
|
type CodeAgentProfile,
|
|
18
|
+
type CodeAgentProviderInfo,
|
|
17
19
|
type CodeAgentQuestion,
|
|
18
20
|
type CodeAgentSessionInfo,
|
|
19
21
|
type CodeAgentState,
|
|
@@ -82,6 +84,8 @@ export class CodeAgent {
|
|
|
82
84
|
#disposed = false;
|
|
83
85
|
#sessionDir: string | undefined;
|
|
84
86
|
#mailbox: CodeMailbox | undefined;
|
|
87
|
+
#mcp: CodeAgentMcpStatus[] = [];
|
|
88
|
+
#workspaceRoot = "";
|
|
85
89
|
/**
|
|
86
90
|
* The frames of the turn in flight, so a host that reconnects mid-turn can be handed what it missed.
|
|
87
91
|
*
|
|
@@ -102,9 +106,8 @@ export class CodeAgent {
|
|
|
102
106
|
const cwd = options.cwd ?? workspaceRoot;
|
|
103
107
|
const agent = new CodeAgent(options.profile);
|
|
104
108
|
const settingsManager = AkanCodeServices.settings();
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
const model = await akanCodeModel(modelRegistry, options.model);
|
|
109
|
+
const modelRuntime = await AkanCodeServices.runtime(workspaceRoot);
|
|
110
|
+
const model = await akanCodeModel(modelRuntime, options.model);
|
|
108
111
|
const compaction = DEFAULT_COMPACTION_SETTINGS;
|
|
109
112
|
const modelWarnings = akanCodeModelWarnings(model, compaction.reserveTokens + compaction.keepRecentTokens);
|
|
110
113
|
|
|
@@ -118,6 +121,8 @@ export class CodeAgent {
|
|
|
118
121
|
currentSessionId: () => agent.sessionId,
|
|
119
122
|
mailbox: () => agent.#mailbox,
|
|
120
123
|
onNotice: (message) => agent.#emit({ type: "notice", level: "warning", message }),
|
|
124
|
+
onAgents: (agents) => agent.#emit({ type: "subagent", agents }),
|
|
125
|
+
onAsk: (question) => agent.ask(question),
|
|
121
126
|
});
|
|
122
127
|
|
|
123
128
|
const resourceLoader = new DefaultResourceLoader({
|
|
@@ -136,8 +141,7 @@ export class CodeAgent {
|
|
|
136
141
|
const { session } = await createAgentSession({
|
|
137
142
|
cwd,
|
|
138
143
|
agentDir: akanCodePaths.globalDir(),
|
|
139
|
-
|
|
140
|
-
modelRegistry,
|
|
144
|
+
modelRuntime,
|
|
141
145
|
settingsManager,
|
|
142
146
|
resourceLoader,
|
|
143
147
|
model,
|
|
@@ -147,6 +151,8 @@ export class CodeAgent {
|
|
|
147
151
|
customTools: options.customTools,
|
|
148
152
|
});
|
|
149
153
|
agent.#disposePlugins = akan.dispose;
|
|
154
|
+
agent.#mcp = akan.mcp;
|
|
155
|
+
agent.#workspaceRoot = workspaceRoot;
|
|
150
156
|
if (options.profile.session.store === "file") agent.#sessionDir = akanCodePaths.sessionsDir(workspaceRoot);
|
|
151
157
|
await agent.#attach(session, options.mode ?? "print");
|
|
152
158
|
if (agent.#sessionDir) agent.#openMail(akanCodePaths.mailDir(workspaceRoot), cwd);
|
|
@@ -169,6 +175,21 @@ export class CodeAgent {
|
|
|
169
175
|
return this.#sessionDir ? CodeSessionIndex.list(this.#sessionDir) : [];
|
|
170
176
|
}
|
|
171
177
|
|
|
178
|
+
/** The repo, which is not `profile.paths.root` — that one is narrowed to an app by `akan code --app`. */
|
|
179
|
+
get workspaceRoot() {
|
|
180
|
+
return this.#workspaceRoot;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The MCP servers this session was built against, and what each one published.
|
|
185
|
+
*
|
|
186
|
+
* Read from the assembly rather than from the live session: the tool allowlist is fixed when the session is
|
|
187
|
+
* created, so a server declared afterwards is not one this agent can reach however the file now reads.
|
|
188
|
+
*/
|
|
189
|
+
mcpServers(): CodeAgentMcpStatus[] {
|
|
190
|
+
return this.#mcp;
|
|
191
|
+
}
|
|
192
|
+
|
|
172
193
|
/** The other `akan code` sessions running in this workspace right now. */
|
|
173
194
|
peers() {
|
|
174
195
|
return this.#mailbox?.peers() ?? [];
|
|
@@ -262,6 +283,7 @@ export class CodeAgent {
|
|
|
262
283
|
const text = CodeAgent.#textOf(message.content);
|
|
263
284
|
if (text) this.#emit({ type: "message", turnId, role: message.role, text });
|
|
264
285
|
}
|
|
286
|
+
this.#emitContextNow();
|
|
265
287
|
}
|
|
266
288
|
|
|
267
289
|
static #textOf(content: unknown) {
|
|
@@ -381,9 +403,36 @@ export class CodeAgent {
|
|
|
381
403
|
this.#emit({ type: "session", info: this.info });
|
|
382
404
|
}
|
|
383
405
|
|
|
406
|
+
/**
|
|
407
|
+
* The model catalogue, as the two questions a person actually asks of it.
|
|
408
|
+
*
|
|
409
|
+
* Built from the registry rather than `getAvailable()` so it needs no await and can answer about a provider
|
|
410
|
+
* nobody has a key for: "what else could I run" is most of why the list is opened, and a list of only what
|
|
411
|
+
* is already reachable cannot answer it.
|
|
412
|
+
*/
|
|
413
|
+
catalogue(): CodeAgentProviderInfo[] {
|
|
414
|
+
const runtime = this.#session?.modelRuntime;
|
|
415
|
+
if (!runtime) return [];
|
|
416
|
+
const current = this.#session?.model;
|
|
417
|
+
return runtime
|
|
418
|
+
.getProviders()
|
|
419
|
+
.map((provider) => ({
|
|
420
|
+
id: provider.id,
|
|
421
|
+
name: provider.name ?? provider.id,
|
|
422
|
+
authorized: runtime.hasConfiguredAuth(provider.id),
|
|
423
|
+
models: (runtime.getModels(provider.id) ?? []).map((model) => ({
|
|
424
|
+
id: model.id,
|
|
425
|
+
name: model.name,
|
|
426
|
+
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
|
|
427
|
+
current: model.provider === current?.provider && model.id === current?.id,
|
|
428
|
+
})),
|
|
429
|
+
}))
|
|
430
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
431
|
+
}
|
|
432
|
+
|
|
384
433
|
async setModel(ref: CodeAgentModelRef) {
|
|
385
434
|
const session = this.#require();
|
|
386
|
-
const model = await akanCodeModel(session.
|
|
435
|
+
const model = await akanCodeModel(session.modelRuntime, ref);
|
|
387
436
|
if (!model) throw new Error(`Unknown model: ${ref.provider}/${ref.id}`);
|
|
388
437
|
await session.setModel(model);
|
|
389
438
|
this.#emit({ type: "session", info: this.info });
|
|
@@ -442,6 +491,17 @@ export class CodeAgent {
|
|
|
442
491
|
*/
|
|
443
492
|
#emitContextUsage(eventType: string) {
|
|
444
493
|
if (eventType !== "agent_end" && eventType !== "compaction_end") return;
|
|
494
|
+
this.#emitContextNow();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The same frame, on demand, for the one moment the window is already full and no turn has run.
|
|
499
|
+
*
|
|
500
|
+
* A resumed session restores a conversation the model is carrying and the host has never seen, so without
|
|
501
|
+
* this the status line offers the declared window and no share of it until the next turn ends — a session
|
|
502
|
+
* reopened at 80% looks identical to one reopened empty.
|
|
503
|
+
*/
|
|
504
|
+
#emitContextNow() {
|
|
445
505
|
const usage = this.#session?.getContextUsage();
|
|
446
506
|
// `== null`, not `=== null`: the field is typed `number | null`, and a strict null check sails straight
|
|
447
507
|
// past an `undefined` the way the mirror of this bug does — which would emit `used: undefined`.
|
|
@@ -518,25 +578,33 @@ export class CodeAgent {
|
|
|
518
578
|
return await this.#asks.openApproval(approvalId);
|
|
519
579
|
}
|
|
520
580
|
|
|
581
|
+
/**
|
|
582
|
+
* Puts one question on the wire and waits for its answer, rendered as the labels a person chose.
|
|
583
|
+
*
|
|
584
|
+
* The single way anything asks: the engine's own UI port and the `ask_user` tool both land here, so the
|
|
585
|
+
* suspend path, the profile's refusal to prompt at all, and the id the host answers against cannot disagree
|
|
586
|
+
* between them. An empty answer is a skip, which is a real answer and not a failure.
|
|
587
|
+
*/
|
|
588
|
+
async ask(spec: Omit<CodeAgentQuestion, "questionId">) {
|
|
589
|
+
if (!this.#profile.ui.canPrompt) return undefined;
|
|
590
|
+
const question: CodeAgentQuestion = { questionId: this.#asks.nextId("q"), ...spec };
|
|
591
|
+
this.#emit({ type: "question", question });
|
|
592
|
+
if (this.#profile.interaction.question === "suspend") {
|
|
593
|
+
this.#mapper.noteOutcome("awaiting");
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
return (await this.#asks.openQuestion(question)) || undefined;
|
|
597
|
+
}
|
|
598
|
+
|
|
521
599
|
#ui() {
|
|
522
600
|
return new CodeAgentUi({
|
|
523
|
-
ask: async (prompt, kind, choices) =>
|
|
524
|
-
|
|
525
|
-
const questionId = this.#asks.nextId("q");
|
|
526
|
-
const question: CodeAgentQuestion = {
|
|
527
|
-
questionId,
|
|
601
|
+
ask: async (prompt, kind, choices) =>
|
|
602
|
+
await this.ask({
|
|
528
603
|
prompt,
|
|
529
604
|
kind,
|
|
530
605
|
...(choices ? { options: choices.map((label) => ({ key: label, label })) } : {}),
|
|
531
606
|
freeText: kind === "text",
|
|
532
|
-
}
|
|
533
|
-
this.#emit({ type: "question", question });
|
|
534
|
-
if (this.#profile.interaction.question === "suspend") {
|
|
535
|
-
this.#mapper.noteOutcome("awaiting");
|
|
536
|
-
return undefined;
|
|
537
|
-
}
|
|
538
|
-
return (await this.#asks.openQuestion(question)) || undefined;
|
|
539
|
-
},
|
|
607
|
+
}),
|
|
540
608
|
notify: (level, message) => this.#emit({ type: "notice", level, message }),
|
|
541
609
|
});
|
|
542
610
|
}
|
|
@@ -54,6 +54,10 @@ export class CodeAgentStreamPrinter {
|
|
|
54
54
|
case "queue":
|
|
55
55
|
case "context":
|
|
56
56
|
return;
|
|
57
|
+
// The rail is a live status for a screen that redraws. A stream has no row to update, and the pool
|
|
58
|
+
// already says on the transcript when a child starts and what it spent when it stops.
|
|
59
|
+
case "subagent":
|
|
60
|
+
return;
|
|
57
61
|
case "question":
|
|
58
62
|
return this.#line(chalk.yellow(`? ${event.question.prompt}`));
|
|
59
63
|
case "approval":
|