@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ModelSelection } from '@adecore/agent-contracts';
|
|
2
|
-
import { type AcpModeMap, type AcpSessionInfo } from './acp-protocol.ts';
|
|
2
|
+
import { type AcpModeMap, type AcpPermissionRequest, type AcpSessionInfo, type AcpTurnUsage } from './acp-protocol.ts';
|
|
3
3
|
import type { ApprovalDecision, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
|
|
4
4
|
export interface AcpClientInfo {
|
|
5
5
|
name: string;
|
|
@@ -16,6 +16,13 @@ export interface AcpAgentSpec {
|
|
|
16
16
|
client?: AcpClientInfo;
|
|
17
17
|
model?(selection: ModelSelection): string | null;
|
|
18
18
|
onSession?(info: AcpSessionInfo): void;
|
|
19
|
+
onUsage?(usage: AcpUsageReport): void;
|
|
20
|
+
autoAllow?(request: AcpPermissionRequest): boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface AcpUsageReport extends AcpTurnUsage {
|
|
23
|
+
sessionId: string;
|
|
24
|
+
cwd: string;
|
|
25
|
+
model: string;
|
|
19
26
|
}
|
|
20
27
|
export declare class AcpBackend implements ChatBackend {
|
|
21
28
|
private readonly launch;
|
|
@@ -29,6 +36,7 @@ export declare class AcpBackend implements ChatBackend {
|
|
|
29
36
|
private replaying;
|
|
30
37
|
private contextPending;
|
|
31
38
|
private prompt;
|
|
39
|
+
private model;
|
|
32
40
|
constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec);
|
|
33
41
|
get running(): boolean;
|
|
34
42
|
get pid(): number | null;
|
|
@@ -52,7 +60,9 @@ export declare class AcpBackend implements ChatBackend {
|
|
|
52
60
|
private respond;
|
|
53
61
|
private fail;
|
|
54
62
|
private handleFrame;
|
|
63
|
+
private hostAllows;
|
|
55
64
|
private handleExit;
|
|
65
|
+
private report;
|
|
56
66
|
private emitAll;
|
|
57
67
|
private emit;
|
|
58
68
|
}
|
package/dist/chat/acp-backend.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { attachmentImageMime } from '@adecore/agent-contracts';
|
|
3
3
|
import { errorText } from '../error-text.js';
|
|
4
4
|
import { MODE_ORDER } from '../modes.js';
|
|
5
|
-
import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, AcpProtocol, acpStopWords, autoAllows, parseInitialize, parseSessionInfo, resolveAcpMode, runtimeModeOf, sessionIdOf } from './acp-protocol.js';
|
|
5
|
+
import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, AcpProtocol, acpStopWords, autoAllows, parseInitialize, parseSessionInfo, promptUsageOf, resolveAcpMode, runtimeModeOf, sessionIdOf } from './acp-protocol.js';
|
|
6
6
|
import { attachmentNote } from './input.js';
|
|
7
7
|
import { JsonRpcError, JsonRpcTransport } from './json-rpc-transport.js';
|
|
8
8
|
export const DEFAULT_ACP_CLIENT = { name: 'agents', title: 'Agents', version: '0.1.0' };
|
|
@@ -30,6 +30,7 @@ export class AcpBackend {
|
|
|
30
30
|
replaying = false;
|
|
31
31
|
contextPending = null;
|
|
32
32
|
prompt = null;
|
|
33
|
+
model = null;
|
|
33
34
|
constructor(launch, host, spec) {
|
|
34
35
|
this.launch = launch;
|
|
35
36
|
this.host = host;
|
|
@@ -86,6 +87,7 @@ export class AcpBackend {
|
|
|
86
87
|
const info = await this.openSession(transport, capabilities);
|
|
87
88
|
const modeId = await this.applyMode(transport, info);
|
|
88
89
|
const model = await this.applyModel(transport, info);
|
|
90
|
+
this.model = model;
|
|
89
91
|
await this.applyThoughtLevel(transport, info);
|
|
90
92
|
this.spec.onSession?.(info);
|
|
91
93
|
this.emit({
|
|
@@ -265,6 +267,7 @@ export class AcpBackend {
|
|
|
265
267
|
.then((result) => {
|
|
266
268
|
if (this.transport === transport && this.prompt === turn) {
|
|
267
269
|
this.prompt = null;
|
|
270
|
+
this.report(result);
|
|
268
271
|
this.emitAll(this.protocol.finish(turn.cancelled ? { stopReason: 'cancelled' } : result, turn.promptId));
|
|
269
272
|
}
|
|
270
273
|
})
|
|
@@ -373,7 +376,7 @@ export class AcpBackend {
|
|
|
373
376
|
}
|
|
374
377
|
return;
|
|
375
378
|
}
|
|
376
|
-
if (autoAllows(this.launch.runtimeMode, permission.kind)) {
|
|
379
|
+
if (autoAllows(this.launch.runtimeMode, permission.kind) || this.hostAllows(permission.request)) {
|
|
377
380
|
this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
|
|
378
381
|
const answer = this.protocol.answer(permission.requestId, 'allow');
|
|
379
382
|
if (answer) {
|
|
@@ -383,6 +386,14 @@ export class AcpBackend {
|
|
|
383
386
|
}
|
|
384
387
|
this.emitAll(permission.events);
|
|
385
388
|
}
|
|
389
|
+
hostAllows(request) {
|
|
390
|
+
try {
|
|
391
|
+
return this.spec.autoAllow?.(request) === true;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
386
397
|
handleExit(transport, exitCode, stderr) {
|
|
387
398
|
if (this.transport !== transport) {
|
|
388
399
|
return;
|
|
@@ -392,12 +403,23 @@ export class AcpBackend {
|
|
|
392
403
|
this.protocol.cancelPermissions();
|
|
393
404
|
this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
|
|
394
405
|
}
|
|
406
|
+
/* A cancelled turn still spent what it reports. */
|
|
407
|
+
report(result) {
|
|
408
|
+
const usage = this.spec.onUsage === undefined ? null : promptUsageOf(result);
|
|
409
|
+
if (usage === null) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
this.spec.onUsage?.({ ...usage, sessionId: this.sessionId, cwd: this.launch.cwd, model: this.model ?? this.launch.selection.model });
|
|
413
|
+
}
|
|
395
414
|
emitAll(events) {
|
|
396
415
|
for (const event of events) {
|
|
397
416
|
this.emit(event);
|
|
398
417
|
}
|
|
399
418
|
}
|
|
400
419
|
emit(event) {
|
|
420
|
+
if (event.type === 'model') {
|
|
421
|
+
this.model = event.model;
|
|
422
|
+
}
|
|
401
423
|
this.host.onEvent(event);
|
|
402
424
|
}
|
|
403
425
|
}
|
|
@@ -9,6 +9,14 @@ declare const DiffSchema: z.ZodObject<{
|
|
|
9
9
|
oldText: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
10
10
|
newText: z.ZodString;
|
|
11
11
|
}, z.core.$loose>;
|
|
12
|
+
export interface AcpTurnUsage {
|
|
13
|
+
input: number;
|
|
14
|
+
cacheRead: number;
|
|
15
|
+
cacheWrite: number;
|
|
16
|
+
output: number;
|
|
17
|
+
reasoning: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function promptUsageOf(result: unknown): AcpTurnUsage | null;
|
|
12
20
|
export interface AcpAgentInfo {
|
|
13
21
|
name: string;
|
|
14
22
|
title: string | null;
|
|
@@ -84,10 +92,16 @@ export declare function toolRow(tool: Pick<ToolState, 'title' | 'name' | 'kind'
|
|
|
84
92
|
input: Record<string, unknown>;
|
|
85
93
|
changes?: ChatFileChange[];
|
|
86
94
|
};
|
|
95
|
+
export interface AcpPermissionRequest {
|
|
96
|
+
kind: string | null;
|
|
97
|
+
title: string | null;
|
|
98
|
+
commands: string[];
|
|
99
|
+
}
|
|
87
100
|
export interface AcpPermission {
|
|
88
101
|
requestId: string;
|
|
89
102
|
rpcId: number | string;
|
|
90
103
|
kind: string | null;
|
|
104
|
+
request: AcpPermissionRequest;
|
|
91
105
|
events: BackendEvent[];
|
|
92
106
|
}
|
|
93
107
|
export interface AcpStopWords {
|
|
@@ -40,6 +40,36 @@ const PermissionRequestSchema = z.looseObject({
|
|
|
40
40
|
});
|
|
41
41
|
const SessionNotificationSchema = z.looseObject({ sessionId: z.string(), update: z.looseObject({ sessionUpdate: z.string() }) });
|
|
42
42
|
const PromptResponseSchema = z.looseObject({ stopReason: z.string() });
|
|
43
|
+
const tokenCount = z.number().int().nonnegative();
|
|
44
|
+
// Unstable in the protocol (`PromptResponse.usage`); only the three totals are required.
|
|
45
|
+
const PromptUsageSchema = z.looseObject({
|
|
46
|
+
totalTokens: tokenCount,
|
|
47
|
+
inputTokens: tokenCount,
|
|
48
|
+
outputTokens: tokenCount,
|
|
49
|
+
thoughtTokens: tokenCount.nullish(),
|
|
50
|
+
cachedReadTokens: tokenCount.nullish(),
|
|
51
|
+
cachedWriteTokens: tokenCount.nullish()
|
|
52
|
+
});
|
|
53
|
+
/*
|
|
54
|
+
* The `usage` a `session/prompt` response carries, or null where the agent reports none. The protocol
|
|
55
|
+
* leaves open whether the input holds the cached part, so the total decides: when the kinds add up
|
|
56
|
+
* to more than it, the input counted the cache too and loses it here.
|
|
57
|
+
*/
|
|
58
|
+
export function promptUsageOf(result) {
|
|
59
|
+
const response = z.looseObject({ usage: PromptUsageSchema }).safeParse(result);
|
|
60
|
+
if (!response.success) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const usage = response.data.usage;
|
|
64
|
+
const cacheRead = usage.cachedReadTokens ?? 0;
|
|
65
|
+
const cacheWrite = usage.cachedWriteTokens ?? 0;
|
|
66
|
+
const apart = usage.inputTokens + usage.outputTokens + cacheRead + cacheWrite <= usage.totalTokens;
|
|
67
|
+
const input = apart ? usage.inputTokens : Math.max(0, usage.inputTokens - cacheRead - cacheWrite);
|
|
68
|
+
if (input + cacheRead + cacheWrite + usage.outputTokens === 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return { input, cacheRead, cacheWrite, output: usage.outputTokens, reasoning: usage.thoughtTokens ?? 0 };
|
|
72
|
+
}
|
|
43
73
|
const ModeStateSchema = z.looseObject({
|
|
44
74
|
currentModeId: z.string(),
|
|
45
75
|
availableModes: z.array(z.unknown())
|
|
@@ -416,7 +446,9 @@ export class AcpProtocol {
|
|
|
416
446
|
description: tool.title === '' ? null : tool.title,
|
|
417
447
|
canAllowAlways: options.some((option) => option.kind === 'allow_always')
|
|
418
448
|
});
|
|
419
|
-
|
|
449
|
+
const command = rawField(tool.rawInput, 'command', 'cmd');
|
|
450
|
+
const asked = { kind: tool.kind, title: tool.title === '' ? null : tool.title, commands: [...(command === null ? [] : [command]), ...tool.text] };
|
|
451
|
+
return { requestId, rpcId, kind: tool.kind, request: asked, events };
|
|
420
452
|
}
|
|
421
453
|
/*
|
|
422
454
|
* The answer to a permission request: allow once, reject, or allow always only when the agent offered it.
|
package/dist/chat/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { type AcpAgentSpec, type AcpClientInfo, createAcpBackend } from './acp-backend.ts';
|
|
2
|
-
export type { AcpAgentInfo, AcpModel, AcpModelState, AcpMode, AcpModeState, AcpSessionInfo, AcpModeMap } from './acp-protocol.ts';
|
|
1
|
+
export { type AcpAgentSpec, type AcpClientInfo, type AcpUsageReport, createAcpBackend } from './acp-backend.ts';
|
|
2
|
+
export type { AcpAgentInfo, AcpModel, AcpModelState, AcpMode, AcpModeState, AcpPermissionRequest, AcpSessionInfo, AcpModeMap, AcpTurnUsage } from './acp-protocol.ts';
|
|
3
3
|
export { AttachmentStore } from './attachment-store.ts';
|
|
4
4
|
export type { BackendLaunch, TurnInput, ApprovalDecision, BackendEvent, BackendHost, ChatBackend } from './backend.ts';
|
|
5
5
|
export { runningInBackground, type BackgroundWork } from './background-work.ts';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createAcpBackend } from '../chat/acp-backend.js';
|
|
2
2
|
import { askAcpAccount } from './accounts/acp-account.js';
|
|
3
|
+
import { writeAcpUsage } from './acp-usage-log.js';
|
|
3
4
|
import { detectCli } from './detect.js';
|
|
4
5
|
/*
|
|
5
6
|
* What every ACP agent can do through this backend. A file change comes as the text before and after;
|
|
@@ -22,6 +23,8 @@ export const ACP_CAPABILITIES = {
|
|
|
22
23
|
reportsThinking: true,
|
|
23
24
|
slashCommands: true
|
|
24
25
|
};
|
|
26
|
+
/* Agents whose own usage files the scanner reads; a log of their turns would count every one twice. */
|
|
27
|
+
const OWN_USAGE_FILES = new Set(['grok']);
|
|
25
28
|
/* An agent's model id may reach a command line, so one that reads as a flag is left out. */
|
|
26
29
|
function adoptableModels(info) {
|
|
27
30
|
return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
|
|
@@ -34,6 +37,13 @@ export function createAcpProvider(spec) {
|
|
|
34
37
|
}
|
|
35
38
|
spec.onSession?.(info);
|
|
36
39
|
};
|
|
40
|
+
const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
|
|
41
|
+
const onUsage = usageLog === null
|
|
42
|
+
? spec.onUsage
|
|
43
|
+
: (report) => {
|
|
44
|
+
writeAcpUsage(usageLog, report, Date.now());
|
|
45
|
+
spec.onUsage?.(report);
|
|
46
|
+
};
|
|
37
47
|
return {
|
|
38
48
|
kind: spec.kind,
|
|
39
49
|
name: spec.name,
|
|
@@ -63,6 +73,6 @@ export function createAcpProvider(spec) {
|
|
|
63
73
|
onSession
|
|
64
74
|
})
|
|
65
75
|
}),
|
|
66
|
-
createBackend: createAcpBackend({ ...spec, onSession })
|
|
76
|
+
createBackend: createAcpBackend({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
|
|
67
77
|
};
|
|
68
78
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { errorText } from '../error-text.js';
|
|
4
|
+
import { hostUsageLine } from '../usage/readers/host-log.js';
|
|
5
|
+
/*
|
|
6
|
+
* Appends one turn to the session's own file in the folder, so a scan that hashes what it already read
|
|
7
|
+
* of a file never rereads the log of every other session. Written at once, since it is one line per turn.
|
|
8
|
+
*/
|
|
9
|
+
export function writeAcpUsage(folder, report, at) {
|
|
10
|
+
const { sessionId, cwd, model, ...tokens } = report;
|
|
11
|
+
try {
|
|
12
|
+
mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
13
|
+
appendFileSync(join(folder, `${encodeURIComponent(sessionId === '' ? 'session' : sessionId)}.jsonl`), hostUsageLine({ at, model, session: sessionId, cwd, ...tokens }), {
|
|
14
|
+
mode: 0o600
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
// A turn whose line could not be written is missing from the usage page; the chat itself goes on.
|
|
19
|
+
console.warn('Could not write the usage of an ACP turn:', errorText(e));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -12,5 +12,6 @@ export { type CodexRules, codexRulesText, codexRulesPathIn, defaultCodexHome, in
|
|
|
12
12
|
export { codexThreadOptions, codexServiceTier } from './codex.ts';
|
|
13
13
|
export { type CliDetection, detectCli } from './detect.ts';
|
|
14
14
|
export type { ChatProvider, ProviderHome } from './provider.ts';
|
|
15
|
+
export { isOnlyCommands } from './shell-commands.ts';
|
|
15
16
|
export { ProviderRegistry, type ProviderRegistryOptions } from './registry.ts';
|
|
16
17
|
export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.ts';
|
package/dist/providers/index.js
CHANGED
|
@@ -10,5 +10,6 @@ export { createCodexProvider } from './codex-provider.js';
|
|
|
10
10
|
export { codexRulesText, codexRulesPathIn, defaultCodexHome, installCodexRules } from './codex-rules.js';
|
|
11
11
|
export { codexThreadOptions, codexServiceTier } from './codex.js';
|
|
12
12
|
export { detectCli } from './detect.js';
|
|
13
|
+
export { isOnlyCommands } from './shell-commands.js';
|
|
13
14
|
export { ProviderRegistry } from './registry.js';
|
|
14
15
|
export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isOnlyCommands(command: string, allowed: readonly string[]): boolean;
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
* Whether a shell command line is only simple commands, joined by `&&`, `||`, `;` or newlines, each of
|
|
6
|
+
* which starts with one of `allowed`. Anything the tokenizer cannot prove simple answers false, so a
|
|
7
|
+
* true is safe to act on without a person: a pipe, a redirect, an expansion, a subshell, a background
|
|
8
|
+
* `&`, an unquoted glob or an unbalanced quote all refuse the whole line.
|
|
9
|
+
*/
|
|
10
|
+
export function isOnlyCommands(command, allowed) {
|
|
11
|
+
let word = '';
|
|
12
|
+
let inWord = false;
|
|
13
|
+
let words = [];
|
|
14
|
+
let commands = 0;
|
|
15
|
+
// A separator that still needs a command after it; a trailing `;` or newline needs none.
|
|
16
|
+
let dangling = false;
|
|
17
|
+
const endWord = () => {
|
|
18
|
+
if (inWord) {
|
|
19
|
+
words.push(word);
|
|
20
|
+
}
|
|
21
|
+
word = '';
|
|
22
|
+
inWord = false;
|
|
23
|
+
};
|
|
24
|
+
const endCommand = (separator) => {
|
|
25
|
+
endWord();
|
|
26
|
+
if (words.length === 0) {
|
|
27
|
+
// Blank lines are fine, and so is a newline after `&&` or `||`; an empty command before any other separator is not.
|
|
28
|
+
return separator === '\n';
|
|
29
|
+
}
|
|
30
|
+
if (!allowed.includes(words[0])) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
commands++;
|
|
34
|
+
words = [];
|
|
35
|
+
dangling = separator === '&&' || separator === '||';
|
|
36
|
+
return true;
|
|
37
|
+
};
|
|
38
|
+
for (let i = 0; i < command.length; i++) {
|
|
39
|
+
const char = command[i];
|
|
40
|
+
const next = command[i + 1];
|
|
41
|
+
if (char === ' ' || char === '\t') {
|
|
42
|
+
endWord();
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (char === '\n' || char === ';') {
|
|
46
|
+
if (!endCommand(char)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if ((char === '&' || char === '|') && next === char) {
|
|
52
|
+
if (!endCommand(char + next)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (char === '&' || REFUSED.has(char)) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (char === '\\') {
|
|
62
|
+
if (next === undefined) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
i++;
|
|
66
|
+
if (next !== '\n') {
|
|
67
|
+
word += next;
|
|
68
|
+
inWord = true;
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (char === "'") {
|
|
73
|
+
const close = command.indexOf("'", i + 1);
|
|
74
|
+
if (close === -1) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
word += command.slice(i + 1, close);
|
|
78
|
+
inWord = true;
|
|
79
|
+
i = close;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (char === '"') {
|
|
83
|
+
inWord = true;
|
|
84
|
+
let closed = false;
|
|
85
|
+
for (i++; i < command.length; i++) {
|
|
86
|
+
const quoted = command[i];
|
|
87
|
+
if (quoted === '"') {
|
|
88
|
+
closed = true;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
if (quoted === '$' || quoted === '`') {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
if (quoted === '\\') {
|
|
95
|
+
const escaped = command[i + 1];
|
|
96
|
+
if (escaped === undefined) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
i++;
|
|
100
|
+
if (escaped === '"' || escaped === '\\' || escaped === '$' || escaped === '`') {
|
|
101
|
+
word += escaped;
|
|
102
|
+
}
|
|
103
|
+
else if (escaped !== '\n') {
|
|
104
|
+
word += quoted + escaped;
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
word += quoted;
|
|
109
|
+
}
|
|
110
|
+
if (!closed) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
word += char;
|
|
116
|
+
inWord = true;
|
|
117
|
+
}
|
|
118
|
+
return endCommand('\n') && commands > 0 && !dangling;
|
|
119
|
+
}
|
|
@@ -6,6 +6,7 @@ export interface FakeAcpOptions {
|
|
|
6
6
|
modes?: readonly string[];
|
|
7
7
|
legacyModels?: boolean;
|
|
8
8
|
closeSession?: boolean;
|
|
9
|
+
turnUsage?: Record<string, number>;
|
|
9
10
|
}
|
|
10
11
|
export declare function fakeAcpWith(options: FakeAcpOptions): FakeCli;
|
|
11
12
|
export declare const fakeAcp: FakeCli;
|
package/dist/testing/fake-acp.js
CHANGED
|
@@ -33,7 +33,7 @@ export function fakeAcpWith(options) {
|
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
35
|
update({ sessionUpdate: 'usage_update', used: 1200, size: 200000, cost: { amount: 0.01, currency: 'USD' } });
|
|
36
|
-
respond(prompt.rpcId, { stopReason });
|
|
36
|
+
respond(prompt.rpcId, { stopReason, ...(options.turnUsage === undefined ? {} : { usage: options.turnUsage }) });
|
|
37
37
|
prompt = null;
|
|
38
38
|
};
|
|
39
39
|
const sessionState = () => {
|
package/dist/usage/aggregate.js
CHANGED
|
@@ -47,7 +47,7 @@ function foldBucket(buckets, slot, record, account, price, cost) {
|
|
|
47
47
|
}
|
|
48
48
|
bucket.totals = addTotals(bucket.totals, record.totals);
|
|
49
49
|
bucket.costUsd += cost;
|
|
50
|
-
bucket.priced ||= price !== null;
|
|
50
|
+
bucket.priced ||= price !== null || record.costUsd !== undefined;
|
|
51
51
|
bucket.cacheSavingsUsd += price === null ? 0 : cacheSavingsOf(record.totals, price);
|
|
52
52
|
if (record.sessionId !== '') {
|
|
53
53
|
bucket.sessions.add(record.sessionId);
|
|
@@ -64,14 +64,15 @@ function foldModel(models, record, account, lookup, cost) {
|
|
|
64
64
|
totals: emptyTotals(),
|
|
65
65
|
costUsd: 0,
|
|
66
66
|
priced: false,
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// A cost the CLI reported is the bill itself, not a price looked up for the model.
|
|
68
|
+
basis: record.costUsd === undefined ? lookup.basis : 'exact',
|
|
69
|
+
pricedAs: record.costUsd === undefined ? lookup.pricedAs : null
|
|
69
70
|
};
|
|
70
71
|
models.set(key, model);
|
|
71
72
|
}
|
|
72
73
|
model.totals = addTotals(model.totals, record.totals);
|
|
73
74
|
model.costUsd += cost;
|
|
74
|
-
model.priced ||= lookup.price !== null;
|
|
75
|
+
model.priced ||= lookup.price !== null || record.costUsd !== undefined;
|
|
75
76
|
}
|
|
76
77
|
function foldProject(project, record, cost) {
|
|
77
78
|
project.totals = addTotals(project.totals, record.totals);
|
|
@@ -111,7 +112,7 @@ export async function aggregate(records, payload, prices, known) {
|
|
|
111
112
|
continue;
|
|
112
113
|
}
|
|
113
114
|
const lookup = prices.look(record.model);
|
|
114
|
-
const cost = lookup.price === null ? 0 : costOf(record.totals, lookup.price);
|
|
115
|
+
const cost = record.costUsd ?? (lookup.price === null ? 0 : costOf(record.totals, lookup.price));
|
|
115
116
|
foldBucket(buckets, slot, record, account, lookup.price, cost);
|
|
116
117
|
foldModel(models, record, account, lookup, cost);
|
|
117
118
|
if (record.sessionId !== '') {
|
package/dist/usage/index-file.js
CHANGED
|
@@ -23,21 +23,27 @@ export function encodeIndex(index) {
|
|
|
23
23
|
const folders = new Interner();
|
|
24
24
|
const accounts = new Interner();
|
|
25
25
|
const files = {};
|
|
26
|
-
const row = (record) =>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
26
|
+
const row = (record) => {
|
|
27
|
+
const counted = [
|
|
28
|
+
record.timestampMs,
|
|
29
|
+
models.index(record.model),
|
|
30
|
+
sessions.index(record.sessionId),
|
|
31
|
+
folders.index(record.cwd),
|
|
32
|
+
record.totals.calls,
|
|
33
|
+
record.totals.input,
|
|
34
|
+
record.totals.cacheRead,
|
|
35
|
+
record.totals.cacheWrite,
|
|
36
|
+
record.totals.cacheWrite1h,
|
|
37
|
+
record.totals.output,
|
|
38
|
+
record.totals.reasoning,
|
|
39
|
+
record.dedupeKey ?? 0,
|
|
40
|
+
record.account === undefined ? 0 : accounts.index(record.account) + 1
|
|
41
|
+
];
|
|
42
|
+
if (record.costUsd !== undefined) {
|
|
43
|
+
counted.push(record.costUsd);
|
|
44
|
+
}
|
|
45
|
+
return counted;
|
|
46
|
+
};
|
|
41
47
|
for (const [path, file] of index) {
|
|
42
48
|
files[path] = {
|
|
43
49
|
p: file.provider,
|
|
@@ -62,10 +68,11 @@ export function encodeIndex(index) {
|
|
|
62
68
|
}
|
|
63
69
|
function isRow(row, version) {
|
|
64
70
|
return (Array.isArray(row) &&
|
|
65
|
-
|
|
71
|
+
(version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length === 13 || row.length === 14) &&
|
|
66
72
|
row.slice(0, 11).every((value) => typeof value === 'number') &&
|
|
67
73
|
(typeof row[11] === 'string' || row[11] === 0) &&
|
|
68
|
-
(version === ACCOUNTLESS_VERSION || typeof row[12] === 'number')
|
|
74
|
+
(version === ACCOUNTLESS_VERSION || typeof row[12] === 'number') &&
|
|
75
|
+
(row.length < 14 || typeof row[13] === 'number'));
|
|
69
76
|
}
|
|
70
77
|
/*
|
|
71
78
|
* A row that does not read back costs a cold parse of the file it belonged to, never a wrong total,
|
|
@@ -119,6 +126,7 @@ export function decodeIndex(text) {
|
|
|
119
126
|
cwd,
|
|
120
127
|
...(account === undefined ? {} : { account }),
|
|
121
128
|
totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
|
|
129
|
+
...(row[13] === undefined ? {} : { costUsd: row[13] }),
|
|
122
130
|
dedupeKey: row[11] === 0 ? null : row[11]
|
|
123
131
|
});
|
|
124
132
|
}
|
|
@@ -10,6 +10,8 @@ const MAX_BACKOFF_MS = 60 * 60_000;
|
|
|
10
10
|
/* An account other than the default one that nobody started a CLI under for this long is only read when a person asks. */
|
|
11
11
|
const RECENT_USE_MS = 24 * 60 * 60_000;
|
|
12
12
|
const PROVIDERS = USAGE_PROVIDERS;
|
|
13
|
+
/* The CLIs the probes know how to ask; any other provider has a plan only through the host's `plans`. */
|
|
14
|
+
const PROBED = ['claude', 'codex'];
|
|
13
15
|
function blank(kind) {
|
|
14
16
|
return {
|
|
15
17
|
kind,
|
|
@@ -106,6 +108,9 @@ export class UsageMonitor {
|
|
|
106
108
|
listed() {
|
|
107
109
|
const accounts = this.accounts?.list() ?? PROVIDERS.map((kind) => ({ id: kind, kind, label: kind, isDefault: true }));
|
|
108
110
|
return PROVIDERS.flatMap((kind) => {
|
|
111
|
+
if (!PROBED.includes(kind) && this.plans[kind] === undefined) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
109
114
|
const own = accounts.filter((account) => account.kind === kind);
|
|
110
115
|
return [...own.filter((account) => account.isDefault), ...own.filter((account) => !account.isDefault)];
|
|
111
116
|
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { basename, dirname } from 'node:path';
|
|
2
|
+
import { asObject, asString, int } from '../record.js';
|
|
3
|
+
/* The file Grok keeps the counts of a session in, beside its other files in the session's folder. */
|
|
4
|
+
export const GROK_USAGE_FILE = 'usage.json';
|
|
5
|
+
// `costUsdTicks` counts in ticks of 10^-10 USD, as Grok's own docs on `grok usage` say.
|
|
6
|
+
const TICKS_PER_USD = 1e10;
|
|
7
|
+
/* The folder above a session's is its working directory, URL-encoded into one name. */
|
|
8
|
+
function cwdOf(path) {
|
|
9
|
+
try {
|
|
10
|
+
return decodeURIComponent(basename(dirname(dirname(path))));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function recordOf(counts, model, base, turn) {
|
|
17
|
+
const cacheRead = int(counts.cachedReadTokens);
|
|
18
|
+
const cacheWrite = int(counts.cacheCreationTokens);
|
|
19
|
+
const totals = {
|
|
20
|
+
calls: int(counts.modelCalls),
|
|
21
|
+
// Grok's input holds both cache kinds: its total is input plus output.
|
|
22
|
+
input: Math.max(0, int(counts.inputTokens) - cacheRead - cacheWrite),
|
|
23
|
+
cacheRead,
|
|
24
|
+
cacheWrite,
|
|
25
|
+
cacheWrite1h: 0,
|
|
26
|
+
output: int(counts.outputTokens),
|
|
27
|
+
reasoning: int(counts.reasoningTokens)
|
|
28
|
+
};
|
|
29
|
+
if (totals.input + totals.cacheRead + totals.cacheWrite + totals.output === 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const ticks = int(counts.costUsdTicks);
|
|
33
|
+
return {
|
|
34
|
+
...base,
|
|
35
|
+
model,
|
|
36
|
+
totals,
|
|
37
|
+
// A missing or zero cost is one Grok did not report, which the price table then estimates.
|
|
38
|
+
...(ticks === 0 ? {} : { costUsd: ticks / TICKS_PER_USD }),
|
|
39
|
+
dedupeKey: `grok\0${base.sessionId}\0${turn}\0${model}`
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/*
|
|
43
|
+
* A session's `usage.json` under `$GROK_HOME/sessions/<cwd>/<session id>/`, which Grok rewrites whole
|
|
44
|
+
* at the end of every turn. One record per turn per model the turn called; null when the file does not
|
|
45
|
+
* read as one, which leaves what the scanner held of it.
|
|
46
|
+
*/
|
|
47
|
+
export function parseGrokUsage(text, path) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const file = asObject(parsed);
|
|
56
|
+
if (file === null || !Array.isArray(file.turns)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const sessionId = asString(file.sessionId) || basename(dirname(path));
|
|
60
|
+
const cwd = cwdOf(path);
|
|
61
|
+
const records = [];
|
|
62
|
+
for (const entry of file.turns) {
|
|
63
|
+
const turn = asObject(entry);
|
|
64
|
+
const timestampMs = turn === null ? Number.NaN : Date.parse(asString(turn.endedAt));
|
|
65
|
+
if (turn === null || Number.isNaN(timestampMs)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const number = typeof turn.turnNumber === 'number' ? String(turn.turnNumber) : asString(turn.endedAt);
|
|
69
|
+
const base = { provider: 'grok', timestampMs, sessionId, cwd };
|
|
70
|
+
const byModel = asObject(turn.modelUsage);
|
|
71
|
+
const models = byModel === null
|
|
72
|
+
? [[asString(turn.primaryModelId), turn]]
|
|
73
|
+
: Object.entries(byModel).flatMap(([model, counts]) => {
|
|
74
|
+
const object = asObject(counts);
|
|
75
|
+
return object === null ? [] : [[model, object]];
|
|
76
|
+
});
|
|
77
|
+
for (const [model, counts] of models) {
|
|
78
|
+
const record = model === '' ? null : recordOf(counts, model, base, number);
|
|
79
|
+
if (record !== null) {
|
|
80
|
+
records.push(record);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return records;
|
|
85
|
+
}
|