pi-posthorse 0.10.24 → 0.10.26
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 +3 -3
- package/package.json +1 -1
- package/src/boundary.ts +101 -0
- package/src/budget.ts +35 -16
- package/src/context-tools.ts +3 -6
- package/src/contracts.ts +49 -1
- package/src/epoch-cache.ts +50 -0
- package/src/files.ts +6 -0
- package/src/guidance.ts +71 -98
- 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 +10 -7
- package/src/note-guards.ts +5 -2
- package/src/note-inventory.ts +12 -5
- package/src/note-store.ts +42 -28
- package/src/notes.ts +20 -11
- package/src/paging.ts +28 -16
- package/src/recovery-inputs.ts +6 -19
- package/src/recovery-note-touches.ts +22 -13
- package/src/recovery-notes.ts +1 -1
- package/src/recovery.ts +11 -28
- package/src/reminders.ts +117 -86
- package/src/reset-requests.ts +22 -7
- package/src/retained-tail.ts +140 -0
- package/src/retention.ts +22 -25
- package/src/rollover.ts +68 -81
- package/src/session-lines.ts +1 -0
- package/src/ui/display.ts +66 -94
- package/ui.ts +3 -2
package/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { policySnapshot } from "./src/budget.ts";
|
|
|
14
14
|
import type { CompactionPolicy, PolicyReader } from "./src/contracts.ts";
|
|
15
15
|
import { createWindowGuidance } from "./src/guidance.ts";
|
|
16
16
|
import { registerHistory } from "./src/history.ts";
|
|
17
|
+
import { toolFacts } from "./src/host.ts";
|
|
17
18
|
import { safeJsonStringify } from "./src/message-text.ts";
|
|
18
19
|
import { registerNotes } from "./src/notes.ts";
|
|
19
20
|
import { createNotesStore } from "./src/note-store.ts";
|
|
@@ -22,9 +23,8 @@ import { PageReservations } from "./src/paging.ts";
|
|
|
22
23
|
import { registerRollover } from "./src/rollover.ts";
|
|
23
24
|
|
|
24
25
|
function activeToolTokens(pi: ExtensionAPI): number {
|
|
25
|
-
const active =
|
|
26
|
-
return
|
|
27
|
-
.getAllTools()
|
|
26
|
+
const { active, tools } = toolFacts(pi);
|
|
27
|
+
return tools
|
|
28
28
|
.filter((tool) => active.has(tool.name))
|
|
29
29
|
.reduce(
|
|
30
30
|
(total, tool) =>
|
package/package.json
CHANGED
package/src/boundary.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTO_HANDOFF_PREFIX,
|
|
3
|
+
POSTHORSE_COMPACTION,
|
|
4
|
+
isRecord,
|
|
5
|
+
type EntryLike,
|
|
6
|
+
type ProjectedEntry,
|
|
7
|
+
} from "./contracts.ts";
|
|
8
|
+
|
|
9
|
+
export type BoundaryKind = "fork" | "native" | "automatic" | "explicit" | "posthorse";
|
|
10
|
+
/** Everything the app derives from one persisted context boundary, decided in one place. */
|
|
11
|
+
export type Boundary = {
|
|
12
|
+
readonly kind: BoundaryKind;
|
|
13
|
+
/** Counts as a Posthorse window: labels, reminder windows, and note provenance. */
|
|
14
|
+
readonly window: boolean;
|
|
15
|
+
/** An automatic recovery record that walks through earlier windows continue past. */
|
|
16
|
+
readonly chainsAncestry: boolean;
|
|
17
|
+
/** Starts a new guidance epoch; a provider summary block keeps the previous one. */
|
|
18
|
+
readonly freshGuidance: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function windowHandoff(entry: EntryLike | undefined): string | undefined {
|
|
22
|
+
return entry?.type === "compaction" ? entry.summary : entry?.handoff;
|
|
23
|
+
}
|
|
24
|
+
/** An automatic recovery record opens with this prefix; an earlier window's walk may continue past it. */
|
|
25
|
+
function automaticHandoff(entry: EntryLike): boolean {
|
|
26
|
+
return windowHandoff(entry)?.trim().startsWith(AUTO_HANDOFF_PREFIX) === true;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A compaction with a provider summary block carries forward the previous window's leading system
|
|
30
|
+
* message unchanged, so its guidance section must stay locked to that earlier epoch too.
|
|
31
|
+
*/
|
|
32
|
+
function providerSummaryBlock(entry: EntryLike): boolean {
|
|
33
|
+
return (
|
|
34
|
+
entry.type === "compaction" &&
|
|
35
|
+
isRecord(entry.providerSummary) &&
|
|
36
|
+
isRecord(entry.providerSummary.block)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
function compactionKind(details: Readonly<Record<string, unknown>>): BoundaryKind {
|
|
40
|
+
if (details.reason === "threshold" || details.reason === "overflow") {
|
|
41
|
+
return "automatic";
|
|
42
|
+
}
|
|
43
|
+
return details.reason === "explicit" ? "explicit" : "posthorse";
|
|
44
|
+
}
|
|
45
|
+
export function classifyBoundary(entry?: EntryLike): Boundary | undefined {
|
|
46
|
+
if (entry?.type === "context_window") {
|
|
47
|
+
return {
|
|
48
|
+
kind: "fork",
|
|
49
|
+
window: true,
|
|
50
|
+
chainsAncestry: automaticHandoff(entry),
|
|
51
|
+
freshGuidance: true,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (entry?.type !== "compaction") {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const details = isRecord(entry.details) ? entry.details : undefined;
|
|
58
|
+
const freshGuidance = !providerSummaryBlock(entry);
|
|
59
|
+
if (details?.posthorse !== POSTHORSE_COMPACTION) {
|
|
60
|
+
return { kind: "native", window: false, chainsAncestry: false, freshGuidance };
|
|
61
|
+
}
|
|
62
|
+
const kind = compactionKind(details);
|
|
63
|
+
return {
|
|
64
|
+
kind,
|
|
65
|
+
window: true,
|
|
66
|
+
chainsAncestry: kind === "automatic" && automaticHandoff(entry),
|
|
67
|
+
freshGuidance,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function isWindow(entry: EntryLike): boolean {
|
|
71
|
+
return classifyBoundary(entry)?.window === true;
|
|
72
|
+
}
|
|
73
|
+
export function startsFreshGuidanceEpoch(entry: EntryLike): boolean {
|
|
74
|
+
return classifyBoundary(entry)?.freshGuidance === true;
|
|
75
|
+
}
|
|
76
|
+
export function chainsAncestry(entry?: EntryLike): boolean {
|
|
77
|
+
return classifyBoundary(entry)?.chainsAncestry === true;
|
|
78
|
+
}
|
|
79
|
+
/** The boundary already applied to a projection: a compaction with a visible tail. -1 when none. */
|
|
80
|
+
export function activeBoundaryIndex(projected: readonly ProjectedEntry[]): number {
|
|
81
|
+
return projected.findIndex(
|
|
82
|
+
(entry) => entry.sourceEntry.type === "compaction" && entry.messages.length > 0,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
/** First active branch entry, including tails retained by Pi's own /compact. */
|
|
86
|
+
export function contextStart(entries: readonly EntryLike[]): {
|
|
87
|
+
start: number;
|
|
88
|
+
boundary?: EntryLike;
|
|
89
|
+
} {
|
|
90
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
91
|
+
const entry = entries[i];
|
|
92
|
+
if (entry?.type === "context_window") {
|
|
93
|
+
return { start: i + 1, boundary: entry };
|
|
94
|
+
}
|
|
95
|
+
if (entry?.type === "compaction") {
|
|
96
|
+
const kept = entries.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
|
|
97
|
+
return { start: kept >= 0 ? kept : i + 1, boundary: entry };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { start: 0 };
|
|
101
|
+
}
|
package/src/budget.ts
CHANGED
|
@@ -24,11 +24,18 @@ export const ALL_GUIDANCE_TOOLS: GuidanceTools = {
|
|
|
24
24
|
export type Budget = {
|
|
25
25
|
readonly contextWindow: number;
|
|
26
26
|
readonly reserveTokens: number;
|
|
27
|
-
|
|
27
|
+
/** One classification of how Posthorse may use this capacity. */
|
|
28
|
+
readonly state: BudgetState;
|
|
28
29
|
readonly usable: number;
|
|
29
30
|
readonly rolloverAt: number;
|
|
30
|
-
readonly supported: boolean;
|
|
31
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
|
+
}
|
|
32
39
|
/** A synchronous evaluation owns one snapshot; never carry it across an await. */
|
|
33
40
|
export function policySnapshot(
|
|
34
41
|
ctx: ExtensionContext,
|
|
@@ -73,10 +80,9 @@ export function budgetFor(
|
|
|
73
80
|
return {
|
|
74
81
|
contextWindow,
|
|
75
82
|
reserveTokens,
|
|
76
|
-
enabled,
|
|
83
|
+
state: budgetState(enabled, usable),
|
|
77
84
|
usable,
|
|
78
85
|
rolloverAt: usable + 1,
|
|
79
|
-
supported: !enabled || usable >= MIN_USABLE_TOKENS,
|
|
80
86
|
};
|
|
81
87
|
}
|
|
82
88
|
export function budgetGuidanceKey(ctx: PolicyContext): string {
|
|
@@ -84,9 +90,9 @@ export function budgetGuidanceKey(ctx: PolicyContext): string {
|
|
|
84
90
|
if (budget === undefined) {
|
|
85
91
|
return ctx.getCompactionSettings().enabled ? "1:unknown" : "0:unknown";
|
|
86
92
|
}
|
|
87
|
-
return budget.
|
|
88
|
-
? `
|
|
89
|
-
: `
|
|
93
|
+
return budget.state === "disabled"
|
|
94
|
+
? `0:${budget.contextWindow}`
|
|
95
|
+
: `1:${budget.contextWindow}:${budget.reserveTokens}`;
|
|
90
96
|
}
|
|
91
97
|
export function unsupportedMessage(budget: Budget, newContextAvailable = true): string {
|
|
92
98
|
const n = (value: number) => value.toLocaleString("en-US");
|
|
@@ -104,7 +110,7 @@ function automaticGuidance(
|
|
|
104
110
|
const reset = tools.newContext ? " new_context remains available." : "";
|
|
105
111
|
return `Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder.${reset}`;
|
|
106
112
|
}
|
|
107
|
-
if (budget
|
|
113
|
+
if (budget?.state === "unsupported") {
|
|
108
114
|
return unsupportedMessage(budget, tools.newContext);
|
|
109
115
|
}
|
|
110
116
|
const line =
|
|
@@ -120,7 +126,8 @@ export function buildGuidance(
|
|
|
120
126
|
tools: GuidanceTools = ALL_GUIDANCE_TOOLS,
|
|
121
127
|
): string {
|
|
122
128
|
const budget = budgetFor(ctx);
|
|
123
|
-
const enabled =
|
|
129
|
+
const enabled =
|
|
130
|
+
budget === undefined ? ctx.getCompactionSettings().enabled : budget.state !== "disabled";
|
|
124
131
|
const capacity =
|
|
125
132
|
budget === undefined
|
|
126
133
|
? "Configured context capacity is unknown."
|
|
@@ -148,20 +155,29 @@ function guidanceTokenCount(guidance: string): number {
|
|
|
148
155
|
export function guidanceTokens(ctx: PolicyContext): number {
|
|
149
156
|
return guidanceTokenCount(guidanceFor(ctx));
|
|
150
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";
|
|
151
163
|
/** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
|
|
152
164
|
export function freshPayloadChars(
|
|
153
165
|
ctx: PolicyContext,
|
|
154
166
|
toolTokens: number,
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
options: {
|
|
168
|
+
readonly pendingMessages?: readonly MessageLike[] | undefined;
|
|
169
|
+
readonly cap?: number | undefined;
|
|
170
|
+
readonly guidance?: GuidanceReservation | undefined;
|
|
171
|
+
} = {},
|
|
157
172
|
): number {
|
|
173
|
+
const { pendingMessages = [], cap = MAX_HANDOFF_CHARS, guidance = "rebuild" } = options;
|
|
158
174
|
const budget = budgetFor(ctx);
|
|
159
175
|
if (budget === undefined) {
|
|
160
176
|
return cap;
|
|
161
177
|
}
|
|
162
|
-
const line = budget.
|
|
178
|
+
const line = budget.state === "enabled" ? budget.rolloverAt : budget.contextWindow;
|
|
163
179
|
const reservedGuidance =
|
|
164
|
-
|
|
180
|
+
guidance === "rebuild" ? guidanceTokenCount(buildGuidance(ctx)) : guidanceTokens(ctx);
|
|
165
181
|
const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4) + reservedGuidance;
|
|
166
182
|
const pendingTokens = pendingMessages.reduce(
|
|
167
183
|
(total, message) =>
|
|
@@ -181,10 +197,13 @@ export function freshPayloadChars(
|
|
|
181
197
|
export function pageCapacity(ctx: PolicyContext, toolTokens: () => number): number {
|
|
182
198
|
const usage = ctx.getContextUsage();
|
|
183
199
|
if (usage === undefined || usage.tokens === null) {
|
|
184
|
-
return
|
|
200
|
+
return (
|
|
201
|
+
freshPayloadChars(ctx, toolTokens(), { cap: MAX_PAGE_CHARS, guidance: "effective" }) /
|
|
202
|
+
4 +
|
|
203
|
+
PAGE_MARGIN_TOKENS
|
|
204
|
+
);
|
|
185
205
|
}
|
|
186
206
|
const budget = budgetFor(ctx, usage.contextWindow);
|
|
187
|
-
const line =
|
|
188
|
-
budget?.enabled === true && budget.supported ? budget.rolloverAt : usage.contextWindow;
|
|
207
|
+
const line = budget?.state === "enabled" ? budget.rolloverAt : usage.contextWindow;
|
|
189
208
|
return line - usage.tokens - guidanceTokens(ctx);
|
|
190
209
|
}
|
package/src/context-tools.ts
CHANGED
|
@@ -12,10 +12,7 @@ import {
|
|
|
12
12
|
} from "./contracts.ts";
|
|
13
13
|
|
|
14
14
|
function rolloverStatus(budget: Budget | undefined): "disabled" | "enabled" | "unsupported" {
|
|
15
|
-
|
|
16
|
-
return "disabled";
|
|
17
|
-
}
|
|
18
|
-
return budget.supported ? "enabled" : "unsupported";
|
|
15
|
+
return budget?.state ?? "disabled";
|
|
19
16
|
}
|
|
20
17
|
function contextReport(host: PolicyContext, newContextAvailable: boolean): TextResult {
|
|
21
18
|
const usage = host.getContextUsage();
|
|
@@ -34,14 +31,14 @@ function contextReport(host: PolicyContext, newContextAvailable: boolean): TextR
|
|
|
34
31
|
rolloverAt: budget?.rolloverAt,
|
|
35
32
|
};
|
|
36
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.`;
|
|
37
|
-
if (budget
|
|
34
|
+
if (budget === undefined || budget.state === "disabled") {
|
|
38
35
|
return textResult(
|
|
39
36
|
`Automatic rollover is disabled in the available settings (Pi compaction.enabled=false). ${configured}`,
|
|
40
37
|
[],
|
|
41
38
|
display,
|
|
42
39
|
);
|
|
43
40
|
}
|
|
44
|
-
if (
|
|
41
|
+
if (budget.state === "unsupported") {
|
|
45
42
|
return textResult(
|
|
46
43
|
`${unsupportedMessage(budget, newContextAvailable)} ${configured}`,
|
|
47
44
|
[],
|
package/src/contracts.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { PosthorseDisplay } from "
|
|
2
|
+
import type { PosthorseDisplay } from "./ui/display.ts";
|
|
3
3
|
|
|
4
4
|
export { isRecord } from "./records.ts";
|
|
5
5
|
|
|
@@ -40,6 +40,8 @@ export type ToolCallLike = {
|
|
|
40
40
|
};
|
|
41
41
|
export type MessageLike = {
|
|
42
42
|
readonly role?: string | undefined;
|
|
43
|
+
readonly timestamp?: number | undefined;
|
|
44
|
+
readonly sections?: unknown;
|
|
43
45
|
readonly stopReason?: string | undefined;
|
|
44
46
|
readonly errorMessage?: string | undefined;
|
|
45
47
|
readonly content?: unknown;
|
|
@@ -112,12 +114,58 @@ export const REMINDER_TYPE = "posthorse-reminder";
|
|
|
112
114
|
export const AUTO_HANDOFF_PREFIX = "Automatic context rollover recovery record.";
|
|
113
115
|
export const EMPTY_HANDOFF = "Fresh context. Restore relevant notes and history before continuing.";
|
|
114
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Journal wording is a storage format: recovery reads sessions back, across releases, by
|
|
119
|
+
* matching this exact text. Producers and parsers share one vocabulary, so a wording change
|
|
120
|
+
* is a migration and must keep reading the previous release's journals.
|
|
121
|
+
*/
|
|
122
|
+
export const BOUNDARY_TYPE = "posthorse-boundary";
|
|
123
|
+
export const RESET_DEFERRED_TYPE = "posthorse-reset-deferred";
|
|
124
|
+
export const RETAINED_TAIL_TYPE = "posthorse-retained-tail";
|
|
125
|
+
export const RETAINED_MARKER_PREFIX = "Context rolled over after entry ";
|
|
126
|
+
/** Display marker the legacy fork's context-window extension projects into messages. */
|
|
127
|
+
export const LEGACY_WINDOW_TYPE = "context-window";
|
|
128
|
+
/** The details marker every compaction Posthorse drafts carries; readers reject other values. */
|
|
129
|
+
export const POSTHORSE_COMPACTION = 1;
|
|
130
|
+
|
|
131
|
+
export function posthorseDetails(reason: string): {
|
|
132
|
+
readonly posthorse: typeof POSTHORSE_COMPACTION;
|
|
133
|
+
readonly reason: string;
|
|
134
|
+
} {
|
|
135
|
+
return { posthorse: POSTHORSE_COMPACTION, reason };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Receipt lines the notes tool returns; recovery matches them to record note touches. */
|
|
139
|
+
export const NOTE_RECEIPT = {
|
|
140
|
+
wrote: "Wrote ",
|
|
141
|
+
appended: "Appended to ",
|
|
142
|
+
file: "File: ",
|
|
143
|
+
directory: "Notes directory: ",
|
|
144
|
+
} as const;
|
|
145
|
+
|
|
146
|
+
export function noteWriteReceipt(path: string): string {
|
|
147
|
+
return `${NOTE_RECEIPT.wrote}${path}`;
|
|
148
|
+
}
|
|
149
|
+
export function noteAppendReceipt(path: string): string {
|
|
150
|
+
return `${NOTE_RECEIPT.appended}${path}`;
|
|
151
|
+
}
|
|
152
|
+
export function noteReadHeader(path: string): string {
|
|
153
|
+
return `${NOTE_RECEIPT.file}${path}\n`;
|
|
154
|
+
}
|
|
155
|
+
export function notesDirectoryHeader(directory: string): string {
|
|
156
|
+
return `${NOTE_RECEIPT.directory}${directory}\n`;
|
|
157
|
+
}
|
|
158
|
+
|
|
115
159
|
export function unknownArray(value: unknown): readonly unknown[] | undefined {
|
|
116
160
|
return Array.isArray(value) ? value : undefined;
|
|
117
161
|
}
|
|
118
162
|
export function optionalString(value: unknown): string | undefined {
|
|
119
163
|
return typeof value === "string" ? value : undefined;
|
|
120
164
|
}
|
|
165
|
+
/** Some models send every optional argument; an empty string means the argument was omitted. */
|
|
166
|
+
export function presentText(value: unknown): string | undefined {
|
|
167
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
168
|
+
}
|
|
121
169
|
export function requireValue(value: string | undefined, name: string, op: string): string {
|
|
122
170
|
if (value === undefined || value === "") {
|
|
123
171
|
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/files.ts
CHANGED
|
@@ -86,6 +86,12 @@ async function existingNote(target: string) {
|
|
|
86
86
|
}
|
|
87
87
|
return previous;
|
|
88
88
|
}
|
|
89
|
+
/** Reserved by publishFile; inventories hide these names without deleting their contents. */
|
|
90
|
+
export function isPublicationTemporaryName(name: string): boolean {
|
|
91
|
+
return /^\.posthorse-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/.test(
|
|
92
|
+
name,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
89
95
|
/** Same-directory replacement; pre-rename failures preserve the old note and its metadata. */
|
|
90
96
|
export async function publishFile(
|
|
91
97
|
path: string,
|
package/src/guidance.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type, type Static } from "typebox";
|
|
3
3
|
import { Check } from "typebox/value";
|
|
4
4
|
import {
|
|
@@ -7,7 +7,16 @@ import {
|
|
|
7
7
|
buildGuidance,
|
|
8
8
|
type GuidanceTools,
|
|
9
9
|
} from "./budget.ts";
|
|
10
|
-
import {
|
|
10
|
+
import type { EntryLike, PolicyContext, SnapshotReader } from "./contracts.ts";
|
|
11
|
+
import { startsFreshGuidanceEpoch } from "./boundary.ts";
|
|
12
|
+
import { createEpochCache, type EpochValue, type SessionContext } from "./epoch-cache.ts";
|
|
13
|
+
import {
|
|
14
|
+
sessionEntry,
|
|
15
|
+
toolFacts,
|
|
16
|
+
type SessionHost,
|
|
17
|
+
type SessionIdentity,
|
|
18
|
+
type ToolRegistry,
|
|
19
|
+
} from "./host.ts";
|
|
11
20
|
|
|
12
21
|
export const WINDOW_GUIDANCE_TYPE = "posthorse-window-guidance";
|
|
13
22
|
const guidanceRecordSchema = Type.Object({
|
|
@@ -24,74 +33,25 @@ type MemoryEpoch = {
|
|
|
24
33
|
readonly byBudget: Map<string, string>;
|
|
25
34
|
tools: GuidanceTools | undefined;
|
|
26
35
|
};
|
|
27
|
-
type
|
|
28
|
-
readonly manager: ExtensionContext["sessionManager"];
|
|
29
|
-
readonly sessionId: string;
|
|
30
|
-
readonly file: string | undefined;
|
|
31
|
-
readonly leaf: EntryLike | undefined;
|
|
36
|
+
type GuidanceEpoch = {
|
|
32
37
|
readonly anchorKey: string;
|
|
33
38
|
readonly byBudget: ReadonlyMap<string, string>;
|
|
34
39
|
readonly tools: GuidanceTools | undefined;
|
|
35
40
|
};
|
|
36
41
|
export type WindowGuidance = {
|
|
37
|
-
|
|
38
|
-
readonly
|
|
42
|
+
/** Only the session manager is read; the same context the epoch cache accepts. */
|
|
43
|
+
readonly resolve: (ctx: SessionContext, host: PolicyContext) => string;
|
|
44
|
+
readonly deliver: (ctx: SessionContext, host: PolicyContext) => string;
|
|
39
45
|
readonly reset: () => void;
|
|
40
46
|
};
|
|
41
47
|
|
|
42
|
-
export function activeGuidanceTools(pi:
|
|
43
|
-
const
|
|
44
|
-
const unnamespaced = new Set(
|
|
45
|
-
pi
|
|
46
|
-
.getAllTools()
|
|
47
|
-
.filter((tool) => tool.namespace === undefined)
|
|
48
|
-
.map((tool) => tool.name),
|
|
49
|
-
);
|
|
48
|
+
export function activeGuidanceTools(pi: ToolRegistry): GuidanceTools {
|
|
49
|
+
const { activeUnnamespaced } = toolFacts(pi);
|
|
50
50
|
return {
|
|
51
|
-
newContext:
|
|
52
|
-
getContextRemaining:
|
|
53
|
-
active.has("get_context_remaining") && unnamespaced.has("get_context_remaining"),
|
|
51
|
+
newContext: activeUnnamespaced.has("new_context"),
|
|
52
|
+
getContextRemaining: activeUnnamespaced.has("get_context_remaining"),
|
|
54
53
|
};
|
|
55
54
|
}
|
|
56
|
-
/**
|
|
57
|
-
* A compaction with a provider summary block carries forward the previous window's leading system
|
|
58
|
-
* message unchanged, so its guidance section must stay locked to that earlier epoch too.
|
|
59
|
-
*/
|
|
60
|
-
function startsFreshGuidanceEpoch(entry: EntryLike): boolean {
|
|
61
|
-
if (entry.type === "context_window") {
|
|
62
|
-
return true;
|
|
63
|
-
}
|
|
64
|
-
return (
|
|
65
|
-
entry.type === "compaction" &&
|
|
66
|
-
!(isRecord(entry.providerSummary) && isRecord(entry.providerSummary.block))
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
function parentEntry(
|
|
70
|
-
manager: ExtensionContext["sessionManager"],
|
|
71
|
-
id: string | null | undefined,
|
|
72
|
-
): EntryLike | undefined {
|
|
73
|
-
const raw: unknown = manager;
|
|
74
|
-
if (id === undefined || id === null || id === "" || !isRecord(raw)) {
|
|
75
|
-
return undefined;
|
|
76
|
-
}
|
|
77
|
-
return typeof raw.getEntry === "function" ? manager.getEntry(id) : undefined;
|
|
78
|
-
}
|
|
79
|
-
function sessionIdentityOf(manager: ExtensionContext["sessionManager"]): {
|
|
80
|
-
readonly sessionId: string;
|
|
81
|
-
readonly file: string | undefined;
|
|
82
|
-
readonly leaf: EntryLike | undefined;
|
|
83
|
-
} {
|
|
84
|
-
const raw: unknown = manager;
|
|
85
|
-
const sessionId =
|
|
86
|
-
isRecord(raw) && typeof raw.getSessionId === "function" ? manager.getSessionId() : "";
|
|
87
|
-
const file =
|
|
88
|
-
isRecord(raw) && typeof raw.getSessionFile === "function"
|
|
89
|
-
? manager.getSessionFile()
|
|
90
|
-
: undefined;
|
|
91
|
-
const leafId =
|
|
92
|
-
isRecord(raw) && typeof raw.getLeafId === "function" ? manager.getLeafId() : null;
|
|
93
|
-
return { sessionId, file, leaf: parentEntry(manager, leafId) };
|
|
94
|
-
}
|
|
95
55
|
function collectGuidanceEntries(
|
|
96
56
|
entries: readonly EntryLike[],
|
|
97
57
|
baseByBudget?: ReadonlyMap<string, string>,
|
|
@@ -130,27 +90,27 @@ function withMemoryEpoch(
|
|
|
130
90
|
return { byBudget: merged, tools: collected.tools ?? memory.tools };
|
|
131
91
|
}
|
|
132
92
|
function scanGuidanceEpoch(
|
|
133
|
-
manager:
|
|
134
|
-
identity:
|
|
135
|
-
previous:
|
|
93
|
+
manager: SessionHost,
|
|
94
|
+
identity: SessionIdentity,
|
|
95
|
+
previous: EpochValue<GuidanceEpoch> | undefined,
|
|
136
96
|
memoryByEpoch: ReadonlyMap<string, MemoryEpoch> | undefined,
|
|
137
|
-
):
|
|
97
|
+
): GuidanceEpoch {
|
|
138
98
|
const reversed: EntryLike[] = [];
|
|
139
99
|
let anchor: EntryLike | undefined;
|
|
140
100
|
for (
|
|
141
101
|
let entry = identity.leaf;
|
|
142
102
|
entry !== undefined;
|
|
143
|
-
entry =
|
|
103
|
+
entry = sessionEntry(manager, entry.parentId)
|
|
144
104
|
) {
|
|
145
|
-
if (previous !== undefined && previous.leaf === entry) {
|
|
105
|
+
if (previous !== undefined && previous.identity.leaf === entry) {
|
|
146
106
|
const collected = collectGuidanceEntries(
|
|
147
107
|
reversed.reverse(),
|
|
148
|
-
previous.byBudget,
|
|
149
|
-
previous.tools,
|
|
108
|
+
previous.value.byBudget,
|
|
109
|
+
previous.value.tools,
|
|
150
110
|
);
|
|
151
111
|
return {
|
|
152
|
-
anchorKey: previous.anchorKey,
|
|
153
|
-
...withMemoryEpoch(collected, memoryByEpoch?.get(previous.anchorKey)),
|
|
112
|
+
anchorKey: previous.value.anchorKey,
|
|
113
|
+
...withMemoryEpoch(collected, memoryByEpoch?.get(previous.value.anchorKey)),
|
|
154
114
|
};
|
|
155
115
|
}
|
|
156
116
|
if (startsFreshGuidanceEpoch(entry)) {
|
|
@@ -169,43 +129,55 @@ function scanGuidanceEpoch(
|
|
|
169
129
|
...withMemoryEpoch(collected, memoryByEpoch?.get(anchorKey)),
|
|
170
130
|
};
|
|
171
131
|
}
|
|
172
|
-
|
|
173
|
-
|
|
132
|
+
/** The journal and tool-registry members guidance borrows; the host's full API satisfies these. */
|
|
133
|
+
export type GuidanceRegistry = ToolRegistry & Pick<ExtensionAPI, "appendEntry">;
|
|
134
|
+
|
|
135
|
+
export function createWindowGuidance(pi: GuidanceRegistry): WindowGuidance {
|
|
174
136
|
let memoryByManager = new WeakMap<object, Map<string, MemoryEpoch>>();
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
137
|
+
const epochs = createEpochCache<GuidanceEpoch>((ctx, identity, previous) =>
|
|
138
|
+
scanGuidanceEpoch(
|
|
139
|
+
ctx.sessionManager,
|
|
140
|
+
identity,
|
|
141
|
+
previous,
|
|
142
|
+
memoryByManager.get(ctx.sessionManager),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
/**
|
|
146
|
+
* A fresh anchor retires every earlier one on this branch: nothing reads an old epoch again
|
|
147
|
+
* without /tree, which resets all memory. Keep only the current anchor's delivered text.
|
|
148
|
+
*/
|
|
149
|
+
const pruneRetiredEpochs = (manager: object, anchorKey: string): void => {
|
|
150
|
+
const byEpoch = memoryByManager.get(manager);
|
|
151
|
+
if (byEpoch === undefined) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const key of byEpoch.keys()) {
|
|
155
|
+
if (key !== anchorKey) {
|
|
156
|
+
byEpoch.delete(key);
|
|
157
|
+
}
|
|
184
158
|
}
|
|
185
|
-
state = {
|
|
186
|
-
manager,
|
|
187
|
-
sessionId,
|
|
188
|
-
file,
|
|
189
|
-
leaf,
|
|
190
|
-
...scanGuidanceEpoch(
|
|
191
|
-
manager,
|
|
192
|
-
{ sessionId, leaf },
|
|
193
|
-
previous,
|
|
194
|
-
memoryByManager.get(manager),
|
|
195
|
-
),
|
|
196
|
-
};
|
|
197
|
-
return state;
|
|
198
159
|
};
|
|
199
160
|
return {
|
|
200
161
|
resolve(ctx, host) {
|
|
201
|
-
const epoch =
|
|
162
|
+
const epoch = epochs.read(ctx);
|
|
202
163
|
const key = budgetGuidanceKey(host);
|
|
203
164
|
return (
|
|
204
165
|
epoch.byBudget.get(key) ?? buildGuidance(host, epoch.tools ?? ALL_GUIDANCE_TOOLS)
|
|
205
166
|
);
|
|
206
167
|
},
|
|
168
|
+
/**
|
|
169
|
+
* Delivering guidance from inside the request hook must persist before the request: a
|
|
170
|
+
* restart after a rollover with a provider summary block has to reuse these exact bytes —
|
|
171
|
+
* the leading system message is sent unchanged, and in-memory delivery state does not
|
|
172
|
+
* survive. On the fork, the provider-summary request runs this hook mid-compaction. That
|
|
173
|
+
* is safe twice over: the request that reached the rollover threshold already delivered
|
|
174
|
+
* this budget's text, so the summary request reuses it and writes nothing; and a
|
|
175
|
+
* compaction with a summary block keeps the previous guidance anchor, so even a budget
|
|
176
|
+
* change mid-compaction appends inside the epoch the next window still reads.
|
|
177
|
+
*/
|
|
207
178
|
deliver(ctx, host) {
|
|
208
|
-
const epoch =
|
|
179
|
+
const epoch = epochs.read(ctx);
|
|
180
|
+
pruneRetiredEpochs(ctx.sessionManager, epoch.anchorKey);
|
|
209
181
|
const key = budgetGuidanceKey(host);
|
|
210
182
|
const existing = epoch.byBudget.get(key);
|
|
211
183
|
if (existing !== undefined) {
|
|
@@ -225,7 +197,7 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
|
|
|
225
197
|
}
|
|
226
198
|
memory.byBudget.set(key, text);
|
|
227
199
|
memory.tools ??= tools;
|
|
228
|
-
|
|
200
|
+
epochs.reset();
|
|
229
201
|
pi.appendEntry(WINDOW_GUIDANCE_TYPE, {
|
|
230
202
|
version: 1,
|
|
231
203
|
budgetKey: key,
|
|
@@ -235,13 +207,13 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
|
|
|
235
207
|
return text;
|
|
236
208
|
},
|
|
237
209
|
reset() {
|
|
238
|
-
|
|
210
|
+
epochs.reset();
|
|
239
211
|
memoryByManager = new WeakMap();
|
|
240
212
|
},
|
|
241
213
|
};
|
|
242
214
|
}
|
|
243
215
|
export function registerGuidance(
|
|
244
|
-
pi: ExtensionAPI,
|
|
216
|
+
pi: Pick<ExtensionAPI, "on">,
|
|
245
217
|
policy: SnapshotReader,
|
|
246
218
|
windowGuidance: WindowGuidance,
|
|
247
219
|
): void {
|
|
@@ -249,6 +221,7 @@ export function registerGuidance(
|
|
|
249
221
|
pi.on("before_agent_start", (event) => {
|
|
250
222
|
promptOptions = event.systemPromptOptions;
|
|
251
223
|
});
|
|
224
|
+
|
|
252
225
|
pi.on("agent_settled", () => {
|
|
253
226
|
// Prompt options belong to one run, never to later idle context evaluation.
|
|
254
227
|
promptOptions = undefined;
|