@quantmind-br/pi-goal-x 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/assets/badge-dark.svg +18 -0
- package/assets/badge-light.svg +18 -0
- package/docs/advanced-usage.md +106 -0
- package/docs/agent-flow-design.md +306 -0
- package/docs/agentic-runtime-prd.md +773 -0
- package/docs/architecture.md +368 -0
- package/docs/controls.md +140 -0
- package/docs/pi-autoresearch-survey.md +50 -0
- package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
- package/docs/prompt-caching.md +31 -0
- package/docs/unified-dashboard.md +286 -0
- package/extensions/auditor-selector.ts +74 -0
- package/extensions/goal-accounting.ts +107 -0
- package/extensions/goal-activity.ts +202 -0
- package/extensions/goal-auditor.ts +480 -0
- package/extensions/goal-commands.ts +866 -0
- package/extensions/goal-compaction.ts +197 -0
- package/extensions/goal-completion.ts +173 -0
- package/extensions/goal-context.ts +12 -0
- package/extensions/goal-contract.ts +58 -0
- package/extensions/goal-controls.ts +202 -0
- package/extensions/goal-core-tools.ts +549 -0
- package/extensions/goal-core.ts +78 -0
- package/extensions/goal-detail.ts +65 -0
- package/extensions/goal-dialog-controls.ts +31 -0
- package/extensions/goal-draft.ts +244 -0
- package/extensions/goal-drafting.ts +535 -0
- package/extensions/goal-events.ts +692 -0
- package/extensions/goal-format.ts +376 -0
- package/extensions/goal-interruption.ts +53 -0
- package/extensions/goal-ledger-index.ts +68 -0
- package/extensions/goal-ledger.ts +1007 -0
- package/extensions/goal-live-retention.ts +239 -0
- package/extensions/goal-oracle.ts +346 -0
- package/extensions/goal-policy.ts +370 -0
- package/extensions/goal-pool.ts +103 -0
- package/extensions/goal-prompt-cache.ts +80 -0
- package/extensions/goal-questionnaire.ts +1073 -0
- package/extensions/goal-record.ts +388 -0
- package/extensions/goal-recovery.ts +256 -0
- package/extensions/goal-runtime.ts +277 -0
- package/extensions/goal-scheduler-state.ts +100 -0
- package/extensions/goal-scheduler.ts +436 -0
- package/extensions/goal-service.ts +843 -0
- package/extensions/goal-session-health.ts +130 -0
- package/extensions/goal-session-safety.ts +65 -0
- package/extensions/goal-settings.ts +1370 -0
- package/extensions/goal-state.ts +1069 -0
- package/extensions/goal-status.ts +305 -0
- package/extensions/goal-task-confirmation.ts +174 -0
- package/extensions/goal-task-count.ts +59 -0
- package/extensions/goal-task-derive.ts +46 -0
- package/extensions/goal-task-index.ts +57 -0
- package/extensions/goal-task-tools.ts +595 -0
- package/extensions/goal-tool-inputs.ts +102 -0
- package/extensions/goal-tool-names.ts +73 -0
- package/extensions/goal-tools.ts +16 -0
- package/extensions/goal-widget.ts +455 -0
- package/extensions/goal.ts +42 -0
- package/extensions/network-error-backoff.ts +55 -0
- package/extensions/prompts/goal-prompts.ts +330 -0
- package/extensions/storage/atomic-rename.ts +52 -0
- package/extensions/storage/goal-files.ts +695 -0
- package/extensions/storage/goal-lock.ts +131 -0
- package/extensions/storage/goal-root.ts +95 -0
- package/extensions/widgets/auditor-dashboard-model.ts +174 -0
- package/extensions/widgets/dialog-scaffold.ts +26 -0
- package/extensions/widgets/goal-controls-dialog.ts +329 -0
- package/extensions/widgets/goal-dashboard-model.ts +530 -0
- package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
- package/extensions/widgets/goal-escape-dialog.ts +148 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +723 -0
- package/extensions/widgets/task-list-overlay.ts +409 -0
- package/extensions/widgets/text-cache.ts +30 -0
- package/package.json +117 -4
- package/scripts/recover-session-checkpoints.mjs +289 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import type { GoalCommands } from "../goal-controls.ts";
|
|
2
|
+
import { schedulerSummaryParts } from "../goal-scheduler-state.ts";
|
|
3
|
+
import { taskIndex } from "../goal-task-index.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Shared dashboard view model (plan §6) — the single source of truth for the
|
|
6
|
+
* persistent compact dashboard, the expanded dashboard, `/goal-status`, audit
|
|
7
|
+
* transitions, golden tests, and documentation examples.
|
|
8
|
+
*
|
|
9
|
+
* Pure data derivation only: this module must never import TUI rendering
|
|
10
|
+
* components. Width-safe truncation, borders, and color belong to the
|
|
11
|
+
* renderer; everything factual here is derived from persisted goal state and
|
|
12
|
+
* the durable ledger.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { displayObjectiveTitle, formatDuration, statusLabel } from "../goal-core.ts";
|
|
16
|
+
import type { GoalLedgerEvent } from "../goal-ledger.ts";
|
|
17
|
+
import type { GoalRecord, GoalTask } from "../goal-record.ts";
|
|
18
|
+
import { deriveGoalActivity, type GoalActivityItem } from "../goal-activity.ts";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types (plan §6)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export type DashboardStatusCode = "running" | "idle" | "paused" | "blocked" | "budget_limited" | "complete";
|
|
25
|
+
|
|
26
|
+
export interface GoalDashboardModel {
|
|
27
|
+
scheduling: string[];
|
|
28
|
+
idlePacing?: string;
|
|
29
|
+
goalId: string;
|
|
30
|
+
title: string;
|
|
31
|
+
|
|
32
|
+
status: {
|
|
33
|
+
code: DashboardStatusCode;
|
|
34
|
+
label: string;
|
|
35
|
+
/** Footer-status label (goal-core statusLabel), e.g. `running`, `paused (agent)`. */
|
|
36
|
+
footerLabel: string;
|
|
37
|
+
reason?: string;
|
|
38
|
+
suggestedAction?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
focused: boolean;
|
|
42
|
+
/** §auditor-toggle: whether the independent completion auditor is enabled for this goal (derived from the goal's persisted skipAuditor; undefined = on). */
|
|
43
|
+
auditorEnabled: boolean;
|
|
44
|
+
filePath?: string;
|
|
45
|
+
|
|
46
|
+
usage: {
|
|
47
|
+
activeSeconds: number;
|
|
48
|
+
tokens: number;
|
|
49
|
+
/** Footer-style usage content, e.g. `49h49m36s 19M` (empty when unused). */
|
|
50
|
+
footerBits: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
budget?: {
|
|
54
|
+
used: number;
|
|
55
|
+
total: number;
|
|
56
|
+
percentage: number;
|
|
57
|
+
remaining: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
taskProgress?: {
|
|
61
|
+
completed: number;
|
|
62
|
+
total: number;
|
|
63
|
+
percentage: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
taskTree: DashboardTaskNode[];
|
|
67
|
+
|
|
68
|
+
currentTask?: {
|
|
69
|
+
id: string;
|
|
70
|
+
title: string;
|
|
71
|
+
depth: number;
|
|
72
|
+
completedSubtasks: number;
|
|
73
|
+
totalSubtasks: number;
|
|
74
|
+
subtaskPercentage: number;
|
|
75
|
+
verificationContract?: string;
|
|
76
|
+
evidence?: string;
|
|
77
|
+
/**
|
|
78
|
+
* True when no valid persisted currentTaskId existed and the current
|
|
79
|
+
* task was inferred from the first pending task for display only —
|
|
80
|
+
* never persisted (§7.4).
|
|
81
|
+
*/
|
|
82
|
+
inferred?: boolean;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
goalVerificationContract?: string;
|
|
86
|
+
otherOpenGoals: number;
|
|
87
|
+
recentActivity: GoalActivityItem[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface DashboardTaskNode {
|
|
91
|
+
id: string;
|
|
92
|
+
title: string;
|
|
93
|
+
status: "pending" | "complete" | "skipped";
|
|
94
|
+
depth: number;
|
|
95
|
+
isCurrent: boolean;
|
|
96
|
+
verificationContract?: string;
|
|
97
|
+
evidence?: string;
|
|
98
|
+
/** Completion timestamp (when status is complete) — the scroll anchor (§9.6). */
|
|
99
|
+
completedAt?: string;
|
|
100
|
+
/** Direct-child subtask total — 0 for a leaf task (§9.3 rule: direct children only). */
|
|
101
|
+
totalSubtasks: number;
|
|
102
|
+
/** Direct children complete or skipped (same "done" rule as §9.1). */
|
|
103
|
+
completedSubtasks: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface GoalDashboardModelOptions {
|
|
107
|
+
commands?: GoalCommands;
|
|
108
|
+
maxAutonomousRuns?: number;
|
|
109
|
+
idleContinuationBackoff?: boolean;
|
|
110
|
+
/** Report the runs line for a finite allowance (default on). */
|
|
111
|
+
showAutonomousRuns?: boolean;
|
|
112
|
+
focused: boolean;
|
|
113
|
+
otherOpenGoals: number;
|
|
114
|
+
ledgerEvents?: readonly GoalLedgerEvent[];
|
|
115
|
+
activityLimit?: number;
|
|
116
|
+
/** §9.5: omit task sections when tasks are disabled by settings. */
|
|
117
|
+
tasksDisabled?: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Status derivation
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
export interface DashboardGoalStatus {
|
|
125
|
+
code: DashboardStatusCode;
|
|
126
|
+
label: string;
|
|
127
|
+
/** Footer-status label (goal-core statusLabel: running / paused (agent) /
|
|
128
|
+
* blocked / budget limited / active / complete, with sisyphus prefix) — the
|
|
129
|
+
* widget status line shows the same terminology as the pi footer. */
|
|
130
|
+
footerLabel: string;
|
|
131
|
+
reason?: string;
|
|
132
|
+
suggestedAction?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Map the persisted lifecycle status to a display code. Labels are explicit
|
|
137
|
+
* words so the dashboard stays understandable without status symbols (§5.2).
|
|
138
|
+
*/
|
|
139
|
+
export function deriveGoalStatus(goal: GoalRecord): DashboardGoalStatus {
|
|
140
|
+
switch (goal.status) {
|
|
141
|
+
case "active":
|
|
142
|
+
return goal.autoContinue
|
|
143
|
+
? { code: "running", label: "In progress", footerLabel: statusLabel(goal) }
|
|
144
|
+
: { code: "idle", label: "Idle", footerLabel: statusLabel(goal) };
|
|
145
|
+
case "paused": {
|
|
146
|
+
const who = goal.stopReason === "agent" ? " (agent)" : goal.stopReason === "user" ? " (user)" : "";
|
|
147
|
+
const status: DashboardGoalStatus = { code: "paused", label: `Paused${who}`, footerLabel: statusLabel(goal) };
|
|
148
|
+
if (goal.pauseReason) status.reason = goal.pauseReason;
|
|
149
|
+
if (goal.pauseSuggestedAction) status.suggestedAction = goal.pauseSuggestedAction;
|
|
150
|
+
return status;
|
|
151
|
+
}
|
|
152
|
+
case "blocked": {
|
|
153
|
+
const status: DashboardGoalStatus = { code: "blocked", label: "Blocked", footerLabel: statusLabel(goal) };
|
|
154
|
+
if (goal.pauseReason) status.reason = goal.pauseReason;
|
|
155
|
+
if (goal.pauseSuggestedAction) status.suggestedAction = goal.pauseSuggestedAction;
|
|
156
|
+
return status;
|
|
157
|
+
}
|
|
158
|
+
case "budget_limited":
|
|
159
|
+
return { code: "budget_limited", label: "Budget limited", footerLabel: statusLabel(goal) };
|
|
160
|
+
case "complete":
|
|
161
|
+
return { code: "complete", label: "Complete", footerLabel: statusLabel(goal) };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Task progress (plan §9)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
export interface TaskProgress {
|
|
170
|
+
completed: number;
|
|
171
|
+
total: number;
|
|
172
|
+
percentage: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Overall progress across TOP-LEVEL tasks only (§9.1): a top-level task
|
|
177
|
+
* counts as done when it is complete or skipped, keeping the main percentage
|
|
178
|
+
* aligned with major milestones.
|
|
179
|
+
*/
|
|
180
|
+
export function deriveTopLevelTaskProgress(goal: GoalRecord): TaskProgress | undefined {
|
|
181
|
+
const tasks = goal.taskList?.tasks;
|
|
182
|
+
if (!tasks || tasks.length === 0) return undefined;
|
|
183
|
+
const completed = tasks.filter((t) => t.status === "complete" || t.status === "skipped").length;
|
|
184
|
+
return { completed, total: tasks.length, percentage: percentageOf(completed, tasks.length) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function percentageOf(done: number, total: number): number {
|
|
188
|
+
if (total <= 0) return 0;
|
|
189
|
+
return Math.min(100, Math.max(0, Math.round((done / total) * 100)));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Task tree (plan §9.2)
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Flatten the recursive task tree into display rows. `currentTaskId` marks
|
|
198
|
+
* the current node; pass the EFFECTIVE current id (persisted, or inferred)
|
|
199
|
+
* so the tree and the current-task block always agree.
|
|
200
|
+
*/
|
|
201
|
+
export function flattenTaskTree(tasks: readonly GoalTask[] | undefined, currentTaskId?: string): DashboardTaskNode[] {
|
|
202
|
+
const index = taskIndex(tasks);
|
|
203
|
+
return index.ordered.map(({task: t, depth}) => ({
|
|
204
|
+
id: t.id, title: t.title, status: t.status, depth, isCurrent: t.id === currentTaskId,
|
|
205
|
+
verificationContract: t.verificationContract, evidence: t.evidence, completedAt: t.completedAt,
|
|
206
|
+
totalSubtasks: t.subtasks?.length ?? 0,
|
|
207
|
+
completedSubtasks: t.subtasks?.filter(c => c.status === "complete" || c.status === "skipped").length ?? 0,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Current task (plan §7.2, §9.3)
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
export interface CurrentTaskSubtaskProgress {
|
|
216
|
+
completedSubtasks: number;
|
|
217
|
+
totalSubtasks: number;
|
|
218
|
+
subtaskPercentage: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Subtask progress for the current task (§9.3 preferred rule): for a parent
|
|
223
|
+
* task, direct-child completion; for a leaf task, all-zero so the renderer
|
|
224
|
+
* can omit the ratio rather than show something confusing.
|
|
225
|
+
*/
|
|
226
|
+
export function deriveCurrentTaskSubtaskProgress(current: { id: string }, tasks: readonly GoalTask[]): CurrentTaskSubtaskProgress {
|
|
227
|
+
const parent = findTask(tasks, current.id);
|
|
228
|
+
const children = parent?.subtasks;
|
|
229
|
+
if (!children || children.length === 0) {
|
|
230
|
+
return { completedSubtasks: 0, totalSubtasks: 0, subtaskPercentage: 0 };
|
|
231
|
+
}
|
|
232
|
+
const completed = children.filter((c) => c.status === "complete" || c.status === "skipped").length;
|
|
233
|
+
return {
|
|
234
|
+
completedSubtasks: completed,
|
|
235
|
+
totalSubtasks: children.length,
|
|
236
|
+
subtaskPercentage: percentageOf(completed, children.length),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface DashboardCurrentTask extends CurrentTaskSubtaskProgress {
|
|
241
|
+
id: string;
|
|
242
|
+
title: string;
|
|
243
|
+
depth: number;
|
|
244
|
+
verificationContract?: string;
|
|
245
|
+
evidence?: string;
|
|
246
|
+
inferred?: boolean;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Resolve the current task: the persisted currentTaskId when it references an
|
|
251
|
+
* existing pending task; otherwise (missing, invalid, or removed) fall back
|
|
252
|
+
* to the first pending task in tree order, marked `inferred` so the fallback
|
|
253
|
+
* is never persisted (§7.4). `currentTaskId` may point at any tree node —
|
|
254
|
+
* top-level task or subtask (§7.2).
|
|
255
|
+
*/
|
|
256
|
+
export function deriveCurrentTask(goal: GoalRecord, nodes: DashboardTaskNode[]): DashboardCurrentTask | undefined {
|
|
257
|
+
const tasks = goal.taskList?.tasks;
|
|
258
|
+
if (!tasks || tasks.length === 0 || nodes.length === 0) return undefined;
|
|
259
|
+
let node: DashboardTaskNode | undefined;
|
|
260
|
+
if (goal.currentTaskId) {
|
|
261
|
+
node = nodes.find((n) => n.id === goal.currentTaskId && n.status === "pending");
|
|
262
|
+
}
|
|
263
|
+
const inferred = node === undefined;
|
|
264
|
+
if (inferred) node = nodes.find((n) => n.status === "pending");
|
|
265
|
+
if (!node) return undefined;
|
|
266
|
+
const subtaskProgress = deriveCurrentTaskSubtaskProgress(node, tasks);
|
|
267
|
+
return {
|
|
268
|
+
id: node.id,
|
|
269
|
+
title: node.title,
|
|
270
|
+
depth: node.depth,
|
|
271
|
+
...subtaskProgress,
|
|
272
|
+
verificationContract: node.verificationContract,
|
|
273
|
+
evidence: node.evidence,
|
|
274
|
+
...(inferred ? { inferred: true } : {}),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Task-list viewport / scroll (plan §9.6)
|
|
280
|
+
//
|
|
281
|
+
// The task list is a window over the rendered rows. Pure functions only: the
|
|
282
|
+
// widget owns the mutable offset and the re-anchor rule; the renderer owns
|
|
283
|
+
// the box drawing. Anchoring means the default viewport is positioned so the
|
|
284
|
+
// most recently completed task is visible — "scrolled down" to recent work
|
|
285
|
+
// instead of always showing the earliest tasks.
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Index of the most recently completed node: the node with status
|
|
290
|
+
* "complete" and the greatest completedAt; ties resolve to the LAST such
|
|
291
|
+
* node in plan order. Returns -1 when nothing qualifies (e.g. legacy tasks
|
|
292
|
+
* without a completion timestamp), in which case the viewport stays at the
|
|
293
|
+
* top.
|
|
294
|
+
*/
|
|
295
|
+
export function latestCompletedNodeIndex(nodes: readonly DashboardTaskNode[]): number {
|
|
296
|
+
let bestIndex = -1;
|
|
297
|
+
let bestAt = "";
|
|
298
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
299
|
+
const node = nodes[i]!;
|
|
300
|
+
if (node.status !== "complete" || !node.completedAt) continue;
|
|
301
|
+
// `>=` keeps the LAST node among equal timestamps (most recent plan
|
|
302
|
+
// position), matching how a viewer reads "most recently done".
|
|
303
|
+
if (bestIndex === -1 || node.completedAt >= bestAt) {
|
|
304
|
+
bestIndex = i;
|
|
305
|
+
bestAt = node.completedAt;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return bestIndex;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Highest valid scroll offset for a list of totalRows shown in rows rows. */
|
|
312
|
+
export function maxScrollOffset(totalRows: number, rows: number): number {
|
|
313
|
+
return Math.max(0, totalRows - rows);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Clamp an offset into [0, maxScrollOffset]. */
|
|
317
|
+
export function clampScrollOffset(offset: number, totalRows: number, rows: number): number {
|
|
318
|
+
return Math.min(Math.max(0, Math.floor(offset)), maxScrollOffset(totalRows, rows));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Default viewport offset, bottom-anchored to the most recently completed
|
|
323
|
+
* task: the anchor is the LAST visible row, so the view shows the recent
|
|
324
|
+
* completions (and whatever follows them in plan order) instead of the
|
|
325
|
+
* earliest tasks. With no completed (or timestamped) tasks the viewport
|
|
326
|
+
* starts at the top.
|
|
327
|
+
*/
|
|
328
|
+
export function anchoredScrollOffset(nodes: readonly DashboardTaskNode[], rows: number): number {
|
|
329
|
+
const anchor = latestCompletedNodeIndex(nodes);
|
|
330
|
+
if (anchor < 0 || rows <= 0) return 0;
|
|
331
|
+
return clampScrollOffset(anchor - (rows - 1), nodes.length, rows);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Task-row budget for the compact viewport by terminal width (§5.5 buckets:
|
|
336
|
+
* wide ≥100, medium 70–99, narrow 50–69, minimal <50). The expanded view
|
|
337
|
+
* derives its own rows from the render height.
|
|
338
|
+
*/
|
|
339
|
+
export function compactTaskViewportRows(width: number): number {
|
|
340
|
+
if (width >= 100) return 5;
|
|
341
|
+
if (width >= 70) return 4;
|
|
342
|
+
if (width >= 50) return 3;
|
|
343
|
+
return 2;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Task-row budget for the expanded dashboard's task-tree window by terminal
|
|
348
|
+
* width. The widget uses this to keep the expanded panel bounded and
|
|
349
|
+
* scrollable; callers that want the full tree pass rows = totalRows.
|
|
350
|
+
*/
|
|
351
|
+
export function expandedTaskViewportRows(width: number): number {
|
|
352
|
+
if (width >= 100) return 20;
|
|
353
|
+
if (width >= 70) return 16;
|
|
354
|
+
if (width >= 50) return 12;
|
|
355
|
+
return 8;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** PgUp/PgDn step: one viewport (a page) of rows. */
|
|
359
|
+
export function taskViewportPageSize(rows: number): number {
|
|
360
|
+
return Math.max(1, Math.floor(rows));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export interface TaskListViewport {
|
|
364
|
+
totalRows: number;
|
|
365
|
+
rows: number;
|
|
366
|
+
offset: number;
|
|
367
|
+
maxOffset: number;
|
|
368
|
+
/** Rows hidden above the window (offset > 0). */
|
|
369
|
+
hiddenAbove: number;
|
|
370
|
+
/** Rows hidden below the window (offset + rows < total). */
|
|
371
|
+
hiddenBelow: number;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Derive the viewport window for a list of totalRows given a clamped offset
|
|
376
|
+
* and row budget. The renderer uses hiddenAbove/hiddenBelow to draw the
|
|
377
|
+
* `↑ N more` / `… +N more` indicator rows.
|
|
378
|
+
*/
|
|
379
|
+
export function deriveTaskListViewport(totalRows: number, rows: number, offset: number): TaskListViewport {
|
|
380
|
+
const safeRows = Math.max(0, Math.floor(rows));
|
|
381
|
+
const clamped = clampScrollOffset(offset, totalRows, safeRows);
|
|
382
|
+
const maxOffset = maxScrollOffset(totalRows, safeRows);
|
|
383
|
+
const visible = Math.min(safeRows, Math.max(0, totalRows - clamped));
|
|
384
|
+
return {
|
|
385
|
+
totalRows,
|
|
386
|
+
rows: safeRows,
|
|
387
|
+
offset: clamped,
|
|
388
|
+
maxOffset,
|
|
389
|
+
hiddenAbove: clamped,
|
|
390
|
+
hiddenBelow: Math.max(0, totalRows - (clamped + visible)),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function findTask(tasks: readonly GoalTask[], id: string): GoalTask | undefined {
|
|
395
|
+
for (const t of tasks) {
|
|
396
|
+
if (t.id === id) return t;
|
|
397
|
+
if (t.subtasks && t.subtasks.length > 0) {
|
|
398
|
+
const found = findTask(t.subtasks, id);
|
|
399
|
+
if (found) return found;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// Activity (re-exported from the durable-ledger mapping, §12)
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
export { deriveGoalActivity };
|
|
410
|
+
export type { GoalActivityItem, GoalActivityKind } from "../goal-activity.ts";
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Formatting helpers
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
/** Compact elapsed duration, e.g. `12m47s` (shared formatter from goal-core). */
|
|
417
|
+
export function formatDashboardDuration(seconds: number): string {
|
|
418
|
+
return formatDuration(seconds);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Audit header duration label from milliseconds (§15.3). */
|
|
422
|
+
export function formatAuditElapsed(elapsedMs: number): string {
|
|
423
|
+
return formatDuration(Math.floor(Math.max(0, elapsedMs) / 1000));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Compact token count, e.g. `18.2K`, `2.5M`, `999`. Shared display label for
|
|
428
|
+
* the dashboard header and budget rows.
|
|
429
|
+
*/
|
|
430
|
+
export function formatCompactTokens(value: number): string {
|
|
431
|
+
const safe = Math.max(0, Math.floor(value));
|
|
432
|
+
if (safe >= 1_000_000) return `${trimZero((safe / 1_000_000).toFixed(1))}M`;
|
|
433
|
+
if (safe >= 1_000) return `${trimZero((safe / 1_000).toFixed(1))}K`;
|
|
434
|
+
return String(safe);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function trimZero(value: string): string {
|
|
438
|
+
return value.replace(/\.0$/, "");
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Budget summary, e.g. `18.2K / 50K · 36%`. */
|
|
442
|
+
export function formatBudget(used: number, total: number): string {
|
|
443
|
+
const pct = total > 0 ? Math.min(100, Math.max(0, Math.round((used / total) * 100))) : 0;
|
|
444
|
+
return `${formatCompactTokens(used)} / ${formatCompactTokens(total)} · ${pct}%`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// Whole-model derivation
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
const presentationCache = new WeakMap<object, Map<string, {taskProgress: TaskProgress | undefined; taskTree: DashboardTaskNode[]; currentTask: DashboardCurrentTask | undefined; taskTitles: Map<string, string>}>>();
|
|
452
|
+
function taskPresentation(goal: GoalRecord, disabled: boolean) {
|
|
453
|
+
const index = taskIndex(disabled ? [] : goal.taskList?.tasks);
|
|
454
|
+
let entries = presentationCache.get(index);
|
|
455
|
+
if (!entries) { entries = new Map(); presentationCache.set(index, entries); }
|
|
456
|
+
const key = `${disabled}:${goal.currentTaskId ?? ""}`;
|
|
457
|
+
const hit = entries.get(key);
|
|
458
|
+
if (hit) return hit;
|
|
459
|
+
const taskProgress = disabled ? undefined : deriveTopLevelTaskProgress(goal);
|
|
460
|
+
const tree = disabled ? [] : flattenTaskTree(index.tasks);
|
|
461
|
+
const currentTask = disabled ? undefined : deriveCurrentTask(goal, tree);
|
|
462
|
+
const taskTree = tree.map(n => n.id === currentTask?.id ? {...n, isCurrent: true} : n);
|
|
463
|
+
const value = {taskProgress, taskTree, currentTask, taskTitles: new Map(taskTree.map(n => [n.id, n.title]))};
|
|
464
|
+
if (entries.size >= 64) entries.delete(entries.keys().next().value!);
|
|
465
|
+
entries.set(key, value);
|
|
466
|
+
return value;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Derive the unified dashboard model for one goal. Returns null when there is
|
|
471
|
+
* no goal record; surfaces that need the "no goal / focus required" panel
|
|
472
|
+
* (plan §4.3) render that from their own context with `otherOpenGoals`.
|
|
473
|
+
*/
|
|
474
|
+
export function deriveGoalDashboardModel(
|
|
475
|
+
goal: GoalRecord | null,
|
|
476
|
+
options: GoalDashboardModelOptions,
|
|
477
|
+
): GoalDashboardModel | null {
|
|
478
|
+
if (!goal) return null;
|
|
479
|
+
const { focused, otherOpenGoals, ledgerEvents = [], activityLimit, tasksDisabled = false } = options;
|
|
480
|
+
|
|
481
|
+
const status = deriveGoalStatus(goal);
|
|
482
|
+
if (goal.status === "active" && goal.scheduler?.phase === "interrupted") { status.code = "idle"; status.label = "Waiting for you"; status.footerLabel = "waiting for you"; }
|
|
483
|
+
if (goal.status === "active" && goal.scheduler?.phase === "waiting") { status.code = "idle"; status.label = "Waiting"; status.footerLabel = "waiting"; }
|
|
484
|
+
// §9.5: with tasks disabled, omit task sections entirely (status,
|
|
485
|
+
// verification, usage, path, and focus remain).
|
|
486
|
+
const {taskProgress, taskTree, currentTask, taskTitles} = taskPresentation(goal, tasksDisabled);
|
|
487
|
+
|
|
488
|
+
const budget =
|
|
489
|
+
goal.tokenBudget !== undefined
|
|
490
|
+
? {
|
|
491
|
+
used: goal.usage.tokensUsed,
|
|
492
|
+
total: goal.tokenBudget,
|
|
493
|
+
percentage: percentageOf(goal.usage.tokensUsed, goal.tokenBudget),
|
|
494
|
+
remaining: Math.max(0, goal.tokenBudget - goal.usage.tokensUsed),
|
|
495
|
+
}
|
|
496
|
+
: undefined;
|
|
497
|
+
|
|
498
|
+
const recentActivity = deriveGoalActivity(ledgerEvents, goal.id, { taskTitles, limit: activityLimit });
|
|
499
|
+
|
|
500
|
+
// Footer-status usage bits (goal-core footerStatus parity): compact
|
|
501
|
+
// duration + compact token count, e.g. `49h49m36s 19M`. The status line
|
|
502
|
+
// renders them inside brackets; empty when the goal has no usage yet.
|
|
503
|
+
const footerUsageBits: string[] = [];
|
|
504
|
+
if (goal.usage.activeSeconds > 0) footerUsageBits.push(formatDuration(goal.usage.activeSeconds));
|
|
505
|
+
if (goal.usage.tokensUsed > 0) footerUsageBits.push(formatCompactTokens(goal.usage.tokensUsed));
|
|
506
|
+
|
|
507
|
+
const scheduling = schedulerSummaryParts(goal.scheduler, options.maxAutonomousRuns, options.showAutonomousRuns !== false, options.idleContinuationBackoff === true, options.commands);
|
|
508
|
+
return {
|
|
509
|
+
scheduling: [scheduling.runs, ...scheduling.instructions.split("\n")].filter(Boolean),
|
|
510
|
+
idlePacing: scheduling.idlePacing,
|
|
511
|
+
goalId: goal.id,
|
|
512
|
+
title: displayObjectiveTitle(goal.objective),
|
|
513
|
+
status,
|
|
514
|
+
focused,
|
|
515
|
+
auditorEnabled: goal.skipAuditor !== true,
|
|
516
|
+
filePath: goal.activePath ?? goal.archivedPath,
|
|
517
|
+
usage: {
|
|
518
|
+
activeSeconds: goal.usage.activeSeconds,
|
|
519
|
+
tokens: goal.usage.tokensUsed,
|
|
520
|
+
footerBits: footerUsageBits.join(" "),
|
|
521
|
+
},
|
|
522
|
+
budget,
|
|
523
|
+
taskProgress,
|
|
524
|
+
taskTree,
|
|
525
|
+
currentTask,
|
|
526
|
+
goalVerificationContract: goal.verificationContract,
|
|
527
|
+
otherOpenGoals,
|
|
528
|
+
recentActivity,
|
|
529
|
+
};
|
|
530
|
+
}
|