@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 +1 -1
- package/src/history-tools.ts +22 -10
- package/src/history.ts +59 -12
- package/src/note-tools.ts +7 -3
- package/src/protocol.ts +1 -1
- package/src/tool-output.ts +38 -5
- package/src/tool-schema.ts +1 -1
package/package.json
CHANGED
package/src/history-tools.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
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(),
|
|
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
|
|
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
|
-
|
|
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(),
|
|
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
|
-
|
|
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"
|
|
58
|
-
if (message.role === "bashExecution") return { toolName: "bash"
|
|
57
|
+
function toolInfo(message: AgentMessage): Pick<HistoryItem, "toolName"> {
|
|
58
|
+
if (message.role === "bashExecution") return { toolName: "bash" };
|
|
59
59
|
if (message.role !== "toolResult") return {};
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
package/src/tool-output.ts
CHANGED
|
@@ -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,
|
|
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) =>
|
|
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
|
-
/**
|
|
154
|
-
|
|
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
|
+
}
|
package/src/tool-schema.ts
CHANGED
|
@@ -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
|
|
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 })]);
|