@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.
- package/README.md +52 -5
- package/dist/build-info.json +4 -0
- package/dist/extension.js +1951 -0
- package/dist/src/context/boot.js +46 -0
- package/dist/src/context/budget.js +150 -0
- package/dist/src/context/context-window.js +112 -0
- package/dist/src/context/prompts.js +91 -0
- package/dist/src/context/reset-artifacts.js +86 -0
- package/dist/src/context/reset-lifecycle.js +182 -0
- package/dist/src/context/runtime.js +151 -0
- package/dist/src/context/thresholds.js +62 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.js +34 -6
- package/dist/src/dream/runner.js +1 -1
- package/dist/src/dream/settings.js +30 -0
- package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
- package/dist/src/{history.js → history/history.js} +8 -46
- package/dist/src/index.js +27 -94
- package/dist/src/notes/address.js +97 -16
- package/dist/src/notes/frontmatter.js +18 -3
- package/dist/src/notes/notes-snapshot.js +30 -0
- package/dist/src/notes/paths.js +64 -7
- package/dist/src/notes/session-replay.js +41 -0
- package/dist/src/notes/store.js +76 -22
- package/dist/src/notes/tools.js +7 -7
- package/dist/src/protocol.js +9 -9
- package/dist/src/settings.js +16 -0
- package/dist/src/tool-schema.js +1 -1
- package/dist/test/agent-loop.test.js +815 -221
- package/dist/test/boot.integration.test.js +219 -0
- package/dist/test/budget-settings.integration.test.js +126 -0
- package/dist/test/doctor.test.js +14 -36
- package/dist/test/dream.test.js +37 -380
- package/dist/test/helpers/extension.js +392 -0
- package/dist/test/history.integration.test.js +316 -0
- package/dist/test/notes.integration.test.js +270 -0
- package/dist/test/notes.test.js +40 -359
- package/dist/test/reset-lifecycle.test.js +443 -178
- package/docs/architecture.md +35 -18
- package/docs/reset-lifecycle.md +73 -14
- package/package.json +11 -10
- package/src/context/boot.ts +68 -0
- package/src/context/budget.ts +148 -0
- package/src/context/context-window.ts +118 -0
- package/src/context/prompts.ts +108 -0
- package/src/context/reset-artifacts.ts +101 -0
- package/src/context/reset-lifecycle.ts +272 -0
- package/src/context/runtime.ts +151 -0
- package/src/context/thresholds.ts +78 -0
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +27 -6
- package/src/dream/runner.ts +1 -1
- package/src/dream/settings.ts +32 -0
- package/src/{history-tools.ts → history/history-tools.ts} +3 -3
- package/src/{history.ts → history/history.ts} +9 -48
- package/src/index.ts +27 -89
- package/src/notes/address.ts +82 -16
- package/src/notes/frontmatter.ts +20 -3
- package/src/notes/notes-snapshot.ts +40 -0
- package/src/notes/paths.ts +64 -7
- package/src/notes/session-replay.ts +53 -0
- package/src/notes/store.ts +78 -25
- package/src/notes/tools.ts +7 -7
- package/src/protocol.ts +9 -10
- package/src/settings.ts +20 -0
- package/src/tool-schema.ts +1 -2
- package/dist/src/budget.js +0 -65
- package/dist/src/notes/model.js +0 -101
- package/dist/src/prompts.js +0 -88
- package/dist/src/reset-lifecycle.js +0 -155
- package/dist/src/thresholds.js +0 -102
- package/dist/src/warning.js +0 -44
- package/dist/test/coherence.test.js +0 -371
- package/dist/test/history.test.js +0 -26
- package/dist/test/integration.test.js +0 -1759
- package/dist/test/pagination.property.test.js +0 -471
- package/src/budget.ts +0 -67
- package/src/notes/model.ts +0 -109
- package/src/prompts.ts +0 -91
- package/src/reset-lifecycle.ts +0 -173
- package/src/thresholds.ts +0 -110
- package/src/warning.ts +0 -46
package/src/prompts.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { historyFromSession } from "./history.js";
|
|
3
|
-
import { listNotes } from "./notes/store.js";
|
|
4
|
-
import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_PERSONAL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, RESET_SUMMARY, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "./protocol.js";
|
|
5
|
-
|
|
6
|
-
/** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
|
|
7
|
-
function identityBlock(agentName: string, firstWindowId: string, currentWindowId: string, previousWindowId?: string): string {
|
|
8
|
-
const lines = [
|
|
9
|
-
`Agent name: ${agentName}`,
|
|
10
|
-
`First context window id: ${firstWindowId}`,
|
|
11
|
-
`Current context window id: ${currentWindowId}`,
|
|
12
|
-
];
|
|
13
|
-
if (previousWindowId) lines.push(`Previous context window id: ${previousWindowId}`);
|
|
14
|
-
return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function relativeTime(timestamp: number, now: number): string {
|
|
18
|
-
const seconds = Math.trunc((timestamp - now) / 1000);
|
|
19
|
-
const [unit, size] = ([["d", 86400], ["h", 3600], ["m", 60], ["s", 1]] as const)
|
|
20
|
-
.find(([unit, size]) => Math.abs(seconds) >= size || unit === "s")!;
|
|
21
|
-
const amount = `${Math.abs(Math.trunc(seconds / size))}${unit}`;
|
|
22
|
-
return seconds > 0 ? `in ${amount}` : `${amount} ago`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the personal and
|
|
27
|
-
* project homes are both injected, broadest first; stale maps are skipped per home, and the
|
|
28
|
-
* session home is never peeked — a session MAP.md is an ordinary note. The pocket then lists
|
|
29
|
-
* recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT / POCKET_PROJECT_LIMIT /
|
|
30
|
-
* POCKET_PERSONAL_LIMIT), most-recently-updated first within each home, one metadata line
|
|
31
|
-
* each: address, line count, UTF-8 byte count, relative update time at window open. Bodies never render
|
|
32
|
-
* in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
|
|
33
|
-
*/
|
|
34
|
-
function notesIndex(ctx: ExtensionContext): string {
|
|
35
|
-
const sections: string[] = [];
|
|
36
|
-
// Map residency ("地图在场"): scope-native maps, both fresh ones injected broadest-first.
|
|
37
|
-
// A session MAP.md is an ordinary note, never resident; stale maps skip independently.
|
|
38
|
-
for (const scope of ["personal", "project"] as const) {
|
|
39
|
-
const toc = listNotes(ctx, { scope }).find((row) => row.path === "MAP.md");
|
|
40
|
-
if (toc && !toc.meta.stale) {
|
|
41
|
-
if (toc.body.length > 0) sections.push(toc.body);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
// listNotes is most-recently-updated first within each home. Per-home quotas keep session
|
|
45
|
-
// churn from evicting project or personal notes; maps never take pocket seats.
|
|
46
|
-
const recentNotes = [
|
|
47
|
-
...listNotes(ctx, { scope: "session" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_SESSION_LIMIT),
|
|
48
|
-
...listNotes(ctx, { scope: "project" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PROJECT_LIMIT),
|
|
49
|
-
...listNotes(ctx, { scope: "personal" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PERSONAL_LIMIT),
|
|
50
|
-
];
|
|
51
|
-
if (recentNotes.length > 0) {
|
|
52
|
-
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_PERSONAL_LIMIT} from personal). A note's content never appears here, so its name has to say what the note is about:`];
|
|
53
|
-
const now = Date.now();
|
|
54
|
-
for (const row of recentNotes) {
|
|
55
|
-
lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, now)})`);
|
|
56
|
-
}
|
|
57
|
-
sections.push(lines.join("\n"));
|
|
58
|
-
}
|
|
59
|
-
return sections.join("\n\n");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function notesHomeBlock(): string {
|
|
63
|
-
return "Notes_* addresses have three homes: bare <vpath> is this session, @project/<vpath> is this project, and @personal/<vpath> is the human's cross-project home. @ means leaving home; there is no cross-home fallback. Any other note is a plain file — use the file tools.";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Assemble the static, once-per-window boot block: the reset line for resets, the
|
|
68
|
-
* <context_window> identity block, the recent-notes index at window-open time, and
|
|
69
|
-
* the <context_window_protocol> teaching block. Nothing here is re-injected, so the
|
|
70
|
-
* head of the window stays cache-stable.
|
|
71
|
-
*/
|
|
72
|
-
export function bootBlock(ctx: ExtensionContext, currentId: string, previousId: string | undefined, resetLine: boolean): string {
|
|
73
|
-
const firstId = historyFromSession(ctx)[0]?.windowId ?? currentId;
|
|
74
|
-
const parts: string[] = [];
|
|
75
|
-
if (resetLine) parts.push(RESET_SUMMARY);
|
|
76
|
-
parts.push(identityBlock(ctx.sessionManager.getSessionName() ?? "root", firstId, currentId, previousId));
|
|
77
|
-
parts.push(notesHomeBlock());
|
|
78
|
-
const index = notesIndex(ctx);
|
|
79
|
-
if (index) parts.push(index);
|
|
80
|
-
parts.push(PROTOCOL_BLOCK);
|
|
81
|
-
return parts.join("\n\n");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Codex-equivalent low-budget reminder. The measured remaining count is frozen into
|
|
86
|
-
* the text at the crossing that fires it, so each persisted copy is a snapshot true
|
|
87
|
-
* at write time; get_context_remaining remains the live source for the current figure.
|
|
88
|
-
*/
|
|
89
|
-
export function tokenBudgetGuidance(remaining: number): string {
|
|
90
|
-
return `${GUIDANCE_OPEN_TAG}\nYour brain is almost out of room — ${remaining} tokens left, and then your memory gets wiped. The wipe is automatic: there is no final turn to write then. Grab the notebook now — the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Replacing an older checkpoint? Mark it stale. Then call wipe_memory yourself — anything you do after the checkpoint isn't in it.\n${GUIDANCE_CLOSE_TAG}`;
|
|
91
|
-
}
|
package/src/reset-lifecycle.ts
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
type ResetResult = { cancel: true } | {
|
|
4
|
-
compaction: { summary: string; firstKeptEntryId: string; tokensBefore: number; details: unknown };
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
/** A reset request is session-local. Only this module schedules compaction/continuation. */
|
|
8
|
-
export function registerResetLifecycle(pi: ExtensionAPI, options: {
|
|
9
|
-
isEnabled: () => boolean;
|
|
10
|
-
continuation: Parameters<ExtensionAPI["sendMessage"]>[0];
|
|
11
|
-
buildReset: (event: SessionBeforeCompactEvent, ctx: ExtensionContext, explicit: boolean) => ResetResult;
|
|
12
|
-
isCurrentReset: (entryId: string, ctx: ExtensionContext) => boolean;
|
|
13
|
-
onReset: (entryId: string) => void;
|
|
14
|
-
}) {
|
|
15
|
-
type Attempt = {
|
|
16
|
-
completed: boolean;
|
|
17
|
-
explicit: boolean;
|
|
18
|
-
nextRequested: boolean;
|
|
19
|
-
continuationStarted: boolean;
|
|
20
|
-
sessionId: string;
|
|
21
|
-
settled: boolean;
|
|
22
|
-
wait: Promise<void>;
|
|
23
|
-
release: () => void;
|
|
24
|
-
};
|
|
25
|
-
type Request =
|
|
26
|
-
| { phase: "idle" }
|
|
27
|
-
| { phase: "requested" }
|
|
28
|
-
| { phase: "compacting"; attempt: Attempt };
|
|
29
|
-
let state: Request = { phase: "idle" };
|
|
30
|
-
let handledEntry: string | undefined;
|
|
31
|
-
let active = true;
|
|
32
|
-
|
|
33
|
-
const release = (attempt: Attempt) => {
|
|
34
|
-
if (attempt.settled) return;
|
|
35
|
-
attempt.settled = true;
|
|
36
|
-
if (state.phase === "compacting" && state.attempt === attempt) {
|
|
37
|
-
state = { phase: "idle" };
|
|
38
|
-
handledEntry = undefined;
|
|
39
|
-
}
|
|
40
|
-
attempt.release();
|
|
41
|
-
};
|
|
42
|
-
const clear = () => {
|
|
43
|
-
if (state.phase === "compacting") release(state.attempt);
|
|
44
|
-
state = { phase: "idle" };
|
|
45
|
-
handledEntry = undefined;
|
|
46
|
-
};
|
|
47
|
-
const valid = (request: Attempt, ctx: ExtensionContext) =>
|
|
48
|
-
active && options.isEnabled() && state.phase === "compacting" && state.attempt === request && ctx.sessionManager.getSessionId() === request.sessionId;
|
|
49
|
-
|
|
50
|
-
const begin = (ctx: ExtensionContext) => {
|
|
51
|
-
let releaseWait!: () => void;
|
|
52
|
-
const request: Attempt = {
|
|
53
|
-
completed: false,
|
|
54
|
-
explicit: true,
|
|
55
|
-
nextRequested: false,
|
|
56
|
-
continuationStarted: false,
|
|
57
|
-
sessionId: ctx.sessionManager.getSessionId(),
|
|
58
|
-
settled: false,
|
|
59
|
-
wait: new Promise<void>((resolve) => { releaseWait = resolve; }),
|
|
60
|
-
release: () => releaseWait(),
|
|
61
|
-
};
|
|
62
|
-
state = { phase: "compacting", attempt: request };
|
|
63
|
-
const onError = (error: Error) => {
|
|
64
|
-
if (!valid(request, ctx)) return;
|
|
65
|
-
release(request);
|
|
66
|
-
// Do not retry from settled in a tight loop. A later prompt may trigger a
|
|
67
|
-
// native reset or explicitly request one.
|
|
68
|
-
ctx.ui.notify(`pi-context: reset did not complete (${error.message}). The conversation is retained; resume with another prompt.`, "warning");
|
|
69
|
-
};
|
|
70
|
-
try {
|
|
71
|
-
ctx.compact({
|
|
72
|
-
onComplete: () => {
|
|
73
|
-
if (!valid(request, ctx)) return;
|
|
74
|
-
// session_compact only confirms the boundary. onComplete runs after
|
|
75
|
-
// Pi clears compaction state; sending inside the hook starts too early.
|
|
76
|
-
// A queued user prompt may already have started at compaction_end.
|
|
77
|
-
if (request.completed && ctx.isIdle() && !ctx.hasPendingMessages()) {
|
|
78
|
-
// The SDK detaches sendMessage, so own the next settled event before
|
|
79
|
-
// starting it. The originating agent_settled handler awaits wait.
|
|
80
|
-
if (request.continuationStarted) return;
|
|
81
|
-
request.continuationStarted = true;
|
|
82
|
-
try {
|
|
83
|
-
pi.sendMessage(options.continuation, { triggerTurn: true });
|
|
84
|
-
} catch (error) {
|
|
85
|
-
onError(error instanceof Error ? error : new Error(String(error)));
|
|
86
|
-
}
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
release(request);
|
|
90
|
-
},
|
|
91
|
-
onError,
|
|
92
|
-
});
|
|
93
|
-
} catch (error) {
|
|
94
|
-
onError(error instanceof Error ? error : new Error(String(error)));
|
|
95
|
-
}
|
|
96
|
-
return request;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
// State is intentionally not resumed from a pending request: a loaded session must
|
|
100
|
-
// not execute work from a tool that belonged to a previous runtime or tree branch.
|
|
101
|
-
pi.on("session_start", () => { clear(); active = true; });
|
|
102
|
-
pi.on("session_shutdown", () => { clear(); active = false; });
|
|
103
|
-
pi.on("session_tree", clear);
|
|
104
|
-
|
|
105
|
-
pi.on("agent_end", (_event, ctx) => {
|
|
106
|
-
if (!active || !options.isEnabled()) return;
|
|
107
|
-
if (ctx.signal?.aborted) {
|
|
108
|
-
// Esc cancels the user's run. Do not reset or resurrect it at settled.
|
|
109
|
-
clear();
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
pi.on("agent_settled", (_event, ctx) => {
|
|
114
|
-
if (!active || !options.isEnabled() || !ctx.isIdle()) return;
|
|
115
|
-
if (state.phase === "compacting" && state.attempt.continuationStarted) {
|
|
116
|
-
const preceding = state.attempt;
|
|
117
|
-
if (!preceding.nextRequested) {
|
|
118
|
-
release(preceding);
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
// This settled event belongs to the continuation started by preceding.
|
|
122
|
-
// If it requested another reset, retain preceding until that reset's own
|
|
123
|
-
// continuation settles. Its eventual nested handler only releases its own
|
|
124
|
-
// waiter, so it never awaits itself.
|
|
125
|
-
const next = begin(ctx);
|
|
126
|
-
return next.wait.then(() => release(preceding));
|
|
127
|
-
}
|
|
128
|
-
if (state.phase !== "requested") return;
|
|
129
|
-
// One owner for requested resets. Consume the request before any external call;
|
|
130
|
-
// repeated settled events and reentrant callbacks are harmless.
|
|
131
|
-
return begin(ctx).wait;
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
pi.on("session_before_compact", (event, ctx) => {
|
|
135
|
-
if (!active || !options.isEnabled()) return undefined;
|
|
136
|
-
if (event.signal.aborted) return { cancel: true };
|
|
137
|
-
// Automatic threshold/overflow compactions reset on the spot — no model turn.
|
|
138
|
-
// The warning steer fired earlier (see warning.ts); what crosses the reserve
|
|
139
|
-
// line now is the wipe itself.
|
|
140
|
-
try {
|
|
141
|
-
return options.buildReset(event, ctx, state.phase === "requested");
|
|
142
|
-
} catch (error) {
|
|
143
|
-
ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
|
|
144
|
-
return { cancel: true }; // Never fall through to a generated default summary.
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
pi.on("session_compact", (event, ctx) => {
|
|
149
|
-
if (!active || !options.isEnabled() || handledEntry === event.compactionEntry.id) return;
|
|
150
|
-
if (!options.isCurrentReset(event.compactionEntry.id, ctx)) return;
|
|
151
|
-
handledEntry = event.compactionEntry.id;
|
|
152
|
-
if (state.phase === "compacting") state.attempt.completed = !event.willRetry;
|
|
153
|
-
else state = { phase: "idle" };
|
|
154
|
-
// A native compaction (including overflow retry) owns its own scheduling.
|
|
155
|
-
// Only a reset we requested gets a continuation from our onComplete callback.
|
|
156
|
-
options.onReset(event.compactionEntry.id);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
request() {
|
|
161
|
-
if (state.phase === "idle") {
|
|
162
|
-
state = { phase: "requested" };
|
|
163
|
-
return "rollover_requested";
|
|
164
|
-
}
|
|
165
|
-
if (state.phase === "compacting" && state.attempt.continuationStarted && !state.attempt.nextRequested) {
|
|
166
|
-
state.attempt.nextRequested = true;
|
|
167
|
-
return "rollover_requested";
|
|
168
|
-
}
|
|
169
|
-
return "rollover_already_pending";
|
|
170
|
-
},
|
|
171
|
-
clear,
|
|
172
|
-
};
|
|
173
|
-
}
|
package/src/thresholds.ts
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { SettingsManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { PI_CONTEXT_SETTINGS_KEY, PI_CONTEXT_DREAMER_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
|
|
3
|
-
|
|
4
|
-
export type ResolvedThresholds = { reminder: number; reserve: number; warning: number };
|
|
5
|
-
type PiContextSettings = { reminderMarginTokens?: unknown; dreamer?: unknown };
|
|
6
|
-
|
|
7
|
-
function isSettingsObject(value: unknown): value is Record<string, unknown> {
|
|
8
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** Read the raw "pi-context" object from one parsed settings scope. */
|
|
12
|
-
function piContextSettings(settings: unknown): Record<string, unknown> {
|
|
13
|
-
if (!isSettingsObject(settings)) return {};
|
|
14
|
-
const value = settings[PI_CONTEXT_SETTINGS_KEY];
|
|
15
|
-
return isSettingsObject(value) ? value : {};
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
|
|
19
|
-
export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextSettings {
|
|
20
|
-
const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
|
|
21
|
-
return { reminderMarginTokens: merged.reminderMarginTokens, dreamer: merged.dreamer };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** A margin is usable only as a positive integer; anything else is ignored. */
|
|
25
|
-
function validMargin(raw: unknown): number | undefined {
|
|
26
|
-
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return undefined;
|
|
27
|
-
return raw;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Pure derivation of the thresholds from Pi's reserve: the reminder fires at reserve
|
|
32
|
-
* plus the pi-context margin, the warning steer at reserve plus WARNING_RUNWAY_TOKENS.
|
|
33
|
-
* An invalid margin degrades to the default and reports one warning. Pi's automatic
|
|
34
|
-
* threshold/overflow compaction itself resets immediately, with no model turn.
|
|
35
|
-
*/
|
|
36
|
-
export function deriveThresholds(reserveTokens: number, margins: PiContextSettings): { thresholds: ResolvedThresholds; warnings: string[] } {
|
|
37
|
-
const warnings: string[] = [];
|
|
38
|
-
const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
|
|
39
|
-
let reminderMargin: number;
|
|
40
|
-
if (margins.reminderMarginTokens === undefined) reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
41
|
-
else {
|
|
42
|
-
const parsed = validMargin(margins.reminderMarginTokens);
|
|
43
|
-
if (parsed === undefined) {
|
|
44
|
-
warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
|
|
45
|
-
reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
46
|
-
} else reminderMargin = parsed;
|
|
47
|
-
}
|
|
48
|
-
return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export type DreamerSetting = { pattern?: string; warnings: string[] };
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* `pi-context.dreamer` is a non-empty model pattern. Anything else present is ignored
|
|
55
|
-
* with one warning; absent means no configured pattern, so the automatic model applies.
|
|
56
|
-
*/
|
|
57
|
-
export function deriveDreamer(settings: PiContextSettings): DreamerSetting {
|
|
58
|
-
const raw = settings.dreamer;
|
|
59
|
-
if (raw === undefined) return { warnings: [] };
|
|
60
|
-
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
61
|
-
return { warnings: [`pi-context: ${PI_CONTEXT_SETTINGS_KEY}.${PI_CONTEXT_DREAMER_KEY} must be a non-empty string; ignoring it.`] };
|
|
62
|
-
}
|
|
63
|
-
return { pattern: raw.trim(), warnings: [] };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Resolve the configurable dreamer model from Pi settings for a CLI invocation: global
|
|
68
|
-
* `~/.pi/agent/settings.json` merged with the project's `.pi/settings.json`, project
|
|
69
|
-
* values winning per key. A settings read failure degrades to no pattern with one warning.
|
|
70
|
-
*/
|
|
71
|
-
export function readDreamerSettings(cwd = process.cwd()): DreamerSetting {
|
|
72
|
-
try {
|
|
73
|
-
const settingsManager = SettingsManager.create(cwd, undefined, { projectTrusted: true });
|
|
74
|
-
return deriveDreamer(mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
|
|
75
|
-
} catch (error) {
|
|
76
|
-
return { warnings: [`pi-context: could not read settings; using the automatic dreamer model (${String(error)}).`] };
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
let cached: ResolvedThresholds | undefined;
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Session-level threshold resolution: Pi's compaction reserve plus the settings.json
|
|
84
|
-
* "pi-context" margins. The file-backed read is cached until resetThresholds (called
|
|
85
|
-
* on session_start/session_tree); invalid configuration degrades per offending key
|
|
86
|
-
* with one warning and never throws during session operation.
|
|
87
|
-
*/
|
|
88
|
-
export function thresholdsFor(ctx: ExtensionContext): ResolvedThresholds {
|
|
89
|
-
if (cached) return cached;
|
|
90
|
-
try {
|
|
91
|
-
const settingsManager = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
|
|
92
|
-
// Pass the active model so per-model compaction.modelOverrides resolve (SDK 0.86);
|
|
93
|
-
// on older runtimes the extra argument is ignored and the ordinary setting wins.
|
|
94
|
-
const model = ctx.model;
|
|
95
|
-
const derived = deriveThresholds(
|
|
96
|
-
settingsManager.getCompactionSettings(model ? { provider: model.provider, id: model.id } : undefined).reserveTokens,
|
|
97
|
-
mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()),
|
|
98
|
-
);
|
|
99
|
-
for (const warning of derived.warnings) ctx.ui.notify(warning, "warning");
|
|
100
|
-
cached = derived.thresholds;
|
|
101
|
-
} catch (error) {
|
|
102
|
-
ctx.ui.notify(`pi-context: could not read settings; using defaults (${String(error)}).`, "warning");
|
|
103
|
-
cached = { reminder: DEFAULT_RESERVE_TOKENS + DEFAULT_REMINDER_MARGIN_TOKENS, reserve: DEFAULT_RESERVE_TOKENS, warning: DEFAULT_RESERVE_TOKENS + WARNING_RUNWAY_TOKENS };
|
|
104
|
-
}
|
|
105
|
-
return cached;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function resetThresholds(): void {
|
|
109
|
-
cached = undefined;
|
|
110
|
-
}
|
package/src/warning.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { WARNING_TYPE, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG, WARNING_PROMPT } from "./protocol.js";
|
|
3
|
-
import { thresholdsFor, resetThresholds, type ResolvedThresholds } from "./thresholds.js";
|
|
4
|
-
import { hasWindowMessage, currentWindowId } from "./history.js";
|
|
5
|
-
import { remainingTokens } from "./budget.js";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The final checkpoint warning, steered to the model once per window. Like the early
|
|
9
|
-
* reminder, the steer text is model-facing only (display: false); the human learns
|
|
10
|
-
* about it from the warning-level notify, not from a chat-visible message.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** Trigger: does the steer fire at this remaining-token count? Pure. */
|
|
14
|
-
export function warningDue(remaining: number, thresholds: ResolvedThresholds): boolean {
|
|
15
|
-
return remaining <= thresholds.warning;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Delivery: what happens when it fires. */
|
|
19
|
-
export function steerWarning(pi: ExtensionAPI, ctx: ExtensionContext, thresholds: ResolvedThresholds, remaining: number): void {
|
|
20
|
-
pi.sendMessage({ customType: WARNING_TYPE, content: `${GUIDANCE_OPEN_TAG}\n${WARNING_PROMPT}\n${GUIDANCE_CLOSE_TAG}`, display: false }, { triggerTurn: true });
|
|
21
|
-
ctx.ui.notify(`pi-context: context budget critical (${Math.max(0, remaining - thresholds.reserve)} tokens before reserve) — final checkpoint warning steered to the model.`, "warning");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Registration: once-per-window guard plus trigger+delivery on the context hook. */
|
|
25
|
-
export function registerWarning(pi: ExtensionAPI, isEnabled: () => boolean): void {
|
|
26
|
-
let firedInWindow: string | undefined;
|
|
27
|
-
// Threshold resolution is owned by budget.ts; this module only consumes the shared
|
|
28
|
-
// cache (lazily on the context hook) so session_start never warns twice.
|
|
29
|
-
pi.on("session_start", () => { firedInWindow = undefined; });
|
|
30
|
-
pi.on("session_tree", () => { firedInWindow = undefined; resetThresholds(); });
|
|
31
|
-
pi.on("context", (_event, ctx) => {
|
|
32
|
-
const windowId = currentWindowId(ctx);
|
|
33
|
-
if (!isEnabled() || firedInWindow === windowId || hasWindowMessage(ctx, WARNING_TYPE)) return undefined;
|
|
34
|
-
const remaining = remainingTokens(ctx);
|
|
35
|
-
if (remaining === null) return undefined;
|
|
36
|
-
const thresholds = thresholdsFor(ctx);
|
|
37
|
-
if (!warningDue(remaining, thresholds)) return undefined;
|
|
38
|
-
firedInWindow = windowId;
|
|
39
|
-
// The steer reaches the model at the next sampling step with at most the runway
|
|
40
|
-
// of invisible budget left. After it, the model decides for itself: end the
|
|
41
|
-
// window, or ride it into Pi's automatic compaction, which resets on the spot
|
|
42
|
-
// with no turn (see reset-lifecycle).
|
|
43
|
-
steerWarning(pi, ctx, thresholds, remaining);
|
|
44
|
-
return undefined;
|
|
45
|
-
});
|
|
46
|
-
}
|