pi-aside 0.0.0-stage → 0.2.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/src/payload.ts ADDED
@@ -0,0 +1,263 @@
1
+ // Provider payload comparison (docs/design.md §4.2): the per-API table, strip(), the trailing configuration items E,
2
+ // the handler delta ΔH, and the checks C1–C3. Pure functions over JSON payloads.
3
+
4
+ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
5
+ export type Payload = { [key: string]: unknown };
6
+
7
+ interface ApiShape {
8
+ /** The name of the content list. */
9
+ list: string;
10
+ /** Whether an item is a trailing configuration item (E). */
11
+ isConfigItem(item: unknown): boolean;
12
+ }
13
+
14
+ const API_TABLE: Record<string, ApiShape> = {
15
+ "anthropic-messages": {
16
+ list: "messages",
17
+ isConfigItem: (item) =>
18
+ isObject(item) &&
19
+ item.role === "system" &&
20
+ Array.isArray(item.content) &&
21
+ item.content.length === 0 &&
22
+ "output_config" in item,
23
+ },
24
+ "openai-responses": {
25
+ list: "input",
26
+ isConfigItem: (item) => isObject(item) && item.type === "configuration_update",
27
+ },
28
+ "openai-completions": {
29
+ list: "messages",
30
+ isConfigItem: () => false,
31
+ },
32
+ };
33
+
34
+ /** APIs whose payloads tier T0 can check and send. */
35
+ export const T0_APIS = new Set(Object.keys(API_TABLE));
36
+
37
+ /** Top-level fields the recipe controls: a handler may not change them (C1). */
38
+ export const RECIPE_FIELDS = new Set([
39
+ "messages",
40
+ "input",
41
+ "system",
42
+ "instructions",
43
+ "tools",
44
+ "tool_choice",
45
+ "model",
46
+ "max_tokens",
47
+ "max_output_tokens",
48
+ "max_completion_tokens",
49
+ "thinking",
50
+ "reasoning",
51
+ "reasoning_effort",
52
+ "output_config",
53
+ "stream",
54
+ ]);
55
+
56
+ export const MARKER_KEY = "cache_control";
57
+
58
+ export function apiShape(api: string): ApiShape | undefined {
59
+ return API_TABLE[api];
60
+ }
61
+
62
+ function isObject(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ export function deepEqual(a: unknown, b: unknown): boolean {
67
+ if (a === b) return true;
68
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") return false;
69
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
70
+ if (Array.isArray(a)) {
71
+ const bb = b as unknown[];
72
+ if (a.length !== bb.length) return false;
73
+ for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], bb[i])) return false;
74
+ return true;
75
+ }
76
+ const ao = a as Record<string, unknown>;
77
+ const bo = b as Record<string, unknown>;
78
+ const ak = Object.keys(ao).filter((k) => ao[k] !== undefined);
79
+ const bk = Object.keys(bo).filter((k) => bo[k] !== undefined);
80
+ if (ak.length !== bk.length) return false;
81
+ for (const k of ak) if (!(k in bo) || !deepEqual(ao[k], bo[k])) return false;
82
+ return true;
83
+ }
84
+
85
+ /**
86
+ * Remove cache markers everywhere, and write a string-valued message content as one text block: pi-ai turns the
87
+ * last user message's string into a block when it attaches a marker, and that message is no longer last in a fork.
88
+ */
89
+ export function strip(value: unknown): unknown {
90
+ if (Array.isArray(value)) return value.map(strip);
91
+ if (!isObject(value)) return value;
92
+ const out: Record<string, unknown> = {};
93
+ for (const [key, inner] of Object.entries(value)) {
94
+ if (key === MARKER_KEY || inner === undefined) continue;
95
+ out[key] = strip(inner);
96
+ }
97
+ if (typeof out.role === "string" && typeof out.content === "string") {
98
+ out.content = [{ type: "text", text: out.content }];
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function containsMarker(value: unknown): boolean {
104
+ if (Array.isArray(value)) return value.some(containsMarker);
105
+ if (!isObject(value)) return false;
106
+ if (MARKER_KEY in value) return true;
107
+ return Object.values(value).some(containsMarker);
108
+ }
109
+
110
+ /** Number of cache markers anywhere in a payload. */
111
+ export function countMarkers(value: unknown): number {
112
+ if (Array.isArray(value)) return value.reduce((n: number, v) => n + countMarkers(v), 0);
113
+ if (!isObject(value)) return 0;
114
+ let n = MARKER_KEY in value ? 1 : 0;
115
+ for (const v of Object.values(value)) n += countMarkers(v);
116
+ return n;
117
+ }
118
+
119
+ export interface SplitList {
120
+ core: unknown[];
121
+ config: unknown[];
122
+ }
123
+
124
+ /** Split a content list as core ++ E, where E is the longest run of trailing configuration items. */
125
+ export function splitList(api: string, payload: Payload): SplitList | undefined {
126
+ const shape = API_TABLE[api];
127
+ if (!shape) return undefined;
128
+ const list = payload[shape.list];
129
+ if (!Array.isArray(list)) return undefined;
130
+ let end = list.length;
131
+ while (end > 0 && shape.isConfigItem(list[end - 1])) end--;
132
+ return { core: list.slice(0, end), config: list.slice(end) };
133
+ }
134
+
135
+ /**
136
+ * Whether stripped `longer` extends stripped `shorter`: every element equal, except that the last element of
137
+ * `shorter` may be extended by appended content blocks of the same role.
138
+ */
139
+ export function extendsList(longer: unknown[], shorter: unknown[]): boolean {
140
+ if (longer.length < shorter.length) return false;
141
+ for (let i = 0; i < shorter.length; i++) {
142
+ const a = strip(longer[i]);
143
+ const b = strip(shorter[i]);
144
+ if (deepEqual(a, b)) continue;
145
+ if (i !== shorter.length - 1) return false;
146
+ if (!isObject(a) || !isObject(b)) return false;
147
+ if (!Array.isArray(a.content) || !Array.isArray(b.content)) return false;
148
+ if (a.content.length < b.content.length) return false;
149
+ const { content: ac, ...arest } = a;
150
+ const { content: bc, ...brest } = b;
151
+ if (!deepEqual(arest, brest)) return false;
152
+ for (let j = 0; j < (bc as unknown[]).length; j++) if (!deepEqual((ac as unknown[])[j], (bc as unknown[])[j])) return false;
153
+ }
154
+ return true;
155
+ }
156
+
157
+ /** ΔH: the top-level fields of P that differ from B0, with P's value (undefined: P removed it). */
158
+ export type Delta = Map<string, unknown>;
159
+
160
+ export function diffTopLevel(base: Payload, observed: Payload): Delta {
161
+ const delta: Delta = new Map();
162
+ const keys = new Set([...Object.keys(base), ...Object.keys(observed)]);
163
+ for (const key of keys) {
164
+ if (!deepEqual(strip(base[key]), strip(observed[key]))) delta.set(key, observed[key]);
165
+ }
166
+ return delta;
167
+ }
168
+
169
+ export function applyDelta(payload: Payload, delta: Delta): Payload {
170
+ const out: Payload = { ...payload };
171
+ for (const [key, value] of delta) {
172
+ if (value === undefined) delete out[key];
173
+ else out[key] = structuredClone(value);
174
+ }
175
+ return out;
176
+ }
177
+
178
+ export interface CheckResult {
179
+ ok: boolean;
180
+ /** Short reason when not ok. */
181
+ reason?: string;
182
+ }
183
+
184
+ const ok: CheckResult = { ok: true };
185
+ const fail = (reason: string): CheckResult => ({ ok: false, reason });
186
+
187
+ /** C1: ΔH touches only fields the recipe does not control, and strip(B0.list) = strip(P.list). */
188
+ export function checkC1(api: string, b0: Payload, p: Payload, delta: Delta): CheckResult {
189
+ const shape = API_TABLE[api];
190
+ if (!shape) return fail(`api ${api} is not supported`);
191
+ for (const key of delta.keys()) {
192
+ if (RECIPE_FIELDS.has(key)) return fail(`a handler or the recipe changed \`${key}\``);
193
+ }
194
+ if (!deepEqual(strip(b0[shape.list]), strip(p[shape.list]))) return fail("the observed transcript does not reproduce the request");
195
+ return ok;
196
+ }
197
+
198
+ function otherFieldsEqual(api: string, a: Payload, b: Payload): string | undefined {
199
+ const shape = API_TABLE[api]!;
200
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
201
+ for (const key of keys) {
202
+ if (key === shape.list) continue;
203
+ if (!deepEqual(strip(a[key]), strip(b[key]))) return key;
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ /** C2: the fork appends to the core of B0, with the same E and every other top-level field equal. */
209
+ export function checkC2(api: string, b0: Payload, b1: Payload, p: Payload): CheckResult {
210
+ const s0 = splitList(api, b0);
211
+ const s1 = splitList(api, b1);
212
+ const sp = splitList(api, p);
213
+ if (!s0 || !s1 || !sp) return fail("no content list");
214
+ if (!deepEqual(strip(s1.config), strip(s0.config))) return fail("the trailing configuration changed");
215
+ if (!extendsList(s1.core, s0.core)) return fail("the fork does not extend the conversation");
216
+ // P's last marker must lie inside core(B0), so the fork can read up to it.
217
+ let lastMarker = -1;
218
+ const plist = p[API_TABLE[api]!.list] as unknown[];
219
+ for (let i = 0; i < plist.length; i++) if (containsMarker(plist[i])) lastMarker = i;
220
+ if (lastMarker >= s0.core.length) return fail("the observed cache marker lies outside the shared prefix");
221
+ const field = otherFieldsEqual(api, b1, b0);
222
+ if (field) return fail(`the fork changed \`${field}\``);
223
+ return ok;
224
+ }
225
+
226
+ /**
227
+ * Whether two dry conversions of the same transcript differ only in their trailing configuration items E: the same
228
+ * core and the same other top-level fields. This is what proves that a lower aside effort keeps the cache prefix.
229
+ */
230
+ export function differsOnlyInConfig(api: string, a: Payload, b: Payload): boolean {
231
+ const sa = splitList(api, a);
232
+ const sb = splitList(api, b);
233
+ if (!sa || !sb) return false;
234
+ if (!deepEqual(strip(sa.core), strip(sb.core))) return false;
235
+ return otherFieldsEqual(api, a, b) === undefined;
236
+ }
237
+
238
+ /**
239
+ * C3, on the bytes sent: F's top-level fields equal P's, and strip(core(F)) extends strip(core(P)). E(F) equals E(P),
240
+ * or `expectedConfig` when the aside runs at its own effort (proved by `differsOnlyInConfig` on the dry runs).
241
+ */
242
+ export function checkC3(api: string, f: Payload, p: Payload, expectedConfig?: unknown[]): CheckResult {
243
+ const sf = splitList(api, f);
244
+ const sp = splitList(api, p);
245
+ if (!sf || !sp) return fail("no content list");
246
+ if (!deepEqual(strip(sf.config), strip(expectedConfig ?? sp.config))) return fail("the trailing configuration differs from the observed request");
247
+ if (!extendsList(sf.core, sp.core)) return fail("the request does not extend the observed request");
248
+ const field = otherFieldsEqual(api, f, p);
249
+ if (field) return fail(`\`${field}\` differs from the observed request`);
250
+ return ok;
251
+ }
252
+
253
+ /** Canonical JSON (sorted keys) for hashing. */
254
+ export function canonicalJson(value: unknown): string {
255
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
256
+ if (isObject(value)) {
257
+ const keys = Object.keys(value)
258
+ .filter((k) => value[k] !== undefined)
259
+ .sort();
260
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
261
+ }
262
+ return JSON.stringify(value) ?? "null";
263
+ }
package/src/qmark.ts ADDED
@@ -0,0 +1,48 @@
1
+ // The question-mark rule (docs/ux.md §3.1): while the agent is busy, a message ending in a question mark is
2
+ // asked on the side. The ending is language-neutral and lives in the user's own text.
3
+
4
+ const QUESTION_MARKS = new Set(["?", "\uFF1F", "\u061F", "\u037E"]);
5
+
6
+ // Trailing whitespace, closing punctuation (Pe), final quotes (Pf) and ASCII quotes are skipped.
7
+ const TRAILING = /[\s\p{Pe}\p{Pf}"']+$/u;
8
+
9
+ const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
10
+
11
+ /** True when the end of `text` lies inside a fenced code block (CommonMark fence rules). */
12
+ export function endsInsideFence(text: string): boolean {
13
+ let open: { char: string; length: number } | undefined;
14
+ for (const line of text.split(/\r?\n/)) {
15
+ const match = FENCE.exec(line);
16
+ if (!match) continue;
17
+ const marker = match[1]!;
18
+ const rest = match[2]!;
19
+ const char = marker[0]!;
20
+ if (!open) {
21
+ // A backtick fence's info string may not contain a backtick.
22
+ if (char === "`" && rest.includes("`")) continue;
23
+ open = { char, length: marker.length };
24
+ } else if (char === open.char && marker.length >= open.length && rest.trim() === "") {
25
+ open = undefined;
26
+ }
27
+ }
28
+ return open !== undefined;
29
+ }
30
+
31
+ /** The question-mark ending of docs/ux.md §3.1 (U9). */
32
+ export function endsWithQuestionMark(text: string): boolean {
33
+ if (endsInsideFence(text)) return false;
34
+ const stripped = text.replace(TRAILING, "");
35
+ if (stripped.length === 0) return false;
36
+ const last = Array.from(stripped).at(-1)!;
37
+ return QUESTION_MARKS.has(last);
38
+ }
39
+
40
+ export type EnterKey = "submit" | "followUp";
41
+
42
+ /** Where a submitted editor text goes: "side" asks on the side, "pi" keeps Pi's meaning. */
43
+ export function routeEnter(input: { text: string; busy: boolean; key: EnterKey }): "side" | "pi" {
44
+ if (input.key !== "submit" || !input.busy) return "pi";
45
+ const head = input.text.trimStart();
46
+ if (head.startsWith("/") || head.startsWith("!")) return "pi";
47
+ return endsWithQuestionMark(input.text) ? "side" : "pi";
48
+ }
package/src/reply.ts ADDED
@@ -0,0 +1,69 @@
1
+ // Parsing of the aside model's reply (docs/ux.md §3.3, §3.4): a hidden first line `route: side|agent`, the
2
+ // visible body, and an optional last line `handoff: <one line for the agent>`.
3
+
4
+ export type Route = "side" | "agent";
5
+
6
+ export interface ParsedReply {
7
+ route: Route | undefined;
8
+ body: string;
9
+ handoff: string | undefined;
10
+ }
11
+
12
+ const ROUTE_LINE = /^route\s*:\s*(side|agent)$/i;
13
+ const HANDOFF_LINE = /^\s*handoff\s*:(.*)$/i;
14
+ const ROUTE_FORMS = ["route: side", "route: agent", "route : side", "route : agent", "route:side", "route:agent"];
15
+
16
+ function cleanLine(line: string): string {
17
+ return line
18
+ .trim()
19
+ .replace(/^[*`]+|[*`]+$/g, "")
20
+ .trim();
21
+ }
22
+
23
+ /** Whether an unterminated first line can still become a route line. */
24
+ function couldBecomeRoute(partial: string): boolean {
25
+ const cleaned = cleanLine(partial).toLowerCase().replace(/\s+/g, " ");
26
+ if (cleaned === "") return true;
27
+ return ROUTE_FORMS.some((form) => form.startsWith(cleaned));
28
+ }
29
+
30
+ export function parseReply(raw: string, done: boolean): ParsedReply {
31
+ const text = raw.replace(/\r\n/g, "\n").replace(/^(?:[ \t]*\n)+/, "");
32
+ const newline = text.indexOf("\n");
33
+ const first = newline < 0 ? text : text.slice(0, newline);
34
+ let route: Route;
35
+ let rest: string;
36
+ if (newline < 0 && !done) {
37
+ if (couldBecomeRoute(first)) return { route: undefined, body: "", handoff: undefined };
38
+ route = "side";
39
+ rest = text;
40
+ } else {
41
+ const match = ROUTE_LINE.exec(cleanLine(first));
42
+ if (match) {
43
+ route = match[1]!.toLowerCase() as Route;
44
+ rest = newline < 0 ? "" : text.slice(newline + 1);
45
+ } else {
46
+ route = "side";
47
+ rest = text;
48
+ }
49
+ }
50
+
51
+ const lines = rest.split("\n");
52
+ while (lines.length > 0 && lines.at(-1)!.trim() === "") lines.pop();
53
+ let handoff: string | undefined;
54
+ const last = lines.at(-1);
55
+ if (last !== undefined) {
56
+ const match = HANDOFF_LINE.exec(last);
57
+ if (done) {
58
+ if (match) {
59
+ lines.pop();
60
+ const value = match[1]!.trim();
61
+ handoff = value === "" || value.toLowerCase() === "none" ? undefined : value;
62
+ }
63
+ } else {
64
+ const trimmed = last.trim().toLowerCase();
65
+ if (match || (trimmed !== "" && "handoff:".startsWith(trimmed))) lines.pop();
66
+ }
67
+ }
68
+ return { route, body: lines.join("\n").trim(), handoff };
69
+ }
package/src/tap.ts ADDED
@@ -0,0 +1,49 @@
1
+ // The final-payload tap. `before_provider_request` handlers run in extension load order, and a handler may return a
2
+ // replacement payload, which extensions loaded earlier never see. Pi sends every session request through the same
3
+ // model runtime that backs `ctx.modelRegistry`, and passes the handler chain as `onPayload`; wrapping that runtime's
4
+ // `streamSimple` lets pi-aside see the payload the handler chain returns, which is the request sent, whatever the
5
+ // load order. Without the tap (another Pi build), the observation falls back to the R event's payload by reference.
6
+
7
+ // biome-ignore lint: Pi's runtime is used structurally.
8
+ type Any = any;
9
+
10
+ const TAP = Symbol.for("pi-aside.final-payload-tap");
11
+ /** Marks pi-aside's own requests, which the tap ignores. */
12
+ export const OWN_REQUEST = Symbol.for("pi-aside.own-request");
13
+
14
+ interface Tap {
15
+ listener: ((payload: unknown) => void) | undefined;
16
+ }
17
+
18
+ /** Install the tap once per runtime; later calls replace the listener. Returns false when unavailable. */
19
+ export function installFinalPayloadTap(modelRegistry: unknown, listener: (payload: unknown) => void): boolean {
20
+ const runtime = (modelRegistry as Any)?.runtime;
21
+ if (!runtime || typeof runtime.streamSimple !== "function") return false;
22
+ const existing: Tap | undefined = runtime[TAP];
23
+ if (existing) {
24
+ existing.listener = listener;
25
+ return true;
26
+ }
27
+ const tap: Tap = { listener };
28
+ const original = runtime.streamSimple;
29
+ runtime.streamSimple = function (this: unknown, model: unknown, context: unknown, options: Any) {
30
+ if (options && !options[OWN_REQUEST] && typeof options.onPayload === "function") {
31
+ const inner = options.onPayload;
32
+ options = {
33
+ ...options,
34
+ onPayload: async (payload: unknown, m: unknown) => {
35
+ const result = await inner(payload, m);
36
+ try {
37
+ tap.listener?.(result === undefined ? payload : result);
38
+ } catch {
39
+ // observation only
40
+ }
41
+ return result;
42
+ },
43
+ };
44
+ }
45
+ return original.call(this, model, context, options);
46
+ };
47
+ runtime[TAP] = tap;
48
+ return true;
49
+ }