portable-agent-layer 0.94.0 → 0.95.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 (47) hide show
  1. package/README.md +2 -0
  2. package/assets/templates/hooks.antigravity.json +1 -1
  3. package/assets/templates/hooks.codex.json +11 -1
  4. package/assets/templates/hooks.copilot.json +11 -4
  5. package/assets/templates/hooks.cursor.json +8 -2
  6. package/assets/templates/settings.claude.json +55 -3
  7. package/package.json +1 -1
  8. package/src/cli/content.ts +23 -0
  9. package/src/cli/events.ts +47 -0
  10. package/src/cli/tree.ts +4 -0
  11. package/src/hooks/AttentionEvent.ts +22 -0
  12. package/src/hooks/LoadContext.ts +2 -2
  13. package/src/hooks/PostToolOrchestrator.ts +20 -0
  14. package/src/hooks/PreCompactPersist.ts +4 -2
  15. package/src/hooks/PreserveTranscripts.ts +13 -0
  16. package/src/hooks/SessionClose.ts +5 -0
  17. package/src/hooks/UserPromptOrchestrator.ts +9 -2
  18. package/src/hooks/lib/attention-events.ts +43 -0
  19. package/src/hooks/lib/auto-update.ts +24 -1
  20. package/src/hooks/lib/commitment-events.ts +20 -0
  21. package/src/hooks/lib/discord-events.ts +25 -0
  22. package/src/hooks/lib/entity-events.ts +16 -0
  23. package/src/hooks/lib/events.ts +252 -0
  24. package/src/hooks/lib/export.ts +1 -1
  25. package/src/hooks/lib/hook-timing.ts +33 -0
  26. package/src/hooks/lib/inference-events.ts +56 -0
  27. package/src/hooks/lib/inference.ts +17 -1
  28. package/src/hooks/lib/interaction.ts +74 -27
  29. package/src/hooks/lib/opencode-events.ts +59 -0
  30. package/src/hooks/lib/paths.ts +1 -0
  31. package/src/hooks/lib/post-tool.ts +66 -0
  32. package/src/hooks/lib/session-events.ts +211 -0
  33. package/src/hooks/lib/settings.ts +6 -0
  34. package/src/hooks/lib/stop.ts +27 -31
  35. package/src/hooks/lib/tool-events.ts +106 -0
  36. package/src/hooks/lib/transcript-preserve.ts +133 -0
  37. package/src/hooks/lib/turn-extras.ts +78 -0
  38. package/src/hooks/lib/waiting-on-user.ts +75 -0
  39. package/src/hooks/lib/work-tracking.ts +17 -3
  40. package/src/targets/opencode/plugin.ts +33 -1
  41. package/src/tools/agent/interaction.ts +2 -2
  42. package/src/tools/agent/project.ts +4 -0
  43. package/src/tools/control-room/snooze.ts +2 -0
  44. package/src/tools/control-room/writes.ts +2 -0
  45. package/src/tools/knowledge/lib.ts +5 -0
  46. package/src/hooks/LedgerCommit.ts +0 -34
  47. package/src/hooks/LedgerUnapplied.ts +0 -47
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The local event log: one append-only metadata stream every pattern, report
3
+ * and dashboard is derived from. Events hold counts, durations, ids and enums;
4
+ * any text goes to the content store, which never leaves the machine.
5
+ */
6
+
7
+ import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { currentAttribution } from "./actor";
10
+ import { logError } from "./log";
11
+ import { ensureDir, paths } from "./paths";
12
+ import { readAllProjects, resolveProjectFromCwd } from "./projects";
13
+ import { eventLogEnabled } from "./settings";
14
+
15
+ export const EVENT_SCHEMA_VERSION = 1;
16
+ const MAX_STRING = 64;
17
+ const MAX_ARRAY = 50;
18
+
19
+ export const EVENT_FIELDS = {
20
+ turn: [
21
+ "hour",
22
+ "weekday",
23
+ "words",
24
+ "gapSec",
25
+ "afterBreak",
26
+ "skimmed",
27
+ "interrupted",
28
+ "repeated",
29
+ "corrected",
30
+ "reply",
31
+ "reaction",
32
+ "reactionRules",
33
+ "mood",
34
+ "hinted",
35
+ "complied",
36
+ "language",
37
+ "pastedChars",
38
+ "images",
39
+ "command",
40
+ "backAfterSec",
41
+ ],
42
+ reply: ["words", "listItems", "headings", "asked", "mode", "latencySec"],
43
+ session_start: [
44
+ "source",
45
+ "model",
46
+ "effort",
47
+ "entrypoint",
48
+ "branch",
49
+ "palVersion",
50
+ "tz",
51
+ "utcOffsetMin",
52
+ ],
53
+ session_end: ["reason", "durationSec", "turns"],
54
+ compaction: ["trigger", "withInstructions", "contextTokens"],
55
+ hook_timing: ["hook", "totalMs", "handlers"],
56
+ inference_call: [
57
+ "caller",
58
+ "route",
59
+ "model",
60
+ "tier",
61
+ "web",
62
+ "latencyMs",
63
+ "failure",
64
+ "inputTokens",
65
+ "outputTokens",
66
+ ],
67
+ pal_update: ["outcome", "from", "to", "durationSec", "skipReason"],
68
+ commitment: ["iscProject", "isc", "outcome", "surface", "snoozeDays"],
69
+ entity_first_seen: ["domain", "entityType"],
70
+ discord_nag: ["stage", "kind", "responseSec"],
71
+ permission_prompt: ["tool"],
72
+ notification: ["kind"],
73
+ tool_call: [
74
+ "tool",
75
+ "outcome",
76
+ "promptWaitSec",
77
+ "durationMs",
78
+ "inputChars",
79
+ "outputChars",
80
+ "fileExt",
81
+ "program",
82
+ "skill",
83
+ "subagent",
84
+ "subagentTokens",
85
+ "subagentTools",
86
+ "mcpServer",
87
+ "host",
88
+ ],
89
+ } as const satisfies Record<string, readonly string[]>;
90
+
91
+ export type EventType = keyof typeof EVENT_FIELDS;
92
+ type EventValue =
93
+ | string
94
+ | number
95
+ | boolean
96
+ | null
97
+ | EventValue[]
98
+ | { [k: string]: EventValue };
99
+
100
+ interface EventEnvelope {
101
+ v: number;
102
+ id: string;
103
+ ts: string;
104
+ type: EventType;
105
+ session: string | null;
106
+ runtime: string;
107
+ machine: string;
108
+ actor: string;
109
+ authority: string;
110
+ channel: string | null;
111
+ project: string | null;
112
+ cwd: string;
113
+ }
114
+
115
+ export type LoggedEvent = EventEnvelope & Record<string, unknown>;
116
+
117
+ export interface AppendOptions {
118
+ id?: string;
119
+ ts?: string;
120
+ session?: string | null;
121
+ channel?: string | null;
122
+ cwd?: string;
123
+ content?: Record<string, unknown>;
124
+ }
125
+
126
+ function isEventValue(value: unknown, depth = 0): value is EventValue {
127
+ if (value === null || typeof value === "boolean") return true;
128
+ if (typeof value === "number") return Number.isFinite(value);
129
+ if (typeof value === "string") return value.length <= MAX_STRING;
130
+ if (depth > 1) return false;
131
+ if (Array.isArray(value))
132
+ return value.length <= MAX_ARRAY && value.every((v) => isEventValue(v, depth + 1));
133
+ if (typeof value === "object")
134
+ return Object.values(value).every((v) => isEventValue(v, depth + 1));
135
+ return false;
136
+ }
137
+
138
+ export function admittedFields(
139
+ type: EventType,
140
+ fields: Record<string, unknown>
141
+ ): Record<string, EventValue> {
142
+ const allowed: readonly string[] = EVENT_FIELDS[type];
143
+ const kept: Record<string, EventValue> = {};
144
+ for (const [name, value] of Object.entries(fields)) {
145
+ if (allowed.includes(name) && isEventValue(value)) kept[name] = value;
146
+ }
147
+ return kept;
148
+ }
149
+
150
+ const projectByCwd = new Map<string, string | null>();
151
+
152
+ function projectOf(cwd: string): string | null {
153
+ if (!projectByCwd.has(cwd)) {
154
+ projectByCwd.set(cwd, resolveProjectFromCwd(cwd, readAllProjects())?.name ?? null);
155
+ }
156
+ return projectByCwd.get(cwd) ?? null;
157
+ }
158
+
159
+ function monthOf(ts: string): string {
160
+ return ts.slice(0, 7);
161
+ }
162
+
163
+ function eventsDir(): string {
164
+ return ensureDir(resolve(paths.memory(), "events"));
165
+ }
166
+
167
+ function contentDir(): string {
168
+ return ensureDir(resolve(paths.content(), "events"));
169
+ }
170
+
171
+ function envelope(type: EventType, options: AppendOptions): EventEnvelope {
172
+ const cwd = options.cwd ?? process.cwd();
173
+ return {
174
+ v: EVENT_SCHEMA_VERSION,
175
+ id: options.id ?? crypto.randomUUID(),
176
+ ts: options.ts ?? new Date().toISOString(),
177
+ type,
178
+ session: options.session ?? null,
179
+ ...currentAttribution(),
180
+ channel:
181
+ options.channel !== undefined ? options.channel : process.env.PAL_CHANNEL || null,
182
+ project: projectOf(cwd),
183
+ cwd,
184
+ };
185
+ }
186
+
187
+ /** Appends one event and returns its id, or null when the log is off or the write failed. */
188
+ export function appendEvent(
189
+ type: EventType,
190
+ fields: Record<string, unknown>,
191
+ options: AppendOptions = {}
192
+ ): string | null {
193
+ if (!eventLogEnabled()) return null;
194
+ try {
195
+ const head = envelope(type, options);
196
+ const line = { ...head, ...admittedFields(type, fields) };
197
+ appendFileSync(
198
+ resolve(eventsDir(), `${monthOf(head.ts)}.jsonl`),
199
+ `${JSON.stringify(line)}\n`
200
+ );
201
+ if (options.content) {
202
+ const stored = { id: head.id, ts: head.ts, type, ...options.content };
203
+ appendFileSync(
204
+ resolve(contentDir(), `${monthOf(head.ts)}.jsonl`),
205
+ `${JSON.stringify(stored)}\n`
206
+ );
207
+ }
208
+ return head.id;
209
+ } catch (err) {
210
+ logError("events:append", err);
211
+ return null;
212
+ }
213
+ }
214
+
215
+ /** Runs a logging step so that nothing it does can fail the hook around it. */
216
+ export function logQuietly(label: string, write: () => void): void {
217
+ try {
218
+ write();
219
+ } catch (err) {
220
+ logError(`events:${label}`, err);
221
+ }
222
+ }
223
+
224
+ function parseLines(file: string): LoggedEvent[] {
225
+ if (!existsSync(file)) return [];
226
+ return readFileSync(file, "utf-8")
227
+ .split("\n")
228
+ .filter(Boolean)
229
+ .flatMap((line) => {
230
+ try {
231
+ return [JSON.parse(line) as LoggedEvent];
232
+ } catch {
233
+ return [];
234
+ }
235
+ });
236
+ }
237
+
238
+ export function eventsInMonth(type: EventType, month: Date): LoggedEvent[] {
239
+ const file = resolve(eventsDir(), `${monthOf(month.toISOString())}.jsonl`);
240
+ return parseLines(file).filter((e) => e.type === type);
241
+ }
242
+
243
+ export function eventsSince(type: EventType, since: Date): LoggedEvent[] {
244
+ const dir = eventsDir();
245
+ const sinceTs = since.toISOString();
246
+ const fromMonth = monthOf(sinceTs);
247
+ return readdirSync(dir)
248
+ .filter((f) => f.endsWith(".jsonl") && f.slice(0, 7) >= fromMonth)
249
+ .sort()
250
+ .flatMap((f) => parseLines(resolve(dir, f)))
251
+ .filter((e) => e.type === type && e.ts >= sinceTs);
252
+ }
@@ -20,7 +20,7 @@ import { palHome } from "./paths";
20
20
  const EXPORT_DIRS = ["telos", "memory", "skills", "agents"];
21
21
 
22
22
  /** Subdirectories/files to skip during export. */
23
- const SKIP_PATTERNS = ["memory/downloads"];
23
+ const SKIP_PATTERNS = ["memory/downloads", "memory/content"];
24
24
 
25
25
  function shouldSkip(relPath: string): boolean {
26
26
  const normalized = relPath.replaceAll("\\", "/");
@@ -0,0 +1,33 @@
1
+ /**
2
+ * How long a hook and each of its handlers took, logged as one event per run.
3
+ */
4
+
5
+ import { appendEvent, logQuietly } from "./events";
6
+
7
+ function elapsedMs(since: number): number {
8
+ return Math.round((performance.now() - since) * 10) / 10;
9
+ }
10
+
11
+ export function startHookTiming(hook: string) {
12
+ const started = performance.now();
13
+ const handlers: Record<string, number> = {};
14
+ return {
15
+ async time<T>(name: string, work: Promise<T> | (() => Promise<T> | T)): Promise<T> {
16
+ const begun = performance.now();
17
+ try {
18
+ return await (typeof work === "function" ? work() : work);
19
+ } finally {
20
+ handlers[name] = elapsedMs(begun);
21
+ }
22
+ },
23
+ log(session: string | null | undefined): void {
24
+ logQuietly("hook_timing", () =>
25
+ appendEvent(
26
+ "hook_timing",
27
+ { hook, totalMs: elapsedMs(started), handlers },
28
+ { session: session ?? null }
29
+ )
30
+ );
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * One event per inference PAL makes for itself: who asked, which route and
3
+ * model answered, how long it took and how it ended.
4
+ */
5
+
6
+ import { appendEvent, logQuietly } from "./events";
7
+
8
+ interface InferenceCall {
9
+ caller?: string;
10
+ sessionId?: string;
11
+ tier?: string;
12
+ web?: boolean;
13
+ }
14
+
15
+ interface InferenceOutcome {
16
+ success: boolean;
17
+ output?: string;
18
+ error?: string;
19
+ usage?: { inputTokens: number; outputTokens: number };
20
+ }
21
+
22
+ export const SCHEMA_MISMATCH = "the reply does not match the schema";
23
+ const NO_ROUTE = ["none", "disabled"];
24
+
25
+ function failureOf(outcome: InferenceOutcome, route: string): string | null {
26
+ if (outcome.success) return null;
27
+ if (NO_ROUTE.includes(route)) return "unavailable";
28
+ if (outcome.error?.startsWith(SCHEMA_MISMATCH)) return "schema";
29
+ if (outcome.output) return "unparsable";
30
+ return outcome.error ? "error" : "no-reply";
31
+ }
32
+
33
+ export function logInferenceCall(
34
+ call: InferenceCall,
35
+ planned: { route: string; model: string | null },
36
+ outcome: InferenceOutcome,
37
+ latencyMs: number
38
+ ): void {
39
+ logQuietly("inference_call", () =>
40
+ appendEvent(
41
+ "inference_call",
42
+ {
43
+ caller: call.caller ?? "anonymous",
44
+ route: planned.route,
45
+ model: planned.model,
46
+ tier: call.tier ?? "small",
47
+ web: Boolean(call.web),
48
+ latencyMs: Math.round(latencyMs),
49
+ failure: failureOf(outcome, planned.route),
50
+ inputTokens: outcome.usage?.inputTokens ?? null,
51
+ outputTokens: outcome.usage?.outputTokens ?? null,
52
+ },
53
+ { session: call.sessionId ?? null }
54
+ )
55
+ );
56
+ }
@@ -40,6 +40,7 @@ import {
40
40
  streamJsonPrompt,
41
41
  writeInferenceAgent,
42
42
  } from "./antigravity-inference";
43
+ import { logInferenceCall, SCHEMA_MISMATCH } from "./inference-events";
43
44
  import { logDebug, logError } from "./log";
44
45
  import {
45
46
  type FixedModelRoute,
@@ -188,7 +189,22 @@ function schemaProblem(
188
189
  .join("; ");
189
190
  }
190
191
 
192
+ function plannedRoute(opts: InferenceOptions): { route: string; model: string | null } {
193
+ const { route } = previewRoute();
194
+ if (route === "opencode-spawn")
195
+ return { route, model: opencodeTierModel(opts.tier ?? "small") };
196
+ if (isFixedModelRoute(route)) return { route, model: modelFor(route, opts) };
197
+ return { route, model: null };
198
+ }
199
+
191
200
  export async function inference(opts: InferenceOptions): Promise<InferenceResult> {
201
+ const started = performance.now();
202
+ const result = await checkedInference(opts);
203
+ logInferenceCall(opts, plannedRoute(opts), result, performance.now() - started);
204
+ return result;
205
+ }
206
+
207
+ async function checkedInference(opts: InferenceOptions): Promise<InferenceResult> {
192
208
  const result = await routeInference(opts);
193
209
  if (!opts.jsonSchema || !result.success || !result.output) return result;
194
210
  const problem = schemaProblem(result.output, opts.jsonSchema);
@@ -200,7 +216,7 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
200
216
  return {
201
217
  ...result,
202
218
  success: false,
203
- error: `the reply does not match the schema: ${problem}`,
219
+ error: `${SCHEMA_MISMATCH}: ${problem}`,
204
220
  };
205
221
  }
206
222
 
@@ -1,18 +1,13 @@
1
1
  /**
2
- * Measures every user turn in code and logs the features, never the text.
2
+ * Measures every user turn in code and logs the features as turn events; the
3
+ * prompt text goes only to the content store, which never leaves the machine.
3
4
  * Per-session tracking lives in state and is discarded with the session.
4
5
  */
5
6
 
6
- import {
7
- appendFileSync,
8
- existsSync,
9
- readdirSync,
10
- readFileSync,
11
- writeFileSync,
12
- } from "node:fs";
7
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
13
8
  import { resolve } from "node:path";
14
- import { currentAttribution } from "./actor";
15
9
  import { hearsPromptContext } from "./agent";
10
+ import { appendEvent, eventsInMonth, eventsSince, logQuietly } from "./events";
16
11
  import { asksForLess, followedHint, ignoredHintReminder } from "./interaction-hint-check";
17
12
  import {
18
13
  type Baseline,
@@ -34,6 +29,8 @@ import { keepSample, replyEnd } from "./interaction-samples";
34
29
  import { ensureDir, paths } from "./paths";
35
30
  import { isSystemText, stripInjectedTags } from "./prompt-text";
36
31
  import { isEnabled } from "./settings";
32
+ import { responseModeOf, turnExtras } from "./turn-extras";
33
+ import { backAfterNotificationSec } from "./waiting-on-user";
37
34
  import { localClock } from "./wall-clock";
38
35
 
39
36
  interface ReplyShape {
@@ -135,20 +132,51 @@ function measureTurn(
135
132
  };
136
133
  }
137
134
 
138
- function eventsDir(): string {
135
+ function legacyTurnsDir(): string {
139
136
  return ensureDir(resolve(paths.signals(), "interaction"));
140
137
  }
141
138
 
142
- function monthFile(date: Date): string {
143
- return resolve(eventsDir(), `${date.toISOString().slice(0, 7)}.jsonl`);
139
+ function logTurn(event: TurnEvent, prompt: string, text: string): void {
140
+ const { ts, session, channel, ...fields } = event;
141
+ logQuietly("turn", () =>
142
+ appendEvent(
143
+ "turn",
144
+ {
145
+ ...fields,
146
+ ...turnExtras(prompt, text),
147
+ backAfterSec: backAfterNotificationSec(session, new Date(ts)),
148
+ },
149
+ { ts, session, channel, content: { prompt: text } }
150
+ )
151
+ );
144
152
  }
145
153
 
146
- function appendEvent(event: TurnEvent): void {
147
- const line = { ...currentAttribution(), type: "turn", ...event };
148
- appendFileSync(monthFile(new Date(event.ts)), `${JSON.stringify(line)}\n`);
154
+ function secondsBetween(from: string | undefined, to: Date): number | null {
155
+ if (!from) return null;
156
+ return Math.round((to.getTime() - new Date(from).getTime()) / 100) / 10;
149
157
  }
150
158
 
151
- function readEvents(file: string): TurnEvent[] {
159
+ function logReply(
160
+ session: string,
161
+ reply: string,
162
+ shape: ReplyShape,
163
+ track: SessionTrack
164
+ ) {
165
+ const { at, ...features } = shape;
166
+ logQuietly("reply", () =>
167
+ appendEvent(
168
+ "reply",
169
+ {
170
+ ...features,
171
+ mode: responseModeOf(reply),
172
+ latencySec: secondsBetween(track.lastPromptAt, new Date(at)),
173
+ },
174
+ { ts: at, session, content: { reply } }
175
+ )
176
+ );
177
+ }
178
+
179
+ function readLegacyTurns(file: string): TurnEvent[] {
152
180
  if (!existsSync(file)) return [];
153
181
  return readFileSync(file, "utf-8")
154
182
  .split("\n")
@@ -162,20 +190,33 @@ function readEvents(file: string): TurnEvent[] {
162
190
  });
163
191
  }
164
192
 
165
- export function turnsSince(since: Date): TurnEvent[] {
193
+ function legacyTurnsSince(since: Date): TurnEvent[] {
166
194
  const fromMonth = since.toISOString().slice(0, 7);
167
195
  const sinceTs = since.toISOString();
168
- return readdirSync(eventsDir())
196
+ return readdirSync(legacyTurnsDir())
169
197
  .filter((f) => f.endsWith(".jsonl") && f.slice(0, 7) >= fromMonth)
170
198
  .sort()
171
- .flatMap((f) => readEvents(resolve(eventsDir(), f)))
199
+ .flatMap((f) => readLegacyTurns(resolve(legacyTurnsDir(), f)))
172
200
  .filter((e) => e.ts >= sinceTs);
173
201
  }
174
202
 
203
+ export function turnsSince(since: Date): TurnEvent[] {
204
+ const streamed = eventsSince("turn", since) as unknown as TurnEvent[];
205
+ return [...legacyTurnsSince(since), ...streamed].sort((a, b) =>
206
+ a.ts.localeCompare(b.ts)
207
+ );
208
+ }
209
+
210
+ function turnsInMonth(month: Date): TurnEvent[] {
211
+ 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];
214
+ }
215
+
175
216
  /** This month and last, outside the session being judged. */
176
217
  function historyOutside(session: string, now: Date): TurnEvent[] {
177
218
  const lastMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
178
- return [...readEvents(monthFile(lastMonth)), ...readEvents(monthFile(now))].filter(
219
+ return [...turnsInMonth(lastMonth), ...turnsInMonth(now)].filter(
179
220
  (e) => e.session !== session
180
221
  );
181
222
  }
@@ -234,10 +275,12 @@ export function recordReply(
234
275
  ): void {
235
276
  if (!session || !reply.trim() || !isEnabled("interactionAwareness")) return;
236
277
  const track = trackOf(session);
278
+ const shape = replyShape(reply, now);
279
+ logReply(session, reply, shape, track);
237
280
  writeTrack(session, {
238
281
  ...track,
239
282
  updated: now.toISOString(),
240
- reply: replyShape(reply, now),
283
+ reply: shape,
241
284
  replyEnd: replyEnd(reply),
242
285
  });
243
286
  }
@@ -295,12 +338,16 @@ export function observeTurn(
295
338
  moodReminder(mood, track.mood ?? ""),
296
339
  reminderForIgnoredHint(complied, mood.key, replyWords, usualReply, history)
297
340
  );
298
- appendEvent({
299
- ...event,
300
- mood: mood.key,
301
- hinted: reminder !== null && hearsPromptContext(),
302
- ...(complied !== null && { complied }),
303
- });
341
+ logTurn(
342
+ {
343
+ ...event,
344
+ mood: mood.key,
345
+ hinted: reminder !== null && hearsPromptContext(),
346
+ ...(complied !== null && { complied }),
347
+ },
348
+ prompt,
349
+ text
350
+ );
304
351
  sampleReaction(event, text, track);
305
352
  writeTrack(session, {
306
353
  ...track,
@@ -0,0 +1,59 @@
1
+ /**
2
+ * opencode's bus events as PAL events. The plugin lives as long as opencode,
3
+ * so a permission's tool is remembered from its ask until its reply.
4
+ */
5
+
6
+ import { logCompaction, logSessionStart } from "./session-events";
7
+ import { logToolCall } from "./tool-events";
8
+
9
+ interface OpencodeSession {
10
+ id?: string;
11
+ parentID?: string;
12
+ }
13
+
14
+ interface OpencodePermission {
15
+ id?: string;
16
+ type?: string;
17
+ }
18
+
19
+ /** v1 of opencode's SDK names these permissionID/response, v2 requestID/reply. */
20
+ interface OpencodePermissionReply {
21
+ sessionID?: string;
22
+ permissionID?: string;
23
+ requestID?: string;
24
+ response?: string;
25
+ reply?: string;
26
+ }
27
+
28
+ const askedTools = new Map<string, string>();
29
+
30
+ export function logOpencodeSessionCreated(session: OpencodeSession | undefined): void {
31
+ if (!session?.id || session.parentID) return;
32
+ logSessionStart({ session_id: session.id, source: "startup" });
33
+ }
34
+
35
+ export function logOpencodeCompaction(sessionID: string | undefined): void {
36
+ logCompaction({ session_id: sessionID });
37
+ }
38
+
39
+ export function rememberAskedTool(permission: OpencodePermission): void {
40
+ if (permission.id && permission.type) askedTools.set(permission.id, permission.type);
41
+ }
42
+
43
+ function takeAskedTool(reply: OpencodePermissionReply): string | undefined {
44
+ const id = reply.permissionID ?? reply.requestID;
45
+ if (!id) return undefined;
46
+ const tool = askedTools.get(id);
47
+ askedTools.delete(id);
48
+ return tool;
49
+ }
50
+
51
+ export function logOpencodePermissionReply(reply: OpencodePermissionReply): void {
52
+ const tool = takeAskedTool(reply);
53
+ if ((reply.response ?? reply.reply) !== "reject" || !tool) return;
54
+ logToolCall({
55
+ hook_event_name: "PermissionDenied",
56
+ tool_name: tool,
57
+ session_id: reply.sessionID,
58
+ });
59
+ }
@@ -111,6 +111,7 @@ export const paths = {
111
111
  home("memory", "learning", "reflections", "algorithm-reflections.jsonl"),
112
112
  retrievalIndex: () => home("memory", "learning", ".retrieval-index.json"),
113
113
  ledger: () => ensureDir(home("memory", "ledger")),
114
+ content: () => ensureDir(home("memory", "content")),
114
115
  progress: () => ensureDir(home("memory", "state", "progress")),
115
116
  projectHistory: () => ensureDir(home("memory", "projects")),
116
117
  unboundHistory: () => ensureDir(home("memory", "state", "unbound-history")),
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Everything PAL does once a tool call has ended: the tool_call event, and the
3
+ * ledger's second half. A failure or a denial is recorded as unapplied, anything
4
+ * else as applied.
5
+ *
6
+ * Not every attempt that fails to land reaches here. A manual denial at the
7
+ * permission dialog, a `deny` rule and a PreToolUse block fire nothing after
8
+ * PreToolUse, and a schema rejection fires no hook at all; the reaper in
9
+ * ledger.ts claims the snapshots those leave behind.
10
+ */
11
+
12
+ import { reapStalePending } from "./ledger";
13
+ import {
14
+ commitApplied,
15
+ commitUnapplied,
16
+ landedCalls,
17
+ ledgeredCalls,
18
+ type UnappliedVerdict,
19
+ unappliedVerdictOf,
20
+ } from "./ledger-hook";
21
+ import { logDebug } from "./log";
22
+ import { logToolCall } from "./tool-events";
23
+
24
+ type Payload = Record<string, unknown>;
25
+
26
+ function recordApplied(payload: Payload): void {
27
+ for (const call of landedCalls(payload)) {
28
+ const entry = commitApplied(call);
29
+ if (entry) logDebug("post-tool", `applied ${entry.id} ${entry.tool} ${entry.target}`);
30
+ }
31
+ }
32
+
33
+ function recordUnapplied(payload: Payload, verdict: UnappliedVerdict): void {
34
+ const calls = ledgeredCalls(payload);
35
+ if (calls.length === 0) return;
36
+ for (const call of calls) {
37
+ const entry = commitUnapplied(call, verdict);
38
+ logDebug("post-tool", `${entry.outcome} ${entry.id} ${entry.target}`);
39
+ }
40
+ reapStalePending();
41
+ }
42
+
43
+ function recordInLedger(payload: Payload): void {
44
+ const verdict = unappliedVerdictOf(payload);
45
+ if (verdict) recordUnapplied(payload, verdict);
46
+ else recordApplied(payload);
47
+ }
48
+
49
+ const HANDLERS: Record<string, (payload: Payload) => void> = {
50
+ events: logToolCall,
51
+ ledger: recordInLedger,
52
+ };
53
+
54
+ /**
55
+ * Runs every handler, or the one an `--only=<name>` registration asks for:
56
+ * Claude Code runs the ledger sync on edits and the events async on every tool.
57
+ */
58
+ export function runPostToolHandlers(
59
+ payload: Payload,
60
+ argv: string[] = process.argv
61
+ ): void {
62
+ const only = argv.find((a) => a.startsWith("--only="))?.slice("--only=".length);
63
+ for (const [name, handle] of Object.entries(HANDLERS)) {
64
+ if (!only || only === name) handle(payload);
65
+ }
66
+ }