portable-agent-layer 0.102.0 → 0.103.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.102.0",
3
+ "version": "0.103.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/cost.ts CHANGED
@@ -1,11 +1,31 @@
1
1
  import { labelOfRuntime } from "../hooks/lib/agent-registry";
2
- import { eventsSince } from "../hooks/lib/events";
2
+ import { allEventsSince } from "../hooks/lib/events";
3
3
  import { leaf } from "../tools/lib/command";
4
+ import { efficiency } from "../tools/lib/cost/efficiency";
5
+ import { leaderboards } from "../tools/lib/cost/leaderboards";
6
+ import { modelMix, palSpend } from "../tools/lib/cost/mix";
7
+ import { contextOverhead } from "../tools/lib/cost/overhead";
8
+ import {
9
+ efficiencyLines,
10
+ leaderboardLines,
11
+ overheadLines,
12
+ shareLines,
13
+ } from "../tools/lib/cost/render";
4
14
  import { costLines, costMonths } from "../tools/lib/cost-report";
15
+ import { ofType } from "../tools/lib/wrapped/year";
5
16
 
6
17
  function printCost(): undefined {
7
- const months = costMonths(eventsSince("model_call", new Date(0)));
8
- for (const line of costLines(months, labelOfRuntime)) console.log(line);
18
+ const events = allEventsSince(new Date(0));
19
+ const months = costMonths(ofType(events, "model_call"));
20
+ const lines = [
21
+ ...costLines(months, labelOfRuntime),
22
+ ...leaderboardLines(leaderboards(events)),
23
+ ...efficiencyLines(efficiency(events)),
24
+ ...shareLines("Model mix", modelMix(events)),
25
+ ...shareLines("PAL's own spend per caller, already in the totals", palSpend(events)),
26
+ ...overheadLines(contextOverhead(events)),
27
+ ];
28
+ for (const line of lines) console.log(line);
9
29
  }
10
30
 
11
31
  export const costCommand = leaf({
@@ -8,23 +8,21 @@
8
8
  * Which agent gets what, in which envelope, is in lib/session-context.ts.
9
9
  */
10
10
 
11
- import { mkdirSync, writeFileSync } from "node:fs";
12
- import { resolve } from "node:path";
13
11
  import { getActiveAgent } from "./lib/agent";
14
12
  import { autoUpdateOnStart } from "./lib/auto-update";
15
13
  import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
16
14
  import { type AgentTarget, buildSystemReminder } from "./lib/context";
15
+ import { logContextInjected } from "./lib/context-overhead";
17
16
  import { duplicatesCursorHooks } from "./lib/cursor-shadow";
18
17
  import { logContextSnapshot, logDebug, logError } from "./lib/log";
19
- import { platform } from "./lib/paths";
20
18
  import {
21
19
  contextEnvelope,
22
20
  contextForSource,
23
- copilotInstructions,
24
21
  isSubagentSession,
25
22
  needsAgentsMd,
23
+ writeCopilotInstructions,
26
24
  } from "./lib/session-context";
27
- import { logSessionStart } from "./lib/session-events";
25
+ import { logSessionStart, type SessionStartPayload } from "./lib/session-events";
28
26
  import { isPalSpawnedInference } from "./lib/spawn-guard";
29
27
  import { readStdinJSON } from "./lib/stdin";
30
28
 
@@ -49,7 +47,7 @@ try {
49
47
 
50
48
  try {
51
49
  const active = getActiveAgent();
52
- const input = await readStdinJSON<{ source?: string }>();
50
+ const input = await readStdinJSON<SessionStartPayload>();
53
51
  logSessionStart(input);
54
52
  const wanted = contextForSource(active, input?.source);
55
53
  if (wanted === "none") {
@@ -75,15 +73,11 @@ try {
75
73
  );
76
74
  if (!envelope) process.exit(0);
77
75
 
78
- if (envelope.file) {
79
- const dir = resolve(platform.copilotDir(), "instructions");
80
- mkdirSync(dir, { recursive: true });
81
- const path = resolve(dir, "pal-session.instructions.md");
82
- writeFileSync(path, copilotInstructions(envelope.file), "utf-8");
83
- }
76
+ if (envelope.file) writeCopilotInstructions(envelope.file);
84
77
 
85
78
  if (envelope.kind === "text") console.log(envelope.payload);
86
79
  else process.stdout.write(envelope.payload);
80
+ logContextInjected(input, reminder);
87
81
  logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
88
82
  } catch (err) {
89
83
  logError("LoadContext:reminder", err);
@@ -0,0 +1,30 @@
1
+ import { buildClaudeMd } from "./claude-md";
2
+ import { appendEvent, logQuietly } from "./events";
3
+ import { type HookTurnPayload, hookSessionId } from "./hook-turn";
4
+ import { getSemiStaticSources, readFileSafe } from "./semi-static";
5
+
6
+ function semiStaticChars(): number {
7
+ return getSemiStaticSources().reduce(
8
+ (sum, source) => sum + readFileSafe(source.path).length,
9
+ 0
10
+ );
11
+ }
12
+
13
+ /** Chars, not tokens: no tokenizer runs at session start. */
14
+ export function logContextInjected(
15
+ payload: HookTurnPayload | null,
16
+ reminder: string,
17
+ now: Date = new Date()
18
+ ): void {
19
+ logQuietly("context_injected", () =>
20
+ appendEvent(
21
+ "context_injected",
22
+ {
23
+ instructionsChars: buildClaudeMd().length,
24
+ semiStaticChars: semiStaticChars(),
25
+ dynamicChars: reminder.length,
26
+ },
27
+ { ts: now.toISOString(), session: hookSessionId(payload) ?? null }
28
+ )
29
+ );
30
+ }
@@ -54,6 +54,7 @@ export const EVENT_FIELDS = {
54
54
  ],
55
55
  session_end: ["reason", "durationSec", "turns", "messages", "status"],
56
56
  compaction: ["trigger", "withInstructions", "contextTokens"],
57
+ context_injected: ["instructionsChars", "semiStaticChars", "dynamicChars"],
57
58
  hook_timing: ["hook", "totalMs", "handlers"],
58
59
  inference_call: [
59
60
  "caller",
@@ -48,7 +48,7 @@ export function antigravityRule(description: string, content: string): string {
48
48
  return `---\ntrigger: always_on\ndescription: ${description}\n---\n\n${content}`;
49
49
  }
50
50
 
51
- function readFileSafe(path: string): string {
51
+ export function readFileSafe(path: string): string {
52
52
  try {
53
53
  if (!existsSync(path)) return "";
54
54
  return readFileSync(path, "utf-8").trim();
@@ -7,7 +7,10 @@
7
7
  * context for weeks — and none of it was reachable from a test.
8
8
  */
9
9
 
10
+ import { mkdirSync, writeFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
10
12
  import type { AgentName } from "./agent-registry";
13
+ import { platform } from "./paths";
11
14
 
12
15
  export interface ContextEnvelope {
13
16
  /** Whether the agent parses stdout as JSON or reads it as raw text. */
@@ -96,3 +99,10 @@ export function contextEnvelope(
96
99
  export function copilotInstructions(context: string): string {
97
100
  return `---\napplyTo: "**"\n---\n\n${context}`;
98
101
  }
102
+
103
+ export function writeCopilotInstructions(context: string): void {
104
+ const dir = resolve(platform.copilotDir(), "instructions");
105
+ mkdirSync(dir, { recursive: true });
106
+ const path = resolve(dir, "pal-session.instructions.md");
107
+ writeFileSync(path, copilotInstructions(context), "utf-8");
108
+ }
@@ -20,7 +20,7 @@ const SESSION_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
20
20
  const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
21
21
  const MAX_FIELD = 64;
22
22
 
23
- interface SessionStartPayload extends HookTurnPayload {
23
+ export interface SessionStartPayload extends HookTurnPayload {
24
24
  source?: string;
25
25
  model?: unknown;
26
26
  }
@@ -0,0 +1,58 @@
1
+ import type { LoggedEvent } from "../../../hooks/lib/events";
2
+ import { apiPriceOf } from "../cost-report";
3
+ import { dayOf, numberOf, ofType } from "../wrapped/year";
4
+
5
+ export interface PerUnit {
6
+ cost: number;
7
+ units: number;
8
+ lowerBound: boolean;
9
+ }
10
+
11
+ export interface Efficiency {
12
+ since: string;
13
+ perCommit: PerUnit | null;
14
+ perClosedIsc: PerUnit | null;
15
+ cacheHitRate: number | null;
16
+ }
17
+
18
+ const PROMPT_FIELDS = [
19
+ "inputTokens",
20
+ "cacheRead",
21
+ "cacheWrite5m",
22
+ "cacheWrite1h",
23
+ ] as const;
24
+
25
+ function cacheHitRate(calls: LoggedEvent[]): number | null {
26
+ let read = 0;
27
+ let prompt = 0;
28
+ for (const call of calls) {
29
+ for (const field of PROMPT_FIELDS) prompt += numberOf(call[field]) ?? 0;
30
+ read += numberOf(call.cacheRead) ?? 0;
31
+ }
32
+ return prompt === 0 ? null : read / prompt;
33
+ }
34
+
35
+ function perUnit(spent: number, unpriced: number, units: number): PerUnit | null {
36
+ return units === 0 ? null : { cost: spent / units, units, lowerBound: unpriced > 0 };
37
+ }
38
+
39
+ /** Only since the first logged model call: older commits and ISCs cost nothing PAL saw. */
40
+ export function efficiency(events: LoggedEvent[]): Efficiency | null {
41
+ const calls = ofType(events, "model_call");
42
+ const [first] = [...calls].sort((a, b) => a.ts.localeCompare(b.ts));
43
+ if (first === undefined) return null;
44
+ const since = (e: LoggedEvent) => e.ts >= first.ts;
45
+ const prices = calls.map(apiPriceOf);
46
+ const spent = prices.reduce<number>((sum, p) => sum + (p ?? 0), 0);
47
+ const unpriced = prices.filter((p) => p === null).length;
48
+ const commits = ofType(events, "commit").filter(since).length;
49
+ const closed = ofType(events, "commitment").filter(
50
+ (e) => since(e) && e.outcome === "kept"
51
+ ).length;
52
+ return {
53
+ since: dayOf(first),
54
+ perCommit: perUnit(spent, unpriced, commits),
55
+ perClosedIsc: perUnit(spent, unpriced, closed),
56
+ cacheHitRate: cacheHitRate(calls),
57
+ };
58
+ }
@@ -0,0 +1,81 @@
1
+ import type { LoggedEvent } from "../../../hooks/lib/events";
2
+ import { apiPriceOf } from "../cost-report";
3
+ import { turnCost, turnSpans, whereAndWhen } from "../wrapped/records";
4
+ import { dayOf, ofType, tally } from "../wrapped/year";
5
+
6
+ export interface Entry {
7
+ label: string;
8
+ cost: number;
9
+ unpriced: number;
10
+ }
11
+
12
+ export interface Leaderboards {
13
+ projects: Entry[];
14
+ sessions: Entry[];
15
+ days: Entry[];
16
+ turns: Entry[];
17
+ }
18
+
19
+ const TOP = 5;
20
+
21
+ const mostExpensive = (entries: Entry[]) =>
22
+ entries.sort((a, b) => b.cost - a.cost || a.label.localeCompare(b.label)).slice(0, TOP);
23
+
24
+ function board(calls: LoggedEvent[], labelOf: (call: LoggedEvent) => string): Entry[] {
25
+ const entries = new Map<string, Entry>();
26
+ for (const call of calls) {
27
+ const label = labelOf(call);
28
+ const entry = entries.get(label) ?? { label, cost: 0, unpriced: 0 };
29
+ entries.set(label, entry);
30
+ const price = apiPriceOf(call);
31
+ if (price === null) entry.unpriced++;
32
+ else entry.cost += price;
33
+ }
34
+ return mostExpensive([...entries.values()]);
35
+ }
36
+
37
+ const projectOf = (call: LoggedEvent) => call.project ?? "no project";
38
+
39
+ function sessionLabel(session: string | null, own: LoggedEvent[]): string {
40
+ const [project] = tally(
41
+ own.filter((call) => call.project !== null),
42
+ projectOf
43
+ );
44
+ const [first] = own.sort((a, b) => a.ts.localeCompare(b.ts));
45
+ const id = session?.slice(0, 8) ?? "no session";
46
+ return `${id} (${project?.name ?? "no project"}, ${dayOf(first)})`;
47
+ }
48
+
49
+ /** A session is named by the project most of its calls ran in, and the day it started. */
50
+ function sessionLabels(calls: LoggedEvent[]): Map<string | null, string> {
51
+ const bySession = new Map<string | null, LoggedEvent[]>();
52
+ for (const call of calls) {
53
+ const own = bySession.get(call.session) ?? [];
54
+ bySession.set(call.session, own);
55
+ own.push(call);
56
+ }
57
+ return new Map(
58
+ [...bySession].map(([session, own]) => [session, sessionLabel(session, own)])
59
+ );
60
+ }
61
+
62
+ function turnBoard(events: LoggedEvent[]): Entry[] {
63
+ return mostExpensive(
64
+ turnSpans(events).flatMap((span) => {
65
+ const cost = turnCost(span);
66
+ return cost === null ? [] : [{ label: whereAndWhen(span.turn), cost, unpriced: 0 }];
67
+ })
68
+ );
69
+ }
70
+
71
+ /** Every model call counts, PAL's own spawns included; its inference_call twins never do. */
72
+ export function leaderboards(events: LoggedEvent[]): Leaderboards {
73
+ const calls = ofType(events, "model_call");
74
+ const sessions = sessionLabels(calls);
75
+ return {
76
+ projects: board(calls, projectOf),
77
+ sessions: board(calls, (call) => String(sessions.get(call.session))),
78
+ days: board(calls, dayOf),
79
+ turns: turnBoard(events),
80
+ };
81
+ }
@@ -0,0 +1,50 @@
1
+ import type { LoggedEvent } from "../../../hooks/lib/events";
2
+ import { apiPriceOf } from "../cost-report";
3
+ import { numberOf, ofType } from "../wrapped/year";
4
+
5
+ export interface Share {
6
+ label: string;
7
+ calls: number;
8
+ cost: number;
9
+ unpriced: number;
10
+ }
11
+
12
+ function shares(
13
+ calls: LoggedEvent[],
14
+ labelOf: (call: LoggedEvent) => string,
15
+ priceOf: (call: LoggedEvent) => number | null
16
+ ): Share[] {
17
+ const byLabel = new Map<string, Share>();
18
+ for (const call of calls) {
19
+ const label = labelOf(call);
20
+ const share = byLabel.get(label) ?? { label, calls: 0, cost: 0, unpriced: 0 };
21
+ byLabel.set(label, share);
22
+ share.calls++;
23
+ const price = priceOf(call);
24
+ if (price === null) share.unpriced++;
25
+ else share.cost += price;
26
+ }
27
+ return [...byLabel.values()].sort(
28
+ (a, b) => b.cost - a.cost || b.calls - a.calls || a.label.localeCompare(b.label)
29
+ );
30
+ }
31
+
32
+ const textOr = (value: unknown, fallback: string) =>
33
+ typeof value === "string" ? value : fallback;
34
+
35
+ export function modelMix(events: LoggedEvent[]): Share[] {
36
+ return shares(
37
+ ofType(events, "model_call"),
38
+ (call) => textOr(call.model, "unknown model"),
39
+ apiPriceOf
40
+ );
41
+ }
42
+
43
+ /** Already inside the model calls PAL spawned; never add it to a total. */
44
+ export function palSpend(events: LoggedEvent[]): Share[] {
45
+ return shares(
46
+ ofType(events, "inference_call"),
47
+ (call) => textOr(call.caller, "unknown caller"),
48
+ (call) => numberOf(call.costUsd)
49
+ );
50
+ }
@@ -0,0 +1,29 @@
1
+ import type { LoggedEvent } from "../../../hooks/lib/events";
2
+ import { dayOf, numberOf, ofType } from "../wrapped/year";
3
+
4
+ type Tier = "instructionsChars" | "semiStaticChars" | "dynamicChars";
5
+
6
+ export interface Overhead {
7
+ since: string;
8
+ sessions: number;
9
+ perSession: Record<Tier, number>;
10
+ }
11
+
12
+ /** A compacted session is injected again; its injections add up. */
13
+ export function contextOverhead(events: LoggedEvent[]): Overhead | null {
14
+ const injections = ofType(events, "context_injected");
15
+ const [first] = [...injections].sort((a, b) => a.ts.localeCompare(b.ts));
16
+ if (first === undefined) return null;
17
+ const sessions = new Set(injections.map((e) => e.session ?? e.id)).size;
18
+ const average = (tier: Tier) =>
19
+ injections.reduce((sum, e) => sum + (numberOf(e[tier]) ?? 0), 0) / sessions;
20
+ return {
21
+ since: dayOf(first),
22
+ sessions,
23
+ perSession: {
24
+ instructionsChars: average("instructionsChars"),
25
+ semiStaticChars: average("semiStaticChars"),
26
+ dynamicChars: average("dynamicChars"),
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,70 @@
1
+ import { count, counted } from "../report-rows";
2
+ import { fmtCost } from "../token-report";
3
+ import type { Efficiency, PerUnit } from "./efficiency";
4
+ import type { Entry, Leaderboards } from "./leaderboards";
5
+ import type { Share } from "./mix";
6
+ import type { Overhead } from "./overhead";
7
+
8
+ function entryLine({ label, cost, unpriced }: Entry): string {
9
+ const extra = unpriced > 0 ? ` + ${count(unpriced)} unpriced calls` : "";
10
+ return ` ${fmtCost(cost).padStart(10)} ${label}${extra}`;
11
+ }
12
+
13
+ function boardLines(title: string, entries: Entry[]): string[] {
14
+ const lines = entries.length === 0 ? [" unknown"] : entries.map(entryLine);
15
+ return [`\n Most expensive ${title}\n`, ...lines];
16
+ }
17
+
18
+ export function leaderboardLines(boards: Leaderboards): string[] {
19
+ return [
20
+ ...boardLines("projects", boards.projects),
21
+ ...boardLines("sessions", boards.sessions),
22
+ ...boardLines("days", boards.days),
23
+ ...boardLines("turns", boards.turns),
24
+ ];
25
+ }
26
+
27
+ function perUnitText(per: PerUnit | null, unit: string): string {
28
+ if (per === null) return "unknown";
29
+ const atLeast = per.lowerBound ? "at least " : "";
30
+ return `${atLeast}${fmtCost(per.cost)} over ${counted(per.units, unit)}`;
31
+ }
32
+
33
+ const percent = (rate: number | null) =>
34
+ rate === null ? "unknown" : `${(rate * 100).toFixed(1)}%`;
35
+
36
+ export function efficiencyLines(efficiency: Efficiency | null): string[] {
37
+ if (efficiency === null) return [];
38
+ return [
39
+ `\n Since ${efficiency.since}\n`,
40
+ ` Per commit ${perUnitText(efficiency.perCommit, "commit")}`,
41
+ ` Per closed ISC ${perUnitText(efficiency.perClosedIsc, "ISC")}`,
42
+ ` Cache hit rate ${percent(efficiency.cacheHitRate)}`,
43
+ ];
44
+ }
45
+
46
+ function shareLine({ label, calls, cost, unpriced }: Share): string {
47
+ const price = unpriced === calls ? "unknown" : fmtCost(cost);
48
+ const extra = unpriced > 0 && unpriced < calls ? `, ${count(unpriced)} unpriced` : "";
49
+ return ` ${price.padStart(10)} ${label} ${counted(calls, "call")}${extra}`;
50
+ }
51
+
52
+ const chars = (n: number) => `${count(Math.round(n))} chars`.padStart(14);
53
+
54
+ export function overheadLines(overhead: Overhead | null): string[] {
55
+ const title = "\n PAL context per session\n";
56
+ if (overhead === null) return [title, " unknown"];
57
+ const { instructionsChars, semiStaticChars, dynamicChars } = overhead.perSession;
58
+ return [
59
+ `\n PAL context per session, over ${counted(overhead.sessions, "session")} since ${overhead.since}\n`,
60
+ ` Instructions ${chars(instructionsChars)}`,
61
+ ` Semi-static ${chars(semiStaticChars)}`,
62
+ ` Dynamic ${chars(dynamicChars)}`,
63
+ ` Total ${chars(instructionsChars + semiStaticChars + dynamicChars)}`,
64
+ ];
65
+ }
66
+
67
+ export function shareLines(title: string, shares: Share[]): string[] {
68
+ const lines = shares.length === 0 ? [" unknown"] : shares.map(shareLine);
69
+ return [`\n ${title}\n`, ...lines];
70
+ }
@@ -11,4 +11,7 @@ export function tallyLabel(tallies: Tally[]): string {
11
11
  return tallies.map((t) => `${t.name} ${count(t.count)}`).join(" · ");
12
12
  }
13
13
 
14
- export const days = (n: number) => `${count(n)} ${n === 1 ? "day" : "days"}`;
14
+ export const counted = (n: number, noun: string) =>
15
+ `${count(n)} ${noun}${n === 1 ? "" : "s"}`;
16
+
17
+ export const days = (n: number) => counted(n, "day");
@@ -65,13 +65,13 @@ function longestAgentRun(events: LoggedEvent[]): Best | null {
65
65
  );
66
66
  }
67
67
 
68
- interface TurnSpan {
68
+ export interface TurnSpan {
69
69
  turn: LoggedEvent;
70
70
  inside: LoggedEvent[];
71
71
  }
72
72
 
73
73
  /** Each user turn and what happened in its session until the next one. */
74
- function turnSpans(events: LoggedEvent[]): TurnSpan[] {
74
+ export function turnSpans(events: LoggedEvent[]): TurnSpan[] {
75
75
  const open = new Map<string, TurnSpan>();
76
76
  const spans: TurnSpan[] = [];
77
77
  const inTimeOrder = [...events].sort((a, b) => a.ts.localeCompare(b.ts));
@@ -86,11 +86,11 @@ function turnSpans(events: LoggedEvent[]): TurnSpan[] {
86
86
  return spans;
87
87
  }
88
88
 
89
- const whereAndWhen = (turn: LoggedEvent) =>
89
+ export const whereAndWhen = (turn: LoggedEvent) =>
90
90
  turn.project === null ? dayOf(turn) : `${dayOf(turn)}, ${turn.project}`;
91
91
 
92
92
  /** A turn with any call PAL cannot price is left out rather than undercounted. */
93
- function turnCost({ inside }: TurnSpan): number | null {
93
+ export function turnCost({ inside }: TurnSpan): number | null {
94
94
  const prices = ofType(inside, "model_call").map(apiPriceOf);
95
95
  if (prices.length === 0 || prices.includes(null)) return null;
96
96
  return prices.reduce<number>((sum, p) => sum + (p ?? 0), 0);