portable-agent-layer 0.85.21 → 0.85.23

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.
@@ -425,6 +425,17 @@ LoadContext.ts
425
425
  8. Other recent learnings (other projects, titles only)
426
426
  ```
427
427
 
428
+ ### Injection Budget
429
+
430
+ Claude Code caps each string a hook injects at **10,000 characters**: plain stdout as a whole, or each JSON field (`additionalContext`, `systemMessage`) on its own. Over the cap, the text is saved to a file and replaced with the file path plus a preview of the first 2,000 characters, and the model is never told to read the file. No setting raises the cap.
431
+
432
+ Design rules for anything injected by a hook (Tier 3 and per-prompt reminders):
433
+ - Stay under 10,000 characters per hook output.
434
+ - Put what matters most in the first 2,000 characters, in case a future change crosses the cap.
435
+ - Measure before adding a section: pipe a SessionStart payload into `LoadContext.ts` and count the bytes.
436
+
437
+ Tier 1 and Tier 2 load as memory files, not hook output, so this cap does not apply to them. Other agents' limits are not documented here; check them before relying on the same budget.
438
+
428
439
  ### On-Demand Context
429
440
 
430
441
  Everything else loads via the routing table in CLAUDE.md. The AI reads files only when the current task requires that context — no upfront loading of the full system.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.85.21",
3
+ "version": "0.85.23",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -64,10 +64,10 @@
64
64
  "tool:thread": "bun run src/tools/agent/thread.ts",
65
65
  "tool:wisdom-frame": "bun run src/tools/agent/wisdom-frame.ts",
66
66
  "tool:reflect": "bun run src/tools/agent/relationship-reflect.ts",
67
- "eval:sentiment": "bun eval/run.ts sentiment",
68
67
  "eval:failure-principle": "bun eval/run.ts failure-principle",
69
68
  "eval:goal-links": "bun eval/run.ts goal-links",
70
- "eval:waiting-on": "bun eval/run.ts waiting-on"
69
+ "eval:waiting-on": "bun eval/run.ts waiting-on",
70
+ "eval:reaction": "bun eval/run.ts reaction"
71
71
  },
72
72
  "lint-staged": {
73
73
  "*.ts": [
@@ -1,19 +1,27 @@
1
1
  /**
2
2
  * UserPromptSubmit handler: detects explicit and implicit ratings.
3
- * Ported from original PAI's RatingCapture.hook.ts with rich sentiment analysis.
4
3
  *
5
4
  * - Explicit: "7", "8 - great work", "rating: 8"
6
- * - Implicit: Haiku-powered sentiment inference on every user message
7
- * - Low ratings (<5) write detailed learning markdown
8
- * - Very low ratings (<=3) write pending-failure.json for Stop handler
5
+ * - Implicit: a model labels the reaction to the previous reply; only a confirmed
6
+ * correction or plain praise becomes a rating
7
+ * - Low ratings (<=4) write pending-failure.json; the Stop handler writes the
8
+ * lesson from the transcript
9
9
  */
10
10
 
11
11
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
13
  import { spawnDetachedInference } from "../lib/detached-inference";
14
14
  import { canInfer, inference } from "../lib/inference";
15
+ import { replyEnd } from "../lib/interaction-samples";
15
16
  import { paths } from "../lib/paths";
16
17
  import { isSystemText, stripInjectedTags } from "../lib/prompt-text";
18
+ import {
19
+ isCorrectionLabel,
20
+ parseReactionLabel,
21
+ ratingContext,
22
+ ratingFromLabels,
23
+ reactionRequest,
24
+ } from "../lib/reaction-rating";
17
25
  import { emitRating } from "../lib/signals";
18
26
  import { now } from "../lib/time";
19
27
  import { logTokenUsage } from "../lib/token-usage";
@@ -129,80 +137,18 @@ function isPraise(prompt: string): boolean {
129
137
  );
130
138
  }
131
139
 
132
- // ── Sentiment Analysis ──
133
-
134
- const SENTIMENT_SCHEMA = {
135
- type: "object",
136
- properties: {
137
- rating: { type: ["number", "null"] },
138
- sentiment: { enum: ["positive", "negative", "neutral"] },
139
- confidence: { type: "number" },
140
- summary: { type: "string" },
141
- detailed_context: { type: "string" },
142
- principle: { type: "string" },
143
- },
144
- required: [
145
- "rating",
146
- "sentiment",
147
- "confidence",
148
- "summary",
149
- "detailed_context",
150
- "principle",
151
- ],
152
- additionalProperties: false,
153
- } as const;
154
-
155
- interface SentimentResult {
156
- rating: number | null;
157
- sentiment: "positive" | "negative" | "neutral";
158
- confidence: number;
159
- summary: string;
160
- detailed_context: string;
161
- principle: string;
162
- }
163
-
164
- const SENTIMENT_SYSTEM_PROMPT = `Analyze the user's message for emotional sentiment toward the AI assistant.
165
-
166
- OUTPUT FORMAT (JSON only):
167
- {
168
- "rating": <1-10 or null>,
169
- "sentiment": "positive" | "negative" | "neutral",
170
- "confidence": <0.0-1.0>,
171
- "summary": "<10 words max>",
172
- "detailed_context": "<what the user wanted, what the AI did or failed to do, root cause, and what to do differently — 50-150 words>",
173
- "principle": "<one actionable rule, 10-20 words, start with a verb: Verify / Always / Never / Ask>"
174
- }
175
-
176
- RATING SCALE: 1-2 strong frustration · 3-4 mild frustration · 5 neutral · 6-7 approval · 8-9 strong approval · 10 exceptional · null = no emotional signal
177
- Short praise ("great job", "nice") = STRONG APPROVAL (8-9), not mild.
178
-
179
- TASK DIRECTIVES — short imperatives with "pls" are approval or sequencing signals (null or 6-7, NOT negative):
180
- "write it pls" / "do it pls" / "step 3 pls" / "option 2 pls" = user directing or approving the AI's own proposal.
181
- EXCEPTION: "fix X pls" / "fix that pls" is always negative — "fix" implies a problem regardless of "pls".
182
-
183
- IMPLIED NEGATIVE (rate 2-4): corrections ("that's wrong", "I meant..."), repeats ("again pls", "still broken", "doesn't work again"), behavioral corrections ("don't do that"), exasperated questions ("why is this still broken?"), failure confirmations ("nothing is fixed").
184
- IMPLIED POSITIVE (rate 6-8): trust signals ("go ahead", "fix all of it"), continuation ("now also add X", "next do..."), moving forward with the next task.
185
- NULL rating: neutral questions, simple continuations ("yes", "continue"), task directives without failure context.`;
186
-
187
- const MIN_CONFIDENCE = 0.5;
188
-
189
140
  // ── Rating Handling ──
190
141
 
191
142
  function handleRating(
192
143
  rating: number,
193
144
  context: string,
194
145
  source: string,
195
- detailedContext?: string,
196
- principle?: string,
197
- sessionId?: string,
146
+ responsePreview: string,
198
147
  userMessage?: string
199
148
  ): void {
200
- const responsePreview = getLastResponse(sessionId).slice(0, 500);
201
149
  emitRating(rating, context, source, responsePreview);
202
150
 
203
151
  if (rating <= 4) {
204
- // Low rating — write pending file for Stop handler with full transcript
205
- const userPreview = userMessage?.slice(0, 400);
206
152
  writeFileSync(
207
153
  resolve(paths.state(), "pending-failure.json"),
208
154
  JSON.stringify(
@@ -210,10 +156,8 @@ function handleRating(
210
156
  rating,
211
157
  context,
212
158
  source,
213
- detailedContext,
214
- principle,
215
159
  responsePreview,
216
- userPreview,
160
+ userPreview: userMessage?.slice(0, 400),
217
161
  cwd: process.cwd(),
218
162
  ts: now(),
219
163
  },
@@ -225,92 +169,63 @@ function handleRating(
225
169
  }
226
170
  }
227
171
 
228
- // ── Implicit Sentiment ──
172
+ // ── Implicit Rating ──
229
173
 
230
- function handleImplicitSentiment(message: string, sessionId?: string): void {
174
+ function handleImplicitReaction(message: string, sessionId?: string): void {
231
175
  const trimmed = message.trim();
176
+ const reply = replyEnd(getLastResponse(sessionId));
232
177
 
233
- // Fast-path: short praise -> rating 8 (synchronous, no inference)
234
178
  if (isPraise(trimmed)) {
235
- handleRating(
236
- 8,
237
- `Direct praise: "${trimmed}"`,
238
- "implicit",
239
- undefined,
240
- undefined,
241
- sessionId,
242
- trimmed
243
- );
179
+ handleRating(8, `Direct praise: "${trimmed}"`, "implicit", reply, trimmed);
244
180
  return;
245
181
  }
246
182
 
247
- // Skip system-injected text
248
183
  if (isSystemText(trimmed)) return;
184
+ if (!reply) return;
249
185
 
250
186
  // Skip very short, very long, or code-like messages
251
187
  if (trimmed.length < 5 || trimmed.length > 500) return;
252
188
  if (/^[/$`{]/.test(trimmed) || trimmed.includes("\n\n")) return;
253
189
 
254
- // Inference path — detach to background. claude --print has 3-5s of cold-start
255
- // overhead per call; running inline would block UserPromptSubmit and exceed
256
- // any reasonable in-line budget. Uses the shared detach helper.
190
+ // claude --print has 3-5s of cold start, too slow for UserPromptSubmit.
191
+ // The reply is read here, before the turn's own reply can replace it in the cache.
257
192
  if (!canInfer()) return;
258
- const msgB64 = Buffer.from(trimmed.slice(0, 800)).toString("base64");
259
193
  spawnDetachedInference(
260
194
  import.meta.filename,
261
- ["--sentiment", sessionId ?? "", msgB64],
195
+ [
196
+ "--sentiment",
197
+ sessionId ?? "",
198
+ Buffer.from(trimmed).toString("base64"),
199
+ Buffer.from(reply).toString("base64"),
200
+ ],
262
201
  "rating"
263
202
  );
264
203
  }
265
204
 
266
- /**
267
- * Background sentiment mode: called via --sentiment flag from a detached subprocess.
268
- * Runs the heavy inference, parses the result, and writes the rating if confident.
269
- */
270
- async function runSentimentInferenceAndStore(
205
+ async function labelReaction(reply: string, message: string, sessionId?: string) {
206
+ const result = await inference(reactionRequest(reply, message, sessionId));
207
+ if (result.usage) logTokenUsage("rating", result.usage);
208
+ return result.success ? parseReactionLabel(result.output) : null;
209
+ }
210
+
211
+ /** Background mode: label the reaction, confirm a correction, store the rating. */
212
+ async function runReactionRatingAndStore(
271
213
  message: string,
214
+ reply: string,
272
215
  sessionId?: string
273
216
  ): Promise<void> {
274
217
  try {
275
- const trimmed = message.trim();
276
- const lastResponse = getLastResponse(sessionId).slice(0, 300);
277
- const contextBlock = lastResponse
278
- ? `CONTEXT (last AI response excerpt):\n${lastResponse}\n\nCURRENT USER MESSAGE:\n${trimmed.slice(0, 300)}`
279
- : trimmed.slice(0, 300);
280
-
281
- const result = await inference({
282
- system: SENTIMENT_SYSTEM_PROMPT,
283
- user: contextBlock,
284
- maxTokens: 500,
285
- timeout: 90000,
286
- jsonSchema: SENTIMENT_SCHEMA,
287
- caller: "rating",
288
- sessionId,
289
- });
290
-
291
- if (result.usage) logTokenUsage("rating", result.usage);
292
- if (!result.success || !result.output) return;
293
-
294
- const parsed = JSON.parse(result.output) as SentimentResult;
295
-
296
- if (parsed.rating === null) return;
297
- if (parsed.confidence < MIN_CONFIDENCE) return;
298
-
299
- const rating = parsed.rating;
300
- if (typeof rating === "number" && rating >= 1 && rating <= 10 && rating !== 5) {
301
- handleRating(
302
- rating,
303
- `${parsed.summary}: ${trimmed.slice(0, 200)}`,
304
- "implicit",
305
- parsed.detailed_context,
306
- parsed.principle,
307
- sessionId,
308
- trimmed
309
- );
218
+ const first = await labelReaction(reply, message, sessionId);
219
+ const confirmation = isCorrectionLabel(first)
220
+ ? await labelReaction(reply, message, sessionId)
221
+ : null;
222
+ const rating = ratingFromLabels(first, confirmation);
223
+ if (first && rating !== null) {
224
+ handleRating(rating, ratingContext(first, message), "implicit", reply, message);
310
225
  }
311
226
  } catch (err) {
312
227
  const { logError } = await import("../lib/log");
313
- logError("rating:sentiment-child", err);
228
+ logError("rating:reaction-child", err);
314
229
  }
315
230
  }
316
231
 
@@ -327,26 +242,29 @@ export function captureRating(message: string, sessionId?: string): void {
327
242
  explicit.rating,
328
243
  explicit.comment || cleaned.slice(0, 200),
329
244
  "explicit",
330
- undefined,
331
- undefined,
332
- sessionId,
245
+ getLastResponse(sessionId).slice(0, 500),
333
246
  cleaned
334
247
  );
335
248
  return;
336
249
  }
337
250
 
338
- // Path 2: Implicit sentiment — fast-paths run synchronously, the inference
339
- // path detaches to a background bun subprocess (mirrors session-name).
340
- handleImplicitSentiment(cleaned, sessionId);
251
+ // Path 2: Implicit reaction — the praise fast-path runs synchronously, the
252
+ // model path detaches to a background bun subprocess (mirrors session-name).
253
+ handleImplicitReaction(cleaned, sessionId);
341
254
  }
342
255
 
343
- // Background sentiment entry point
256
+ // Background reaction entry point
344
257
  if (process.argv[2] === "--sentiment") {
345
258
  const sid = process.argv[3];
346
259
  const msgB64 = process.argv[4];
347
- if (msgB64) {
348
- const msg = Buffer.from(msgB64, "base64").toString("utf-8");
349
- await runSentimentInferenceAndStore(msg, sid === "" ? undefined : sid);
260
+ const replyB64 = process.argv[5];
261
+ if (msgB64 && replyB64) {
262
+ const decode = (b64: string) => Buffer.from(b64, "base64").toString("utf-8");
263
+ await runReactionRatingAndStore(
264
+ decode(msgB64),
265
+ decode(replyB64),
266
+ sid === "" ? undefined : sid
267
+ );
350
268
  }
351
269
  process.exit(0);
352
270
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Implicit rating from the user's reaction to the previous reply.
3
+ *
4
+ * A model labels the reaction from the end of the reply and the message. Only a
5
+ * correction, confirmed by a second call, or plain praise becomes a rating; a
6
+ * go-ahead, a question or news carries no rating at all.
7
+ */
8
+
9
+ const MODEL_REACTIONS = [
10
+ "corrected",
11
+ "repeated",
12
+ "praised",
13
+ "approved",
14
+ "new-topic",
15
+ "follow-up",
16
+ ] as const;
17
+
18
+ type ModelReaction = (typeof MODEL_REACTIONS)[number];
19
+
20
+ export interface ReactionLabel {
21
+ reaction: ModelReaction;
22
+ issue: string;
23
+ }
24
+
25
+ const REACTION_SYSTEM_PROMPT = `You classify how a user reacted to an AI assistant's reply.
26
+
27
+ Pick exactly one label:
28
+ - corrected: the message shows the reply itself was wrong. Something the reply claimed is false, something it said was done is missing, a command or step it gave does not work, a promise it made was not kept, it forgot or missed what the user asked, or the user still does not understand after its explanation.
29
+ - repeated: the user restates their previous request because it was not done.
30
+ - praised: the user explicitly praises the reply or the work, such as "well done", "perfect", "exactly", "this works great".
31
+ - approved: the user accepts the reply or tells the assistant to go ahead, with nothing else to fix.
32
+ - new-topic: the user moves to something unrelated to the reply.
33
+ - follow-up: anything else on the same work.
34
+
35
+ These are follow-up, not corrected:
36
+ - news about something that failed after the reply, when the reply never claimed it would work, such as a failed CI run or pasted errors;
37
+ - asking "are you sure?" or asking the assistant to double-check, without saying the reply is wrong;
38
+ - suggesting more, or a new requirement on top of the work;
39
+ - asking where or how to do a step the reply described;
40
+ - a complaint about something other than the reply.
41
+
42
+ A go-ahead, "yes", "ok", or "thanks" with a next instruction is approved, not praised.
43
+
44
+ Judge the message against what the reply said. Answer corrected only when the message clearly says or shows the reply was wrong; otherwise answer follow-up.
45
+
46
+ For corrected or repeated, set issue to what the reply got wrong, in at most twelve words. Otherwise set issue to an empty string.`;
47
+
48
+ const REACTION_SCHEMA = {
49
+ type: "object",
50
+ properties: {
51
+ reaction: { enum: [...MODEL_REACTIONS] },
52
+ issue: { type: "string" },
53
+ },
54
+ required: ["reaction", "issue"],
55
+ additionalProperties: false,
56
+ } as const;
57
+
58
+ const MESSAGE_MAX = 800;
59
+
60
+ export function reactionRequest(replyEnd: string, message: string, sessionId?: string) {
61
+ return {
62
+ system: REACTION_SYSTEM_PROMPT,
63
+ user: `The end of the assistant's reply:\n<reply_end>\n${replyEnd}\n</reply_end>\n\nThe user's next message:\n<message>\n${message.slice(0, MESSAGE_MAX)}\n</message>`,
64
+ maxTokens: 200,
65
+ timeout: 90_000,
66
+ jsonSchema: REACTION_SCHEMA,
67
+ caller: "rating",
68
+ sessionId,
69
+ };
70
+ }
71
+
72
+ function isModelReaction(value: unknown): value is ModelReaction {
73
+ return MODEL_REACTIONS.includes(value as ModelReaction);
74
+ }
75
+
76
+ export function parseReactionLabel(
77
+ output: string | null | undefined
78
+ ): ReactionLabel | null {
79
+ if (!output) return null;
80
+ try {
81
+ const parsed = JSON.parse(output) as { reaction?: unknown; issue?: unknown };
82
+ if (!isModelReaction(parsed.reaction)) return null;
83
+ return {
84
+ reaction: parsed.reaction,
85
+ issue: typeof parsed.issue === "string" ? parsed.issue.trim() : "",
86
+ };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ export function isCorrectionLabel(label: ReactionLabel | null): boolean {
93
+ return label?.reaction === "corrected" || label?.reaction === "repeated";
94
+ }
95
+
96
+ const RATING_BY_REACTION: Partial<Record<ModelReaction, number>> = {
97
+ corrected: 3,
98
+ repeated: 2,
99
+ praised: 8,
100
+ };
101
+
102
+ /** A correction counts only when a second, independent label agrees. */
103
+ export function ratingFromLabels(
104
+ first: ReactionLabel | null,
105
+ confirmation: ReactionLabel | null
106
+ ): number | null {
107
+ if (!first) return null;
108
+ if (isCorrectionLabel(first) && !isCorrectionLabel(confirmation)) return null;
109
+ return RATING_BY_REACTION[first.reaction] ?? null;
110
+ }
111
+
112
+ export function ratingContext(label: ReactionLabel, message: string): string {
113
+ const what = label.reaction === "praised" ? "Praised the reply" : label.issue;
114
+ return `${what || label.reaction}: ${message.slice(0, 200)}`;
115
+ }