@ian-pascoe/pi-advisor 0.6.0 → 0.8.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.
- package/README.md +37 -13
- package/package.json +6 -6
- package/skills/pi-advisor/SKILL.md +1 -1
- package/src/advisor-calibration.ts +62 -0
- package/src/advisor-command.ts +26 -47
- package/src/advisor-evidence.ts +45 -273
- package/src/advisor-menu.ts +52 -257
- package/src/advisor-observer.ts +118 -18
- package/src/advisor-rendering.ts +185 -112
- package/src/advisor-session.ts +32 -1
- package/src/advisor-settings.ts +53 -128
- package/src/index.ts +27 -13
package/src/advisor-evidence.ts
CHANGED
|
@@ -1,72 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import type { Context, Tool } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
combineEvidence,
|
|
4
|
+
evidenceItemsCost,
|
|
5
|
+
fitEvidence,
|
|
6
|
+
projectEvidenceItem,
|
|
7
|
+
shortenEvidence,
|
|
8
|
+
type Evidence,
|
|
9
|
+
type EvidenceItem,
|
|
10
|
+
type ToolResultCap,
|
|
11
|
+
} from "@ian-pascoe/pi-utils/evidence";
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
evidenceRefs,
|
|
15
|
+
evidenceTokens,
|
|
16
|
+
messageOrigins,
|
|
17
|
+
projectEvidence,
|
|
18
|
+
toolCallRef,
|
|
19
|
+
type Evidence,
|
|
20
|
+
type EvidenceBlock,
|
|
21
|
+
type EvidenceMessage,
|
|
22
|
+
type ToolResultCap,
|
|
23
|
+
} from "@ian-pascoe/pi-utils/evidence";
|
|
24
|
+
|
|
25
|
+
/** Marks text shortened to fit the Context Seed budget. */
|
|
26
|
+
const omissionMarker = (omitted: number) =>
|
|
27
|
+
`[… ${omitted} characters omitted from the Context Seed]`;
|
|
12
28
|
|
|
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
29
|
/** Observed Setup: the observed system prompt and tool summaries that open a Context Seed. */
|
|
46
30
|
export interface ObservedSetup {
|
|
47
31
|
systemPrompt: string | undefined;
|
|
48
32
|
tools: { name: string; summary: string }[];
|
|
49
33
|
}
|
|
50
34
|
|
|
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
35
|
const summaryLimit = 160;
|
|
71
36
|
|
|
72
37
|
/** First sentence of a tool description's first line, bounded for the Observed Setup. */
|
|
@@ -90,45 +55,6 @@ export function projectObservedSetup(
|
|
|
90
55
|
};
|
|
91
56
|
}
|
|
92
57
|
|
|
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
58
|
/** A Context Seed: the Observed Setup and the observed messages that fit its token budget. */
|
|
133
59
|
export interface ContextSeed extends Evidence {
|
|
134
60
|
observedSetup: ObservedSetup;
|
|
@@ -138,101 +64,25 @@ export interface ContextSeed extends Evidence {
|
|
|
138
64
|
shortened: number;
|
|
139
65
|
}
|
|
140
66
|
|
|
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
67
|
/**
|
|
217
68
|
* 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
|
|
69
|
+
* Pi's per-image estimate. The caller scales a budget in reported tokens down to that estimate. Always kept: the Observed Setup; the original request (the first
|
|
219
70
|
* user request, or after compaction the summary, which carries the earlier goal, and the first
|
|
220
71
|
* request after it); and the newest turn with the request that prompted it, shortened if they
|
|
221
72
|
* 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.
|
|
73
|
+
* one included. A turn (an assistant message with its tool results) is never split. With
|
|
74
|
+
* `toolResultCap`, each tool result's text is first capped to its head and tail, so the budget
|
|
75
|
+
* is spent on the capped evidence.
|
|
223
76
|
*/
|
|
224
77
|
export function selectContextSeed(
|
|
225
78
|
context: Pick<Context, "systemPrompt" | "tools" | "messages">,
|
|
226
|
-
options: { budgetTokens: number; origins?: readonly string[] },
|
|
79
|
+
options: { budgetTokens: number; origins?: readonly string[]; toolResultCap?: ToolResultCap },
|
|
227
80
|
): ContextSeed {
|
|
228
81
|
const { messages } = context;
|
|
229
82
|
const observedSetup = projectObservedSetup(context);
|
|
230
|
-
const projected = messages.map((message):
|
|
231
|
-
const {
|
|
232
|
-
|
|
233
|
-
images,
|
|
234
|
-
} = projectEvidence([message]);
|
|
235
|
-
return only ? [{ message: only, images }] : [];
|
|
83
|
+
const projected = messages.map((message): EvidenceItem[] => {
|
|
84
|
+
const item = projectEvidenceItem(message, { toolResultCap: options.toolResultCap });
|
|
85
|
+
return item ? [item] : [];
|
|
236
86
|
});
|
|
237
87
|
// Fall back to the converted role only when no converted user message has a known origin.
|
|
238
88
|
const origins = options.origins?.some(
|
|
@@ -265,17 +115,19 @@ export function selectContextSeed(
|
|
|
265
115
|
const recent = new Set([prompt, messages.length - 1].filter((index) => index >= 0).map(unitAt));
|
|
266
116
|
for (const unit of anchors) recent.delete(unit);
|
|
267
117
|
|
|
268
|
-
const chosen = new Map<number,
|
|
118
|
+
const chosen = new Map<number, EvidenceItem[]>();
|
|
269
119
|
let remaining =
|
|
270
120
|
options.budgetTokens - Math.ceil(JSON.stringify({ observedSetup, messages: [] }).length / 4);
|
|
271
121
|
const anchorItems = [...anchors].flatMap(content);
|
|
272
122
|
const recentItems = [...recent].flatMap(content);
|
|
273
123
|
// Shorten the newest turn first, then the original request too, so both always appear.
|
|
274
124
|
const required =
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
125
|
+
fitEvidence(recentItems, remaining - evidenceItemsCost(anchorItems), omissionMarker)?.concat(
|
|
126
|
+
anchorItems,
|
|
127
|
+
) ??
|
|
128
|
+
fitEvidence([...recentItems, ...anchorItems], remaining, omissionMarker) ??
|
|
129
|
+
shortenEvidence([...recentItems, ...anchorItems], 0, omissionMarker);
|
|
130
|
+
remaining -= evidenceItemsCost(required);
|
|
279
131
|
const shortenedRequired = new Map(
|
|
280
132
|
[...recentItems, ...anchorItems].map((item, index) => [item, required[index]] as const),
|
|
281
133
|
);
|
|
@@ -286,10 +138,10 @@ export function selectContextSeed(
|
|
|
286
138
|
);
|
|
287
139
|
for (let unit = newest; unit >= 0; unit--) {
|
|
288
140
|
if (chosen.has(unit)) continue;
|
|
289
|
-
const items =
|
|
141
|
+
const items = fitEvidence(content(unit), remaining, omissionMarker);
|
|
290
142
|
if (!items) break;
|
|
291
143
|
chosen.set(unit, items);
|
|
292
|
-
remaining -=
|
|
144
|
+
remaining -= evidenceItemsCost(items);
|
|
293
145
|
if (items.some((item, index) => item !== content(unit)[index])) break;
|
|
294
146
|
}
|
|
295
147
|
|
|
@@ -302,88 +154,8 @@ export function selectContextSeed(
|
|
|
302
154
|
const original = ordered.flatMap(content);
|
|
303
155
|
return {
|
|
304
156
|
observedSetup,
|
|
305
|
-
...
|
|
157
|
+
...combineEvidence(items),
|
|
306
158
|
kept,
|
|
307
159
|
shortened: items.filter((item, index) => item.message !== original[index]?.message).length,
|
|
308
160
|
};
|
|
309
161
|
}
|
|
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
|
-
}
|