@astrosheep/pi-context 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +52 -5
  2. package/dist/build-info.json +4 -0
  3. package/dist/extension.js +1951 -0
  4. package/dist/src/context/boot.js +46 -0
  5. package/dist/src/context/budget.js +150 -0
  6. package/dist/src/context/context-window.js +112 -0
  7. package/dist/src/context/prompts.js +91 -0
  8. package/dist/src/context/reset-artifacts.js +86 -0
  9. package/dist/src/context/reset-lifecycle.js +182 -0
  10. package/dist/src/context/runtime.js +151 -0
  11. package/dist/src/context/thresholds.js +62 -0
  12. package/dist/src/dream/cli.js +1 -1
  13. package/dist/src/dream/doctor.js +34 -6
  14. package/dist/src/dream/runner.js +1 -1
  15. package/dist/src/dream/settings.js +30 -0
  16. package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
  17. package/dist/src/{history.js → history/history.js} +8 -46
  18. package/dist/src/index.js +27 -94
  19. package/dist/src/notes/address.js +97 -16
  20. package/dist/src/notes/frontmatter.js +18 -3
  21. package/dist/src/notes/notes-snapshot.js +30 -0
  22. package/dist/src/notes/paths.js +64 -7
  23. package/dist/src/notes/session-replay.js +41 -0
  24. package/dist/src/notes/store.js +76 -22
  25. package/dist/src/notes/tools.js +7 -7
  26. package/dist/src/protocol.js +9 -9
  27. package/dist/src/settings.js +16 -0
  28. package/dist/src/tool-schema.js +1 -1
  29. package/dist/test/agent-loop.test.js +815 -221
  30. package/dist/test/boot.integration.test.js +219 -0
  31. package/dist/test/budget-settings.integration.test.js +126 -0
  32. package/dist/test/doctor.test.js +14 -36
  33. package/dist/test/dream.test.js +37 -380
  34. package/dist/test/helpers/extension.js +392 -0
  35. package/dist/test/history.integration.test.js +316 -0
  36. package/dist/test/notes.integration.test.js +270 -0
  37. package/dist/test/notes.test.js +40 -359
  38. package/dist/test/reset-lifecycle.test.js +443 -178
  39. package/docs/architecture.md +35 -18
  40. package/docs/reset-lifecycle.md +73 -14
  41. package/package.json +11 -10
  42. package/src/context/boot.ts +68 -0
  43. package/src/context/budget.ts +148 -0
  44. package/src/context/context-window.ts +118 -0
  45. package/src/context/prompts.ts +108 -0
  46. package/src/context/reset-artifacts.ts +101 -0
  47. package/src/context/reset-lifecycle.ts +272 -0
  48. package/src/context/runtime.ts +151 -0
  49. package/src/context/thresholds.ts +78 -0
  50. package/src/dream/cli.ts +1 -1
  51. package/src/dream/doctor.ts +27 -6
  52. package/src/dream/runner.ts +1 -1
  53. package/src/dream/settings.ts +32 -0
  54. package/src/{history-tools.ts → history/history-tools.ts} +3 -3
  55. package/src/{history.ts → history/history.ts} +9 -48
  56. package/src/index.ts +27 -89
  57. package/src/notes/address.ts +82 -16
  58. package/src/notes/frontmatter.ts +20 -3
  59. package/src/notes/notes-snapshot.ts +40 -0
  60. package/src/notes/paths.ts +64 -7
  61. package/src/notes/session-replay.ts +53 -0
  62. package/src/notes/store.ts +78 -25
  63. package/src/notes/tools.ts +7 -7
  64. package/src/protocol.ts +9 -10
  65. package/src/settings.ts +20 -0
  66. package/src/tool-schema.ts +1 -2
  67. package/dist/src/budget.js +0 -65
  68. package/dist/src/notes/model.js +0 -101
  69. package/dist/src/prompts.js +0 -88
  70. package/dist/src/reset-lifecycle.js +0 -155
  71. package/dist/src/thresholds.js +0 -102
  72. package/dist/src/warning.js +0 -44
  73. package/dist/test/coherence.test.js +0 -371
  74. package/dist/test/history.test.js +0 -26
  75. package/dist/test/integration.test.js +0 -1759
  76. package/dist/test/pagination.property.test.js +0 -471
  77. package/src/budget.ts +0 -67
  78. package/src/notes/model.ts +0 -109
  79. package/src/prompts.ts +0 -91
  80. package/src/reset-lifecycle.ts +0 -173
  81. package/src/thresholds.ts +0 -110
  82. package/src/warning.ts +0 -46
@@ -0,0 +1,46 @@
1
+ import { loadNotesSnapshot } from "../notes/notes-snapshot.js";
2
+ import { agentSlug, modelSlug } from "../notes/paths.js";
3
+ import { BOOT_TYPE } from "../protocol.js";
4
+ import { renderBootBlock } from "./prompts.js";
5
+ import { currentReset, isWindowBoot, rootWindowId } from "./context-window.js";
6
+ import { repairResetTail } from "./reset-artifacts.js";
7
+ /** Render the boot block from the live context; acquisition stays with loadNotesSnapshot. */
8
+ function bootContent(ctx, currentId, previousId, notes) {
9
+ return renderBootBlock({
10
+ agentName: agentSlug(ctx),
11
+ modelName: modelSlug(ctx),
12
+ firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
13
+ currentWindowId: currentId,
14
+ previousWindowId: previousId,
15
+ notes,
16
+ });
17
+ }
18
+ /**
19
+ * Acquire one notes snapshot and build the boot custom message for a window. The caller
20
+ * supplies `previousId` only when a reset boundary needs the prior window identity.
21
+ */
22
+ export function buildBootMessage(ctx, windowId, previousId, notifyIncompleteNotes) {
23
+ const notes = loadNotesSnapshot(ctx);
24
+ notifyIncompleteNotes?.(ctx, windowId, notes);
25
+ return { customType: BOOT_TYPE, content: bootContent(ctx, windowId, previousId, notes), display: false, details: { windowId } };
26
+ }
27
+ /** Persist one hidden boot message without triggering a model turn. */
28
+ export function sendBoot(pi, boot) {
29
+ pi.sendMessage({ customType: boot.customType, content: boot.content, display: boot.display, details: boot.details }, { triggerTurn: false });
30
+ }
31
+ /**
32
+ * Boot entry point for `session_start` / `session_tree`. Ordinary startup ensures one root
33
+ * boot; a reset marker instead asks reset-artifact repair to complete its persisted tail.
34
+ * Boot idempotence lives here: an already-projected root boot or a complete reset tail emits nothing.
35
+ */
36
+ export function ensureBoot(pi, ctx, notifyIncompleteNotes) {
37
+ const reset = currentReset(ctx);
38
+ if (reset) {
39
+ repairResetTail(pi, ctx, reset, notifyIncompleteNotes);
40
+ return;
41
+ }
42
+ const windowId = rootWindowId(ctx.sessionManager.getSessionId());
43
+ if (ctx.sessionManager.buildSessionProjection().messages.some((message) => isWindowBoot(message, windowId)))
44
+ return;
45
+ sendBoot(pi, buildBootMessage(ctx, windowId, undefined, notifyIncompleteNotes));
46
+ }
@@ -0,0 +1,150 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool } from "@earendil-works/pi-coding-agent";
3
+ import { GUIDANCE_CLOSE_TAG, GUIDANCE_OPEN_TAG, GUIDANCE_TYPE, WARNING_PROMPT, WARNING_TYPE } from "../protocol.js";
4
+ import { readThresholdSettings } from "./thresholds.js";
5
+ import { currentWindowId, hasWindowMessage, windowUsage } from "./context-window.js";
6
+ import { tokenBudgetGuidance } from "./prompts.js";
7
+ import { output } from "../tool-output.js";
8
+ /** Remaining tokens in the provider's active window, or null without a usable estimate. */
9
+ export function remainingTokens(ctx) {
10
+ const usage = windowUsage(ctx);
11
+ return !usage || usage.tokens === null ? null : Math.max(0, usage.contextWindow - usage.tokens);
12
+ }
13
+ export function registerBudget(pi, isEnabled, settingsManager) {
14
+ let cachedPolicy;
15
+ const notifiedWarnings = new Set();
16
+ const resolvePolicy = (ctx) => {
17
+ if (!settingsManager && cachedPolicy)
18
+ return { ...cachedPolicy, warnings: [] };
19
+ const resolution = readThresholdSettings(ctx, settingsManager);
20
+ for (const warning of resolution.warnings) {
21
+ if (notifiedWarnings.has(warning))
22
+ continue;
23
+ notifiedWarnings.add(warning);
24
+ ctx.ui.notify(warning, "warning");
25
+ }
26
+ if (!settingsManager)
27
+ cachedPolicy = { thresholds: resolution.thresholds, automatic: resolution.automatic };
28
+ return resolution;
29
+ };
30
+ const thresholdsFor = (ctx) => {
31
+ return resolvePolicy(ctx).thresholds;
32
+ };
33
+ const automaticResetEnabled = (ctx) => {
34
+ return resolvePolicy(ctx).automatic;
35
+ };
36
+ const resetDue = (ctx) => {
37
+ if (!automaticResetEnabled(ctx))
38
+ return false;
39
+ const usage = windowUsage(ctx);
40
+ return usage !== undefined && usage.tokens !== null && usage.contextWindow - usage.tokens <= thresholdsFor(ctx).reserve;
41
+ };
42
+ const invalidateThresholds = () => { cachedPolicy = undefined; };
43
+ let pendingGuidance;
44
+ let pendingWarning;
45
+ let pendingNotices = [];
46
+ const notifyCommittedReminders = (ctx) => {
47
+ const windowId = currentWindowId(ctx);
48
+ for (const notice of pendingNotices) {
49
+ if (notice.windowId !== windowId || !hasWindowMessage(ctx, notice.customType))
50
+ continue;
51
+ ctx.ui.notify(notice.customType === WARNING_TYPE
52
+ ? "pi-context: context budget critical — final checkpoint warning recorded for the model."
53
+ : "pi-context: context budget low — checkpoint reminder recorded for the model, kept out of the chat view.", "warning");
54
+ }
55
+ pendingNotices = [];
56
+ };
57
+ const clearStaged = () => {
58
+ pendingGuidance = undefined;
59
+ pendingWarning = undefined;
60
+ };
61
+ const resetForTransition = () => {
62
+ clearStaged();
63
+ pendingNotices = [];
64
+ invalidateThresholds();
65
+ notifiedWarnings.clear();
66
+ };
67
+ const consumeTurnEnd = (ctx) => {
68
+ const staged = [
69
+ pendingGuidance ? { ...pendingGuidance, customType: GUIDANCE_TYPE } : undefined,
70
+ pendingWarning ? { ...pendingWarning, customType: WARNING_TYPE } : undefined,
71
+ ];
72
+ clearStaged();
73
+ const windowId = currentWindowId(ctx);
74
+ const drafts = staged.filter((draft) => draft !== undefined && draft.windowId === windowId);
75
+ pendingNotices = drafts.map(({ windowId, customType }) => ({ windowId, customType }));
76
+ return drafts.map((draft) => ({
77
+ type: "custom_message",
78
+ customType: draft.customType,
79
+ content: draft.content,
80
+ display: false,
81
+ }));
82
+ };
83
+ pi.on("session_start", (_event, ctx) => { resetForTransition(); thresholdsFor(ctx); });
84
+ pi.on("session_tree", resetForTransition);
85
+ pi.on("model_select", resetForTransition);
86
+ pi.on("session_shutdown", resetForTransition);
87
+ // A request can fail before Pi emits turn_end. agent_settled is the public
88
+ // lifecycle point that must discard an uncommitted draft before the next prompt.
89
+ // UI notices follow committed reminders. Aborted requests can retry their drafts
90
+ // without showing the same low-budget notification twice.
91
+ pi.on("turn_start", (_event, ctx) => notifyCommittedReminders(ctx));
92
+ pi.on("agent_settled", (_event, ctx) => {
93
+ notifyCommittedReminders(ctx);
94
+ clearStaged();
95
+ });
96
+ pi.on("context", (_event, ctx) => {
97
+ if (!isEnabled())
98
+ return undefined;
99
+ // The early reminder persists once per window the first time remaining crosses
100
+ // reserve+margin. It never edits the outgoing request.
101
+ const remaining = remainingTokens(ctx);
102
+ if (remaining === null)
103
+ return undefined;
104
+ const windowId = currentWindowId(ctx);
105
+ const { reminder, warning } = thresholdsFor(ctx);
106
+ if (hasWindowMessage(ctx, WARNING_TYPE) || pendingWarning?.windowId === windowId)
107
+ return undefined;
108
+ if (remaining <= warning) {
109
+ // A not-yet-committed shallow reminder is superseded by the final warning.
110
+ pendingGuidance = undefined;
111
+ const content = `${GUIDANCE_OPEN_TAG}\n${WARNING_PROMPT}\n${GUIDANCE_CLOSE_TAG}`;
112
+ pendingWarning = { windowId, content };
113
+ const warningMessage = {
114
+ role: "custom",
115
+ customType: WARNING_TYPE,
116
+ content,
117
+ display: false,
118
+ timestamp: Date.now(),
119
+ };
120
+ return { messages: [..._event.messages, warningMessage] };
121
+ }
122
+ if (hasWindowMessage(ctx, GUIDANCE_TYPE) || pendingGuidance?.windowId === windowId)
123
+ return undefined;
124
+ if (remaining <= reminder) {
125
+ // Persist at turn_end, before any reset drafts. A queued sendMessage could
126
+ // otherwise cross the marker and leak the old window's reminder forward.
127
+ const left = Math.max(0, remaining - warning);
128
+ pendingGuidance = { windowId, content: tokenBudgetGuidance(left) };
129
+ }
130
+ return undefined;
131
+ });
132
+ pi.registerTool(defineTool({
133
+ name: "get_context_remaining",
134
+ label: "Get context remaining",
135
+ description: "Return estimated context tokens left before your memory is wiped; null when Pi cannot estimate usage.",
136
+ parameters: Type.Object({}, { additionalProperties: false }),
137
+ async execute(_id, _params, _signal, _update, ctx) {
138
+ // The countdown the model sees ends at the warning line (reserve + runway);
139
+ // the runway below it is overdraft the model never sees. See protocol.ts.
140
+ const remaining = remainingTokens(ctx);
141
+ return output({ remaining_tokens: remaining === null ? null : Math.max(0, remaining - thresholdsFor(ctx).warning) });
142
+ },
143
+ }));
144
+ return {
145
+ automaticResetEnabled,
146
+ resetDue,
147
+ consumeTurnEnd,
148
+ clear: () => { clearStaged(); pendingNotices = []; },
149
+ };
150
+ }
@@ -0,0 +1,112 @@
1
+ import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
2
+ import { estimateContextTokens } from "@earendil-works/pi-ai/utils/estimate";
3
+ import { convertToLlm } from "@earendil-works/pi-coding-agent";
4
+ import { BOOT_TYPE, RESET_MARKER_TYPE } from "../protocol.js";
5
+ export function isWindowMarker(entry) {
6
+ return entry.type === "custom" && entry.customType === RESET_MARKER_TYPE &&
7
+ typeof entry.data === "object" && entry.data !== null &&
8
+ typeof entry.data.windowId === "string" &&
9
+ entry.data.windowId.length > 0;
10
+ }
11
+ /** Only the active branch can supply a window boundary. */
12
+ export function currentReset(ctx) {
13
+ const branch = ctx.sessionManager.getBranch();
14
+ for (let i = branch.length - 1; i >= 0; i--) {
15
+ const entry = branch[i];
16
+ if (entry && isWindowMarker(entry))
17
+ return entry;
18
+ }
19
+ return undefined;
20
+ }
21
+ /** Mint the durable identity of a session's root history window. */
22
+ export function rootWindowId(sessionId) {
23
+ return `pcw:${sessionId.slice(0, 8)}:root`;
24
+ }
25
+ /** Persisted messages in the active window, excluding earlier windows on this branch. */
26
+ export function hasWindowMessage(ctx, customType) {
27
+ const branch = ctx.sessionManager.getBranch();
28
+ for (let i = branch.length - 1; i >= 0; i--) {
29
+ const entry = branch[i];
30
+ if (isWindowMarker(entry))
31
+ break;
32
+ if (entry.type === "custom_message" && entry.customType === customType)
33
+ return true;
34
+ }
35
+ return false;
36
+ }
37
+ /** The root or latest durable marker on the active branch. */
38
+ export function currentWindowId(ctx) {
39
+ return currentReset(ctx)?.data.windowId ?? rootWindowId(ctx.sessionManager.getSessionId());
40
+ }
41
+ /** The durable window active just before the marker: the last earlier marker, else the root window. */
42
+ export function previousWindowId(ctx, markerId) {
43
+ let previousId = rootWindowId(ctx.sessionManager.getSessionId());
44
+ for (const entry of ctx.sessionManager.getBranch()) {
45
+ if (entry.id === markerId)
46
+ break;
47
+ if (isWindowMarker(entry))
48
+ previousId = entry.data.windowId;
49
+ }
50
+ return previousId;
51
+ }
52
+ function hasWindowId(details, windowId) {
53
+ return typeof details === "object" && details !== null &&
54
+ typeof details.windowId === "string" &&
55
+ details.windowId === windowId;
56
+ }
57
+ /** Match a provider-facing boot message, optionally by window identity. */
58
+ export function isWindowBoot(message, windowId) {
59
+ return message.role === "custom" && message.customType === BOOT_TYPE && (windowId === undefined || hasWindowId(message.details, windowId));
60
+ }
61
+ /** Match a persisted boot entry by raw identity, even when a later edit hides it from projection. */
62
+ export function isWindowBootEntry(entry, windowId) {
63
+ return entry.type === "custom_message" && entry.customType === BOOT_TYPE && hasWindowId(entry.details, windowId);
64
+ }
65
+ /**
66
+ * The durable marker selects a boot message by identity, never by wall-clock time.
67
+ * The boot is the first conversation message of the window. Folding only its prefix
68
+ * preserves later prompt/tool patches in place, including their cacheable ordering.
69
+ */
70
+ export function projectWindow(messages, windowId) {
71
+ const cut = messages.findIndex((message) => isWindowBoot(message, windowId));
72
+ if (cut < 0)
73
+ throw new Error(`Missing boot for context window ${windowId}`);
74
+ const head = getCurrentSystemMessage(messages.slice(0, cut));
75
+ const suffix = messages.slice(cut);
76
+ return head ? [head, ...suffix] : suffix;
77
+ }
78
+ /**
79
+ * Root windows are not reset boundaries. A forked session can copy a root boot whose
80
+ * details name the source session; refresh that boot in-place in the provider projection
81
+ * while retaining every user/assistant/tool message from the copied root transcript.
82
+ */
83
+ export function projectRootWindow(messages, windowId) {
84
+ const matching = messages.filter((message) => isWindowBoot(message, windowId));
85
+ if (matching.length === 0)
86
+ return messages;
87
+ const activeBoot = matching[matching.length - 1];
88
+ const firstBoot = messages.findIndex((message) => isWindowBoot(message));
89
+ const withoutBoots = messages.filter((message) => !isWindowBoot(message));
90
+ return [...withoutBoots.slice(0, firstBoot), activeBoot, ...withoutBoots.slice(firstBoot)];
91
+ }
92
+ /** Usage for the selected window, excluding provider usage recorded before its marker. */
93
+ export function windowUsage(ctx) {
94
+ const reset = currentReset(ctx);
95
+ if (!reset)
96
+ return ctx.getContextUsage();
97
+ const contextWindow = ctx.model?.contextWindow ?? ctx.getContextUsage()?.contextWindow;
98
+ if (!contextWindow)
99
+ return undefined;
100
+ const windowId = reset.data.windowId;
101
+ try {
102
+ const messages = projectWindow(ctx.sessionManager.buildSessionProjection().messages, windowId);
103
+ const { tokens } = estimateContextTokens(convertToLlm(messages));
104
+ return { tokens, contextWindow, percent: tokens / contextWindow * 100 };
105
+ }
106
+ catch {
107
+ // A marker can be durable before its boot when a process stops between the two
108
+ // public writes. Startup/tree repair will append the missing boot; until then the
109
+ // budget hook must not turn a recoverable partial append into a swallowed error.
110
+ return undefined;
111
+ }
112
+ }
@@ -0,0 +1,91 @@
1
+ import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_AGENT_LIMIT, POCKET_HUMAN_LIMIT, POCKET_MODEL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "../protocol.js";
2
+ /** Codex-style <context_window> identity block: the resolved agent and model names plus first/current/previous window ids. */
3
+ function identityBlock(agentName, modelName, firstWindowId, currentWindowId, previousWindowId) {
4
+ const lines = [
5
+ `Agent name: ${agentName} (brain: ${modelName})`,
6
+ `First context window id: ${firstWindowId}`,
7
+ `Current context window id: ${currentWindowId}`,
8
+ ];
9
+ if (previousWindowId)
10
+ lines.push(`Previous context window id: ${previousWindowId}`);
11
+ return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
12
+ }
13
+ function relativeTime(timestamp, now) {
14
+ const seconds = Math.trunc((timestamp - now) / 1000);
15
+ const [unit, size] = [["d", 86400], ["h", 3600], ["m", 60], ["s", 1]]
16
+ .find(([unit, size]) => Math.abs(seconds) >= size || unit === "s");
17
+ const amount = `${Math.abs(Math.trunc(seconds / size))}${unit}`;
18
+ return seconds > 0 ? `in ${amount}` : `${amount} ago`;
19
+ }
20
+ function rowsFor(snapshot, scope) {
21
+ return snapshot.homes.get(scope) ?? [];
22
+ }
23
+ function notesUnavailableNotice(snapshot) {
24
+ if (snapshot.unavailable.length === 0)
25
+ return undefined;
26
+ const homes = snapshot.unavailable.map((home) => home.label).join(", ");
27
+ return `Notes index incomplete: index for ${homes} unavailable during boot; notes_list can retry after recovery.`;
28
+ }
29
+ /**
30
+ * Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the human, project,
31
+ * own-agent, and current-model homes are all injected, broadest first; stale maps are skipped
32
+ * per home, and the session home is never peeked — a session MAP.md is an ordinary note. The
33
+ * pocket then lists recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT /
34
+ * POCKET_PROJECT_LIMIT / POCKET_HUMAN_LIMIT / POCKET_AGENT_LIMIT / POCKET_MODEL_LIMIT),
35
+ * most-recently-updated first within each home, one metadata line each: address, line count,
36
+ * UTF-8 byte count, relative update time at window open. Bodies never render
37
+ * in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
38
+ */
39
+ function notesIndex(snapshot) {
40
+ const sections = [];
41
+ // Map residency ("地图在场"): scope-native maps, fresh ones injected broadest-first.
42
+ // A session MAP.md is an ordinary note, never resident; stale maps skip independently.
43
+ for (const scope of ["human", "project", "agent", "model"]) {
44
+ const toc = rowsFor(snapshot, scope).find((row) => row.path === "MAP.md");
45
+ if (toc && !toc.meta.stale) {
46
+ if (toc.body.length > 0)
47
+ sections.push(toc.body);
48
+ }
49
+ }
50
+ // listNotes is most-recently-updated first within each home. Per-home quotas keep session
51
+ // churn from evicting the durable homes; maps never take pocket seats.
52
+ const recentNotes = [
53
+ ...rowsFor(snapshot, "session").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_SESSION_LIMIT),
54
+ ...rowsFor(snapshot, "project").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PROJECT_LIMIT),
55
+ ...rowsFor(snapshot, "human").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_HUMAN_LIMIT),
56
+ ...rowsFor(snapshot, "agent").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_AGENT_LIMIT),
57
+ ...rowsFor(snapshot, "model").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_MODEL_LIMIT),
58
+ ];
59
+ if (recentNotes.length > 0) {
60
+ const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by prefix, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from @project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from @self, ${POCKET_MODEL_LIMIT} from @model). A note's content never appears here, so its name has to say what the note is about:`];
61
+ for (const row of recentNotes) {
62
+ lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, snapshot.openedAt)})`);
63
+ }
64
+ sections.push(lines.join("\n"));
65
+ }
66
+ return sections.join("\n\n");
67
+ }
68
+ function notesHomeBlock() {
69
+ return "Note addresses: bare <vpath> is this session; @project/<vpath> is this project; @human/<vpath> is the human's cross-project notes; @self/<vpath> is your own (current agent); @model/<vpath> is the current model's. @self and @model resolve to who is running now. Any other @ prefix, or @ inside a vpath, is a hard error; there is no fallback across prefixes. Anything not matching these is a plain file — use the file tools.";
70
+ }
71
+ export function renderBootBlock(data) {
72
+ const parts = [];
73
+ parts.push(identityBlock(data.agentName, data.modelName, data.firstWindowId, data.currentWindowId, data.previousWindowId));
74
+ parts.push(notesHomeBlock());
75
+ const incomplete = notesUnavailableNotice(data.notes);
76
+ if (incomplete)
77
+ parts.push(incomplete);
78
+ const index = notesIndex(data.notes);
79
+ if (index)
80
+ parts.push(index);
81
+ parts.push(PROTOCOL_BLOCK);
82
+ return parts.join("\n\n");
83
+ }
84
+ /**
85
+ * Codex-equivalent low-budget reminder. The measured remaining count is frozen into
86
+ * the text at the crossing that fires it, so each persisted copy is a snapshot true
87
+ * at write time; get_context_remaining remains the live source for the current figure.
88
+ */
89
+ export function tokenBudgetGuidance(remaining) {
90
+ 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 call wipe_memory yourself — anything you do after the checkpoint isn't in it.\n${GUIDANCE_CLOSE_TAG}`;
91
+ }
@@ -0,0 +1,86 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { BOOT_TYPE, CONTINUATION, CONTINUATION_TYPE, RESET_MARKER_TYPE } from "../protocol.js";
3
+ import { currentWindowId, isWindowBootEntry, isWindowMarker, previousWindowId } from "./context-window.js";
4
+ import { buildBootMessage, sendBoot } from "./boot.js";
5
+ /** Match the hidden continuation entry that carries the one reset message. */
6
+ export function isWindowContinuationEntry(entry) {
7
+ return entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE;
8
+ }
9
+ /** The single continuation sender: the only reset prose persisted for a window. */
10
+ export function sendContinuation(pi) {
11
+ pi.sendMessage({ customType: CONTINUATION_TYPE, content: CONTINUATION, display: false }, { triggerTurn: false });
12
+ }
13
+ /**
14
+ * The closed, ordered reset shape: marker, matching boot, continuation. This is the one
15
+ * source of the reset message and the one place that mints the new window identity.
16
+ */
17
+ export function buildResetDrafts(ctx, notifyIncompleteNotes) {
18
+ const sessionPrefix = ctx.sessionManager.getSessionId().slice(0, 8);
19
+ const usedWindowIds = new Set(ctx.sessionManager.getBranch().filter(isWindowMarker).map((entry) => entry.data.windowId));
20
+ let windowId;
21
+ do {
22
+ windowId = `pcw:${sessionPrefix}:${randomUUID().slice(0, 8)}`;
23
+ } while (usedWindowIds.has(windowId));
24
+ const boot = buildBootMessage(ctx, windowId, currentWindowId(ctx), notifyIncompleteNotes);
25
+ return [
26
+ { type: "custom", customType: RESET_MARKER_TYPE, data: { windowId } },
27
+ { type: "custom_message", customType: BOOT_TYPE, content: boot.content, display: false, details: { windowId } },
28
+ { type: "custom_message", customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
29
+ ];
30
+ }
31
+ /** Persist the marker and send both hidden reset messages; returns the new window id. */
32
+ export function persistManualReset(pi, ctx, notifyIncompleteNotes) {
33
+ const [marker, boot, continuation] = buildResetDrafts(ctx, notifyIncompleteNotes);
34
+ pi.appendEntry(marker.customType, marker.data);
35
+ pi.sendMessage({ customType: boot.customType, content: boot.content, display: boot.display, details: boot.details }, { triggerTurn: false });
36
+ pi.sendMessage({ customType: continuation.customType, content: continuation.content, display: continuation.display }, { triggerTurn: false });
37
+ return boot.details.windowId;
38
+ }
39
+ /**
40
+ * Inspect the persisted tail of a reset marker. It reports which reset messages are present
41
+ * only while the tail stays repairable: metadata may follow the marker, but real conversation,
42
+ * a foreign message, a later marker, or a misordered/duplicate reset artifact refuses repair.
43
+ */
44
+ export function inspectResetTail(ctx, markerId, windowId) {
45
+ const branch = ctx.sessionManager.getBranch();
46
+ const markerIndex = branch.findIndex((entry) => entry.id === markerId);
47
+ if (markerIndex < 0)
48
+ return undefined;
49
+ let boot = false;
50
+ let continuation = false;
51
+ for (const entry of branch.slice(markerIndex + 1)) {
52
+ if (isWindowBootEntry(entry, windowId)) {
53
+ // The boot is unique and must precede the continuation; a repeat or a late
54
+ // boot would move the provider boundary or misorder the reset shape.
55
+ if (boot || continuation)
56
+ return undefined;
57
+ boot = true;
58
+ continue;
59
+ }
60
+ if (isWindowContinuationEntry(entry)) {
61
+ if (continuation || !boot)
62
+ return undefined;
63
+ continuation = true;
64
+ continue;
65
+ }
66
+ if (isWindowMarker(entry) || entry.type === "message" || entry.type === "custom_message" || entry.type === "compaction" || entry.type === "branch_summary") {
67
+ return undefined;
68
+ }
69
+ }
70
+ return { boot, continuation };
71
+ }
72
+ /** True once the marker's tail already carries its boot and continuation in a valid order. */
73
+ export function resetTailCommitted(ctx, markerId, windowId) {
74
+ const tail = inspectResetTail(ctx, markerId, windowId);
75
+ return tail?.boot === true && tail.continuation === true;
76
+ }
77
+ /** Emit only the reset artifacts an incomplete tail is missing, in the closed order. */
78
+ export function repairResetTail(pi, ctx, marker, notifyIncompleteNotes) {
79
+ const tail = inspectResetTail(ctx, marker.id, marker.data.windowId);
80
+ if (!tail || (tail.boot && tail.continuation))
81
+ return;
82
+ if (!tail.boot)
83
+ sendBoot(pi, buildBootMessage(ctx, marker.data.windowId, previousWindowId(ctx, marker.id), notifyIncompleteNotes));
84
+ if (!tail.continuation)
85
+ sendContinuation(pi);
86
+ }