@astrosheep/pi-context 0.15.1 → 0.17.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 +21 -7
- package/src/history.ts +71 -12
- package/src/note-tools.ts +9 -0
- package/src/tool-schema.ts +9 -7
package/package.json
CHANGED
package/src/history-tools.ts
CHANGED
|
@@ -2,14 +2,14 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
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
|
},
|
|
@@ -62,6 +66,12 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
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" });
|
|
69
|
+
const totalChars = Array.from(item.content).length;
|
|
70
|
+
// A positive offset past the end is an addressing error, not an empty page: say so,
|
|
71
|
+
// and name the largest legal offset (offset == total stays the legal empty end-read).
|
|
72
|
+
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
|
|
73
|
+
return output({ error: `offset_chars ${params.offset_chars} is past the end: the item has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, window_id: item.windowId, item_id: item.itemId, offset_chars: params.offset_chars, total_chars: totalChars });
|
|
74
|
+
}
|
|
65
75
|
const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
|
|
66
76
|
return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
|
|
67
77
|
const { content, ...cursor } = window;
|
|
@@ -73,9 +83,13 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
73
83
|
pi.registerTool(defineTool({
|
|
74
84
|
name: "history_search_contents",
|
|
75
85
|
label: "History search",
|
|
76
|
-
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.",
|
|
77
|
-
parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_first: recentFirst(),
|
|
86
|
+
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.",
|
|
87
|
+
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 }),
|
|
78
88
|
async execute(_id, params, _signal, _update, ctx) {
|
|
89
|
+
const invalid = vacuousRoleToolCombo(params);
|
|
90
|
+
if (invalid) return output({ error: invalid, role: params.role, tool_name: params.tool_name });
|
|
91
|
+
const badWindow = unknownWindowId(ctx, params);
|
|
92
|
+
if (badWindow) return output({ error: badWindow.message, window_id: params.window_id, known_windows: badWindow.known });
|
|
79
93
|
const queries = searchQueries(params.query);
|
|
80
94
|
const matching = filteredItems(ctx, params)
|
|
81
95
|
.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,21 @@ 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
|
-
|
|
13
|
+
// bashExecution only: the persisted output was truncated and the full text lives on disk.
|
|
14
|
+
outputTruncated?: boolean;
|
|
15
|
+
fullOutputPath?: string;
|
|
16
|
+
// toolResult only: the run reported an error.
|
|
17
|
+
toolError?: boolean;
|
|
14
18
|
};
|
|
15
19
|
type HistoryWindow = { windowId: string; createdAt?: string; items: HistoryItem[] };
|
|
16
20
|
|
|
17
21
|
type HistoryFilter = {
|
|
18
22
|
window_id?: string | null;
|
|
19
23
|
role?: HistoryItem["role"] | null;
|
|
20
|
-
tool_namespace?: string | null;
|
|
21
24
|
tool_name?: string | null;
|
|
22
25
|
recent_first?: boolean;
|
|
23
26
|
};
|
|
@@ -45,7 +48,9 @@ const PI_CONTEXT_ENTRY_PREFIX = "pi-context/";
|
|
|
45
48
|
function messageContent(message: AgentMessage): string {
|
|
46
49
|
switch (message.role) {
|
|
47
50
|
case "bashExecution":
|
|
48
|
-
|
|
51
|
+
// The command is as much the record as its output: without it the typed line is
|
|
52
|
+
// unsearchable. Mirrors the tool-call projection below.
|
|
53
|
+
return message.output ? `${message.command}\n${message.output}` : message.command;
|
|
49
54
|
case "branchSummary":
|
|
50
55
|
case "compactionSummary":
|
|
51
56
|
return message.summary;
|
|
@@ -54,11 +59,38 @@ function messageContent(message: AgentMessage): string {
|
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
function toolInfo(message: AgentMessage): Pick<HistoryItem, "toolName" | "
|
|
58
|
-
if (message.role === "bashExecution")
|
|
62
|
+
function toolInfo(message: AgentMessage): Pick<HistoryItem, "toolName" | "outputTruncated" | "fullOutputPath" | "toolError"> {
|
|
63
|
+
if (message.role === "bashExecution") {
|
|
64
|
+
// A truncated bash run is only half the record without the on-disk path: surface both.
|
|
65
|
+
return { toolName: "bash", outputTruncated: message.truncated || undefined, fullOutputPath: message.truncated ? message.fullOutputPath : undefined };
|
|
66
|
+
}
|
|
59
67
|
if (message.role !== "toolResult") return {};
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
return { toolName: message.toolName, toolError: message.isError === true ? true : undefined };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* An assistant turn's tool calls, projected as their own items: calls wear their own role so the
|
|
73
|
+
* authoring turn's visible text (role "assistant") stays pure; what was invoked stays as
|
|
74
|
+
* searchable as what came back (role "tool"). Ids derive from the turn's entry id and stay
|
|
75
|
+
* opaque; history_read_item resolves them like any other item.
|
|
76
|
+
*/
|
|
77
|
+
function toolCallItems(windowId: string, entry: { id: string; timestamp?: string }, message: AgentMessage): HistoryItem[] {
|
|
78
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) return [];
|
|
79
|
+
const items: HistoryItem[] = [];
|
|
80
|
+
let callIndex = 0;
|
|
81
|
+
for (const part of message.content) {
|
|
82
|
+
if (typeof part !== "object" || part === null || (part as { type?: unknown }).type !== "toolCall") continue;
|
|
83
|
+
const call = part as ToolCall;
|
|
84
|
+
items.push({
|
|
85
|
+
windowId,
|
|
86
|
+
itemId: `${entry.id}#${callIndex++}`,
|
|
87
|
+
role: "tool_call",
|
|
88
|
+
content: JSON.stringify(call.arguments),
|
|
89
|
+
createdAt: entry.timestamp,
|
|
90
|
+
toolName: call.name,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return items;
|
|
62
94
|
}
|
|
63
95
|
|
|
64
96
|
/** The extension-owned window id baked onto a reset-v2 compaction entry, if present. */
|
|
@@ -104,6 +136,7 @@ export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
|
|
|
104
136
|
createdAt: entry.timestamp,
|
|
105
137
|
...toolInfo(entry.message),
|
|
106
138
|
});
|
|
139
|
+
window.items.push(...toolCallItems(window.windowId, entry, entry.message));
|
|
107
140
|
continue;
|
|
108
141
|
}
|
|
109
142
|
if (entry.type === "custom_message") {
|
|
@@ -127,8 +160,11 @@ export function visibleItem(item: HistoryItem, maxChars = 1200) {
|
|
|
127
160
|
window_id: item.windowId,
|
|
128
161
|
item_id: item.itemId,
|
|
129
162
|
role: item.role,
|
|
130
|
-
tool_namespace: item.toolNamespace ?? null,
|
|
131
163
|
tool_name: item.toolName ?? null,
|
|
164
|
+
// Surfaced only when set: a truncated bash run names its full-output path, and an
|
|
165
|
+
// errored tool run says so. Absent keys mean nothing special happened.
|
|
166
|
+
...(item.outputTruncated ? { output_truncated: true, full_output_path: item.fullOutputPath ?? null } : {}),
|
|
167
|
+
...(item.toolError ? { tool_error: true } : {}),
|
|
132
168
|
truncated,
|
|
133
169
|
total_chars: characters.length,
|
|
134
170
|
// A truncated payload is a plain prefix: no synthetic marker is appended, and
|
|
@@ -141,11 +177,34 @@ export function allItems(ctx: SessionReader) {
|
|
|
141
177
|
return historyFromSession(ctx).flatMap((window) => window.items);
|
|
142
178
|
}
|
|
143
179
|
|
|
144
|
-
|
|
180
|
+
/**
|
|
181
|
+
* window_id must name a real window; anything else is a named error, not a silent empty page
|
|
182
|
+
* (a window that exists but has no matching items after the other filters stays a legal empty
|
|
183
|
+
* page). Returns the teaching message plus the known window ids so the error is self-healing.
|
|
184
|
+
*/
|
|
185
|
+
export function unknownWindowId(ctx: SessionReader, params: HistoryFilter): { message: string; known: string[] } | undefined {
|
|
186
|
+
if (typeof params.window_id !== "string") return undefined;
|
|
187
|
+
const known = historyFromSession(ctx).map((window) => window.windowId);
|
|
188
|
+
return known.includes(params.window_id) ? undefined : { message: `unknown window_id "${params.window_id}"`, known };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A role×tool_name combination is vacuous — provably empty from the taxonomy alone, before
|
|
193
|
+
* any data is read — when tool_name is given alongside a role that never carries one. Only
|
|
194
|
+
* "tool_call" and "tool" items have a tool name. Returns the teaching error message, or
|
|
195
|
+
* undefined when the combination can match.
|
|
196
|
+
*/
|
|
197
|
+
export function vacuousRoleToolCombo(params: HistoryFilter): string | undefined {
|
|
198
|
+
if (typeof params.tool_name === "string" && typeof params.role === "string" && params.role !== "tool_call" && params.role !== "tool") {
|
|
199
|
+
return `tool_name is only set on "tool_call" and "tool" items; role "${params.role}" never carries one`;
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function filteredItems(ctx: SessionReader, params: HistoryFilter): HistoryItem[] {
|
|
145
205
|
let items = allItems(ctx);
|
|
146
206
|
if (typeof params.window_id === "string") items = items.filter((item) => item.windowId === params.window_id);
|
|
147
207
|
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
208
|
if (typeof params.tool_name === "string") items = items.filter((item) => item.toolName === params.tool_name);
|
|
150
209
|
if (params.recent_first !== false) items.reverse();
|
|
151
210
|
return items;
|
package/src/note-tools.ts
CHANGED
|
@@ -54,6 +54,12 @@ export function registerNoteTools(pi: ExtensionAPI) {
|
|
|
54
54
|
const path = assertVirtualPath(params.path);
|
|
55
55
|
const file = notesFromSession(ctx).get(path);
|
|
56
56
|
if (!file) return output({ error: "note file not found", path });
|
|
57
|
+
const totalChars = Array.from(file.text).length;
|
|
58
|
+
// A positive offset past the end is an addressing error, not an empty page: say so,
|
|
59
|
+
// and name the largest legal offset (offset == total stays the legal empty end-read).
|
|
60
|
+
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
|
|
61
|
+
return output({ error: `offset_chars ${params.offset_chars} is past the end: the note has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, path, offset_chars: params.offset_chars, total_chars: totalChars });
|
|
62
|
+
}
|
|
57
63
|
const created_at = localIso(file.createdAt);
|
|
58
64
|
const updated_at = localIso(file.updatedAt);
|
|
59
65
|
const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
|
|
@@ -146,6 +152,9 @@ export function registerNoteTools(pi: ExtensionAPI) {
|
|
|
146
152
|
if (!hasText && !hasStale) return output({ error: "provide text, mark_stale, or both", path });
|
|
147
153
|
const old = notesFromSession(ctx).get(path);
|
|
148
154
|
if (!hasText && !old) return output({ error: "note file not found", path });
|
|
155
|
+
// Appending is not creating: an append to a path that does not exist almost always
|
|
156
|
+
// means a typo'd path, so it dies loudly instead of silently minting a new note.
|
|
157
|
+
if (op === "append" && hasText && !old) return output({ error: "note file not found (use notes_write_file to create)", path });
|
|
149
158
|
const next = hasText ? (op === "append" ? `${old?.text ?? ""}${params.text}` : params.text as string) : old!.text;
|
|
150
159
|
const bytes = Buffer.byteLength(next, "utf8");
|
|
151
160
|
if (hasText && bytes > MAX_NOTE_BYTES) return output({ error: `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes`, path, size_bytes: bytes });
|
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 })]);
|
|
@@ -12,13 +12,15 @@ export const searchQuery = () => Type.Union([Type.String(), Type.Array(Type.Stri
|
|
|
12
12
|
/**
|
|
13
13
|
* Normalize a search `query` parameter into the literal needles to match.
|
|
14
14
|
* A bare string is a one-element list, so single-query behavior is unchanged.
|
|
15
|
-
* An empty list
|
|
16
|
-
* for nothing:
|
|
15
|
+
* An empty list, a non-string element, or an empty string is refused rather than silently
|
|
16
|
+
* searching for nothing: those are argument errors, not empty result sets. An empty string
|
|
17
|
+
* matches every line and every item, so it can never be what the caller meant.
|
|
17
18
|
*/
|
|
18
19
|
export function searchQueries(query: unknown): string[] {
|
|
19
|
-
|
|
20
|
-
if (!Array.isArray(
|
|
21
|
-
if (!
|
|
22
|
-
|
|
20
|
+
const candidates = typeof query === "string" ? [query] : query;
|
|
21
|
+
if (!Array.isArray(candidates) || candidates.length === 0) throw new Error("query must be a string or a non-empty array of strings");
|
|
22
|
+
if (!candidates.every((candidate) => typeof candidate === "string")) throw new Error("query array elements must be strings");
|
|
23
|
+
if (candidates.some((candidate) => candidate === "")) throw new Error("query strings must be non-empty: an empty query matches everything");
|
|
24
|
+
return candidates as string[];
|
|
23
25
|
}
|
|
24
26
|
|