@astrosheep/pi-context 0.23.1 → 0.25.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.
Files changed (78) hide show
  1. package/README.md +52 -5
  2. package/dist/build-info.json +4 -0
  3. package/dist/extension.js +1861 -0
  4. package/dist/src/context/budget.js +150 -0
  5. package/dist/src/context/context-window.js +97 -0
  6. package/dist/src/context/prompts.js +94 -0
  7. package/dist/src/context/reset-lifecycle.js +134 -0
  8. package/dist/src/context/runtime.js +236 -0
  9. package/dist/src/context/thresholds.js +62 -0
  10. package/dist/src/dream/cli.js +1 -1
  11. package/dist/src/dream/doctor.js +34 -6
  12. package/dist/src/dream/runner.js +1 -1
  13. package/dist/src/dream/settings.js +30 -0
  14. package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
  15. package/dist/src/{history.js → history/history.js} +8 -46
  16. package/dist/src/index.js +27 -94
  17. package/dist/src/notes/address.js +97 -16
  18. package/dist/src/notes/frontmatter.js +18 -3
  19. package/dist/src/notes/notes-snapshot.js +30 -0
  20. package/dist/src/notes/paths.js +64 -7
  21. package/dist/src/notes/session-replay.js +41 -0
  22. package/dist/src/notes/store.js +76 -22
  23. package/dist/src/notes/tools.js +7 -7
  24. package/dist/src/protocol.js +11 -9
  25. package/dist/src/settings.js +16 -0
  26. package/dist/src/tool-schema.js +1 -1
  27. package/dist/test/agent-loop.test.js +813 -213
  28. package/dist/test/boot.integration.test.js +167 -0
  29. package/dist/test/budget-settings.integration.test.js +126 -0
  30. package/dist/test/doctor.test.js +14 -36
  31. package/dist/test/dream.test.js +37 -380
  32. package/dist/test/helpers/extension.js +393 -0
  33. package/dist/test/history.integration.test.js +316 -0
  34. package/dist/test/notes.integration.test.js +273 -0
  35. package/dist/test/notes.test.js +40 -370
  36. package/dist/test/reset-lifecycle.test.js +248 -180
  37. package/docs/architecture.md +35 -18
  38. package/docs/reset-lifecycle.md +16 -14
  39. package/package.json +11 -10
  40. package/src/context/budget.ts +148 -0
  41. package/src/context/context-window.ts +103 -0
  42. package/src/context/prompts.ts +111 -0
  43. package/src/context/reset-lifecycle.ts +145 -0
  44. package/src/context/runtime.ts +246 -0
  45. package/src/context/thresholds.ts +78 -0
  46. package/src/dream/cli.ts +1 -1
  47. package/src/dream/doctor.ts +27 -6
  48. package/src/dream/runner.ts +1 -1
  49. package/src/dream/settings.ts +32 -0
  50. package/src/{history-tools.ts → history/history-tools.ts} +3 -3
  51. package/src/{history.ts → history/history.ts} +9 -48
  52. package/src/index.ts +27 -89
  53. package/src/notes/address.ts +82 -16
  54. package/src/notes/frontmatter.ts +20 -3
  55. package/src/notes/notes-snapshot.ts +40 -0
  56. package/src/notes/paths.ts +64 -7
  57. package/src/notes/session-replay.ts +53 -0
  58. package/src/notes/store.ts +78 -25
  59. package/src/notes/tools.ts +7 -7
  60. package/src/protocol.ts +11 -9
  61. package/src/settings.ts +20 -0
  62. package/src/tool-schema.ts +1 -2
  63. package/dist/src/budget.js +0 -65
  64. package/dist/src/notes/model.js +0 -101
  65. package/dist/src/prompts.js +0 -88
  66. package/dist/src/reset-lifecycle.js +0 -155
  67. package/dist/src/thresholds.js +0 -102
  68. package/dist/src/warning.js +0 -44
  69. package/dist/test/coherence.test.js +0 -371
  70. package/dist/test/history.test.js +0 -26
  71. package/dist/test/integration.test.js +0 -1775
  72. package/dist/test/pagination.property.test.js +0 -471
  73. package/src/budget.ts +0 -67
  74. package/src/notes/model.ts +0 -109
  75. package/src/prompts.ts +0 -91
  76. package/src/reset-lifecycle.ts +0 -173
  77. package/src/thresholds.ts +0 -110
  78. package/src/warning.ts +0 -46
@@ -1,471 +0,0 @@
1
- /**
2
- * Property-based pagination tests for the four paginating pi-context tools:
3
- * history_list, history_search, notes_search,
4
- * notes_list.
5
- *
6
- * Each case is generated from a seed, so a failure names its seed and reproduces by
7
- * re-running that one seed:
8
- *
9
- * PI_CONTEXT_PROPERTY_SEED=137 npm test
10
- *
11
- * Invariants asserted per tool across random session shapes x page caps x budgets:
12
- * 1. enumeration complete: concatenated pages equal the expected ordered set
13
- * 2. no duplicates across pages
14
- * 3. cursors strictly advance: no cycle, no repeated page, no empty non-terminal page
15
- * 4. next_cursor is null only at the true end
16
- * 5. every serialized page stays within the 32 KiB tool-output budget
17
- *
18
- * Expected sets come from the underlying stores (historyFromSession / notesFromSession)
19
- * combined with the tools' documented filters -- never from the pagination code under test.
20
- */
21
- import assert from "node:assert/strict";
22
- import test from "node:test";
23
- import { historyFromSession } from "../src/index.js";
24
- import { listNotes, searchNotes } from "../src/notes/store.js";
25
- import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
26
- import { MAX_NOTE_PATH_BYTES } from "../src/protocol.js";
27
- import { appendText, call, context, makeExtension, manager, resultJson } from "./integration.test.js";
28
- const NEEDLE = "PAGE_NEEDLE";
29
- /** Default seed corpus; PI_CONTEXT_PROPERTY_SEED=<n|n,n,...> re-runs exactly those seeds. */
30
- const DEFAULT_SEEDS = [11, 23, 37, 51, 67, 89, 101, 137, 173, 211, 251, 307];
31
- const OVERRIDE = process.env.PI_CONTEXT_PROPERTY_SEED
32
- ?.split(",")
33
- .map((part) => Number(part.trim()))
34
- .filter((value) => Number.isFinite(value));
35
- const SEEDS = OVERRIDE && OVERRIDE.length > 0 ? OVERRIDE : DEFAULT_SEEDS;
36
- /** Deterministic PRNG (mulberry32): the whole generated case is a pure function of the seed. */
37
- class Rng {
38
- state;
39
- constructor(seed) {
40
- this.state = seed >>> 0;
41
- }
42
- next() {
43
- this.state = (this.state + 0x6d2b79f5) | 0;
44
- let t = this.state;
45
- t = Math.imul(t ^ (t >>> 15), 1 | t);
46
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
47
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
48
- }
49
- int(min, max) {
50
- return min + Math.floor(this.next() * (max - min + 1));
51
- }
52
- bool(probability = 0.5) {
53
- return this.next() < probability;
54
- }
55
- pick(values) {
56
- return values[this.int(0, values.length - 1)];
57
- }
58
- }
59
- const TOOL_NAMES = ["bash", "notes_read", "notes_write", "history_list", "history_search", "web_search", "mcp_tool_call", "read", "_odd"];
60
- const CONTENT_LIMITS = [1, 5, 60, 1200, 24_000, 50_000];
61
- const PAGE_LIMITS = [1, 2, 3, 5, 8, 13, 34, 200];
62
- const ROLE_FILTERS = [null, "user", "assistant", "tool_call", "tool", "system", "developer"];
63
- const NAME_FILTERS = [null, "bash", "notes_read", "history_list", "read", "_odd", "mcp_tool_call"];
64
- function makeContent(rng, large) {
65
- const shape = large ? rng.pick(["large", "large", "medium", "needle"]) : rng.pick(["empty", "tiny", "tiny", "needle", "medium"]);
66
- switch (shape) {
67
- case "empty": return "";
68
- case "tiny": return rng.pick(["", "x", "hello world", "日本語のテキスト", "line one\nline two", NEEDLE, `${NEEDLE} ${NEEDLE}`]);
69
- case "needle": return `${NEEDLE} item ${rng.int(0, 9999)}`;
70
- case "medium": return rng.bool() ? `${NEEDLE}\n${"m".repeat(rng.int(400, 4000))}` : `${"m".repeat(rng.int(400, 4000))}\n${NEEDLE}`;
71
- case "large": return `${NEEDLE}\n${"L".repeat(rng.int(24_000, 70_000))}`;
72
- }
73
- }
74
- function historyPlan(seed) {
75
- const rng = new Rng(seed * 2 + 1);
76
- const count = rng.pick([0, 1, 1, 2, 3, 5, 8, 13, 21]);
77
- const entries = [];
78
- let compactions = 0;
79
- for (let index = 0; index < count; index++) {
80
- // A compaction entry splits the branch into a new window.
81
- if (index > 0 && rng.bool(0.12)) {
82
- compactions++;
83
- entries.push({ kind: "compaction", summary: makeContent(rng, rng.bool(0.2)) });
84
- }
85
- const roll = rng.next();
86
- if (roll < 0.12) {
87
- entries.push({ kind: "custom_message", content: makeContent(rng, rng.bool(0.15)) });
88
- }
89
- else {
90
- const role = roll < 0.45 ? "user" : roll < 0.75 ? "assistant" : "toolResult";
91
- entries.push({ kind: "message", role, toolName: role === "toolResult" ? rng.pick(TOOL_NAMES) : "bash", content: makeContent(rng, rng.bool(0.18)) });
92
- }
93
- }
94
- // Rare pathological shape on a deterministic subset of seeds: a tool result whose 40 KB
95
- // tool_name only fits once the metadata is middle-truncated. Inserted at the head so the
96
- // oversized item is the first item of its page, and a fixed size keeps later rng draws
97
- // (and therefore the filters) unchanged.
98
- if (seed % 3 === 0)
99
- entries.unshift({ kind: "message", role: "toolResult", toolName: `oversized_${"t".repeat(40_000)}`, content: "oversized tool_name probe" });
100
- const windowCount = compactions + 1;
101
- const variant = (label, query) => ({
102
- label,
103
- query,
104
- windowIndex: rng.bool(0.3) ? rng.int(0, windowCount - 1) : null,
105
- role: rng.pick(ROLE_FILTERS),
106
- toolName: rng.pick(NAME_FILTERS),
107
- recentFirst: rng.bool(),
108
- limit: rng.pick(PAGE_LIMITS),
109
- maxCharsPerItem: rng.pick(CONTENT_LIMITS),
110
- });
111
- // Variant 0 of each list is deliberately unfiltered: it must enumerate the whole set.
112
- const list = [
113
- { label: "unfiltered", windowIndex: null, role: null, toolName: null, recentFirst: false, limit: 200, maxCharsPerItem: rng.pick(CONTENT_LIMITS) },
114
- variant("list-1"),
115
- variant("list-2"),
116
- variant("list-3"),
117
- ];
118
- const search = [
119
- { label: "needle-unfiltered", query: NEEDLE, windowIndex: null, role: null, toolName: null, recentFirst: false, limit: 200, maxCharsPerItem: rng.pick(CONTENT_LIMITS) },
120
- variant("search-1", rng.pick([NEEDLE, "line", "no-such-token", "…"])),
121
- variant("search-2", rng.pick([NEEDLE, "x", "item", "_odd"])),
122
- variant("search-3", rng.pick([NEEDLE, "日本語", "m", "L"])),
123
- ];
124
- return { seed, entries, windowCount, list, search };
125
- }
126
- function materializeHistory(session, plan) {
127
- for (const entry of plan.entries) {
128
- if (entry.kind === "message")
129
- appendText(session, entry.role, entry.content, entry.toolName);
130
- else if (entry.kind === "custom_message")
131
- session.appendCustomMessageEntry("pi-context/property", entry.content, false);
132
- else
133
- session.appendCompaction(entry.summary, session.getLeafId() ?? "property-root", 1000);
134
- }
135
- }
136
- function historyParams(ctx, variant) {
137
- const windows = historyFromSession(ctx);
138
- const windowId = variant.windowIndex === null ? null : windows[variant.windowIndex]?.windowId ?? null;
139
- return {
140
- limit: variant.limit,
141
- recent_first: variant.recentFirst,
142
- role: variant.role,
143
- tool_name: variant.toolName,
144
- window_id: windowId,
145
- max_chars_per_item: variant.maxCharsPerItem,
146
- };
147
- }
148
- /** The documented filter order (filteredItems), reimplemented over the store, never over page(). */
149
- function storeHistoryItems(ctx, params) {
150
- let items = historyFromSession(ctx).flatMap((window) => window.items);
151
- if (typeof params.window_id === "string")
152
- items = items.filter((item) => item.windowId === params.window_id);
153
- if (typeof params.role === "string")
154
- items = items.filter((item) => item.role === params.role);
155
- if (typeof params.tool_name === "string")
156
- items = items.filter((item) => item.toolName === params.tool_name);
157
- if (params.recent_first !== false)
158
- items = [...items].reverse();
159
- return items;
160
- }
161
- function makeNotePath(rng, index) {
162
- const dir = rng.pick(["", "notes/", "deep/nested/dir/", "unicode-日本語/"]);
163
- const name = rng.pick([`f${index}.md`, `long-${"x".repeat(rng.int(1, 80))}-${index}.md`, `note ${index}.md`, `ünïcode-${index}.md`, `checkpoint-${index}.md`]);
164
- return `${dir}${name}`;
165
- }
166
- function makeNoteText(rng) {
167
- const shape = rng.pick(["empty", "tiny", "needle", "lines", "huge-line", "huge-lines"]);
168
- switch (shape) {
169
- case "empty": return "";
170
- case "tiny": return rng.pick(["small note", "needle", "one\ntwo"]);
171
- case "needle": return `${NEEDLE} ${rng.int(0, 999)}\nsecond line`;
172
- case "lines": return Array.from({ length: rng.int(2, 40) }, (_, line) => `${rng.bool(0.4) ? `${NEEDLE} ` : ""}line ${line} ${"z".repeat(rng.int(0, 80))}`).join("\n");
173
- case "huge-line": return `${NEEDLE} ${"H".repeat(rng.int(24_000, 70_000))}`;
174
- case "huge-lines": return Array.from({ length: rng.int(2, 5) }, (_, line) => `${NEEDLE} line ${line} ${"G".repeat(rng.int(8_000, 20_000))}`).join("\n");
175
- }
176
- }
177
- function notesPlan(seed) {
178
- const rng = new Rng(seed * 4 + 3);
179
- const writes = [];
180
- const fileCount = rng.pick([0, 1, 2, 3, 6, 11, 17]);
181
- const used = new Set();
182
- for (let index = 0; index < fileCount; index++) {
183
- const path = makeNotePath(rng, index);
184
- if (used.has(path))
185
- continue;
186
- used.add(path);
187
- writes.push({ path, body: makeNoteText(rng) });
188
- }
189
- const patterns = [null, "", "**", "*.md", "**.md", "notes/*", "notes", "deep/**", "deep/nested", "unicode-日本語/*", "checkpoint-*", "absent*", "f?.md"];
190
- const list = [
191
- { label: "all", pattern: null, maxResults: 200 },
192
- { label: "paged-1", pattern: rng.pick(patterns), maxResults: rng.pick([1, 2, 3, 5]) },
193
- { label: "paged-2", pattern: rng.pick(patterns), maxResults: rng.pick([1, 3, 7, 200]) },
194
- { label: "paged-3", pattern: rng.pick(patterns), maxResults: rng.pick([2, 4, 200]) },
195
- ];
196
- const search = [
197
- { label: "needle-all", query: NEEDLE, pattern: null, maxFiles: 200, maxMatchesPerFile: 100 },
198
- { label: "needle-paged", query: NEEDLE, pattern: null, maxFiles: rng.pick([1, 2, 3]), maxMatchesPerFile: rng.pick([1, 2, 5, 100]) },
199
- { label: "rare-query", query: rng.pick(["line 3", "z", "日本語", "absent-token", "…"]), pattern: rng.pick(patterns), maxFiles: rng.pick([1, 5, 200]), maxMatchesPerFile: rng.pick([1, 100]) },
200
- ];
201
- return { seed, writes, list, search };
202
- }
203
- async function materializeNotes(plan, captured, ctx) {
204
- for (const write of plan.writes) {
205
- const result = resultJson(await call(captured, "notes_write", { path: write.path, content: write.body }, ctx));
206
- assert.equal(result.error, undefined, `seed=${plan.seed}: write ${write.path}`);
207
- }
208
- }
209
- /**
210
- * The store's own enumeration is the oracle for membership and order here; the pagination
211
- * layer (page()) is what is under test, and walkPages asserts it enumerates exactly this set.
212
- */
213
- function expectedListRows(ctx, variant) {
214
- return listNotes(ctx, { pattern: variant.pattern ?? undefined });
215
- }
216
- function expectedSearchRows(ctx, variant) {
217
- return searchNotes(ctx, [variant.query], { pattern: variant.pattern ?? undefined });
218
- }
219
- // ---------------------------------------------------------------------------
220
- // Paging driver
221
- // ---------------------------------------------------------------------------
222
- function rawText(result) {
223
- const part = result.content[0];
224
- if (!part || part.type !== "text")
225
- throw new Error("tool result carries text");
226
- return part.text;
227
- }
228
- /**
229
- * Follow next_cursor to the true end, asserting invariants 1-5 for every page.
230
- * `expected` is the full ordered id sequence the store says the tool must enumerate.
231
- * `idsOf` receives the page and the page's starting cursor (its index into the expected
232
- * ordered set), so an identity-paginating tool can map a visibly truncated identity back
233
- * to the expected one instead of pretending it was never returned.
234
- */
235
- async function walkPages(options) {
236
- const { captured, ctx, tool, params, idsOf, expected, label } = options;
237
- const seen = new Set();
238
- const cursors = new Set();
239
- const collected = [];
240
- const pages = [];
241
- let cursor = 0;
242
- let next = 0;
243
- const guard = expected.length + 4;
244
- while (next !== null) {
245
- assert.ok(pages.length < guard, `${label}: pagination exceeded ${guard} pages (cursor=${cursor}); cursor cycle or stall, collected ${collected.length}/${expected.length}`);
246
- const result = await call(captured, tool, { ...params, cursor }, ctx);
247
- const text = rawText(result);
248
- const bytes = Buffer.byteLength(text, "utf8");
249
- assert.ok(bytes <= TOOL_OUTPUT_MAX_BYTES, `${label} cursor=${cursor}: serialized page is ${bytes} bytes, over the ${TOOL_OUTPUT_MAX_BYTES}-byte budget`);
250
- const page = resultJson(result);
251
- assert.ok(page.next_cursor === null || Number.isInteger(page.next_cursor), `${label} cursor=${cursor}: next_cursor is an integer or null`);
252
- const ids = idsOf(page, cursor);
253
- for (const id of ids) {
254
- assert.equal(seen.has(id), false, `${label} cursor=${cursor}: duplicate id ${id} across pages`);
255
- seen.add(id);
256
- }
257
- collected.push(...ids);
258
- if (page.next_cursor === null) {
259
- assert.equal(collected.length, expected.length, `${label} cursor=${cursor}: next_cursor is null but ${expected.length - collected.length} of ${expected.length} items are unenumerated`);
260
- }
261
- else {
262
- assert.ok(ids.length > 0, `${label} cursor=${cursor}: non-terminal page is empty (stall)`);
263
- assert.ok(page.next_cursor > cursor, `${label}: cursor did not strictly advance (${cursor} -> ${page.next_cursor})`);
264
- assert.equal(cursors.has(page.next_cursor), false, `${label}: cursor ${page.next_cursor} was revisited`);
265
- cursors.add(page.next_cursor);
266
- }
267
- assert.ok(collected.length <= expected.length, `${label} cursor=${cursor}: enumerated ${collected.length} items, more than the expected ${expected.length}`);
268
- pages.push(page);
269
- next = page.next_cursor;
270
- if (next !== null)
271
- cursor = next;
272
- }
273
- assert.deepEqual(collected, [...expected], `${label}: concatenated pages differ from the expected enumeration`);
274
- return pages;
275
- }
276
- const TRUNCATION_MARKER = /^([\s\S]*)…\[truncated \d+ chars\]…([\s\S]*)$/;
277
- /**
278
- * A truncated identity is only legitimate when it is visibly a middle-truncation of the
279
- * expected store path: same head, same tail, and strictly fewer characters. This is what
280
- * keeps `path` from being silently mangled.
281
- */
282
- function assertTruncatedIdentity(expectedPath, actual, label) {
283
- const match = TRUNCATION_MARKER.exec(actual);
284
- assert.ok(match, `${label}: truncated path carries the …[truncated N chars]… marker`);
285
- const head = match[1];
286
- const tail = match[2];
287
- const expectedChars = Array.from(expectedPath);
288
- const headChars = Array.from(head);
289
- const tailChars = Array.from(tail);
290
- assert.equal(expectedChars.slice(0, headChars.length).join(""), head, `${label}: truncated path keeps the original head`);
291
- assert.equal(expectedChars.slice(expectedChars.length - tailChars.length).join(""), tail, `${label}: truncated path keeps the original tail`);
292
- assert.ok(headChars.length + tailChars.length < expectedChars.length, `${label}: truncation actually removes characters`);
293
- }
294
- /**
295
- * Map a notes page entry's address back to the expected address. A non-truncated address must
296
- * equal it; a flagged address must be a visible middle-truncation of a legacy address that the
297
- * write cap could never have produced. The expected address is returned either way so the
298
- * pagination invariants compare like with like.
299
- */
300
- function notePathIdentity(expectedPaths, cursor, label, page, key) {
301
- return page[key].map((file, index) => {
302
- const expectedPath = expectedPaths[cursor + index];
303
- assert.ok(expectedPath !== undefined, `${label} cursor=${cursor}: page returned more entries than the store holds`);
304
- if (file.address_truncated) {
305
- assert.ok(Buffer.byteLength(expectedPath, "utf8") > MAX_NOTE_PATH_BYTES, `${label} cursor=${cursor}: only a legacy address beyond the write cap may be truncated, got ${file.address}`);
306
- assertTruncatedIdentity(expectedPath, file.address, `${label} cursor=${cursor}`);
307
- }
308
- else {
309
- assert.equal(file.address, expectedPath, `${label} cursor=${cursor}: address is returned intact when its entry fits`);
310
- }
311
- return expectedPath;
312
- });
313
- }
314
- // ---------------------------------------------------------------------------
315
- // Properties
316
- // ---------------------------------------------------------------------------
317
- /** Run one generated case per seed, naming the seed in any thrown failure so it reproduces alone. */
318
- async function runSeeds(label, body) {
319
- for (const seed of SEEDS) {
320
- try {
321
- await body(seed);
322
- }
323
- catch (error) {
324
- const detail = error instanceof Error ? error.message : String(error);
325
- throw new Error(`${label} failed at seed=${seed} (re-run with PI_CONTEXT_PROPERTY_SEED=${seed}): ${detail}`, { cause: error });
326
- }
327
- }
328
- }
329
- test("generators are deterministic: the same seed replays the same shapes", () => {
330
- for (const seed of SEEDS) {
331
- assert.deepEqual(historyPlan(seed), historyPlan(seed), `historyPlan(seed=${seed}) replays identically`);
332
- assert.deepEqual(notesPlan(seed), notesPlan(seed), `notesPlan(seed=${seed}) replays identically`);
333
- }
334
- assert.notDeepEqual(historyPlan(DEFAULT_SEEDS[0]), historyPlan(DEFAULT_SEEDS[1]), "different seeds generate different history shapes");
335
- assert.notDeepEqual(notesPlan(DEFAULT_SEEDS[0]), notesPlan(DEFAULT_SEEDS[1]), "different seeds generate different notes shapes");
336
- // The rare pathological history shape is reachable from the committed corpus.
337
- const historyEntries = DEFAULT_SEEDS.flatMap((seed) => historyPlan(seed).entries);
338
- assert.ok(historyEntries.some((entry) => entry.kind === "message" && Buffer.byteLength(entry.toolName, "utf8") > TOOL_OUTPUT_MAX_BYTES), "some committed seed generates an oversized tool_name");
339
- const noteWrites = DEFAULT_SEEDS.flatMap((seed) => notesPlan(seed).writes);
340
- assert.ok(noteWrites.some((write) => write.body.includes(NEEDLE)), "some committed seed generates a needle body");
341
- });
342
- test("history_list enumerates every item across seeded session shapes", async () => {
343
- console.log(`pagination property seeds: ${SEEDS.join(", ")}`);
344
- await runSeeds("history_list", async (seed) => {
345
- const plan = historyPlan(seed);
346
- const session = manager();
347
- const captured = makeExtension(session);
348
- const ctx = context(session);
349
- materializeHistory(session, plan);
350
- const windows = historyFromSession(ctx);
351
- assert.equal(windows.length, plan.windowCount, `seed=${seed}: generated ${plan.windowCount} windows`);
352
- for (const variant of plan.list) {
353
- const params = historyParams(ctx, variant);
354
- // A role×tool_name combination the taxonomy proves empty is a named error, not a page.
355
- if (variant.toolName !== null && variant.role !== null && variant.role !== "tool_call" && variant.role !== "tool") {
356
- const dead = resultJson(await call(captured, "history_list", params, ctx));
357
- assert.ok(dead.error?.includes("only set on"), `history_list seed=${seed} ${variant.label} role=${variant.role} tool_name=${variant.toolName}: vacuous combo must be a named error`);
358
- continue;
359
- }
360
- const expected = storeHistoryItems(ctx, params).map((item) => item.itemId);
361
- await walkPages({
362
- captured, ctx, tool: "history_list", params,
363
- idsOf: (page) => page.items.map((item) => item.item_id),
364
- expected,
365
- label: `history_list seed=${seed} ${variant.label} windowIndex=${variant.windowIndex} role=${variant.role} tool_name=${variant.toolName} recent_first=${variant.recentFirst} limit=${variant.limit} max_chars_per_item=${variant.maxCharsPerItem}`,
366
- });
367
- }
368
- });
369
- });
370
- test("history_search enumerates every match across seeded session shapes", async () => {
371
- await runSeeds("history_search", async (seed) => {
372
- const plan = historyPlan(seed);
373
- const session = manager();
374
- const captured = makeExtension(session);
375
- const ctx = context(session);
376
- materializeHistory(session, plan);
377
- for (const variant of plan.search) {
378
- const params = { ...historyParams(ctx, variant), query: variant.query ?? "" };
379
- if (variant.toolName !== null && variant.role !== null && variant.role !== "tool_call" && variant.role !== "tool") {
380
- const dead = resultJson(await call(captured, "history_search", params, ctx));
381
- assert.ok(dead.error?.includes("only set on"), `history_search seed=${seed} ${variant.label} role=${variant.role} tool_name=${variant.toolName}: vacuous combo must be a named error`);
382
- continue;
383
- }
384
- const expected = storeHistoryItems(ctx, params).filter((item) => item.content.includes(params.query)).map((item) => item.itemId);
385
- await walkPages({
386
- captured, ctx, tool: "history_search", params,
387
- idsOf: (page) => page.items.map((item) => item.item_id),
388
- expected,
389
- label: `history_search seed=${seed} ${variant.label} query=${JSON.stringify(params.query)} windowIndex=${variant.windowIndex} role=${variant.role} tool_name=${variant.toolName} recent_first=${variant.recentFirst} limit=${variant.limit} max_chars_per_item=${variant.maxCharsPerItem}`,
390
- });
391
- }
392
- });
393
- });
394
- test("notes_list enumerates every note file across seeded mixes", async () => {
395
- await runSeeds("notes_list", async (seed) => {
396
- const plan = notesPlan(seed);
397
- const session = manager();
398
- const captured = makeExtension(session);
399
- const ctx = context(session);
400
- await materializeNotes(plan, captured, ctx);
401
- const all = new Map(listNotes(ctx, {}).map((row) => [row.address, row]));
402
- for (const variant of plan.list) {
403
- const params = { pattern: variant.pattern, max_results: variant.maxResults };
404
- const expected = expectedListRows(ctx, variant).map((row) => row.address);
405
- const label = `notes_list seed=${seed} ${variant.label} pattern=${JSON.stringify(variant.pattern)} max_results=${variant.maxResults}`;
406
- const pages = await walkPages({
407
- captured, ctx, tool: "notes_list", params,
408
- idsOf: (page, cursor) => notePathIdentity(expected, cursor, label, page, "files"),
409
- expected,
410
- label,
411
- });
412
- // Each listed file must describe the store's file exactly, not a stale or invented one.
413
- let flat = 0;
414
- for (const page of pages) {
415
- for (const file of page.files) {
416
- const address = expected[flat++];
417
- const row = all.get(address);
418
- assert.ok(row, `${label}: listed ${address} is not in the note store`);
419
- assert.deepEqual(Object.keys(file).sort(), file.address_truncated ? ["address", "address_truncated", "stale", "updated_at"] : ["address", "stale", "updated_at"]);
420
- assert.equal(file.stale, row.meta.stale, `${label}: stale for ${address}`);
421
- assert.equal(Date.parse(file.updated_at), row.meta.updated_at, `${label}: updated_at for ${address}`);
422
- }
423
- }
424
- }
425
- });
426
- });
427
- test("notes_search enumerates every matching file across seeded mixes", async () => {
428
- await runSeeds("notes_search", async (seed) => {
429
- const plan = notesPlan(seed);
430
- const session = manager();
431
- const captured = makeExtension(session);
432
- const ctx = context(session);
433
- await materializeNotes(plan, captured, ctx);
434
- const bodies = new Map(plan.writes.map((write) => [write.path, write.body]));
435
- for (const variant of plan.search) {
436
- const params = { query: variant.query, pattern: variant.pattern, max_files: variant.maxFiles, max_matches_per_file: variant.maxMatchesPerFile };
437
- const expectedRows = expectedSearchRows(ctx, variant);
438
- const expected = expectedRows.map((row) => row.address);
439
- const expectedByAddress = new Map(expectedRows.map((row) => [row.address, row]));
440
- const label = `notes_search seed=${seed} ${variant.label} query=${JSON.stringify(variant.query)} pattern=${JSON.stringify(variant.pattern)} max_files=${variant.maxFiles} max_matches_per_file=${variant.maxMatchesPerFile}`;
441
- const pages = await walkPages({
442
- captured, ctx, tool: "notes_search", params,
443
- idsOf: (page, cursor) => notePathIdentity(expected, cursor, label, page, "files"),
444
- expected,
445
- label,
446
- });
447
- // Matches are a prefix of the file's real matching lines (never invented, never reordered).
448
- let flat = 0;
449
- for (const page of pages) {
450
- for (const file of page.files) {
451
- const address = expected[flat++];
452
- const storePath = address;
453
- const body = bodies.get(storePath);
454
- assert.ok(body !== undefined, `${label}: reported ${storePath} was never written`);
455
- const lines = body.split("\n");
456
- const matchingLines = lines.flatMap((line, index) => line.includes(variant.query) ? [index + 1] : []);
457
- assert.ok(file.matches.length >= 1, `${label}: ${storePath} reports no matches but appears in the result`);
458
- assert.ok(file.matches.length <= Math.min(matchingLines.length, variant.maxMatchesPerFile), `${label}: ${storePath} reports ${file.matches.length} matches beyond its cap`);
459
- assert.deepEqual(file.matches.map((match) => match.line), matchingLines.slice(0, file.matches.length), `${label}: ${storePath} match lines are not the first matching lines`);
460
- const expectedMatches = expectedByAddress.get(address)?.matches;
461
- assert.ok(expectedMatches, `${label}: ${address} is absent from the store search`);
462
- for (const [index, match] of file.matches.entries()) {
463
- const line = lines[match.line - 1];
464
- assert.ok(line.includes(variant.query), `${label}: ${storePath}:${match.line} does not contain the query`);
465
- assert.equal(match.offset_chars, expectedMatches[index]?.offsetChars, `${label}: ${storePath}:${match.line} offset_chars does not address the serialized read stream`);
466
- }
467
- }
468
- }
469
- }
470
- });
471
- });
package/src/budget.ts DELETED
@@ -1,67 +0,0 @@
1
- import { Type } from "@earendil-works/pi-ai";
2
- import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { GUIDANCE_TYPE, WARNING_TYPE } from "./protocol.js";
4
- import { thresholdsFor, resetThresholds } from "./thresholds.js";
5
- import { currentWindowId, hasWindowMessage } from "./history.js";
6
- import { tokenBudgetGuidance } from "./prompts.js";
7
- import { output } from "./tool-output.js";
8
-
9
- /** Remaining tokens in the current context window, or null when Pi has no usage estimate. */
10
- export function remainingTokens(ctx: Pick<ExtensionContext, "getContextUsage">): number | null {
11
- const usage = ctx.getContextUsage();
12
- return !usage || usage.tokens === null ? null : Math.max(0, usage.contextWindow - usage.tokens);
13
- }
14
-
15
- export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean) {
16
- let guidancePersistedInWindow: string | undefined;
17
-
18
- pi.on("session_start", (_event, ctx) => { guidancePersistedInWindow = undefined; resetThresholds(); thresholdsFor(ctx); });
19
- pi.on("session_tree", () => { guidancePersistedInWindow = undefined; resetThresholds(); });
20
- pi.on("context", (_event, ctx) => {
21
- if (!isEnabled() || hasWindowMessage(ctx, GUIDANCE_TYPE)) return undefined;
22
- // The early reminder persists once per window the first time remaining crosses
23
- // reserve+margin. It never edits the outgoing request.
24
- const remaining = remainingTokens(ctx);
25
- if (remaining === null) return undefined;
26
- const windowId = currentWindowId(ctx);
27
- const { reminder, reserve, warning } = thresholdsFor(ctx);
28
- // The final warning owns the deep band: when it has fired (or is due now),
29
- // the shallow reminder would only repeat the same instruction closer to
30
- // the wipe, at a worse position. See warning.ts.
31
- if (remaining <= warning || hasWindowMessage(ctx, WARNING_TYPE)) return undefined;
32
- if (remaining <= reminder && guidancePersistedInWindow !== windowId) {
33
- guidancePersistedInWindow = windowId;
34
- // Persist once per window — no transient copy. A transient bridge would
35
- // cover the crossing request, but history would record the reminder after
36
- // that request's assistant reply, so across the boundary the model would
37
- // meet the same text twice at shifted positions. The reminder is an early
38
- // warning, not a per-request instruction: arriving from the next request
39
- // on (sendMessage defers safely to end of turn while streaming, queueing
40
- // instead of splitting a tool call/result pair) costs nothing, and the
41
- // model's view stays identical to recorded history, Codex-style.
42
- // The persisted copy stays out of the TUI (display: false); one ephemeral
43
- // notify tells the user instead — visible to the human, invisible to the
44
- // model, and never recorded, so history and the model's view don't diverge.
45
- // The model-facing count ends at the warning line: what lies below is the
46
- // runway, invisible by design. The human's notify keeps the honest count.
47
- const left = Math.max(0, remaining - warning);
48
- pi.sendMessage({ customType: GUIDANCE_TYPE, content: tokenBudgetGuidance(left), display: false }, { triggerTurn: false });
49
- ctx.ui.notify(`pi-context: context budget low (${Math.max(0, remaining - reserve)} tokens before reserve) — checkpoint reminder recorded for the model, kept out of the chat view.`, "warning");
50
- }
51
- return undefined;
52
- });
53
-
54
- pi.registerTool(defineTool({
55
- name: "get_context_remaining",
56
- label: "Get context remaining",
57
- description: "Return estimated context tokens left before your memory is wiped; null when Pi cannot estimate usage.",
58
- parameters: Type.Object({}, { additionalProperties: false }),
59
- async execute(_id, _params, _signal, _update, ctx) {
60
- // The countdown the model sees ends at the warning line (reserve + runway);
61
- // the runway below it is overdraft the model never sees. See protocol.ts.
62
- const remaining = remainingTokens(ctx);
63
- return output({ remaining_tokens: remaining === null ? null : Math.max(0, remaining - thresholdsFor(ctx as ExtensionContext).warning) });
64
- },
65
- }));
66
-
67
- }
@@ -1,109 +0,0 @@
1
- import type { SessionReader } from "../session-reader.js";
2
- import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
3
-
4
- export type NoteFile = { text: string; stale: boolean; createdAt: number; updatedAt: number };
5
- export type NoteOperation = {
6
- op: "write" | "append";
7
- path: string;
8
- // Both are optional on the wire so mark-only and explicit-revive operations replay:
9
- // at least one of text/stale is present, enforced by the note tools and isNoteOperation.
10
- text?: string;
11
- stale?: boolean;
12
- createdAt: number;
13
- updatedAt: number;
14
- };
15
- export function assertVirtualPath(value: unknown): string {
16
- if (typeof value !== "string" || value.length === 0) throw new Error("path must be a non-empty virtual relative path");
17
- if (value.includes("\0") || value.includes("\\") || value.startsWith("/")) throw new Error("path must be a safe virtual relative path");
18
- const parts = value.split("/");
19
- if (parts.some((part) => part.length === 0 || part === "." || part === "..")) throw new Error("path contains an unsupported component");
20
- return value;
21
- }
22
-
23
- /**
24
- * Minimal glob over virtual note paths: `*` matches any run within a segment (never
25
- * `/`), `**` matches any run across segments (a leading double-star followed by a
26
- * slash also matches zero segments, so it covers the root too), `?` matches exactly
27
- * one non-`/` character. Everything else is literal and the match is anchored to the
28
- * whole path.
29
- */
30
- export function globToRegExp(pattern: string): RegExp {
31
- let source = "^";
32
- for (let index = 0; index < pattern.length; index++) {
33
- const char = pattern[index]!;
34
- if (char === "*") {
35
- if (pattern[index + 1] === "*") {
36
- const followedBySlash = pattern[index + 2] === "/";
37
- source += followedBySlash ? "(?:[^]*\\/)?" : "[^]*";
38
- index += followedBySlash ? 2 : 1;
39
- } else {
40
- source += "[^/]*";
41
- }
42
- } else {
43
- source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
44
- }
45
- }
46
- return new RegExp(`${source}$`);
47
- }
48
-
49
- /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
50
- export function assertGlobPattern(value: unknown): string | undefined {
51
- if (value === undefined || value === null || value === "") return undefined;
52
- if (typeof value !== "string") throw new Error("glob pattern must be a string");
53
- if (value.includes("\0") || value.includes("\\")) throw new Error("glob pattern must not contain NUL or backslashes");
54
- return value;
55
- }
56
-
57
- /** Replays only pi-context note operations from session custom entries. */
58
- function isNoteOperation(data: unknown): data is NoteOperation {
59
- if (typeof data !== "object" || data === null) return false;
60
- const op = data as Partial<NoteOperation>;
61
- return (
62
- (op.op === "write" || op.op === "append") &&
63
- typeof op.path === "string" &&
64
- (op.text === undefined || typeof op.text === "string") &&
65
- (op.stale === undefined || typeof op.stale === "boolean") &&
66
- (op.text !== undefined || op.stale !== undefined) &&
67
- typeof op.createdAt === "number" && Number.isFinite(new Date(op.createdAt).getTime()) &&
68
- typeof op.updatedAt === "number" && Number.isFinite(new Date(op.updatedAt).getTime())
69
- );
70
- }
71
-
72
- export function notesFromSession(ctx: SessionReader): Map<string, NoteFile> {
73
- const files = new Map<string, NoteFile>();
74
- for (const entry of ctx.sessionManager.getBranch()) {
75
- if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !isNoteOperation(entry.data)) continue;
76
- const op = entry.data;
77
- try {
78
- assertVirtualPath(op.path);
79
- } catch {
80
- continue;
81
- }
82
- const previous = files.get(op.path);
83
- const hasText = op.text !== undefined;
84
- // A mark-only operation needs an existing note to change; without one it is a no-op.
85
- if (!hasText && !previous) continue;
86
- const text = hasText ? (op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text as string) : previous!.text;
87
- if (Buffer.byteLength(text, "utf8") > MAX_NOTE_BYTES) continue;
88
- // Carrying text revives unless the call also marks stale; a mark-only op keeps its flag.
89
- const stale = hasText ? op.stale ?? false : op.stale ?? previous!.stale;
90
- files.set(op.path, { text, stale, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
91
- }
92
- return files;
93
- }
94
-
95
- const pad2 = (value: number) => String(value).padStart(2, "0");
96
-
97
- /**
98
- * Format epoch milliseconds as an ISO 8601 string in the host's local time zone with an
99
- * explicit numeric offset (e.g. 2026-09-15T17:31:45.392+08:00). A UTC host renders
100
- * "+00:00"; the "Z" designator is never used, and Date.parse round-trips the value.
101
- */
102
- export function localIso(epochMs: number): string {
103
- const date = new Date(epochMs);
104
- const offsetMinutes = -date.getTimezoneOffset();
105
- const absOffset = Math.abs(offsetMinutes);
106
- const offset = `${offsetMinutes < 0 ? "-" : "+"}${pad2(Math.floor(absOffset / 60))}:${pad2(absOffset % 60)}`;
107
- const wallClock = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`;
108
- return `${wallClock}${offset}`;
109
- }