@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.16.0",
3
+ "version": "0.18.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
@@ -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, assertVirtualPrefix, assertGlobPattern, globToRegExp, localIso, type NoteOperation } from "./notes.js";
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(), path_prefix: nullableString() }, { additionalProperties: false }),
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 prefix = assertVirtualPrefix(params.path_prefix);
75
- let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
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
-
@@ -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