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
package/src/cli/builtin-tools.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
+
}
|