@astrosheep/pi-context 0.25.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.
@@ -1,4 +1,4 @@
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, RESET_SUMMARY, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "../protocol.js";
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
2
  /** Codex-style <context_window> identity block: the resolved agent and model names plus first/current/previous window ids. */
3
3
  function identityBlock(agentName, modelName, firstWindowId, currentWindowId, previousWindowId) {
4
4
  const lines = [
@@ -24,8 +24,7 @@ function notesUnavailableNotice(snapshot) {
24
24
  if (snapshot.unavailable.length === 0)
25
25
  return undefined;
26
26
  const homes = snapshot.unavailable.map((home) => home.label).join(", ");
27
- const noun = snapshot.unavailable.length === 1 ? "home's index was" : "home indexes were";
28
- return `Notes index incomplete: ${homes} ${noun} unavailable during boot; notes_list can retry after recovery.`;
27
+ return `Notes index incomplete: index for ${homes} unavailable during boot; notes_list can retry after recovery.`;
29
28
  }
30
29
  /**
31
30
  * Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the human, project,
@@ -58,7 +57,7 @@ function notesIndex(snapshot) {
58
57
  ...rowsFor(snapshot, "model").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_MODEL_LIMIT),
59
58
  ];
60
59
  if (recentNotes.length > 0) {
61
- const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by home, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from this project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from your @self home, ${POCKET_MODEL_LIMIT} from the current @model home). A note's content never appears here, so its name has to say what the note is about:`];
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:`];
62
61
  for (const row of recentNotes) {
63
62
  lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, snapshot.openedAt)})`);
64
63
  }
@@ -67,12 +66,10 @@ function notesIndex(snapshot) {
67
66
  return sections.join("\n\n");
68
67
  }
69
68
  function notesHomeBlock() {
70
- return "Notes_* addresses have five homes: bare <vpath> is this session, @project/<vpath> is this project, @human/<vpath> is the human's cross-project home, @self/<vpath> and @agents/<name>/<vpath> are agent homes (current vs named), and @model/<vpath> and @models/<name>/<vpath> are model homes. @self and @model resolve to who is running now; listings always show resolved names. @ means leaving home; there is no cross-home fallback. Anything else after @ — or @ inside a vpath — is a hard error. Any other note is a plain file — use the file tools.";
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.";
71
70
  }
72
71
  export function renderBootBlock(data) {
73
72
  const parts = [];
74
- if (data.resetLine)
75
- parts.push(RESET_SUMMARY);
76
73
  parts.push(identityBlock(data.agentName, data.modelName, data.firstWindowId, data.currentWindowId, data.previousWindowId));
77
74
  parts.push(notesHomeBlock());
78
75
  const incomplete = notesUnavailableNotice(data.notes);
@@ -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
+ }
@@ -9,22 +9,85 @@ function isOverflowLike(message, ctx) {
9
9
  return isContextOverflow(message, ctx.model?.contextWindow) ||
10
10
  (ctx.model !== undefined && isRecoverableLength(message, ctx.model.maxTokens));
11
11
  }
12
+ export function initialResetControl() {
13
+ return { request: "none", overflow: "idle" };
14
+ }
15
+ /**
16
+ * Pure reset-control transition. It performs no writes, no policy resolution of its own, and
17
+ * no UI work; every fact it reads is supplied by the caller. Callers can therefore drive the
18
+ * full transition table without a live Pi session.
19
+ */
20
+ export function reduceResetControl(state, event) {
21
+ switch (event.type) {
22
+ case "request": {
23
+ if (state.request === "explicit")
24
+ return { state, effect: "already-requested" };
25
+ return { state: { ...state, request: "explicit" }, effect: "requested" };
26
+ }
27
+ case "turn_end": {
28
+ const facts = event.facts;
29
+ const requested = state.request === "explicit";
30
+ // The explicit request is consumed by the turn boundary whether or not it commits.
31
+ const request = "none";
32
+ if (facts.aborted) {
33
+ // An aborted turn drops the whole boundary: no explicit request, no overflow chain.
34
+ return { state: { request, overflow: "idle" }, effect: "none" };
35
+ }
36
+ if (facts.overflow) {
37
+ // A failure that Pi may recover natively: arm (or re-arm) the bounded settle path.
38
+ // A queued message, disabled mode, or disabled automatic reset leaves it disarmed.
39
+ const pending = !facts.queued && facts.enabled && facts.automaticResetEnabled;
40
+ const spent = state.overflow === "pending-spent" || state.overflow === "spent";
41
+ const overflow = pending
42
+ ? (spent ? "pending-spent" : "pending")
43
+ : (spent ? "spent" : "idle");
44
+ return { state: { request, overflow }, effect: "none" };
45
+ }
46
+ // Any non-overflow completed turn supersedes an older overflow failure. A non-overflow
47
+ // error leaves the armed overflow chain untouched for the settle boundary.
48
+ const overflow = facts.failed ? state.overflow : "idle";
49
+ if (!facts.enabled || facts.failed)
50
+ return { state: { request, overflow }, effect: "none" };
51
+ // Explicit and threshold resets both commit at turn_end, after incoming and budget drafts.
52
+ const commit = requested || facts.thresholdDue;
53
+ return { state: { request, overflow }, effect: commit ? "commit-boundary" : "none" };
54
+ }
55
+ case "before_settle": {
56
+ const facts = event.facts;
57
+ if (state.overflow !== "pending" && state.overflow !== "pending-spent")
58
+ return { state, effect: "none" };
59
+ // Let a queued turn run first; its own turn_end settles or clears this chain.
60
+ if (facts.queued)
61
+ return { state, effect: "none" };
62
+ const spent = state.overflow === "pending-spent";
63
+ if (!facts.enabled || !facts.automaticResetEnabled || facts.aborted) {
64
+ return { state: { ...state, overflow: spent ? "spent" : "idle" }, effect: "none" };
65
+ }
66
+ // The recovery is one-use per failure chain. Committing it spends the attempt.
67
+ return { state: { ...state, overflow: "spent" }, effect: spent ? "none" : "recover-overflow" };
68
+ }
69
+ case "settled":
70
+ // Settlement ends the failure chain but leaves a pending explicit request armed.
71
+ return { state: { ...state, overflow: "idle" }, effect: "none" };
72
+ case "abort":
73
+ case "clear":
74
+ return { state: initialResetControl(), effect: "none" };
75
+ }
76
+ }
12
77
  /**
13
78
  * Own reset requests at Pi 0.87 boundaries. Persisted windows are custom entries, not
14
79
  * compaction summaries: turn_end commits explicit/threshold resets after a complete tool
15
80
  * batch, while agent_before_settle commits the one bounded overflow recovery after Pi's
16
81
  * native recovery attempt has been cancelled.
82
+ *
83
+ * This function is the effect adapter: it captures Pi events, translates them into pure
84
+ * reset-control transitions, and performs the resulting marker/boot/continuation writes and
85
+ * notifications. The decision of what to do lives entirely in `reduceResetControl`.
17
86
  */
18
87
  export function registerResetLifecycle(pi, options) {
19
- let explicitRequested = false;
20
- let overflowPending = false;
21
- let overflowRecoveryUsed = false;
22
- let active = true;
23
- const clear = () => {
24
- explicitRequested = false;
25
- overflowPending = false;
26
- overflowRecoveryUsed = false;
27
- };
88
+ let sessionActive = true;
89
+ let control = initialResetControl();
90
+ const clear = () => { control = reduceResetControl(control, { type: "clear" }).state; };
28
91
  const resetBoundaryResult = (entries, ctx) => {
29
92
  try {
30
93
  return { entries: [...entries, ...options.buildReset(ctx)], continue: true };
@@ -38,63 +101,50 @@ export function registerResetLifecycle(pi, options) {
38
101
  }
39
102
  };
40
103
  pi.on("turn_end", (event, ctx) => {
41
- if (!active)
104
+ if (!sessionActive)
42
105
  return undefined;
43
- const requested = explicitRequested;
44
- explicitRequested = false;
45
106
  const aborted = isAbort(event.message, event.outcome, ctx);
46
107
  const stagedBudgetEntries = options.budget.consumeTurnEnd(ctx);
47
108
  // Lifecycle owns whether drafts are acceptable for this turn. Budget only
48
109
  // drains its instance-local staging, so aborts and disabled mode cannot commit it.
49
110
  const budgetEntries = options.isEnabled() && !aborted ? stagedBudgetEntries : [];
50
111
  const entries = [...(event.entries ?? []), ...budgetEntries];
51
- if (aborted) {
52
- overflowPending = false;
53
- overflowRecoveryUsed = false;
54
- return entries.length > 0 ? { entries } : undefined;
55
- }
56
- // Native overflow/length recovery is handled after turn_end through the bounded
57
- // settle path; do not turn that failed response into a threshold reset. If Pi has
58
- // already queued the next user message, the successful queued turn owns settlement
59
- // and must supersede this stale failure.
60
- if (isOverflowLike(event.message, ctx)) {
61
- const queued = event.context.pendingMessages.length > 0 || ctx.hasPendingMessages();
62
- overflowPending = !queued && options.isEnabled() && options.budget.automaticResetEnabled(ctx);
63
- return entries.length > 0 ? { entries } : undefined;
64
- }
65
- // A successful turn, including one drained from Pi's queue, supersedes any
66
- // older overflow failure before the settle boundary gets a chance to recover it.
67
- if (event.outcome !== "error") {
68
- overflowPending = false;
69
- overflowRecoveryUsed = false;
70
- }
71
- if (!options.isEnabled() || event.outcome === "error")
72
- return entries.length > 0 ? { entries } : undefined;
73
- // The completed response may be the first event whose persisted usage crosses the
74
- // reserve, so a final assistant response does not defer the reset until another prompt.
75
- const autoThreshold = options.budget.resetDue(ctx);
76
- if (!requested && !autoThreshold)
77
- return entries.length > 0 ? { entries } : undefined;
78
- return resetBoundaryResult(entries, ctx);
112
+ const decision = reduceResetControl(control, {
113
+ type: "turn_end",
114
+ facts: {
115
+ aborted,
116
+ overflow: aborted ? false : isOverflowLike(event.message, ctx),
117
+ failed: event.outcome === "error",
118
+ enabled: options.isEnabled(),
119
+ get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
120
+ get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
121
+ get thresholdDue() { return options.budget.resetDue(ctx); },
122
+ },
123
+ });
124
+ control = decision.state;
125
+ if (decision.effect === "commit-boundary")
126
+ return resetBoundaryResult(entries, ctx);
127
+ return entries.length > 0 ? { entries } : undefined;
79
128
  });
80
129
  pi.on("agent_before_settle", (event, ctx) => {
81
- if (!active || !overflowPending)
82
- return undefined;
83
- // Pi invokes this boundary before settlement even when an agent_end handler has
84
- // queued user input. Let that turn run first; its successful turn_end clears the
85
- // stale failure, while another failure leaves the bounded recovery armed.
86
- if (event.context.pendingMessages.length > 0 || ctx.hasPendingMessages())
87
- return undefined;
88
- overflowPending = false;
89
- if (!options.isEnabled() || !options.budget.automaticResetEnabled(ctx) || event.outcome === "aborted" || ctx.signal?.aborted)
130
+ if (!sessionActive)
90
131
  return undefined;
91
- if (overflowRecoveryUsed)
132
+ const decision = reduceResetControl(control, {
133
+ type: "before_settle",
134
+ facts: {
135
+ get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
136
+ get enabled() { return options.isEnabled(); },
137
+ get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
138
+ get aborted() { return event.outcome === "aborted" || ctx.signal?.aborted === true; },
139
+ },
140
+ });
141
+ control = decision.state;
142
+ if (decision.effect !== "recover-overflow")
92
143
  return undefined;
93
- overflowRecoveryUsed = true;
94
144
  return resetBoundaryResult(event.entries, ctx);
95
145
  });
96
146
  pi.on("session_before_compact", (event, ctx) => {
97
- if (!active)
147
+ if (!sessionActive)
98
148
  return undefined;
99
149
  if (event.signal.aborted)
100
150
  return { cancel: true };
@@ -116,18 +166,16 @@ export function registerResetLifecycle(pi, options) {
116
166
  pi.on("agent_settled", () => {
117
167
  // A failed recovery chain is bounded to one reset/retry. Once Pi settles, a later
118
168
  // user prompt starts a new chain; successful continuations clear this earlier.
119
- overflowPending = false;
120
- overflowRecoveryUsed = false;
169
+ control = reduceResetControl(control, { type: "settled" }).state;
121
170
  });
122
- pi.on("session_start", () => { clear(); active = true; });
171
+ pi.on("session_start", () => { clear(); sessionActive = true; });
123
172
  pi.on("session_tree", clear);
124
- pi.on("session_shutdown", () => { clear(); options.budget.clear(); active = false; });
173
+ pi.on("session_shutdown", () => { clear(); options.budget.clear(); sessionActive = false; });
125
174
  return {
126
175
  request() {
127
- if (explicitRequested)
128
- return "rollover_already_pending";
129
- explicitRequested = true;
130
- return "rollover_requested";
176
+ const decision = reduceResetControl(control, { type: "request" });
177
+ control = decision.state;
178
+ return decision.effect === "already-requested" ? "rollover_already_pending" : "rollover_requested";
131
179
  },
132
180
  clear,
133
181
  };
@@ -1,102 +1,16 @@
1
1
  import { getCurrentSystemMessage, Type } from "@earendil-works/pi-ai";
2
2
  import { VERSION, defineTool } from "@earendil-works/pi-coding-agent";
3
- import { randomUUID } from "node:crypto";
4
3
  import { registerBudget } from "./budget.js";
5
4
  import { output } from "../tool-output.js";
6
- import { BOOT_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, CONTINUATION } from "../protocol.js";
7
- import { agentSlug, migrateLegacyHomes, modelSlug } from "../notes/paths.js";
8
- import { loadNotesSnapshot } from "../notes/notes-snapshot.js";
9
- import { renderBootBlock } from "./prompts.js";
10
- import { currentReset, currentWindowId, isWindowBoot, isWindowMarker, projectRootWindow, projectWindow, rootWindowId } from "./context-window.js";
5
+ import { migrateLegacyHomes } from "../notes/paths.js";
6
+ import { currentReset, isWindowMarker, projectRootWindow, projectWindow, rootWindowId } from "./context-window.js";
11
7
  import { registerResetLifecycle } from "./reset-lifecycle.js";
8
+ import { buildResetDrafts, persistManualReset, resetTailCommitted } from "./reset-artifacts.js";
9
+ import { ensureBoot } from "./boot.js";
12
10
  // The bundle captures its identity; direct source loads must not claim a built hash.
13
11
  const buildLabel = typeof __PI_CONTEXT_BUILD__ === "undefined"
14
12
  ? "unbundled source (build unknown)"
15
13
  : `${__PI_CONTEXT_BUILD__.version} · build ${__PI_CONTEXT_BUILD__.sourceHash.slice(0, 12)}`;
16
- function bootContent(ctx, currentId, previousId, resetLine, notes) {
17
- return renderBootBlock({
18
- agentName: agentSlug(ctx),
19
- modelName: modelSlug(ctx),
20
- firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
21
- currentWindowId: currentId,
22
- previousWindowId: previousId,
23
- resetLine,
24
- notes,
25
- });
26
- }
27
- function buildResetDrafts(ctx, notifyIncompleteNotes) {
28
- const sessionPrefix = ctx.sessionManager.getSessionId().slice(0, 8);
29
- const usedWindowIds = new Set(ctx.sessionManager.getBranch().filter(isWindowMarker).map((entry) => entry.data.windowId));
30
- let windowId;
31
- do {
32
- windowId = `pcw:${sessionPrefix}:${randomUUID().slice(0, 8)}`;
33
- } while (usedWindowIds.has(windowId));
34
- const notes = loadNotesSnapshot(ctx);
35
- notifyIncompleteNotes?.(ctx, windowId, notes);
36
- return [
37
- { type: "custom", customType: RESET_MARKER_TYPE, data: { windowId } },
38
- {
39
- type: "custom_message",
40
- customType: BOOT_TYPE,
41
- content: bootContent(ctx, windowId, currentWindowId(ctx), true, notes),
42
- display: false,
43
- details: { windowId },
44
- },
45
- {
46
- type: "custom_message",
47
- customType: CONTINUATION_TYPE,
48
- content: CONTINUATION,
49
- display: false,
50
- },
51
- ];
52
- }
53
- function ensureBoot(pi, ctx, notifyIncompleteNotes) {
54
- const reset = currentReset(ctx);
55
- const sessionId = ctx.sessionManager.getSessionId();
56
- const windowId = reset?.data?.windowId ?? rootWindowId(sessionId);
57
- if (ctx.sessionManager.buildSessionProjection().messages.some((message) => isWindowBoot(message, windowId)))
58
- return;
59
- if (reset && !resetBootMayBeRepaired(ctx, reset.id, windowId))
60
- return;
61
- let previousId = reset ? rootWindowId(sessionId) : undefined;
62
- if (reset) {
63
- for (const entry of ctx.sessionManager.getBranch()) {
64
- if (entry.id === reset.id)
65
- break;
66
- if (isWindowMarker(entry))
67
- previousId = entry.data.windowId;
68
- }
69
- }
70
- const notes = loadNotesSnapshot(ctx);
71
- notifyIncompleteNotes?.(ctx, windowId, notes);
72
- pi.sendMessage({ customType: BOOT_TYPE, content: bootContent(ctx, windowId, previousId, reset !== undefined, notes), display: false, details: { windowId } }, { triggerTurn: false });
73
- }
74
- function persistManualReset(pi, ctx, notifyIncompleteNotes) {
75
- const [marker, boot] = buildResetDrafts(ctx, notifyIncompleteNotes);
76
- pi.appendEntry(marker.customType, marker.data);
77
- pi.sendMessage({ customType: boot.customType, content: boot.content, display: boot.display, details: boot.details }, { triggerTurn: false });
78
- return boot.details.windowId;
79
- }
80
- function resetBootMayBeRepaired(ctx, markerId, windowId) {
81
- const branch = ctx.sessionManager.getBranch();
82
- const markerIndex = branch.findIndex((entry) => entry.id === markerId);
83
- if (markerIndex < 0)
84
- return false;
85
- const afterMarker = branch.slice(markerIndex + 1);
86
- // A raw boot is authoritative even when a later context_edit hides it from the
87
- // projection. Appending another boot at the tail would move the boundary.
88
- if (afterMarker.some((entry) => isWindowBootEntry(entry, windowId)))
89
- return false;
90
- // Only a genuinely incomplete marker tail can be repaired. Once conversation or
91
- // a context-bearing custom message follows it, refusing is safer than guessing.
92
- return !afterMarker.some((entry) => entry.type === "message" || entry.type === "custom_message" || entry.type === "compaction" || entry.type === "branch_summary");
93
- }
94
- function isWindowBootEntry(entry, windowId) {
95
- return entry.type === "custom_message" && entry.customType === BOOT_TYPE &&
96
- typeof entry.details === "object" && entry.details !== null &&
97
- typeof entry.details.windowId === "string" &&
98
- entry.details.windowId === windowId;
99
- }
100
14
  function branchHasWindowMarker(ctx, fromId) {
101
15
  return ctx.sessionManager.getBranch(fromId).some((entry) => isWindowMarker(entry));
102
16
  }
@@ -106,6 +20,8 @@ export function registerContext(pi, settingsManager) {
106
20
  let missingBootNotice;
107
21
  const incompleteNotesNotified = new Set();
108
22
  const pendingResetNotices = new Set();
23
+ // Announce only a fully committed reset (marker + matching boot + continuation), not a
24
+ // reset request or a partial boot repair.
109
25
  const notifyCommittedResets = (ctx, addedWindowId) => {
110
26
  if (addedWindowId)
111
27
  pendingResetNotices.add(addedWindowId);
@@ -113,14 +29,13 @@ export function registerContext(pi, settingsManager) {
113
29
  return;
114
30
  const branch = ctx.sessionManager.getBranch();
115
31
  for (const windowId of pendingResetNotices) {
116
- if (!branch.some((entry) => isWindowMarker(entry) && entry.data.windowId === windowId) ||
117
- !branch.some((entry) => isWindowBootEntry(entry, windowId)))
32
+ const marker = branch.find((entry) => isWindowMarker(entry) && entry.data.windowId === windowId);
33
+ if (!marker || !resetTailCommitted(ctx, marker.id, windowId))
118
34
  continue;
119
35
  pendingResetNotices.delete(windowId);
120
36
  ctx.ui.notify(`pi-context: memory cleared · ${windowId}`, "info");
121
37
  }
122
38
  };
123
- // Announce only a committed reset (marker + boot), not a reset request or boot repair.
124
39
  pi.on("turn_start", (_event, ctx) => notifyCommittedResets(ctx));
125
40
  pi.on("agent_settled", (_event, ctx) => {
126
41
  notifyCommittedResets(ctx);
package/dist/src/index.js CHANGED
@@ -4,7 +4,7 @@ import { registerNotesTools } from "./notes/tools.js";
4
4
  import { deriveThresholds } from "./context/thresholds.js";
5
5
  import { registerContext } from "./context/runtime.js";
6
6
  import { mergePiContextSettings } from "./settings.js";
7
- import { NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, 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
+ import { NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, 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, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
8
8
  import { assertVirtualPath } from "./notes/address.js";
9
9
  export { historyFromSession } from "./history/history.js";
10
10
  export { notesFromSession } from "./notes/session-replay.js";
@@ -28,4 +28,4 @@ export function createPiContext(options = {}) {
28
28
  export default function piContext(pi) {
29
29
  registerPiContext(pi);
30
30
  }
31
- export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, CONTINUATION_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 };
31
+ export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, CONTINUATION_TYPE, WARNING_PROMPT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE, 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 };
@@ -1,5 +1,5 @@
1
1
  import { agentSlug, modelSlug, SLUG_PATTERN } from "./paths.js";
2
- export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/, @agents/<name>/, @model/, and @models/<name>/; bare names are the session home";
2
+ export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/, and @model/; bare names are this session";
3
3
  export function assertVirtualPath(value) {
4
4
  if (typeof value !== "string" || value.length === 0)
5
5
  throw new Error("path must be a non-empty virtual relative path");
@@ -8,7 +8,7 @@ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from
8
8
  const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
9
9
  description: "Where the note's content came from. user: written or dictated by the human. self: written by you, the agent (default). external: anything else — third-party text, tool output, fetched material.",
10
10
  }));
11
- const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project, `@human/<vpath>` for the human's cross-project home, `@self/<vpath>` / `@agents/<name>/<vpath>` for agent homes, and `@model/<vpath>` / `@models/<name>/<vpath>` for model homes. `@self` and `@model` mean the current agent/model; the `<name>` forms name one absolutely. The word after `@` is always one of the reserved home names — names live at the second level, never `@faye/`. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes. Homes you do not own (`@agents/<other>/`, `@models/<other>/`) are read-only.";
11
+ const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project, `@human/<vpath>` for the human's cross-project notes, `@self/<vpath>` for your own, and `@model/<vpath>` for the current model's. `@self` and `@model` mean whoever is running now. Any other `@` prefix, or `@` inside a vpath, is a hard error. There is no fallback across prefixes. Paths reject `..`, absolute paths, and backslashes.";
12
12
  function failure(error) {
13
13
  if (error instanceof NoteError) {
14
14
  const payload = { error: error.message };
@@ -79,7 +79,7 @@ export function registerNotesTools(pi) {
79
79
  }));
80
80
  pi.registerTool(defineTool({
81
81
  name: "notes_list", label: "Notes list",
82
- description: `List note files as rows carrying address, updated_at, and stale, most recently updated first. ${ADDRESS_DESCRIPTION} Listings merge your five reachable homes: this session, @project/, @human/, your @self home, and the current @model home; other agents and models appear only under an explicit glob (@agents/<name>/**, @models/<name>/**, or a glob in the name segment to scan a whole namespace).`,
82
+ description: `List note files as rows carrying address, updated_at, and stale, most recently updated first. ${ADDRESS_DESCRIPTION} Listings merge your five prefixes: this session, @project/, @human/, @self/, and @model/.`,
83
83
  parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
84
84
  async execute(_id, params, _signal, _update, ctx) {
85
85
  let rows;
@@ -100,7 +100,7 @@ export function registerNotesTools(pi) {
100
100
  }));
101
101
  pi.registerTool(defineTool({
102
102
  name: "notes_search", label: "Notes search",
103
- description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} Search merges the same five reachable homes as notes_list; explicit globs reach other agents and models. Patterns glob over full address strings. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (a code-point offset into the serialized note returned by notes_read, at the earliest query match), and truncated.`,
103
+ description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} Search merges the same five prefixes as notes_list. Patterns glob over full address strings. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (a code-point offset into the serialized note returned by notes_read, at the earliest query match), and truncated.`,
104
104
  parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
105
105
  async execute(_id, params, _signal, _update, ctx) {
106
106
  const queries = searchQueries(params.query);
@@ -32,8 +32,8 @@ export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
32
32
  * never sees — Codex's fallback buffer, relocated above the line.
33
33
  */
34
34
  export const WARNING_RUNWAY_TOKENS = 12_288;
35
- export const RESET_SUMMARY = "You wake up. Your head is empty — no memories, the past a blank. The memory is gone for good. What outlived it: the notes you wrote, and the history that was recorded. They are not your memory — read them to rebuild what you need.";
36
- export const CONTINUATION = "Your memory was just erased. Pull only the details you need from history_* and notes_*, then get back to work.";
35
+ /** The single reset message: the only reset prose persisted, carried by the continuation entry. */
36
+ export const CONTINUATION = "Your memory was just erased. Your head is blank. Good news: your notes are still here, and history remains... searchable. Do try to keep up.";
37
37
  /**
38
38
  * Static protocol teaching adapted from Codex's token_budget.guidance_message to
39
39
  * pi-context's tool names. It lives once per window in the persisted boot block;
@@ -47,9 +47,7 @@ Keep a running checkpoint while you work, not at the last minute — the next wi
47
47
 
48
48
  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 call wipe_memory yourself instead of waiting for the erase. Do not let a window die undocumented.
49
49
 
50
- 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.
51
-
52
- Notes live in five homes, and the word after @ is always one of their reserved names — your own name and other people's names live at the second level (@agents/faye/, never @faye/). Bare names are this session; @project/<vpath> is this project's workspace; @human/<vpath> is the human's cross-project home; @self/<vpath> and @agents/<name>/<vpath> are agent homes; @model/<vpath> and @models/<name>/<vpath> are model homes. @self and @model are the only relative forms — the current agent, the current model — and listings never show them, only the resolved name. There is no cross-home fallback.
50
+ Note addresses take five prefixes: bare <vpath> is this session; @project/<vpath> is this project; @human/<vpath> is the human's cross-project notes; @self/<vpath> is your own, as the current agent; @model/<vpath> is the current model's. @self and @model resolve to who is running now; listings always show resolved names. Nothing else is legal — any other @ prefix, or @ inside a vpath, is a hard error, with no fallback across prefixes.
53
51
  Session notes belong to this trip — the goal, the progress, the loose ends. The next window of THIS trip wakes to them; once the trip is over, nobody does.
54
52
  @project notes hold facts about this project — architecture, conventions, workflows, deployment and environment details — for whoever works here next.
55
53
  @human notes hold the human's durable preferences and standing rules, plus lessons that apply across projects — for every agent that serves this human, whoever is running. You write there as the human's scribe; what the human dictates carries origin: user. When the intended scope is unclear, keep the note in the narrowest stated scope rather than widening it.
@@ -6,7 +6,7 @@ import test from "node:test";
6
6
  import { createAssistantMessageEventStream, getCurrentSystemMessage } from "@earendil-works/pi-ai";
7
7
  import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
8
8
  import piContext, { createPiContext } from "../src/index.js";
9
- import { BOOT_TYPE, CONTEXT_WINDOW_OPEN_TAG, GUIDANCE_OPEN_TAG, GUIDANCE_TYPE, RESET_MARKER_TYPE, WARNING_TYPE } from "../src/protocol.js";
9
+ import { BOOT_TYPE, CONTEXT_WINDOW_OPEN_TAG, CONTINUATION, CONTINUATION_TYPE, GUIDANCE_OPEN_TAG, GUIDANCE_TYPE, RESET_MARKER_TYPE, WARNING_TYPE } from "../src/protocol.js";
10
10
  async function openFixture(options) {
11
11
  const dir = options.cwd ?? mkdtempSync(join(tmpdir(), "pi-context-agent-loop-"));
12
12
  const ownsDir = options.cwd === undefined;
@@ -211,6 +211,7 @@ function assertFreshRequest(fixture, requestIndex, oldSentinel) {
211
211
  const body = text(fixture.requests[requestIndex]);
212
212
  assert.equal(body.includes(oldSentinel), false, "the new provider request excludes the old window transcript");
213
213
  assert.ok(body.includes(CONTEXT_WINDOW_OPEN_TAG), "the new provider request includes the fresh context-window boot");
214
+ assert.equal(body.split(CONTINUATION).length - 1, 1, "the fresh window carries exactly one reset message");
214
215
  }
215
216
  test("real AgentSession: aborted low-budget requests notify only after a retry commits the reminder", async () => {
216
217
  let fixture;
@@ -610,6 +611,7 @@ test("real AgentSession: concurrent trusted projects keep reserve and automatic
610
611
  ]);
611
612
  await Promise.all([automatic.session.waitForIdle(), modelInvalidated.session.waitForIdle(), sessionInvalidated.session.waitForIdle()]);
612
613
  assert.equal(resetMarkers(automatic).length, 1, "the low reserve and enabled project resets automatically");
614
+ assert.equal(automatic.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE).length, 1, "the automatic reset persists exactly one continuation");
613
615
  assert.equal(resetMarkers(modelInvalidated).length, 0, "the high reserve and disabled project does not reset");
614
616
  assert.equal(resetMarkers(sessionInvalidated).length, 0, "the second high reserve and disabled session does not reset");
615
617
  writeFileSync(join(cwdModel, ".pi", "settings.json"), JSON.stringify({ compaction: { enabled: true, reserveTokens: 20_000 } }));