@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.0

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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/assets/badge-dark.svg +18 -0
  4. package/assets/badge-light.svg +18 -0
  5. package/docs/advanced-usage.md +106 -0
  6. package/docs/agent-flow-design.md +306 -0
  7. package/docs/agentic-runtime-prd.md +773 -0
  8. package/docs/architecture.md +368 -0
  9. package/docs/controls.md +140 -0
  10. package/docs/pi-autoresearch-survey.md +50 -0
  11. package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
  12. package/docs/prompt-caching.md +31 -0
  13. package/docs/unified-dashboard.md +286 -0
  14. package/extensions/auditor-selector.ts +74 -0
  15. package/extensions/goal-accounting.ts +107 -0
  16. package/extensions/goal-activity.ts +202 -0
  17. package/extensions/goal-auditor.ts +480 -0
  18. package/extensions/goal-commands.ts +866 -0
  19. package/extensions/goal-compaction.ts +197 -0
  20. package/extensions/goal-completion.ts +173 -0
  21. package/extensions/goal-context.ts +12 -0
  22. package/extensions/goal-contract.ts +58 -0
  23. package/extensions/goal-controls.ts +202 -0
  24. package/extensions/goal-core-tools.ts +549 -0
  25. package/extensions/goal-core.ts +78 -0
  26. package/extensions/goal-detail.ts +65 -0
  27. package/extensions/goal-dialog-controls.ts +31 -0
  28. package/extensions/goal-draft.ts +244 -0
  29. package/extensions/goal-drafting.ts +535 -0
  30. package/extensions/goal-events.ts +692 -0
  31. package/extensions/goal-format.ts +376 -0
  32. package/extensions/goal-interruption.ts +53 -0
  33. package/extensions/goal-ledger-index.ts +68 -0
  34. package/extensions/goal-ledger.ts +1007 -0
  35. package/extensions/goal-live-retention.ts +219 -0
  36. package/extensions/goal-oracle.ts +346 -0
  37. package/extensions/goal-policy.ts +370 -0
  38. package/extensions/goal-pool.ts +103 -0
  39. package/extensions/goal-prompt-cache.ts +80 -0
  40. package/extensions/goal-questionnaire.ts +1073 -0
  41. package/extensions/goal-record.ts +388 -0
  42. package/extensions/goal-recovery.ts +256 -0
  43. package/extensions/goal-runtime.ts +277 -0
  44. package/extensions/goal-scheduler-state.ts +100 -0
  45. package/extensions/goal-scheduler.ts +436 -0
  46. package/extensions/goal-service.ts +843 -0
  47. package/extensions/goal-session-health.ts +130 -0
  48. package/extensions/goal-session-safety.ts +65 -0
  49. package/extensions/goal-settings.ts +1370 -0
  50. package/extensions/goal-state.ts +1069 -0
  51. package/extensions/goal-status.ts +305 -0
  52. package/extensions/goal-task-confirmation.ts +174 -0
  53. package/extensions/goal-task-count.ts +59 -0
  54. package/extensions/goal-task-derive.ts +46 -0
  55. package/extensions/goal-task-index.ts +57 -0
  56. package/extensions/goal-task-tools.ts +595 -0
  57. package/extensions/goal-tool-inputs.ts +102 -0
  58. package/extensions/goal-tool-names.ts +73 -0
  59. package/extensions/goal-tools.ts +16 -0
  60. package/extensions/goal-widget.ts +455 -0
  61. package/extensions/goal.ts +42 -0
  62. package/extensions/network-error-backoff.ts +55 -0
  63. package/extensions/prompts/goal-prompts.ts +321 -0
  64. package/extensions/storage/atomic-rename.ts +52 -0
  65. package/extensions/storage/goal-files.ts +695 -0
  66. package/extensions/storage/goal-lock.ts +131 -0
  67. package/extensions/storage/goal-root.ts +95 -0
  68. package/extensions/widgets/auditor-dashboard-model.ts +174 -0
  69. package/extensions/widgets/dialog-scaffold.ts +26 -0
  70. package/extensions/widgets/goal-controls-dialog.ts +329 -0
  71. package/extensions/widgets/goal-dashboard-model.ts +530 -0
  72. package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
  73. package/extensions/widgets/goal-escape-dialog.ts +148 -0
  74. package/extensions/widgets/goal-notifications.ts +9 -0
  75. package/extensions/widgets/goal-widget.ts +723 -0
  76. package/extensions/widgets/task-list-overlay.ts +409 -0
  77. package/extensions/widgets/text-cache.ts +30 -0
  78. package/package.json +117 -4
  79. package/scripts/recover-session-checkpoints.mjs +289 -0
@@ -0,0 +1,370 @@
1
+ import { statusLabel, type GoalDisplayRecordLike } from "./goal-core.ts";
2
+ import type { GoalTask, GoalTaskList, TaskStatus } from "./goal-record.ts";
3
+ import { countTaskSubtree } from "./goal-task-count.ts";
4
+
5
+ export type GoalStatusLike = "active" | "paused" | "blocked" | "budget_limited" | "complete";
6
+ export type StopReasonLike = "user" | "agent";
7
+
8
+ export interface GoalPolicyRecordLike extends GoalDisplayRecordLike {
9
+ id: string;
10
+ status: GoalStatusLike;
11
+ updatedAt?: string;
12
+ pauseReason?: string;
13
+ pauseSuggestedAction?: string;
14
+ taskList?: GoalTaskList;
15
+ }
16
+
17
+ export type PolicyValidation =
18
+ | { ok: true }
19
+ | { ok: false; message: string };
20
+
21
+ export function isRunnableStatus(status: GoalStatusLike): boolean {
22
+ return status === "active";
23
+ }
24
+
25
+ export function isCompletableStatus(status: GoalStatusLike): boolean {
26
+ // A budget-limited goal is NOT completed by the transition itself, but the
27
+ // user (or the model on explicit evidence) may still complete it. A blocked
28
+ // goal is the model's terminal surrender — resume or clear it instead.
29
+ return status === "active" || status === "paused" || status === "budget_limited";
30
+ }
31
+
32
+ export function validateGoalCompletion(args: {
33
+ goal: GoalPolicyRecordLike | null;
34
+ runningGoalId?: string | null;
35
+ }): PolicyValidation {
36
+ const { goal, runningGoalId } = args;
37
+ if (!goal) return { ok: false, message: "No goal is set." };
38
+ if (runningGoalId && goal.id !== runningGoalId) return { ok: false, message: "The active goal changed during this run; not marking it complete." };
39
+ if (!isCompletableStatus(goal.status)) return { ok: false, message: `Goal is ${statusLabel(goal)}; update_goal(complete) does not apply.` };
40
+ return { ok: true };
41
+ }
42
+
43
+ /** update_goal(blocked) applies only to an ACTIVE goal — paused/complete/blocked/budget-limited reject it. */
44
+ export function validateGoalBlock(args: {
45
+ goal: GoalPolicyRecordLike | null;
46
+ runningGoalId?: string | null;
47
+ }): PolicyValidation {
48
+ const { goal } = args;
49
+ if (!goal) return { ok: false, message: "No goal is set." };
50
+ if (goal.status !== "active") {
51
+ return { ok: false, message: `Goal is ${statusLabel(goal)}; update_goal(blocked) applies only to an active goal.` };
52
+ }
53
+ return { ok: true };
54
+ }
55
+
56
+ /**
57
+ * update_goal(paused) is the agent-initiated immediate pause (Stage 5.1-C).
58
+ * It applies only to an ACTIVE goal and is distinct from the three-turn
59
+ * blocked gate and from the user-owned pause/resume commands.
60
+ */
61
+ export function validateGoalAgentPause(args: {
62
+ goal: GoalPolicyRecordLike | null;
63
+ runningGoalId?: string | null;
64
+ }): PolicyValidation {
65
+ const { goal } = args;
66
+ if (!goal) return { ok: false, message: "No goal is set." };
67
+ if (goal.status !== "active") {
68
+ return { ok: false, message: `Goal is ${statusLabel(goal)}; update_goal(paused) applies only to an active goal.` };
69
+ }
70
+ return { ok: true };
71
+ }
72
+
73
+ export function validateResumeGoal(goal: GoalPolicyRecordLike | null): PolicyValidation {
74
+ if (!goal) return { ok: false, message: "No goal is set. Use /goal to draft one, or /goal-direct <objective> to start immediately." };
75
+ if (goal.status === "complete") return { ok: false, message: "Goal is complete. Use /goal to draft a new one, or /goal-direct <objective> to start immediately." };
76
+ if (goal.status === "active" && goal.autoContinue) return { ok: false, message: "Goal is already running." };
77
+ return { ok: true };
78
+ }
79
+
80
+ export function clearGoalCommandMessage(args: { archived: boolean }): string {
81
+ return args.archived ? "Goal cleared and archived." : "No goal is set.";
82
+ }
83
+
84
+ /** Count tasks in subtree recursively */
85
+ export function buildTaskSummary(taskList: GoalTaskList): string {
86
+ const { total, complete, skipped } = countTaskSubtree(taskList.tasks);
87
+ if (total === 0) return "No tasks";
88
+ const parts: string[] = [`${complete}/${total} tasks complete`];
89
+ if (skipped > 0) parts.push(`(${skipped} skipped)`);
90
+ return parts.join(" ");
91
+ }
92
+
93
+ export function taskCompletionBlockWarning(taskList: GoalTaskList): string | null {
94
+ if (!taskList.blockCompletion) return null;
95
+ const { pending } = countTaskSubtree(taskList.tasks);
96
+ if (pending === 0) return null;
97
+ return `${pending} task${pending > 1 ? "s" : ""} still pending with blockCompletion enabled. Complete or skip all pending tasks before finishing the goal.`;
98
+ }
99
+
100
+ /**
101
+ * Validate a task completion transition (evidence requirement is enforced by
102
+ * the update_goal_task handler against the task's verification contract).
103
+ */
104
+ export function validateTaskCompletion(args: {
105
+ goal: GoalPolicyRecordLike | null;
106
+ taskId: string;
107
+ }): PolicyValidation {
108
+ if (!args.goal) return { ok: false, message: "No goal is set." };
109
+ if (!args.goal.taskList) return { ok: false, message: "Goal has no task list." };
110
+ const task = findTaskInTree(args.goal.taskList.tasks, args.taskId);
111
+ if (!task) return { ok: false, message: `Task "${args.taskId}" not found.` };
112
+ if (task.status === "complete") return { ok: false, message: `Task "${args.taskId}" is already complete.` };
113
+ if (task.status === "skipped") return { ok: false, message: `Task "${args.taskId}" was already skipped.` };
114
+ return { ok: true };
115
+ }
116
+
117
+ export function validateTaskSkip(args: {
118
+ goal: GoalPolicyRecordLike | null;
119
+ taskId: string;
120
+ reason: string;
121
+ }): PolicyValidation {
122
+ if (!args.goal) return { ok: false, message: "No goal is set." };
123
+ if (!args.goal.taskList) return { ok: false, message: "Goal has no task list." };
124
+ const task = findTaskInTree(args.goal.taskList.tasks, args.taskId);
125
+ if (!task) return { ok: false, message: `Task "${args.taskId}" not found.` };
126
+ if (task.status === "complete") return { ok: false, message: `Task "${args.taskId}" is already complete.` };
127
+ // Skipped tasks toggle via the executor; reason is only required for first-time skips.
128
+ if (task.status === "skipped") return { ok: true };
129
+ if (!args.reason.trim()) return { ok: false, message: "Skipping requires a non-empty reason." };
130
+ return { ok: true };
131
+ }
132
+
133
+ /**
134
+ * Count the maximum nesting depth of a task's subtask tree.
135
+ * Root level = 0. Returns the deepest nesting depth found.
136
+ */
137
+ export function measureSubtaskDepth(task: GoalTask): number {
138
+ if (!task.subtasks || task.subtasks.length === 0) return 0;
139
+ let maxChild = 0;
140
+ for (const child of task.subtasks) {
141
+ const childDepth = measureSubtaskDepth(child);
142
+ if (childDepth > maxChild) maxChild = childDepth;
143
+ }
144
+ return maxChild + 1;
145
+ }
146
+
147
+ /**
148
+ * Validate that a task's subtask tree does not exceed the configured max depth.
149
+ * maxDepth is the subtaskDepth setting (default 1) — how many levels of nesting are allowed.
150
+ * Returns the first violation found, or undefined if valid.
151
+ */
152
+ export function findSubtaskDepthViolation(tasks: GoalTask[], maxDepth: number): string | undefined {
153
+ for (const task of tasks) {
154
+ const depth = measureSubtaskDepth(task);
155
+ if (depth > maxDepth) {
156
+ return `Task "${task.id}" has subtask nesting depth ${depth}, exceeding the configured maximum of ${maxDepth}`;
157
+ }
158
+ if (task.subtasks) {
159
+ const childViolation = findSubtaskDepthViolation(task.subtasks, maxDepth);
160
+ if (childViolation) return childViolation;
161
+ }
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ function checkDuplicateTaskIds(tasks: GoalTask[], ids: Set<string>): string | undefined {
167
+ for (const t of tasks) {
168
+ const id = t.id.trim();
169
+ if (!id) return "All tasks must have a non-empty id.";
170
+ if (ids.has(id)) return `Duplicate task id: "${id}".`;
171
+ ids.add(id);
172
+ if (t.subtasks) {
173
+ const childErr = checkDuplicateTaskIds(t.subtasks, ids);
174
+ if (childErr) return childErr;
175
+ }
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ export function validateTaskListProposal(args: {
181
+ goal: GoalPolicyRecordLike | null;
182
+ tasks: GoalTask[];
183
+ maxSubtaskDepth?: number;
184
+ }): PolicyValidation {
185
+ if (!args.goal) return { ok: false, message: "No goal is set." };
186
+ if (args.tasks.length > 50) return { ok: false, message: "Task list cannot exceed 50 tasks." };
187
+ const ids = new Set<string>();
188
+ for (const t of args.tasks) {
189
+ if (!t.id.trim()) return { ok: false, message: "All tasks must have a non-empty id." };
190
+ if (!t.title.trim()) return { ok: false, message: `Task "${t.id}" must have a non-empty title.` };
191
+ if (ids.has(t.id)) return { ok: false, message: `Duplicate task id: "${t.id}".` };
192
+ ids.add(t.id);
193
+ // Recursively check subtask ids against the same global set
194
+ if (t.subtasks && t.subtasks.length > 0) {
195
+ const childErr = checkDuplicateTaskIds(t.subtasks, ids);
196
+ if (childErr) return { ok: false, message: childErr };
197
+ }
198
+ }
199
+ // Check subtask depth limit
200
+ const maxDepth = args.maxSubtaskDepth ?? 1;
201
+ const depthViolation = findSubtaskDepthViolation(args.tasks, maxDepth);
202
+ if (depthViolation) return { ok: false, message: depthViolation };
203
+ return { ok: true };
204
+ }
205
+
206
+ /**
207
+ * Recursively find a task by ID in a task tree.
208
+ */
209
+ export function findTaskInTree(tasks: GoalTask[], taskId: string): GoalTask | undefined {
210
+ for (const t of tasks) {
211
+ if (t.id === taskId) return t;
212
+ if (t.subtasks) {
213
+ const found = findTaskInTree(t.subtasks, taskId);
214
+ if (found) return found;
215
+ }
216
+ }
217
+ return undefined;
218
+ }
219
+
220
+ /**
221
+ * Recursively update a task by ID in a task tree using an updater function.
222
+ */
223
+ export function updateTaskInTree(tasks: GoalTask[], taskId: string, updater: (task: GoalTask) => GoalTask): GoalTask[] {
224
+ let next: GoalTask[] | undefined;
225
+ for (let i = 0; i < tasks.length; i++) {
226
+ const task = tasks[i]!;
227
+ let updated = task;
228
+ if (task.id === taskId) updated = updater(task);
229
+ else if (task.subtasks) {
230
+ const subtasks = updateTaskInTree(task.subtasks, taskId, updater);
231
+ if (subtasks !== task.subtasks) updated = {...task, subtasks};
232
+ }
233
+ if (updated !== task) { next ??= tasks.slice(); next[i] = updated; }
234
+ }
235
+ return next ?? tasks;
236
+ }
237
+
238
+ /**
239
+ * Check if all subtasks of a task are complete (for full subtasks only).
240
+ * Returns undefined when all are complete/skipped, or an error message.
241
+ */
242
+ export function checkSubtasksComplete(task: GoalTask): string | undefined {
243
+ if (!task.subtasks || task.subtasks.length === 0 || task.lightweightSubtasks) return undefined;
244
+ for (const child of task.subtasks) {
245
+ if (child.status === "pending") {
246
+ return `Task "${task.id}" has pending subtask "${child.id}". Complete or skip all subtasks first.`;
247
+ }
248
+ // Check recursively
249
+ const childCheck = checkSubtasksComplete(child);
250
+ if (childCheck) return childCheck;
251
+ }
252
+ return undefined;
253
+ }
254
+
255
+ /**
256
+ * Recursively skip all subtasks of a task.
257
+ * Returns a set of all skipped task IDs.
258
+ */
259
+ export function skipAllSubtasks(task: GoalTask, now: string, reason: string): GoalTask {
260
+ if (!task.subtasks || task.subtasks.length === 0) return task;
261
+ return {
262
+ ...task,
263
+ subtasks: task.subtasks.map((child) => {
264
+ if (child.status === "complete") return child;
265
+ const skipped = {
266
+ ...child,
267
+ status: "skipped" as const,
268
+ skippedAt: now,
269
+ skipReason: reason,
270
+ };
271
+ return skipAllSubtasks(skipped, now, reason);
272
+ }),
273
+ };
274
+ }
275
+
276
+ export function buildCompletionReport(args: { detailedSummary: string; auditorReport?: string | null; auditSkippedReason?: string | null; taskSummary?: string | null }): string {
277
+ const auditSkipped = args.auditSkippedReason?.trim();
278
+ const auditorReport = args.auditorReport?.trim();
279
+ const lines = auditSkipped
280
+ ? ["Goal audit skipped.", "", "Reason: " + auditSkipped, "", "Goal complete."]
281
+ : auditorReport
282
+ ? ["Goal audit approved.", "", "Auditor approval:", auditorReport, "", "Goal complete."]
283
+ : ["Goal complete."];
284
+ const taskSummary = args.taskSummary?.trim();
285
+ if (taskSummary) {
286
+ lines.push("", `Task summary: ${taskSummary}`);
287
+ }
288
+ lines.push("", args.detailedSummary);
289
+ return lines.join("\n");
290
+ }
291
+
292
+ export interface GoalCreatedReportArgs {
293
+ objective: string;
294
+ detailedSummary?: string | null;
295
+ /**
296
+ * Guided-confirmation report (§14): the richer opening line plus expanded
297
+ * detail (goal id, active file, task count, verification contract, auditor
298
+ * configuration, token budget). Direct creation keeps the default text.
299
+ */
300
+ confirmed?: boolean;
301
+ goalId?: string;
302
+ filePath?: string;
303
+ taskCount?: number;
304
+ verificationContract?: string;
305
+ auditorEnabled?: boolean;
306
+ tokenBudget?: number;
307
+ }
308
+
309
+ export function formatGoalBudget(budget: number | undefined): string {
310
+ return `Budget: ${budget === undefined ? "none" : `${budget} tokens`}`;
311
+ }
312
+
313
+ export function buildGoalCreatedReport(args: GoalCreatedReportArgs): string {
314
+ const opening = args.confirmed
315
+ ? ["✓ Goal created and focused.", "Continuing automatically with the confirmed plan."]
316
+ : ["Goal confirmed and created."];
317
+ const lines = [...opening, "", "Finalized goal:", "", args.objective.trim()];
318
+ const details: string[] = [];
319
+ if (args.goalId) details.push(`Goal id: ${args.goalId}`);
320
+ if (args.filePath) details.push(`File: ${args.filePath}`);
321
+ if (args.taskCount !== undefined) details.push(`Tasks: ${args.taskCount}`);
322
+ if (args.verificationContract?.trim()) details.push(`Verification: ${args.verificationContract.trim()}`);
323
+ if (args.auditorEnabled !== undefined) details.push(`Auditor: ${args.auditorEnabled ? "enabled" : "disabled"}`);
324
+ details.push(formatGoalBudget(args.tokenBudget));
325
+ const summary = args.detailedSummary?.trim();
326
+ if (summary) details.push(summary);
327
+ if (details.length > 0) {
328
+ lines.push("", "Goal details:", ...details);
329
+ }
330
+ return lines.join("\n");
331
+ }
332
+
333
+ /** Count the ordered steps in a sisyphus objective (numbered items or "Step N"). */
334
+ export function countOrderedSteps(objective: string): number {
335
+ const numbered = objective.match(/^\s*\d{1,2}\s*[.):]/gm)?.length ?? 0;
336
+ const stepMarkers = objective.match(/^\s*step\s+\d+/gim)?.length ?? 0;
337
+ return Math.max(numbered, stepMarkers);
338
+ }
339
+
340
+ /**
341
+ * E6/F4: where the goal is in its ordered sisyphus sequence. M = ordered steps
342
+ * in the objective; N = completed top-level tasks + 1 (clamped), falling back
343
+ * to 1 when no task tracking exists. Returns null for non-sisyphus goals or
344
+ * objectives without ordered markers.
345
+ */
346
+ export function sisyphusStepProgress(goal: { sisyphus: boolean; objective: string; taskList?: { tasks?: Array<{ status: string }> } | null }): { current: number; total: number } | null {
347
+ if (!goal.sisyphus) return null;
348
+ const total = countOrderedSteps(goal.objective);
349
+ if (total === 0) return null;
350
+ let current = 1;
351
+ const tasks = goal.taskList?.tasks;
352
+ if (tasks && tasks.length > 0) {
353
+ const completedTop = tasks.filter((t) => t.status === "complete").length;
354
+ current = Math.min(total, completedTop + 1);
355
+ }
356
+ return { current, total };
357
+ }
358
+
359
+ export function shouldQueueContinuation(goal: Pick<GoalPolicyRecordLike, "status" | "autoContinue"> | null): boolean {
360
+ return !!goal && goal.status === "active" && goal.autoContinue;
361
+ }
362
+
363
+
364
+ export function shouldArmPostCompactReminder(goal: Pick<GoalPolicyRecordLike, "sisyphus" | "status"> | null): boolean {
365
+ return !!goal && isRunnableStatus(goal.status);
366
+ }
367
+
368
+ export function shouldInjectPostCompactReminder(args: { pending: boolean; goal: Pick<GoalPolicyRecordLike, "sisyphus"> | null }): boolean {
369
+ return args.pending && !!args.goal;
370
+ }
@@ -0,0 +1,103 @@
1
+ import {
2
+ displayObjectiveTitle,
3
+ formatDuration,
4
+ formatTokenValue,
5
+ statusLabel,
6
+ truncateText,
7
+ } from "./goal-core.ts";
8
+ import { cloneGoal, type GoalFocusEntry, type GoalRecord } from "./goal-record.ts";
9
+
10
+ export function goalPoolFromGoals(goals: Iterable<GoalRecord>): Map<string, GoalRecord> {
11
+ const pool = new Map<string, GoalRecord>();
12
+ for (const goal of goals) {
13
+ if (goal.status !== "complete") pool.set(goal.id, cloneGoal(goal));
14
+ }
15
+ return pool;
16
+ }
17
+
18
+ export function openGoalsFromPool(pool: Map<string, GoalRecord>): GoalRecord[] {
19
+ return Array.from(pool.values())
20
+ .filter((goal) => goal.status !== "complete")
21
+ .sort((a, b) => {
22
+ const byCreated = a.createdAt.localeCompare(b.createdAt);
23
+ return byCreated !== 0 ? byCreated : a.id.localeCompare(b.id);
24
+ });
25
+ }
26
+
27
+ export function focusedGoalFromPool(pool: Map<string, GoalRecord>, focusedGoalId: string | null): GoalRecord | null {
28
+ if (!focusedGoalId) return null;
29
+ const goal = pool.get(focusedGoalId) ?? null;
30
+ return goal;
31
+ }
32
+
33
+ export function otherOpenGoalCount(pool: Map<string, GoalRecord>, focusedGoalId: string | null): number {
34
+ let count = 0;
35
+ for (const goal of pool.values()) if (goal.status !== "complete" && goal.id !== focusedGoalId) count++;
36
+ return count;
37
+ }
38
+
39
+ export function resolveSessionFocus(args: {
40
+ pool: Map<string, GoalRecord>;
41
+ focusEntry?: GoalFocusEntry | null;
42
+ legacyGoal?: GoalRecord | null;
43
+ autoSelectSingleGoal?: boolean;
44
+ }): string | null {
45
+ const focusedGoalId = args.focusEntry?.focusedGoalId ?? null;
46
+ const focused = focusedGoalId ? focusedGoalFromPool(args.pool, focusedGoalId) : null;
47
+ if (focused && focused.status !== "complete") {
48
+ return focusedGoalId;
49
+ }
50
+ if (args.focusEntry) {
51
+ return null;
52
+ }
53
+ if (args.legacyGoal && args.legacyGoal.status !== "complete") {
54
+ if (args.pool.has(args.legacyGoal.id)) return args.legacyGoal.id;
55
+ args.pool.set(args.legacyGoal.id, cloneGoal(args.legacyGoal));
56
+ return args.legacyGoal.id;
57
+ }
58
+ // Auto-select single open goal only when explicitly enabled.
59
+ // Default (false): sessions start unfocused to prevent goals from
60
+ // bleeding across sessions that share the same .pi/goals/ directory.
61
+ if (args.autoSelectSingleGoal) {
62
+ const open = openGoalsFromPool(args.pool);
63
+ return open.length === 1 ? open[0]?.id ?? null : null;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ export function goalSelectorLabel(goal: GoalRecord, focusedGoalId: string | null): string {
69
+ const marker = goal.id === focusedGoalId ? "*" : " ";
70
+ const mode = goal.sisyphus ? "sisyphus" : "goal";
71
+ const path = goal.activePath ? ` ${goal.activePath}` : "";
72
+ return `${marker} ${goal.id} | ${statusLabel(goal)} | ${mode} | ${truncateText(displayObjectiveTitle(goal.objective), 72)}${path}`;
73
+ }
74
+
75
+ export function buildGoalListText(pool: Map<string, GoalRecord>, focusedGoalId: string | null): string {
76
+ const open = openGoalsFromPool(pool);
77
+ if (open.length === 0) return "No open goals. Use /goal <objective> or /sisyphus <objective> to start immediately.";
78
+ const lines = [`Open goals: ${open.length}`, ""];
79
+ for (const goal of open) {
80
+ const focused = goal.id === focusedGoalId ? "*" : " ";
81
+ const mode = goal.sisyphus ? "sisyphus" : "goal";
82
+ const usage = goal.usage.tokensUsed > 0 || goal.usage.activeSeconds > 0
83
+ ? ` · ${formatDuration(goal.usage.activeSeconds)} · ${formatTokenValue(goal.usage.tokensUsed).split(" ")[0]}`
84
+ : "";
85
+ lines.push(`${focused} ${goal.id} — ${statusLabel(goal)} · ${mode}${usage}`);
86
+ lines.push(` ${displayObjectiveTitle(goal.objective)}`);
87
+ if (goal.activePath) lines.push(` ${goal.activePath}`);
88
+ }
89
+ return lines.join("\n");
90
+ }
91
+
92
+ export function buildUnfocusedOpenGoalsSummary(openGoalCount: number): string {
93
+ return `No goal is focused in this session. ${openGoalCount} open goal${openGoalCount === 1 ? "" : "s"} exist in the goal pool. Use /goal-focus to choose the session focus before doing goal work.`;
94
+ }
95
+
96
+ export function mergeFocusedGoalWithDisk(args: { memoryGoal: GoalRecord; diskGoal: GoalRecord }): GoalRecord {
97
+ const tokensUsed = Math.max(args.memoryGoal.usage.tokensUsed, args.diskGoal.usage.tokensUsed);
98
+ const activeSeconds = Math.max(args.memoryGoal.usage.activeSeconds, args.diskGoal.usage.activeSeconds);
99
+ return {
100
+ ...args.diskGoal,
101
+ usage: { tokensUsed, activeSeconds },
102
+ };
103
+ }
@@ -0,0 +1,80 @@
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
+ * The live tail may span several request-only blocks (stable goal state plus
11
+ * volatile counters) and previously sent tails may be retained verbatim
12
+ * mid-history for implicit-prefix stability. Every such block is transient:
13
+ * the breakpoint must skip all of them and land on real history.
14
+ */
15
+ export function cacheGoalHistory(payload: unknown, liveContent: string | readonly string[] | undefined): unknown {
16
+ const live = liveContent === undefined
17
+ ? undefined
18
+ : new Set((typeof liveContent === "string" ? [liveContent] : liveContent).filter(text => text.length > 0));
19
+ const root = asRecord(payload);
20
+ if (!live || live.size === 0 || !Array.isArray(root?.messages)) return undefined;
21
+ const messages = root.messages;
22
+ let lastIndex = messages.length - 1;
23
+ for (; lastIndex >= 0; lastIndex--) {
24
+ const content = asRecord(messages[lastIndex])?.content;
25
+ if (typeof content === "string" || Array.isArray(content) ? content.length > 0 : content != null) break;
26
+ }
27
+ const last = asRecord(messages[lastIndex]);
28
+ if (last?.role !== "user" || !Array.isArray(last.content)) return undefined;
29
+ // Match on text membership, not the type tag: Bedrock payloads use
30
+ // typeless { text } blocks, and neither cachePoint markers nor thinking
31
+ // blocks carry a text field, so they can never match by accident. A real
32
+ // history block quoting a live tail verbatim would misclassify as transient;
33
+ // accepted: it requires an exact full-string quote of a volatile block.
34
+ const isLiveText = (block: unknown): boolean => {
35
+ const record = asRecord(block);
36
+ return typeof record?.text === "string" && live.has(record.text);
37
+ };
38
+ // Bedrock represents its breakpoint as a separate content block. The tail
39
+ // may be several live blocks; skip back over all of them.
40
+ const bedrockPoint = asRecord(last.content.at(-1));
41
+ if (asRecord(bedrockPoint?.cachePoint)) {
42
+ // Require our live text immediately before the marker. A bare marker
43
+ // or another extension's suffix is not ours to relocate.
44
+ if (!isLiveText(last.content.at(-2))) return undefined;
45
+ for (let i = lastIndex; i >= 0; i--) {
46
+ const previous = asRecord(messages[i]);
47
+ if (!Array.isArray(previous?.content)) continue;
48
+ for (let j = previous.content.length - 1; j >= 0; j--) {
49
+ const block = asRecord(previous.content[j]);
50
+ if (!block || isLiveText(block) || !["text", "image", "document", "toolUse", "toolResult"].some(key => key in block)) continue;
51
+ last.content.pop();
52
+ if (!asRecord(previous.content[j + 1])?.cachePoint) previous.content.splice(j + 1, 0, { ...bedrockPoint });
53
+ return payload;
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ const tail = asRecord(last.content.at(-1));
59
+ if (tail?.type !== "text" || typeof tail.text !== "string" || !live.has(tail.text) || !asRecord(tail.cache_control)) return undefined;
60
+ for (let i = lastIndex; i >= 0; i--) {
61
+ const message = asRecord(messages[i]);
62
+ if (!message || !["user", "assistant", "tool"].includes(String(message.role))) continue;
63
+ if (typeof message.content === "string" && message.content.length > 0 && !live.has(message.content)) {
64
+ message.content = [{ type: "text", text: message.content, cache_control: tail.cache_control }];
65
+ delete tail.cache_control;
66
+ return payload;
67
+ }
68
+ if (!Array.isArray(message.content)) continue;
69
+ for (let j = message.content.length - 1; j >= 0; j--) {
70
+ const block = asRecord(message.content[j]);
71
+ // Thinking blocks cannot carry cache_control. Never split/reorder tool pairs.
72
+ // Live tails are transient by construction: never anchor the breakpoint on one.
73
+ if (!block || isLiveText(block) || !["text", "image", "tool_use", "tool_result"].includes(String(block.type))) continue;
74
+ block.cache_control ??= tail.cache_control;
75
+ delete tail.cache_control;
76
+ return payload;
77
+ }
78
+ }
79
+ return undefined;
80
+ }