portable-agent-layer 0.82.0 → 0.84.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); read it with `pal cli interaction report` | `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.82.0",
3
+ "version": "0.84.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }
@@ -12,6 +12,7 @@ import {
12
12
  } from "node:fs";
13
13
  import { resolve } from "node:path";
14
14
  import { currentAttribution } from "./actor";
15
+ import { hearsPromptContext } from "./agent";
15
16
  import {
16
17
  type Baseline,
17
18
  DEFAULT_BASELINE,
@@ -50,6 +51,7 @@ export interface TurnEvent extends MoodTurn {
50
51
  reaction: Reaction | null;
51
52
  mood?: string;
52
53
  hinted?: boolean;
54
+ runtime?: string;
53
55
  }
54
56
 
55
57
  interface SessionTrack {
@@ -252,7 +254,11 @@ export function observeTurn(
252
254
  const recent = [...track.recent, event].slice(-RECENT_KEPT);
253
255
  const mood = readMood(recent, baseline(session, now));
254
256
  const reminder = moodReminder(mood, track.mood ?? "");
255
- appendEvent({ ...event, mood: mood.key, hinted: reminder !== null });
257
+ appendEvent({
258
+ ...event,
259
+ mood: mood.key,
260
+ hinted: reminder !== null && hearsPromptContext(),
261
+ });
256
262
  sampleReaction(event, text, track);
257
263
  writeTrack(session, {
258
264
  ...track,
@@ -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;
@@ -15,7 +15,8 @@ const HELP = `
15
15
  PAL Interaction report
16
16
 
17
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
18
+ text) and prints turns, channels, reactions, the hints sent, per agent the
19
+ turns, replies filed and hints sent, and for each
19
20
  label (short, long, fast, skimming, friction) how the replies written while
20
21
  it was active compare with the replies written while none was.
21
22
 
@@ -22,10 +22,17 @@ interface ReplyStats {
22
22
  correctedShare: number;
23
23
  }
24
24
 
25
+ interface AgentCounts {
26
+ turns: number;
27
+ replies: number;
28
+ hints: number;
29
+ }
30
+
25
31
  export interface InteractionSummary {
26
32
  turns: number;
27
33
  sessions: number;
28
34
  channels: Record<string, number>;
35
+ agents: Record<string, AgentCounts>;
29
36
  reactions: Record<string, number>;
30
37
  hints: Record<string, number>;
31
38
  unlogged: number;
@@ -70,6 +77,20 @@ function repliesUnderMood(events: TurnEvent[]): ReplyUnderMood[] {
70
77
  return replies;
71
78
  }
72
79
 
80
+ /** A turn carries the reply that answered the turn before it, so a filed reply shows up there. */
81
+ function byAgent(events: TurnEvent[]): Record<string, AgentCounts> {
82
+ const agents: Record<string, AgentCounts> = {};
83
+ for (const e of events) {
84
+ const agent = e.runtime ?? "unknown";
85
+ const counts = agents[agent] ?? { turns: 0, replies: 0, hints: 0 };
86
+ agents[agent] = counts;
87
+ counts.turns++;
88
+ if (e.reply) counts.replies++;
89
+ if (e.hinted) counts.hints++;
90
+ }
91
+ return agents;
92
+ }
93
+
73
94
  export function summarize(events: TurnEvent[]): InteractionSummary {
74
95
  const replies = repliesUnderMood(events);
75
96
  const hinted = events.filter((e) => e.hinted);
@@ -77,6 +98,7 @@ export function summarize(events: TurnEvent[]): InteractionSummary {
77
98
  turns: events.length,
78
99
  sessions: new Set(events.map((e) => e.session)).size,
79
100
  channels: tally(events.map((e) => e.channel ?? "unknown")),
101
+ agents: byAgent(events),
80
102
  reactions: tally(events.flatMap((e) => (e.reaction ? [e.reaction] : []))),
81
103
  hints: tally(hinted.map((e) => e.mood || "back to usual")),
82
104
  unlogged: events.filter((e) => e.mood === undefined).length,
@@ -119,8 +141,20 @@ function labelLine(label: string, s: ReplyStats, usual: ReplyStats): string {
119
141
  ].join(" · ");
120
142
  }
121
143
 
122
- function plural(n: number, noun: string): string {
123
- return `${n} ${noun}${n === 1 ? "" : "s"}`;
144
+ function plural(n: number, noun: string, nouns = `${noun}s`): string {
145
+ return `${n} ${n === 1 ? noun : nouns}`;
146
+ }
147
+
148
+ function agentLines(agents: Record<string, AgentCounts>): string[] {
149
+ return Object.entries(agents)
150
+ .sort(([, a], [, b]) => b.turns - a.turns)
151
+ .map(([agent, c]) =>
152
+ [
153
+ ` ${agent.padEnd(9)} ${plural(c.turns, "turn")}`,
154
+ `${plural(c.replies, "reply", "replies")} filed`,
155
+ `${plural(c.hints, "hint")} sent`,
156
+ ].join(" · ")
157
+ );
124
158
  }
125
159
 
126
160
  export function reportLines(summary: InteractionSummary, days: number): string[] {
@@ -132,6 +166,9 @@ export function reportLines(summary: InteractionSummary, days: number): string[]
132
166
  `Reactions: ${shares(summary.reactions)}`,
133
167
  `Hints sent: ${counts(summary.hints)}`,
134
168
  "",
169
+ "By agent:",
170
+ ...agentLines(summary.agents),
171
+ "",
135
172
  "Replies written while a label was active, against replies while none was:",
136
173
  ...LABELS.map((label) => labelLine(label, summary.byLabel[label], summary.usual)),
137
174
  ];