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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-posthorse",
3
- "version": "0.10.22",
3
+ "version": "0.10.24",
4
4
  "type": "module",
5
5
  "description": "Posthorse: fresh context, same journey. No-summary context rollover for Pi, with sparse budget reminders, handoffs, notes, and history.",
6
6
  "keywords": [
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
- return {
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 unsupportedMessage(budget: Budget): string {
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
- 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. new_context remains available with a model-aware handoff limit.`;
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(budget: Budget | undefined, enabled: boolean): string {
98
+ function automaticGuidance(
99
+ budget: Budget | undefined,
100
+ enabled: boolean,
101
+ tools: GuidanceTools,
102
+ ): string {
71
103
  if (!enabled) {
72
- return "Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder. new_context remains available.";
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. new_context is an optional clean reset.`;
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(ctx: PolicyContext): string {
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. Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining. If usage is unknown, report it as unknown. No routine budget checks are needed.
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
- /** ponytail: native estimates omit request-local guidance; reserve it until post-transform usage is exposed. */
98
- export function guidanceTokens(ctx: PolicyContext): number {
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${buildGuidance(ctx)}\n</posthorse>`,
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 promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4) + guidanceTokens(ctx);
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 +
@@ -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(`${unsupportedMessage(budget)} ${configured}`, [], display);
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 { buildGuidance } from "./budget.ts";
3
- import type { SnapshotReader } from "./contracts.ts";
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 function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void {
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 = buildGuidance(policy(ctx));
257
+ const guidance = windowGuidance.deliver(ctx, policy(ctx));
16
258
  const system =
17
259
  event.messages.findLast(
18
260
  (message) =>
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
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 (existsSync(notePath(current, path))) {
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.
@@ -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(path: string, signal?: AbortSignal): Promise<NoteAddress | undefined> {
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, options.signal);
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 relative = requireValue(params.path, "path", params.op);
96
- const target = await session.read(relative, { scope: params.scope, signal: access.signal });
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(session, { ...params, scope: scopeDirectory(target.address) }, access);
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: Type.Object({
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?.notePath ?? resTarget?.notePath;
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);