@astrosheep/pi-context 0.15.1 → 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 +15 -7
- package/src/history.ts +59 -12
- package/src/tool-schema.ts +1 -1
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
|
},
|
|
@@ -73,9 +77,13 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
73
77
|
pi.registerTool(defineTool({
|
|
74
78
|
name: "history_search_contents",
|
|
75
79
|
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(),
|
|
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 }),
|
|
78
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 });
|
|
79
87
|
const queries = searchQueries(params.query);
|
|
80
88
|
const matching = filteredItems(ctx, params)
|
|
81
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/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 })]);
|