@astrosheep/pi-context 0.12.0 → 0.14.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
@@ -14,46 +14,13 @@ Or load it for a single invocation without installing:
14
14
  pi -e npm:@astrosheep/pi-context
15
15
  ```
16
16
 
17
- ## How it works
17
+ ## What you get
18
18
 
19
- The extension composes Pi's public `session_before_compact` / `session_compact` hooks, custom session entries, and the `context` hook to approximate Codex's experimental context management:
20
-
21
- - **`new_context` tool** — the model requests a fresh context window. The extension waits for the current run to settle, compacts with a short deterministic reset message (old conversation is excluded from the new provider context but stays in the session), then sends one hidden continuation turn once compaction has fully completed, unless another prompt is already queued or running.
22
- - **`<context_window>` boot block** — the head of every fresh window. For a reset it IS the summary returned from `session_before_compact` (position 0, persisted, no extra message); for the root window `session_start` persists it once as a hidden custom message (`display: false`, kept out of the TUI). It carries the agent name and first/current/previous window IDs, the recent-notes index, and a `<context_window_protocol>` teaching block. The notes index lists up to three most-recent notes, each with its `X lines, Y UTF-8 bytes` metadata plus a local-time ISO 8601 `updated` timestamp (explicit UTC offset, never `Z`) and an inline preview. A note of 320 Unicode characters or fewer is shown whole; a longer one shows its first 80 and last 240 Unicode characters joined by an ellipsis, so the two ends never overlap and no text is repeated. The notes index is a window-open snapshot with the same frozen-at-write semantics as the reminder count. Nothing is injected transiently per request: the boot block is static once-per-window content, so the head of the window stays cache-stable. Codex diverges here — its `<context_window>` block carries only the agent path and window IDs, while the notes index is our own addition.
23
- - **Low-budget guidance** — when estimated remaining context first drops to the reminder threshold (by default **40,960 tokens**: Pi's default 16,384 `reserveTokens` plus a 24,576 reminder margin; see [Reminder timing](#reminder-timing)), a `<context_window_guidance>` reminder is **persisted once per window** into history (hidden from the TUI transcript — one ephemeral `ui.notify` warning tells the user instead, and the model never sees it; no extra turn; `sendMessage` safely defers mid-stream). There is deliberately no transient copy: a bridge would make the model meet the same text twice at shifted positions, because history records the persisted copy after the crossing request's assistant reply. The reminder is an early warning, so arriving from the next request on costs nothing and keeps the model's view identical to recorded history. The measured remaining budget excludes `reserveTokens` and is frozen into the text at the threshold crossing, so the persisted reminder is a snapshot true at write time; `get_context_remaining` remains the live source for the current figure. The text is appended rather than prepended; existing history is not rewritten.
24
- - **Two-phase automatic fallback** — while Pi is streaming, the first automatic `threshold`/`overflow` crossing of the reserve line does not reset immediately. `session_before_compact` queues the final note-taking instruction with `pi.sendMessage(..., { triggerTurn: true })` — which Pi routes to `agent.steer()` while streaming, so the message is queued synchronously and reaches the model before any pending user input — and returns `{ cancel: true }`. Pi records that as an aborted compaction, spends no summary, and continues the same run with the borrowed turn; no user text or images are copied, intercepted, or replayed, and no `input` handler is registered. Once the borrowed run has finished, `agent_end` arms the real reset and `agent_settled` requests it through `ctx.compact()` for both `threshold` and `overflow`, unless another compaction has already completed. Pi can perform another automatic check after a run, and overflow recovery has a one-shot guard. The settled scheduler ensures the borrowed turn has one reset owner even when no native check resets it. Requesting the reset after the run settles avoids issuing it from `agent_end` while Pi is still finishing the run. A phase flag makes the cancel happen at most once per window — it re-arms only after a completed reset starts a fresh window. A failed reset clears the pending request, warns the user, and leaves history intact; another prompt can retry without borrowing another turn. Extension-requested fallback resets send a continuation after the compaction completion callback. The borrow is skipped when the crossing arrives while Pi is idle (the pre-prompt check in `AgentSession.prompt()`, where `triggerTurn` would start a nested run and make the pending `Agent.prompt()` reject); that crossing resets directly. `manual` `/compact` and `new_context` never take the borrowed-turn path. The reminder asks the model to write notes early; if it misses that opportunity, old history remains searchable.
25
- - **Runtime toggle** — `/pi-context off` disables the boot block, guidance, and reset-style compaction (Pi's default compaction, including `keepRecentTokens`, applies again). `/pi-context on` re-enables; a bare `/pi-context` reports the current state.
26
- - **History tools** — the model searches pre-reset conversation with case-sensitive literal substring search, exactly like Codex's `history.*` namespace.
27
- - **Notes tools** — persistent, session-scoped virtual files that survive window resets.
28
-
29
- ## Reminder timing
30
-
31
- The reminder threshold derives from Pi's compaction reserve plus a margin configured in `settings.json` under the top-level `pi-context` key:
32
-
33
- ```json
34
- {
35
- "compaction": { "reserveTokens": 16384 },
36
- "pi-context": {
37
- "reminderMarginTokens": 24576
38
- }
39
- }
40
- ```
41
-
42
- The margin is measured in **remaining context tokens** added on top of Pi's `compaction.reserveTokens`:
43
-
44
- - `reminder = reserveTokens + reminderMarginTokens` (default margin `24576`)
45
-
46
- Put the key in the global settings (`~/.pi/agent/settings.json`) or the project settings (`<cwd>/.pi/settings.json`); project values win per key, mirroring Pi's own settings merge. With Pi's default `reserveTokens: 16384` the default gives reminder `40960`, leaving 24,576 tokens of working room above Pi's reset line no matter how you set `reserveTokens`. The reminder is an early warning; once fallback guidance has been persisted in the current window, no later early reminder is appended. This also applies after session reload. The borrowed fallback turn needs no threshold of its own: it is driven by Pi's automatic `threshold`/`overflow` compaction request, so `before_agent_start`/`turn_end` send nothing and no `fallbackMarginTokens` setting exists.
47
-
48
- Pi's `reserveTokens` and the `pi-context` reminder margin are re-read from disk at every `session_start` and cached for that session. An invalid margin — not a positive integer — is ignored with one TUI warning naming the key and the default used instead; session handling never throws.
49
-
50
- This is a file-backed read through Pi's public `SettingsManager`. It sees committed `settings.json` only: SDK-level ephemeral `applyOverrides()` calls and the unreleased `compaction.modelOverrides` are not seen by this extension.
51
-
52
- The extension does not change Pi settings or reserve additional context. Large tool outputs or user inputs can jump over the reminder. For small context windows, tune the reminder margin (or Pi's reserve) to fit the model.
53
-
54
- ## Tools
55
-
56
- The nine Codex History/Notes actions are flattened because Pi tools have one global name space:
19
+ - **`new_context`** the model can start a fresh context window. The old conversation leaves the provider context but stays in the session, so nothing is lost. Call it on its own, not inside a parallel tool batch.
20
+ - **A boot block at every window head** — static once-per-window content (cache-stable) carrying the window identity, the recent-notes index, and a short protocol that teaches the model how to recover: notes for its own bookkeeping, history tools for everything before the reset.
21
+ - **Low-budget guidance** — one persisted early warning per window when the estimated remaining budget crosses the reminder line, so the model checkpoints before the lights go out.
22
+ - **`get_context_remaining`** — the live, reserve-adjusted estimate of the context budget left before Pi's compaction reserve.
23
+ - **Nine history/notes tools** — Codex's History/Notes actions flattened into Pi's single tool namespace:
57
24
 
58
25
  | Codex action | Pi tool |
59
26
  | --- | --- |
@@ -61,42 +28,42 @@ The nine Codex History/Notes actions are flattened because Pi tools have one glo
61
28
  | `history.list_items` | `history_list_items` |
62
29
  | `history.read_item` | `history_read_item` |
63
30
  | `history.search_contents` | `history_search_contents` |
64
- | `notes.list_files_by_prefix` | `notes_list_files_by_prefix` |
31
+ | `notes.list_files` | `notes_list_files` |
65
32
  | `notes.read_file` | `notes_read_file` |
66
33
  | `notes.search_contents` | `notes_search_contents` |
67
34
  | `notes.append_to_file` | `notes_append_to_file` |
68
35
  | `notes.write_file` | `notes_write_file` |
69
36
 
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.
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.
73
-
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
-
76
- Unlike Codex, the history tools do not advertise `agent_name`: Pi has no cross-agent session routing, so the parameter is omitted from the schemas entirely (strict `additionalProperties: false` still rejects it) instead of costing schema tokens on every request.
77
-
78
- Two extra controls compose Pi public APIs:
37
+ The tool descriptions the model sees are the behavioral documentation: search is case-sensitive literal substring; reads are character windows whose cursors reconstruct the original exactly; anything a response does not deliver is named by an explicit field.
79
38
 
80
- - `get_context_remaining` returns `{ "remaining_tokens": number | null }`, computed as `max(0, contextWindow - usedTokens - reserveTokens)`: the estimated budget available before Pi's compaction reserve. `null` means Pi itself cannot make a reliable estimate (notably immediately after compaction). The low-budget guidance reports this same reserve-adjusted budget and stays silent when the estimate is unknown. Its trigger still compares physical remaining capacity against `reserveTokens + reminderMarginTokens`, so subtracting reserve from the reported number does not change reminder timing.
81
- - `new_context` returns terminal tool output, then waits for Pi's `agent_settled`, triggers public `ctx.compact()`, installs a short deterministic reset compaction, and sends exactly one hidden continuation turn after compaction succeeds. Call it by itself in a tool batch. Pi only ends a tool turn when every parallel tool result is terminal, so Pi 0.85.1 cannot force an atomic rollover from the middle of a mixed parallel tool batch.
39
+ - **Runtime toggle** `/pi-context off` restores Pi's default compaction (including `keepRecentTokens`); `/pi-context on` re-enables; bare `/pi-context` reports the current state.
82
40
 
83
- ## Reset behavior and limits
41
+ ## Configuration
84
42
 
85
- On `session_before_compact`, the extension appends a persistent custom reset marker through public `pi.appendEntry`, reads that real marker ID from the readonly session manager, and returns it as `firstKeptEntryId`. Pi's `buildContextEntries()` then keeps the compaction envelope plus that custom marker; custom markers are excluded from LLM context. Thus the subsequent provider context contains the boot block summary and the marker, not old conversation messages. The boot summary also carries the extension-minted window id in its `details.windowId`, so the fresh window names itself with an id Pi could not have supplied at bake time. Both explicit `new_context` and the reset requested after a borrowed fallback run add a hidden continuation from `ctx.compact`'s `onComplete` callback. Native automatic resets and user `/compact` keep Pi's native scheduling. Explicit and fallback reset requests share the `agent_settled` scheduler: if the model calls `new_context` in a borrowed fallback run, the explicit request consumes that fallback allowance, producing one reset and at most one continuation. Continuation is suppressed if another prompt is queued or already running. The old entries remain only in the session tree for `history_*`.
43
+ The reminder threshold is Pi's compaction reserve plus a margin, configured under the top-level `pi-context` key in `~/.pi/agent/settings.json` or `<cwd>/.pi/settings.json` (project values win per key):
86
44
 
87
- The scheduler lives in [`src/reset-lifecycle.ts`](src/reset-lifecycle.ts); `src/index.ts` composes the features and builds reset boundaries. History and notes projections, tool adapters, budget policy, and prompt rendering have separate ownership described in [Architecture](docs/architecture.md). See the [lifecycle event table](docs/reset-lifecycle.md) for ownership and cancellation rules. Shutdown, tree navigation, and toggling off invalidate outstanding callbacks. An aborted run clears pending rollover work. Queued steering/follow-up messages may continue before the run settles; the extension does not clear or replay that queue.
45
+ ```json
46
+ {
47
+ "compaction": { "reserveTokens": 16384 },
48
+ "pi-context": { "reminderMarginTokens": 24576 }
49
+ }
50
+ ```
88
51
 
89
- Pi's built-in “compacted into the following summary” envelope is left intact. Its content says, in the extension's wake-up voice: “You wake up. Your head is empty — no memories, the past a blank. But nothing is lost: the notes you wrote and the recorded history still remember for you.” No context filtering or TUI override is used to hide that envelope.
52
+ `reminder = reserveTokens + reminderMarginTokens`; with the defaults the early warning fires 24,576 tokens above Pi's reset line.
90
53
 
91
- The same handler is used for native automatic compaction. When Pi marks an overflow compaction `willRetry`, Pi core performs its single retry itself and this extension deliberately sends no second continuation. While the extension is enabled, its custom reset keeps nothing after the boundary marker, so Pi's `keepRecentTokens` setting has no effect; with `/pi-context off`, Pi's default compaction (and `keepRecentTokens`) applies again.
54
+ ## Documentation
92
55
 
93
- This is a composition of public `session_before_compact`, `session_compact`, `pi.appendEntry`, `ctx.compact`, and `pi.sendMessage`; it is not a Pi-core `newSession` call. A manual Pi compaction is only eligible when Pi's own `prepareCompaction()` accepts the session. Therefore a `new_context` request in a too-small/uncompactable session fails cleanly without default-summary fallback or continuation. A core change would be needed only to guarantee a force-reset at arbitrary small context sizes or to atomically interrupt a mixed parallel tool batch.
56
+ Implementation architecture and the reset lifecycle live in [docs/](docs/).
94
57
 
95
- ## Verification
58
+ ## Development
96
59
 
97
60
  ```sh
61
+ npm test # build from a clean dist, then run the suite
98
62
  npm run typecheck
99
- npm test
100
63
  ```
101
64
 
102
- The integration harness uses the installed Pi `SessionManager` and `SettingsManager` (global and project settings fixtures in temp directories, so the real `~/.pi` is never touched), including an on-disk JSONL reload. It verifies note persistence/Unicode/path rules, the boot block contents and extension-minted window ids on reset and root paths, that the `context` hook never injects, provider context exclusion after the real `firstKeptEntryId` boundary while history remains searchable, completed tool-result boundary placement, settings-derived reminder threshold resolution and margin validation, that the removed `before_agent_start`/`turn_end` fallback is gone, one early reminder per window, one continuation only, the two-phase automatic ordering (one cancel, one steer, one real reset, re-armed per window, `ctx.compact()` for both threshold and overflow, idle crossings and manual/`new_context` resets bypassing the borrow), and cancellation/failure/no-double-retry behavior. Additional lifecycle tests cover duplicate callbacks, stale callback identity, shutdown/tree/toggle changes, user abort, synchronous errors, native retry ownership, and queued prompts. SDK tests run the real Pi agent loop, extension hooks, manual compaction, and continuation with scripted provider responses: explicit and fallback resets each produce one boundary, exclude old provider context, and preserve durable history. A real uncompactable-session case verifies no automatic retry and recovery on the next user prompt. Further SDK cases verify fallback note-writing while usage remains above the reserve line, with no obsolete early reminder before reset; steering/follow-up delivery before reset without replay; consecutive distinct windows; and user cancellation without automatic continuation. Persisted-data tests verify boot/reminder deduplication after JSONL reload and branch navigation, and reject malformed note timestamps without overwriting valid notes. These tests use temporary settings and fake credentials and make no model or network call.
65
+ The harness runs against the real installed Pi `SessionManager`/`SettingsManager` in temporary directories with fake credentials no model or network calls, and the real `~/.pi` is never touched.
66
+
67
+ ## License
68
+
69
+ MIT
@@ -8,7 +8,7 @@ pi-context uses Pi's session branch as the durable source of truth. It does not
8
8
  | --- | --- | --- |
9
9
  | `index.ts` | Compose features, expose toggle/reset tool, construct reset boundary | Pi extension API |
10
10
  | `history.ts` | Project branch entries into windows/items; identify active window | `SessionReader`, read-only branch and session ID |
11
- | `notes.ts` | Replay note operations, validate paths/timestamps, slice lines | `SessionReader`; no scheduling or writes |
11
+ | `notes.ts` | Replay note operations, validate paths/timestamps | `SessionReader`; no scheduling or writes |
12
12
  | `history-tools.ts`, `note-tools.ts` | Public schemas and tool results; append validated note operations | Pi tool API plus read projections |
13
13
  | `budget.ts` | Resolve settings, report usable budget, persist guidance once | Pi settings/context hooks |
14
14
  | `prompts.ts` | Render static boot block, note index and reminder | Read projections and protocol text |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.12.0",
3
+ "version": "0.14.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",
@@ -25,7 +25,7 @@
25
25
  "README.md"
26
26
  ],
27
27
  "scripts": {
28
- "build": "tsc -p tsconfig.json",
28
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
29
29
  "typecheck": "tsc -p tsconfig.json --noEmit",
30
30
  "test": "npm run build && node --test dist/test/*.test.js",
31
31
  "prepublishOnly": "npm run typecheck"
@@ -1,9 +1,34 @@
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, withinBudget } from "./tool-output.js";
4
- import { positiveInteger, recentFirst, nullableString, role } from "./tool-schema.js";
3
+ import { output, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow } from "./tool-output.js";
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
 
7
+ /**
8
+ * Shrink one page item to fit the wire budget. `truncated`/`total_chars` stay honest: the
9
+ * payload is only ever cut to a plain prefix of itself, never filled with a marker, and the
10
+ * flag flips on whenever a shrink actually removed characters. `tool_name` is metadata, not a
11
+ * payload, and keeps its visible middle-truncation marker. `item_id`, `window_id`, `role`, and
12
+ * `tool_namespace` are identity or tiny metadata and are never touched.
13
+ */
14
+ function truncateHistoryItem<T extends { truncated_content: string; truncated: boolean; tool_name: string | null }>(item: T, fits: (candidate: T) => boolean): T {
15
+ if (fits(item)) return item;
16
+ const shrinkContent = (base: T): T => ({
17
+ ...base,
18
+ truncated: true,
19
+ truncated_content: prefixFit(base.truncated_content, (candidate) => fits({ ...base, truncated: true, truncated_content: candidate } as T)),
20
+ });
21
+ const withContent = shrinkContent(item);
22
+ if (fits(withContent)) return withContent;
23
+ if (item.tool_name === null) return withContent;
24
+ // Content could not help even as an empty prefix: tool_name is oversized, so keep the
25
+ // original payload and truncate the metadata as the last resort.
26
+ const withName = { ...item, tool_name: middleTruncate(item.tool_name, (candidate) => fits({ ...item, tool_name: candidate } as T)) } as T;
27
+ if (fits(withName)) return withName;
28
+ // Both fields are oversized: dissolve the payload against the truncated metadata.
29
+ return shrinkContent(withName);
30
+ }
31
+
7
32
  export function registerHistoryTools(pi: ExtensionAPI) {
8
33
  pi.registerTool(defineTool({
9
34
  name: "history_list_windows",
@@ -21,41 +46,37 @@ export function registerHistoryTools(pi: ExtensionAPI) {
21
46
  pi.registerTool(defineTool({
22
47
  name: "history_list_items",
23
48
  label: "History list items",
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 }),
49
+ description: "List durable session items, including items before compaction, using opaque item and window IDs. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with history_read_item.",
50
+ 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
51
  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 })) })));
52
+ const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200));
53
+ return output(page(items, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
29
54
  },
30
55
  }));
31
56
 
32
57
  pi.registerTool(defineTool({
33
58
  name: "history_read_item",
34
59
  label: "History read item",
35
- description: "Read a bounded character range from one durable session item.",
36
- parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ minimum: 0 })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000 })), window_id: Type.String() }, { additionalProperties: false }),
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. next_offset_chars is exactly offset_chars plus the delivered code-point count and is null only once the item ends: follow it to reconstruct the item exactly. A negative offset_chars counts back from the item's end, and the response always echoes the resolved absolute offset. Offsets and counts are code points (an emoji or CJK character counts as one).",
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 }),
37
62
  async execute(_id, params, _signal, _update, ctx) {
38
63
  const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
39
64
  if (!item) return output({ error: "unknown item_id or window_id" });
40
- const chars = Array.from(item.content);
41
- const offset = params.offset_chars ?? 0;
42
- const limit = Math.min(params.limit_chars ?? 12000, 50000);
43
- const result = (content: string) => ({ window_id: item.windowId, item_id: item.itemId, offset_chars: offset, content, total_chars: chars.length, next_offset_chars: offset + limit < chars.length ? offset + limit : null });
44
- // One clean middle-truncation replaces the old 0.9 shrink loop: a requested window larger
45
- // than the budget comes back with its middle elided, never empty, and the cursor advances.
46
- const content = middleTruncate(chars.slice(offset, offset + limit).join(""), (candidate) => withinBudget(result(candidate)));
47
- return output(result(content));
65
+ return output(readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => ({ window_id: item.windowId, item_id: item.itemId, ...window })));
48
66
  },
49
67
  }));
50
68
 
51
69
  pi.registerTool(defineTool({
52
70
  name: "history_search_contents",
53
71
  label: "History search",
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 }),
72
+ description: "Case-sensitive literal substring search over durable Pi session history; query accepts one string or an array of strings, an item matches when it contains any of them (OR), and each item appears once. No semantic search. Each hit carries truncated and total_chars plus match_offset_chars: the code-point offset of the earliest query occurrence in the item's full content. With max_chars_per_item: 1 the page is an address list; resolve an address with history_read_item at match_offset_chars.",
73
+ parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_first: recentFirst(), tool_namespace: nullableString(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
56
74
  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 })) })));
75
+ const queries = searchQueries(params.query);
76
+ const matching = filteredItems(ctx, params)
77
+ .filter((item) => queries.some((query) => item.content.includes(query)))
78
+ .map((item) => ({ ...visibleItem(item, params.max_chars_per_item ?? 1200), match_offset_chars: earliestMatchOffsetChars(item.content, queries) }));
79
+ return output(page(matching, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
59
80
  },
60
81
  }));
61
82
 
package/src/history.ts CHANGED
@@ -39,6 +39,9 @@ function mapRole(role: AgentMessage["role"]): HistoryItem["role"] | undefined {
39
39
  return undefined;
40
40
  }
41
41
 
42
+ /** This extension's own custom-entry namespace; entries under it are authored by pi-context. */
43
+ const PI_CONTEXT_ENTRY_PREFIX = "pi-context/";
44
+
42
45
  function messageContent(message: AgentMessage): string {
43
46
  switch (message.role) {
44
47
  case "bashExecution":
@@ -68,13 +71,13 @@ export function resetV2WindowId(details: unknown): string | undefined {
68
71
 
69
72
  /** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
70
73
  function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
71
- return resetV2WindowId(entry.details) ?? `pcw:${sessionId}:${entry.id}`;
74
+ return resetV2WindowId(entry.details) ?? `pcw:${sessionId.slice(0, 8)}:${entry.id}`;
72
75
  }
73
76
 
74
77
  /** Build durable, on-demand history directly from every entry on the current session branch. */
75
78
  export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
76
79
  const sessionId = ctx.sessionManager.getSessionId();
77
- let window: HistoryWindow = { windowId: `pcw:${sessionId}:root`, items: [] };
80
+ let window: HistoryWindow = { windowId: `pcw:${sessionId.slice(0, 8)}:root`, items: [] };
78
81
  const windows = [window];
79
82
  for (const entry of ctx.sessionManager.getBranch()) {
80
83
  if (entry.type === "compaction") {
@@ -83,7 +86,8 @@ export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
83
86
  window.items.push({
84
87
  windowId: window.windowId,
85
88
  itemId: entry.id,
86
- role: "system",
89
+ // A reset-v2 compaction is authored by this extension; a native Pi compaction is not.
90
+ role: resetV2WindowId(entry.details) === undefined ? "system" : "developer",
87
91
  content: entry.summary,
88
92
  createdAt: entry.timestamp,
89
93
  });
@@ -106,7 +110,8 @@ export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
106
110
  window.items.push({
107
111
  windowId: window.windowId,
108
112
  itemId: entry.id,
109
- role: "user",
113
+ // Only entries this extension wrote are its own; every foreign custom message stays a user turn.
114
+ role: entry.customType.startsWith(PI_CONTEXT_ENTRY_PREFIX) ? "developer" : "user",
110
115
  content: contentText(entry.content),
111
116
  createdAt: entry.timestamp,
112
117
  });
@@ -117,13 +122,18 @@ export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
117
122
 
118
123
  export function visibleItem(item: HistoryItem, maxChars = 1200) {
119
124
  const characters = Array.from(item.content);
125
+ const truncated = characters.length > maxChars;
120
126
  return {
121
127
  window_id: item.windowId,
122
128
  item_id: item.itemId,
123
129
  role: item.role,
124
130
  tool_namespace: item.toolNamespace ?? null,
125
131
  tool_name: item.toolName ?? null,
126
- truncated_content: characters.length > maxChars ? `${characters.slice(0, Math.max(0, maxChars - 1)).join("")}…` : item.content,
132
+ truncated,
133
+ total_chars: characters.length,
134
+ // A truncated payload is a plain prefix: no synthetic marker is appended, and
135
+ // `total_chars` names exactly how many code points were left out.
136
+ truncated_content: truncated ? characters.slice(0, maxChars).join("") : item.content,
127
137
  };
128
138
  }
129
139
 
@@ -161,6 +171,6 @@ export function currentWindowId(ctx: SessionReader): string {
161
171
  const entry = branch[i];
162
172
  if (entry?.type === "compaction") return windowIdOf(sessionId, entry);
163
173
  }
164
- return `pcw:${sessionId}:root`;
174
+ return `pcw:${sessionId.slice(0, 8)}:root`;
165
175
  }
166
176
 
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ import { output } from "./tool-output.js";
5
5
  export { deriveThresholds, mergePiContextSettings } from "./budget.js";
6
6
  import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, FALLBACK_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, RESET_V2, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, RESET_SUMMARY, CONTINUATION, FALLBACK_PROMPT } from "./protocol.js";
7
7
  import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId } from "./history.js";
8
- import { assertVirtualPath, lineRange } from "./notes.js";
8
+ import { assertVirtualPath } from "./notes.js";
9
9
  import { bootBlock } from "./prompts.js";
10
10
  export { historyFromSession } from "./history.js";
11
11
  export { notesFromSession } from "./notes.js";
@@ -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();
@@ -96,4 +96,4 @@ export default function piContext(pi: ExtensionAPI) {
96
96
  });
97
97
  }
98
98
 
99
- export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, FALLBACK_TYPE, FALLBACK_PROMPT, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, lineRange, assertVirtualPath };
99
+ export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, FALLBACK_TYPE, FALLBACK_PROMPT, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, assertVirtualPath };
package/src/note-tools.ts CHANGED
@@ -1,9 +1,9 @@
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, withinBudget } from "./tool-output.js";
4
- import { nullableString, nullableInteger, positiveInteger } from "./tool-schema.js";
5
- import { notesFromSession, assertVirtualPath, assertVirtualPrefix, lineRange, localIso, type NoteOperation } from "./notes.js";
6
- import { NOTE_TYPE, MAX_NOTE_BYTES } from "./protocol.js";
3
+ import { output, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow } from "./tool-output.js";
4
+ import { nullableString, positiveInteger, cursor, searchQuery, searchQueries } from "./tool-schema.js";
5
+ import { notesFromSession, assertVirtualPath, assertVirtualPrefix, assertGlobPattern, globToRegExp, localIso, type NoteOperation } from "./notes.js";
6
+ import { NOTE_TYPE, MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./protocol.js";
7
7
 
8
8
  export function registerNoteTools(pi: ExtensionAPI) {
9
9
  const saveNote = (op: NoteOperation) => {
@@ -13,67 +13,108 @@ export function registerNoteTools(pi: ExtensionAPI) {
13
13
  };
14
14
 
15
15
  pi.registerTool(defineTool({
16
- name: "notes_list_files_by_prefix",
16
+ name: "notes_list_files",
17
17
  label: "Notes list files",
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 }),
18
+ description: "List note files, optionally filtered by a glob pattern (* within a path segment, ** across segments). The default order is most recently updated first; file_order_by (name, created_at, updated_at) and file_order (ascending, descending) select another. Entries carry each file's stale flag.",
19
+ parameters: Type.Object({ pattern: 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
- const prefix = assertVirtualPrefix(params.prefix);
22
- let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
23
- const key = params.file_order_by ?? "name";
24
- files.sort(([aPath, a], [bPath, b]) => key === "name" ? aPath.localeCompare(bPath) : (key === "created_at" ? a.createdAt - b.createdAt : a.updatedAt - b.updatedAt));
25
- if (params.file_order === "descending") files.reverse();
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 })) })));
21
+ const pattern = assertGlobPattern(params.pattern);
22
+ const matcher = pattern ? globToRegExp(pattern) : undefined;
23
+ let files = [...notesFromSession(ctx)].filter(([path]) => !matcher || matcher.test(path));
24
+ const key = params.file_order_by ?? "updated_at";
25
+ // Deterministic total order: (axis key, createdAt, path) ascending. Paths are unique, so
26
+ // this never depends on map iteration order; descending reverses the whole comparator.
27
+ files.sort(([aPath, a], [bPath, b]) => {
28
+ const primary = key === "name" ? aPath.localeCompare(bPath) : key === "created_at" ? a.createdAt - b.createdAt : a.updatedAt - b.updatedAt;
29
+ if (primary !== 0) return primary;
30
+ if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
31
+ return aPath.localeCompare(bPath);
32
+ });
33
+ // An explicit file_order always wins; otherwise the axis's natural direction applies
34
+ // (descending for the time axes, ascending for name).
35
+ if (params.file_order ? params.file_order === "descending" : key !== "name") files.reverse();
36
+ const listed: Array<{ path: string; size_bytes: number; stale: boolean; created_at: string; updated_at: string; path_truncated?: boolean }> = 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) }));
37
+ // `path` is the entry's identity: return it intact whenever the entry fits, and only
38
+ // ever alter it together with a visible `path_truncated: true` flag. A pathological
39
+ // legacy path predating the write cap is the one case that cannot fit at all.
40
+ return output(page(listed, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
41
+ if (fits(file)) return file;
42
+ const path = middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate, path_truncated: true }));
43
+ return { ...file, path, path_truncated: true };
44
+ }));
28
45
  },
29
46
  }));
30
47
 
31
48
  pi.registerTool(defineTool({
32
49
  name: "notes_read_file",
33
50
  label: "Notes read file",
34
- description: "Read a virtual note file, optionally by inclusive 1-based line range; negative lines count from the end. Success results carry created_at and updated_at as local-time ISO 8601 strings with an explicit UTC offset.",
35
- parameters: Type.Object({ path: Type.String(), start_line: nullableInteger(), stop_line: nullableInteger() }, { additionalProperties: false }),
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 the response echoes the resolved absolute offset and limit_chars caps the window (default 12000, max 50000). Each response delivers the longest fitting prefix of that window: pass next_offset_chars back unchanged to continue, concatenate pages in order, null only at the end.",
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 }),
36
53
  async execute(_id, params, _signal, _update, ctx) {
37
54
  const path = assertVirtualPath(params.path);
38
55
  const file = notesFromSession(ctx).get(path);
39
56
  if (!file) return output({ error: "note file not found", path });
40
- const range = lineRange(file.text, params.start_line, params.stop_line);
41
- const lines = range.content ? range.content.split("\n") : [];
42
- const totalLines = file.text.split("\n").length;
43
- const result = (content: string, count: number) => ({ path, start_line: range.start_line, stop_line: range.start_line + count - 1, content, total_lines: totalLines, next_start_line: range.start_line + count <= range.stop_line ? range.start_line + count : null, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) });
44
- let count = lines.length;
45
- while (count > 0 && !withinBudget(result(lines.slice(0, count).join("\n"), count))) count--;
46
- if (count === 0 && lines.length > 0) {
47
- // One indivisible line is larger than the whole budget: return it middle-truncated and
48
- // advance past it instead of looping on an empty page whose cursor never moves.
49
- return output(result(middleTruncate(lines[0], (candidate) => withinBudget(result(candidate, 1))), 1));
50
- }
51
- return output(result(lines.slice(0, count).join("\n"), count));
57
+ const created_at = localIso(file.createdAt);
58
+ const updated_at = localIso(file.updatedAt);
59
+ return output(readCharacterWindow(file.text, params.offset_chars, params.limit_chars, (window) => ({ path, ...window, created_at, updated_at })));
52
60
  },
53
61
  }));
54
62
 
55
63
  pi.registerTool(defineTool({
56
64
  name: "notes_search_contents",
57
65
  label: "Notes search",
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 }),
66
+ 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.",
67
+ 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 }),
60
68
  async execute(_id, params, _signal, _update, ctx) {
69
+ const queries = searchQueries(params.query);
61
70
  const prefix = assertVirtualPrefix(params.path_prefix);
62
71
  let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
63
72
  if (params.recent_file_first) files.sort((a, b) => b[1].createdAt - a[1].createdAt);
64
73
  const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
65
- const result = files.map(([path, file]) => ({ path, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt), matches: file.text.split("\n").flatMap((line, index) => line.includes(params.query) ? [{ line: index + 1, text: line }] : []).slice(0, maxPerFile) })).filter((file) => file.matches.length > 0);
66
- // A file is capped by dropping whole trailing matches, but its last match is never
67
- // dropped: one oversized line is middle-truncated so the file still appears.
74
+ 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
75
+ .map(([path, file]) => {
76
+ // A match's offset_chars is file-absolute: the code points before its line, plus the
77
+ // earliest occurrence of any query inside that line. Search then composes with
78
+ // notes_read_file exactly like history_search_contents composes with history_read_item.
79
+ let baseChars = 0;
80
+ const allMatches = file.text.split("\n").flatMap((line, index) => {
81
+ const match = queries.some((query) => line.includes(query))
82
+ ? [{ line: index + 1, text: line, truncated: false, total_chars: Array.from(line).length, offset_chars: baseChars + earliestMatchOffsetChars(line, queries) }]
83
+ : [];
84
+ baseChars += Array.from(line).length + 1;
85
+ return match;
86
+ });
87
+ return { path, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt), matches_total: allMatches.length, matches: allMatches.slice(0, maxPerFile) };
88
+ })
89
+ .filter((file) => file.matches.length > 0);
90
+ // Trailing matches are dropped to fit the budget (bounded by a monotone binary search),
91
+ // and the entry's matches_total keeps naming the drop. Only when a single intact match is
92
+ // over budget is its line delivered as a plain prefix, flagged and counted. Only when the
93
+ // entry cannot fit even then is the identity field itself truncated, and then only together
94
+ // with a visible `path_truncated: true` flag.
68
95
  const fitFile = (file: (typeof result)[number], fits: (candidate: (typeof result)[number]) => boolean) => {
69
- let matches = file.matches;
70
- while (matches.length > 1 && !fits({ ...file, matches })) matches = matches.slice(0, -1);
71
- const first = matches[0];
72
- if (!first) return { ...file, matches };
73
- const text = middleTruncate(first.text, (candidate) => fits({ ...file, matches: [{ ...first, text: candidate }, ...matches.slice(1)] }));
74
- return { ...file, matches: [{ ...first, text }, ...matches.slice(1)] };
96
+ if (fits(file)) return file;
97
+ const matches = file.matches;
98
+ // First, drop whole trailing matches: the largest prefix that fits intact is kept, so an
99
+ // entry only truncates a line when that single line alone is over budget.
100
+ let low = 0;
101
+ let high = matches.length;
102
+ while (low < high) {
103
+ const mid = Math.ceil((low + high) / 2);
104
+ if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) })) low = mid;
105
+ else high = mid - 1;
106
+ }
107
+ if (low >= 1) return { ...file, matches: matches.slice(0, low) };
108
+ // Even one intact match is over budget: keep the first match as a plain, named prefix.
109
+ const first = matches[0]!;
110
+ const fitted = (text: string): (typeof result)[number] => ({ ...file, matches: [{ ...first, text, truncated: true }] });
111
+ const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
112
+ const prefix: (typeof result)[number] = fitted(text);
113
+ if (fits(prefix)) return prefix;
114
+ const path = middleTruncate(prefix.path, (candidate) => fits({ ...prefix, path: candidate, path_truncated: true }));
115
+ return { ...prefix, path, path_truncated: true };
75
116
  };
76
- return output(page(result.slice(0, params.max_files ?? result.length), params.offset ?? 0, "files", undefined, fitFile));
117
+ return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
77
118
  },
78
119
  }));
79
120
 
@@ -82,8 +123,8 @@ export function registerNoteTools(pi: ExtensionAPI) {
82
123
  name,
83
124
  label: name === "notes_append_to_file" ? "Notes append" : "Notes write",
84
125
  description: name === "notes_append_to_file"
85
- ? "Append exact text to a persistent virtual note file. Appending suits chronological logs; for current-state notes, replace the whole file with notes_write_file instead. Accepts the same mark_stale flag to close a note."
86
- : "Create or replace a persistent virtual note file. Keep notes small and split by topic; replace outdated notes whole. With mark_stale: true, flag the note as stale instead — optionally writing its final content in the same call: stale notes leave the boot index but stay readable and searchable, and rewriting revives them.",
126
+ ? "Append exact text to a note file. Appending suits chronological logs; for current-state notes use notes_write_file instead. mark_stale closes a note."
127
+ : "Create or replace a note file. Keep notes small and split by topic; replace outdated notes whole. mark_stale: true flags a note stale (optionally with its final content): stale notes leave the boot index but stay readable and searchable; rewriting revives them.",
87
128
  parameters: Type.Object({ text: Type.Optional(Type.String()), path: Type.String(), mark_stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
88
129
  // Codex sets supports_parallel_tool_calls = false on notes.write_file/append_to_file.
89
130
  // Pi's per-tool equivalent is executionMode "sequential": a batch containing either
@@ -91,6 +132,11 @@ export function registerNoteTools(pi: ExtensionAPI) {
91
132
  executionMode: "sequential",
92
133
  async execute(_id, params, _signal, _update, ctx) {
93
134
  const path = assertVirtualPath(params.path);
135
+ const pathBytes = Buffer.byteLength(path, "utf8");
136
+ // The cap lives here, at the tool boundary, and never in assertVirtualPath: note
137
+ // replay validates persisted ops through that helper and must keep loading sessions
138
+ // that already contain a longer legacy path (reads stay un-capped too).
139
+ if (pathBytes > MAX_NOTE_PATH_BYTES) return output({ error: `note path exceeds ${MAX_NOTE_PATH_BYTES} UTF-8 bytes`, path_bytes: pathBytes });
94
140
  const hasText = params.text !== undefined;
95
141
  const hasStale = params.mark_stale !== undefined;
96
142
  if (!hasText && !hasStale) return output({ error: "provide text, mark_stale, or both", path });
package/src/notes.ts CHANGED
@@ -25,6 +25,40 @@ export function assertVirtualPrefix(value: unknown): string | undefined {
25
25
  return assertVirtualPath(value);
26
26
  }
27
27
 
28
+ /**
29
+ * Minimal glob over virtual note paths: `*` matches any run within a segment (never
30
+ * `/`), `**` matches any run across segments (a leading double-star followed by a
31
+ * slash also matches zero segments, so it covers the root too), `?` matches exactly
32
+ * one non-`/` character. Everything else is literal and the match is anchored to the
33
+ * whole path.
34
+ */
35
+ export function globToRegExp(pattern: string): RegExp {
36
+ let source = "^";
37
+ for (let index = 0; index < pattern.length; index++) {
38
+ const char = pattern[index]!;
39
+ if (char === "*") {
40
+ if (pattern[index + 1] === "*") {
41
+ const followedBySlash = pattern[index + 2] === "/";
42
+ source += followedBySlash ? "(?:[^]*\\/)?" : "[^]*";
43
+ index += followedBySlash ? 2 : 1;
44
+ } else {
45
+ source += "[^/]*";
46
+ }
47
+ } else {
48
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
49
+ }
50
+ }
51
+ return new RegExp(`${source}$`);
52
+ }
53
+
54
+ /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
55
+ export function assertGlobPattern(value: unknown): string | undefined {
56
+ if (value === undefined || value === null || value === "") return undefined;
57
+ if (typeof value !== "string") throw new Error("glob pattern must be a string");
58
+ if (value.includes("\0") || value.includes("\\")) throw new Error("glob pattern must not contain NUL or backslashes");
59
+ return value;
60
+ }
61
+
28
62
  /** Replays only pi-context note operations from session custom entries. */
29
63
  function isNoteOperation(data: unknown): data is NoteOperation {
30
64
  if (typeof data !== "object" || data === null) return false;
@@ -63,19 +97,6 @@ export function notesFromSession(ctx: SessionReader): Map<string, NoteFile> {
63
97
  return files;
64
98
  }
65
99
 
66
- export function lineRange(text: string, startValue: unknown, stopValue: unknown) {
67
- const lines = text.split("\n");
68
- const resolve = (value: unknown, fallback: number) => {
69
- if (value === undefined || value === null) return fallback;
70
- if (!Number.isInteger(value) || value === 0) throw new Error("line numbers must be non-zero integers; negative values count from the end");
71
- const line = value as number;
72
- return line > 0 ? line : lines.length + line + 1;
73
- };
74
- const start = Math.max(1, resolve(startValue, 1));
75
- const stop = Math.min(lines.length, resolve(stopValue, lines.length));
76
- return { start_line: start, stop_line: stop, content: start > stop ? "" : lines.slice(start - 1, stop).join("\n") };
77
- }
78
-
79
100
  const pad2 = (value: number) => String(value).padStart(2, "0");
80
101
 
81
102
  /**
package/src/prompts.ts CHANGED
@@ -65,6 +65,6 @@ export function bootBlock(ctx: ExtensionContext, currentId: string, previousId:
65
65
  * at write time; get_context_remaining remains the live source for the current figure.
66
66
  */
67
67
  export function tokenBudgetGuidance(remaining: number): string {
68
- return `${GUIDANCE_OPEN_TAG}\nYour memory is about to be erased — only ${remaining} tokens left at last count; get_context_remaining has the live number. Before the lights go out, write your checkpoint with notes_write_file: the goal, decisions, progress, open issues, next steps, the skills you still need, and the window ID and item ID of every user request you are still solving. Then call new_context and wake clean. Don't count on the automatic reset leaving you another turn to write.\n${GUIDANCE_CLOSE_TAG}`;
68
+ return `${GUIDANCE_OPEN_TAG}\nYour memory is about to be erased — only ${remaining} tokens left at last count; get_context_remaining has the live number. Before the lights go out, write your checkpoint with notes_write_file: the goal, decisions, progress, open issues, next steps, the skills you still need, and the window ID and item ID of every user request you are still solving. If this checkpoint replaces an older note, close it in the same sitting with mark_stale: true — stale notes leave the boot index but stay readable and searchable. Then call new_context and wake clean. Don't count on the automatic reset leaving you another turn to write.\n${GUIDANCE_CLOSE_TAG}`;
69
69
  }
70
70
 
package/src/protocol.ts CHANGED
@@ -7,6 +7,10 @@ export const RESET_MARKER_TYPE = "pi-context/reset-marker";
7
7
  export const CONTINUATION_TYPE = "pi-context/continuation";
8
8
  export const RESET_V2 = "reset-v2";
9
9
  export const MAX_NOTE_BYTES = 1_000_000;
10
+ // Write-time cap on a virtual note path. Deliberately NOT enforced by assertVirtualPath:
11
+ // notesFromSession replays already-persisted operations, which must keep loading sessions
12
+ // that contain a longer legacy path. Reads and replay stay un-capped.
13
+ export const MAX_NOTE_PATH_BYTES = 512;
10
14
  export const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
11
15
  export const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
12
16
  export const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
@@ -41,6 +41,83 @@ export function middleTruncate(text: string, fits: (content: string) => boolean)
41
41
  return build(low);
42
42
  }
43
43
 
44
+ /**
45
+ * Longest contiguous prefix of `text` (counted in code points) accepted by `fits`.
46
+ *
47
+ * This is the truncation used by every cursor-bearing payload: the delivered text is
48
+ * always a plain prefix of the original, so a cursor computed from its code-point length
49
+ * addresses exactly the first undelivered character. No marker character is ever appended;
50
+ * the companion `truncated`/`total_chars` fields name what was left out.
51
+ */
52
+ export function prefixFit(text: string, fits: (content: string) => boolean): string {
53
+ if (fits(text)) return text;
54
+ const chars = Array.from(text);
55
+ // Serialized size is non-decreasing in the kept count, so the largest fitting prefix is
56
+ // found by a monotone binary search instead of a quadratic shrink loop.
57
+ let low = 0;
58
+ let high = chars.length;
59
+ while (low < high) {
60
+ const mid = Math.ceil((low + high) / 2);
61
+ if (fits(chars.slice(0, mid).join(""))) low = mid;
62
+ else high = mid - 1;
63
+ }
64
+ // A candidate's serialized size can dip by a byte or two at the very end (a numeric cursor
65
+ // becoming null), so the predicate is not perfectly monotone at the tail. Back off until the
66
+ // returned prefix provably fits; in the monotone case this loop never runs.
67
+ while (low > 0 && !fits(chars.slice(0, low).join(""))) low -= 1;
68
+ return chars.slice(0, low).join("");
69
+ }
70
+
71
+ /**
72
+ * Fields every character-window read returns; each tool adds its own identity and metadata.
73
+ * `offset_chars` is always the resolved absolute offset, and `next_offset_chars` is exactly
74
+ * that offset plus the delivered code-point count, null only at the text's true end.
75
+ */
76
+ export type CharacterWindow = {
77
+ offset_chars: number;
78
+ content: string;
79
+ total_chars: number;
80
+ next_offset_chars: number | null;
81
+ };
82
+
83
+ /**
84
+ * Read one character window of `text`: the longest contiguous prefix of
85
+ * `chars[resolved, resolved + limit)` that fits the wire budget.
86
+ *
87
+ * `offsetChars` is a code-point offset. A negative value counts back from the end and
88
+ * resolves to `max(0, total_chars + offsetChars)`, so `-N` reaches the tail and any
89
+ * `N >= total_chars` reads from the start; the resolved absolute offset is always echoed.
90
+ * Following `next_offset_chars` reconstructs `text` by plain concatenation, because the
91
+ * payload is always a plain prefix with no marker. `render` builds the exact response for
92
+ * a candidate window, so the budget is measured on the bytes that go on the wire.
93
+ */
94
+ export function readCharacterWindow<T>(text: string, offsetChars: number | undefined, limitChars: number | undefined, render: (window: CharacterWindow) => T): T {
95
+ const chars = Array.from(text);
96
+ const requested = offsetChars ?? 0;
97
+ const resolved = requested < 0 ? Math.max(0, chars.length + requested) : Math.max(0, requested);
98
+ const windowChars = chars.slice(resolved, resolved + Math.min(limitChars ?? 12000, 50000));
99
+ const build = (content: string): CharacterWindow => {
100
+ const next = resolved + Array.from(content).length;
101
+ return { offset_chars: resolved, content, total_chars: chars.length, next_offset_chars: next < chars.length ? next : null };
102
+ };
103
+ const content = prefixFit(windowChars.join(""), (candidate) => withinBudget(render(build(candidate))));
104
+ return render(build(content));
105
+ }
106
+
107
+ /**
108
+ * Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
109
+ * none occurs. Shared by the two search tools so a match address is computed identically.
110
+ */
111
+ export function earliestMatchOffsetChars(text: string, queries: string[]): number {
112
+ let earliest = -1;
113
+ for (const query of queries) {
114
+ const index = text.indexOf(query);
115
+ if (index < 0) continue;
116
+ if (earliest < 0 || index < earliest) earliest = index;
117
+ }
118
+ return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
119
+ }
120
+
44
121
  /** Shrink a single page item to fit; only invoked when that item alone exceeds the budget. */
45
122
  export type ItemTruncator<T> = (item: T, fits: (candidate: T) => boolean) => T;
46
123
 
@@ -48,17 +125,17 @@ export type ItemTruncator<T> = (item: T, fits: (candidate: T) => boolean) => T;
48
125
  * Build a page without ever adding an item that would exceed the wire budget.
49
126
  *
50
127
  * 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
128
+ * callback and still included, with `next_cursor` advanced past it. Without that fallback
52
129
  * an oversized item would yield an empty page forever: the cursor would keep pointing back
53
130
  * at the same index.
54
131
  */
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));
132
+ export function page<T>(items: T[], cursor: number, key: string, limit?: number, truncate?: ItemTruncator<T>) {
133
+ const end = Math.min(items.length, cursor + (limit ?? items.length));
57
134
  const selected: T[] = [];
58
135
  let next = end < items.length ? end : null;
59
- for (let index = offset; index < end; index++) {
136
+ for (let index = cursor; index < end; index++) {
60
137
  const candidateNext = index + 1 < end || end < items.length ? index + 1 : null;
61
- const fits = (list: T[]) => withinBudget({ [key]: list, next_offset: candidateNext });
138
+ const fits = (list: T[]) => withinBudget({ [key]: list, next_cursor: candidateNext });
62
139
  if (!fits([...selected, items[index]])) {
63
140
  if (selected.length === 0 && truncate) {
64
141
  selected.push(truncate(items[index], (candidate) => fits([candidate])));
@@ -70,7 +147,7 @@ export function page<T>(items: T[], offset: number, key: string, limit?: number,
70
147
  }
71
148
  selected.push(items[index]);
72
149
  }
73
- return { [key]: selected, next_offset: next };
150
+ return { [key]: selected, next_cursor: next };
74
151
  }
75
152
 
76
153
  /** Encode a result through the common tool result boundary. */
@@ -1,7 +1,24 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  export const nullableString = () => Type.Optional(Type.Union([Type.String(), Type.Null()]));
3
- export const nullableInteger = () => Type.Optional(Type.Union([Type.Integer(), Type.Null()]));
4
3
  export const positiveInteger = () => Type.Optional(Type.Integer({ minimum: 1 }));
4
+ 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
5
  export const recentFirst = () => Type.Optional(Type.Boolean({ description: "Return newest-first. Only an explicit false returns oldest-first. Defaults to true." }));
6
- export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()]);
6
+ /** Role filter. `developer` is the known author for this extension's own custom entries. */
7
+ export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()], { description: "Filter by the entry's known author: user/assistant/tool from the conversation, system for native Pi compaction summaries, developer for entries this extension authored (its boot, guidance, fallback, and continuation messages, its reset-window compaction summaries, and any other pi-context/* entry)." });
8
+
9
+ /** Search query parameter: one literal, or several literals combined with OR. */
10
+ export const searchQuery = () => Type.Union([Type.String(), Type.Array(Type.String(), { minItems: 1 })]);
11
+
12
+ /**
13
+ * Normalize a search `query` parameter into the literal needles to match.
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.
17
+ */
18
+ 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[];
23
+ }
7
24