@astrosheep/pi-context 0.12.0 → 0.13.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/README.md CHANGED
@@ -67,9 +67,9 @@ The nine Codex History/Notes actions are flattened because Pi tools have one glo
67
67
  | `notes.append_to_file` | `notes_append_to_file` |
68
68
  | `notes.write_file` | `notes_write_file` |
69
69
 
70
- `history_*` reads the current branch's actual Pi session entries, including entries hidden by earlier compaction. Window IDs are extension-owned: the root window is `pcw:<session-id>:root`, and each reset mints an 8-hex id baked into the compaction entry's `details.windowId` as `pcw:<session-id>:<minted>`. Pi-native compactions (extension toggled off) fall back to `pcw:<session-id>:<compaction-entry-id>`. Item IDs are the persisted Pi entry IDs. No transcript copy or volatile archive is maintained. Ordering is newest-first by default: `recent_first` omitted or `true` returns newest-first, and only an explicit `false` returns oldest-first; `history_list_windows`, `history_list_items`, and `history_search_contents` share that switch.
70
+ `history_*` reads the current branch's actual Pi session entries, including entries hidden by earlier compaction. Window IDs are extension-owned: the root window is `pcw:<session8>:root`, and each reset mints an 8-hex id baked into the compaction entry's `details.windowId` as `pcw:<session8>:<minted>`. Pi-native compactions (extension toggled off) fall back to `pcw:<session8>:<compaction-entry-id>`. Here `<session8>` is the first 8 characters of the session id; baked window ids stay opaque on read, so reset windows minted under the older full-session form remain resolvable. Item IDs are the persisted Pi entry IDs. No transcript copy or volatile archive is maintained. Ordering is newest-first by default: `recent_first` omitted or `true` returns newest-first, and only an explicit `false` returns oldest-first; `history_list_windows`, `history_list_items`, and `history_search_contents` share that switch.
71
71
 
72
- `history_*` and paged `notes_*` read/search results share a **32 KiB (32 * 1024 UTF-8 bytes) per-result budget**. List/search tools return whole items only and include `next_offset` (an integer cursor, or `null` when exhausted); pass that cursor back with the same parameters to continue. `history_read_item` defaults to `limit_chars: 12000`, accepts at most 50000, and returns `total_chars` plus `next_offset_chars` when more content remains. `notes_read_file` returns whole lines only, includes `total_lines`, and uses `next_start_line` for a bounded continuation. When one indivisible unit is larger than the whole budget (a single note line, search match, or history item), it is returned middle-truncated — head and tail joined by a `…[truncated N chars]…` marker, mirroring Codex's `truncate_middle` — instead of being dropped, so a page is never empty and its cursor always advances. `max_chars_per_item` limits Unicode code points including the truncation ellipsis (`…`). The 32 KiB choice matches Aider's default and Claude Code's roughly 30k-character limit while keeping worst-case CJK output (about 11k tokens) below Pi's default `reserveTokens` of 16384.
72
+ `history_*` and paged `notes_*` read/search results share a **32 KiB (32 * 1024 UTF-8 bytes) per-result budget**. List/search tools return whole items only and include `next_cursor` (an integer cursor, `null` only when the set is exhausted); pass it back unchanged as the `cursor` parameter, with the same filters and ordering, to continue. Page caps (`limit`, `max_results`, `max_files`) bound a single page, never the enumerable set; with no cap, a page holds as many items as the budget fits. A live window keeps growing while it is paged, so enumerate a closed window when a stable, complete set is needed. `history_read_item` defaults to `limit_chars: 12000`, accepts at most 50000, and returns `total_chars` plus `next_offset_chars` when more content remains. `notes_read_file` returns whole lines only, includes `total_lines`, and uses `next_start_line` for a bounded continuation. When one indivisible unit is larger than the whole budget (a single note line, search match, or history item), it is returned middle-truncated — head and tail joined by a `…[truncated N chars]…` marker, mirroring Codex's `truncate_middle` — instead of being dropped, so a page is never empty and its cursor always advances. `max_chars_per_item` limits Unicode code points including the truncation ellipsis (`…`). The 32 KiB choice matches Aider's default and Claude Code's roughly 30k-character limit while keeping worst-case CJK output (about 11k tokens) below Pi's default `reserveTokens` of 16384.
73
73
 
74
74
  `notes_*` stores operation entries in the same append-only Pi session under `pi-context/note`. They are session-scoped, survive JSONL reload, never enter provider context, and use safe relative virtual paths only (no absolute paths, `..`, `.`, empty components, or backslashes). Searches are literal and case-sensitive. `notes_read_file` accepts inclusive 1-based line ranges; negative line numbers count from the last line. Staleness is explicit and never inferred from timestamps. `notes_write_file` and `notes_append_to_file` accept an optional `mark_stale` boolean, and `notes_list_files_by_prefix` reports each file's `stale` flag. Stale notes leave the boot notes index — which omits itself once no fresh note remains — but stay listed, readable, and searchable. Each call must carry `text`, `mark_stale`, or both: `mark_stale: true` alone flags a note without touching its content, `mark_stale: false` alone clears the flag without touching content, and either call may carry `text` too — so final content can be written and marked stale in one call, and a closing log line can be appended and marked stale in one call. Carrying `text` without `mark_stale` revives a stale note, and marking a path that does not exist is an error. `notes_read_file` success results and every matched file object from `notes_search_contents` also carry `created_at` and `updated_at`, the same fields `notes_list_files_by_prefix` returns. All note timestamps are local-time ISO 8601 strings with an explicit UTC offset (for example `2026-09-15T17:31:45.392+08:00`; a UTC host renders `+00:00`, never `Z`), while the persisted `NoteFile`/`NoteOperation` metadata keeps plain epoch milliseconds. Error results carry no timestamps. Writes are capped at 1,000,000 UTF-8 bytes. `notes_write_file` and `notes_append_to_file` declare `executionMode: "sequential"` (the Pi equivalent of Codex's `supports_parallel_tool_calls = false`), so a tool batch containing either runs its calls one at a time and note read-modify-write cannot race with itself.
75
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",
@@ -1,7 +1,7 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { output, page, middleTruncate, withinBudget } from "./tool-output.js";
4
- import { positiveInteger, recentFirst, nullableString, role } from "./tool-schema.js";
4
+ import { positiveInteger, recentFirst, nullableString, role, cursor } from "./tool-schema.js";
5
5
  import { historyFromSession, filteredItems, visibleItem, allItems } from "./history.js";
6
6
 
7
7
  export function registerHistoryTools(pi: ExtensionAPI) {
@@ -22,10 +22,10 @@ export function registerHistoryTools(pi: ExtensionAPI) {
22
22
  name: "history_list_items",
23
23
  label: "History list items",
24
24
  description: "List durable session items, including items before compaction, using opaque item and window IDs.",
25
- parameters: Type.Object({ limit: positiveInteger(), offset: Type.Optional(Type.Integer({ minimum: 0 })), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
25
+ parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
26
26
  async execute(_id, params, _signal, _update, ctx) {
27
- const items = filteredItems(ctx, params).slice(0, params.limit ?? Number.POSITIVE_INFINITY).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200));
28
- return output(page(items, params.offset ?? 0, "items", undefined, (item, fits) => ({ ...item, truncated_content: middleTruncate(item.truncated_content, (candidate) => fits({ ...item, truncated_content: candidate })) })));
27
+ const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200));
28
+ return output(page(items, params.cursor ?? 0, "items", params.limit, (item, fits) => ({ ...item, truncated_content: middleTruncate(item.truncated_content, (candidate) => fits({ ...item, truncated_content: candidate })) })));
29
29
  },
30
30
  }));
31
31
 
@@ -52,10 +52,10 @@ export function registerHistoryTools(pi: ExtensionAPI) {
52
52
  name: "history_search_contents",
53
53
  label: "History search",
54
54
  description: "Case-sensitive literal substring search over durable Pi session history; no semantic search.",
55
- parameters: Type.Object({ limit: positiveInteger(), offset: Type.Optional(Type.Integer({ minimum: 0 })), query: Type.String(), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
55
+ parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), query: Type.String(), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
56
56
  async execute(_id, params, _signal, _update, ctx) {
57
- const matching = filteredItems(ctx, params).filter((item) => item.content.includes(params.query)).slice(0, params.limit ?? Number.POSITIVE_INFINITY).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200));
58
- return output(page(matching, params.offset ?? 0, "items", undefined, (item, fits) => ({ ...item, truncated_content: middleTruncate(item.truncated_content, (candidate) => fits({ ...item, truncated_content: candidate })) })));
57
+ const matching = filteredItems(ctx, params).filter((item) => item.content.includes(params.query)).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200));
58
+ return output(page(matching, params.cursor ?? 0, "items", params.limit, (item, fits) => ({ ...item, truncated_content: middleTruncate(item.truncated_content, (candidate) => fits({ ...item, truncated_content: candidate })) })));
59
59
  },
60
60
  }));
61
61
 
package/src/history.ts CHANGED
@@ -68,13 +68,13 @@ export function resetV2WindowId(details: unknown): string | undefined {
68
68
 
69
69
  /** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
70
70
  function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
71
- return resetV2WindowId(entry.details) ?? `pcw:${sessionId}:${entry.id}`;
71
+ return resetV2WindowId(entry.details) ?? `pcw:${sessionId.slice(0, 8)}:${entry.id}`;
72
72
  }
73
73
 
74
74
  /** Build durable, on-demand history directly from every entry on the current session branch. */
75
75
  export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
76
76
  const sessionId = ctx.sessionManager.getSessionId();
77
- let window: HistoryWindow = { windowId: `pcw:${sessionId}:root`, items: [] };
77
+ let window: HistoryWindow = { windowId: `pcw:${sessionId.slice(0, 8)}:root`, items: [] };
78
78
  const windows = [window];
79
79
  for (const entry of ctx.sessionManager.getBranch()) {
80
80
  if (entry.type === "compaction") {
@@ -161,6 +161,6 @@ export function currentWindowId(ctx: SessionReader): string {
161
161
  const entry = branch[i];
162
162
  if (entry?.type === "compaction") return windowIdOf(sessionId, entry);
163
163
  }
164
- return `pcw:${sessionId}:root`;
164
+ return `pcw:${sessionId.slice(0, 8)}:root`;
165
165
  }
166
166
 
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ export default function piContext(pi: ExtensionAPI) {
24
24
  // it once as a hidden custom message. Reset windows already carry theirs at
25
25
  // position 0 in the compaction summary, so a resumed session adds nothing.
26
26
  const sessionId = ctx.sessionManager.getSessionId();
27
- const rootId = `pcw:${sessionId}:root`;
27
+ const rootId = `pcw:${sessionId.slice(0, 8)}:root`;
28
28
  if (currentWindowId(ctx) !== rootId || hasWindowMessage(ctx, BOOT_TYPE)) return;
29
29
  pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: false }, { triggerTurn: false });
30
30
  });
@@ -53,7 +53,7 @@ export default function piContext(pi: ExtensionAPI) {
53
53
  pi.registerTool(defineTool({
54
54
  name: "new_context",
55
55
  label: "New context",
56
- description: "Request a reset-style context rollover after this tool result is safely recorded. Call alone in a tool batch.",
56
+ description: "Clear your mind and start a new context window. Your session, notes, and history survive.",
57
57
  parameters: Type.Object({}, { additionalProperties: false }),
58
58
  async execute() {
59
59
  if (!enabled) return output({ error: "pi-context is off (/pi-context on to enable)" });
@@ -71,15 +71,15 @@ export default function piContext(pi: ExtensionAPI) {
71
71
  },
72
72
  onReset: (entryId) => pi.appendEntry(STATE_TYPE, { version: 1, lastResetEntryId: entryId }),
73
73
  buildReset: (event, ctx, explicit) => {
74
- const sessionId = ctx.sessionManager.getSessionId();
74
+ const session8 = ctx.sessionManager.getSessionId().slice(0, 8);
75
75
  // Window IDs are independent of Pi entry IDs. Avoid reusing a window
76
76
  // identity already present on this branch.
77
77
  const windows = historyFromSession(ctx);
78
78
  const usedIds = new Set(windows.map((window) => window.windowId));
79
79
  let minted = randomUUID().slice(0, 8);
80
- while (usedIds.has(`pcw:${sessionId}:${minted}`)) minted = randomUUID().slice(0, 8);
81
- const windowId = `pcw:${sessionId}:${minted}`;
82
- const previousId = windows[windows.length - 1]?.windowId ?? `pcw:${sessionId}:root`;
80
+ while (usedIds.has(`pcw:${session8}:${minted}`)) minted = randomUUID().slice(0, 8);
81
+ const windowId = `pcw:${session8}:${minted}`;
82
+ const previousId = windows[windows.length - 1]?.windowId ?? `pcw:${session8}:root`;
83
83
  // The reset marker stays as firstKeptEntryId; it no longer names the window.
84
84
  pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: explicit });
85
85
  const markerId = ctx.sessionManager.getLeafId();
package/src/note-tools.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { output, page, middleTruncate, withinBudget } from "./tool-output.js";
4
- import { nullableString, nullableInteger, positiveInteger } from "./tool-schema.js";
4
+ import { nullableString, nullableInteger, positiveInteger, cursor } from "./tool-schema.js";
5
5
  import { notesFromSession, assertVirtualPath, assertVirtualPrefix, lineRange, localIso, type NoteOperation } from "./notes.js";
6
6
  import { NOTE_TYPE, MAX_NOTE_BYTES } from "./protocol.js";
7
7
 
@@ -16,7 +16,7 @@ export function registerNoteTools(pi: ExtensionAPI) {
16
16
  name: "notes_list_files_by_prefix",
17
17
  label: "Notes list files",
18
18
  description: "List persistent, session-scoped virtual note files. created_at and updated_at are local-time ISO 8601 strings with an explicit UTC offset.",
19
- parameters: Type.Object({ prefix: nullableString(), max_results: positiveInteger(), offset: Type.Optional(Type.Integer({ minimum: 0 })), file_order_by: Type.Optional(Type.Union([Type.Literal("name"), Type.Literal("created_at"), Type.Literal("updated_at")])), file_order: Type.Optional(Type.Union([Type.Literal("ascending"), Type.Literal("descending")])) }, { additionalProperties: false }),
19
+ parameters: Type.Object({ prefix: nullableString(), max_results: positiveInteger(), cursor: cursor(), file_order_by: Type.Optional(Type.Union([Type.Literal("name"), Type.Literal("created_at"), Type.Literal("updated_at")])), file_order: Type.Optional(Type.Union([Type.Literal("ascending"), Type.Literal("descending")])) }, { additionalProperties: false }),
20
20
  async execute(_id, params, _signal, _update, ctx) {
21
21
  const prefix = assertVirtualPrefix(params.prefix);
22
22
  let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
@@ -24,7 +24,7 @@ export function registerNoteTools(pi: ExtensionAPI) {
24
24
  files.sort(([aPath, a], [bPath, b]) => key === "name" ? aPath.localeCompare(bPath) : (key === "created_at" ? a.createdAt - b.createdAt : a.updatedAt - b.updatedAt));
25
25
  if (params.file_order === "descending") files.reverse();
26
26
  const listed = files.map(([path, file]) => ({ path, size_bytes: Buffer.byteLength(file.text, "utf8"), stale: file.stale, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) }));
27
- return output(page(listed, params.offset ?? 0, "files", params.max_results, (file, fits) => ({ ...file, path: middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate })) })));
27
+ return output(page(listed, params.cursor ?? 0, "files", params.max_results, (file, fits) => ({ ...file, path: middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate })) })));
28
28
  },
29
29
  }));
30
30
 
@@ -56,7 +56,7 @@ export function registerNoteTools(pi: ExtensionAPI) {
56
56
  name: "notes_search_contents",
57
57
  label: "Notes search",
58
58
  description: "Case-sensitive literal substring search over virtual note lines; no semantic search. Each matched file carries created_at and updated_at as local-time ISO 8601 strings with an explicit UTC offset.",
59
- parameters: Type.Object({ max_matches_per_file: positiveInteger(), offset: Type.Optional(Type.Integer({ minimum: 0 })), query: Type.String(), recent_file_first: Type.Optional(Type.Boolean()), max_files: positiveInteger(), path_prefix: nullableString() }, { additionalProperties: false }),
59
+ parameters: Type.Object({ max_matches_per_file: positiveInteger(), cursor: cursor(), query: Type.String(), recent_file_first: Type.Optional(Type.Boolean()), max_files: positiveInteger(), path_prefix: nullableString() }, { additionalProperties: false }),
60
60
  async execute(_id, params, _signal, _update, ctx) {
61
61
  const prefix = assertVirtualPrefix(params.path_prefix);
62
62
  let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
@@ -73,7 +73,7 @@ export function registerNoteTools(pi: ExtensionAPI) {
73
73
  const text = middleTruncate(first.text, (candidate) => fits({ ...file, matches: [{ ...first, text: candidate }, ...matches.slice(1)] }));
74
74
  return { ...file, matches: [{ ...first, text }, ...matches.slice(1)] };
75
75
  };
76
- return output(page(result.slice(0, params.max_files ?? result.length), params.offset ?? 0, "files", undefined, fitFile));
76
+ return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
77
77
  },
78
78
  }));
79
79
 
@@ -48,17 +48,17 @@ export type ItemTruncator<T> = (item: T, fits: (candidate: T) => boolean) => T;
48
48
  * Build a page without ever adding an item that would exceed the wire budget.
49
49
  *
50
50
  * A single item that cannot fit is middle-truncated through the optional `truncate`
51
- * callback and still included, with `next_offset` advanced past it. Without that fallback
51
+ * callback and still included, with `next_cursor` advanced past it. Without that fallback
52
52
  * an oversized item would yield an empty page forever: the cursor would keep pointing back
53
53
  * at the same index.
54
54
  */
55
- export function page<T>(items: T[], offset: number, key: string, limit?: number, truncate?: ItemTruncator<T>) {
56
- const end = Math.min(items.length, offset + (limit ?? items.length));
55
+ export function page<T>(items: T[], cursor: number, key: string, limit?: number, truncate?: ItemTruncator<T>) {
56
+ const end = Math.min(items.length, cursor + (limit ?? items.length));
57
57
  const selected: T[] = [];
58
58
  let next = end < items.length ? end : null;
59
- for (let index = offset; index < end; index++) {
59
+ for (let index = cursor; index < end; index++) {
60
60
  const candidateNext = index + 1 < end || end < items.length ? index + 1 : null;
61
- const fits = (list: T[]) => withinBudget({ [key]: list, next_offset: candidateNext });
61
+ const fits = (list: T[]) => withinBudget({ [key]: list, next_cursor: candidateNext });
62
62
  if (!fits([...selected, items[index]])) {
63
63
  if (selected.length === 0 && truncate) {
64
64
  selected.push(truncate(items[index], (candidate) => fits([candidate])));
@@ -70,7 +70,7 @@ export function page<T>(items: T[], offset: number, key: string, limit?: number,
70
70
  }
71
71
  selected.push(items[index]);
72
72
  }
73
- return { [key]: selected, next_offset: next };
73
+ return { [key]: selected, next_cursor: next };
74
74
  }
75
75
 
76
76
  /** Encode a result through the common tool result boundary. */
@@ -2,6 +2,7 @@ import { Type } from "@earendil-works/pi-ai";
2
2
  export const nullableString = () => Type.Optional(Type.Union([Type.String(), Type.Null()]));
3
3
  export const nullableInteger = () => Type.Optional(Type.Union([Type.Integer(), Type.Null()]));
4
4
  export const positiveInteger = () => Type.Optional(Type.Integer({ minimum: 1 }));
5
+ 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
6
  export const recentFirst = () => Type.Optional(Type.Boolean({ description: "Return newest-first. Only an explicit false returns oldest-first. Defaults to true." }));
6
7
  export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()]);
7
8