@astrosheep/pi-context 0.15.0 → 0.15.1
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 +7 -3
- package/src/note-tools.ts +7 -3
- package/src/protocol.ts +1 -1
- package/src/tool-output.ts +38 -5
package/package.json
CHANGED
package/src/history-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 { positiveInteger, recentFirst, nullableString, role, cursor, searchQuery, searchQueries } from "./tool-schema.js";
|
|
5
5
|
import { historyFromSession, filteredItems, visibleItem, allItems } from "./history.js";
|
|
6
6
|
|
|
@@ -57,12 +57,16 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
57
57
|
pi.registerTool(defineTool({
|
|
58
58
|
name: "history_read_item",
|
|
59
59
|
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
|
|
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: 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
61
|
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
62
|
async execute(_id, params, _signal, _update, ctx) {
|
|
63
63
|
const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
|
|
64
64
|
if (!item) return output({ error: "unknown item_id or window_id" });
|
|
65
|
-
|
|
65
|
+
const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
|
|
66
|
+
return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
|
|
67
|
+
const { content, ...cursor } = window;
|
|
68
|
+
return outputRaw(characterWindowHeader(`${item.windowId} · item ${item.itemId}`, window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor, limit_chars });
|
|
69
|
+
}, (result) => withinTextBudget(result.content[0].text));
|
|
66
70
|
},
|
|
67
71
|
}));
|
|
68
72
|
|
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
|
+
}
|