portable-agent-layer 0.97.0 → 0.99.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/README.md +1 -0
- package/package.json +2 -2
- package/src/cli/cost.ts +18 -0
- package/src/cli/events.ts +22 -0
- package/src/cli/tree.ts +2 -0
- package/src/hooks/lib/commitment-events.ts +3 -3
- package/src/hooks/lib/entity-events.ts +18 -5
- package/src/hooks/lib/events.ts +54 -4
- package/src/hooks/lib/interaction.ts +25 -5
- package/src/hooks/lib/ledger.ts +61 -2
- package/src/hooks/lib/models.ts +42 -10
- package/src/hooks/lib/remote.ts +3 -0
- package/src/hooks/lib/signals.ts +54 -4
- package/src/hooks/lib/work-tracking.ts +39 -2
- package/src/targets/lib.ts +1 -3
- package/src/tools/agent/project.ts +7 -2
- package/src/tools/knowledge/lib.ts +11 -2
- package/src/tools/lib/backfill/entities.ts +31 -0
- package/src/tools/lib/backfill/git.ts +184 -0
- package/src/tools/lib/backfill/index.ts +38 -0
- package/src/tools/lib/backfill/ledger.ts +28 -0
- package/src/tools/lib/backfill/projects.ts +40 -0
- package/src/tools/lib/backfill/promises.ts +91 -0
- package/src/tools/lib/backfill/ratings.ts +21 -0
- package/src/tools/lib/backfill/rows.ts +33 -0
- package/src/tools/lib/backfill/sessions.ts +56 -0
- package/src/tools/lib/backfill/turns.ts +32 -0
- package/src/tools/lib/collectors/billing.ts +105 -0
- package/src/tools/lib/collectors/claude.ts +61 -21
- package/src/tools/lib/collectors/codex.ts +6 -3
- package/src/tools/lib/collectors/copilot.ts +2 -0
- package/src/tools/lib/collectors/index.ts +8 -1
- package/src/tools/lib/collectors/opencode.ts +10 -2
- package/src/tools/lib/cost-report.ts +134 -0
- package/src/tools/lib/plan-prices.ts +24 -0
- package/src/tools/lib/project-history.ts +100 -0
- package/src/tools/lib/session-usage.ts +11 -7
- package/src/tools/lib/usage-buckets.ts +10 -5
package/README.md
CHANGED
|
@@ -85,6 +85,7 @@ pal cli status # check your setup
|
|
|
85
85
|
| `pal cli migrate` | Run pending data migrations (non-destructive) |
|
|
86
86
|
| `pal cli analyze [--actionable]` | Learning analysis: rating trends, failure patterns, graduation candidates |
|
|
87
87
|
| `pal cli usage` | Summarize token usage and estimated cost |
|
|
88
|
+
| `pal cli cost` | What each month was paid against its API-equivalent cost, per agent |
|
|
88
89
|
| `pal cli content preserve` | Copy every Claude Code transcript, gzipped, into `memory/content/` before Claude Code deletes it (30 days by default). Runs on its own whenever a session closes; the content store never leaves the machine, so `export` skips it |
|
|
89
90
|
| `pal cli events discord-nag <sent\|answered>` | For a bot outside PAL: record a nag it sent (`--kind <kind>`), or the answer to one and how long it took (`--after-sec <n>`), in the local event log |
|
|
90
91
|
| `pal cli infer` | One model call for scripts and bots, on the active agent's route: prompt on stdin, reply on stdout. `--tier small\|medium`, `--system <file>`, `--schema <file>` for a JSON reply checked against it, `--timeout <seconds>`. The call loads no PAL hooks or context; exits 1 with the reason when no route answers |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.99.0",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"@commitlint/cli": "21.0.1",
|
|
81
81
|
"@commitlint/config-conventional": "21.0.1",
|
|
82
82
|
"@hughescr/stryker-bun-runner": "1.3.8",
|
|
83
|
-
"@konvert7/klint": "0.
|
|
83
|
+
"@konvert7/klint": "0.35.1",
|
|
84
84
|
"@opencode-ai/plugin": "latest",
|
|
85
85
|
"@secretlint/secretlint-rule-preset-recommend": "13.0.2",
|
|
86
86
|
"@semantic-release/changelog": "^6.0.3",
|
package/src/cli/cost.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AGENT_REGISTRY, type AgentName } from "../hooks/lib/agent-registry";
|
|
2
|
+
import { eventsSince } from "../hooks/lib/events";
|
|
3
|
+
import { leaf } from "../tools/lib/command";
|
|
4
|
+
import { costLines, costMonths } from "../tools/lib/cost-report";
|
|
5
|
+
|
|
6
|
+
function labelOf(runtime: string): string {
|
|
7
|
+
return AGENT_REGISTRY[runtime as AgentName]?.label ?? runtime;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function printCost(): undefined {
|
|
11
|
+
const months = costMonths(eventsSince("model_call", new Date(0)));
|
|
12
|
+
for (const line of costLines(months, labelOf)) console.log(line);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const costCommand = leaf({
|
|
16
|
+
summary: "What each month was paid against its API-equivalent cost, per agent",
|
|
17
|
+
run: () => printCost(),
|
|
18
|
+
});
|
package/src/cli/events.ts
CHANGED
|
@@ -41,9 +41,31 @@ function reportCollected(): undefined {
|
|
|
41
41
|
for (const [source, count] of sources) console.log(`${source}: ${count}`);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
async function reportBackfilled(since: string | undefined): Promise<undefined> {
|
|
45
|
+
const { backfillEvents } = await import("../tools/lib/backfill");
|
|
46
|
+
const appended = backfillEvents(since);
|
|
47
|
+
const sources = Object.entries(appended);
|
|
48
|
+
if (sources.length === 0) {
|
|
49
|
+
console.log("The event log is off; nothing backfilled.");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (const [source, count] of sources) console.log(`${source}: ${count}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
export const eventsCommand = group({
|
|
45
56
|
summary: "PAL's local event log",
|
|
46
57
|
commands: {
|
|
58
|
+
backfill: leaf({
|
|
59
|
+
summary: "Read everything that still exists into the event log, once",
|
|
60
|
+
options: {
|
|
61
|
+
since: {
|
|
62
|
+
type: "string",
|
|
63
|
+
value: "<date>",
|
|
64
|
+
description: "Only what happened after this date",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
run: ({ values }) => reportBackfilled(values.since),
|
|
68
|
+
}),
|
|
47
69
|
collect: leaf({
|
|
48
70
|
summary: "Read every agent's own records into the event log",
|
|
49
71
|
run: () => reportCollected(),
|
package/src/cli/tree.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "../tools/lib/command";
|
|
16
16
|
import { builtinTools, builtinToolVerbs } from "./builtin-tools";
|
|
17
17
|
import { contentCommand } from "./content";
|
|
18
|
+
import { costCommand } from "./cost";
|
|
18
19
|
import { eventsCommand } from "./events";
|
|
19
20
|
import { identityCommand } from "./identity";
|
|
20
21
|
import { inferCommand } from "./infer";
|
|
@@ -153,6 +154,7 @@ export function cliTree(admin: AdminHandlers): Group {
|
|
|
153
154
|
commands: {
|
|
154
155
|
...adminCommands(admin),
|
|
155
156
|
usage: usageCommand,
|
|
157
|
+
cost: costCommand,
|
|
156
158
|
content: contentCommand,
|
|
157
159
|
events: eventsCommand,
|
|
158
160
|
infer: inferCommand,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A commitment is an ISC:
|
|
3
|
-
* snooze puts it off. Each change of state is one event, whichever surface made it.
|
|
2
|
+
* A commitment is an ISC: adding it opens it, completing it keeps it, retiring
|
|
3
|
+
* it drops it, and a snooze puts it off. Each change of state is one event, whichever surface made it.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { appendEvent, logQuietly } from "./events";
|
|
7
7
|
|
|
8
|
-
export type CommitmentOutcome = "kept" | "dropped" | "snoozed" | "reopened";
|
|
8
|
+
export type CommitmentOutcome = "opened" | "kept" | "dropped" | "snoozed" | "reopened";
|
|
9
9
|
|
|
10
10
|
export function logCommitment(
|
|
11
11
|
iscProject: string,
|
|
@@ -3,14 +3,27 @@
|
|
|
3
3
|
* store. The name stays in the content store; the event carries only its kind.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { appendEvent, logQuietly } from "./events";
|
|
6
|
+
import { type AppendOptions, appendEvent, logQuietly } from "./events";
|
|
7
|
+
|
|
8
|
+
export interface SeenEntity {
|
|
9
|
+
domain: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
entityType: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const entityEventId = (entity: Pick<SeenEntity, "domain" | "slug">) =>
|
|
16
|
+
`entity:${entity.domain}:${entity.slug}`;
|
|
7
17
|
|
|
8
18
|
export function logEntityFirstSeen(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
name: string
|
|
19
|
+
entity: SeenEntity,
|
|
20
|
+
options: AppendOptions = {}
|
|
12
21
|
): void {
|
|
13
22
|
logQuietly("entity_first_seen", () =>
|
|
14
|
-
appendEvent(
|
|
23
|
+
appendEvent(
|
|
24
|
+
"entity_first_seen",
|
|
25
|
+
{ domain: entity.domain, entityType: entity.entityType },
|
|
26
|
+
{ id: entityEventId(entity), content: { name: entity.name }, ...options }
|
|
27
|
+
)
|
|
15
28
|
);
|
|
16
29
|
}
|
package/src/hooks/lib/events.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
|
-
import { currentAttribution } from "./actor";
|
|
9
|
+
import { currentAttribution, type RecordAttribution } from "./actor";
|
|
10
10
|
import type { AgentType } from "./agent";
|
|
11
11
|
import { logError } from "./log";
|
|
12
12
|
import { ensureDir, paths } from "./paths";
|
|
@@ -51,7 +51,7 @@ export const EVENT_FIELDS = {
|
|
|
51
51
|
"tz",
|
|
52
52
|
"utcOffsetMin",
|
|
53
53
|
],
|
|
54
|
-
session_end: ["reason", "durationSec", "turns"],
|
|
54
|
+
session_end: ["reason", "durationSec", "turns", "messages", "status"],
|
|
55
55
|
compaction: ["trigger", "withInstructions", "contextTokens"],
|
|
56
56
|
hook_timing: ["hook", "totalMs", "handlers"],
|
|
57
57
|
inference_call: [
|
|
@@ -70,6 +70,9 @@ export const EVENT_FIELDS = {
|
|
|
70
70
|
entity_first_seen: ["domain", "entityType"],
|
|
71
71
|
discord_nag: ["stage", "kind", "responseSec"],
|
|
72
72
|
model_call: [
|
|
73
|
+
"billing",
|
|
74
|
+
"plan",
|
|
75
|
+
"extraUsage",
|
|
73
76
|
"model",
|
|
74
77
|
"requestedModel",
|
|
75
78
|
"provider",
|
|
@@ -98,6 +101,34 @@ export const EVENT_FIELDS = {
|
|
|
98
101
|
"host",
|
|
99
102
|
],
|
|
100
103
|
conversation: ["steps", "status", "depth", "subagent", "killed"],
|
|
104
|
+
commit: [
|
|
105
|
+
"hash",
|
|
106
|
+
"committedAt",
|
|
107
|
+
"files",
|
|
108
|
+
"added",
|
|
109
|
+
"deleted",
|
|
110
|
+
"commitType",
|
|
111
|
+
"breaking",
|
|
112
|
+
"revert",
|
|
113
|
+
"merge",
|
|
114
|
+
"tags",
|
|
115
|
+
"coAuthors",
|
|
116
|
+
"aiCoAuthored",
|
|
117
|
+
],
|
|
118
|
+
rating: ["rating", "source"],
|
|
119
|
+
project: ["change", "status"],
|
|
120
|
+
decision: ["words"],
|
|
121
|
+
promise: ["change", "dueInSec", "lateSec", "snoozes", "nags"],
|
|
122
|
+
action: [
|
|
123
|
+
"tool",
|
|
124
|
+
"outcome",
|
|
125
|
+
"fileExt",
|
|
126
|
+
"change",
|
|
127
|
+
"linesAdded",
|
|
128
|
+
"linesRemoved",
|
|
129
|
+
"beforeBytes",
|
|
130
|
+
"afterBytes",
|
|
131
|
+
],
|
|
101
132
|
permission_prompt: ["tool"],
|
|
102
133
|
notification: ["kind"],
|
|
103
134
|
tool_call: [
|
|
@@ -148,7 +179,9 @@ export interface AppendOptions {
|
|
|
148
179
|
id?: string;
|
|
149
180
|
ts?: string;
|
|
150
181
|
session?: string | null;
|
|
151
|
-
runtime?: AgentType;
|
|
182
|
+
runtime?: AgentType | "git" | "unknown";
|
|
183
|
+
attribution?: Partial<RecordAttribution>;
|
|
184
|
+
project?: string | null;
|
|
152
185
|
channel?: string | null;
|
|
153
186
|
cwd?: string;
|
|
154
187
|
content?: Record<string, unknown>;
|
|
@@ -209,13 +242,19 @@ function envelope(type: EventType, options: AppendOptions): EventEnvelope {
|
|
|
209
242
|
session: options.session ?? null,
|
|
210
243
|
...currentAttribution(),
|
|
211
244
|
...(options.runtime && { runtime: options.runtime }),
|
|
245
|
+
...options.attribution,
|
|
212
246
|
channel:
|
|
213
247
|
options.channel !== undefined ? options.channel : process.env.PAL_CHANNEL || null,
|
|
214
|
-
project:
|
|
248
|
+
project: projectFor(options, cwd),
|
|
215
249
|
cwd,
|
|
216
250
|
};
|
|
217
251
|
}
|
|
218
252
|
|
|
253
|
+
function projectFor(options: AppendOptions, cwd: string): string | null {
|
|
254
|
+
if (options.project !== undefined) return options.project;
|
|
255
|
+
return cwd ? projectOf(cwd) : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
219
258
|
/** Appends one event and returns its id, or null when the log is off or the write failed. */
|
|
220
259
|
export function appendEvent(
|
|
221
260
|
type: EventType,
|
|
@@ -267,6 +306,17 @@ function parseLines(file: string): LoggedEvent[] {
|
|
|
267
306
|
});
|
|
268
307
|
}
|
|
269
308
|
|
|
309
|
+
/** Every id already in the log, so a source read twice appends nothing new. */
|
|
310
|
+
export function loggedEventIds(): Set<string> {
|
|
311
|
+
const dir = eventsDir();
|
|
312
|
+
return new Set(
|
|
313
|
+
readdirSync(dir)
|
|
314
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
315
|
+
.flatMap((f) => parseLines(resolve(dir, f)))
|
|
316
|
+
.map((e) => e.id)
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
270
320
|
export function eventsInMonth(type: EventType, month: Date): LoggedEvent[] {
|
|
271
321
|
const file = resolve(eventsDir(), `${monthOf(month.toISOString())}.jsonl`);
|
|
272
322
|
return parseLines(file).filter((e) => e.type === type);
|
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
9
|
import { hearsPromptContext } from "./agent";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
appendEvent,
|
|
12
|
+
eventsInMonth,
|
|
13
|
+
eventsSince,
|
|
14
|
+
type LoggedEvent,
|
|
15
|
+
logQuietly,
|
|
16
|
+
} from "./events";
|
|
11
17
|
import { asksForLess, followedHint, ignoredHintReminder } from "./interaction-hint-check";
|
|
12
18
|
import {
|
|
13
19
|
type Baseline,
|
|
@@ -200,17 +206,31 @@ function legacyTurnsSince(since: Date): TurnEvent[] {
|
|
|
200
206
|
.filter((e) => e.ts >= sinceTs);
|
|
201
207
|
}
|
|
202
208
|
|
|
209
|
+
export const legacyTurnId = (turn: TurnEvent) => `turn:${turn.session}:${turn.ts}`;
|
|
210
|
+
|
|
211
|
+
/** Every turn logged before turns streamed into the event log. */
|
|
212
|
+
export function legacyTurns(): TurnEvent[] {
|
|
213
|
+
return legacyTurnsSince(new Date(0));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function withStreamed(legacy: TurnEvent[], streamed: LoggedEvent[]): TurnEvent[] {
|
|
217
|
+
const backfilled = new Set(streamed.map((e) => e.id));
|
|
218
|
+
const notBackfilled = legacy.filter((turn) => !backfilled.has(legacyTurnId(turn)));
|
|
219
|
+
return [...notBackfilled, ...(streamed as unknown as TurnEvent[])];
|
|
220
|
+
}
|
|
221
|
+
|
|
203
222
|
export function turnsSince(since: Date): TurnEvent[] {
|
|
204
|
-
|
|
205
|
-
return [...legacyTurnsSince(since), ...streamed].sort((a, b) =>
|
|
223
|
+
return withStreamed(legacyTurnsSince(since), eventsSince("turn", since)).sort((a, b) =>
|
|
206
224
|
a.ts.localeCompare(b.ts)
|
|
207
225
|
);
|
|
208
226
|
}
|
|
209
227
|
|
|
210
228
|
function turnsInMonth(month: Date): TurnEvent[] {
|
|
211
229
|
const file = `${month.toISOString().slice(0, 7)}.jsonl`;
|
|
212
|
-
|
|
213
|
-
|
|
230
|
+
return withStreamed(
|
|
231
|
+
readLegacyTurns(resolve(legacyTurnsDir(), file)),
|
|
232
|
+
eventsInMonth("turn", month)
|
|
233
|
+
);
|
|
214
234
|
}
|
|
215
235
|
|
|
216
236
|
/** This month and last, outside the session being judged. */
|
package/src/hooks/lib/ledger.ts
CHANGED
|
@@ -30,10 +30,11 @@ import {
|
|
|
30
30
|
unlinkSync,
|
|
31
31
|
writeFileSync,
|
|
32
32
|
} from "node:fs";
|
|
33
|
-
import { resolve } from "node:path";
|
|
33
|
+
import { dirname, extname, resolve } from "node:path";
|
|
34
34
|
import { calcPatch } from "fast-myers-diff";
|
|
35
35
|
import { currentAttribution, type RecordAttribution } from "./actor";
|
|
36
|
-
import { encodeAnchor } from "./anchor";
|
|
36
|
+
import { encodeAnchor, resolveAnchor } from "./anchor";
|
|
37
|
+
import { appendEvent } from "./events";
|
|
37
38
|
import { ensureDir, paths } from "./paths";
|
|
38
39
|
import { isSensitivePath } from "./sensitive-path";
|
|
39
40
|
|
|
@@ -273,9 +274,67 @@ export function recordAction(input: RecordActionInput): LedgerEntry {
|
|
|
273
274
|
const file = ledgerPath();
|
|
274
275
|
rotateIfFull(file);
|
|
275
276
|
appendFileSync(file, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
277
|
+
logActionEvent(entry);
|
|
276
278
|
return entry;
|
|
277
279
|
}
|
|
278
280
|
|
|
281
|
+
export const actionEventId = (entry: LedgerEntry) => `ledger:${entry.id}`;
|
|
282
|
+
|
|
283
|
+
function changeKindOf(delta: LedgerDelta | undefined): string {
|
|
284
|
+
if (!delta) return "none";
|
|
285
|
+
if (delta.redacted) return "redacted";
|
|
286
|
+
if (delta.truncated) return "truncated";
|
|
287
|
+
return "hunks";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function changeOf({ delta }: LedgerEntry) {
|
|
291
|
+
const change = changeKindOf(delta);
|
|
292
|
+
if (change !== "hunks" || !delta)
|
|
293
|
+
return { change, linesAdded: null, linesRemoved: null };
|
|
294
|
+
return {
|
|
295
|
+
change,
|
|
296
|
+
linesAdded: delta.hunks.reduce((n, h) => n + h.insert.length, 0),
|
|
297
|
+
linesRemoved: delta.hunks.reduce((n, h) => n + h.remove, 0),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function directoryOf(entry: LedgerEntry): string {
|
|
302
|
+
const resolved = resolveAnchor(entry.target);
|
|
303
|
+
if (resolved.state === "unresolvable") return "";
|
|
304
|
+
return entry.command === undefined ? dirname(resolved.path) : resolved.path;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The entry as an action event; its target, reason and command are text, so they go to content. */
|
|
308
|
+
export function logActionEvent(
|
|
309
|
+
entry: LedgerEntry,
|
|
310
|
+
attribution: Partial<RecordAttribution> = {}
|
|
311
|
+
): string | null {
|
|
312
|
+
return appendEvent(
|
|
313
|
+
"action",
|
|
314
|
+
{
|
|
315
|
+
tool: entry.tool,
|
|
316
|
+
outcome: entry.outcome,
|
|
317
|
+
fileExt: entry.command === undefined ? extname(entry.target) || null : null,
|
|
318
|
+
...changeOf(entry),
|
|
319
|
+
beforeBytes: entry.before?.bytes ?? null,
|
|
320
|
+
afterBytes: entry.after?.bytes ?? null,
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: actionEventId(entry),
|
|
324
|
+
ts: entry.ts,
|
|
325
|
+
session: null,
|
|
326
|
+
channel: null,
|
|
327
|
+
cwd: directoryOf(entry),
|
|
328
|
+
attribution,
|
|
329
|
+
content: {
|
|
330
|
+
target: entry.target,
|
|
331
|
+
reason: entry.reason ?? null,
|
|
332
|
+
command: entry.command ?? null,
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
279
338
|
/** A refused command is quoted back, not stored whole. */
|
|
280
339
|
const MAX_COMMAND_CHARS = 500;
|
|
281
340
|
|
package/src/hooks/lib/models.ts
CHANGED
|
@@ -107,7 +107,15 @@ export interface TokenUsage {
|
|
|
107
107
|
cacheRead: number;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
interface SupersededRates {
|
|
111
|
+
until: string;
|
|
112
|
+
rates: ModelPricing;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A model's earlier rates, oldest first; a price change moves today's row here. */
|
|
116
|
+
export const PRICE_HISTORY: Record<string, SupersededRates[]> = {};
|
|
117
|
+
|
|
118
|
+
/** Pricing per million tokens (USD) in force today — from https://platform.claude.com/docs/en/about-claude/pricing */
|
|
111
119
|
export const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
112
120
|
[HAIKU_MODEL]: {
|
|
113
121
|
input: 1,
|
|
@@ -124,6 +132,13 @@ export const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
|
124
132
|
cacheWrite1h: 0.2,
|
|
125
133
|
cacheRead: 0.01,
|
|
126
134
|
},
|
|
135
|
+
"claude-fable-5-1": {
|
|
136
|
+
input: 10,
|
|
137
|
+
output: 50,
|
|
138
|
+
cacheWrite5m: 12.5,
|
|
139
|
+
cacheWrite1h: 20,
|
|
140
|
+
cacheRead: 0.25,
|
|
141
|
+
},
|
|
127
142
|
[FABLE_MODEL]: {
|
|
128
143
|
input: 10,
|
|
129
144
|
output: 50,
|
|
@@ -131,6 +146,13 @@ export const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
|
131
146
|
cacheWrite1h: 20,
|
|
132
147
|
cacheRead: 1,
|
|
133
148
|
},
|
|
149
|
+
"claude-opus-5-5": {
|
|
150
|
+
input: 4,
|
|
151
|
+
output: 20,
|
|
152
|
+
cacheWrite5m: 5,
|
|
153
|
+
cacheWrite1h: 8,
|
|
154
|
+
cacheRead: 0.2,
|
|
155
|
+
},
|
|
134
156
|
"claude-opus-5": {
|
|
135
157
|
input: 5,
|
|
136
158
|
output: 25,
|
|
@@ -166,7 +188,13 @@ export const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
|
166
188
|
cacheWrite1h: 10,
|
|
167
189
|
cacheRead: 0.5,
|
|
168
190
|
},
|
|
169
|
-
|
|
191
|
+
"claude-sonnet-5-5": {
|
|
192
|
+
input: 2,
|
|
193
|
+
output: 10,
|
|
194
|
+
cacheWrite5m: 2.5,
|
|
195
|
+
cacheWrite1h: 4,
|
|
196
|
+
cacheRead: 0.1,
|
|
197
|
+
},
|
|
170
198
|
"claude-sonnet-5": {
|
|
171
199
|
input: 2,
|
|
172
200
|
output: 10,
|
|
@@ -203,16 +231,20 @@ function longestPrefixKey(model: string): string | null {
|
|
|
203
231
|
* (`claude-opus-5-20260115`) and context-tagged (`claude-opus-5[1m]`) — so an
|
|
204
232
|
* exact miss falls back to the longest table key the ID starts with.
|
|
205
233
|
*/
|
|
206
|
-
export function pricingFor(model: string): ModelPricing | null {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
234
|
+
export function pricingFor(model: string, at?: string): ModelPricing | null {
|
|
235
|
+
const key = MODEL_PRICING[model] ? model : longestPrefixKey(model);
|
|
236
|
+
return key ? ratesInForce(key, at) : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function ratesInForce(key: string, at: string | undefined): ModelPricing {
|
|
240
|
+
const when = Date.parse(at ?? "");
|
|
241
|
+
const superseded = PRICE_HISTORY[key]?.find((s) => when < Date.parse(s.until));
|
|
242
|
+
return superseded?.rates ?? MODEL_PRICING[key];
|
|
211
243
|
}
|
|
212
244
|
|
|
213
|
-
/** USD cost of a token usage record
|
|
214
|
-
export function costOfUsage(model: string, usage: TokenUsage): number {
|
|
215
|
-
const p = pricingFor(model);
|
|
245
|
+
/** USD cost of a token usage record at the rates in force at `at`. Unpriced models bill as 0. */
|
|
246
|
+
export function costOfUsage(model: string, usage: TokenUsage, at?: string): number {
|
|
247
|
+
const p = pricingFor(model, at);
|
|
216
248
|
if (!p) return 0;
|
|
217
249
|
return (
|
|
218
250
|
(usage.input * p.input +
|
package/src/hooks/lib/remote.ts
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
|
|
15
15
|
import { spawnSync } from "node:child_process";
|
|
16
16
|
|
|
17
|
+
/** Public PAL repository — the link surfaced in commit/PR co-author credits. */
|
|
18
|
+
export const PAL_REPO_URL = "https://github.com/kovrichard/portable-agent-layer";
|
|
19
|
+
|
|
17
20
|
const GIT_TIMEOUT_MS = 2000;
|
|
18
21
|
|
|
19
22
|
/**
|
package/src/hooks/lib/signals.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { appendFileSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import { currentAttribution } from "./actor";
|
|
3
|
+
import { currentAttribution, type RecordAttribution } from "./actor";
|
|
4
|
+
import { type AppendOptions, appendEvent } from "./events";
|
|
4
5
|
import { paths } from "./paths";
|
|
5
6
|
import { now } from "./time";
|
|
6
7
|
|
|
@@ -14,10 +15,58 @@ interface Signal {
|
|
|
14
15
|
function emitSignal(
|
|
15
16
|
filename: string,
|
|
16
17
|
data: { type: string; [key: string]: unknown }
|
|
17
|
-
):
|
|
18
|
+
): Signal {
|
|
18
19
|
const signal: Signal = { ts: now(), ...currentAttribution(), ...data };
|
|
19
20
|
const filepath = resolve(paths.signals(), filename);
|
|
20
21
|
appendFileSync(filepath, `${JSON.stringify(signal)}\n`);
|
|
22
|
+
return signal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RatingSignal extends Signal, Partial<RecordAttribution> {
|
|
26
|
+
rating: number;
|
|
27
|
+
context: string;
|
|
28
|
+
source: string;
|
|
29
|
+
response_preview?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const ratingEventId = (row: RatingSignal) =>
|
|
33
|
+
`rating:${row.machine ?? "unknown"}:${row.ts}`;
|
|
34
|
+
|
|
35
|
+
/** Logs a rating row as an event, under an id the row alone determines. */
|
|
36
|
+
export function logRatingEvent(
|
|
37
|
+
row: RatingSignal,
|
|
38
|
+
placement: Pick<AppendOptions, "attribution" | "cwd"> = {}
|
|
39
|
+
): string | null {
|
|
40
|
+
return appendEvent(
|
|
41
|
+
"rating",
|
|
42
|
+
{ rating: row.rating, source: row.source },
|
|
43
|
+
{
|
|
44
|
+
id: ratingEventId(row),
|
|
45
|
+
ts: row.ts,
|
|
46
|
+
session: null,
|
|
47
|
+
channel: null,
|
|
48
|
+
content: { context: row.context, responsePreview: row.response_preview ?? null },
|
|
49
|
+
...placement,
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseSignal(line: string): RatingSignal[] {
|
|
55
|
+
try {
|
|
56
|
+
return [JSON.parse(line) as RatingSignal];
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readRatings(): RatingSignal[] {
|
|
63
|
+
const filepath = resolve(paths.signals(), "ratings.jsonl");
|
|
64
|
+
if (!existsSync(filepath)) return [];
|
|
65
|
+
return readFileSync(filepath, "utf-8")
|
|
66
|
+
.split("\n")
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.flatMap(parseSignal)
|
|
69
|
+
.filter((row) => row.type === "rating");
|
|
21
70
|
}
|
|
22
71
|
|
|
23
72
|
/** Append a rating signal */
|
|
@@ -34,5 +83,6 @@ export function emitRating(
|
|
|
34
83
|
source,
|
|
35
84
|
...(responsePreview ? { response_preview: responsePreview } : {}),
|
|
36
85
|
};
|
|
37
|
-
emitSignal("ratings.jsonl", data);
|
|
86
|
+
const signal = emitSignal("ratings.jsonl", data) as RatingSignal;
|
|
87
|
+
logRatingEvent(signal);
|
|
38
88
|
}
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
* Used by both Claude Code (StopOrchestrator) and opencode (plugin).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
appendFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
7
13
|
import { dirname, resolve } from "node:path";
|
|
8
14
|
import { ensureDir, paths } from "./paths";
|
|
9
15
|
import { defaultSlug, readAllProjects, resolveProjectFromCwd } from "./projects";
|
|
@@ -41,9 +47,40 @@ function readSessions(): SessionRecord[] {
|
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
function sessionArchiveDir(): string {
|
|
51
|
+
return ensureDir(resolve(paths.state(), "sessions-archive"));
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
function sessionArchiveFile(record: SessionRecord): string {
|
|
45
55
|
const month = Number.isNaN(Date.parse(record.ts)) ? "undated" : record.ts.slice(0, 7);
|
|
46
|
-
return resolve(
|
|
56
|
+
return resolve(sessionArchiveDir(), `${month}.jsonl`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseArchiveLine(line: string): SessionRecord[] {
|
|
60
|
+
try {
|
|
61
|
+
return [JSON.parse(line) as SessionRecord];
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readArchivedSessions(): SessionRecord[] {
|
|
68
|
+
const dir = sessionArchiveDir();
|
|
69
|
+
return readdirSync(dir)
|
|
70
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
71
|
+
.flatMap((f) => readFileSync(resolve(dir, f), "utf-8").split("\n"))
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.flatMap(parseArchiveLine);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every session still on record, archived or current, each at its latest state. */
|
|
77
|
+
export function readSessionHistory(): SessionRecord[] {
|
|
78
|
+
const latest = new Map<string, SessionRecord>();
|
|
79
|
+
for (const record of [...readArchivedSessions(), ...readSessions()]) {
|
|
80
|
+
const seen = latest.get(record.sessionId);
|
|
81
|
+
if (!seen || seen.ts <= record.ts) latest.set(record.sessionId, record);
|
|
82
|
+
}
|
|
83
|
+
return [...latest.values()];
|
|
47
84
|
}
|
|
48
85
|
|
|
49
86
|
function archiveSessions(records: SessionRecord[]): void {
|
package/src/targets/lib.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
skillsDirOf,
|
|
27
27
|
} from "../hooks/lib/agent-registry";
|
|
28
28
|
import { agentDirOverrides, assets, palHome, platform } from "../hooks/lib/paths";
|
|
29
|
+
import { PAL_REPO_URL } from "../hooks/lib/remote";
|
|
29
30
|
import { declaredTriggers } from "../hooks/lib/skill-triggers";
|
|
30
31
|
import { agentFileName, renderAgentForPlatform } from "./agent-render";
|
|
31
32
|
|
|
@@ -118,9 +119,6 @@ export function writeJson(path: string, data: unknown): void {
|
|
|
118
119
|
|
|
119
120
|
// --- Git attribution ---
|
|
120
121
|
|
|
121
|
-
/** Public PAL repository — the link surfaced in commit/PR co-author credits. */
|
|
122
|
-
export const PAL_REPO_URL = "https://github.com/kovrichard/portable-agent-layer";
|
|
123
|
-
|
|
124
122
|
/**
|
|
125
123
|
* Build the commit footer (bare URL, autolinks on GitHub) and PR body line
|
|
126
124
|
* (markdown link). `sessionUrl` is off because Claude Code otherwise appends a
|