@astrosheep/pi-context 0.13.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 +27 -60
- package/docs/architecture.md +1 -1
- package/package.json +2 -2
- package/src/history-tools.ts +39 -18
- package/src/history.ts +13 -3
- package/src/index.ts +2 -2
- package/src/note-tools.ts +87 -41
- package/src/notes.ts +34 -13
- package/src/prompts.ts +1 -1
- package/src/protocol.ts +4 -0
- package/src/tool-output.ts +77 -0
- package/src/tool-schema.ts +18 -2
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
|
-
##
|
|
17
|
+
## What you get
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
- **
|
|
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.
|
|
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
|
-
|
|
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_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
|
-
|
|
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
|
-
-
|
|
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
|
-
##
|
|
41
|
+
## Configuration
|
|
84
42
|
|
|
85
|
-
|
|
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
|
-
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"compaction": { "reserveTokens": 16384 },
|
|
48
|
+
"pi-context": { "reminderMarginTokens": 24576 }
|
|
49
|
+
}
|
|
50
|
+
```
|
|
88
51
|
|
|
89
|
-
|
|
52
|
+
`reminder = reserveTokens + reminderMarginTokens`; with the defaults the early warning fires 24,576 tokens above Pi's reset line.
|
|
90
53
|
|
|
91
|
-
|
|
54
|
+
## Documentation
|
|
92
55
|
|
|
93
|
-
|
|
56
|
+
Implementation architecture and the reset lifecycle live in [docs/](docs/).
|
|
94
57
|
|
|
95
|
-
##
|
|
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
|
|
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
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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.
|
|
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"
|
package/src/history-tools.ts
CHANGED
|
@@ -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,
|
|
4
|
-
import { positiveInteger, recentFirst, nullableString, role, cursor } 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.",
|
|
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.",
|
|
25
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
52
|
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,
|
|
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
|
|
36
|
-
parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({
|
|
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
|
-
|
|
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;
|
|
55
|
-
parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), query:
|
|
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
|
|
58
|
-
|
|
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":
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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";
|
|
@@ -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,
|
|
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,
|
|
4
|
-
import { nullableString,
|
|
5
|
-
import { notesFromSession, assertVirtualPath, assertVirtualPrefix,
|
|
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,65 +13,106 @@ export function registerNoteTools(pi: ExtensionAPI) {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
pi.registerTool(defineTool({
|
|
16
|
-
name: "
|
|
16
|
+
name: "notes_list_files",
|
|
17
17
|
label: "Notes list files",
|
|
18
|
-
description: "List
|
|
19
|
-
parameters: Type.Object({
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
35
|
-
parameters: Type.Object({ path: Type.String(),
|
|
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
|
|
41
|
-
const
|
|
42
|
-
|
|
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
|
|
59
|
-
parameters: Type.Object({ max_matches_per_file: positiveInteger(), cursor: cursor(), query:
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
117
|
return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
|
|
77
118
|
},
|
|
@@ -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
|
|
86
|
-
: "Create or replace a
|
|
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>";
|
package/src/tool-output.ts
CHANGED
|
@@ -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
|
|
package/src/tool-schema.ts
CHANGED
|
@@ -1,8 +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 }));
|
|
5
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." }));
|
|
6
5
|
export const recentFirst = () => Type.Optional(Type.Boolean({ description: "Return newest-first. Only an explicit false returns oldest-first. Defaults to true." }));
|
|
7
|
-
|
|
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
|
+
}
|
|
8
24
|
|