portable-agent-layer 0.81.1 → 0.82.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); 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.81.1",
3
+ "version": "0.82.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"),
@@ -3,7 +3,13 @@
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";
9
15
  import {
@@ -42,6 +48,8 @@ export interface TurnEvent extends MoodTurn {
42
48
  weekday: string;
43
49
  reply: Omit<ReplyShape, "at"> | null;
44
50
  reaction: Reaction | null;
51
+ mood?: string;
52
+ hinted?: boolean;
45
53
  }
46
54
 
47
55
  interface SessionTrack {
@@ -146,6 +154,16 @@ function readEvents(file: string): TurnEvent[] {
146
154
  });
147
155
  }
148
156
 
157
+ export function turnsSince(since: Date): TurnEvent[] {
158
+ const fromMonth = since.toISOString().slice(0, 7);
159
+ const sinceTs = since.toISOString();
160
+ return readdirSync(eventsDir())
161
+ .filter((f) => f.endsWith(".jsonl") && f.slice(0, 7) >= fromMonth)
162
+ .sort()
163
+ .flatMap((f) => readEvents(resolve(eventsDir(), f)))
164
+ .filter((e) => e.ts >= sinceTs);
165
+ }
166
+
149
167
  /** The user's own normal, from this month and last, outside the session being judged. */
150
168
  function baseline(session: string, now: Date = new Date()): Baseline {
151
169
  const lastMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
@@ -231,10 +249,11 @@ export function observeTurn(
231
249
  if (!text) return null;
232
250
  const track = trackOf(session);
233
251
  const event = measureTurn(text, session, track, now, channel);
234
- appendEvent(event);
235
- sampleReaction(event, text, track);
236
252
  const recent = [...track.recent, event].slice(-RECENT_KEPT);
237
253
  const mood = readMood(recent, baseline(session, now));
254
+ const reminder = moodReminder(mood, track.mood ?? "");
255
+ appendEvent({ ...event, mood: mood.key, hinted: reminder !== null });
256
+ sampleReaction(event, text, track);
238
257
  writeTrack(session, {
239
258
  ...track,
240
259
  updated: now.toISOString(),
@@ -243,5 +262,5 @@ export function observeTurn(
243
262
  recent,
244
263
  mood: mood.key,
245
264
  });
246
- return moodReminder(mood, track.mood ?? "");
265
+ return reminder;
247
266
  }
@@ -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
+ }