portable-agent-layer 0.89.1 → 0.91.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.
@@ -0,0 +1,108 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { palPkg } from "../hooks/lib/paths";
4
+
5
+ export interface Release {
6
+ tag_name: string;
7
+ body?: string | null;
8
+ }
9
+
10
+ export interface WhatsNew {
11
+ items: string[];
12
+ more: number;
13
+ url: string;
14
+ }
15
+
16
+ const SHOWN = 3;
17
+
18
+ function parts(version: string): number[] {
19
+ return version.replace(/^v/, "").split(".").map(Number);
20
+ }
21
+
22
+ export function newer(a: string, b: string): boolean {
23
+ const [x, y] = [parts(a), parts(b)];
24
+ for (let i = 0; i < 3; i++) {
25
+ if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /** "* **rating:** confirm corrections ([abc](url))" reads "Confirm corrections". */
31
+ export function noteText(bullet: string): string {
32
+ const text = bullet
33
+ .replace(/^\s*[*-]\s*/, "")
34
+ .replace(/^\*\*[^*]+:\*\*\s*/, "")
35
+ .replace(/\s*\(\[[^\]]*\]\([^)]*\)\)/g, "")
36
+ .trim();
37
+ return text.charAt(0).toUpperCase() + text.slice(1);
38
+ }
39
+
40
+ function section(body: string, heading: string): string[] {
41
+ const start = body.indexOf(`### ${heading}`);
42
+ if (start < 0) return [];
43
+ const rest = body.slice(start).split("\n").slice(1);
44
+ const end = rest.findIndex((line) => line.startsWith("#"));
45
+ return (end < 0 ? rest : rest.slice(0, end))
46
+ .filter((line) => /^\s*[*-]\s/.test(line))
47
+ .map(noteText);
48
+ }
49
+
50
+ export function releaseNotes(body: string): string[] {
51
+ return [...section(body, "Features"), ...section(body, "Bug Fixes")];
52
+ }
53
+
54
+ function repoSlug(pkg = palPkg()): string | null {
55
+ try {
56
+ const manifest = JSON.parse(readFileSync(resolve(pkg, "package.json"), "utf-8")) as {
57
+ repository?: { url?: string };
58
+ };
59
+ return (
60
+ /github\.com\/([^/]+\/[^/.]+)/.exec(manifest.repository?.url ?? "")?.[1] ?? null
61
+ );
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export function summarizeReleases(
68
+ releases: Release[],
69
+ from: string,
70
+ to: string,
71
+ slug: string
72
+ ): WhatsNew | null {
73
+ const items = releases
74
+ .filter((r) => newer(r.tag_name, from) && !newer(r.tag_name, to))
75
+ .flatMap((r) => releaseNotes(r.body ?? ""));
76
+ if (items.length === 0) return null;
77
+ return {
78
+ items: items.slice(0, SHOWN),
79
+ more: Math.max(0, items.length - SHOWN),
80
+ url: `github.com/${slug}/releases/tag/v${to}`,
81
+ };
82
+ }
83
+
84
+ async function fetchReleases(slug: string): Promise<Release[]> {
85
+ const response = await fetch(
86
+ `https://api.github.com/repos/${slug}/releases?per_page=30`,
87
+ {
88
+ headers: { accept: "application/vnd.github+json" },
89
+ signal: AbortSignal.timeout(3000),
90
+ }
91
+ );
92
+ return response.ok ? ((await response.json()) as Release[]) : [];
93
+ }
94
+
95
+ /** Null when the notes cannot be fetched in time: an update never waits on GitHub. */
96
+ export async function whatsNew(
97
+ from: string,
98
+ to: string,
99
+ fetchAll: (slug: string) => Promise<Release[]> = fetchReleases
100
+ ): Promise<WhatsNew | null> {
101
+ const slug = repoSlug();
102
+ if (!slug) return null;
103
+ try {
104
+ return summarizeReleases(await fetchAll(slug), from, to, slug);
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
@@ -21,14 +21,18 @@ import { spawnDetachedInference } from "../lib/detached-inference";
21
21
  import { canInfer, inference } from "../lib/inference";
22
22
  import { replyEnd } from "../lib/interaction-samples";
23
23
  import { logDebug } from "../lib/log";
24
+ import { HAIKU_5_5_MODEL } from "../lib/models";
24
25
  import { paths } from "../lib/paths";
25
26
  import { isSystemText, stripInjectedTags } from "../lib/prompt-text";
26
27
  import {
27
- isCorrectionLabel,
28
+ correctionCheckRequest,
29
+ needsCorrectionCheck,
30
+ parseCorrectionCheck,
28
31
  parseReactionLabel,
29
32
  ratingContext,
30
33
  ratingFromLabels,
31
34
  reactionRequest,
35
+ settledLabel,
32
36
  turnFromLabels,
33
37
  } from "../lib/reaction-rating";
34
38
  import {
@@ -225,6 +229,12 @@ async function labelReaction(reply: string, message: string, sessionId?: string)
225
229
  return result.success ? parseReactionLabel(result.output) : null;
226
230
  }
227
231
 
232
+ async function checkCorrection(reply: string, message: string, sessionId?: string) {
233
+ const result = await inference(correctionCheckRequest(reply, message, sessionId));
234
+ if (result.usage) logTokenUsage("rating", result.usage);
235
+ return result.success ? parseCorrectionCheck(result.output) : null;
236
+ }
237
+
228
238
  function knownRules() {
229
239
  const waiting = readCandidates().filter((candidate) => candidate.verdict === "waiting");
230
240
  return [
@@ -240,13 +250,13 @@ async function draftRuleCandidate(sessionId?: string): Promise<void> {
240
250
  const known = knownRules();
241
251
  if (!canRepeat(corrections) && !canWiden(corrections, known)) return;
242
252
  const result = await inference(drafterRequest(corrections, known, sessionId));
243
- if (result.usage) logTokenUsage("rule-drafter", result.usage);
253
+ if (result.usage) logTokenUsage("rule-drafter", result.usage, HAIKU_5_5_MODEL);
244
254
  const candidate = result.success ? parseDraft(result.output, corrections, known) : null;
245
255
  if (candidate) recordCandidate(candidate, turns);
246
256
  else logDebug("rule-drafter", `no candidate: ${result.output ?? result.error ?? ""}`);
247
257
  }
248
258
 
249
- /** Background mode: label the reaction, confirm a correction, store the rating. */
259
+ /** Background mode: label the reaction, check a possible correction, store the rating. */
250
260
  async function runReactionRatingAndStore(
251
261
  message: string,
252
262
  reply: string,
@@ -255,17 +265,18 @@ async function runReactionRatingAndStore(
255
265
  ): Promise<void> {
256
266
  try {
257
267
  const first = await labelReaction(reply, message, sessionId);
258
- const confirmation = isCorrectionLabel(first)
259
- ? await labelReaction(reply, message, sessionId)
268
+ const check = needsCorrectionCheck(first)
269
+ ? await checkCorrection(reply, message, sessionId)
260
270
  : null;
261
- const rating = ratingFromLabels(first, confirmation);
262
- if (first && rating !== null) {
263
- handleRating(rating, ratingContext(first, message), "implicit", reply, message);
271
+ const label = settledLabel(first, check);
272
+ const rating = ratingFromLabels(label, check);
273
+ if (label && rating !== null) {
274
+ handleRating(rating, ratingContext(label, message), "implicit", reply, message);
264
275
  }
265
276
  const turn = turnFromLabels(
266
277
  { session: sessionId ?? "", message, replyEnd: reply },
267
- first,
268
- confirmation
278
+ label,
279
+ check
269
280
  );
270
281
  if (turn) appendTurn(turn, sentAt);
271
282
  if (turn?.confirmed) await draftRuleCandidate(sessionId);
@@ -74,7 +74,7 @@ interface RoutePreview {
74
74
  }
75
75
 
76
76
  function modelFor(route: FixedModelRoute, opts: InferenceOptions): string {
77
- return inferenceModel(route, opts.tier);
77
+ return inferenceModel(route, opts.tier, opts.caller);
78
78
  }
79
79
 
80
80
  function smallModelOf(route: InferenceRoute): string | undefined {
@@ -5,6 +5,7 @@
5
5
  import type { AgentType } from "./agent";
6
6
 
7
7
  export const HAIKU_MODEL = "claude-haiku-4-5-20251001";
8
+ export const HAIKU_5_5_MODEL = "claude-haiku-5-5";
8
9
  export const SONNET_MODEL = "claude-sonnet-5";
9
10
  export const FABLE_MODEL = "claude-fable-5";
10
11
 
@@ -54,11 +55,23 @@ export function isFixedModelRoute(route: string): route is FixedModelRoute {
54
55
  return Object.hasOwn(INFERENCE_MODELS, route);
55
56
  }
56
57
 
58
+ /** Callers whose prompt has been evaluated on Haiku 5.5; the rest stay on HAIKU_MODEL until theirs is. */
59
+ const ANTHROPIC_EVALUATED_ON_HAIKU_5_5 = new Set(["rule-drafter"]);
60
+
61
+ function evaluatedModel(models: RouteModels, caller?: string): string | undefined {
62
+ const onAnthropic = models === ANTHROPIC_MODELS;
63
+ return onAnthropic && caller && ANTHROPIC_EVALUATED_ON_HAIKU_5_5.has(caller)
64
+ ? HAIKU_5_5_MODEL
65
+ : undefined;
66
+ }
67
+
57
68
  export function inferenceModel(
58
69
  route: FixedModelRoute,
59
- tier: InferenceTier = "small"
70
+ tier: InferenceTier = "small",
71
+ caller?: string
60
72
  ): string {
61
- return INFERENCE_MODELS[route][tier];
73
+ const models = INFERENCE_MODELS[route];
74
+ return evaluatedModel(models, caller) ?? models[tier];
62
75
  }
63
76
 
64
77
  const AGENT_SPAWN_ROUTE: Partial<Record<AgentType, FixedModelRoute>> = {
@@ -102,6 +115,14 @@ export const MODEL_PRICING: Record<string, ModelPricing> = {
102
115
  cacheWrite1h: 2,
103
116
  cacheRead: 0.1,
104
117
  },
118
+ // Rates for prompts up to 100k tokens; longer prompts cost 5x, and PAL's never get there.
119
+ [HAIKU_5_5_MODEL]: {
120
+ input: 0.1,
121
+ output: 0.5,
122
+ cacheWrite5m: 0.125,
123
+ cacheWrite1h: 0.2,
124
+ cacheRead: 0.01,
125
+ },
105
126
  [FABLE_MODEL]: {
106
127
  input: 10,
107
128
  output: 50,
@@ -57,12 +57,47 @@ const REACTION_SCHEMA = {
57
57
  additionalProperties: false,
58
58
  } as const;
59
59
 
60
+ const CORRECTION_CHECK_PROMPT = `An AI assistant sent a reply and the user answered. A first reader labelled the answer a correction of the reply. Check whether that is right.
61
+
62
+ It is a correction only when the user says or shows that the reply itself was wrong:
63
+ - something the reply claimed is false;
64
+ - something it said was done is not done;
65
+ - a command or step it gave does not work;
66
+ - a promise it made was not kept;
67
+ - following its advice or steps caused a problem;
68
+ - it missed what the user asked, or is waiting on or asking for something the user had already done;
69
+ - the user still does not understand after its explanation, even when they phrase it as a question.
70
+
71
+ It is not a correction when the user:
72
+ - wants more, or adds a requirement the reply was not asked to meet. "This needs more" or "it should also do X" is a new requirement unless the reply claimed X was already done;
73
+ - cannot find where or how to do a step the reply described;
74
+ - doubts the reply or asks for a check, without saying it is wrong;
75
+ - reports something that failed later, when the reply never claimed it would work;
76
+ - complains about something other than what the reply claimed;
77
+ - decides to change or drop a feature.`;
78
+
79
+ const CORRECTION_CHECK_ANSWER = `Answer corrected if it is a correction, otherwise not-corrected. For corrected, set issue to what the reply got wrong, in at most twelve words; otherwise set issue to an empty string.`;
80
+
81
+ const CORRECTION_CHECK_SCHEMA = {
82
+ type: "object",
83
+ properties: {
84
+ reaction: { enum: ["corrected", "not-corrected"] },
85
+ issue: { type: "string" },
86
+ },
87
+ required: ["reaction", "issue"],
88
+ additionalProperties: false,
89
+ } as const;
90
+
60
91
  const MESSAGE_MAX = 800;
61
92
 
93
+ function reactionPrompt(replyEnd: string, message: string): string {
94
+ return `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>`;
95
+ }
96
+
62
97
  export function reactionRequest(replyEnd: string, message: string, sessionId?: string) {
63
98
  return {
64
99
  system: REACTION_SYSTEM_PROMPT,
65
- 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>`,
100
+ user: reactionPrompt(replyEnd, message),
66
101
  maxTokens: 200,
67
102
  timeout: 90_000,
68
103
  jsonSchema: REACTION_SCHEMA,
@@ -71,6 +106,51 @@ export function reactionRequest(replyEnd: string, message: string, sessionId?: s
71
106
  };
72
107
  }
73
108
 
109
+ /**
110
+ * Asked again with the labelling prompt, a model repeats its own mistake; a
111
+ * narrower question catches the follow-ups it misreads as corrections, and the
112
+ * corrections it misreads as follow-ups. The answer instruction follows the
113
+ * message, the order eval/correction-check measures.
114
+ */
115
+ export function correctionCheckRequest(
116
+ replyEnd: string,
117
+ message: string,
118
+ sessionId?: string
119
+ ) {
120
+ return {
121
+ ...reactionRequest(replyEnd, message, sessionId),
122
+ system: CORRECTION_CHECK_PROMPT,
123
+ user: `${reactionPrompt(replyEnd, message)}\n\n${CORRECTION_CHECK_ANSWER}`,
124
+ jsonSchema: CORRECTION_CHECK_SCHEMA,
125
+ };
126
+ }
127
+
128
+ export function parseCorrectionCheck(
129
+ output: string | null | undefined
130
+ ): ReactionLabel | null {
131
+ if (!output) return null;
132
+ try {
133
+ const parsed = JSON.parse(output) as { reaction?: unknown; issue?: unknown };
134
+ if (parsed.reaction !== "corrected") return { reaction: "follow-up", issue: "" };
135
+ const issue = typeof parsed.issue === "string" ? parsed.issue.trim() : "";
136
+ return { reaction: "corrected", issue };
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ export function needsCorrectionCheck(first: ReactionLabel | null): boolean {
143
+ return isCorrectionLabel(first) || first?.reaction === "follow-up";
144
+ }
145
+
146
+ export function settledLabel(
147
+ first: ReactionLabel | null,
148
+ check: ReactionLabel | null
149
+ ): ReactionLabel | null {
150
+ const caughtFollowUp = first?.reaction === "follow-up" && isCorrectionLabel(check);
151
+ return caughtFollowUp ? check : first;
152
+ }
153
+
74
154
  function isModelReaction(value: unknown): value is ModelReaction {
75
155
  return MODEL_REACTIONS.includes(value as ModelReaction);
76
156
  }
@@ -18,6 +18,7 @@ import {
18
18
  } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { dirname, isAbsolute, resolve, sep } from "node:path";
21
+ import { createStyle, PALETTE, type Rgb } from "../cli/ui/style";
21
22
  import {
22
23
  AGENT_NAMES,
23
24
  AGENT_REGISTRY,
@@ -45,12 +46,47 @@ function narrate(line: string): void {
45
46
  if (narrating) console.log(line);
46
47
  }
47
48
 
49
+ export type LogLevel = "info" | "success" | "warn" | "error" | "detail";
50
+ type LogSink = (level: LogLevel, message: string) => void;
51
+
52
+ let sink: LogSink | null = null;
53
+
54
+ /** Hands every log line to `collect` while `work` runs, so a caller can place it under its own step. */
55
+ export async function capturingLog<T>(
56
+ collect: LogSink,
57
+ work: () => Promise<T>
58
+ ): Promise<T> {
59
+ const previous = sink;
60
+ sink = collect;
61
+ try {
62
+ return await work();
63
+ } finally {
64
+ sink = previous;
65
+ }
66
+ }
67
+
68
+ const LEVEL_COLOR: Record<LogLevel, Rgb> = {
69
+ info: PALETTE.indigo,
70
+ success: PALETTE.green,
71
+ warn: PALETTE.amber,
72
+ error: PALETTE.rose,
73
+ detail: PALETTE.indigo,
74
+ };
75
+
76
+ function tagged(level: LogLevel, message: string): string {
77
+ return `${createStyle().paint(LEVEL_COLOR[level], "[pal]")} ${message}`;
78
+ }
79
+
80
+ function emit(level: LogLevel, message: string, write: (line: string) => void): void {
81
+ if (sink) sink(level, message);
82
+ else write(tagged(level, message));
83
+ }
84
+
48
85
  export const log = {
49
- info: (msg: string) => narrate(`\x1b[34m[pal]\x1b[0m ${msg}`),
50
- success: (msg: string) => narrate(`\x1b[32m[pal]\x1b[0m ${msg}`),
51
- heading: (label: string) => narrate(`\n━━━ ${label} ━━━`),
52
- warn: (msg: string) => console.log(`\x1b[33m[pal]\x1b[0m ${msg}`),
53
- error: (msg: string) => console.error(`\x1b[31m[pal]\x1b[0m ${msg}`),
86
+ info: (msg: string) => emit("info", msg, narrate),
87
+ success: (msg: string) => emit("success", msg, narrate),
88
+ warn: (msg: string) => emit("warn", msg, (line) => console.log(line)),
89
+ error: (msg: string) => emit("error", msg, (line) => console.error(line)),
54
90
 
55
91
  /**
56
92
  * Per-item narration from inside a loop, where the caller already reports the
@@ -61,7 +97,7 @@ export const log = {
61
97
  */
62
98
  detail: (msg: string) => {
63
99
  if (runningUnderTest()) return;
64
- narrate(`\x1b[34m[pal]\x1b[0m ${msg}`);
100
+ emit("detail", msg, narrate);
65
101
  },
66
102
  };
67
103
 
@@ -1152,6 +1188,13 @@ function shippedAgentNames(): Set<string> {
1152
1188
  );
1153
1189
  }
1154
1190
 
1191
+ export function installedShippedAgentCount(agent: AgentName): number {
1192
+ const dir = nativeAgentsDir(agent);
1193
+ return [...shippedAgentNames()].filter((name) =>
1194
+ existsSync(resolve(dir, agentFileName(name, agent)))
1195
+ ).length;
1196
+ }
1197
+
1155
1198
  /** List the user-authored subagents in ~/.pal/agents/. */
1156
1199
  export function listPersonalSubagents(): string[] {
1157
1200
  if (!existsSync(palAgentsStore())) return [];