portable-agent-layer 0.103.1 → 0.104.1
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/assets/templates/PAL/MEMORY_SYSTEM.md +1 -2
- package/package.json +1 -1
- package/src/hooks/handlers/agenda.ts +0 -5
- package/src/hooks/handlers/entity-extraction.ts +0 -2
- package/src/hooks/handlers/rating.ts +0 -4
- package/src/hooks/handlers/session-intelligence.ts +0 -3
- package/src/hooks/handlers/session-name.ts +0 -2
- package/src/hooks/handlers/update-counts.ts +0 -70
- package/src/hooks/lib/entity-research.ts +0 -2
- package/src/hooks/lib/inference-usage.ts +53 -3
- package/src/hooks/lib/inference.ts +30 -4
- package/src/hooks/lib/security.ts +0 -1
- package/src/hooks/lib/transcript-preserve.ts +3 -1
- package/src/tools/lib/agent-usage.ts +4 -3
- package/src/tools/lib/usage-buckets.ts +87 -57
- package/src/tools/self-model.ts +0 -2
- package/src/tools/token-cost.ts +10 -3
- package/src/hooks/lib/token-usage.ts +0 -53
|
@@ -15,8 +15,7 @@ memory/
|
|
|
15
15
|
│ ├── pending-failure.json # Deferred failure capture
|
|
16
16
|
│ ├── captured-learnings.json # Session-intelligence dedup map
|
|
17
17
|
│ ├── session-names.json # Generated session titles
|
|
18
|
-
│
|
|
19
|
-
│ └── token-usage.jsonl # Per-call inference token usage
|
|
18
|
+
│ └── debug.log[.1-.5] # Rotated hook execution logs
|
|
20
19
|
│
|
|
21
20
|
├── signals/
|
|
22
21
|
│ ├── ratings.jsonl # Append-only rating signals (explicit + implicit)
|
package/package.json
CHANGED
|
@@ -20,7 +20,6 @@ import { logDebug, logError } from "../lib/log";
|
|
|
20
20
|
import { readAllProjects } from "../lib/projects";
|
|
21
21
|
import { isServesKind, SERVES_KINDS, setServes } from "../lib/serves";
|
|
22
22
|
import { readTelosGoals } from "../lib/telos-goals";
|
|
23
|
-
import { logTokenUsage } from "../lib/token-usage";
|
|
24
23
|
|
|
25
24
|
const FRESH_HOURS = 6;
|
|
26
25
|
const MAX_PROJECTS_PER_GUESS = 40;
|
|
@@ -181,7 +180,6 @@ async function guessMissingServes(sessionId?: string): Promise<number> {
|
|
|
181
180
|
caller: "agenda-serves",
|
|
182
181
|
sessionId,
|
|
183
182
|
});
|
|
184
|
-
logTokenUsage("agenda-serves", result);
|
|
185
183
|
if (!result.success || !result.output) return 0;
|
|
186
184
|
|
|
187
185
|
const parsed = parsePayload<{
|
|
@@ -255,7 +253,6 @@ async function refreshGoalLinks(
|
|
|
255
253
|
caller: "agenda-goal-links",
|
|
256
254
|
sessionId,
|
|
257
255
|
});
|
|
258
|
-
logTokenUsage("agenda-goal-links", result);
|
|
259
256
|
if (!result.success || !result.output) return "failed";
|
|
260
257
|
|
|
261
258
|
const parsed = parsePayload<{ links: { goalId: string; projects: unknown }[] }>(
|
|
@@ -309,7 +306,6 @@ async function extractWaitingOn(sessionId?: string): Promise<number> {
|
|
|
309
306
|
caller: "agenda-waiting-on",
|
|
310
307
|
sessionId,
|
|
311
308
|
});
|
|
312
|
-
logTokenUsage("agenda-waiting-on", result);
|
|
313
309
|
if (!result.success || !result.output) continue;
|
|
314
310
|
|
|
315
311
|
const parsed = parsePayload<{ question: string }>(result.output, "agenda:waiting-on");
|
|
@@ -355,7 +351,6 @@ async function writeMoves(sessionId?: string): Promise<boolean> {
|
|
|
355
351
|
caller: "agenda-moves",
|
|
356
352
|
sessionId,
|
|
357
353
|
});
|
|
358
|
-
logTokenUsage("agenda-moves", result);
|
|
359
354
|
if (!result.success || !result.output) return false;
|
|
360
355
|
|
|
361
356
|
const parsed = parsePayload<{ moves: (AgendaMove & { project?: unknown })[] }>(
|
|
@@ -23,7 +23,6 @@ import { newResearchTargets, researchNewEntities } from "../lib/entity-research"
|
|
|
23
23
|
import { canInfer, inference } from "../lib/inference";
|
|
24
24
|
import { logDebug, logError } from "../lib/log";
|
|
25
25
|
import { identity, isEnabled, isOptedIn } from "../lib/settings";
|
|
26
|
-
import { logTokenUsage } from "../lib/token-usage";
|
|
27
26
|
import { parseMessages } from "../lib/transcript";
|
|
28
27
|
|
|
29
28
|
function sourceId(sessionId: string, count: number): string {
|
|
@@ -56,7 +55,6 @@ export async function extractSessionEntities(
|
|
|
56
55
|
caller: "entity-extraction",
|
|
57
56
|
sessionId,
|
|
58
57
|
});
|
|
59
|
-
logTokenUsage("entity-extraction", result);
|
|
60
58
|
if (!result.success || !result.output) return;
|
|
61
59
|
|
|
62
60
|
const source = sourceId(sessionId, count);
|
|
@@ -43,7 +43,6 @@ import {
|
|
|
43
43
|
} from "../lib/rule-drafter";
|
|
44
44
|
import { emitRating } from "../lib/signals";
|
|
45
45
|
import { now } from "../lib/time";
|
|
46
|
-
import { logTokenUsage } from "../lib/token-usage";
|
|
47
46
|
|
|
48
47
|
/** Read cached last assistant response (written by StopOrchestrator), looked up by session */
|
|
49
48
|
function getLastResponse(sessionId?: string): string {
|
|
@@ -224,13 +223,11 @@ function handleImplicitReaction(message: string, sentAt: Date, sessionId?: strin
|
|
|
224
223
|
|
|
225
224
|
async function labelReaction(reply: string, message: string, sessionId?: string) {
|
|
226
225
|
const result = await inference(reactionRequest(reply, message, sessionId));
|
|
227
|
-
logTokenUsage("rating", result);
|
|
228
226
|
return result.success ? parseReactionLabel(result.output) : null;
|
|
229
227
|
}
|
|
230
228
|
|
|
231
229
|
async function checkCorrection(reply: string, message: string, sessionId?: string) {
|
|
232
230
|
const result = await inference(correctionCheckRequest(reply, message, sessionId));
|
|
233
|
-
logTokenUsage("rating", result);
|
|
234
231
|
return result.success ? parseCorrectionCheck(result.output) : null;
|
|
235
232
|
}
|
|
236
233
|
|
|
@@ -249,7 +246,6 @@ async function draftRuleCandidate(sessionId?: string): Promise<void> {
|
|
|
249
246
|
const known = knownRules();
|
|
250
247
|
if (!canRepeat(corrections) && !canWiden(corrections, known)) return;
|
|
251
248
|
const result = await inference(drafterRequest(corrections, known, sessionId));
|
|
252
|
-
logTokenUsage("rule-drafter", result);
|
|
253
249
|
const candidate = result.success ? parseDraft(result.output, corrections, known) : null;
|
|
254
250
|
if (candidate) recordCandidate(candidate, turns);
|
|
255
251
|
else logDebug("rule-drafter", `no candidate: ${result.output ?? result.error ?? ""}`);
|
|
@@ -27,7 +27,6 @@ import { logDebug, logError } from "../lib/log";
|
|
|
27
27
|
import { ensureDir, paths } from "../lib/paths";
|
|
28
28
|
import { sessionDir } from "../lib/session-dir";
|
|
29
29
|
import { fileTimestamp, monthPath } from "../lib/time";
|
|
30
|
-
import { logTokenUsage } from "../lib/token-usage";
|
|
31
30
|
import {
|
|
32
31
|
extractContent,
|
|
33
32
|
extractLastAssistant,
|
|
@@ -162,8 +161,6 @@ export async function captureSessionIntelligence(
|
|
|
162
161
|
sessionId,
|
|
163
162
|
});
|
|
164
163
|
|
|
165
|
-
logTokenUsage("session-intelligence", result);
|
|
166
|
-
|
|
167
164
|
if (result.success && result.output) {
|
|
168
165
|
output = JSON.parse(result.output) as IntelligenceOutput;
|
|
169
166
|
}
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
readSessionNames,
|
|
19
19
|
writeSessionName,
|
|
20
20
|
} from "../lib/session-names";
|
|
21
|
-
import { logTokenUsage } from "../lib/token-usage";
|
|
22
21
|
|
|
23
22
|
const NAME_PROMPT =
|
|
24
23
|
"You generate concise 4-word session titles for AI assistant sessions. " +
|
|
@@ -76,7 +75,6 @@ async function upgradeWithInference(
|
|
|
76
75
|
sessionId,
|
|
77
76
|
});
|
|
78
77
|
|
|
79
|
-
logTokenUsage("session-name", result);
|
|
80
78
|
if (!result.success || !result.output) return;
|
|
81
79
|
|
|
82
80
|
const label = labelFromModel(result.output);
|
|
@@ -9,24 +9,11 @@ import { writeFile } from "node:fs/promises";
|
|
|
9
9
|
import { resolve } from "node:path";
|
|
10
10
|
import { assets, ensureDir, paths } from "../lib/paths";
|
|
11
11
|
|
|
12
|
-
interface TokenSummary {
|
|
13
|
-
input: number;
|
|
14
|
-
output: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface TokenUsage {
|
|
18
|
-
today: TokenSummary;
|
|
19
|
-
week: TokenSummary;
|
|
20
|
-
month: TokenSummary;
|
|
21
|
-
total: TokenSummary;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
12
|
interface Counts {
|
|
25
13
|
signals: number;
|
|
26
14
|
telos: number;
|
|
27
15
|
skills: number;
|
|
28
16
|
sessions: number;
|
|
29
|
-
tokens: TokenUsage;
|
|
30
17
|
updatedAt: string;
|
|
31
18
|
}
|
|
32
19
|
|
|
@@ -49,60 +36,6 @@ function countMdFiles(dir: string): number {
|
|
|
49
36
|
}
|
|
50
37
|
}
|
|
51
38
|
|
|
52
|
-
function getTokenUsage(): TokenUsage {
|
|
53
|
-
const today: TokenSummary = { input: 0, output: 0 };
|
|
54
|
-
const week: TokenSummary = { input: 0, output: 0 };
|
|
55
|
-
const month: TokenSummary = { input: 0, output: 0 };
|
|
56
|
-
const total: TokenSummary = { input: 0, output: 0 };
|
|
57
|
-
|
|
58
|
-
const filepath = resolve(paths.signals(), "token-usage.jsonl");
|
|
59
|
-
if (!existsSync(filepath)) return { today, week, month, total };
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
const content = readFileSync(filepath, "utf-8").trim();
|
|
63
|
-
if (!content) return { today, week, month, total };
|
|
64
|
-
|
|
65
|
-
const now = new Date();
|
|
66
|
-
const todayPrefix = now.toISOString().slice(0, 10);
|
|
67
|
-
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
68
|
-
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
69
|
-
|
|
70
|
-
for (const line of content.split("\n")) {
|
|
71
|
-
try {
|
|
72
|
-
const entry = JSON.parse(line) as {
|
|
73
|
-
ts?: string;
|
|
74
|
-
inputTokens?: number;
|
|
75
|
-
outputTokens?: number;
|
|
76
|
-
};
|
|
77
|
-
const input = entry.inputTokens ?? 0;
|
|
78
|
-
const output = entry.outputTokens ?? 0;
|
|
79
|
-
const ts = entry.ts ?? "";
|
|
80
|
-
|
|
81
|
-
total.input += input;
|
|
82
|
-
total.output += output;
|
|
83
|
-
if (ts >= monthAgo) {
|
|
84
|
-
month.input += input;
|
|
85
|
-
month.output += output;
|
|
86
|
-
}
|
|
87
|
-
if (ts >= weekAgo) {
|
|
88
|
-
week.input += input;
|
|
89
|
-
week.output += output;
|
|
90
|
-
}
|
|
91
|
-
if (ts.startsWith(todayPrefix)) {
|
|
92
|
-
today.input += input;
|
|
93
|
-
today.output += output;
|
|
94
|
-
}
|
|
95
|
-
} catch {
|
|
96
|
-
/* skip malformed line */
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
} catch {
|
|
100
|
-
/* file read error */
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return { today, week, month, total };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
39
|
function getCounts(): Counts {
|
|
107
40
|
const signalsDir = paths.signals();
|
|
108
41
|
const signals = countJsonlLines(resolve(signalsDir, "ratings.jsonl"));
|
|
@@ -133,14 +66,11 @@ function getCounts(): Counts {
|
|
|
133
66
|
/* skip */
|
|
134
67
|
}
|
|
135
68
|
|
|
136
|
-
const tokens = getTokenUsage();
|
|
137
|
-
|
|
138
69
|
return {
|
|
139
70
|
signals,
|
|
140
71
|
telos,
|
|
141
72
|
skills,
|
|
142
73
|
sessions,
|
|
143
|
-
tokens,
|
|
144
74
|
updatedAt: new Date().toISOString(),
|
|
145
75
|
};
|
|
146
76
|
}
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
} from "./entity-extraction";
|
|
15
15
|
import type { inference } from "./inference";
|
|
16
16
|
import { logDebug, logError } from "./log";
|
|
17
|
-
import { logTokenUsage } from "./token-usage";
|
|
18
17
|
|
|
19
18
|
export interface ResearchTarget {
|
|
20
19
|
kind: ExtractedEntity["kind"];
|
|
@@ -180,7 +179,6 @@ export async function researchEntity(
|
|
|
180
179
|
caller: "entity-research",
|
|
181
180
|
web: true,
|
|
182
181
|
});
|
|
183
|
-
logTokenUsage("entity-research", result);
|
|
184
182
|
const profile = result.success ? parseProfile(result.output) : null;
|
|
185
183
|
if (!profile) return { found: false, reason: result.error ?? "no usable reply" };
|
|
186
184
|
if (profile.match !== "sure")
|
|
@@ -193,13 +193,63 @@ export function copilotSpawnUsage(raw: string): InferenceUsage | undefined {
|
|
|
193
193
|
);
|
|
194
194
|
const models = checkpoint ? copilotModelCaches(checkpoint) : [];
|
|
195
195
|
if (models.length === 0) return undefined;
|
|
196
|
-
const prompt = sum(models.map((m) => m.prompt_tokens));
|
|
197
196
|
const cacheRead = sum(models.map((m) => m.cache_read));
|
|
198
197
|
const cacheWrite = sum(models.map((m) => m.cache_write));
|
|
199
198
|
return {
|
|
200
|
-
inputTokens:
|
|
201
|
-
|
|
199
|
+
inputTokens: uncachedInput(
|
|
200
|
+
sum(models.map((m) => m.prompt_tokens)),
|
|
201
|
+
cacheRead,
|
|
202
|
+
cacheWrite
|
|
203
|
+
),
|
|
204
|
+
cacheWrite5m: cacheWrite,
|
|
205
|
+
cacheRead,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function uncachedInput(
|
|
210
|
+
prompt: number | undefined,
|
|
211
|
+
cacheRead: number | undefined,
|
|
212
|
+
cacheWrite: number | undefined
|
|
213
|
+
): number | undefined {
|
|
214
|
+
return prompt === undefined ? undefined : prompt - (cacheRead ?? 0) - (cacheWrite ?? 0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const COPILOT_SESSION_ID = /^[\w-]+$/;
|
|
218
|
+
|
|
219
|
+
export function copilotSessionId(raw: string): string | undefined {
|
|
220
|
+
const id = jsonLines(raw).findLast((event) => event.type === "result")?.sessionId;
|
|
221
|
+
return typeof id === "string" && COPILOT_SESSION_ID.test(id) ? id : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface CopilotRunUsage {
|
|
225
|
+
inputTokens?: number;
|
|
226
|
+
outputTokens?: number;
|
|
227
|
+
cacheReadTokens?: number;
|
|
228
|
+
cacheWriteTokens?: number;
|
|
229
|
+
reasoningTokens?: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A run's session.shutdown carries all of its usage, output included; inputTokens folds in cache reads and writes. */
|
|
233
|
+
export function copilotShutdownUsage(eventsJsonl: string): InferenceUsage | undefined {
|
|
234
|
+
const shutdown = jsonLines(eventsJsonl).findLast(
|
|
235
|
+
(event) => event.type === "session.shutdown"
|
|
236
|
+
);
|
|
237
|
+
const data = shutdown?.data as
|
|
238
|
+
| { modelMetrics?: Record<string, { usage?: CopilotRunUsage }> }
|
|
239
|
+
| undefined;
|
|
240
|
+
const runs = Object.values(data?.modelMetrics ?? {}).map((model) => model.usage ?? {});
|
|
241
|
+
if (runs.length === 0) return undefined;
|
|
242
|
+
const cacheRead = sum(runs.map((u) => u.cacheReadTokens));
|
|
243
|
+
const cacheWrite = sum(runs.map((u) => u.cacheWriteTokens));
|
|
244
|
+
return {
|
|
245
|
+
inputTokens: uncachedInput(
|
|
246
|
+
sum(runs.map((u) => u.inputTokens)),
|
|
247
|
+
cacheRead,
|
|
248
|
+
cacheWrite
|
|
249
|
+
),
|
|
250
|
+
outputTokens: sum(runs.map((u) => u.outputTokens)),
|
|
202
251
|
cacheWrite5m: cacheWrite,
|
|
203
252
|
cacheRead,
|
|
253
|
+
thinkingTokens: sum(runs.map((u) => u.reasoningTokens)),
|
|
204
254
|
};
|
|
205
255
|
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* yet wired and currently fall through to the API path.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
23
24
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
24
25
|
import { tmpdir } from "node:os";
|
|
25
26
|
import { basename, join } from "node:path";
|
|
@@ -48,6 +49,8 @@ import {
|
|
|
48
49
|
claudeResult,
|
|
49
50
|
claudeSpawnUsage,
|
|
50
51
|
codexSpawnUsage,
|
|
52
|
+
copilotSessionId,
|
|
53
|
+
copilotShutdownUsage,
|
|
51
54
|
copilotSpawnUsage,
|
|
52
55
|
type InferenceUsage,
|
|
53
56
|
jsonLines,
|
|
@@ -64,6 +67,7 @@ import {
|
|
|
64
67
|
} from "./models";
|
|
65
68
|
import { opencodeTierModel, writeInstructionFreeConfig } from "./opencode-config";
|
|
66
69
|
import { withPalEnv } from "./pal-env";
|
|
70
|
+
import { platform } from "./paths";
|
|
67
71
|
import { buildSpawnGuardEnv, getInferenceDepth, SPAWN_GUARD_ENV } from "./spawn-guard";
|
|
68
72
|
import { findBinaryOnPath } from "./which";
|
|
69
73
|
|
|
@@ -703,6 +707,26 @@ const COPILOT_REPLY: CliReply = {
|
|
|
703
707
|
usage: copilotSpawnUsage,
|
|
704
708
|
};
|
|
705
709
|
|
|
710
|
+
function copilotSessionUsage(home: string, raw: string): InferenceUsage | undefined {
|
|
711
|
+
const id = copilotSessionId(raw);
|
|
712
|
+
if (!id) return undefined;
|
|
713
|
+
try {
|
|
714
|
+
return copilotShutdownUsage(
|
|
715
|
+
readFileSync(join(home, "session-state", id, "events.jsonl"), "utf-8")
|
|
716
|
+
);
|
|
717
|
+
} catch {
|
|
718
|
+
return undefined;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** stdout reports only the prompt side; the run's own session file in its home has the rest. */
|
|
723
|
+
function copilotReply(home: string): CliReply {
|
|
724
|
+
return {
|
|
725
|
+
...COPILOT_REPLY,
|
|
726
|
+
usage: (raw) => copilotSessionUsage(home, raw) ?? copilotSpawnUsage(raw),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
706
730
|
const COPILOT_TOKEN_ENV = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
|
|
707
731
|
|
|
708
732
|
/** A /login account lives in COPILOT_HOME's config.json; only a token variable logs in without it. */
|
|
@@ -715,19 +739,21 @@ async function inferenceViaCopilotSpawn(
|
|
|
715
739
|
bin: string,
|
|
716
740
|
opts: InferenceOptions
|
|
717
741
|
): Promise<InferenceResult> {
|
|
718
|
-
const spawn = (place?: SpawnPlace) =>
|
|
742
|
+
const spawn = (home: string, place?: SpawnPlace) =>
|
|
719
743
|
inferenceViaCliSpawn(
|
|
720
744
|
bin,
|
|
721
745
|
buildCopilotArgs(opts),
|
|
722
746
|
buildCliPrompt(opts),
|
|
723
747
|
opts,
|
|
724
|
-
|
|
748
|
+
copilotReply(home),
|
|
725
749
|
place
|
|
726
750
|
);
|
|
727
|
-
|
|
751
|
+
const env = withPalEnv(process.env);
|
|
752
|
+
if (!copilotLogsInWithoutHome(env))
|
|
753
|
+
return spawn(env.COPILOT_HOME || platform.copilotDir());
|
|
728
754
|
const dir = await mkdtemp(join(tmpdir(), "pal-copilot-"));
|
|
729
755
|
try {
|
|
730
|
-
return await spawn({ cwd: dir, env: { COPILOT_HOME: dir } });
|
|
756
|
+
return await spawn(dir, { cwd: dir, env: { COPILOT_HOME: dir } });
|
|
731
757
|
} finally {
|
|
732
758
|
await rm(dir, { recursive: true, force: true });
|
|
733
759
|
}
|
|
@@ -34,10 +34,12 @@ interface PreserveLocations {
|
|
|
34
34
|
|
|
35
35
|
type Manifest = Record<string, string>;
|
|
36
36
|
|
|
37
|
+
export const transcriptArchive = () => resolve(paths.content(), "claude");
|
|
38
|
+
|
|
37
39
|
function defaultLocations(): PreserveLocations {
|
|
38
40
|
return {
|
|
39
41
|
source: resolve(platform.claudeDir(), "projects"),
|
|
40
|
-
target:
|
|
42
|
+
target: transcriptArchive(),
|
|
41
43
|
manifest: resolve(paths.state(), "transcript-copies.json"),
|
|
42
44
|
};
|
|
43
45
|
}
|
|
@@ -218,13 +218,14 @@ interface CopilotEvent {
|
|
|
218
218
|
};
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
/** Copilot counts cache reads inside inputTokens. */
|
|
221
|
+
/** Copilot counts cache reads and writes inside inputTokens. */
|
|
222
222
|
function copilotTokens(usage: CopilotModelMetrics["usage"] = {}): Tokens {
|
|
223
223
|
const cacheRead = usage.cacheReadTokens ?? 0;
|
|
224
|
+
const cacheWrite = usage.cacheWriteTokens ?? 0;
|
|
224
225
|
return {
|
|
225
|
-
input: (usage.inputTokens ?? 0) - cacheRead,
|
|
226
|
+
input: (usage.inputTokens ?? 0) - cacheRead - cacheWrite,
|
|
226
227
|
output: usage.outputTokens ?? 0,
|
|
227
|
-
cacheWrite5m:
|
|
228
|
+
cacheWrite5m: cacheWrite,
|
|
228
229
|
cacheWrite1h: 0,
|
|
229
230
|
cacheRead,
|
|
230
231
|
};
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* rather than finding it themselves, which is what makes them testable.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
|
-
import { resolve } from "node:path";
|
|
7
|
+
import { type Dirent, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { relative, resolve } from "node:path";
|
|
9
|
+
import type { LoggedEvent } from "../../hooks/lib/events";
|
|
9
10
|
import { costOfUsage } from "../../hooks/lib/models";
|
|
10
11
|
import { cacheWritesOf, finalPerMessage, type TranscriptUsage } from "./transcript-usage";
|
|
11
12
|
|
|
@@ -193,10 +194,18 @@ interface TranscriptLine {
|
|
|
193
194
|
* wrote — those sit one level down and are billed to the same project, so
|
|
194
195
|
* missing them undercounts every session that spawned one.
|
|
195
196
|
*/
|
|
196
|
-
function
|
|
197
|
+
function entriesOf(dir: string): Dirent[] {
|
|
198
|
+
try {
|
|
199
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function transcriptsIn(projPath: string, extension = ".jsonl"): string[] {
|
|
197
206
|
const files: string[] = [];
|
|
198
|
-
for (const entry of
|
|
199
|
-
if (entry.isFile() && entry.name.endsWith(
|
|
207
|
+
for (const entry of entriesOf(projPath)) {
|
|
208
|
+
if (entry.isFile() && entry.name.endsWith(extension)) {
|
|
200
209
|
files.push(resolve(projPath, entry.name));
|
|
201
210
|
continue;
|
|
202
211
|
}
|
|
@@ -204,7 +213,7 @@ function transcriptsIn(projPath: string): string[] {
|
|
|
204
213
|
const subagentsDir = resolve(projPath, entry.name, "subagents");
|
|
205
214
|
try {
|
|
206
215
|
for (const sub of readdirSync(subagentsDir)) {
|
|
207
|
-
if (sub.endsWith(
|
|
216
|
+
if (sub.endsWith(extension)) files.push(resolve(subagentsDir, sub));
|
|
208
217
|
}
|
|
209
218
|
} catch {
|
|
210
219
|
/* no subagents dir */
|
|
@@ -255,30 +264,64 @@ function pricedCallOf(line: string): PricedCall | null {
|
|
|
255
264
|
};
|
|
256
265
|
}
|
|
257
266
|
|
|
267
|
+
const projectDirsIn = (dir: string) =>
|
|
268
|
+
entriesOf(dir)
|
|
269
|
+
.filter((entry) => entry.isDirectory())
|
|
270
|
+
.map((entry) => entry.name);
|
|
271
|
+
|
|
272
|
+
const GZIP = ".gz";
|
|
273
|
+
|
|
274
|
+
/** Claude Code deletes a transcript after cleanupPeriodDays; PAL's gzipped copy outlives it. */
|
|
275
|
+
function deletedTranscriptsIn(archivedProject: string, liveProject: string): string[] {
|
|
276
|
+
return transcriptsIn(archivedProject, `.jsonl${GZIP}`).filter((copy) => {
|
|
277
|
+
const original = relative(archivedProject, copy).slice(0, -GZIP.length);
|
|
278
|
+
return !existsSync(resolve(liveProject, original));
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function transcriptText(filepath: string): string | null {
|
|
283
|
+
try {
|
|
284
|
+
const raw = readFileSync(filepath);
|
|
285
|
+
return filepath.endsWith(GZIP)
|
|
286
|
+
? new TextDecoder().decode(Bun.gunzipSync(raw))
|
|
287
|
+
: raw.toString("utf-8");
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function projectTranscripts(
|
|
294
|
+
claudeDir: string,
|
|
295
|
+
archiveDir: string | undefined,
|
|
296
|
+
dirName: string
|
|
297
|
+
) {
|
|
298
|
+
const live = resolve(claudeDir, dirName);
|
|
299
|
+
return [
|
|
300
|
+
...transcriptsIn(live),
|
|
301
|
+
...(archiveDir ? deletedTranscriptsIn(resolve(archiveDir, dirName), live) : []),
|
|
302
|
+
];
|
|
303
|
+
}
|
|
304
|
+
|
|
258
305
|
export function readClaudeCode(
|
|
259
306
|
claudeDir: string,
|
|
260
307
|
projectFilter?: string,
|
|
261
|
-
now: Date = new Date()
|
|
308
|
+
now: Date = new Date(),
|
|
309
|
+
archiveDir?: string
|
|
262
310
|
): AgentUsage {
|
|
263
311
|
const tally = usageTally(projectFilter, now);
|
|
264
|
-
if (!existsSync(claudeDir)) return tally.usage;
|
|
265
312
|
const calls: (PricedCall & { project: string })[] = [];
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
313
|
+
const projectDirs = new Set([
|
|
314
|
+
...projectDirsIn(claudeDir),
|
|
315
|
+
...(archiveDir ? projectDirsIn(archiveDir) : []),
|
|
316
|
+
]);
|
|
270
317
|
|
|
271
318
|
for (const dirName of projectDirs) {
|
|
272
319
|
const projName = projectNameOf(dirName);
|
|
273
320
|
if (typeof projectFilter === "string" && !projName.includes(projectFilter)) continue;
|
|
274
321
|
|
|
275
|
-
for (const filepath of
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
content = readFileSync(filepath, "utf-8");
|
|
279
|
-
} catch {
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
322
|
+
for (const filepath of projectTranscripts(claudeDir, archiveDir, dirName)) {
|
|
323
|
+
const content = transcriptText(filepath);
|
|
324
|
+
if (content === null) continue;
|
|
282
325
|
|
|
283
326
|
for (const line of content.split("\n")) {
|
|
284
327
|
// Cheap reject before the parse: most lines are not model turns.
|
|
@@ -307,27 +350,27 @@ export interface PalInferenceUsage {
|
|
|
307
350
|
byCaller: Record<string, Bucket>;
|
|
308
351
|
}
|
|
309
352
|
|
|
310
|
-
|
|
311
|
-
ts: string;
|
|
312
|
-
caller: string;
|
|
313
|
-
model: string;
|
|
314
|
-
inputTokens: number;
|
|
315
|
-
outputTokens: number;
|
|
316
|
-
}
|
|
353
|
+
const countOf = (value: unknown) => (typeof value === "number" ? value : null);
|
|
317
354
|
|
|
318
|
-
/**
|
|
319
|
-
function inferenceTokens(
|
|
355
|
+
/** A call whose input or output went unreported has no tokens to count. */
|
|
356
|
+
function inferenceTokens(call: LoggedEvent): Tokens | null {
|
|
357
|
+
const input = countOf(call.inputTokens);
|
|
358
|
+
const output = countOf(call.outputTokens);
|
|
359
|
+
if (input === null || output === null) return null;
|
|
320
360
|
return {
|
|
321
|
-
input
|
|
322
|
-
output
|
|
323
|
-
cacheWrite5m: 0,
|
|
324
|
-
cacheWrite1h: 0,
|
|
325
|
-
cacheRead: 0,
|
|
361
|
+
input,
|
|
362
|
+
output,
|
|
363
|
+
cacheWrite5m: countOf(call.cacheWrite5m) ?? 0,
|
|
364
|
+
cacheWrite1h: countOf(call.cacheWrite1h) ?? 0,
|
|
365
|
+
cacheRead: countOf(call.cacheRead) ?? 0,
|
|
326
366
|
};
|
|
327
367
|
}
|
|
328
368
|
|
|
369
|
+
const textOr = (value: unknown, fallback: string) =>
|
|
370
|
+
typeof value === "string" ? value : fallback;
|
|
371
|
+
|
|
329
372
|
export function readPalInference(
|
|
330
|
-
|
|
373
|
+
events: LoggedEvent[],
|
|
331
374
|
now: Date = new Date()
|
|
332
375
|
): PalInferenceUsage {
|
|
333
376
|
const horizons = horizonsFrom(now);
|
|
@@ -336,33 +379,20 @@ export function readPalInference(
|
|
|
336
379
|
byModel: {},
|
|
337
380
|
byCaller: {},
|
|
338
381
|
};
|
|
339
|
-
if (!existsSync(filepath)) return result;
|
|
340
|
-
|
|
341
|
-
const content = readFileSync(filepath, "utf-8").trim();
|
|
342
|
-
if (!content) return result;
|
|
343
382
|
|
|
344
|
-
for (const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
|
-
const tokens = inferenceTokens(entry);
|
|
383
|
+
for (const call of events.filter((e) => e.type === "inference_call")) {
|
|
384
|
+
const tokens = inferenceTokens(call);
|
|
385
|
+
if (tokens === null) continue;
|
|
386
|
+
const model = textOr(call.model, "unknown");
|
|
387
|
+
const caller = textOr(call.caller, "anonymous");
|
|
352
388
|
|
|
353
|
-
addToTimeBuckets(result.buckets,
|
|
389
|
+
addToTimeBuckets(result.buckets, call.ts, model, tokens, horizons);
|
|
354
390
|
|
|
355
|
-
result.byModel[
|
|
356
|
-
addToTimeBuckets(
|
|
357
|
-
result.byModel[entry.model],
|
|
358
|
-
entry.ts,
|
|
359
|
-
entry.model,
|
|
360
|
-
tokens,
|
|
361
|
-
horizons
|
|
362
|
-
);
|
|
391
|
+
result.byModel[model] ??= emptyTimeBuckets();
|
|
392
|
+
addToTimeBuckets(result.byModel[model], call.ts, model, tokens, horizons);
|
|
363
393
|
|
|
364
|
-
result.byCaller[
|
|
365
|
-
addToBucket(result.byCaller[
|
|
394
|
+
result.byCaller[caller] ??= emptyBucket();
|
|
395
|
+
addToBucket(result.byCaller[caller], model, tokens, call.ts);
|
|
366
396
|
}
|
|
367
397
|
|
|
368
398
|
return result;
|
package/src/tools/self-model.ts
CHANGED
|
@@ -21,7 +21,6 @@ import { parseArgs } from "node:util";
|
|
|
21
21
|
import { inference } from "../hooks/lib/inference";
|
|
22
22
|
import { ensureDir, paths } from "../hooks/lib/paths";
|
|
23
23
|
import { identity as loadSettingsIdentity } from "../hooks/lib/settings";
|
|
24
|
-
import { logTokenUsage } from "../hooks/lib/token-usage";
|
|
25
24
|
import {
|
|
26
25
|
archiveDateOf,
|
|
27
26
|
buildPrompt,
|
|
@@ -89,7 +88,6 @@ async function composeSelfModel(days: number): Promise<string> {
|
|
|
89
88
|
caller: "self-model",
|
|
90
89
|
});
|
|
91
90
|
|
|
92
|
-
logTokenUsage("self-model", result);
|
|
93
91
|
if (result.success && result.output) {
|
|
94
92
|
return result.output.trimEnd() + metaFooter(data);
|
|
95
93
|
}
|
package/src/tools/token-cost.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
8
|
import { AGENT_REGISTRY, type AgentName } from "../hooks/lib/agent-registry";
|
|
9
|
-
import {
|
|
9
|
+
import { eventsSince } from "../hooks/lib/events";
|
|
10
|
+
import { platform } from "../hooks/lib/paths";
|
|
11
|
+
import { transcriptArchive } from "../hooks/lib/transcript-preserve";
|
|
10
12
|
import { findBinaryOnPath } from "../hooks/lib/which";
|
|
11
13
|
import {
|
|
12
14
|
opencodeDatabase,
|
|
@@ -31,7 +33,12 @@ function agentReports(project: string | undefined): AgentReport[] {
|
|
|
31
33
|
});
|
|
32
34
|
return [
|
|
33
35
|
report("claude", () =>
|
|
34
|
-
readClaudeCode(
|
|
36
|
+
readClaudeCode(
|
|
37
|
+
resolve(platform.claudeDir(), "projects"),
|
|
38
|
+
project,
|
|
39
|
+
new Date(),
|
|
40
|
+
transcriptArchive()
|
|
41
|
+
)
|
|
35
42
|
),
|
|
36
43
|
report("codex", () => readCodex(platform.codexDir(), project)),
|
|
37
44
|
report("opencode", () => readOpencode(opencodeDatabase(), project)),
|
|
@@ -60,7 +67,7 @@ function rtkGain(): RtkGain {
|
|
|
60
67
|
export function collectUsage(project: string | undefined): UsageData {
|
|
61
68
|
return {
|
|
62
69
|
agents: agentReports(project),
|
|
63
|
-
pal: readPalInference(
|
|
70
|
+
pal: readPalInference(eventsSince("inference_call", new Date(0))),
|
|
64
71
|
rtk: rtkGain(),
|
|
65
72
|
untracked: installedUntracked(),
|
|
66
73
|
};
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Append-only JSONL logger for inference token usage.
|
|
3
|
-
* Writes to memory/signals/token-usage.jsonl
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { appendFileSync } from "node:fs";
|
|
7
|
-
import { resolve } from "node:path";
|
|
8
|
-
import type { InferenceUsage } from "./inference-usage";
|
|
9
|
-
import { ensureDir, paths } from "./paths";
|
|
10
|
-
|
|
11
|
-
type TokenCaller =
|
|
12
|
-
| "rating"
|
|
13
|
-
| "rule-drafter"
|
|
14
|
-
| "failure"
|
|
15
|
-
| "session-name"
|
|
16
|
-
| "session-intelligence"
|
|
17
|
-
| "entity-extraction"
|
|
18
|
-
| "entity-research"
|
|
19
|
-
| "relationship"
|
|
20
|
-
| "self-model"
|
|
21
|
-
| "agenda-serves"
|
|
22
|
-
| "agenda-goal-links"
|
|
23
|
-
| "agenda-waiting-on"
|
|
24
|
-
| "agenda-moves";
|
|
25
|
-
|
|
26
|
-
const UNKNOWN_MODEL = "unknown";
|
|
27
|
-
|
|
28
|
-
interface TokenUsageEntry {
|
|
29
|
-
ts: string;
|
|
30
|
-
caller: TokenCaller;
|
|
31
|
-
model: string;
|
|
32
|
-
inputTokens: number;
|
|
33
|
-
outputTokens: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function logTokenUsage(
|
|
37
|
-
caller: TokenCaller,
|
|
38
|
-
call: { usage?: InferenceUsage; model?: string }
|
|
39
|
-
): void {
|
|
40
|
-
const { inputTokens, outputTokens } = call.usage ?? {};
|
|
41
|
-
if (inputTokens === undefined || outputTokens === undefined) return;
|
|
42
|
-
const entry: TokenUsageEntry = {
|
|
43
|
-
ts: new Date().toISOString(),
|
|
44
|
-
caller,
|
|
45
|
-
model: call.model ?? UNKNOWN_MODEL,
|
|
46
|
-
inputTokens,
|
|
47
|
-
outputTokens,
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const dir = ensureDir(paths.signals());
|
|
51
|
-
const filepath = resolve(dir, "token-usage.jsonl");
|
|
52
|
-
appendFileSync(filepath, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
53
|
-
}
|