@astrosheep/pi-context 0.15.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "description": "Codex-style context windows for Pi: reset-style compaction, durable session history tools, and persistent notes.",
6
6
  "license": "MIT",
@@ -1,15 +1,15 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { output, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow } from "./tool-output.js";
3
+ import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget } from "./tool-output.js";
4
4
  import { positiveInteger, recentFirst, nullableString, role, cursor, searchQuery, searchQueries } from "./tool-schema.js";
5
- import { historyFromSession, filteredItems, visibleItem, allItems } from "./history.js";
5
+ import { historyFromSession, filteredItems, visibleItem, allItems, vacuousRoleToolCombo, unknownWindowId } from "./history.js";
6
6
 
7
7
  /**
8
8
  * Shrink one page item to fit the wire budget. `truncated`/`total_chars` stay honest: the
9
9
  * payload is only ever cut to a plain prefix of itself, never filled with a marker, and the
10
10
  * flag flips on whenever a shrink actually removed characters. `tool_name` is metadata, not a
11
- * payload, and keeps its visible middle-truncation marker. `item_id`, `window_id`, `role`, and
12
- * `tool_namespace` are identity or tiny metadata and are never touched.
11
+ * payload, and keeps its visible middle-truncation marker. `item_id`, `window_id`, and `role`
12
+ * are identity or tiny metadata and are never touched.
13
13
  */
14
14
  function truncateHistoryItem<T extends { truncated_content: string; truncated: boolean; tool_name: string | null }>(item: T, fits: (candidate: T) => boolean): T {
15
15
  if (fits(item)) return item;
@@ -46,9 +46,13 @@ export function registerHistoryTools(pi: ExtensionAPI) {
46
46
  pi.registerTool(defineTool({
47
47
  name: "history_list_items",
48
48
  label: "History list items",
49
- description: "List durable session items, including items before compaction, using opaque item and window IDs. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with history_read_item.",
50
- parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
49
+ description: "List durable session items, including items before compaction, using opaque item and window IDs; the role parameter's description enumerates the six roles. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with history_read_item.",
50
+ parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), recent_first: recentFirst(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
51
51
  async execute(_id, params, _signal, _update, ctx) {
52
+ const invalid = vacuousRoleToolCombo(params);
53
+ if (invalid) return output({ error: invalid, role: params.role, tool_name: params.tool_name });
54
+ const badWindow = unknownWindowId(ctx, params);
55
+ if (badWindow) return output({ error: badWindow.message, window_id: params.window_id, known_windows: badWindow.known });
52
56
  const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200));
53
57
  return output(page(items, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
54
58
  },
@@ -57,21 +61,29 @@ export function registerHistoryTools(pi: ExtensionAPI) {
57
61
  pi.registerTool(defineTool({
58
62
  name: "history_read_item",
59
63
  label: "History read item",
60
- description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget. next_offset_chars is exactly offset_chars plus the delivered code-point count and is null only once the item ends: follow it to reconstruct the item exactly. A negative offset_chars counts back from the item's end, and the response always echoes the resolved absolute offset. Offsets and counts are code points (an emoji or CJK character counts as one).",
64
+ description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget: follow the resume cursor to reconstruct the item exactly. A negative offset_chars counts back from the item's end. Offsets and counts are code points (an emoji or CJK character counts as one). The response is the raw item text behind a one-line [bracketed] header naming the item, the resolved offset, the delivered char range, and the resume cursor (continue at offset_chars=N, or end).",
61
65
  parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from. A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000, description: "Largest requested window in code points (default 12000). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes." })), window_id: Type.String() }, { additionalProperties: false }),
62
66
  async execute(_id, params, _signal, _update, ctx) {
63
67
  const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
64
68
  if (!item) return output({ error: "unknown item_id or window_id" });
65
- return output(readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => ({ window_id: item.windowId, item_id: item.itemId, ...window })));
69
+ const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
70
+ return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
71
+ const { content, ...cursor } = window;
72
+ return outputRaw(characterWindowHeader(`${item.windowId} · item ${item.itemId}`, window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor, limit_chars });
73
+ }, (result) => withinTextBudget(result.content[0].text));
66
74
  },
67
75
  }));
68
76
 
69
77
  pi.registerTool(defineTool({
70
78
  name: "history_search_contents",
71
79
  label: "History search",
72
- description: "Case-sensitive literal substring search over durable Pi session history; query accepts one string or an array of strings, an item matches when it contains any of them (OR), and each item appears once. No semantic search. Each hit carries truncated and total_chars plus match_offset_chars: the code-point offset of the earliest query occurrence in the item's full content. With max_chars_per_item: 1 the page is an address list; resolve an address with history_read_item at match_offset_chars.",
73
- parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
80
+ description: "Case-sensitive literal substring search over durable Pi session history; query accepts one string or an array of strings, an item matches when it contains any of them (OR), and each item appears once. No semantic search. Invocations and outputs are separate items (roles \"tool_call\" and \"tool\"), so both are searchable; the role parameter's description enumerates all six. Each hit carries truncated and total_chars plus match_offset_chars: the code-point offset of the earliest query occurrence in the item's full content. With max_chars_per_item: 1 the page is an address list; resolve an address with history_read_item at match_offset_chars.",
81
+ parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_first: recentFirst(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
74
82
  async execute(_id, params, _signal, _update, ctx) {
83
+ const invalid = vacuousRoleToolCombo(params);
84
+ if (invalid) return output({ error: invalid, role: params.role, tool_name: params.tool_name });
85
+ const badWindow = unknownWindowId(ctx, params);
86
+ if (badWindow) return output({ error: badWindow.message, window_id: params.window_id, known_windows: badWindow.known });
75
87
  const queries = searchQueries(params.query);
76
88
  const matching = filteredItems(ctx, params)
77
89
  .filter((item) => queries.some((query) => item.content.includes(query)))
package/src/history.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { TextContent } from "@earendil-works/pi-ai";
1
+ import type { TextContent, ToolCall } from "@earendil-works/pi-ai";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
3
  import type { SessionReader } from "./session-reader.js";
4
4
  import { RESET_V2 } from "./protocol.js";
@@ -6,18 +6,16 @@ import { RESET_V2 } from "./protocol.js";
6
6
  type HistoryItem = {
7
7
  windowId: string;
8
8
  itemId: string;
9
- role: "user" | "assistant" | "tool" | "system" | "developer";
9
+ role: "user" | "assistant" | "tool_call" | "tool" | "system" | "developer";
10
10
  content: string;
11
11
  createdAt: string | undefined;
12
12
  toolName?: string;
13
- toolNamespace?: string;
14
13
  };
15
14
  type HistoryWindow = { windowId: string; createdAt?: string; items: HistoryItem[] };
16
15
 
17
16
  type HistoryFilter = {
18
17
  window_id?: string | null;
19
18
  role?: HistoryItem["role"] | null;
20
- tool_namespace?: string | null;
21
19
  tool_name?: string | null;
22
20
  recent_first?: boolean;
23
21
  };
@@ -45,7 +43,9 @@ const PI_CONTEXT_ENTRY_PREFIX = "pi-context/";
45
43
  function messageContent(message: AgentMessage): string {
46
44
  switch (message.role) {
47
45
  case "bashExecution":
48
- return message.output;
46
+ // The command is as much the record as its output: without it the typed line is
47
+ // unsearchable. Mirrors the tool-call projection below.
48
+ return message.output ? `${message.command}\n${message.output}` : message.command;
49
49
  case "branchSummary":
50
50
  case "compactionSummary":
51
51
  return message.summary;
@@ -54,11 +54,35 @@ function messageContent(message: AgentMessage): string {
54
54
  }
55
55
  }
56
56
 
57
- function toolInfo(message: AgentMessage): Pick<HistoryItem, "toolName" | "toolNamespace"> {
58
- if (message.role === "bashExecution") return { toolName: "bash", toolNamespace: undefined };
57
+ function toolInfo(message: AgentMessage): Pick<HistoryItem, "toolName"> {
58
+ if (message.role === "bashExecution") return { toolName: "bash" };
59
59
  if (message.role !== "toolResult") return {};
60
- const underscore = message.toolName.indexOf("_");
61
- return { toolName: message.toolName, toolNamespace: underscore > 0 ? message.toolName.slice(0, underscore) : undefined };
60
+ return { toolName: message.toolName };
61
+ }
62
+
63
+ /**
64
+ * An assistant turn's tool calls, projected as their own items: calls wear their own role so the
65
+ * authoring turn's visible text (role "assistant") stays pure; what was invoked stays as
66
+ * searchable as what came back (role "tool"). Ids derive from the turn's entry id and stay
67
+ * opaque; history_read_item resolves them like any other item.
68
+ */
69
+ function toolCallItems(windowId: string, entry: { id: string; timestamp?: string }, message: AgentMessage): HistoryItem[] {
70
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return [];
71
+ const items: HistoryItem[] = [];
72
+ let callIndex = 0;
73
+ for (const part of message.content) {
74
+ if (typeof part !== "object" || part === null || (part as { type?: unknown }).type !== "toolCall") continue;
75
+ const call = part as ToolCall;
76
+ items.push({
77
+ windowId,
78
+ itemId: `${entry.id}#${callIndex++}`,
79
+ role: "tool_call",
80
+ content: JSON.stringify(call.arguments),
81
+ createdAt: entry.timestamp,
82
+ toolName: call.name,
83
+ });
84
+ }
85
+ return items;
62
86
  }
63
87
 
64
88
  /** The extension-owned window id baked onto a reset-v2 compaction entry, if present. */
@@ -104,6 +128,7 @@ export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
104
128
  createdAt: entry.timestamp,
105
129
  ...toolInfo(entry.message),
106
130
  });
131
+ window.items.push(...toolCallItems(window.windowId, entry, entry.message));
107
132
  continue;
108
133
  }
109
134
  if (entry.type === "custom_message") {
@@ -127,7 +152,6 @@ export function visibleItem(item: HistoryItem, maxChars = 1200) {
127
152
  window_id: item.windowId,
128
153
  item_id: item.itemId,
129
154
  role: item.role,
130
- tool_namespace: item.toolNamespace ?? null,
131
155
  tool_name: item.toolName ?? null,
132
156
  truncated,
133
157
  total_chars: characters.length,
@@ -141,11 +165,34 @@ export function allItems(ctx: SessionReader) {
141
165
  return historyFromSession(ctx).flatMap((window) => window.items);
142
166
  }
143
167
 
144
- export function filteredItems(ctx: SessionReader, params: HistoryFilter): HistoryItem[] {
168
+ /**
169
+ * window_id must name a real window; anything else is a named error, not a silent empty page
170
+ * (a window that exists but has no matching items after the other filters stays a legal empty
171
+ * page). Returns the teaching message plus the known window ids so the error is self-healing.
172
+ */
173
+ export function unknownWindowId(ctx: SessionReader, params: HistoryFilter): { message: string; known: string[] } | undefined {
174
+ if (typeof params.window_id !== "string") return undefined;
175
+ const known = historyFromSession(ctx).map((window) => window.windowId);
176
+ return known.includes(params.window_id) ? undefined : { message: `unknown window_id "${params.window_id}"`, known };
177
+ }
178
+
179
+ /**
180
+ * A role×tool_name combination is vacuous — provably empty from the taxonomy alone, before
181
+ * any data is read — when tool_name is given alongside a role that never carries one. Only
182
+ * "tool_call" and "tool" items have a tool name. Returns the teaching error message, or
183
+ * undefined when the combination can match.
184
+ */
185
+ export function vacuousRoleToolCombo(params: HistoryFilter): string | undefined {
186
+ if (typeof params.tool_name === "string" && typeof params.role === "string" && params.role !== "tool_call" && params.role !== "tool") {
187
+ return `tool_name is only set on "tool_call" and "tool" items; role "${params.role}" never carries one`;
188
+ }
189
+ return undefined;
190
+ }
191
+
192
+ export function filteredItems(ctx: SessionReader, params: HistoryFilter): HistoryItem[] {
145
193
  let items = allItems(ctx);
146
194
  if (typeof params.window_id === "string") items = items.filter((item) => item.windowId === params.window_id);
147
195
  if (typeof params.role === "string") items = items.filter((item) => item.role === params.role);
148
- if (typeof params.tool_namespace === "string") items = items.filter((item) => item.toolNamespace === params.tool_namespace);
149
196
  if (typeof params.tool_name === "string") items = items.filter((item) => item.toolName === params.tool_name);
150
197
  if (params.recent_first !== false) items.reverse();
151
198
  return items;
package/src/note-tools.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { output, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow } from "./tool-output.js";
3
+ import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget } from "./tool-output.js";
4
4
  import { nullableString, positiveInteger, cursor, searchQuery, searchQueries } from "./tool-schema.js";
5
5
  import { notesFromSession, assertVirtualPath, assertVirtualPrefix, assertGlobPattern, globToRegExp, localIso, type NoteOperation } from "./notes.js";
6
6
  import { NOTE_TYPE, MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./protocol.js";
@@ -48,7 +48,7 @@ export function registerNoteTools(pi: ExtensionAPI) {
48
48
  pi.registerTool(defineTool({
49
49
  name: "notes_read_file",
50
50
  label: "Notes read file",
51
- description: "Read a character window from a note file: offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end (offset_chars: -2000 reads the last 2000) and the response echoes the resolved absolute offset — and limit_chars caps the window (default 12000, max 50000). Each response delivers the longest fitting prefix of that window: pass next_offset_chars back unchanged to continue, concatenate pages in order, null only at the end.",
51
+ description: "Read a character window from a note file: offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end (offset_chars: -2000 reads the last 2000) — and limit_chars caps the window (default 12000, max 50000). Each response delivers the longest fitting prefix of that window: concatenate pages in order to reconstruct the note. The response is the raw note text behind a one-line [bracketed] header naming the file, the resolved offset, the delivered char range, and the resume cursor (continue at offset_chars=N, or end).",
52
52
  parameters: Type.Object({ path: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000, description: "Largest requested window in code points (default 12000). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes." })) }, { additionalProperties: false }),
53
53
  async execute(_id, params, _signal, _update, ctx) {
54
54
  const path = assertVirtualPath(params.path);
@@ -56,7 +56,11 @@ export function registerNoteTools(pi: ExtensionAPI) {
56
56
  if (!file) return output({ error: "note file not found", path });
57
57
  const created_at = localIso(file.createdAt);
58
58
  const updated_at = localIso(file.updatedAt);
59
- return output(readCharacterWindow(file.text, params.offset_chars, params.limit_chars, (window) => ({ path, ...window, created_at, updated_at })));
59
+ const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
60
+ return readCharacterWindow(file.text, params.offset_chars, params.limit_chars, (window) => {
61
+ const { content, ...cursor } = window;
62
+ return outputRaw(characterWindowHeader(path, window, ` · created ${created_at} · updated ${updated_at}`), content, { path, ...cursor, limit_chars, created_at, updated_at });
63
+ }, (result) => withinTextBudget(result.content[0].text));
60
64
  },
61
65
  }));
62
66
 
package/src/protocol.ts CHANGED
@@ -52,5 +52,5 @@ Notes are session-scoped virtual files. Treat notes and history as internal book
52
52
  ${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
53
53
 
54
54
  export const WARNING_PROMPT =
55
- "Memory wipe incoming this turn is all you get. Do not continue any task. Get that checkpoint down NOW — one note, write it or append to it: the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Then call new_context immediately and go out clean — anything not in the note is gone.";
55
+ "Your memory is about to be erased. Write the note. NOW. If it already exists, append instead: the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Do not continue any task. Then call new_context IMMEDIATELY — anything not in the note dies with the window.";
56
56
 
@@ -9,6 +9,11 @@ export function withinBudget(value: unknown, budget = TOOL_OUTPUT_MAX_BYTES): bo
9
9
  return Buffer.byteLength(json(value), "utf8") <= budget;
10
10
  }
11
11
 
12
+ /** True when `text` fits the wire budget verbatim, for raw payloads with no JSON encoding. */
13
+ export function withinTextBudget(text: string, budget = TOOL_OUTPUT_MAX_BYTES): boolean {
14
+ return Buffer.byteLength(text, "utf8") <= budget;
15
+ }
16
+
12
17
  /** Marker standing in for characters elided from the middle of an oversized single unit. */
13
18
  export function truncationMarker(removedChars: number): string {
14
19
  return `…[truncated ${removedChars} chars]…`;
@@ -89,9 +94,11 @@ export type CharacterWindow = {
89
94
  * `N >= total_chars` reads from the start; the resolved absolute offset is always echoed.
90
95
  * Following `next_offset_chars` reconstructs `text` by plain concatenation, because the
91
96
  * payload is always a plain prefix with no marker. `render` builds the exact response for
92
- * a candidate window, so the budget is measured on the bytes that go on the wire.
97
+ * a candidate window, and `measure` decides whether that response fits the wire budget (JSON
98
+ * serialization by default; raw-text renders pass a verbatim byte measure), so the budget is
99
+ * always measured on the bytes that go on the wire.
93
100
  */
94
- export function readCharacterWindow<T>(text: string, offsetChars: number | undefined, limitChars: number | undefined, render: (window: CharacterWindow) => T): T {
101
+ export function readCharacterWindow<T>(text: string, offsetChars: number | undefined, limitChars: number | undefined, render: (window: CharacterWindow) => T, measure: (rendered: T) => boolean = withinBudget): T {
95
102
  const chars = Array.from(text);
96
103
  const requested = offsetChars ?? 0;
97
104
  const resolved = requested < 0 ? Math.max(0, chars.length + requested) : Math.max(0, requested);
@@ -100,10 +107,23 @@ export function readCharacterWindow<T>(text: string, offsetChars: number | undef
100
107
  const next = resolved + Array.from(content).length;
101
108
  return { offset_chars: resolved, content, total_chars: chars.length, next_offset_chars: next < chars.length ? next : null };
102
109
  };
103
- const content = prefixFit(windowChars.join(""), (candidate) => withinBudget(render(build(candidate))));
110
+ const content = prefixFit(windowChars.join(""), (candidate) => measure(render(build(candidate))));
104
111
  return render(build(content));
105
112
  }
106
113
 
114
+ /**
115
+ * One-line bracketed header preceding a raw character-window payload: the identity, the
116
+ * delivered char range, and either the resume cursor or `end`. `tail` appends extra
117
+ * metadata (notes add their timestamps) inside the same brackets.
118
+ */
119
+ export function characterWindowHeader(identity: string, window: CharacterWindow, tail = ""): string {
120
+ // The range end is offset + delivered count, never `total_chars`: a read resolved past the
121
+ // end delivers zero characters there, and the header must not render an inverted range.
122
+ const end = window.offset_chars + Array.from(window.content).length;
123
+ const resume = window.next_offset_chars === null ? "end" : `continue at offset_chars=${window.next_offset_chars}`;
124
+ return `[${identity} · chars ${window.offset_chars}-${end} of ${window.total_chars} · ${resume}${tail}]`;
125
+ }
126
+
107
127
  /**
108
128
  * Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
109
129
  * none occurs. Shared by the two search tools so a match address is computed identically.
@@ -150,7 +170,20 @@ export function page<T>(items: T[], cursor: number, key: string, limit?: number,
150
170
  return { [key]: selected, next_cursor: next };
151
171
  }
152
172
 
153
- /** Encode a result through the common tool result boundary. */
154
- export function output(value: unknown, details: unknown = value, terminate = false) {
173
+ /**
174
+ * Encode a structured result through the common tool result boundary. `details` is slim
175
+ * metadata for logs/UI (pi convention: never a second copy of the payload) and stays
176
+ * undefined unless the tool has metadata worth persisting.
177
+ */
178
+ export function output(value: unknown, details?: unknown, terminate = false) {
155
179
  return { content: [{ type: "text" as const, text: json(value) }], details, terminate };
156
180
  }
181
+
182
+ /**
183
+ * Encode a prose payload as raw text: a one-line bracketed metadata header, then the payload
184
+ * verbatim. The model reads the note or history item itself instead of a JSON envelope;
185
+ * `details` carries the slim metadata object and never duplicates the payload.
186
+ */
187
+ export function outputRaw(header: string, content: string, details: unknown, terminate = false) {
188
+ return { content: [{ type: "text" as const, text: `${header}\n${content}` }], details, terminate };
189
+ }
@@ -4,7 +4,7 @@ export const positiveInteger = () => Type.Optional(Type.Integer({ minimum: 1 }))
4
4
  export const cursor = () => Type.Optional(Type.Integer({ minimum: 0, description: "Continuation cursor: pass the previous next_cursor back unchanged, with the same filters and ordering. Omit to start. next_cursor is null only when the set is exhausted." }));
5
5
  export const recentFirst = () => Type.Optional(Type.Boolean({ description: "Return newest-first. Only an explicit false returns oldest-first. Defaults to true." }));
6
6
  /** Role filter. `developer` is the known author for this extension's own custom entries. */
7
- export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()], { description: "Filter by the entry's known author: user/assistant/tool from the conversation, system for native Pi compaction summaries, developer for entries this extension authored (its boot, guidance, warning, and continuation messages, its reset-window compaction summaries, and any other pi-context/* entry)." });
7
+ export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool_call"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()], { description: "Filter by the item's role. Exactly six: \"user\" and \"assistant\" are a message's visible text (assistant text never contains tool calls); \"tool_call\" is one tool invocation (tool_name set, content = the call's JSON arguments); \"tool\" is one tool run's output (tool_name set); \"system\" is a native Pi compaction summary; \"developer\" is an entry this extension authored (boot, guidance, warning, continuation messages, reset-window compaction summaries, any pi-context/* entry)." });
8
8
 
9
9
  /** Search query parameter: one literal, or several literals combined with OR. */
10
10
  export const searchQuery = () => Type.Union([Type.String(), Type.Array(Type.String(), { minItems: 1 })]);