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.
Files changed (38) hide show
  1. package/README.md +1 -0
  2. package/package.json +2 -2
  3. package/src/cli/cost.ts +18 -0
  4. package/src/cli/events.ts +22 -0
  5. package/src/cli/tree.ts +2 -0
  6. package/src/hooks/lib/commitment-events.ts +3 -3
  7. package/src/hooks/lib/entity-events.ts +18 -5
  8. package/src/hooks/lib/events.ts +54 -4
  9. package/src/hooks/lib/interaction.ts +25 -5
  10. package/src/hooks/lib/ledger.ts +61 -2
  11. package/src/hooks/lib/models.ts +42 -10
  12. package/src/hooks/lib/remote.ts +3 -0
  13. package/src/hooks/lib/signals.ts +54 -4
  14. package/src/hooks/lib/work-tracking.ts +39 -2
  15. package/src/targets/lib.ts +1 -3
  16. package/src/tools/agent/project.ts +7 -2
  17. package/src/tools/knowledge/lib.ts +11 -2
  18. package/src/tools/lib/backfill/entities.ts +31 -0
  19. package/src/tools/lib/backfill/git.ts +184 -0
  20. package/src/tools/lib/backfill/index.ts +38 -0
  21. package/src/tools/lib/backfill/ledger.ts +28 -0
  22. package/src/tools/lib/backfill/projects.ts +40 -0
  23. package/src/tools/lib/backfill/promises.ts +91 -0
  24. package/src/tools/lib/backfill/ratings.ts +21 -0
  25. package/src/tools/lib/backfill/rows.ts +33 -0
  26. package/src/tools/lib/backfill/sessions.ts +56 -0
  27. package/src/tools/lib/backfill/turns.ts +32 -0
  28. package/src/tools/lib/collectors/billing.ts +105 -0
  29. package/src/tools/lib/collectors/claude.ts +61 -21
  30. package/src/tools/lib/collectors/codex.ts +6 -3
  31. package/src/tools/lib/collectors/copilot.ts +2 -0
  32. package/src/tools/lib/collectors/index.ts +8 -1
  33. package/src/tools/lib/collectors/opencode.ts +10 -2
  34. package/src/tools/lib/cost-report.ts +134 -0
  35. package/src/tools/lib/plan-prices.ts +24 -0
  36. package/src/tools/lib/project-history.ts +100 -0
  37. package/src/tools/lib/session-usage.ts +11 -7
  38. 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.97.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.34.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",
@@ -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: completing it keeps it, retiring it drops it, and a
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
- domain: string,
10
- entityType: string,
11
- name: string
19
+ entity: SeenEntity,
20
+ options: AppendOptions = {}
12
21
  ): void {
13
22
  logQuietly("entity_first_seen", () =>
14
- appendEvent("entity_first_seen", { domain, entityType }, { content: { name } })
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
  }
@@ -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: cwd ? projectOf(cwd) : null,
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 { appendEvent, eventsInMonth, eventsSince, logQuietly } from "./events";
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
- const streamed = eventsSince("turn", since) as unknown as TurnEvent[];
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
- const streamed = eventsInMonth("turn", month) as unknown as TurnEvent[];
213
- return [...readLegacyTurns(resolve(legacyTurnsDir(), file)), ...streamed];
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. */
@@ -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
 
@@ -107,7 +107,15 @@ export interface TokenUsage {
107
107
  cacheRead: number;
108
108
  }
109
109
 
110
- /** Pricing per million tokens (USD) — from https://platform.claude.com/docs/en/about-claude/pricing */
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
- // Claude Sonnet 5 — introductory pricing through 2026-08-31; standard (3/15/3.75/6/0.30) applies from 2026-09-01.
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 exact = MODEL_PRICING[model];
208
- if (exact) return exact;
209
- const prefix = longestPrefixKey(model);
210
- return prefix ? MODEL_PRICING[prefix] : null;
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. Unpriced models bill as 0. */
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 +
@@ -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
  /**
@@ -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
- ): void {
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 { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
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(ensureDir(resolve(paths.state(), "sessions-archive")), `${month}.jsonl`);
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 {
@@ -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