@pasko70/pibo 1.10.0 → 1.10.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.
Files changed (54) hide show
  1. package/dist/apps/chat/chat-request-normalizers.js +7 -0
  2. package/dist/apps/chat/data/chat-data-mappers.js +2 -0
  3. package/dist/apps/chat/loop-api.js +11 -6
  4. package/dist/apps/chat/web-app.js +30 -10
  5. package/dist/apps/chat-ui/assets/{dist-LHRs1Nhr.js → dist-3Sts0Afa.js} +1 -1
  6. package/dist/apps/chat-ui/assets/{dist-wE9nop9V.js → dist-4NlLjLs7.js} +1 -1
  7. package/dist/apps/chat-ui/assets/{dist-BwKObYnX.js → dist-BA6mhAec.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-Y-AA2omI.js → dist-BPl-fzRB.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-GdEM8UW1.js → dist-BPnn9e2B.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-DlATLa-U.js → dist-BpfBSMzK.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-CS7wdk0Z.js → dist-Cvx5M97R.js} +1 -1
  12. package/dist/apps/chat-ui/assets/{dist-nOLTkZrJ.js → dist-DnXWNQRm.js} +1 -1
  13. package/dist/apps/chat-ui/assets/{dist-DUlaXAk7.js → dist-hU-2oAb6.js} +1 -1
  14. package/dist/apps/chat-ui/assets/{dist-CKtT8YGm.js → dist-r31x5Fcc.js} +1 -1
  15. package/dist/apps/chat-ui/assets/{dist-D-cxLQO1.js → dist-uF6UXzI7.js} +1 -1
  16. package/dist/apps/chat-ui/assets/index-CaTGYBOS.js +173 -0
  17. package/dist/apps/chat-ui/assets/{index-C0x9nEcf.css → index-al8DeEjA.css} +1 -1
  18. package/dist/apps/chat-ui/index.html +2 -2
  19. package/dist/apps/chat-vscode-web/assets/index-CK4SMZuu.js +41 -0
  20. package/dist/apps/chat-vscode-web/index.html +1 -1
  21. package/dist/cli-session/localSessionSource.js +34 -12
  22. package/dist/compute/resource-health.js +35 -2
  23. package/dist/core/events.js +6 -1
  24. package/dist/core/routed-session.js +67 -4
  25. package/dist/core/session-router.js +23 -6
  26. package/dist/data/ingest-service.js +3 -1
  27. package/dist/debug/index.js +4 -0
  28. package/dist/debug/trace.js +42 -7
  29. package/dist/index.js +1 -0
  30. package/dist/loops/accounting.js +19 -0
  31. package/dist/loops/cli.js +22 -6
  32. package/dist/loops/prompts.js +4 -1
  33. package/dist/loops/service.js +97 -8
  34. package/dist/loops/store.js +128 -17
  35. package/dist/loops/tools.js +24 -7
  36. package/dist/reliability/store.js +34 -7
  37. package/dist/runs/lifecycle.js +59 -0
  38. package/dist/runs/registry.js +47 -1
  39. package/dist/runs/tools.js +31 -13
  40. package/dist/session-ui/terminalRows.js +66 -2
  41. package/dist/shared/trace-async-agent-runs.js +4 -4
  42. package/dist/shared/trace-event-projection.js +59 -19
  43. package/dist/shared/trace-nodes.js +5 -0
  44. package/dist/shared/trace-page-merge.js +15 -0
  45. package/dist/shared/trace-run-notifications.js +3 -1
  46. package/dist/signals/projector.js +6 -2
  47. package/dist/tools/browser-pool.js +50 -0
  48. package/dist/tools/browser-use-leases.js +12 -8
  49. package/dist/tools/guides.js +8 -1
  50. package/dist/tools/index.js +1 -0
  51. package/package.json +2 -1
  52. package/skills/builtin/loop/SKILL.md +12 -3
  53. package/dist/apps/chat-ui/assets/index-8W_yMHQI.js +0 -173
  54. package/dist/apps/chat-vscode-web/assets/index-BAMxIaI_.js +0 -41
@@ -5,7 +5,7 @@ import { getDefaultPiboWorkspace } from '../core/workspace.js';
5
5
  import { PiboDataStore } from '../data/pibo-store.js';
6
6
  import { ChatRoomService } from '../apps/chat/data/room-service.js';
7
7
  import { isPiboRoomArchived } from '../apps/chat/types/rooms.js';
8
- import { browserPoolPaths, releaseBrowserPoolLease } from '../tools/browser-pool.js';
8
+ import { acquireBrowserPoolLease, browserPoolPaths, releaseBrowserPoolLease, restartRecordedBrowserPoolChrome } from '../tools/browser-pool.js';
9
9
  import { createDefaultPiboLoopStore } from './store.js';
10
10
  import { createBuiltInLoopStopConditions, evaluateLoopStopPolicy } from './stopping.js';
11
11
  import { buildLoopTurnPrompt } from './prompts.js';
@@ -48,6 +48,8 @@ export class PiboLoopService {
48
48
  activeRuns = 0;
49
49
  stopped = true;
50
50
  cancelledRuns = new Set();
51
+ browserLeaseHeartbeatTimers = new Map();
52
+ browserLeaseHeartbeatWork = new Map();
51
53
  unsubscribeProductEvents;
52
54
  unsubscribeOutputEvents;
53
55
  constructor(options) {
@@ -62,7 +64,8 @@ export class PiboLoopService {
62
64
  start() { if (!this.stopped)
63
65
  return; this.stopped = false; this.store.recoverInterruptedRuns(); this.unsubscribeProductEvents = this.options.context.subscribeProductEvents?.((event) => this.handleProductEvent(event)); this.unsubscribeOutputEvents = this.options.context.subscribe((event) => this.handleOutputEvent(event)); this.arm(250); }
64
66
  stop() { this.stopped = true; if (this.timer)
65
- clearTimeout(this.timer); this.timer = undefined; this.unsubscribeProductEvents?.(); this.unsubscribeProductEvents = undefined; this.unsubscribeOutputEvents?.(); this.unsubscribeOutputEvents = undefined; this.dataStore.close(); this.store.close(); }
67
+ clearTimeout(this.timer); this.timer = undefined; for (const timer of this.browserLeaseHeartbeatTimers.values())
68
+ clearInterval(timer); this.browserLeaseHeartbeatTimers.clear(); this.unsubscribeProductEvents?.(); this.unsubscribeProductEvents = undefined; this.unsubscribeOutputEvents?.(); this.unsubscribeOutputEvents = undefined; this.dataStore.close(); this.store.close(); }
66
69
  status() { return { enabled: !this.stopped, ...this.store.status() }; }
67
70
  async startJob(id) { const job = this.store.updateJob(id, { enabled: true }); if (!job)
68
71
  return undefined; const reserved = await this.reserveAfterBeforeRunEvaluation(job); if (!reserved)
@@ -109,7 +112,70 @@ export class PiboLoopService {
109
112
  return undefined;
110
113
  }
111
114
  this.store.applyStopEvaluation({ jobId: fresh.id, evaluation, conditionStates, disable: false });
112
- return this.store.reserveRun(fresh.id);
115
+ if (fresh.mode === 'goal' && !await this.renewGoalBrowserLeases(fresh))
116
+ return undefined;
117
+ const reserved = this.store.reserveRun(fresh.id);
118
+ if (reserved)
119
+ this.startGoalBrowserLeaseHeartbeat(reserved.job, reserved.run);
120
+ return reserved;
121
+ }
122
+ startGoalBrowserLeaseHeartbeat(job, run) {
123
+ if (job.mode !== 'goal' || (job.resources?.browserLeaseIds?.length ?? 0) === 0)
124
+ return;
125
+ const renew = () => {
126
+ if (this.browserLeaseHeartbeatWork.has(run.id))
127
+ return;
128
+ const work = this.renewGoalBrowserLeases(this.store.getJob(job.id) ?? job, run).finally(() => { this.browserLeaseHeartbeatWork.delete(run.id); });
129
+ this.browserLeaseHeartbeatWork.set(run.id, work);
130
+ };
131
+ const timer = setInterval(renew, Math.max(10, this.options.resourceCleanup?.browserLeaseRenewIntervalMs ?? 5 * 60_000));
132
+ this.browserLeaseHeartbeatTimers.set(run.id, timer);
133
+ }
134
+ async stopGoalBrowserLeaseHeartbeat(runId) {
135
+ const timer = this.browserLeaseHeartbeatTimers.get(runId);
136
+ if (timer)
137
+ clearInterval(timer);
138
+ this.browserLeaseHeartbeatTimers.delete(runId);
139
+ await this.browserLeaseHeartbeatWork.get(runId);
140
+ }
141
+ async renewGoalBrowserLeases(job, run) {
142
+ const resources = mergeRunResources(job.resources, run?.resources);
143
+ const leaseIds = resources?.browserLeaseIds ?? [];
144
+ if (!resources || leaseIds.length === 0)
145
+ return true;
146
+ const workerId = resources.workerId || process.env.PIBO_BROWSER_POOL_WORKER_ID || process.env.PIBO_COMPUTE_WORKER_ID || process.env.HOSTNAME || 'local';
147
+ const poolId = this.options.resourceCleanup?.browserPoolId || process.env.PIBO_BROWSER_POOL_ID || 'default';
148
+ const rootDir = this.options.resourceCleanup?.browserPoolRootDir || process.env.PIBO_BROWSER_POOL_ROOT || join(process.env.BROWSER_USE_HOME || join(homedir(), '.browser-use'), 'pibo-browser-pool');
149
+ const identity = { workerId, poolId };
150
+ const paths = browserPoolPaths(rootDir, identity);
151
+ const acquire = this.options.resourceCleanup?.acquireBrowserPoolLease ?? acquireBrowserPoolLease;
152
+ let retainedUntil;
153
+ for (const leaseId of leaseIds) {
154
+ try {
155
+ const result = await acquire(paths, identity, {
156
+ leaseId,
157
+ holder: run ? `loop:${job.id}:run:${run.id}` : `loop:${job.id}`,
158
+ idleTimeoutMs: this.options.resourceCleanup?.browserLeaseIdleTimeoutMs,
159
+ startBrowser: restartRecordedBrowserPoolChrome,
160
+ lockOptions: { holder: run ? `loop:${job.id}:run:${run.id}` : `loop:${job.id}` },
161
+ });
162
+ if (!result.acquired)
163
+ throw new Error(result.staleReason);
164
+ retainedUntil = result.state.idleExpiresAt ?? retainedUntil;
165
+ }
166
+ catch (error) {
167
+ const reason = `Browser lease ${leaseId} could not be renewed or reacquired: ${errorMessage(error)}. Authenticated browser access requires operator attention.`;
168
+ this.markRunResourcesDirty(job, reason);
169
+ this.store.updateGoalStatus(job.id, 'blocked');
170
+ return false;
171
+ }
172
+ }
173
+ const updatedAt = new Date().toISOString();
174
+ const next = clearDirtyReason({ ...resources, workerId, cleanupState: 'active', retainedUntil, updatedAt });
175
+ this.store.updateJobResources(job.id, next);
176
+ if (run)
177
+ this.store.updateRunResources({ jobId: job.id, runId: run.id, resources: next });
178
+ return true;
113
179
  }
114
180
  async abortCancelRequestedJobs() { for (const job of this.store.listJobs({ includeDisabled: true })) {
115
181
  if (job.state.cancelRequestedAt)
@@ -134,17 +200,25 @@ export class PiboLoopService {
134
200
  }
135
201
  async executeReserved(job, run) {
136
202
  this.activeRuns += 1;
203
+ const activeStartedAt = Date.now();
204
+ let activeTimeRecorded = false;
205
+ const recordActiveTime = () => {
206
+ if (activeTimeRecorded)
207
+ return;
208
+ activeTimeRecorded = true;
209
+ this.recordGoalRunTime(job, run, activeStartedAt);
210
+ };
137
211
  try {
138
212
  const result = await this.executeJob(job, run);
213
+ recordActiveTime();
139
214
  const cancelled = this.cancelledRuns.delete(run.id);
140
- if (job.mode === 'goal')
141
- this.store.recordGoalProgress(job.id, { timeUsedSeconds: result.timeUsedSeconds });
142
215
  const outcome = { status: cancelled ? 'cancelled' : 'ok', piboSessionId: result.piboSessionId, finalAnswer: result.finalAnswer };
143
216
  const { evaluation, conditionStates } = await this.evaluateStopPolicy(this.store.getJob(job.id) ?? job, 'after-run', run, outcome);
144
217
  this.store.completeRun({ jobId: job.id, runId: run.id, status: outcome.status, piboSessionId: result.piboSessionId, reason: cancelled ? 'cancelled' : evaluation.reason, stopAfterRun: evaluation.finalAction !== 'continue', stopEvaluation: evaluation, conditionStates });
145
218
  await this.cleanupRunResources(job, run);
146
219
  }
147
220
  catch (error) {
221
+ recordActiveTime();
148
222
  const cancelled = this.cancelledRuns.delete(run.id);
149
223
  const message = errorMessage(error);
150
224
  const fatalProfileError = !cancelled && isUnknownProfileErrorMessage(message);
@@ -160,6 +234,11 @@ export class PiboLoopService {
160
234
  this.activeRuns -= 1;
161
235
  }
162
236
  }
237
+ recordGoalRunTime(job, run, startedAt) {
238
+ if (job.mode !== 'goal')
239
+ return;
240
+ this.store.recordGoalRunTime(job.id, run.id, Math.max(0, Math.ceil((Date.now() - startedAt) / 1000)));
241
+ }
163
242
  markRunResourcesDirty(job, dirtyReason) {
164
243
  const latestJob = this.store.getJob(job.id) ?? job;
165
244
  const runId = latestJob.state.lastRunId ?? job.state.lastRunId;
@@ -178,12 +257,20 @@ export class PiboLoopService {
178
257
  }
179
258
  }
180
259
  async cleanupRunResources(job, run) {
260
+ await this.stopGoalBrowserLeaseHeartbeat(run.id);
181
261
  const latestJob = this.store.getJob(job.id) ?? job;
182
262
  const latestRun = this.store.listRuns({ jobId: job.id, limit: 100 }).find((candidate) => candidate.id === run.id) ?? run;
183
263
  const resources = mergeRunResources(latestJob.resources, latestRun.resources);
184
264
  const leaseIds = resources?.browserLeaseIds ?? [];
185
265
  if (!resources || leaseIds.length === 0)
186
266
  return;
267
+ const goalStatus = latestJob.mode === 'goal' ? latestJob.state.goalStatus ?? (latestJob.enabled ? 'active' : 'paused') : undefined;
268
+ if (latestJob.mode === 'goal' && latestJob.enabled && goalStatus === 'active') {
269
+ const retained = clearDirtyReason({ ...resources, cleanupState: 'retained', updatedAt: new Date().toISOString() });
270
+ this.store.updateRunResources({ jobId: job.id, runId: run.id, resources: retained });
271
+ this.store.updateJobResources(job.id, retained);
272
+ return;
273
+ }
187
274
  const workerId = resources.workerId || process.env.PIBO_BROWSER_POOL_WORKER_ID || process.env.PIBO_COMPUTE_WORKER_ID || process.env.HOSTNAME || 'local';
188
275
  const poolId = this.options.resourceCleanup?.browserPoolId || process.env.PIBO_BROWSER_POOL_ID || 'default';
189
276
  const rootDir = this.options.resourceCleanup?.browserPoolRootDir || process.env.PIBO_BROWSER_POOL_ROOT || join(process.env.BROWSER_USE_HOME || join(homedir(), '.browser-use'), 'pibo-browser-pool');
@@ -228,7 +315,10 @@ export class PiboLoopService {
228
315
  const status = job.state.goalStatus ?? (job.enabled ? 'active' : 'paused');
229
316
  if (!job.state.runningAt && status !== 'active')
230
317
  return;
231
- this.store.recordGoalProgress(job.id, { tokens: event.totalTokens });
318
+ if (job.state.runningAt && job.state.lastRunId)
319
+ this.store.recordGoalTurnUsage(job.id, job.state.lastRunId, event.totalTokens);
320
+ else
321
+ this.store.recordGoalProgress(job.id, { tokens: event.totalTokens });
232
322
  }
233
323
  handleProductEvent(event) {
234
324
  if (event.type !== 'pibo.loop.fact' && event.type !== 'loop.fact' && event.type !== 'pibo.ralph.fact' && event.type !== 'ralph.fact')
@@ -304,7 +394,6 @@ export class PiboLoopService {
304
394
  } const room = this.roomService.ensureDefaultRoom({ name: 'Shared Chat' }); return { roomId: room.id, workspace: room.workspace ?? getDefaultPiboWorkspace() }; }
305
395
  async emitMessageAndWait(piboSessionId, text) {
306
396
  const eventId = `loop_msg_${randomUUID()}`;
307
- const startedAt = Date.now();
308
397
  return await new Promise((resolve, reject) => {
309
398
  let settled = false;
310
399
  let deltaAnswer = '';
@@ -323,7 +412,7 @@ export class PiboLoopService {
323
412
  if (error)
324
413
  reject(error);
325
414
  else
326
- resolve({ finalAnswer: finalAnswer || deltaAnswer, timeUsedSeconds: Math.max(0, Math.ceil((Date.now() - startedAt) / 1000)) });
415
+ resolve({ finalAnswer: finalAnswer || deltaAnswer });
327
416
  };
328
417
  if (this.runTimeoutMs !== undefined) {
329
418
  timeout = setTimeout(() => {
@@ -75,13 +75,37 @@ function resourceMetadataJson(resources) {
75
75
  const normalized = normalizeLoopResourceMetadata(resources);
76
76
  return normalized ? JSON.stringify(normalized) : null;
77
77
  }
78
+ function parseRunAccounting(json) {
79
+ if (!json)
80
+ return undefined;
81
+ try {
82
+ const value = JSON.parse(json);
83
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ function runAccountingJson(accounting) { return accounting ? JSON.stringify(accounting) : null; }
90
+ function normalizeJobState(state, mode, enabled, createdAt) {
91
+ if (mode !== 'goal')
92
+ return state;
93
+ const activeTimeSeconds = Math.max(0, Math.floor(state.activeTimeSeconds ?? state.timeUsedSeconds ?? 0));
94
+ const goalStartedAt = state.goalStartedAt ?? (enabled || (state.completedIterations ?? 0) > 0 || (state.tokensUsed ?? 0) > 0 || (state.goalStatus !== undefined && state.goalStatus !== 'paused') ? createdAt : undefined);
95
+ const normalized = { ...state, activeTimeSeconds, ...(goalStartedAt ? { goalStartedAt } : {}) };
96
+ delete normalized.timeUsedSeconds;
97
+ return normalized;
98
+ }
78
99
  function jobFromRow(row) {
79
100
  const resources = parseResourceMetadata(row.resource_json);
80
- return { id: row.id, mode: normalizeLoopMode(row.loop_mode, 'ralph'), name: row.name, description: row.description ?? undefined, enabled: row.enabled === 1, target: parseLoopTarget(row.target_json), profile: row.profile, prompt: row.prompt, maxIterations: row.max_iterations ?? undefined, tokenBudget: row.token_budget ?? undefined, stopPolicy: parseStopPolicy(row.stop_policy_json), ...parseRuntimeOptions(row.runtime_options_json), ...(resources ? { resources } : {}), state: parseJson(row.state_json), createdAt: row.created_at, updatedAt: row.updated_at };
101
+ const mode = normalizeLoopMode(row.loop_mode, 'ralph');
102
+ const enabled = row.enabled === 1;
103
+ return { id: row.id, mode, name: row.name, description: row.description ?? undefined, enabled, target: parseLoopTarget(row.target_json), profile: row.profile, prompt: row.prompt, maxIterations: row.max_iterations ?? undefined, tokenBudget: row.token_budget ?? undefined, tokenReserve: row.token_reserve ?? undefined, stopPolicy: parseStopPolicy(row.stop_policy_json), ...parseRuntimeOptions(row.runtime_options_json), ...(resources ? { resources } : {}), state: normalizeJobState(parseJson(row.state_json), mode, enabled, row.created_at), createdAt: row.created_at, updatedAt: row.updated_at };
81
104
  }
82
105
  function runFromRow(row) {
83
106
  const resources = parseResourceMetadata(row.resource_json);
84
- return { id: row.id, jobId: row.job_id, piboSessionId: row.pibo_session_id ?? undefined, status: row.status, reason: row.reason ?? undefined, error: row.error ?? undefined, startedAt: row.started_at ?? undefined, completedAt: row.completed_at ?? undefined, ...(resources ? { resources } : {}), createdAt: row.created_at, updatedAt: row.updated_at };
107
+ const accounting = parseRunAccounting(row.accounting_json);
108
+ return { id: row.id, jobId: row.job_id, piboSessionId: row.pibo_session_id ?? undefined, status: row.status, reason: row.reason ?? undefined, error: row.error ?? undefined, startedAt: row.started_at ?? undefined, completedAt: row.completed_at ?? undefined, ...(accounting ? { accounting } : {}), ...(resources ? { resources } : {}), createdAt: row.created_at, updatedAt: row.updated_at };
85
109
  }
86
110
  function mergeResourceMetadata(jobResources, runResources) {
87
111
  if (!jobResources && !runResources)
@@ -106,6 +130,13 @@ function normalizeTokenBudget(value) {
106
130
  throw new Error('tokenBudget must be a positive integer');
107
131
  return value;
108
132
  }
133
+ function normalizeTokenReserve(value) {
134
+ if (value === undefined)
135
+ return undefined;
136
+ if (!Number.isInteger(value) || value < 0)
137
+ throw new Error('tokenReserve must be a non-negative integer');
138
+ return value;
139
+ }
109
140
  function goalStatus(job) {
110
141
  if (job.mode !== 'goal')
111
142
  return undefined;
@@ -213,10 +244,15 @@ function validateJobInput(input) {
213
244
  throw new Error('prompt is required');
214
245
  if (input.target.kind === 'room' && !input.target.roomId.trim())
215
246
  throw new Error('target.roomId is required');
216
- if (input.mode === 'ralph' && input.tokenBudget !== undefined)
217
- throw new Error('tokenBudget is only available for goal mode');
247
+ if (input.mode === 'ralph' && (input.tokenBudget !== undefined || input.tokenReserve !== undefined))
248
+ throw new Error('tokenBudget and tokenReserve are only available for goal mode');
218
249
  normalizeMaxIterations(input.maxIterations);
219
250
  normalizeTokenBudget(input.tokenBudget);
251
+ normalizeTokenReserve(input.tokenReserve);
252
+ if (input.tokenReserve !== undefined && input.tokenBudget === undefined)
253
+ throw new Error('tokenReserve requires tokenBudget');
254
+ if (input.tokenReserve !== undefined && input.tokenBudget !== undefined && input.tokenReserve >= input.tokenBudget)
255
+ throw new Error('tokenReserve must be smaller than tokenBudget');
220
256
  normalizeRuntimeOptions(input);
221
257
  normalizeLoopStopPolicy(input.stopPolicy);
222
258
  normalizeLoopResourceMetadata(input.resources);
@@ -246,10 +282,10 @@ export class PiboLoopStore {
246
282
  const enabled = input.enabled === true;
247
283
  const state = {
248
284
  completedIterations: 0,
249
- ...(mode === 'goal' ? { goalStatus: enabled ? 'active' : 'paused', tokensUsed: 0, timeUsedSeconds: 0 } : {}),
285
+ ...(mode === 'goal' ? { goalStatus: enabled ? 'active' : 'paused', tokensUsed: 0, activeTimeSeconds: 0, ...(enabled ? { goalStartedAt: timestamp } : {}) } : {}),
250
286
  ...(input.initialPiboSessionId?.trim() ? { lastPiboSessionId: input.initialPiboSessionId.trim() } : {}),
251
287
  };
252
- const job = { id: mode === 'ralph' ? `ralph_${randomUUID()}` : `loop_${randomUUID()}`, mode, name: (input.name ?? defaultName(input.prompt)).trim(), description: input.description?.trim() || undefined, enabled, target, profile: input.profile, prompt: input.prompt, maxIterations: normalizeMaxIterations(input.maxIterations), tokenBudget: normalizeTokenBudget(input.tokenBudget), stopPolicy: normalizeLoopStopPolicy(input.stopPolicy), ...runtimeOptions, ...(resources ? { resources } : {}), state, createdAt: timestamp, updatedAt: timestamp };
288
+ const job = { id: mode === 'ralph' ? `ralph_${randomUUID()}` : `loop_${randomUUID()}`, mode, name: (input.name ?? defaultName(input.prompt)).trim(), description: input.description?.trim() || undefined, enabled, target, profile: input.profile, prompt: input.prompt, maxIterations: normalizeMaxIterations(input.maxIterations), tokenBudget: normalizeTokenBudget(input.tokenBudget), tokenReserve: normalizeTokenReserve(input.tokenReserve), stopPolicy: normalizeLoopStopPolicy(input.stopPolicy), ...runtimeOptions, ...(resources ? { resources } : {}), state, createdAt: timestamp, updatedAt: timestamp };
253
289
  this.insertJob(job);
254
290
  return this.getJob(job.id);
255
291
  }
@@ -264,6 +300,10 @@ export class PiboLoopStore {
264
300
  return undefined;
265
301
  const timestamp = nowIso(now);
266
302
  const state = { ...job.state, goalStatus: status, runningAt: job.state.runningAt };
303
+ if (job.state.runningAt)
304
+ delete state.goalEndedAt;
305
+ else
306
+ state.goalEndedAt = timestamp;
267
307
  this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = 0, state_json = ?, updated_at = ? WHERE id = ?').run(JSON.stringify(state), timestamp, id);
268
308
  return this.getJob(id);
269
309
  }
@@ -272,20 +312,65 @@ export class PiboLoopStore {
272
312
  if (!job || job.mode !== 'goal')
273
313
  return job;
274
314
  const tokens = Math.max(0, Math.floor(input.tokens ?? 0));
275
- const timeUsedSeconds = Math.max(0, Math.floor(input.timeUsedSeconds ?? 0));
315
+ const activeTimeSeconds = Math.max(0, Math.floor(input.activeTimeSeconds ?? 0));
276
316
  const nextTokens = (job.state.tokensUsed ?? 0) + tokens;
277
317
  const currentStatus = goalStatus(job) ?? 'active';
278
318
  const budgetLimited = currentStatus === 'active' && job.tokenBudget !== undefined && nextTokens >= job.tokenBudget;
279
319
  const state = {
280
320
  ...job.state,
281
321
  tokensUsed: nextTokens,
282
- timeUsedSeconds: (job.state.timeUsedSeconds ?? 0) + timeUsedSeconds,
322
+ activeTimeSeconds: (job.state.activeTimeSeconds ?? 0) + activeTimeSeconds,
283
323
  goalStatus: budgetLimited ? 'budget_limited' : currentStatus,
284
324
  };
285
325
  const timestamp = nowIso(now);
326
+ if (budgetLimited && !job.state.runningAt)
327
+ state.goalEndedAt = timestamp;
286
328
  this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = ?, state_json = ?, updated_at = ? WHERE id = ?').run(budgetLimited ? 0 : job.enabled ? 1 : 0, JSON.stringify(state), timestamp, id);
287
329
  return this.getJob(id);
288
330
  }
331
+ recordGoalTurnUsage(id, runId, tokens, now = new Date()) {
332
+ this.db.exec('BEGIN IMMEDIATE');
333
+ try {
334
+ const job = this.recordGoalProgress(id, { tokens }, now);
335
+ if (job?.mode === 'goal') {
336
+ const row = this.db.prepare('SELECT * FROM pibo_ralph_runs WHERE id = ? AND job_id = ?').get(runId, id);
337
+ if (row) {
338
+ const accounting = parseRunAccounting(row.accounting_json) ?? {};
339
+ const turnTokens = (accounting.tokensUsed ?? 0) + Math.max(0, Math.floor(tokens));
340
+ const budget = accounting.tokenBudget;
341
+ const before = accounting.tokensUsedBefore ?? 0;
342
+ const nextAccounting = { ...accounting, tokensUsed: turnTokens, ...(budget !== undefined ? { overshootTokens: Math.max(0, before + turnTokens - budget) } : {}) };
343
+ this.db.prepare('UPDATE pibo_ralph_runs SET accounting_json = ?, updated_at = ? WHERE id = ?').run(runAccountingJson(nextAccounting), nowIso(now), runId);
344
+ }
345
+ }
346
+ this.db.exec('COMMIT');
347
+ return job;
348
+ }
349
+ catch (error) {
350
+ this.db.exec('ROLLBACK');
351
+ throw error;
352
+ }
353
+ }
354
+ recordGoalRunTime(id, runId, activeTimeSeconds, now = new Date()) {
355
+ const seconds = Math.max(0, Math.floor(activeTimeSeconds));
356
+ this.db.exec('BEGIN IMMEDIATE');
357
+ try {
358
+ const job = this.recordGoalProgress(id, { activeTimeSeconds: seconds }, now);
359
+ if (job?.mode === 'goal') {
360
+ const row = this.db.prepare('SELECT * FROM pibo_ralph_runs WHERE id = ? AND job_id = ?').get(runId, id);
361
+ if (row) {
362
+ const accounting = { ...(parseRunAccounting(row.accounting_json) ?? {}), activeTimeSeconds: seconds };
363
+ this.db.prepare('UPDATE pibo_ralph_runs SET accounting_json = ?, updated_at = ? WHERE id = ?').run(runAccountingJson(accounting), nowIso(now), runId);
364
+ }
365
+ }
366
+ this.db.exec('COMMIT');
367
+ return job;
368
+ }
369
+ catch (error) {
370
+ this.db.exec('ROLLBACK');
371
+ throw error;
372
+ }
373
+ }
289
374
  listJobs(input = {}) {
290
375
  const clauses = [];
291
376
  const values = [];
@@ -309,16 +394,19 @@ export class PiboLoopStore {
309
394
  const enabled = patch.enabled ?? existing.enabled;
310
395
  let state = mode === existing.mode
311
396
  ? { ...existing.state }
312
- : { completedIterations: existing.state.completedIterations ?? 0, ...(mode === 'goal' ? { goalStatus: enabled ? 'active' : 'paused', tokensUsed: 0, timeUsedSeconds: 0 } : {}) };
397
+ : { completedIterations: existing.state.completedIterations ?? 0, ...(mode === 'goal' ? { goalStatus: enabled ? 'active' : 'paused', tokensUsed: 0, activeTimeSeconds: 0, ...(enabled ? { goalStartedAt: nowIso(now) } : {}) } : {}) };
313
398
  if (mode === 'goal' && patch.enabled !== undefined) {
314
399
  const currentGoalStatus = goalStatus({ mode, enabled: existing.enabled, state: existing.state }) ?? 'paused';
315
400
  if (patch.enabled) {
316
401
  if (currentGoalStatus === 'complete')
317
402
  throw new Error('Completed goals cannot be restarted; create a new goal');
318
403
  const nextBudget = hasOwn(patch, 'tokenBudget') ? normalizeTokenBudget(patch.tokenBudget ?? undefined) : existing.tokenBudget;
319
- if (currentGoalStatus === 'budget_limited' && nextBudget !== undefined && (existing.state.tokensUsed ?? 0) >= nextBudget)
320
- throw new Error('Increase or clear the token budget before resuming this goal');
404
+ const nextReserve = hasOwn(patch, 'tokenReserve') ? normalizeTokenReserve(patch.tokenReserve ?? undefined) : existing.tokenReserve;
405
+ if (currentGoalStatus === 'budget_limited' && nextBudget !== undefined && (existing.state.tokensUsed ?? 0) + (nextReserve ?? 0) >= nextBudget)
406
+ throw new Error('Increase or clear the token budget, or lower the token reserve, before resuming this goal');
321
407
  state.goalStatus = 'active';
408
+ state.goalStartedAt ??= nowIso(now);
409
+ delete state.goalEndedAt;
322
410
  state.stopRequestedAt = undefined;
323
411
  state.cancelRequestedAt = undefined;
324
412
  }
@@ -326,7 +414,7 @@ export class PiboLoopStore {
326
414
  state.goalStatus = currentGoalStatus === 'active' ? 'paused' : currentGoalStatus;
327
415
  }
328
416
  }
329
- const next = { ...existing, mode, state, name: patch.name !== undefined ? patch.name.trim() : existing.name, description: patch.description !== undefined ? patch.description?.trim() || undefined : existing.description, enabled, target, profile: patch.profile ?? existing.profile, prompt: patch.prompt ?? existing.prompt, maxIterations: hasOwn(patch, 'maxIterations') ? normalizeMaxIterations(patch.maxIterations ?? undefined) : existing.maxIterations, tokenBudget: mode === 'ralph' ? undefined : hasOwn(patch, 'tokenBudget') ? normalizeTokenBudget(patch.tokenBudget ?? undefined) : existing.tokenBudget, stopPolicy, modelOverride: runtimeOptions.modelOverride, thinkingLevel: runtimeOptions.thinkingLevel, fastMode: runtimeOptions.fastMode, updatedAt: nowIso(now) };
417
+ const next = { ...existing, mode, state, name: patch.name !== undefined ? patch.name.trim() : existing.name, description: patch.description !== undefined ? patch.description?.trim() || undefined : existing.description, enabled, target, profile: patch.profile ?? existing.profile, prompt: patch.prompt ?? existing.prompt, maxIterations: hasOwn(patch, 'maxIterations') ? normalizeMaxIterations(patch.maxIterations ?? undefined) : existing.maxIterations, tokenBudget: mode === 'ralph' ? undefined : hasOwn(patch, 'tokenBudget') ? normalizeTokenBudget(patch.tokenBudget ?? undefined) : existing.tokenBudget, tokenReserve: mode === 'ralph' || (hasOwn(patch, 'tokenBudget') && patch.tokenBudget === null && !hasOwn(patch, 'tokenReserve')) ? undefined : hasOwn(patch, 'tokenReserve') ? normalizeTokenReserve(patch.tokenReserve ?? undefined) : existing.tokenReserve, stopPolicy, modelOverride: runtimeOptions.modelOverride, thinkingLevel: runtimeOptions.thinkingLevel, fastMode: runtimeOptions.fastMode, updatedAt: nowIso(now) };
330
418
  validateJobInput(next);
331
419
  this.writeJob(next);
332
420
  return this.getJob(id);
@@ -418,7 +506,7 @@ export class PiboLoopStore {
418
506
  const reachedMaxIterations = job.maxIterations !== undefined && completedIterations >= job.maxIterations;
419
507
  const terminalGoalStatus = job.mode === 'goal' && ['complete', 'blocked', 'budget_limited'].includes(goalStatus(job) ?? '');
420
508
  const shouldDisable = terminalGoalStatus || reachedMaxIterations || input.stopAfterRun === true || input.stopEvaluation?.finalAction === 'stop-after-run' || input.stopEvaluation?.finalAction === 'cancel-current-run';
421
- const state = { ...job.state, runningAt: undefined, completedIterations, lastRunAt: timestamp, lastRunId: input.runId, lastStatus: input.status === 'error' ? 'error' : input.status === 'cancelled' ? 'cancelled' : 'ok', lastError: input.error, lastPiboSessionId: input.piboSessionId ?? job.state.lastPiboSessionId, consecutiveErrors: input.status === 'error' ? (job.state.consecutiveErrors ?? 0) + 1 : 0, conditionStates: input.conditionStates ?? job.state.conditionStates, lastStopEvaluation: input.stopEvaluation ?? job.state.lastStopEvaluation };
509
+ const state = { ...job.state, runningAt: undefined, completedIterations, lastRunAt: timestamp, lastRunId: input.runId, lastStatus: input.status === 'error' ? 'error' : input.status === 'cancelled' ? 'cancelled' : 'ok', lastError: input.error, lastPiboSessionId: input.piboSessionId ?? job.state.lastPiboSessionId, consecutiveErrors: input.status === 'error' ? (job.state.consecutiveErrors ?? 0) + 1 : 0, conditionStates: input.conditionStates ?? job.state.conditionStates, lastStopEvaluation: input.stopEvaluation ?? job.state.lastStopEvaluation, ...(terminalGoalStatus ? { goalEndedAt: job.state.goalEndedAt ?? timestamp } : {}) };
422
510
  this.db.prepare('UPDATE pibo_ralph_runs SET status = ?, pibo_session_id = COALESCE(?, pibo_session_id), reason = ?, error = ?, completed_at = ?, updated_at = ? WHERE id = ?').run(input.status, input.piboSessionId ?? null, input.reason ?? input.stopEvaluation?.reason ?? null, input.error ?? null, timestamp, timestamp, input.runId);
423
511
  this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = ?, state_json = ?, updated_at = ? WHERE id = ?').run(shouldDisable ? 0 : job.enabled ? 1 : 0, JSON.stringify(state), timestamp, job.id);
424
512
  }
@@ -486,6 +574,15 @@ export class PiboLoopStore {
486
574
  this.db.exec('COMMIT');
487
575
  return undefined;
488
576
  }
577
+ if (job.mode === 'goal' && job.tokenBudget !== undefined) {
578
+ const remaining = Math.max(0, job.tokenBudget - (job.state.tokensUsed ?? 0));
579
+ if (remaining <= (job.tokenReserve ?? 0)) {
580
+ const state = { ...job.state, goalStatus: 'budget_limited', goalEndedAt: timestamp };
581
+ this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = 0, state_json = ?, updated_at = ? WHERE id = ?').run(JSON.stringify(state), timestamp, job.id);
582
+ this.db.exec('COMMIT');
583
+ return undefined;
584
+ }
585
+ }
489
586
  const run = this.createRunLocked(job, timestamp);
490
587
  const state = { ...job.state, runningAt: timestamp, lastRunAt: timestamp, lastRunId: run.id };
491
588
  this.updateJobStateLocked(job.id, state, timestamp);
@@ -502,13 +599,15 @@ export class PiboLoopStore {
502
599
  this.ensureJobColumn('loop_mode', "TEXT NOT NULL DEFAULT 'ralph'");
503
600
  this.ensureJobColumn('max_iterations', 'INTEGER');
504
601
  this.ensureJobColumn('token_budget', 'INTEGER');
602
+ this.ensureJobColumn('token_reserve', 'INTEGER');
505
603
  this.ensureJobColumn('runtime_options_json', 'TEXT');
506
604
  this.ensureJobColumn('stop_policy_json', 'TEXT');
507
605
  this.ensureJobColumn('resource_json', 'TEXT');
508
606
  this.ensureRunColumn('resource_json', 'TEXT');
607
+ this.ensureRunColumn('accounting_json', 'TEXT');
509
608
  }
510
609
  createFreshSchema() {
511
- this.db.exec(`CREATE TABLE IF NOT EXISTS pibo_ralph_jobs (id TEXT PRIMARY KEY, loop_mode TEXT NOT NULL DEFAULT 'goal', name TEXT NOT NULL, description TEXT, enabled INTEGER NOT NULL, target_json TEXT NOT NULL, profile TEXT NOT NULL, prompt TEXT NOT NULL, max_iterations INTEGER, token_budget INTEGER, runtime_options_json TEXT, stop_policy_json TEXT, resource_json TEXT, state_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_jobs_enabled ON pibo_ralph_jobs(enabled, updated_at DESC); CREATE TABLE IF NOT EXISTS pibo_ralph_runs (id TEXT PRIMARY KEY, job_id TEXT NOT NULL, pibo_session_id TEXT, status TEXT NOT NULL, reason TEXT, error TEXT, resource_json TEXT, started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_runs_job_created ON pibo_ralph_runs(job_id, created_at DESC); CREATE TABLE IF NOT EXISTS pibo_ralph_run_facts (id TEXT PRIMARY KEY, job_id TEXT NOT NULL, run_id TEXT, pibo_session_id TEXT, type TEXT NOT NULL, source TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_facts_job_created ON pibo_ralph_run_facts(job_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_facts_run_type ON pibo_ralph_run_facts(run_id, type, created_at DESC);`);
610
+ this.db.exec(`CREATE TABLE IF NOT EXISTS pibo_ralph_jobs (id TEXT PRIMARY KEY, loop_mode TEXT NOT NULL DEFAULT 'goal', name TEXT NOT NULL, description TEXT, enabled INTEGER NOT NULL, target_json TEXT NOT NULL, profile TEXT NOT NULL, prompt TEXT NOT NULL, max_iterations INTEGER, token_budget INTEGER, token_reserve INTEGER, runtime_options_json TEXT, stop_policy_json TEXT, resource_json TEXT, state_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_jobs_enabled ON pibo_ralph_jobs(enabled, updated_at DESC); CREATE TABLE IF NOT EXISTS pibo_ralph_runs (id TEXT PRIMARY KEY, job_id TEXT NOT NULL, pibo_session_id TEXT, status TEXT NOT NULL, reason TEXT, error TEXT, accounting_json TEXT, resource_json TEXT, started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_runs_job_created ON pibo_ralph_runs(job_id, created_at DESC); CREATE TABLE IF NOT EXISTS pibo_ralph_run_facts (id TEXT PRIMARY KEY, job_id TEXT NOT NULL, run_id TEXT, pibo_session_id TEXT, type TEXT NOT NULL, source TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_facts_job_created ON pibo_ralph_run_facts(job_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_facts_run_type ON pibo_ralph_run_facts(run_id, type, created_at DESC);`);
512
611
  }
513
612
  ensureJobColumn(name, definition) {
514
613
  const columns = this.tableColumns('pibo_ralph_jobs');
@@ -523,9 +622,21 @@ export class PiboLoopStore {
523
622
  tableColumns(tableName) {
524
623
  return new Set(this.db.prepare(`PRAGMA table_info(${tableName})`).all().map((column) => column.name));
525
624
  }
526
- insertJob(job) { this.db.prepare('INSERT INTO pibo_ralph_jobs (id, loop_mode, name, description, enabled, target_json, profile, prompt, max_iterations, token_budget, runtime_options_json, stop_policy_json, resource_json, state_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(job.id, job.mode, job.name, job.description ?? null, job.enabled ? 1 : 0, targetJson(job.target), job.profile, job.prompt, job.maxIterations ?? null, job.tokenBudget ?? null, runtimeOptionsJson(job), stopPolicyJson(job.stopPolicy), resourceMetadataJson(job.resources), JSON.stringify(job.state), job.createdAt, job.updatedAt); }
527
- writeJob(job) { this.db.prepare('UPDATE pibo_ralph_jobs SET loop_mode = ?, name = ?, description = ?, enabled = ?, target_json = ?, profile = ?, prompt = ?, max_iterations = ?, token_budget = ?, runtime_options_json = ?, stop_policy_json = ?, resource_json = ?, state_json = ?, updated_at = ? WHERE id = ?').run(job.mode, job.name, job.description ?? null, job.enabled ? 1 : 0, targetJson(job.target), job.profile, job.prompt, job.maxIterations ?? null, job.tokenBudget ?? null, runtimeOptionsJson(job), stopPolicyJson(job.stopPolicy), resourceMetadataJson(job.resources), JSON.stringify(job.state), job.updatedAt, job.id); }
625
+ insertJob(job) { this.db.prepare('INSERT INTO pibo_ralph_jobs (id, loop_mode, name, description, enabled, target_json, profile, prompt, max_iterations, token_budget, token_reserve, runtime_options_json, stop_policy_json, resource_json, state_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(job.id, job.mode, job.name, job.description ?? null, job.enabled ? 1 : 0, targetJson(job.target), job.profile, job.prompt, job.maxIterations ?? null, job.tokenBudget ?? null, job.tokenReserve ?? null, runtimeOptionsJson(job), stopPolicyJson(job.stopPolicy), resourceMetadataJson(job.resources), JSON.stringify(job.state), job.createdAt, job.updatedAt); }
626
+ writeJob(job) { this.db.prepare('UPDATE pibo_ralph_jobs SET loop_mode = ?, name = ?, description = ?, enabled = ?, target_json = ?, profile = ?, prompt = ?, max_iterations = ?, token_budget = ?, token_reserve = ?, runtime_options_json = ?, stop_policy_json = ?, resource_json = ?, state_json = ?, updated_at = ? WHERE id = ?').run(job.mode, job.name, job.description ?? null, job.enabled ? 1 : 0, targetJson(job.target), job.profile, job.prompt, job.maxIterations ?? null, job.tokenBudget ?? null, job.tokenReserve ?? null, runtimeOptionsJson(job), stopPolicyJson(job.stopPolicy), resourceMetadataJson(job.resources), JSON.stringify(job.state), job.updatedAt, job.id); }
528
627
  updateJobStateLocked(id, state, updatedAt) { this.db.prepare('UPDATE pibo_ralph_jobs SET state_json = ?, updated_at = ? WHERE id = ?').run(JSON.stringify(state), updatedAt, id); }
529
- createRunLocked(job, timestamp) { const run = { id: job.mode === 'ralph' ? `rrun_${randomUUID()}` : `lrun_${randomUUID()}`, jobId: job.id, status: 'running', startedAt: timestamp, ...(job.resources ? { resources: job.resources } : {}), createdAt: timestamp, updatedAt: timestamp }; this.db.prepare('INSERT INTO pibo_ralph_runs (id, job_id, pibo_session_id, status, reason, error, resource_json, started_at, completed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(run.id, run.jobId, null, run.status, null, null, resourceMetadataJson(run.resources), run.startedAt ?? null, null, run.createdAt, run.updatedAt); return run; }
628
+ createRunLocked(job, timestamp) {
629
+ const tokensUsedBefore = job.state.tokensUsed ?? 0;
630
+ const accounting = job.mode === 'goal' ? {
631
+ ...(job.tokenBudget !== undefined ? { tokenBudget: job.tokenBudget, remainingTokensBefore: Math.max(0, job.tokenBudget - tokensUsedBefore) } : {}),
632
+ ...(job.tokenReserve !== undefined ? { tokenReserve: job.tokenReserve } : {}),
633
+ tokensUsedBefore,
634
+ tokensUsed: 0,
635
+ overshootTokens: 0,
636
+ } : undefined;
637
+ const run = { id: job.mode === 'ralph' ? `rrun_${randomUUID()}` : `lrun_${randomUUID()}`, jobId: job.id, status: 'running', startedAt: timestamp, ...(accounting ? { accounting } : {}), ...(job.resources ? { resources: job.resources } : {}), createdAt: timestamp, updatedAt: timestamp };
638
+ this.db.prepare('INSERT INTO pibo_ralph_runs (id, job_id, pibo_session_id, status, reason, error, accounting_json, resource_json, started_at, completed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(run.id, run.jobId, null, run.status, null, null, runAccountingJson(run.accounting), resourceMetadataJson(run.resources), run.startedAt ?? null, null, run.createdAt, run.updatedAt);
639
+ return run;
640
+ }
530
641
  }
531
642
  export function createDefaultPiboLoopStore(options = {}) { return new PiboLoopStore(options); }
@@ -1,5 +1,6 @@
1
1
  import { StringEnum, Type } from '@earendil-works/pi-ai';
2
2
  import { defineTool } from '@earendil-works/pi-coding-agent';
3
+ import { goalActiveTimeSeconds, goalCanStartNextTurn, goalElapsedWallClockSeconds, goalRemainingTokens } from './accounting.js';
3
4
  import { createDefaultPiboLoopStore } from './store.js';
4
5
  export const PIBO_GOAL_TOOL_NAMES = ['get_goal', 'create_goal', 'update_goal'];
5
6
  let configuredStorePath;
@@ -33,17 +34,30 @@ function positiveInteger(value, field) {
33
34
  throw new Error(`${field} must be a positive integer`);
34
35
  return value;
35
36
  }
37
+ function nonNegativeInteger(value, field) {
38
+ if (value === undefined)
39
+ return undefined;
40
+ if (!Number.isInteger(value) || value < 0)
41
+ throw new Error(`${field} must be a non-negative integer`);
42
+ return value;
43
+ }
36
44
  function goalPayload(job) {
37
- const tokensUsed = job.state.tokensUsed ?? 0;
38
45
  const tokenBudget = job.tokenBudget;
39
46
  return {
40
47
  goalId: job.id,
41
48
  objective: job.prompt,
42
49
  status: effectiveGoalStatus(job),
50
+ budgetType: tokenBudget === undefined ? 'unbounded' : 'soft',
43
51
  tokenBudget: tokenBudget ?? null,
44
- tokensUsed,
45
- remainingTokens: tokenBudget === undefined ? null : Math.max(0, tokenBudget - tokensUsed),
46
- timeUsedSeconds: job.state.timeUsedSeconds ?? 0,
52
+ tokenReserve: job.tokenReserve ?? 0,
53
+ tokensUsed: job.state.tokensUsed ?? 0,
54
+ remainingTokens: goalRemainingTokens(job) ?? null,
55
+ canStartNextTurn: goalCanStartNextTurn(job),
56
+ activeAgentTimeSeconds: goalActiveTimeSeconds(job),
57
+ elapsedWallClockSeconds: goalElapsedWallClockSeconds(job),
58
+ goalStartedAt: job.state.goalStartedAt ?? null,
59
+ goalEndedAt: job.state.goalEndedAt ?? null,
60
+ wallClockIncludesPausedTime: true,
47
61
  };
48
62
  }
49
63
  function effectiveGoalStatus(job) {
@@ -64,7 +78,7 @@ function createGetGoalTool(context, options) {
64
78
  return defineTool({
65
79
  name: 'get_goal',
66
80
  label: 'Get Goal',
67
- description: 'Get the current goal for this Pibo Session, including status, token budget, consumed tokens, remaining tokens, and elapsed active time.',
81
+ description: 'Get the current goal for this Pibo Session, including soft-budget risk, per-turn reserve, active agent time, and wall-clock elapsed time.',
68
82
  promptSnippet: 'Use get_goal when you need the authoritative persisted status or accounting for the current Pibo Session goal.',
69
83
  parameters: Type.Object({}),
70
84
  async execute() {
@@ -89,7 +103,8 @@ function createCreateGoalTool(context, options) {
89
103
  promptSnippet: 'Call create_goal only when the user or system explicitly requests a persistent goal. Do not infer a goal from an ordinary task.',
90
104
  parameters: Type.Object({
91
105
  objective: Type.String({ description: 'Concrete objective to pursue across automatic continuations.' }),
92
- token_budget: Type.Optional(Type.Number({ description: 'Optional positive token budget. Omit unless explicitly requested.' })),
106
+ token_budget: Type.Optional(Type.Number({ description: 'Optional soft token budget. Usage arrives after each model response, so the final turn can overshoot.' })),
107
+ token_reserve: Type.Optional(Type.Number({ description: 'Optional non-negative minimum remaining tokens required before Pibo starts another turn.' })),
93
108
  }),
94
109
  async execute(_toolCallId, params) {
95
110
  try {
@@ -98,6 +113,7 @@ function createCreateGoalTool(context, options) {
98
113
  if (!objective)
99
114
  throw new Error('objective is required');
100
115
  const tokenBudget = positiveInteger(params.token_budget, 'token_budget');
116
+ const tokenReserve = nonNegativeInteger(params.token_reserve, 'token_reserve');
101
117
  return await withStore(options, (store) => {
102
118
  const existing = store.getLatestGoalForSession(session.piboSessionId);
103
119
  if (existing && effectiveGoalStatus(existing) !== 'complete') {
@@ -110,6 +126,7 @@ function createCreateGoalTool(context, options) {
110
126
  profile: session.profileName,
111
127
  prompt: objective,
112
128
  tokenBudget,
129
+ tokenReserve,
113
130
  initialPiboSessionId: session.piboSessionId,
114
131
  });
115
132
  return toolResult({ ok: true, goal: goalPayload(job) });
@@ -147,7 +164,7 @@ function createUpdateGoalTool(context, options) {
147
164
  ok: true,
148
165
  goal: goalPayload(job),
149
166
  ...(status === 'complete' && job.tokenBudget !== undefined
150
- ? { completionBudgetReport: `${job.state.tokensUsed ?? 0}/${job.tokenBudget} reported tokens consumed before the current model turn finishes` }
167
+ ? { completionBudgetReport: `${job.state.tokensUsed ?? 0}/${job.tokenBudget} reported tokens consumed against a soft budget before the current model turn finishes` }
151
168
  : {}),
152
169
  });
153
170
  });