@astrosheep/pi-context 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/src/budget.js +63 -0
  2. package/dist/src/dream/apply.js +87 -0
  3. package/dist/src/dream/cli.js +82 -0
  4. package/dist/src/dream/gates.js +21 -0
  5. package/dist/src/dream/lock.js +58 -0
  6. package/dist/src/dream/manifest.js +16 -0
  7. package/dist/src/dream/runner.js +56 -0
  8. package/dist/src/history-tools.js +105 -0
  9. package/dist/src/history.js +210 -0
  10. package/dist/src/index.js +99 -0
  11. package/dist/src/memory/frontmatter.js +134 -0
  12. package/dist/src/memory/paths.js +54 -0
  13. package/dist/src/memory/store.js +297 -0
  14. package/dist/src/memory/tools.js +175 -0
  15. package/dist/src/notes.js +101 -0
  16. package/dist/src/prompts.js +79 -0
  17. package/dist/src/protocol.js +52 -0
  18. package/dist/src/reset-lifecycle.js +101 -0
  19. package/dist/src/session-reader.js +1 -0
  20. package/dist/src/thresholds.js +72 -0
  21. package/dist/src/tool-output.js +172 -0
  22. package/dist/src/tool-schema.js +26 -0
  23. package/dist/src/warning.js +44 -0
  24. package/dist/test/agent-loop.test.js +212 -0
  25. package/dist/test/coherence.test.js +371 -0
  26. package/dist/test/dream.test.js +43 -0
  27. package/dist/test/history.test.js +21 -0
  28. package/dist/test/integration.test.js +1716 -0
  29. package/dist/test/memory.test.js +370 -0
  30. package/dist/test/pagination.property.test.js +476 -0
  31. package/dist/test/reset-lifecycle.test.js +199 -0
  32. package/package.json +9 -3
  33. package/playbook.md +5 -0
  34. package/src/dream/apply.ts +47 -0
  35. package/src/dream/cli.ts +33 -0
  36. package/src/dream/gates.ts +19 -0
  37. package/src/dream/lock.ts +39 -0
  38. package/src/dream/manifest.ts +21 -0
  39. package/src/dream/runner.ts +53 -0
  40. package/src/memory/store.ts +15 -0
  41. package/src/memory/tools.ts +12 -3
  42. package/src/protocol.ts +2 -2
@@ -0,0 +1,52 @@
1
+ export const STATE_TYPE = "pi-context/state";
2
+ export const NOTE_TYPE = "pi-context/note";
3
+ export const BOOT_TYPE = "pi-context/boot";
4
+ export const GUIDANCE_TYPE = "pi-context/guidance";
5
+ export const WARNING_TYPE = "pi-context/warning";
6
+ export const RESET_MARKER_TYPE = "pi-context/reset-marker";
7
+ export const CONTINUATION_TYPE = "pi-context/continuation";
8
+ export const RESET_V2 = "reset-v2";
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;
14
+ export const CONTEXT_WINDOW_OPEN_TAG = "<context_window>";
15
+ export const CONTEXT_WINDOW_CLOSE_TAG = "</context_window>";
16
+ export const CONTEXT_WINDOW_PROTOCOL_OPEN_TAG = "<context_window_protocol>";
17
+ export const CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG = "</context_window_protocol>";
18
+ export const GUIDANCE_OPEN_TAG = "<context_window_guidance>";
19
+ export const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
20
+ export const PI_CONTEXT_SETTINGS_KEY = "pi-context";
21
+ export const DEFAULT_RESERVE_TOKENS = 16_384;
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 = 12_288;
30
+ export const RESET_SUMMARY = "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.";
31
+ export const NOTE_PREVIEW_HEAD_CHARS = 80;
32
+ export const NOTE_PREVIEW_TAIL_CHARS = 240;
33
+ export const NOTE_PREVIEW_CHARS = NOTE_PREVIEW_HEAD_CHARS + NOTE_PREVIEW_TAIL_CHARS;
34
+ export const CONTINUATION = "Your memory was just erased. Pull only the details you need from history_* and notes_*, then get back to work.";
35
+ /**
36
+ * Static protocol teaching adapted from Codex's token_budget.guidance_message to
37
+ * pi-context's tool names. It lives once per window in the persisted boot block;
38
+ * it is never re-injected, so it stays cache-stable at the head of the window.
39
+ */
40
+ export const PROTOCOL_BLOCK = `${CONTEXT_WINDOW_PROTOCOL_OPEN_TAG}
41
+ 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, revise them with notes_edit, and read them back with notes_read / notes_search / notes_list; history is read-only through the history_* tools. Everything else wakes blank.
42
+ Mark outdated or unneeded notes stale — leave them, and they will keep misleading you.
43
+
44
+ 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 returns those IDs; history_read pulls the exact item back out. Bookmark anything expensive the same way — a window/item ID beats re-running or re-searching.
45
+
46
+ 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.
47
+
48
+ 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 directly when you know the window and item IDs, history_list or history_search to find them when you don't.
49
+
50
+ Notes are real markdown files scoped session, project, or global — pick scope by reach: session dies with the session, project follows the repo, global follows you. Treat notes and history as internal bookkeeping; never mention them in user-facing messages.
51
+ ${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
52
+ export const WARNING_PROMPT = "Your memory is about to be erased. Write the note. NOW. If it already exists, revise it with notes_edit (or rewrite it whole): 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. Do not continue any task. Then call new_context IMMEDIATELY — anything not in the note dies with the window.";
@@ -0,0 +1,101 @@
1
+ /** A reset request is session-local. Only this module schedules compaction/continuation. */
2
+ export function registerResetLifecycle(pi, options) {
3
+ let state = { phase: "idle" };
4
+ let handledEntry;
5
+ let active = true;
6
+ const clear = () => {
7
+ state = { phase: "idle" };
8
+ handledEntry = undefined;
9
+ };
10
+ 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 };
33
+ state = { phase: "compacting", attempt: request };
34
+ const onError = (error) => {
35
+ if (!valid(request, ctx))
36
+ return;
37
+ state = { phase: "idle" };
38
+ // Do not retry from settled in a tight loop. A later prompt may trigger a
39
+ // native reset or explicitly request one.
40
+ ctx.ui.notify(`pi-context: reset did not complete (${error.message}). The conversation is retained; resume with another prompt.`, "warning");
41
+ };
42
+ try {
43
+ ctx.compact({
44
+ onComplete: () => {
45
+ if (!valid(request, ctx))
46
+ return;
47
+ state = { phase: "idle" };
48
+ // session_compact only confirms the boundary. onComplete runs after
49
+ // Pi clears compaction state; sending inside the hook starts too early.
50
+ // A queued user prompt may already have started at compaction_end.
51
+ if (request.completed && ctx.isIdle() && !ctx.hasPendingMessages()) {
52
+ pi.sendMessage(options.continuation, { triggerTurn: true });
53
+ }
54
+ },
55
+ onError,
56
+ });
57
+ }
58
+ catch (error) {
59
+ onError(error instanceof Error ? error : new Error(String(error)));
60
+ }
61
+ });
62
+ pi.on("session_before_compact", (event, ctx) => {
63
+ if (!active || !options.isEnabled())
64
+ return undefined;
65
+ if (event.signal.aborted)
66
+ return { cancel: true };
67
+ // Automatic threshold/overflow compactions reset on the spot — no model turn.
68
+ // The warning steer fired earlier (see warning.ts); what crosses the reserve
69
+ // line now is the wipe itself.
70
+ try {
71
+ return options.buildReset(event, ctx, state.phase === "requested");
72
+ }
73
+ catch (error) {
74
+ ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
75
+ return { cancel: true }; // Never fall through to a generated default summary.
76
+ }
77
+ });
78
+ pi.on("session_compact", (event, ctx) => {
79
+ if (!active || !options.isEnabled() || handledEntry === event.compactionEntry.id)
80
+ return;
81
+ if (!options.isCurrentReset(event.compactionEntry.id, ctx))
82
+ return;
83
+ handledEntry = event.compactionEntry.id;
84
+ if (state.phase === "compacting")
85
+ state.attempt.completed = !event.willRetry;
86
+ else
87
+ state = { phase: "idle" };
88
+ // A native compaction (including overflow retry) owns its own scheduling.
89
+ // Only a reset we requested gets a continuation from our onComplete callback.
90
+ options.onReset(event.compactionEntry.id);
91
+ });
92
+ return {
93
+ request() {
94
+ const pending = state.phase !== "idle";
95
+ if (!pending)
96
+ state = { phase: "requested" };
97
+ return pending ? "rollover_already_pending" : "rollover_requested";
98
+ },
99
+ clear,
100
+ };
101
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { SettingsManager } 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
+ function isSettingsObject(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ /** Read the raw "pi-context" object from one parsed settings scope. */
7
+ function piContextSettings(settings) {
8
+ if (!isSettingsObject(settings))
9
+ return {};
10
+ const value = settings[PI_CONTEXT_SETTINGS_KEY];
11
+ return isSettingsObject(value) ? value : {};
12
+ }
13
+ /** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
14
+ export function mergePiContextSettings(globalSettings, projectSettings) {
15
+ const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
16
+ return { reminderMarginTokens: merged.reminderMarginTokens };
17
+ }
18
+ /** A margin is usable only as a positive integer; anything else is ignored. */
19
+ function validMargin(raw) {
20
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0)
21
+ return undefined;
22
+ return raw;
23
+ }
24
+ /**
25
+ * Pure derivation of the thresholds from Pi's reserve: the reminder fires at reserve
26
+ * plus the pi-context margin, the warning steer at reserve plus WARNING_RUNWAY_TOKENS.
27
+ * An invalid margin degrades to the default and reports one warning. Pi's automatic
28
+ * threshold/overflow compaction itself resets immediately, with no model turn.
29
+ */
30
+ export function deriveThresholds(reserveTokens, margins) {
31
+ const warnings = [];
32
+ const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
33
+ let reminderMargin;
34
+ if (margins.reminderMarginTokens === undefined)
35
+ reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
36
+ else {
37
+ const parsed = validMargin(margins.reminderMarginTokens);
38
+ if (parsed === undefined) {
39
+ warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
40
+ reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
41
+ }
42
+ else
43
+ reminderMargin = parsed;
44
+ }
45
+ return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
46
+ }
47
+ let cached;
48
+ /**
49
+ * Session-level threshold resolution: Pi's compaction reserve plus the settings.json
50
+ * "pi-context" margins. The file-backed read is cached until resetThresholds (called
51
+ * on session_start/session_tree); invalid configuration degrades per offending key
52
+ * with one warning and never throws during session operation.
53
+ */
54
+ export function thresholdsFor(ctx) {
55
+ if (cached)
56
+ return cached;
57
+ try {
58
+ const settingsManager = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
59
+ const derived = deriveThresholds(settingsManager.getCompactionSettings().reserveTokens, mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
60
+ for (const warning of derived.warnings)
61
+ ctx.ui.notify(warning, "warning");
62
+ cached = derived.thresholds;
63
+ }
64
+ catch (error) {
65
+ ctx.ui.notify(`pi-context: could not read settings; using defaults (${String(error)}).`, "warning");
66
+ cached = { reminder: DEFAULT_RESERVE_TOKENS + DEFAULT_REMINDER_MARGIN_TOKENS, reserve: DEFAULT_RESERVE_TOKENS, warning: DEFAULT_RESERVE_TOKENS + WARNING_RUNWAY_TOKENS };
67
+ }
68
+ return cached;
69
+ }
70
+ export function resetThresholds() {
71
+ cached = undefined;
72
+ }
@@ -0,0 +1,172 @@
1
+ export const TOOL_OUTPUT_MAX_BYTES = 32 * 1024;
2
+ function json(value) {
3
+ return JSON.stringify(value, null, 2);
4
+ }
5
+ /** True when `value` serializes within the same wire budget `output()` enforces. */
6
+ export function withinBudget(value, budget = TOOL_OUTPUT_MAX_BYTES) {
7
+ return Buffer.byteLength(json(value), "utf8") <= budget;
8
+ }
9
+ /** True when `text` fits the wire budget verbatim, for raw payloads with no JSON encoding. */
10
+ export function withinTextBudget(text, budget = TOOL_OUTPUT_MAX_BYTES) {
11
+ return Buffer.byteLength(text, "utf8") <= budget;
12
+ }
13
+ /** Marker standing in for characters elided from the middle of an oversized single unit. */
14
+ export function truncationMarker(removedChars) {
15
+ return `…[truncated ${removedChars} chars]…`;
16
+ }
17
+ /**
18
+ * Middle-truncate `text` until `fits` accepts it, keeping a head and a tail joined by
19
+ * `truncationMarker`. Codex's `truncate_middle` semantics: when one indivisible unit
20
+ * (a note line, a single match, a history item) exceeds the wire budget on its own, it is
21
+ * still returned — visibly truncated — so cursors advance and no page comes back empty.
22
+ * Returns `text` unchanged when it already fits.
23
+ */
24
+ export function middleTruncate(text, fits) {
25
+ if (fits(text))
26
+ return text;
27
+ const chars = Array.from(text);
28
+ const build = (kept) => {
29
+ const head = Math.ceil(kept / 2);
30
+ return chars.slice(0, head).join("") + truncationMarker(chars.length - kept) + chars.slice(chars.length - (kept - head)).join("");
31
+ };
32
+ // The serialized size is non-decreasing in `kept` (each kept character adds at least one
33
+ // byte while the marker loses at most one digit), so a binary search finds the largest
34
+ // keep count that still fits.
35
+ let low = 0;
36
+ let high = chars.length;
37
+ while (low < high) {
38
+ const mid = Math.ceil((low + high) / 2);
39
+ if (fits(build(mid)))
40
+ low = mid;
41
+ else
42
+ high = mid - 1;
43
+ }
44
+ return build(low);
45
+ }
46
+ /**
47
+ * Longest contiguous prefix of `text` (counted in code points) accepted by `fits`.
48
+ *
49
+ * This is the truncation used by every cursor-bearing payload: the delivered text is
50
+ * always a plain prefix of the original, so a cursor computed from its code-point length
51
+ * addresses exactly the first undelivered character. No marker character is ever appended;
52
+ * the companion `truncated`/`total_chars` fields name what was left out.
53
+ */
54
+ export function prefixFit(text, fits) {
55
+ if (fits(text))
56
+ return text;
57
+ const chars = Array.from(text);
58
+ // Serialized size is non-decreasing in the kept count, so the largest fitting prefix is
59
+ // found by a monotone binary search instead of a quadratic shrink loop.
60
+ let low = 0;
61
+ let high = chars.length;
62
+ while (low < high) {
63
+ const mid = Math.ceil((low + high) / 2);
64
+ if (fits(chars.slice(0, mid).join("")))
65
+ low = mid;
66
+ else
67
+ high = mid - 1;
68
+ }
69
+ // A candidate's serialized size can dip by a byte or two at the very end (a numeric cursor
70
+ // becoming null), so the predicate is not perfectly monotone at the tail. Back off until the
71
+ // returned prefix provably fits; in the monotone case this loop never runs.
72
+ while (low > 0 && !fits(chars.slice(0, low).join("")))
73
+ low -= 1;
74
+ return chars.slice(0, low).join("");
75
+ }
76
+ /**
77
+ * Read one character window of `text`: the longest contiguous prefix of
78
+ * `chars[resolved, resolved + limit)` that fits the wire budget.
79
+ *
80
+ * `offsetChars` is a code-point offset. A negative value counts back from the end and
81
+ * resolves to `max(0, total_chars + offsetChars)`, so `-N` reaches the tail and any
82
+ * `N >= total_chars` reads from the start; the resolved absolute offset is always echoed.
83
+ * Following `next_offset_chars` reconstructs `text` by plain concatenation, because the
84
+ * payload is always a plain prefix with no marker. `render` builds the exact response for
85
+ * a candidate window, and `measure` decides whether that response fits the wire budget (JSON
86
+ * serialization by default; raw-text renders pass a verbatim byte measure), so the budget is
87
+ * always measured on the bytes that go on the wire.
88
+ */
89
+ export function readCharacterWindow(text, offsetChars, limitChars, render, measure = withinBudget) {
90
+ const chars = Array.from(text);
91
+ const requested = offsetChars ?? 0;
92
+ const resolved = requested < 0 ? Math.max(0, chars.length + requested) : Math.max(0, requested);
93
+ const windowChars = chars.slice(resolved, resolved + Math.min(limitChars ?? 12000, 50000));
94
+ const build = (content) => {
95
+ const next = resolved + Array.from(content).length;
96
+ return { offset_chars: resolved, content, total_chars: chars.length, next_offset_chars: next < chars.length ? next : null };
97
+ };
98
+ const content = prefixFit(windowChars.join(""), (candidate) => measure(render(build(candidate))));
99
+ return render(build(content));
100
+ }
101
+ /**
102
+ * One-line bracketed header preceding a raw character-window payload: the identity, the
103
+ * delivered char range, and either the resume cursor or `end`. `tail` appends extra
104
+ * metadata (notes add their timestamps) inside the same brackets.
105
+ */
106
+ export function characterWindowHeader(identity, window, tail = "") {
107
+ // The range end is offset + delivered count, never `total_chars`: a read resolved past the
108
+ // end delivers zero characters there, and the header must not render an inverted range.
109
+ const end = window.offset_chars + Array.from(window.content).length;
110
+ const resume = window.next_offset_chars === null ? "end" : `continue at offset_chars=${window.next_offset_chars}`;
111
+ return `[${identity} · chars ${window.offset_chars}-${end} of ${window.total_chars} · ${resume}${tail}]`;
112
+ }
113
+ /**
114
+ * Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
115
+ * none occurs. Shared by the two search tools so a match address is computed identically.
116
+ */
117
+ export function earliestMatchOffsetChars(text, queries) {
118
+ let earliest = -1;
119
+ for (const query of queries) {
120
+ const index = text.indexOf(query);
121
+ if (index < 0)
122
+ continue;
123
+ if (earliest < 0 || index < earliest)
124
+ earliest = index;
125
+ }
126
+ return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
127
+ }
128
+ /**
129
+ * Build a page without ever adding an item that would exceed the wire budget.
130
+ *
131
+ * A single item that cannot fit is middle-truncated through the optional `truncate`
132
+ * callback and still included, with `next_cursor` advanced past it. Without that treatment
133
+ * an oversized item would yield an empty page forever: the cursor would keep pointing back
134
+ * at the same index.
135
+ */
136
+ export function page(items, cursor, key, limit, truncate) {
137
+ const end = Math.min(items.length, cursor + (limit ?? items.length));
138
+ const selected = [];
139
+ let next = end < items.length ? end : null;
140
+ for (let index = cursor; index < end; index++) {
141
+ const candidateNext = index + 1 < end || end < items.length ? index + 1 : null;
142
+ const fits = (list) => withinBudget({ [key]: list, next_cursor: candidateNext });
143
+ if (!fits([...selected, items[index]])) {
144
+ if (selected.length === 0 && truncate) {
145
+ selected.push(truncate(items[index], (candidate) => fits([candidate])));
146
+ next = candidateNext;
147
+ }
148
+ else {
149
+ next = index;
150
+ }
151
+ break;
152
+ }
153
+ selected.push(items[index]);
154
+ }
155
+ return { [key]: selected, next_cursor: next };
156
+ }
157
+ /**
158
+ * Encode a structured result through the common tool result boundary. `details` is slim
159
+ * metadata for logs/UI (pi convention: never a second copy of the payload) and stays
160
+ * undefined unless the tool has metadata worth persisting.
161
+ */
162
+ export function output(value, details, terminate = false) {
163
+ return { content: [{ type: "text", text: json(value) }], details, terminate };
164
+ }
165
+ /**
166
+ * Encode a prose payload as raw text: a one-line bracketed metadata header, then the payload
167
+ * verbatim. The model reads the note or history item itself instead of a JSON envelope;
168
+ * `details` carries the slim metadata object and never duplicates the payload.
169
+ */
170
+ export function outputRaw(header, content, details, terminate = false) {
171
+ return { content: [{ type: "text", text: `${header}\n${content}` }], details, terminate };
172
+ }
@@ -0,0 +1,26 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ export const nullableString = () => Type.Optional(Type.Union([Type.String(), Type.Null()]));
3
+ export const positiveInteger = () => Type.Optional(Type.Integer({ minimum: 1 }));
4
+ export const cursor = () => Type.Optional(Type.Integer({ minimum: 0, description: "Continuation cursor: pass the previous next_cursor back unchanged, with the same filters and ordering. Omit to start. next_cursor is null only when the set is exhausted." }));
5
+ export const recentFirst = () => Type.Optional(Type.Boolean({ description: "Return newest-first. Only an explicit false returns oldest-first. Defaults to true." }));
6
+ /** Role filter. `developer` is the known author for this extension's own custom entries. */
7
+ export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool_call"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()], { description: "Filter by the item's role. Exactly six: \"user\" and \"assistant\" are a message's visible text (assistant text never contains tool calls); \"tool_call\" is one tool invocation (tool_name set, content = the call's JSON arguments); \"tool\" is one tool run's output (tool_name set); \"system\" is a native Pi compaction summary; \"developer\" is an entry this extension authored (boot, guidance, warning, continuation messages, reset-window compaction summaries, any pi-context/* entry)." });
8
+ /** Search query parameter: one literal, or several literals combined with OR. */
9
+ export const searchQuery = () => Type.Union([Type.String(), Type.Array(Type.String(), { minItems: 1 })]);
10
+ /**
11
+ * Normalize a search `query` parameter into the literal needles to match.
12
+ * A bare string is a one-element list, so single-query behavior is unchanged.
13
+ * An empty list, a non-string element, or an empty string is refused rather than silently
14
+ * searching for nothing: those are argument errors, not empty result sets. An empty string
15
+ * matches every line and every item, so it can never be what the caller meant.
16
+ */
17
+ export function searchQueries(query) {
18
+ const candidates = typeof query === "string" ? [query] : query;
19
+ if (!Array.isArray(candidates) || candidates.length === 0)
20
+ throw new Error("query must be a string or a non-empty array of strings");
21
+ if (!candidates.every((candidate) => typeof candidate === "string"))
22
+ throw new Error("query array elements must be strings");
23
+ if (candidates.some((candidate) => candidate === ""))
24
+ throw new Error("query strings must be non-empty: an empty query matches everything");
25
+ return candidates;
26
+ }
@@ -0,0 +1,44 @@
1
+ import { WARNING_TYPE, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG, WARNING_PROMPT } from "./protocol.js";
2
+ import { thresholdsFor, resetThresholds } from "./thresholds.js";
3
+ import { hasWindowMessage, currentWindowId } from "./history.js";
4
+ /**
5
+ * The final checkpoint warning, steered to the model once per window. Like the early
6
+ * reminder, the steer text is model-facing only (display: false); the human learns
7
+ * about it from the warning-level notify, not from a chat-visible message.
8
+ */
9
+ /** Trigger: does the steer fire at this remaining-token count? Pure. */
10
+ export function warningDue(remaining, thresholds) {
11
+ return remaining <= thresholds.warning;
12
+ }
13
+ /** Delivery: what happens when it fires. */
14
+ export function steerWarning(pi, ctx, thresholds, remaining) {
15
+ pi.sendMessage({ customType: WARNING_TYPE, content: `${GUIDANCE_OPEN_TAG}\n${WARNING_PROMPT}\n${GUIDANCE_CLOSE_TAG}`, display: false }, { triggerTurn: true });
16
+ ctx.ui.notify(`pi-context: context budget critical (${Math.max(0, remaining - thresholds.reserve)} tokens before reserve) — final checkpoint warning steered to the model.`, "warning");
17
+ }
18
+ /** Registration: once-per-window guard plus trigger+delivery on the context hook. */
19
+ export function registerWarning(pi, isEnabled) {
20
+ let firedInWindow;
21
+ // Threshold resolution is owned by budget.ts; this module only consumes the shared
22
+ // cache (lazily on the context hook) so session_start never warns twice.
23
+ pi.on("session_start", () => { firedInWindow = undefined; });
24
+ pi.on("session_tree", () => { firedInWindow = undefined; resetThresholds(); });
25
+ pi.on("context", (_event, ctx) => {
26
+ const windowId = currentWindowId(ctx);
27
+ if (!isEnabled() || firedInWindow === windowId || hasWindowMessage(ctx, WARNING_TYPE))
28
+ return undefined;
29
+ const usage = ctx.getContextUsage();
30
+ if (!usage || usage.tokens === null)
31
+ return undefined;
32
+ const remaining = Math.max(0, usage.contextWindow - usage.tokens);
33
+ const thresholds = thresholdsFor(ctx);
34
+ if (!warningDue(remaining, thresholds))
35
+ return undefined;
36
+ firedInWindow = windowId;
37
+ // The steer reaches the model at the next sampling step with at most the runway
38
+ // of invisible budget left. After it, the model decides for itself: end the
39
+ // window, or ride it into Pi's automatic compaction, which resets on the spot
40
+ // with no turn (see reset-lifecycle).
41
+ steerWarning(pi, ctx, thresholds, remaining);
42
+ return undefined;
43
+ });
44
+ }