portable-agent-layer 0.79.3 → 0.81.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.
@@ -70,6 +70,14 @@
70
70
  "type": "boolean",
71
71
  "description": "One line of the current time with every prompt, in identity.principal.timezone, so a session resumed hours later knows the day changed."
72
72
  },
73
+ "interactionAwareness": {
74
+ "type": "boolean",
75
+ "description": "Measure each user turn (pace, message length, skimming, corrections; never the text) into memory/signals/interaction/, and tell the agent when the session's pattern changes."
76
+ },
77
+ "reactionSampling": {
78
+ "type": "boolean",
79
+ "description": "Keep a rolling local sample (about 230 messages) of user message text, its reaction label and the end of the reply it answered, in memory/state/reaction-samples.json, so the reaction rules can be audited. Stores message text; set false to keep none."
80
+ },
73
81
  "steeringTestReport": { "type": "boolean", "description": "When true, the assistant notes to the user which steering self-check fired. For the dual-live test period; ships false." }
74
82
  }
75
83
  },
@@ -19,7 +19,8 @@ memory/
19
19
  │ └── token-usage.jsonl # Per-call inference token usage
20
20
  │
21
21
  ├── signals/
22
- │ └── ratings.jsonl # Append-only rating signals (explicit + implicit)
22
+ │ ├── ratings.jsonl # Append-only rating signals (explicit + implicit)
23
+ │ └── interaction/YYYY-MM.jsonl # Measured features of each user turn (never the text)
23
24
  │
24
25
  ├── relationship/
25
26
  │ ├── YYYY-MM/YYYY-MM-DD.md # Daily interaction notes (W / O / B)
@@ -54,6 +55,8 @@ Almost everything in `state/`, `signals/`, `relationship/opinions.json`, `learni
54
55
  | A reusable session insight | Session-intelligence handler (automatic) | `learning/session/YYYY-MM/*.md` |
55
56
  | A failure worth learning from | Failure-principle handler (automatic, low ratings) | `learning/failures/YYYY-MM/*.md` |
56
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` |
59
+ | A rolling sample of labelled messages, for auditing the reaction rules | Prompt-time interaction measurement (automatic, `reactionSampling`) | `state/reaction-samples.json` |
57
60
  | A project handoff | Project skill / detached handlers | `projects/<slug>/` |
58
61
 
59
62
  ## Format conventions
@@ -26,6 +26,8 @@
26
26
  "contextualSteering": true,
27
27
  "skillMatching": true,
28
28
  "wallClock": true,
29
+ "interactionAwareness": true,
30
+ "reactionSampling": true,
29
31
  "steeringTestReport": false
30
32
  },
31
33
  "steering": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.79.3",
3
+ "version": "0.81.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@ logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)
30
30
  if (!input?.prompt) process.exit(0);
31
31
 
32
32
  const sessionId = input.session_id ?? input.sessionId ?? input.conversation_id;
33
- const injected = await injectPromptContext(input.prompt);
33
+ const injected = await injectPromptContext(input.prompt, sessionId);
34
34
  logPromptSnapshot(input.prompt, injected);
35
35
 
36
36
  const results = await Promise.allSettled([
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { isCodex, isCursor } from "../lib/agent";
11
+ import { observeTurn } from "../lib/interaction";
11
12
  import { logDebug, logError } from "../lib/log";
12
13
  import { runRetrieval } from "../lib/retrieval";
13
14
  import { ensureIndex } from "../lib/retrieval-index";
@@ -85,9 +86,13 @@ function writeForAgent(reminder: string): void {
85
86
  * produced anything. The clock leads: it is the only part that is true of the
86
87
  * moment rather than of the prompt.
87
88
  * @lintignore dynamically imported by opencode plugin */
88
- export async function getPromptContext(prompt: string): Promise<string | null> {
89
+ export async function getPromptContext(
90
+ prompt: string,
91
+ sessionId?: string
92
+ ): Promise<string | null> {
89
93
  const parts = [
90
94
  getWallClockReminder(),
95
+ withinBudget(() => observeTurn(prompt, sessionId), BUDGET_MS),
91
96
  getSteeringReminder(prompt),
92
97
  getSkillReminder(prompt),
93
98
  await getRetrievalReminder(prompt),
@@ -98,8 +103,11 @@ export async function getPromptContext(prompt: string): Promise<string | null> {
98
103
 
99
104
  /** Gather all prompt-time context and do the one per-agent write. Returns the
100
105
  * combined reminder that was injected, or null if there was nothing to inject. */
101
- export async function injectPromptContext(prompt: string): Promise<string | null> {
102
- const combined = await getPromptContext(prompt);
106
+ export async function injectPromptContext(
107
+ prompt: string,
108
+ sessionId?: string
109
+ ): Promise<string | null> {
110
+ const combined = await getPromptContext(prompt, sessionId);
103
111
  if (combined) writeForAgent(combined);
104
112
  return combined;
105
113
  }
@@ -13,6 +13,7 @@ import { resolve } from "node:path";
13
13
  import { spawnDetachedInference } from "../lib/detached-inference";
14
14
  import { canInfer, inference } from "../lib/inference";
15
15
  import { paths } from "../lib/paths";
16
+ import { isSystemText, stripInjectedTags } from "../lib/prompt-text";
16
17
  import { emitRating } from "../lib/signals";
17
18
  import { now } from "../lib/time";
18
19
  import { logTokenUsage } from "../lib/token-usage";
@@ -128,35 +129,6 @@ function isPraise(prompt: string): boolean {
128
129
  );
129
130
  }
130
131
 
131
- // ── System-Injected Tag Stripping ──
132
-
133
- /**
134
- * Strip IDE/system-injected XML tags from the prompt to recover the raw user text.
135
- * Claude Code VSCode extension prepends tags like <ide_opened_file>...</ide_opened_file>
136
- * and <ide_selection>...</ide_selection> to the prompt field in hooks.
137
- */
138
- const INJECTED_TAG_RE =
139
- /<(?:ide_opened_file|ide_selection|system-reminder|task-notification)[^>]*>[\s\S]*?<\/(?:ide_opened_file|ide_selection|system-reminder|task-notification)>/gi;
140
-
141
- function stripInjectedTags(prompt: string): string {
142
- return prompt.replace(INJECTED_TAG_RE, "").trim();
143
- }
144
-
145
- // ── System Text Filters ──
146
-
147
- const SYSTEM_TEXT_PATTERNS = [
148
- /^<task-notification>/i,
149
- /^<system-reminder>/i,
150
- /^This session is being continued from a previous conversation/i,
151
- /^Please continue the conversation/i,
152
- /^Note:.*was read before/i,
153
- ];
154
-
155
- function isSystemText(prompt: string): boolean {
156
- const trimmed = prompt.trim();
157
- return SYSTEM_TEXT_PATTERNS.some((re) => re.test(trimmed));
158
- }
159
-
160
132
  // ── Sentiment Analysis ──
161
133
 
162
134
  const SENTIMENT_SCHEMA = {
@@ -14,6 +14,7 @@ import { loadHandoffContext } from "./handoff-context";
14
14
  import { loadOpinionContext } from "./opinions";
15
15
  import { paths, toPath } from "./paths";
16
16
  import { loadActiveProjectsContext } from "./projects";
17
+ import { loadReactionAuditNudge } from "./reaction-audit";
17
18
  import { loadRecentNotes } from "./relationship";
18
19
  import { loadFailurePatterns } from "./semi-static";
19
20
  import { sessionDir } from "./session-dir";
@@ -220,12 +221,14 @@ export function buildSystemReminder(
220
221
  : "";
221
222
  // Maintainer-only: self-gates to a repo checkout, "" for everyone else.
222
223
  const algoReview = oncePerDay("algorithm-review", loadAlgorithmReviewNudge());
224
+ const reactionAudit = oncePerDay("reaction-audit", loadReactionAuditNudge());
223
225
  const reflectNudge = oncePerDay("reflect", loadReflectNudge());
224
226
  const analyzeNudge = oncePerDay("analyze", loadAnalyzeNudge());
225
227
  const parts: string[] = [];
226
228
  if (startup) parts.push(startup);
227
229
  if (handoff) parts.push(handoff);
228
230
  if (algoReview) parts.push(algoReview);
231
+ if (reactionAudit) parts.push(reactionAudit);
229
232
  if (reflectNudge) parts.push(reflectNudge);
230
233
  if (analyzeNudge) parts.push(analyzeNudge);
231
234
  if (selfModel) parts.push(selfModel);
@@ -0,0 +1,103 @@
1
+ /**
2
+ * What the user's recent turns say about how they want to work right now,
3
+ * measured against their own history rather than a fixed idea of a user.
4
+ */
5
+
6
+ export interface MoodTurn {
7
+ words: number;
8
+ gapSec: number | null;
9
+ afterBreak: boolean;
10
+ skimmed: boolean;
11
+ interrupted: boolean;
12
+ repeated: boolean;
13
+ corrected: boolean;
14
+ }
15
+
16
+ export interface Baseline {
17
+ words: number;
18
+ gapSec: number;
19
+ }
20
+
21
+ export const DEFAULT_BASELINE: Baseline = { words: 30, gapSec: 120 };
22
+
23
+ export interface Mood {
24
+ key: string;
25
+ facts: string[];
26
+ }
27
+
28
+ const WINDOW = 6;
29
+ const MIN_TURNS = 3;
30
+
31
+ export function median(values: number[]): number | null {
32
+ if (values.length === 0) return null;
33
+ const sorted = [...values].sort((a, b) => a - b);
34
+ const mid = Math.floor(sorted.length / 2);
35
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
36
+ }
37
+
38
+ function duration(sec: number): string {
39
+ return sec < 90 ? `${Math.round(sec)}s` : `${Math.round(sec / 60)}m`;
40
+ }
41
+
42
+ /** A break starts a new sitting; turns before it describe a different moment. */
43
+ function currentSitting(turns: MoodTurn[]): MoodTurn[] {
44
+ const lastBreak = turns.findLastIndex((t) => t.afterBreak);
45
+ return turns.slice(Math.max(lastBreak, 0)).slice(-WINDOW);
46
+ }
47
+
48
+ /** Time away is not a signal of focus, so a break's own gap never counts toward pace. */
49
+ function paceFact(sitting: MoodTurn[], base: Baseline): [string, string] | null {
50
+ const gaps = sitting
51
+ .filter((t) => t.gapSec !== null && !t.afterBreak)
52
+ .map((t) => t.gapSec as number);
53
+ const pace = gaps.length >= MIN_TURNS ? median(gaps) : null;
54
+ if (pace === null || pace > base.gapSec / 2) return null;
55
+ return [
56
+ "fast",
57
+ `replying fast (median ${duration(pace)} after your reply, usually ${duration(base.gapSec)})`,
58
+ ];
59
+ }
60
+
61
+ function lengthFact(sitting: MoodTurn[], base: Baseline): [string, string] | null {
62
+ if (sitting.length < MIN_TURNS) return null;
63
+ const words = median(sitting.map((t) => t.words)) as number;
64
+ const usual = `median ${Math.round(words)} words, usually ${Math.round(base.words)}`;
65
+ if (words <= base.words / 2) return ["short", `writing short messages (${usual})`];
66
+ if (words >= base.words * 2) return ["long", `writing long messages (${usual})`];
67
+ return null;
68
+ }
69
+
70
+ function countOf(turns: MoodTurn[], test: (t: MoodTurn) => boolean): number {
71
+ return turns.filter(test).length;
72
+ }
73
+
74
+ function skimFact(sitting: MoodTurn[]): [string, string] | null {
75
+ if (countOf(sitting.slice(-3), (t) => t.skimmed) < 2) return null;
76
+ return ["skimming", "answering your recent replies faster than they can be read"];
77
+ }
78
+
79
+ function frictionFact(sitting: MoodTurn[]): [string, string] | null {
80
+ const rough = (t: MoodTurn) => t.corrected || t.repeated || t.interrupted;
81
+ if (countOf(sitting.slice(-4), rough) < 2) return null;
82
+ return ["friction", "correcting, repeating or interrupting in recent turns"];
83
+ }
84
+
85
+ export function readMood(turns: MoodTurn[], base: Baseline): Mood {
86
+ const sitting = currentSitting(turns);
87
+ const found = [
88
+ paceFact(sitting, base),
89
+ lengthFact(sitting, base),
90
+ skimFact(sitting),
91
+ frictionFact(sitting),
92
+ ].filter((f): f is [string, string] => f !== null);
93
+ return { key: found.map(([label]) => label).join(","), facts: found.map(([, f]) => f) };
94
+ }
95
+
96
+ /** Said only when the picture changes, including once when it returns to normal. */
97
+ export function moodReminder(mood: Mood, previousKey: string): string | null {
98
+ if (mood.key === previousKey) return null;
99
+ const text = mood.key
100
+ ? `Interaction, measured over this sitting: the user is ${mood.facts.join("; ")}. Adapt how you reply to this.`
101
+ : "Interaction: the user's recent turns are back to their usual pattern.";
102
+ return `<system-reminder>${text}</system-reminder>`;
103
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * How the user's message answers the agent's previous reply, read in code.
3
+ * This is the outcome a reply is judged by, so precision beats recall:
4
+ * anything unclear is a follow-up, never a guess at approval or complaint.
5
+ */
6
+
7
+ import { extractKeywords, similarity } from "./text-similarity";
8
+
9
+ export type Reaction = "corrected" | "repeated" | "approved" | "new-topic" | "follow-up";
10
+
11
+ export interface ReplyContext {
12
+ previousPrompt?: string;
13
+ replyKeywords: string[];
14
+ }
15
+
16
+ const CORRECTION_RE =
17
+ /^(?:(?: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
+
19
+ const APPROVAL_RE =
20
+ /^(?: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
+
22
+ 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;
24
+
25
+ const GO_AHEAD_MAX_WORDS = 6;
26
+ const BARE_OK_RE = /^(?:ok(?:ay)?|k)[,.!]?(?:\s+\S+){0,2}$/i;
27
+ const HEDGE_RE = /\b(?:but|however|though|except|instead)\b/i;
28
+ const REPEAT_SIMILARITY = 0.6;
29
+ const REPEAT_MIN_KEYWORDS = 5;
30
+ const NEW_TOPIC_MIN_KEYWORDS = 4;
31
+ const NEW_TOPIC_MAX_OVERLAP = 0.15;
32
+
33
+ export function isCorrection(text: string): boolean {
34
+ return CORRECTION_RE.test(text.trim());
35
+ }
36
+
37
+ export function isRepeat(text: string, previous: string | undefined): boolean {
38
+ if (!previous || extractKeywords(text).size < REPEAT_MIN_KEYWORDS) return false;
39
+ return similarity(text, previous) >= REPEAT_SIMILARITY;
40
+ }
41
+
42
+ function isApproval(text: string): boolean {
43
+ const trimmed = text.trim();
44
+ if (HEDGE_RE.test(trimmed.split(/[.!?\n]/)[0])) return false;
45
+ 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;
47
+ }
48
+
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
+ }
60
+
61
+ export function reactionTo(text: string, context: ReplyContext): Reaction {
62
+ if (isCorrection(text)) return "corrected";
63
+ if (isRepeat(text, context.previousPrompt)) return "repeated";
64
+ if (isApproval(text)) return "approved";
65
+ if (isNewTopic(text, context)) return "new-topic";
66
+ return "follow-up";
67
+ }
68
+
69
+ export function replyKeywords(reply: string): string[] {
70
+ return [...extractKeywords(reply)];
71
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * A rolling, local sample of labelled messages with the reply they answered,
3
+ * kept so the reaction rules can be audited against the user's real language.
4
+ * Follow-ups get most of the room: that is where the rules' misses land.
5
+ */
6
+
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import type { Reaction } from "./interaction-reaction";
10
+ import { paths } from "./paths";
11
+ import { isEnabled } from "./settings";
12
+
13
+ export interface ReactionSample {
14
+ ts: string;
15
+ session: string;
16
+ reaction: Reaction;
17
+ text: string;
18
+ replyEnd: string;
19
+ }
20
+
21
+ const KEPT: Record<Reaction, number> = {
22
+ "follow-up": 150,
23
+ approved: 20,
24
+ corrected: 20,
25
+ repeated: 20,
26
+ "new-topic": 20,
27
+ };
28
+ const TEXT_MAX = 1000;
29
+ const REPLY_END_MAX = 600;
30
+
31
+ function samplesFile(): string {
32
+ return resolve(paths.state(), "reaction-samples.json");
33
+ }
34
+
35
+ /** @lintignore exercised directly by test/interaction-samples.test.ts */
36
+ export function readSamples(): ReactionSample[] {
37
+ try {
38
+ return JSON.parse(readFileSync(samplesFile(), "utf-8"));
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ /** A rare label keeps its own room, so frequent approvals never push out the corrections. */
45
+ function newestPerReaction(samples: ReactionSample[]): ReactionSample[] {
46
+ const kept = (Object.keys(KEPT) as Reaction[]).flatMap((reaction) =>
47
+ samples.filter((s) => s.reaction === reaction).slice(-KEPT[reaction])
48
+ );
49
+ return kept.sort((a, b) => a.ts.localeCompare(b.ts));
50
+ }
51
+
52
+ /** The end of a reply is what the user's next message usually answers. */
53
+ export function replyEnd(reply: string): string {
54
+ return reply.trim().slice(-REPLY_END_MAX);
55
+ }
56
+
57
+ export function keepSample(sample: ReactionSample): void {
58
+ if (!isEnabled("reactionSampling")) return;
59
+ const trimmed = { ...sample, text: sample.text.slice(0, TEXT_MAX) };
60
+ const samples = newestPerReaction([...readSamples(), trimmed]);
61
+ writeFileSync(samplesFile(), JSON.stringify(samples), "utf-8");
62
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Measures every user turn in code and logs the features, never the text.
3
+ * Per-session tracking lives in state and is discarded with the session.
4
+ */
5
+
6
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { currentAttribution } from "./actor";
9
+ import {
10
+ type Baseline,
11
+ DEFAULT_BASELINE,
12
+ type MoodTurn,
13
+ median,
14
+ moodReminder,
15
+ readMood,
16
+ } from "./interaction-mood";
17
+ import {
18
+ isCorrection,
19
+ isRepeat,
20
+ type Reaction,
21
+ reactionTo,
22
+ replyKeywords,
23
+ } from "./interaction-reaction";
24
+ import { keepSample, replyEnd } from "./interaction-samples";
25
+ import { ensureDir, paths } from "./paths";
26
+ import { isSystemText, stripInjectedTags } from "./prompt-text";
27
+ import { isEnabled } from "./settings";
28
+ import { localClock } from "./wall-clock";
29
+
30
+ interface ReplyShape {
31
+ at: string;
32
+ words: number;
33
+ listItems: number;
34
+ headings: number;
35
+ asked: boolean;
36
+ }
37
+
38
+ export interface TurnEvent extends MoodTurn {
39
+ ts: string;
40
+ session: string;
41
+ channel: string | null;
42
+ hour: number;
43
+ weekday: string;
44
+ reply: Omit<ReplyShape, "at"> | null;
45
+ reaction: Reaction | null;
46
+ }
47
+
48
+ interface SessionTrack {
49
+ updated: string;
50
+ lastPrompt?: string;
51
+ lastPromptAt?: string;
52
+ reply?: ReplyShape;
53
+ replyKeywords?: string[];
54
+ replyEnd?: string;
55
+ recent: TurnEvent[];
56
+ mood?: string;
57
+ }
58
+
59
+ const BREAK_SEC = 20 * 60;
60
+ const SKIM_MIN_WORDS = 150;
61
+ const SKIM_WORDS_PER_SEC = 10;
62
+ const RECENT_KEPT = 12;
63
+ const MAX_TRACKED_SESSIONS = 20;
64
+ const BASELINE_MIN_SAMPLES = 20;
65
+
66
+ function wordCount(text: string): number {
67
+ return text.split(/\s+/).filter(Boolean).length;
68
+ }
69
+
70
+ function replyShape(text: string, at: Date): ReplyShape {
71
+ const lines = text.split("\n");
72
+ return {
73
+ at: at.toISOString(),
74
+ words: wordCount(text),
75
+ listItems: lines.filter((l) => /^\s*(?:[-*]|\d+\.)\s/.test(l)).length,
76
+ headings: lines.filter((l) => /^#{1,6}\s/.test(l)).length,
77
+ asked: text.trim().endsWith("?"),
78
+ };
79
+ }
80
+
81
+ /** The reply belongs to this turn only if it arrived after the previous prompt. */
82
+ function answeredReply(track: SessionTrack): ReplyShape | null {
83
+ const reply = track.reply;
84
+ if (!reply || !track.lastPromptAt) return null;
85
+ return reply.at >= track.lastPromptAt ? reply : null;
86
+ }
87
+
88
+ function isSkimmed(reply: ReplyShape | null, gapSec: number | null): boolean {
89
+ if (!reply || gapSec === null || gapSec <= 0 || reply.words < SKIM_MIN_WORDS)
90
+ return false;
91
+ return reply.words / gapSec > SKIM_WORDS_PER_SEC;
92
+ }
93
+
94
+ function replyFeatures({ at: _at, ...features }: ReplyShape): Omit<ReplyShape, "at"> {
95
+ return features;
96
+ }
97
+
98
+ function measureTurn(
99
+ text: string,
100
+ session: string,
101
+ track: SessionTrack,
102
+ now: Date,
103
+ channel: string | null
104
+ ): TurnEvent {
105
+ const reply = answeredReply(track);
106
+ const gapSec = reply ? (now.getTime() - new Date(reply.at).getTime()) / 1000 : null;
107
+ return {
108
+ ts: now.toISOString(),
109
+ session,
110
+ channel,
111
+ ...localClock(now),
112
+ words: wordCount(text),
113
+ gapSec,
114
+ afterBreak: gapSec !== null && gapSec > BREAK_SEC,
115
+ skimmed: isSkimmed(reply, gapSec),
116
+ interrupted: Boolean(track.lastPromptAt) && !reply,
117
+ repeated: isRepeat(text, track.lastPrompt),
118
+ corrected: isCorrection(text),
119
+ reply: reply ? replyFeatures(reply) : null,
120
+ reaction: reply
121
+ ? reactionTo(text, {
122
+ previousPrompt: track.lastPrompt,
123
+ replyKeywords: track.replyKeywords ?? [],
124
+ })
125
+ : null,
126
+ };
127
+ }
128
+
129
+ function eventsDir(): string {
130
+ return ensureDir(resolve(paths.signals(), "interaction"));
131
+ }
132
+
133
+ function monthFile(date: Date): string {
134
+ return resolve(eventsDir(), `${date.toISOString().slice(0, 7)}.jsonl`);
135
+ }
136
+
137
+ function appendEvent(event: TurnEvent): void {
138
+ const line = { ...currentAttribution(), type: "turn", ...event };
139
+ appendFileSync(monthFile(new Date(event.ts)), `${JSON.stringify(line)}\n`);
140
+ }
141
+
142
+ function readEvents(file: string): TurnEvent[] {
143
+ if (!existsSync(file)) return [];
144
+ return readFileSync(file, "utf-8")
145
+ .split("\n")
146
+ .filter(Boolean)
147
+ .flatMap((line) => {
148
+ try {
149
+ return [JSON.parse(line) as TurnEvent];
150
+ } catch {
151
+ return [];
152
+ }
153
+ });
154
+ }
155
+
156
+ /** The user's own normal, from this month and last, outside the session being judged. */
157
+ function baseline(session: string, now: Date = new Date()): Baseline {
158
+ const lastMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
159
+ const events = [
160
+ ...readEvents(monthFile(lastMonth)),
161
+ ...readEvents(monthFile(now)),
162
+ ].filter((e) => e.session !== session);
163
+ const gaps = events
164
+ .filter((e) => e.gapSec !== null && !e.afterBreak)
165
+ .map((e) => e.gapSec);
166
+ return {
167
+ words:
168
+ events.length >= BASELINE_MIN_SAMPLES
169
+ ? (median(events.map((e) => e.words)) as number)
170
+ : DEFAULT_BASELINE.words,
171
+ gapSec:
172
+ gaps.length >= BASELINE_MIN_SAMPLES
173
+ ? (median(gaps as number[]) as number)
174
+ : DEFAULT_BASELINE.gapSec,
175
+ };
176
+ }
177
+
178
+ function tracksFile(): string {
179
+ return resolve(paths.state(), "interaction-sessions.json");
180
+ }
181
+
182
+ function readTracks(): Record<string, SessionTrack> {
183
+ try {
184
+ return JSON.parse(readFileSync(tracksFile(), "utf-8"));
185
+ } catch {
186
+ return {};
187
+ }
188
+ }
189
+
190
+ function writeTrack(session: string, track: SessionTrack): void {
191
+ const tracks = { ...readTracks(), [session]: track };
192
+ const kept = Object.entries(tracks)
193
+ .sort(([, a], [, b]) => b.updated.localeCompare(a.updated))
194
+ .slice(0, MAX_TRACKED_SESSIONS);
195
+ writeFileSync(tracksFile(), JSON.stringify(Object.fromEntries(kept)), "utf-8");
196
+ }
197
+
198
+ function trackOf(session: string): SessionTrack {
199
+ return readTracks()[session] ?? { updated: "", recent: [] };
200
+ }
201
+
202
+ /** Called when the agent finishes a reply, so the next turn can be measured against it. */
203
+ export function recordReply(
204
+ session: string,
205
+ reply: string,
206
+ now: Date = new Date()
207
+ ): void {
208
+ if (!session || !reply.trim() || !isEnabled("interactionAwareness")) return;
209
+ const track = trackOf(session);
210
+ writeTrack(session, {
211
+ ...track,
212
+ updated: now.toISOString(),
213
+ reply: replyShape(reply, now),
214
+ replyKeywords: replyKeywords(reply),
215
+ replyEnd: replyEnd(reply),
216
+ });
217
+ }
218
+
219
+ function sampleReaction(event: TurnEvent, text: string, track: SessionTrack): void {
220
+ if (!event.reaction) return;
221
+ keepSample({
222
+ ts: event.ts,
223
+ session: event.session,
224
+ reaction: event.reaction,
225
+ text,
226
+ replyEnd: track.replyEnd ?? "",
227
+ });
228
+ }
229
+
230
+ /** Logs the turn and returns a reminder when the session's picture changed. */
231
+ export function observeTurn(
232
+ prompt: string,
233
+ session: string | undefined,
234
+ now: Date = new Date(),
235
+ channel: string | null = process.env.PAL_CHANNEL || null
236
+ ): string | null {
237
+ if (!session || isSystemText(prompt) || !isEnabled("interactionAwareness")) return null;
238
+ const text = stripInjectedTags(prompt);
239
+ if (!text) return null;
240
+ const track = trackOf(session);
241
+ const event = measureTurn(text, session, track, now, channel);
242
+ appendEvent(event);
243
+ sampleReaction(event, text, track);
244
+ const recent = [...track.recent, event].slice(-RECENT_KEPT);
245
+ const mood = readMood(recent, baseline(session, now));
246
+ writeTrack(session, {
247
+ ...track,
248
+ updated: now.toISOString(),
249
+ lastPrompt: text,
250
+ lastPromptAt: now.toISOString(),
251
+ recent,
252
+ mood: mood.key,
253
+ });
254
+ return moodReminder(mood, track.mood ?? "");
255
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The prompt field carries more than the user typed: IDE tags, reminders, task
3
+ * notifications and continuation boilerplate arrive through the same hook.
4
+ */
5
+
6
+ const INJECTED_TAG_RE =
7
+ /<(?:ide_opened_file|ide_selection|system-reminder|task-notification)[^>]*>[\s\S]*?<\/(?:ide_opened_file|ide_selection|system-reminder|task-notification)>/gi;
8
+
9
+ export function stripInjectedTags(prompt: string): string {
10
+ return prompt.replace(INJECTED_TAG_RE, "").trim();
11
+ }
12
+
13
+ const SYSTEM_TEXT_PATTERNS = [
14
+ /^<task-notification>/i,
15
+ /^<system-reminder>/i,
16
+ /^This session is being continued from a previous conversation/i,
17
+ /^Please continue the conversation/i,
18
+ /^Note:.*was read before/i,
19
+ /^Another Claude session sent a message:/i,
20
+ ];
21
+
22
+ export function isSystemText(prompt: string): boolean {
23
+ const trimmed = prompt.trim();
24
+ return SYSTEM_TEXT_PATTERNS.some((re) => re.test(trimmed));
25
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Reaction-rule audit cadence and comparison. Maintainer-only: the nudge fires
3
+ * solely in a PAL checkout (see algorithm-review.ts), and the audit never edits
4
+ * the rules, it only reports where a model's reading disagrees with them.
5
+ */
6
+
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { isMaintainerEnv } from "./algorithm-review";
10
+ import type { Reaction } from "./interaction-reaction";
11
+ import { type ReactionSample, readSamples } from "./interaction-samples";
12
+ import { paths } from "./paths";
13
+
14
+ interface AuditMark {
15
+ lastAuditTs: string;
16
+ followUpShare: number;
17
+ }
18
+
19
+ export interface BlindSample {
20
+ id: string;
21
+ text: string;
22
+ replyEnd: string;
23
+ }
24
+
25
+ interface Disagreement extends BlindSample {
26
+ rule: Reaction;
27
+ model: string;
28
+ }
29
+
30
+ export interface AuditComparison {
31
+ compared: number;
32
+ agreed: number;
33
+ disagreements: Disagreement[];
34
+ }
35
+
36
+ const NUDGE_MIN_NEW = 40;
37
+ const DRIFT_MIN_NEW = 20;
38
+ const DRIFT_MIN_DELTA = 0.15;
39
+
40
+ function markFile(): string {
41
+ return resolve(paths.state(), "reaction-audit.json");
42
+ }
43
+
44
+ function readAuditMark(): AuditMark | null {
45
+ try {
46
+ const parsed = JSON.parse(readFileSync(markFile(), "utf-8"));
47
+ return typeof parsed.lastAuditTs === "string" ? parsed : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function followUpShare(samples: ReactionSample[]): number {
54
+ if (samples.length === 0) return 0;
55
+ return samples.filter((s) => s.reaction === "follow-up").length / samples.length;
56
+ }
57
+
58
+ export function unauditedSamples(mark = readAuditMark()): ReactionSample[] {
59
+ const samples = readSamples();
60
+ return mark ? samples.filter((s) => s.ts > mark.lastAuditTs) : samples;
61
+ }
62
+
63
+ /** The rule's label stays out, so the model's reading is a second opinion, not a review. */
64
+ export function blindSamples(samples: ReactionSample[]): BlindSample[] {
65
+ return samples.map(({ ts, text, replyEnd }) => ({ id: ts, text, replyEnd }));
66
+ }
67
+
68
+ export function compareLabels(
69
+ samples: ReactionSample[],
70
+ modelLabels: Record<string, string>
71
+ ): AuditComparison {
72
+ const labelled = samples.filter((s) => modelLabels[s.ts]);
73
+ const disagreements = labelled
74
+ .filter((s) => modelLabels[s.ts] !== s.reaction)
75
+ .map((s) => ({
76
+ ...blindSamples([s])[0],
77
+ rule: s.reaction,
78
+ model: modelLabels[s.ts],
79
+ }));
80
+ return {
81
+ compared: labelled.length,
82
+ agreed: labelled.length - disagreements.length,
83
+ disagreements,
84
+ };
85
+ }
86
+
87
+ export function writeAuditMark(now: Date = new Date()): void {
88
+ const mark: AuditMark = {
89
+ lastAuditTs: now.toISOString(),
90
+ followUpShare: followUpShare(readSamples()),
91
+ };
92
+ writeFileSync(markFile(), `${JSON.stringify(mark, null, 2)}\n`);
93
+ }
94
+
95
+ function auditReason(): string | null {
96
+ const mark = readAuditMark();
97
+ const fresh = unauditedSamples(mark);
98
+ if (fresh.length >= NUDGE_MIN_NEW) return `${fresh.length} new labelled messages`;
99
+ if (!mark || fresh.length < DRIFT_MIN_NEW) return null;
100
+ const share = followUpShare(fresh);
101
+ if (Math.abs(share - mark.followUpShare) < DRIFT_MIN_DELTA) return null;
102
+ const pct = (n: number) => `${Math.round(n * 100)}%`;
103
+ return `the follow-up share moved from ${pct(mark.followUpShare)} to ${pct(share)}`;
104
+ }
105
+
106
+ /** Formatted nudge section for the session reminder, or "" when it shouldn't fire. */
107
+ export function loadReactionAuditNudge(cwd: string = process.cwd()): string {
108
+ if (!isMaintainerEnv(cwd)) return "";
109
+ const reason = auditReason();
110
+ if (!reason) return "";
111
+ return [
112
+ "## Reaction Rules Audit Due",
113
+ `🔎 ${reason} since the last audit — run \`/reaction-audit\` to check the reaction rules against them.`,
114
+ ].join("\n");
115
+ }
@@ -102,10 +102,12 @@ export function loadRecentNotes(days: number = 2): string {
102
102
  const cutoff = new Date();
103
103
  cutoff.setDate(cutoff.getDate() - days);
104
104
 
105
+ const cutoffMonth = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}`;
105
106
  const sections: string[] = [];
106
107
 
107
108
  for (const monthDir of readdirSync(relDir).sort().reverse()) {
108
109
  if (!/^\d{4}-\d{2}$/.test(monthDir)) continue;
110
+ if (monthDir < cutoffMonth) break;
109
111
  const monthPath = resolve(relDir, monthDir);
110
112
 
111
113
  let files: string[];
@@ -129,8 +131,6 @@ export function loadRecentNotes(days: number = 2): string {
129
131
  // skip unreadable files
130
132
  }
131
133
  }
132
-
133
- if (sections.length > 0) break; // only go back one month at most
134
134
  }
135
135
 
136
136
  return sections.join("\n\n---\n\n");
@@ -20,6 +20,7 @@ import { resetTab } from "../handlers/tab";
20
20
  import { updateCounts } from "../handlers/update-counts";
21
21
  import { captureWorkSession } from "../handlers/work-session";
22
22
  import { spawnDetachedInference } from "./detached-inference";
23
+ import { recordReply } from "./interaction";
23
24
  import { logDebug, logError } from "./log";
24
25
  import { assets, ensureDir, paths } from "./paths";
25
26
  import {
@@ -48,6 +49,7 @@ export async function runStopHandlers(
48
49
 
49
50
  // Cache last assistant response (session-scoped)
50
51
  cacheLastResponse(messages, options.lastAssistantMessage, options.sessionId);
52
+ if (options.sessionId) recordFinalReply(messages, options.sessionId);
51
53
 
52
54
  // Always persist last exchange — drives CompactRecover + "Pick Up Where You Left Off"
53
55
  if (options.sessionId) persistLastExchange(messages, options.sessionId);
@@ -161,6 +163,17 @@ function cacheLastResponse(
161
163
  }
162
164
  }
163
165
 
166
+ function recordFinalReply(
167
+ messages: ReturnType<typeof parseMessages>,
168
+ sessionId: string
169
+ ): void {
170
+ try {
171
+ recordReply(sessionId, extractContent(extractLastAssistant(messages)));
172
+ } catch (err) {
173
+ logError("runStopHandlers:recordReply", err);
174
+ }
175
+ }
176
+
164
177
  /** Write transcript to a fresh tmp file and return the path. Child unlinks it. */
165
178
  async function writeTranscriptTmp(transcript: string): Promise<string> {
166
179
  const dir = await mkdtemp(resolve(tmpdir(), "pal-transcript-"));
@@ -57,6 +57,12 @@ export function localDay(now: Date = new Date()): string {
57
57
  return `${f.year}-${f.month}-${f.day}`;
58
58
  }
59
59
 
60
+ /** Hour (0-23) and short weekday in the principal's timezone. */
61
+ export function localClock(now: Date = new Date()): { hour: number; weekday: string } {
62
+ const f = fieldsIn(now, zoneOrUtc(identity().principal.timezone));
63
+ return { hour: Number(f.hour), weekday: f.weekday };
64
+ }
65
+
60
66
  export function getWallClockReminder(now: Date = new Date()): string | null {
61
67
  if (!isEnabled("wallClock")) return null;
62
68
  const line = wallClockLine(now, identity().principal.timezone);
@@ -175,7 +175,7 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
175
175
  const text = partsToText(output.parts ?? []);
176
176
  if (!text.trim()) return;
177
177
 
178
- const injectedText = (await getPromptContext(text)) ?? "";
178
+ const injectedText = (await getPromptContext(text, input.sessionID)) ?? "";
179
179
  logPromptSnapshot(text, injectedText || null);
180
180
 
181
181
  await Promise.allSettled([