@astrosheep/pi-context 0.24.0 → 0.25.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 (78) hide show
  1. package/README.md +52 -5
  2. package/dist/build-info.json +4 -0
  3. package/dist/extension.js +1861 -0
  4. package/dist/src/context/budget.js +150 -0
  5. package/dist/src/context/context-window.js +97 -0
  6. package/dist/src/context/prompts.js +94 -0
  7. package/dist/src/context/reset-lifecycle.js +134 -0
  8. package/dist/src/context/runtime.js +236 -0
  9. package/dist/src/context/thresholds.js +62 -0
  10. package/dist/src/dream/cli.js +1 -1
  11. package/dist/src/dream/doctor.js +34 -6
  12. package/dist/src/dream/runner.js +1 -1
  13. package/dist/src/dream/settings.js +30 -0
  14. package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
  15. package/dist/src/{history.js → history/history.js} +8 -46
  16. package/dist/src/index.js +27 -94
  17. package/dist/src/notes/address.js +97 -16
  18. package/dist/src/notes/frontmatter.js +18 -3
  19. package/dist/src/notes/notes-snapshot.js +30 -0
  20. package/dist/src/notes/paths.js +64 -7
  21. package/dist/src/notes/session-replay.js +41 -0
  22. package/dist/src/notes/store.js +76 -22
  23. package/dist/src/notes/tools.js +7 -7
  24. package/dist/src/protocol.js +7 -5
  25. package/dist/src/settings.js +16 -0
  26. package/dist/src/tool-schema.js +1 -1
  27. package/dist/test/agent-loop.test.js +813 -221
  28. package/dist/test/boot.integration.test.js +167 -0
  29. package/dist/test/budget-settings.integration.test.js +126 -0
  30. package/dist/test/doctor.test.js +14 -36
  31. package/dist/test/dream.test.js +37 -380
  32. package/dist/test/helpers/extension.js +393 -0
  33. package/dist/test/history.integration.test.js +316 -0
  34. package/dist/test/notes.integration.test.js +273 -0
  35. package/dist/test/notes.test.js +40 -359
  36. package/dist/test/reset-lifecycle.test.js +248 -180
  37. package/docs/architecture.md +35 -18
  38. package/docs/reset-lifecycle.md +16 -14
  39. package/package.json +11 -10
  40. package/src/context/budget.ts +148 -0
  41. package/src/context/context-window.ts +103 -0
  42. package/src/context/prompts.ts +111 -0
  43. package/src/context/reset-lifecycle.ts +145 -0
  44. package/src/context/runtime.ts +246 -0
  45. package/src/context/thresholds.ts +78 -0
  46. package/src/dream/cli.ts +1 -1
  47. package/src/dream/doctor.ts +27 -6
  48. package/src/dream/runner.ts +1 -1
  49. package/src/dream/settings.ts +32 -0
  50. package/src/{history-tools.ts → history/history-tools.ts} +3 -3
  51. package/src/{history.ts → history/history.ts} +9 -48
  52. package/src/index.ts +27 -89
  53. package/src/notes/address.ts +82 -16
  54. package/src/notes/frontmatter.ts +20 -3
  55. package/src/notes/notes-snapshot.ts +40 -0
  56. package/src/notes/paths.ts +64 -7
  57. package/src/notes/session-replay.ts +53 -0
  58. package/src/notes/store.ts +78 -25
  59. package/src/notes/tools.ts +7 -7
  60. package/src/protocol.ts +7 -5
  61. package/src/settings.ts +20 -0
  62. package/src/tool-schema.ts +1 -2
  63. package/dist/src/budget.js +0 -65
  64. package/dist/src/notes/model.js +0 -101
  65. package/dist/src/prompts.js +0 -88
  66. package/dist/src/reset-lifecycle.js +0 -155
  67. package/dist/src/thresholds.js +0 -102
  68. package/dist/src/warning.js +0 -44
  69. package/dist/test/coherence.test.js +0 -371
  70. package/dist/test/history.test.js +0 -26
  71. package/dist/test/integration.test.js +0 -1759
  72. package/dist/test/pagination.property.test.js +0 -471
  73. package/src/budget.ts +0 -67
  74. package/src/notes/model.ts +0 -109
  75. package/src/prompts.ts +0 -91
  76. package/src/reset-lifecycle.ts +0 -173
  77. package/src/thresholds.ts +0 -110
  78. package/src/warning.ts +0 -46
@@ -0,0 +1,246 @@
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";
4
+ import { registerBudget } from "./budget.js";
5
+ 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";
11
+ import { registerResetLifecycle } from "./reset-lifecycle.js";
12
+
13
+ declare const __PI_CONTEXT_BUILD__: { version: string; sourceHash: string };
14
+
15
+ // The bundle captures its identity; direct source loads must not claim a built hash.
16
+ const buildLabel = typeof __PI_CONTEXT_BUILD__ === "undefined"
17
+ ? "unbundled source (build unknown)"
18
+ : `${__PI_CONTEXT_BUILD__.version} · build ${__PI_CONTEXT_BUILD__.sourceHash.slice(0, 12)}`;
19
+
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
+ function branchHasWindowMarker(ctx: ExtensionContext, fromId?: string): boolean {
115
+ return ctx.sessionManager.getBranch(fromId).some((entry) => isWindowMarker(entry));
116
+ }
117
+
118
+ /** Register the context-window runtime and its context-owned commands/tools. */
119
+ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsManager): void {
120
+ let enabled = true;
121
+ let missingBootNotice: string | undefined;
122
+ const incompleteNotesNotified = new Set<string>();
123
+ const pendingResetNotices = new Set<string>();
124
+ const notifyCommittedResets = (ctx: ExtensionContext, addedWindowId?: string) => {
125
+ if (addedWindowId) pendingResetNotices.add(addedWindowId);
126
+ if (pendingResetNotices.size === 0) return;
127
+ const branch = ctx.sessionManager.getBranch();
128
+ for (const windowId of pendingResetNotices) {
129
+ if (!branch.some((entry) => isWindowMarker(entry) && entry.data.windowId === windowId) ||
130
+ !branch.some((entry) => isWindowBootEntry(entry, windowId))) continue;
131
+ pendingResetNotices.delete(windowId);
132
+ ctx.ui.notify(`pi-context: memory cleared · ${windowId}`, "info");
133
+ }
134
+ };
135
+ // Announce only a committed reset (marker + boot), not a reset request or boot repair.
136
+ pi.on("turn_start", (_event, ctx) => notifyCommittedResets(ctx));
137
+ pi.on("agent_settled", (_event, ctx) => {
138
+ notifyCommittedResets(ctx);
139
+ pendingResetNotices.clear();
140
+ });
141
+ const notifyIncompleteNotes: IncompleteNotesNotifier = (ctx, windowId, snapshot) => {
142
+ if (snapshot.unavailable.length === 0 || incompleteNotesNotified.has(windowId)) return;
143
+ incompleteNotesNotified.add(windowId);
144
+ const homes = snapshot.unavailable.map((home) => home.label).join(", ");
145
+ ctx.ui.notify(`pi-context: notes index incomplete for ${homes}; notes_list can retry after recovery.`, "warning");
146
+ };
147
+ const migrationWarning = migrateLegacyHomes();
148
+ if (migrationWarning) console.warn(`pi-context: ${migrationWarning}`);
149
+
150
+ const budget = registerBudget(pi, () => enabled, settingsManager);
151
+
152
+ pi.on("session_start", (_event, ctx) => {
153
+ if (!enabled) return;
154
+ missingBootNotice = undefined;
155
+ pendingResetNotices.clear();
156
+ ensureBoot(pi, ctx, notifyIncompleteNotes);
157
+ });
158
+ pi.on("session_tree", (_event, ctx) => {
159
+ missingBootNotice = undefined;
160
+ pendingResetNotices.clear();
161
+ if (enabled) ensureBoot(pi, ctx, notifyIncompleteNotes);
162
+ });
163
+
164
+ // Pi's branch summarizer receives raw entries and bypasses context_with_system. Do not
165
+ // let a summary of a reset branch smuggle erased history back into the destination.
166
+ pi.on("session_before_tree", (event, ctx) => {
167
+ if (!event.preparation.userWantsSummary) return undefined;
168
+ if (!branchHasWindowMarker(ctx) && !branchHasWindowMarker(ctx, event.preparation.targetId)) return undefined;
169
+ ctx.ui.notify("pi-context: skipped branch summary across a reset window; navigation continues without erased history.", "info");
170
+ return { summary: { summary: "" } };
171
+ });
172
+
173
+ // This is the final provider-facing projection. Reset windows cut at their matching boot;
174
+ // root windows only refresh a forked boot identity and retain the copied root transcript.
175
+ pi.on("context_with_system", (event, ctx) => {
176
+ const reset = currentReset(ctx);
177
+ const windowId = reset?.data.windowId ?? rootWindowId(ctx.sessionManager.getSessionId());
178
+ try {
179
+ return { messages: reset ? projectWindow(event.messages, windowId) : projectRootWindow(event.messages, windowId) };
180
+ } catch (error) {
181
+ if (missingBootNotice !== windowId) {
182
+ missingBootNotice = windowId;
183
+ ctx.ui.notify(`pi-context: active context window ${windowId} has no visible boot; request cancelled safely. Use /wipe-memory to start another window.`, "error");
184
+ }
185
+ ctx.abort();
186
+ const safeHead = getCurrentSystemMessage(event.messages);
187
+ return { messages: safeHead ? [safeHead] : [] };
188
+ }
189
+ });
190
+
191
+ pi.registerCommand("pi-context", {
192
+ description: "Show loaded version/build and toggle pi-context context windows",
193
+ getArgumentCompletions: (prefix) =>
194
+ ["on", "off"].filter((a) => a.startsWith(prefix)).map((a) => ({ value: a, label: a })),
195
+ handler: async (args, cmdCtx) => {
196
+ const arg = args.trim().toLowerCase();
197
+ if (arg === "on") {
198
+ enabled = true;
199
+ ensureBoot(pi, cmdCtx, notifyIncompleteNotes);
200
+ } else if (arg === "off") {
201
+ enabled = false;
202
+ budget.clear();
203
+ resets.clear();
204
+ } else if (arg !== "") {
205
+ cmdCtx.ui.notify("Usage: /pi-context [on|off]", "error");
206
+ return;
207
+ }
208
+ cmdCtx.ui.notify(`pi-context: ${enabled ? "on" : "off"} · ${buildLabel} · Pi ${VERSION}`, "info");
209
+ },
210
+ });
211
+
212
+ pi.registerCommand("wipe-memory", {
213
+ description: "Persist a fresh context window without calling the model",
214
+ handler: async (_args, cmdCtx) => {
215
+ if (!enabled) {
216
+ cmdCtx.ui.notify("pi-context: /wipe-memory requires /pi-context on.", "error");
217
+ return;
218
+ }
219
+ await cmdCtx.waitForIdle();
220
+ if (!enabled) return;
221
+ resets.clear();
222
+ notifyCommittedResets(cmdCtx, persistManualReset(pi, cmdCtx, notifyIncompleteNotes));
223
+ },
224
+ });
225
+
226
+ pi.registerTool(defineTool({
227
+ name: "wipe_memory",
228
+ label: "Wipe memory",
229
+ description: "Wipe your in-context memory and start a fresh context window. Your session, notes, and history survive.",
230
+ parameters: Type.Object({}, { additionalProperties: false }),
231
+ async execute() {
232
+ if (!enabled) return output({ error: "pi-context is off (/pi-context on to enable)" });
233
+ return output({ status: resets.request() }, undefined, true);
234
+ },
235
+ }));
236
+
237
+ const resets = registerResetLifecycle(pi, {
238
+ isEnabled: () => enabled,
239
+ buildReset: (ctx) => {
240
+ const drafts = buildResetDrafts(ctx, notifyIncompleteNotes);
241
+ pendingResetNotices.add(drafts[1].details.windowId);
242
+ return drafts;
243
+ },
244
+ budget,
245
+ });
246
+ }
@@ -0,0 +1,78 @@
1
+ import { SettingsManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "../protocol.js";
3
+ import { mergePiContextSettings, type PiContextSettings } from "../settings.js";
4
+
5
+ export type ResolvedThresholds = { reminder: number; reserve: number; warning: number };
6
+ /** A margin is usable only as a positive integer; anything else is ignored. */
7
+ function validMargin(raw: unknown): number | undefined {
8
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return undefined;
9
+ return raw;
10
+ }
11
+
12
+ /**
13
+ * Pure derivation of the thresholds from Pi's reserve: the reminder fires at reserve
14
+ * plus the pi-context margin, the warning steer at reserve plus WARNING_RUNWAY_TOKENS.
15
+ * An invalid margin degrades to the default and reports one warning. Automatic
16
+ * threshold/overflow handling is represented by reset lifecycle boundary drafts;
17
+ * no compaction summary is generated.
18
+ */
19
+ export function deriveThresholds(reserveTokens: number, margins: PiContextSettings): { thresholds: ResolvedThresholds; warnings: string[] } {
20
+ const warnings: string[] = [];
21
+ const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
22
+ let reminderMargin: number;
23
+ if (margins.reminderMarginTokens === undefined) reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
24
+ else {
25
+ const parsed = validMargin(margins.reminderMarginTokens);
26
+ if (parsed === undefined) {
27
+ warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
28
+ reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
29
+ } else reminderMargin = parsed;
30
+ }
31
+ return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
32
+ }
33
+
34
+ /**
35
+ * Read the active compaction reserve, enablement, and pi-context margin settings. This
36
+ * function deliberately has no cache: the budget owner supplies the invocation-scoped
37
+ * cache so two live piContext instances cannot share mutable policy state.
38
+ */
39
+ export type ThresholdSettingsResolution = {
40
+ thresholds: ResolvedThresholds;
41
+ automatic: boolean;
42
+ warnings: string[];
43
+ };
44
+
45
+ function readThresholdSettingsFromManager(ctx: ExtensionContext, settingsManager: SettingsManager): ThresholdSettingsResolution {
46
+ // Resolve the active provider/model override from the public settings API.
47
+ const model = ctx.model;
48
+ const compaction = settingsManager.getCompactionSettings(model ? { provider: model.provider, id: model.id } : undefined);
49
+ const derived = deriveThresholds(
50
+ compaction.reserveTokens,
51
+ mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()),
52
+ );
53
+ return { thresholds: derived.thresholds, automatic: compaction.enabled, warnings: derived.warnings };
54
+ }
55
+
56
+ /**
57
+ * Resolve policy from either the explicitly supplied SDK authority or Pi's default
58
+ * file-backed settings. The caller owns diagnostics and any lifecycle caching.
59
+ */
60
+ export function readThresholdSettings(ctx: ExtensionContext, settingsManager?: SettingsManager): ThresholdSettingsResolution {
61
+ try {
62
+ if (settingsManager) return readThresholdSettingsFromManager(ctx, settingsManager);
63
+ return readThresholdSettingsFromManager(
64
+ ctx,
65
+ SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() }),
66
+ );
67
+ } catch (error) {
68
+ return {
69
+ thresholds: {
70
+ reminder: DEFAULT_RESERVE_TOKENS + DEFAULT_REMINDER_MARGIN_TOKENS,
71
+ reserve: DEFAULT_RESERVE_TOKENS,
72
+ warning: DEFAULT_RESERVE_TOKENS + WARNING_RUNWAY_TOKENS,
73
+ },
74
+ automatic: true,
75
+ warnings: [`pi-context: could not read settings; using defaults (${String(error)}).`],
76
+ };
77
+ }
78
+ }
package/src/dream/cli.ts CHANGED
@@ -6,7 +6,7 @@ import { acquireLock, failLock, lastRunPath, releaseLock } from "./lock.js";
6
6
  import { materialGate, timeGate } from "./gates.js";
7
7
  import { loadPlaybook, runDreamer, type DreamerSessionFactory, type DreamResult, type DreamWrite } from "./runner.js";
8
8
  import { gitCommit } from "./git.js";
9
- import { readDreamerSettings, type DreamerSetting } from "../thresholds.js";
9
+ import { readDreamerSettings, type DreamerSetting } from "./settings.js";
10
10
  import { doctor } from "./doctor.js";
11
11
  import { notesRoot } from "../notes/paths.js";
12
12
 
@@ -1,6 +1,7 @@
1
1
  import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { basename, join, relative } from "node:path";
3
- import { assertAddress } from "../notes/address.js";
3
+ import { ADDRESS_FORMS, assertAddress } from "../notes/address.js";
4
+ import { SLUG_PATTERN } from "../notes/paths.js";
4
5
 
5
6
  /** Read-only diagnostics. Never follows symlinks or acquires/removes a dream lock. */
6
7
  export function doctor(home: string): string[] {
@@ -41,10 +42,17 @@ export function doctor(home: string): string[] {
41
42
  if (!address.startsWith("@") && basename(path) !== "MAP.md") continue;
42
43
  try {
43
44
  const parsed = assertAddress(address);
44
- const targetHome = parsed.scope === "personal" ? join(home, "personal") : parsed.scope === "project" ? project : root;
45
+ // Relative homes (@self/, @model/) name whoever is running; a static doctor
46
+ // cannot resolve them, so only absolute links are checked.
47
+ if ((parsed.scope === "agent" || parsed.scope === "model") && parsed.who === undefined) continue;
48
+ const targetHome = parsed.scope === "human" ? join(home, "human")
49
+ : parsed.scope === "project" ? project
50
+ : parsed.scope === "agent" ? join(home, "agents", parsed.who!)
51
+ : parsed.scope === "model" ? join(home, "models", parsed.who!)
52
+ : root;
45
53
  if (!targetHome) { report(path, `${address}: project context unavailable; use a resolvable reference`); continue; }
46
54
  if (!existsSync(join(targetHome, parsed.path))) report(path, `${address}: target missing; update or remove the reference`);
47
- } catch { report(path, `${address}: invalid address; use bare, @project/ or @personal/ addresses`); }
55
+ } catch { report(path, `${address}: invalid address; ${ADDRESS_FORMS}`); }
48
56
  }
49
57
  };
50
58
  const walk = (dir: string, root: string, project?: string) => {
@@ -64,14 +72,27 @@ export function doctor(home: string): string[] {
64
72
  for (const name of readdirSync(home)) {
65
73
  const path = join(home, name);
66
74
  inspect(path, () => {
67
- if (name === "global") { report(path, "legacy home; manually migrate to personal/ without overwriting existing files"); return; }
75
+ if (name === "global") { report(path, "legacy home; manually migrate to human/ without overwriting existing files"); return; }
76
+ if (name === "personal") { report(path, "legacy home; migrate to human/ (rename the directory), merging by hand if human/ already exists"); return; }
68
77
  if (name === ".dream.lock") {
69
78
  const valid = lstatSync(path).isFile() && /^[1-9]\d* [\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\s*$/i.test(readFileSync(path, "utf8"));
70
79
  report(path, `${valid ? "lock present" : "malformed lock"}; verify no dream is running before manual removal; liveness not inferred`);
71
80
  return;
72
81
  }
73
82
  if ([".git", "dreams", "snapshots", "trash", ".dream.lock.last-run"].includes(name)) return;
74
- if (name === "personal") { if (directory(path)) walk(path, path); return; }
83
+ if (name === "human") { if (directory(path)) walk(path, path); return; }
84
+ if (name === "agents" || name === "models") {
85
+ if (!directory(path)) return;
86
+ for (const slug of readdirSync(path)) {
87
+ const dir = join(path, slug);
88
+ inspect(dir, () => {
89
+ if (!SLUG_PATTERN.test(slug)) report(dir, `invalid ${name.slice(0, -1)} slug; expected [a-z0-9-]`);
90
+ if (!directory(dir)) return;
91
+ walk(dir, dir);
92
+ });
93
+ }
94
+ return;
95
+ }
75
96
  if (name === "project" || name === "pi") {
76
97
  if (!directory(path)) return;
77
98
  const homes = name === "pi" ? join(path, "session") : path;
@@ -89,7 +110,7 @@ export function doctor(home: string): string[] {
89
110
  }
90
111
  return;
91
112
  }
92
- report(path, "unexpected root entry; expected personal/, project/, pi/session/ or dream artifacts");
113
+ report(path, "unexpected root entry; expected human/, project/, agents/, models/, pi/session/ or dream artifacts");
93
114
  });
94
115
  }
95
116
  });
@@ -3,7 +3,7 @@ import { lstat, mkdir, realpath } from "node:fs/promises";
3
3
  import { dirname, isAbsolute, relative, resolve } from "node:path";
4
4
  import { createAgentSession, createEditToolDefinition, createWriteToolDefinition, ModelRuntime, resolveModelScopeWithDiagnostics, SessionManager, type AgentSession, type ToolDefinition } from "@earendil-works/pi-coding-agent";
5
5
  import type { Api, Model } from "@earendil-works/pi-ai";
6
- import { contentText } from "../history.js";
6
+ import { contentText } from "../history/history.js";
7
7
 
8
8
  export type DreamWrite = { tool: "write" | "edit"; path: string };
9
9
  export type DreamResult = { report: string; writes: DreamWrite[]; error?: string };
@@ -0,0 +1,32 @@
1
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { PI_CONTEXT_DREAMER_KEY, PI_CONTEXT_SETTINGS_KEY } from "../protocol.js";
3
+ import { mergePiContextSettings, type PiContextSettings } from "../settings.js";
4
+
5
+ export type DreamerSetting = { pattern?: string; warnings: string[] };
6
+
7
+ /**
8
+ * `pi-context.dreamer` is a non-empty model pattern. Anything else present is ignored
9
+ * with one warning; absent means no configured pattern, so the automatic model applies.
10
+ */
11
+ export function deriveDreamer(settings: PiContextSettings): DreamerSetting {
12
+ const raw = settings.dreamer;
13
+ if (raw === undefined) return { warnings: [] };
14
+ if (typeof raw !== "string" || raw.trim().length === 0) {
15
+ return { warnings: [`pi-context: ${PI_CONTEXT_SETTINGS_KEY}.${PI_CONTEXT_DREAMER_KEY} must be a non-empty string; ignoring it.`] };
16
+ }
17
+ return { pattern: raw.trim(), warnings: [] };
18
+ }
19
+
20
+ /**
21
+ * Resolve the configurable dreamer model from Pi settings for a CLI invocation: global
22
+ * `~/.pi/agent/settings.json` merged with the project's `.pi/settings.json`, project
23
+ * values winning per key. A settings read failure degrades to no pattern with one warning.
24
+ */
25
+ export function readDreamerSettings(cwd = process.cwd()): DreamerSetting {
26
+ try {
27
+ const settingsManager = SettingsManager.create(cwd, undefined, { projectTrusted: true });
28
+ return deriveDreamer(mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
29
+ } catch (error) {
30
+ return { warnings: [`pi-context: could not read settings; using the automatic dreamer model (${String(error)}).`] };
31
+ }
32
+ }
@@ -1,7 +1,7 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, readWindowBlock, withinTextBudget, DEFAULT_READ_WINDOW_CHARS, HISTORY_PREVIEW_CHARS, MAX_READ_WINDOW_CHARS } from "./tool-output.js";
4
- import { positiveInteger, recentFirst, nullableString, role, cursor, searchQuery, searchQueries } from "./tool-schema.js";
3
+ import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, readWindowBlock, withinTextBudget, DEFAULT_READ_WINDOW_CHARS, HISTORY_PREVIEW_CHARS, MAX_READ_WINDOW_CHARS } from "../tool-output.js";
4
+ import { positiveInteger, recentFirst, nullableString, role, cursor, searchQuery, searchQueries } from "../tool-schema.js";
5
5
  import { historyFromSession, filteredItems, visibleItem, allItems, vacuousRoleToolCombo, unknownWindowId } from "./history.js";
6
6
 
7
7
  /**
@@ -46,7 +46,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
46
46
  pi.registerTool(defineTool({
47
47
  name: "history_list",
48
48
  label: "History list items",
49
- description: "List durable session items, including items before compaction, using opaque item and window IDs; the role parameter's description enumerates the six roles. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with history_read.",
49
+ description: "List durable session items, including items from earlier reset windows, using opaque item and window IDs; native compaction and branch summaries remain history items in their current window. The role parameter's description enumerates the six roles. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with history_read.",
50
50
  parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), recent_first: recentFirst(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
51
51
  async execute(_id, params, _signal, _update, ctx) {
52
52
  const invalid = vacuousRoleToolCombo(params);
@@ -1,8 +1,8 @@
1
1
  import type { TextContent, ToolCall } from "@earendil-works/pi-ai";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
- import type { SessionReader } from "./session-reader.js";
4
- import { RESET_V2 } from "./protocol.js";
5
- import { HISTORY_PREVIEW_CHARS } from "./tool-output.js";
3
+ import type { SessionReader } from "../session-reader.js";
4
+ import { isWindowMarker, rootWindowId } from "../context/context-window.js";
5
+ import { HISTORY_PREVIEW_CHARS } from "../tool-output.js";
6
6
 
7
7
  type HistoryItem = {
8
8
  windowId: string;
@@ -94,38 +94,22 @@ function toolCallItems(windowId: string, entry: { id: string; timestamp?: string
94
94
  return items;
95
95
  }
96
96
 
97
- /** The extension-owned window id baked onto a reset-v2 compaction entry, if present. */
98
- export function resetV2WindowId(details: unknown): string | undefined {
99
- if (typeof details !== "object" || details === null) return undefined;
100
- const candidate = details as { piContext?: unknown; windowId?: unknown };
101
- if (candidate.piContext !== RESET_V2 || typeof candidate.windowId !== "string") return undefined;
102
- return candidate.windowId;
103
- }
104
-
105
- /** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
106
- export function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
107
- return resetV2WindowId(entry.details) ?? `pcw:${sessionId.slice(0, 8)}:${entry.id}`;
108
- }
109
-
110
- /** Mint the durable identity of a session's root history window. */
111
- export function rootWindowId(sessionId: string): string {
112
- return `pcw:${sessionId.slice(0, 8)}:root`;
113
- }
114
-
115
97
  /** Build durable, on-demand history directly from every entry on the current session branch. */
116
98
  export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
117
99
  const sessionId = ctx.sessionManager.getSessionId();
118
100
  let window: HistoryWindow = { windowId: rootWindowId(sessionId), items: [] };
119
101
  const windows = [window];
120
102
  for (const entry of ctx.sessionManager.getBranch()) {
121
- if (entry.type === "compaction") {
122
- window = { windowId: windowIdOf(sessionId, entry), createdAt: entry.timestamp, items: [] };
103
+ if (isWindowMarker(entry)) {
104
+ window = { windowId: entry.data.windowId, createdAt: entry.timestamp, items: [] };
123
105
  windows.push(window);
106
+ continue;
107
+ }
108
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
124
109
  window.items.push({
125
110
  windowId: window.windowId,
126
111
  itemId: entry.id,
127
- // A reset-v2 compaction is authored by this extension; a native Pi compaction is not.
128
- role: resetV2WindowId(entry.details) === undefined ? "system" : "developer",
112
+ role: "system",
129
113
  content: entry.summary,
130
114
  createdAt: entry.timestamp,
131
115
  });
@@ -215,26 +199,3 @@ export function filteredItems(ctx: SessionReader, params: HistoryFilter): Histor
215
199
  if (params.recent_first !== false) items.reverse();
216
200
  return items;
217
201
  }
218
-
219
-
220
- /** Persisted messages in the active window, excluding earlier windows on this branch. */
221
- export function hasWindowMessage(ctx: SessionReader, customType: string): boolean {
222
- const branch = ctx.sessionManager.getBranch();
223
- for (let i = branch.length - 1; i >= 0; i--) {
224
- const entry = branch[i];
225
- if (entry.type === "compaction") break;
226
- if (entry.type === "custom_message" && entry.customType === customType) return true;
227
- }
228
- return false;
229
- }
230
-
231
- /** Cheap current-window lookup: scan the branch tail for the latest compaction entry. */
232
- export function currentWindowId(ctx: SessionReader): string {
233
- const sessionId = ctx.sessionManager.getSessionId();
234
- const branch = ctx.sessionManager.getBranch();
235
- for (let i = branch.length - 1; i >= 0; i--) {
236
- const entry = branch[i];
237
- if (entry?.type === "compaction") return windowIdOf(sessionId, entry);
238
- }
239
- return rootWindowId(sessionId);
240
- }