@astrosheep/pi-context 0.25.0 → 0.25.2

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,14 +1,12 @@
1
1
  import { getCurrentSystemMessage, Type } from "@earendil-works/pi-ai";
2
- import { VERSION, defineTool, type ExtensionAPI, type ExtensionContext, type SessionBoundaryDraft, type SettingsManager } from "@earendil-works/pi-coding-agent";
3
- import { randomUUID } from "node:crypto";
2
+ import { VERSION, defineTool, type ExtensionAPI, type ExtensionContext, type SettingsManager } from "@earendil-works/pi-coding-agent";
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, type NotesSnapshot } 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, type IncompleteNotesNotifier } from "./boot.js";
12
10
 
13
11
  declare const __PI_CONTEXT_BUILD__: { version: string; sourceHash: string };
14
12
 
@@ -17,100 +15,6 @@ const buildLabel = typeof __PI_CONTEXT_BUILD__ === "undefined"
17
15
  ? "unbundled source (build unknown)"
18
16
  : `${__PI_CONTEXT_BUILD__.version} · build ${__PI_CONTEXT_BUILD__.sourceHash.slice(0, 12)}`;
19
17
 
20
- type IncompleteNotesNotifier = (ctx: ExtensionContext, windowId: string, snapshot: NotesSnapshot) => void;
21
-
22
- function bootContent(ctx: ExtensionContext, currentId: string, previousId: string | undefined, resetLine: boolean, notes: NotesSnapshot): string {
23
- return renderBootBlock({
24
- agentName: agentSlug(ctx),
25
- modelName: modelSlug(ctx),
26
- firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
27
- currentWindowId: currentId,
28
- previousWindowId: previousId,
29
- resetLine,
30
- notes,
31
- });
32
- }
33
-
34
- function buildResetDrafts(ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier) {
35
- const sessionPrefix = ctx.sessionManager.getSessionId().slice(0, 8);
36
- const usedWindowIds = new Set(
37
- ctx.sessionManager.getBranch().filter(isWindowMarker).map((entry) => entry.data.windowId),
38
- );
39
- let windowId: string;
40
- do {
41
- windowId = `pcw:${sessionPrefix}:${randomUUID().slice(0, 8)}`;
42
- } while (usedWindowIds.has(windowId));
43
- const notes = loadNotesSnapshot(ctx);
44
- notifyIncompleteNotes?.(ctx, windowId, notes);
45
- return [
46
- { type: "custom", customType: RESET_MARKER_TYPE, data: { windowId } },
47
- {
48
- type: "custom_message",
49
- customType: BOOT_TYPE,
50
- content: bootContent(ctx, windowId, currentWindowId(ctx), true, notes),
51
- display: false,
52
- details: { windowId },
53
- },
54
- {
55
- type: "custom_message",
56
- customType: CONTINUATION_TYPE,
57
- content: CONTINUATION,
58
- display: false,
59
- },
60
- ] satisfies [SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft];
61
- }
62
-
63
- function ensureBoot(pi: ExtensionAPI, ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier): void {
64
- const reset = currentReset(ctx);
65
- const sessionId = ctx.sessionManager.getSessionId();
66
- const windowId = reset?.data?.windowId ?? rootWindowId(sessionId);
67
- if (ctx.sessionManager.buildSessionProjection().messages.some((message) => isWindowBoot(message, windowId))) return;
68
- if (reset && !resetBootMayBeRepaired(ctx, reset.id, windowId)) return;
69
- let previousId: string | undefined = reset ? rootWindowId(sessionId) : undefined;
70
- if (reset) {
71
- for (const entry of ctx.sessionManager.getBranch()) {
72
- if (entry.id === reset.id) break;
73
- if (isWindowMarker(entry)) previousId = entry.data.windowId;
74
- }
75
- }
76
- const notes = loadNotesSnapshot(ctx);
77
- notifyIncompleteNotes?.(ctx, windowId, notes);
78
- pi.sendMessage(
79
- { customType: BOOT_TYPE, content: bootContent(ctx, windowId, previousId, reset !== undefined, notes), display: false, details: { windowId } },
80
- { triggerTurn: false },
81
- );
82
- }
83
-
84
- function persistManualReset(pi: ExtensionAPI, ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier): string {
85
- const [marker, boot] = buildResetDrafts(ctx, notifyIncompleteNotes);
86
- pi.appendEntry(marker.customType, marker.data);
87
- pi.sendMessage(
88
- { customType: boot.customType, content: boot.content, display: boot.display, details: boot.details },
89
- { triggerTurn: false },
90
- );
91
- return boot.details.windowId;
92
- }
93
-
94
- function resetBootMayBeRepaired(ctx: ExtensionContext, markerId: string, windowId: string): boolean {
95
- const branch = ctx.sessionManager.getBranch();
96
- const markerIndex = branch.findIndex((entry) => entry.id === markerId);
97
- if (markerIndex < 0) return false;
98
- const afterMarker = branch.slice(markerIndex + 1);
99
- // A raw boot is authoritative even when a later context_edit hides it from the
100
- // projection. Appending another boot at the tail would move the boundary.
101
- if (afterMarker.some((entry) => isWindowBootEntry(entry, windowId))) return false;
102
- // Only a genuinely incomplete marker tail can be repaired. Once conversation or
103
- // a context-bearing custom message follows it, refusing is safer than guessing.
104
- return !afterMarker.some((entry) => entry.type === "message" || entry.type === "custom_message" || entry.type === "compaction" || entry.type === "branch_summary");
105
- }
106
-
107
- function isWindowBootEntry(entry: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>[number], windowId: string): boolean {
108
- return entry.type === "custom_message" && entry.customType === BOOT_TYPE &&
109
- typeof entry.details === "object" && entry.details !== null &&
110
- typeof (entry.details as { windowId?: unknown }).windowId === "string" &&
111
- (entry.details as { windowId: string }).windowId === windowId;
112
- }
113
-
114
18
  function branchHasWindowMarker(ctx: ExtensionContext, fromId?: string): boolean {
115
19
  return ctx.sessionManager.getBranch(fromId).some((entry) => isWindowMarker(entry));
116
20
  }
@@ -121,18 +25,19 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
121
25
  let missingBootNotice: string | undefined;
122
26
  const incompleteNotesNotified = new Set<string>();
123
27
  const pendingResetNotices = new Set<string>();
28
+ // Announce only a fully committed reset (marker + matching boot + continuation), not a
29
+ // reset request or a partial boot repair.
124
30
  const notifyCommittedResets = (ctx: ExtensionContext, addedWindowId?: string) => {
125
31
  if (addedWindowId) pendingResetNotices.add(addedWindowId);
126
32
  if (pendingResetNotices.size === 0) return;
127
33
  const branch = ctx.sessionManager.getBranch();
128
34
  for (const windowId of pendingResetNotices) {
129
- if (!branch.some((entry) => isWindowMarker(entry) && entry.data.windowId === windowId) ||
130
- !branch.some((entry) => isWindowBootEntry(entry, windowId))) continue;
35
+ const marker = branch.find((entry) => isWindowMarker(entry) && entry.data.windowId === windowId);
36
+ if (!marker || !resetTailCommitted(ctx, marker.id, windowId)) continue;
131
37
  pendingResetNotices.delete(windowId);
132
38
  ctx.ui.notify(`pi-context: memory cleared · ${windowId}`, "info");
133
39
  }
134
40
  };
135
- // Announce only a committed reset (marker + boot), not a reset request or boot repair.
136
41
  pi.on("turn_start", (_event, ctx) => notifyCommittedResets(ctx));
137
42
  pi.on("agent_settled", (_event, ctx) => {
138
43
  notifyCommittedResets(ctx);
package/src/index.ts 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";
@@ -32,4 +32,4 @@ export default function piContext(pi: ExtensionAPI): void {
32
32
  registerPiContext(pi);
33
33
  }
34
34
 
35
- 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 };
35
+ 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 };
@@ -3,7 +3,7 @@ import { agentSlug, modelSlug, SLUG_PATTERN, type Scope } from "./paths.js";
3
3
 
4
4
  export type NoteAddress = { scope: Scope; path: string; who?: string };
5
5
 
6
- export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/, @agents/<name>/, @model/, and @models/<name>/; bare names are the session home";
6
+ export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/, and @model/; bare names are this session";
7
7
 
8
8
  export function assertVirtualPath(value: unknown): string {
9
9
  if (typeof value !== "string" || value.length === 0) throw new Error("path must be a non-empty virtual relative path");
@@ -10,7 +10,7 @@ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from
10
10
  const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
11
11
  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.",
12
12
  }));
13
- 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.";
13
+ 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.";
14
14
 
15
15
  function failure(error: unknown) {
16
16
  if (error instanceof NoteError) {
@@ -73,7 +73,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
73
73
 
74
74
  pi.registerTool(defineTool({
75
75
  name: "notes_list", label: "Notes list",
76
- 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).`,
76
+ 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/.`,
77
77
  parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
78
78
  async execute(_id, params, _signal, _update, ctx) {
79
79
  let rows: ReturnType<typeof listNotes>;
@@ -89,7 +89,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
89
89
 
90
90
  pi.registerTool(defineTool({
91
91
  name: "notes_search", label: "Notes search",
92
- 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.`,
92
+ 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.`,
93
93
  parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
94
94
  async execute(_id, params, _signal, _update, ctx) {
95
95
  const queries = searchQueries(params.query);
package/src/protocol.ts CHANGED
@@ -32,9 +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 =
36
- "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.";
37
- 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.";
38
37
 
39
38
  /**
40
39
  * Static protocol teaching adapted from Codex's token_budget.guidance_message to
@@ -49,9 +48,7 @@ Keep a running checkpoint while you work, not at the last minute — the next wi
49
48
 
50
49
  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.
51
50
 
52
- 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.
53
-
54
- 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.
51
+ 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.
55
52
  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.
56
53
  @project notes hold facts about this project — architecture, conventions, workflows, deployment and environment details — for whoever works here next.
57
54
  @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.