pi-posthorse 0.10.24 → 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 +3 -3
- package/package.json +1 -1
- package/src/boundary.ts +95 -0
- package/src/budget.ts +35 -16
- package/src/context-tools.ts +3 -6
- package/src/contracts.ts +4 -0
- package/src/epoch-cache.ts +50 -0
- package/src/guidance.ts +30 -94
- 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-store.ts +36 -25
- package/src/notes.ts +13 -8
- package/src/paging.ts +28 -16
- package/src/recovery-inputs.ts +6 -19
- package/src/recovery-note-touches.ts +2 -2
- 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 +28 -70
- package/src/ui/display.ts +66 -94
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,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
|
@@ -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
|
@@ -118,6 +118,10 @@ export function unknownArray(value: unknown): readonly unknown[] | undefined {
|
|
|
118
118
|
export function optionalString(value: unknown): string | undefined {
|
|
119
119
|
return typeof value === "string" ? value : undefined;
|
|
120
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
|
+
}
|
|
121
125
|
export function requireValue(value: string | undefined, name: string, op: string): string {
|
|
122
126
|
if (value === undefined || value === "") {
|
|
123
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
|
@@ -7,7 +7,10 @@ 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 } from "./epoch-cache.ts";
|
|
13
|
+
import { sessionEntry, toolFacts, type SessionHost, type SessionIdentity } from "./host.ts";
|
|
11
14
|
|
|
12
15
|
export const WINDOW_GUIDANCE_TYPE = "posthorse-window-guidance";
|
|
13
16
|
const guidanceRecordSchema = Type.Object({
|
|
@@ -24,11 +27,7 @@ type MemoryEpoch = {
|
|
|
24
27
|
readonly byBudget: Map<string, string>;
|
|
25
28
|
tools: GuidanceTools | undefined;
|
|
26
29
|
};
|
|
27
|
-
type
|
|
28
|
-
readonly manager: ExtensionContext["sessionManager"];
|
|
29
|
-
readonly sessionId: string;
|
|
30
|
-
readonly file: string | undefined;
|
|
31
|
-
readonly leaf: EntryLike | undefined;
|
|
30
|
+
type GuidanceEpoch = {
|
|
32
31
|
readonly anchorKey: string;
|
|
33
32
|
readonly byBudget: ReadonlyMap<string, string>;
|
|
34
33
|
readonly tools: GuidanceTools | undefined;
|
|
@@ -40,58 +39,12 @@ export type WindowGuidance = {
|
|
|
40
39
|
};
|
|
41
40
|
|
|
42
41
|
export function activeGuidanceTools(pi: ExtensionAPI): GuidanceTools {
|
|
43
|
-
const
|
|
44
|
-
const unnamespaced = new Set(
|
|
45
|
-
pi
|
|
46
|
-
.getAllTools()
|
|
47
|
-
.filter((tool) => tool.namespace === undefined)
|
|
48
|
-
.map((tool) => tool.name),
|
|
49
|
-
);
|
|
42
|
+
const { activeUnnamespaced } = toolFacts(pi);
|
|
50
43
|
return {
|
|
51
|
-
newContext:
|
|
52
|
-
getContextRemaining:
|
|
53
|
-
active.has("get_context_remaining") && unnamespaced.has("get_context_remaining"),
|
|
44
|
+
newContext: activeUnnamespaced.has("new_context"),
|
|
45
|
+
getContextRemaining: activeUnnamespaced.has("get_context_remaining"),
|
|
54
46
|
};
|
|
55
47
|
}
|
|
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
48
|
function collectGuidanceEntries(
|
|
96
49
|
entries: readonly EntryLike[],
|
|
97
50
|
baseByBudget?: ReadonlyMap<string, string>,
|
|
@@ -130,27 +83,27 @@ function withMemoryEpoch(
|
|
|
130
83
|
return { byBudget: merged, tools: collected.tools ?? memory.tools };
|
|
131
84
|
}
|
|
132
85
|
function scanGuidanceEpoch(
|
|
133
|
-
manager:
|
|
134
|
-
identity:
|
|
135
|
-
previous:
|
|
86
|
+
manager: SessionHost,
|
|
87
|
+
identity: SessionIdentity,
|
|
88
|
+
previous: EpochValue<GuidanceEpoch> | undefined,
|
|
136
89
|
memoryByEpoch: ReadonlyMap<string, MemoryEpoch> | undefined,
|
|
137
|
-
):
|
|
90
|
+
): GuidanceEpoch {
|
|
138
91
|
const reversed: EntryLike[] = [];
|
|
139
92
|
let anchor: EntryLike | undefined;
|
|
140
93
|
for (
|
|
141
94
|
let entry = identity.leaf;
|
|
142
95
|
entry !== undefined;
|
|
143
|
-
entry =
|
|
96
|
+
entry = sessionEntry(manager, entry.parentId)
|
|
144
97
|
) {
|
|
145
|
-
if (previous !== undefined && previous.leaf === entry) {
|
|
98
|
+
if (previous !== undefined && previous.identity.leaf === entry) {
|
|
146
99
|
const collected = collectGuidanceEntries(
|
|
147
100
|
reversed.reverse(),
|
|
148
|
-
previous.byBudget,
|
|
149
|
-
previous.tools,
|
|
101
|
+
previous.value.byBudget,
|
|
102
|
+
previous.value.tools,
|
|
150
103
|
);
|
|
151
104
|
return {
|
|
152
|
-
anchorKey: previous.anchorKey,
|
|
153
|
-
...withMemoryEpoch(collected, memoryByEpoch?.get(previous.anchorKey)),
|
|
105
|
+
anchorKey: previous.value.anchorKey,
|
|
106
|
+
...withMemoryEpoch(collected, memoryByEpoch?.get(previous.value.anchorKey)),
|
|
154
107
|
};
|
|
155
108
|
}
|
|
156
109
|
if (startsFreshGuidanceEpoch(entry)) {
|
|
@@ -170,42 +123,25 @@ function scanGuidanceEpoch(
|
|
|
170
123
|
};
|
|
171
124
|
}
|
|
172
125
|
export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
|
|
173
|
-
let state: EpochState | undefined;
|
|
174
126
|
let memoryByManager = new WeakMap<object, Map<string, MemoryEpoch>>();
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return previous;
|
|
184
|
-
}
|
|
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
|
-
};
|
|
127
|
+
const epochs = createEpochCache<GuidanceEpoch>((ctx, identity, previous) =>
|
|
128
|
+
scanGuidanceEpoch(
|
|
129
|
+
ctx.sessionManager,
|
|
130
|
+
identity,
|
|
131
|
+
previous,
|
|
132
|
+
memoryByManager.get(ctx.sessionManager),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
199
135
|
return {
|
|
200
136
|
resolve(ctx, host) {
|
|
201
|
-
const epoch =
|
|
137
|
+
const epoch = epochs.read(ctx);
|
|
202
138
|
const key = budgetGuidanceKey(host);
|
|
203
139
|
return (
|
|
204
140
|
epoch.byBudget.get(key) ?? buildGuidance(host, epoch.tools ?? ALL_GUIDANCE_TOOLS)
|
|
205
141
|
);
|
|
206
142
|
},
|
|
207
143
|
deliver(ctx, host) {
|
|
208
|
-
const epoch =
|
|
144
|
+
const epoch = epochs.read(ctx);
|
|
209
145
|
const key = budgetGuidanceKey(host);
|
|
210
146
|
const existing = epoch.byBudget.get(key);
|
|
211
147
|
if (existing !== undefined) {
|
|
@@ -225,7 +161,7 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
|
|
|
225
161
|
}
|
|
226
162
|
memory.byBudget.set(key, text);
|
|
227
163
|
memory.tools ??= tools;
|
|
228
|
-
|
|
164
|
+
epochs.reset();
|
|
229
165
|
pi.appendEntry(WINDOW_GUIDANCE_TYPE, {
|
|
230
166
|
version: 1,
|
|
231
167
|
budgetKey: key,
|
|
@@ -235,7 +171,7 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
|
|
|
235
171
|
return text;
|
|
236
172
|
},
|
|
237
173
|
reset() {
|
|
238
|
-
|
|
174
|
+
epochs.reset();
|
|
239
175
|
memoryByManager = new WeakMap();
|
|
240
176
|
},
|
|
241
177
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { presentText, unknownArray } from "./contracts.ts";
|
|
2
|
+
|
|
3
|
+
/** Where a search page stopped: the anchor entry, its rank, its offset, and the search it belongs to. */
|
|
4
|
+
export type HistoryCursor = readonly [id: string, priority: 0 | 1, offset: number, key: string];
|
|
5
|
+
|
|
6
|
+
export function encodeCursor(cursor: HistoryCursor): string {
|
|
7
|
+
return Buffer.from(JSON.stringify(cursor)).toString("base64url");
|
|
8
|
+
}
|
|
9
|
+
export function cursorFor(
|
|
10
|
+
key: string,
|
|
11
|
+
hit: { readonly id: string; readonly priority: 0 | 1 },
|
|
12
|
+
offset: number,
|
|
13
|
+
): string {
|
|
14
|
+
return encodeCursor([hit.id, hit.priority, offset, key]);
|
|
15
|
+
}
|
|
16
|
+
function cursorOffset(value: unknown, storedKey: unknown, key: string): number {
|
|
17
|
+
if (
|
|
18
|
+
typeof value !== "number" ||
|
|
19
|
+
!Number.isSafeInteger(value) ||
|
|
20
|
+
value < 0 ||
|
|
21
|
+
storedKey !== key
|
|
22
|
+
) {
|
|
23
|
+
throw new Error("Invalid cursor offset or scope.");
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
/** A cursor is anonymous unless it decodes to the same query and scope that produced it. */
|
|
28
|
+
export function decodeCursor(encoded: string | undefined, key: string): HistoryCursor | undefined {
|
|
29
|
+
const cursor = presentText(encoded);
|
|
30
|
+
if (cursor === undefined) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const value: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString());
|
|
34
|
+
const parts = unknownArray(value);
|
|
35
|
+
if (parts === undefined || parts.length !== 4) {
|
|
36
|
+
throw new Error("Invalid cursor tuple.");
|
|
37
|
+
}
|
|
38
|
+
const [id, priority, offset, storedKey] = parts;
|
|
39
|
+
if (typeof id !== "string" || id === "" || (priority !== 0 && priority !== 1)) {
|
|
40
|
+
throw new Error("Invalid cursor anchor.");
|
|
41
|
+
}
|
|
42
|
+
return [id, priority, cursorOffset(offset, storedKey, key), key];
|
|
43
|
+
}
|
package/src/history-entries.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
isRecord,
|
|
3
2
|
isReminderType,
|
|
4
3
|
unknownArray,
|
|
5
4
|
type EntryLike,
|
|
@@ -7,6 +6,7 @@ import {
|
|
|
7
6
|
type MessageLike,
|
|
8
7
|
type WindowedEntry,
|
|
9
8
|
} from "./contracts.ts";
|
|
9
|
+
import { isWindow } from "./boundary.ts";
|
|
10
10
|
import {
|
|
11
11
|
assistantFailure,
|
|
12
12
|
excerptAround,
|
|
@@ -20,32 +20,6 @@ import {
|
|
|
20
20
|
toolIdentity,
|
|
21
21
|
} from "./message-text.ts";
|
|
22
22
|
|
|
23
|
-
export function isWindow(entry: EntryLike): boolean {
|
|
24
|
-
return (
|
|
25
|
-
entry.type === "context_window" ||
|
|
26
|
-
(entry.type === "compaction" && isRecord(entry.details) && entry.details.posthorse === 1)
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
export function windowHandoff(entry: EntryLike | undefined): string | undefined {
|
|
30
|
-
return entry?.type === "compaction" ? entry.summary : entry?.handoff;
|
|
31
|
-
}
|
|
32
|
-
/** First active branch entry, including tails retained by Pi's own /compact. */
|
|
33
|
-
export function contextStart(entries: readonly EntryLike[]): {
|
|
34
|
-
start: number;
|
|
35
|
-
boundary?: EntryLike;
|
|
36
|
-
} {
|
|
37
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
38
|
-
const entry = entries[i];
|
|
39
|
-
if (entry?.type === "context_window") {
|
|
40
|
-
return { start: i + 1, boundary: entry };
|
|
41
|
-
}
|
|
42
|
-
if (entry?.type === "compaction") {
|
|
43
|
-
const kept = entries.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
|
|
44
|
-
return { start: kept >= 0 ? kept : i + 1, boundary: entry };
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return { start: 0 };
|
|
48
|
-
}
|
|
49
23
|
export function contextEdits(entries: readonly EntryLike[]): Map<string, EntryLike> {
|
|
50
24
|
return new Map(
|
|
51
25
|
entries.flatMap((entry) =>
|
package/src/history-pages.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type WindowedEntry,
|
|
8
8
|
} from "./contracts.ts";
|
|
9
9
|
import { excerpt, shortKey, sourceTag } from "./message-text.ts";
|
|
10
|
+
import { cursorFor as cursorAt } from "./history-cursor.ts";
|
|
10
11
|
import type { HistoryMatches, HistoryParams } from "./history-search.ts";
|
|
11
12
|
import type { PageAccess } from "./paging.ts";
|
|
12
13
|
|
|
@@ -18,11 +19,6 @@ type SearchPage = {
|
|
|
18
19
|
readonly spans: readonly { readonly headerLength: number; readonly length: number }[];
|
|
19
20
|
readonly continuation: Continuation<string>;
|
|
20
21
|
};
|
|
21
|
-
function cursorFor(matches: HistoryMatches, hit: HistoryHit, offset: number): string {
|
|
22
|
-
return Buffer.from(JSON.stringify([hit.id, hit.priority, offset, matches.key])).toString(
|
|
23
|
-
"base64url",
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
22
|
function searchFooter(next: string): string {
|
|
27
23
|
return `\n[More results; continue with cursor "${next}" and the same query/scope.]`;
|
|
28
24
|
}
|
|
@@ -70,7 +66,7 @@ function searchParts(matches: HistoryMatches, chars: number): SearchPage {
|
|
|
70
66
|
available -= fragment.part.length + 1;
|
|
71
67
|
continuation =
|
|
72
68
|
fragment.end < hit.text.length || parts.length < matches.hits.length
|
|
73
|
-
? { kind: "more", next:
|
|
69
|
+
? { kind: "more", next: cursorAt(matches.key, hit, fragment.end) }
|
|
74
70
|
: { kind: "complete" };
|
|
75
71
|
if (available <= 0 || parts.length >= matches.limit) {
|
|
76
72
|
break;
|
|
@@ -87,7 +83,9 @@ export function searchPage(
|
|
|
87
83
|
const chars = pages.size(host);
|
|
88
84
|
const reserve = Math.max(
|
|
89
85
|
0,
|
|
90
|
-
...matches.hits.map(
|
|
86
|
+
...matches.hits.map(
|
|
87
|
+
(hit) => searchFooter(cursorAt(matches.key, hit, hit.text.length)).length,
|
|
88
|
+
),
|
|
91
89
|
);
|
|
92
90
|
if (reserve >= chars) {
|
|
93
91
|
pages.error(host, "History entry id is too large for pagination.");
|