imprint-mcp 0.6.2 → 0.6.4

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.
@@ -10,7 +10,7 @@
10
10
  import { type ChildProcess, spawn, spawnSync } from 'node:child_process';
11
11
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
12
12
  import { isAbsolute as pathIsAbsolute, join as pathJoin } from 'node:path';
13
- import type { Span } from '@opentelemetry/api';
13
+ import { type Span, context as otelContext } from '@opentelemetry/api';
14
14
  import type { AuthCliCompileMode } from './auth-compile-tools.ts';
15
15
  import { type SharedModuleManifestEntry, resolvePlanSliceFromFile } from './build-plan.ts';
16
16
  import type {
@@ -25,13 +25,12 @@ import { COMPILE_SENTINELS, compileDeadlineAfterVerification } from './mcp-compi
25
25
  import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
26
26
  import {
27
27
  endTraceSpan,
28
- llmSpanAttributes,
29
- resolveTraceTokenCount,
28
+ recordLlmUsageSpan,
30
29
  setSpanAttributes,
31
30
  startTraceSpan,
31
+ traceInputOutputAttributes,
32
32
  traceJsonInputOutputAttributes,
33
33
  traceLlmIoEnabled,
34
- traceLlmMessages,
35
34
  traceToolIoEnabled,
36
35
  traced,
37
36
  } from './tracing.ts';
@@ -44,6 +43,80 @@ const CLI_PATH = pathJoin(REPO_ROOT, 'src', 'cli.ts');
44
43
  const MCP_SERVER_NAME = 'imprint-compile';
45
44
  const MAX_VERIFICATION_CYCLES = 5;
46
45
  const MAX_MCP_TOOL_TIMEOUT_SEC = 1800;
46
+ const SENTINEL_USAGE_GRACE_MS = 15_000;
47
+
48
+ interface SentinelGraceController {
49
+ observeSentinel(): void;
50
+ observeTurnCompleted(): void;
51
+ dispose(): void;
52
+ }
53
+
54
+ interface TurnActivityTracker {
55
+ isActive(): boolean;
56
+ started(): void;
57
+ completed(): void;
58
+ }
59
+
60
+ /** Track Codex turn lifecycle without depending on optional tracing spans. */
61
+ export function createTurnActivityTracker(): TurnActivityTracker {
62
+ let active = false;
63
+ return {
64
+ isActive: () => active,
65
+ started: () => {
66
+ active = true;
67
+ },
68
+ completed: () => {
69
+ active = false;
70
+ },
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Wait just long enough for the terminal Codex usage event after done/give_up.
76
+ * A completed turn terminates immediately; the timer is only a bounded fallback
77
+ * for CLI versions that never emit turn.completed.
78
+ */
79
+ export function createSentinelGraceController(opts: {
80
+ hasActiveTurn: () => boolean;
81
+ terminate: () => void;
82
+ fallbackMs?: number;
83
+ schedule?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
84
+ cancel?: (timer: ReturnType<typeof setTimeout>) => void;
85
+ }): SentinelGraceController {
86
+ const schedule = opts.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs));
87
+ const cancel = opts.cancel ?? clearTimeout;
88
+ let sentinelObserved = false;
89
+ let terminated = false;
90
+ let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
91
+
92
+ const terminateOnce = (): void => {
93
+ if (terminated) return;
94
+ terminated = true;
95
+ if (fallbackTimer) cancel(fallbackTimer);
96
+ fallbackTimer = undefined;
97
+ opts.terminate();
98
+ };
99
+
100
+ return {
101
+ observeSentinel(): void {
102
+ sentinelObserved = true;
103
+ if (!opts.hasActiveTurn()) {
104
+ terminateOnce();
105
+ return;
106
+ }
107
+ if (fallbackTimer || terminated) return;
108
+ fallbackTimer = schedule(terminateOnce, opts.fallbackMs ?? SENTINEL_USAGE_GRACE_MS);
109
+ fallbackTimer.unref?.();
110
+ },
111
+ observeTurnCompleted(): void {
112
+ if (sentinelObserved) terminateOnce();
113
+ },
114
+ dispose(): void {
115
+ if (fallbackTimer) cancel(fallbackTimer);
116
+ fallbackTimer = undefined;
117
+ },
118
+ };
119
+ }
47
120
 
48
121
  function formatRevisionMode(enabled: boolean | undefined): string {
49
122
  return enabled
@@ -127,6 +200,7 @@ export async function compileViaCodexCli(
127
200
  },
128
201
  async (span) => {
129
202
  const result = await compileViaCodexCliImpl(opts, span);
203
+ const model = opts.model ?? preferredAgentModel('codex-cli');
130
204
  setSpanAttributes(span, {
131
205
  'imprint.compile.outcome': result.outcome,
132
206
  'imprint.compile.success': result.success,
@@ -136,6 +210,20 @@ export async function compileViaCodexCli(
136
210
  'imprint.compile.output_tokens': result.outputTokens,
137
211
  'imprint.compile.conversation_log': result.conversationLogPath,
138
212
  });
213
+ // Codex reports input_tokens as the total prompt; cached_input_tokens is
214
+ // a subset, not an additional token bucket.
215
+ recordLlmUsageSpan(
216
+ 'compile.codex_cli_usage',
217
+ {
218
+ provider: 'codex-cli',
219
+ model,
220
+ inputTokens: result.inputTokens,
221
+ outputTokens: result.outputTokens,
222
+ cacheReadTokens: result.cacheReadInputTokens,
223
+ cacheWriteTokens: result.cacheCreationInputTokens,
224
+ },
225
+ { 'imprint.compile.turns': result.turns },
226
+ );
139
227
  return result;
140
228
  },
141
229
  );
@@ -237,30 +325,16 @@ Use the imprint-compile MCP tools to inspect the session, write artifacts, run t
237
325
  }
238
326
 
239
327
  const model = opts.model ?? preferredAgentModel('codex-cli');
240
- const initialTokenCount = resolveTraceTokenCount(null, initialPrompt);
241
328
  const captureLlmIo = traceLlmIoEnabled();
242
329
  // done() owns a separately bounded live-verification phase, so its transport
243
330
  // timeout cannot be derived from the compiler's remaining reasoning budget.
244
331
  const mcpToolTimeoutSec = MAX_MCP_TOOL_TIMEOUT_SEC;
245
332
  setSpanAttributes(traceSpan, {
246
- ...llmSpanAttributes({
247
- provider: 'codex-cli',
248
- model,
249
- inputTokens: initialTokenCount.tokens,
250
- tokenCountsEstimated: true,
251
- inputTokenSource: initialTokenCount.source,
252
- inputMessages: captureLlmIo
253
- ? traceLlmMessages([{ role: 'user', content: initialPrompt }])
254
- : undefined,
255
- inputValue: captureLlmIo ? initialPrompt : undefined,
256
- invocationParameters: {
257
- command: 'codex exec',
258
- json: true,
259
- sandbox: 'workspace-write',
260
- tool_timeout_sec: mcpToolTimeoutSec,
261
- },
262
- }),
333
+ ...(captureLlmIo ? traceInputOutputAttributes('input', initialPrompt) : {}),
263
334
  'imprint.compile.initial_prompt_chars': initialPrompt.length,
335
+ 'imprint.compile.command': 'codex exec',
336
+ 'imprint.compile.sandbox': 'workspace-write',
337
+ 'imprint.compile.tool_timeout_sec': mcpToolTimeoutSec,
264
338
  });
265
339
 
266
340
  const execArgs = opts.resume
@@ -352,27 +426,9 @@ Use the imprint-compile MCP tools to inspect the session, write artifacts, run t
352
426
  } finally {
353
427
  process.removeListener('exit', terminateOnParentExit);
354
428
  }
355
- const hasActualUsage = result.inputTokens > 0 || result.outputTokens > 0;
356
- const inputTokenCount = resolveTraceTokenCount(
357
- hasActualUsage ? result.inputTokens : null,
358
- initialPrompt,
359
- );
360
- const outputTokenCount = resolveTraceTokenCount(
361
- hasActualUsage ? result.outputTokens : null,
362
- result.message,
363
- );
364
429
  setSpanAttributes(traceSpan, {
365
- ...llmSpanAttributes({
366
- provider: 'codex-cli',
367
- model,
368
- inputTokens: inputTokenCount.tokens,
369
- outputTokens: outputTokenCount.tokens,
370
- tokenCountsEstimated:
371
- inputTokenCount.source === 'estimated' || outputTokenCount.source === 'estimated',
372
- inputTokenSource: inputTokenCount.source,
373
- outputTokenSource: outputTokenCount.source,
374
- }),
375
430
  'imprint.compile.message': result.message,
431
+ ...(captureLlmIo ? traceInputOutputAttributes('output', result.message) : {}),
376
432
  });
377
433
  return result;
378
434
  }
@@ -392,6 +448,10 @@ async function driveJsonl(
392
448
  opts: CompileViaCodexCliOptions,
393
449
  traceSpan?: Span,
394
450
  ): Promise<CompileAgentResult> {
451
+ // Bun's child-process event emitters do not preserve AsyncLocalStorage.
452
+ // Restore the compile span context inside stdout callbacks so turn/tool
453
+ // spans remain children of compile.codex_cli_agent in Phoenix.
454
+ const parentCtx = otelContext.active();
395
455
  const conversationLog: unknown[] = [];
396
456
  const conversationLogPath = pathJoin(opts.absoluteToolDir, '.compile-log.json');
397
457
  const rawStdoutPath = pathJoin(opts.absoluteToolDir, '.codex-stdout.jsonl');
@@ -414,6 +474,7 @@ async function driveJsonl(
414
474
  const rawStderrChunks: string[] = [];
415
475
  let inputTokens = 0;
416
476
  let outputTokens = 0;
477
+ let cacheReadInputTokens = 0;
417
478
  let turn = 0;
418
479
  let lastErrorMessage = '';
419
480
  let stderrBuf = '';
@@ -421,6 +482,8 @@ async function driveJsonl(
421
482
  let capturedSessionId: string | undefined;
422
483
  const toolSpans = new Map<string, Span>();
423
484
  let currentTurnSpan: Span | null = null;
485
+ const turnActivity = createTurnActivityTracker();
486
+ let onTurnCompletedAfterSentinel: (() => void) | undefined;
424
487
 
425
488
  const budgetMs = Math.max(0, opts.deadlineMs - Date.now());
426
489
  const fireProgress = (phase: 'thinking' | 'tool', toolName?: string): void => {
@@ -481,103 +544,106 @@ async function driveJsonl(
481
544
 
482
545
  let stdoutBuf = '';
483
546
  child.stdout?.on('data', (chunk: Buffer) => {
484
- const chunkText = chunk.toString('utf8');
485
- rawStdoutChunks.push(chunkText);
486
- stdoutBuf += chunkText;
487
- while (true) {
488
- const nl = stdoutBuf.indexOf('\n');
489
- if (nl < 0) break;
490
- const line = stdoutBuf.slice(0, nl).trim();
491
- stdoutBuf = stdoutBuf.slice(nl + 1);
492
- if (!line) continue;
493
-
494
- let evt: CodexJsonEvent;
495
- try {
496
- evt = JSON.parse(line) as CodexJsonEvent;
497
- } catch (err) {
498
- log(`unparseable jsonl line: ${errMsg(err)}`);
499
- continue;
500
- }
547
+ otelContext.with(parentCtx, () => {
548
+ const chunkText = chunk.toString('utf8');
549
+ rawStdoutChunks.push(chunkText);
550
+ stdoutBuf += chunkText;
551
+ while (true) {
552
+ const nl = stdoutBuf.indexOf('\n');
553
+ if (nl < 0) break;
554
+ const line = stdoutBuf.slice(0, nl).trim();
555
+ stdoutBuf = stdoutBuf.slice(nl + 1);
556
+ if (!line) continue;
557
+
558
+ let evt: CodexJsonEvent;
559
+ try {
560
+ evt = JSON.parse(line) as CodexJsonEvent;
561
+ } catch (err) {
562
+ log(`unparseable jsonl line: ${errMsg(err)}`);
563
+ continue;
564
+ }
501
565
 
502
- conversationLog.push(evt);
503
- // Keep the human-readable compile log current during a long agent turn.
504
- // Rewriting is throttled so streamed bursts do not cause one full-file
505
- // write per JSONL event, while a stalled/crashed turn still leaves the
506
- // latest tool activity visible on disk.
507
- scheduleLogFlush();
508
-
509
- if (evt.type === 'thread.started') {
510
- log(`thread_id=${evt.thread_id ?? '(none)'}`);
511
- setSpanAttributes(traceSpan, { 'codex.thread_id': evt.thread_id });
512
- if (evt.thread_id) capturedSessionId = evt.thread_id;
513
- continue;
514
- }
566
+ conversationLog.push(evt);
567
+ // Keep the human-readable compile log current during a long agent turn.
568
+ // Rewriting is throttled so streamed bursts do not cause one full-file
569
+ // write per JSONL event, while a stalled/crashed turn still leaves the
570
+ // latest tool activity visible on disk.
571
+ scheduleLogFlush();
572
+
573
+ if (evt.type === 'thread.started') {
574
+ log(`thread_id=${evt.thread_id ?? '(none)'}`);
575
+ setSpanAttributes(traceSpan, { 'codex.thread_id': evt.thread_id });
576
+ if (evt.thread_id) capturedSessionId = evt.thread_id;
577
+ continue;
578
+ }
515
579
 
516
- if (evt.type === 'turn.started') {
517
- if (currentTurnSpan) endTraceSpan(currentTurnSpan);
518
- flushLog();
519
- turn++;
520
- currentTurnSpan = startTraceSpan(`agent.turn.${turn}`, 'CHAIN', {
521
- 'imprint.agent.turn': turn,
522
- 'imprint.agent.cumulative_input_tokens': inputTokens,
523
- 'imprint.agent.cumulative_output_tokens': outputTokens,
524
- });
525
- fireProgress('thinking');
526
- continue;
527
- }
580
+ if (evt.type === 'turn.started') {
581
+ turnActivity.started();
582
+ if (currentTurnSpan) endTraceSpan(currentTurnSpan);
583
+ flushLog();
584
+ turn++;
585
+ currentTurnSpan = startTraceSpan(`agent.turn.${turn}`, 'CHAIN', {
586
+ 'imprint.agent.turn': turn,
587
+ 'imprint.agent.cumulative_input_tokens': inputTokens,
588
+ 'imprint.agent.cumulative_output_tokens': outputTokens,
589
+ });
590
+ fireProgress('thinking');
591
+ continue;
592
+ }
528
593
 
529
- const normalizedToolEvt = normalizeCodexToolEvent(evt);
530
- if (normalizedToolEvt) {
531
- const { eventType, item } = normalizedToolEvt;
532
- const toolName = codexToolName(item);
533
- if (toolName) {
534
- traceCodexToolEvent(toolSpans, eventType, item, toolName);
535
- fireProgress(eventType === 'item.started' ? 'tool' : 'thinking', toolName);
594
+ const normalizedToolEvt = normalizeCodexToolEvent(evt);
595
+ if (normalizedToolEvt) {
596
+ const { eventType, item } = normalizedToolEvt;
597
+ const toolName = codexToolName(item);
598
+ if (toolName) {
599
+ traceCodexToolEvent(toolSpans, eventType, item, toolName);
600
+ fireProgress(eventType === 'item.started' ? 'tool' : 'thinking', toolName);
601
+ }
602
+ continue;
536
603
  }
537
- continue;
538
- }
539
604
 
540
- if ((evt.type === 'item.started' || evt.type === 'item.completed') && evt.item) {
541
- const agentMessage = codexAgentMessageText(evt.item);
542
- if (agentMessage && evt.type === 'item.completed') {
543
- agentMessageCount++;
544
- setSpanAttributes(traceSpan, {
545
- 'imprint.codex.agent_messages': agentMessageCount,
546
- 'imprint.codex.last_agent_message_chars': agentMessage.length,
547
- ...(traceLlmIoEnabled()
548
- ? llmSpanAttributes({
549
- provider: 'codex-cli',
550
- model: opts.model ?? preferredAgentModel('codex-cli'),
551
- outputMessages: traceLlmMessages([{ role: 'assistant', content: agentMessage }]),
552
- outputValue: agentMessage,
553
- })
554
- : {}),
555
- });
605
+ if ((evt.type === 'item.started' || evt.type === 'item.completed') && evt.item) {
606
+ const agentMessage = codexAgentMessageText(evt.item);
607
+ if (agentMessage && evt.type === 'item.completed') {
608
+ agentMessageCount++;
609
+ setSpanAttributes(traceSpan, {
610
+ 'imprint.codex.agent_messages': agentMessageCount,
611
+ 'imprint.codex.last_agent_message_chars': agentMessage.length,
612
+ ...(traceLlmIoEnabled()
613
+ ? traceInputOutputAttributes('output', agentMessage, undefined, 'agent_message')
614
+ : {}),
615
+ });
616
+ continue;
617
+ }
556
618
  continue;
557
619
  }
558
- continue;
559
- }
560
620
 
561
- if (evt.type === 'turn.completed') {
562
- const turnInput = evt.usage?.input_tokens ?? 0;
563
- const turnOutput = evt.usage?.output_tokens ?? 0;
564
- inputTokens += turnInput;
565
- outputTokens += turnOutput;
566
- if (currentTurnSpan) {
567
- setSpanAttributes(currentTurnSpan, {
568
- 'imprint.agent.turn_input_tokens': turnInput,
569
- 'imprint.agent.turn_output_tokens': turnOutput,
570
- });
571
- endTraceSpan(currentTurnSpan);
572
- currentTurnSpan = null;
621
+ if (evt.type === 'turn.completed') {
622
+ const turnInput = evt.usage?.input_tokens ?? 0;
623
+ const turnOutput = evt.usage?.output_tokens ?? 0;
624
+ const turnCacheRead = evt.usage?.cached_input_tokens ?? 0;
625
+ inputTokens += turnInput;
626
+ outputTokens += turnOutput;
627
+ cacheReadInputTokens += turnCacheRead;
628
+ if (currentTurnSpan) {
629
+ setSpanAttributes(currentTurnSpan, {
630
+ 'imprint.agent.turn_input_tokens': turnInput,
631
+ 'imprint.agent.turn_output_tokens': turnOutput,
632
+ 'imprint.agent.turn_cache_read_input_tokens': turnCacheRead,
633
+ });
634
+ endTraceSpan(currentTurnSpan);
635
+ currentTurnSpan = null;
636
+ }
637
+ turnActivity.completed();
638
+ onTurnCompletedAfterSentinel?.();
639
+ continue;
573
640
  }
574
- continue;
575
- }
576
641
 
577
- if (evt.type === 'error' || evt.type === 'turn.failed') {
578
- lastErrorMessage = evt.message ?? evt.error?.message ?? JSON.stringify(evt);
642
+ if (evt.type === 'error' || evt.type === 'turn.failed') {
643
+ lastErrorMessage = evt.message ?? evt.error?.message ?? JSON.stringify(evt);
644
+ }
579
645
  }
580
- }
646
+ });
581
647
  });
582
648
 
583
649
  child.stderr?.on('data', (chunk: Buffer) => {
@@ -593,6 +659,8 @@ async function driveJsonl(
593
659
  const finish = (code: number): void => {
594
660
  if (resolved) return;
595
661
  resolved = true;
662
+ sentinelGrace.dispose();
663
+ onTurnCompletedAfterSentinel = undefined;
596
664
  if (forcedKillTimer) clearTimeout(forcedKillTimer);
597
665
  clearInterval(sentinelTimer);
598
666
  resolve(code);
@@ -616,10 +684,17 @@ async function driveJsonl(
616
684
  }, 2000);
617
685
  forcedKillTimer.unref?.();
618
686
  };
687
+ const sentinelGrace = createSentinelGraceController({
688
+ // Turn lifecycle is independent of tracing. startTraceSpan() returns null
689
+ // when tracing is disabled, but terminal usage still must be captured.
690
+ hasActiveTurn: turnActivity.isActive,
691
+ terminate: terminateForSentinel,
692
+ });
693
+ onTurnCompletedAfterSentinel = sentinelGrace.observeTurnCompleted;
619
694
  const sentinelTimer = setInterval(() => {
620
695
  const checkpointReached = opts.authMode && existsSync(checkpointSentinel);
621
696
  if (!existsSync(doneSentinel) && !existsSync(giveUpSentinel) && !checkpointReached) return;
622
- terminateForSentinel();
697
+ sentinelGrace.observeSentinel();
623
698
  }, 500);
624
699
  child.once('close', (code) => {
625
700
  finish(code ?? -1);
@@ -629,6 +704,7 @@ async function driveJsonl(
629
704
  });
630
705
  });
631
706
  childExited = true;
707
+ turnActivity.completed();
632
708
  clearTimeout(deadlineTimer);
633
709
  if (currentTurnSpan) endTraceSpan(currentTurnSpan);
634
710
  for (const span of toolSpans.values()) endTraceSpan(span);
@@ -687,7 +763,7 @@ async function driveJsonl(
687
763
  durationMs: Date.now() - opts.startTime,
688
764
  inputTokens,
689
765
  outputTokens,
690
- cacheReadInputTokens: 0,
766
+ cacheReadInputTokens,
691
767
  cacheCreationInputTokens: 0,
692
768
  sessionId: capturedSessionId,
693
769
  };
@@ -10,6 +10,29 @@ import type { AgentProgress } from './agent.ts';
10
10
  import { type AssignedSharedModule, describeAssignedModules } from './build-plan.ts';
11
11
  import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
12
12
 
13
+ /** Only an independent semantic-verifier result consumes the bounded review
14
+ * budget. Deterministic preflight failures remain deadline-bounded but do not
15
+ * spend a compiler↔reviewer iteration. */
16
+ export function advanceSemanticVerificationCycle(
17
+ current: number,
18
+ semanticReviewCompleted: boolean,
19
+ ): number {
20
+ return semanticReviewCompleted ? current + 1 : current;
21
+ }
22
+
23
+ /** Bound verifier infrastructure failures separately from semantic review
24
+ * cycles. One verifier invocation already includes its own provider retry; a
25
+ * second failed invocation gives the compiler one chance to react without
26
+ * allowing a missing provider to extend the compile deadline forever. */
27
+ export function advanceIncompleteSemanticVerificationRuns(
28
+ current: number,
29
+ semanticReviewAttempted: boolean,
30
+ semanticReviewCompleted: boolean,
31
+ ): number {
32
+ if (semanticReviewCompleted) return 0;
33
+ return semanticReviewAttempted ? current + 1 : current;
34
+ }
35
+
13
36
  /** Render a per-tool implementation plan (param→field mapping, request
14
37
  * construction, response parsing, shared-module imports, edge cases) into an
15
38
  * initial-message section the compile agent must follow. Shared verbatim by the
@@ -47,8 +70,8 @@ Compile only the selected candidate. Do not create tools for other actions in th
47
70
  }
48
71
 
49
72
  export interface CompileAgentProgress extends AgentProgress {
50
- /** 1-based verification cycle. Cycle 1 is the initial agent run. Subsequent cycles
51
- * happen when the agent claims done() but external verification fails. */
73
+ /** 1-based semantic-review cycle. Deterministic preflight retries leave this
74
+ * value unchanged. */
52
75
  verificationCycle: number;
53
76
  /** Verification cap for bounded data-tool compiles; auth is deadline-bounded. */
54
77
  maxVerificationCycles?: number;