@astrosheep/pi-context 0.16.0 → 0.18.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 +6 -0
- package/src/history.ts +15 -3
- package/src/note-tools.ts +15 -5
- package/src/notes.ts +0 -6
- package/src/tool-schema.ts +8 -6
package/package.json
CHANGED
package/src/history-tools.ts
CHANGED
|
@@ -66,6 +66,12 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
66
66
|
async execute(_id, params, _signal, _update, ctx) {
|
|
67
67
|
const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
|
|
68
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
|
+
}
|
|
69
75
|
const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
|
|
70
76
|
return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
|
|
71
77
|
const { content, ...cursor } = window;
|
package/src/history.ts
CHANGED
|
@@ -10,6 +10,11 @@ type HistoryItem = {
|
|
|
10
10
|
content: string;
|
|
11
11
|
createdAt: string | undefined;
|
|
12
12
|
toolName?: string;
|
|
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;
|
|
13
18
|
};
|
|
14
19
|
type HistoryWindow = { windowId: string; createdAt?: string; items: HistoryItem[] };
|
|
15
20
|
|
|
@@ -54,10 +59,13 @@ 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
|
-
return { toolName: message.toolName };
|
|
68
|
+
return { toolName: message.toolName, toolError: message.isError === true ? true : undefined };
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
/**
|
|
@@ -153,6 +161,10 @@ export function visibleItem(item: HistoryItem, maxChars = 1200) {
|
|
|
153
161
|
item_id: item.itemId,
|
|
154
162
|
role: item.role,
|
|
155
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 } : {}),
|
|
156
168
|
truncated,
|
|
157
169
|
total_chars: characters.length,
|
|
158
170
|
// A truncated payload is a plain prefix: no synthetic marker is appended, and
|
package/src/note-tools.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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 { nullableString, positiveInteger, cursor, searchQuery, searchQueries } from "./tool-schema.js";
|
|
5
|
-
import { notesFromSession, assertVirtualPath,
|
|
5
|
+
import { notesFromSession, assertVirtualPath, assertGlobPattern, globToRegExp, localIso, type NoteOperation } from "./notes.js";
|
|
6
6
|
import { NOTE_TYPE, MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./protocol.js";
|
|
7
7
|
|
|
8
8
|
export function registerNoteTools(pi: ExtensionAPI) {
|
|
@@ -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);
|
|
@@ -67,12 +73,13 @@ export function registerNoteTools(pi: ExtensionAPI) {
|
|
|
67
73
|
pi.registerTool(defineTool({
|
|
68
74
|
name: "notes_search_contents",
|
|
69
75
|
label: "Notes search",
|
|
70
|
-
description: "Case-sensitive literal substring search over note lines; query is one string or several (OR), each matched line appears once. No semantic search. Each file entry carries matches_total, its full match count before capping: matches_total minus matches.length is how many were dropped. Each match carries line and offset_chars (the file-absolute code-point offset of the earliest match): notes_read_file at offset_chars shows the query. An over-budget matched line comes back as a prefix with truncated and total_chars; read the rest at the same offset_chars.",
|
|
71
|
-
parameters: Type.Object({ max_matches_per_file: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_file_first: Type.Optional(Type.Boolean()), max_files: positiveInteger(),
|
|
76
|
+
description: "Case-sensitive literal substring search over note lines; query is one string or several (OR), each matched line appears once. No semantic search. Optionally filtered by a glob pattern (* within a path segment, ** across segments). Each file entry carries matches_total, its full match count before capping: matches_total minus matches.length is how many were dropped. Each match carries line and offset_chars (the file-absolute code-point offset of the earliest match): notes_read_file at offset_chars shows the query. An over-budget matched line comes back as a prefix with truncated and total_chars; read the rest at the same offset_chars.",
|
|
77
|
+
parameters: Type.Object({ max_matches_per_file: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_file_first: Type.Optional(Type.Boolean()), max_files: positiveInteger(), pattern: nullableString() }, { additionalProperties: false }),
|
|
72
78
|
async execute(_id, params, _signal, _update, ctx) {
|
|
73
79
|
const queries = searchQueries(params.query);
|
|
74
|
-
const
|
|
75
|
-
|
|
80
|
+
const pattern = assertGlobPattern(params.pattern);
|
|
81
|
+
const matcher = pattern ? globToRegExp(pattern) : undefined;
|
|
82
|
+
let files = [...notesFromSession(ctx)].filter(([path]) => !matcher || matcher.test(path));
|
|
76
83
|
if (params.recent_file_first) files.sort((a, b) => b[1].createdAt - a[1].createdAt);
|
|
77
84
|
const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
|
|
78
85
|
const result: Array<{ path: string; created_at: string; updated_at: string; matches_total: number; matches: Array<{ line: number; text: string; truncated: boolean; total_chars: number; offset_chars: number }>; path_truncated?: boolean }> = files
|
|
@@ -146,6 +153,9 @@ export function registerNoteTools(pi: ExtensionAPI) {
|
|
|
146
153
|
if (!hasText && !hasStale) return output({ error: "provide text, mark_stale, or both", path });
|
|
147
154
|
const old = notesFromSession(ctx).get(path);
|
|
148
155
|
if (!hasText && !old) return output({ error: "note file not found", path });
|
|
156
|
+
// Appending is not creating: an append to a path that does not exist almost always
|
|
157
|
+
// means a typo'd path, so it dies loudly instead of silently minting a new note.
|
|
158
|
+
if (op === "append" && hasText && !old) return output({ error: "note file not found (use notes_write_file to create)", path });
|
|
149
159
|
const next = hasText ? (op === "append" ? `${old?.text ?? ""}${params.text}` : params.text as string) : old!.text;
|
|
150
160
|
const bytes = Buffer.byteLength(next, "utf8");
|
|
151
161
|
if (hasText && bytes > MAX_NOTE_BYTES) return output({ error: `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes`, path, size_bytes: bytes });
|
package/src/notes.ts
CHANGED
|
@@ -20,11 +20,6 @@ export function assertVirtualPath(value: unknown): string {
|
|
|
20
20
|
return value;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function assertVirtualPrefix(value: unknown): string | undefined {
|
|
24
|
-
if (value === undefined || value === null || value === "") return undefined;
|
|
25
|
-
return assertVirtualPath(value);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
23
|
/**
|
|
29
24
|
* Minimal glob over virtual note paths: `*` matches any run within a segment (never
|
|
30
25
|
* `/`), `**` matches any run across segments (a leading double-star followed by a
|
|
@@ -112,4 +107,3 @@ export function localIso(epochMs: number): string {
|
|
|
112
107
|
const wallClock = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`;
|
|
113
108
|
return `${wallClock}${offset}`;
|
|
114
109
|
}
|
|
115
|
-
|
package/src/tool-schema.ts
CHANGED
|
@@ -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
|
|