pi-posthorse 0.10.22 → 0.10.25
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/index.ts +9 -4
- package/package.json +1 -1
- package/src/boundary.ts +95 -0
- package/src/budget.ts +93 -26
- package/src/context-tools.ts +11 -9
- package/src/contracts.ts +6 -0
- package/src/epoch-cache.ts +50 -0
- package/src/guidance.ts +183 -5
- package/src/history-cursor.ts +43 -0
- package/src/history-entries.ts +1 -27
- package/src/history-pages.ts +5 -7
- package/src/history-search.ts +39 -60
- package/src/host.ts +78 -0
- package/src/note-discovery.ts +5 -6
- package/src/note-guards.ts +5 -2
- package/src/note-inventory.ts +4 -3
- package/src/note-provenance.ts +14 -2
- package/src/note-scopes.ts +30 -0
- package/src/note-store.ts +50 -26
- package/src/notes.ts +55 -43
- package/src/paging.ts +28 -16
- package/src/recovery-inputs.ts +6 -19
- package/src/recovery-note-touches.ts +16 -3
- package/src/recovery-notes.ts +1 -1
- package/src/recovery.ts +5 -25
- package/src/reminders.ts +89 -81
- package/src/reset-requests.ts +22 -7
- package/src/retained-tail.ts +56 -0
- package/src/retention.ts +22 -25
- package/src/rollover.ts +33 -72
- package/src/ui/display.ts +66 -94
package/index.ts
CHANGED
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
import { registerPosthorseMessages } from "./ui.ts";
|
|
13
13
|
import { policySnapshot } from "./src/budget.ts";
|
|
14
14
|
import type { CompactionPolicy, PolicyReader } from "./src/contracts.ts";
|
|
15
|
+
import { createWindowGuidance } from "./src/guidance.ts";
|
|
15
16
|
import { registerHistory } from "./src/history.ts";
|
|
17
|
+
import { toolFacts } from "./src/host.ts";
|
|
16
18
|
import { safeJsonStringify } from "./src/message-text.ts";
|
|
17
19
|
import { registerNotes } from "./src/notes.ts";
|
|
18
20
|
import { createNotesStore } from "./src/note-store.ts";
|
|
@@ -21,9 +23,8 @@ import { PageReservations } from "./src/paging.ts";
|
|
|
21
23
|
import { registerRollover } from "./src/rollover.ts";
|
|
22
24
|
|
|
23
25
|
function activeToolTokens(pi: ExtensionAPI): number {
|
|
24
|
-
const active =
|
|
25
|
-
return
|
|
26
|
-
.getAllTools()
|
|
26
|
+
const { active, tools } = toolFacts(pi);
|
|
27
|
+
return tools
|
|
27
28
|
.filter((tool) => active.has(tool.name))
|
|
28
29
|
.reduce(
|
|
29
30
|
(total, tool) =>
|
|
@@ -44,8 +45,11 @@ export function createPosthorse(getPolicy?: PolicyReader): (pi: ExtensionAPI) =>
|
|
|
44
45
|
const readPolicy: PolicyReader =
|
|
45
46
|
getPolicy ??
|
|
46
47
|
((ctx) => SettingsManager.inMemory(pi.getSettings()).getCompactionSettings(ctx.model));
|
|
48
|
+
const guidance = createWindowGuidance(pi);
|
|
47
49
|
const policy = (ctx: ExtensionContext, livePolicy?: CompactionPolicy) =>
|
|
48
|
-
policySnapshot(ctx, readPolicy, livePolicy)
|
|
50
|
+
policySnapshot(ctx, readPolicy, livePolicy, (target, host) =>
|
|
51
|
+
guidance.resolve(target, host),
|
|
52
|
+
);
|
|
49
53
|
const toolTokens = () => activeToolTokens(pi);
|
|
50
54
|
const pages = new PageReservations(toolTokens);
|
|
51
55
|
registerPosthorseMessages(pi);
|
|
@@ -55,6 +59,7 @@ export function createPosthorse(getPolicy?: PolicyReader): (pi: ExtensionAPI) =>
|
|
|
55
59
|
pages.reset();
|
|
56
60
|
},
|
|
57
61
|
notes: (ctx) => notes.recovery(ctx),
|
|
62
|
+
guidance,
|
|
58
63
|
});
|
|
59
64
|
pi.on("session_start", async (_event, ctx) => {
|
|
60
65
|
await notes.get(ctx);
|
package/package.json
CHANGED
package/src/boundary.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { AUTO_HANDOFF_PREFIX, isRecord, type EntryLike, type ProjectedEntry } from "./contracts.ts";
|
|
2
|
+
|
|
3
|
+
export type BoundaryKind = "fork" | "native" | "automatic" | "explicit" | "posthorse";
|
|
4
|
+
/** Everything the app derives from one persisted context boundary, decided in one place. */
|
|
5
|
+
export type Boundary = {
|
|
6
|
+
readonly kind: BoundaryKind;
|
|
7
|
+
/** Counts as a Posthorse window: labels, reminder windows, and note provenance. */
|
|
8
|
+
readonly window: boolean;
|
|
9
|
+
/** An automatic recovery record that walks through earlier windows continue past. */
|
|
10
|
+
readonly chainsAncestry: boolean;
|
|
11
|
+
/** Starts a new guidance epoch; a provider summary block keeps the previous one. */
|
|
12
|
+
readonly freshGuidance: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function windowHandoff(entry: EntryLike | undefined): string | undefined {
|
|
16
|
+
return entry?.type === "compaction" ? entry.summary : entry?.handoff;
|
|
17
|
+
}
|
|
18
|
+
/** An automatic recovery record opens with this prefix; an earlier window's walk may continue past it. */
|
|
19
|
+
function automaticHandoff(entry: EntryLike): boolean {
|
|
20
|
+
return windowHandoff(entry)?.trim().startsWith(AUTO_HANDOFF_PREFIX) === true;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A compaction with a provider summary block carries forward the previous window's leading system
|
|
24
|
+
* message unchanged, so its guidance section must stay locked to that earlier epoch too.
|
|
25
|
+
*/
|
|
26
|
+
function providerSummaryBlock(entry: EntryLike): boolean {
|
|
27
|
+
return (
|
|
28
|
+
entry.type === "compaction" &&
|
|
29
|
+
isRecord(entry.providerSummary) &&
|
|
30
|
+
isRecord(entry.providerSummary.block)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
function compactionKind(details: Readonly<Record<string, unknown>>): BoundaryKind {
|
|
34
|
+
if (details.reason === "threshold" || details.reason === "overflow") {
|
|
35
|
+
return "automatic";
|
|
36
|
+
}
|
|
37
|
+
return details.reason === "explicit" ? "explicit" : "posthorse";
|
|
38
|
+
}
|
|
39
|
+
export function classifyBoundary(entry?: EntryLike): Boundary | undefined {
|
|
40
|
+
if (entry?.type === "context_window") {
|
|
41
|
+
return {
|
|
42
|
+
kind: "fork",
|
|
43
|
+
window: true,
|
|
44
|
+
chainsAncestry: automaticHandoff(entry),
|
|
45
|
+
freshGuidance: true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (entry?.type !== "compaction") {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const details = isRecord(entry.details) ? entry.details : undefined;
|
|
52
|
+
const freshGuidance = !providerSummaryBlock(entry);
|
|
53
|
+
if (details?.posthorse !== 1) {
|
|
54
|
+
return { kind: "native", window: false, chainsAncestry: false, freshGuidance };
|
|
55
|
+
}
|
|
56
|
+
const kind = compactionKind(details);
|
|
57
|
+
return {
|
|
58
|
+
kind,
|
|
59
|
+
window: true,
|
|
60
|
+
chainsAncestry: kind === "automatic" && automaticHandoff(entry),
|
|
61
|
+
freshGuidance,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function isWindow(entry: EntryLike): boolean {
|
|
65
|
+
return classifyBoundary(entry)?.window === true;
|
|
66
|
+
}
|
|
67
|
+
export function startsFreshGuidanceEpoch(entry: EntryLike): boolean {
|
|
68
|
+
return classifyBoundary(entry)?.freshGuidance === true;
|
|
69
|
+
}
|
|
70
|
+
export function chainsAncestry(entry?: EntryLike): boolean {
|
|
71
|
+
return classifyBoundary(entry)?.chainsAncestry === true;
|
|
72
|
+
}
|
|
73
|
+
/** The boundary already applied to a projection: a compaction with a visible tail. -1 when none. */
|
|
74
|
+
export function activeBoundaryIndex(projected: readonly ProjectedEntry[]): number {
|
|
75
|
+
return projected.findIndex(
|
|
76
|
+
(entry) => entry.sourceEntry.type === "compaction" && entry.messages.length > 0,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
/** First active branch entry, including tails retained by Pi's own /compact. */
|
|
80
|
+
export function contextStart(entries: readonly EntryLike[]): {
|
|
81
|
+
start: number;
|
|
82
|
+
boundary?: EntryLike;
|
|
83
|
+
} {
|
|
84
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
85
|
+
const entry = entries[i];
|
|
86
|
+
if (entry?.type === "context_window") {
|
|
87
|
+
return { start: i + 1, boundary: entry };
|
|
88
|
+
}
|
|
89
|
+
if (entry?.type === "compaction") {
|
|
90
|
+
const kept = entries.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
|
|
91
|
+
return { start: kept >= 0 ? kept : i + 1, boundary: entry };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { start: 0 };
|
|
95
|
+
}
|
package/src/budget.ts
CHANGED
|
@@ -13,24 +13,41 @@ import {
|
|
|
13
13
|
import { imagesOf, textOf } from "./message-text.ts";
|
|
14
14
|
|
|
15
15
|
const MIN_USABLE_TOKENS = Math.ceil(MAX_HANDOFF_CHARS / 4) * 2;
|
|
16
|
+
export type GuidanceTools = {
|
|
17
|
+
readonly newContext: boolean;
|
|
18
|
+
readonly getContextRemaining: boolean;
|
|
19
|
+
};
|
|
20
|
+
export const ALL_GUIDANCE_TOOLS: GuidanceTools = {
|
|
21
|
+
newContext: true,
|
|
22
|
+
getContextRemaining: true,
|
|
23
|
+
};
|
|
16
24
|
export type Budget = {
|
|
17
25
|
readonly contextWindow: number;
|
|
18
26
|
readonly reserveTokens: number;
|
|
19
|
-
|
|
27
|
+
/** One classification of how Posthorse may use this capacity. */
|
|
28
|
+
readonly state: BudgetState;
|
|
20
29
|
readonly usable: number;
|
|
21
30
|
readonly rolloverAt: number;
|
|
22
|
-
readonly supported: boolean;
|
|
23
31
|
};
|
|
32
|
+
export type BudgetState = "disabled" | "unsupported" | "enabled";
|
|
33
|
+
function budgetState(enabled: boolean, usable: number): BudgetState {
|
|
34
|
+
if (!enabled) {
|
|
35
|
+
return "disabled";
|
|
36
|
+
}
|
|
37
|
+
return usable >= MIN_USABLE_TOKENS ? "enabled" : "unsupported";
|
|
38
|
+
}
|
|
24
39
|
/** A synchronous evaluation owns one snapshot; never carry it across an await. */
|
|
25
40
|
export function policySnapshot(
|
|
26
41
|
ctx: ExtensionContext,
|
|
27
42
|
getPolicy: PolicyReader,
|
|
28
43
|
livePolicy?: CompactionPolicy,
|
|
44
|
+
resolveGuidance?: (ctx: ExtensionContext, host: PolicyContext) => string,
|
|
29
45
|
): PolicyContext {
|
|
30
46
|
let usageRead = false;
|
|
31
47
|
let usage: ContextUsage | undefined;
|
|
32
48
|
let settings: CompactionPolicy | undefined;
|
|
33
|
-
|
|
49
|
+
let guidance: string | undefined;
|
|
50
|
+
const host: PolicyContext = {
|
|
34
51
|
getCompactionSettings: () => {
|
|
35
52
|
settings ??= { ...(livePolicy ?? getPolicy(ctx)) };
|
|
36
53
|
return settings;
|
|
@@ -43,7 +60,13 @@ export function policySnapshot(
|
|
|
43
60
|
return usage;
|
|
44
61
|
},
|
|
45
62
|
getSystemPrompt: () => ctx.getSystemPrompt(),
|
|
63
|
+
getGuidance: () => {
|
|
64
|
+
guidance ??=
|
|
65
|
+
resolveGuidance !== undefined ? resolveGuidance(ctx, host) : buildGuidance(host);
|
|
66
|
+
return guidance;
|
|
67
|
+
},
|
|
46
68
|
};
|
|
69
|
+
return host;
|
|
47
70
|
}
|
|
48
71
|
export function budgetFor(
|
|
49
72
|
ctx: PolicyContext,
|
|
@@ -57,64 +80,105 @@ export function budgetFor(
|
|
|
57
80
|
return {
|
|
58
81
|
contextWindow,
|
|
59
82
|
reserveTokens,
|
|
60
|
-
enabled,
|
|
83
|
+
state: budgetState(enabled, usable),
|
|
61
84
|
usable,
|
|
62
85
|
rolloverAt: usable + 1,
|
|
63
|
-
supported: !enabled || usable >= MIN_USABLE_TOKENS,
|
|
64
86
|
};
|
|
65
87
|
}
|
|
66
|
-
export function
|
|
88
|
+
export function budgetGuidanceKey(ctx: PolicyContext): string {
|
|
89
|
+
const budget = budgetFor(ctx);
|
|
90
|
+
if (budget === undefined) {
|
|
91
|
+
return ctx.getCompactionSettings().enabled ? "1:unknown" : "0:unknown";
|
|
92
|
+
}
|
|
93
|
+
return budget.state === "disabled"
|
|
94
|
+
? `0:${budget.contextWindow}`
|
|
95
|
+
: `1:${budget.contextWindow}:${budget.reserveTokens}`;
|
|
96
|
+
}
|
|
97
|
+
export function unsupportedMessage(budget: Budget, newContextAvailable = true): string {
|
|
67
98
|
const n = (value: number) => value.toLocaleString("en-US");
|
|
68
|
-
|
|
99
|
+
const reset = newContextAvailable
|
|
100
|
+
? " new_context remains available with a model-aware handoff limit."
|
|
101
|
+
: "";
|
|
102
|
+
return `Posthorse: unsupported configuration. The model's context window (${n(budget.contextWindow)} tokens) minus Pi's compaction.reserveTokens (${n(budget.reserveTokens)}) leaves ${n(budget.usable)} usable tokens; Posthorse needs at least ${n(MIN_USABLE_TOKENS)}. Automatic Posthorse rollover and checkpoint reminders are off for this model, so Pi's own compaction applies. Lower compaction.reserveTokens in Pi settings or use a larger-context model.${reset}`;
|
|
69
103
|
}
|
|
70
|
-
function automaticGuidance(
|
|
104
|
+
function automaticGuidance(
|
|
105
|
+
budget: Budget | undefined,
|
|
106
|
+
enabled: boolean,
|
|
107
|
+
tools: GuidanceTools,
|
|
108
|
+
): string {
|
|
71
109
|
if (!enabled) {
|
|
72
|
-
|
|
110
|
+
const reset = tools.newContext ? " new_context remains available." : "";
|
|
111
|
+
return `Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder.${reset}`;
|
|
73
112
|
}
|
|
74
|
-
if (budget
|
|
75
|
-
return unsupportedMessage(budget);
|
|
113
|
+
if (budget?.state === "unsupported") {
|
|
114
|
+
return unsupportedMessage(budget, tools.newContext);
|
|
76
115
|
}
|
|
77
116
|
const line =
|
|
78
117
|
budget === undefined
|
|
79
118
|
? ""
|
|
80
119
|
: `The rollover line is ${budget.rolloverAt.toLocaleString("en-US")} tokens used. `;
|
|
120
|
+
const reset = tools.newContext ? " new_context is an optional clean reset." : "";
|
|
81
121
|
return `Pi rolls context over automatically, so the context window is not a deadline. Do not reduce scope, skip steps or verification, or write a final summary because rollover is near, and do not wait for the user to say "continue". Finish when the requested work and its verification are complete, or report a genuine blocker.
|
|
82
|
-
${line}Rollover keeps a recent-message tail without summarizing. One task-note reminder may arrive at the end of a working turn near the line: update the note only if it is stale, or write one if the task has none, then continue with the next step in the same run
|
|
122
|
+
${line}Rollover keeps a recent-message tail without summarizing. One task-note reminder may arrive at the end of a working turn near the line: update the note only if it is stale, or write one if the task has none, then continue with the next step in the same run.${reset}`;
|
|
83
123
|
}
|
|
84
|
-
export function buildGuidance(
|
|
124
|
+
export function buildGuidance(
|
|
125
|
+
ctx: PolicyContext,
|
|
126
|
+
tools: GuidanceTools = ALL_GUIDANCE_TOOLS,
|
|
127
|
+
): string {
|
|
85
128
|
const budget = budgetFor(ctx);
|
|
86
|
-
const enabled =
|
|
129
|
+
const enabled =
|
|
130
|
+
budget === undefined ? ctx.getCompactionSettings().enabled : budget.state !== "disabled";
|
|
87
131
|
const capacity =
|
|
88
132
|
budget === undefined
|
|
89
133
|
? "Configured context capacity is unknown."
|
|
90
134
|
: `Configured context capacity: ${budget.contextWindow.toLocaleString("en-US")} tokens (not remaining space).`;
|
|
135
|
+
const checkRemaining = tools.getContextRemaining
|
|
136
|
+
? " Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining."
|
|
137
|
+
: "";
|
|
91
138
|
return `## Automatic context management (Posthorse)
|
|
92
|
-
${automaticGuidance(budget, enabled)}
|
|
93
|
-
${capacity} Do not guess capacity or remaining space from response-token or reasoning limits
|
|
139
|
+
${automaticGuidance(budget, enabled, tools)}
|
|
140
|
+
${capacity} Do not guess capacity or remaining space from response-token or reasoning limits.${checkRemaining} If usage is unknown, report it as unknown. No routine budget checks are needed.
|
|
94
141
|
After a rollover, earlier conversation stays in history. Restore notes and todos, then verify live state before stateful or external work; automatic handoffs record inputs, not progress.
|
|
95
142
|
Keep one concise current-state note per task: goal, progress, decisions, and next steps. Preserve decisions and safety constraints, link fuller evidence instead of copying it, and edit only changed sections.`;
|
|
96
143
|
}
|
|
97
|
-
|
|
98
|
-
|
|
144
|
+
export function guidanceFor(ctx: PolicyContext): string {
|
|
145
|
+
return ctx.getGuidance !== undefined ? ctx.getGuidance() : buildGuidance(ctx);
|
|
146
|
+
}
|
|
147
|
+
function guidanceTokenCount(guidance: string): number {
|
|
99
148
|
return estimateTokens({
|
|
100
149
|
role: "system",
|
|
101
|
-
content: `<posthorse>\n${
|
|
150
|
+
content: `<posthorse>\n${guidance}\n</posthorse>`,
|
|
102
151
|
timestamp: 0,
|
|
103
152
|
});
|
|
104
153
|
}
|
|
154
|
+
/** ponytail: native estimates omit request-local guidance; reserve it until post-transform usage is exposed. */
|
|
155
|
+
export function guidanceTokens(ctx: PolicyContext): number {
|
|
156
|
+
return guidanceTokenCount(guidanceFor(ctx));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Which guidance the reservation subtracts: the text rebuilt for the next request, or the host's
|
|
160
|
+
* effective text that is already in the prompt.
|
|
161
|
+
*/
|
|
162
|
+
export type GuidanceReservation = "rebuild" | "effective";
|
|
105
163
|
/** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
|
|
106
164
|
export function freshPayloadChars(
|
|
107
165
|
ctx: PolicyContext,
|
|
108
166
|
toolTokens: number,
|
|
109
|
-
|
|
110
|
-
|
|
167
|
+
options: {
|
|
168
|
+
readonly pendingMessages?: readonly MessageLike[] | undefined;
|
|
169
|
+
readonly cap?: number | undefined;
|
|
170
|
+
readonly guidance?: GuidanceReservation | undefined;
|
|
171
|
+
} = {},
|
|
111
172
|
): number {
|
|
173
|
+
const { pendingMessages = [], cap = MAX_HANDOFF_CHARS, guidance = "rebuild" } = options;
|
|
112
174
|
const budget = budgetFor(ctx);
|
|
113
175
|
if (budget === undefined) {
|
|
114
176
|
return cap;
|
|
115
177
|
}
|
|
116
|
-
const line = budget.
|
|
117
|
-
const
|
|
178
|
+
const line = budget.state === "enabled" ? budget.rolloverAt : budget.contextWindow;
|
|
179
|
+
const reservedGuidance =
|
|
180
|
+
guidance === "rebuild" ? guidanceTokenCount(buildGuidance(ctx)) : guidanceTokens(ctx);
|
|
181
|
+
const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4) + reservedGuidance;
|
|
118
182
|
const pendingTokens = pendingMessages.reduce(
|
|
119
183
|
(total, message) =>
|
|
120
184
|
total +
|
|
@@ -133,10 +197,13 @@ export function freshPayloadChars(
|
|
|
133
197
|
export function pageCapacity(ctx: PolicyContext, toolTokens: () => number): number {
|
|
134
198
|
const usage = ctx.getContextUsage();
|
|
135
199
|
if (usage === undefined || usage.tokens === null) {
|
|
136
|
-
return
|
|
200
|
+
return (
|
|
201
|
+
freshPayloadChars(ctx, toolTokens(), { cap: MAX_PAGE_CHARS, guidance: "effective" }) /
|
|
202
|
+
4 +
|
|
203
|
+
PAGE_MARGIN_TOKENS
|
|
204
|
+
);
|
|
137
205
|
}
|
|
138
206
|
const budget = budgetFor(ctx, usage.contextWindow);
|
|
139
|
-
const line =
|
|
140
|
-
budget?.enabled === true && budget.supported ? budget.rolloverAt : usage.contextWindow;
|
|
207
|
+
const line = budget?.state === "enabled" ? budget.rolloverAt : usage.contextWindow;
|
|
141
208
|
return line - usage.tokens - guidanceTokens(ctx);
|
|
142
209
|
}
|
package/src/context-tools.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { toolCards, type PosthorseDisplay } from "../ui.ts";
|
|
4
4
|
import { budgetFor, freshPayloadChars, unsupportedMessage, type Budget } from "./budget.ts";
|
|
5
|
+
import { activeGuidanceTools } from "./guidance.ts";
|
|
5
6
|
import {
|
|
6
7
|
EMPTY_HANDOFF,
|
|
7
8
|
MAX_HANDOFF_CHARS,
|
|
@@ -11,12 +12,9 @@ import {
|
|
|
11
12
|
} from "./contracts.ts";
|
|
12
13
|
|
|
13
14
|
function rolloverStatus(budget: Budget | undefined): "disabled" | "enabled" | "unsupported" {
|
|
14
|
-
|
|
15
|
-
return "disabled";
|
|
16
|
-
}
|
|
17
|
-
return budget.supported ? "enabled" : "unsupported";
|
|
15
|
+
return budget?.state ?? "disabled";
|
|
18
16
|
}
|
|
19
|
-
function contextReport(host: PolicyContext): TextResult {
|
|
17
|
+
function contextReport(host: PolicyContext, newContextAvailable: boolean): TextResult {
|
|
20
18
|
const usage = host.getContextUsage();
|
|
21
19
|
if (usage === undefined || usage.tokens === null) {
|
|
22
20
|
return textResult("Context usage is not known until the next model response.", [], {
|
|
@@ -33,15 +31,19 @@ function contextReport(host: PolicyContext): TextResult {
|
|
|
33
31
|
rolloverAt: budget?.rolloverAt,
|
|
34
32
|
};
|
|
35
33
|
const configured = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens until the configured context limit (${n(usage.tokens)}/${n(usage.contextWindow)} used, ${Math.round(usage.percent ?? 0)}%). Best available native estimate.`;
|
|
36
|
-
if (budget
|
|
34
|
+
if (budget === undefined || budget.state === "disabled") {
|
|
37
35
|
return textResult(
|
|
38
36
|
`Automatic rollover is disabled in the available settings (Pi compaction.enabled=false). ${configured}`,
|
|
39
37
|
[],
|
|
40
38
|
display,
|
|
41
39
|
);
|
|
42
40
|
}
|
|
43
|
-
if (
|
|
44
|
-
return textResult(
|
|
41
|
+
if (budget.state === "unsupported") {
|
|
42
|
+
return textResult(
|
|
43
|
+
`${unsupportedMessage(budget, newContextAvailable)} ${configured}`,
|
|
44
|
+
[],
|
|
45
|
+
display,
|
|
46
|
+
);
|
|
45
47
|
}
|
|
46
48
|
return textResult(
|
|
47
49
|
`≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${configured}`,
|
|
@@ -97,7 +99,7 @@ export function registerContextTools(
|
|
|
97
99
|
"verify remaining context before reporting a token count or changing plans because of context limits",
|
|
98
100
|
parameters: Type.Object({}),
|
|
99
101
|
async execute(...[, , , , ctx]) {
|
|
100
|
-
return contextReport(policy(ctx));
|
|
102
|
+
return contextReport(policy(ctx), activeGuidanceTools(pi).newContext);
|
|
101
103
|
},
|
|
102
104
|
});
|
|
103
105
|
}
|
package/src/contracts.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type PolicyContext = {
|
|
|
17
17
|
readonly getCompactionSettings: () => CompactionPolicy;
|
|
18
18
|
readonly getContextUsage: () => ContextUsage | undefined;
|
|
19
19
|
readonly getSystemPrompt: () => string;
|
|
20
|
+
readonly getGuidance?: () => string;
|
|
20
21
|
};
|
|
21
22
|
export type PolicyReader = (ctx: ExtensionContext) => CompactionPolicy;
|
|
22
23
|
export type SnapshotReader = (
|
|
@@ -70,6 +71,7 @@ export type EntryLike = {
|
|
|
70
71
|
readonly parentSession?: string | undefined;
|
|
71
72
|
readonly handoff?: string | undefined;
|
|
72
73
|
readonly firstKeptEntryId?: string | null | undefined;
|
|
74
|
+
readonly providerSummary?: unknown;
|
|
73
75
|
readonly targetId?: string | undefined;
|
|
74
76
|
readonly replacement?: { readonly content: unknown } | null | undefined;
|
|
75
77
|
};
|
|
@@ -116,6 +118,10 @@ export function unknownArray(value: unknown): readonly unknown[] | undefined {
|
|
|
116
118
|
export function optionalString(value: unknown): string | undefined {
|
|
117
119
|
return typeof value === "string" ? value : undefined;
|
|
118
120
|
}
|
|
121
|
+
/** Some models send every optional argument; an empty string means the argument was omitted. */
|
|
122
|
+
export function presentText(value: unknown): string | undefined {
|
|
123
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
124
|
+
}
|
|
119
125
|
export function requireValue(value: string | undefined, name: string, op: string): string {
|
|
120
126
|
if (value === undefined || value === "") {
|
|
121
127
|
throw new Error(`"${name}" is required for op "${op}".`);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { sessionIdentity, type SessionHost, type SessionIdentity } from "./host.ts";
|
|
2
|
+
|
|
3
|
+
/** A cached value with the session state it was computed from. */
|
|
4
|
+
export type EpochValue<Value> = {
|
|
5
|
+
readonly identity: SessionIdentity;
|
|
6
|
+
readonly value: Value;
|
|
7
|
+
};
|
|
8
|
+
export type EpochCache<Value> = {
|
|
9
|
+
readonly read: (ctx: SessionContext) => Value;
|
|
10
|
+
readonly reset: () => void;
|
|
11
|
+
};
|
|
12
|
+
export type SessionContext = { readonly sessionManager: SessionHost };
|
|
13
|
+
|
|
14
|
+
function sameSession(left: SessionIdentity, right: SessionIdentity): boolean {
|
|
15
|
+
return left.sessionId === right.sessionId && left.file === right.file;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Caches one session fact for the current leaf. A new leaf recomputes unless its walk reaches the
|
|
19
|
+
* cached leaf entry itself: entry identity, not a reusable id, certifies an append-only extension.
|
|
20
|
+
*/
|
|
21
|
+
export function createEpochCache<Value>(
|
|
22
|
+
compute: (
|
|
23
|
+
ctx: SessionContext,
|
|
24
|
+
identity: SessionIdentity,
|
|
25
|
+
previous: EpochValue<Value> | undefined,
|
|
26
|
+
) => Value,
|
|
27
|
+
): EpochCache<Value> {
|
|
28
|
+
let cached: (EpochValue<Value> & { readonly manager: object }) | undefined;
|
|
29
|
+
return {
|
|
30
|
+
reset: () => {
|
|
31
|
+
cached = undefined;
|
|
32
|
+
},
|
|
33
|
+
read: (ctx) => {
|
|
34
|
+
const manager: object = ctx.sessionManager;
|
|
35
|
+
const identity = sessionIdentity(ctx.sessionManager);
|
|
36
|
+
const previous =
|
|
37
|
+
cached !== undefined &&
|
|
38
|
+
cached.manager === manager &&
|
|
39
|
+
sameSession(cached.identity, identity)
|
|
40
|
+
? cached
|
|
41
|
+
: undefined;
|
|
42
|
+
if (previous !== undefined && previous.identity.leaf === identity.leaf) {
|
|
43
|
+
return previous.value;
|
|
44
|
+
}
|
|
45
|
+
const value = compute(ctx, identity, previous);
|
|
46
|
+
cached = { manager, identity, value };
|
|
47
|
+
return value;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
package/src/guidance.ts
CHANGED
|
@@ -1,8 +1,186 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import { Check } from "typebox/value";
|
|
4
|
+
import {
|
|
5
|
+
ALL_GUIDANCE_TOOLS,
|
|
6
|
+
budgetGuidanceKey,
|
|
7
|
+
buildGuidance,
|
|
8
|
+
type GuidanceTools,
|
|
9
|
+
} from "./budget.ts";
|
|
10
|
+
import type { EntryLike, PolicyContext, SnapshotReader } from "./contracts.ts";
|
|
11
|
+
import { startsFreshGuidanceEpoch } from "./boundary.ts";
|
|
12
|
+
import { createEpochCache, type EpochValue } from "./epoch-cache.ts";
|
|
13
|
+
import { sessionEntry, toolFacts, type SessionHost, type SessionIdentity } from "./host.ts";
|
|
4
14
|
|
|
5
|
-
export
|
|
15
|
+
export const WINDOW_GUIDANCE_TYPE = "posthorse-window-guidance";
|
|
16
|
+
const guidanceRecordSchema = Type.Object({
|
|
17
|
+
version: Type.Literal(1),
|
|
18
|
+
budgetKey: Type.String(),
|
|
19
|
+
text: Type.String(),
|
|
20
|
+
tools: Type.Object({
|
|
21
|
+
newContext: Type.Boolean(),
|
|
22
|
+
getContextRemaining: Type.Boolean(),
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
type WindowGuidanceRecord = Static<typeof guidanceRecordSchema>;
|
|
26
|
+
type MemoryEpoch = {
|
|
27
|
+
readonly byBudget: Map<string, string>;
|
|
28
|
+
tools: GuidanceTools | undefined;
|
|
29
|
+
};
|
|
30
|
+
type GuidanceEpoch = {
|
|
31
|
+
readonly anchorKey: string;
|
|
32
|
+
readonly byBudget: ReadonlyMap<string, string>;
|
|
33
|
+
readonly tools: GuidanceTools | undefined;
|
|
34
|
+
};
|
|
35
|
+
export type WindowGuidance = {
|
|
36
|
+
readonly resolve: (ctx: ExtensionContext, host: PolicyContext) => string;
|
|
37
|
+
readonly deliver: (ctx: ExtensionContext, host: PolicyContext) => string;
|
|
38
|
+
readonly reset: () => void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function activeGuidanceTools(pi: ExtensionAPI): GuidanceTools {
|
|
42
|
+
const { activeUnnamespaced } = toolFacts(pi);
|
|
43
|
+
return {
|
|
44
|
+
newContext: activeUnnamespaced.has("new_context"),
|
|
45
|
+
getContextRemaining: activeUnnamespaced.has("get_context_remaining"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function collectGuidanceEntries(
|
|
49
|
+
entries: readonly EntryLike[],
|
|
50
|
+
baseByBudget?: ReadonlyMap<string, string>,
|
|
51
|
+
baseTools?: GuidanceTools,
|
|
52
|
+
): { readonly byBudget: Map<string, string>; readonly tools: GuidanceTools | undefined } {
|
|
53
|
+
const byBudget = new Map<string, string>(baseByBudget);
|
|
54
|
+
let tools = baseTools;
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (
|
|
57
|
+
entry.type === "custom" &&
|
|
58
|
+
entry.customType === WINDOW_GUIDANCE_TYPE &&
|
|
59
|
+
Check(guidanceRecordSchema, entry.data)
|
|
60
|
+
) {
|
|
61
|
+
byBudget.set(entry.data.budgetKey, entry.data.text);
|
|
62
|
+
tools ??= entry.data.tools;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { byBudget, tools };
|
|
66
|
+
}
|
|
67
|
+
function withMemoryEpoch(
|
|
68
|
+
collected: {
|
|
69
|
+
readonly byBudget: Map<string, string>;
|
|
70
|
+
readonly tools: GuidanceTools | undefined;
|
|
71
|
+
},
|
|
72
|
+
memory: MemoryEpoch | undefined,
|
|
73
|
+
): { readonly byBudget: ReadonlyMap<string, string>; readonly tools: GuidanceTools | undefined } {
|
|
74
|
+
if (memory === undefined) {
|
|
75
|
+
return collected;
|
|
76
|
+
}
|
|
77
|
+
const merged = new Map(collected.byBudget);
|
|
78
|
+
for (const [key, text] of memory.byBudget) {
|
|
79
|
+
if (!merged.has(key)) {
|
|
80
|
+
merged.set(key, text);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { byBudget: merged, tools: collected.tools ?? memory.tools };
|
|
84
|
+
}
|
|
85
|
+
function scanGuidanceEpoch(
|
|
86
|
+
manager: SessionHost,
|
|
87
|
+
identity: SessionIdentity,
|
|
88
|
+
previous: EpochValue<GuidanceEpoch> | undefined,
|
|
89
|
+
memoryByEpoch: ReadonlyMap<string, MemoryEpoch> | undefined,
|
|
90
|
+
): GuidanceEpoch {
|
|
91
|
+
const reversed: EntryLike[] = [];
|
|
92
|
+
let anchor: EntryLike | undefined;
|
|
93
|
+
for (
|
|
94
|
+
let entry = identity.leaf;
|
|
95
|
+
entry !== undefined;
|
|
96
|
+
entry = sessionEntry(manager, entry.parentId)
|
|
97
|
+
) {
|
|
98
|
+
if (previous !== undefined && previous.identity.leaf === entry) {
|
|
99
|
+
const collected = collectGuidanceEntries(
|
|
100
|
+
reversed.reverse(),
|
|
101
|
+
previous.value.byBudget,
|
|
102
|
+
previous.value.tools,
|
|
103
|
+
);
|
|
104
|
+
return {
|
|
105
|
+
anchorKey: previous.value.anchorKey,
|
|
106
|
+
...withMemoryEpoch(collected, memoryByEpoch?.get(previous.value.anchorKey)),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (startsFreshGuidanceEpoch(entry)) {
|
|
110
|
+
anchor = entry;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
reversed.push(entry);
|
|
114
|
+
}
|
|
115
|
+
const anchorKey =
|
|
116
|
+
anchor === undefined
|
|
117
|
+
? `${identity.sessionId}:root`
|
|
118
|
+
: `${identity.sessionId}:${anchor.id ?? "boundary"}`;
|
|
119
|
+
const collected = collectGuidanceEntries(reversed.reverse());
|
|
120
|
+
return {
|
|
121
|
+
anchorKey,
|
|
122
|
+
...withMemoryEpoch(collected, memoryByEpoch?.get(anchorKey)),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
|
|
126
|
+
let memoryByManager = new WeakMap<object, Map<string, MemoryEpoch>>();
|
|
127
|
+
const epochs = createEpochCache<GuidanceEpoch>((ctx, identity, previous) =>
|
|
128
|
+
scanGuidanceEpoch(
|
|
129
|
+
ctx.sessionManager,
|
|
130
|
+
identity,
|
|
131
|
+
previous,
|
|
132
|
+
memoryByManager.get(ctx.sessionManager),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
return {
|
|
136
|
+
resolve(ctx, host) {
|
|
137
|
+
const epoch = epochs.read(ctx);
|
|
138
|
+
const key = budgetGuidanceKey(host);
|
|
139
|
+
return (
|
|
140
|
+
epoch.byBudget.get(key) ?? buildGuidance(host, epoch.tools ?? ALL_GUIDANCE_TOOLS)
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
deliver(ctx, host) {
|
|
144
|
+
const epoch = epochs.read(ctx);
|
|
145
|
+
const key = budgetGuidanceKey(host);
|
|
146
|
+
const existing = epoch.byBudget.get(key);
|
|
147
|
+
if (existing !== undefined) {
|
|
148
|
+
return existing;
|
|
149
|
+
}
|
|
150
|
+
const tools = epoch.tools ?? activeGuidanceTools(pi);
|
|
151
|
+
const text = buildGuidance(host, tools);
|
|
152
|
+
let byEpoch = memoryByManager.get(ctx.sessionManager);
|
|
153
|
+
if (byEpoch === undefined) {
|
|
154
|
+
byEpoch = new Map();
|
|
155
|
+
memoryByManager.set(ctx.sessionManager, byEpoch);
|
|
156
|
+
}
|
|
157
|
+
let memory = byEpoch.get(epoch.anchorKey);
|
|
158
|
+
if (memory === undefined) {
|
|
159
|
+
memory = { byBudget: new Map(), tools };
|
|
160
|
+
byEpoch.set(epoch.anchorKey, memory);
|
|
161
|
+
}
|
|
162
|
+
memory.byBudget.set(key, text);
|
|
163
|
+
memory.tools ??= tools;
|
|
164
|
+
epochs.reset();
|
|
165
|
+
pi.appendEntry(WINDOW_GUIDANCE_TYPE, {
|
|
166
|
+
version: 1,
|
|
167
|
+
budgetKey: key,
|
|
168
|
+
text,
|
|
169
|
+
tools,
|
|
170
|
+
} satisfies WindowGuidanceRecord);
|
|
171
|
+
return text;
|
|
172
|
+
},
|
|
173
|
+
reset() {
|
|
174
|
+
epochs.reset();
|
|
175
|
+
memoryByManager = new WeakMap();
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export function registerGuidance(
|
|
180
|
+
pi: ExtensionAPI,
|
|
181
|
+
policy: SnapshotReader,
|
|
182
|
+
windowGuidance: WindowGuidance,
|
|
183
|
+
): void {
|
|
6
184
|
let promptOptions: { readonly forceSystemPrompt?: string } | undefined;
|
|
7
185
|
pi.on("before_agent_start", (event) => {
|
|
8
186
|
promptOptions = event.systemPromptOptions;
|
|
@@ -12,7 +190,7 @@ export function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void
|
|
|
12
190
|
promptOptions = undefined;
|
|
13
191
|
});
|
|
14
192
|
pi.on("context_with_system", (event, ctx) => {
|
|
15
|
-
const guidance =
|
|
193
|
+
const guidance = windowGuidance.deliver(ctx, policy(ctx));
|
|
16
194
|
const system =
|
|
17
195
|
event.messages.findLast(
|
|
18
196
|
(message) =>
|