portable-agent-layer 0.85.3 → 0.85.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.85.3",
3
+ "version": "0.85.5",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Whether a reply followed the hint it was written under. A label saying the user
3
+ * wants less asks for a reply at most half the user's usual one, the same factor
4
+ * of two the mood rules use to call a message short.
5
+ */
6
+
7
+ import { approvedShare, type Side, type Split } from "./interaction-preferences";
8
+
9
+ const ASKS_FOR_LESS = ["short", "fast", "skimming"];
10
+
11
+ function lengthTarget(usualReplyWords: number): number {
12
+ return Math.round(usualReplyWords / 2);
13
+ }
14
+
15
+ export function asksForLess(moodKey: string | undefined): boolean {
16
+ return (moodKey ?? "").split(",").some((label) => ASKS_FOR_LESS.includes(label));
17
+ }
18
+
19
+ /** Null when the reply was not written under a hint for less, or nothing says what usual is. */
20
+ export function followedHint(
21
+ moodKey: string | undefined,
22
+ replyWords: number | undefined,
23
+ usualReplyWords: number | null
24
+ ): boolean | null {
25
+ if (replyWords === undefined || usualReplyWords === null) return null;
26
+ if (!asksForLess(moodKey)) return null;
27
+ return replyWords <= lengthTarget(usualReplyWords);
28
+ }
29
+
30
+ function pct(s: Side): string {
31
+ return `${Math.round(approvedShare(s) * 100)}%`;
32
+ }
33
+
34
+ function citedApprovals(evidence: Split | null): string {
35
+ if (!evidence) return "";
36
+ return ` Replies ${evidence.shape} were approved ${pct(evidence.has)} of the time, longer ones ${pct(evidence.lacks)}.`;
37
+ }
38
+
39
+ export function ignoredHintReminder(
40
+ replyWords: number,
41
+ usualReplyWords: number,
42
+ evidence: Split | null
43
+ ): string {
44
+ const target = lengthTarget(usualReplyWords);
45
+ return `<system-reminder>Interaction: the user still wants less. Your last reply was ${replyWords} words (usually ${usualReplyWords}); keep this one under ${target} words.${citedApprovals(evidence)}</system-reminder>`;
46
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * What the user's reactions say about the replies they get: for each shape a reply
3
+ * can take, how often replies with it were approved against replies without it.
4
+ * A split counts as evidence only with enough replies on both sides and a gap wider
5
+ * than noise; the data is thin, so most splits are expected to say not yet.
6
+ */
7
+
8
+ import { median } from "./interaction-mood";
9
+
10
+ const MIN_REPLIES_PER_SIDE = 20;
11
+ const MIN_GAP = 0.1;
12
+
13
+ interface ReplyFeatures {
14
+ words: number;
15
+ listItems: number;
16
+ headings: number;
17
+ asked: boolean;
18
+ }
19
+
20
+ interface ReactedTurn {
21
+ reply: ReplyFeatures | null;
22
+ reaction: string | null;
23
+ }
24
+
25
+ interface Outcome {
26
+ reply: ReplyFeatures;
27
+ approved: boolean;
28
+ }
29
+
30
+ export interface Side {
31
+ replies: number;
32
+ approved: number;
33
+ }
34
+
35
+ export interface Split {
36
+ shape: string;
37
+ opposite: string;
38
+ has: Side;
39
+ lacks: Side;
40
+ }
41
+
42
+ function outcomes(events: ReactedTurn[]): Outcome[] {
43
+ return events.flatMap((e) =>
44
+ e.reply && e.reaction ? [{ reply: e.reply, approved: e.reaction === "approved" }] : []
45
+ );
46
+ }
47
+
48
+ function side(outcomes: Outcome[]): Side {
49
+ return {
50
+ replies: outcomes.length,
51
+ approved: outcomes.filter((o) => o.approved).length,
52
+ };
53
+ }
54
+
55
+ function splitBy(
56
+ outcomes: Outcome[],
57
+ shape: string,
58
+ opposite: string,
59
+ test: (reply: ReplyFeatures) => boolean
60
+ ): Split {
61
+ return {
62
+ shape,
63
+ opposite,
64
+ has: side(outcomes.filter((o) => test(o.reply))),
65
+ lacks: side(outcomes.filter((o) => !test(o.reply))),
66
+ };
67
+ }
68
+
69
+ function lengthSplit(outcomes: Outcome[]): Split {
70
+ const cut = Math.round(median(outcomes.map((o) => o.reply.words)) ?? 0);
71
+ return splitBy(
72
+ outcomes,
73
+ `under ${cut} words`,
74
+ `${cut} words or more`,
75
+ (r) => r.words < cut
76
+ );
77
+ }
78
+
79
+ export function splits(events: ReactedTurn[]): Split[] {
80
+ const reacted = outcomes(events);
81
+ return [
82
+ lengthSplit(reacted),
83
+ splitBy(reacted, "with a list", "without a list", (r) => r.listItems > 0),
84
+ splitBy(reacted, "with headings", "without headings", (r) => r.headings > 0),
85
+ splitBy(reacted, "ending with a question", "not ending with one", (r) => r.asked),
86
+ ];
87
+ }
88
+
89
+ export function approvedShare(s: Side): number {
90
+ return s.replies ? s.approved / s.replies : 0;
91
+ }
92
+
93
+ export function hasEnoughReplies(split: Split): boolean {
94
+ return Math.min(split.has.replies, split.lacks.replies) >= MIN_REPLIES_PER_SIDE;
95
+ }
96
+
97
+ export function isEvidence(split: Split): boolean {
98
+ const gap = Math.abs(approvedShare(split.has) - approvedShare(split.lacks));
99
+ return hasEnoughReplies(split) && gap >= MIN_GAP;
100
+ }
101
+
102
+ export function reactedReplies(events: ReactedTurn[]): number {
103
+ return outcomes(events).length;
104
+ }
105
+
106
+ /** The length split, when it is evidence that the user approves shorter replies. */
107
+ export function shorterApprovedMore(events: ReactedTurn[]): Split | null {
108
+ const length = lengthSplit(outcomes(events));
109
+ if (!isEvidence(length)) return null;
110
+ return approvedShare(length.has) > approvedShare(length.lacks) ? length : null;
111
+ }
@@ -13,6 +13,7 @@ import {
13
13
  import { resolve } from "node:path";
14
14
  import { currentAttribution } from "./actor";
15
15
  import { hearsPromptContext } from "./agent";
16
+ import { asksForLess, followedHint, ignoredHintReminder } from "./interaction-hint-check";
16
17
  import {
17
18
  type Baseline,
18
19
  DEFAULT_BASELINE,
@@ -21,6 +22,7 @@ import {
21
22
  moodReminder,
22
23
  readMood,
23
24
  } from "./interaction-mood";
25
+ import { shorterApprovedMore } from "./interaction-preferences";
24
26
  import {
25
27
  isCorrection,
26
28
  isRepeat,
@@ -51,6 +53,7 @@ export interface TurnEvent extends MoodTurn {
51
53
  reaction: Reaction | null;
52
54
  mood?: string;
53
55
  hinted?: boolean;
56
+ complied?: boolean;
54
57
  runtime?: string;
55
58
  }
56
59
 
@@ -166,13 +169,21 @@ export function turnsSince(since: Date): TurnEvent[] {
166
169
  .filter((e) => e.ts >= sinceTs);
167
170
  }
168
171
 
169
- /** The user's own normal, from this month and last, outside the session being judged. */
170
- function baseline(session: string, now: Date = new Date()): Baseline {
172
+ /** This month and last, outside the session being judged. */
173
+ function historyOutside(session: string, now: Date): TurnEvent[] {
171
174
  const lastMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
172
- const events = [
173
- ...readEvents(monthFile(lastMonth)),
174
- ...readEvents(monthFile(now)),
175
- ].filter((e) => e.session !== session);
175
+ return [...readEvents(monthFile(lastMonth)), ...readEvents(monthFile(now))].filter(
176
+ (e) => e.session !== session
177
+ );
178
+ }
179
+
180
+ function usualReplyWords(history: TurnEvent[]): number | null {
181
+ const replies = history.flatMap((e) => (e.reply ? [e.reply.words] : []));
182
+ return replies.length >= BASELINE_MIN_SAMPLES ? median(replies) : null;
183
+ }
184
+
185
+ /** The user's own normal, measured on their history. */
186
+ function baseline(events: TurnEvent[]): Baseline {
176
187
  const gaps = events
177
188
  .filter((e) => e.gapSec !== null && !e.afterBreak)
178
189
  .map((e) => e.gapSec);
@@ -239,7 +250,25 @@ function sampleReaction(event: TurnEvent, text: string, track: SessionTrack): vo
239
250
  });
240
251
  }
241
252
 
242
- /** Logs the turn and returns a reminder when the session's picture changed. */
253
+ /** Only while the user still wants less: once the mood is back to usual, length is not the point. */
254
+ function reminderForIgnoredHint(
255
+ complied: boolean | null,
256
+ moodKey: string,
257
+ replyWords: number | undefined,
258
+ usualReply: number | null,
259
+ history: TurnEvent[]
260
+ ): string | null {
261
+ if (complied !== false || !asksForLess(moodKey)) return null;
262
+ if (replyWords === undefined || usualReply === null) return null;
263
+ return ignoredHintReminder(replyWords, usualReply, shorterApprovedMore(history));
264
+ }
265
+
266
+ function joinReminders(...reminders: (string | null)[]): string | null {
267
+ const said = reminders.filter((r): r is string => r !== null);
268
+ return said.length ? said.join("\n") : null;
269
+ }
270
+
271
+ /** Logs the turn and returns a reminder when the session's picture changed, or a hint went unheeded. */
243
272
  export function observeTurn(
244
273
  prompt: string,
245
274
  session: string | undefined,
@@ -250,14 +279,24 @@ export function observeTurn(
250
279
  const text = stripInjectedTags(prompt);
251
280
  if (!text) return null;
252
281
  const track = trackOf(session);
282
+ const history = historyOutside(session, now);
283
+ const usualReply = usualReplyWords(history);
253
284
  const event = measureTurn(text, session, track, now, channel);
285
+ const replyWords = event.reply?.words;
286
+ const complied = hearsPromptContext()
287
+ ? followedHint(track.mood, replyWords, usualReply)
288
+ : null;
254
289
  const recent = [...track.recent, event].slice(-RECENT_KEPT);
255
- const mood = readMood(recent, baseline(session, now));
256
- const reminder = moodReminder(mood, track.mood ?? "");
290
+ const mood = readMood(recent, baseline(history));
291
+ const reminder = joinReminders(
292
+ moodReminder(mood, track.mood ?? ""),
293
+ reminderForIgnoredHint(complied, mood.key, replyWords, usualReply, history)
294
+ );
257
295
  appendEvent({
258
296
  ...event,
259
297
  mood: mood.key,
260
298
  hinted: reminder !== null && hearsPromptContext(),
299
+ ...(complied !== null && { complied }),
261
300
  });
262
301
  sampleReaction(event, text, track);
263
302
  writeTrack(session, {
@@ -5,6 +5,15 @@
5
5
 
6
6
  import type { TurnEvent } from "../../hooks/lib/interaction";
7
7
  import { median } from "../../hooks/lib/interaction-mood";
8
+ import {
9
+ approvedShare,
10
+ hasEnoughReplies,
11
+ isEvidence,
12
+ reactedReplies,
13
+ type Side,
14
+ type Split,
15
+ splits,
16
+ } from "../../hooks/lib/interaction-preferences";
8
17
 
9
18
  const LABELS = ["short", "long", "fast", "skimming", "friction"] as const;
10
19
 
@@ -13,6 +22,12 @@ interface ReplyUnderMood {
13
22
  words: number;
14
23
  approved: boolean;
15
24
  corrected: boolean;
25
+ complied?: boolean;
26
+ }
27
+
28
+ interface HintFollowing {
29
+ checked: number;
30
+ followed: number;
16
31
  }
17
32
 
18
33
  interface ReplyStats {
@@ -20,6 +35,7 @@ interface ReplyStats {
20
35
  medianWords: number | null;
21
36
  approvedShare: number;
22
37
  correctedShare: number;
38
+ followed: HintFollowing;
23
39
  }
24
40
 
25
41
  interface AgentCounts {
@@ -38,6 +54,17 @@ export interface InteractionSummary {
38
54
  unlogged: number;
39
55
  usual: ReplyStats;
40
56
  byLabel: Record<string, ReplyStats>;
57
+ approvals: Approvals;
58
+ approvalsByChannel: Record<string, Approvals>;
59
+ }
60
+
61
+ interface Approvals {
62
+ reacted: number;
63
+ splits: Split[];
64
+ }
65
+
66
+ function approvals(events: TurnEvent[]): Approvals {
67
+ return { reacted: reactedReplies(events), splits: splits(events) };
41
68
  }
42
69
 
43
70
  function tally(values: string[]): Record<string, number> {
@@ -56,6 +83,10 @@ function stats(replies: ReplyUnderMood[]): ReplyStats {
56
83
  medianWords: median(replies.map((r) => r.words)),
57
84
  approvedShare: share(replies, (r) => r.approved),
58
85
  correctedShare: share(replies, (r) => r.corrected),
86
+ followed: {
87
+ checked: replies.filter((r) => r.complied !== undefined).length,
88
+ followed: replies.filter((r) => r.complied === true).length,
89
+ },
59
90
  };
60
91
  }
61
92
 
@@ -71,6 +102,7 @@ function repliesUnderMood(events: TurnEvent[]): ReplyUnderMood[] {
71
102
  words: e.reply.words,
72
103
  approved: e.reaction === "approved",
73
104
  corrected: e.reaction === "corrected" || e.reaction === "repeated",
105
+ complied: e.complied,
74
106
  });
75
107
  lastMood.set(e.session, e.mood);
76
108
  }
@@ -109,9 +141,20 @@ export function summarize(events: TurnEvent[]): InteractionSummary {
109
141
  stats(replies.filter((r) => r.labels.includes(label))),
110
142
  ])
111
143
  ),
144
+ approvals: approvals(events),
145
+ approvalsByChannel: approvalsByChannel(events),
112
146
  };
113
147
  }
114
148
 
149
+ function approvalsByChannel(events: TurnEvent[]): Record<string, Approvals> {
150
+ const channels = Map.groupBy(events, (e) => e.channel ?? "unknown");
151
+ return Object.fromEntries(
152
+ [...channels]
153
+ .map(([channel, turns]) => [channel, approvals(turns)] as const)
154
+ .filter(([, a]) => a.reacted > 0)
155
+ );
156
+ }
157
+
115
158
  function pct(n: number): string {
116
159
  return `${Math.round(n * 100)}%`;
117
160
  }
@@ -133,18 +176,52 @@ function words(s: ReplyStats): string {
133
176
 
134
177
  function labelLine(label: string, s: ReplyStats, usual: ReplyStats): string {
135
178
  if (s.replies === 0) return ` ${label.padEnd(9)} no replies yet`;
136
- return [
179
+ const parts = [
137
180
  ` ${label.padEnd(9)} ${s.replies} replies`,
138
181
  `median ${words(s)} words (usual ${words(usual)})`,
139
182
  `approved ${pct(s.approvedShare)} (usual ${pct(usual.approvedShare)})`,
140
183
  `corrected ${pct(s.correctedShare)} (usual ${pct(usual.correctedShare)})`,
141
- ].join(" · ");
184
+ ];
185
+ if (s.followed.checked)
186
+ parts.push(`followed ${s.followed.followed} of ${s.followed.checked}`);
187
+ return parts.join(" · ");
142
188
  }
143
189
 
144
190
  function plural(n: number, noun: string, nouns = `${noun}s`): string {
145
191
  return `${n} ${n === 1 ? noun : nouns}`;
146
192
  }
147
193
 
194
+ function approvedOf(s: Side): string {
195
+ return `${pct(approvedShare(s))} of ${s.replies}`;
196
+ }
197
+
198
+ function splitLine(split: Split): string {
199
+ const head = ` ${split.shape}:`;
200
+ if (!hasEnoughReplies(split))
201
+ return `${head} too few replies (${split.has.replies} against ${split.lacks.replies})`;
202
+ if (!isEvidence(split))
203
+ return `${head} no real difference (${approvedOf(split.has)} against ${approvedOf(split.lacks)})`;
204
+ return `${head} approved ${approvedOf(split.has)} · ${split.opposite}: ${approvedOf(split.lacks)}`;
205
+ }
206
+
207
+ function approvalLines(title: string, a: Approvals): string[] {
208
+ return [
209
+ "",
210
+ `${title}, from ${plural(a.reacted, "reacted reply", "reacted replies")}:`,
211
+ ...a.splits.map(splitLine),
212
+ ];
213
+ }
214
+
215
+ function allApprovalLines(summary: InteractionSummary): string[] {
216
+ if (summary.approvals.reacted === 0) return [];
217
+ return [
218
+ ...approvalLines("What gets approved", summary.approvals),
219
+ ...Object.entries(summary.approvalsByChannel).flatMap(([channel, a]) =>
220
+ approvalLines(`On ${channel}`, a)
221
+ ),
222
+ ];
223
+ }
224
+
148
225
  function agentLines(agents: Record<string, AgentCounts>): string[] {
149
226
  return Object.entries(agents)
150
227
  .sort(([, a], [, b]) => b.turns - a.turns)
@@ -171,6 +248,7 @@ export function reportLines(summary: InteractionSummary, days: number): string[]
171
248
  "",
172
249
  "Replies written while a label was active, against replies while none was:",
173
250
  ...LABELS.map((label) => labelLine(label, summary.byLabel[label], summary.usual)),
251
+ ...allApprovalLines(summary),
174
252
  ];
175
253
  if (summary.unlogged)
176
254
  lines.push(