@aemonge-dev/pi-stardeck 0.9.0 → 0.9.2
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/docs/architecture.md +1 -1
- package/package.json +1 -1
- package/packages/pi-stardeck/README.md +4 -4
- package/packages/pi-stardeck/index.ts +10 -0
- package/packages/pi-stardeck/src/deck.ts +94 -105
- package/packages/pi-stardeck/src/gauge.ts +1 -58
- package/packages/pi-stardeck/src/title.ts +1 -0
- package/packages/pi-vim-top-border/prompt-rail.ts +0 -2
- package/packages/status/README.md +13 -24
- package/packages/status/index.ts +17 -144
- package/packages/status/src/active-work.ts +0 -9
- package/packages/status/src/ledger.ts +0 -3
- package/packages/status/src/publisher.ts +8 -173
- package/packages/status/src/reducer.ts +0 -21
- package/packages/status/src/title-activity.ts +18 -2
- package/packages/status/src/types.ts +0 -7
- package/packages/status/src/goal-block.ts +0 -377
- package/packages/status/src/goal.ts +0 -170
|
@@ -1,377 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
ExtensionContext,
|
|
4
|
-
Theme,
|
|
5
|
-
} from '@earendil-works/pi-coding-agent';
|
|
6
|
-
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
7
|
-
import { readFileSync, statSync } from 'node:fs';
|
|
8
|
-
import { homedir } from 'node:os';
|
|
9
|
-
import { dirname, join, resolve } from 'node:path';
|
|
10
|
-
import type { GoalHeaderState } from './goal.ts';
|
|
11
|
-
import { GOAL_HEADER_STATUSES } from './goal.ts';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Goal-block takeover for pi-goal-x: replaces its persistent aboveEditor
|
|
15
|
-
* widget ("goal" key) with a compact deck-style block rendered from the
|
|
16
|
-
* goal entries pi-goal-x already appends to the session branch.
|
|
17
|
-
*
|
|
18
|
-
* pi-goal-x registers its widget once per session (one-shot flag), so the
|
|
19
|
-
* first same-key registration after theirs owns the slot for the session.
|
|
20
|
-
* We re-assert briefly after session start and on turn starts until the
|
|
21
|
-
* takeover sticks. pi-goal-x modals, tools, and storage are untouched.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
const GOAL_WIDGET_KEY = 'goal';
|
|
25
|
-
const GOAL_X_STATE_ENTRY = 'pi-goal-state';
|
|
26
|
-
const GOAL_X_FOCUS_ENTRY = 'pi-goal-focus';
|
|
27
|
-
const REASSERT_DELAYS_MS = [800, 2_000, 5_000, 10_000];
|
|
28
|
-
|
|
29
|
-
export interface GoalBlockTaskCounts {
|
|
30
|
-
pending: number;
|
|
31
|
-
active: number;
|
|
32
|
-
complete: number;
|
|
33
|
-
total: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface GoalBlockState {
|
|
37
|
-
id: string;
|
|
38
|
-
objective: string;
|
|
39
|
-
status: string;
|
|
40
|
-
tokensUsed?: number;
|
|
41
|
-
activeSeconds?: number;
|
|
42
|
-
tokenBudget?: number;
|
|
43
|
-
autoContinue?: boolean;
|
|
44
|
-
tasks?: GoalBlockTaskCounts;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function countTasks(node: unknown, counts: GoalBlockTaskCounts): void {
|
|
52
|
-
if (!isRecord(node)) return;
|
|
53
|
-
const status = node.status;
|
|
54
|
-
if (status === 'pending' || status === 'active' || status === 'complete') {
|
|
55
|
-
counts.total += 1;
|
|
56
|
-
counts[status] += 1;
|
|
57
|
-
}
|
|
58
|
-
if (Array.isArray(node.subtasks)) {
|
|
59
|
-
for (const child of node.subtasks) countTasks(child, counts);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function parseTaskCounts(taskList: unknown): GoalBlockTaskCounts | undefined {
|
|
64
|
-
if (!isRecord(taskList) || !Array.isArray(taskList.tasks)) return undefined;
|
|
65
|
-
const counts: GoalBlockTaskCounts = { pending: 0, active: 0, complete: 0, total: 0 };
|
|
66
|
-
for (const task of taskList.tasks) countTasks(task, counts);
|
|
67
|
-
return counts.total > 0 ? counts : undefined;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function parseNumber(value: unknown): number | undefined {
|
|
71
|
-
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
72
|
-
? value
|
|
73
|
-
: undefined;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function parseGoalRecord(value: unknown): GoalBlockState | null {
|
|
77
|
-
if (!isRecord(value)) return null;
|
|
78
|
-
if (typeof value.id !== 'string' || value.id.trim() === '') return null;
|
|
79
|
-
if (typeof value.objective !== 'string' || value.objective.trim() === '') return null;
|
|
80
|
-
const usage = isRecord(value.usage) ? value.usage : {};
|
|
81
|
-
const budget = parseNumber(usage.tokenBudget) ?? parseNumber(value.tokenBudget);
|
|
82
|
-
return {
|
|
83
|
-
id: value.id.slice(0, 128),
|
|
84
|
-
objective: value.objective.replace(/\s+/gu, ' ').trim().slice(0, 160),
|
|
85
|
-
status: typeof value.status === 'string' ? value.status.slice(0, 24) : 'active',
|
|
86
|
-
tokensUsed: parseNumber(usage.tokensUsed),
|
|
87
|
-
activeSeconds: parseNumber(usage.activeSeconds),
|
|
88
|
-
...(budget !== undefined && budget > 0 ? { tokenBudget: budget } : {}),
|
|
89
|
-
...(value.autoContinue === true ? { autoContinue: true } : {}),
|
|
90
|
-
...(parseTaskCounts(value.taskList)
|
|
91
|
-
? { tasks: parseTaskCounts(value.taskList) }
|
|
92
|
-
: {}),
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function readGoalBlockRecord(entries: readonly unknown[]): {
|
|
97
|
-
focusedGoalId: string | null | undefined;
|
|
98
|
-
record: unknown;
|
|
99
|
-
recordSeen: boolean;
|
|
100
|
-
} {
|
|
101
|
-
let focusedGoalId: string | null | undefined;
|
|
102
|
-
let record: unknown;
|
|
103
|
-
let recordSeen = false;
|
|
104
|
-
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
105
|
-
const entry = entries[index];
|
|
106
|
-
if (!isRecord(entry) || entry.type !== 'custom') continue;
|
|
107
|
-
if (
|
|
108
|
-
focusedGoalId === undefined &&
|
|
109
|
-
entry.customType === GOAL_X_FOCUS_ENTRY &&
|
|
110
|
-
isRecord(entry.data)
|
|
111
|
-
) {
|
|
112
|
-
focusedGoalId =
|
|
113
|
-
typeof entry.data.focusedGoalId === 'string' ? entry.data.focusedGoalId : null;
|
|
114
|
-
}
|
|
115
|
-
if (
|
|
116
|
-
!recordSeen &&
|
|
117
|
-
entry.customType === GOAL_X_STATE_ENTRY &&
|
|
118
|
-
isRecord(entry.data)
|
|
119
|
-
) {
|
|
120
|
-
record = entry.data.goal;
|
|
121
|
-
recordSeen = true;
|
|
122
|
-
}
|
|
123
|
-
if (focusedGoalId !== undefined && recordSeen) break;
|
|
124
|
-
}
|
|
125
|
-
return { focusedGoalId, record, recordSeen };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function isDirectorySafe(path: string): boolean {
|
|
129
|
-
try {
|
|
130
|
-
return statSync(path).isDirectory();
|
|
131
|
-
} catch {
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Mirror pi-goal-x storage resolution: env root, project root, then the
|
|
137
|
-
* agent-level fallback. Broken symlinks are skipped, never followed. */
|
|
138
|
-
export function resolveGoalRoot(ctx: ExtensionContext): string | undefined {
|
|
139
|
-
const candidates: string[] = [];
|
|
140
|
-
if (process.env.PI_GOAL_ROOT) candidates.push(resolve(process.env.PI_GOAL_ROOT));
|
|
141
|
-
if (typeof ctx.cwd === 'string') {
|
|
142
|
-
candidates.push(resolve(ctx.cwd, '.pi/goals'));
|
|
143
|
-
}
|
|
144
|
-
candidates.push(join(homedir(), '.pi', 'goals'));
|
|
145
|
-
return candidates.find((candidate) => isDirectorySafe(candidate));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const poolCache = new Map<string, { mtimeMs: number; goals: unknown[] }>();
|
|
149
|
-
|
|
150
|
-
function snapshotPaths(root: string): string[] {
|
|
151
|
-
return [
|
|
152
|
-
join(root, '.metadata', '.goals-pool-snapshot.json'),
|
|
153
|
-
join(dirname(root), '.goals-pool-snapshot.json'),
|
|
154
|
-
];
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function loadPoolGoals(root: string): unknown[] {
|
|
158
|
-
for (const snapshotPath of snapshotPaths(root)) {
|
|
159
|
-
try {
|
|
160
|
-
const stats = statSync(snapshotPath);
|
|
161
|
-
if (!stats.isFile()) continue;
|
|
162
|
-
const cached = poolCache.get(snapshotPath);
|
|
163
|
-
if (cached && cached.mtimeMs === stats.mtimeMs) return cached.goals;
|
|
164
|
-
const parsed: unknown = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
165
|
-
if (!isRecord(parsed) || !Array.isArray(parsed.goals)) continue;
|
|
166
|
-
const goals = parsed.goals.filter(isRecord);
|
|
167
|
-
poolCache.set(snapshotPath, { mtimeMs: stats.mtimeMs, goals });
|
|
168
|
-
return goals;
|
|
169
|
-
} catch {
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return [];
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export function readGoalBlockState(ctx: ExtensionContext): GoalBlockState | null {
|
|
177
|
-
const { focusedGoalId, record, recordSeen } = readGoalBlockRecord(
|
|
178
|
-
ctx.sessionManager.getBranch(),
|
|
179
|
-
);
|
|
180
|
-
if (focusedGoalId === null) return null;
|
|
181
|
-
let candidate: unknown = recordSeen ? record : undefined;
|
|
182
|
-
const root = resolveGoalRoot(ctx);
|
|
183
|
-
if (root) {
|
|
184
|
-
const goals = loadPoolGoals(root);
|
|
185
|
-
const fromPool = goals.find((goal) => isRecord(goal) && goal.id === focusedGoalId);
|
|
186
|
-
if (fromPool !== undefined) candidate = fromPool;
|
|
187
|
-
}
|
|
188
|
-
const state = parseGoalRecord(candidate);
|
|
189
|
-
if (!state) return null;
|
|
190
|
-
if (
|
|
191
|
-
focusedGoalId !== undefined &&
|
|
192
|
-
focusedGoalId !== null &&
|
|
193
|
-
focusedGoalId !== state.id
|
|
194
|
-
) {
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
return state;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export function readGoalHeadline(ctx: ExtensionContext): GoalHeaderState | null {
|
|
201
|
-
const state = readGoalBlockState(ctx);
|
|
202
|
-
if (!state) return null;
|
|
203
|
-
return {
|
|
204
|
-
id: state.id,
|
|
205
|
-
status: (GOAL_HEADER_STATUSES as readonly string[]).includes(state.status)
|
|
206
|
-
? (state.status as GoalHeaderState['status'])
|
|
207
|
-
: 'active',
|
|
208
|
-
automaticModelTurns: 0,
|
|
209
|
-
waiting: state.status === 'active',
|
|
210
|
-
...(state.objective !== undefined ? { objective: state.objective } : {}),
|
|
211
|
-
...(state.activeSeconds !== undefined
|
|
212
|
-
? { elapsedSeconds: state.activeSeconds }
|
|
213
|
-
: {}),
|
|
214
|
-
...(state.tasks
|
|
215
|
-
? {
|
|
216
|
-
tasksDone: state.tasks.complete,
|
|
217
|
-
tasksActive: state.tasks.active,
|
|
218
|
-
tasksTotal: state.tasks.total,
|
|
219
|
-
}
|
|
220
|
-
: {}),
|
|
221
|
-
...(state.tokensUsed !== undefined ? { tokensUsed: state.tokensUsed } : {}),
|
|
222
|
-
...(state.tokenBudget !== undefined ? { tokenBudget: state.tokenBudget } : {}),
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export function countOpenGoals(ctx: ExtensionContext): number {
|
|
227
|
-
const root = resolveGoalRoot(ctx);
|
|
228
|
-
if (!root) return 0;
|
|
229
|
-
return loadPoolGoals(root).filter(
|
|
230
|
-
(goal) => isRecord(goal) && goal.status !== 'complete',
|
|
231
|
-
).length;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export function formatGoalElapsed(seconds: number): string {
|
|
235
|
-
if (seconds < 60) return `${Math.floor(seconds)}s`;
|
|
236
|
-
const totalMinutes = Math.floor(seconds / 60);
|
|
237
|
-
if (totalMinutes < 60) {
|
|
238
|
-
return `${totalMinutes}m ${Math.floor(seconds % 60)}s`;
|
|
239
|
-
}
|
|
240
|
-
return `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function formatTokens(tokens: number): string {
|
|
244
|
-
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
245
|
-
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
246
|
-
return `${Math.floor(tokens)}`;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const STATUS_COLORS: Record<
|
|
250
|
-
string,
|
|
251
|
-
'success' | 'accent' | 'warning' | 'error' | 'dim'
|
|
252
|
-
> = {
|
|
253
|
-
active: 'accent',
|
|
254
|
-
complete: 'success',
|
|
255
|
-
paused: 'warning',
|
|
256
|
-
blocked: 'error',
|
|
257
|
-
usage_limited: 'warning',
|
|
258
|
-
budget_limited: 'warning',
|
|
259
|
-
queued: 'dim',
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
export function renderGoalBlock(
|
|
263
|
-
state: GoalBlockState | null,
|
|
264
|
-
width: number,
|
|
265
|
-
theme: Theme,
|
|
266
|
-
openGoals = 0,
|
|
267
|
-
): string[] {
|
|
268
|
-
if (width < 40) return [];
|
|
269
|
-
const fg = (semantic: string, text: string): string =>
|
|
270
|
-
theme.fg(semantic as never, text);
|
|
271
|
-
|
|
272
|
-
if (!state) {
|
|
273
|
-
if (openGoals > 0 && width >= 48) {
|
|
274
|
-
return [
|
|
275
|
-
fg(
|
|
276
|
-
'dim',
|
|
277
|
-
`◆ ${openGoals} open goal${openGoals === 1 ? '' : 's'} · /goal-focus`,
|
|
278
|
-
),
|
|
279
|
-
];
|
|
280
|
-
}
|
|
281
|
-
return [];
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const badge = fg(STATUS_COLORS[state.status] ?? 'text', `● ${state.status}`);
|
|
285
|
-
const elapsed =
|
|
286
|
-
state.activeSeconds !== undefined
|
|
287
|
-
? ` · ${formatGoalElapsed(state.activeSeconds)}`
|
|
288
|
-
: '';
|
|
289
|
-
const headline = `◆ ${state.objective} ${badge}${elapsed}`;
|
|
290
|
-
const rows: string[] = [truncateToWidth(headline, width, '…')];
|
|
291
|
-
|
|
292
|
-
if (state.tasks && width >= 48) {
|
|
293
|
-
const { complete, total, active } = state.tasks;
|
|
294
|
-
rows.push(
|
|
295
|
-
truncateToWidth(
|
|
296
|
-
` ├ tasks ${complete}/${total} done` +
|
|
297
|
-
(active > 0 ? ` · ${active} active` : ''),
|
|
298
|
-
width,
|
|
299
|
-
'…',
|
|
300
|
-
),
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (
|
|
305
|
-
width >= 60 &&
|
|
306
|
-
(state.tokensUsed !== undefined || state.tokenBudget !== undefined)
|
|
307
|
-
) {
|
|
308
|
-
const parts: string[] = [];
|
|
309
|
-
if (state.tokensUsed !== undefined)
|
|
310
|
-
parts.push(`${formatTokens(state.tokensUsed)} tokens`);
|
|
311
|
-
if (state.tokenBudget !== undefined && state.tokensUsed !== undefined) {
|
|
312
|
-
parts.push(
|
|
313
|
-
`budget ${Math.min(999, Math.round((state.tokensUsed / state.tokenBudget) * 100))}%`,
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
|
-
if (parts.length > 0) {
|
|
317
|
-
rows.push(truncateToWidth(` └ usage ${parts.join(' · ')}`, width, '…'));
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
return rows.map((line) => fg('dim', line));
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export function installGoalBlockTakeover(pi: ExtensionAPI): () => void {
|
|
325
|
-
let activeCtx: ExtensionContext | undefined;
|
|
326
|
-
let stopped = false;
|
|
327
|
-
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
328
|
-
|
|
329
|
-
const register = (ctx: ExtensionContext): void => {
|
|
330
|
-
if (stopped || ctx.mode !== 'tui' || !ctx.hasUI) return;
|
|
331
|
-
if (typeof ctx.ui.setWidget !== 'function') return;
|
|
332
|
-
ctx.ui.setWidget(GOAL_WIDGET_KEY, (tui, _theme: Theme) => {
|
|
333
|
-
const requestRender = (): void => tui.requestRender();
|
|
334
|
-
return {
|
|
335
|
-
render(width: number): string[] {
|
|
336
|
-
// Pure suppression: the deck owns every goal display, including
|
|
337
|
-
// the unfocused open-goal hint in its title row.
|
|
338
|
-
const lines: string[] = [];
|
|
339
|
-
return lines.every((line) => visibleWidth(line) <= width)
|
|
340
|
-
? lines
|
|
341
|
-
: lines.map((line) => truncateToWidth(line, width, '…'));
|
|
342
|
-
},
|
|
343
|
-
invalidate() {
|
|
344
|
-
requestRender();
|
|
345
|
-
},
|
|
346
|
-
};
|
|
347
|
-
});
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
const scheduleReasserts = (ctx: ExtensionContext): void => {
|
|
351
|
-
for (const delay of REASSERT_DELAYS_MS) {
|
|
352
|
-
const timer = setTimeout(() => {
|
|
353
|
-
if (!stopped && activeCtx === ctx) register(ctx);
|
|
354
|
-
}, delay);
|
|
355
|
-
timer.unref?.();
|
|
356
|
-
timers.push(timer);
|
|
357
|
-
}
|
|
358
|
-
};
|
|
359
|
-
|
|
360
|
-
pi.on('session_start', (_event, ctx) => {
|
|
361
|
-
if (!ctx) return;
|
|
362
|
-
activeCtx = ctx;
|
|
363
|
-
register(ctx);
|
|
364
|
-
scheduleReasserts(ctx);
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
pi.on('turn_start', () => {
|
|
368
|
-
if (!stopped && activeCtx) register(activeCtx);
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
return () => {
|
|
372
|
-
stopped = true;
|
|
373
|
-
for (const timer of timers) clearTimeout(timer);
|
|
374
|
-
activeCtx?.ui.setWidget(GOAL_WIDGET_KEY, undefined);
|
|
375
|
-
activeCtx = undefined;
|
|
376
|
-
};
|
|
377
|
-
}
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
4
|
-
|
|
5
|
-
export const GOAL_STATE_ENTRY_TYPE = 'goal-state';
|
|
6
|
-
|
|
7
|
-
export const GOAL_HEADER_STATUSES = [
|
|
8
|
-
'active',
|
|
9
|
-
'queued',
|
|
10
|
-
'paused',
|
|
11
|
-
'blocked',
|
|
12
|
-
'usage_limited',
|
|
13
|
-
'budget_limited',
|
|
14
|
-
'complete',
|
|
15
|
-
] as const;
|
|
16
|
-
|
|
17
|
-
export type GoalHeaderStatus = (typeof GOAL_HEADER_STATUSES)[number];
|
|
18
|
-
|
|
19
|
-
export interface GoalHeaderState {
|
|
20
|
-
id: string;
|
|
21
|
-
status: GoalHeaderStatus;
|
|
22
|
-
automaticModelTurns: number;
|
|
23
|
-
waiting: boolean;
|
|
24
|
-
objective?: string;
|
|
25
|
-
elapsedSeconds?: number;
|
|
26
|
-
tasksDone?: number;
|
|
27
|
-
tasksActive?: number;
|
|
28
|
-
tasksTotal?: number;
|
|
29
|
-
tokensUsed?: number;
|
|
30
|
-
tokenBudget?: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const STATUS_SET = new Set<string>(GOAL_HEADER_STATUSES);
|
|
34
|
-
const MAX_GOAL_ID_POINTS = 128;
|
|
35
|
-
const MAX_HEADER_PROGRESS = 9_999;
|
|
36
|
-
const GOAL_PROMPT_MARKER =
|
|
37
|
-
/<!-- pi-goal-(?:prompt|continuation):[A-Za-z0-9:._-]{1,300} -->/u;
|
|
38
|
-
|
|
39
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
40
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function parseGoalHeaderState(value: unknown): GoalHeaderState | null {
|
|
44
|
-
if (value === null) return null;
|
|
45
|
-
if (!isRecord(value)) return null;
|
|
46
|
-
if (typeof value.id !== 'string' || !value.id || value.id !== value.id.trim()) {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
if (Array.from(value.id).length > MAX_GOAL_ID_POINTS) return null;
|
|
50
|
-
if (typeof value.status !== 'string' || !STATUS_SET.has(value.status)) return null;
|
|
51
|
-
const automaticModelTurns =
|
|
52
|
-
typeof value.automaticModelTurns === 'number' &&
|
|
53
|
-
Number.isSafeInteger(value.automaticModelTurns) &&
|
|
54
|
-
value.automaticModelTurns >= 0
|
|
55
|
-
? Math.min(value.automaticModelTurns, MAX_HEADER_PROGRESS)
|
|
56
|
-
: 0;
|
|
57
|
-
const boundedCount = (input: unknown): number | undefined =>
|
|
58
|
-
typeof input === 'number' &&
|
|
59
|
-
Number.isSafeInteger(input) &&
|
|
60
|
-
input >= 0 &&
|
|
61
|
-
input <= 9_999
|
|
62
|
-
? input
|
|
63
|
-
: undefined;
|
|
64
|
-
const objective =
|
|
65
|
-
typeof value.objective === 'string' && value.objective.trim() !== ''
|
|
66
|
-
? value.objective.replace(/\s+/gu, ' ').trim().slice(0, 160)
|
|
67
|
-
: undefined;
|
|
68
|
-
const elapsedSeconds =
|
|
69
|
-
typeof value.elapsedSeconds === 'number' &&
|
|
70
|
-
Number.isFinite(value.elapsedSeconds) &&
|
|
71
|
-
value.elapsedSeconds >= 0
|
|
72
|
-
? Math.min(value.elapsedSeconds, 3_600_000)
|
|
73
|
-
: undefined;
|
|
74
|
-
const tokensUsed =
|
|
75
|
-
typeof value.tokensUsed === 'number' &&
|
|
76
|
-
Number.isFinite(value.tokensUsed) &&
|
|
77
|
-
value.tokensUsed >= 0
|
|
78
|
-
? Math.min(value.tokensUsed, 9_999_999_999)
|
|
79
|
-
: undefined;
|
|
80
|
-
return {
|
|
81
|
-
id: value.id,
|
|
82
|
-
status: value.status as GoalHeaderStatus,
|
|
83
|
-
automaticModelTurns,
|
|
84
|
-
waiting: value.status === 'active' && isRecord(value.waiting),
|
|
85
|
-
...(objective !== undefined ? { objective } : {}),
|
|
86
|
-
...(elapsedSeconds !== undefined ? { elapsedSeconds } : {}),
|
|
87
|
-
...(boundedCount(value.tasksDone) !== undefined
|
|
88
|
-
? { tasksDone: boundedCount(value.tasksDone) }
|
|
89
|
-
: {}),
|
|
90
|
-
...(boundedCount(value.tasksActive) !== undefined
|
|
91
|
-
? { tasksActive: boundedCount(value.tasksActive) }
|
|
92
|
-
: {}),
|
|
93
|
-
...(boundedCount(value.tasksTotal) !== undefined
|
|
94
|
-
? { tasksTotal: boundedCount(value.tasksTotal) }
|
|
95
|
-
: {}),
|
|
96
|
-
...(tokensUsed !== undefined ? { tokensUsed } : {}),
|
|
97
|
-
...(boundedCount(value.tokenBudget) !== undefined &&
|
|
98
|
-
(boundedCount(value.tokenBudget) ?? 0) > 0
|
|
99
|
-
? { tokenBudget: boundedCount(value.tokenBudget) }
|
|
100
|
-
: {}),
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function restoreGoalHeaderState(
|
|
105
|
-
entries: readonly unknown[],
|
|
106
|
-
): GoalHeaderState | null {
|
|
107
|
-
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
108
|
-
const entry = entries[index];
|
|
109
|
-
if (
|
|
110
|
-
!isRecord(entry) ||
|
|
111
|
-
entry.type !== 'custom' ||
|
|
112
|
-
entry.customType !== GOAL_STATE_ENTRY_TYPE
|
|
113
|
-
) {
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
const data = isRecord(entry.data) ? entry.data : undefined;
|
|
117
|
-
return parseGoalHeaderState(data?.goal);
|
|
118
|
-
}
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function isGoalOwnedPrompt(value: unknown): boolean {
|
|
123
|
-
return typeof value === 'string' && GOAL_PROMPT_MARKER.test(value);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function sameGoalHeaderState(
|
|
127
|
-
left: GoalHeaderState | null,
|
|
128
|
-
right: GoalHeaderState | null,
|
|
129
|
-
): boolean {
|
|
130
|
-
return (
|
|
131
|
-
left === right ||
|
|
132
|
-
(left !== null &&
|
|
133
|
-
right !== null &&
|
|
134
|
-
left.id === right.id &&
|
|
135
|
-
left.status === right.status &&
|
|
136
|
-
left.automaticModelTurns === right.automaticModelTurns &&
|
|
137
|
-
left.waiting === right.waiting &&
|
|
138
|
-
left.objective === right.objective &&
|
|
139
|
-
left.tasksDone === right.tasksDone &&
|
|
140
|
-
left.tasksActive === right.tasksActive &&
|
|
141
|
-
left.tasksTotal === right.tasksTotal &&
|
|
142
|
-
left.tokensUsed === right.tokensUsed &&
|
|
143
|
-
left.tokenBudget === right.tokenBudget &&
|
|
144
|
-
Math.floor((left.elapsedSeconds ?? 0) / 10) ===
|
|
145
|
-
Math.floor((right.elapsedSeconds ?? 0) / 10))
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function parseGoalAutomaticTurnLimit(value: unknown): number | undefined {
|
|
150
|
-
if (!isRecord(value)) return undefined;
|
|
151
|
-
const limits = isRecord(value.continuationLimits)
|
|
152
|
-
? value.continuationLimits
|
|
153
|
-
: undefined;
|
|
154
|
-
const limit = limits?.automaticTurns;
|
|
155
|
-
return typeof limit === 'number' &&
|
|
156
|
-
Number.isSafeInteger(limit) &&
|
|
157
|
-
limit > 0 &&
|
|
158
|
-
limit <= MAX_HEADER_PROGRESS
|
|
159
|
-
? limit
|
|
160
|
-
: undefined;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export async function loadGoalAutomaticTurnLimit(): Promise<number | undefined> {
|
|
164
|
-
try {
|
|
165
|
-
const raw = await readFile(join(getAgentDir(), 'pi-goal.json'), 'utf8');
|
|
166
|
-
return parseGoalAutomaticTurnLimit(JSON.parse(raw) as unknown);
|
|
167
|
-
} catch {
|
|
168
|
-
return undefined;
|
|
169
|
-
}
|
|
170
|
-
}
|