@astrosheep/pi-context 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +52 -5
  2. package/dist/build-info.json +4 -0
  3. package/dist/extension.js +1951 -0
  4. package/dist/src/context/boot.js +46 -0
  5. package/dist/src/context/budget.js +150 -0
  6. package/dist/src/context/context-window.js +112 -0
  7. package/dist/src/context/prompts.js +91 -0
  8. package/dist/src/context/reset-artifacts.js +86 -0
  9. package/dist/src/context/reset-lifecycle.js +182 -0
  10. package/dist/src/context/runtime.js +151 -0
  11. package/dist/src/context/thresholds.js +62 -0
  12. package/dist/src/dream/cli.js +1 -1
  13. package/dist/src/dream/doctor.js +34 -6
  14. package/dist/src/dream/runner.js +1 -1
  15. package/dist/src/dream/settings.js +30 -0
  16. package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
  17. package/dist/src/{history.js → history/history.js} +8 -46
  18. package/dist/src/index.js +27 -94
  19. package/dist/src/notes/address.js +97 -16
  20. package/dist/src/notes/frontmatter.js +18 -3
  21. package/dist/src/notes/notes-snapshot.js +30 -0
  22. package/dist/src/notes/paths.js +64 -7
  23. package/dist/src/notes/session-replay.js +41 -0
  24. package/dist/src/notes/store.js +76 -22
  25. package/dist/src/notes/tools.js +7 -7
  26. package/dist/src/protocol.js +9 -9
  27. package/dist/src/settings.js +16 -0
  28. package/dist/src/tool-schema.js +1 -1
  29. package/dist/test/agent-loop.test.js +815 -221
  30. package/dist/test/boot.integration.test.js +219 -0
  31. package/dist/test/budget-settings.integration.test.js +126 -0
  32. package/dist/test/doctor.test.js +14 -36
  33. package/dist/test/dream.test.js +37 -380
  34. package/dist/test/helpers/extension.js +392 -0
  35. package/dist/test/history.integration.test.js +316 -0
  36. package/dist/test/notes.integration.test.js +270 -0
  37. package/dist/test/notes.test.js +40 -359
  38. package/dist/test/reset-lifecycle.test.js +443 -178
  39. package/docs/architecture.md +35 -18
  40. package/docs/reset-lifecycle.md +73 -14
  41. package/package.json +11 -10
  42. package/src/context/boot.ts +68 -0
  43. package/src/context/budget.ts +148 -0
  44. package/src/context/context-window.ts +118 -0
  45. package/src/context/prompts.ts +108 -0
  46. package/src/context/reset-artifacts.ts +101 -0
  47. package/src/context/reset-lifecycle.ts +272 -0
  48. package/src/context/runtime.ts +151 -0
  49. package/src/context/thresholds.ts +78 -0
  50. package/src/dream/cli.ts +1 -1
  51. package/src/dream/doctor.ts +27 -6
  52. package/src/dream/runner.ts +1 -1
  53. package/src/dream/settings.ts +32 -0
  54. package/src/{history-tools.ts → history/history-tools.ts} +3 -3
  55. package/src/{history.ts → history/history.ts} +9 -48
  56. package/src/index.ts +27 -89
  57. package/src/notes/address.ts +82 -16
  58. package/src/notes/frontmatter.ts +20 -3
  59. package/src/notes/notes-snapshot.ts +40 -0
  60. package/src/notes/paths.ts +64 -7
  61. package/src/notes/session-replay.ts +53 -0
  62. package/src/notes/store.ts +78 -25
  63. package/src/notes/tools.ts +7 -7
  64. package/src/protocol.ts +9 -10
  65. package/src/settings.ts +20 -0
  66. package/src/tool-schema.ts +1 -2
  67. package/dist/src/budget.js +0 -65
  68. package/dist/src/notes/model.js +0 -101
  69. package/dist/src/prompts.js +0 -88
  70. package/dist/src/reset-lifecycle.js +0 -155
  71. package/dist/src/thresholds.js +0 -102
  72. package/dist/src/warning.js +0 -44
  73. package/dist/test/coherence.test.js +0 -371
  74. package/dist/test/history.test.js +0 -26
  75. package/dist/test/integration.test.js +0 -1759
  76. package/dist/test/pagination.property.test.js +0 -471
  77. package/src/budget.ts +0 -67
  78. package/src/notes/model.ts +0 -109
  79. package/src/prompts.ts +0 -91
  80. package/src/reset-lifecycle.ts +0 -173
  81. package/src/thresholds.ts +0 -110
  82. package/src/warning.ts +0 -46
@@ -0,0 +1,182 @@
1
+ import { isContextOverflow, isRecoverableLength } from "@earendil-works/pi-ai";
2
+ import { currentReset } from "./context-window.js";
3
+ function isAbort(message, outcome, ctx) {
4
+ return outcome === "aborted" || (message.role === "assistant" && message.stopReason === "aborted") || ctx.signal?.aborted === true;
5
+ }
6
+ function isOverflowLike(message, ctx) {
7
+ if (message.role !== "assistant")
8
+ return false;
9
+ return isContextOverflow(message, ctx.model?.contextWindow) ||
10
+ (ctx.model !== undefined && isRecoverableLength(message, ctx.model.maxTokens));
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
+ }
77
+ /**
78
+ * Own reset requests at Pi 0.87 boundaries. Persisted windows are custom entries, not
79
+ * compaction summaries: turn_end commits explicit/threshold resets after a complete tool
80
+ * batch, while agent_before_settle commits the one bounded overflow recovery after Pi's
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`.
86
+ */
87
+ export function registerResetLifecycle(pi, options) {
88
+ let sessionActive = true;
89
+ let control = initialResetControl();
90
+ const clear = () => { control = reduceResetControl(control, { type: "clear" }).state; };
91
+ const resetBoundaryResult = (entries, ctx) => {
92
+ try {
93
+ return { entries: [...entries, ...options.buildReset(ctx)], continue: true };
94
+ }
95
+ catch (error) {
96
+ ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
97
+ // The incoming drafts and budget drafts are already valid work from this
98
+ // boundary. Preserve them, but do not claim a continuation when reset
99
+ // construction failed.
100
+ return entries.length > 0 ? { entries } : undefined;
101
+ }
102
+ };
103
+ pi.on("turn_end", (event, ctx) => {
104
+ if (!sessionActive)
105
+ return undefined;
106
+ const aborted = isAbort(event.message, event.outcome, ctx);
107
+ const stagedBudgetEntries = options.budget.consumeTurnEnd(ctx);
108
+ // Lifecycle owns whether drafts are acceptable for this turn. Budget only
109
+ // drains its instance-local staging, so aborts and disabled mode cannot commit it.
110
+ const budgetEntries = options.isEnabled() && !aborted ? stagedBudgetEntries : [];
111
+ const entries = [...(event.entries ?? []), ...budgetEntries];
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;
128
+ });
129
+ pi.on("agent_before_settle", (event, ctx) => {
130
+ if (!sessionActive)
131
+ return undefined;
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")
143
+ return undefined;
144
+ return resetBoundaryResult(event.entries, ctx);
145
+ });
146
+ pi.on("session_before_compact", (event, ctx) => {
147
+ if (!sessionActive)
148
+ return undefined;
149
+ if (event.signal.aborted)
150
+ return { cancel: true };
151
+ const markerExists = currentReset(ctx) !== undefined;
152
+ if (options.isEnabled() || markerExists) {
153
+ if (event.reason === "manual") {
154
+ ctx.ui.notify("pi-context: /compact is disabled while context windows are active; use /wipe-memory to start a fresh window.", "warning");
155
+ }
156
+ // Native compaction is cancelled here. Threshold resets are decided solely from
157
+ // completed-turn usage at turn_end, never from canonical pre-request history.
158
+ return { cancel: true };
159
+ }
160
+ return undefined;
161
+ });
162
+ pi.on("agent_end", (_event, ctx) => {
163
+ if (ctx.signal?.aborted)
164
+ clear();
165
+ });
166
+ pi.on("agent_settled", () => {
167
+ // A failed recovery chain is bounded to one reset/retry. Once Pi settles, a later
168
+ // user prompt starts a new chain; successful continuations clear this earlier.
169
+ control = reduceResetControl(control, { type: "settled" }).state;
170
+ });
171
+ pi.on("session_start", () => { clear(); sessionActive = true; });
172
+ pi.on("session_tree", clear);
173
+ pi.on("session_shutdown", () => { clear(); options.budget.clear(); sessionActive = false; });
174
+ return {
175
+ request() {
176
+ const decision = reduceResetControl(control, { type: "request" });
177
+ control = decision.state;
178
+ return decision.effect === "already-requested" ? "rollover_already_pending" : "rollover_requested";
179
+ },
180
+ clear,
181
+ };
182
+ }
@@ -0,0 +1,151 @@
1
+ import { getCurrentSystemMessage, Type } from "@earendil-works/pi-ai";
2
+ import { VERSION, defineTool } from "@earendil-works/pi-coding-agent";
3
+ import { registerBudget } from "./budget.js";
4
+ import { output } from "../tool-output.js";
5
+ import { migrateLegacyHomes } from "../notes/paths.js";
6
+ import { currentReset, isWindowMarker, projectRootWindow, projectWindow, rootWindowId } from "./context-window.js";
7
+ import { registerResetLifecycle } from "./reset-lifecycle.js";
8
+ import { buildResetDrafts, persistManualReset, resetTailCommitted } from "./reset-artifacts.js";
9
+ import { ensureBoot } from "./boot.js";
10
+ // The bundle captures its identity; direct source loads must not claim a built hash.
11
+ const buildLabel = typeof __PI_CONTEXT_BUILD__ === "undefined"
12
+ ? "unbundled source (build unknown)"
13
+ : `${__PI_CONTEXT_BUILD__.version} · build ${__PI_CONTEXT_BUILD__.sourceHash.slice(0, 12)}`;
14
+ function branchHasWindowMarker(ctx, fromId) {
15
+ return ctx.sessionManager.getBranch(fromId).some((entry) => isWindowMarker(entry));
16
+ }
17
+ /** Register the context-window runtime and its context-owned commands/tools. */
18
+ export function registerContext(pi, settingsManager) {
19
+ let enabled = true;
20
+ let missingBootNotice;
21
+ const incompleteNotesNotified = new Set();
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.
25
+ const notifyCommittedResets = (ctx, addedWindowId) => {
26
+ if (addedWindowId)
27
+ pendingResetNotices.add(addedWindowId);
28
+ if (pendingResetNotices.size === 0)
29
+ return;
30
+ const branch = ctx.sessionManager.getBranch();
31
+ for (const windowId of pendingResetNotices) {
32
+ const marker = branch.find((entry) => isWindowMarker(entry) && entry.data.windowId === windowId);
33
+ if (!marker || !resetTailCommitted(ctx, marker.id, windowId))
34
+ continue;
35
+ pendingResetNotices.delete(windowId);
36
+ ctx.ui.notify(`pi-context: memory cleared · ${windowId}`, "info");
37
+ }
38
+ };
39
+ pi.on("turn_start", (_event, ctx) => notifyCommittedResets(ctx));
40
+ pi.on("agent_settled", (_event, ctx) => {
41
+ notifyCommittedResets(ctx);
42
+ pendingResetNotices.clear();
43
+ });
44
+ const notifyIncompleteNotes = (ctx, windowId, snapshot) => {
45
+ if (snapshot.unavailable.length === 0 || incompleteNotesNotified.has(windowId))
46
+ return;
47
+ incompleteNotesNotified.add(windowId);
48
+ const homes = snapshot.unavailable.map((home) => home.label).join(", ");
49
+ ctx.ui.notify(`pi-context: notes index incomplete for ${homes}; notes_list can retry after recovery.`, "warning");
50
+ };
51
+ const migrationWarning = migrateLegacyHomes();
52
+ if (migrationWarning)
53
+ console.warn(`pi-context: ${migrationWarning}`);
54
+ const budget = registerBudget(pi, () => enabled, settingsManager);
55
+ pi.on("session_start", (_event, ctx) => {
56
+ if (!enabled)
57
+ return;
58
+ missingBootNotice = undefined;
59
+ pendingResetNotices.clear();
60
+ ensureBoot(pi, ctx, notifyIncompleteNotes);
61
+ });
62
+ pi.on("session_tree", (_event, ctx) => {
63
+ missingBootNotice = undefined;
64
+ pendingResetNotices.clear();
65
+ if (enabled)
66
+ ensureBoot(pi, ctx, notifyIncompleteNotes);
67
+ });
68
+ // Pi's branch summarizer receives raw entries and bypasses context_with_system. Do not
69
+ // let a summary of a reset branch smuggle erased history back into the destination.
70
+ pi.on("session_before_tree", (event, ctx) => {
71
+ if (!event.preparation.userWantsSummary)
72
+ return undefined;
73
+ if (!branchHasWindowMarker(ctx) && !branchHasWindowMarker(ctx, event.preparation.targetId))
74
+ return undefined;
75
+ ctx.ui.notify("pi-context: skipped branch summary across a reset window; navigation continues without erased history.", "info");
76
+ return { summary: { summary: "" } };
77
+ });
78
+ // This is the final provider-facing projection. Reset windows cut at their matching boot;
79
+ // root windows only refresh a forked boot identity and retain the copied root transcript.
80
+ pi.on("context_with_system", (event, ctx) => {
81
+ const reset = currentReset(ctx);
82
+ const windowId = reset?.data.windowId ?? rootWindowId(ctx.sessionManager.getSessionId());
83
+ try {
84
+ return { messages: reset ? projectWindow(event.messages, windowId) : projectRootWindow(event.messages, windowId) };
85
+ }
86
+ catch (error) {
87
+ if (missingBootNotice !== windowId) {
88
+ missingBootNotice = windowId;
89
+ ctx.ui.notify(`pi-context: active context window ${windowId} has no visible boot; request cancelled safely. Use /wipe-memory to start another window.`, "error");
90
+ }
91
+ ctx.abort();
92
+ const safeHead = getCurrentSystemMessage(event.messages);
93
+ return { messages: safeHead ? [safeHead] : [] };
94
+ }
95
+ });
96
+ pi.registerCommand("pi-context", {
97
+ description: "Show loaded version/build and toggle pi-context context windows",
98
+ getArgumentCompletions: (prefix) => ["on", "off"].filter((a) => a.startsWith(prefix)).map((a) => ({ value: a, label: a })),
99
+ handler: async (args, cmdCtx) => {
100
+ const arg = args.trim().toLowerCase();
101
+ if (arg === "on") {
102
+ enabled = true;
103
+ ensureBoot(pi, cmdCtx, notifyIncompleteNotes);
104
+ }
105
+ else if (arg === "off") {
106
+ enabled = false;
107
+ budget.clear();
108
+ resets.clear();
109
+ }
110
+ else if (arg !== "") {
111
+ cmdCtx.ui.notify("Usage: /pi-context [on|off]", "error");
112
+ return;
113
+ }
114
+ cmdCtx.ui.notify(`pi-context: ${enabled ? "on" : "off"} · ${buildLabel} · Pi ${VERSION}`, "info");
115
+ },
116
+ });
117
+ pi.registerCommand("wipe-memory", {
118
+ description: "Persist a fresh context window without calling the model",
119
+ handler: async (_args, cmdCtx) => {
120
+ if (!enabled) {
121
+ cmdCtx.ui.notify("pi-context: /wipe-memory requires /pi-context on.", "error");
122
+ return;
123
+ }
124
+ await cmdCtx.waitForIdle();
125
+ if (!enabled)
126
+ return;
127
+ resets.clear();
128
+ notifyCommittedResets(cmdCtx, persistManualReset(pi, cmdCtx, notifyIncompleteNotes));
129
+ },
130
+ });
131
+ pi.registerTool(defineTool({
132
+ name: "wipe_memory",
133
+ label: "Wipe memory",
134
+ description: "Wipe your in-context memory and start a fresh context window. Your session, notes, and history survive.",
135
+ parameters: Type.Object({}, { additionalProperties: false }),
136
+ async execute() {
137
+ if (!enabled)
138
+ return output({ error: "pi-context is off (/pi-context on to enable)" });
139
+ return output({ status: resets.request() }, undefined, true);
140
+ },
141
+ }));
142
+ const resets = registerResetLifecycle(pi, {
143
+ isEnabled: () => enabled,
144
+ buildReset: (ctx) => {
145
+ const drafts = buildResetDrafts(ctx, notifyIncompleteNotes);
146
+ pendingResetNotices.add(drafts[1].details.windowId);
147
+ return drafts;
148
+ },
149
+ budget,
150
+ });
151
+ }
@@ -0,0 +1,62 @@
1
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "../protocol.js";
3
+ import { mergePiContextSettings } from "../settings.js";
4
+ /** A margin is usable only as a positive integer; anything else is ignored. */
5
+ function validMargin(raw) {
6
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0)
7
+ return undefined;
8
+ return raw;
9
+ }
10
+ /**
11
+ * Pure derivation of the thresholds from Pi's reserve: the reminder fires at reserve
12
+ * plus the pi-context margin, the warning steer at reserve plus WARNING_RUNWAY_TOKENS.
13
+ * An invalid margin degrades to the default and reports one warning. Automatic
14
+ * threshold/overflow handling is represented by reset lifecycle boundary drafts;
15
+ * no compaction summary is generated.
16
+ */
17
+ export function deriveThresholds(reserveTokens, margins) {
18
+ const warnings = [];
19
+ const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
20
+ let reminderMargin;
21
+ if (margins.reminderMarginTokens === undefined)
22
+ reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
23
+ else {
24
+ const parsed = validMargin(margins.reminderMarginTokens);
25
+ if (parsed === undefined) {
26
+ warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
27
+ reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
28
+ }
29
+ else
30
+ reminderMargin = parsed;
31
+ }
32
+ return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
33
+ }
34
+ function readThresholdSettingsFromManager(ctx, settingsManager) {
35
+ // Resolve the active provider/model override from the public settings API.
36
+ const model = ctx.model;
37
+ const compaction = settingsManager.getCompactionSettings(model ? { provider: model.provider, id: model.id } : undefined);
38
+ const derived = deriveThresholds(compaction.reserveTokens, mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
39
+ return { thresholds: derived.thresholds, automatic: compaction.enabled, warnings: derived.warnings };
40
+ }
41
+ /**
42
+ * Resolve policy from either the explicitly supplied SDK authority or Pi's default
43
+ * file-backed settings. The caller owns diagnostics and any lifecycle caching.
44
+ */
45
+ export function readThresholdSettings(ctx, settingsManager) {
46
+ try {
47
+ if (settingsManager)
48
+ return readThresholdSettingsFromManager(ctx, settingsManager);
49
+ return readThresholdSettingsFromManager(ctx, SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() }));
50
+ }
51
+ catch (error) {
52
+ return {
53
+ thresholds: {
54
+ reminder: DEFAULT_RESERVE_TOKENS + DEFAULT_REMINDER_MARGIN_TOKENS,
55
+ reserve: DEFAULT_RESERVE_TOKENS,
56
+ warning: DEFAULT_RESERVE_TOKENS + WARNING_RUNWAY_TOKENS,
57
+ },
58
+ automatic: true,
59
+ warnings: [`pi-context: could not read settings; using defaults (${String(error)}).`],
60
+ };
61
+ }
62
+ }
@@ -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 } from "./runner.js";
8
8
  import { gitCommit } from "./git.js";
9
- import { readDreamerSettings } from "../thresholds.js";
9
+ import { readDreamerSettings } from "./settings.js";
10
10
  import { doctor } from "./doctor.js";
11
11
  import { notesRoot } from "../notes/paths.js";
12
12
  function args(argv) { const out = {}; for (let i = 0; i < argv.length; i++) {
@@ -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
  /** Read-only diagnostics. Never follows symlinks or acquires/removes a dream lock. */
5
6
  export function doctor(home) {
6
7
  const issues = [];
@@ -56,7 +57,15 @@ export function doctor(home) {
56
57
  continue;
57
58
  try {
58
59
  const parsed = assertAddress(address);
59
- const targetHome = parsed.scope === "personal" ? join(home, "personal") : parsed.scope === "project" ? project : root;
60
+ // Relative homes (@self/, @model/) name whoever is running; a static doctor
61
+ // cannot resolve them, so only absolute links are checked.
62
+ if ((parsed.scope === "agent" || parsed.scope === "model") && parsed.who === undefined)
63
+ continue;
64
+ const targetHome = parsed.scope === "human" ? join(home, "human")
65
+ : parsed.scope === "project" ? project
66
+ : parsed.scope === "agent" ? join(home, "agents", parsed.who)
67
+ : parsed.scope === "model" ? join(home, "models", parsed.who)
68
+ : root;
60
69
  if (!targetHome) {
61
70
  report(path, `${address}: project context unavailable; use a resolvable reference`);
62
71
  continue;
@@ -65,7 +74,7 @@ export function doctor(home) {
65
74
  report(path, `${address}: target missing; update or remove the reference`);
66
75
  }
67
76
  catch {
68
- report(path, `${address}: invalid address; use bare, @project/ or @personal/ addresses`);
77
+ report(path, `${address}: invalid address; ${ADDRESS_FORMS}`);
69
78
  }
70
79
  }
71
80
  };
@@ -92,7 +101,11 @@ export function doctor(home) {
92
101
  const path = join(home, name);
93
102
  inspect(path, () => {
94
103
  if (name === "global") {
95
- report(path, "legacy home; manually migrate to personal/ without overwriting existing files");
104
+ report(path, "legacy home; manually migrate to human/ without overwriting existing files");
105
+ return;
106
+ }
107
+ if (name === "personal") {
108
+ report(path, "legacy home; migrate to human/ (rename the directory), merging by hand if human/ already exists");
96
109
  return;
97
110
  }
98
111
  if (name === ".dream.lock") {
@@ -102,11 +115,26 @@ export function doctor(home) {
102
115
  }
103
116
  if ([".git", "dreams", "snapshots", "trash", ".dream.lock.last-run"].includes(name))
104
117
  return;
105
- if (name === "personal") {
118
+ if (name === "human") {
106
119
  if (directory(path))
107
120
  walk(path, path);
108
121
  return;
109
122
  }
123
+ if (name === "agents" || name === "models") {
124
+ if (!directory(path))
125
+ return;
126
+ for (const slug of readdirSync(path)) {
127
+ const dir = join(path, slug);
128
+ inspect(dir, () => {
129
+ if (!SLUG_PATTERN.test(slug))
130
+ report(dir, `invalid ${name.slice(0, -1)} slug; expected [a-z0-9-]`);
131
+ if (!directory(dir))
132
+ return;
133
+ walk(dir, dir);
134
+ });
135
+ }
136
+ return;
137
+ }
110
138
  if (name === "project" || name === "pi") {
111
139
  if (!directory(path))
112
140
  return;
@@ -130,7 +158,7 @@ export function doctor(home) {
130
158
  }
131
159
  return;
132
160
  }
133
- report(path, "unexpected root entry; expected personal/, project/, pi/session/ or dream artifacts");
161
+ report(path, "unexpected root entry; expected human/, project/, agents/, models/, pi/session/ or dream artifacts");
134
162
  });
135
163
  }
136
164
  });
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  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 } from "@earendil-works/pi-coding-agent";
5
- import { contentText } from "../history.js";
5
+ import { contentText } from "../history/history.js";
6
6
  export const DREAMER_TOOLS = ["read", "grep", "find", "ls", "write", "edit"];
7
7
  function isOutside(notesHome, target) {
8
8
  const fromHome = relative(notesHome, target);
@@ -0,0 +1,30 @@
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 } from "../settings.js";
4
+ /**
5
+ * `pi-context.dreamer` is a non-empty model pattern. Anything else present is ignored
6
+ * with one warning; absent means no configured pattern, so the automatic model applies.
7
+ */
8
+ export function deriveDreamer(settings) {
9
+ const raw = settings.dreamer;
10
+ if (raw === undefined)
11
+ return { warnings: [] };
12
+ if (typeof raw !== "string" || raw.trim().length === 0) {
13
+ return { warnings: [`pi-context: ${PI_CONTEXT_SETTINGS_KEY}.${PI_CONTEXT_DREAMER_KEY} must be a non-empty string; ignoring it.`] };
14
+ }
15
+ return { pattern: raw.trim(), warnings: [] };
16
+ }
17
+ /**
18
+ * Resolve the configurable dreamer model from Pi settings for a CLI invocation: global
19
+ * `~/.pi/agent/settings.json` merged with the project's `.pi/settings.json`, project
20
+ * values winning per key. A settings read failure degrades to no pattern with one warning.
21
+ */
22
+ export function readDreamerSettings(cwd = process.cwd()) {
23
+ try {
24
+ const settingsManager = SettingsManager.create(cwd, undefined, { projectTrusted: true });
25
+ return deriveDreamer(mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
26
+ }
27
+ catch (error) {
28
+ return { warnings: [`pi-context: could not read settings; using the automatic dreamer model (${String(error)}).`] };
29
+ }
30
+ }
@@ -1,7 +1,7 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool } 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
  * Shrink one page item to fit the wire budget. `truncated`/`total_chars` stay honest: the
@@ -48,7 +48,7 @@ export function registerHistoryTools(pi) {
48
48
  pi.registerTool(defineTool({
49
49
  name: "history_list",
50
50
  label: "History list items",
51
- 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.",
51
+ 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.",
52
52
  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 }),
53
53
  async execute(_id, params, _signal, _update, ctx) {
54
54
  const invalid = vacuousRoleToolCombo(params);
@@ -1,5 +1,5 @@
1
- import { RESET_V2 } from "./protocol.js";
2
- import { HISTORY_PREVIEW_CHARS } from "./tool-output.js";
1
+ import { isWindowMarker, rootWindowId } from "../context/context-window.js";
2
+ import { HISTORY_PREVIEW_CHARS } from "../tool-output.js";
3
3
  function isTextContent(part) {
4
4
  return typeof part === "object" && part !== null && part.type === "text" && typeof part.text === "string";
5
5
  }
@@ -69,37 +69,22 @@ function toolCallItems(windowId, entry, message) {
69
69
  }
70
70
  return items;
71
71
  }
72
- /** The extension-owned window id baked onto a reset-v2 compaction entry, if present. */
73
- export function resetV2WindowId(details) {
74
- if (typeof details !== "object" || details === null)
75
- return undefined;
76
- const candidate = details;
77
- if (candidate.piContext !== RESET_V2 || typeof candidate.windowId !== "string")
78
- return undefined;
79
- return candidate.windowId;
80
- }
81
- /** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
82
- export function windowIdOf(sessionId, entry) {
83
- return resetV2WindowId(entry.details) ?? `pcw:${sessionId.slice(0, 8)}:${entry.id}`;
84
- }
85
- /** Mint the durable identity of a session's root history window. */
86
- export function rootWindowId(sessionId) {
87
- return `pcw:${sessionId.slice(0, 8)}:root`;
88
- }
89
72
  /** Build durable, on-demand history directly from every entry on the current session branch. */
90
73
  export function historyFromSession(ctx) {
91
74
  const sessionId = ctx.sessionManager.getSessionId();
92
75
  let window = { windowId: rootWindowId(sessionId), items: [] };
93
76
  const windows = [window];
94
77
  for (const entry of ctx.sessionManager.getBranch()) {
95
- if (entry.type === "compaction") {
96
- window = { windowId: windowIdOf(sessionId, entry), createdAt: entry.timestamp, items: [] };
78
+ if (isWindowMarker(entry)) {
79
+ window = { windowId: entry.data.windowId, createdAt: entry.timestamp, items: [] };
97
80
  windows.push(window);
81
+ continue;
82
+ }
83
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
98
84
  window.items.push({
99
85
  windowId: window.windowId,
100
86
  itemId: entry.id,
101
- // A reset-v2 compaction is authored by this extension; a native Pi compaction is not.
102
- role: resetV2WindowId(entry.details) === undefined ? "system" : "developer",
87
+ role: "system",
103
88
  content: entry.summary,
104
89
  createdAt: entry.timestamp,
105
90
  });
@@ -190,26 +175,3 @@ export function filteredItems(ctx, params) {
190
175
  items.reverse();
191
176
  return items;
192
177
  }
193
- /** Persisted messages in the active window, excluding earlier windows on this branch. */
194
- export function hasWindowMessage(ctx, customType) {
195
- const branch = ctx.sessionManager.getBranch();
196
- for (let i = branch.length - 1; i >= 0; i--) {
197
- const entry = branch[i];
198
- if (entry.type === "compaction")
199
- break;
200
- if (entry.type === "custom_message" && entry.customType === customType)
201
- return true;
202
- }
203
- return false;
204
- }
205
- /** Cheap current-window lookup: scan the branch tail for the latest compaction entry. */
206
- export function currentWindowId(ctx) {
207
- const sessionId = ctx.sessionManager.getSessionId();
208
- const branch = ctx.sessionManager.getBranch();
209
- for (let i = branch.length - 1; i >= 0; i--) {
210
- const entry = branch[i];
211
- if (entry?.type === "compaction")
212
- return windowIdOf(sessionId, entry);
213
- }
214
- return rootWindowId(sessionId);
215
- }