@adecore/agents 0.31.2 → 0.31.3
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 +11 -1
- package/dist/chat/acp-backend.js +24 -2
- package/dist/chat/acp-protocol.d.ts +14 -0
- package/dist/chat/acp-protocol.js +33 -1
- package/dist/chat/index.d.ts +2 -2
- package/dist/providers/acp-provider.d.ts +1 -0
- package/dist/providers/acp-provider.js +11 -1
- package/dist/providers/acp-usage-log.d.ts +2 -0
- package/dist/providers/acp-usage-log.js +21 -0
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/providers/shell-commands.d.ts +1 -0
- package/dist/providers/shell-commands.js +119 -0
- package/dist/testing/fake-acp.d.ts +1 -0
- package/dist/testing/fake-acp.js +1 -1
- package/dist/usage/aggregate.js +6 -5
- package/dist/usage/index-file.js +25 -17
- package/dist/usage/limits/monitor.js +5 -0
- package/dist/usage/readers/grok.d.ts +3 -0
- package/dist/usage/readers/grok.js +85 -0
- package/dist/usage/record.d.ts +1 -0
- package/dist/usage/record.js +5 -1
- package/dist/usage/roots.js +7 -2
- package/dist/usage/scanner.d.ts +1 -0
- package/dist/usage/scanner.js +39 -5
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +40 -2
- package/src/chat/acp-protocol.ts +56 -1
- package/src/chat/index.ts +12 -2
- package/src/providers/acp-provider.ts +16 -2
- package/src/providers/acp-usage-log.ts +26 -0
- package/src/providers/index.ts +1 -0
- package/src/providers/shell-commands.ts +122 -0
- package/src/testing/fake-acp.ts +3 -1
- package/src/usage/aggregate.ts +6 -5
- package/src/usage/index-file.ts +27 -18
- package/src/usage/limits/monitor.ts +5 -0
- package/src/usage/readers/grok.ts +89 -0
- package/src/usage/record.ts +7 -1
- package/src/usage/roots.ts +7 -2
- package/src/usage/scanner.ts +42 -5
package/dist/usage/record.js
CHANGED
|
@@ -33,7 +33,11 @@ export function foldByKey(records) {
|
|
|
33
33
|
folded.push({ ...record, totals: { ...record.totals } });
|
|
34
34
|
continue;
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
const into = folded[seen];
|
|
37
|
+
maxTotals(into.totals, record.totals);
|
|
38
|
+
if (record.costUsd !== undefined) {
|
|
39
|
+
into.costUsd = Math.max(into.costUsd ?? 0, record.costUsd);
|
|
40
|
+
}
|
|
37
41
|
}
|
|
38
42
|
return folded;
|
|
39
43
|
}
|
package/dist/usage/roots.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { homeFolder } from '../home-folder.js';
|
|
3
3
|
/* Where in its config folder each CLI leaves its transcripts. A provider with no CLI has a log its host passes as a root of its own. */
|
|
4
|
-
const TRANSCRIPTS = {
|
|
4
|
+
const TRANSCRIPTS = {
|
|
5
|
+
claude: 'projects',
|
|
6
|
+
codex: 'sessions',
|
|
7
|
+
grok: 'sessions'
|
|
8
|
+
};
|
|
5
9
|
/* The transcripts of the default accounts, found through the variables the CLIs themselves read. */
|
|
6
10
|
export function usageRoots(env = process.env) {
|
|
7
11
|
const home = homeFolder(env);
|
|
8
12
|
return [
|
|
9
13
|
{ provider: 'claude', path: join(env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), TRANSCRIPTS.claude) },
|
|
10
|
-
{ provider: 'codex', path: join(env.CODEX_HOME ?? join(home, '.codex'), TRANSCRIPTS.codex) }
|
|
14
|
+
{ provider: 'codex', path: join(env.CODEX_HOME ?? join(home, '.codex'), TRANSCRIPTS.codex) },
|
|
15
|
+
{ provider: 'grok', path: join(env.GROK_HOME ?? join(home, '.grok'), TRANSCRIPTS.grok) }
|
|
11
16
|
];
|
|
12
17
|
}
|
|
13
18
|
/* The transcripts of every account, once per folder: Codex accounts over one home share its sessions. */
|
package/dist/usage/scanner.d.ts
CHANGED
package/dist/usage/scanner.js
CHANGED
|
@@ -6,6 +6,7 @@ import { isNotFound, writeAtomic } from '../fs.js';
|
|
|
6
6
|
import { decodeIndex, encodeIndex } from './index-file.js';
|
|
7
7
|
import { foldByKey } from './record.js';
|
|
8
8
|
import { claudeMightCarryUsage, parseClaudeLine } from './readers/claude.js';
|
|
9
|
+
import { GROK_USAGE_FILE, parseGrokUsage } from './readers/grok.js';
|
|
9
10
|
import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.js';
|
|
10
11
|
import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine } from './readers/codex.js';
|
|
11
12
|
import { usageRoots } from './roots.js';
|
|
@@ -49,7 +50,14 @@ function headOf(hash) {
|
|
|
49
50
|
// Older indexes hashed only the first 4 KB; they cannot establish that the rest stayed unchanged.
|
|
50
51
|
return `sha256:${hash.copy().digest('base64')}`;
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
/* A log of lines for every provider but Grok, which keeps one JSON file per session that it rewrites whole. */
|
|
54
|
+
function readsWhole(provider) {
|
|
55
|
+
return provider === 'grok';
|
|
56
|
+
}
|
|
57
|
+
function isUsageFile(provider, name) {
|
|
58
|
+
return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl');
|
|
59
|
+
}
|
|
60
|
+
async function listUsageFiles(dir, provider, out = []) {
|
|
53
61
|
let entries;
|
|
54
62
|
try {
|
|
55
63
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -61,9 +69,9 @@ async function listJsonl(dir, out = []) {
|
|
|
61
69
|
for (const entry of entries) {
|
|
62
70
|
const path = join(dir, entry.name);
|
|
63
71
|
if (entry.isDirectory()) {
|
|
64
|
-
await
|
|
72
|
+
await listUsageFiles(path, provider, out);
|
|
65
73
|
}
|
|
66
|
-
else if (entry.isFile() && entry.name
|
|
74
|
+
else if (entry.isFile() && isUsageFile(provider, entry.name)) {
|
|
67
75
|
out.push(path);
|
|
68
76
|
}
|
|
69
77
|
}
|
|
@@ -103,7 +111,7 @@ function parseChunk(text, provider, from, state) {
|
|
|
103
111
|
}
|
|
104
112
|
continue;
|
|
105
113
|
}
|
|
106
|
-
// Claude Code and Codex write transcripts of their own; every other provider is a log its host keeps.
|
|
114
|
+
// Claude Code and Codex write transcripts of their own, Grok a file read whole; every other provider is a log its host keeps.
|
|
107
115
|
if (hostLogMightCarryUsage(line)) {
|
|
108
116
|
const record = parseHostUsageLine(line, provider);
|
|
109
117
|
if (record !== null) {
|
|
@@ -207,7 +215,7 @@ export class UsageScanner {
|
|
|
207
215
|
}
|
|
208
216
|
let failure = null;
|
|
209
217
|
const accountOf = accountResolver(root, sessionAccounts);
|
|
210
|
-
for (const path of await
|
|
218
|
+
for (const path of await listUsageFiles(root.path, root.provider)) {
|
|
211
219
|
files += 1;
|
|
212
220
|
try {
|
|
213
221
|
if (await this.readFileInto(path, root.provider, accountOf)) {
|
|
@@ -242,6 +250,9 @@ export class UsageScanner {
|
|
|
242
250
|
if (known !== undefined && known.provider === provider && known.size === info.size && known.mtimeMs === info.mtimeMs) {
|
|
243
251
|
return false;
|
|
244
252
|
}
|
|
253
|
+
if (readsWhole(provider)) {
|
|
254
|
+
return this.readWholeInto(path, provider, accountOf, info);
|
|
255
|
+
}
|
|
245
256
|
const appended = known !== undefined && known.provider === provider && info.size > known.size && known.head?.startsWith('sha256:') === true;
|
|
246
257
|
let read = await readHashed(path, appended ? known.offset : 0);
|
|
247
258
|
// A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
|
|
@@ -268,4 +279,27 @@ export class UsageScanner {
|
|
|
268
279
|
this.dirty = true;
|
|
269
280
|
return true;
|
|
270
281
|
}
|
|
282
|
+
/*
|
|
283
|
+
* A file rewritten as a whole replaces what the index held of it, so a turn it still lists is never
|
|
284
|
+
* counted twice. One that does not parse, such as one caught halfway through a write, keeps the
|
|
285
|
+
* records it had and is read again on the next scan.
|
|
286
|
+
*/
|
|
287
|
+
async readWholeInto(path, provider, accountOf, info) {
|
|
288
|
+
const records = parseGrokUsage(await readFile(path, 'utf8'), path);
|
|
289
|
+
if (records === null) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
this.index.set(path, {
|
|
293
|
+
provider,
|
|
294
|
+
size: info.size,
|
|
295
|
+
mtimeMs: info.mtimeMs,
|
|
296
|
+
offset: info.size,
|
|
297
|
+
records: foldByKey(withAccount(records, accountOf)),
|
|
298
|
+
tail: [],
|
|
299
|
+
codex: null,
|
|
300
|
+
head: null
|
|
301
|
+
});
|
|
302
|
+
this.dirty = true;
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
271
305
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.3",
|
|
4
4
|
"description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -81,8 +81,8 @@
|
|
|
81
81
|
"test": "bun --conditions=source test"
|
|
82
82
|
},
|
|
83
83
|
"dependencies": {
|
|
84
|
-
"@adecore/agent-contracts": "0.31.
|
|
85
|
-
"@adecore/intelligent-ui": "0.31.
|
|
84
|
+
"@adecore/agent-contracts": "0.31.3",
|
|
85
|
+
"@adecore/intelligent-ui": "0.31.3",
|
|
86
86
|
"zod": "^4.6.5"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
package/src/chat/acp-backend.ts
CHANGED
|
@@ -10,12 +10,15 @@ import {
|
|
|
10
10
|
autoAllows,
|
|
11
11
|
parseInitialize,
|
|
12
12
|
parseSessionInfo,
|
|
13
|
+
promptUsageOf,
|
|
13
14
|
resolveAcpMode,
|
|
14
15
|
runtimeModeOf,
|
|
15
16
|
sessionIdOf,
|
|
16
17
|
type AcpAgentCapabilities,
|
|
17
18
|
type AcpModeMap,
|
|
18
|
-
type
|
|
19
|
+
type AcpPermissionRequest,
|
|
20
|
+
type AcpSessionInfo,
|
|
21
|
+
type AcpTurnUsage
|
|
19
22
|
} from './acp-protocol.ts';
|
|
20
23
|
import type { ApprovalDecision, BackendEvent, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
|
|
21
24
|
import { attachmentNote } from './input.ts';
|
|
@@ -49,6 +52,18 @@ export interface AcpAgentSpec {
|
|
|
49
52
|
model?(selection: ModelSelection): string | null;
|
|
50
53
|
// What every new, loaded or resumed session offers, for a host that fills its model catalog from the agent.
|
|
51
54
|
onSession?(info: AcpSessionInfo): void;
|
|
55
|
+
// The tokens of every turn whose `session/prompt` response reports them, for a host that keeps a usage log.
|
|
56
|
+
onUsage?(usage: AcpUsageReport): void;
|
|
57
|
+
// A permission request the host answers itself, in any runtime mode: true allows it once without asking the
|
|
58
|
+
// person, for a call the host already guards. A throw counts as false.
|
|
59
|
+
autoAllow?(request: AcpPermissionRequest): boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* One turn's usage, with the session, folder and model it ran in. */
|
|
63
|
+
export interface AcpUsageReport extends AcpTurnUsage {
|
|
64
|
+
sessionId: string;
|
|
65
|
+
cwd: string;
|
|
66
|
+
model: string;
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
// What `initialize` and a session request may take; a prompt and a login wait for as long as the agent runs.
|
|
@@ -77,6 +92,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
77
92
|
private replaying = false;
|
|
78
93
|
private contextPending: string | null = null;
|
|
79
94
|
private prompt: { promptId: string | undefined; cancelled: boolean } | null = null;
|
|
95
|
+
private model: string | null = null;
|
|
80
96
|
|
|
81
97
|
constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec) {
|
|
82
98
|
this.launch = launch;
|
|
@@ -139,6 +155,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
139
155
|
const info = await this.openSession(transport, capabilities);
|
|
140
156
|
const modeId = await this.applyMode(transport, info);
|
|
141
157
|
const model = await this.applyModel(transport, info);
|
|
158
|
+
this.model = model;
|
|
142
159
|
await this.applyThoughtLevel(transport, info);
|
|
143
160
|
this.spec.onSession?.(info);
|
|
144
161
|
this.emit({
|
|
@@ -322,6 +339,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
322
339
|
.then((result) => {
|
|
323
340
|
if (this.transport === transport && this.prompt === turn) {
|
|
324
341
|
this.prompt = null;
|
|
342
|
+
this.report(result);
|
|
325
343
|
this.emitAll(this.protocol.finish(turn.cancelled ? { stopReason: 'cancelled' } : result, turn.promptId));
|
|
326
344
|
}
|
|
327
345
|
})
|
|
@@ -438,7 +456,7 @@ export class AcpBackend implements ChatBackend {
|
|
|
438
456
|
}
|
|
439
457
|
return;
|
|
440
458
|
}
|
|
441
|
-
if (autoAllows(this.launch.runtimeMode, permission.kind)) {
|
|
459
|
+
if (autoAllows(this.launch.runtimeMode, permission.kind) || this.hostAllows(permission.request)) {
|
|
442
460
|
this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
|
|
443
461
|
const answer = this.protocol.answer(permission.requestId, 'allow');
|
|
444
462
|
if (answer) {
|
|
@@ -449,6 +467,14 @@ export class AcpBackend implements ChatBackend {
|
|
|
449
467
|
this.emitAll(permission.events);
|
|
450
468
|
}
|
|
451
469
|
|
|
470
|
+
private hostAllows(request: AcpPermissionRequest): boolean {
|
|
471
|
+
try {
|
|
472
|
+
return this.spec.autoAllow?.(request) === true;
|
|
473
|
+
} catch {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
452
478
|
private handleExit(transport: JsonRpcTransport, exitCode: number | null, stderr: string | null): void {
|
|
453
479
|
if (this.transport !== transport) {
|
|
454
480
|
return;
|
|
@@ -459,6 +485,15 @@ export class AcpBackend implements ChatBackend {
|
|
|
459
485
|
this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
|
|
460
486
|
}
|
|
461
487
|
|
|
488
|
+
/* A cancelled turn still spent what it reports. */
|
|
489
|
+
private report(result: unknown): void {
|
|
490
|
+
const usage = this.spec.onUsage === undefined ? null : promptUsageOf(result);
|
|
491
|
+
if (usage === null) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
this.spec.onUsage?.({ ...usage, sessionId: this.sessionId, cwd: this.launch.cwd, model: this.model ?? this.launch.selection.model });
|
|
495
|
+
}
|
|
496
|
+
|
|
462
497
|
private emitAll(events: BackendEvent[]): void {
|
|
463
498
|
for (const event of events) {
|
|
464
499
|
this.emit(event);
|
|
@@ -466,6 +501,9 @@ export class AcpBackend implements ChatBackend {
|
|
|
466
501
|
}
|
|
467
502
|
|
|
468
503
|
private emit(event: BackendEvent): void {
|
|
504
|
+
if (event.type === 'model') {
|
|
505
|
+
this.model = event.model;
|
|
506
|
+
}
|
|
469
507
|
this.host.onEvent(event);
|
|
470
508
|
}
|
|
471
509
|
}
|
package/src/chat/acp-protocol.ts
CHANGED
|
@@ -58,6 +58,48 @@ const SessionNotificationSchema = z.looseObject({ sessionId: z.string(), update:
|
|
|
58
58
|
|
|
59
59
|
const PromptResponseSchema = z.looseObject({ stopReason: z.string() });
|
|
60
60
|
|
|
61
|
+
const tokenCount = z.number().int().nonnegative();
|
|
62
|
+
|
|
63
|
+
// Unstable in the protocol (`PromptResponse.usage`); only the three totals are required.
|
|
64
|
+
const PromptUsageSchema = z.looseObject({
|
|
65
|
+
totalTokens: tokenCount,
|
|
66
|
+
inputTokens: tokenCount,
|
|
67
|
+
outputTokens: tokenCount,
|
|
68
|
+
thoughtTokens: tokenCount.nullish(),
|
|
69
|
+
cachedReadTokens: tokenCount.nullish(),
|
|
70
|
+
cachedWriteTokens: tokenCount.nullish()
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/* The tokens of one turn with their kinds apart, as a usage record keeps them: `reasoning` is a part of `output`. */
|
|
74
|
+
export interface AcpTurnUsage {
|
|
75
|
+
input: number;
|
|
76
|
+
cacheRead: number;
|
|
77
|
+
cacheWrite: number;
|
|
78
|
+
output: number;
|
|
79
|
+
reasoning: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/*
|
|
83
|
+
* The `usage` a `session/prompt` response carries, or null where the agent reports none. The protocol
|
|
84
|
+
* leaves open whether the input holds the cached part, so the total decides: when the kinds add up
|
|
85
|
+
* to more than it, the input counted the cache too and loses it here.
|
|
86
|
+
*/
|
|
87
|
+
export function promptUsageOf(result: unknown): AcpTurnUsage | null {
|
|
88
|
+
const response = z.looseObject({ usage: PromptUsageSchema }).safeParse(result);
|
|
89
|
+
if (!response.success) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const usage = response.data.usage;
|
|
93
|
+
const cacheRead = usage.cachedReadTokens ?? 0;
|
|
94
|
+
const cacheWrite = usage.cachedWriteTokens ?? 0;
|
|
95
|
+
const apart = usage.inputTokens + usage.outputTokens + cacheRead + cacheWrite <= usage.totalTokens;
|
|
96
|
+
const input = apart ? usage.inputTokens : Math.max(0, usage.inputTokens - cacheRead - cacheWrite);
|
|
97
|
+
if (input + cacheRead + cacheWrite + usage.outputTokens === 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return { input, cacheRead, cacheWrite, output: usage.outputTokens, reasoning: usage.thoughtTokens ?? 0 };
|
|
101
|
+
}
|
|
102
|
+
|
|
61
103
|
const ModeStateSchema = z.looseObject({
|
|
62
104
|
currentModeId: z.string(),
|
|
63
105
|
availableModes: z.array(z.unknown())
|
|
@@ -440,11 +482,22 @@ function toolOutput(tool: ToolState): string | null {
|
|
|
440
482
|
return null;
|
|
441
483
|
}
|
|
442
484
|
|
|
485
|
+
/* What an agent asks permission for, in the plain form a host's `autoAllow` judges it by. */
|
|
486
|
+
export interface AcpPermissionRequest {
|
|
487
|
+
// The ACP tool kind (`execute`, `edit`, `fetch`, ...), or null when the agent names none.
|
|
488
|
+
kind: string | null;
|
|
489
|
+
title: string | null;
|
|
490
|
+
// Every text the call carries that may be what it runs: the command in its raw input and each text in its
|
|
491
|
+
// content. The agent decides which one it runs, so a host allows the call only when every one passes.
|
|
492
|
+
commands: string[];
|
|
493
|
+
}
|
|
494
|
+
|
|
443
495
|
/* A permission request as the backend acts on it: the event it raises, and the tool kind a runtime mode may answer it by. */
|
|
444
496
|
export interface AcpPermission {
|
|
445
497
|
requestId: string;
|
|
446
498
|
rpcId: number | string;
|
|
447
499
|
kind: string | null;
|
|
500
|
+
request: AcpPermissionRequest;
|
|
448
501
|
events: BackendEvent[];
|
|
449
502
|
}
|
|
450
503
|
|
|
@@ -562,7 +615,9 @@ export class AcpProtocol {
|
|
|
562
615
|
description: tool.title === '' ? null : tool.title,
|
|
563
616
|
canAllowAlways: options.some((option) => option.kind === 'allow_always')
|
|
564
617
|
});
|
|
565
|
-
|
|
618
|
+
const command = rawField(tool.rawInput, 'command', 'cmd');
|
|
619
|
+
const asked = { kind: tool.kind, title: tool.title === '' ? null : tool.title, commands: [...(command === null ? [] : [command]), ...tool.text] };
|
|
620
|
+
return { requestId, rpcId, kind: tool.kind, request: asked, events };
|
|
566
621
|
}
|
|
567
622
|
|
|
568
623
|
/*
|
package/src/chat/index.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
// Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
|
|
2
|
-
export { type AcpAgentSpec, type AcpClientInfo, createAcpBackend } from './acp-backend.ts';
|
|
3
|
-
export type {
|
|
2
|
+
export { type AcpAgentSpec, type AcpClientInfo, type AcpUsageReport, createAcpBackend } from './acp-backend.ts';
|
|
3
|
+
export type {
|
|
4
|
+
AcpAgentInfo,
|
|
5
|
+
AcpModel,
|
|
6
|
+
AcpModelState,
|
|
7
|
+
AcpMode,
|
|
8
|
+
AcpModeState,
|
|
9
|
+
AcpPermissionRequest,
|
|
10
|
+
AcpSessionInfo,
|
|
11
|
+
AcpModeMap,
|
|
12
|
+
AcpTurnUsage
|
|
13
|
+
} from './acp-protocol.ts';
|
|
4
14
|
export { AttachmentStore } from './attachment-store.ts';
|
|
5
15
|
export type { BackendLaunch, TurnInput, ApprovalDecision, BackendEvent, BackendHost, ChatBackend } from './backend.ts';
|
|
6
16
|
export { runningInBackground, type BackgroundWork } from './background-work.ts';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
|
|
2
|
-
import { createAcpBackend, type AcpAgentSpec } from '../chat/acp-backend.ts';
|
|
2
|
+
import { createAcpBackend, type AcpAgentSpec, type AcpUsageReport } from '../chat/acp-backend.ts';
|
|
3
3
|
import type { AcpSessionInfo } from '../chat/acp-protocol.ts';
|
|
4
4
|
import { askAcpAccount } from './accounts/acp-account.ts';
|
|
5
|
+
import { writeAcpUsage } from './acp-usage-log.ts';
|
|
5
6
|
import type { ModelCatalog } from './catalog.ts';
|
|
6
7
|
import { detectCli, type CliDetection } from './detect.ts';
|
|
7
8
|
import type { ChatProvider, ProviderHome } from './provider.ts';
|
|
@@ -53,8 +54,13 @@ export interface AcpProviderSpec extends AcpAgentSpec {
|
|
|
53
54
|
accountProbe?: boolean;
|
|
54
55
|
// Absent or true lets the models a session reports replace the catalog's; false keeps the catalog as the host built it.
|
|
55
56
|
adoptModels?: boolean;
|
|
57
|
+
// The folder the usage of every turn the agent reports is logged to, for a usage root of the agent's kind; absent logs nothing.
|
|
58
|
+
usageLog?: string;
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
/* Agents whose own usage files the scanner reads; a log of their turns would count every one twice. */
|
|
62
|
+
const OWN_USAGE_FILES: ReadonlySet<AgentKind> = new Set(['grok']);
|
|
63
|
+
|
|
58
64
|
/* An agent's model id may reach a command line, so one that reads as a flag is left out. */
|
|
59
65
|
function adoptableModels(info: AcpSessionInfo): Array<{ slug: string; name: string }> {
|
|
60
66
|
return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
|
|
@@ -68,6 +74,14 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
|
|
|
68
74
|
}
|
|
69
75
|
spec.onSession?.(info);
|
|
70
76
|
};
|
|
77
|
+
const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
|
|
78
|
+
const onUsage =
|
|
79
|
+
usageLog === null
|
|
80
|
+
? spec.onUsage
|
|
81
|
+
: (report: AcpUsageReport): void => {
|
|
82
|
+
writeAcpUsage(usageLog, report, Date.now());
|
|
83
|
+
spec.onUsage?.(report);
|
|
84
|
+
};
|
|
71
85
|
return {
|
|
72
86
|
kind: spec.kind,
|
|
73
87
|
name: spec.name,
|
|
@@ -99,6 +113,6 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
|
|
|
99
113
|
onSession
|
|
100
114
|
})
|
|
101
115
|
}),
|
|
102
|
-
createBackend: createAcpBackend({ ...spec, onSession })
|
|
116
|
+
createBackend: createAcpBackend({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
|
|
103
117
|
};
|
|
104
118
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { AcpUsageReport } from '../chat/acp-backend.ts';
|
|
4
|
+
import { errorText } from '../error-text.ts';
|
|
5
|
+
import { hostUsageLine } from '../usage/readers/host-log.ts';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Appends one turn to the session's own file in the folder, so a scan that hashes what it already read
|
|
9
|
+
* of a file never rereads the log of every other session. Written at once, since it is one line per turn.
|
|
10
|
+
*/
|
|
11
|
+
export function writeAcpUsage(folder: string, report: AcpUsageReport, at: number): void {
|
|
12
|
+
const { sessionId, cwd, model, ...tokens } = report;
|
|
13
|
+
try {
|
|
14
|
+
mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
15
|
+
appendFileSync(
|
|
16
|
+
join(folder, `${encodeURIComponent(sessionId === '' ? 'session' : sessionId)}.jsonl`),
|
|
17
|
+
hostUsageLine({ at, model, session: sessionId, cwd, ...tokens }),
|
|
18
|
+
{
|
|
19
|
+
mode: 0o600
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
// A turn whose line could not be written is missing from the usage page; the chat itself goes on.
|
|
24
|
+
console.warn('Could not write the usage of an ACP turn:', errorText(e));
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/providers/index.ts
CHANGED
|
@@ -13,5 +13,6 @@ export { type CodexRules, codexRulesText, codexRulesPathIn, defaultCodexHome, in
|
|
|
13
13
|
export { codexThreadOptions, codexServiceTier } from './codex.ts';
|
|
14
14
|
export { type CliDetection, detectCli } from './detect.ts';
|
|
15
15
|
export type { ChatProvider, ProviderHome } from './provider.ts';
|
|
16
|
+
export { isOnlyCommands } from './shell-commands.ts';
|
|
16
17
|
export { ProviderRegistry, type ProviderRegistryOptions } from './registry.ts';
|
|
17
18
|
export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.ts';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Unquoted, each of these makes the shell do more than run a word list: a pipe, a redirect, an expansion,
|
|
2
|
+
// a subshell or group, a glob, a comment or a history or negation mark.
|
|
3
|
+
const REFUSED = new Set(['|', '>', '<', '$', '`', '(', ')', '{', '}', '*', '?', '[', '~', '#', '!']);
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Whether a shell command line is only simple commands, joined by `&&`, `||`, `;` or newlines, each of
|
|
7
|
+
* which starts with one of `allowed`. Anything the tokenizer cannot prove simple answers false, so a
|
|
8
|
+
* true is safe to act on without a person: a pipe, a redirect, an expansion, a subshell, a background
|
|
9
|
+
* `&`, an unquoted glob or an unbalanced quote all refuse the whole line.
|
|
10
|
+
*/
|
|
11
|
+
export function isOnlyCommands(command: string, allowed: readonly string[]): boolean {
|
|
12
|
+
let word = '';
|
|
13
|
+
let inWord = false;
|
|
14
|
+
let words: string[] = [];
|
|
15
|
+
let commands = 0;
|
|
16
|
+
// A separator that still needs a command after it; a trailing `;` or newline needs none.
|
|
17
|
+
let dangling = false;
|
|
18
|
+
|
|
19
|
+
const endWord = (): void => {
|
|
20
|
+
if (inWord) {
|
|
21
|
+
words.push(word);
|
|
22
|
+
}
|
|
23
|
+
word = '';
|
|
24
|
+
inWord = false;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const endCommand = (separator: string): boolean => {
|
|
28
|
+
endWord();
|
|
29
|
+
if (words.length === 0) {
|
|
30
|
+
// Blank lines are fine, and so is a newline after `&&` or `||`; an empty command before any other separator is not.
|
|
31
|
+
return separator === '\n';
|
|
32
|
+
}
|
|
33
|
+
if (!allowed.includes(words[0]!)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
commands++;
|
|
37
|
+
words = [];
|
|
38
|
+
dangling = separator === '&&' || separator === '||';
|
|
39
|
+
return true;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < command.length; i++) {
|
|
43
|
+
const char = command[i]!;
|
|
44
|
+
const next = command[i + 1];
|
|
45
|
+
if (char === ' ' || char === '\t') {
|
|
46
|
+
endWord();
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (char === '\n' || char === ';') {
|
|
50
|
+
if (!endCommand(char)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if ((char === '&' || char === '|') && next === char) {
|
|
56
|
+
if (!endCommand(char + next)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
i++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (char === '&' || REFUSED.has(char)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (char === '\\') {
|
|
66
|
+
if (next === undefined) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
i++;
|
|
70
|
+
if (next !== '\n') {
|
|
71
|
+
word += next;
|
|
72
|
+
inWord = true;
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (char === "'") {
|
|
77
|
+
const close = command.indexOf("'", i + 1);
|
|
78
|
+
if (close === -1) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
word += command.slice(i + 1, close);
|
|
82
|
+
inWord = true;
|
|
83
|
+
i = close;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (char === '"') {
|
|
87
|
+
inWord = true;
|
|
88
|
+
let closed = false;
|
|
89
|
+
for (i++; i < command.length; i++) {
|
|
90
|
+
const quoted = command[i]!;
|
|
91
|
+
if (quoted === '"') {
|
|
92
|
+
closed = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
if (quoted === '$' || quoted === '`') {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (quoted === '\\') {
|
|
99
|
+
const escaped = command[i + 1];
|
|
100
|
+
if (escaped === undefined) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
i++;
|
|
104
|
+
if (escaped === '"' || escaped === '\\' || escaped === '$' || escaped === '`') {
|
|
105
|
+
word += escaped;
|
|
106
|
+
} else if (escaped !== '\n') {
|
|
107
|
+
word += quoted + escaped;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
word += quoted;
|
|
112
|
+
}
|
|
113
|
+
if (!closed) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
word += char;
|
|
119
|
+
inWord = true;
|
|
120
|
+
}
|
|
121
|
+
return endCommand('\n') && commands > 0 && !dangling;
|
|
122
|
+
}
|
package/src/testing/fake-acp.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface FakeAcpOptions {
|
|
|
22
22
|
legacyModels?: boolean;
|
|
23
23
|
// Advertises and answers `session/close`.
|
|
24
24
|
closeSession?: boolean;
|
|
25
|
+
// The `usage` every `session/prompt` response carries; absent reports none.
|
|
26
|
+
turnUsage?: Record<string, number>;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
@@ -63,7 +65,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
|
63
65
|
return;
|
|
64
66
|
}
|
|
65
67
|
update({ sessionUpdate: 'usage_update', used: 1200, size: 200000, cost: { amount: 0.01, currency: 'USD' } });
|
|
66
|
-
respond(prompt.rpcId, { stopReason });
|
|
68
|
+
respond(prompt.rpcId, { stopReason, ...(options.turnUsage === undefined ? {} : { usage: options.turnUsage }) });
|
|
67
69
|
prompt = null;
|
|
68
70
|
};
|
|
69
71
|
|
package/src/usage/aggregate.ts
CHANGED
|
@@ -112,7 +112,7 @@ function foldBucket(
|
|
|
112
112
|
}
|
|
113
113
|
bucket.totals = addTotals(bucket.totals, record.totals);
|
|
114
114
|
bucket.costUsd += cost;
|
|
115
|
-
bucket.priced ||= price !== null;
|
|
115
|
+
bucket.priced ||= price !== null || record.costUsd !== undefined;
|
|
116
116
|
bucket.cacheSavingsUsd += price === null ? 0 : cacheSavingsOf(record.totals, price);
|
|
117
117
|
if (record.sessionId !== '') {
|
|
118
118
|
bucket.sessions.add(record.sessionId);
|
|
@@ -130,14 +130,15 @@ function foldModel(models: Map<string, ModelRow>, record: UsageRecord, account:
|
|
|
130
130
|
totals: emptyTotals(),
|
|
131
131
|
costUsd: 0,
|
|
132
132
|
priced: false,
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
// A cost the CLI reported is the bill itself, not a price looked up for the model.
|
|
134
|
+
basis: record.costUsd === undefined ? lookup.basis : 'exact',
|
|
135
|
+
pricedAs: record.costUsd === undefined ? lookup.pricedAs : null
|
|
135
136
|
};
|
|
136
137
|
models.set(key, model);
|
|
137
138
|
}
|
|
138
139
|
model.totals = addTotals(model.totals, record.totals);
|
|
139
140
|
model.costUsd += cost;
|
|
140
|
-
model.priced ||= lookup.price !== null;
|
|
141
|
+
model.priced ||= lookup.price !== null || record.costUsd !== undefined;
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
function foldProject(project: ProjectAccumulator, record: UsageRecord, cost: number): void {
|
|
@@ -186,7 +187,7 @@ export async function aggregate(
|
|
|
186
187
|
continue;
|
|
187
188
|
}
|
|
188
189
|
const lookup = prices.look(record.model);
|
|
189
|
-
const cost = lookup.price === null ? 0 : costOf(record.totals, lookup.price);
|
|
190
|
+
const cost = record.costUsd ?? (lookup.price === null ? 0 : costOf(record.totals, lookup.price));
|
|
190
191
|
foldBucket(buckets, slot, record, account, lookup.price, cost);
|
|
191
192
|
foldModel(models, record, account, lookup, cost);
|
|
192
193
|
if (record.sessionId !== '') {
|