@astrosheep/pi-context 0.26.0 → 0.26.2

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 (37) hide show
  1. package/README.md +2 -2
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +69 -64
  4. package/dist/src/context/budget.js +37 -18
  5. package/dist/src/context/prompts.js +3 -3
  6. package/dist/src/dream/doctor.js +0 -4
  7. package/dist/src/notes/frontmatter.d.ts +1 -2
  8. package/dist/src/notes/frontmatter.js +1 -6
  9. package/dist/src/notes/store.js +24 -40
  10. package/dist/src/protocol.d.ts +4 -4
  11. package/dist/src/protocol.js +4 -4
  12. package/dist/src/text-match.d.ts +5 -0
  13. package/dist/src/text-match.js +15 -0
  14. package/dist/src/tool-output.d.ts +1 -5
  15. package/dist/src/tool-output.js +1 -15
  16. package/dist/test/agent-loop.test.js +78 -10
  17. package/dist/test/boot.integration.test.js +5 -7
  18. package/dist/test/budget-settings.integration.test.js +20 -5
  19. package/dist/test/doctor.test.js +4 -5
  20. package/dist/test/helpers/extension-test-environment.d.ts +1 -0
  21. package/dist/test/helpers/extension-test-environment.js +9 -0
  22. package/dist/test/helpers/extension.d.ts +0 -11
  23. package/dist/test/helpers/extension.js +1 -75
  24. package/dist/test/history.integration.test.js +17 -19
  25. package/dist/test/notes-library.test.js +17 -0
  26. package/dist/test/notes.integration.test.js +47 -31
  27. package/dist/test/notes.test.js +51 -44
  28. package/docs/architecture.md +1 -1
  29. package/package.json +1 -1
  30. package/src/context/budget.ts +35 -18
  31. package/src/context/prompts.ts +3 -3
  32. package/src/dream/doctor.ts +0 -3
  33. package/src/notes/frontmatter.ts +1 -5
  34. package/src/notes/store.ts +24 -37
  35. package/src/protocol.ts +4 -4
  36. package/src/text-match.ts +13 -0
  37. package/src/tool-output.ts +2 -14
package/README.md CHANGED
@@ -109,9 +109,9 @@ const matches = await notes.search(["library"]);
109
109
 
110
110
  `list` and `search` share the `NotesQuery` type. A merged query uses `{ pattern? }`; a single-home query adds `scope`. Only `scope: "agent" | "model"` accepts `who`. TypeScript rejects combinations such as `{ scope: "project", who: "root" }`, and JavaScript callers receive a runtime refusal.
111
111
 
112
- The library returns full data, not tool envelopes or paginated/truncated output. `NoteError` exposes the existing named store refusals through `code`, with `lineNumbers` for ambiguous edits and `editIndex` for a failed edit. Runtime API fields and known persisted note metadata use camelCase; Pi tool wire fields such as `updated_at`, `offset_chars`, and `replace_all` retain their established names. A note carrying a known legacy snake_case metadata key is refused with an explicit manual-migration-required error; import and construction never migrate note data. Invalid addresses/identities and filesystem failures reject; only a missing `read` returns `undefined`. Notes remain markdown files with the existing size limits and same-directory atomic rename. Same-file read/modify/write work is serialized by absolute physical filename across all store instances in this process (including `.md` address aliases); symlink/case aliases and cross-process locking are not guaranteed. `list` and `search` asynchronously traverse homes and serialize each discovered file read against pending mutations, but are not global snapshots and may not discover a file created after traversal. Foreign named homes can be read (including the access-metadata update), but their bodies cannot be written or edited through the store. These are cooperative address rules, not an OS security sandbox.
112
+ The library returns full data, not tool envelopes or paginated/truncated output. `NoteError` exposes the existing named store refusals through `code`, with `lineNumbers` for ambiguous edits and `editIndex` for a failed edit. Runtime API fields and known persisted note metadata use camelCase; Pi tool wire fields such as `updated_at`, `offset_chars`, and `replace_all` retain their established names. Unrecognized frontmatter keys, including old snake_case metadata, are preserved as ordinary extras; they are not interpreted as current camelCase fields or migrated automatically. Invalid addresses/identities and filesystem failures reject; only a missing `read` returns `undefined`. Notes remain markdown files with the existing size limits and same-directory atomic rename. Same-file read/modify/write work is serialized by absolute physical filename across all store instances in this process (including `.md` address aliases); symlink/case aliases and cross-process locking are not guaranteed. `list` and `search` asynchronously traverse homes and serialize each discovered file read against pending mutations, but are not global snapshots and may not discover a file created after traversal. Foreign named homes can be read (including the access-metadata update), but their bodies cannot be written or edited through the store. These are cooperative address rules, not an OS security sandbox.
113
113
 
114
- Addresses use bare paths, `@project/`, `@human/`, `@self/`, `@model/`, or explicit `@agents/<slug>/` and `@models/<slug>/`. Relative self/model addresses resolve to the supplied identity; listing renders their concrete names. The disk layout remains `pi/session/<sessionId>/`, `project/<projectKey>/`, `human/`, `agents/<agent>/`, and `models/<model>/`. No data migration happens on library import or construction. Notes already using camelCase metadata retain their metadata; known legacy snake_case keys require the root-coordinated manual migration before use. New session notes record the supplied project key.
114
+ Addresses use bare paths, `@project/`, `@human/`, `@self/`, `@model/`, or explicit `@agents/<slug>/` and `@models/<slug>/`. Relative self/model addresses resolve to the supplied identity; listing renders their concrete names. The disk layout remains `pi/session/<sessionId>/`, `project/<projectKey>/`, `human/`, `agents/<agent>/`, and `models/<model>/`. No data migration happens on library import or construction. Notes already using camelCase metadata retain their metadata; old snake_case keys are preserved as unrecognized extras, not interpreted or migrated. New session notes record the supplied project key.
115
115
 
116
116
  ### Library and plugin boundary
117
117
 
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.26.0",
3
- "sourceHash": "48dcba30fd66f729c046d9ec89214e69ada06592df8d464f3b8cc70562dd5174"
2
+ "version": "0.26.2",
3
+ "sourceHash": "b35cc5826ee3ac651b5a18c57833aab912a9ee999af2c2dfc7f8672b74e31f81"
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.2", sourceHash: "b35cc5826ee3ac651b5a18c57833aab912a9ee999af2c2dfc7f8672b74e31f81" };
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 = [];
@@ -247,10 +249,10 @@ var WARNING_TYPE = "pi-context/warning";
247
249
  var RESET_MARKER_TYPE = "pi-context/reset-marker";
248
250
  var CONTINUATION_TYPE = "pi-context/continuation";
249
251
  var POCKET_SESSION_LIMIT = 5;
250
- var POCKET_PROJECT_LIMIT = 2;
251
- var POCKET_HUMAN_LIMIT = 2;
252
- var POCKET_AGENT_LIMIT = 1;
253
- var POCKET_MODEL_LIMIT = 1;
252
+ var POCKET_PROJECT_LIMIT = 5;
253
+ var POCKET_HUMAN_LIMIT = 5;
254
+ var POCKET_AGENT_LIMIT = 5;
255
+ var POCKET_MODEL_LIMIT = 3;
254
256
  var CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
255
257
  var CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
256
258
  var CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
@@ -592,7 +594,6 @@ var SCOPES = ["session", "project", "human", "agent", "model"];
592
594
  var ORIGINS = ["user", "self", "external"];
593
595
  var STATUSES = ["active", "superseded", "pending", "archived"];
594
596
  var TIMESTAMP_KEYS = ["createdAt", "updatedAt", "lastAccessed"];
595
- var LEGACY_KNOWN_KEYS = ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"];
596
597
  var KNOWN_KEYS = ["origin", "status", "stale", "createdAt", "updatedAt", "lastAccessed", "accessCount", "sourceWindow", "supersedes", "recurrenceCount", "recurrenceWindows"];
597
598
  var pad2 = (value) => String(value).padStart(2, "0");
598
599
  function localIso(epochMs) {
@@ -666,8 +667,6 @@ function parseFrontmatter(raw) {
666
667
  }
667
668
  function parseNote(raw, now = Date.now()) {
668
669
  const { fields, body } = parseFrontmatter(raw);
669
- const legacyKeys = LEGACY_KNOWN_KEYS.filter((key) => Object.hasOwn(fields, key));
670
- if (legacyKeys.length > 0) throw new Error(`legacy note metadata ${legacyKeys.join(", ")} requires manual migration to camelCase before this note can be used`);
671
670
  const meta = { ...fields };
672
671
  meta.scope = isScope(meta.scope) ? meta.scope : "session";
673
672
  meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
@@ -1003,15 +1002,6 @@ function matchLineNumbers(body, needle) {
1003
1002
  }
1004
1003
  return lines;
1005
1004
  }
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
1005
  async function atomicWrite(path, content) {
1016
1006
  await mkdir(dirname2(path), { recursive: true });
1017
1007
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
@@ -1156,11 +1146,10 @@ function createNotesStore(input) {
1156
1146
  return { meta, applied: operations.length, resolvedScope: scope, change };
1157
1147
  });
1158
1148
  }
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)) {
1149
+ async function* scan(options) {
1150
+ const matcher = matcherFor(normalizePattern(options.pattern, context));
1151
+ const homes = await homesFor(context, options);
1152
+ for (const home of homes) {
1164
1153
  const scope = home.scope;
1165
1154
  const root = scopeDir(scope, context, home.who);
1166
1155
  for (const path of await walkMarkdown(root)) {
@@ -1170,38 +1159,35 @@ function createNotesStore(input) {
1170
1159
  const raw = await withPathQueue(fullPath, () => readFile(fullPath, "utf8"));
1171
1160
  const { meta, body } = parseNote(raw);
1172
1161
  meta.scope = scope;
1173
- rows.push({ address, scope, path, meta, body, sizeBytes: Buffer.byteLength(body, "utf8") });
1162
+ yield { address, scope, path, meta, body };
1174
1163
  }
1175
1164
  }
1165
+ }
1166
+ async function list(options = {}) {
1167
+ const stableOptions = { ...options };
1168
+ const rows = [];
1169
+ for await (const row of scan(stableOptions)) {
1170
+ rows.push({ ...row, sizeBytes: Buffer.byteLength(row.body, "utf8") });
1171
+ }
1176
1172
  rows.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt || a.address.localeCompare(b.address));
1177
1173
  return rows;
1178
1174
  }
1179
1175
  async function search(queries, options = {}) {
1180
1176
  const stableQueries = [...queries];
1181
1177
  const stableOptions = { ...options };
1182
- const matcher = matcherFor(normalizePattern(stableOptions.pattern, context));
1183
1178
  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;
1179
+ for await (const note of scan(stableOptions)) {
1180
+ const { address, path, scope, meta, body } = note;
1181
+ const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
1182
+ let baseChars = 0;
1183
+ const matches = [];
1184
+ for (const [index, line] of body.split("\n").entries()) {
1185
+ if (stableQueries.some((query) => line.includes(query))) {
1186
+ matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, stableQueries) });
1202
1187
  }
1203
- if (matches.length > 0) rows.push({ address, path, scope, meta, matches });
1188
+ baseChars += Array.from(line).length + 1;
1204
1189
  }
1190
+ if (matches.length > 0) rows.push({ address, path, scope, meta, matches });
1205
1191
  }
1206
1192
  rows.sort((a, b) => a.address.localeCompare(b.address));
1207
1193
  return rows;
@@ -1494,7 +1480,7 @@ function notesIndex(snapshot) {
1494
1480
  if (recentNotes.length > 0) {
1495
1481
  const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by prefix, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from @project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from @self, ${POCKET_MODEL_LIMIT} from @model). A note's content never appears here, so its name has to say what the note is about:`];
1496
1482
  for (const row of recentNotes) {
1497
- lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updatedAt, snapshot.openedAt)})`);
1483
+ lines.push(`- ${row.address} \xB7 ${Array.from(row.body).length} chars \xB7 ${relativeTime(row.meta.updatedAt, snapshot.openedAt)}`);
1498
1484
  }
1499
1485
  sections.push(lines.join("\n"));
1500
1486
  }
@@ -1556,14 +1542,29 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
1556
1542
  };
1557
1543
  let pendingGuidance;
1558
1544
  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");
1545
+ let pendingNotices = /* @__PURE__ */ new Map();
1546
+ const noticeKey = (sessionId, windowId) => `${sessionId}:${windowId}`;
1547
+ const warningCommittedInWindow = (ctx, notice) => {
1548
+ if (ctx.sessionManager.getSessionId() !== notice.sessionId) return false;
1549
+ let windowId = rootWindowId(notice.sessionId);
1550
+ for (const entry of ctx.sessionManager.getBranch()) {
1551
+ if (isWindowMarker(entry)) {
1552
+ windowId = entry.data.windowId;
1553
+ continue;
1554
+ }
1555
+ if (windowId === notice.windowId && entry.type === "custom_message" && entry.customType === WARNING_TYPE) return true;
1556
+ }
1557
+ return false;
1558
+ };
1559
+ const notifyCommittedWarnings = (ctx, settled = false) => {
1560
+ for (const [key, notice] of pendingNotices) {
1561
+ if (warningCommittedInWindow(ctx, notice)) {
1562
+ pendingNotices.delete(key);
1563
+ ctx.ui.notify("pi-context: Context almost full; close out the current memory window.", "warning");
1564
+ } else if (settled) {
1565
+ pendingNotices.delete(key);
1566
+ }
1565
1567
  }
1566
- pendingNotices = [];
1567
1568
  };
1568
1569
  const clearStaged = () => {
1569
1570
  pendingGuidance = void 0;
@@ -1571,7 +1572,7 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
1571
1572
  };
1572
1573
  const resetForTransition = () => {
1573
1574
  clearStaged();
1574
- pendingNotices = [];
1575
+ pendingNotices.clear();
1575
1576
  invalidateThresholds();
1576
1577
  notifiedWarnings.clear();
1577
1578
  };
@@ -1583,7 +1584,10 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
1583
1584
  clearStaged();
1584
1585
  const windowId = currentWindowId(ctx);
1585
1586
  const drafts = staged.filter((draft) => draft !== void 0 && draft.windowId === windowId);
1586
- pendingNotices = drafts.map(({ windowId: windowId2, customType, remaining }) => ({ windowId: windowId2, customType, remaining }));
1587
+ if (drafts.some((draft) => draft.customType === WARNING_TYPE)) {
1588
+ const sessionId = ctx.sessionManager.getSessionId();
1589
+ pendingNotices.set(noticeKey(sessionId, windowId), { sessionId, windowId });
1590
+ }
1587
1591
  return drafts.map((draft) => ({
1588
1592
  type: "custom_message",
1589
1593
  customType: draft.customType,
@@ -1598,8 +1602,9 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
1598
1602
  pi.on("session_tree", resetForTransition);
1599
1603
  pi.on("model_select", resetForTransition);
1600
1604
  pi.on("session_shutdown", resetForTransition);
1605
+ pi.on("turn_start", (_event, ctx) => notifyCommittedWarnings(ctx));
1601
1606
  pi.on("agent_settled", (_event, ctx) => {
1602
- notifyCommittedReminders(ctx);
1607
+ notifyCommittedWarnings(ctx, true);
1603
1608
  clearStaged();
1604
1609
  });
1605
1610
  pi.on("context", (_event, ctx) => {
@@ -1647,7 +1652,7 @@ function registerBudget(pi, isEnabled, settingsManager, onCloseOut = () => {
1647
1652
  consumeTurnEnd,
1648
1653
  clear: () => {
1649
1654
  clearStaged();
1650
- pendingNotices = [];
1655
+ pendingNotices.clear();
1651
1656
  }
1652
1657
  };
1653
1658
  }
@@ -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
  }
@@ -32,8 +32,8 @@ function notesUnavailableNotice(snapshot) {
32
32
  * per home, and the session home is never peeked — a session MAP.md is an ordinary note. The
33
33
  * pocket then lists recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT /
34
34
  * POCKET_PROJECT_LIMIT / POCKET_HUMAN_LIMIT / POCKET_AGENT_LIMIT / POCKET_MODEL_LIMIT),
35
- * most-recently-updated first within each home, one metadata line each: address, line count,
36
- * UTF-8 byte count, relative update time at window open. Bodies never render
35
+ * most-recently-updated first within each home, one metadata line each: address, body character
36
+ * count, relative update time at window open. Bodies never render
37
37
  * in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
38
38
  */
39
39
  function notesIndex(snapshot) {
@@ -59,7 +59,7 @@ function notesIndex(snapshot) {
59
59
  if (recentNotes.length > 0) {
60
60
  const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by prefix, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from @project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from @self, ${POCKET_MODEL_LIMIT} from @model). A note's content never appears here, so its name has to say what the note is about:`];
61
61
  for (const row of recentNotes) {
62
- lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updatedAt, snapshot.openedAt)})`);
62
+ lines.push(`- ${row.address} · ${Array.from(row.body).length} chars · ${relativeTime(row.meta.updatedAt, snapshot.openedAt)}`);
63
63
  }
64
64
  sections.push(lines.join("\n"));
65
65
  }
@@ -41,10 +41,6 @@ export function doctor(home) {
41
41
  if (!valid.test(fields.get(key) ?? ""))
42
42
  report(path, `missing/invalid ${key}; repair frontmatter`);
43
43
  }
44
- for (const [legacy, current] of [["created_at", "createdAt"], ["updated_at", "updatedAt"], ["last_accessed", "lastAccessed"], ["access_count", "accessCount"], ["source_window", "sourceWindow"], ["recurrence_count", "recurrenceCount"], ["recurrence_windows", "recurrenceWindows"]]) {
45
- if (fields.has(legacy))
46
- report(path, `legacy metadata key ${legacy}; manually migrate to ${current}`);
47
- }
48
44
  for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
49
45
  const value = fields.get(key);
50
46
  if (!value || !Number.isFinite(Date.parse(value)))
@@ -33,8 +33,7 @@ export declare function isScope(value: unknown): value is Scope;
33
33
  export declare function isOrigin(value: unknown): value is Origin;
34
34
  /**
35
35
  * Parse a note file. Missing known keys take the Design defaults (status active, stale false,
36
- * accessCount 0, timestamps now); unknown keys are carried through untouched. Known
37
- * snake_case metadata is refused because it requires the explicit manual migration.
36
+ * accessCount 0, timestamps now); unknown keys are carried through untouched.
38
37
  */
39
38
  export declare function parseNote(raw: string, now?: number): {
40
39
  meta: NoteMeta;
@@ -2,7 +2,6 @@ const SCOPES = ["session", "project", "human", "agent", "model"];
2
2
  const ORIGINS = ["user", "self", "external"];
3
3
  const STATUSES = ["active", "superseded", "pending", "archived"];
4
4
  const TIMESTAMP_KEYS = ["createdAt", "updatedAt", "lastAccessed"];
5
- const LEGACY_KNOWN_KEYS = ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"];
6
5
  /** Emission order, exactly the Design's key list. */
7
6
  const KNOWN_KEYS = ["origin", "status", "stale", "createdAt", "updatedAt", "lastAccessed", "accessCount", "sourceWindow", "supersedes", "recurrenceCount", "recurrenceWindows"];
8
7
  const pad2 = (value) => String(value).padStart(2, "0");
@@ -98,14 +97,10 @@ function parseFrontmatter(raw) {
98
97
  }
99
98
  /**
100
99
  * Parse a note file. Missing known keys take the Design defaults (status active, stale false,
101
- * accessCount 0, timestamps now); unknown keys are carried through untouched. Known
102
- * snake_case metadata is refused because it requires the explicit manual migration.
100
+ * accessCount 0, timestamps now); unknown keys are carried through untouched.
103
101
  */
104
102
  export function parseNote(raw, now = Date.now()) {
105
103
  const { fields, body } = parseFrontmatter(raw);
106
- const legacyKeys = LEGACY_KNOWN_KEYS.filter((key) => Object.hasOwn(fields, key));
107
- if (legacyKeys.length > 0)
108
- throw new Error(`legacy note metadata ${legacyKeys.join(", ")} requires manual migration to camelCase before this note can be used`);
109
104
  const meta = { ...fields };
110
105
  // scope is a legacy on-disk field: store callers derive it from the file's home and
111
106
  // overwrite it after parsing, so an absent or outdated value just falls back.
@@ -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;
@@ -6,10 +6,10 @@ export declare const RESET_MARKER_TYPE = "pi-context/reset-marker";
6
6
  export declare const CONTINUATION_TYPE = "pi-context/continuation";
7
7
  export { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./notes/constants.js";
8
8
  export declare const POCKET_SESSION_LIMIT = 5;
9
- export declare const POCKET_PROJECT_LIMIT = 2;
10
- export declare const POCKET_HUMAN_LIMIT = 2;
11
- export declare const POCKET_AGENT_LIMIT = 1;
12
- export declare const POCKET_MODEL_LIMIT = 1;
9
+ export declare const POCKET_PROJECT_LIMIT = 5;
10
+ export declare const POCKET_HUMAN_LIMIT = 5;
11
+ export declare const POCKET_AGENT_LIMIT = 5;
12
+ export declare const POCKET_MODEL_LIMIT = 3;
13
13
  export declare const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
14
14
  export declare const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
15
15
  export declare const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
@@ -6,10 +6,10 @@ export const RESET_MARKER_TYPE = "pi-context/reset-marker";
6
6
  export const CONTINUATION_TYPE = "pi-context/continuation";
7
7
  export { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./notes/constants.js";
8
8
  export const POCKET_SESSION_LIMIT = 5;
9
- export const POCKET_PROJECT_LIMIT = 2;
10
- export const POCKET_HUMAN_LIMIT = 2;
11
- export const POCKET_AGENT_LIMIT = 1;
12
- export const POCKET_MODEL_LIMIT = 1;
9
+ export const POCKET_PROJECT_LIMIT = 5;
10
+ export const POCKET_HUMAN_LIMIT = 5;
11
+ export const POCKET_AGENT_LIMIT = 5;
12
+ export const POCKET_MODEL_LIMIT = 3;
13
13
  export const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
14
14
  export const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
15
15
  export const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
@@ -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
  /**