@adecore/agents 0.31.11 → 0.32.0
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/dist/chat/acp-backend.d.ts +4 -2
- package/dist/chat/acp-backend.js +50 -11
- package/dist/chat/acp-protocol.d.ts +29 -4
- package/dist/chat/acp-protocol.js +262 -23
- package/dist/chat/backend.d.ts +3 -0
- package/dist/chat/chat-core.js +6 -3
- package/dist/chat/chat-session.js +5 -2
- package/dist/chat/claude-backend.js +4 -4
- package/dist/chat/claude-protocol.d.ts +4 -1
- package/dist/chat/claude-protocol.js +46 -2
- package/dist/chat/codex-backend.js +4 -4
- package/dist/chat/fixtures/antigravity-1.4.1-question.json +69 -0
- package/dist/chat/fixtures/antigravity-1.4.1-quota.json +35 -0
- package/dist/chat/fixtures/grok-1.0.62-limits.json +66 -0
- package/dist/chat/fixtures/grok-1.0.62-question.json +99 -0
- package/dist/chat/json-rpc-transport.js +2 -2
- package/dist/chat/projector.js +22 -10
- package/dist/providers/accounts/service.js +4 -0
- package/dist/providers/claude-provider.js +2 -1
- package/dist/providers/detect.d.ts +1 -1
- package/dist/providers/detect.js +6 -3
- package/dist/providers/registry.d.ts +7 -0
- package/dist/providers/registry.js +64 -5
- package/dist/skills.d.ts +9 -1
- package/dist/skills.js +178 -20
- package/dist/testing/fake-acp-capture.d.ts +1 -0
- package/dist/testing/fake-acp-capture.js +4 -3
- package/dist/usage/accounts.js +3 -2
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +56 -11
- package/src/chat/acp-protocol.ts +308 -26
- package/src/chat/backend.ts +5 -1
- package/src/chat/chat-core.ts +6 -3
- package/src/chat/chat-session.ts +5 -2
- package/src/chat/claude-backend.ts +4 -4
- package/src/chat/claude-protocol.ts +48 -2
- package/src/chat/codex-backend.ts +4 -4
- package/src/chat/fixtures/antigravity-1.4.1-question.json +69 -0
- package/src/chat/fixtures/antigravity-1.4.1-quota.json +35 -0
- package/src/chat/fixtures/grok-1.0.62-limits.json +66 -0
- package/src/chat/fixtures/grok-1.0.62-question.json +99 -0
- package/src/chat/json-rpc-transport.ts +2 -2
- package/src/chat/projector.ts +24 -12
- package/src/providers/accounts/service.ts +4 -0
- package/src/providers/claude-provider.ts +2 -1
- package/src/providers/detect.ts +7 -3
- package/src/providers/registry.ts +71 -5
- package/src/skills.ts +198 -19
- package/src/testing/fake-acp-capture.ts +8 -4
- package/src/usage/accounts.ts +3 -2
package/src/skills.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, type Dirent } from 'node:fs';
|
|
2
|
-
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { open, readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join, parse } from 'node:path';
|
|
5
5
|
import type { AgentKind, ChatSkill, ChatSkillSource } from '@adecore/agent-contracts';
|
|
@@ -10,8 +10,57 @@ export interface SkillRoot {
|
|
|
10
10
|
source: ChatSkillSource;
|
|
11
11
|
// A plugin names its skills `<plugin>:<skill>`, the way the CLI's init frame does.
|
|
12
12
|
prefix?: string;
|
|
13
|
+
// How many folders below `dir` a skill folder may sit; absent is one, right under it.
|
|
14
|
+
depth?: number;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
/* How far one scan reaches, so a home or a project of any size answers quickly. */
|
|
18
|
+
export interface SkillScanLimits {
|
|
19
|
+
// Folders listed under one root, nearest first, so a deep tree never costs the skills near the top.
|
|
20
|
+
directories: number;
|
|
21
|
+
skills: number;
|
|
22
|
+
// Entries of one folder looked at, by name; absent is `ENTRIES_PER_FOLDER`.
|
|
23
|
+
entries?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SCAN_LIMITS: SkillScanLimits = { directories: 2000, skills: 1000 };
|
|
27
|
+
|
|
28
|
+
const ENTRIES_PER_FOLDER = 1000;
|
|
29
|
+
|
|
30
|
+
// Paths one folder resolves or reads at once, so a large folder never runs the process out of file handles.
|
|
31
|
+
const SCAN_CONCURRENCY = 16;
|
|
32
|
+
|
|
33
|
+
// A process short of file handles gets them back as other work closes its files, so a few short waits cover it.
|
|
34
|
+
const BUSY_RETRIES = 3;
|
|
35
|
+
const BUSY_RETRY_MS = 50;
|
|
36
|
+
|
|
37
|
+
// Codex finds skills up to five folders below a root (`MAX_SCAN_DEPTH` of 6 in its skill loader counts the root itself).
|
|
38
|
+
const CODEX_SKILL_DEPTH = 5;
|
|
39
|
+
|
|
40
|
+
// Folders a package manager or a build fills. One may hold a SKILL.md of its own, but nothing under it is listed.
|
|
41
|
+
const DEPENDENCY_FOLDERS = new Set([
|
|
42
|
+
'node_modules',
|
|
43
|
+
'bower_components',
|
|
44
|
+
'jspm_packages',
|
|
45
|
+
'vendor',
|
|
46
|
+
'Pods',
|
|
47
|
+
'Carthage',
|
|
48
|
+
'DerivedData',
|
|
49
|
+
'__pycache__',
|
|
50
|
+
'venv',
|
|
51
|
+
'site-packages',
|
|
52
|
+
'target',
|
|
53
|
+
'dist',
|
|
54
|
+
'build',
|
|
55
|
+
'out',
|
|
56
|
+
'coverage'
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// What a scan reads of a SKILL.md; the front matter sits at its top.
|
|
60
|
+
const HEAD_BYTES = 64 * 1024;
|
|
61
|
+
|
|
62
|
+
const MAX_DESCRIPTION = 1024;
|
|
63
|
+
|
|
15
64
|
export interface SkillRootOptions {
|
|
16
65
|
// The person's home directory; a test points it at a temporary tree.
|
|
17
66
|
home?: string;
|
|
@@ -118,9 +167,9 @@ export async function skillRootsFor(kind: AgentKind, cwd: string, options: Skill
|
|
|
118
167
|
const home = options.home ?? homedir();
|
|
119
168
|
if (kind === 'codex') {
|
|
120
169
|
return [
|
|
121
|
-
{ dir: join(home, '.agents', 'skills'), source: 'user' },
|
|
122
|
-
{ dir: join(home, '.codex', 'skills'), source: 'user' },
|
|
123
|
-
...projectDirs(cwd, home).map((dir): SkillRoot => ({ dir: join(dir, '.agents', 'skills'), source: 'project' }))
|
|
170
|
+
{ dir: join(home, '.agents', 'skills'), source: 'user', depth: CODEX_SKILL_DEPTH },
|
|
171
|
+
{ dir: join(home, '.codex', 'skills'), source: 'user', depth: CODEX_SKILL_DEPTH },
|
|
172
|
+
...projectDirs(cwd, home).map((dir): SkillRoot => ({ dir: join(dir, '.agents', 'skills'), source: 'project', depth: CODEX_SKILL_DEPTH }))
|
|
124
173
|
];
|
|
125
174
|
}
|
|
126
175
|
const configDir = options.claudeConfigDir ?? join(home, '.claude');
|
|
@@ -131,37 +180,167 @@ export async function skillRootsFor(kind: AgentKind, cwd: string, options: Skill
|
|
|
131
180
|
];
|
|
132
181
|
}
|
|
133
182
|
|
|
134
|
-
|
|
135
|
-
|
|
183
|
+
/* Too many files open, in this process or on the system: it says nothing about the path that was asked for. */
|
|
184
|
+
function isBusy(e: unknown): boolean {
|
|
185
|
+
return typeof e === 'object' && e !== null && 'code' in e && (e.code === 'EMFILE' || e.code === 'ENFILE');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function sleep(ms: number): Promise<void> {
|
|
189
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* Runs `operation` again a few times while the process is short of file handles, and throws if it still is. */
|
|
193
|
+
export async function whileBusy<T>(operation: () => Promise<T>, wait: (ms: number) => Promise<void> = sleep): Promise<T> {
|
|
194
|
+
for (let attempt = 1; ; attempt++) {
|
|
195
|
+
try {
|
|
196
|
+
return await operation();
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (!isBusy(e) || attempt > BUSY_RETRIES) {
|
|
199
|
+
throw e;
|
|
200
|
+
}
|
|
201
|
+
await wait(BUSY_RETRY_MS * attempt);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* `work` over every item with at most `limit` running at once, answered in the order of `items`. */
|
|
207
|
+
export async function mapLimited<T, R>(items: readonly T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
|
|
208
|
+
const results = new Array<R>(items.length);
|
|
209
|
+
let next = 0;
|
|
210
|
+
const worker = async (): Promise<void> => {
|
|
211
|
+
while (next < items.length) {
|
|
212
|
+
const index = next++;
|
|
213
|
+
results[index] = await work(items[index]!);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
217
|
+
return results;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* The top of a folder's SKILL.md, or null when it has none. A process short of file handles throws instead of finding none. */
|
|
221
|
+
async function skillHead(folder: string): Promise<string | null> {
|
|
222
|
+
let handle;
|
|
223
|
+
try {
|
|
224
|
+
handle = await whileBusy(() => open(join(folder, 'SKILL.md'), 'r'));
|
|
225
|
+
} catch (e) {
|
|
226
|
+
if (isBusy(e)) {
|
|
227
|
+
throw e;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
136
231
|
try {
|
|
137
|
-
|
|
232
|
+
if (!(await handle.stat()).isFile()) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
236
|
+
const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
|
|
237
|
+
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
138
238
|
} catch {
|
|
139
239
|
return null;
|
|
240
|
+
} finally {
|
|
241
|
+
await handle.close();
|
|
140
242
|
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function skillOf(root: SkillRoot, folder: string, text: string): ChatSkill | null {
|
|
141
246
|
const fields = parseFrontmatter(text);
|
|
142
247
|
const own = fields.name && NAME.test(fields.name) ? fields.name : folder;
|
|
143
248
|
const name = root.prefix ? `${root.prefix}:${own}` : own;
|
|
144
249
|
if (!NAME.test(name)) {
|
|
145
250
|
return null;
|
|
146
251
|
}
|
|
147
|
-
return { name, description: fields.description ?? '', source: root.source };
|
|
252
|
+
return { name, description: (fields.description ?? '').slice(0, MAX_DESCRIPTION), source: root.source };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* The folder a path leads to after every link, or null when it leads to no folder. */
|
|
256
|
+
async function realFolder(path: string): Promise<string | null> {
|
|
257
|
+
try {
|
|
258
|
+
const real = await whileBusy(() => realpath(path));
|
|
259
|
+
return (await whileBusy(() => stat(real))).isDirectory() ? real : null;
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (isBusy(e)) {
|
|
262
|
+
throw e;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/*
|
|
269
|
+
* The skills under one root, nearest first, down to the root's depth. A folder with a SKILL.md is a skill and is not
|
|
270
|
+
* searched further. Each root keeps its own record of the folders it read or went into, by real path, so a link back
|
|
271
|
+
* up ends there, while a skill two plugins link in shows under each of them.
|
|
272
|
+
*/
|
|
273
|
+
async function scanRoot(root: SkillRoot, limits: SkillScanLimits): Promise<ChatSkill[]> {
|
|
274
|
+
const start = await realFolder(root.dir);
|
|
275
|
+
if (start === null) {
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
const visited = new Set([start]);
|
|
279
|
+
const depth = root.depth ?? 1;
|
|
280
|
+
const entriesPerFolder = limits.entries ?? ENTRIES_PER_FOLDER;
|
|
281
|
+
const skills: ChatSkill[] = [];
|
|
282
|
+
let level = [root.dir];
|
|
283
|
+
let listed = 0;
|
|
284
|
+
for (let below = 1; below <= depth && level.length > 0; below++) {
|
|
285
|
+
const next: string[] = [];
|
|
286
|
+
for (const folder of level) {
|
|
287
|
+
if (listed >= limits.directories || skills.length >= limits.skills) {
|
|
288
|
+
return skills;
|
|
289
|
+
}
|
|
290
|
+
listed++;
|
|
291
|
+
let entries: Dirent[];
|
|
292
|
+
try {
|
|
293
|
+
entries = await whileBusy(() => readdir(folder, { withFileTypes: true }));
|
|
294
|
+
} catch (e) {
|
|
295
|
+
if (isBusy(e)) {
|
|
296
|
+
throw e;
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
// A skill manager links each skill in from a shared folder, so a link counts as much as a folder.
|
|
301
|
+
const candidates = entries
|
|
302
|
+
.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !entry.name.startsWith('.'))
|
|
303
|
+
.map((entry) => ({ name: entry.name, path: join(folder, entry.name) }))
|
|
304
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
305
|
+
.slice(0, entriesPerFolder);
|
|
306
|
+
const reals = await mapLimited(candidates, SCAN_CONCURRENCY, (candidate) => realFolder(candidate.path));
|
|
307
|
+
const fresh = candidates.flatMap((candidate, index) => {
|
|
308
|
+
const real = reals[index];
|
|
309
|
+
return real === null || real === undefined || visited.has(real) ? [] : [{ ...candidate, real }];
|
|
310
|
+
});
|
|
311
|
+
const heads = await mapLimited(fresh, SCAN_CONCURRENCY, (candidate) => skillHead(candidate.path));
|
|
312
|
+
for (const [index, candidate] of fresh.entries()) {
|
|
313
|
+
// Two links in one folder can lead to the same place; the first one read it.
|
|
314
|
+
if (visited.has(candidate.real)) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const head = heads[index];
|
|
318
|
+
if (head !== null && head !== undefined) {
|
|
319
|
+
visited.add(candidate.real);
|
|
320
|
+
const skill = skillOf(root, candidate.name, head);
|
|
321
|
+
if (skill !== null && skills.length < limits.skills) {
|
|
322
|
+
skills.push(skill);
|
|
323
|
+
}
|
|
324
|
+
} else if (below < depth && !DEPENDENCY_FOLDERS.has(candidate.name)) {
|
|
325
|
+
visited.add(candidate.real);
|
|
326
|
+
next.push(candidate.path);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
level = next;
|
|
331
|
+
}
|
|
332
|
+
return skills;
|
|
148
333
|
}
|
|
149
334
|
|
|
150
335
|
/* Every skill under these roots, sorted by name; the first root that carries a name wins. */
|
|
151
|
-
export async function scanSkillRoots(roots: SkillRoot[]): Promise<ChatSkill[]> {
|
|
336
|
+
export async function scanSkillRoots(roots: SkillRoot[], limits: SkillScanLimits = SCAN_LIMITS): Promise<ChatSkill[]> {
|
|
152
337
|
const found = new Map<string, ChatSkill>();
|
|
153
338
|
for (const root of roots) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
entries = await readdir(root.dir, { withFileTypes: true });
|
|
157
|
-
} catch {
|
|
158
|
-
continue;
|
|
339
|
+
if (found.size >= limits.skills) {
|
|
340
|
+
break;
|
|
159
341
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const skills = await Promise.all(folders.map((entry) => readSkill(root, entry.name)));
|
|
163
|
-
for (const skill of skills) {
|
|
164
|
-
if (skill && !found.has(skill.name)) {
|
|
342
|
+
for (const skill of await scanRoot(root, limits)) {
|
|
343
|
+
if (!found.has(skill.name) && found.size < limits.skills) {
|
|
165
344
|
found.set(skill.name, skill);
|
|
166
345
|
}
|
|
167
346
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* An ACP agent that answers with the frames a real one sent, recorded in a capture: its `initialize`, its
|
|
3
3
|
* `session/new`, and every update of one prompt before the response. Every prompt gets the same turn. A
|
|
4
4
|
* `{ permission }` entry among the updates is a `session/request_permission` the turn waits on until the
|
|
5
|
-
* client answers it,
|
|
5
|
+
* client answers it, a `{ request: { method, params } }` entry is any other request of the agent the turn waits on the
|
|
6
|
+
* same way, an `{ update, _meta }` entry is a notification with a `_meta` of its own, and with
|
|
6
7
|
* `titleFromPrompt` a `session_info_update` carries the prompt's own text as its title, as Copilot CLI names a session.
|
|
7
8
|
*/
|
|
8
9
|
import type { FakeCli } from './fake-cli.ts';
|
|
@@ -18,6 +19,8 @@ export interface AcpCapture {
|
|
|
18
19
|
setConfigOption: unknown;
|
|
19
20
|
promptUpdates: unknown[];
|
|
20
21
|
promptResponse: unknown;
|
|
22
|
+
// The JSON-RPC error a prompt fails with instead of the response, when set.
|
|
23
|
+
promptError?: unknown;
|
|
21
24
|
titleFromPrompt?: boolean;
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -36,16 +39,17 @@ export function fakeAcpCapture(capture: AcpCapture, answers: unknown[] = []): Fa
|
|
|
36
39
|
const play = (updates: unknown[], promptId: unknown, text: unknown): void => {
|
|
37
40
|
for (const [index, fields] of updates.entries()) {
|
|
38
41
|
const permission = (fields as Frame).permission;
|
|
39
|
-
|
|
42
|
+
const request = (fields as Frame).request as { method: string; params: unknown } | undefined;
|
|
43
|
+
if (permission !== undefined || request !== undefined) {
|
|
40
44
|
const rpcId = nextRpcId++;
|
|
41
45
|
waiting = { rpcId, rest: updates.slice(index + 1), promptId, text };
|
|
42
|
-
send({ id: rpcId, method: 'session/request_permission', params: permission });
|
|
46
|
+
send({ id: rpcId, method: request?.method ?? 'session/request_permission', params: request?.params ?? permission });
|
|
43
47
|
return;
|
|
44
48
|
}
|
|
45
49
|
const named = capture.titleFromPrompt === true && (fields as Frame).sessionUpdate === 'session_info_update';
|
|
46
50
|
update(named ? { ...(fields as Frame), title: text } : fields);
|
|
47
51
|
}
|
|
48
|
-
send({ id: promptId, result: capture.promptResponse });
|
|
52
|
+
send(capture.promptError === undefined ? { id: promptId, result: capture.promptResponse } : { id: promptId, error: capture.promptError });
|
|
49
53
|
};
|
|
50
54
|
return {
|
|
51
55
|
onLine: (line) => {
|
package/src/usage/accounts.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { USAGE_PROVIDERS, type ChatInfo, type ProviderAccounts, type UsageAccount, type UsageProvider } from '@adecore/agent-contracts';
|
|
2
|
-
import { definedEnv } from '../providers/accounts/launch.ts';
|
|
2
|
+
import { definedEnv, storedAccount } from '../providers/accounts/launch.ts';
|
|
3
3
|
import type { ProviderAccountsService } from '../providers/accounts/service.ts';
|
|
4
4
|
import type { LimitAccount, LimitAccounts } from './limits/monitor.ts';
|
|
5
5
|
import { accountRoots, type UsageRootPath } from './roots.ts';
|
|
@@ -69,7 +69,8 @@ export function limitAccountsOf(
|
|
|
69
69
|
machineAccounts(service.snapshot(), nameOf)
|
|
70
70
|
.filter((account) => account.isDefault || (account.enabled && account.signedIn))
|
|
71
71
|
.map((account): LimitAccount => ({ ...usageAccount(account), isDefault: account.isDefault })),
|
|
72
|
-
|
|
72
|
+
// A plan read is no start: the default account goes unnamed, so turning it off does not fail its read.
|
|
73
|
+
envFor: (kind, id) => definedEnv(service.envFor(kind, storedAccount(kind, id), baseEnv)),
|
|
73
74
|
lastUsedAt: (id) => service.lastLaunchAt(id)
|
|
74
75
|
};
|
|
75
76
|
}
|