@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.
- package/README.md +1 -0
- package/codeAgent/agent/AkanAuthStorageBackend.ts +105 -0
- package/codeAgent/agent/AkanCodeServices.ts +104 -0
- package/codeAgent/agent/CodeAgent.ts +543 -0
- package/codeAgent/agent/CodeAgentAsks.ts +87 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +260 -0
- package/codeAgent/agent/CodeAgentGate.ts +119 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +96 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +93 -0
- package/codeAgent/agent/CodeAgentUi.ts +52 -0
- package/codeAgent/agent/CodeMailbox.ts +175 -0
- package/codeAgent/agent/CodeSessionFork.ts +71 -0
- package/codeAgent/agent/CodeSessionIndex.ts +104 -0
- package/codeAgent/agent/SubagentPool.ts +173 -0
- package/codeAgent/agent/akanCodeModel.ts +81 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanSystemPrompt.ts +49 -0
- package/codeAgent/agent/codeAgentContract.test.ts +529 -0
- package/codeAgent/agent/codeMailbox.test.ts +80 -0
- package/codeAgent/agent/codeSessionFork.test.ts +60 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +72 -0
- package/codeAgent/agent/consoleToStderr.ts +18 -0
- package/codeAgent/feedback/DevLogFeedback.ts +97 -0
- package/codeAgent/feedback/PreviewChrome.ts +106 -0
- package/codeAgent/feedback/PreviewFeedback.ts +53 -0
- package/codeAgent/feedback/PreviewView.ts +107 -0
- package/codeAgent/feedback/TurnFeedback.ts +113 -0
- package/codeAgent/feedback/VerifyFeedback.ts +46 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +43 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +63 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +62 -0
- package/codeAgent/tools/AkanCodePlugins.ts +135 -0
- package/codeAgent/tools/AkanEditScope.ts +58 -0
- package/codeAgent/tools/AkanEnvKeys.ts +56 -0
- package/codeAgent/tools/AkanToolPack.ts +114 -0
- package/codeAgent/tools/AkanVerifier.ts +55 -0
- package/codeAgent/tools/MailToolPack.ts +85 -0
- package/codeAgent/tools/McpClient.ts +162 -0
- package/codeAgent/tools/McpToolPack.ts +134 -0
- package/codeAgent/tools/SessionToolPack.ts +101 -0
- package/codeAgent/tools/WebToolPack.ts +153 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +476 -0
- package/index.ts +2 -0
- package/package.json +5 -2
- package/stripAnsi.ts +3 -0
- package/testHelpers.ts +179 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CodeSessionFork } from "./CodeSessionFork";
|
|
6
|
+
import { CodeSessionIndex } from "./CodeSessionIndex";
|
|
7
|
+
|
|
8
|
+
const lines = (id: string) => [
|
|
9
|
+
JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-09-20T14:35:32.579Z", cwd: "/repo" }),
|
|
10
|
+
JSON.stringify({ type: "session_info", id: "aa", parentId: null, name: "read-the-guard" }),
|
|
11
|
+
JSON.stringify({ type: "message", id: "bb", parentId: "aa", message: { role: "user", content: "who guards it" } }),
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const seed = () => {
|
|
15
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-fork-"));
|
|
16
|
+
const id = "01a0bf3e-6563-78f4-9017-c541ca067354";
|
|
17
|
+
const file = path.join(dir, `2026-09-20T14-35-32-579Z_${id}.jsonl`);
|
|
18
|
+
writeFileSync(file, `${lines(id).join("\n")}\n`);
|
|
19
|
+
return { dir, id, file };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const records = (file: string) =>
|
|
23
|
+
readFileSync(file, "utf8")
|
|
24
|
+
.split("\n")
|
|
25
|
+
.filter((line) => !!line.trim())
|
|
26
|
+
.map((line) => JSON.parse(line) as { type?: string; id?: string; name?: string });
|
|
27
|
+
|
|
28
|
+
describe("CodeSessionFork", () => {
|
|
29
|
+
test("the copy carries the conversation under an id of its own", () => {
|
|
30
|
+
const { dir, id, file } = seed();
|
|
31
|
+
const forked = CodeSessionFork.fork(dir, id);
|
|
32
|
+
expect(forked.id).not.toBe(id);
|
|
33
|
+
expect(forked.file).not.toBe(file);
|
|
34
|
+
const copied = records(forked.file);
|
|
35
|
+
expect(copied.find((record) => record.type === "session")?.id).toBe(forked.id);
|
|
36
|
+
expect(copied.filter((record) => record.type === "message").length).toBe(1);
|
|
37
|
+
// Two live agents sharing one id would share one inbox, one presence entry and one resume target.
|
|
38
|
+
expect(CodeSessionIndex.fileOf(dir, forked.id)).toBe(forked.file);
|
|
39
|
+
expect(CodeSessionIndex.fileOf(dir, id)).toBe(file);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the original is left exactly as it was", () => {
|
|
43
|
+
const { dir, id, file } = seed();
|
|
44
|
+
const before = readFileSync(file, "utf8");
|
|
45
|
+
CodeSessionFork.fork(dir, id);
|
|
46
|
+
expect(readFileSync(file, "utf8")).toBe(before);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("the copy says it is one, and takes a name when given one", () => {
|
|
50
|
+
const { dir, id } = seed();
|
|
51
|
+
expect(records(CodeSessionFork.fork(dir, id).file).at(-1)?.name).toBe("read-the-guard-fork");
|
|
52
|
+
// A name somebody typed is kept as typed, the way `/name` keeps one; only a derived name is kebabed.
|
|
53
|
+
expect(records(CodeSessionFork.fork(dir, id, "try the other guard").file).at(-1)?.name).toBe("try the other guard");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("an id nothing on disk answers to is refused rather than opening a new session", () => {
|
|
57
|
+
const { dir } = seed();
|
|
58
|
+
expect(() => CodeSessionFork.fork(dir, "nope")).toThrow("No stored session nope");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { codeAgentSessionName } from "akanjs/common";
|
|
6
|
+
import { CodeSessionIndex } from "./CodeSessionIndex";
|
|
7
|
+
|
|
8
|
+
const write = (dir: string, id: string, lines: unknown[]) => {
|
|
9
|
+
const file = path.join(dir, `2026-09-20T10-00-00-000Z_${id}.jsonl`);
|
|
10
|
+
writeFileSync(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`);
|
|
11
|
+
return file;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const session = (id: string) => ({ type: "session", version: 3, id, cwd: "/repo" });
|
|
15
|
+
const user = (text: string) => ({ type: "message", message: { role: "user", content: [{ type: "text", text }] } });
|
|
16
|
+
const assistant = (text: string) => ({
|
|
17
|
+
type: "message",
|
|
18
|
+
message: { role: "assistant", content: [{ type: "text", text }] },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("CodeSessionIndex", () => {
|
|
22
|
+
test("reads the name, the opening ask and how many turns a session holds", () => {
|
|
23
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-sessions-"));
|
|
24
|
+
write(dir, "s1", [
|
|
25
|
+
session("s1"),
|
|
26
|
+
user("add a comment module"),
|
|
27
|
+
assistant("done"),
|
|
28
|
+
user("now wire it up"),
|
|
29
|
+
{ type: "session_info", name: "add-a-comment-module" },
|
|
30
|
+
// A rename appends rather than editing, so the last entry has to win.
|
|
31
|
+
{ type: "session_info", name: "comment-module" },
|
|
32
|
+
]);
|
|
33
|
+
const [entry] = CodeSessionIndex.list(dir);
|
|
34
|
+
expect(entry?.id).toBe("s1");
|
|
35
|
+
expect(entry?.name).toBe("comment-module");
|
|
36
|
+
expect(entry?.opening).toBe("add a comment module");
|
|
37
|
+
expect(entry?.turns).toBe(2);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a file that is not a session is skipped rather than listed as a blank one", () => {
|
|
41
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-sessions-"));
|
|
42
|
+
writeFileSync(path.join(dir, "notes.txt"), "not a session");
|
|
43
|
+
write(dir, "s2", [{ type: "model_change", provider: "deepseek" }, user("hi")]);
|
|
44
|
+
write(dir, "s3", [session("s3"), user("hi")]);
|
|
45
|
+
expect(CodeSessionIndex.list(dir).map((entry) => entry.id)).toEqual(["s3"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("an id resolves to its file, and an unknown one to nothing", () => {
|
|
49
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-sessions-"));
|
|
50
|
+
const file = write(dir, "s4", [session("s4"), user("hi")]);
|
|
51
|
+
expect(CodeSessionIndex.fileOf(dir, "s4")).toBe(file);
|
|
52
|
+
// Resuming a typo has to fail rather than open a new session that looks like one that lost its history.
|
|
53
|
+
expect(CodeSessionIndex.fileOf(dir, "s5")).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a missing directory lists nothing instead of throwing", () => {
|
|
57
|
+
expect(CodeSessionIndex.list(path.join(tmpdir(), "akan-sessions-missing"))).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("codeAgentSessionName", () => {
|
|
62
|
+
test("names a session after what it was asked, in one glanceable line", () => {
|
|
63
|
+
expect(codeAgentSessionName("Add a comment module to apps/minimal")).toBe("add-a-comment-module-to-apps-minimal");
|
|
64
|
+
// Cut on a word, never mid-word: half a word reads as a typo rather than a shortening.
|
|
65
|
+
expect(codeAgentSessionName("Add a comment module to apps/minimal and wire up its dictionary")).toBe(
|
|
66
|
+
"add-a-comment-module-to-apps-minimal-and",
|
|
67
|
+
);
|
|
68
|
+
expect(codeAgentSessionName("안녕, 모듈 하나 만들어줘")).toBe("안녕-모듈-하나-만들어줘");
|
|
69
|
+
// A prompt that is all code says nothing about itself; a name is still better than none.
|
|
70
|
+
expect(codeAgentSessionName("```ts\nconst a = 1;\n```")).toBe("session");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Console } from "node:console";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sends every `console.*` call in this process to stderr, so stdout carries the RPC protocol and nothing else.
|
|
5
|
+
*
|
|
6
|
+
* The engine already installs a guard that rewrites `process.stdout.write`, which is enough on Node because
|
|
7
|
+
* `console.log` routes through it there. Bun's global `console` is native and writes to fd 1 directly, so it
|
|
8
|
+
* sails straight past that guard and corrupts the JSONL stream — measured: 203 non-JSON lines in one turn that
|
|
9
|
+
* logged while a tool ran.
|
|
10
|
+
*
|
|
11
|
+
* **This is a side-effect module and must be the first import of any RPC entry.** An assignment written beside
|
|
12
|
+
* the static imports would run *after* them: ESM hoists imports and evaluates those modules first, so anything
|
|
13
|
+
* the engine logs at module-evaluation time would already have escaped.
|
|
14
|
+
*/
|
|
15
|
+
globalThis.console = new Console({
|
|
16
|
+
stdout: process.stderr,
|
|
17
|
+
stderr: process.stderr,
|
|
18
|
+
}) as unknown as typeof globalThis.console;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AkanEditScope } from "../tools/AkanEditScope";
|
|
4
|
+
import type { TurnFeedbackSource } from "./TurnFeedback";
|
|
5
|
+
|
|
6
|
+
export interface DevLogFeedbackOptions {
|
|
7
|
+
workspaceRoot: string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
apps: string[];
|
|
10
|
+
/** Hot reload finishes after the turn does, so the read waits for it. */
|
|
11
|
+
graceMs?: number;
|
|
12
|
+
maxLines?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const errorPattern = /\bERROR\b|\bFATAL\b|\[stderr\]|error:|Error:|✖|Unhandled|Cannot find|is not a function/;
|
|
16
|
+
const noisePattern = /DEBUG|\bWARN\b|MCP catalogue|rate limit/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Feeds back what the dev server said after a turn edited code.
|
|
20
|
+
*
|
|
21
|
+
* `akan start` writes `local/apps/<app>/runtime/dev.log` with no ANSI, nothing truncated, and every process of
|
|
22
|
+
* the app in arrival order including the dev host's own build output — which reaches no other file. That is the
|
|
23
|
+
* one thing a coding agent working on an akan app has and a general one does not: lint and typecheck can both
|
|
24
|
+
* pass on code that dies the moment it runs.
|
|
25
|
+
*/
|
|
26
|
+
export class DevLogFeedback implements TurnFeedbackSource {
|
|
27
|
+
readonly key = "dev-server";
|
|
28
|
+
readonly #options: Required<DevLogFeedbackOptions>;
|
|
29
|
+
readonly #marks = new Map<string, number>();
|
|
30
|
+
#baseline: ReadonlySet<string> = new Set();
|
|
31
|
+
|
|
32
|
+
constructor(options: DevLogFeedbackOptions) {
|
|
33
|
+
this.#options = { graceMs: 1_500, maxLines: 30, ...options };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async begin() {
|
|
37
|
+
for (const app of this.#options.apps) this.#marks.set(app, DevLogFeedback.#sizeOf(this.#pathOf(app)));
|
|
38
|
+
this.#baseline = await AkanEditScope.baseline(this.#options.cwd);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async observe() {
|
|
42
|
+
const running = this.#options.apps.filter((app) => existsSync(this.#pathOf(app)));
|
|
43
|
+
if (!running.length) return undefined;
|
|
44
|
+
// A turn that wrote nothing cannot have broken the running app. Without this, one line from a dev server
|
|
45
|
+
// somebody else is driving reopens a turn that only read a file — and a reopened turn looks, from the
|
|
46
|
+
// transcript, exactly like an agent that will not stop.
|
|
47
|
+
if (!(await AkanEditScope.since(this.#options.cwd, this.#baseline)).paths.length) return undefined;
|
|
48
|
+
await Bun.sleep(this.#options.graceMs);
|
|
49
|
+
const reports: string[] = [];
|
|
50
|
+
for (const app of running) {
|
|
51
|
+
const lines = await this.#linesSince(app);
|
|
52
|
+
if (lines.length) reports.push(`**${app}** dev server:\n\`\`\`\n${lines.join("\n")}\n\`\`\``);
|
|
53
|
+
}
|
|
54
|
+
if (!reports.length) return undefined;
|
|
55
|
+
return [
|
|
56
|
+
"The dev server logged errors after your edits:",
|
|
57
|
+
...reports,
|
|
58
|
+
`Full log: ${running.map((app) => path.relative(this.#options.workspaceRoot, this.#pathOf(app))).join(", ")}`,
|
|
59
|
+
].join("\n\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async #linesSince(app: string) {
|
|
63
|
+
const file = this.#pathOf(app);
|
|
64
|
+
const from = this.#marks.get(app) ?? 0;
|
|
65
|
+
const size = DevLogFeedback.#sizeOf(file);
|
|
66
|
+
// A smaller file means `akan start` rotated to a new session mid-turn; its own boot output is not a finding.
|
|
67
|
+
if (size <= from) return [];
|
|
68
|
+
const text = await Bun.file(file).slice(from, size).text();
|
|
69
|
+
this.#marks.set(app, size);
|
|
70
|
+
return text
|
|
71
|
+
.split("\n")
|
|
72
|
+
.filter((line) => errorPattern.test(line) && !noisePattern.test(line))
|
|
73
|
+
.slice(-this.#options.maxLines);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#pathOf(app: string) {
|
|
77
|
+
return path.join(this.#options.workspaceRoot, "local", "apps", app, "runtime", "dev.log");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The dev server's own URL, read back from the line `akan start` prints when the app comes up.
|
|
82
|
+
*
|
|
83
|
+
* The port is allocated, not predicted — a second checkout or a stale listener shifts it — so the log is the
|
|
84
|
+
* only place that knows which one this session actually got.
|
|
85
|
+
*/
|
|
86
|
+
static async previewUrl(workspaceRoot: string, app: string) {
|
|
87
|
+
const file = path.join(workspaceRoot, "local", "apps", app, "runtime", "dev.log");
|
|
88
|
+
if (!existsSync(file)) return undefined;
|
|
89
|
+
const matches = [...(await Bun.file(file).text()).matchAll(/ready \(pid=\d+\) — (https?:\/\/\S+)/g)];
|
|
90
|
+
return matches.at(-1)?.[1];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A dev server that is not running has no log, which is not an error — the source simply reports nothing. */
|
|
94
|
+
static #sizeOf(file: string) {
|
|
95
|
+
return existsSync(file) ? statSync(file).size : 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which `Bun.WebView` backend the preview probe runs on, and what an image needs to carry one.
|
|
7
|
+
*
|
|
8
|
+
* `webkit` is the macOS system framework — zero install, and it throws on every other platform — so anywhere
|
|
9
|
+
* else the probe drives Chrome. The generated akan image installs `ca-certificates` and `tzdata` and nothing
|
|
10
|
+
* else, which is why the install steps are named here instead of being reinvented per image.
|
|
11
|
+
*/
|
|
12
|
+
export class PreviewChrome {
|
|
13
|
+
/** Read by `Bun.WebView` itself; listed so the availability check agrees with what the launcher will do. */
|
|
14
|
+
static readonly pathEnvKey = "BUN_CHROME_PATH";
|
|
15
|
+
|
|
16
|
+
/** Set this to drive Chrome on macOS too, e.g. to reproduce what a pod sees. */
|
|
17
|
+
static readonly backendEnvKey = "AKAN_CODE_WEBVIEW_BACKEND";
|
|
18
|
+
|
|
19
|
+
/** The executables Bun looks for on PATH before giving up. */
|
|
20
|
+
static readonly executables = [
|
|
21
|
+
"chromium",
|
|
22
|
+
"chromium-browser",
|
|
23
|
+
"google-chrome-stable",
|
|
24
|
+
"google-chrome",
|
|
25
|
+
"brave-browser",
|
|
26
|
+
"microsoft-edge",
|
|
27
|
+
"chrome",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* macOS keeps browsers in app bundles, off PATH, and Bun looks inside these before reporting none.
|
|
32
|
+
*
|
|
33
|
+
* The list has to match Bun's or the two disagree in the expensive direction: refusing to probe on a machine
|
|
34
|
+
* whose browser the launcher would have found.
|
|
35
|
+
*/
|
|
36
|
+
static readonly appBundles = [
|
|
37
|
+
"Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
38
|
+
"Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
39
|
+
"Chromium.app/Contents/MacOS/Chromium",
|
|
40
|
+
"Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
41
|
+
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
static get backend(): "webkit" | "chrome" {
|
|
45
|
+
const declared = process.env[PreviewChrome.backendEnvKey];
|
|
46
|
+
if (declared === "webkit" || declared === "chrome") return declared;
|
|
47
|
+
return process.platform === "darwin" ? "webkit" : "chrome";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static executable() {
|
|
51
|
+
const declared = process.env[PreviewChrome.pathEnvKey];
|
|
52
|
+
if (declared) return declared;
|
|
53
|
+
for (const name of PreviewChrome.executables) {
|
|
54
|
+
const found = Bun.which(name);
|
|
55
|
+
if (found) return found;
|
|
56
|
+
}
|
|
57
|
+
if (process.platform !== "darwin") return undefined;
|
|
58
|
+
for (const root of ["/Applications", path.join(homedir(), "Applications")])
|
|
59
|
+
for (const bundle of PreviewChrome.appBundles) {
|
|
60
|
+
const candidate = path.join(root, bundle);
|
|
61
|
+
if (existsSync(candidate)) return candidate;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static get available() {
|
|
67
|
+
return PreviewChrome.backend === "webkit" ? process.platform === "darwin" : !!PreviewChrome.executable();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `backend` value to construct a view with.
|
|
72
|
+
*
|
|
73
|
+
* Two flags are added on the Chrome path, and Bun adds neither:
|
|
74
|
+
* - Chromium refuses to run as uid 0 unless its sandbox is off, and uid 0 is the ordinary uid in a container.
|
|
75
|
+
* - Docker gives a container 64MB of `/dev/shm` by default, below what Chromium's renderer allocates, and the
|
|
76
|
+
* crash it produces looks like a page that never loads rather than a resource limit.
|
|
77
|
+
*/
|
|
78
|
+
static backendOption() {
|
|
79
|
+
if (PreviewChrome.backend === "webkit") return "webkit";
|
|
80
|
+
const argv = ["--disable-dev-shm-usage"];
|
|
81
|
+
if (process.getuid?.() === 0) argv.push("--no-sandbox");
|
|
82
|
+
return { type: "chrome" as const, argv };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `RUN` bodies for an image whose agent has to look at the page it just changed — an akan app's
|
|
87
|
+
* `docker.preRuns`, or a coding-agent pod.
|
|
88
|
+
*
|
|
89
|
+
* The font packages are not decoration: a container with no fonts renders every glyph as a box, so a
|
|
90
|
+
* screenshot says nothing and the "text is the same colour as its background" probe reads a blank page.
|
|
91
|
+
* `fonts-noto-cjk` is what keeps a Korean UI legible.
|
|
92
|
+
*/
|
|
93
|
+
static readonly dockerRuns = [
|
|
94
|
+
"apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation fonts-noto-color-emoji fonts-noto-cjk && rm -rf /var/lib/apt/lists/*",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
static unavailableReason() {
|
|
98
|
+
if (PreviewChrome.available) return undefined;
|
|
99
|
+
if (PreviewChrome.backend === "webkit")
|
|
100
|
+
return `The webkit WebView backend exists only on macOS. Set ${PreviewChrome.backendEnvKey}=chrome and install a browser.`;
|
|
101
|
+
return [
|
|
102
|
+
"The page preview needs a browser and this machine has none on PATH.",
|
|
103
|
+
`Install one (${PreviewChrome.dockerRuns[0]}) or point ${PreviewChrome.pathEnvKey} at an existing Chrome.`,
|
|
104
|
+
].join(" ");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { AkanEditScope } from "../tools/AkanEditScope";
|
|
2
|
+
import { PreviewView } from "./PreviewView";
|
|
3
|
+
import type { TurnFeedbackFinding, TurnFeedbackSource } from "./TurnFeedback";
|
|
4
|
+
|
|
5
|
+
export interface PreviewFeedbackOptions {
|
|
6
|
+
cwd: string;
|
|
7
|
+
/** Origin of a running dev server. Without one the source is inert. */
|
|
8
|
+
previewUrl?: string;
|
|
9
|
+
/** Every akan route sits under `/:lang`, so a bare path renders nothing. */
|
|
10
|
+
routes?: string[];
|
|
11
|
+
/** Only a model whose input includes images gets one attached; a screenshot is ~83KB per shot. */
|
|
12
|
+
canSeeImages?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Looks at the page after a turn changed the UI.
|
|
17
|
+
*
|
|
18
|
+
* akan is a UI framework, so "does it run" is half the question. The default findings are **text** — page
|
|
19
|
+
* console errors, an empty body, an error overlay, horizontal overflow, a dropped colour slot — because they
|
|
20
|
+
* cost a few hundred tokens, work with any model, and name the defect rather than depicting it. A screenshot is
|
|
21
|
+
* attached only when the active model can actually read one.
|
|
22
|
+
*/
|
|
23
|
+
export class PreviewFeedback implements TurnFeedbackSource {
|
|
24
|
+
readonly key = "preview";
|
|
25
|
+
readonly #options: PreviewFeedbackOptions;
|
|
26
|
+
#baseline: ReadonlySet<string> = new Set();
|
|
27
|
+
|
|
28
|
+
constructor(options: PreviewFeedbackOptions) {
|
|
29
|
+
this.#options = options;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async begin() {
|
|
33
|
+
this.#baseline = await AkanEditScope.baseline(this.#options.cwd);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async observe(): Promise<TurnFeedbackFinding | undefined> {
|
|
37
|
+
if (!this.#options.previewUrl || !PreviewView.available) return undefined;
|
|
38
|
+
const scope = await AkanEditScope.since(this.#options.cwd, this.#baseline);
|
|
39
|
+
if (!scope.touchesTsx) return undefined;
|
|
40
|
+
const findings: string[] = [];
|
|
41
|
+
const images: { data: string; mimeType: string }[] = [];
|
|
42
|
+
for (const route of this.#options.routes ?? ["/en/"]) {
|
|
43
|
+
const url = new URL(route, this.#options.previewUrl).toString();
|
|
44
|
+
const probe = await PreviewView.probe(url, { screenshot: !!this.#options.canSeeImages });
|
|
45
|
+
const problems = [...probe.pageErrors, ...probe.consoleErrors, ...probe.domFindings];
|
|
46
|
+
if (!problems.length) continue;
|
|
47
|
+
findings.push(`**${url}**\n${problems.map((line) => `- ${line}`).join("\n")}`);
|
|
48
|
+
if (probe.screenshot) images.push({ data: probe.screenshot, mimeType: "image/png" });
|
|
49
|
+
}
|
|
50
|
+
if (!findings.length) return undefined;
|
|
51
|
+
return { text: ["The rendered page has problems after your UI edits:", ...findings].join("\n\n"), images };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { PreviewChrome } from "./PreviewChrome";
|
|
2
|
+
|
|
3
|
+
export interface PreviewProbeResult {
|
|
4
|
+
url: string;
|
|
5
|
+
title: string;
|
|
6
|
+
consoleErrors: string[];
|
|
7
|
+
pageErrors: string[];
|
|
8
|
+
/** Cheap structural smells that read as "the page is broken" without anyone looking at it. */
|
|
9
|
+
domFindings: string[];
|
|
10
|
+
screenshot?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface WebViewLike {
|
|
14
|
+
navigate(url: string): Promise<void>;
|
|
15
|
+
evaluate(expression: string): Promise<unknown>;
|
|
16
|
+
screenshot(options: { encoding: "base64" }): Promise<string>;
|
|
17
|
+
close(): void | Promise<void>;
|
|
18
|
+
readonly title: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Opens a page headlessly and reports what is wrong with it.
|
|
23
|
+
*
|
|
24
|
+
* `Bun.WebView` rather than a downloaded browser: on macOS the `webkit` backend is the system framework, so
|
|
25
|
+
* there is no Chromium to fetch and no executable path to track, and everywhere else it drives whatever Chrome
|
|
26
|
+
* the image carries. It is marked experimental, which is why the whole surface is behind this adapter — a Bun
|
|
27
|
+
* minor that changes it should break one file.
|
|
28
|
+
*
|
|
29
|
+
* Which backend, and what an image needs for it, is {@link PreviewChrome}.
|
|
30
|
+
*/
|
|
31
|
+
export class PreviewView {
|
|
32
|
+
static get available() {
|
|
33
|
+
return typeof (Bun as unknown as { WebView?: unknown }).WebView === "function" && PreviewChrome.available;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Why `available` is false, for a host that should say so rather than skip the check in silence. */
|
|
37
|
+
static unavailableReason() {
|
|
38
|
+
if (typeof (Bun as unknown as { WebView?: unknown }).WebView !== "function")
|
|
39
|
+
return "This Bun build has no WebView, so the page preview is off.";
|
|
40
|
+
return PreviewChrome.unavailableReason();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static async probe(url: string, { screenshot = false, width = 1280, height = 800 } = {}) {
|
|
44
|
+
const logs: string[] = [];
|
|
45
|
+
const view = PreviewView.#open({ width, height, onConsole: (line) => logs.push(line) });
|
|
46
|
+
try {
|
|
47
|
+
await view.navigate(url);
|
|
48
|
+
const result: PreviewProbeResult = {
|
|
49
|
+
url,
|
|
50
|
+
title: view.title,
|
|
51
|
+
consoleErrors: logs.filter((line) => line.startsWith("error") || line.startsWith("warn")),
|
|
52
|
+
pageErrors: PreviewView.#strings(await view.evaluate(pageErrorsExpression)),
|
|
53
|
+
domFindings: PreviewView.#strings(await view.evaluate(domFindingsExpression)),
|
|
54
|
+
};
|
|
55
|
+
if (screenshot) result.screenshot = await view.screenshot({ encoding: "base64" });
|
|
56
|
+
return result;
|
|
57
|
+
} finally {
|
|
58
|
+
await view.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static #open({ width, height, onConsole }: { width: number; height: number; onConsole: (line: string) => void }) {
|
|
63
|
+
const Ctor = (Bun as unknown as { WebView: new (options: unknown) => WebViewLike }).WebView;
|
|
64
|
+
return new Ctor({
|
|
65
|
+
width,
|
|
66
|
+
height,
|
|
67
|
+
dataStore: "ephemeral",
|
|
68
|
+
backend: PreviewChrome.backendOption(),
|
|
69
|
+
// The console option is a callback taking `(type, ...args)`, not an object of console methods.
|
|
70
|
+
console: (type: string, ...args: unknown[]) => onConsole(`${type} ${args.map(String).join(" ")}`),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static #strings(value: unknown) {
|
|
75
|
+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `evaluate` takes an expression, not a statement list — `const x = 1; x` is a `SyntaxError` — so each probe is
|
|
81
|
+
* wrapped in an IIFE. Only one may be in flight per view.
|
|
82
|
+
*/
|
|
83
|
+
const pageErrorsExpression = `(() => {
|
|
84
|
+
const found = [];
|
|
85
|
+
const root = document.getElementById("__next") || document.body;
|
|
86
|
+
const rendered = root ? Array.from(root.children).filter((el) => !["SCRIPT", "STYLE", "TEMPLATE", "LINK", "NOSCRIPT"].includes(el.tagName)) : [];
|
|
87
|
+
if (!rendered.length && !(root && root.innerText && root.innerText.trim())) found.push("The page body rendered empty.");
|
|
88
|
+
const overlay = document.querySelector("[data-nextjs-dialog], .error-overlay, #akan-error");
|
|
89
|
+
if (overlay) found.push("An error overlay is on the page: " + overlay.textContent.slice(0, 400));
|
|
90
|
+
return found;
|
|
91
|
+
})()`;
|
|
92
|
+
|
|
93
|
+
const domFindingsExpression = `(() => {
|
|
94
|
+
const found = [];
|
|
95
|
+
const all = Array.from(document.querySelectorAll("body *"));
|
|
96
|
+
const overflowing = all.filter((el) => el.scrollWidth > document.documentElement.clientWidth + 8);
|
|
97
|
+
if (overflowing.length) found.push(overflowing.length + " element(s) overflow the viewport horizontally, first: " + (overflowing[0].tagName + "." + overflowing[0].className).slice(0, 120));
|
|
98
|
+
const invisibleText = all.filter((el) => {
|
|
99
|
+
if (!el.textContent || !el.textContent.trim()) return false;
|
|
100
|
+
const style = getComputedStyle(el);
|
|
101
|
+
return style.color === style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)";
|
|
102
|
+
});
|
|
103
|
+
if (invisibleText.length) found.push(invisibleText.length + " element(s) render text in the same colour as their background.");
|
|
104
|
+
const unresolved = all.filter((el) => /(^|\\s)(bg|text|border)-(base-100|base-content|primary-content|error)(\\s|$)/.test(el.className || ""));
|
|
105
|
+
if (unresolved.length) found.push(unresolved.length + " element(s) use a dropped daisyUI colour slot that renders no CSS.");
|
|
106
|
+
return found;
|
|
107
|
+
})()`;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** A base64 PNG, attached only when the active model can read one. */
|
|
4
|
+
export interface TurnFeedbackImage {
|
|
5
|
+
data: string;
|
|
6
|
+
mimeType: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TurnFeedbackFinding {
|
|
10
|
+
text: string;
|
|
11
|
+
images?: TurnFeedbackImage[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TurnFeedbackSource {
|
|
15
|
+
/** Budget is counted per key, so one stuck source cannot spend another's attempts. */
|
|
16
|
+
key: string;
|
|
17
|
+
/** Record whatever "before" this source compares against. Never throws — a source that cannot arm is skipped. */
|
|
18
|
+
begin(): Promise<void> | void;
|
|
19
|
+
/** What the turn caused that the agent cannot see, or `undefined` when there is nothing to say. */
|
|
20
|
+
observe(): Promise<TurnFeedbackFinding | string | undefined> | TurnFeedbackFinding | string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TurnFeedbackOptions {
|
|
24
|
+
/** How many times one source may reopen a turn before it gives up and lets the human see it. */
|
|
25
|
+
budget: number;
|
|
26
|
+
onNotice?: (message: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Closes the loop between what a turn did and what the agent knows about it.
|
|
31
|
+
*
|
|
32
|
+
* The agent cannot see that the dev server died, that a generated barrel went stale, or that the page it built
|
|
33
|
+
* renders blank — so it finishes, satisfied, on a broken tree. Each source watermarks at the start of a run and
|
|
34
|
+
* reports at the end; a report is delivered as a follow-up message, which opens a new turn.
|
|
35
|
+
*
|
|
36
|
+
* **It is a follow-up, not a veto.** `agent_end` has no result type — of the engine's hooks only
|
|
37
|
+
* `before_agent_start`, `message_end`, `tool_call`, `tool_result`, `input`, `user_bash`, the `session_before_*`
|
|
38
|
+
* pair, `context` and `resources_discover` can change an outcome — so there is no way to refuse to end a turn.
|
|
39
|
+
*
|
|
40
|
+
* **The budget goes in before the feature.** A source that reports the same unfixable error every turn would
|
|
41
|
+
* otherwise loop until the context window or the wallet runs out, and the loop looks like progress from outside.
|
|
42
|
+
*/
|
|
43
|
+
export class TurnFeedback {
|
|
44
|
+
readonly #sources: TurnFeedbackSource[];
|
|
45
|
+
readonly #options: TurnFeedbackOptions;
|
|
46
|
+
readonly #spent = new Map<string, number>();
|
|
47
|
+
|
|
48
|
+
constructor(sources: TurnFeedbackSource[], options: TurnFeedbackOptions) {
|
|
49
|
+
this.#sources = sources;
|
|
50
|
+
this.#options = options;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
extension(): InlineExtension {
|
|
54
|
+
return {
|
|
55
|
+
name: "akan-turn-feedback",
|
|
56
|
+
factory: (pi: ExtensionAPI) => {
|
|
57
|
+
pi.on("agent_start", async () => {
|
|
58
|
+
await Promise.all(this.#sources.map((source) => TurnFeedback.#quietly(() => source.begin())));
|
|
59
|
+
});
|
|
60
|
+
pi.on("agent_end", async () => {
|
|
61
|
+
const message = await this.collect();
|
|
62
|
+
if (message) pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Exposed so a host can drive the same machine without an engine session behind it. */
|
|
69
|
+
async collect() {
|
|
70
|
+
const texts: string[] = [];
|
|
71
|
+
const images: TurnFeedbackImage[] = [];
|
|
72
|
+
const reopened: string[] = [];
|
|
73
|
+
for (const source of this.#sources) {
|
|
74
|
+
const finding = await TurnFeedback.#quietly(() => source.observe());
|
|
75
|
+
if (!finding) {
|
|
76
|
+
this.#spent.delete(source.key);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const spent = this.#spent.get(source.key) ?? 0;
|
|
80
|
+
if (spent >= this.#options.budget) {
|
|
81
|
+
this.#options.onNotice?.(`${source.key}: still failing after ${spent} attempts — handing back to you.`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
this.#spent.set(source.key, spent + 1);
|
|
85
|
+
reopened.push(source.key);
|
|
86
|
+
if (typeof finding === "string") texts.push(finding);
|
|
87
|
+
else {
|
|
88
|
+
texts.push(finding.text);
|
|
89
|
+
images.push(...(finding.images ?? []));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!texts.length) return undefined;
|
|
93
|
+
// Said out loud, because the turn that follows is one nobody asked for: the transcript shows more tool
|
|
94
|
+
// calls after the answer and carries nothing that names who sent the agent back to work.
|
|
95
|
+
this.#options.onNotice?.(`${reopened.join(" · ")} reopened the turn — esc stops it`);
|
|
96
|
+
const text = [
|
|
97
|
+
"The previous turn left problems you cannot see from the transcript. Fix them, then stop.",
|
|
98
|
+
...texts,
|
|
99
|
+
].join("\n\n");
|
|
100
|
+
return images.length
|
|
101
|
+
? [{ type: "text" as const, text }, ...images.map((image) => ({ type: "image" as const, ...image }))]
|
|
102
|
+
: text;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every source is best-effort: no dev server, no git, no preview — all of them mean "nothing to report". */
|
|
106
|
+
static async #quietly<T>(fn: () => T | Promise<T>) {
|
|
107
|
+
try {
|
|
108
|
+
return await fn();
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|