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
|
@@ -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
|
-
/**
|
|
170
|
-
function
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
/**
|
|
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(
|
|
256
|
-
const reminder =
|
|
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
|
-
|
|
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
|
-
]
|
|
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(
|