throughline 0.4.10 → 0.4.12

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.
@@ -1,8 +1,16 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir, platform } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+
1
5
  import { runCodexTrimExecution } from './codex-app-server.mjs';
2
6
  import { buildCodexRolloutTrimSource } from './codex-rollout-memory.mjs';
3
7
  import { buildTrimPlan } from './trim-model.mjs';
4
8
 
5
9
  export const CODEX_AUTO_REFRESH_THRESHOLD = 0.75;
10
+ export const CODEX_AUTO_REFRESH_USAGE_EPOCH_PERCENT = 5;
11
+ export const CODEX_AUTO_REFRESH_STATE_VERSION = 1;
12
+
13
+ const MAX_AUTO_REFRESH_STATE_ENTRIES = 50;
6
14
 
7
15
  export function evaluateCodexAutoRefreshUsage(usage, { threshold = CODEX_AUTO_REFRESH_THRESHOLD } = {}) {
8
16
  if (!usage) {
@@ -72,6 +80,8 @@ export async function runCodexAutoRefresh({
72
80
  usage = null,
73
81
  threshold = CODEX_AUTO_REFRESH_THRESHOLD,
74
82
  command = process.env.THROUGHLINE_CODEX_APP_SERVER_BIN ?? 'codex',
83
+ autoRefreshStateStore = null,
84
+ enabled = false,
75
85
  deps = {},
76
86
  } = {}) {
77
87
  if (!db) throw new Error('runCodexAutoRefresh: db is required');
@@ -82,6 +92,13 @@ export async function runCodexAutoRefresh({
82
92
  throw new Error('runCodexAutoRefresh: projectPath is required');
83
93
  }
84
94
 
95
+ if (!enabled) {
96
+ return {
97
+ status: 'skipped',
98
+ reason: 'codex_auto_refresh_disabled',
99
+ };
100
+ }
101
+
85
102
  const decision = evaluateCodexAutoRefreshUsage(usage, { threshold });
86
103
  if (!decision.shouldRefresh) {
87
104
  return {
@@ -120,31 +137,90 @@ export async function runCodexAutoRefresh({
120
137
  codexThreadIdSource,
121
138
  trimSource,
122
139
  });
123
- if (plan.status === 'unavailable') {
140
+
141
+ const rolloutState = buildCodexAutoRefreshRolloutState({ trimSource, plan });
142
+ const fingerprint = buildCodexAutoRefreshFingerprint({
143
+ threadId,
144
+ projectPath,
145
+ usage,
146
+ rolloutState,
147
+ });
148
+ const backoff = checkCodexAutoRefreshBackoff({
149
+ autoRefreshStateStore,
150
+ threadId,
151
+ fingerprint,
152
+ family: 'execute',
153
+ suppressFamilies: ['execute'],
154
+ rolloutState,
155
+ });
156
+ if (backoff.shouldBackoff) {
124
157
  return {
125
158
  status: 'skipped',
126
- reason: plan.reason,
159
+ reason: 'auto_refresh_backoff',
127
160
  decision,
128
161
  plan,
162
+ backoff,
129
163
  };
130
164
  }
165
+
166
+ if (plan.status === 'unavailable') {
167
+ return recordCodexAutoRefreshResult({
168
+ autoRefreshStateStore,
169
+ threadId,
170
+ fingerprint,
171
+ family: 'execute',
172
+ rolloutState,
173
+ result: {
174
+ status: 'skipped',
175
+ reason: plan.reason,
176
+ decision,
177
+ plan,
178
+ },
179
+ });
180
+ }
131
181
  if (plan.trim.rollbackTurns < 1) {
132
- return {
133
- status: 'skipped',
134
- reason: 'nothing_to_trim',
135
- decision,
136
- plan,
137
- };
182
+ return recordCodexAutoRefreshResult({
183
+ autoRefreshStateStore,
184
+ threadId,
185
+ fingerprint,
186
+ family: 'execute',
187
+ rolloutState,
188
+ result: {
189
+ status: 'skipped',
190
+ reason: 'nothing_to_trim',
191
+ decision,
192
+ plan,
193
+ },
194
+ });
138
195
  }
139
196
  if (!hasInjectableMemory(plan.memoryPreview)) {
140
- return {
141
- status: 'skipped',
142
- reason: 'injectable_memory_required',
143
- decision,
144
- plan,
145
- };
197
+ return recordCodexAutoRefreshResult({
198
+ autoRefreshStateStore,
199
+ threadId,
200
+ fingerprint,
201
+ family: 'execute',
202
+ rolloutState,
203
+ result: {
204
+ status: 'skipped',
205
+ reason: 'injectable_memory_required',
206
+ decision,
207
+ plan,
208
+ },
209
+ });
146
210
  }
147
211
 
212
+ recordCodexAutoRefreshOutcome({
213
+ autoRefreshStateStore,
214
+ threadId,
215
+ fingerprint,
216
+ family: 'execute',
217
+ outcome: {
218
+ status: 'started',
219
+ reason: 'threshold_reached',
220
+ },
221
+ rolloutState,
222
+ });
223
+
148
224
  const execution = await executeTrim({
149
225
  threadId,
150
226
  cwd: projectPath,
@@ -154,35 +230,228 @@ export async function runCodexAutoRefresh({
154
230
  command,
155
231
  });
156
232
  if (execution.status === 'refused') {
157
- return {
158
- status: 'refused',
159
- reason: execution.reason,
160
- decision,
161
- plan,
162
- execution,
163
- };
233
+ return recordCodexAutoRefreshResult({
234
+ autoRefreshStateStore,
235
+ threadId,
236
+ fingerprint,
237
+ family: 'execute',
238
+ rolloutState,
239
+ result: {
240
+ status: 'refused',
241
+ reason: execution.reason,
242
+ decision,
243
+ plan,
244
+ execution,
245
+ },
246
+ });
164
247
  }
165
248
 
166
249
  const postInjectVisibilityStatus = execution.postInjectVisibilityCheck?.status ?? 'unchecked';
167
250
  if (postInjectVisibilityStatus !== 'match') {
168
- return {
169
- status: 'unverified',
170
- reason: execution.postInjectVisibilityCheck?.reason ?? 'post_inject_visibility_unverified',
251
+ return recordCodexAutoRefreshResult({
252
+ autoRefreshStateStore,
253
+ threadId,
254
+ fingerprint,
255
+ family: 'execute',
256
+ rolloutState,
257
+ quietThreadUntilNewUserTurn: Boolean(execution.rollbackSent || execution.injectSent),
258
+ result: {
259
+ status: 'unverified',
260
+ reason: execution.postInjectVisibilityCheck?.reason ?? 'post_inject_visibility_unverified',
261
+ decision,
262
+ plan,
263
+ execution,
264
+ },
265
+ });
266
+ }
267
+
268
+ return recordCodexAutoRefreshResult({
269
+ autoRefreshStateStore,
270
+ threadId,
271
+ fingerprint,
272
+ family: 'execute',
273
+ rolloutState,
274
+ quietThreadUntilNewUserTurn: Boolean(execution.rollbackSent || execution.injectSent),
275
+ result: {
276
+ status: 'refreshed-live',
277
+ reason: 'rollback_and_inject_sent_live',
171
278
  decision,
172
279
  plan,
173
280
  execution,
281
+ },
282
+ });
283
+ }
284
+
285
+ export function buildCodexAutoRefreshRolloutState({ captured = null, trimSource = null, plan = null } = {}) {
286
+ const stats = trimSource?.stats ?? plan?.trim?.rolloutStats ?? captured?.stats ?? {};
287
+ return {
288
+ rolloutPath: plan?.trim?.rolloutPath ?? trimSource?.rolloutPath ?? captured?.rolloutPath ?? null,
289
+ capturedTurns: numberOrNull(plan?.trim?.capturedTurns ?? trimSource?.capturedTurns ?? captured?.capturedTurns),
290
+ rollbackTurns: numberOrNull(plan?.trim?.rollbackTurns),
291
+ capturedRows: numberOrNull(captured?.capturedRows),
292
+ capturedDetails: numberOrNull(captured?.capturedDetails),
293
+ rollbackEvents: numberOrNull(stats?.rollbackEvents),
294
+ rolledBackTurns: numberOrNull(stats?.rolledBackTurns),
295
+ injectedDeveloperMessages: numberOrNull(stats?.injectedDeveloperMessages),
296
+ userMessagesAfterRollback: numberOrNull(stats?.userMessagesAfterRollback),
297
+ };
298
+ }
299
+
300
+ export function buildCodexAutoRefreshFingerprint({
301
+ threadId,
302
+ projectPath,
303
+ usage,
304
+ rolloutState,
305
+ } = {}) {
306
+ return JSON.stringify({
307
+ version: CODEX_AUTO_REFRESH_STATE_VERSION,
308
+ threadId: typeof threadId === 'string' ? threadId : null,
309
+ projectPath: normalizeProjectPath(projectPath),
310
+ usageEpochPercent: usageEpochPercent(usage),
311
+ contextWindowSize: numberOrNull(usage?.contextWindowSize),
312
+ usageEstimated: Boolean(usage?.estimated),
313
+ contextWindowEstimated: Boolean(usage?.contextWindowEstimated),
314
+ usageSource: typeof usage?.source === 'string' ? usage.source : null,
315
+ rolloutPath: rolloutState?.rolloutPath ?? null,
316
+ capturedTurns: numberOrNull(rolloutState?.capturedTurns),
317
+ rollbackTurns: numberOrNull(rolloutState?.rollbackTurns),
318
+ rollbackEvents: numberOrNull(rolloutState?.rollbackEvents),
319
+ rolledBackTurns: numberOrNull(rolloutState?.rolledBackTurns),
320
+ injectedDeveloperMessages: numberOrNull(rolloutState?.injectedDeveloperMessages),
321
+ userMessagesAfterRollback: numberOrNull(rolloutState?.userMessagesAfterRollback),
322
+ });
323
+ }
324
+
325
+ export function checkCodexAutoRefreshBackoff({
326
+ autoRefreshStateStore,
327
+ threadId,
328
+ fingerprint,
329
+ family,
330
+ suppressFamilies = [family],
331
+ rolloutState = null,
332
+ } = {}) {
333
+ if (!autoRefreshStateStore) {
334
+ return { shouldBackoff: false, reason: 'state_store_unavailable' };
335
+ }
336
+
337
+ const state = normalizeAutoRefreshState(autoRefreshStateStore.read(threadId), threadId);
338
+ const quiet = state.threadQuiet;
339
+ if (
340
+ quiet?.family === 'execute' &&
341
+ suppressFamilies.includes('execute') &&
342
+ shouldSuppressForThreadQuiet(quiet, rolloutState)
343
+ ) {
344
+ return {
345
+ shouldBackoff: true,
346
+ reason: 'thread_quiet_until_new_user_turn',
347
+ family,
348
+ suppressFamilies,
349
+ entry: quiet,
350
+ };
351
+ }
352
+
353
+ const entry = state.entries.find(
354
+ (candidate) => suppressFamilies.includes(candidate.family) && candidate.fingerprint === fingerprint,
355
+ );
356
+ if (entry) {
357
+ return {
358
+ shouldBackoff: true,
359
+ reason: 'matching_refresh_state',
360
+ family,
361
+ suppressFamilies,
362
+ entry,
363
+ };
364
+ }
365
+
366
+ return {
367
+ shouldBackoff: false,
368
+ reason: 'no_matching_refresh_state',
369
+ family,
370
+ suppressFamilies,
371
+ };
372
+ }
373
+
374
+ export function recordCodexAutoRefreshOutcome({
375
+ autoRefreshStateStore,
376
+ threadId,
377
+ fingerprint,
378
+ family,
379
+ outcome,
380
+ rolloutState = null,
381
+ quietThreadUntilNewUserTurn = false,
382
+ } = {}) {
383
+ if (!autoRefreshStateStore) return null;
384
+
385
+ const state = normalizeAutoRefreshState(autoRefreshStateStore.read(threadId), threadId);
386
+ const entry = {
387
+ family,
388
+ fingerprint,
389
+ outcomeStatus: outcome?.status ?? null,
390
+ outcomeReason: outcome?.reason ?? null,
391
+ recordedAt: Date.now(),
392
+ rolloutState,
393
+ };
394
+ state.entries = [
395
+ entry,
396
+ ...state.entries.filter(
397
+ (candidate) => candidate.family !== family || candidate.fingerprint !== fingerprint,
398
+ ),
399
+ ].slice(0, MAX_AUTO_REFRESH_STATE_ENTRIES);
400
+
401
+ if (quietThreadUntilNewUserTurn) {
402
+ state.threadQuiet = {
403
+ family,
404
+ reason: 'refresh_sent_quiet_until_new_user_turn',
405
+ outcomeStatus: outcome?.status ?? null,
406
+ outcomeReason: outcome?.reason ?? null,
407
+ recordedAt: entry.recordedAt,
408
+ userMessagesAfterRollback: numberOrNull(rolloutState?.userMessagesAfterRollback),
409
+ rollbackEvents: numberOrNull(rolloutState?.rollbackEvents),
410
+ injectedDeveloperMessages: numberOrNull(rolloutState?.injectedDeveloperMessages),
174
411
  };
175
412
  }
176
413
 
414
+ autoRefreshStateStore.write(threadId, state);
415
+ return entry;
416
+ }
417
+
418
+ export function createFileCodexAutoRefreshStateStore({
419
+ dir = join(homedir(), '.throughline', 'codex-auto-refresh'),
420
+ } = {}) {
177
421
  return {
178
- status: 'refreshed-live',
179
- reason: 'rollback_and_inject_sent_live',
180
- decision,
181
- plan,
182
- execution,
422
+ read(threadId) {
423
+ const file = codexAutoRefreshStatePath(dir, threadId);
424
+ if (!existsSync(file)) return null;
425
+ return JSON.parse(readFileSync(file, 'utf8'));
426
+ },
427
+ write(threadId, state) {
428
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
429
+ writeFileSync(codexAutoRefreshStatePath(dir, threadId), JSON.stringify(state));
430
+ },
183
431
  };
184
432
  }
185
433
 
434
+ function recordCodexAutoRefreshResult({
435
+ autoRefreshStateStore,
436
+ threadId,
437
+ fingerprint,
438
+ family,
439
+ rolloutState,
440
+ quietThreadUntilNewUserTurn = false,
441
+ result,
442
+ }) {
443
+ recordCodexAutoRefreshOutcome({
444
+ autoRefreshStateStore,
445
+ threadId,
446
+ fingerprint,
447
+ family,
448
+ outcome: result,
449
+ rolloutState,
450
+ quietThreadUntilNewUserTurn,
451
+ });
452
+ return result;
453
+ }
454
+
186
455
  function hasInjectableMemory(memoryPreview) {
187
456
  const text = memoryPreview?.text;
188
457
  return (
@@ -192,3 +461,66 @@ function hasInjectableMemory(memoryPreview) {
192
461
  text !== '(no captured memory available)'
193
462
  );
194
463
  }
464
+
465
+ function normalizeAutoRefreshState(value, threadId) {
466
+ if (!value || typeof value !== 'object') {
467
+ return {
468
+ version: CODEX_AUTO_REFRESH_STATE_VERSION,
469
+ threadId,
470
+ entries: [],
471
+ threadQuiet: null,
472
+ };
473
+ }
474
+ return {
475
+ version: CODEX_AUTO_REFRESH_STATE_VERSION,
476
+ threadId: typeof value.threadId === 'string' ? value.threadId : threadId,
477
+ entries: Array.isArray(value.entries) ? value.entries.filter(isRefreshStateEntry) : [],
478
+ threadQuiet: value.threadQuiet && typeof value.threadQuiet === 'object' ? value.threadQuiet : null,
479
+ };
480
+ }
481
+
482
+ function isRefreshStateEntry(entry) {
483
+ return (
484
+ entry &&
485
+ typeof entry === 'object' &&
486
+ typeof entry.family === 'string' &&
487
+ typeof entry.fingerprint === 'string'
488
+ );
489
+ }
490
+
491
+ function shouldSuppressForThreadQuiet(quiet, rolloutState) {
492
+ const quietUserMessages = numberOrNull(quiet?.userMessagesAfterRollback);
493
+ const currentUserMessages = numberOrNull(rolloutState?.userMessagesAfterRollback);
494
+ if (quietUserMessages === null || currentUserMessages === null) return false;
495
+ return currentUserMessages <= quietUserMessages;
496
+ }
497
+
498
+ function usageEpochPercent(usage) {
499
+ const tokens = Number(usage?.tokens);
500
+ const contextWindowSize = Number(usage?.contextWindowSize);
501
+ if (!Number.isFinite(tokens) || !Number.isFinite(contextWindowSize) || contextWindowSize <= 0) {
502
+ return null;
503
+ }
504
+ const percent = (tokens / contextWindowSize) * 100;
505
+ return Math.floor(percent / CODEX_AUTO_REFRESH_USAGE_EPOCH_PERCENT) * CODEX_AUTO_REFRESH_USAGE_EPOCH_PERCENT;
506
+ }
507
+
508
+ function normalizeProjectPath(value) {
509
+ if (typeof value !== 'string' || value.length === 0) return '';
510
+ let result = resolve(value).replace(/\\/g, '/');
511
+ if (result.length > 1 && result.endsWith('/')) result = result.slice(0, -1);
512
+ if (platform() === 'win32') result = result.toLowerCase();
513
+ return result;
514
+ }
515
+
516
+ function numberOrNull(value) {
517
+ const number = Number(value);
518
+ return Number.isFinite(number) ? number : null;
519
+ }
520
+
521
+ function codexAutoRefreshStatePath(dir, threadId) {
522
+ if (typeof threadId !== 'string' || threadId.length === 0) {
523
+ throw new Error('codex auto-refresh state requires threadId');
524
+ }
525
+ return join(dir, `${encodeURIComponent(threadId)}.json`);
526
+ }