pi-goal-x 0.31.5 → 0.31.6

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  <a href="https://pi.dev/packages?type=extension" target="_blank" rel="noopener noreferrer">
7
7
  <picture>
8
8
  <source media="(prefers-color-scheme: dark)" srcset="assets/badge-dark.svg">
9
- <img src="assets/badge-light.svg" alt="TOP 0.3% of Pi coding agent extensions: #7 of 3,352 by downloads · Sep 16, 2026 (best recorded rank)" width="480">
9
+ <img src="assets/badge-light.svg" alt="TOP 0.3% of Pi coding agent extensions: #7 of 3,318 by downloads · Sep 17, 2026 (best recorded rank)" width="480">
10
10
  </picture>
11
11
  </a>
12
12
  </div>
@@ -168,3 +168,7 @@ Existing producers that directly send `triggerTurn`/`followUp` messages still ru
168
168
  ## License
169
169
 
170
170
  MIT
171
+
172
+ ### Prompt caching
173
+
174
+ Goal state is refreshed at the request tail while the system prompt and conversation prefix stay stable. Pi retains control of provider cache settings. See [prompt caching](docs/prompt-caching.md) for explicit-cache handling, validation, and cache invalidation boundaries.
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="480" height="54" viewBox="0 0 480 54" role="img" aria-label="TOP 0.3% of Pi coding agent extensions: #7 of 3,352 by downloads · Sep 16, 2026 (best recorded rank)">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="480" height="54" viewBox="0 0 480 54" role="img" aria-label="TOP 0.3% of Pi coding agent extensions: #7 of 3,318 by downloads · Sep 17, 2026 (best recorded rank)">
2
2
  <!-- Dark mode -->
3
3
  <style>
4
4
  .frame { fill: #14181f; stroke: #2a3040; stroke-width: 1; }
@@ -13,6 +13,6 @@
13
13
  <text class="rank" x="18" y="34">TOP 0.3%</text>
14
14
  <line class="divider" x1="128" y1="13" x2="128" y2="41"/>
15
15
  <text class="label" x="143" y="25">of Pi coding agent extensions</text>
16
- <text class="meta" x="143" y="41">#7 of 3,352 by downloads · Sep 16, 2026</text>
16
+ <text class="meta" x="143" y="41">#7 of 3,318 by downloads · Sep 17, 2026</text>
17
17
  </a>
18
18
  </svg>
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="480" height="54" viewBox="0 0 480 54" role="img" aria-label="TOP 0.3% of Pi coding agent extensions: #7 of 3,352 by downloads · Sep 16, 2026 (best recorded rank)">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="480" height="54" viewBox="0 0 480 54" role="img" aria-label="TOP 0.3% of Pi coding agent extensions: #7 of 3,318 by downloads · Sep 17, 2026 (best recorded rank)">
2
2
  <!-- Light mode -->
3
3
  <style>
4
4
  .frame { fill: #f6f8fa; stroke: #d0d7de; stroke-width: 1; }
@@ -13,6 +13,6 @@
13
13
  <text class="rank" x="18" y="34">TOP 0.3%</text>
14
14
  <line class="divider" x1="128" y1="13" x2="128" y2="41"/>
15
15
  <text class="label" x="143" y="25">of Pi coding agent extensions</text>
16
- <text class="meta" x="143" y="41">#7 of 3,352 by downloads · Sep 16, 2026</text>
16
+ <text class="meta" x="143" y="41">#7 of 3,318 by downloads · Sep 17, 2026</text>
17
17
  </a>
18
18
  </svg>
@@ -19,7 +19,7 @@ handlers from their dedicated modules:
19
19
  | `goal-task-tools.ts` | `set_goal_tasks` / `update_goal_task` executors plus flat parent-linked conversion, id-stable merge, `countTasks` |
20
20
  | `goal-task-confirmation.ts` | Task-only result boundary (`{decision}`, no auditor toggle) with neutral Confirm task list / Keep current tasks labels |
21
21
  | `goal-commands.ts` | The curated fourteen-command palette and its handlers |
22
- | `goal-events.ts` | The 14 lifecycle event handlers (`context`, `turn_start`, `tool_call`, `tool_execution_end`, `turn_end`, `message_end`, `session_start`, `session_before_compact`, `session_compact`, `session_tree`, `before_agent_start`, `agent_end`, `agent_settled`, `session_shutdown`) |
22
+ | `goal-events.ts` | Lifecycle and provider event handlers (`context`, `before_provider_request`, `turn_start`, `tool_call`, `tool_execution_end`, `turn_end`, `message_end`, `session_start`, `session_before_compact`, `session_compact`, `session_tree`, `before_agent_start`, `agent_end`, `agent_settled`, `session_shutdown`) |
23
23
  | `goal-widget.ts` | Terminal input keybindings (Esc pause / abort-audit, Ctrl+Shift+T overlay) and the hidden debug helpers |
24
24
  | `goal-format.ts` | Pure formatting/message-introspection helpers and renderers |
25
25
  | `goal-service.ts` | `GoalService` — the sole mutation boundary: ordered reconcile → id/focus-revision validation → clone-mutate → write/archive → ledger → memory commit → returned effects |
@@ -341,3 +341,5 @@ operable; `/goal-clear` confirms; task confirmation uses neutral labels;
341
341
  completion commits are failure-checked; and cross-process mutations are
342
342
  serialized with persisted revisions plus per-goal filesystem locks that
343
343
  return typed conflicts to stale writers instead of overwriting blindly.
344
+
345
+ Prompt layout and explicit provider cache handling are documented in [prompt caching](prompt-caching.md). Live goal state is constructed in the context hook; before_agent_start performs lifecycle preflight without modifying the system prompt.
@@ -0,0 +1,21 @@
1
+ # Prompt caching
2
+
3
+ pi-goal-x preserves the provider's reusable prompt prefix during normal user turns, autonomous checkpoints, and tool loops. No extra extension setting is required. Pi's provider configuration still determines whether caching is enabled, its retention period, and model support.
4
+
5
+ The request layout is:
6
+
7
+ 1. Host system prompt and tool schemas.
8
+ 2. Conversation history, with each goal checkpoint normalized to a tiny, stable trigger in its original position.
9
+ 3. One request-only goal-state message containing the current objective, tasks, lifecycle rules, usage, budget, scheduling state, and applicable recovery/auditor reminders.
10
+
11
+ Changing goal state never rewrites the system prompt or historical messages. The full goal-state message is not persisted into the session. Old checkpoints remain until Pi compacts history; retaining small markers avoids shifting the prefix on every continuation. Display-only auditor messages are consistently excluded, and tool calls stay adjacent to their results.
12
+
13
+ For Anthropic-style explicit caching, the extension moves Pi's existing cache_control breakpoint from the transient state message onto the preceding cacheable history block. The Bedrock equivalent moves the cachePoint. Both preserve the configured TTL and do not add breakpoints. Payloads without an existing explicit marker—including caching disabled and implicit-cache providers—are unchanged. The extension does not set cache keys, enable paid extended retention, or replace provider defaults.
14
+
15
+ Execution tools already use an idempotent profile: goal progress, focus, and lifecycle changes do not change schemas. Explicit switches into/out of drafting and changes to disableTasks can change tools and legitimately rebuild the cache. Drafting messages are appended to conversation history. Isolated auditor and Oracle sessions already use fixed instructions/tool profiles and place goal-specific evidence in their user messages; their tool loops retain history. They have separate sessions and use Pi's cache defaults. Opting these sessions into project resources also opts into any behavior from those resources.
16
+
17
+ Cache reuse can still be interrupted by Pi compaction, branch/session changes, model/provider switches, other extensions changing earlier content, changed tool schemas, provider expiry, minimum-length or breakpoint-lookback limits, or routing. The extension cannot guarantee a hit rate or a particular cost reduction. The reported issue used Pi 0.85.1; this change is validated against the repository's supported installed SDK, without expanding its peer dependency range.
18
+
19
+ Validation includes consecutive request-prefix regressions; actual Anthropic, Anthropic-compatible Chat Completions, OpenAI Chat Completions and Responses serialization with short/long/disabled retention; Bedrock payload-shape checks; lifecycle/tool-pair safety tests; and full SDK parent/auditor/Oracle payload captures. These tests stop before HTTP dispatch. They verify request construction, not live provider hit rates.
20
+
21
+ Provider references: [Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) describes exact-prefix matching and explicit breakpoints; [DeepSeek context caching](https://api-docs.deepseek.com/guides/kv_cache/) describes its automatic prefix cache and usage counters.
@@ -1,4 +1,4 @@
1
- import { schedulerSummary } from "./goal-scheduler-state.ts";
1
+ import { cacheGoalHistory } from "./goal-prompt-cache.ts";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
4
  GOAL_EVENT_ENTRY,
@@ -34,45 +34,21 @@ import type { GoalCore } from "./goal-state.ts";
34
34
  import { filterGoalSessionContext } from "./goal-session-safety.ts";
35
35
  import type { GoalMutationOutcome } from "./goal-service.ts";
36
36
 
37
- /**
38
- * Issue #30: provider-context checkpoint compaction (pure helper).
39
- *
40
- * Every historical checkpoint message is redundant: its authoritative state is
41
- * reconstructed from goal storage and injected once per turn by
42
- * before_agent_start. Normal provider requests therefore retain at most ONE
43
- * checkpoint marker — the latest — rewritten to the tiny bounded v2 trigger
44
- * content. Keeping one user-role turn-start marker avoids provider edge cases
45
- * where removing it would leave the request ending on an assistant or tool
46
- * result. Audit events, user messages, assistant messages, and tool results
47
- * pass through untouched.
48
- */
37
+ /** Normalize checkpoints independently: deleting old markers shifts the cached prefix. */
49
38
  export function compactGoalCheckpointContext(
50
- messages: readonly unknown[],
51
- currentGoal: GoalRecord | null,
39
+ messages: readonly unknown[],
40
+ _currentGoal: GoalRecord | null,
52
41
  ): unknown[] | null {
53
- let lastCheckpointIndex = -1;
54
- let checkpointGoalId: string | null = null;
55
- const checkpoints: number[] = [];
56
- // Parse each message once; retain ordinary messages and rewrite only the last marker.
42
+ let output: unknown[] | null = null;
57
43
  for (let i = 0; i < messages.length; i++) {
58
- const id = goalEventMessageId(messages[i] as {customType?: string; details?: unknown; content?: unknown});
59
- if (id !== null) { checkpoints.push(i); lastCheckpointIndex = i; checkpointGoalId = id; }
44
+ const message = messages[i] as { customType?: string; details?: unknown; content?: unknown };
45
+ const id = goalEventMessageId(message);
46
+ if (id === null) { output?.push(message); continue; }
47
+ output ??= messages.slice(0, i);
48
+ output.push({ ...message, content: checkpointTriggerPrompt(id), display: false,
49
+ details: { version: 2, kind: "checkpoint", goalId: id } });
60
50
  }
61
- if (checkpointGoalId === null) return null;
62
- const output: unknown[] = [];
63
- let start = 0;
64
- for (const index of checkpoints) {
65
- for (let i = start; i < index; i++) output.push(messages[i]);
66
- start = index + 1;
67
- }
68
- const message = messages[lastCheckpointIndex] as Record<string, unknown>;
69
- output.push({...message, content: checkpointTriggerPrompt(checkpointGoalId), display: false, details: {
70
- version: 2,
71
- kind: currentGoal?.id === checkpointGoalId && currentGoal?.status === "active" ? "checkpoint" : "stale",
72
- goalId: checkpointGoalId, currentGoalId: currentGoal?.id ?? null, currentStatus: currentGoal?.status ?? null,
73
- }});
74
- for (let i = start; i < messages.length; i++) output.push(messages[i]);
75
- return output;
51
+ return output;
76
52
  }
77
53
 
78
54
  /**
@@ -84,22 +60,18 @@ export function compactGoalCheckpointContext(
84
60
  */
85
61
  export function registerGoalEvents(core: GoalCore): void {
86
62
  const { pi } = core;
63
+ let liveContent: string | undefined;
64
+ pi.on("before_provider_request", event => cacheGoalHistory(event.payload, liveContent));
87
65
  let continuationAfterSettleFor: string | null = null;
88
66
  let networkErrorRecoveryAfterSettleFor: string | null = null;
89
67
 
90
68
  pi.on("context", async (event, ctx) => {
91
69
  const filtered = filterGoalSessionContext(event.messages);
92
70
  const messages = compactGoalCheckpointContext(filtered ?? event.messages, core.state.goal) ?? filtered;
93
- if (core.scheduler.needsLiveContext() && core.state.goal) {
94
- const goal = core.state.goal;
95
- const settings = loadGoalSettings(ctx.cwd);
96
- // Custom-message runs bypass before_agent_start. Refresh mutable scheduling
97
- // state, but do not repeat policy/objective already in the inherited prompt.
98
- const content = ctx.getSystemPrompt?.().includes(`[PI GOAL ACTIVE goalId=${goal.id}]`)
99
- ? `[CURRENT EXECUTION STATE goalId=${goal.id}]\nStatus: ${goal.status}; autoContinue: ${goal.autoContinue}.\n${schedulerSummary(goal.scheduler, settings.maxAutonomousRuns)}`
100
- : goalPrompt(goal, settings);
101
- const live = { role: "custom" as const, customType: "pi-goal-live-context", content, display: false, timestamp: Date.now() };
102
- return { messages: [live, ...(messages ?? event.messages)] as typeof event.messages };
71
+ const content = liveContent = currentGoalContext(ctx);
72
+ if (content) {
73
+ const live = { role: "custom" as const, customType: "pi-goal-live-context", content, display: false, timestamp: 0 };
74
+ return { messages: [...(messages ?? event.messages), live] as typeof event.messages };
103
75
  }
104
76
  return messages === null ? undefined : { messages: messages as typeof event.messages };
105
77
  });
@@ -343,16 +315,9 @@ export function registerGoalEvents(core: GoalCore): void {
343
315
  });
344
316
 
345
317
  pi.on("before_agent_start", async (event, ctx) => {
346
- core.scheduler.prepare();
347
318
  core.advanceTurnSeq();
348
319
  if (!hasActiveDraft(core)) core.installGoalToolProfile(!loadGoalSettings(ctx.cwd).disableTasks);
349
- const currentSystemPrompt = () => ctx.getSystemPrompt?.() || event.systemPrompt;
350
320
  const incomingGoalId = extractGoalIdFromInjectedMessage(event.prompt ?? "");
351
- // Several prompt enrichments may need the same ledger snapshot. Keep one
352
- // local read for this hook instead of repeatedly traversing the cached
353
- // ledger when rejection and post-compaction steering overlap.
354
- let promptLedger: ReturnType<typeof readGoalLedger> | undefined;
355
- const getPromptLedger = () => promptLedger ??= { events: core.state.goal ? goalRuntimeEvents(ctx, core.state.goal.id) : [], malformed: 0 };
356
321
 
357
322
  // If this turn was triggered by a hidden goal checkpoint that no longer
358
323
  // matches the active goal, abort the whole turn instead of letting the
@@ -371,9 +336,7 @@ export function registerGoalEvents(core: GoalCore): void {
371
336
  ctx.abort?.();
372
337
  } catch {}
373
338
  core.updateUI(ctx);
374
- return {
375
- systemPrompt: `${currentSystemPrompt()}\n\n${staleContinuationPrompt(incomingGoalId, core.state.goal)}`,
376
- };
339
+ return;
377
340
  }
378
341
  core.runtime.setCheckpoint(null);
379
342
  } else {
@@ -385,22 +348,29 @@ export function registerGoalEvents(core: GoalCore): void {
385
348
  networkErrorRecoveryAfterSettleFor = null;
386
349
  }
387
350
 
351
+ core.reconcileFocusedGoalFromDisk(ctx);
352
+ core.runningGoalId = core.state.goal?.status === "active" ? core.state.goal.id : null;
353
+ });
354
+
355
+ /** Request-only state: no live counters, focus, or reminders enter the system prefix. */
356
+ function currentGoalContext(ctx: ExtensionContext): string | undefined {
357
+ const checkpoint = core.runtime.getCheckpointGoalId();
358
+ if (checkpoint !== null && !core.isActionableContinuationGoal(checkpoint)) {
359
+ return staleContinuationPrompt(checkpoint, core.state.goal);
360
+ }
361
+ // Several prompt enrichments may need the same ledger snapshot. Keep one
362
+ // local read for this hook instead of repeatedly traversing the cached
363
+ // ledger when rejection and post-compaction steering overlap.
364
+ let promptLedger: ReturnType<typeof readGoalLedger> | undefined;
365
+ const getPromptLedger = () => promptLedger ??= { events: core.state.goal ? goalRuntimeEvents(ctx, core.state.goal.id) : [], malformed: 0 };
366
+
388
367
  if (!core.state.goal) {
389
- core.runningGoalId = null;
390
368
  const openCount = otherOpenGoalCount(core.goalsById, null);
391
369
  if (openCount > 0) {
392
- return { systemPrompt: `${currentSystemPrompt()}\n\n${unfocusedOpenGoalsPrompt(openCount)}` };
370
+ return unfocusedOpenGoalsPrompt(openCount);
393
371
  }
394
372
  return;
395
373
  }
396
- core.reconcileFocusedGoalFromDisk(ctx);
397
- if (!core.state.goal) {
398
- core.runningGoalId = null;
399
- const openCount = otherOpenGoalCount(core.goalsById, null);
400
- if (openCount > 0) return { systemPrompt: `${currentSystemPrompt()}\n\n${unfocusedOpenGoalsPrompt(openCount)}` };
401
- return;
402
- }
403
- core.runningGoalId = core.state.goal.status === "active" ? core.state.goal.id : null;
404
374
  if (core.state.goal.status === "complete") return;
405
375
  if (core.state.goal.status === "paused") {
406
376
  const current = core.state.goal;
@@ -421,9 +391,7 @@ export function registerGoalEvents(core: GoalCore): void {
421
391
  } catch {
422
392
  // Ledger read failure should not break the prompt
423
393
  }
424
- return {
425
- systemPrompt: `${currentSystemPrompt()}\n\n[PI GOAL PAUSED goalId=${current.id}]\n${untrustedObjectiveBlock(current)}${pauseExtras.join("\n")}${auditorExtra}\n\nThe goal is paused. Do not autonomously continue substantive work unless the user resumes it with /goal-resume. If the user explicitly asks to finish the paused goal and the objective is already satisfied based on available evidence, you may call update_goal({status: "complete"}). To abandon a goal, the user runs /goal-clear. Do not report the goal blocked in response to a pause.`,
426
- };
394
+ return `[PI GOAL PAUSED goalId=${current.id}]\n${untrustedObjectiveBlock(current)}${pauseExtras.join("\n")}${auditorExtra}\n\nThe goal is paused. Do not autonomously continue substantive work unless the user resumes it with /goal-resume. If the user explicitly asks to finish the paused goal and the objective is already satisfied based on available evidence, you may call update_goal({status: "complete"}). To abandon a goal, the user runs /goal-clear. Do not report the goal blocked in response to a pause.`;
427
395
  }
428
396
  // Token-budget-limited goals get one-time wrap-up steering: summarize,
429
397
  // do not start new substantive work, never claim completion unless real.
@@ -440,13 +408,11 @@ export function registerGoalEvents(core: GoalCore): void {
440
408
  const reminder = core.runtime.consumePostBudgetReminder()
441
409
  ? `\n\n[TOKEN BUDGET REACHED goalId=${limitedGoal.id}]\nThe goal's token budget has been reached${budgetText ? ` (${budgetText}${balanceText})` : ""}. Wrap up the current work in one final response: summarize what was accomplished and what remains, do not start new substantive work, and do not claim the goal is complete unless it actually is. To continue, the user must raise or remove the budget and resume the goal.`
442
410
  : "";
443
- return {
444
- systemPrompt: `${currentSystemPrompt()}\n\n[PI GOAL BUDGET LIMITED goalId=${limitedGoal.id}]\n${untrustedObjectiveBlock(limitedGoal)}${budgetText ? `\n${budgetText}` : ""}${reminder}`,
445
- };
411
+ return `[PI GOAL BUDGET LIMITED goalId=${limitedGoal.id}]\n${untrustedObjectiveBlock(limitedGoal)}${budgetText ? `\n${budgetText}` : ""}${reminder}`;
446
412
  }
447
413
  if (core.state.goal.status === "blocked") {
448
414
  const blocked = core.state.goal;
449
- return {systemPrompt: `${currentSystemPrompt()}\n\n[PI GOAL BLOCKED goalId=${blocked.id}]\n${untrustedObjectiveBlock(blocked)}\nBlocker: ${blocked.pauseReason ?? "unspecified"}\nThe goal is blocked; the user must run /goal-resume before goal work continues.`};
415
+ return `[PI GOAL BLOCKED goalId=${blocked.id}]\n${untrustedObjectiveBlock(blocked)}\nBlocker: ${blocked.pauseReason ?? "unspecified"}\nThe goal is blocked; the user must run /goal-resume before goal work continues.`;
450
416
  }
451
417
  const activeGoal = core.state.goal;
452
418
  const settings = loadGoalSettings(ctx.cwd);
@@ -466,7 +432,7 @@ export function registerGoalEvents(core: GoalCore): void {
466
432
  }
467
433
  if (core.runtime.isPostCompactReminderPending() && shouldInjectPostCompactReminder({ pending: true, goal: activeGoal })) {
468
434
  core.runtime.clearPostCompactReminder();
469
- // PR E §62: post-compaction DELTA — the active system goal block already
435
+ // PR E §62: post-compaction DELTA — the current goal block already
470
436
  // carries objective/policy/task gate/contract; inject only what
471
437
  // compaction may have lost. Falls back to a generic note on ledger
472
438
  // read failure.
@@ -479,8 +445,8 @@ export function registerGoalEvents(core: GoalCore): void {
479
445
  prompt = `${prompt}\n\n[POST-COMPACTION RESYNC goalId=${core.state.goal.id}]\nThe conversation was just compacted. Re-read the objective and continue from the actual artifacts/state; do not rely on memory of the prior chat.`;
480
446
  }
481
447
  }
482
- return { systemPrompt: `${currentSystemPrompt()}\n\n${prompt}` };
483
- });
448
+ return prompt;
449
+ }
484
450
 
485
451
  pi.on("agent_end", async (event, ctx) => {
486
452
  const endedGoalId = core.runningGoalId;
@@ -0,0 +1,49 @@
1
+ import { asRecord } from "./goal-record.ts";
2
+
3
+ /**
4
+ * Pi marks the final user block for explicit caching. Our request-only state
5
+ * never enters history, so that block cannot be reused on the next request.
6
+ * Move that existing breakpoint to the preceding cacheable history block.
7
+ * Implicit caches and cacheRetention=none have no marker and are untouched.
8
+ * Reuse the provider's TTL and marker count rather than enabling caching here.
9
+ */
10
+ export function cacheGoalHistory(payload: unknown, liveContent: string | undefined): unknown {
11
+ const root = asRecord(payload);
12
+ if (!liveContent || !Array.isArray(root?.messages)) return undefined;
13
+ const messages = root.messages;
14
+ const last = asRecord(messages.at(-1));
15
+ if (last?.role !== "user" || !Array.isArray(last.content)) return undefined;
16
+ // Bedrock represents its breakpoint as a separate content block.
17
+ const bedrockPoint = asRecord(last.content.at(-1));
18
+ if (asRecord(bedrockPoint?.cachePoint) && asRecord(last.content.at(-2))?.text === liveContent) {
19
+ for (let i = messages.length - 2; i >= 0; i--) {
20
+ const previous = asRecord(messages[i]);
21
+ if (!Array.isArray(previous?.content) || previous.content.length === 0) continue;
22
+ if (!previous.content.some(block => asRecord(block)?.cachePoint)) previous.content.push(bedrockPoint);
23
+ last.content.pop();
24
+ return payload;
25
+ }
26
+ return undefined;
27
+ }
28
+ const tail = asRecord(last.content.at(-1));
29
+ if (tail?.type !== "text" || tail.text !== liveContent || !asRecord(tail.cache_control)) return undefined;
30
+ for (let i = messages.length - 1; i >= 0; i--) {
31
+ const message = asRecord(messages[i]);
32
+ if (!message || !["user", "assistant", "tool"].includes(String(message.role))) continue;
33
+ if (typeof message.content === "string" && message.content.length > 0) {
34
+ message.content = [{ type: "text", text: message.content, cache_control: tail.cache_control }];
35
+ delete tail.cache_control;
36
+ return payload;
37
+ }
38
+ if (!Array.isArray(message.content)) continue;
39
+ for (let j = message.content.length - 1; j >= 0; j--) {
40
+ const block = asRecord(message.content[j]);
41
+ // Thinking blocks cannot carry cache_control. Never split/reorder tool pairs.
42
+ if (!block || block === tail || !["text", "image", "tool_use", "tool_result"].includes(String(block.type))) continue;
43
+ block.cache_control ??= tail.cache_control;
44
+ delete tail.cache_control;
45
+ return payload;
46
+ }
47
+ }
48
+ return undefined;
49
+ }
@@ -11,8 +11,6 @@ export class GoalScheduler {
11
11
  private timer: ReturnType<typeof setTimeout> | undefined;
12
12
  private ctx: ExtensionContext | undefined;
13
13
  private inRun = false;
14
- private prepared = false;
15
- private liveContext = false;
16
14
  private armedGeneration: string | undefined;
17
15
  private declared = false;
18
16
  private runGoalId: string | undefined;
@@ -183,14 +181,10 @@ export class GoalScheduler {
183
181
  } catch (error) { return { content: [{ type: "text", text: `Scheduling decision NOT saved: ${error instanceof Error ? error.message : String(error)}` }], details: { error: true }, terminate: false }; }
184
182
  }
185
183
 
186
- prepare(): void { this.prepared = true; }
187
- needsLiveContext(): boolean { return this.liveContext; }
188
-
189
184
  /** One logical run can contain multiple agent_start events during native retry/compaction. */
190
185
  begin(ctx: ExtensionContext): void {
191
186
  this.ctx = ctx;
192
187
  if (this.inRun) { this.core.runningGoalId = this.core.state.goal?.id ?? null; return; }
193
- this.liveContext = !this.prepared; this.prepared = false;
194
188
  this.inRun = true; this.declared = false; this.denied = false;
195
189
  this.runGoalId = this.core.state.goal?.id;
196
190
  this.cancelTimer(); this.core.runtime.clearContinuationTimer();
@@ -11,8 +11,8 @@ export const MAX_PROMPT_FRAGMENT_CHARS = 10_000;
11
11
 
12
12
  /**
13
13
  * Issue #30: a persisted continuation checkpoint is a tiny trigger record, not
14
- * a full prompt. The authoritative goal state is injected once per turn by
15
- * before_agent_start; the persisted marker only needs to carry the goal id.
14
+ * a full prompt. The authoritative goal state is injected at the request tail by
15
+ * the context hook; the persisted marker only needs to carry the goal id.
16
16
  */
17
17
  export const CHECKPOINT_TRIGGER_MAX_CHARS = 160;
18
18
 
@@ -265,8 +265,8 @@ export function objectiveEditedPrompt(goal: GoalRecord): string {
265
265
  * Deprecated compatibility wrapper (issue #30). The full continuation prompt
266
266
  * was the defect: every auto-continue turn persisted the whole objective/task/
267
267
  * contract/policy block as a custom session message, growing sessions by
268
- * ~6.4K chars per turn. The authoritative state is now injected once per turn
269
- * by before_agent_start; the persisted follow-up is only a tiny trigger.
268
+ * ~6.4K chars per turn. The authoritative state is now injected at the request tail
269
+ * by the context hook; the persisted follow-up is only a tiny trigger.
270
270
  *
271
271
  * Kept for one minor release so external call sites migrate explicitly.
272
272
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.31.5",
3
+ "version": "0.31.6",
4
4
  "description": "Adds /goal to pi: conversational goal planning, flexible or ordered goals, persistent progress, and an independent completion auditor.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",
@@ -34,6 +34,7 @@
34
34
  "docs/agent-flow-design.md",
35
35
  "docs/agentic-runtime-prd.md",
36
36
  "docs/architecture.md",
37
+ "docs/prompt-caching.md",
37
38
  "docs/pi-autoresearch-survey.md",
38
39
  "docs/pi-goal-x-unified-dashboard-plan.md",
39
40
  "docs/unified-dashboard.md",