pi-posthorse 0.10.22 → 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/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/recovery-note-touches.ts +14 -1
- package/src/rollover.ts +5 -2
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.
|
|
@@ -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/rollover.ts
CHANGED
|
@@ -17,7 +17,7 @@ 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
22
|
import {
|
|
23
23
|
hostMakesProviderSummary,
|
|
@@ -31,6 +31,7 @@ import type { NotesRecovery } from "./note-evidence.ts";
|
|
|
31
31
|
type RolloverResources = {
|
|
32
32
|
readonly resetPages: () => void;
|
|
33
33
|
readonly notes: (ctx: ExtensionContext) => NotesRecovery | undefined;
|
|
34
|
+
readonly guidance: WindowGuidance;
|
|
34
35
|
};
|
|
35
36
|
import {
|
|
36
37
|
countRetainedMessages,
|
|
@@ -428,12 +429,14 @@ export function registerRollover(
|
|
|
428
429
|
pi.on("session_start", () => {
|
|
429
430
|
resetTurn();
|
|
430
431
|
reminders.reset();
|
|
432
|
+
resources.guidance.reset();
|
|
431
433
|
});
|
|
432
434
|
pi.on("session_tree", () => {
|
|
433
435
|
reminders.reset();
|
|
436
|
+
resources.guidance.reset();
|
|
434
437
|
});
|
|
435
438
|
registerReminderFilter(pi, policy, reminders);
|
|
436
|
-
registerGuidance(pi, policy);
|
|
439
|
+
registerGuidance(pi, policy, resources.guidance);
|
|
437
440
|
registerAutomaticRollover(pi, policy, activeToolTokens, resources.notes);
|
|
438
441
|
pi.on("turn_end", (event, ctx) => {
|
|
439
442
|
const summary = resetRequests.take(event.toolResults);
|