@astrosheep/pi-context 0.16.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "Codex-style context windows for Pi: reset-style compaction, durable session history tools, and persistent notes.",
6
6
  "license": "MIT",
@@ -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") return { toolName: "bash" };
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
@@ -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 });
@@ -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 or a non-string element is refused rather than silently searching
16
- * for nothing: an empty array is an argument error, not an empty result set.
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
- if (typeof query === "string") return [query];
20
- if (!Array.isArray(query) || query.length === 0) throw new Error("query must be a string or a non-empty array of strings");
21
- if (!query.every((candidate) => typeof candidate === "string")) throw new Error("query array elements must be strings");
22
- return query as string[];
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