@akanjs/devkit 3.0.0-beta.13 → 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/cloud/cloudApi.ts +17 -4
- package/cloud/constants.ts +0 -3
- package/cloud/globalConfig.ts +1 -9
- 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/commandDecorators/command.ts +1 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +1 -1
- package/index.ts +3 -2
- package/package.json +5 -5
- package/stripAnsi.ts +3 -0
- package/testHelpers.ts +179 -0
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
export interface CodePeer {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
/** When the session last said it was alive. */
|
|
19
|
+
at: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CodeMail {
|
|
23
|
+
at: number;
|
|
24
|
+
from: string;
|
|
25
|
+
fromName: string;
|
|
26
|
+
text: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How two `akan code` sessions in one workspace reach each other.
|
|
31
|
+
*
|
|
32
|
+
* A directory, not a socket or a daemon: the sessions already share a checkout and already write their
|
|
33
|
+
* transcripts there, and a mailbox on disk survives one end restarting, needs nothing listening to accept a
|
|
34
|
+
* message, and is inspectable with `cat` when a message does not arrive.
|
|
35
|
+
*
|
|
36
|
+
* Presence is a heartbeat file rather than a registration, because the interesting failure is a session that
|
|
37
|
+
* died without saying so — a registry would keep listing it forever, while a stale mtime says it is gone.
|
|
38
|
+
*/
|
|
39
|
+
export class CodeMailbox {
|
|
40
|
+
/** A session older than this said nothing for four heartbeats, so it is not there to answer. */
|
|
41
|
+
static readonly staleMs = 20_000;
|
|
42
|
+
static readonly beatMs = 5_000;
|
|
43
|
+
static readonly pollMs = 1_500;
|
|
44
|
+
/** How many messages one session may send in a minute, so two agents answering each other cannot run away. */
|
|
45
|
+
static readonly sendsPerMinute = 20;
|
|
46
|
+
|
|
47
|
+
readonly #dir: string;
|
|
48
|
+
readonly #id: string;
|
|
49
|
+
#name: string;
|
|
50
|
+
#read = 0;
|
|
51
|
+
#sent: number[] = [];
|
|
52
|
+
#beat: ReturnType<typeof setInterval> | null = null;
|
|
53
|
+
#poll: ReturnType<typeof setInterval> | null = null;
|
|
54
|
+
|
|
55
|
+
constructor(dir: string, id: string, name: string) {
|
|
56
|
+
this.#dir = dir;
|
|
57
|
+
this.#id = id;
|
|
58
|
+
this.#name = name;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get inbox() {
|
|
62
|
+
return path.join(this.#dir, "inbox", `${this.#id}.jsonl`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Announces this session and starts delivering what arrives for it. */
|
|
66
|
+
open(cwd: string, onMail: (mail: CodeMail) => void) {
|
|
67
|
+
mkdirSync(path.join(this.#dir, "live"), { recursive: true });
|
|
68
|
+
mkdirSync(path.join(this.#dir, "inbox"), { recursive: true });
|
|
69
|
+
// Whatever was already in the inbox belongs to an earlier run of this session, which has read it or is
|
|
70
|
+
// never going to; delivering it now would replay a conversation that already happened.
|
|
71
|
+
this.#read = CodeMailbox.#lines(this.inbox).length;
|
|
72
|
+
this.beat(cwd);
|
|
73
|
+
this.#beat = setInterval(() => this.beat(cwd), CodeMailbox.beatMs);
|
|
74
|
+
this.#poll = setInterval(() => {
|
|
75
|
+
for (const mail of this.#drain()) onMail(mail);
|
|
76
|
+
}, CodeMailbox.pollMs);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
close() {
|
|
80
|
+
if (this.#beat) clearInterval(this.#beat);
|
|
81
|
+
if (this.#poll) clearInterval(this.#poll);
|
|
82
|
+
this.#beat = null;
|
|
83
|
+
this.#poll = null;
|
|
84
|
+
try {
|
|
85
|
+
rmSync(path.join(this.#dir, "live", `${this.#id}.json`), { force: true });
|
|
86
|
+
} catch {
|
|
87
|
+
// A leftover presence file goes stale on its own; failing to remove it is not worth an error.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
rename(name: string, cwd: string) {
|
|
92
|
+
this.#name = name;
|
|
93
|
+
this.beat(cwd);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
beat(cwd: string) {
|
|
97
|
+
const peer: CodePeer = { id: this.#id, name: this.#name, cwd, pid: process.pid, at: Date.now() };
|
|
98
|
+
try {
|
|
99
|
+
writeFileSync(path.join(this.#dir, "live", `${this.#id}.json`), JSON.stringify(peer));
|
|
100
|
+
} catch {
|
|
101
|
+
// A read-only or missing directory means no presence; messaging then simply finds nobody.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The live sessions of this workspace, this one excluded — it is not a peer of itself. */
|
|
106
|
+
peers(): CodePeer[] {
|
|
107
|
+
const dir = path.join(this.#dir, "live");
|
|
108
|
+
if (!existsSync(dir)) return [];
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
return readdirSync(dir)
|
|
111
|
+
.filter((entry) => entry.endsWith(".json"))
|
|
112
|
+
.map((entry) => CodeMailbox.#peer(path.join(dir, entry)))
|
|
113
|
+
.filter((peer): peer is CodePeer => !!peer && peer.id !== this.#id && now - peer.at < CodeMailbox.staleMs)
|
|
114
|
+
.sort((left, right) => right.at - left.at);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Resolves what a person typed — a name, or the front of an id — to exactly one peer. */
|
|
118
|
+
find(target: string) {
|
|
119
|
+
const needle = target.trim().toLowerCase();
|
|
120
|
+
const peers = this.peers();
|
|
121
|
+
const byName = peers.filter((peer) => peer.name.toLowerCase() === needle);
|
|
122
|
+
if (byName.length === 1) return byName[0];
|
|
123
|
+
const byPrefix = peers.filter((peer) => peer.id.startsWith(needle) || peer.name.toLowerCase().startsWith(needle));
|
|
124
|
+
return byPrefix.length === 1 ? byPrefix[0] : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
send(to: string, text: string) {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
this.#sent = this.#sent.filter((at) => now - at < 60_000);
|
|
130
|
+
if (this.#sent.length >= CodeMailbox.sendsPerMinute)
|
|
131
|
+
throw new Error(`Too many messages: ${CodeMailbox.sendsPerMinute} a minute is the ceiling.`);
|
|
132
|
+
this.#sent.push(now);
|
|
133
|
+
const mail: CodeMail = { at: now, from: this.#id, fromName: this.#name, text };
|
|
134
|
+
mkdirSync(path.join(this.#dir, "inbox"), { recursive: true });
|
|
135
|
+
// Append, never rewrite: two senders may be writing at the same moment, and one line is one write.
|
|
136
|
+
appendFileSync(path.join(this.#dir, "inbox", `${to}.jsonl`), `${JSON.stringify(mail)}\n`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#drain(): CodeMail[] {
|
|
140
|
+
const lines = CodeMailbox.#lines(this.inbox);
|
|
141
|
+
if (lines.length <= this.#read) return [];
|
|
142
|
+
const fresh = lines.slice(this.#read);
|
|
143
|
+
this.#read = lines.length;
|
|
144
|
+
return fresh
|
|
145
|
+
.map((line) => CodeMailbox.#parse(line))
|
|
146
|
+
.filter((mail): mail is CodeMail => !!mail?.text && typeof mail.from === "string");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
static #peer(file: string) {
|
|
150
|
+
try {
|
|
151
|
+
const peer = JSON.parse(readFileSync(file, "utf8")) as CodePeer;
|
|
152
|
+
return typeof peer.id === "string" ? { ...peer, at: peer.at || statSync(file).mtimeMs } : undefined;
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
static #lines(file: string) {
|
|
159
|
+
try {
|
|
160
|
+
return readFileSync(file, "utf8")
|
|
161
|
+
.split("\n")
|
|
162
|
+
.filter((line) => !!line.trim());
|
|
163
|
+
} catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
static #parse(line: string) {
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(line) as CodeMail;
|
|
171
|
+
} catch {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { codeAgentSessionName } from "akanjs/common";
|
|
5
|
+
import { CodeSessionIndex } from "./CodeSessionIndex";
|
|
6
|
+
|
|
7
|
+
interface CodeSessionRecord {
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
type?: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Copies a session's file so one conversation can continue two ways.
|
|
15
|
+
*
|
|
16
|
+
* A fork is a file copy because that is exactly what a session is: an append-only log the engine replays. The
|
|
17
|
+
* copy is rewritten in one place — the `session` record's id — and then the two files know nothing about each
|
|
18
|
+
* other, which is the property that matters. Sharing an id instead would give two live agents one inbox, one
|
|
19
|
+
* presence entry and one resume target, and the second one to write would win silently.
|
|
20
|
+
*/
|
|
21
|
+
export class CodeSessionFork {
|
|
22
|
+
static fork(dir: string, id: string, name?: string) {
|
|
23
|
+
const source = CodeSessionIndex.fileOf(dir, id);
|
|
24
|
+
if (!source) throw new Error(`No stored session ${id} in ${dir}`);
|
|
25
|
+
const lines = readFileSync(source, "utf8")
|
|
26
|
+
.split("\n")
|
|
27
|
+
.filter((line) => !!line.trim());
|
|
28
|
+
if (!lines.length) throw new Error(`Session ${id} has nothing in it to fork`);
|
|
29
|
+
const at = new Date();
|
|
30
|
+
const forked = Bun.randomUUIDv7();
|
|
31
|
+
const records = lines
|
|
32
|
+
.map((line) => CodeSessionFork.#parse(line))
|
|
33
|
+
.filter((record): record is CodeSessionRecord => !!record);
|
|
34
|
+
const first = records.find((record) => record.type === "session");
|
|
35
|
+
if (!first) throw new Error(`Session ${id} carries no session record`);
|
|
36
|
+
first.id = forked;
|
|
37
|
+
first.timestamp = at.toISOString();
|
|
38
|
+
records.push(CodeSessionFork.#named(records, name ?? CodeSessionFork.#nameOf(records), at));
|
|
39
|
+
// The name is the timestamp the file was created plus the id, which is the shape the engine writes and
|
|
40
|
+
// what `CodeSessionIndex` matches on when a resume names one.
|
|
41
|
+
const file = path.join(dir, `${at.toISOString().replace(/[:.]/g, "-")}_${forked}.jsonl`);
|
|
42
|
+
writeFileSync(file, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`);
|
|
43
|
+
return { id: forked, file };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A rename is an appended record, so the fork is named by adding one rather than by editing the original. */
|
|
47
|
+
static #named(records: CodeSessionRecord[], name: string, at: Date) {
|
|
48
|
+
const parent = records.at(-1)?.id;
|
|
49
|
+
return {
|
|
50
|
+
type: "session_info",
|
|
51
|
+
id: randomBytes(4).toString("hex"),
|
|
52
|
+
parentId: typeof parent === "string" ? parent : null,
|
|
53
|
+
timestamp: at.toISOString(),
|
|
54
|
+
name,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static #nameOf(records: CodeSessionRecord[]) {
|
|
59
|
+
const named = records.filter((record) => record.type === "session_info").at(-1);
|
|
60
|
+
const current = typeof named?.name === "string" ? named.name : "";
|
|
61
|
+
return codeAgentSessionName(current ? `${current} fork` : "fork");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static #parse(line: string) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(line) as CodeSessionRecord;
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface CodeSessionEntry {
|
|
5
|
+
id: string;
|
|
6
|
+
file: string;
|
|
7
|
+
/** What the session calls itself, when it was named. */
|
|
8
|
+
name: string | undefined;
|
|
9
|
+
/** The first thing the person asked, which is what a list of sessions is actually scanned for. */
|
|
10
|
+
opening: string;
|
|
11
|
+
updatedAt: number;
|
|
12
|
+
turns: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The sessions on disk, read as files rather than through the engine.
|
|
17
|
+
*
|
|
18
|
+
* The engine's session manager addresses one session at a time — it can open a file and append to it, and has
|
|
19
|
+
* nothing that enumerates the directory. The format is self-describing JSONL, so the listing is a read of
|
|
20
|
+
* lines we already own the shape of, and it costs no engine instance to produce.
|
|
21
|
+
*/
|
|
22
|
+
export class CodeSessionIndex {
|
|
23
|
+
/** Past this, a file is a session long enough that its opening lines are far from the interesting part. */
|
|
24
|
+
static readonly readLimit = 512 * 1024;
|
|
25
|
+
|
|
26
|
+
static list(dir: string, limit = 30): CodeSessionEntry[] {
|
|
27
|
+
if (!existsSync(dir)) return [];
|
|
28
|
+
const files = readdirSync(dir)
|
|
29
|
+
.filter((entry) => entry.endsWith(".jsonl"))
|
|
30
|
+
.map((entry) => path.join(dir, entry))
|
|
31
|
+
.map((file) => ({ file, updatedAt: CodeSessionIndex.#mtime(file) }))
|
|
32
|
+
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
33
|
+
.slice(0, limit);
|
|
34
|
+
return files
|
|
35
|
+
.map((entry) => CodeSessionIndex.#read(entry.file, entry.updatedAt))
|
|
36
|
+
.filter((entry): entry is CodeSessionEntry => !!entry);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static fileOf(dir: string, id: string) {
|
|
40
|
+
if (!existsSync(dir)) return undefined;
|
|
41
|
+
// The file name carries the id after its timestamp, so the directory is searched without reading anything.
|
|
42
|
+
const match = readdirSync(dir).find((entry) => entry.endsWith(`${id}.jsonl`) || entry === `${id}.jsonl`);
|
|
43
|
+
return match ? path.join(dir, match) : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static #mtime(file: string) {
|
|
47
|
+
try {
|
|
48
|
+
return statSync(file).mtimeMs;
|
|
49
|
+
} catch {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static #read(file: string, updatedAt: number): CodeSessionEntry | undefined {
|
|
55
|
+
const lines = CodeSessionIndex.#lines(file);
|
|
56
|
+
if (!lines.length) return undefined;
|
|
57
|
+
const entry: CodeSessionEntry = { id: "", file, name: undefined, opening: "", updatedAt, turns: 0 };
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const record = CodeSessionIndex.#parse(line);
|
|
60
|
+
if (!record) continue;
|
|
61
|
+
if (record.type === "session" && typeof record.id === "string") entry.id = record.id;
|
|
62
|
+
// A rename appends a second entry rather than editing the first, so the last one on the file wins.
|
|
63
|
+
if (record.type === "session_info" && typeof record.name === "string") entry.name = record.name;
|
|
64
|
+
if (record.type !== "message") continue;
|
|
65
|
+
const message = record.message as { role?: string; content?: unknown } | undefined;
|
|
66
|
+
if (message?.role !== "user") continue;
|
|
67
|
+
entry.turns += 1;
|
|
68
|
+
if (!entry.opening) entry.opening = CodeSessionIndex.#text(message.content);
|
|
69
|
+
}
|
|
70
|
+
return entry.id ? entry : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static #lines(file: string) {
|
|
74
|
+
try {
|
|
75
|
+
if (statSync(file).size > CodeSessionIndex.readLimit) return [];
|
|
76
|
+
return readFileSync(file, "utf8")
|
|
77
|
+
.split("\n")
|
|
78
|
+
.filter((line) => !!line.trim());
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static #parse(line: string): { [key: string]: unknown; type?: string } | undefined {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(line) as { type?: string };
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One line, always: the opening ask is a label in a list, and a pasted stack trace is not a label. */
|
|
93
|
+
static #text(content: unknown) {
|
|
94
|
+
const text =
|
|
95
|
+
typeof content === "string"
|
|
96
|
+
? content
|
|
97
|
+
: Array.isArray(content)
|
|
98
|
+
? content
|
|
99
|
+
.map((part) => (typeof part === "object" && part && "text" in part ? String(part.text) : ""))
|
|
100
|
+
.join(" ")
|
|
101
|
+
: "";
|
|
102
|
+
return text.replace(/\s+/g, " ").trim();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type CodeAgentProfile, codeAgentReadOnlyBuiltins } from "akanjs/common";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import type { Workspace } from "../../commandDecorators";
|
|
5
|
+
|
|
6
|
+
export interface SubagentPoolOptions {
|
|
7
|
+
workspace: Workspace;
|
|
8
|
+
cwd: string;
|
|
9
|
+
parent: CodeAgentProfile;
|
|
10
|
+
depth: number;
|
|
11
|
+
/** Progress and refusals reach the parent's host through this rather than through a new event kind. */
|
|
12
|
+
onNotice?: (message: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const maxResultChars = 8_000;
|
|
16
|
+
|
|
17
|
+
export type SubagentKind = keyof typeof SubagentPool.kinds;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The `task` tool: run a narrowed copy of the agent on one question and bring back a summary.
|
|
21
|
+
*
|
|
22
|
+
* **Only the summary crosses back.** A subagent's whole transcript in the parent's context is the fastest way
|
|
23
|
+
* to spend a window, and the reason to spawn one is precisely that its reading should not become the parent's
|
|
24
|
+
* reading.
|
|
25
|
+
*
|
|
26
|
+
* **The budget is not optional.** Depth, concurrency and token ceilings are checked before a child exists,
|
|
27
|
+
* because a recursive `task` with no ceiling is a fork bomb that bills per token.
|
|
28
|
+
*/
|
|
29
|
+
export class SubagentPool {
|
|
30
|
+
/**
|
|
31
|
+
* What a sub-agent is allowed to be, and the only axis that matters: whether it can change the tree.
|
|
32
|
+
*
|
|
33
|
+
* A reader can be spawned for anything — the worst it costs is tokens. A writer works unwatched on the same
|
|
34
|
+
* checkout as its parent, so it is the one the parent has to ask for by name rather than get by default.
|
|
35
|
+
*/
|
|
36
|
+
static readonly kinds = {
|
|
37
|
+
explore: {
|
|
38
|
+
readOnly: true,
|
|
39
|
+
desc: "reads, searches and reports back. It cannot write a file or run a command.",
|
|
40
|
+
},
|
|
41
|
+
code: {
|
|
42
|
+
readOnly: false,
|
|
43
|
+
desc: "makes the change itself, with the same tools as this session.",
|
|
44
|
+
},
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
static readonly defaultKind: SubagentKind = "explore";
|
|
48
|
+
|
|
49
|
+
readonly #options: SubagentPoolOptions;
|
|
50
|
+
#running = 0;
|
|
51
|
+
#spent = 0;
|
|
52
|
+
|
|
53
|
+
constructor(options: SubagentPoolOptions) {
|
|
54
|
+
this.#options = options;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static extensionFor(options: SubagentPoolOptions): InlineExtension | undefined {
|
|
58
|
+
if (!options.parent.tools.subagent) return undefined;
|
|
59
|
+
if (options.depth >= options.parent.tools.subagent.maxDepth) return undefined;
|
|
60
|
+
const pool = new SubagentPool(options);
|
|
61
|
+
return { name: "akan-subagent", factory: (pi: ExtensionAPI) => pool.#register(pi) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static readonly toolName = "task";
|
|
65
|
+
|
|
66
|
+
#register(pi: ExtensionAPI) {
|
|
67
|
+
pi.registerTool({
|
|
68
|
+
name: SubagentPool.toolName,
|
|
69
|
+
label: "Task",
|
|
70
|
+
description:
|
|
71
|
+
"Run a focused sub-agent on one self-contained question and get back its answer. Use it for searching and reading that would otherwise fill this conversation — the sub-agent's own reading does not come back, only its answer. Give it everything it needs: it cannot see this conversation.",
|
|
72
|
+
promptSnippet: "task: run a sub-agent on one self-contained question and get back only its answer",
|
|
73
|
+
parameters: Type.Object({
|
|
74
|
+
description: Type.String({ description: "Three to five words naming the task, for the activity log." }),
|
|
75
|
+
prompt: Type.String({
|
|
76
|
+
description: "The complete instruction. The sub-agent sees nothing of this conversation.",
|
|
77
|
+
}),
|
|
78
|
+
type: Type.Optional(
|
|
79
|
+
Type.Union(
|
|
80
|
+
Object.entries(SubagentPool.kinds).map(([kind, spec]) => Type.Literal(kind, { description: spec.desc })),
|
|
81
|
+
{ description: `What the sub-agent may do. Defaults to ${SubagentPool.defaultKind}.` },
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
}),
|
|
85
|
+
execute: async (_id, params, signal) => {
|
|
86
|
+
const kind = (params.type ?? SubagentPool.defaultKind) as SubagentKind;
|
|
87
|
+
const refusal = this.#refuse();
|
|
88
|
+
if (refusal) return { content: [{ type: "text", text: refusal }], details: undefined, isError: true };
|
|
89
|
+
this.#running += 1;
|
|
90
|
+
// Said before it starts, not only when it ends: a turn that goes quiet for a minute while a child
|
|
91
|
+
// reads the repo is otherwise indistinguishable from a turn that has stopped responding.
|
|
92
|
+
this.#options.onNotice?.(`${kind} sub-agent · ${params.description}`);
|
|
93
|
+
try {
|
|
94
|
+
const text = await this.#run(kind, params.prompt, signal);
|
|
95
|
+
return { content: [{ type: "text", text }], details: undefined };
|
|
96
|
+
} finally {
|
|
97
|
+
this.#running -= 1;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#refuse() {
|
|
104
|
+
const budget = this.#options.parent.tools.subagent;
|
|
105
|
+
if (!budget) return "Sub-agents are disabled by this profile.";
|
|
106
|
+
if (this.#running >= budget.maxConcurrent)
|
|
107
|
+
return `Too many sub-agents are already running (limit ${budget.maxConcurrent}). Wait for one to finish.`;
|
|
108
|
+
if (this.#spent >= budget.budget)
|
|
109
|
+
return `The sub-agent token budget (${budget.budget}) is spent. Do the rest of this work yourself.`;
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The child is the parent profile **narrowed** and never widened: in-memory sessions so nothing it does
|
|
115
|
+
* shows up in the workspace's session tree, no `AGENTS.md` — a sub-agent is asked one narrow question and
|
|
116
|
+
* the guide is 26k tokens of its budget — and no ability to ask a human, there being nobody on the other
|
|
117
|
+
* end of a nested turn. The skills stay: they are descriptions until one is needed, and the child writes
|
|
118
|
+
* code too.
|
|
119
|
+
*/
|
|
120
|
+
static childOf(parent: CodeAgentProfile, kind: SubagentKind): CodeAgentProfile {
|
|
121
|
+
const budget = parent.tools.subagent;
|
|
122
|
+
return {
|
|
123
|
+
...parent,
|
|
124
|
+
name: `${parent.name}:${kind}`,
|
|
125
|
+
tools: {
|
|
126
|
+
...parent.tools,
|
|
127
|
+
// Filtered rather than replaced, so a kind can only ever take tools away from the parent: a child of
|
|
128
|
+
// a read-only session must not become the one thing in the tree that can write.
|
|
129
|
+
builtin: SubagentPool.kinds[kind].readOnly
|
|
130
|
+
? parent.tools.builtin.filter((tool) => codeAgentReadOnlyBuiltins.includes(tool))
|
|
131
|
+
: parent.tools.builtin,
|
|
132
|
+
subagent: budget ? { ...budget, maxDepth: budget.maxDepth } : false,
|
|
133
|
+
},
|
|
134
|
+
session: { store: "memory", crossSession: false },
|
|
135
|
+
ui: { canPrompt: false },
|
|
136
|
+
context: { projectFiles: false, skills: parent.context.skills },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async #run(kind: SubagentKind, prompt: string, signal: AbortSignal | undefined) {
|
|
141
|
+
const { CodeAgent } = await import("./CodeAgent");
|
|
142
|
+
const budget = this.#options.parent.tools.subagent;
|
|
143
|
+
const child = SubagentPool.childOf(this.#options.parent, kind);
|
|
144
|
+
const agent = await CodeAgent.create({
|
|
145
|
+
workspace: this.#options.workspace,
|
|
146
|
+
cwd: this.#options.cwd,
|
|
147
|
+
profile: child,
|
|
148
|
+
apps: [],
|
|
149
|
+
depth: this.#options.depth + 1,
|
|
150
|
+
});
|
|
151
|
+
const abort = () => void agent.abort();
|
|
152
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
153
|
+
try {
|
|
154
|
+
let answer = "";
|
|
155
|
+
agent.on((event) => {
|
|
156
|
+
if (event.type === "message" && event.role === "assistant") answer = event.text;
|
|
157
|
+
});
|
|
158
|
+
await agent.prompt(prompt);
|
|
159
|
+
await agent.waitForIdle();
|
|
160
|
+
this.#spent += agent.tokensUsed;
|
|
161
|
+
const ceiling = budget ? budget.budget : 0;
|
|
162
|
+
this.#options.onNotice?.(
|
|
163
|
+
`${kind} sub-agent finished, ${agent.tokensUsed} tokens (${this.#spent} of ${ceiling} spent)`,
|
|
164
|
+
);
|
|
165
|
+
return answer.length > maxResultChars
|
|
166
|
+
? `${answer.slice(0, maxResultChars)}\n…(truncated)`
|
|
167
|
+
: answer || "(no answer)";
|
|
168
|
+
} finally {
|
|
169
|
+
signal?.removeEventListener("abort", abort);
|
|
170
|
+
agent.dispose();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The engine's `Model` type lives in `@earendil-works/pi-ai` and is not re-exported from the package we depend
|
|
5
|
+
* on, so it is recovered from the one public function that returns it rather than by adding a dependency on a
|
|
6
|
+
* transitive package whose version we do not control.
|
|
7
|
+
*/
|
|
8
|
+
export type CodeAgentModel = NonNullable<ReturnType<ModelRegistry["find"]>>;
|
|
9
|
+
|
|
10
|
+
export interface CodeAgentModelRef {
|
|
11
|
+
provider: string;
|
|
12
|
+
id: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** DeepSeek's million-token window is what makes a 26k-token `AGENTS.md` affordable on every turn. */
|
|
16
|
+
export const akanCodeDefaultModel: CodeAgentModelRef = { provider: "deepseek", id: "deepseek-v4-flash" };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Models the catalogue declares text-only that take a picture anyway.
|
|
20
|
+
*
|
|
21
|
+
* `input` is the catalogue's claim, and a stale one is expensive in one direction only: with `image` missing
|
|
22
|
+
* the `read` tool refuses to hand the model a picture and a paste is refused before it is ever sent, so
|
|
23
|
+
* nothing ever finds out. Measured 2026-09-20 against the provider — `deepseek-v4-flash` answered "A red
|
|
24
|
+
* star." to `apps/akan/public/icon-192x192.png` and "Red" to the colour, over two requests.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ `deepseek-v4-pro` is **not** on this list. It answers 200 to the same request and then says it cannot
|
|
27
|
+
* view the image, which is not the same evidence, and guessing the family from one member is how the
|
|
28
|
+
* catalogue got it wrong in the first place.
|
|
29
|
+
*/
|
|
30
|
+
const catalogueMissesImages = new Set(["deepseek/deepseek-v4-flash"]);
|
|
31
|
+
|
|
32
|
+
const correctInputs = (model: CodeAgentModel | undefined) => {
|
|
33
|
+
if (!model || model.input.includes("image")) return model;
|
|
34
|
+
if (!catalogueMissesImages.has(`${model.provider}/${model.id}`)) return model;
|
|
35
|
+
return { ...model, input: [...model.input, "image" as const] };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolves the model to run with, preferring an explicit request, then the default, then anything the user has
|
|
40
|
+
* credentials for.
|
|
41
|
+
*
|
|
42
|
+
* An empty API key produces a request that succeeds with no content rather than an error, so a provider with no
|
|
43
|
+
* configured auth is skipped here instead of answering with nothing three seconds later.
|
|
44
|
+
*/
|
|
45
|
+
export const akanCodeModel = async (registry: ModelRegistry, ref?: CodeAgentModelRef) => {
|
|
46
|
+
if (ref) {
|
|
47
|
+
const requested = registry.find(ref.provider, ref.id);
|
|
48
|
+
if (!requested) throw new Error(`Unknown model: ${ref.provider}/${ref.id}`);
|
|
49
|
+
if (!registry.hasConfiguredAuth(requested))
|
|
50
|
+
throw new Error(`No API key configured for ${ref.provider}. Set ${ref.provider.toUpperCase()}_API_KEY.`);
|
|
51
|
+
return correctInputs(requested);
|
|
52
|
+
}
|
|
53
|
+
const fallback = registry.find(akanCodeDefaultModel.provider, akanCodeDefaultModel.id);
|
|
54
|
+
if (fallback && registry.hasConfiguredAuth(fallback)) return correctInputs(fallback);
|
|
55
|
+
return correctInputs(registry.getAvailable()[0]);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** A screenshot is worth attaching only to a model that can see it — after the catalogue has been corrected. */
|
|
59
|
+
export const akanCodeModelSupportsImages = (model: CodeAgentModel | undefined) => !!model?.input.includes("image");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Says so when the chosen model's window is too small for the compaction policy to leave room for a
|
|
63
|
+
* conversation.
|
|
64
|
+
*
|
|
65
|
+
* This is deliberately the **only** descriptor check. Two more obvious ones are wrong here: `maxTokens >=
|
|
66
|
+
* contextWindow` matches 153 of the engine's own 1,057 catalogue entries (`gpt-4` is declared 8192/8192, and
|
|
67
|
+
* several qwen models 262000/262000), and `reserveTokens > maxTokens` matches 145 — every model with a small
|
|
68
|
+
* output cap, which is normal, because the reserve is context headroom and the cap is output length. Both
|
|
69
|
+
* would cry wolf on one model in seven.
|
|
70
|
+
*
|
|
71
|
+
* ⚠️ **A wrong descriptor that is internally consistent cannot be detected here at all.** A custom model
|
|
72
|
+
* declaring a 65k window for a provider that serves 1M is coherent and simply false, and the only thing that
|
|
73
|
+
* catches it is a person seeing the number — which is why the session line prints it.
|
|
74
|
+
*/
|
|
75
|
+
export const akanCodeModelWarnings = (model: CodeAgentModel | undefined, compactionFloor: number) => {
|
|
76
|
+
if (!model) return [];
|
|
77
|
+
if (model.contextWindow >= compactionFloor) return [];
|
|
78
|
+
return [
|
|
79
|
+
`${model.provider}/${model.id} declares a ${model.contextWindow}-token context window, below the ${compactionFloor} the compaction policy needs, so every turn will compact immediately. Check the model entry in ~/.akan/code/models.json.`,
|
|
80
|
+
];
|
|
81
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where the shipped akan skills sit, which differs between the source tree and the bundle: the CLI's entry
|
|
7
|
+
* chunks are flattened into the package root, so `skills/` is a sibling of the caller there and one level up
|
|
8
|
+
* here, beside this folder. Same two-candidate shape the guidelines lookup uses.
|
|
9
|
+
*/
|
|
10
|
+
const builtinSkillsDir = () => {
|
|
11
|
+
const candidates = [path.join(import.meta.dir, "skills"), path.join(import.meta.dir, "..", "skills")];
|
|
12
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Sessions live in the workspace — they are project history, and a second checkout of the same repo is a
|
|
17
|
+
* different project. Credentials live in the home directory instead: a repo directory is the one place a key
|
|
18
|
+
* must never be, because it is the place that gets committed, zipped and shared.
|
|
19
|
+
*/
|
|
20
|
+
export const akanCodePaths = {
|
|
21
|
+
workspaceDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code"),
|
|
22
|
+
sessionsDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "sessions"),
|
|
23
|
+
extensionsDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "extensions"),
|
|
24
|
+
skillsDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "skills"),
|
|
25
|
+
mailDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "mail"),
|
|
26
|
+
builtinSkillsDir,
|
|
27
|
+
mcpFile: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "mcp.json"),
|
|
28
|
+
globalDir: () => path.join(homedir(), ".akan", "code"),
|
|
29
|
+
authFile: () => path.join(homedir(), ".akan", "code", "auth.json"),
|
|
30
|
+
modelsFile: () => path.join(homedir(), ".akan", "code", "models.json"),
|
|
31
|
+
} as const;
|