pi-posthorse 0.10.21 → 0.10.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/index.ts +6 -1
- package/package.json +1 -1
- package/src/budget.ts +62 -14
- package/src/context-tools.ts +8 -3
- package/src/contracts.ts +2 -0
- package/src/guidance.ts +247 -5
- package/src/note-provenance.ts +14 -2
- package/src/note-scopes.ts +30 -0
- package/src/note-store.ts +18 -5
- package/src/notes.ts +43 -36
- package/src/provider-summary.ts +53 -0
- package/src/recovery-note-touches.ts +14 -1
- package/src/recovery-notes.ts +43 -15
- package/src/recovery.ts +15 -1
- package/src/retention.ts +63 -8
- package/src/rollover.ts +25 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Posthorse
|
|
2
2
|
|
|
3
|
-
Posthorse starts fresh context windows for long [Pi](https://github.com/earendil-works/pi) tasks without an automatic model-generated summary. Stock Pi already preserves the session journal and retains a recent tail during compaction; Posthorse adds explicit notes and history retrieval, with a handoff or bounded recovery record instead of the automatic summary.
|
|
3
|
+
Posthorse starts fresh context windows for long [Pi](https://github.com/earendil-works/pi) tasks without an automatic model-generated summary of the work. Stock Pi already preserves the session journal and retains a recent tail during compaction; Posthorse adds explicit notes and history retrieval, with a handoff or bounded recovery record instead of the automatic summary.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
@@ -25,7 +25,7 @@ Give Pi your task. For example:
|
|
|
25
25
|
|
|
26
26
|
Posthorse can send a routine task-note reminder before rollover. The reminder tells the model to save current task state if needed, then immediately continue the prescribed work. The context window is not a task deadline. The reminder does not require `new_context` or wake an agent after its final answer. Large turns can reach rollover before the reminder. See [Reminder timing](docs/reminder-timing.md) for the trigger and its relationship to Pi's reserve and retention settings.
|
|
27
27
|
|
|
28
|
-
Automatic rollover retains a safe recent-message tail using Pi's `compaction.keepRecentTokens`, alongside a bounded recovery record for discarded inputs and tool evidence.
|
|
28
|
+
Automatic rollover retains a safe recent-message tail using Pi's `compaction.keepRecentTokens`, alongside a bounded recovery record for discarded inputs and tool evidence. No model summarizes the work. The model must restore notes and check live state before it continues. On [fitchmultz/pi](https://github.com/fitchmultz/pi), a Claude model also writes one line that points to the task note, so that Claude keeps [the reasoning of the retained replies](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/context-recovery.md#claude-reasoning-in-the-retained-tail). Optional `new_context` deliberately retains no prior messages; manual `/compact` uses Pi's normal summarization.
|
|
29
29
|
|
|
30
30
|
Posthorse does not schedule rollover when a provider cache expires. An idle session below the
|
|
31
31
|
rollover threshold keeps its context; use an explicit `new_context` when a deliberate clean reset
|
package/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ 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";
|
|
16
17
|
import { safeJsonStringify } from "./src/message-text.ts";
|
|
17
18
|
import { registerNotes } from "./src/notes.ts";
|
|
@@ -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/budget.ts
CHANGED
|
@@ -13,6 +13,14 @@ 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;
|
|
@@ -26,11 +34,13 @@ export function policySnapshot(
|
|
|
26
34
|
ctx: ExtensionContext,
|
|
27
35
|
getPolicy: PolicyReader,
|
|
28
36
|
livePolicy?: CompactionPolicy,
|
|
37
|
+
resolveGuidance?: (ctx: ExtensionContext, host: PolicyContext) => string,
|
|
29
38
|
): PolicyContext {
|
|
30
39
|
let usageRead = false;
|
|
31
40
|
let usage: ContextUsage | undefined;
|
|
32
41
|
let settings: CompactionPolicy | undefined;
|
|
33
|
-
|
|
42
|
+
let guidance: string | undefined;
|
|
43
|
+
const host: PolicyContext = {
|
|
34
44
|
getCompactionSettings: () => {
|
|
35
45
|
settings ??= { ...(livePolicy ?? getPolicy(ctx)) };
|
|
36
46
|
return settings;
|
|
@@ -43,7 +53,13 @@ export function policySnapshot(
|
|
|
43
53
|
return usage;
|
|
44
54
|
},
|
|
45
55
|
getSystemPrompt: () => ctx.getSystemPrompt(),
|
|
56
|
+
getGuidance: () => {
|
|
57
|
+
guidance ??=
|
|
58
|
+
resolveGuidance !== undefined ? resolveGuidance(ctx, host) : buildGuidance(host);
|
|
59
|
+
return guidance;
|
|
60
|
+
},
|
|
46
61
|
};
|
|
62
|
+
return host;
|
|
47
63
|
}
|
|
48
64
|
export function budgetFor(
|
|
49
65
|
ctx: PolicyContext,
|
|
@@ -63,45 +79,75 @@ export function budgetFor(
|
|
|
63
79
|
supported: !enabled || usable >= MIN_USABLE_TOKENS,
|
|
64
80
|
};
|
|
65
81
|
}
|
|
66
|
-
export function
|
|
82
|
+
export function budgetGuidanceKey(ctx: PolicyContext): string {
|
|
83
|
+
const budget = budgetFor(ctx);
|
|
84
|
+
if (budget === undefined) {
|
|
85
|
+
return ctx.getCompactionSettings().enabled ? "1:unknown" : "0:unknown";
|
|
86
|
+
}
|
|
87
|
+
return budget.enabled
|
|
88
|
+
? `1:${budget.contextWindow}:${budget.reserveTokens}`
|
|
89
|
+
: `0:${budget.contextWindow}`;
|
|
90
|
+
}
|
|
91
|
+
export function unsupportedMessage(budget: Budget, newContextAvailable = true): string {
|
|
67
92
|
const n = (value: number) => value.toLocaleString("en-US");
|
|
68
|
-
|
|
93
|
+
const reset = newContextAvailable
|
|
94
|
+
? " new_context remains available with a model-aware handoff limit."
|
|
95
|
+
: "";
|
|
96
|
+
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
97
|
}
|
|
70
|
-
function automaticGuidance(
|
|
98
|
+
function automaticGuidance(
|
|
99
|
+
budget: Budget | undefined,
|
|
100
|
+
enabled: boolean,
|
|
101
|
+
tools: GuidanceTools,
|
|
102
|
+
): string {
|
|
71
103
|
if (!enabled) {
|
|
72
|
-
|
|
104
|
+
const reset = tools.newContext ? " new_context remains available." : "";
|
|
105
|
+
return `Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder.${reset}`;
|
|
73
106
|
}
|
|
74
107
|
if (budget !== undefined && !budget.supported) {
|
|
75
|
-
return unsupportedMessage(budget);
|
|
108
|
+
return unsupportedMessage(budget, tools.newContext);
|
|
76
109
|
}
|
|
77
110
|
const line =
|
|
78
111
|
budget === undefined
|
|
79
112
|
? ""
|
|
80
113
|
: `The rollover line is ${budget.rolloverAt.toLocaleString("en-US")} tokens used. `;
|
|
114
|
+
const reset = tools.newContext ? " new_context is an optional clean reset." : "";
|
|
81
115
|
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
|
|
116
|
+
${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
117
|
}
|
|
84
|
-
export function buildGuidance(
|
|
118
|
+
export function buildGuidance(
|
|
119
|
+
ctx: PolicyContext,
|
|
120
|
+
tools: GuidanceTools = ALL_GUIDANCE_TOOLS,
|
|
121
|
+
): string {
|
|
85
122
|
const budget = budgetFor(ctx);
|
|
86
123
|
const enabled = budget?.enabled ?? ctx.getCompactionSettings().enabled;
|
|
87
124
|
const capacity =
|
|
88
125
|
budget === undefined
|
|
89
126
|
? "Configured context capacity is unknown."
|
|
90
127
|
: `Configured context capacity: ${budget.contextWindow.toLocaleString("en-US")} tokens (not remaining space).`;
|
|
128
|
+
const checkRemaining = tools.getContextRemaining
|
|
129
|
+
? " Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining."
|
|
130
|
+
: "";
|
|
91
131
|
return `## Automatic context management (Posthorse)
|
|
92
|
-
${automaticGuidance(budget, enabled)}
|
|
93
|
-
${capacity} Do not guess capacity or remaining space from response-token or reasoning limits
|
|
132
|
+
${automaticGuidance(budget, enabled, tools)}
|
|
133
|
+
${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
134
|
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
135
|
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
136
|
}
|
|
97
|
-
|
|
98
|
-
|
|
137
|
+
export function guidanceFor(ctx: PolicyContext): string {
|
|
138
|
+
return ctx.getGuidance !== undefined ? ctx.getGuidance() : buildGuidance(ctx);
|
|
139
|
+
}
|
|
140
|
+
function guidanceTokenCount(guidance: string): number {
|
|
99
141
|
return estimateTokens({
|
|
100
142
|
role: "system",
|
|
101
|
-
content: `<posthorse>\n${
|
|
143
|
+
content: `<posthorse>\n${guidance}\n</posthorse>`,
|
|
102
144
|
timestamp: 0,
|
|
103
145
|
});
|
|
104
146
|
}
|
|
147
|
+
/** ponytail: native estimates omit request-local guidance; reserve it until post-transform usage is exposed. */
|
|
148
|
+
export function guidanceTokens(ctx: PolicyContext): number {
|
|
149
|
+
return guidanceTokenCount(guidanceFor(ctx));
|
|
150
|
+
}
|
|
105
151
|
/** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
|
|
106
152
|
export function freshPayloadChars(
|
|
107
153
|
ctx: PolicyContext,
|
|
@@ -114,7 +160,9 @@ export function freshPayloadChars(
|
|
|
114
160
|
return cap;
|
|
115
161
|
}
|
|
116
162
|
const line = budget.enabled && budget.supported ? budget.rolloverAt : budget.contextWindow;
|
|
117
|
-
const
|
|
163
|
+
const reservedGuidance =
|
|
164
|
+
cap === MAX_HANDOFF_CHARS ? guidanceTokenCount(buildGuidance(ctx)) : guidanceTokens(ctx);
|
|
165
|
+
const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4) + reservedGuidance;
|
|
118
166
|
const pendingTokens = pendingMessages.reduce(
|
|
119
167
|
(total, message) =>
|
|
120
168
|
total +
|
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,
|
|
@@ -16,7 +17,7 @@ function rolloverStatus(budget: Budget | undefined): "disabled" | "enabled" | "u
|
|
|
16
17
|
}
|
|
17
18
|
return budget.supported ? "enabled" : "unsupported";
|
|
18
19
|
}
|
|
19
|
-
function contextReport(host: PolicyContext): TextResult {
|
|
20
|
+
function contextReport(host: PolicyContext, newContextAvailable: boolean): TextResult {
|
|
20
21
|
const usage = host.getContextUsage();
|
|
21
22
|
if (usage === undefined || usage.tokens === null) {
|
|
22
23
|
return textResult("Context usage is not known until the next model response.", [], {
|
|
@@ -41,7 +42,11 @@ function contextReport(host: PolicyContext): TextResult {
|
|
|
41
42
|
);
|
|
42
43
|
}
|
|
43
44
|
if (!budget.supported) {
|
|
44
|
-
return textResult(
|
|
45
|
+
return textResult(
|
|
46
|
+
`${unsupportedMessage(budget, newContextAvailable)} ${configured}`,
|
|
47
|
+
[],
|
|
48
|
+
display,
|
|
49
|
+
);
|
|
45
50
|
}
|
|
46
51
|
return textResult(
|
|
47
52
|
`≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${configured}`,
|
|
@@ -97,7 +102,7 @@ export function registerContextTools(
|
|
|
97
102
|
"verify remaining context before reporting a token count or changing plans because of context limits",
|
|
98
103
|
parameters: Type.Object({}),
|
|
99
104
|
async execute(...[, , , , ctx]) {
|
|
100
|
-
return contextReport(policy(ctx));
|
|
105
|
+
return contextReport(policy(ctx), activeGuidanceTools(pi).newContext);
|
|
101
106
|
},
|
|
102
107
|
});
|
|
103
108
|
}
|
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
|
};
|
package/src/guidance.ts
CHANGED
|
@@ -1,8 +1,250 @@
|
|
|
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 { isRecord, type EntryLike, type PolicyContext, type SnapshotReader } from "./contracts.ts";
|
|
4
11
|
|
|
5
|
-
export
|
|
12
|
+
export const WINDOW_GUIDANCE_TYPE = "posthorse-window-guidance";
|
|
13
|
+
const guidanceRecordSchema = Type.Object({
|
|
14
|
+
version: Type.Literal(1),
|
|
15
|
+
budgetKey: Type.String(),
|
|
16
|
+
text: Type.String(),
|
|
17
|
+
tools: Type.Object({
|
|
18
|
+
newContext: Type.Boolean(),
|
|
19
|
+
getContextRemaining: Type.Boolean(),
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
type WindowGuidanceRecord = Static<typeof guidanceRecordSchema>;
|
|
23
|
+
type MemoryEpoch = {
|
|
24
|
+
readonly byBudget: Map<string, string>;
|
|
25
|
+
tools: GuidanceTools | undefined;
|
|
26
|
+
};
|
|
27
|
+
type EpochState = {
|
|
28
|
+
readonly manager: ExtensionContext["sessionManager"];
|
|
29
|
+
readonly sessionId: string;
|
|
30
|
+
readonly file: string | undefined;
|
|
31
|
+
readonly leaf: EntryLike | undefined;
|
|
32
|
+
readonly anchorKey: string;
|
|
33
|
+
readonly byBudget: ReadonlyMap<string, string>;
|
|
34
|
+
readonly tools: GuidanceTools | undefined;
|
|
35
|
+
};
|
|
36
|
+
export type WindowGuidance = {
|
|
37
|
+
readonly resolve: (ctx: ExtensionContext, host: PolicyContext) => string;
|
|
38
|
+
readonly deliver: (ctx: ExtensionContext, host: PolicyContext) => string;
|
|
39
|
+
readonly reset: () => void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function activeGuidanceTools(pi: ExtensionAPI): GuidanceTools {
|
|
43
|
+
const active = new Set(pi.getActiveTools());
|
|
44
|
+
const unnamespaced = new Set(
|
|
45
|
+
pi
|
|
46
|
+
.getAllTools()
|
|
47
|
+
.filter((tool) => tool.namespace === undefined)
|
|
48
|
+
.map((tool) => tool.name),
|
|
49
|
+
);
|
|
50
|
+
return {
|
|
51
|
+
newContext: active.has("new_context") && unnamespaced.has("new_context"),
|
|
52
|
+
getContextRemaining:
|
|
53
|
+
active.has("get_context_remaining") && unnamespaced.has("get_context_remaining"),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
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
|
+
function collectGuidanceEntries(
|
|
96
|
+
entries: readonly EntryLike[],
|
|
97
|
+
baseByBudget?: ReadonlyMap<string, string>,
|
|
98
|
+
baseTools?: GuidanceTools,
|
|
99
|
+
): { readonly byBudget: Map<string, string>; readonly tools: GuidanceTools | undefined } {
|
|
100
|
+
const byBudget = new Map<string, string>(baseByBudget);
|
|
101
|
+
let tools = baseTools;
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (
|
|
104
|
+
entry.type === "custom" &&
|
|
105
|
+
entry.customType === WINDOW_GUIDANCE_TYPE &&
|
|
106
|
+
Check(guidanceRecordSchema, entry.data)
|
|
107
|
+
) {
|
|
108
|
+
byBudget.set(entry.data.budgetKey, entry.data.text);
|
|
109
|
+
tools ??= entry.data.tools;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { byBudget, tools };
|
|
113
|
+
}
|
|
114
|
+
function withMemoryEpoch(
|
|
115
|
+
collected: {
|
|
116
|
+
readonly byBudget: Map<string, string>;
|
|
117
|
+
readonly tools: GuidanceTools | undefined;
|
|
118
|
+
},
|
|
119
|
+
memory: MemoryEpoch | undefined,
|
|
120
|
+
): { readonly byBudget: ReadonlyMap<string, string>; readonly tools: GuidanceTools | undefined } {
|
|
121
|
+
if (memory === undefined) {
|
|
122
|
+
return collected;
|
|
123
|
+
}
|
|
124
|
+
const merged = new Map(collected.byBudget);
|
|
125
|
+
for (const [key, text] of memory.byBudget) {
|
|
126
|
+
if (!merged.has(key)) {
|
|
127
|
+
merged.set(key, text);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { byBudget: merged, tools: collected.tools ?? memory.tools };
|
|
131
|
+
}
|
|
132
|
+
function scanGuidanceEpoch(
|
|
133
|
+
manager: ExtensionContext["sessionManager"],
|
|
134
|
+
identity: { readonly sessionId: string; readonly leaf: EntryLike | undefined },
|
|
135
|
+
previous: EpochState | undefined,
|
|
136
|
+
memoryByEpoch: ReadonlyMap<string, MemoryEpoch> | undefined,
|
|
137
|
+
): Pick<EpochState, "anchorKey" | "byBudget" | "tools"> {
|
|
138
|
+
const reversed: EntryLike[] = [];
|
|
139
|
+
let anchor: EntryLike | undefined;
|
|
140
|
+
for (
|
|
141
|
+
let entry = identity.leaf;
|
|
142
|
+
entry !== undefined;
|
|
143
|
+
entry = parentEntry(manager, entry.parentId)
|
|
144
|
+
) {
|
|
145
|
+
if (previous !== undefined && previous.leaf === entry) {
|
|
146
|
+
const collected = collectGuidanceEntries(
|
|
147
|
+
reversed.reverse(),
|
|
148
|
+
previous.byBudget,
|
|
149
|
+
previous.tools,
|
|
150
|
+
);
|
|
151
|
+
return {
|
|
152
|
+
anchorKey: previous.anchorKey,
|
|
153
|
+
...withMemoryEpoch(collected, memoryByEpoch?.get(previous.anchorKey)),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (startsFreshGuidanceEpoch(entry)) {
|
|
157
|
+
anchor = entry;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
reversed.push(entry);
|
|
161
|
+
}
|
|
162
|
+
const anchorKey =
|
|
163
|
+
anchor === undefined
|
|
164
|
+
? `${identity.sessionId}:root`
|
|
165
|
+
: `${identity.sessionId}:${anchor.id ?? "boundary"}`;
|
|
166
|
+
const collected = collectGuidanceEntries(reversed.reverse());
|
|
167
|
+
return {
|
|
168
|
+
anchorKey,
|
|
169
|
+
...withMemoryEpoch(collected, memoryByEpoch?.get(anchorKey)),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
|
|
173
|
+
let state: EpochState | undefined;
|
|
174
|
+
let memoryByManager = new WeakMap<object, Map<string, MemoryEpoch>>();
|
|
175
|
+
const readEpoch = (ctx: ExtensionContext): EpochState => {
|
|
176
|
+
const manager = ctx.sessionManager;
|
|
177
|
+
const { sessionId, file, leaf } = sessionIdentityOf(manager);
|
|
178
|
+
const previous =
|
|
179
|
+
state?.manager === manager && state.sessionId === sessionId && state.file === file
|
|
180
|
+
? state
|
|
181
|
+
: undefined;
|
|
182
|
+
if (previous !== undefined && previous.leaf === leaf) {
|
|
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
|
+
};
|
|
199
|
+
return {
|
|
200
|
+
resolve(ctx, host) {
|
|
201
|
+
const epoch = readEpoch(ctx);
|
|
202
|
+
const key = budgetGuidanceKey(host);
|
|
203
|
+
return (
|
|
204
|
+
epoch.byBudget.get(key) ?? buildGuidance(host, epoch.tools ?? ALL_GUIDANCE_TOOLS)
|
|
205
|
+
);
|
|
206
|
+
},
|
|
207
|
+
deliver(ctx, host) {
|
|
208
|
+
const epoch = readEpoch(ctx);
|
|
209
|
+
const key = budgetGuidanceKey(host);
|
|
210
|
+
const existing = epoch.byBudget.get(key);
|
|
211
|
+
if (existing !== undefined) {
|
|
212
|
+
return existing;
|
|
213
|
+
}
|
|
214
|
+
const tools = epoch.tools ?? activeGuidanceTools(pi);
|
|
215
|
+
const text = buildGuidance(host, tools);
|
|
216
|
+
let byEpoch = memoryByManager.get(ctx.sessionManager);
|
|
217
|
+
if (byEpoch === undefined) {
|
|
218
|
+
byEpoch = new Map();
|
|
219
|
+
memoryByManager.set(ctx.sessionManager, byEpoch);
|
|
220
|
+
}
|
|
221
|
+
let memory = byEpoch.get(epoch.anchorKey);
|
|
222
|
+
if (memory === undefined) {
|
|
223
|
+
memory = { byBudget: new Map(), tools };
|
|
224
|
+
byEpoch.set(epoch.anchorKey, memory);
|
|
225
|
+
}
|
|
226
|
+
memory.byBudget.set(key, text);
|
|
227
|
+
memory.tools ??= tools;
|
|
228
|
+
state = undefined;
|
|
229
|
+
pi.appendEntry(WINDOW_GUIDANCE_TYPE, {
|
|
230
|
+
version: 1,
|
|
231
|
+
budgetKey: key,
|
|
232
|
+
text,
|
|
233
|
+
tools,
|
|
234
|
+
} satisfies WindowGuidanceRecord);
|
|
235
|
+
return text;
|
|
236
|
+
},
|
|
237
|
+
reset() {
|
|
238
|
+
state = undefined;
|
|
239
|
+
memoryByManager = new WeakMap();
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
export function registerGuidance(
|
|
244
|
+
pi: ExtensionAPI,
|
|
245
|
+
policy: SnapshotReader,
|
|
246
|
+
windowGuidance: WindowGuidance,
|
|
247
|
+
): void {
|
|
6
248
|
let promptOptions: { readonly forceSystemPrompt?: string } | undefined;
|
|
7
249
|
pi.on("before_agent_start", (event) => {
|
|
8
250
|
promptOptions = event.systemPromptOptions;
|
|
@@ -12,7 +254,7 @@ export function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void
|
|
|
12
254
|
promptOptions = undefined;
|
|
13
255
|
});
|
|
14
256
|
pi.on("context_with_system", (event, ctx) => {
|
|
15
|
-
const guidance =
|
|
257
|
+
const guidance = windowGuidance.deliver(ctx, policy(ctx));
|
|
16
258
|
const system =
|
|
17
259
|
event.messages.findLast(
|
|
18
260
|
(message) =>
|
package/src/note-provenance.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { isRecord, type EntryLike } from "./contracts.ts";
|
|
@@ -115,10 +115,22 @@ async function sourceScope(
|
|
|
115
115
|
source.file === undefined ? manager.getEntries() : await journalMetadata(source.file, true);
|
|
116
116
|
return legacySource(legacy.length > 0 ? legacy : manager.getEntries());
|
|
117
117
|
}
|
|
118
|
+
function hasReferencedTarget(
|
|
119
|
+
current: NoteScope,
|
|
120
|
+
path: string,
|
|
121
|
+
includeDirectories: boolean,
|
|
122
|
+
): boolean {
|
|
123
|
+
const info = statSync(notePath(current, path), { throwIfNoEntry: false });
|
|
124
|
+
if (info === undefined) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return info.isFile() || (includeDirectories && info.isDirectory());
|
|
128
|
+
}
|
|
118
129
|
export async function resolveReference(
|
|
119
130
|
scope: NoteScope,
|
|
120
131
|
path: string,
|
|
121
132
|
entries: readonly EntryLike[],
|
|
133
|
+
options: { readonly includeDirectories?: boolean | undefined } = {},
|
|
122
134
|
): Promise<NoteAddress | undefined> {
|
|
123
135
|
let source: NoteScope | null = scope;
|
|
124
136
|
const bindings = bindingsOf(entries);
|
|
@@ -130,7 +142,7 @@ export async function resolveReference(
|
|
|
130
142
|
throw new Error(`Cyclic note provenance at ${directory}.`);
|
|
131
143
|
}
|
|
132
144
|
visited.add(directory);
|
|
133
|
-
if (
|
|
145
|
+
if (hasReferencedTarget(current, path, options.includeDirectories === true)) {
|
|
134
146
|
return addressIn(current, path);
|
|
135
147
|
}
|
|
136
148
|
// A manifest includes later adoptions; the journal includes later writes and deletions.
|
package/src/note-scopes.ts
CHANGED
|
@@ -113,6 +113,36 @@ export function noteRelative(path: string): string {
|
|
|
113
113
|
}
|
|
114
114
|
return normalized;
|
|
115
115
|
}
|
|
116
|
+
function ancestorsOf(path: string): string[] {
|
|
117
|
+
const ancestors: string[] = [];
|
|
118
|
+
let current = dirname(path);
|
|
119
|
+
while (true) {
|
|
120
|
+
ancestors.push(current);
|
|
121
|
+
const parent = dirname(current);
|
|
122
|
+
if (parent === current) {
|
|
123
|
+
return ancestors.toReversed();
|
|
124
|
+
}
|
|
125
|
+
current = parent;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export function scopeNoteRelative(scope: NoteScope, path: string): string {
|
|
129
|
+
if (
|
|
130
|
+
path !== "" &&
|
|
131
|
+
isAbsolute(path) &&
|
|
132
|
+
!path.includes("\0") &&
|
|
133
|
+
!path.split(/[/\\]/u).includes("..")
|
|
134
|
+
) {
|
|
135
|
+
const targetDirectory = canonicalPath(scopeDirectory(scope));
|
|
136
|
+
const resolved = resolve(path);
|
|
137
|
+
const matched = ancestorsOf(resolved).find(
|
|
138
|
+
(ancestor) => canonicalPath(ancestor) === targetDirectory,
|
|
139
|
+
);
|
|
140
|
+
if (matched !== undefined) {
|
|
141
|
+
return noteRelative(relative(matched, resolved));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return noteRelative(path);
|
|
145
|
+
}
|
|
116
146
|
/** Resolve existing ancestors too, so aliases to files not created yet have one owner. */
|
|
117
147
|
export function canonicalPath(path: string): string {
|
|
118
148
|
const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`;
|
package/src/note-store.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
sameOwner,
|
|
24
24
|
sameScope,
|
|
25
25
|
scopeDirectory,
|
|
26
|
+
scopeNoteRelative,
|
|
26
27
|
type NoteAddress,
|
|
27
28
|
type NoteBinding,
|
|
28
29
|
type NoteScope,
|
|
@@ -133,16 +134,24 @@ export class SessionNotes {
|
|
|
133
134
|
`Unknown notes scope ${JSON.stringify(selector)}. Omit scope for this session's notes, or copy a scope selector from notes list/search.`,
|
|
134
135
|
);
|
|
135
136
|
}
|
|
136
|
-
async #reference(
|
|
137
|
+
async #reference(
|
|
138
|
+
path: string,
|
|
139
|
+
options: {
|
|
140
|
+
readonly includeDirectories?: boolean | undefined;
|
|
141
|
+
readonly signal?: AbortSignal | undefined;
|
|
142
|
+
} = {},
|
|
143
|
+
): Promise<NoteAddress | undefined> {
|
|
137
144
|
if (this.binding.kind !== "fork" || this.binding.source === null) {
|
|
138
145
|
return;
|
|
139
146
|
}
|
|
140
147
|
const entries = [
|
|
141
148
|
...this.#manager.getEntries(),
|
|
142
149
|
...this.#source.entries,
|
|
143
|
-
...(await sourceEntries(this.#source.file, signal)),
|
|
150
|
+
...(await sourceEntries(this.#source.file, options.signal)),
|
|
144
151
|
];
|
|
145
|
-
return resolveReference(this.binding.source, path, entries
|
|
152
|
+
return resolveReference(this.binding.source, path, entries, {
|
|
153
|
+
includeDirectories: options.includeDirectories,
|
|
154
|
+
});
|
|
146
155
|
}
|
|
147
156
|
async read(
|
|
148
157
|
path: string,
|
|
@@ -151,8 +160,8 @@ export class SessionNotes {
|
|
|
151
160
|
readonly signal?: AbortSignal | undefined;
|
|
152
161
|
} = {},
|
|
153
162
|
): Promise<NoteRead> {
|
|
154
|
-
const relative = noteRelative(path);
|
|
155
163
|
if (options.scope !== undefined) {
|
|
164
|
+
const relative = noteRelative(path);
|
|
156
165
|
const scope = await this.selectScope(options.scope);
|
|
157
166
|
return {
|
|
158
167
|
address: addressIn(scope, relative),
|
|
@@ -160,9 +169,13 @@ export class SessionNotes {
|
|
|
160
169
|
writable: sameScope(scope, this.binding),
|
|
161
170
|
};
|
|
162
171
|
}
|
|
172
|
+
const relative = scopeNoteRelative(this.binding, path);
|
|
163
173
|
const reference = this.owns(relative)
|
|
164
174
|
? undefined
|
|
165
|
-
: await this.#reference(relative,
|
|
175
|
+
: await this.#reference(relative, {
|
|
176
|
+
includeDirectories: true,
|
|
177
|
+
signal: options.signal,
|
|
178
|
+
});
|
|
166
179
|
const address = reference ?? addressIn(this.binding, relative);
|
|
167
180
|
return { address, path: notePath(address, relative), writable: reference === undefined };
|
|
168
181
|
}
|
package/src/notes.ts
CHANGED
|
@@ -92,15 +92,20 @@ async function readNote(
|
|
|
92
92
|
access: NotesAccess,
|
|
93
93
|
): Promise<TextResult> {
|
|
94
94
|
const { pages } = access;
|
|
95
|
-
const
|
|
96
|
-
const target = await session.read(
|
|
95
|
+
const requested = requireValue(params.path, "path", params.op);
|
|
96
|
+
const target = await session.read(requested, { scope: params.scope, signal: access.signal });
|
|
97
|
+
const relative = target.address.path;
|
|
97
98
|
const path = target.path;
|
|
98
99
|
const info = statSync(path, { throwIfNoEntry: false });
|
|
99
100
|
if (info === undefined) {
|
|
100
101
|
throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
|
|
101
102
|
}
|
|
102
103
|
if (info.isDirectory()) {
|
|
103
|
-
return listNotes(
|
|
104
|
+
return listNotes(
|
|
105
|
+
session,
|
|
106
|
+
{ ...params, path: relative, scope: scopeDirectory(target.address) },
|
|
107
|
+
access,
|
|
108
|
+
);
|
|
104
109
|
}
|
|
105
110
|
if (await isBinaryFile(path)) {
|
|
106
111
|
throw new Error(
|
|
@@ -283,6 +288,40 @@ function withoutEmptyScope(params: NotesParams): NotesParams {
|
|
|
283
288
|
const { scope, ...unscoped } = params;
|
|
284
289
|
return scope === "" ? unscoped : params;
|
|
285
290
|
}
|
|
291
|
+
const notesParameters = Type.Object({
|
|
292
|
+
op: Type.Union(
|
|
293
|
+
[
|
|
294
|
+
Type.Literal("list"),
|
|
295
|
+
Type.Literal("read"),
|
|
296
|
+
Type.Literal("write"),
|
|
297
|
+
Type.Literal("append"),
|
|
298
|
+
Type.Literal("search"),
|
|
299
|
+
],
|
|
300
|
+
{ description: "Operation to perform" },
|
|
301
|
+
),
|
|
302
|
+
path: Type.Optional(
|
|
303
|
+
Type.String({
|
|
304
|
+
description:
|
|
305
|
+
"Note or folder path relative to the notes directory (read also accepts a path in this session's notes directory)",
|
|
306
|
+
}),
|
|
307
|
+
),
|
|
308
|
+
scope: Type.Optional(
|
|
309
|
+
Type.String({
|
|
310
|
+
description:
|
|
311
|
+
"Absolute scope selector from list/search; only for read/list/search. Omit for current notes and project-wide discovery.",
|
|
312
|
+
}),
|
|
313
|
+
),
|
|
314
|
+
content: Type.Optional(
|
|
315
|
+
Type.String({ description: "Full file content (write) or text to add (append)" }),
|
|
316
|
+
),
|
|
317
|
+
query: Type.Optional(Type.String({ description: "Substring to find in notes (search)" })),
|
|
318
|
+
offset: Type.Optional(
|
|
319
|
+
Type.Integer({
|
|
320
|
+
description: "Character offset for list/read/search (default 0)",
|
|
321
|
+
minimum: 0,
|
|
322
|
+
}),
|
|
323
|
+
),
|
|
324
|
+
});
|
|
286
325
|
export function registerNotes(
|
|
287
326
|
pi: ExtensionAPI,
|
|
288
327
|
policy: (ctx: ExtensionContext) => PolicyContext,
|
|
@@ -297,39 +336,7 @@ export function registerNotes(
|
|
|
297
336
|
description:
|
|
298
337
|
"Durable task notes shared across Git worktrees and context resets; forks get private copies. Ops: list (newest first), read, search (case-insensitive), write (replace; empty clears), append (one record). list/search also cover other sessions' note scopes and show their selectors; pass scope to read, list, or search one of them. list/read/search are paged; continue with the returned offset. A read of a current note returns its file path.",
|
|
299
338
|
promptSnippet: "save and recall durable state with private notes for forks",
|
|
300
|
-
parameters:
|
|
301
|
-
op: Type.Union(
|
|
302
|
-
[
|
|
303
|
-
Type.Literal("list"),
|
|
304
|
-
Type.Literal("read"),
|
|
305
|
-
Type.Literal("write"),
|
|
306
|
-
Type.Literal("append"),
|
|
307
|
-
Type.Literal("search"),
|
|
308
|
-
],
|
|
309
|
-
{ description: "Operation to perform" },
|
|
310
|
-
),
|
|
311
|
-
path: Type.Optional(
|
|
312
|
-
Type.String({ description: "Note or folder path relative to the notes directory" }),
|
|
313
|
-
),
|
|
314
|
-
scope: Type.Optional(
|
|
315
|
-
Type.String({
|
|
316
|
-
description:
|
|
317
|
-
"Absolute scope selector from list/search; only for read/list/search. Omit for current notes and project-wide discovery.",
|
|
318
|
-
}),
|
|
319
|
-
),
|
|
320
|
-
content: Type.Optional(
|
|
321
|
-
Type.String({ description: "Full file content (write) or text to add (append)" }),
|
|
322
|
-
),
|
|
323
|
-
query: Type.Optional(
|
|
324
|
-
Type.String({ description: "Substring to find in notes (search)" }),
|
|
325
|
-
),
|
|
326
|
-
offset: Type.Optional(
|
|
327
|
-
Type.Integer({
|
|
328
|
-
description: "Character offset for list/read/search (default 0)",
|
|
329
|
-
minimum: 0,
|
|
330
|
-
}),
|
|
331
|
-
),
|
|
332
|
-
}),
|
|
339
|
+
parameters: notesParameters,
|
|
333
340
|
async execute(...[_id, received, signal, , ctx]) {
|
|
334
341
|
const params = withoutEmptyScope(received);
|
|
335
342
|
// Migrate newly returned legacy package notes without changing this session’s binding.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as host from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A one-line summary block written by the provider at an automatic rollover.
|
|
5
|
+
*
|
|
6
|
+
* A host that implements `providerSummary` on a compaction asks the session's model for the block
|
|
7
|
+
* and sends it ahead of the retained tail, which keeps the reasoning of the tail's replies valid.
|
|
8
|
+
* The block carries no task state; the recovery record after the tail and the task note do.
|
|
9
|
+
*/
|
|
10
|
+
export type ProviderSummaryRequest = { readonly instructions: string };
|
|
11
|
+
type SupportProbe = (model: unknown) => unknown;
|
|
12
|
+
function isProbe(value: unknown): value is SupportProbe {
|
|
13
|
+
return typeof value === "function";
|
|
14
|
+
}
|
|
15
|
+
/** Exported by a host that can make the block; it knows which models return one. */
|
|
16
|
+
const hostSupport: unknown = Reflect.get(host, "supportsProviderSummary");
|
|
17
|
+
export function hostMakesProviderSummary(model: unknown): boolean {
|
|
18
|
+
return isProbe(hostSupport) && model !== undefined && hostSupport(model) === true;
|
|
19
|
+
}
|
|
20
|
+
/** Measured size of a one-line block is 59 to 77 tokens; Pi's estimate does not count it. */
|
|
21
|
+
export const PROVIDER_SUMMARY_TOKENS = 100;
|
|
22
|
+
/**
|
|
23
|
+
* The model answers a request for a one-line summary. It refuses a request to reply with a fixed
|
|
24
|
+
* line or with the path only, and one that tells the summary to speak about "the recent messages",
|
|
25
|
+
* so the wording asks for a summary that points to the note, or to the record when there is none.
|
|
26
|
+
*/
|
|
27
|
+
export function providerSummaryInstructions(notePath: string | undefined): string {
|
|
28
|
+
const pointer =
|
|
29
|
+
notePath === undefined
|
|
30
|
+
? "state that the details are in the recovery record below"
|
|
31
|
+
: `point to the task note \`${notePath}\` and say to read that note and proceed from there`;
|
|
32
|
+
return `Write a summary of one line, at most 20 words: name the task in a few words, then ${pointer}. Do not mention progress, a pending step or a next step. Do not call any tools; respond with the summary text only.`;
|
|
33
|
+
}
|
|
34
|
+
type MessageLike = { readonly role: string };
|
|
35
|
+
/**
|
|
36
|
+
* How many leading messages Pi sends exactly as the provider last saw them. With a block, those
|
|
37
|
+
* are the leading system message and the retained tail up to the record; changing, removing or
|
|
38
|
+
* reordering one makes the provider drop the reasoning of every reply after it. A tail that
|
|
39
|
+
* reaches back across an earlier rollover holds that rollover's record too, so the boundary is
|
|
40
|
+
* the last record.
|
|
41
|
+
*/
|
|
42
|
+
export function messagesHeldByProviderSummary(messages: readonly MessageLike[]): number {
|
|
43
|
+
const [first] = messages;
|
|
44
|
+
if (
|
|
45
|
+
first?.role !== "system" ||
|
|
46
|
+
!("providerSummary" in first) ||
|
|
47
|
+
first.providerSummary === undefined
|
|
48
|
+
) {
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
const record = messages.findLastIndex((message) => message.role === "compactionSummary");
|
|
52
|
+
return record < 0 ? messages.length : record;
|
|
53
|
+
}
|
|
@@ -244,11 +244,24 @@ function notesCallKind(args: Readonly<Record<string, unknown>>): ContentTouchKin
|
|
|
244
244
|
return args.op === "read" ? "read" : undefined;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
function preferredNotePath(
|
|
248
|
+
callTarget: NoteTarget | undefined,
|
|
249
|
+
resTarget: NoteTarget | undefined,
|
|
250
|
+
): string | undefined {
|
|
251
|
+
if (callTarget === undefined) {
|
|
252
|
+
return resTarget?.notePath;
|
|
253
|
+
}
|
|
254
|
+
if (callTarget.absPath !== undefined && resTarget !== undefined) {
|
|
255
|
+
return resTarget.notePath;
|
|
256
|
+
}
|
|
257
|
+
return callTarget.notePath;
|
|
258
|
+
}
|
|
259
|
+
|
|
247
260
|
function mergeNoteTargets(
|
|
248
261
|
callTarget: NoteTarget | undefined,
|
|
249
262
|
resTarget: NoteTarget | undefined,
|
|
250
263
|
): NoteTarget | undefined {
|
|
251
|
-
const notePath = callTarget
|
|
264
|
+
const notePath = preferredNotePath(callTarget, resTarget);
|
|
252
265
|
return notePath === undefined
|
|
253
266
|
? undefined
|
|
254
267
|
: { notePath, absPath: resTarget?.absPath ?? callTarget?.absPath };
|
package/src/recovery-notes.ts
CHANGED
|
@@ -172,34 +172,62 @@ function trackedNotes(
|
|
|
172
172
|
}
|
|
173
173
|
return [...tracked.values()].sort((a, b) => b.order - a.order);
|
|
174
174
|
}
|
|
175
|
-
|
|
175
|
+
type NoteSources = {
|
|
176
|
+
readonly retained: ReadonlySet<string>;
|
|
177
|
+
readonly notes?: NotesRecovery | undefined;
|
|
178
|
+
};
|
|
179
|
+
function sessionNotes(
|
|
176
180
|
chainEntries: readonly EntryLike[],
|
|
177
181
|
allEntries: readonly EntryLike[],
|
|
178
182
|
projected: readonly ProjectedEntry[],
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
readonly span: RetainedSpan | undefined;
|
|
182
|
-
readonly maxChars: number;
|
|
183
|
-
readonly notes?: NotesRecovery | undefined;
|
|
184
|
-
},
|
|
185
|
-
): string | undefined {
|
|
186
|
-
const { retained, span, maxChars } = options;
|
|
183
|
+
sources: NoteSources,
|
|
184
|
+
): { readonly all: readonly TrackedNote[]; readonly notesDirectory: string | undefined } {
|
|
187
185
|
const collected = sessionNoteTouches({
|
|
188
186
|
branch: chainEntries,
|
|
189
|
-
allEntries:
|
|
187
|
+
allEntries: sources.notes?.entries ?? allEntries,
|
|
190
188
|
projected,
|
|
191
|
-
retained,
|
|
189
|
+
retained: sources.retained,
|
|
192
190
|
});
|
|
193
|
-
const touches = scopeRecoveryTouches(collected.touches,
|
|
191
|
+
const touches = scopeRecoveryTouches(collected.touches, sources.notes);
|
|
194
192
|
const { defaultDir } = collected;
|
|
195
|
-
|
|
193
|
+
return {
|
|
194
|
+
all: trackedNotes(touches, defaultDir),
|
|
195
|
+
notesDirectory:
|
|
196
|
+
sources.notes === undefined ? defaultDir : scopeDirectory(sources.notes.binding),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The file path of the note that the record names first, when this session wrote one. The path is
|
|
201
|
+
* absolute whenever the notes directory is known, so the `read` tool opens it from any directory.
|
|
202
|
+
*/
|
|
203
|
+
export function primaryNotePath(
|
|
204
|
+
chainEntries: readonly EntryLike[],
|
|
205
|
+
allEntries: readonly EntryLike[],
|
|
206
|
+
projected: readonly ProjectedEntry[],
|
|
207
|
+
sources: NoteSources,
|
|
208
|
+
): string | undefined {
|
|
209
|
+
const { all, notesDirectory } = sessionNotes(chainEntries, allEntries, projected, sources);
|
|
210
|
+
const primary = all.find((note) => note.kind === "written");
|
|
211
|
+
return primary === undefined
|
|
212
|
+
? undefined
|
|
213
|
+
: (pathElsewhere(primary, notesDirectory) ?? primary.absPath ?? primary.notePath);
|
|
214
|
+
}
|
|
215
|
+
export function renderSessionNotes(
|
|
216
|
+
chainEntries: readonly EntryLike[],
|
|
217
|
+
allEntries: readonly EntryLike[],
|
|
218
|
+
projected: readonly ProjectedEntry[],
|
|
219
|
+
options: NoteSources & {
|
|
220
|
+
readonly span: RetainedSpan | undefined;
|
|
221
|
+
readonly maxChars: number;
|
|
222
|
+
},
|
|
223
|
+
): string | undefined {
|
|
224
|
+
const { span, maxChars } = options;
|
|
225
|
+
const { all, notesDirectory } = sessionNotes(chainEntries, allEntries, projected, options);
|
|
196
226
|
if (all.length === 0) {
|
|
197
227
|
return;
|
|
198
228
|
}
|
|
199
229
|
const written = all.filter((note) => note.kind === "written").slice(0, MAX_WRITTEN_NOTES);
|
|
200
230
|
const readOnly = all.filter((note) => note.kind === "read").slice(0, MAX_READ_NOTES);
|
|
201
|
-
const notesDirectory =
|
|
202
|
-
options.notes === undefined ? defaultDir : scopeDirectory(options.notes.binding);
|
|
203
231
|
const lines = [
|
|
204
232
|
...written.map((note) => formatWrittenNoteLine(note, span, notesDirectory)),
|
|
205
233
|
...readOnly.map((note) => formatReadNoteLine(note, span, notesDirectory)),
|
package/src/recovery.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type RecoveryRecord,
|
|
10
10
|
} from "./recovery-records.ts";
|
|
11
11
|
import type { NotesRecovery } from "./note-evidence.ts";
|
|
12
|
-
import { renderSessionNotes, type RetainedSpan } from "./recovery-notes.ts";
|
|
12
|
+
import { primaryNotePath, renderSessionNotes, type RetainedSpan } from "./recovery-notes.ts";
|
|
13
13
|
import {
|
|
14
14
|
recoverableToolResults,
|
|
15
15
|
type RecoveryBatch,
|
|
@@ -255,6 +255,20 @@ function rolloverPreambleWithNotes(
|
|
|
255
255
|
);
|
|
256
256
|
return joined([basePreamble, notesSection]);
|
|
257
257
|
}
|
|
258
|
+
/** The note the automatic record tells the agent to read, for text that names it outside the record. */
|
|
259
|
+
export function recoveryNotePath(
|
|
260
|
+
entries: readonly EntryLike[],
|
|
261
|
+
projected: readonly ProjectedEntry[],
|
|
262
|
+
options: {
|
|
263
|
+
readonly retainedEntryIds: ReadonlySet<string>;
|
|
264
|
+
readonly notes?: NotesRecovery | undefined;
|
|
265
|
+
},
|
|
266
|
+
): string | undefined {
|
|
267
|
+
return primaryNotePath(entries.slice(automaticChainStart(entries)), entries, projected, {
|
|
268
|
+
retained: options.retainedEntryIds,
|
|
269
|
+
notes: options.notes,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
258
272
|
/** Raw owner anchors and projected tool evidence have distinct provenance and share one bounded payload. */
|
|
259
273
|
export function buildAutoHandoff(
|
|
260
274
|
entries: readonly EntryLike[],
|
package/src/retention.ts
CHANGED
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
type PolicyContext,
|
|
16
16
|
} from "./contracts.ts";
|
|
17
17
|
import type { NotesRecovery } from "./note-evidence.ts";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
PROVIDER_SUMMARY_TOKENS,
|
|
20
|
+
providerSummaryInstructions,
|
|
21
|
+
type ProviderSummaryRequest,
|
|
22
|
+
} from "./provider-summary.ts";
|
|
23
|
+
import { buildAutoHandoff, recoveryNotePath } from "./recovery.ts";
|
|
19
24
|
|
|
20
25
|
type RecoveryCapacity = {
|
|
21
26
|
readonly host: PolicyContext;
|
|
@@ -23,6 +28,8 @@ type RecoveryCapacity = {
|
|
|
23
28
|
readonly pendingMessages?: readonly MessageLike[];
|
|
24
29
|
readonly firstKeptEntryId?: string | undefined;
|
|
25
30
|
readonly notes?: NotesRecovery | undefined;
|
|
31
|
+
/** The host will ask the provider for a summary block, which needs an assistant message first in the tail. */
|
|
32
|
+
readonly providerSummary?: boolean | undefined;
|
|
26
33
|
};
|
|
27
34
|
type RetainedTail = {
|
|
28
35
|
readonly firstKeptEntryId?: string | undefined;
|
|
@@ -53,6 +60,19 @@ function safeStart(entry: ProjectionEntry): boolean {
|
|
|
53
60
|
visibleMessages(entry).some((message) => CUT_ROLES.has(message.role))
|
|
54
61
|
);
|
|
55
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* A provider summary block stands for the request that produced the tail's first message, so
|
|
65
|
+
* that message must be a reply the provider completed.
|
|
66
|
+
*/
|
|
67
|
+
function assistantStart(entry: ProjectionEntry): boolean {
|
|
68
|
+
const [first] = visibleMessages(entry);
|
|
69
|
+
return (
|
|
70
|
+
entry.sourceEntry.type !== "compaction" &&
|
|
71
|
+
first?.role === "assistant" &&
|
|
72
|
+
first.stopReason !== "error" &&
|
|
73
|
+
first.stopReason !== "aborted"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
56
76
|
function nativeMessage(
|
|
57
77
|
message: ProjectionEntry["messages"][number],
|
|
58
78
|
): SessionMessageEntry["message"] {
|
|
@@ -129,6 +149,7 @@ function boundedTail(
|
|
|
129
149
|
entries: readonly ProjectionEntry[],
|
|
130
150
|
start: number,
|
|
131
151
|
maxTokens: number,
|
|
152
|
+
startsTail: (entry: ProjectionEntry) => boolean,
|
|
132
153
|
): RetainedTail {
|
|
133
154
|
const costs = entries.slice(start).map((entry) => ({
|
|
134
155
|
entry,
|
|
@@ -143,7 +164,7 @@ function boundedTail(
|
|
|
143
164
|
let tokens = costs.reduce((sum, { cost }) => sum + cost, 0);
|
|
144
165
|
for (const [offset, { entry, cost }] of costs.entries()) {
|
|
145
166
|
const index = start + offset;
|
|
146
|
-
if (tokens > 0 && tokens <= maxTokens &&
|
|
167
|
+
if (tokens > 0 && tokens <= maxTokens && startsTail(entry)) {
|
|
147
168
|
const tail = entries.slice(index);
|
|
148
169
|
if (pairedTail(tail)) {
|
|
149
170
|
return {
|
|
@@ -178,7 +199,14 @@ function retainedTail(
|
|
|
178
199
|
(entry) => entry.sourceEntry.id === capacity.firstKeptEntryId,
|
|
179
200
|
);
|
|
180
201
|
const start = prepared >= 0 ? prepared : projectedCut(entries, keep);
|
|
181
|
-
return start < 0
|
|
202
|
+
return start < 0
|
|
203
|
+
? { ids: new Set() }
|
|
204
|
+
: boundedTail(
|
|
205
|
+
entries,
|
|
206
|
+
start,
|
|
207
|
+
maxTokens,
|
|
208
|
+
capacity.providerSummary === true ? assistantStart : safeStart,
|
|
209
|
+
);
|
|
182
210
|
}
|
|
183
211
|
/** Recent messages and recovery share half the fresh operational budget, leaving work headroom. */
|
|
184
212
|
export function automaticRecovery(
|
|
@@ -186,7 +214,13 @@ export function automaticRecovery(
|
|
|
186
214
|
projected: readonly ProjectionEntry[],
|
|
187
215
|
capacity: RecoveryCapacity,
|
|
188
216
|
ownerQuestionRegistered: boolean,
|
|
189
|
-
):
|
|
217
|
+
):
|
|
218
|
+
| {
|
|
219
|
+
readonly handoff: string;
|
|
220
|
+
readonly firstKeptEntryId?: string | undefined;
|
|
221
|
+
readonly providerSummary?: ProviderSummaryRequest | undefined;
|
|
222
|
+
}
|
|
223
|
+
| undefined {
|
|
190
224
|
const payload = freshPayloadChars(
|
|
191
225
|
capacity.host,
|
|
192
226
|
capacity.toolTokens,
|
|
@@ -197,13 +231,34 @@ export function automaticRecovery(
|
|
|
197
231
|
if (limit < MIN_PAGE_CHARS) {
|
|
198
232
|
return undefined;
|
|
199
233
|
}
|
|
200
|
-
const
|
|
234
|
+
const asksProvider = capacity.providerSummary === true;
|
|
235
|
+
const retained = retainedTail(
|
|
236
|
+
projected,
|
|
237
|
+
capacity,
|
|
238
|
+
Math.floor((payload - limit) / 4) - (asksProvider ? PROVIDER_SUMMARY_TOKENS : 0),
|
|
239
|
+
);
|
|
201
240
|
const handoff = buildAutoHandoff(entries, projected, limit, {
|
|
202
241
|
ownerQuestionRegistered,
|
|
203
242
|
retainedEntryIds: retained.ids,
|
|
204
243
|
notes: capacity.notes,
|
|
205
244
|
});
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
245
|
+
if (handoff.length > limit) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
handoff,
|
|
250
|
+
firstKeptEntryId: retained.firstKeptEntryId,
|
|
251
|
+
// Without a tail there is no reasoning to keep, so the host is not asked for a block.
|
|
252
|
+
providerSummary:
|
|
253
|
+
!asksProvider || retained.firstKeptEntryId === undefined
|
|
254
|
+
? undefined
|
|
255
|
+
: {
|
|
256
|
+
instructions: providerSummaryInstructions(
|
|
257
|
+
recoveryNotePath(entries, projected, {
|
|
258
|
+
retainedEntryIds: retained.ids,
|
|
259
|
+
notes: capacity.notes,
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
},
|
|
263
|
+
};
|
|
209
264
|
}
|
package/src/rollover.ts
CHANGED
|
@@ -17,8 +17,13 @@ import {
|
|
|
17
17
|
type SnapshotReader,
|
|
18
18
|
} from "./contracts.ts";
|
|
19
19
|
import { registerContextTools } from "./context-tools.ts";
|
|
20
|
-
import { registerGuidance } from "./guidance.ts";
|
|
20
|
+
import { registerGuidance, type WindowGuidance } from "./guidance.ts";
|
|
21
21
|
import { safeJsonStringify } from "./message-text.ts";
|
|
22
|
+
import {
|
|
23
|
+
hostMakesProviderSummary,
|
|
24
|
+
messagesHeldByProviderSummary,
|
|
25
|
+
type ProviderSummaryRequest,
|
|
26
|
+
} from "./provider-summary.ts";
|
|
22
27
|
import { hasRetainedRolloverMarker } from "./recovery.ts";
|
|
23
28
|
import { automaticRecovery } from "./retention.ts";
|
|
24
29
|
import { ResetRequests } from "./reset-requests.ts";
|
|
@@ -26,6 +31,7 @@ import type { NotesRecovery } from "./note-evidence.ts";
|
|
|
26
31
|
type RolloverResources = {
|
|
27
32
|
readonly resetPages: () => void;
|
|
28
33
|
readonly notes: (ctx: ExtensionContext) => NotesRecovery | undefined;
|
|
34
|
+
readonly guidance: WindowGuidance;
|
|
29
35
|
};
|
|
30
36
|
import {
|
|
31
37
|
countRetainedMessages,
|
|
@@ -157,8 +163,11 @@ function registerReminderFilter(
|
|
|
157
163
|
): void {
|
|
158
164
|
pi.on("context_with_system", (event, ctx) => {
|
|
159
165
|
const stale = staleReminderPredicate(event.messages, ctx, policy, reminders);
|
|
160
|
-
const
|
|
161
|
-
|
|
166
|
+
const held = messagesHeldByProviderSummary(event.messages);
|
|
167
|
+
const removable = (message: (typeof event.messages)[number], index: number) =>
|
|
168
|
+
index >= held && stale(message);
|
|
169
|
+
const filtered = event.messages.some(removable)
|
|
170
|
+
? event.messages.filter((message, index) => !removable(message, index))
|
|
162
171
|
: event.messages;
|
|
163
172
|
const ordered = chronologicalRetainedTail(filtered, ctx, stale);
|
|
164
173
|
if (ordered !== event.messages) {
|
|
@@ -167,6 +176,10 @@ function registerReminderFilter(
|
|
|
167
176
|
return;
|
|
168
177
|
});
|
|
169
178
|
}
|
|
179
|
+
/** A host without the capability ignores the field; one that has it stores why no block was made. */
|
|
180
|
+
function providerSummaryField(request: ProviderSummaryRequest | undefined) {
|
|
181
|
+
return request === undefined ? {} : { providerSummary: request };
|
|
182
|
+
}
|
|
170
183
|
function automaticDraft(
|
|
171
184
|
pi: ExtensionAPI,
|
|
172
185
|
ctx: ExtensionContext,
|
|
@@ -175,6 +188,7 @@ function automaticDraft(
|
|
|
175
188
|
readonly tokensBefore: number;
|
|
176
189
|
readonly reason: string;
|
|
177
190
|
readonly firstKeptEntryId?: string | undefined;
|
|
191
|
+
readonly providerSummary?: ProviderSummaryRequest | undefined;
|
|
178
192
|
},
|
|
179
193
|
) {
|
|
180
194
|
let firstKeptEntryId = recovery.firstKeptEntryId;
|
|
@@ -192,6 +206,7 @@ function automaticDraft(
|
|
|
192
206
|
firstKeptEntryId,
|
|
193
207
|
tokensBefore: recovery.tokensBefore,
|
|
194
208
|
details: { posthorse: 1, reason: recovery.reason },
|
|
209
|
+
...providerSummaryField(recovery.providerSummary),
|
|
195
210
|
},
|
|
196
211
|
};
|
|
197
212
|
}
|
|
@@ -280,6 +295,7 @@ function thresholdFallback(
|
|
|
280
295
|
toolTokens: toolTokens(),
|
|
281
296
|
pendingMessages: event.context.pendingMessages,
|
|
282
297
|
notes: capacity.notes(ctx),
|
|
298
|
+
providerSummary: hostMakesProviderSummary(ctx.model),
|
|
283
299
|
},
|
|
284
300
|
ownerQuestionRegistered,
|
|
285
301
|
);
|
|
@@ -298,6 +314,7 @@ function thresholdFallback(
|
|
|
298
314
|
summary: recovery.handoff,
|
|
299
315
|
firstKeptEntryId: recovery.firstKeptEntryId ?? null,
|
|
300
316
|
details: { posthorse: 1, reason: "threshold" },
|
|
317
|
+
...providerSummaryField(recovery.providerSummary),
|
|
301
318
|
},
|
|
302
319
|
],
|
|
303
320
|
continue: event.continue,
|
|
@@ -341,6 +358,8 @@ function prepareAutomatic(
|
|
|
341
358
|
toolTokens: capacity.toolTokens(),
|
|
342
359
|
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
343
360
|
notes: capacity.notes(ctx),
|
|
361
|
+
// Pi makes no block at an overflow, so the tail keeps its usual start there.
|
|
362
|
+
providerSummary: event.reason !== "overflow" && hostMakesProviderSummary(ctx.model),
|
|
344
363
|
},
|
|
345
364
|
ownerQuestionRegistered,
|
|
346
365
|
);
|
|
@@ -410,12 +429,14 @@ export function registerRollover(
|
|
|
410
429
|
pi.on("session_start", () => {
|
|
411
430
|
resetTurn();
|
|
412
431
|
reminders.reset();
|
|
432
|
+
resources.guidance.reset();
|
|
413
433
|
});
|
|
414
434
|
pi.on("session_tree", () => {
|
|
415
435
|
reminders.reset();
|
|
436
|
+
resources.guidance.reset();
|
|
416
437
|
});
|
|
417
438
|
registerReminderFilter(pi, policy, reminders);
|
|
418
|
-
registerGuidance(pi, policy);
|
|
439
|
+
registerGuidance(pi, policy, resources.guidance);
|
|
419
440
|
registerAutomaticRollover(pi, policy, activeToolTokens, resources.notes);
|
|
420
441
|
pi.on("turn_end", (event, ctx) => {
|
|
421
442
|
const summary = resetRequests.take(event.toolResults);
|