@akanjs/devkit 3.0.0-beta.14 → 3.0.0-beta.15

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 (56) hide show
  1. package/README.md +1 -0
  2. package/codeAgent/agent/AkanAuthStorageBackend.ts +105 -0
  3. package/codeAgent/agent/AkanCodeServices.ts +104 -0
  4. package/codeAgent/agent/CodeAgent.ts +543 -0
  5. package/codeAgent/agent/CodeAgentAsks.ts +87 -0
  6. package/codeAgent/agent/CodeAgentEventMapper.ts +260 -0
  7. package/codeAgent/agent/CodeAgentGate.ts +119 -0
  8. package/codeAgent/agent/CodeAgentRpcHost.ts +96 -0
  9. package/codeAgent/agent/CodeAgentStreamPrinter.ts +93 -0
  10. package/codeAgent/agent/CodeAgentUi.ts +52 -0
  11. package/codeAgent/agent/CodeMailbox.ts +175 -0
  12. package/codeAgent/agent/CodeSessionFork.ts +71 -0
  13. package/codeAgent/agent/CodeSessionIndex.ts +104 -0
  14. package/codeAgent/agent/SubagentPool.ts +173 -0
  15. package/codeAgent/agent/akanCodeModel.ts +81 -0
  16. package/codeAgent/agent/akanCodePaths.ts +31 -0
  17. package/codeAgent/agent/akanSystemPrompt.ts +49 -0
  18. package/codeAgent/agent/codeAgentContract.test.ts +529 -0
  19. package/codeAgent/agent/codeMailbox.test.ts +80 -0
  20. package/codeAgent/agent/codeSessionFork.test.ts +60 -0
  21. package/codeAgent/agent/codeSessionIndex.test.ts +72 -0
  22. package/codeAgent/agent/consoleToStderr.ts +18 -0
  23. package/codeAgent/feedback/DevLogFeedback.ts +97 -0
  24. package/codeAgent/feedback/PreviewChrome.ts +106 -0
  25. package/codeAgent/feedback/PreviewFeedback.ts +53 -0
  26. package/codeAgent/feedback/PreviewView.ts +107 -0
  27. package/codeAgent/feedback/TurnFeedback.ts +113 -0
  28. package/codeAgent/feedback/VerifyFeedback.ts +46 -0
  29. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  30. package/codeAgent/index.ts +43 -0
  31. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  32. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  33. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  34. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  35. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  36. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  37. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  38. package/codeAgent/skills/akan-store/SKILL.md +63 -0
  39. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  40. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  41. package/codeAgent/tools/AkanCli.ts +62 -0
  42. package/codeAgent/tools/AkanCodePlugins.ts +135 -0
  43. package/codeAgent/tools/AkanEditScope.ts +58 -0
  44. package/codeAgent/tools/AkanEnvKeys.ts +56 -0
  45. package/codeAgent/tools/AkanToolPack.ts +114 -0
  46. package/codeAgent/tools/AkanVerifier.ts +55 -0
  47. package/codeAgent/tools/MailToolPack.ts +85 -0
  48. package/codeAgent/tools/McpClient.ts +162 -0
  49. package/codeAgent/tools/McpToolPack.ts +134 -0
  50. package/codeAgent/tools/SessionToolPack.ts +101 -0
  51. package/codeAgent/tools/WebToolPack.ts +153 -0
  52. package/codeAgent/tools/akanCodePlugins.test.ts +476 -0
  53. package/index.ts +2 -0
  54. package/package.json +5 -2
  55. package/stripAnsi.ts +3 -0
  56. package/testHelpers.ts +179 -0
@@ -0,0 +1,476 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { codeAgentPresets } from "akanjs/common";
4
+ import { cleanupCliTempWorkspace, makeCliTempWorkspace, writeText } from "../../testHelpers";
5
+ import { SubagentPool } from "../agent/SubagentPool";
6
+ import { DevLogFeedback } from "../feedback/DevLogFeedback";
7
+ import { PreviewView } from "../feedback/PreviewView";
8
+ import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
9
+ import { AkanEditScope } from "./AkanEditScope";
10
+ import { AkanEnvKeys } from "./AkanEnvKeys";
11
+ import { AkanVerifier } from "./AkanVerifier";
12
+ import { McpClient } from "./McpClient";
13
+ import { McpToolPack } from "./McpToolPack";
14
+ import { SessionToolPack } from "./SessionToolPack";
15
+ import { WebToolPack } from "./WebToolPack";
16
+
17
+ const tempRoots: string[] = [];
18
+
19
+ afterEach(async () => {
20
+ await Promise.all(tempRoots.splice(0).map((root) => cleanupCliTempWorkspace(root)));
21
+ });
22
+
23
+ const source = (key: string, findings: (string | undefined)[]): TurnFeedbackSource => {
24
+ let call = 0;
25
+ return {
26
+ key,
27
+ begin: () => {},
28
+ observe: () => {
29
+ const finding = findings[Math.min(call, findings.length - 1)];
30
+ call += 1;
31
+ return finding;
32
+ },
33
+ };
34
+ };
35
+
36
+ describe("TurnFeedback", () => {
37
+ test("reports a finding as one message the agent can act on", async () => {
38
+ const feedback = new TurnFeedback([source("a", ["dev server died"])], { budget: 2 });
39
+ const message = await feedback.collect();
40
+ expect(message).toContain("dev server died");
41
+ expect(message).toContain("Fix them, then stop.");
42
+ });
43
+
44
+ test("says nothing when every source is quiet", async () => {
45
+ const feedback = new TurnFeedback([source("a", [undefined])], { budget: 2 });
46
+ expect(await feedback.collect()).toBeUndefined();
47
+ });
48
+
49
+ test("stops after the budget so one unfixable error cannot loop forever", async () => {
50
+ const notices: string[] = [];
51
+ const feedback = new TurnFeedback([source("a", ["still broken"])], { budget: 2, onNotice: (m) => notices.push(m) });
52
+ expect(await feedback.collect()).toContain("still broken");
53
+ expect(await feedback.collect()).toContain("still broken");
54
+ expect(await feedback.collect()).toBeUndefined();
55
+ // Every reopen is announced: a turn nobody asked for looks, on the transcript, like an agent that will
56
+ // not stop — more tool calls after the answer and nothing naming who sent it back to work.
57
+ expect(notices[0]).toContain("a reopened the turn");
58
+ expect(notices.at(-1)).toContain("still failing after 2 attempts");
59
+ });
60
+
61
+ test("a source that goes quiet gets its budget back", async () => {
62
+ const feedback = new TurnFeedback([source("a", ["broken", undefined, "broken again"])], { budget: 1 });
63
+ expect(await feedback.collect()).toContain("broken");
64
+ expect(await feedback.collect()).toBeUndefined();
65
+ expect(await feedback.collect()).toContain("broken again");
66
+ });
67
+
68
+ test("budgets are per source, so one stuck source does not silence another", async () => {
69
+ const feedback = new TurnFeedback([source("a", ["stuck"]), source("b", [undefined, "new"])], { budget: 1 });
70
+ expect(await feedback.collect()).toContain("stuck");
71
+ expect(await feedback.collect()).toContain("new");
72
+ });
73
+
74
+ test("a source that throws is skipped rather than taking the turn down", async () => {
75
+ const angry: TurnFeedbackSource = {
76
+ key: "angry",
77
+ begin: () => {},
78
+ observe: () => {
79
+ throw new Error("no dev server");
80
+ },
81
+ };
82
+ const feedback = new TurnFeedback([angry, source("b", ["real finding"])], { budget: 2 });
83
+ expect(await feedback.collect()).toContain("real finding");
84
+ });
85
+
86
+ test("attaches images only when a source supplies them", async () => {
87
+ const withImage: TurnFeedbackSource = {
88
+ key: "preview",
89
+ begin: () => {},
90
+ observe: () => ({ text: "page is blank", images: [{ data: "AAA", mimeType: "image/png" }] }),
91
+ };
92
+ const message = await new TurnFeedback([withImage], { budget: 2 }).collect();
93
+ expect(Array.isArray(message)).toBe(true);
94
+ expect(message).toEqual([
95
+ { type: "text", text: expect.stringContaining("page is blank") },
96
+ { type: "image", data: "AAA", mimeType: "image/png" },
97
+ ]);
98
+ });
99
+ });
100
+
101
+ describe("AkanVerifier.summarize", () => {
102
+ const scope = { paths: ["apps/a/x.ts"], apps: ["a"], libs: [], needsSync: false, touchesTsx: false };
103
+
104
+ test("names the failing step and keeps only its tail", () => {
105
+ const output = [...Array(100).keys()].map((n) => `line ${n}`).join("\n");
106
+ const text = AkanVerifier.summarize(
107
+ {
108
+ ok: false,
109
+ scope,
110
+ steps: [
111
+ { command: "akan lint a", ok: true, exitCode: 0, output: "" },
112
+ { command: "akan typecheck a", ok: false, exitCode: 1, output },
113
+ ],
114
+ },
115
+ { tailLines: 5 },
116
+ );
117
+ expect(text).toContain("Verification failed: akan typecheck a");
118
+ expect(text).toContain("Passed: akan lint a");
119
+ expect(text).toContain("line 99");
120
+ expect(text).not.toContain("line 10\n");
121
+ });
122
+
123
+ test("says so plainly when everything passed", () => {
124
+ const text = AkanVerifier.summarize({
125
+ ok: true,
126
+ scope,
127
+ steps: [{ command: "akan lint a", ok: true, exitCode: 0, output: "" }],
128
+ });
129
+ expect(text).toContain("Verification passed");
130
+ });
131
+
132
+ test("an untouched workspace is not a failure", () => {
133
+ expect(AkanVerifier.summarize({ ok: true, scope, steps: [] })).toContain("nothing to verify");
134
+ });
135
+ });
136
+
137
+ describe("AkanEditScope", () => {
138
+ test("derives apps, libs, sync need and tsx from a git status diff", async () => {
139
+ const { root } = await makeCliTempWorkspace();
140
+ tempRoots.push(root);
141
+ await Bun.spawn(["git", "init", "-q"], { cwd: root, stdout: "ignore", stderr: "ignore" }).exited;
142
+ const baseline = await AkanEditScope.baseline(root);
143
+ await writeText(path.join(root, "apps", "demo", "lib", "post", "post.service.ts"), "export {};\n");
144
+ await writeText(path.join(root, "libs", "shared", "ui", "Card.tsx"), "export const Card = () => null;\n");
145
+ const scope = await AkanEditScope.since(root, baseline);
146
+ expect(scope.apps).toEqual(["demo"]);
147
+ expect(scope.libs).toEqual(["shared"]);
148
+ expect(scope.needsSync).toBe(true);
149
+ expect(scope.touchesTsx).toBe(true);
150
+ });
151
+
152
+ test("an unchanged tree implicates no target", async () => {
153
+ const { root } = await makeCliTempWorkspace();
154
+ tempRoots.push(root);
155
+ await Bun.spawn(["git", "init", "-q"], { cwd: root, stdout: "ignore", stderr: "ignore" }).exited;
156
+ const baseline = await AkanEditScope.baseline(root);
157
+ const scope = await AkanEditScope.since(root, baseline);
158
+ expect(scope).toEqual({ paths: [], apps: [], libs: [], needsSync: false, touchesTsx: false });
159
+ });
160
+ });
161
+
162
+ describe("DevLogFeedback", () => {
163
+ const logPath = (root: string, app: string) => path.join(root, "local", "apps", app, "runtime", "dev.log");
164
+
165
+ /**
166
+ * A turn that wrote nothing cannot have broken the running app, so the source is gated on the working tree
167
+ * moving — which makes a repo with one edit in it the minimum a test has to set up.
168
+ */
169
+ const armed = async (root: string, feedback: DevLogFeedback) => {
170
+ Bun.spawnSync(["git", "init", "-q"], { cwd: root });
171
+ await feedback.begin();
172
+ await writeText(path.join(root, "apps", "demo", "touched.ts"), "export const a = 1;\n");
173
+ };
174
+
175
+ test("reports only the errors written after the watermark", async () => {
176
+ const { root } = await makeCliTempWorkspace();
177
+ tempRoots.push(root);
178
+ await writeText(logPath(root, "demo"), "[demo] boot ok\n");
179
+ const feedback = new DevLogFeedback({ workspaceRoot: root, cwd: root, apps: ["demo"], graceMs: 0 });
180
+ await armed(root, feedback);
181
+ await writeText(logPath(root, "demo"), "[demo] boot ok\n[AkanApp] ERROR Port 8282 is already in use\n");
182
+ const finding = await feedback.observe();
183
+ expect(finding).toContain("Port 8282 is already in use");
184
+ expect(finding).not.toContain("boot ok");
185
+ });
186
+
187
+ test("stays quiet when the new lines are only debug noise", async () => {
188
+ const { root } = await makeCliTempWorkspace();
189
+ tempRoots.push(root);
190
+ await writeText(logPath(root, "demo"), "start\n");
191
+ const feedback = new DevLogFeedback({ workspaceRoot: root, cwd: root, apps: ["demo"], graceMs: 0 });
192
+ await armed(root, feedback);
193
+ await writeText(logPath(root, "demo"), "start\n[McpRouter] DEBUG MCP catalogue: tools=3\n");
194
+ expect(await feedback.observe()).toBeUndefined();
195
+ });
196
+
197
+ test("no dev server means nothing to say, not an error", async () => {
198
+ const { root } = await makeCliTempWorkspace();
199
+ tempRoots.push(root);
200
+ const feedback = new DevLogFeedback({ workspaceRoot: root, cwd: root, apps: ["demo"], graceMs: 0 });
201
+ await armed(root, feedback);
202
+ expect(await feedback.observe()).toBeUndefined();
203
+ });
204
+
205
+ test("a turn that changed nothing is not told about a dev server somebody else is driving", async () => {
206
+ const { root } = await makeCliTempWorkspace();
207
+ tempRoots.push(root);
208
+ Bun.spawnSync(["git", "init", "-q"], { cwd: root });
209
+ await writeText(logPath(root, "demo"), "[demo] boot ok\n");
210
+ const feedback = new DevLogFeedback({ workspaceRoot: root, cwd: root, apps: ["demo"], graceMs: 0 });
211
+ await feedback.begin();
212
+ await writeText(logPath(root, "demo"), "[demo] boot ok\n[AkanApp] ERROR Port 8282 is already in use\n");
213
+ expect(await feedback.observe()).toBeUndefined();
214
+ });
215
+
216
+ test("reads the dev server's allocated URL back out of its own log", async () => {
217
+ const { root } = await makeCliTempWorkspace();
218
+ tempRoots.push(root);
219
+ await writeText(
220
+ logPath(root, "demo"),
221
+ "[demo] demo ready (pid=1) — http://localhost:3000\n[demo] demo ready (pid=2) — http://localhost:8282\n",
222
+ );
223
+ expect(await DevLogFeedback.previewUrl(root, "demo")).toBe("http://localhost:8282");
224
+ expect(await DevLogFeedback.previewUrl(root, "absent")).toBeUndefined();
225
+ });
226
+ });
227
+
228
+ describe("AkanEnvKeys", () => {
229
+ test("maps <PROVIDER>_API_KEY onto the engine's provider id without persisting it", async () => {
230
+ const { root } = await makeCliTempWorkspace();
231
+ tempRoots.push(root);
232
+ await writeText(path.join(root, ".env"), '# comment\nDEEPSEEK_API_KEY="sk-test"\nSOMETHING_ELSE=1\n');
233
+ const applied: [string, string][] = [];
234
+ const fake = { setRuntimeApiKey: (provider: string, key: string) => applied.push([provider, key]) };
235
+ const providers = AkanEnvKeys.apply(fake as never, root);
236
+ expect(providers).toContain("deepseek");
237
+ expect(applied).toContainEqual(["deepseek", "sk-test"]);
238
+ });
239
+ });
240
+
241
+ describe("PreviewView", () => {
242
+ test("reports whether this Bun build can drive a headless page at all", () => {
243
+ expect(typeof PreviewView.available).toBe("boolean");
244
+ });
245
+
246
+ test.skipIf(!PreviewView.available)("finds an empty body and a console error on a real page", async () => {
247
+ const server = Bun.serve({
248
+ port: 0,
249
+ fetch: () =>
250
+ new Response('<!doctype html><html><body></body><script>console.error("boom")</script></html>', {
251
+ headers: { "content-type": "text/html" },
252
+ }),
253
+ });
254
+ try {
255
+ const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
256
+ expect(probe.pageErrors.join(" ")).toContain("rendered empty");
257
+ expect(probe.consoleErrors.join(" ")).toContain("boom");
258
+ } finally {
259
+ server.stop(true);
260
+ }
261
+ });
262
+ });
263
+
264
+ describe("SubagentPool", () => {
265
+ const workspace = { workspaceRoot: "/tmp/akan" } as never;
266
+
267
+ test("is absent when the profile disables sub-agents", () => {
268
+ const profile = codeAgentPresets.local("/tmp/akan");
269
+ const off = { ...profile, tools: { ...profile.tools, subagent: false as const } };
270
+ expect(SubagentPool.extensionFor({ workspace, cwd: "/tmp/akan", parent: off, depth: 0 })).toBeUndefined();
271
+ });
272
+
273
+ test("is absent once the depth limit is reached, so a task cannot recurse forever", () => {
274
+ const parent = codeAgentPresets.local("/tmp/akan");
275
+ expect(SubagentPool.extensionFor({ workspace, cwd: "/tmp/akan", parent, depth: 0 })).toBeDefined();
276
+ expect(SubagentPool.extensionFor({ workspace, cwd: "/tmp/akan", parent, depth: 2 })).toBeUndefined();
277
+ });
278
+
279
+ test("review allows one level only", () => {
280
+ const parent = codeAgentPresets.review("/tmp/akan");
281
+ expect(SubagentPool.extensionFor({ workspace, cwd: "/tmp/akan", parent, depth: 0 })).toBeDefined();
282
+ expect(SubagentPool.extensionFor({ workspace, cwd: "/tmp/akan", parent, depth: 1 })).toBeUndefined();
283
+ });
284
+
285
+ test("an explore child cannot write or run anything, whatever its parent could", () => {
286
+ const child = SubagentPool.childOf(codeAgentPresets.local("/tmp/akan"), "explore");
287
+ expect(child.tools.builtin).toEqual(["read", "ls", "grep", "find"]);
288
+ });
289
+
290
+ test("a code child keeps the parent's tools, and never more than them", () => {
291
+ const full = SubagentPool.childOf(codeAgentPresets.local("/tmp/akan"), "code");
292
+ expect(full.tools.builtin).toContain("write");
293
+ // A read-only parent stays read-only through the kind that is allowed to write: narrowing only narrows.
294
+ const reader = SubagentPool.childOf(codeAgentPresets.review("/tmp/akan"), "code");
295
+ expect(reader.tools.builtin).not.toContain("write");
296
+ expect(reader.tools.builtin).not.toContain("bash");
297
+ });
298
+
299
+ /** Nothing a sub-agent does belongs in the workspace's session tree, and nobody is there to answer it. */
300
+ test("a child keeps no session and cannot ask a human", () => {
301
+ const child = SubagentPool.childOf(codeAgentPresets.local("/tmp/akan"), "explore");
302
+ expect(child.session).toEqual({ store: "memory", crossSession: false });
303
+ expect(child.ui.canPrompt).toBe(false);
304
+ expect(child.context.projectFiles).toBe(false);
305
+ });
306
+ });
307
+
308
+ describe("McpToolPack", () => {
309
+ /** A minimal stdio MCP server, written at test time so the suite carries no extra source file. */
310
+ const serverSource = `
311
+ const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\\n");
312
+ for await (const line of console) {
313
+ if (!line.trim()) continue;
314
+ const message = JSON.parse(line);
315
+ if (message.method === "initialize") reply(message.id, { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "echo", version: "1" } });
316
+ if (message.method === "tools/list")
317
+ reply(message.id, { tools: [{ name: "echo", description: "Echo the text back.", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }] });
318
+ if (message.method === "tools/call")
319
+ reply(message.id, { content: [{ type: "text", text: "echo: " + message.params.arguments.text }] });
320
+ }
321
+ `;
322
+
323
+ const withServer = async () => {
324
+ const { root } = await makeCliTempWorkspace();
325
+ tempRoots.push(root);
326
+ const script = path.join(root, "echo-mcp.ts");
327
+ await writeText(script, serverSource);
328
+ await writeText(
329
+ path.join(root, ".akan", "code", "mcp.json"),
330
+ JSON.stringify({ mcpServers: { echo: { command: process.execPath, args: [script] } } }),
331
+ );
332
+ return root;
333
+ };
334
+
335
+ test("connects a declared stdio server, lists its tools and calls one", async () => {
336
+ const root = await withServer();
337
+ const pack = await McpToolPack.connect({ workspaceRoot: root, profile: codeAgentPresets.local(root) });
338
+ expect(pack).toBeDefined();
339
+ try {
340
+ expect(pack?.toolNames).toEqual(["mcp__echo__echo"]);
341
+ const client = await new McpClient({
342
+ name: "echo",
343
+ transport: "stdio",
344
+ command: process.execPath,
345
+ args: [path.join(root, "echo-mcp.ts")],
346
+ }).connect();
347
+ try {
348
+ expect(await client.callTool("echo", { text: "hi" })).toEqual({ text: "echo: hi", isError: false });
349
+ } finally {
350
+ client.close();
351
+ }
352
+ } finally {
353
+ pack?.close();
354
+ }
355
+ });
356
+
357
+ test("a server that cannot start costs its own tools and nothing else", async () => {
358
+ const { root } = await makeCliTempWorkspace();
359
+ tempRoots.push(root);
360
+ await writeText(
361
+ path.join(root, ".akan", "code", "mcp.json"),
362
+ JSON.stringify({ mcpServers: { broken: { command: "/nonexistent/binary" } } }),
363
+ );
364
+ const notices: string[] = [];
365
+ const pack = await McpToolPack.connect({
366
+ workspaceRoot: root,
367
+ profile: codeAgentPresets.local(root),
368
+ onNotice: (message) => notices.push(message),
369
+ });
370
+ expect(pack).toBeUndefined();
371
+ expect(notices.join(" ")).toContain("broken");
372
+ });
373
+
374
+ test("declares nothing when the profile turns MCP off", async () => {
375
+ const root = await withServer();
376
+ const pack = await McpToolPack.connect({ workspaceRoot: root, profile: codeAgentPresets.review(root) });
377
+ expect(pack).toBeUndefined();
378
+ });
379
+ });
380
+
381
+ describe("WebToolPack", () => {
382
+ const root = "/tmp/akan-web";
383
+
384
+ test("publishes only what the profile allows and the environment can serve", () => {
385
+ const readOnly = codeAgentPresets.review(root);
386
+ expect(new WebToolPack({ profile: readOnly }).names()).toEqual([]);
387
+ expect(new WebToolPack({ profile: codeAgentPresets.local(root) }).names()).toContain("web_fetch");
388
+ });
389
+
390
+ test("registers web_search only when a provider key is present", () => {
391
+ const profile = codeAgentPresets.local(root);
392
+ const had = process.env.BRAVE_API_KEY;
393
+ try {
394
+ delete process.env.BRAVE_API_KEY;
395
+ delete process.env.TAVILY_API_KEY;
396
+ expect(new WebToolPack({ profile }).names()).not.toContain("web_search");
397
+ process.env.BRAVE_API_KEY = "test";
398
+ expect(new WebToolPack({ profile }).names()).toContain("web_search");
399
+ } finally {
400
+ if (had === undefined) delete process.env.BRAVE_API_KEY;
401
+ else process.env.BRAVE_API_KEY = had;
402
+ }
403
+ });
404
+
405
+ test("reads a page down to text and refuses a host the profile excludes", async () => {
406
+ const server = Bun.serve({
407
+ port: 0,
408
+ fetch: () =>
409
+ new Response(
410
+ "<html><head><style>b{}</style></head><body><h1>Akan</h1><p>Hello &amp; welcome</p></body></html>",
411
+ {
412
+ headers: { "content-type": "text/html" },
413
+ },
414
+ ),
415
+ });
416
+ try {
417
+ const profile = codeAgentPresets.local(root);
418
+ const pack = new WebToolPack({ profile });
419
+ const tools = new Map<
420
+ string,
421
+ (id: string, params: never) => Promise<{ content: { text: string }[]; isError?: boolean }>
422
+ >();
423
+ const factory = (pack.extension() as { factory: (pi: unknown) => void }).factory;
424
+ factory({ registerTool: (tool: { name: string; execute: never }) => tools.set(tool.name, tool.execute) });
425
+ const fetchTool = tools.get("web_fetch");
426
+ const ok = await fetchTool?.("1", { url: `http://localhost:${server.port}/` } as never);
427
+ expect(ok?.content[0]?.text).toContain("Akan");
428
+ expect(ok?.content[0]?.text).toContain("Hello & welcome");
429
+ expect(ok?.content[0]?.text).not.toContain("<h1>");
430
+
431
+ const narrowed = new WebToolPack({ profile: { ...profile, network: { allowHosts: ["docs.akanjs.com"] } } });
432
+ const narrowedTools = new Map<
433
+ string,
434
+ (id: string, params: never) => Promise<{ content: { text: string }[]; isError?: boolean }>
435
+ >();
436
+ (narrowed.extension() as { factory: (pi: unknown) => void }).factory({
437
+ registerTool: (tool: { name: string; execute: never }) => narrowedTools.set(tool.name, tool.execute),
438
+ });
439
+ const refused = await narrowedTools.get("web_fetch")?.("1", { url: `http://localhost:${server.port}/` } as never);
440
+ expect(refused?.isError).toBe(true);
441
+ expect(refused?.content[0]?.text).toContain("docs.akanjs.com");
442
+ } finally {
443
+ server.stop(true);
444
+ }
445
+ });
446
+ });
447
+
448
+ describe("SessionToolPack", () => {
449
+ const options = (root: string, profile = codeAgentPresets.local(root)) => ({
450
+ workspaceRoot: root,
451
+ cwd: root,
452
+ profile,
453
+ currentSessionId: () => "current",
454
+ });
455
+
456
+ test("is absent when the profile forbids reaching other sessions", () => {
457
+ const pack = new SessionToolPack(options("/tmp/akan", codeAgentPresets.review("/tmp/akan")));
458
+ expect(pack.names()).toEqual([]);
459
+ expect(pack.extension()).toBeUndefined();
460
+ });
461
+
462
+ test("publishes both readers when cross-session is on", () => {
463
+ expect(new SessionToolPack(options("/tmp/akan")).names()).toEqual(["session_search", "session_read"]);
464
+ });
465
+
466
+ test("says so plainly when nothing earlier matches", async () => {
467
+ const { root } = await makeCliTempWorkspace();
468
+ tempRoots.push(root);
469
+ const tools = new Map<string, (id: string, params: never) => Promise<{ content: { text: string }[] }>>();
470
+ (new SessionToolPack(options(root)).extension() as { factory: (pi: unknown) => void }).factory({
471
+ registerTool: (tool: { name: string; execute: never }) => tools.set(tool.name, tool.execute),
472
+ });
473
+ const result = await tools.get("session_search")?.("1", { query: "tunnel wire" } as never);
474
+ expect(result?.content[0]?.text).toContain("No earlier session");
475
+ });
476
+ });
package/index.ts CHANGED
@@ -23,6 +23,7 @@ export type * from "./applicationTestPreload";
23
23
  export type * from "./artifact";
24
24
  export type * from "./capacitorApp";
25
25
  export type * from "./cloud";
26
+ export type * from "./codeAgent";
26
27
  export type * from "./commandDecorators";
27
28
  export type * from "./createTunnel";
28
29
  export type * from "./dependencyScanner";
@@ -41,6 +42,7 @@ export type * from "./recipeScanner";
41
42
  export type * from "./scanInfo";
42
43
  export type * from "./selectModel";
43
44
  export type * from "./spinner";
45
+ export type * from "./stripAnsi";
44
46
  export type * from "./transforms";
45
47
  export type * from "./typeChecker";
46
48
  export type * from "./types";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-beta.14",
3
+ "version": "3.0.0-beta.15",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -28,6 +28,7 @@
28
28
  "./akanConfig": "./akanConfig/index.ts",
29
29
  "./artifact": "./artifact/index.ts",
30
30
  "./cloud": "./cloud/index.ts",
31
+ "./codeAgent": "./codeAgent/index.ts",
31
32
  "./commandDecorators": "./commandDecorators/index.ts",
32
33
  "./frontendBuild": "./frontendBuild/index.ts",
33
34
  "./incrementalBuilder": "./incrementalBuilder/index.ts",
@@ -38,10 +39,11 @@
38
39
  "./*": "./*.ts"
39
40
  },
40
41
  "dependencies": {
42
+ "@earendil-works/pi-coding-agent": "0.80.6",
41
43
  "@inquirer/prompts": "^8.4.3",
42
44
  "@tailwindcss/node": "^4.3.0",
43
45
  "@trapezedev/project": "^7.1.4",
44
- "akanjs": "3.0.0-beta.14",
46
+ "akanjs": "3.0.0-beta.15",
45
47
  "chalk": "^5.6.2",
46
48
  "commander": "^14.0.3",
47
49
  "dayjs": "^1.11.20",
@@ -53,6 +55,7 @@
53
55
  "subset-font": "^2.5.0",
54
56
  "tailwind-scrollbar": "^4.0.2",
55
57
  "tailwindcss": "^4.3.0",
58
+ "typebox": "1.1.38",
56
59
  "typescript": "^6.0.3"
57
60
  },
58
61
  "peerDependencies": {
package/stripAnsi.ts ADDED
@@ -0,0 +1,3 @@
1
+ const ansi = new RegExp(`${String.fromCharCode(27)}(?:[@-Z\\\\-_]|\\[[\\s\\S]*?[@-~])`, "g");
2
+
3
+ export const stripAnsi = (text: string) => text.replace(ansi, "");