@quantmind-br/pi-goal-x 0.35.0 → 0.35.1

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.
@@ -6,7 +6,7 @@ The request layout is:
6
6
 
7
7
  1. Host system prompt and tool schemas.
8
8
  2. Conversation history, with each goal checkpoint normalized to a tiny, stable trigger in its original position.
9
- 3. Request-only goal policy plus compact counter/context snapshots, replayed at verified history anchors when safe. Current policy includes the objective, tasks, lifecycle rules, limits, scheduling state and applicable recovery/auditor reminders.
9
+ 3. Request-only goal policy, then a task snapshot, then compact counter/context snapshots, replayed at verified history anchors when safe. Current policy includes the objective, lifecycle rules, the task gate, limits, scheduling state and applicable recovery/auditor reminders. The task snapshot carries task progress: the `[TASK LIST]` header with completed/total counts, the current task with its contract, pending tasks, and the count of omitted pending tasks.
10
10
 
11
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
12
 
@@ -24,7 +24,7 @@ Validation includes consecutive request-prefix regressions; actual Anthropic, An
24
24
 
25
25
  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.
26
26
 
27
- Active requests split policy from compact counter/context snapshots. Already-sent tails are replayed at verified history anchors so implicit-cache requests extend their previous prefix. Policy changes (including budget/run-limit removal), rewritten history and unsafe tool-result boundaries reset retention. At most 32 tails and 4 KiB of counter text are retained per session, across at most 16 session/model identities; no full-state tails are persisted. Unchanged requests add no tail. Older snapshots are historical, and current policy takes precedence over cache reuse.
27
+ Active requests split policy, task snapshots and compact counter/context snapshots into separate tails, always in that order. Already-sent tails are replayed at verified history anchors so implicit-cache requests extend their previous prefix. Each kind is appended only when it differs from its previous copy, so starting, completing or skipping a task, or changing the current task, appends one task snapshot instead of resetting retention (#108 measured four prefix breaks across one three-task run; now only the task setup that enables the completion gate resets). With `blockCompletion` enabled, the policy carries a fixed TASK GATE line that defers to the latest task snapshot, so the pending count never changes policy. Policy changes (including budget/run-limit removal and enabling or disabling the task gate), dropping the task list, rewritten history and unsafe tool-result boundaries reset retention. At most 32 tails, 16 KiB of task snapshots and 4 KiB of counter text are retained per session, across at most 16 session/model identities; exceeding a bound performs one full reset. No full-state tails are persisted. Unchanged requests add no tail. Older task and counter snapshots are historical: the latest snapshot supersedes earlier ones, and current policy takes precedence over cache reuse.
28
28
 
29
29
  Explicit-marker relocation skips all retained live text and tolerates Pi's trailing effort-only messages. Another extension's content-bearing suffix is left alone. Changing models/providers starts a separate retention identity. Unknown session identities do not retain history between requests.
30
30
 
@@ -108,8 +108,8 @@ export function registerGoalEvents(core: GoalCore): void {
108
108
  if (parts) {
109
109
  // Retain previously sent tails verbatim at their anchors so this
110
110
  // request extends the previous one instead of displacing its tail.
111
- // Only genuinely new state/counters content is appended.
112
- const { messages: retained } = liveRetention.apply(sessionKey, base, { state: parts.state, counters: parts.counters });
111
+ // Only genuinely new state, task, or counter content is appended.
112
+ const { messages: retained } = liveRetention.apply(sessionKey, base, { state: parts.state, tasks: parts.tasks, counters: parts.counters });
113
113
  return retained;
114
114
  }
115
115
  liveRetention.clear(sessionKey);
@@ -468,8 +468,8 @@ export function registerGoalEvents(core: GoalCore): void {
468
468
  });
469
469
 
470
470
  /** Request-only state: no live counters, focus, or reminders enter the system prefix. */
471
- /** Request-only goal state split into a rarely-changing policy block and per-turn counters. Other branches are stable-only. */
472
- function currentGoalContextParts(ctx: ExtensionContext): { state: string; counters?: string } | undefined {
471
+ /** Request-only goal state split into a rarely-changing policy block, a task snapshot, and per-turn counters. Other branches are stable-only. */
472
+ function currentGoalContextParts(ctx: ExtensionContext): { state: string; tasks?: string; counters?: string } | undefined {
473
473
  const checkpoint = core.runtime.getCheckpointGoalId();
474
474
  if (checkpoint !== null && (core.scheduler.isDenied() || !core.isActionableContinuationGoal(checkpoint))) {
475
475
  return { state: staleContinuationPrompt(checkpoint, core.state.goal) };
@@ -536,7 +536,7 @@ export function registerGoalEvents(core: GoalCore): void {
536
536
  }
537
537
  const activeGoal = core.state.goal;
538
538
  const settings = loadGoalSettings(ctx.cwd);
539
- const { state: policy, counters } = goalPromptParts(activeGoal, { ...settings, commands: core.loadedCommands }, ctx.getContextUsage?.());
539
+ const { state: policy, tasks, counters } = goalPromptParts(activeGoal, { ...settings, commands: core.loadedCommands }, ctx.getContextUsage?.());
540
540
  // Rare transitional steering rides in the stable block; per-turn usage and
541
541
  // scheduling counters stay in the volatile block so retention grows slowly.
542
542
  let state = policy;
@@ -568,7 +568,7 @@ export function registerGoalEvents(core: GoalCore): void {
568
568
  state = `${state}\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.`;
569
569
  }
570
570
  }
571
- return { state, counters };
571
+ return { state, tasks, counters };
572
572
  }
573
573
 
574
574
  pi.on("agent_end", async (event, ctx) => {
@@ -21,11 +21,15 @@ import { asRecord } from "./goal-record.ts";
21
21
  * edits), an unsafe insertion point, or overflow all perform one documented
22
22
  * full reset instead of silently shifting the prefix or corrupting message
23
23
  * structure.
24
+ *
25
+ * Task snapshots and usage counters are separate tail kinds. Each is appended
26
+ * only when it differs from its previous emission, so task progress extends
27
+ * the prefix instead of resetting it; only the policy block forces a reset.
24
28
  */
25
29
 
26
30
  export const LIVE_CONTEXT_TYPE = "pi-goal-live-context";
27
31
 
28
- export type LiveTailKind = "goal-state" | "goal-counters";
32
+ export type LiveTailKind = "goal-state" | "goal-tasks" | "goal-counters";
29
33
 
30
34
  /** A request-only tail message. Never persisted; the hook replays it per request. */
31
35
  export interface LiveMessage {
@@ -48,19 +52,28 @@ export const MAX_RETAINED_LIVE_TAILS = 32;
48
52
  export const MAX_TRACKED_SESSIONS = 16;
49
53
  /** Extra historical observations, excluding the single current policy block. */
50
54
  export const MAX_RETAINED_COUNTER_BYTES = 4096;
55
+ /**
56
+ * Retained task snapshots. Each task transition appends one snapshot, so a
57
+ * long task run stays prefix-stable until this bound forces a single reset.
58
+ */
59
+ export const MAX_RETAINED_TASK_BYTES = 16_384;
51
60
 
52
61
  export interface FreshLiveTails {
53
62
  state: string;
63
+ tasks?: string | undefined;
54
64
  counters?: string | undefined;
55
65
  }
56
66
 
57
- interface RetainedTail {
58
- anchorIndex: number;
59
- anchorFingerprint: string | null;
67
+ interface LiveBlock {
60
68
  content: string;
61
69
  kind: LiveTailKind;
62
70
  }
63
71
 
72
+ interface RetainedTail extends LiveBlock {
73
+ anchorIndex: number;
74
+ anchorFingerprint: string | null;
75
+ }
76
+
64
77
  interface SessionRetention {
65
78
  tails: RetainedTail[];
66
79
  lastFresh: FreshLiveTails | undefined;
@@ -68,8 +81,15 @@ interface SessionRetention {
68
81
  baseFingerprints: Array<string | null>;
69
82
  }
70
83
 
71
- function freshBlocks(fresh: FreshLiveTails): string[] {
72
- return fresh.counters === undefined ? [fresh.state] : [fresh.state, fresh.counters];
84
+ function freshBlocks(fresh: FreshLiveTails): LiveBlock[] {
85
+ const blocks: LiveBlock[] = [{ content: fresh.state, kind: "goal-state" }];
86
+ if (fresh.tasks) blocks.push({ content: fresh.tasks, kind: "goal-tasks" });
87
+ if (fresh.counters !== undefined) blocks.push({ content: fresh.counters, kind: "goal-counters" });
88
+ return blocks;
89
+ }
90
+
91
+ function kindBytes(blocks: readonly LiveBlock[], kind: LiveTailKind): number {
92
+ return blocks.reduce((n, block) => block.kind === kind ? n + Buffer.byteLength(block.content) : n, 0);
73
93
  }
74
94
 
75
95
  /** Full-message digest, or null (never a match) when serialization fails. */
@@ -144,8 +164,10 @@ export class LiveTailRetention {
144
164
  };
145
165
  // A changed state block supersedes every previously sent tail: start
146
166
  // fresh rather than leave a stale objective or a cancelled scheduling
147
- // instruction lingering mid-history.
167
+ // instruction lingering mid-history. A dropped task snapshot or counter
168
+ // block resets too, so its last copy never reads as the current one.
148
169
  if (session.lastFresh !== undefined && (session.lastFresh.state !== fresh.state
170
+ || (session.lastFresh.tasks !== undefined && !fresh.tasks)
149
171
  || (session.lastFresh.counters !== undefined && fresh.counters === undefined))) reset();
150
172
  // Tails anchored while history was empty would linger at the head once
151
173
  // real history arrives. Reset once so fresh tails anchor at the tail.
@@ -178,34 +200,32 @@ export class LiveTailRetention {
178
200
  }
179
201
  if (valid < session.tails.length) reset();
180
202
  const blocks = freshBlocks(fresh);
181
- // Append only the suffix that differs from the last emitted tails, so
182
- // unchanged content across repeats, tool loops, and history advances
183
- // adds zero blocks while keeping the prefix literal.
203
+ // Append only blocks that differ from the last emitted block of the same
204
+ // kind, so unchanged content across repeats, tool loops, and history
205
+ // advances adds zero blocks while keeping the prefix literal. The state
206
+ // block never differs here: a changed state already reset above.
184
207
  const previous = session.lastFresh === undefined ? [] : freshBlocks(session.lastFresh);
185
- let shared = 0;
186
- while (shared < blocks.length && shared < previous.length && blocks[shared] === previous[shared]) shared++;
208
+ let appended = blocks.filter(block => !previous.some(prior => prior.kind === block.kind && prior.content === block.content));
187
209
  // Overflow is measured against what this request actually appends: an
188
210
  // unchanged request adds nothing and must never trigger a reset.
189
- if (session.tails.length + (blocks.length - shared) > MAX_RETAINED_LIVE_TAILS
190
- || session.tails.filter(tail => tail.kind === "goal-counters").reduce((n, tail) => n + Buffer.byteLength(tail.content), 0)
191
- + (fresh.counters === undefined || shared === blocks.length ? 0 : Buffer.byteLength(fresh.counters)) > MAX_RETAINED_COUNTER_BYTES) {
211
+ if (session.tails.length + appended.length > MAX_RETAINED_LIVE_TAILS
212
+ || kindBytes(session.tails, "goal-counters") + kindBytes(appended, "goal-counters") > MAX_RETAINED_COUNTER_BYTES
213
+ || kindBytes(session.tails, "goal-tasks") + kindBytes(appended, "goal-tasks") > MAX_RETAINED_TASK_BYTES) {
192
214
  reset();
193
- shared = 0;
215
+ appended = blocks;
194
216
  }
195
217
  const messages: Array<T | LiveMessage> = [...base];
196
218
  for (let i = session.tails.length - 1; i >= 0; i--) {
197
219
  const tail = session.tails[i]!;
198
220
  messages.splice(tail.anchorIndex, 0, makeLiveMessage(tail.content));
199
221
  }
200
- for (let i = shared; i < blocks.length; i++) {
201
- const content = blocks[i]!;
202
- const kind: LiveTailKind = i === 0 ? "goal-state" : "goal-counters";
203
- messages.push(makeLiveMessage(content));
204
- session.tails.push({ anchorIndex: base.length, anchorFingerprint: fingerprints.at(-1) ?? null, content, kind });
222
+ for (const block of appended) {
223
+ messages.push(makeLiveMessage(block.content));
224
+ session.tails.push({ anchorIndex: base.length, anchorFingerprint: fingerprints.at(-1) ?? null, content: block.content, kind: block.kind });
205
225
  }
206
226
  session.baseFingerprints = fingerprints;
207
- session.lastFresh = { state: fresh.state, counters: fresh.counters };
208
- return { messages, transientContents: [...new Set([...session.tails.map(tail => tail.content), ...blocks])] };
227
+ session.lastFresh = { state: fresh.state, tasks: fresh.tasks || undefined, counters: fresh.counters };
228
+ return { messages, transientContents: [...new Set([...session.tails.map(tail => tail.content), ...blocks.map(block => block.content)])] };
209
229
  }
210
230
 
211
231
  contents(sessionKey: string): string[] | undefined {
@@ -100,6 +100,10 @@ function renderPendingTasks(tasks: GoalTask[], indent: number, rendered: { count
100
100
  * indentation and contract snippets; completed/skipped are collapsed to the
101
101
  * header counts. Previously the ENTIRE tree (up to 50 tasks + subtrees) was
102
102
  * injected into every continuation prompt — most of it already-completed work.
103
+ *
104
+ * This is the request-only task snapshot: it changes with task progress, so it
105
+ * travels apart from the stable policy block (see goalPromptParts). The
106
+ * completion gate is policy and lives in taskGateBlock.
103
107
  */
104
108
  export function taskListBlock(goal: GoalRecord, settings?: GoalSettings): string {
105
109
  if (settings?.disableTasks) return "";
@@ -145,12 +149,19 @@ export function taskListBlock(goal: GoalRecord, settings?: GoalSettings): string
145
149
  lines.push(` ${hiddenPending} additional pending tasks omitted; retrieve with get_goal(section="tasks").`);
146
150
  }
147
151
  }
148
- if (goal.taskList.blockCompletion && pending! > 0) {
149
- lines.push(" TASK GATE: do not request completion while tasks remain in [ ] pending state");
150
- }
151
152
  return lines.join("\n");
152
153
  }
153
154
 
155
+ /**
156
+ * Stable completion gate for the policy block. It depends only on the
157
+ * blockCompletion setting, never on how many tasks are pending, so task
158
+ * progress cannot change the policy block and reset prompt-cache retention.
159
+ */
160
+ export function taskGateBlock(goal: GoalRecord, settings?: GoalSettings): string {
161
+ if (settings?.disableTasks || goal.taskList?.blockCompletion !== true) return "";
162
+ return "TASK GATE: do not request completion while the latest [TASK LIST] snapshot shows tasks in [ ] pending state.";
163
+ }
164
+
154
165
  /** Bounded verification-contract block. */
155
166
  function excerpt(text: string, cap: number, section: "objective" | "tasks"): string {
156
167
  const safe = promptSafeObjective(text);
@@ -207,46 +218,44 @@ const promptFragmentCache: Array<{key: readonly unknown[]; value: string; chars:
207
218
  let promptCacheChars = 0;
208
219
 
209
220
  function cachedPrompt(goal: GoalRecord, settings: GoalSettings | undefined, kind: "goal" | "continuation", build: () => string): string {
221
+ // Task progress is deliberately absent: it renders in the separate task
222
+ // snapshot, so starting or finishing a task reuses the cached policy block.
210
223
  const key = [kind, goal.id, goal.status, goal.autoContinue, goal.sisyphus, goal.objective,
211
- goal.verificationContract, settings?.disableTasks ? undefined : taskIndex(goal.taskList?.tasks),
212
- goal.taskList?.blockCompletion, promptProfile(), goal.currentTaskId, settings?.disableTasks, settings?.disableContracts, settings?.maxAutonomousRuns !== 0, settings?.strictExecutionContract === true, !!goal.scheduler?.wait, settings?.commands ? JSON.stringify(settings.commands) : undefined];
224
+ goal.verificationContract, goal.taskList?.blockCompletion, promptProfile(), settings?.disableTasks, settings?.disableContracts, settings?.maxAutonomousRuns !== 0, settings?.strictExecutionContract === true, !!goal.scheduler?.wait, settings?.commands ? JSON.stringify(settings.commands) : undefined];
213
225
  for (let i = promptFragmentCache.length - 1; i >= 0; i--) {
214
226
  const entry = promptFragmentCache[i]!;
215
227
  if (key.every((part, j) => part === entry.key[j])) return entry.value;
216
228
  }
217
229
  const value = build();
218
230
  // Include source text retained by this entry in the cache's memory allowance.
219
- const chars = goal.objective.length + (goal.verificationContract?.length ?? 0) + value.length
220
- + (goal.taskList ? goal.taskList.tasks.reduce((n, task) => n + retainedTaskChars(task), 0) : 0);
231
+ const chars = goal.objective.length + (goal.verificationContract?.length ?? 0) + value.length;
221
232
  if (chars <= 2_000_000) {
222
233
  while (promptFragmentCache.length >= 32 || promptCacheChars + chars > 2_000_000) promptCacheChars -= promptFragmentCache.shift()!.chars;
223
234
  promptFragmentCache.push({key, value, chars}); promptCacheChars += chars;
224
235
  }
225
236
  return value;
226
237
  }
227
- function retainedTaskChars(task: GoalTask): number {
228
- return task.id.length + task.title.length + (task.verificationContract?.length ?? 0) + (task.evidence?.length ?? 0)
229
- + (task.skipReason?.length ?? 0) + (task.subtasks?.reduce((n, child) => n + retainedTaskChars(child), 0) ?? 0);
230
- }
231
238
 
232
- export function goalPromptParts(goal: GoalRecord, settings?: GoalSettings, contextUsage?: GoalContextUsage): { state: string; counters: string } {
233
- // The policy block changes only when the goal itself changes; the counters
234
- // change nearly every turn. Splitting them lets request-only injection retain
235
- // each at its own rate so consecutive provider requests stay prefix-stable.
239
+ export function goalPromptParts(goal: GoalRecord, settings?: GoalSettings, contextUsage?: GoalContextUsage): { state: string; tasks?: string; counters: string } {
240
+ // The policy block changes only when the goal itself changes; the task
241
+ // snapshot changes with task progress and the counters nearly every turn.
242
+ // Splitting them lets request-only injection retain each at its own rate so
243
+ // consecutive provider requests stay prefix-stable.
236
244
  // Scheduling instructions ride with the policy block: they are cleared by
237
245
  // omission, so a retained copy would keep issuing a cancelled order.
238
246
  const fixed = cachedPrompt(goal, settings, "goal", () => buildGoalPrompt(goal, settings));
239
247
  const { runs, instructions } = schedulerSummaryParts(goal.scheduler, settings?.maxAutonomousRuns, settings?.showAutonomousRuns, settings?.idleContinuationBackoff === true, settings?.commands);
240
248
  const limits = `Limits: lifetime tokens=${goal.tokenBudget ?? "none"}; runs=${settings?.maxAutonomousRuns ?? "unlimited"}.`;
241
- const state = [fixed, instructions, limits, "Usage spans goal turns, not context. Latest snapshot supersedes earlier snapshots."].filter(Boolean).join("\n");
249
+ const state = [fixed, instructions, limits, "Usage spans goal turns, not context. The latest task and usage snapshots supersede earlier snapshots."].filter(Boolean).join("\n");
250
+ const tasks = taskListBlock(goal, settings);
242
251
  const context = settings?.showContextUsage === true ? `\n${contextUsageLine(contextUsage)}` : "";
243
252
  const counters = `Goal snapshot: ${formatUsage(goal)}${context}\n${runs}`;
244
- return { state, counters };
253
+ return tasks ? { state, tasks, counters } : { state, counters };
245
254
  }
246
255
 
247
256
  export function goalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
248
- const { state, counters } = goalPromptParts(goal, settings);
249
- return `${state}\n${counters}`;
257
+ const { state, tasks, counters } = goalPromptParts(goal, settings);
258
+ return [state, tasks, counters].filter(Boolean).join("\n");
250
259
  }
251
260
 
252
261
  function buildGoalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
@@ -256,7 +265,7 @@ function buildGoalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
256
265
  `[PI GOAL ACTIVE goalId=${goal.id}]`,
257
266
  lifecyclePolicyBlock(settings?.maxAutonomousRuns !== 0, settings?.strictExecutionContract === true || !!goal.scheduler?.wait, settings?.commands), sisyphusDisciplineBlock(goal),
258
267
  `Status: ${statusLabel(goal)}\nMode: ${goal.sisyphus ? "sisyphus" : "regular"}`,
259
- untrustedObjectiveBlock(goal), taskListBlock(goal, settings), verificationContractBlock(goal, settings),
268
+ untrustedObjectiveBlock(goal), taskGateBlock(goal, settings), verificationContractBlock(goal, settings),
260
269
  ].filter(Boolean).join("\n\n");
261
270
  }
262
271
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quantmind-br/pi-goal-x",
3
- "version": "0.35.0",
3
+ "version": "0.35.1",
4
4
  "description": "Fork of pi-goal-x with a fixed, cache-stable goal tool profile. 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",