portable-agent-layer 0.99.2 → 0.100.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/package.json +3 -2
- package/src/hooks/handlers/agenda.ts +4 -5
- package/src/hooks/handlers/entity-extraction.ts +1 -1
- package/src/hooks/handlers/rating.ts +3 -4
- package/src/hooks/handlers/session-intelligence.ts +1 -1
- package/src/hooks/handlers/session-name.ts +1 -1
- package/src/hooks/lib/entity-research.ts +1 -1
- package/src/hooks/lib/events.ts +2 -2
- package/src/hooks/lib/inference-events.ts +7 -3
- package/src/hooks/lib/inference-usage.ts +205 -0
- package/src/hooks/lib/inference.ts +89 -30
- package/src/hooks/lib/token-usage.ts +10 -7
- package/src/tools/self-model.ts +1 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.100.0",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"check-write": "bun .agents/scripts/check-write.ts",
|
|
49
49
|
"knip": "knip-bun",
|
|
50
50
|
"klint": "klint && klint --config test",
|
|
51
|
+
"klint:hook": "klint --json --hook && klint --config test --json --hook",
|
|
51
52
|
"jscpd": "jscpd --noTips",
|
|
52
53
|
"madge": "madge --circular --extensions ts --ts-config tsconfig.json src",
|
|
53
54
|
"lf": "bun .agents/scripts/check-lf.ts",
|
|
@@ -80,7 +81,7 @@
|
|
|
80
81
|
"@commitlint/cli": "21.0.1",
|
|
81
82
|
"@commitlint/config-conventional": "21.0.1",
|
|
82
83
|
"@hughescr/stryker-bun-runner": "1.3.8",
|
|
83
|
-
"@konvert7/klint": "0.35.
|
|
84
|
+
"@konvert7/klint": "0.35.2",
|
|
84
85
|
"@opencode-ai/plugin": "latest",
|
|
85
86
|
"@secretlint/secretlint-rule-preset-recommend": "13.0.2",
|
|
86
87
|
"@semantic-release/changelog": "^6.0.3",
|
|
@@ -17,7 +17,6 @@ import { type AgendaMove, readAgenda, writeAgenda } from "../lib/agenda-store";
|
|
|
17
17
|
import { linkInputs, readGoalLinks, writeGoalLinks } from "../lib/goal-links";
|
|
18
18
|
import { canInfer, inference } from "../lib/inference";
|
|
19
19
|
import { logDebug, logError } from "../lib/log";
|
|
20
|
-
import { SONNET_MODEL } from "../lib/models";
|
|
21
20
|
import { readAllProjects } from "../lib/projects";
|
|
22
21
|
import { isServesKind, SERVES_KINDS, setServes } from "../lib/serves";
|
|
23
22
|
import { readTelosGoals } from "../lib/telos-goals";
|
|
@@ -182,7 +181,7 @@ async function guessMissingServes(sessionId?: string): Promise<number> {
|
|
|
182
181
|
caller: "agenda-serves",
|
|
183
182
|
sessionId,
|
|
184
183
|
});
|
|
185
|
-
|
|
184
|
+
logTokenUsage("agenda-serves", result);
|
|
186
185
|
if (!result.success || !result.output) return 0;
|
|
187
186
|
|
|
188
187
|
const parsed = parsePayload<{
|
|
@@ -256,7 +255,7 @@ async function refreshGoalLinks(
|
|
|
256
255
|
caller: "agenda-goal-links",
|
|
257
256
|
sessionId,
|
|
258
257
|
});
|
|
259
|
-
|
|
258
|
+
logTokenUsage("agenda-goal-links", result);
|
|
260
259
|
if (!result.success || !result.output) return "failed";
|
|
261
260
|
|
|
262
261
|
const parsed = parsePayload<{ links: { goalId: string; projects: unknown }[] }>(
|
|
@@ -310,7 +309,7 @@ async function extractWaitingOn(sessionId?: string): Promise<number> {
|
|
|
310
309
|
caller: "agenda-waiting-on",
|
|
311
310
|
sessionId,
|
|
312
311
|
});
|
|
313
|
-
|
|
312
|
+
logTokenUsage("agenda-waiting-on", result);
|
|
314
313
|
if (!result.success || !result.output) continue;
|
|
315
314
|
|
|
316
315
|
const parsed = parsePayload<{ question: string }>(result.output, "agenda:waiting-on");
|
|
@@ -356,7 +355,7 @@ async function writeMoves(sessionId?: string): Promise<boolean> {
|
|
|
356
355
|
caller: "agenda-moves",
|
|
357
356
|
sessionId,
|
|
358
357
|
});
|
|
359
|
-
|
|
358
|
+
logTokenUsage("agenda-moves", result);
|
|
360
359
|
if (!result.success || !result.output) return false;
|
|
361
360
|
|
|
362
361
|
const parsed = parsePayload<{ moves: (AgendaMove & { project?: unknown })[] }>(
|
|
@@ -56,7 +56,7 @@ export async function extractSessionEntities(
|
|
|
56
56
|
caller: "entity-extraction",
|
|
57
57
|
sessionId,
|
|
58
58
|
});
|
|
59
|
-
|
|
59
|
+
logTokenUsage("entity-extraction", result);
|
|
60
60
|
if (!result.success || !result.output) return;
|
|
61
61
|
|
|
62
62
|
const source = sourceId(sessionId, count);
|
|
@@ -21,7 +21,6 @@ import { spawnDetachedInference } from "../lib/detached-inference";
|
|
|
21
21
|
import { canInfer, inference } from "../lib/inference";
|
|
22
22
|
import { replyEnd } from "../lib/interaction-samples";
|
|
23
23
|
import { logDebug } from "../lib/log";
|
|
24
|
-
import { HAIKU_5_5_MODEL } from "../lib/models";
|
|
25
24
|
import { paths } from "../lib/paths";
|
|
26
25
|
import { isSystemText, stripInjectedTags } from "../lib/prompt-text";
|
|
27
26
|
import {
|
|
@@ -225,13 +224,13 @@ function handleImplicitReaction(message: string, sentAt: Date, sessionId?: strin
|
|
|
225
224
|
|
|
226
225
|
async function labelReaction(reply: string, message: string, sessionId?: string) {
|
|
227
226
|
const result = await inference(reactionRequest(reply, message, sessionId));
|
|
228
|
-
|
|
227
|
+
logTokenUsage("rating", result);
|
|
229
228
|
return result.success ? parseReactionLabel(result.output) : null;
|
|
230
229
|
}
|
|
231
230
|
|
|
232
231
|
async function checkCorrection(reply: string, message: string, sessionId?: string) {
|
|
233
232
|
const result = await inference(correctionCheckRequest(reply, message, sessionId));
|
|
234
|
-
|
|
233
|
+
logTokenUsage("rating", result);
|
|
235
234
|
return result.success ? parseCorrectionCheck(result.output) : null;
|
|
236
235
|
}
|
|
237
236
|
|
|
@@ -250,7 +249,7 @@ async function draftRuleCandidate(sessionId?: string): Promise<void> {
|
|
|
250
249
|
const known = knownRules();
|
|
251
250
|
if (!canRepeat(corrections) && !canWiden(corrections, known)) return;
|
|
252
251
|
const result = await inference(drafterRequest(corrections, known, sessionId));
|
|
253
|
-
|
|
252
|
+
logTokenUsage("rule-drafter", result);
|
|
254
253
|
const candidate = result.success ? parseDraft(result.output, corrections, known) : null;
|
|
255
254
|
if (candidate) recordCandidate(candidate, turns);
|
|
256
255
|
else logDebug("rule-drafter", `no candidate: ${result.output ?? result.error ?? ""}`);
|
|
@@ -162,7 +162,7 @@ export async function captureSessionIntelligence(
|
|
|
162
162
|
sessionId,
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
logTokenUsage("session-intelligence", result);
|
|
166
166
|
|
|
167
167
|
if (result.success && result.output) {
|
|
168
168
|
output = JSON.parse(result.output) as IntelligenceOutput;
|
|
@@ -76,7 +76,7 @@ async function upgradeWithInference(
|
|
|
76
76
|
sessionId,
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
logTokenUsage("session-name", result);
|
|
80
80
|
if (!result.success || !result.output) return;
|
|
81
81
|
|
|
82
82
|
const label = labelFromModel(result.output);
|
|
@@ -180,7 +180,7 @@ export async function researchEntity(
|
|
|
180
180
|
caller: "entity-research",
|
|
181
181
|
web: true,
|
|
182
182
|
});
|
|
183
|
-
|
|
183
|
+
logTokenUsage("entity-research", result);
|
|
184
184
|
const profile = result.success ? parseProfile(result.output) : null;
|
|
185
185
|
if (!profile) return { found: false, reason: result.error ?? "no usable reply" };
|
|
186
186
|
if (profile.match !== "sure")
|
package/src/hooks/lib/events.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
9
|
import { currentAttribution, type RecordAttribution } from "./actor";
|
|
10
10
|
import type { AgentType } from "./agent";
|
|
11
|
+
import { USAGE_FIELDS } from "./inference-usage";
|
|
11
12
|
import { logError } from "./log";
|
|
12
13
|
import { ensureDir, paths } from "./paths";
|
|
13
14
|
import { readAllProjects, resolveProjectFromCwd } from "./projects";
|
|
@@ -62,8 +63,7 @@ export const EVENT_FIELDS = {
|
|
|
62
63
|
"web",
|
|
63
64
|
"latencyMs",
|
|
64
65
|
"failure",
|
|
65
|
-
|
|
66
|
-
"outputTokens",
|
|
66
|
+
...USAGE_FIELDS,
|
|
67
67
|
],
|
|
68
68
|
pal_update: ["outcome", "from", "to", "durationSec", "skipReason"],
|
|
69
69
|
commitment: ["iscProject", "isc", "outcome", "surface", "snoozeDays"],
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { appendEvent, logQuietly } from "./events";
|
|
7
|
+
import { type InferenceUsage, USAGE_FIELDS } from "./inference-usage";
|
|
7
8
|
|
|
8
9
|
interface InferenceCall {
|
|
9
10
|
caller?: string;
|
|
@@ -16,7 +17,11 @@ interface InferenceOutcome {
|
|
|
16
17
|
success: boolean;
|
|
17
18
|
output?: string;
|
|
18
19
|
error?: string;
|
|
19
|
-
usage?:
|
|
20
|
+
usage?: InferenceUsage;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function knownOrNull(usage: InferenceUsage = {}) {
|
|
24
|
+
return Object.fromEntries(USAGE_FIELDS.map((field) => [field, usage[field] ?? null]));
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
export const SCHEMA_MISMATCH = "the reply does not match the schema";
|
|
@@ -47,8 +52,7 @@ export function logInferenceCall(
|
|
|
47
52
|
web: Boolean(call.web),
|
|
48
53
|
latencyMs: Math.round(latencyMs),
|
|
49
54
|
failure: failureOf(outcome, planned.route),
|
|
50
|
-
|
|
51
|
-
outputTokens: outcome.usage?.outputTokens ?? null,
|
|
55
|
+
...knownOrNull(outcome.usage),
|
|
52
56
|
},
|
|
53
57
|
{ session: call.sessionId ?? null }
|
|
54
58
|
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one inference cost, in the token fields model_call uses, read from each
|
|
3
|
+
* route's own usage report. A field the route does not report stays undefined.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { cacheWritesOf, type TranscriptUsage } from "../../tools/lib/transcript-usage";
|
|
7
|
+
|
|
8
|
+
export interface InferenceUsage {
|
|
9
|
+
inputTokens?: number;
|
|
10
|
+
outputTokens?: number;
|
|
11
|
+
cacheWrite5m?: number;
|
|
12
|
+
cacheWrite1h?: number;
|
|
13
|
+
cacheRead?: number;
|
|
14
|
+
thinkingTokens?: number;
|
|
15
|
+
costUsd?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const USAGE_FIELDS = [
|
|
19
|
+
"inputTokens",
|
|
20
|
+
"outputTokens",
|
|
21
|
+
"cacheWrite5m",
|
|
22
|
+
"cacheWrite1h",
|
|
23
|
+
"cacheRead",
|
|
24
|
+
"thinkingTokens",
|
|
25
|
+
"costUsd",
|
|
26
|
+
] as const satisfies readonly (keyof InferenceUsage)[];
|
|
27
|
+
|
|
28
|
+
export function jsonLines(raw: string): Record<string, unknown>[] {
|
|
29
|
+
return raw.split("\n").flatMap((line) => {
|
|
30
|
+
try {
|
|
31
|
+
const value: unknown = JSON.parse(line);
|
|
32
|
+
return value && typeof value === "object" ? [value as Record<string, unknown>] : [];
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sum = (values: (number | undefined)[]) =>
|
|
40
|
+
values.some((v) => v !== undefined)
|
|
41
|
+
? values.reduce<number>((total, v) => total + (v ?? 0), 0)
|
|
42
|
+
: undefined;
|
|
43
|
+
|
|
44
|
+
export function anthropicUsage(usage: TranscriptUsage, costUsd?: number): InferenceUsage {
|
|
45
|
+
return {
|
|
46
|
+
inputTokens: usage.input_tokens,
|
|
47
|
+
outputTokens: usage.output_tokens,
|
|
48
|
+
...cacheWritesOf(usage),
|
|
49
|
+
cacheRead: usage.cache_read_input_tokens,
|
|
50
|
+
thinkingTokens: usage.output_tokens_details?.thinking_tokens,
|
|
51
|
+
costUsd,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface OpenAiUsage {
|
|
56
|
+
prompt_tokens?: number;
|
|
57
|
+
completion_tokens?: number;
|
|
58
|
+
prompt_tokens_details?: { cached_tokens?: number };
|
|
59
|
+
completion_tokens_details?: { reasoning_tokens?: number };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** OpenAI counts cached tokens inside prompt_tokens. */
|
|
63
|
+
export function openAiUsage(usage: OpenAiUsage): InferenceUsage {
|
|
64
|
+
const cacheRead = usage.prompt_tokens_details?.cached_tokens;
|
|
65
|
+
return {
|
|
66
|
+
inputTokens:
|
|
67
|
+
usage.prompt_tokens === undefined
|
|
68
|
+
? undefined
|
|
69
|
+
: usage.prompt_tokens - (cacheRead ?? 0),
|
|
70
|
+
outputTokens: usage.completion_tokens,
|
|
71
|
+
cacheRead,
|
|
72
|
+
thinkingTokens: usage.completion_tokens_details?.reasoning_tokens,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function claudeResult(raw: string): Record<string, unknown> | undefined {
|
|
77
|
+
return jsonLines(raw).find((event) => event.type === "result");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function claudeSpawnUsage(raw: string): InferenceUsage | undefined {
|
|
81
|
+
const result = claudeResult(raw);
|
|
82
|
+
if (!result?.usage) return undefined;
|
|
83
|
+
return anthropicUsage(result.usage as TranscriptUsage, result.total_cost_usd as number);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface CodexUsage {
|
|
87
|
+
input_tokens?: number;
|
|
88
|
+
cached_input_tokens?: number;
|
|
89
|
+
cache_write_input_tokens?: number;
|
|
90
|
+
output_tokens?: number;
|
|
91
|
+
reasoning_output_tokens?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Codex counts cache reads and writes inside input_tokens. */
|
|
95
|
+
export function codexSpawnUsage(raw: string): InferenceUsage | undefined {
|
|
96
|
+
const turns = jsonLines(raw)
|
|
97
|
+
.filter((event) => event.type === "turn.completed" && event.usage)
|
|
98
|
+
.map((event) => event.usage as CodexUsage);
|
|
99
|
+
if (turns.length === 0) return undefined;
|
|
100
|
+
const cacheRead = sum(turns.map((u) => u.cached_input_tokens));
|
|
101
|
+
const cacheWrite = sum(turns.map((u) => u.cache_write_input_tokens));
|
|
102
|
+
const input = sum(turns.map((u) => u.input_tokens));
|
|
103
|
+
return {
|
|
104
|
+
inputTokens:
|
|
105
|
+
input === undefined ? undefined : input - (cacheRead ?? 0) - (cacheWrite ?? 0),
|
|
106
|
+
outputTokens: sum(turns.map((u) => u.output_tokens)),
|
|
107
|
+
thinkingTokens: sum(turns.map((u) => u.reasoning_output_tokens)),
|
|
108
|
+
cacheWrite5m: cacheWrite,
|
|
109
|
+
cacheRead,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface OpencodeTokens {
|
|
114
|
+
input?: number;
|
|
115
|
+
output?: number;
|
|
116
|
+
reasoning?: number;
|
|
117
|
+
cache?: { read?: number; write?: number };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface OpencodeStep {
|
|
121
|
+
tokens?: OpencodeTokens | null;
|
|
122
|
+
cost?: number | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function opencodeSteps(raw: string): (OpencodeTokens & { cost?: number })[] {
|
|
126
|
+
return jsonLines(raw)
|
|
127
|
+
.filter((event) => event.type === "step_finish")
|
|
128
|
+
.flatMap((event) => {
|
|
129
|
+
const { tokens, cost } = (event.part ?? {}) as OpencodeStep;
|
|
130
|
+
return tokens ? [{ ...tokens, cost: cost ?? undefined }] : [];
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** opencode reports reasoning apart from output; model_call counts it in both. */
|
|
135
|
+
export function opencodeSpawnUsage(raw: string): InferenceUsage | undefined {
|
|
136
|
+
const steps = opencodeSteps(raw);
|
|
137
|
+
if (steps.length === 0) return undefined;
|
|
138
|
+
const output = sum(steps.map((s) => s.output));
|
|
139
|
+
const reasoning = sum(steps.map((s) => s.reasoning));
|
|
140
|
+
return {
|
|
141
|
+
inputTokens: sum(steps.map((s) => s.input)),
|
|
142
|
+
outputTokens: sum([output, reasoning]),
|
|
143
|
+
thinkingTokens: reasoning,
|
|
144
|
+
cacheWrite5m: sum(steps.map((s) => s.cache?.write)),
|
|
145
|
+
cacheRead: sum(steps.map((s) => s.cache?.read)),
|
|
146
|
+
costUsd: sum(steps.map((s) => s.cost)),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface AntigravityUsage {
|
|
151
|
+
input_tokens?: number;
|
|
152
|
+
output_tokens?: number;
|
|
153
|
+
thinking_tokens?: number;
|
|
154
|
+
cache_read_tokens?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function antigravitySpawnUsage(raw: string): InferenceUsage | undefined {
|
|
158
|
+
const result = jsonLines(raw).find((event) => event.event === "result")?.result as
|
|
159
|
+
| { usage?: AntigravityUsage }
|
|
160
|
+
| undefined;
|
|
161
|
+
const usage = result?.usage;
|
|
162
|
+
if (!usage) return undefined;
|
|
163
|
+
return {
|
|
164
|
+
inputTokens: usage.input_tokens,
|
|
165
|
+
outputTokens: usage.output_tokens,
|
|
166
|
+
thinkingTokens: usage.thinking_tokens,
|
|
167
|
+
cacheRead: usage.cache_read_tokens,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface CopilotModelCache {
|
|
172
|
+
prompt_tokens?: number;
|
|
173
|
+
cache_read?: number;
|
|
174
|
+
cache_write?: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function copilotModelCaches(event: Record<string, unknown>): CopilotModelCache[] {
|
|
178
|
+
const data = event.data as
|
|
179
|
+
| { promptCacheBreakState?: { models?: Record<string, CopilotModelCache> }[] }
|
|
180
|
+
| undefined;
|
|
181
|
+
return (data?.promptCacheBreakState ?? []).flatMap((state) =>
|
|
182
|
+
Object.values(state.models ?? {})
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Copilot's JSON output reports the prompt side of a call only, in its last
|
|
188
|
+
* usage checkpoint, and counts cache reads and writes inside prompt_tokens.
|
|
189
|
+
*/
|
|
190
|
+
export function copilotSpawnUsage(raw: string): InferenceUsage | undefined {
|
|
191
|
+
const checkpoint = jsonLines(raw).findLast(
|
|
192
|
+
(event) => event.type === "session.usage_checkpoint"
|
|
193
|
+
);
|
|
194
|
+
const models = checkpoint ? copilotModelCaches(checkpoint) : [];
|
|
195
|
+
if (models.length === 0) return undefined;
|
|
196
|
+
const prompt = sum(models.map((m) => m.prompt_tokens));
|
|
197
|
+
const cacheRead = sum(models.map((m) => m.cache_read));
|
|
198
|
+
const cacheWrite = sum(models.map((m) => m.cache_write));
|
|
199
|
+
return {
|
|
200
|
+
inputTokens:
|
|
201
|
+
prompt === undefined ? undefined : prompt - (cacheRead ?? 0) - (cacheWrite ?? 0),
|
|
202
|
+
cacheWrite5m: cacheWrite,
|
|
203
|
+
cacheRead,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 4. otherwise → { success: false }
|
|
12
12
|
*
|
|
13
13
|
* The claude-spawn path mirrors PAI/TOOLS/Inference.ts in spawn args:
|
|
14
|
-
* --print --tools '' --setting-sources '' --output-format
|
|
14
|
+
* --print --tools '' --setting-sources '' --output-format json --system-prompt …
|
|
15
15
|
* These flags prevent the spawned subprocess from loading PAL hooks or making
|
|
16
16
|
* tool calls — the primary recursion defense. PAL's spawn-guard env sentinel
|
|
17
17
|
* is the secondary belt-and-suspenders layer (see lib/spawn-guard.ts).
|
|
@@ -24,6 +24,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
24
24
|
import { tmpdir } from "node:os";
|
|
25
25
|
import { basename, join } from "node:path";
|
|
26
26
|
import { z } from "zod";
|
|
27
|
+
import type { TranscriptUsage } from "../../tools/lib/transcript-usage";
|
|
27
28
|
import {
|
|
28
29
|
getActiveAgent,
|
|
29
30
|
isAntigravity,
|
|
@@ -41,6 +42,19 @@ import {
|
|
|
41
42
|
writeInferenceAgent,
|
|
42
43
|
} from "./antigravity-inference";
|
|
43
44
|
import { logInferenceCall, SCHEMA_MISMATCH } from "./inference-events";
|
|
45
|
+
import {
|
|
46
|
+
anthropicUsage,
|
|
47
|
+
antigravitySpawnUsage,
|
|
48
|
+
claudeResult,
|
|
49
|
+
claudeSpawnUsage,
|
|
50
|
+
codexSpawnUsage,
|
|
51
|
+
copilotSpawnUsage,
|
|
52
|
+
type InferenceUsage,
|
|
53
|
+
jsonLines,
|
|
54
|
+
type OpenAiUsage,
|
|
55
|
+
openAiUsage,
|
|
56
|
+
opencodeSpawnUsage,
|
|
57
|
+
} from "./inference-usage";
|
|
44
58
|
import { logDebug, logError } from "./log";
|
|
45
59
|
import {
|
|
46
60
|
type FixedModelRoute,
|
|
@@ -175,7 +189,9 @@ interface InferenceResult {
|
|
|
175
189
|
output?: string;
|
|
176
190
|
/** First line of a failed CLI's error output. */
|
|
177
191
|
error?: string;
|
|
178
|
-
usage?:
|
|
192
|
+
usage?: InferenceUsage;
|
|
193
|
+
/** The model the route ran on, when PAL picked it. */
|
|
194
|
+
model?: string;
|
|
179
195
|
}
|
|
180
196
|
|
|
181
197
|
function schemaProblem(
|
|
@@ -200,8 +216,9 @@ function plannedRoute(opts: InferenceOptions): { route: string; model: string |
|
|
|
200
216
|
export async function inference(opts: InferenceOptions): Promise<InferenceResult> {
|
|
201
217
|
const started = performance.now();
|
|
202
218
|
const result = await checkedInference(opts);
|
|
203
|
-
|
|
204
|
-
|
|
219
|
+
const planned = plannedRoute(opts);
|
|
220
|
+
logInferenceCall(opts, planned, result, performance.now() - started);
|
|
221
|
+
return { ...result, model: planned.model ?? undefined };
|
|
205
222
|
}
|
|
206
223
|
|
|
207
224
|
async function checkedInference(opts: InferenceOptions): Promise<InferenceResult> {
|
|
@@ -253,7 +270,13 @@ async function routeInference(opts: InferenceOptions): Promise<InferenceResult>
|
|
|
253
270
|
const bin = getCodexBinary();
|
|
254
271
|
if (bin) {
|
|
255
272
|
logDebug("inference", `${tag} route=codex-spawn agent=${agent}`);
|
|
256
|
-
return inferenceViaCliSpawn(
|
|
273
|
+
return inferenceViaCliSpawn(
|
|
274
|
+
bin,
|
|
275
|
+
buildCodexArgs(opts),
|
|
276
|
+
buildCliPrompt(opts),
|
|
277
|
+
opts,
|
|
278
|
+
CODEX_REPLY
|
|
279
|
+
);
|
|
257
280
|
}
|
|
258
281
|
}
|
|
259
282
|
if (isCodex() && hasOpenAiKey()) {
|
|
@@ -275,7 +298,8 @@ async function routeInference(opts: InferenceOptions): Promise<InferenceResult>
|
|
|
275
298
|
bin,
|
|
276
299
|
buildCopilotArgs(opts),
|
|
277
300
|
buildCliPrompt(opts),
|
|
278
|
-
opts
|
|
301
|
+
opts,
|
|
302
|
+
COPILOT_REPLY
|
|
279
303
|
);
|
|
280
304
|
}
|
|
281
305
|
}
|
|
@@ -390,7 +414,7 @@ export function buildClaudeArgs(
|
|
|
390
414
|
modelFor("claude-spawn", opts),
|
|
391
415
|
...claudeTools(opts),
|
|
392
416
|
"--output-format",
|
|
393
|
-
"
|
|
417
|
+
"json",
|
|
394
418
|
"--setting-sources",
|
|
395
419
|
"",
|
|
396
420
|
];
|
|
@@ -398,6 +422,14 @@ export function buildClaudeArgs(
|
|
|
398
422
|
return args;
|
|
399
423
|
}
|
|
400
424
|
|
|
425
|
+
const CLAUDE_REPLY: CliReply = {
|
|
426
|
+
text: (raw) => {
|
|
427
|
+
const result = claudeResult(raw);
|
|
428
|
+
return result?.is_error ? "" : String(result?.result ?? "").trim();
|
|
429
|
+
},
|
|
430
|
+
usage: claudeSpawnUsage,
|
|
431
|
+
};
|
|
432
|
+
|
|
401
433
|
type ClaudeAuthMode = "token" | "native";
|
|
402
434
|
|
|
403
435
|
function claudeAuthMode(): ClaudeAuthMode {
|
|
@@ -434,7 +466,7 @@ async function inferenceViaClaudeSpawn(
|
|
|
434
466
|
): Promise<InferenceResult> {
|
|
435
467
|
const mode = claudeAuthMode();
|
|
436
468
|
const spawnClaude = (args: string[]) =>
|
|
437
|
-
inferenceViaCliSpawn(bin, args, opts.user, opts,
|
|
469
|
+
inferenceViaCliSpawn(bin, args, opts.user, opts, CLAUDE_REPLY, {
|
|
438
470
|
env: credentialOverridesFor(mode),
|
|
439
471
|
logTag: `auth=${mode}`,
|
|
440
472
|
});
|
|
@@ -461,6 +493,7 @@ async function inferenceViaClaudeSpawn(
|
|
|
461
493
|
* --ignore-rules → no execpolicy .rules files load
|
|
462
494
|
* --sandbox read-only → child cannot execute shell commands even if it tries
|
|
463
495
|
* --ephemeral → no session persistence; one-shot only
|
|
496
|
+
* --json → NDJSON events: the reply and the turn's token usage
|
|
464
497
|
*
|
|
465
498
|
* Codex has no --system-prompt equivalent, so system + user + JSON-schema become
|
|
466
499
|
* one prompt. That prompt goes in on stdin, not as a positional argument, because
|
|
@@ -484,10 +517,26 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
|
|
|
484
517
|
"--sandbox",
|
|
485
518
|
"read-only",
|
|
486
519
|
"--ephemeral",
|
|
520
|
+
"--json",
|
|
487
521
|
...(opts.web ? ["-c", "web_search=live"] : []),
|
|
488
522
|
];
|
|
489
523
|
}
|
|
490
524
|
|
|
525
|
+
interface CodexItem {
|
|
526
|
+
type?: string;
|
|
527
|
+
text?: string;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const CODEX_REPLY: CliReply = {
|
|
531
|
+
text: (raw) =>
|
|
532
|
+
jsonLines(raw)
|
|
533
|
+
.filter((event) => event.type === "item.completed")
|
|
534
|
+
.map((event) => event.item as CodexItem)
|
|
535
|
+
.findLast((item) => item?.type === "agent_message")
|
|
536
|
+
?.text?.trim() ?? "",
|
|
537
|
+
usage: codexSpawnUsage,
|
|
538
|
+
};
|
|
539
|
+
|
|
491
540
|
/**
|
|
492
541
|
* Build the argv for `opencode run …` from inference options. Pure.
|
|
493
542
|
*
|
|
@@ -529,7 +578,7 @@ async function inferenceViaOpencodeSpawn(
|
|
|
529
578
|
buildOpencodeArgs(opts),
|
|
530
579
|
buildCliPrompt(opts),
|
|
531
580
|
opts,
|
|
532
|
-
extractOpencodeText,
|
|
581
|
+
{ text: extractOpencodeText, usage: opencodeSpawnUsage },
|
|
533
582
|
{ cwd: dir, env: { XDG_CONFIG_HOME: dir, ...OPENCODE_WITHOUT_EXTERNAL_CONTEXT } }
|
|
534
583
|
);
|
|
535
584
|
} finally {
|
|
@@ -600,7 +649,7 @@ async function inferenceViaAntigravitySpawn(
|
|
|
600
649
|
buildAntigravityArgs(opts),
|
|
601
650
|
streamJsonPrompt(buildCliPrompt(opts)),
|
|
602
651
|
opts,
|
|
603
|
-
extractAntigravityText,
|
|
652
|
+
{ text: extractAntigravityText, usage: antigravitySpawnUsage },
|
|
604
653
|
{ cwd: dir, afterRun: removeSpawnedConversation }
|
|
605
654
|
);
|
|
606
655
|
} finally {
|
|
@@ -622,6 +671,7 @@ async function inferenceViaAntigravitySpawn(
|
|
|
622
671
|
* naming an unknown tool hides every tool
|
|
623
672
|
* --deny-tool=… → backstop: deny rules win over any allow rule
|
|
624
673
|
* --silent → keeps the tool-filter notices out of stdout
|
|
674
|
+
* --output-format json → JSONL events: the reply and the prompt's token usage
|
|
625
675
|
* web: only web_fetch, pre-approved for every URL; Copilot has no search tool,
|
|
626
676
|
* so it fetches search pages itself
|
|
627
677
|
*
|
|
@@ -638,12 +688,27 @@ export function buildCopilotArgs(opts: InferenceOptions): string[] {
|
|
|
638
688
|
"--no-auto-update",
|
|
639
689
|
"--no-color",
|
|
640
690
|
"--silent",
|
|
691
|
+
"--output-format",
|
|
692
|
+
"json",
|
|
641
693
|
...copilotTools(opts),
|
|
642
694
|
"--deny-tool=shell",
|
|
643
695
|
"--deny-tool=write",
|
|
644
696
|
];
|
|
645
697
|
}
|
|
646
698
|
|
|
699
|
+
interface CopilotMessage {
|
|
700
|
+
content?: string;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const COPILOT_REPLY: CliReply = {
|
|
704
|
+
text: (raw) =>
|
|
705
|
+
jsonLines(raw)
|
|
706
|
+
.filter((event) => event.type === "assistant.message")
|
|
707
|
+
.map((event) => (event.data as CopilotMessage | undefined)?.content?.trim())
|
|
708
|
+
.findLast(Boolean) ?? "",
|
|
709
|
+
usage: copilotSpawnUsage,
|
|
710
|
+
};
|
|
711
|
+
|
|
647
712
|
function copilotTools(opts: InferenceOptions): string[] {
|
|
648
713
|
return opts.web
|
|
649
714
|
? ["--available-tools=web_fetch", "--allow-tool=web_fetch", "--allow-all-urls"]
|
|
@@ -831,6 +896,14 @@ async function singleCliAttempt(
|
|
|
831
896
|
});
|
|
832
897
|
}
|
|
833
898
|
|
|
899
|
+
/** How to read a CLI's stdout: the reply's text, and what the call cost. */
|
|
900
|
+
interface CliReply {
|
|
901
|
+
text: (rawStdout: string) => string;
|
|
902
|
+
usage: (rawStdout: string) => InferenceUsage | undefined;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const PLAIN_REPLY: CliReply = { text: (raw) => raw, usage: () => undefined };
|
|
906
|
+
|
|
834
907
|
interface SpawnPlace {
|
|
835
908
|
cwd?: string;
|
|
836
909
|
env?: Record<string, string | undefined>;
|
|
@@ -849,7 +922,7 @@ async function inferenceViaCliSpawn(
|
|
|
849
922
|
args: string[],
|
|
850
923
|
stdinInput: string,
|
|
851
924
|
opts: InferenceOptions,
|
|
852
|
-
|
|
925
|
+
reply: CliReply = PLAIN_REPLY,
|
|
853
926
|
place: SpawnPlace = {}
|
|
854
927
|
): Promise<InferenceResult> {
|
|
855
928
|
const timeout = opts.timeout ?? 15000;
|
|
@@ -887,12 +960,13 @@ async function inferenceViaCliSpawn(
|
|
|
887
960
|
|
|
888
961
|
place.afterRun?.(attempt.stdout);
|
|
889
962
|
const elapsedMs = Date.now() - started;
|
|
963
|
+
const usage = reply.usage(attempt.stdout);
|
|
890
964
|
const finish = (result: InferenceResult): InferenceResult => {
|
|
891
965
|
logDebug(
|
|
892
966
|
"inference:spawn",
|
|
893
967
|
`${tag} done binary=${binaryName} success=${result.success} bytes=${result.output?.length ?? 0} elapsedMs=${elapsedMs}`
|
|
894
968
|
);
|
|
895
|
-
return result;
|
|
969
|
+
return { ...result, usage };
|
|
896
970
|
};
|
|
897
971
|
|
|
898
972
|
if (attempt.timedOut) {
|
|
@@ -908,7 +982,7 @@ async function inferenceViaCliSpawn(
|
|
|
908
982
|
}
|
|
909
983
|
const rawText = attempt.stdout.trim();
|
|
910
984
|
if (!rawText) return finish({ success: false });
|
|
911
|
-
const text =
|
|
985
|
+
const text = reply.text(rawText);
|
|
912
986
|
if (!text) {
|
|
913
987
|
// Extraction returned empty — the binary succeeded but our extractor found
|
|
914
988
|
// no usable text. Log the raw stdout so we can see what was actually emitted.
|
|
@@ -973,13 +1047,7 @@ async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult>
|
|
|
973
1047
|
}
|
|
974
1048
|
|
|
975
1049
|
const data = (await response.json()) as Record<string, unknown>;
|
|
976
|
-
const
|
|
977
|
-
| { input_tokens?: number; output_tokens?: number }
|
|
978
|
-
| undefined;
|
|
979
|
-
const usage =
|
|
980
|
-
rawUsage?.input_tokens != null && rawUsage?.output_tokens != null
|
|
981
|
-
? { inputTokens: rawUsage.input_tokens, outputTokens: rawUsage.output_tokens }
|
|
982
|
-
: undefined;
|
|
1050
|
+
const usage = data?.usage ? anthropicUsage(data.usage as TranscriptUsage) : undefined;
|
|
983
1051
|
|
|
984
1052
|
const content = data?.content as Array<{ text?: string }> | undefined;
|
|
985
1053
|
const text = content?.[0]?.text?.trim();
|
|
@@ -1044,16 +1112,7 @@ async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceR
|
|
|
1044
1112
|
}
|
|
1045
1113
|
|
|
1046
1114
|
const data = (await response.json()) as Record<string, unknown>;
|
|
1047
|
-
const
|
|
1048
|
-
| { prompt_tokens?: number; completion_tokens?: number }
|
|
1049
|
-
| undefined;
|
|
1050
|
-
const usage =
|
|
1051
|
-
rawUsage?.prompt_tokens != null && rawUsage?.completion_tokens != null
|
|
1052
|
-
? {
|
|
1053
|
-
inputTokens: rawUsage.prompt_tokens,
|
|
1054
|
-
outputTokens: rawUsage.completion_tokens,
|
|
1055
|
-
}
|
|
1056
|
-
: undefined;
|
|
1115
|
+
const usage = data?.usage ? openAiUsage(data.usage as OpenAiUsage) : undefined;
|
|
1057
1116
|
|
|
1058
1117
|
const choices = data?.choices as
|
|
1059
1118
|
| Array<{ message?: { content?: string } }>
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Append-only JSONL logger for
|
|
2
|
+
* Append-only JSONL logger for inference token usage.
|
|
3
3
|
* Writes to memory/signals/token-usage.jsonl
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { appendFileSync } from "node:fs";
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
|
-
import {
|
|
8
|
+
import type { InferenceUsage } from "./inference-usage";
|
|
9
9
|
import { ensureDir, paths } from "./paths";
|
|
10
10
|
|
|
11
11
|
type TokenCaller =
|
|
@@ -23,6 +23,8 @@ type TokenCaller =
|
|
|
23
23
|
| "agenda-waiting-on"
|
|
24
24
|
| "agenda-moves";
|
|
25
25
|
|
|
26
|
+
const UNKNOWN_MODEL = "unknown";
|
|
27
|
+
|
|
26
28
|
interface TokenUsageEntry {
|
|
27
29
|
ts: string;
|
|
28
30
|
caller: TokenCaller;
|
|
@@ -33,15 +35,16 @@ interface TokenUsageEntry {
|
|
|
33
35
|
|
|
34
36
|
export function logTokenUsage(
|
|
35
37
|
caller: TokenCaller,
|
|
36
|
-
|
|
37
|
-
model?: string
|
|
38
|
+
call: { usage?: InferenceUsage; model?: string }
|
|
38
39
|
): void {
|
|
40
|
+
const { inputTokens, outputTokens } = call.usage ?? {};
|
|
41
|
+
if (inputTokens === undefined || outputTokens === undefined) return;
|
|
39
42
|
const entry: TokenUsageEntry = {
|
|
40
43
|
ts: new Date().toISOString(),
|
|
41
44
|
caller,
|
|
42
|
-
model: model ??
|
|
43
|
-
inputTokens
|
|
44
|
-
outputTokens
|
|
45
|
+
model: call.model ?? UNKNOWN_MODEL,
|
|
46
|
+
inputTokens,
|
|
47
|
+
outputTokens,
|
|
45
48
|
};
|
|
46
49
|
|
|
47
50
|
const dir = ensureDir(paths.signals());
|
package/src/tools/self-model.ts
CHANGED
|
@@ -19,7 +19,6 @@ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
19
19
|
import { resolve } from "node:path";
|
|
20
20
|
import { parseArgs } from "node:util";
|
|
21
21
|
import { inference } from "../hooks/lib/inference";
|
|
22
|
-
import { SONNET_MODEL } from "../hooks/lib/models";
|
|
23
22
|
import { ensureDir, paths } from "../hooks/lib/paths";
|
|
24
23
|
import { identity as loadSettingsIdentity } from "../hooks/lib/settings";
|
|
25
24
|
import { logTokenUsage } from "../hooks/lib/token-usage";
|
|
@@ -90,7 +89,7 @@ async function composeSelfModel(days: number): Promise<string> {
|
|
|
90
89
|
caller: "self-model",
|
|
91
90
|
});
|
|
92
91
|
|
|
93
|
-
|
|
92
|
+
logTokenUsage("self-model", result);
|
|
94
93
|
if (result.success && result.output) {
|
|
95
94
|
return result.output.trimEnd() + metaFooter(data);
|
|
96
95
|
}
|