@astrosheep/pi-context 0.26.0 → 0.26.1

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.26.0",
3
- "sourceHash": "48dcba30fd66f729c046d9ec89214e69ada06592df8d464f3b8cc70562dd5174"
2
+ "version": "0.26.1",
3
+ "sourceHash": "fb313022cd1aab93e422144e8f336eced42b67c4d35b89a1133be2254f066093"
4
4
  }
package/dist/extension.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // <define:__PI_CONTEXT_BUILD__>
2
- var define_PI_CONTEXT_BUILD_default = { version: "0.26.0", sourceHash: "48dcba30fd66f729c046d9ec89214e69ada06592df8d464f3b8cc70562dd5174" };
2
+ var define_PI_CONTEXT_BUILD_default = { version: "0.26.1", sourceHash: "fb313022cd1aab93e422144e8f336eced42b67c4d35b89a1133be2254f066093" };
3
3
 
4
4
  // src/index.ts
5
5
  import { VERSION as VERSION2 } from "@earendil-works/pi-coding-agent";
@@ -8,6 +8,17 @@ import { VERSION as VERSION2 } from "@earendil-works/pi-coding-agent";
8
8
  import { Type as Type2 } from "@earendil-works/pi-ai";
9
9
  import { defineTool } from "@earendil-works/pi-coding-agent";
10
10
 
11
+ // src/text-match.ts
12
+ function earliestMatchOffsetChars(text, queries) {
13
+ let earliest = -1;
14
+ for (const query of queries) {
15
+ const index = text.indexOf(query);
16
+ if (index < 0) continue;
17
+ if (earliest < 0 || index < earliest) earliest = index;
18
+ }
19
+ return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
20
+ }
21
+
11
22
  // src/tool-output.ts
12
23
  var TOOL_OUTPUT_MAX_BYTES = 32 * 1024;
13
24
  var DEFAULT_READ_WINDOW_CHARS = 12e3;
@@ -76,15 +87,6 @@ chars: [${window.offset_chars},${end}) of ${window.total_chars}
76
87
  next_offset_chars: ${next}
77
88
  `;
78
89
  }
79
- function earliestMatchOffsetChars(text, queries) {
80
- let earliest = -1;
81
- for (const query of queries) {
82
- const index = text.indexOf(query);
83
- if (index < 0) continue;
84
- if (earliest < 0 || index < earliest) earliest = index;
85
- }
86
- return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
87
- }
88
90
  function page(items, cursor2, key, limit, truncate) {
89
91
  const end = Math.min(items.length, cursor2 + (limit ?? items.length));
90
92
  const selected = [];
@@ -1003,15 +1005,6 @@ function matchLineNumbers(body, needle) {
1003
1005
  }
1004
1006
  return lines;
1005
1007
  }
1006
- function earliestMatchOffsetChars2(text, queries) {
1007
- let earliest = -1;
1008
- for (const query of queries) {
1009
- const index = text.indexOf(query);
1010
- if (index < 0) continue;
1011
- if (earliest < 0 || index < earliest) earliest = index;
1012
- }
1013
- return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
1014
- }
1015
1008
  async function atomicWrite(path, content) {
1016
1009
  await mkdir(dirname2(path), { recursive: true });
1017
1010
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
@@ -1156,11 +1149,10 @@ function createNotesStore(input) {
1156
1149
  return { meta, applied: operations.length, resolvedScope: scope, change };
1157
1150
  });
1158
1151
  }
1159
- async function list(options = {}) {
1160
- const stableOptions = { ...options };
1161
- const matcher = matcherFor(normalizePattern(stableOptions.pattern, context));
1162
- const rows = [];
1163
- for (const home of await homesFor(context, stableOptions)) {
1152
+ async function* scan(options) {
1153
+ const matcher = matcherFor(normalizePattern(options.pattern, context));
1154
+ const homes = await homesFor(context, options);
1155
+ for (const home of homes) {
1164
1156
  const scope = home.scope;
1165
1157
  const root = scopeDir(scope, context, home.who);
1166
1158
  for (const path of await walkMarkdown(root)) {
@@ -1170,38 +1162,35 @@ function createNotesStore(input) {
1170
1162
  const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
1171
1163
  const { meta, body } = parseNote(raw);
1172
1164
  meta.scope = scope;
1173
- rows.push({ address, scope, path, meta, body, sizeBytes: Buffer.byteLength(body, "utf8") });
1165
+ yield { address, scope, path, meta, body };
1174
1166
  }
1175
1167
  }
1168
+ }
1169
+ async function list(options = {}) {
1170
+ const stableOptions = { ...options };
1171
+ const rows = [];
1172
+ for await (const row of scan(stableOptions)) {
1173
+ rows.push({ ...row, sizeBytes: Buffer.byteLength(row.body, "utf8") });
1174
+ }
1176
1175
  rows.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt || a.address.localeCompare(b.address));
1177
1176
  return rows;
1178
1177
  }
1179
1178
  async function search(queries, options = {}) {
1180
1179
  const stableQueries = [...queries];
1181
1180
  const stableOptions = { ...options };
1182
- const matcher = matcherFor(normalizePattern(stableOptions.pattern, context));
1183
1181
  const rows = [];
1184
- for (const home of await homesFor(context, stableOptions)) {
1185
- const scope = home.scope;
1186
- const root = scopeDir(scope, context, home.who);
1187
- for (const path of await walkMarkdown(root)) {
1188
- const address = addressFor(context, scope, path, home.who);
1189
- if (matcher && !matcher.test(address)) continue;
1190
- const fullPath = join2(root, path);
1191
- const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
1192
- const { meta, body } = parseNote(raw);
1193
- meta.scope = scope;
1194
- const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
1195
- let baseChars = 0;
1196
- const matches = [];
1197
- for (const [index, line] of body.split("\n").entries()) {
1198
- if (stableQueries.some((query) => line.includes(query))) {
1199
- matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars2(line, stableQueries) });
1200
- }
1201
- baseChars += Array.from(line).length + 1;
1182
+ for await (const note of scan(stableOptions)) {
1183
+ const { address, path, scope, meta, body } = note;
1184
+ const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
1185
+ let baseChars = 0;
1186
+ const matches = [];
1187
+ for (const [index, line] of body.split("\n").entries()) {
1188
+ if (stableQueries.some((query) => line.includes(query))) {
1189
+ matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, stableQueries) });
1202
1190
  }
1203
- if (matches.length > 0) rows.push({ address, path, scope, meta, matches });
1191
+ baseChars += Array.from(line).length + 1;
1204
1192
  }
1193
+ if (matches.length > 0) rows.push({ address, path, scope, meta, matches });
1205
1194
  }
1206
1195
  rows.sort((a, b) => a.address.localeCompare(b.address));
1207
1196
  return rows;
@@ -1556,14 +1545,29 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
1556
1545
  };
1557
1546
  let pendingGuidance;
1558
1547
  let pendingWarning;
1559
- let pendingNotices = [];
1560
- const notifyCommittedReminders = (ctx) => {
1561
- const windowId = currentWindowId(ctx);
1562
- for (const notice of pendingNotices) {
1563
- if (notice.windowId !== windowId || !hasWindowMessage(ctx, notice.customType)) continue;
1564
- ctx.ui.notify(notice.customType === WARNING_TYPE ? "pi-context: Context almost full; close out the current memory window." : "pi-context: Context running low; checkpoint your notes soon.", "warning");
1548
+ let pendingNotices = /* @__PURE__ */ new Map();
1549
+ const noticeKey = (sessionId, windowId) => `${sessionId}:${windowId}`;
1550
+ const warningCommittedInWindow = (ctx, notice) => {
1551
+ if (ctx.sessionManager.getSessionId() !== notice.sessionId) return false;
1552
+ let windowId = rootWindowId(notice.sessionId);
1553
+ for (const entry of ctx.sessionManager.getBranch()) {
1554
+ if (isWindowMarker(entry)) {
1555
+ windowId = entry.data.windowId;
1556
+ continue;
1557
+ }
1558
+ if (windowId === notice.windowId && entry.type === "custom_message" && entry.customType === WARNING_TYPE) return true;
1559
+ }
1560
+ return false;
1561
+ };
1562
+ const notifyCommittedWarnings = (ctx, settled = false) => {
1563
+ for (const [key, notice] of pendingNotices) {
1564
+ if (warningCommittedInWindow(ctx, notice)) {
1565
+ pendingNotices.delete(key);
1566
+ ctx.ui.notify("pi-context: Context almost full; close out the current memory window.", "warning");
1567
+ } else if (settled) {
1568
+ pendingNotices.delete(key);
1569
+ }
1565
1570
  }
1566
- pendingNotices = [];
1567
1571
  };
1568
1572
  const clearStaged = () => {
1569
1573
  pendingGuidance = void 0;
@@ -1571,7 +1575,7 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
1571
1575
  };
1572
1576
  const resetForTransition = () => {
1573
1577
  clearStaged();
1574
- pendingNotices = [];
1578
+ pendingNotices.clear();
1575
1579
  invalidateThresholds();
1576
1580
  notifiedWarnings.clear();
1577
1581
  };
@@ -1583,7 +1587,10 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
1583
1587
  clearStaged();
1584
1588
  const windowId = currentWindowId(ctx);
1585
1589
  const drafts = staged.filter((draft) => draft !== void 0 && draft.windowId === windowId);
1586
- pendingNotices = drafts.map(({ windowId: windowId2, customType, remaining }) => ({ windowId: windowId2, customType, remaining }));
1590
+ if (drafts.some((draft) => draft.customType === WARNING_TYPE)) {
1591
+ const sessionId = ctx.sessionManager.getSessionId();
1592
+ pendingNotices.set(noticeKey(sessionId, windowId), { sessionId, windowId });
1593
+ }
1587
1594
  return drafts.map((draft) => ({
1588
1595
  type: "custom_message",
1589
1596
  customType: draft.customType,
@@ -1598,8 +1605,9 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
1598
1605
  pi.on("session_tree", resetForTransition);
1599
1606
  pi.on("model_select", resetForTransition);
1600
1607
  pi.on("session_shutdown", resetForTransition);
1608
+ pi.on("turn_start", (_event, ctx) => notifyCommittedWarnings(ctx));
1601
1609
  pi.on("agent_settled", (_event, ctx) => {
1602
- notifyCommittedReminders(ctx);
1610
+ notifyCommittedWarnings(ctx, true);
1603
1611
  clearStaged();
1604
1612
  });
1605
1613
  pi.on("context", (_event, ctx) => {
@@ -1647,7 +1655,7 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
1647
1655
  consumeTurnEnd,
1648
1656
  clear: () => {
1649
1657
  clearStaged();
1650
- pendingNotices = [];
1658
+ pendingNotices.clear();
1651
1659
  }
1652
1660
  };
1653
1661
  }
@@ -2,7 +2,7 @@ import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool } from "@earendil-works/pi-coding-agent";
3
3
  import { GUIDANCE_TYPE, WARNING_CONTENT, WARNING_TYPE } from "../protocol.js";
4
4
  import { readThresholdSettings } from "./thresholds.js";
5
- import { currentWindowId, hasWindowMessage, windowUsage } from "./context-window.js";
5
+ import { currentWindowId, hasWindowMessage, isWindowMarker, rootWindowId, windowUsage } from "./context-window.js";
6
6
  import { tokenBudgetGuidance } from "./prompts.js";
7
7
  import { output } from "../tool-output.js";
8
8
  /** Remaining tokens in the provider's active window, or null without a usable estimate. */
@@ -42,17 +42,33 @@ export function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () =
42
42
  const invalidateThresholds = () => { cachedPolicy = undefined; };
43
43
  let pendingGuidance;
44
44
  let pendingWarning;
45
- let pendingNotices = [];
46
- const notifyCommittedReminders = (ctx) => {
47
- const windowId = currentWindowId(ctx);
48
- for (const notice of pendingNotices) {
49
- if (notice.windowId !== windowId || !hasWindowMessage(ctx, notice.customType))
45
+ let pendingNotices = new Map();
46
+ const noticeKey = (sessionId, windowId) => `${sessionId}:${windowId}`;
47
+ const warningCommittedInWindow = (ctx, notice) => {
48
+ if (ctx.sessionManager.getSessionId() !== notice.sessionId)
49
+ return false;
50
+ let windowId = rootWindowId(notice.sessionId);
51
+ for (const entry of ctx.sessionManager.getBranch()) {
52
+ if (isWindowMarker(entry)) {
53
+ windowId = entry.data.windowId;
50
54
  continue;
51
- ctx.ui.notify(notice.customType === WARNING_TYPE
52
- ? "pi-context: Context almost full; close out the current memory window."
53
- : "pi-context: Context running low; checkpoint your notes soon.", "warning");
55
+ }
56
+ if (windowId === notice.windowId && entry.type === "custom_message" && entry.customType === WARNING_TYPE)
57
+ return true;
58
+ }
59
+ return false;
60
+ };
61
+ const notifyCommittedWarnings = (ctx, settled = false) => {
62
+ for (const [key, notice] of pendingNotices) {
63
+ if (warningCommittedInWindow(ctx, notice)) {
64
+ pendingNotices.delete(key);
65
+ ctx.ui.notify("pi-context: Context almost full; close out the current memory window.", "warning");
66
+ }
67
+ else if (settled) {
68
+ // An uncommitted draft must not be matched to a later manual warning.
69
+ pendingNotices.delete(key);
70
+ }
54
71
  }
55
- pendingNotices = [];
56
72
  };
57
73
  const clearStaged = () => {
58
74
  pendingGuidance = undefined;
@@ -60,7 +76,7 @@ export function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () =
60
76
  };
61
77
  const resetForTransition = () => {
62
78
  clearStaged();
63
- pendingNotices = [];
79
+ pendingNotices.clear();
64
80
  invalidateThresholds();
65
81
  notifiedWarnings.clear();
66
82
  };
@@ -72,7 +88,10 @@ export function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () =
72
88
  clearStaged();
73
89
  const windowId = currentWindowId(ctx);
74
90
  const drafts = staged.filter((draft) => draft !== undefined && draft.windowId === windowId);
75
- pendingNotices = drafts.map(({ windowId, customType, remaining }) => ({ windowId, customType, remaining }));
91
+ if (drafts.some((draft) => draft.customType === WARNING_TYPE)) {
92
+ const sessionId = ctx.sessionManager.getSessionId();
93
+ pendingNotices.set(noticeKey(sessionId, windowId), { sessionId, windowId });
94
+ }
76
95
  return drafts.map((draft) => ({
77
96
  type: "custom_message",
78
97
  customType: draft.customType,
@@ -84,12 +103,12 @@ export function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () =
84
103
  pi.on("session_tree", resetForTransition);
85
104
  pi.on("model_select", resetForTransition);
86
105
  pi.on("session_shutdown", resetForTransition);
87
- // A request can fail before Pi emits turn_end. agent_settled is the public
88
- // lifecycle point that must discard an uncommitted draft before the next prompt.
89
- // UI notices follow committed reminders. Aborted requests can retry their drafts
90
- // without showing the same low-budget notification twice.
106
+ // A warning can be committed at turn_end, before a tool turn or a reset changes the
107
+ // active window. Observe the active branch at public lifecycle boundaries and match
108
+ // the candidate against its originating window segment, not only the current window.
109
+ pi.on("turn_start", (_event, ctx) => notifyCommittedWarnings(ctx));
91
110
  pi.on("agent_settled", (_event, ctx) => {
92
- notifyCommittedReminders(ctx);
111
+ notifyCommittedWarnings(ctx, true);
93
112
  clearStaged();
94
113
  });
95
114
  pi.on("context", (_event, ctx) => {
@@ -149,6 +168,6 @@ export function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () =
149
168
  automaticResetEnabled,
150
169
  hardReserveDue,
151
170
  consumeTurnEnd,
152
- clear: () => { clearStaged(); pendingNotices = []; },
171
+ clear: () => { clearStaged(); pendingNotices.clear(); },
153
172
  };
154
173
  }
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
+ import { earliestMatchOffsetChars } from "../text-match.js";
4
5
  import { assertAddress, assertGlobPattern, addressFor, globToRegExp } from "./address.js";
5
6
  import { snapshotNotesContext } from "./context.js";
6
7
  import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./constants.js";
@@ -149,18 +150,6 @@ function matchLineNumbers(body, needle) {
149
150
  }
150
151
  return lines;
151
152
  }
152
- /** Code-point offset of the earliest query occurrence, matching the serialized read text. */
153
- function earliestMatchOffsetChars(text, queries) {
154
- let earliest = -1;
155
- for (const query of queries) {
156
- const index = text.indexOf(query);
157
- if (index < 0)
158
- continue;
159
- if (earliest < 0 || index < earliest)
160
- earliest = index;
161
- }
162
- return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
163
- }
164
153
  /** Every mutation uses a tmp file renamed into place in the same directory. */
165
154
  async function atomicWrite(path, content) {
166
155
  await mkdir(dirname(path), { recursive: true });
@@ -329,11 +318,10 @@ export function createNotesStore(input) {
329
318
  return { meta, applied: operations.length, resolvedScope: scope, change };
330
319
  });
331
320
  }
332
- async function list(options = {}) {
333
- const stableOptions = { ...options };
334
- const matcher = matcherFor(normalizePattern(stableOptions.pattern, context));
335
- const rows = [];
336
- for (const home of await homesFor(context, stableOptions)) {
321
+ async function* scan(options) {
322
+ const matcher = matcherFor(normalizePattern(options.pattern, context));
323
+ const homes = await homesFor(context, options);
324
+ for (const home of homes) {
337
325
  const scope = home.scope;
338
326
  const root = scopeDir(scope, context, home.who);
339
327
  for (const path of await walkMarkdown(root)) {
@@ -344,40 +332,36 @@ export function createNotesStore(input) {
344
332
  const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
345
333
  const { meta, body } = parseNote(raw);
346
334
  meta.scope = scope;
347
- rows.push({ address, scope, path, meta, body, sizeBytes: Buffer.byteLength(body, "utf8") });
335
+ yield { address, scope, path, meta, body };
348
336
  }
349
337
  }
338
+ }
339
+ async function list(options = {}) {
340
+ const stableOptions = { ...options };
341
+ const rows = [];
342
+ for await (const row of scan(stableOptions)) {
343
+ rows.push({ ...row, sizeBytes: Buffer.byteLength(row.body, "utf8") });
344
+ }
350
345
  rows.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt || a.address.localeCompare(b.address));
351
346
  return rows;
352
347
  }
353
348
  async function search(queries, options = {}) {
354
349
  const stableQueries = [...queries];
355
350
  const stableOptions = { ...options };
356
- const matcher = matcherFor(normalizePattern(stableOptions.pattern, context));
357
351
  const rows = [];
358
- for (const home of await homesFor(context, stableOptions)) {
359
- const scope = home.scope;
360
- const root = scopeDir(scope, context, home.who);
361
- for (const path of await walkMarkdown(root)) {
362
- const address = addressFor(context, scope, path, home.who);
363
- if (matcher && !matcher.test(address))
364
- continue;
365
- const fullPath = join(root, path);
366
- const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
367
- const { meta, body } = parseNote(raw);
368
- meta.scope = scope;
369
- const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
370
- let baseChars = 0;
371
- const matches = [];
372
- for (const [index, line] of body.split("\n").entries()) {
373
- if (stableQueries.some((query) => line.includes(query))) {
374
- matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, stableQueries) });
375
- }
376
- baseChars += Array.from(line).length + 1;
352
+ for await (const note of scan(stableOptions)) {
353
+ const { address, path, scope, meta, body } = note;
354
+ const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
355
+ let baseChars = 0;
356
+ const matches = [];
357
+ for (const [index, line] of body.split("\n").entries()) {
358
+ if (stableQueries.some((query) => line.includes(query))) {
359
+ matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, stableQueries) });
377
360
  }
378
- if (matches.length > 0)
379
- rows.push({ address, path, scope, meta, matches });
361
+ baseChars += Array.from(line).length + 1;
380
362
  }
363
+ if (matches.length > 0)
364
+ rows.push({ address, path, scope, meta, matches });
381
365
  }
382
366
  rows.sort((a, b) => a.address.localeCompare(b.address));
383
367
  return rows;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
3
+ * none occurs. Shared by the two search tools so a match address is computed identically.
4
+ */
5
+ export declare function earliestMatchOffsetChars(text: string, queries: string[]): number;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
3
+ * none occurs. Shared by the two search tools so a match address is computed identically.
4
+ */
5
+ export function earliestMatchOffsetChars(text, queries) {
6
+ let earliest = -1;
7
+ for (const query of queries) {
8
+ const index = text.indexOf(query);
9
+ if (index < 0)
10
+ continue;
11
+ if (earliest < 0 || index < earliest)
12
+ earliest = index;
13
+ }
14
+ return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
15
+ }
@@ -1,3 +1,4 @@
1
+ export { earliestMatchOffsetChars } from "./text-match.js";
1
2
  export declare const TOOL_OUTPUT_MAX_BYTES: number;
2
3
  export declare const DEFAULT_READ_WINDOW_CHARS = 12000;
3
4
  export declare const MAX_READ_WINDOW_CHARS = 50000;
@@ -55,11 +56,6 @@ export declare function readCharacterWindow<T>(text: string, offsetChars: number
55
56
  * source identity fields in wire order; range and continuation semantics are shared.
56
57
  */
57
58
  export declare function readWindowBlock(identity: ReadonlyArray<readonly [string, string]>, window: CharacterWindow): string;
58
- /**
59
- * Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
60
- * none occurs. Shared by the two search tools so a match address is computed identically.
61
- */
62
- export declare function earliestMatchOffsetChars(text: string, queries: string[]): number;
63
59
  /** Shrink a single page item to fit; only invoked when that item alone exceeds the budget. */
64
60
  export type ItemTruncator<T> = (item: T, fits: (candidate: T) => boolean) => T;
65
61
  /**
@@ -1,3 +1,4 @@
1
+ export { earliestMatchOffsetChars } from "./text-match.js";
1
2
  export const TOOL_OUTPUT_MAX_BYTES = 32 * 1024;
2
3
  export const DEFAULT_READ_WINDOW_CHARS = 12000;
3
4
  export const MAX_READ_WINDOW_CHARS = 50000;
@@ -111,21 +112,6 @@ export function readWindowBlock(identity, window) {
111
112
  const fields = identity.map(([name, value]) => `${name}: ${value}`).join("\n");
112
113
  return `--- READ WINDOW ---\n${fields}\nchars: [${window.offset_chars},${end}) of ${window.total_chars}\nnext_offset_chars: ${next}\n`;
113
114
  }
114
- /**
115
- * Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
116
- * none occurs. Shared by the two search tools so a match address is computed identically.
117
- */
118
- export function earliestMatchOffsetChars(text, queries) {
119
- let earliest = -1;
120
- for (const query of queries) {
121
- const index = text.indexOf(query);
122
- if (index < 0)
123
- continue;
124
- if (earliest < 0 || index < earliest)
125
- earliest = index;
126
- }
127
- return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
128
- }
129
115
  /**
130
116
  * Build a page without ever adding an item that would exceed the wire budget.
131
117
  *
@@ -127,7 +127,7 @@ async function openFixture(options) {
127
127
  assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE && entry.data?.windowId === windowId), "notification follows the reset marker commit");
128
128
  assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE && entry.details?.windowId === windowId), "notification follows the reset boot commit");
129
129
  }
130
- if (type === "warning" && sessionManager.getBranch().some((entry) => entry.type === "custom_message" && (entry.customType === GUIDANCE_TYPE || entry.customType === WARNING_TYPE)))
130
+ if (type === "warning" && /^pi-context: Context (?:almost full|running low)/.test(message))
131
131
  budgetNotices++;
132
132
  notices.push(message);
133
133
  },
@@ -213,30 +213,98 @@ function assertFreshRequest(fixture, requestIndex, oldSentinel) {
213
213
  assert.ok(body.includes(CONTEXT_WINDOW_OPEN_TAG), "the new provider request includes the fresh context-window boot");
214
214
  assert.equal(body.split(CONTINUATION).length - 1, 1, "the fresh window carries exactly one reset message");
215
215
  }
216
- test("real AgentSession: aborted low-budget requests notify only after a retry commits the reminder", async () => {
216
+ test("real AgentSession: aborted early guidance retries silently with automatic reset disabled", async () => {
217
217
  let fixture;
218
218
  fixture = await openFixture({
219
219
  compactionEnabled: false,
220
220
  script: (request) => assistant(fixture, [{ type: "text", text: `response ${request}` }], request === 2 ? "aborted" : "stop", { usage: usage(50_000) }),
221
221
  });
222
222
  try {
223
- const reminders = () => fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === GUIDANCE_TYPE);
223
+ const guidance = () => fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === GUIDANCE_TYPE);
224
224
  const notices = () => fixture.budgetNotices();
225
225
  await fixture.session.prompt("Establish usage below the reminder line.");
226
226
  await fixture.session.waitForIdle();
227
- assert.equal(reminders().length, 0);
227
+ assert.equal(guidance().length, 0);
228
228
  await fixture.session.prompt("Abort this low-budget request.");
229
229
  await fixture.session.waitForIdle();
230
- assert.equal(reminders().length, 0, "an aborted turn does not commit its reminder");
231
- assert.equal(notices(), 0, "an uncommitted reminder never notifies");
230
+ assert.equal(guidance().length, 0, "an aborted turn does not commit early guidance");
231
+ assert.equal(notices(), 0, "early guidance never notifies");
232
232
  await fixture.session.prompt("Retry successfully.");
233
233
  await fixture.session.waitForIdle();
234
- assert.equal(reminders().length, 1);
235
- assert.equal(notices(), 1, "the committed retry notifies once");
234
+ assert.equal(guidance().length, 1, "the successful retry commits the model guidance");
235
+ assert.equal(notices(), 0, "committed early guidance stays UI-silent");
236
236
  await fixture.session.prompt("Continue in the same window.");
237
237
  await fixture.session.waitForIdle();
238
- assert.equal(reminders().length, 1);
239
- assert.equal(notices(), 1, "later turns cannot repeat the notice");
238
+ assert.equal(guidance().length, 1);
239
+ assert.equal(notices(), 0, "later turns cannot toast the early reminder");
240
+ }
241
+ finally {
242
+ fixture.close();
243
+ }
244
+ });
245
+ test("real AgentSession: an aborted final warning stays silent until a successful retry commits it", async () => {
246
+ let fixture;
247
+ fixture = await openFixture({
248
+ compactionEnabled: true,
249
+ script: (request, context) => {
250
+ if (request === 1)
251
+ return assistant(fixture, [{ type: "toolCall", id: "abort-budget-probe-1", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(50_000) });
252
+ if (request === 2)
253
+ return assistant(fixture, [{ type: "toolCall", id: "abort-budget-probe-2", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(60_000) });
254
+ assert.ok(text(context).includes(WARNING_PROMPT), "the final warning reaches the model on both attempts");
255
+ return assistant(fixture, [{ type: "text", text: `response ${request}` }], request === 3 ? "aborted" : "stop", { usage: usage(60_000) });
256
+ },
257
+ });
258
+ try {
259
+ const warnings = () => fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE);
260
+ const notices = () => fixture.budgetNotices();
261
+ await fixture.session.prompt("Establish usage and reach the final runway.");
262
+ await fixture.session.waitForIdle();
263
+ assert.equal(fixture.requests.length, 3, "budget probes reach one final-warning attempt");
264
+ assert.equal(warnings().length, 0, "the aborted attempt leaves no durable warning");
265
+ assert.equal(notices(), 0, "an uncommitted warning never notifies");
266
+ await fixture.session.prompt("Retry successfully.");
267
+ await fixture.session.waitForIdle();
268
+ assert.equal(warnings().length, 1, "the successful retry commits one hidden warning");
269
+ assert.equal(resetMarkers(fixture).length, 1, "normal close-out resets after the committed warning");
270
+ assert.equal(notices(), 1, "the committed warning notifies once, including after reset");
271
+ assert.equal(fixture.notices.some((notice) => notice.includes("Context running low")), false, "early guidance remains UI-silent");
272
+ }
273
+ finally {
274
+ fixture.close();
275
+ }
276
+ });
277
+ test("real AgentSession: final warning survives its reset boundary and notifies once", { timeout: 20000 }, async () => {
278
+ let fixture;
279
+ fixture = await openFixture({
280
+ compactionEnabled: true,
281
+ script: (request, context) => {
282
+ if (request === 1)
283
+ return assistant(fixture, [{ type: "toolCall", id: "final-budget-probe-1", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(50_000) });
284
+ if (request === 2)
285
+ return assistant(fixture, [{ type: "toolCall", id: "final-budget-probe-2", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(60_000) });
286
+ if (request === 3) {
287
+ assert.ok(text(context).includes(WARNING_PROMPT), "the final warning reaches the model");
288
+ return assistant(fixture, [{ type: "toolCall", id: "final-budget-wipe", name: "wipe_memory", arguments: {} }], "toolUse", { usage: usage(60_000) });
289
+ }
290
+ return assistant(fixture, [{ type: "text", text: "fresh window ready" }]);
291
+ },
292
+ });
293
+ try {
294
+ assert.equal(fixture.notices.length, 0, "boot is silent");
295
+ await fixture.session.prompt("FINAL_WARNING_RESET_SENTINEL");
296
+ await fixture.session.waitForIdle();
297
+ assert.equal(fixture.requests.length, 4, "two budget probes, close-out tool, and fresh-window request run");
298
+ assertFreshRequest(fixture, 3, "FINAL_WARNING_RESET_SENTINEL");
299
+ assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE).length, 1);
300
+ assert.equal(resetMarkers(fixture).length, 1, "the reset commits in the same boundary as the final warning");
301
+ assert.equal(fixture.budgetNotices(), 1, "the old-window committed warning reaches the UI after reset");
302
+ assert.equal(fixture.notices.filter((notice) => notice.startsWith("pi-context: memory cleared · ")).length, 1, "reset notification remains intact");
303
+ assert.equal(fixture.notices.some((notice) => notice.includes("Context running low")), false, "early guidance stays silent");
304
+ await fixture.session.prompt("Continue after reset.");
305
+ await fixture.session.waitForIdle();
306
+ assert.equal(fixture.budgetNotices(), 1, "later turns cannot repeat the consumed final warning");
307
+ assert.equal(fixture.notices.filter((notice) => notice.startsWith("pi-context: memory cleared · ")).length, 1, "the prior reset notification is also deduplicated");
240
308
  }
241
309
  finally {
242
310
  fixture.close();
@@ -4,11 +4,9 @@ import test from "node:test";
4
4
  import { historyFromSession, internal } from "../src/index.js";
5
5
  import { listNotes } from "./helpers/notes.js";
6
6
  import { CONTINUATION_TYPE, WARNING_TYPE } from "../src/protocol.js";
7
- import { appendText, call, commitTurnEndBoundary, context, installExtensionTestEnvironment, makeExtension, manager, noticesOf, resultJson, resultRead, runContextHook, runContextWithSystemHook, runHandlers, runManualCompact, runCommand, sentOf, } from "./helpers/extension.js";
8
- const testEnvironment = installExtensionTestEnvironment("pi-context-integration");
9
- test.beforeEach(() => testEnvironment.beforeEach());
10
- test.afterEach(() => testEnvironment.afterEach());
11
- test.after(() => testEnvironment.dispose());
7
+ import { appendText, call, commitTurnEndBoundary, context, makeExtension, manager, noticesOf, resultJson, resultRead, runContextHook, runContextWithSystemHook, runHandlers, runManualCompact, runCommand, sentOf, } from "./helpers/extension.js";
8
+ import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
9
+ const testEnvironment = installExtensionTestHooks("pi-context-integration");
12
10
  test("custom reset marker removes old provider context while history remains searchable", async () => {
13
11
  const sessionManager = manager();
14
12
  const captured = makeExtension(sessionManager);
@@ -50,7 +48,7 @@ test("the root boot and reset boot carry durable window identity", async () => {
50
48
  const ctx = context(sessionManager);
51
49
  appendText(sessionManager, "user", "task before reset");
52
50
  appendText(sessionManager, "assistant", "working");
53
- await call(captured, "notes_write", { path: "decisions.md", content: "use terra" }, ctx);
51
+ await call(captured, "notes_write", { address: "decisions.md", content: "use terra" }, ctx);
54
52
  // Root window: session_start persists the boot block without triggering a turn.
55
53
  await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
56
54
  assert.equal(captured.sent.length, 1);