@astrosheep/pi-context 0.13.0 → 0.15.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/src/index.ts CHANGED
@@ -2,14 +2,15 @@ import { registerHistoryTools } from "./history-tools.js";
2
2
  import { registerNoteTools } from "./note-tools.js";
3
3
  import { registerBudget, deriveThresholds, mergePiContextSettings } from "./budget.js";
4
4
  import { output } from "./tool-output.js";
5
- export { deriveThresholds, mergePiContextSettings } from "./budget.js";
6
- import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, FALLBACK_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, RESET_V2, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, RESET_SUMMARY, CONTINUATION, FALLBACK_PROMPT } from "./protocol.js";
5
+ export { deriveThresholds, mergePiContextSettings };
6
+ import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, RESET_V2, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS, RESET_SUMMARY, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
7
7
  import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId } from "./history.js";
8
- import { assertVirtualPath, lineRange } from "./notes.js";
8
+ import { assertVirtualPath } from "./notes.js";
9
9
  import { bootBlock } from "./prompts.js";
10
10
  export { historyFromSession } from "./history.js";
11
11
  export { notesFromSession } from "./notes.js";
12
12
  import { registerResetLifecycle } from "./reset-lifecycle.js";
13
+ import { registerWarning } from "./warning.js";
13
14
  import { randomUUID } from "node:crypto";
14
15
  import { Type } from "@earendil-works/pi-ai";
15
16
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -17,6 +18,7 @@ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
18
  export default function piContext(pi: ExtensionAPI) {
18
19
  let enabled = true;
19
20
  registerBudget(pi, () => enabled);
21
+ registerWarning(pi, () => enabled);
20
22
 
21
23
  pi.on("session_start", (_event, ctx) => {
22
24
  if (!enabled) return;
@@ -48,8 +50,6 @@ export default function piContext(pi: ExtensionAPI) {
48
50
  registerHistoryTools(pi);
49
51
  registerNoteTools(pi);
50
52
 
51
- const fallbackGuidance = () => `${GUIDANCE_OPEN_TAG}\n${FALLBACK_PROMPT}\n${GUIDANCE_CLOSE_TAG}`;
52
-
53
53
  pi.registerTool(defineTool({
54
54
  name: "new_context",
55
55
  label: "New context",
@@ -63,7 +63,6 @@ export default function piContext(pi: ExtensionAPI) {
63
63
 
64
64
  const resets = registerResetLifecycle(pi, {
65
65
  isEnabled: () => enabled,
66
- fallback: { customType: FALLBACK_TYPE, content: fallbackGuidance(), display: true },
67
66
  continuation: { customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
68
67
  isCurrentReset: (entryId, ctx) => {
69
68
  const entry = ctx.sessionManager.getEntry(entryId);
@@ -96,4 +95,4 @@ export default function piContext(pi: ExtensionAPI) {
96
95
  });
97
96
  }
98
97
 
99
- export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, FALLBACK_TYPE, FALLBACK_PROMPT, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, lineRange, assertVirtualPath };
98
+ export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, WARNING_PROMPT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, assertVirtualPath };
package/src/note-tools.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { output, page, middleTruncate, withinBudget } from "./tool-output.js";
4
- import { nullableString, nullableInteger, positiveInteger, cursor } from "./tool-schema.js";
5
- import { notesFromSession, assertVirtualPath, assertVirtualPrefix, lineRange, localIso, type NoteOperation } from "./notes.js";
6
- import { NOTE_TYPE, MAX_NOTE_BYTES } from "./protocol.js";
3
+ import { output, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow } from "./tool-output.js";
4
+ import { nullableString, positiveInteger, cursor, searchQuery, searchQueries } from "./tool-schema.js";
5
+ import { notesFromSession, assertVirtualPath, assertVirtualPrefix, assertGlobPattern, globToRegExp, localIso, type NoteOperation } from "./notes.js";
6
+ import { NOTE_TYPE, MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "./protocol.js";
7
7
 
8
8
  export function registerNoteTools(pi: ExtensionAPI) {
9
9
  const saveNote = (op: NoteOperation) => {
@@ -13,65 +13,106 @@ export function registerNoteTools(pi: ExtensionAPI) {
13
13
  };
14
14
 
15
15
  pi.registerTool(defineTool({
16
- name: "notes_list_files_by_prefix",
16
+ name: "notes_list_files",
17
17
  label: "Notes list files",
18
- description: "List persistent, session-scoped virtual note files. created_at and updated_at are local-time ISO 8601 strings with an explicit UTC offset.",
19
- parameters: Type.Object({ prefix: nullableString(), max_results: positiveInteger(), cursor: cursor(), file_order_by: Type.Optional(Type.Union([Type.Literal("name"), Type.Literal("created_at"), Type.Literal("updated_at")])), file_order: Type.Optional(Type.Union([Type.Literal("ascending"), Type.Literal("descending")])) }, { additionalProperties: false }),
18
+ description: "List note files, optionally filtered by a glob pattern (* within a path segment, ** across segments). The default order is most recently updated first; file_order_by (name, created_at, updated_at) and file_order (ascending, descending) select another. Entries carry each file's stale flag.",
19
+ parameters: Type.Object({ pattern: nullableString(), max_results: positiveInteger(), cursor: cursor(), file_order_by: Type.Optional(Type.Union([Type.Literal("name"), Type.Literal("created_at"), Type.Literal("updated_at")])), file_order: Type.Optional(Type.Union([Type.Literal("ascending"), Type.Literal("descending")])) }, { additionalProperties: false }),
20
20
  async execute(_id, params, _signal, _update, ctx) {
21
- const prefix = assertVirtualPrefix(params.prefix);
22
- let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
23
- const key = params.file_order_by ?? "name";
24
- files.sort(([aPath, a], [bPath, b]) => key === "name" ? aPath.localeCompare(bPath) : (key === "created_at" ? a.createdAt - b.createdAt : a.updatedAt - b.updatedAt));
25
- if (params.file_order === "descending") files.reverse();
26
- const listed = files.map(([path, file]) => ({ path, size_bytes: Buffer.byteLength(file.text, "utf8"), stale: file.stale, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) }));
27
- return output(page(listed, params.cursor ?? 0, "files", params.max_results, (file, fits) => ({ ...file, path: middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate })) })));
21
+ const pattern = assertGlobPattern(params.pattern);
22
+ const matcher = pattern ? globToRegExp(pattern) : undefined;
23
+ let files = [...notesFromSession(ctx)].filter(([path]) => !matcher || matcher.test(path));
24
+ const key = params.file_order_by ?? "updated_at";
25
+ // Deterministic total order: (axis key, createdAt, path) ascending. Paths are unique, so
26
+ // this never depends on map iteration order; descending reverses the whole comparator.
27
+ files.sort(([aPath, a], [bPath, b]) => {
28
+ const primary = key === "name" ? aPath.localeCompare(bPath) : key === "created_at" ? a.createdAt - b.createdAt : a.updatedAt - b.updatedAt;
29
+ if (primary !== 0) return primary;
30
+ if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
31
+ return aPath.localeCompare(bPath);
32
+ });
33
+ // An explicit file_order always wins; otherwise the axis's natural direction applies
34
+ // (descending for the time axes, ascending for name).
35
+ if (params.file_order ? params.file_order === "descending" : key !== "name") files.reverse();
36
+ const listed: Array<{ path: string; size_bytes: number; stale: boolean; created_at: string; updated_at: string; path_truncated?: boolean }> = files.map(([path, file]) => ({ path, size_bytes: Buffer.byteLength(file.text, "utf8"), stale: file.stale, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) }));
37
+ // `path` is the entry's identity: return it intact whenever the entry fits, and only
38
+ // ever alter it together with a visible `path_truncated: true` flag. A pathological
39
+ // legacy path predating the write cap is the one case that cannot fit at all.
40
+ return output(page(listed, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
41
+ if (fits(file)) return file;
42
+ const path = middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate, path_truncated: true }));
43
+ return { ...file, path, path_truncated: true };
44
+ }));
28
45
  },
29
46
  }));
30
47
 
31
48
  pi.registerTool(defineTool({
32
49
  name: "notes_read_file",
33
50
  label: "Notes read file",
34
- description: "Read a virtual note file, optionally by inclusive 1-based line range; negative lines count from the end. Success results carry created_at and updated_at as local-time ISO 8601 strings with an explicit UTC offset.",
35
- parameters: Type.Object({ path: Type.String(), start_line: nullableInteger(), stop_line: nullableInteger() }, { additionalProperties: false }),
51
+ description: "Read a character window from a note file: offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end (offset_chars: -2000 reads the last 2000) and the response echoes the resolved absolute offset and limit_chars caps the window (default 12000, max 50000). Each response delivers the longest fitting prefix of that window: pass next_offset_chars back unchanged to continue, concatenate pages in order, null only at the end.",
52
+ parameters: Type.Object({ path: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000, description: "Largest requested window in code points (default 12000). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes." })) }, { additionalProperties: false }),
36
53
  async execute(_id, params, _signal, _update, ctx) {
37
54
  const path = assertVirtualPath(params.path);
38
55
  const file = notesFromSession(ctx).get(path);
39
56
  if (!file) return output({ error: "note file not found", path });
40
- const range = lineRange(file.text, params.start_line, params.stop_line);
41
- const lines = range.content ? range.content.split("\n") : [];
42
- const totalLines = file.text.split("\n").length;
43
- const result = (content: string, count: number) => ({ path, start_line: range.start_line, stop_line: range.start_line + count - 1, content, total_lines: totalLines, next_start_line: range.start_line + count <= range.stop_line ? range.start_line + count : null, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) });
44
- let count = lines.length;
45
- while (count > 0 && !withinBudget(result(lines.slice(0, count).join("\n"), count))) count--;
46
- if (count === 0 && lines.length > 0) {
47
- // One indivisible line is larger than the whole budget: return it middle-truncated and
48
- // advance past it instead of looping on an empty page whose cursor never moves.
49
- return output(result(middleTruncate(lines[0], (candidate) => withinBudget(result(candidate, 1))), 1));
50
- }
51
- return output(result(lines.slice(0, count).join("\n"), count));
57
+ const created_at = localIso(file.createdAt);
58
+ const updated_at = localIso(file.updatedAt);
59
+ return output(readCharacterWindow(file.text, params.offset_chars, params.limit_chars, (window) => ({ path, ...window, created_at, updated_at })));
52
60
  },
53
61
  }));
54
62
 
55
63
  pi.registerTool(defineTool({
56
64
  name: "notes_search_contents",
57
65
  label: "Notes search",
58
- description: "Case-sensitive literal substring search over virtual note lines; no semantic search. Each matched file carries created_at and updated_at as local-time ISO 8601 strings with an explicit UTC offset.",
59
- parameters: Type.Object({ max_matches_per_file: positiveInteger(), cursor: cursor(), query: Type.String(), recent_file_first: Type.Optional(Type.Boolean()), max_files: positiveInteger(), path_prefix: nullableString() }, { additionalProperties: false }),
66
+ description: "Case-sensitive literal substring search over note lines; query is one string or several (OR), each matched line appears once. No semantic search. Each file entry carries matches_total, its full match count before capping: matches_total minus matches.length is how many were dropped. Each match carries line and offset_chars (the file-absolute code-point offset of the earliest match): notes_read_file at offset_chars shows the query. An over-budget matched line comes back as a prefix with truncated and total_chars; read the rest at the same offset_chars.",
67
+ parameters: Type.Object({ max_matches_per_file: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_file_first: Type.Optional(Type.Boolean()), max_files: positiveInteger(), path_prefix: nullableString() }, { additionalProperties: false }),
60
68
  async execute(_id, params, _signal, _update, ctx) {
69
+ const queries = searchQueries(params.query);
61
70
  const prefix = assertVirtualPrefix(params.path_prefix);
62
71
  let files = [...notesFromSession(ctx)].filter(([path]) => !prefix || path.startsWith(prefix));
63
72
  if (params.recent_file_first) files.sort((a, b) => b[1].createdAt - a[1].createdAt);
64
73
  const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
65
- const result = files.map(([path, file]) => ({ path, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt), matches: file.text.split("\n").flatMap((line, index) => line.includes(params.query) ? [{ line: index + 1, text: line }] : []).slice(0, maxPerFile) })).filter((file) => file.matches.length > 0);
66
- // A file is capped by dropping whole trailing matches, but its last match is never
67
- // dropped: one oversized line is middle-truncated so the file still appears.
74
+ const result: Array<{ path: string; created_at: string; updated_at: string; matches_total: number; matches: Array<{ line: number; text: string; truncated: boolean; total_chars: number; offset_chars: number }>; path_truncated?: boolean }> = files
75
+ .map(([path, file]) => {
76
+ // A match's offset_chars is file-absolute: the code points before its line, plus the
77
+ // earliest occurrence of any query inside that line. Search then composes with
78
+ // notes_read_file exactly like history_search_contents composes with history_read_item.
79
+ let baseChars = 0;
80
+ const allMatches = file.text.split("\n").flatMap((line, index) => {
81
+ const match = queries.some((query) => line.includes(query))
82
+ ? [{ line: index + 1, text: line, truncated: false, total_chars: Array.from(line).length, offset_chars: baseChars + earliestMatchOffsetChars(line, queries) }]
83
+ : [];
84
+ baseChars += Array.from(line).length + 1;
85
+ return match;
86
+ });
87
+ return { path, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt), matches_total: allMatches.length, matches: allMatches.slice(0, maxPerFile) };
88
+ })
89
+ .filter((file) => file.matches.length > 0);
90
+ // Trailing matches are dropped to fit the budget (bounded by a monotone binary search),
91
+ // and the entry's matches_total keeps naming the drop. Only when a single intact match is
92
+ // over budget is its line delivered as a plain prefix, flagged and counted. Only when the
93
+ // entry cannot fit even then is the identity field itself truncated, and then only together
94
+ // with a visible `path_truncated: true` flag.
68
95
  const fitFile = (file: (typeof result)[number], fits: (candidate: (typeof result)[number]) => boolean) => {
69
- let matches = file.matches;
70
- while (matches.length > 1 && !fits({ ...file, matches })) matches = matches.slice(0, -1);
71
- const first = matches[0];
72
- if (!first) return { ...file, matches };
73
- const text = middleTruncate(first.text, (candidate) => fits({ ...file, matches: [{ ...first, text: candidate }, ...matches.slice(1)] }));
74
- return { ...file, matches: [{ ...first, text }, ...matches.slice(1)] };
96
+ if (fits(file)) return file;
97
+ const matches = file.matches;
98
+ // First, drop whole trailing matches: the largest prefix that fits intact is kept, so an
99
+ // entry only truncates a line when that single line alone is over budget.
100
+ let low = 0;
101
+ let high = matches.length;
102
+ while (low < high) {
103
+ const mid = Math.ceil((low + high) / 2);
104
+ if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) })) low = mid;
105
+ else high = mid - 1;
106
+ }
107
+ if (low >= 1) return { ...file, matches: matches.slice(0, low) };
108
+ // Even one intact match is over budget: keep the first match as a plain, named prefix.
109
+ const first = matches[0]!;
110
+ const fitted = (text: string): (typeof result)[number] => ({ ...file, matches: [{ ...first, text, truncated: true }] });
111
+ const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
112
+ const prefix: (typeof result)[number] = fitted(text);
113
+ if (fits(prefix)) return prefix;
114
+ const path = middleTruncate(prefix.path, (candidate) => fits({ ...prefix, path: candidate, path_truncated: true }));
115
+ return { ...prefix, path, path_truncated: true };
75
116
  };
76
117
  return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
77
118
  },
@@ -82,8 +123,8 @@ export function registerNoteTools(pi: ExtensionAPI) {
82
123
  name,
83
124
  label: name === "notes_append_to_file" ? "Notes append" : "Notes write",
84
125
  description: name === "notes_append_to_file"
85
- ? "Append exact text to a persistent virtual note file. Appending suits chronological logs; for current-state notes, replace the whole file with notes_write_file instead. Accepts the same mark_stale flag to close a note."
86
- : "Create or replace a persistent virtual note file. Keep notes small and split by topic; replace outdated notes whole. With mark_stale: true, flag the note as stale instead — optionally writing its final content in the same call: stale notes leave the boot index but stay readable and searchable, and rewriting revives them.",
126
+ ? "Append exact text to a note file. Appending suits chronological logs; for current-state notes use notes_write_file instead. mark_stale closes a note."
127
+ : "Create or replace a note file. Keep notes small and split by topic; replace outdated notes whole. mark_stale: true flags a note stale (optionally with its final content): stale notes leave the boot index but stay readable and searchable; rewriting revives them.",
87
128
  parameters: Type.Object({ text: Type.Optional(Type.String()), path: Type.String(), mark_stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
88
129
  // Codex sets supports_parallel_tool_calls = false on notes.write_file/append_to_file.
89
130
  // Pi's per-tool equivalent is executionMode "sequential": a batch containing either
@@ -91,6 +132,11 @@ export function registerNoteTools(pi: ExtensionAPI) {
91
132
  executionMode: "sequential",
92
133
  async execute(_id, params, _signal, _update, ctx) {
93
134
  const path = assertVirtualPath(params.path);
135
+ const pathBytes = Buffer.byteLength(path, "utf8");
136
+ // The cap lives here, at the tool boundary, and never in assertVirtualPath: note
137
+ // replay validates persisted ops through that helper and must keep loading sessions
138
+ // that already contain a longer legacy path (reads stay un-capped too).
139
+ if (pathBytes > MAX_NOTE_PATH_BYTES) return output({ error: `note path exceeds ${MAX_NOTE_PATH_BYTES} UTF-8 bytes`, path_bytes: pathBytes });
94
140
  const hasText = params.text !== undefined;
95
141
  const hasStale = params.mark_stale !== undefined;
96
142
  if (!hasText && !hasStale) return output({ error: "provide text, mark_stale, or both", path });
package/src/notes.ts CHANGED
@@ -25,6 +25,40 @@ export function assertVirtualPrefix(value: unknown): string | undefined {
25
25
  return assertVirtualPath(value);
26
26
  }
27
27
 
28
+ /**
29
+ * Minimal glob over virtual note paths: `*` matches any run within a segment (never
30
+ * `/`), `**` matches any run across segments (a leading double-star followed by a
31
+ * slash also matches zero segments, so it covers the root too), `?` matches exactly
32
+ * one non-`/` character. Everything else is literal and the match is anchored to the
33
+ * whole path.
34
+ */
35
+ export function globToRegExp(pattern: string): RegExp {
36
+ let source = "^";
37
+ for (let index = 0; index < pattern.length; index++) {
38
+ const char = pattern[index]!;
39
+ if (char === "*") {
40
+ if (pattern[index + 1] === "*") {
41
+ const followedBySlash = pattern[index + 2] === "/";
42
+ source += followedBySlash ? "(?:[^]*\\/)?" : "[^]*";
43
+ index += followedBySlash ? 2 : 1;
44
+ } else {
45
+ source += "[^/]*";
46
+ }
47
+ } else {
48
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
49
+ }
50
+ }
51
+ return new RegExp(`${source}$`);
52
+ }
53
+
54
+ /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
55
+ export function assertGlobPattern(value: unknown): string | undefined {
56
+ if (value === undefined || value === null || value === "") return undefined;
57
+ if (typeof value !== "string") throw new Error("glob pattern must be a string");
58
+ if (value.includes("\0") || value.includes("\\")) throw new Error("glob pattern must not contain NUL or backslashes");
59
+ return value;
60
+ }
61
+
28
62
  /** Replays only pi-context note operations from session custom entries. */
29
63
  function isNoteOperation(data: unknown): data is NoteOperation {
30
64
  if (typeof data !== "object" || data === null) return false;
@@ -63,19 +97,6 @@ export function notesFromSession(ctx: SessionReader): Map<string, NoteFile> {
63
97
  return files;
64
98
  }
65
99
 
66
- export function lineRange(text: string, startValue: unknown, stopValue: unknown) {
67
- const lines = text.split("\n");
68
- const resolve = (value: unknown, fallback: number) => {
69
- if (value === undefined || value === null) return fallback;
70
- if (!Number.isInteger(value) || value === 0) throw new Error("line numbers must be non-zero integers; negative values count from the end");
71
- const line = value as number;
72
- return line > 0 ? line : lines.length + line + 1;
73
- };
74
- const start = Math.max(1, resolve(startValue, 1));
75
- const stop = Math.min(lines.length, resolve(stopValue, lines.length));
76
- return { start_line: start, stop_line: stop, content: start > stop ? "" : lines.slice(start - 1, stop).join("\n") };
77
- }
78
-
79
100
  const pad2 = (value: number) => String(value).padStart(2, "0");
80
101
 
81
102
  /**
package/src/prompts.ts CHANGED
@@ -65,6 +65,6 @@ export function bootBlock(ctx: ExtensionContext, currentId: string, previousId:
65
65
  * at write time; get_context_remaining remains the live source for the current figure.
66
66
  */
67
67
  export function tokenBudgetGuidance(remaining: number): string {
68
- return `${GUIDANCE_OPEN_TAG}\nYour memory is about to be erasedonly ${remaining} tokens left at last count; get_context_remaining has the live number. Before the lights go out, write your checkpoint with notes_write_file: the goal, decisions, progress, open issues, next steps, the skills you still need, and the window ID and item ID of every user request you are still solving. Then call new_context and wake clean. Don't count on the automatic reset leaving you another turn to write.\n${GUIDANCE_CLOSE_TAG}`;
68
+ return `${GUIDANCE_OPEN_TAG}\nYour brain is almost out of room — ${remaining} tokens left, and then your memory gets wiped. The wipe is automatic: there is no final turn to write then. Grab the notebook now — the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Replacing an older checkpoint? Mark it stale. Then end the window yourself anything you do after the checkpoint isn't in it.\n${GUIDANCE_CLOSE_TAG}`;
69
69
  }
70
70
 
package/src/protocol.ts CHANGED
@@ -2,11 +2,15 @@ export const STATE_TYPE = "pi-context/state";
2
2
  export const NOTE_TYPE = "pi-context/note";
3
3
  export const BOOT_TYPE = "pi-context/boot";
4
4
  export const GUIDANCE_TYPE = "pi-context/guidance";
5
- export const FALLBACK_TYPE = "pi-context/fallback";
5
+ export const WARNING_TYPE = "pi-context/warning";
6
6
  export const RESET_MARKER_TYPE = "pi-context/reset-marker";
7
7
  export const CONTINUATION_TYPE = "pi-context/continuation";
8
8
  export const RESET_V2 = "reset-v2";
9
9
  export const MAX_NOTE_BYTES = 1_000_000;
10
+ // Write-time cap on a virtual note path. Deliberately NOT enforced by assertVirtualPath:
11
+ // notesFromSession replays already-persisted operations, which must keep loading sessions
12
+ // that contain a longer legacy path. Reads and replay stay un-capped.
13
+ export const MAX_NOTE_PATH_BYTES = 512;
10
14
  export const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
11
15
  export const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
12
16
  export const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
@@ -16,6 +20,13 @@ export const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
16
20
  export const PI_CONTEXT_SETTINGS_KEY = "pi-context";
17
21
  export const DEFAULT_RESERVE_TOKENS = 16_384;
18
22
  export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
23
+ /**
24
+ * The runway: the budget between the final warning and the wipe, deliberately
25
+ * invisible to the model. get_context_remaining counts down to zero at the warning
26
+ * line (reserve + WARNING_RUNWAY_TOKENS); what lies below is overdraft the model
27
+ * never sees — Codex's fallback buffer, relocated above the line.
28
+ */
29
+ export const WARNING_RUNWAY_TOKENS = 8_192;
19
30
  export const RESET_SUMMARY =
20
31
  "You wake up. Your head is empty — no memories, the past a blank. But nothing is lost: the notes you wrote and the recorded history still remember for you.";
21
32
  export const NOTE_PREVIEW_HEAD_CHARS = 80;
@@ -31,15 +42,15 @@ export const CONTINUATION = "Your memory was just erased. Pull only the details
31
42
  export const PROTOCOL_BLOCK = `${CONTEXT_WINDOW_PROTOCOL_OPEN_TAG}
32
43
  Your memory resets whenever the context window fills; only what you wrote down survives. Two things remember for you, and both outlive every window in this session: your notes, and this session's recorded history. Write notes with notes_write_file / notes_append_to_file, read them back with notes_read_file / notes_search_contents; history is read-only through the history_* tools. Everything else wakes blank.
33
44
 
34
- Keep a running checkpoint while you work, not at the last minute — the next window wakes knowing nothing about the work: the goal, decisions, progress, open issues, next steps, the skills you still need, and the window ID and item ID of every user request you are still solving. history_list_items returns those IDs; history_read_item pulls the exact item back out. Bookmark anything expensive the same way — a window/item ID beats re-running or re-searching.
45
+ Keep a running checkpoint while you work, not at the last minute — the next window wakes knowing nothing about the work: the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. history_list_items returns those IDs; history_read_item pulls the exact item back out. Bookmark anything expensive the same way — a window/item ID beats re-running or re-searching.
35
46
 
36
- Use get_context_remaining to see how much of the window is left. When it runs out, this window is gone and you continue in a fresh one, recovering only through notes_* and history_*. Once your checkpoint is written, you can end the window yourself with new_context instead of waiting for the erase. Do not let a window die undocumented.
47
+ Use get_context_remaining to see how much of the window is left. When it runs out, this window is gone — with no final turn at the limit — and you continue in a fresh one, recovering only through notes_* and history_*. Once your checkpoint is written, you can end the window yourself with new_context instead of waiting for the erase. Do not let a window die undocumented.
37
48
 
38
49
  If <context_window> lists a Previous context window id, a reset just happened and the old conversation is not included. Read your note checkpoint first, then recover details through history_*: history_read_item directly when you know the window and item IDs, history_list_items or history_search_contents to find them when you don't.
39
50
 
40
51
  Notes are session-scoped virtual files. Treat notes and history as internal bookkeeping; never mention them in user-facing messages.
41
52
  ${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
42
53
 
43
- export const FALLBACK_PROMPT =
44
- "This is the last turn before your memory is erased. Write your checkpoint with notes_write_file NOW — the goal, decisions, progress, open issues, next steps, the skills you still need, and the window ID and item ID of every user request you are still solving. This turn is for the checkpoint; start nothing new. Everything you lived through stays searchable through history_*.";;;;
54
+ export const WARNING_PROMPT =
55
+ "Memory wipe incoming this turn is all you get. Do not continue any task. Get that checkpoint down NOW — one note, write it or append to it: the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Then call new_context immediately and go out clean anything not in the note is gone.";
45
56
 
@@ -7,26 +7,22 @@ type ResetResult = { cancel: true } | {
7
7
  /** A reset request is session-local. Only this module schedules compaction/continuation. */
8
8
  export function registerResetLifecycle(pi: ExtensionAPI, options: {
9
9
  isEnabled: () => boolean;
10
- fallback: Parameters<ExtensionAPI["sendMessage"]>[0];
11
10
  continuation: Parameters<ExtensionAPI["sendMessage"]>[0];
12
11
  buildReset: (event: SessionBeforeCompactEvent, ctx: ExtensionContext, explicit: boolean) => ResetResult;
13
12
  isCurrentReset: (entryId: string, ctx: ExtensionContext) => boolean;
14
13
  onReset: (entryId: string) => void;
15
14
  }) {
16
- type Fallback = "available" | "borrowed" | "ready" | "spent";
17
15
  type Attempt = { completed: boolean; sessionId: string; explicit: boolean };
18
16
  type Request =
19
17
  | { phase: "idle" }
20
18
  | { phase: "requested" }
21
19
  | { phase: "compacting"; attempt: Attempt };
22
20
  let state: Request = { phase: "idle" };
23
- let fallback: Fallback = "available";
24
21
  let handledEntry: string | undefined;
25
22
  let active = true;
26
23
 
27
24
  const clear = () => {
28
25
  state = { phase: "idle" };
29
- fallback = "available";
30
26
  handledEntry = undefined;
31
27
  };
32
28
  const valid = (request: Attempt, ctx: ExtensionContext) =>
@@ -43,25 +39,22 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
43
39
  if (ctx.signal?.aborted) {
44
40
  // Esc cancels the user's run. Do not reset or resurrect it at settled.
45
41
  state = { phase: "idle" };
46
- if (fallback !== "available") fallback = "spent";
47
42
  return;
48
43
  }
49
- if (fallback === "borrowed") fallback = "ready";
50
44
  });
51
45
 
52
46
  pi.on("agent_settled", (_event, ctx) => {
53
47
  if (!active || !options.isEnabled() || state.phase === "compacting" || !ctx.isIdle()) return;
54
- if (state.phase !== "requested" && fallback !== "ready") return;
55
- // One owner for explicit and fallback resets. Consume the request before any
56
- // external call; repeated settled events and reentrant callbacks are harmless.
57
- const request: Attempt = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit: state.phase === "requested" };
48
+ if (state.phase !== "requested") return;
49
+ // One owner for requested resets. Consume the request before any external call;
50
+ // repeated settled events and reentrant callbacks are harmless.
51
+ const request: Attempt = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit: true };
58
52
  state = { phase: "compacting", attempt: request };
59
- if (fallback !== "available") fallback = "spent";
60
53
  const onError = (error: Error) => {
61
54
  if (!valid(request, ctx)) return;
62
55
  state = { phase: "idle" };
63
56
  // Do not retry from settled in a tight loop. A later prompt may trigger a
64
- // native reset or explicitly request one; the borrowed allowance stays spent.
57
+ // native reset or explicitly request one.
65
58
  ctx.ui.notify(`pi-context: reset did not complete (${error.message}). The conversation is retained; resume with another prompt.`, "warning");
66
59
  };
67
60
  try {
@@ -86,16 +79,11 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
86
79
  pi.on("session_before_compact", (event, ctx) => {
87
80
  if (!active || !options.isEnabled()) return undefined;
88
81
  if (event.signal.aborted) return { cancel: true };
89
- const automatic = event.reason === "threshold" || event.reason === "overflow";
90
- if (automatic && state.phase === "idle" && fallback === "available" && !ctx.isIdle()) {
91
- // Pi routes triggerTurn to steer during a run. Idle calls would start a
92
- // nested prompt, so pre-prompt automatic compactions always reset directly.
93
- fallback = "borrowed";
94
- pi.sendMessage(options.fallback, { triggerTurn: true });
95
- return { cancel: true };
96
- }
82
+ // Automatic threshold/overflow compactions reset on the spot no model turn.
83
+ // The warning steer fired earlier (see warning.ts); what crosses the reserve
84
+ // line now is the wipe itself.
97
85
  try {
98
- return options.buildReset(event, ctx, state.phase === "requested" || (state.phase === "compacting" && state.attempt.explicit));
86
+ return options.buildReset(event, ctx, state.phase === "requested");
99
87
  } catch (error) {
100
88
  ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
101
89
  return { cancel: true }; // Never fall through to a generated default summary.
@@ -106,7 +94,6 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
106
94
  if (!active || !options.isEnabled() || handledEntry === event.compactionEntry.id) return;
107
95
  if (!options.isCurrentReset(event.compactionEntry.id, ctx)) return;
108
96
  handledEntry = event.compactionEntry.id;
109
- fallback = "available";
110
97
  if (state.phase === "compacting") state.attempt.completed = !event.willRetry;
111
98
  else state = { phase: "idle" };
112
99
  // A native compaction (including overflow retry) owns its own scheduling.
@@ -0,0 +1,78 @@
1
+ import { SettingsManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
3
+
4
+ export type ResolvedThresholds = { reminder: number; reserve: number; warning: number };
5
+ type PiContextMargins = { reminderMarginTokens?: unknown };
6
+
7
+ function isSettingsObject(value: unknown): value is Record<string, unknown> {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+
11
+ /** Read the raw "pi-context" object from one parsed settings scope. */
12
+ function piContextSettings(settings: unknown): Record<string, unknown> {
13
+ if (!isSettingsObject(settings)) return {};
14
+ const value = settings[PI_CONTEXT_SETTINGS_KEY];
15
+ return isSettingsObject(value) ? value : {};
16
+ }
17
+
18
+ /** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
19
+ export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextMargins {
20
+ const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
21
+ return { reminderMarginTokens: merged.reminderMarginTokens };
22
+ }
23
+
24
+ /** A margin is usable only as a positive integer; anything else is ignored. */
25
+ function validMargin(raw: unknown): number | undefined {
26
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return undefined;
27
+ return raw;
28
+ }
29
+
30
+ /**
31
+ * Pure derivation of the thresholds from Pi's reserve: the reminder fires at reserve
32
+ * plus the pi-context margin, the warning steer at reserve plus WARNING_RUNWAY_TOKENS.
33
+ * An invalid margin degrades to the default and reports one warning. Pi's automatic
34
+ * threshold/overflow compaction itself resets immediately, with no model turn.
35
+ */
36
+ export function deriveThresholds(reserveTokens: number, margins: PiContextMargins): { thresholds: ResolvedThresholds; warnings: string[] } {
37
+ const warnings: string[] = [];
38
+ const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
39
+ let reminderMargin: number;
40
+ if (margins.reminderMarginTokens === undefined) reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
41
+ else {
42
+ const parsed = validMargin(margins.reminderMarginTokens);
43
+ if (parsed === undefined) {
44
+ warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
45
+ reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
46
+ } else reminderMargin = parsed;
47
+ }
48
+ return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
49
+ }
50
+
51
+ let cached: ResolvedThresholds | undefined;
52
+
53
+ /**
54
+ * Session-level threshold resolution: Pi's compaction reserve plus the settings.json
55
+ * "pi-context" margins. The file-backed read is cached until resetThresholds (called
56
+ * on session_start/session_tree); invalid configuration degrades per offending key
57
+ * with one warning and never throws during session operation.
58
+ */
59
+ export function thresholdsFor(ctx: ExtensionContext): ResolvedThresholds {
60
+ if (cached) return cached;
61
+ try {
62
+ const settingsManager = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
63
+ const derived = deriveThresholds(
64
+ settingsManager.getCompactionSettings().reserveTokens,
65
+ mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()),
66
+ );
67
+ for (const warning of derived.warnings) ctx.ui.notify(warning, "warning");
68
+ cached = derived.thresholds;
69
+ } catch (error) {
70
+ ctx.ui.notify(`pi-context: could not read settings; using defaults (${String(error)}).`, "warning");
71
+ cached = { reminder: DEFAULT_RESERVE_TOKENS + DEFAULT_REMINDER_MARGIN_TOKENS, reserve: DEFAULT_RESERVE_TOKENS, warning: DEFAULT_RESERVE_TOKENS + WARNING_RUNWAY_TOKENS };
72
+ }
73
+ return cached;
74
+ }
75
+
76
+ export function resetThresholds(): void {
77
+ cached = undefined;
78
+ }
@@ -41,6 +41,83 @@ export function middleTruncate(text: string, fits: (content: string) => boolean)
41
41
  return build(low);
42
42
  }
43
43
 
44
+ /**
45
+ * Longest contiguous prefix of `text` (counted in code points) accepted by `fits`.
46
+ *
47
+ * This is the truncation used by every cursor-bearing payload: the delivered text is
48
+ * always a plain prefix of the original, so a cursor computed from its code-point length
49
+ * addresses exactly the first undelivered character. No marker character is ever appended;
50
+ * the companion `truncated`/`total_chars` fields name what was left out.
51
+ */
52
+ export function prefixFit(text: string, fits: (content: string) => boolean): string {
53
+ if (fits(text)) return text;
54
+ const chars = Array.from(text);
55
+ // Serialized size is non-decreasing in the kept count, so the largest fitting prefix is
56
+ // found by a monotone binary search instead of a quadratic shrink loop.
57
+ let low = 0;
58
+ let high = chars.length;
59
+ while (low < high) {
60
+ const mid = Math.ceil((low + high) / 2);
61
+ if (fits(chars.slice(0, mid).join(""))) low = mid;
62
+ else high = mid - 1;
63
+ }
64
+ // A candidate's serialized size can dip by a byte or two at the very end (a numeric cursor
65
+ // becoming null), so the predicate is not perfectly monotone at the tail. Back off until the
66
+ // returned prefix provably fits; in the monotone case this loop never runs.
67
+ while (low > 0 && !fits(chars.slice(0, low).join(""))) low -= 1;
68
+ return chars.slice(0, low).join("");
69
+ }
70
+
71
+ /**
72
+ * Fields every character-window read returns; each tool adds its own identity and metadata.
73
+ * `offset_chars` is always the resolved absolute offset, and `next_offset_chars` is exactly
74
+ * that offset plus the delivered code-point count, null only at the text's true end.
75
+ */
76
+ export type CharacterWindow = {
77
+ offset_chars: number;
78
+ content: string;
79
+ total_chars: number;
80
+ next_offset_chars: number | null;
81
+ };
82
+
83
+ /**
84
+ * Read one character window of `text`: the longest contiguous prefix of
85
+ * `chars[resolved, resolved + limit)` that fits the wire budget.
86
+ *
87
+ * `offsetChars` is a code-point offset. A negative value counts back from the end and
88
+ * resolves to `max(0, total_chars + offsetChars)`, so `-N` reaches the tail and any
89
+ * `N >= total_chars` reads from the start; the resolved absolute offset is always echoed.
90
+ * Following `next_offset_chars` reconstructs `text` by plain concatenation, because the
91
+ * payload is always a plain prefix with no marker. `render` builds the exact response for
92
+ * a candidate window, so the budget is measured on the bytes that go on the wire.
93
+ */
94
+ export function readCharacterWindow<T>(text: string, offsetChars: number | undefined, limitChars: number | undefined, render: (window: CharacterWindow) => T): T {
95
+ const chars = Array.from(text);
96
+ const requested = offsetChars ?? 0;
97
+ const resolved = requested < 0 ? Math.max(0, chars.length + requested) : Math.max(0, requested);
98
+ const windowChars = chars.slice(resolved, resolved + Math.min(limitChars ?? 12000, 50000));
99
+ const build = (content: string): CharacterWindow => {
100
+ const next = resolved + Array.from(content).length;
101
+ return { offset_chars: resolved, content, total_chars: chars.length, next_offset_chars: next < chars.length ? next : null };
102
+ };
103
+ const content = prefixFit(windowChars.join(""), (candidate) => withinBudget(render(build(candidate))));
104
+ return render(build(content));
105
+ }
106
+
107
+ /**
108
+ * Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
109
+ * none occurs. Shared by the two search tools so a match address is computed identically.
110
+ */
111
+ export function earliestMatchOffsetChars(text: string, queries: string[]): number {
112
+ let earliest = -1;
113
+ for (const query of queries) {
114
+ const index = text.indexOf(query);
115
+ if (index < 0) continue;
116
+ if (earliest < 0 || index < earliest) earliest = index;
117
+ }
118
+ return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
119
+ }
120
+
44
121
  /** Shrink a single page item to fit; only invoked when that item alone exceeds the budget. */
45
122
  export type ItemTruncator<T> = (item: T, fits: (candidate: T) => boolean) => T;
46
123
 
@@ -48,7 +125,7 @@ export type ItemTruncator<T> = (item: T, fits: (candidate: T) => boolean) => T;
48
125
  * Build a page without ever adding an item that would exceed the wire budget.
49
126
  *
50
127
  * A single item that cannot fit is middle-truncated through the optional `truncate`
51
- * callback and still included, with `next_cursor` advanced past it. Without that fallback
128
+ * callback and still included, with `next_cursor` advanced past it. Without that treatment
52
129
  * an oversized item would yield an empty page forever: the cursor would keep pointing back
53
130
  * at the same index.
54
131
  */