portable-agent-layer 0.81.0 → 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.0",
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"),
@@ -8,11 +8,6 @@ import { extractKeywords, similarity } from "./text-similarity";
8
8
 
9
9
  export type Reaction = "corrected" | "repeated" | "approved" | "new-topic" | "follow-up";
10
10
 
11
- export interface ReplyContext {
12
- previousPrompt?: string;
13
- replyKeywords: string[];
14
- }
15
-
16
11
  const CORRECTION_RE =
17
12
  /^(?:(?:no|nope|wrong)(?:[,.!]|$)|(?:that'?s|this is|it'?s) (?:wrong|not)\b|not what i\b|i said\b|i told you\b|you forgot\b|you missed\b|still (?:broken|failing|wrong)\b)/i;
18
13
 
@@ -20,15 +15,16 @@ const APPROVAL_RE =
20
15
  /^(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|it works|thanks|thank you|thx|y+e+s+|yep|yeah|yup|sure|exactly|correct|agreed|lgtm|well done|love it|sounds good|looks good|that'?s it|(?:it|this|that) (?:is|'s) (?:fine|good|great|right))\b/i;
21
16
 
22
17
  const GO_AHEAD_RE =
23
- /^(?:go ahead|go|do it|proceed|continue|merge|push|ship|commit|build|approved|let'?s (?:see|do|go))\b/i;
18
+ /^(?:go ahead|go|do it|proceed|continue|merge|push|ship|commit|build|start|fix (?:all|them|both)|approved|let'?s (?:see|do|go))\b/i;
19
+
20
+ const NEW_TOPIC_RE =
21
+ /^(?:(?:ok(?:ay)?|btw|also),?\s+)?(?:new (?:\w+ ){0,2}(?:task|topic|question|thing)|(?:different|unrelated|separate|another) (?:task|topic|question|thing)|unrelated\b|switching (?:gears|topics?)|change of (?:topic|subject))/i;
24
22
 
25
23
  const GO_AHEAD_MAX_WORDS = 6;
26
24
  const BARE_OK_RE = /^(?:ok(?:ay)?|k)[,.!]?(?:\s+\S+){0,2}$/i;
27
25
  const HEDGE_RE = /\b(?:but|however|though|except|instead)\b/i;
28
26
  const REPEAT_SIMILARITY = 0.6;
29
27
  const REPEAT_MIN_KEYWORDS = 5;
30
- const NEW_TOPIC_MIN_KEYWORDS = 4;
31
- const NEW_TOPIC_MAX_OVERLAP = 0.15;
32
28
 
33
29
  export function isCorrection(text: string): boolean {
34
30
  return CORRECTION_RE.test(text.trim());
@@ -39,33 +35,36 @@ export function isRepeat(text: string, previous: string | undefined): boolean {
39
35
  return similarity(text, previous) >= REPEAT_SIMILARITY;
40
36
  }
41
37
 
38
+ function isShortGoAhead(sentence: string): boolean {
39
+ return GO_AHEAD_RE.test(sentence) && sentence.split(/\s+/).length <= GO_AHEAD_MAX_WORDS;
40
+ }
41
+
42
+ function lastSentence(text: string): string {
43
+ return (
44
+ text
45
+ .split(/[.!\n]\s*/)
46
+ .filter(Boolean)
47
+ .at(-1)
48
+ ?.trim() ?? ""
49
+ );
50
+ }
51
+
42
52
  function isApproval(text: string): boolean {
43
53
  const trimmed = text.trim();
44
- if (HEDGE_RE.test(trimmed.split(/[.!?\n]/)[0])) return false;
54
+ if (trimmed.includes("?") || HEDGE_RE.test(trimmed.split(/[.!?\n]/)[0])) return false;
45
55
  if (APPROVAL_RE.test(trimmed) || BARE_OK_RE.test(trimmed)) return true;
46
- return GO_AHEAD_RE.test(trimmed) && trimmed.split(/\s+/).length <= GO_AHEAD_MAX_WORDS;
56
+ return isShortGoAhead(trimmed) || isShortGoAhead(lastSentence(trimmed));
47
57
  }
48
58
 
49
- /** Few of the message's words appear in the exchange it follows. */
50
- function isNewTopic(text: string, context: ReplyContext): boolean {
51
- const words = extractKeywords(text);
52
- if (words.size < NEW_TOPIC_MIN_KEYWORDS) return false;
53
- const exchange = new Set([
54
- ...context.replyKeywords,
55
- ...extractKeywords(context.previousPrompt ?? ""),
56
- ]);
57
- const shared = [...words].filter((w) => exchange.has(w)).length;
58
- return shared / words.size <= NEW_TOPIC_MAX_OVERLAP;
59
+ /** Word overlap was measured against real messages and does not separate topics; only saying so does. */
60
+ function isNewTopic(text: string): boolean {
61
+ return NEW_TOPIC_RE.test(text.trim());
59
62
  }
60
63
 
61
- export function reactionTo(text: string, context: ReplyContext): Reaction {
64
+ export function reactionTo(text: string, previousPrompt: string | undefined): Reaction {
62
65
  if (isCorrection(text)) return "corrected";
63
- if (isRepeat(text, context.previousPrompt)) return "repeated";
66
+ if (isRepeat(text, previousPrompt)) return "repeated";
64
67
  if (isApproval(text)) return "approved";
65
- if (isNewTopic(text, context)) return "new-topic";
68
+ if (isNewTopic(text)) return "new-topic";
66
69
  return "follow-up";
67
70
  }
68
-
69
- export function replyKeywords(reply: string): string[] {
70
- return [...extractKeywords(reply)];
71
- }
@@ -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 {
@@ -19,7 +25,6 @@ import {
19
25
  isRepeat,
20
26
  type Reaction,
21
27
  reactionTo,
22
- replyKeywords,
23
28
  } from "./interaction-reaction";
24
29
  import { keepSample, replyEnd } from "./interaction-samples";
25
30
  import { ensureDir, paths } from "./paths";
@@ -43,6 +48,8 @@ export interface TurnEvent extends MoodTurn {
43
48
  weekday: string;
44
49
  reply: Omit<ReplyShape, "at"> | null;
45
50
  reaction: Reaction | null;
51
+ mood?: string;
52
+ hinted?: boolean;
46
53
  }
47
54
 
48
55
  interface SessionTrack {
@@ -50,7 +57,6 @@ interface SessionTrack {
50
57
  lastPrompt?: string;
51
58
  lastPromptAt?: string;
52
59
  reply?: ReplyShape;
53
- replyKeywords?: string[];
54
60
  replyEnd?: string;
55
61
  recent: TurnEvent[];
56
62
  mood?: string;
@@ -117,12 +123,7 @@ function measureTurn(
117
123
  repeated: isRepeat(text, track.lastPrompt),
118
124
  corrected: isCorrection(text),
119
125
  reply: reply ? replyFeatures(reply) : null,
120
- reaction: reply
121
- ? reactionTo(text, {
122
- previousPrompt: track.lastPrompt,
123
- replyKeywords: track.replyKeywords ?? [],
124
- })
125
- : null,
126
+ reaction: reply ? reactionTo(text, track.lastPrompt) : null,
126
127
  };
127
128
  }
128
129
 
@@ -153,6 +154,16 @@ function readEvents(file: string): TurnEvent[] {
153
154
  });
154
155
  }
155
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
+
156
167
  /** The user's own normal, from this month and last, outside the session being judged. */
157
168
  function baseline(session: string, now: Date = new Date()): Baseline {
158
169
  const lastMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
@@ -211,7 +222,6 @@ export function recordReply(
211
222
  ...track,
212
223
  updated: now.toISOString(),
213
224
  reply: replyShape(reply, now),
214
- replyKeywords: replyKeywords(reply),
215
225
  replyEnd: replyEnd(reply),
216
226
  });
217
227
  }
@@ -239,10 +249,11 @@ export function observeTurn(
239
249
  if (!text) return null;
240
250
  const track = trackOf(session);
241
251
  const event = measureTurn(text, session, track, now, channel);
242
- appendEvent(event);
243
- sampleReaction(event, text, track);
244
252
  const recent = [...track.recent, event].slice(-RECENT_KEPT);
245
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);
246
257
  writeTrack(session, {
247
258
  ...track,
248
259
  updated: now.toISOString(),
@@ -251,5 +262,5 @@ export function observeTurn(
251
262
  recent,
252
263
  mood: mood.key,
253
264
  });
254
- return moodReminder(mood, track.mood ?? "");
265
+ return reminder;
255
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
+ }