portable-agent-layer 0.81.1 → 0.83.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.
@@ -55,7 +55,7 @@ Almost everything in `state/`, `signals/`, `relationship/opinions.json`, `learni
55
55
  | A reusable session insight | Session-intelligence handler (automatic) | `learning/session/YYYY-MM/*.md` |
56
56
  | A failure worth learning from | Failure-principle handler (automatic, low ratings) | `learning/failures/YYYY-MM/*.md` |
57
57
  | A rating signal | UserPromptOrchestrator rating capture | `signals/ratings.jsonl` |
58
- | How a user turn went (pace, length, skimming, how it received the last reply) | Prompt-time interaction measurement (automatic) | `signals/interaction/YYYY-MM.jsonl` |
58
+ | How a user turn went (pace, length, skimming, how it received the last reply) | Prompt-time interaction measurement (automatic, every agent; see the per-agent turn contract in SYSTEM_ARCHITECTURE.md); read it with `pal cli interaction report` | `signals/interaction/YYYY-MM.jsonl` |
59
59
  | A rolling sample of labelled messages, for auditing the reaction rules | Prompt-time interaction measurement (automatic, `reactionSampling`) | `state/reaction-samples.json` |
60
60
  | A project handoff | Project skill / detached handlers | `projects/<slug>/` |
61
61
 
@@ -250,6 +250,21 @@ Brief description.
250
250
  └─────────────────────┘
251
251
  ```
252
252
 
253
+ ### Per-Agent Turn Contract
254
+
255
+ Every agent's turns are measured and its replies filed under the session id the agent sent. What differs is whether the prompt hook can hand context back, and where the final reply comes from.
256
+
257
+ | Agent | Prompt event → context per turn | Final reply from |
258
+ |-------|---------------------------------|------------------|
259
+ | Claude Code | `UserPromptSubmit` → plain text | `Stop` transcript |
260
+ | Codex | `UserPromptSubmit` → `hookSpecificOutput.additionalContext` | `Stop` `last_assistant_message` |
261
+ | VS Code | `UserPromptSubmit` → `hookSpecificOutput.additionalContext` | `Stop` transcript |
262
+ | opencode | `chat.message` → synthetic part | `session.idle` session messages |
263
+ | Cursor | `beforeSubmitPrompt` → `additional_context`, undocumented | `afterAgentResponse` `text` (`AgentResponse.ts`) |
264
+ | Copilot CLI | `userPromptTransformed` → `modifiedTransformedPrompt` (`PromptTransformed.ts`) | `agentStop` transcript |
265
+
266
+ The Copilot CLI drops what its `userPromptSubmitted` hook returns, so that hook only measures the turn and parks the context; `userPromptTransformed` fires next and appends it to the prompt the model receives. Cursor documents no context field on `beforeSubmitPrompt`, so PAL offers `additional_context` there but logs no interaction hint as sent until it is shown to reach the model; its documented context channel is session start. `test/agent-turns.test.ts` holds each agent to this table.
267
+
253
268
  ### Design Principles
254
269
 
255
270
  - **Fail-open**: Hook errors never block the user's session
@@ -15,6 +15,13 @@
15
15
  "powershell": "bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts --agent=copilot"
16
16
  }
17
17
  ],
18
+ "userPromptTransformed": [
19
+ {
20
+ "type": "command",
21
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/PromptTransformed.ts --agent=copilot",
22
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/PromptTransformed.ts --agent=copilot"
23
+ }
24
+ ],
18
25
  "preToolUse": [
19
26
  {
20
27
  "type": "command",
@@ -49,6 +49,12 @@
49
49
  "command": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=cursor"
50
50
  }
51
51
  ],
52
+ "afterAgentResponse": [
53
+ {
54
+ "type": "command",
55
+ "command": "bun run {{PKG_ROOT}}/src/hooks/AgentResponse.ts --agent=cursor"
56
+ }
57
+ ],
52
58
  "stop": [
53
59
  {
54
60
  "type": "command",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.81.1",
3
+ "version": "0.83.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@ const BUILTIN_TOOLS = {
11
11
  "algorithm-synthesize": () => import("../tools/agent/algorithm-synthesize"),
12
12
  analyze: () => import("../tools/agent/analyze"),
13
13
  "handoff-note": () => import("../tools/agent/handoff-note"),
14
+ interaction: () => import("../tools/agent/interaction"),
14
15
  project: () => import("../tools/agent/project"),
15
16
  "relationship-note": () => import("../tools/agent/relationship-note"),
16
17
  "relationship-reflect": () => import("../tools/agent/relationship-reflect"),
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Hook: afterAgentResponse (Cursor) — files the final reply against the turn it
3
+ * answered. Cursor's stop event carries no reply, this one does.
4
+ */
5
+
6
+ import { fileFinalReply, type HookTurnPayload } from "./lib/hook-turn";
7
+ import { isPalSpawnedInference } from "./lib/spawn-guard";
8
+ import { readStdinJSON } from "./lib/stdin";
9
+
10
+ if (isPalSpawnedInference()) process.exit(0);
11
+
12
+ fileFinalReply(await readStdinJSON<HookTurnPayload>());
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Hook: userPromptTransformed (Copilot CLI) — appends the context the prompt
3
+ * hook parked to the prompt the model receives.
4
+ */
5
+
6
+ import {
7
+ type TransformedPromptPayload,
8
+ transformedPromptResponse,
9
+ } from "./lib/parked-context";
10
+ import { isPalSpawnedInference } from "./lib/spawn-guard";
11
+ import { readStdinJSON } from "./lib/stdin";
12
+
13
+ if (isPalSpawnedInference()) process.exit(0);
14
+
15
+ const response = transformedPromptResponse(
16
+ await readStdinJSON<TransformedPromptPayload>()
17
+ );
18
+ if (response) process.stdout.write(response);
@@ -3,7 +3,6 @@
3
3
  * Fans out to independent handlers via Promise.allSettled.
4
4
  *
5
5
  * stdin: JSON object with { session_id, transcript_path, last_assistant_message, ... }
6
- * Transcript is read from the file at transcript_path, NOT from stdin.
7
6
  */
8
7
 
9
8
  import { checkReadmeSync } from "./handlers/readme-sync";
@@ -11,22 +10,12 @@ import { blockResponse, isCodex, isCursor } from "./lib/agent";
11
10
  import { logError } from "./lib/log";
12
11
  import { isPalSpawnedInference } from "./lib/spawn-guard";
13
12
  import { readStdinJSON } from "./lib/stdin";
14
- import { runStopHandlers } from "./lib/stop";
15
- import { readTranscriptFile } from "./lib/transcript";
13
+ import { type StopTurnPayload, stopTurn } from "./lib/stop";
16
14
 
17
15
  // Recursion guard — spawned inference subprocesses must not record session
18
16
  // learning, ratings, or handoffs from their throwaway transcript.
19
17
  if (isPalSpawnedInference()) process.exit(0);
20
18
 
21
- interface StopHookInput {
22
- session_id?: string;
23
- sessionId?: string; // Copilot uses camelCase
24
- transcript_path?: string;
25
- transcriptPath?: string; // Copilot uses camelCase
26
- last_assistant_message?: string;
27
- lastAssistantMessage?: string; // Copilot uses camelCase
28
- }
29
-
30
19
  // Check README sync before anything else — may block the session
31
20
  try {
32
21
  // A block carrying no reason stops the turn without telling the model why, so it
@@ -50,23 +39,4 @@ try {
50
39
  logError("StopOrchestrator:readme-sync", err);
51
40
  }
52
41
 
53
- const input = await readStdinJSON<StopHookInput>();
54
- const transcriptPath = input?.transcript_path ?? input?.transcriptPath;
55
- const sessionId = input?.session_id ?? input?.sessionId;
56
- const lastAssistant = input?.last_assistant_message ?? input?.lastAssistantMessage;
57
-
58
- if (!transcriptPath) {
59
- logError("StopOrchestrator", "No transcript_path in hook input");
60
- process.exit(0);
61
- }
62
-
63
- // Read the actual transcript from the file on disk
64
- const messages = readTranscriptFile(transcriptPath);
65
- if (messages.length < 2) process.exit(0);
66
-
67
- // Serialize and run handlers
68
- const transcript = JSON.stringify(messages);
69
- await runStopHandlers(transcript, {
70
- lastAssistantMessage: lastAssistant,
71
- sessionId,
72
- });
42
+ await stopTurn(await readStdinJSON<StopTurnPayload>());
@@ -10,6 +10,7 @@
10
10
  import { injectPromptContext } from "./handlers/inject-retrieval";
11
11
  import { captureRating } from "./handlers/rating";
12
12
  import { captureSessionName } from "./handlers/session-name";
13
+ import { type HookTurnPayload, hookSessionId } from "./lib/hook-turn";
13
14
  import { logDebug, logError, logPromptSnapshot } from "./lib/log";
14
15
  import { isPalSpawnedInference } from "./lib/spawn-guard";
15
16
  import { readStdinJSON } from "./lib/stdin";
@@ -18,18 +19,15 @@ import { readStdinJSON } from "./lib/stdin";
18
19
  // payload, not a real user message. Skip rating capture, session naming, etc.
19
20
  if (isPalSpawnedInference()) process.exit(0);
20
21
 
21
- interface PromptSubmitInput {
22
+ interface PromptSubmitInput extends HookTurnPayload {
22
23
  prompt: string;
23
- session_id?: string;
24
- sessionId?: string; // Copilot sends this (camelCase) instead of session_id
25
- conversation_id?: string; // Cursor sends this instead of session_id
26
24
  }
27
25
 
28
26
  const input = await readStdinJSON<PromptSubmitInput>();
29
27
  logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)}`);
30
28
  if (!input?.prompt) process.exit(0);
31
29
 
32
- const sessionId = input.session_id ?? input.sessionId ?? input.conversation_id;
30
+ const sessionId = hookSessionId(input);
33
31
  const injected = await injectPromptContext(input.prompt, sessionId);
34
32
  logPromptSnapshot(input.prompt, injected);
35
33
 
@@ -7,9 +7,10 @@
7
7
  * produces empty output, never blocks the prompt.
8
8
  */
9
9
 
10
- import { isCodex, isCursor } from "../lib/agent";
10
+ import { promptContextResponse } from "../lib/agent";
11
11
  import { observeTurn } from "../lib/interaction";
12
12
  import { logDebug, logError } from "../lib/log";
13
+ import { parkPromptContext } from "../lib/parked-context";
13
14
  import { runRetrieval } from "../lib/retrieval";
14
15
  import { ensureIndex } from "../lib/retrieval-index";
15
16
  import { isEnabled } from "../lib/settings";
@@ -60,25 +61,12 @@ export async function getRetrievalReminder(prompt: string): Promise<string | nul
60
61
  return result.reminder;
61
62
  }
62
63
 
63
- /** Write a reminder to stdout in the correct format for the current agent.
64
- * Claude Code: plain text. Cursor: { additional_context }. Codex: hookSpecificOutput JSON.
65
- * MUST be called at most once per hook run — Cursor/Codex expect a single JSON
66
- * object on stdout, so all prompt-time context is merged before this call. */
67
- function writeForAgent(reminder: string): void {
68
- if (isCursor()) {
69
- process.stdout.write(JSON.stringify({ additional_context: reminder }));
70
- } else if (isCodex()) {
71
- process.stdout.write(
72
- JSON.stringify({
73
- hookSpecificOutput: {
74
- hookEventName: "UserPromptSubmit",
75
- additionalContext: reminder,
76
- },
77
- })
78
- );
79
- } else {
80
- process.stdout.write(`${reminder}\n`);
81
- }
64
+ /** MUST be called at most once per hook run — the JSON shapes are a single object
65
+ * on stdout, so all prompt-time context is merged before this call. */
66
+ function writeForAgent(reminder: string, sessionId?: string): void {
67
+ const response = promptContextResponse(reminder);
68
+ if (response) process.stdout.write(response);
69
+ else if (sessionId) parkPromptContext(sessionId, reminder);
82
70
  }
83
71
 
84
72
  /** Merge every prompt-time source — the wall clock, contextual steering, skill
@@ -108,6 +96,6 @@ export async function injectPromptContext(
108
96
  sessionId?: string
109
97
  ): Promise<string | null> {
110
98
  const combined = await getPromptContext(prompt, sessionId);
111
- if (combined) writeForAgent(combined);
99
+ if (combined) writeForAgent(combined, sessionId);
112
100
  return combined;
113
101
  }
@@ -245,6 +245,28 @@ function claudeBlock(reason: string, hookEventName?: string): string {
245
245
  });
246
246
  }
247
247
 
248
+ /** Cursor's beforeSubmitPrompt documents no context field. */
249
+ export const hearsPromptContext = () => !isCursor();
250
+
251
+ /**
252
+ * How a prompt hook hands context to the model, in the shape this agent reads.
253
+ * Null for the Copilot CLI, which drops a userPromptSubmitted hook's output and
254
+ * takes the context on userPromptTransformed instead.
255
+ */
256
+ export function promptContextResponse(context: string): string | null {
257
+ if (isCopilot()) return null;
258
+ if (isCursor()) return JSON.stringify({ additional_context: context });
259
+ if (isCodex() || isVscode()) {
260
+ return JSON.stringify({
261
+ hookSpecificOutput: {
262
+ hookEventName: "UserPromptSubmit",
263
+ additionalContext: context,
264
+ },
265
+ });
266
+ }
267
+ return `${context}\n`;
268
+ }
269
+
248
270
  export function blockResponse(reason: string, hookEventName?: string): string {
249
271
  if (isCursor()) {
250
272
  return JSON.stringify({ permission: "deny", user_message: reason });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The fields each agent's prompt and stop hooks use for the same things.
3
+ * Claude Code, Codex and VS Code send snake_case, the Copilot CLI camelCase, and
4
+ * Cursor names the session `conversation_id` and hands its final reply to
5
+ * `afterAgentResponse` as `text`.
6
+ */
7
+
8
+ import { recordReply } from "./interaction";
9
+
10
+ export interface HookTurnPayload {
11
+ session_id?: string;
12
+ sessionId?: string;
13
+ conversation_id?: string;
14
+ last_assistant_message?: string | null;
15
+ lastAssistantMessage?: string | null;
16
+ text?: string;
17
+ }
18
+
19
+ export function hookSessionId(payload: HookTurnPayload | null | undefined) {
20
+ return payload?.session_id ?? payload?.sessionId ?? payload?.conversation_id;
21
+ }
22
+
23
+ export function hookFinalReply(payload: HookTurnPayload | null | undefined) {
24
+ return (
25
+ payload?.last_assistant_message ??
26
+ payload?.lastAssistantMessage ??
27
+ payload?.text ??
28
+ undefined
29
+ );
30
+ }
31
+
32
+ export function fileFinalReply(payload: HookTurnPayload | null | undefined): void {
33
+ const sessionId = hookSessionId(payload);
34
+ const reply = hookFinalReply(payload);
35
+ if (sessionId && reply) recordReply(sessionId, reply);
36
+ }
@@ -3,9 +3,16 @@
3
3
  * Per-session tracking lives in state and is discarded with the session.
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 { resolve } from "node:path";
8
14
  import { currentAttribution } from "./actor";
15
+ import { hearsPromptContext } from "./agent";
9
16
  import {
10
17
  type Baseline,
11
18
  DEFAULT_BASELINE,
@@ -42,6 +49,8 @@ export interface TurnEvent extends MoodTurn {
42
49
  weekday: string;
43
50
  reply: Omit<ReplyShape, "at"> | null;
44
51
  reaction: Reaction | null;
52
+ mood?: string;
53
+ hinted?: boolean;
45
54
  }
46
55
 
47
56
  interface SessionTrack {
@@ -146,6 +155,16 @@ function readEvents(file: string): TurnEvent[] {
146
155
  });
147
156
  }
148
157
 
158
+ export function turnsSince(since: Date): TurnEvent[] {
159
+ const fromMonth = since.toISOString().slice(0, 7);
160
+ const sinceTs = since.toISOString();
161
+ return readdirSync(eventsDir())
162
+ .filter((f) => f.endsWith(".jsonl") && f.slice(0, 7) >= fromMonth)
163
+ .sort()
164
+ .flatMap((f) => readEvents(resolve(eventsDir(), f)))
165
+ .filter((e) => e.ts >= sinceTs);
166
+ }
167
+
149
168
  /** The user's own normal, from this month and last, outside the session being judged. */
150
169
  function baseline(session: string, now: Date = new Date()): Baseline {
151
170
  const lastMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
@@ -231,10 +250,15 @@ export function observeTurn(
231
250
  if (!text) return null;
232
251
  const track = trackOf(session);
233
252
  const event = measureTurn(text, session, track, now, channel);
234
- appendEvent(event);
235
- sampleReaction(event, text, track);
236
253
  const recent = [...track.recent, event].slice(-RECENT_KEPT);
237
254
  const mood = readMood(recent, baseline(session, now));
255
+ const reminder = moodReminder(mood, track.mood ?? "");
256
+ appendEvent({
257
+ ...event,
258
+ mood: mood.key,
259
+ hinted: reminder !== null && hearsPromptContext(),
260
+ });
261
+ sampleReaction(event, text, track);
238
262
  writeTrack(session, {
239
263
  ...track,
240
264
  updated: now.toISOString(),
@@ -243,5 +267,5 @@ export function observeTurn(
243
267
  recent,
244
268
  mood: mood.key,
245
269
  });
246
- return moodReminder(mood, track.mood ?? "");
270
+ return reminder;
247
271
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The Copilot CLI drops what its userPromptSubmitted hook returns, but lets the
3
+ * userPromptTransformed hook that follows rewrite the prompt the model receives.
4
+ * The prompt hook parks its context here; the transform hook appends it.
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { type HookTurnPayload, hookSessionId } from "./hook-turn";
10
+ import { logError } from "./log";
11
+ import { ensureDir, paths } from "./paths";
12
+
13
+ const MAX_AGE_MS = 2 * 60_000;
14
+
15
+ interface Parked {
16
+ context: string;
17
+ at: string;
18
+ }
19
+
20
+ export interface TransformedPromptPayload extends HookTurnPayload {
21
+ transformedPrompt?: string;
22
+ }
23
+
24
+ function parkedPath(): string {
25
+ return resolve(ensureDir(paths.state()), "parked-prompt-context.json");
26
+ }
27
+
28
+ function readParked(): Record<string, Parked> {
29
+ const path = parkedPath();
30
+ if (!existsSync(path)) return {};
31
+ try {
32
+ return JSON.parse(readFileSync(path, "utf-8"));
33
+ } catch {
34
+ return {};
35
+ }
36
+ }
37
+
38
+ function isFresh(parked: Parked, now: Date): boolean {
39
+ return now.getTime() - new Date(parked.at).getTime() <= MAX_AGE_MS;
40
+ }
41
+
42
+ function writeFresh(parked: Record<string, Parked>, now: Date): void {
43
+ const fresh = Object.entries(parked).filter(([, p]) => isFresh(p, now));
44
+ writeFileSync(parkedPath(), JSON.stringify(Object.fromEntries(fresh)), "utf-8");
45
+ }
46
+
47
+ export function parkPromptContext(session: string, context: string, now = new Date()) {
48
+ try {
49
+ writeFresh({ ...readParked(), [session]: { context, at: now.toISOString() } }, now);
50
+ } catch (err) {
51
+ logError("parkPromptContext", err);
52
+ }
53
+ }
54
+
55
+ function takePromptContext(session: string, now: Date): string | null {
56
+ const parked = readParked();
57
+ const entry = parked[session];
58
+ if (!entry) return null;
59
+ delete parked[session];
60
+ writeFresh(parked, now);
61
+ return isFresh(entry, now) ? entry.context : null;
62
+ }
63
+
64
+ /** The userPromptTransformed reply that appends the parked context, or null to leave the prompt alone. */
65
+ export function transformedPromptResponse(
66
+ payload: TransformedPromptPayload | null,
67
+ now = new Date()
68
+ ): string | null {
69
+ const session = hookSessionId(payload);
70
+ const facing = payload?.transformedPrompt;
71
+ if (!session || !facing) return null;
72
+ const context = takePromptContext(session, now);
73
+ if (!context) return null;
74
+ return JSON.stringify({ modifiedTransformedPrompt: `${facing}\n\n${context}` });
75
+ }
@@ -20,6 +20,12 @@ import { resetTab } from "../handlers/tab";
20
20
  import { updateCounts } from "../handlers/update-counts";
21
21
  import { captureWorkSession } from "../handlers/work-session";
22
22
  import { spawnDetachedInference } from "./detached-inference";
23
+ import {
24
+ fileFinalReply,
25
+ type HookTurnPayload,
26
+ hookFinalReply,
27
+ hookSessionId,
28
+ } from "./hook-turn";
23
29
  import { recordReply } from "./interaction";
24
30
  import { logDebug, logError } from "./log";
25
31
  import { assets, ensureDir, paths } from "./paths";
@@ -27,9 +33,30 @@ import {
27
33
  extractContent,
28
34
  extractLastAssistant,
29
35
  parseMessages,
36
+ readTranscriptFile,
30
37
  withFinalReply,
31
38
  } from "./transcript";
32
39
 
40
+ export interface StopTurnPayload extends HookTurnPayload {
41
+ transcript_path?: string | null;
42
+ transcriptPath?: string | null;
43
+ }
44
+
45
+ /** Everything a stop hook does once the agent's payload is read. */
46
+ export async function stopTurn(payload: StopTurnPayload | null): Promise<void> {
47
+ const transcriptPath = payload?.transcript_path ?? payload?.transcriptPath;
48
+ const messages = transcriptPath ? readTranscriptFile(transcriptPath) : [];
49
+ if (messages.length >= 2) {
50
+ await runStopHandlers(JSON.stringify(messages), {
51
+ lastAssistantMessage: hookFinalReply(payload),
52
+ sessionId: hookSessionId(payload),
53
+ });
54
+ return;
55
+ }
56
+ logDebug("stopTurn", "No readable transcript; filing only the final reply");
57
+ fileFinalReply(payload);
58
+ }
59
+
33
60
  interface RunStopHandlersOptions {
34
61
  lastAssistantMessage?: string;
35
62
  sessionId?: string;
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Interaction report — how the user's turns went, and whether replies changed
4
+ * while a measured label was in the agent's context.
5
+ *
6
+ * Usage: pal cli interaction report [--days N]
7
+ */
8
+
9
+ import { parseArgs } from "node:util";
10
+ import { turnsSince } from "../../hooks/lib/interaction";
11
+ import { reportLines, summarize } from "../lib/interaction-report";
12
+ import { scriptArgs } from "../lib/script-args";
13
+
14
+ const HELP = `
15
+ PAL Interaction report
16
+
17
+ Reads the measured turns in memory/signals/interaction/ (features only, no
18
+ text) and prints turns, channels, reactions, the hints sent, and for each
19
+ label (short, long, fast, skimming, friction) how the replies written while
20
+ it was active compare with the replies written while none was.
21
+
22
+ Usage: pal cli interaction report [--days N] (default 7)
23
+ `;
24
+
25
+ const DAY_MS = 86_400_000;
26
+
27
+ export function run(argv: string[] = scriptArgs()) {
28
+ const { values, positionals } = parseArgs({
29
+ args: argv,
30
+ allowPositionals: true,
31
+ options: {
32
+ help: { type: "boolean", short: "h" },
33
+ days: { type: "string", default: "7" },
34
+ },
35
+ });
36
+ const days = Number(values.days);
37
+ if (values.help || positionals[0] !== "report" || !(days > 0)) {
38
+ console.log(HELP);
39
+ return;
40
+ }
41
+ const events = turnsSince(new Date(Date.now() - days * DAY_MS));
42
+ for (const line of reportLines(summarize(events), days)) console.log(line);
43
+ }
44
+
45
+ if (import.meta.main) run();
@@ -0,0 +1,144 @@
1
+ /**
2
+ * What `pal cli interaction report` says: how the user's turns went, and whether
3
+ * the agent's replies changed while a measured label was in its context.
4
+ */
5
+
6
+ import type { TurnEvent } from "../../hooks/lib/interaction";
7
+ import { median } from "../../hooks/lib/interaction-mood";
8
+
9
+ const LABELS = ["short", "long", "fast", "skimming", "friction"] as const;
10
+
11
+ interface ReplyUnderMood {
12
+ labels: string[];
13
+ words: number;
14
+ approved: boolean;
15
+ corrected: boolean;
16
+ }
17
+
18
+ interface ReplyStats {
19
+ replies: number;
20
+ medianWords: number | null;
21
+ approvedShare: number;
22
+ correctedShare: number;
23
+ }
24
+
25
+ export interface InteractionSummary {
26
+ turns: number;
27
+ sessions: number;
28
+ channels: Record<string, number>;
29
+ reactions: Record<string, number>;
30
+ hints: Record<string, number>;
31
+ unlogged: number;
32
+ usual: ReplyStats;
33
+ byLabel: Record<string, ReplyStats>;
34
+ }
35
+
36
+ function tally(values: string[]): Record<string, number> {
37
+ const counts: Record<string, number> = {};
38
+ for (const v of values) counts[v] = (counts[v] ?? 0) + 1;
39
+ return counts;
40
+ }
41
+
42
+ function share(replies: ReplyUnderMood[], test: (r: ReplyUnderMood) => boolean): number {
43
+ return replies.length ? replies.filter(test).length / replies.length : 0;
44
+ }
45
+
46
+ function stats(replies: ReplyUnderMood[]): ReplyStats {
47
+ return {
48
+ replies: replies.length,
49
+ medianWords: median(replies.map((r) => r.words)),
50
+ approvedShare: share(replies, (r) => r.approved),
51
+ correctedShare: share(replies, (r) => r.corrected),
52
+ };
53
+ }
54
+
55
+ /** The reply to a turn arrives with the session's next turn, written under that turn's mood. */
56
+ function repliesUnderMood(events: TurnEvent[]): ReplyUnderMood[] {
57
+ const lastMood = new Map<string, string | undefined>();
58
+ const replies: ReplyUnderMood[] = [];
59
+ for (const e of events) {
60
+ const mood = lastMood.get(e.session);
61
+ if (e.reply && mood !== undefined)
62
+ replies.push({
63
+ labels: mood ? mood.split(",") : [],
64
+ words: e.reply.words,
65
+ approved: e.reaction === "approved",
66
+ corrected: e.reaction === "corrected" || e.reaction === "repeated",
67
+ });
68
+ lastMood.set(e.session, e.mood);
69
+ }
70
+ return replies;
71
+ }
72
+
73
+ export function summarize(events: TurnEvent[]): InteractionSummary {
74
+ const replies = repliesUnderMood(events);
75
+ const hinted = events.filter((e) => e.hinted);
76
+ return {
77
+ turns: events.length,
78
+ sessions: new Set(events.map((e) => e.session)).size,
79
+ channels: tally(events.map((e) => e.channel ?? "unknown")),
80
+ reactions: tally(events.flatMap((e) => (e.reaction ? [e.reaction] : []))),
81
+ hints: tally(hinted.map((e) => e.mood || "back to usual")),
82
+ unlogged: events.filter((e) => e.mood === undefined).length,
83
+ usual: stats(replies.filter((r) => r.labels.length === 0)),
84
+ byLabel: Object.fromEntries(
85
+ LABELS.map((label) => [
86
+ label,
87
+ stats(replies.filter((r) => r.labels.includes(label))),
88
+ ])
89
+ ),
90
+ };
91
+ }
92
+
93
+ function pct(n: number): string {
94
+ return `${Math.round(n * 100)}%`;
95
+ }
96
+
97
+ function counts(record: Record<string, number>): string {
98
+ const entries = Object.entries(record).sort(([, a], [, b]) => b - a);
99
+ return entries.length ? entries.map(([k, n]) => `${k} ${n}`).join(" · ") : "none";
100
+ }
101
+
102
+ function shares(record: Record<string, number>): string {
103
+ const total = Object.values(record).reduce((a, b) => a + b, 0);
104
+ const entries = Object.entries(record).sort(([, a], [, b]) => b - a);
105
+ return total ? entries.map(([k, n]) => `${k} ${pct(n / total)}`).join(" · ") : "none";
106
+ }
107
+
108
+ function words(s: ReplyStats): string {
109
+ return s.medianWords === null ? "-" : `${Math.round(s.medianWords)}`;
110
+ }
111
+
112
+ function labelLine(label: string, s: ReplyStats, usual: ReplyStats): string {
113
+ if (s.replies === 0) return ` ${label.padEnd(9)} no replies yet`;
114
+ return [
115
+ ` ${label.padEnd(9)} ${s.replies} replies`,
116
+ `median ${words(s)} words (usual ${words(usual)})`,
117
+ `approved ${pct(s.approvedShare)} (usual ${pct(usual.approvedShare)})`,
118
+ `corrected ${pct(s.correctedShare)} (usual ${pct(usual.correctedShare)})`,
119
+ ].join(" · ");
120
+ }
121
+
122
+ function plural(n: number, noun: string): string {
123
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
124
+ }
125
+
126
+ export function reportLines(summary: InteractionSummary, days: number): string[] {
127
+ const period = days === 1 ? "day" : plural(days, "day");
128
+ if (summary.turns === 0) return [`No measured turns in the last ${period}.`];
129
+ const lines = [
130
+ `Interaction report, last ${period}`,
131
+ `Turns: ${summary.turns} in ${plural(summary.sessions, "session")} · ${counts(summary.channels)}`,
132
+ `Reactions: ${shares(summary.reactions)}`,
133
+ `Hints sent: ${counts(summary.hints)}`,
134
+ "",
135
+ "Replies written while a label was active, against replies while none was:",
136
+ ...LABELS.map((label) => labelLine(label, summary.byLabel[label], summary.usual)),
137
+ ];
138
+ if (summary.unlogged)
139
+ lines.push(
140
+ "",
141
+ `${summary.unlogged} older turns were logged before moods were, and are left out of the comparison.`
142
+ );
143
+ return lines;
144
+ }