@ian-pascoe/pi-advisor 0.5.0 → 0.6.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,389 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import type {
4
+ Context,
5
+ ImageContent,
6
+ Message,
7
+ TextContent,
8
+ Tool,
9
+ ToolCall,
10
+ } from "@earendil-works/pi-ai";
11
+ import { convertToLlm, estimateTokens, type AgentSession } from "@earendil-works/pi-coding-agent";
12
+
13
+ /**
14
+ * Review Evidence: the observed model's view of its context, without what Pi stores only for
15
+ * replay or display (signatures, `details`, provider/response metadata, native IDs).
16
+ */
17
+ export type EvidenceBlock =
18
+ | { type: "text"; text: string }
19
+ | { type: "thinking"; thinking: string }
20
+ /** Reasoning the provider withheld; the observed model saw only an opaque payload. */
21
+ | { type: "thinking"; redacted: true }
22
+ | { type: "image"; attachment: number }
23
+ | { type: "toolCall"; ref: string; name: string; arguments: ToolCall["arguments"] };
24
+ export type EvidenceMessage =
25
+ | { role: "user"; content: string | EvidenceBlock[] }
26
+ | {
27
+ role: "assistant";
28
+ content: EvidenceBlock[];
29
+ /** Present only for responses that did not finish normally. */
30
+ stopReason?: "error" | "aborted" | "length";
31
+ errorMessage?: string;
32
+ }
33
+ | {
34
+ role: "toolResult";
35
+ ref: string;
36
+ toolName: string;
37
+ isError: boolean;
38
+ content: EvidenceBlock[];
39
+ };
40
+ export interface Evidence {
41
+ messages: EvidenceMessage[];
42
+ /** Native image attachments, referenced 1-based by `{ type: "image", attachment }` blocks. */
43
+ images: ImageContent[];
44
+ }
45
+ /** Observed Setup: the observed system prompt and tool summaries that open a Context Seed. */
46
+ export interface ObservedSetup {
47
+ systemPrompt: string | undefined;
48
+ tools: { name: string; summary: string }[];
49
+ }
50
+
51
+ /**
52
+ * Tool-Call Reference: compact, shared by an observed tool call and its result. Derived from
53
+ * the native call ID alone, so it is stable across projections and Advisor Sessions and can
54
+ * be recomputed from the observed transcript.
55
+ */
56
+ export function toolCallRef(toolCallId: string): string {
57
+ // Coerce rather than trust the stored shape: a malformed ID must not pause the Advisor.
58
+ return createHash("sha256").update(String(toolCallId)).digest("base64url").slice(0, 8);
59
+ }
60
+
61
+ /** Every Tool-Call Reference in projected Review Evidence, from calls and their results. */
62
+ export function evidenceRefs(messages: readonly EvidenceMessage[]): string[] {
63
+ return messages.flatMap((message) => {
64
+ if (message.role === "toolResult") return [message.ref];
65
+ if (message.role !== "assistant") return [];
66
+ return message.content.flatMap((block) => (block.type === "toolCall" ? [block.ref] : []));
67
+ });
68
+ }
69
+
70
+ const summaryLimit = 160;
71
+
72
+ /** First sentence of a tool description's first line, bounded for the Observed Setup. */
73
+ function toolSummary(description: string): string {
74
+ const line = description.trim().split("\n", 1)[0]?.trim() ?? "";
75
+ const sentence = /^(.+?[.!?])(?:\s|$)/.exec(line)?.[1] ?? line;
76
+ return sentence.length > summaryLimit
77
+ ? `${sentence.slice(0, summaryLimit - 1).trimEnd()}…`
78
+ : sentence;
79
+ }
80
+
81
+ export function projectObservedSetup(
82
+ context: Pick<Context, "systemPrompt" | "tools">,
83
+ ): ObservedSetup {
84
+ return {
85
+ systemPrompt: context.systemPrompt,
86
+ tools: (context.tools ?? []).map((tool: Tool) => ({
87
+ name: tool.name,
88
+ summary: toolSummary(tool.description),
89
+ })),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Each observed message's role before Pi converted it for the model. Compaction and branch
95
+ * summaries, custom messages (including delivered Advisor findings), and `!` command results all
96
+ * reach the model as `user` messages; only `user` origins are requests from the user.
97
+ */
98
+ export function messageOrigins(
99
+ messages: readonly Message[],
100
+ sources: AgentSession["messages"],
101
+ ): string[] {
102
+ // Sources that reach the model as user messages, in order, grouped by timestamp.
103
+ const byTime = Map.groupBy(
104
+ sources.flatMap((source) => {
105
+ const [converted] = convertToLlm([source]);
106
+ return converted?.role === "user" ? [{ source, content: converted.content }] : [];
107
+ }),
108
+ ({ source }) => source.timestamp,
109
+ );
110
+ const used = new Set<object>();
111
+ const origins = messages.map((message): string | undefined =>
112
+ message.role === "user" ? undefined : message.role,
113
+ );
114
+ const claim = (index: number, matches: (content: Message["content"]) => boolean) => {
115
+ const message = messages[index];
116
+ if (message?.role !== "user" || origins[index]) return;
117
+ const match = byTime
118
+ .get(message.timestamp)
119
+ ?.find(({ source, content }) => !used.has(source) && matches(content));
120
+ if (!match) return;
121
+ used.add(match.source);
122
+ origins[index] = match.source.role;
123
+ };
124
+ // Exact conversions first; then, within a timestamp, the k-th unmatched message pairs with the
125
+ // k-th unused source (a message a context hook rewrote, such as with images removed).
126
+ for (const [index, message] of messages.entries())
127
+ claim(index, (content) => isDeepStrictEqual(content, message.content));
128
+ for (const index of messages.keys()) claim(index, () => true);
129
+ return origins.map((origin) => origin ?? "unknown");
130
+ }
131
+
132
+ /** A Context Seed: the Observed Setup and the observed messages that fit its token budget. */
133
+ export interface ContextSeed extends Evidence {
134
+ observedSetup: ObservedSetup;
135
+ /** Zero-based positions of the kept observed messages, ascending. */
136
+ kept: number[];
137
+ /** Long texts shortened, with a marker, so the newest messages fit. */
138
+ shortened: number;
139
+ }
140
+
141
+ interface Projected {
142
+ message: EvidenceMessage;
143
+ images: ImageContent[];
144
+ }
145
+
146
+ // Pi's compaction estimate for one image, plus a token for a longer attachment index.
147
+ const imageTokens =
148
+ estimateTokens({
149
+ role: "user",
150
+ content: [{ type: "image", data: "", mimeType: "image/png" }],
151
+ timestamp: 0,
152
+ }) + 1;
153
+
154
+ /** Pi's chars/4 estimate of projected evidence messages, plus Pi's per-image estimate. */
155
+ export function evidenceTokens({ messages, images }: Evidence): number {
156
+ return Math.ceil(JSON.stringify(messages).length / 4) + images.length * imageTokens;
157
+ }
158
+
159
+ /** Tokens a projected message adds to the seed JSON, by Pi's chars/4 heuristic. */
160
+ function cost(items: readonly Projected[]): number {
161
+ return items.reduce(
162
+ (total, { message, images }) =>
163
+ total + Math.ceil((JSON.stringify(message).length + 1) / 4) + images.length * imageTokens,
164
+ 0,
165
+ );
166
+ }
167
+
168
+ /** Shorten every text longer than `limit` characters, marking how much was cut. */
169
+ function shorten(items: readonly Projected[], limit: number): Projected[] {
170
+ return items.map((item) => {
171
+ let changed = false;
172
+ const cut = (text: string) => {
173
+ const shortened = `${text.slice(0, limit)}\n[… ${text.length - limit} characters omitted from the Context Seed]`;
174
+ if (shortened.length >= text.length) return text;
175
+ changed = true;
176
+ return shortened;
177
+ };
178
+ const block = (part: EvidenceBlock): EvidenceBlock => {
179
+ if (part.type === "text") return { type: "text", text: cut(part.text) };
180
+ if (part.type === "thinking" && "thinking" in part)
181
+ return { type: "thinking", thinking: cut(part.thinking) };
182
+ if (part.type !== "toolCall") return part;
183
+ const json = JSON.stringify(part.arguments);
184
+ const shortened = cut(json);
185
+ // Oversized arguments become their marked JSON prefix, which no longer parses.
186
+ return shortened === json ? part : { ...part, arguments: { shortenedJson: shortened } };
187
+ };
188
+ const { message } = item;
189
+ const next: EvidenceMessage =
190
+ message.role === "user"
191
+ ? {
192
+ role: "user",
193
+ content: Array.isArray(message.content)
194
+ ? message.content.map(block)
195
+ : cut(message.content),
196
+ }
197
+ : { ...message, content: message.content.map(block) };
198
+ return changed ? { message: next, images: item.images } : item;
199
+ });
200
+ }
201
+
202
+ /** The unit unchanged if it fits, else with the longest per-string limit that fits. */
203
+ function fit(items: readonly Projected[], allowance: number): Projected[] | undefined {
204
+ if (cost(items) <= allowance) return [...items];
205
+ let low = 0;
206
+ let high = JSON.stringify(items.map(({ message }) => message)).length;
207
+ if (cost(shorten(items, low)) > allowance) return undefined;
208
+ while (low < high) {
209
+ const middle = Math.ceil((low + high) / 2);
210
+ if (cost(shorten(items, middle)) <= allowance) low = middle;
211
+ else high = middle - 1;
212
+ }
213
+ return shorten(items, low);
214
+ }
215
+
216
+ /**
217
+ * Fit a Context Seed to a token budget, measured as Pi's chars/4 estimate of the seed JSON with
218
+ * Pi's per-image estimate. Always kept: the Observed Setup; the original request (the first
219
+ * user request, or after compaction the summary, which carries the earlier goal, and the first
220
+ * request after it); and the newest turn with the request that prompted it, shortened if they
221
+ * alone exceed the budget. The rest takes the newest turns that fit, shortening only the oldest
222
+ * one included. A turn (an assistant message with its tool results) is never split.
223
+ */
224
+ export function selectContextSeed(
225
+ context: Pick<Context, "systemPrompt" | "tools" | "messages">,
226
+ options: { budgetTokens: number; origins?: readonly string[] },
227
+ ): ContextSeed {
228
+ const { messages } = context;
229
+ const observedSetup = projectObservedSetup(context);
230
+ const projected = messages.map((message): Projected[] => {
231
+ const {
232
+ messages: [only],
233
+ images,
234
+ } = projectEvidence([message]);
235
+ return only ? [{ message: only, images }] : [];
236
+ });
237
+ // Fall back to the converted role only when no converted user message has a known origin.
238
+ const origins = options.origins?.some(
239
+ (origin, index) => messages[index]?.role === "user" && origin !== "unknown",
240
+ )
241
+ ? options.origins
242
+ : undefined;
243
+ const isRequest = (index: number) =>
244
+ origins ? origins[index] === "user" : messages[index]?.role === "user";
245
+ // Units: a message, with any tool results that follow it.
246
+ const starts = messages.flatMap((message, index) =>
247
+ index === 0 || message.role !== "toolResult" ? [index] : [],
248
+ );
249
+ const units = starts.map((start, index) => ({
250
+ start,
251
+ end: starts[index + 1] ?? messages.length,
252
+ }));
253
+ const unitAt = (position: number) => units.findLastIndex((unit) => unit.start <= position);
254
+ const content = (unit: number) => {
255
+ const { start, end } = units[unit] ?? { start: 0, end: 0 };
256
+ return projected.slice(start, end).flat();
257
+ };
258
+ const summary = options.origins?.indexOf("compactionSummary") ?? -1;
259
+ const request = messages.findIndex((_message, index) => index > summary && isRequest(index));
260
+ const newest = units.length - 1;
261
+ const prompt = messages.findLastIndex(
262
+ (_message, index) => index <= (units[newest]?.start ?? -1) && isRequest(index),
263
+ );
264
+ const anchors = new Set([summary, request].filter((index) => index >= 0).map(unitAt));
265
+ const recent = new Set([prompt, messages.length - 1].filter((index) => index >= 0).map(unitAt));
266
+ for (const unit of anchors) recent.delete(unit);
267
+
268
+ const chosen = new Map<number, Projected[]>();
269
+ let remaining =
270
+ options.budgetTokens - Math.ceil(JSON.stringify({ observedSetup, messages: [] }).length / 4);
271
+ const anchorItems = [...anchors].flatMap(content);
272
+ const recentItems = [...recent].flatMap(content);
273
+ // Shorten the newest turn first, then the original request too, so both always appear.
274
+ const required =
275
+ fit(recentItems, remaining - cost(anchorItems))?.concat(anchorItems) ??
276
+ fit([...recentItems, ...anchorItems], remaining) ??
277
+ shorten([...recentItems, ...anchorItems], 0);
278
+ remaining -= cost(required);
279
+ const shortenedRequired = new Map(
280
+ [...recentItems, ...anchorItems].map((item, index) => [item, required[index]] as const),
281
+ );
282
+ for (const unit of [...anchors, ...recent])
283
+ chosen.set(
284
+ unit,
285
+ content(unit).map((item) => shortenedRequired.get(item) ?? item),
286
+ );
287
+ for (let unit = newest; unit >= 0; unit--) {
288
+ if (chosen.has(unit)) continue;
289
+ const items = fit(content(unit), remaining);
290
+ if (!items) break;
291
+ chosen.set(unit, items);
292
+ remaining -= cost(items);
293
+ if (items.some((item, index) => item !== content(unit)[index])) break;
294
+ }
295
+
296
+ const ordered = [...chosen.keys()].toSorted((left, right) => left - right);
297
+ const kept = ordered.flatMap((unit) => {
298
+ const { start, end } = units[unit] ?? { start: 0, end: 0 };
299
+ return Array.from({ length: end - start }, (_value, offset) => start + offset);
300
+ });
301
+ const items = ordered.flatMap((unit) => chosen.get(unit) ?? []);
302
+ const original = ordered.flatMap(content);
303
+ return {
304
+ observedSetup,
305
+ ...combine(items),
306
+ kept,
307
+ shortened: items.filter((item, index) => item.message !== original[index]?.message).length,
308
+ };
309
+ }
310
+
311
+ /** Join individually projected messages, renumbering their image attachments. */
312
+ function combine(items: readonly Projected[]): Evidence {
313
+ const images: ImageContent[] = [];
314
+ const messages = items.map(({ message, images: own }) => {
315
+ const offset = images.length;
316
+ images.push(...own);
317
+ if (!offset || !own.length || !Array.isArray(message.content)) return message;
318
+ const content = message.content.map((block) =>
319
+ block.type === "image" ? { ...block, attachment: block.attachment + offset } : block,
320
+ );
321
+ return { ...message, content };
322
+ });
323
+ return { messages, images };
324
+ }
325
+
326
+ /** Project observed messages in order; attachment indexes restart at 1 for each projection. */
327
+ export function projectEvidence(messages: readonly Message[]): Evidence {
328
+ const images: ImageContent[] = [];
329
+ /** User and tool-result content; unknown future block types are omitted, not forwarded. */
330
+ const media = (blocks: readonly (TextContent | ImageContent)[]) =>
331
+ blocks.flatMap((block): EvidenceBlock[] => {
332
+ if (block.type === "text") return [{ type: "text", text: block.text }];
333
+ if (block.type === "image") {
334
+ images.push(block);
335
+ return [{ type: "image", attachment: images.length }];
336
+ }
337
+ return [];
338
+ });
339
+ const projected = messages.flatMap((message): EvidenceMessage[] => {
340
+ switch (message.role) {
341
+ case "user":
342
+ return [
343
+ {
344
+ role: "user",
345
+ content: Array.isArray(message.content) ? media(message.content) : message.content,
346
+ },
347
+ ];
348
+ case "toolResult":
349
+ return [
350
+ {
351
+ role: "toolResult",
352
+ ref: toolCallRef(message.toolCallId),
353
+ toolName: message.toolName,
354
+ isError: message.isError,
355
+ content: media(message.content),
356
+ },
357
+ ];
358
+ case "assistant": {
359
+ const content = message.content.flatMap((block): EvidenceBlock[] => {
360
+ if (block.type === "text") return [{ type: "text", text: block.text }];
361
+ if (block.type === "thinking") {
362
+ if (block.redacted) return [{ type: "thinking", redacted: true }];
363
+ return block.thinking.trim() ? [{ type: "thinking", thinking: block.thinking }] : [];
364
+ }
365
+ if (block.type === "toolCall")
366
+ return [
367
+ {
368
+ type: "toolCall",
369
+ ref: toolCallRef(block.id),
370
+ name: block.name,
371
+ arguments: block.arguments,
372
+ },
373
+ ];
374
+ return [];
375
+ });
376
+ const stop = message.stopReason;
377
+ if (stop !== "error" && stop !== "aborted" && stop !== "length")
378
+ return [{ role: "assistant", content }];
379
+ const abnormal: EvidenceMessage = { role: "assistant", content, stopReason: stop };
380
+ if (message.errorMessage) abnormal.errorMessage = message.errorMessage;
381
+ return [abnormal];
382
+ }
383
+ default:
384
+ // System messages reach the Advisor through the Observed Setup; unknown roles are omitted.
385
+ return [];
386
+ }
387
+ });
388
+ return { messages: projected, images };
389
+ }
@@ -99,6 +99,13 @@ const descriptions = {
99
99
  maxToolCalls: "Investigative tool calls per Review",
100
100
  maxCorrectiveTurns: "Automatic Corrective Turns per request",
101
101
  maxFindingsPerReview: "Findings accepted from one Review (1–32)",
102
+ maxNitsPerRequest: "Nits delivered per request; further Nits are dropped (0 delivers none)",
103
+ seedBudgetTokens:
104
+ "Token budget for the Context Seed; auto is a quarter of the Advisor model's context window, at most 100k",
105
+ reviewEvery:
106
+ "When Reviews run: every turn, every N turns, or once per request; a tool error reviews at once",
107
+ maxSessionTokens:
108
+ "Advisor Session size that triggers native compaction; auto is half the Advisor model's context window, at most 200k",
102
109
  } satisfies Record<keyof AdvisorOptions, string>;
103
110
  const inheritRow = "\u0000inherit";
104
111
  const inputHints = {
@@ -107,6 +114,10 @@ const inputHints = {
107
114
  maxToolCalls: "a number, or inherit",
108
115
  maxCorrectiveTurns: "a number, or inherit",
109
116
  maxFindingsPerReview: "a number from 1 to 32, or inherit",
117
+ maxNitsPerRequest: "a number, or inherit",
118
+ seedBudgetTokens: "a token count, auto, or inherit",
119
+ reviewEvery: "turn, request, a number of turns, or inherit",
120
+ maxSessionTokens: "a token count, auto, or inherit",
110
121
  } as const;
111
122
  const listActions = [
112
123
  "tui.select.up",
@@ -133,9 +144,18 @@ function parseAdvisorMenuValue(
133
144
  return { catchUpThreshold: value === "off" ? value : Number(value) };
134
145
  case "reviewTimeoutMs":
135
146
  return { reviewTimeoutMs: Math.round(Number(value) * 1_000) };
147
+ case "seedBudgetTokens":
148
+ return { seedBudgetTokens: value === "auto" ? value : Number(value) };
149
+ case "maxSessionTokens":
150
+ return { maxSessionTokens: value === "auto" ? value : Number(value) };
151
+ case "reviewEvery":
152
+ return {
153
+ reviewEvery: value === "turn" || value === "request" ? value : Number(value),
154
+ };
136
155
  case "maxToolCalls":
137
156
  case "maxCorrectiveTurns":
138
157
  case "maxFindingsPerReview":
158
+ case "maxNitsPerRequest":
139
159
  return { [key]: Number(value) };
140
160
  default:
141
161
  return { [key]: value };