@astrosheep/pi-context 0.22.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,7 @@ pi -e npm:@astrosheep/pi-context
34
34
  | `notes.list` | `notes_list` |
35
35
  | `notes.search` | `notes_search` |
36
36
 
37
- The tool descriptions the model sees are the behavioral documentation: search is case-sensitive literal substring; reads are character windows whose cursors reconstruct the original exactly; anything a response does not deliver is named by an explicit field.
37
+ The tool descriptions the model sees are the behavioral documentation: note results use `address` as the sole home identity; search is case-sensitive literal substring; both `notes_read` and `history_read` are character windows prefixed with the same `READ WINDOW` block, whose cursors reconstruct the source exactly when only the content after each block is concatenated; anything a response does not deliver is named by an explicit field.
38
38
 
39
39
  - **Runtime toggle** — `/pi-context off` restores Pi's default compaction (including `keepRecentTokens`); `/pi-context on` re-enables; bare `/pi-context` reports the current state.
40
40
 
@@ -1,6 +1,6 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool } from "@earendil-works/pi-coding-agent";
3
- import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget, DEFAULT_READ_WINDOW_CHARS, HISTORY_PREVIEW_CHARS, MAX_READ_WINDOW_CHARS } from "./tool-output.js";
3
+ import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, readWindowBlock, withinTextBudget, DEFAULT_READ_WINDOW_CHARS, HISTORY_PREVIEW_CHARS, MAX_READ_WINDOW_CHARS } from "./tool-output.js";
4
4
  import { positiveInteger, recentFirst, nullableString, role, cursor, searchQuery, searchQueries } from "./tool-schema.js";
5
5
  import { historyFromSession, filteredItems, visibleItem, allItems, vacuousRoleToolCombo, unknownWindowId } from "./history.js";
6
6
  /**
@@ -64,7 +64,7 @@ export function registerHistoryTools(pi) {
64
64
  pi.registerTool(defineTool({
65
65
  name: "history_read",
66
66
  label: "History read item",
67
- description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget: follow the resume cursor to reconstruct the item exactly. A negative offset_chars counts back from the item's end. Offsets and counts are code points (an emoji or CJK character counts as one). The response is the raw item text behind a one-line [bracketed] header naming the item, the resolved offset, the delivered char range, and the resume cursor (continue at offset_chars=N, or end).",
67
+ description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget: follow the resume cursor to reconstruct the item exactly. A negative offset_chars counts back from the item's end. Offsets and counts are code points (an emoji or CJK character counts as one). The response begins with the shared READ WINDOW block naming window_id and item_id; concatenate only the content after that block to reconstruct the item.",
68
68
  parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from. A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_READ_WINDOW_CHARS, description: `Largest requested window in code points (default ${DEFAULT_READ_WINDOW_CHARS}). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes.` })), window_id: Type.String() }, { additionalProperties: false }),
69
69
  async execute(_id, params, _signal, _update, ctx) {
70
70
  const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
@@ -76,10 +76,9 @@ export function registerHistoryTools(pi) {
76
76
  if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
77
77
  return output({ error: `offset_chars ${params.offset_chars} is past the end: the item has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, window_id: item.windowId, item_id: item.itemId, offset_chars: params.offset_chars, total_chars: totalChars });
78
78
  }
79
- const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
80
79
  return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
81
80
  const { content, ...cursor } = window;
82
- return outputRaw(characterWindowHeader(`${item.windowId} · item ${item.itemId}`, window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor, limit_chars });
81
+ return outputRaw(readWindowBlock([["window_id", item.windowId], ["item_id", item.itemId]], window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor });
83
82
  }, (result) => withinTextBudget(result.content[0].text));
84
83
  },
85
84
  }));
@@ -208,6 +208,13 @@ export function editNote(ctx, vpath, scope, edits, opts = {}) {
208
208
  atomicWrite(path, serialized);
209
209
  return { meta, applied: operations.length, resolved_scope: scope, diff };
210
210
  }
211
+ /** Normalize a parsed note exactly as a read does, including its access metadata mutation. */
212
+ function accessedMeta(meta, scope, now) {
213
+ const next = { ...meta, scope };
214
+ next.last_accessed = now;
215
+ next.access_count = (typeof next.access_count === "number" ? next.access_count : 0) + 1;
216
+ return next;
217
+ }
211
218
  /** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
212
219
  export function readNote(ctx, vpath, scope) {
213
220
  assertVirtualPath(vpath);
@@ -215,13 +222,11 @@ export function readNote(ctx, vpath, scope) {
215
222
  if (!existsSync(path))
216
223
  return undefined;
217
224
  const now = Date.now();
218
- const { meta, body } = parseNote(readFileSync(path, "utf8"), now);
219
- meta.scope = scope;
220
- // Only the two access keys move; updated_at and every other key keep their bytes.
221
- meta.last_accessed = now;
222
- meta.access_count = (typeof meta.access_count === "number" ? meta.access_count : 0) + 1;
223
- atomicWrite(path, serializeNote(meta, body));
224
- return { meta, body, resolvedScope: scope };
225
+ const parsed = parseNote(readFileSync(path, "utf8"), now);
226
+ const meta = accessedMeta(parsed.meta, scope, now);
227
+ const text = serializeNote(meta, parsed.body);
228
+ atomicWrite(path, text);
229
+ return { meta, body: parsed.body, text, resolvedScope: scope };
225
230
  }
226
231
  /** Merged rows across homes, most recently updated first (address breaks ties). */
227
232
  export function listNotes(ctx, opts = {}) {
@@ -253,11 +258,12 @@ export function searchNotes(ctx, queries, opts = {}) {
253
258
  continue;
254
259
  const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
255
260
  meta.scope = scope;
261
+ const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
256
262
  let baseChars = 0;
257
263
  const matches = [];
258
264
  for (const [index, line] of body.split("\n").entries()) {
259
265
  if (queries.some((query) => line.includes(query))) {
260
- matches.push({ line: index + 1, text: line, offsetChars: baseChars + earliestMatchOffsetChars(line, queries) });
266
+ matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, queries) });
261
267
  }
262
268
  baseChars += Array.from(line).length + 1;
263
269
  }
@@ -1,18 +1,14 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool } from "@earendil-works/pi-coding-agent";
3
3
  import { localIso } from "./model.js";
4
- import { characterWindowHeader, DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, withinTextBudget } from "../tool-output.js";
4
+ import { DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, readWindowBlock, withinTextBudget } from "../tool-output.js";
5
5
  import { cursor, nullableString, positiveInteger, searchQueries, searchQuery } from "../tool-schema.js";
6
6
  import { assertAddress } from "./address.js";
7
- import { serializeNote, stripLeadingFrontmatter } from "./frontmatter.js";
8
7
  import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from "./store.js";
9
8
  const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
10
9
  description: "Where the note's content came from. user: written or dictated by the human. self: written by you, the agent (default). external: anything else — third-party text, tool output, fetched material.",
11
10
  }));
12
11
  const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@personal/<vpath>` for the human's cross-project home. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error: legal prefixes are `@project/` and `@personal/`; bare names are the session home. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes.";
13
- function wireMeta(meta) {
14
- return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
15
- }
16
12
  function failure(error) {
17
13
  if (error instanceof NoteError) {
18
14
  const payload = { error: error.message };
@@ -33,8 +29,8 @@ export function registerNotesTools(pi) {
33
29
  const content = params.content;
34
30
  try {
35
31
  const destination = assertAddress(params.address);
36
- const { meta } = writeNote(ctx, destination.path, content, { scope: destination.scope, origin: (params.origin ?? "self"), stale: params.stale });
37
- return output({ address: params.address, scope: meta.scope, size_bytes: Buffer.byteLength(stripLeadingFrontmatter(content), "utf8"), meta: wireMeta(meta) });
32
+ writeNote(ctx, destination.path, content, { scope: destination.scope, origin: (params.origin ?? "self"), stale: params.stale });
33
+ return output({ address: params.address, written: true });
38
34
  }
39
35
  catch (error) {
40
36
  return failure(error);
@@ -43,13 +39,13 @@ export function registerNotesTools(pi) {
43
39
  }));
44
40
  pi.registerTool(defineTool({
45
41
  name: "notes_edit", label: "Notes edit",
46
- description: `Edit a note body by exact-text replacement; frontmatter is never editable this way. ${ADDRESS_DESCRIPTION} Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of origin/stale. Moving while awake means notes_write at a new address and notes_edit at the old address with stale=true. The success return carries resolved_scope and a diff of what changed.`,
42
+ description: `Edit a note body by exact-text replacement; frontmatter is never editable this way. ${ADDRESS_DESCRIPTION} Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of origin/stale. Moving while awake means notes_write at a new address and notes_edit at the old address with stale=true. The success return carries the address and a diff of what changed.`,
47
43
  parameters: Type.Object({ address: Type.String(), edits: Type.Optional(Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() }, { additionalProperties: false }))), origin: ORIGIN, stale: Type.Optional(Type.Boolean()), replace_all: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), executionMode: "sequential",
48
44
  async execute(_id, params, _signal, _update, ctx) {
49
45
  try {
50
46
  const destination = assertAddress(params.address);
51
- const { meta, applied, resolved_scope, diff } = editNote(ctx, destination.path, destination.scope, params.edits, { origin: params.origin, stale: params.stale, replaceAll: params.replace_all });
52
- return output({ address: params.address, applied, resolved_scope, diff, meta: wireMeta(meta) });
47
+ const { applied, diff } = editNote(ctx, destination.path, destination.scope, params.edits, { origin: params.origin, stale: params.stale, replaceAll: params.replace_all });
48
+ return output({ address: params.address, applied, diff });
53
49
  }
54
50
  catch (error) {
55
51
  return failure(error);
@@ -58,7 +54,7 @@ export function registerNotesTools(pi) {
58
54
  }));
59
55
  pi.registerTool(defineTool({
60
56
  name: "notes_read", label: "Notes read",
61
- description: `Read a character window of a note file, frontmatter included. ${ADDRESS_DESCRIPTION} offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default ${DEFAULT_READ_WINDOW_CHARS}, max ${MAX_READ_WINDOW_CHARS}). Each response delivers the longest fitting prefix of that window: concatenate pages in order to reconstruct the note. The response is the raw frontmatter + body behind a one-line [bracketed] header naming the address, the resolved offset, the delivered char range, and the resume cursor.`,
57
+ description: `Read a character window of a note file, frontmatter included. ${ADDRESS_DESCRIPTION} offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default ${DEFAULT_READ_WINDOW_CHARS}, max ${MAX_READ_WINDOW_CHARS}). Each response delivers the longest fitting prefix of that window in the shared READ WINDOW block: concatenate only the content after the block to reconstruct the note.`,
62
58
  parameters: Type.Object({ address: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_READ_WINDOW_CHARS, description: `Largest requested window in code points (default ${DEFAULT_READ_WINDOW_CHARS}). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes.` })) }, { additionalProperties: false }),
63
59
  async execute(_id, params, _signal, _update, ctx) {
64
60
  let note;
@@ -71,22 +67,19 @@ export function registerNotesTools(pi) {
71
67
  }
72
68
  if (!note)
73
69
  return output({ error: "note not found", address: params.address });
74
- const text = serializeNote(note.meta, note.body);
70
+ const text = note.text;
75
71
  const totalChars = Array.from(text).length;
76
72
  if (typeof params.offset_chars === "number" && params.offset_chars > totalChars)
77
73
  return output({ error: `offset_chars ${params.offset_chars} is past the end: the note has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, address: params.address, offset_chars: params.offset_chars, total_chars: totalChars });
78
- const created_at = localIso(note.meta.created_at);
79
- const updated_at = localIso(note.meta.updated_at);
80
- const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
81
74
  return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
82
75
  const { content, ...rest } = window;
83
- return outputRaw(characterWindowHeader(params.address, window, ` · ${note.resolvedScope} · created ${created_at} · updated ${updated_at}`), content, { address: params.address, scope: note.resolvedScope, ...rest, limit_chars, created_at, updated_at });
76
+ return outputRaw(readWindowBlock([["address", params.address]], window), content, { address: params.address, ...rest });
84
77
  }, (result) => withinTextBudget(result.content[0].text));
85
78
  },
86
79
  }));
87
80
  pi.registerTool(defineTool({
88
81
  name: "notes_list", label: "Notes list",
89
- description: `List note files as rows carrying address, scope, origin, status, stale, size_bytes, created_at, and updated_at, most recently updated first. ${ADDRESS_DESCRIPTION} All three homes are merged. A glob pattern (* within a path segment, ** across segments) filters full address strings: *.md is session-only, @project/** is project-only, and ** covers every home.`,
82
+ description: `List note files as rows carrying address, updated_at, and stale, most recently updated first. ${ADDRESS_DESCRIPTION} All three homes are merged. A glob pattern (* within a path segment, ** across segments) filters full address strings: *.md is session-only, @project/** is project-only, and ** covers every home.`,
90
83
  parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
91
84
  async execute(_id, params, _signal, _update, ctx) {
92
85
  let rows;
@@ -96,7 +89,7 @@ export function registerNotesTools(pi) {
96
89
  catch (error) {
97
90
  return failure(error);
98
91
  }
99
- const files = rows.map((row) => ({ address: row.address, scope: row.scope, origin: row.meta.origin, status: row.meta.status, stale: row.meta.stale, size_bytes: row.sizeBytes, created_at: localIso(row.meta.created_at), updated_at: localIso(row.meta.updated_at) }));
92
+ const files = rows.map((row) => ({ address: row.address, stale: row.meta.stale, updated_at: localIso(row.meta.updated_at) }));
100
93
  return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
101
94
  if (fits(file))
102
95
  return file;
@@ -107,7 +100,7 @@ export function registerNotesTools(pi) {
107
100
  }));
108
101
  pi.registerTool(defineTool({
109
102
  name: "notes_search", label: "Notes search",
110
- description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} All three homes are merged and every entry carries its full address and derived scope. Patterns glob over full address strings. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (the body-absolute code-point offset of the earliest match).`,
103
+ description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} All three homes are merged and every entry carries its full address. Patterns glob over full address strings. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (a code-point offset into the serialized note returned by notes_read, at the earliest query match), and truncated.`,
111
104
  parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
112
105
  async execute(_id, params, _signal, _update, ctx) {
113
106
  const queries = searchQueries(params.query);
@@ -120,8 +113,8 @@ export function registerNotesTools(pi) {
120
113
  }
121
114
  const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
122
115
  const result = rows.map((row) => {
123
- const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false, total_chars: Array.from(match.text).length, offset_chars: match.offsetChars }));
124
- return { address: row.address, scope: row.scope, created_at: localIso(row.meta.created_at), updated_at: localIso(row.meta.updated_at), matches_total: matches.length, matches: matches.slice(0, maxPerFile) };
116
+ const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false, offset_chars: match.offsetChars }));
117
+ return { address: row.address, updated_at: localIso(row.meta.updated_at), stale: row.meta.stale, matches_total: matches.length, matches: matches.slice(0, maxPerFile) };
125
118
  });
126
119
  const fitFile = (file, fits) => {
127
120
  if (fits(file))
@@ -3,38 +3,40 @@ export function registerResetLifecycle(pi, options) {
3
3
  let state = { phase: "idle" };
4
4
  let handledEntry;
5
5
  let active = true;
6
+ const release = (attempt) => {
7
+ if (attempt.settled)
8
+ return;
9
+ attempt.settled = true;
10
+ if (state.phase === "compacting" && state.attempt === attempt) {
11
+ state = { phase: "idle" };
12
+ handledEntry = undefined;
13
+ }
14
+ attempt.release();
15
+ };
6
16
  const clear = () => {
17
+ if (state.phase === "compacting")
18
+ release(state.attempt);
7
19
  state = { phase: "idle" };
8
20
  handledEntry = undefined;
9
21
  };
10
22
  const valid = (request, ctx) => active && options.isEnabled() && state.phase === "compacting" && state.attempt === request && ctx.sessionManager.getSessionId() === request.sessionId;
11
- // State is intentionally not resumed from a pending request: a loaded session must
12
- // not execute work from a tool that belonged to a previous runtime or tree branch.
13
- pi.on("session_start", () => { clear(); active = true; });
14
- pi.on("session_shutdown", () => { clear(); active = false; });
15
- pi.on("session_tree", clear);
16
- pi.on("agent_end", (_event, ctx) => {
17
- if (!active || !options.isEnabled())
18
- return;
19
- if (ctx.signal?.aborted) {
20
- // Esc cancels the user's run. Do not reset or resurrect it at settled.
21
- state = { phase: "idle" };
22
- return;
23
- }
24
- });
25
- pi.on("agent_settled", (_event, ctx) => {
26
- if (!active || !options.isEnabled() || state.phase === "compacting" || !ctx.isIdle())
27
- return;
28
- if (state.phase !== "requested")
29
- return;
30
- // One owner for requested resets. Consume the request before any external call;
31
- // repeated settled events and reentrant callbacks are harmless.
32
- const request = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit: true };
23
+ const begin = (ctx) => {
24
+ let releaseWait;
25
+ const request = {
26
+ completed: false,
27
+ explicit: true,
28
+ nextRequested: false,
29
+ continuationStarted: false,
30
+ sessionId: ctx.sessionManager.getSessionId(),
31
+ settled: false,
32
+ wait: new Promise((resolve) => { releaseWait = resolve; }),
33
+ release: () => releaseWait(),
34
+ };
33
35
  state = { phase: "compacting", attempt: request };
34
36
  const onError = (error) => {
35
37
  if (!valid(request, ctx))
36
38
  return;
37
- state = { phase: "idle" };
39
+ release(request);
38
40
  // Do not retry from settled in a tight loop. A later prompt may trigger a
39
41
  // native reset or explicitly request one.
40
42
  ctx.ui.notify(`pi-context: reset did not complete (${error.message}). The conversation is retained; resume with another prompt.`, "warning");
@@ -44,13 +46,24 @@ export function registerResetLifecycle(pi, options) {
44
46
  onComplete: () => {
45
47
  if (!valid(request, ctx))
46
48
  return;
47
- state = { phase: "idle" };
48
49
  // session_compact only confirms the boundary. onComplete runs after
49
50
  // Pi clears compaction state; sending inside the hook starts too early.
50
51
  // A queued user prompt may already have started at compaction_end.
51
52
  if (request.completed && ctx.isIdle() && !ctx.hasPendingMessages()) {
52
- pi.sendMessage(options.continuation, { triggerTurn: true });
53
+ // The SDK detaches sendMessage, so own the next settled event before
54
+ // starting it. The originating agent_settled handler awaits wait.
55
+ if (request.continuationStarted)
56
+ return;
57
+ request.continuationStarted = true;
58
+ try {
59
+ pi.sendMessage(options.continuation, { triggerTurn: true });
60
+ }
61
+ catch (error) {
62
+ onError(error instanceof Error ? error : new Error(String(error)));
63
+ }
64
+ return;
53
65
  }
66
+ release(request);
54
67
  },
55
68
  onError,
56
69
  });
@@ -58,6 +71,42 @@ export function registerResetLifecycle(pi, options) {
58
71
  catch (error) {
59
72
  onError(error instanceof Error ? error : new Error(String(error)));
60
73
  }
74
+ return request;
75
+ };
76
+ // State is intentionally not resumed from a pending request: a loaded session must
77
+ // not execute work from a tool that belonged to a previous runtime or tree branch.
78
+ pi.on("session_start", () => { clear(); active = true; });
79
+ pi.on("session_shutdown", () => { clear(); active = false; });
80
+ pi.on("session_tree", clear);
81
+ pi.on("agent_end", (_event, ctx) => {
82
+ if (!active || !options.isEnabled())
83
+ return;
84
+ if (ctx.signal?.aborted) {
85
+ // Esc cancels the user's run. Do not reset or resurrect it at settled.
86
+ clear();
87
+ }
88
+ });
89
+ pi.on("agent_settled", (_event, ctx) => {
90
+ if (!active || !options.isEnabled() || !ctx.isIdle())
91
+ return;
92
+ if (state.phase === "compacting" && state.attempt.continuationStarted) {
93
+ const preceding = state.attempt;
94
+ if (!preceding.nextRequested) {
95
+ release(preceding);
96
+ return;
97
+ }
98
+ // This settled event belongs to the continuation started by preceding.
99
+ // If it requested another reset, retain preceding until that reset's own
100
+ // continuation settles. Its eventual nested handler only releases its own
101
+ // waiter, so it never awaits itself.
102
+ const next = begin(ctx);
103
+ return next.wait.then(() => release(preceding));
104
+ }
105
+ if (state.phase !== "requested")
106
+ return;
107
+ // One owner for requested resets. Consume the request before any external call;
108
+ // repeated settled events and reentrant callbacks are harmless.
109
+ return begin(ctx).wait;
61
110
  });
62
111
  pi.on("session_before_compact", (event, ctx) => {
63
112
  if (!active || !options.isEnabled())
@@ -91,10 +140,15 @@ export function registerResetLifecycle(pi, options) {
91
140
  });
92
141
  return {
93
142
  request() {
94
- const pending = state.phase !== "idle";
95
- if (!pending)
143
+ if (state.phase === "idle") {
96
144
  state = { phase: "requested" };
97
- return pending ? "rollover_already_pending" : "rollover_requested";
145
+ return "rollover_requested";
146
+ }
147
+ if (state.phase === "compacting" && state.attempt.continuationStarted && !state.attempt.nextRequested) {
148
+ state.attempt.nextRequested = true;
149
+ return "rollover_requested";
150
+ }
151
+ return "rollover_already_pending";
98
152
  },
99
153
  clear,
100
154
  };
@@ -102,16 +102,14 @@ export function readCharacterWindow(text, offsetChars, limitChars, render, measu
102
102
  return render(build(content));
103
103
  }
104
104
  /**
105
- * One-line bracketed header preceding a raw character-window payload: the identity, the
106
- * delivered char range, and either the resume cursor or `end`. `tail` appends extra
107
- * metadata (notes add their timestamps) inside the same brackets.
105
+ * Fixed metadata block preceding any raw character-window payload. Callers supply their
106
+ * source identity fields in wire order; range and continuation semantics are shared.
108
107
  */
109
- export function characterWindowHeader(identity, window, tail = "") {
110
- // The range end is offset + delivered count, never `total_chars`: a read resolved past the
111
- // end delivers zero characters there, and the header must not render an inverted range.
108
+ export function readWindowBlock(identity, window) {
112
109
  const end = window.offset_chars + Array.from(window.content).length;
113
- const resume = window.next_offset_chars === null ? "end" : `continue at offset_chars=${window.next_offset_chars}`;
114
- return `[${identity} · chars ${window.offset_chars}-${end} of ${window.total_chars} · ${resume}${tail}]`;
110
+ const next = window.next_offset_chars === null ? "null" : String(window.next_offset_chars);
111
+ const fields = identity.map(([name, value]) => `${name}: ${value}`).join("\n");
112
+ return `--- READ WINDOW ---\n${fields}\nchars: [${window.offset_chars},${end}) of ${window.total_chars}\nnext_offset_chars: ${next}\n`;
115
113
  }
116
114
  /**
117
115
  * Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
@@ -166,9 +164,8 @@ export function output(value, details, terminate = false) {
166
164
  return { content: [{ type: "text", text: json(value) }], details, terminate };
167
165
  }
168
166
  /**
169
- * Encode a prose payload as raw text: a one-line bracketed metadata header, then the payload
170
- * verbatim. The model reads the note or history item itself instead of a JSON envelope;
171
- * `details` carries the slim metadata object and never duplicates the payload.
167
+ * Encode a prose payload as raw text: metadata prefix, a blank line, then the payload
168
+ * verbatim. `details` carries the slim metadata object and never duplicates the payload.
172
169
  */
173
170
  export function outputRaw(header, content, details, terminate = false) {
174
171
  return { content: [{ type: "text", text: `${header}\n${content}` }], details, terminate };
@@ -7,7 +7,7 @@ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
7
7
  import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
8
8
  import piContext from "../src/index.js";
9
9
  import { WARNING_TYPE, GUIDANCE_TYPE } from "../src/protocol.js";
10
- for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "uncompactable", "followup", "steering", "repeat", "abort"]) {
10
+ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "uncompactable", "followup", "steering", "repeat", "nested", "immediate-dispose", "abort"]) {
11
11
  test(`real Pi loop: ${mode} reset preserves history and handles completion`, { timeout: 15000 }, async () => {
12
12
  const dir = mkdtempSync(join(tmpdir(), "pi-context-loop-"));
13
13
  const previousDir = process.env.PI_CODING_AGENT_DIR;
@@ -16,6 +16,7 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
16
16
  const notesRoot = mkdtempSync(join(tmpdir(), "pi-context-loop-notes-"));
17
17
  process.env.PI_NOTES_HOME = notesRoot;
18
18
  let session;
19
+ let disposed = false;
19
20
  try {
20
21
  const runtime = await ModelRuntime.create({ authPath: join(dir, "auth.json"), modelsPath: null, modelsStorePath: join(dir, "models"), refreshOnCreate: false });
21
22
  await runtime.setRuntimeApiKey("openai", "scripted-test-key");
@@ -23,7 +24,7 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
23
24
  assert.ok(base);
24
25
  const model = { ...base, contextWindow: 100000, maxTokens: 4096 };
25
26
  const usageMode = mode === "golden" || mode === "write-error" || mode === "ignored-warning";
26
- const expectedResets = mode === "abort" || mode === "uncompactable" ? 0 : 1;
27
+ const expectedResets = mode === "abort" || mode === "uncompactable" ? 0 : mode === "nested" ? 2 : 1;
27
28
  // 0.86 split-turn cut can still summarize a turn prefix, so keepRecentTokens: 1 no longer
28
29
  // makes a reset uncompactable; a keep larger than the whole session keeps everything and does.
29
30
  const settings = { compaction: { enabled: usageMode, reserveTokens: 32768, keepRecentTokens: mode === "uncompactable" ? 1_000_000 : 200 }, retry: { enabled: false } };
@@ -36,7 +37,7 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
36
37
  let finish;
37
38
  let failFinish;
38
39
  const finished = new Promise((resolve, reject) => { finish = resolve; failFinish = reject; });
39
- const finishTimeout = setTimeout(() => failFinish(new Error(`timed out waiting for ${mode} agent settlement`)), 5000);
40
+ const finishTimeout = setTimeout(() => failFinish(new Error(`timed out waiting for ${mode} agent settlement (resets=${resets}, settled=${settled}, requests=${requests.length})`)), 5000);
40
41
  const loader = new DefaultResourceLoader({ cwd: dir, agentDir: dir, settingsManager,
41
42
  noExtensions: true, noSkills: true, noThemes: true, noPromptTemplates: true,
42
43
  systemPromptOverride: () => "Use the tools as requested.", agentsFilesOverride: () => ({ agentsFiles: [] }),
@@ -83,7 +84,8 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
83
84
  freshTurns++;
84
85
  const sawWarning = request.includes("Your memory is about to be erased");
85
86
  const sawGuidance = request.includes("Your brain is almost out of room");
86
- const explicitReset = (n === 1 && !usageMode && mode !== "uncompactable") || (mode === "repeat" && (n === 1 || n === 3));
87
+ const explicitReset = (n === 1 && !usageMode && mode !== "uncompactable") || (mode === "repeat" && (n === 1 || n === 3)) || (mode === "nested" && n === 3);
88
+ const nestedCheckpoint = mode === "nested" && n === 2;
87
89
  const checkpoint = usageMode && sawWarning && !checkpointed && mode !== "ignored-warning";
88
90
  if (checkpoint)
89
91
  checkpointed = true;
@@ -92,11 +94,11 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
92
94
  // window's reminder. "ignored-warning" keeps working instead of checkpointing.
93
95
  const probe = usageMode && !checkpoint && ((!fresh && !sawWarning) || (mode === "ignored-warning" && sawWarning) || (fresh && freshTurns === 2 && !sawGuidance));
94
96
  const tokens = usageMode ? (fresh ? (freshTurns === 1 ? 100 : 50000) : sawWarning ? 70000 : n === 1 ? 50000 : 60000) : 100;
95
- const tool = explicitReset || (mode === "uncompactable" && n === 1);
96
- const call = probe ? "get_context_remaining" : checkpoint ? "notes_write" : tool ? "new_context" : undefined;
97
+ const tool = explicitReset || nestedCheckpoint || (mode === "uncompactable" && n === 1);
98
+ const call = probe ? "get_context_remaining" : checkpoint || nestedCheckpoint ? "notes_write" : tool ? "new_context" : undefined;
97
99
  const message = { role: "assistant", api: model.api, provider: model.provider, model: model.id,
98
100
  content: probe ? [{ type: "toolCall", id: "probe-call", name: "get_context_remaining", arguments: {} }]
99
- : checkpoint ? [{ type: "toolCall", id: "checkpoint-call", name: "notes_write", arguments: { address: mode === "write-error" ? "../invalid.md" : "checkpoint.md", content: "CHECKPOINT_SENTINEL" } }]
101
+ : checkpoint || nestedCheckpoint ? [{ type: "toolCall", id: "checkpoint-call", name: "notes_write", arguments: { address: mode === "write-error" ? "../invalid.md" : "checkpoint.md", content: nestedCheckpoint ? "NESTED_RESET_PADDING ".repeat(300) : "CHECKPOINT_SENTINEL" } }]
100
102
  : tool ? [{ type: "toolCall", id: "reset-call", name: "new_context", arguments: {} }]
101
103
  : [{ type: "text", text: fresh ? "Resumed." : "Working." }],
102
104
  stopReason: call ? "toolUse" : "stop", timestamp: Date.now(),
@@ -116,9 +118,18 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
116
118
  }
117
119
  });
118
120
  await session.prompt("OLD_CONTEXT_SENTINEL: save progress and continue the task.");
121
+ if (mode === "immediate-dispose") {
122
+ // prompt() must not resolve after compaction merely because sendMessage is
123
+ // detached: by this point the continuation has settled and answered.
124
+ session.dispose();
125
+ disposed = true;
126
+ assert.ok(settled >= 2, "the continuation settles before the originating prompt resolves");
127
+ assert.ok(requests.length >= 2 && !requests.at(-1).includes("OLD_CONTEXT_SENTINEL"), "the resumed answer exists before immediate disposal");
128
+ }
119
129
  await finished;
120
130
  clearTimeout(finishTimeout);
121
- await session.waitForIdle();
131
+ if (!disposed)
132
+ await session.waitForIdle();
122
133
  if (mode === "abort") {
123
134
  assert.equal(resets, 0, "user cancellation clears pending rollover");
124
135
  assert.equal(requests.length, 1, "no continuation resurrects the cancelled run");
@@ -169,6 +180,10 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
169
180
  const queuedEntries = sm.getBranch().filter((entry) => entry.type === "message" && JSON.stringify(entry.message).includes("QUEUED_INPUT_SENTINEL"));
170
181
  assert.equal(queuedEntries.length, 1, "one durable user input");
171
182
  }
183
+ if (mode === "nested") {
184
+ assert.equal(resets, 2, "a continuation-requested reset forms a second completed handoff");
185
+ assert.equal(new Set(sm.getBranch().filter((entry) => entry.type === "compaction").map((entry) => JSON.stringify(entry.details))).size, 2);
186
+ }
172
187
  if (mode === "repeat") {
173
188
  const nextFinished = new Promise((resolve) => { finish = resolve; });
174
189
  targetResets = 2;
@@ -198,7 +213,8 @@ for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "unc
198
213
  }
199
214
  }
200
215
  finally {
201
- session?.dispose();
216
+ if (!disposed)
217
+ session?.dispose();
202
218
  if (previousDir === undefined)
203
219
  delete process.env.PI_CODING_AGENT_DIR;
204
220
  else
@@ -71,25 +71,19 @@ function assertWithinBudget(result, label) {
71
71
  function appendText(sessionManager, text) {
72
72
  return sessionManager.appendMessage({ role: "user", content: [{ type: "text", text }], timestamp: Date.now() });
73
73
  }
74
- /**
75
- * Decode a raw read (notes_read / history_read): a one-line bracketed header, then
76
- * the payload verbatim (which may itself contain newlines), so split on the first newline only.
77
- */
74
+ /** Decode either raw read through the shared READ WINDOW grammar without including metadata in the payload. */
78
75
  function resultRead(result) {
79
76
  const text = result.content[0];
80
77
  assert.ok(text && text.type === "text", "read result carries text");
81
- const newline = text.text.indexOf("\n");
82
- assert.ok(newline !== -1, "raw read carries a header line and a payload");
83
- const header = text.text.slice(0, newline);
84
- const content = text.text.slice(newline + 1);
85
- assert.match(header, /^\[/, "the header is bracketed");
86
- assert.match(header, /\]$/, "the header closes its bracket");
87
- const match = header.match(/ · chars (\d+)-(\d+) of (\d+) · (end|continue at offset_chars=(\d+))/);
88
- assert.ok(match, `read header names the char range and resume cursor: ${header}`);
89
- const offset_chars = Number(match[1]);
90
- const total_chars = Number(match[3]);
91
- const next_offset_chars = match[4] === "end" ? null : Number(match[5]);
92
- assert.equal([...content].length, Number(match[2]) - offset_chars, "the header range matches the delivered payload");
78
+ const block = /^(--- READ WINDOW ---\n(?:[a-z_]+: [^\n]*\n)+chars: \[(\d+),(\d+)\) of (\d+)\nnext_offset_chars: (null|\d+)\n)\n/.exec(text.text);
79
+ assert.ok(block, "raw read carries one READ WINDOW block followed by exactly one blank line");
80
+ const header = block[1];
81
+ const content = text.text.slice(block[0].length);
82
+ const offset_chars = Number(block[2]);
83
+ const end = Number(block[3]);
84
+ const total_chars = Number(block[4]);
85
+ const next_offset_chars = block[5] === "null" ? null : Number(block[5]);
86
+ assert.equal([...content].length, end - offset_chars, "READ WINDOW range matches the delivered payload");
93
87
  return { header, content, offset_chars, total_chars, next_offset_chars };
94
88
  }
95
89
  const PROFILES = [
@@ -213,18 +207,17 @@ test("coherence: following the returned cursors reconstructs the original text e
213
207
  const searched = resultJson(await call(captured, "notes_search", { query: "历", pattern: "huge-cjk.md" }, ctx));
214
208
  const matchedFile = searched.files[0];
215
209
  const matched = matchedFile?.matches[0];
216
- report.push(`notes_search: matches_total=${String(matchedFile?.matches_total)} returned=${String(matchedFile?.matches.length)} first match delivered ${codePoints(matched?.text ?? "")} of ${String(matched?.total_chars)} chars, truncated=${String(matched?.truncated)}`);
210
+ report.push(`notes_search: matches_total=${String(matchedFile?.matches_total)} returned=${String(matchedFile?.matches.length)} first match delivered ${codePoints(matched?.text ?? "")} chars, truncated=${String(matched?.truncated)}`);
217
211
  if (!matched)
218
212
  failures.push("notes_search dropped the over-budget matched line entirely");
219
213
  else {
220
214
  if (matched.truncated !== true)
221
215
  failures.push("notes_search does not flag the over-budget matched line as truncated");
222
- if (matched.total_chars !== codePoints(hugeCjkLine))
223
- failures.push(`notes_search match total_chars=${matched.total_chars}, expected ${codePoints(hugeCjkLine)}`);
224
216
  if (!hugeCjkLine.startsWith(matched.text))
225
217
  failures.push("notes_search delivered a non-prefix of the matched line");
226
- if (codePoints(matched.text) >= matched.total_chars)
227
- failures.push("notes_search claims the over-budget line fits in one response");
218
+ const atMatch = resultRead(await call(captured, "notes_read", { path: "huge-cjk.md", offset_chars: matched.offset_chars }, ctx));
219
+ if (!atMatch.content.startsWith(""))
220
+ failures.push("notes_search offset does not start notes_read at the matched substring");
228
221
  const walked = stripLeadingFrontmatter(await walkNote(captured, ctx, "huge-cjk.md"));
229
222
  if (walked !== `${hugeCjkLine}\ntail line`)
230
223
  failures.push(`notes_search match line is not reconstructible from the note read: missing ${codePoints(`${hugeCjkLine}\ntail line`) - codePoints(walked)} chars`);
@@ -309,27 +302,30 @@ test("coherence: following the returned cursors reconstructs the original text e
309
302
  if (hugeEntry.matches[0]?.truncated !== true)
310
303
  failures.push("huge-many: the kept match is not flagged as a prefix");
311
304
  }
312
- // --- notes_search addresses: a match's offset_chars is the body-absolute code-point
313
- // position of the earliest query occurrence in its line, so search → read composes exactly like
314
- // history's match_offset_chars two-stage.
305
+ // --- notes_search addresses: each match offset directly starts notes_read at the earliest
306
+ // query occurrence in its line, including multi-query OR.
315
307
  const addressLine1 = "pad ".repeat(50);
316
308
  const addressLine3 = `${"历".repeat(20)}needle-address here`;
317
309
  const addressLine4 = "zeta 历 needle-address";
318
310
  await call(captured, "notes_write", { path: "address.md", content: `${addressLine1}\nsecond\n${addressLine3}\n${addressLine4}` }, ctx);
319
- const expectedAddress = codePoints(addressLine1) + 1 + codePoints("second") + 1 + 20;
320
311
  const addressHit = resultJson(await call(captured, "notes_search", { query: "needle-address", pattern: "address.md" }, ctx)).files[0]?.matches.find((match) => match.line === 3);
321
- report.push(`notes_search address: line=${String(addressHit?.line)} offset_chars=${String(addressHit?.offset_chars)} expected=${expectedAddress}`);
322
- const addressOffset = addressHit?.offset_chars;
323
- if (typeof addressOffset !== "number")
324
- failures.push("notes_search carries no offset_chars");
325
- else if (addressOffset !== expectedAddress)
326
- failures.push(`notes_search offset_chars=${addressOffset}, expected ${expectedAddress} (body-absolute, at the query)`);
327
- // Multi-query OR: a line's address is the earliest occurrence of any query inside that line.
328
- const line4Base = expectedAddress - 20 + codePoints(addressLine3) + 1;
312
+ report.push(`notes_search address: line=${String(addressHit?.line)} offset_chars=${String(addressHit?.offset_chars)}`);
313
+ if (addressHit === undefined)
314
+ failures.push("notes_search carries no line-three match");
315
+ else {
316
+ const atMatch = resultRead(await call(captured, "notes_read", { path: "address.md", offset_chars: addressHit.offset_chars }, ctx));
317
+ if (!atMatch.content.startsWith("needle-address"))
318
+ failures.push(`notes_search offset_chars=${addressHit.offset_chars} does not start at the line-three query`);
319
+ }
329
320
  const orLine4 = resultJson(await call(captured, "notes_search", { query: ["needle-address", "zeta"], pattern: "address.md" }, ctx)).files[0]?.matches.find((match) => match.line === 4);
330
- report.push(`notes_search OR address: offset_chars=${String(orLine4?.offset_chars)} expected=${line4Base}`);
331
- if (orLine4?.offset_chars !== line4Base)
332
- failures.push(`notes_search OR offset_chars=${String(orLine4?.offset_chars)}, expected ${line4Base} (earliest of any query)`);
321
+ report.push(`notes_search OR address: offset_chars=${String(orLine4?.offset_chars)}`);
322
+ if (orLine4 === undefined)
323
+ failures.push("notes_search carries no line-four OR match");
324
+ else {
325
+ const atMatch = resultRead(await call(captured, "notes_read", { path: "address.md", offset_chars: orLine4.offset_chars }, ctx));
326
+ if (!atMatch.content.startsWith("zeta"))
327
+ failures.push(`notes_search OR offset_chars=${orLine4.offset_chars} does not start at the earliest line-four query`);
328
+ }
333
329
  // --- Negative offsets on both stores: a tail read reaches the end in one call, the response
334
330
  // echoes the resolved absolute offset, N >= total_chars reads from the start, and the cursor
335
331
  // law still holds when a negative-start page is cut short.