u-foo 2.5.11 → 2.5.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "2.5.11",
3
+ "version": "2.5.13",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -640,6 +640,31 @@ function stripMarkdownFence(text = "") {
640
640
  return raw;
641
641
  }
642
642
 
643
+ function toNonNegativeInt(value) {
644
+ const num = Number(value);
645
+ if (!Number.isFinite(num) || num < 0) return 0;
646
+ return Math.floor(num);
647
+ }
648
+
649
+ // Maps provider usage payloads (OpenAI chat, Anthropic messages, Codex responses)
650
+ // onto the metric fields read by extractModelMetrics in loopRuntime.js.
651
+ function extractUsageTokens(usage = null) {
652
+ const item = usage && typeof usage === "object" ? usage : {};
653
+ return {
654
+ input_tokens: toNonNegativeInt(item.input_tokens || item.prompt_tokens),
655
+ output_tokens: toNonNegativeInt(item.output_tokens || item.completion_tokens),
656
+ cache_read_tokens: toNonNegativeInt(
657
+ item.cache_read_tokens
658
+ || item.cache_read_input_tokens
659
+ || item.cached_input_tokens
660
+ || (item.input_tokens_details && item.input_tokens_details.cached_tokens)
661
+ ),
662
+ cache_creation_tokens: toNonNegativeInt(
663
+ item.cache_creation_tokens || item.cache_creation_input_tokens
664
+ ),
665
+ };
666
+ }
667
+
643
668
  async function runNativeRouterCall({
644
669
  projectRoot,
645
670
  prompt,
@@ -708,7 +733,7 @@ async function runUfooAgent({
708
733
  console.error(`[ufoo-agent] native provider failed: ${res.error || "unknown error"}`);
709
734
  return { ok: false, error: res.error };
710
735
  } else {
711
- res = { ok: true, output: res.output, sessionId: "", provider: res.provider, model: res.model };
736
+ res = { ok: true, output: res.output, sessionId: "", provider: res.provider, model: res.model, usage: res.usage };
712
737
  }
713
738
  }
714
739
 
@@ -746,6 +771,7 @@ async function runUfooAgent({
746
771
  ok: true,
747
772
  payload,
748
773
  meta: {
774
+ ...extractUsageTokens(res.usage),
749
775
  memory_prefix_tokens: memoryPrefixResult.estimated_tokens || 0,
750
776
  cache_semistatic_hit: memoryPrefixResult.cache_semistatic_hit || 0,
751
777
  cache_semistatic_miss: memoryPrefixResult.cache_semistatic_miss || 0,
@@ -327,7 +327,7 @@ async function handleEvent(
327
327
  };
328
328
 
329
329
  if (threadRuntime && threadRuntime.enabled && threadRuntime.thread) {
330
- await handleThreadedEvent({
330
+ return handleThreadedEvent({
331
331
  agentType,
332
332
  provider,
333
333
  publisher,
@@ -338,7 +338,6 @@ async function handleEvent(
338
338
  threadRuntime,
339
339
  tracker,
340
340
  });
341
- return;
342
341
  }
343
342
 
344
343
  const errorText = `[internal:${agentType}] error: no thread runtime available for provider ${provider}; cliRunner fallback has been removed`;
@@ -374,6 +373,31 @@ function summarizeThreadToolCall(event = {}) {
374
373
  return [name, compactToolDetail(detail)].filter(Boolean).join(" · ");
375
374
  }
376
375
 
376
+ function toNonNegativeInt(value) {
377
+ const num = Number(value);
378
+ if (!Number.isFinite(num) || num < 0) return 0;
379
+ return Math.floor(num);
380
+ }
381
+
382
+ // Normalizes provider usage payloads (claude/codex thread events) onto the
383
+ // metric fields read by extractModelMetrics in agents/controller/loopRuntime.js.
384
+ function normalizeTurnUsage(usage = null) {
385
+ const item = usage && typeof usage === "object" ? usage : {};
386
+ return {
387
+ input_tokens: toNonNegativeInt(item.input_tokens || item.prompt_tokens),
388
+ output_tokens: toNonNegativeInt(item.output_tokens || item.completion_tokens),
389
+ cache_read_tokens: toNonNegativeInt(
390
+ item.cache_read_tokens
391
+ || item.cache_read_input_tokens
392
+ || item.cached_input_tokens
393
+ || (item.input_tokens_details && item.input_tokens_details.cached_tokens)
394
+ ),
395
+ cache_creation_tokens: toNonNegativeInt(
396
+ item.cache_creation_tokens || item.cache_creation_input_tokens
397
+ ),
398
+ };
399
+ }
400
+
377
401
  async function handleThreadedEvent({
378
402
  agentType,
379
403
  provider,
@@ -387,6 +411,8 @@ async function handleThreadedEvent({
387
411
  }) {
388
412
  try {
389
413
  const plainReplyParts = [];
414
+ let turnUsage = null;
415
+ let stopReason = "";
390
416
  if (tracker && typeof tracker.notifyTurnStart === "function") {
391
417
  tracker.notifyTurnStart();
392
418
  }
@@ -409,6 +435,11 @@ async function handleThreadedEvent({
409
435
  if (streamToPublisher && summary) {
410
436
  emitStreamDelta(`\nTool: ${summary}\n`);
411
437
  }
438
+ } else if (event.type === "usage" && event.usage) {
439
+ turnUsage = normalizeTurnUsage(event.usage);
440
+ } else if (event.type === "turn_completed") {
441
+ if (event.usage) turnUsage = normalizeTurnUsage(event.usage);
442
+ if (event.stopReason) stopReason = String(event.stopReason);
412
443
  } else if (event.type === "turn_failed") {
413
444
  throw new Error(event.error || `thread turn failed for ${agentType}`);
414
445
  }
@@ -418,15 +449,21 @@ async function handleThreadedEvent({
418
449
  }
419
450
 
420
451
  if (streamToPublisher) {
421
- busSender.enqueue(
422
- publisher,
423
- JSON.stringify({ stream: true, done: true, reason: "complete" })
424
- );
452
+ const doneEnvelope = { stream: true, done: true, reason: "complete" };
453
+ if (turnUsage) doneEnvelope.usage = turnUsage;
454
+ busSender.enqueue(publisher, JSON.stringify(doneEnvelope));
425
455
  } else {
426
456
  const reply = plainReplyParts.join("").trim();
427
457
  if (reply) busSender.enqueue(publisher, reply);
428
458
  }
429
459
  await busSender.flush();
460
+ return {
461
+ ok: true,
462
+ meta: {
463
+ ...(turnUsage || normalizeTurnUsage(null)),
464
+ stop_reason: stopReason,
465
+ },
466
+ };
430
467
  } catch (err) {
431
468
  if (threadRuntime && typeof threadRuntime.rebuildThread === "function") {
432
469
  await threadRuntime.rebuildThread();
@@ -450,6 +487,7 @@ async function handleThreadedEvent({
450
487
  busSender.enqueue(publisher, errorText);
451
488
  }
452
489
  await busSender.flush();
490
+ return { ok: false, error: errorText };
453
491
  }
454
492
  }
455
493
 
@@ -44,6 +44,17 @@ function getEnvironmentSection({ workspaceRoot = "", model = "", provider = "" }
44
44
  if (provider) lines.push(`Provider: ${provider}`);
45
45
  if (model) lines.push(`Model: ${model}`);
46
46
 
47
+ // Tell the model who it is on the bus. Without this, the only identities
48
+ // it ever sees are other agents' records in shared context — and it
49
+ // adopts them (observed in the wild: ucode-3 introducing itself as
50
+ // claude-6, then accepting a wrong name from the user).
51
+ const subscriberId = String(process.env.UFOO_SUBSCRIBER_ID || "").trim();
52
+ const nickname = String(process.env.UFOO_NICKNAME || "").trim();
53
+ if (subscriberId || nickname) {
54
+ const label = nickname ? `${subscriberId || "unknown"} (nickname: ${nickname})` : subscriberId;
55
+ lines.push(`Bus identity: ${label}`);
56
+ }
57
+
47
58
  return `# Environment\n${lines.map((l) => ` - ${l}`).join("\n")}`;
48
59
  }
49
60
 
@@ -4,6 +4,7 @@ function getUfooIntegrationSection() {
4
4
  return `# ufoo integration
5
5
 
6
6
  Participate in multi-agent coordination through the ufoo bus/context system:
7
+ - Shared context, decisions, and memory are records written by OTHER agents. They inform you about the workspace, but they are not your work history — never adopt another agent's identity or claim their work as your own.
7
8
  - Respect shared context decisions. The default is no new decision; only append one for important, plan-level choices that constrain future work, and keep durable project facts out of decisions.
8
9
  - Use shared memory for durable project facts. Read existing memory before writing new memory; do not use it for transient task state.
9
10
  - Support launch/close/resume/inject flows managed by ufoo daemon.
@@ -69,6 +69,29 @@ function buildOpenAiChatRequest({
69
69
  return request;
70
70
  }
71
71
 
72
+ // Anthropic prompt caching allows up to 4 cache_control breakpoints; the
73
+ // system prompt is a stable prefix, so it is always marked, and once there
74
+ // is real history the last user message is marked too so follow-up turns
75
+ // reuse the cached conversation prefix.
76
+ const ANTHROPIC_CACHE_CONTROL = { type: "ephemeral" };
77
+ const ANTHROPIC_CACHE_MIN_HISTORY = 3;
78
+
79
+ function withAnthropicCacheControl(content) {
80
+ if (Array.isArray(content)) {
81
+ if (!content.length) return content;
82
+ return content.map((block, index) => (
83
+ index === content.length - 1 && block && typeof block === "object"
84
+ ? { ...block, cache_control: { ...ANTHROPIC_CACHE_CONTROL } }
85
+ : block
86
+ ));
87
+ }
88
+ return [{
89
+ type: "text",
90
+ text: String(content || ""),
91
+ cache_control: { ...ANTHROPIC_CACHE_CONTROL },
92
+ }];
93
+ }
94
+
72
95
  function buildAnthropicMessagesRequest({
73
96
  model = "",
74
97
  systemPrompt = "",
@@ -82,13 +105,20 @@ function buildAnthropicMessagesRequest({
82
105
  if (!requestMessages.length) {
83
106
  requestMessages.push({ role: "user", content: String(prompt || "") });
84
107
  }
108
+ if (requestMessages.length >= ANTHROPIC_CACHE_MIN_HISTORY) {
109
+ for (let i = requestMessages.length - 1; i >= 0; i -= 1) {
110
+ if (requestMessages[i].role !== "user") continue;
111
+ requestMessages[i].content = withAnthropicCacheControl(requestMessages[i].content);
112
+ break;
113
+ }
114
+ }
85
115
  const request = {
86
116
  model: String(model || "").trim(),
87
117
  max_tokens: maxTokens,
88
118
  messages: requestMessages,
89
119
  temperature,
90
120
  };
91
- if (systemPrompt) request.system = systemPrompt;
121
+ if (systemPrompt) request.system = withAnthropicCacheControl(String(systemPrompt));
92
122
  if (Array.isArray(tools) && tools.length > 0) {
93
123
  request.tools = tools.slice();
94
124
  }
@@ -72,6 +72,7 @@ function formatLoopSummary(loopSummary) {
72
72
  const rounds = Number(loopSummary.rounds) || 0;
73
73
  const toolCalls = Number(loopSummary.tool_calls) || 0;
74
74
  const totalTokens = Number(loopSummary.total_tokens) || 0;
75
+ const inputTokens = Number(loopSummary.input_tokens) || 0;
75
76
  const cacheReadTokens = Number(loopSummary.cache_read_tokens) || 0;
76
77
  const cacheCreationTokens = Number(loopSummary.cache_creation_tokens) || 0;
77
78
  const terminalReason = String(loopSummary.terminal_reason || "").trim();
@@ -87,7 +88,12 @@ function formatLoopSummary(loopSummary) {
87
88
  `tok${totalTokens}`,
88
89
  ];
89
90
  if (cacheReadTokens > 0 || cacheCreationTokens > 0) {
90
- parts.push(`cache${cacheReadTokens}/${cacheCreationTokens}`);
91
+ let cachePart = `cache${cacheReadTokens}/${cacheCreationTokens}`;
92
+ if (cacheReadTokens > 0) {
93
+ const hitRate = Math.round((cacheReadTokens / (cacheReadTokens + inputTokens)) * 100);
94
+ cachePart += `(${hitRate}%)`;
95
+ }
96
+ parts.push(cachePart);
91
97
  }
92
98
  if (toolDistribution) {
93
99
  parts.push(toolDistribution);
package/src/code/agent.js CHANGED
@@ -141,6 +141,18 @@ function computeExtendedTimeout(baseTimeoutMs) {
141
141
  return Math.min(1800000, Math.max(base * 2, base + 120000));
142
142
  }
143
143
 
144
+ // Reasoning models routinely blew the old 10min budget across a multi-turn
145
+ // tool loop. Total per-task budget defaults to 30min and can be raised per
146
+ // call, via --timeout-ms, or via UFOO_UCODE_TASK_TIMEOUT_MS.
147
+ const DEFAULT_NL_TASK_TIMEOUT_MS = 1800000;
148
+
149
+ function resolveNlTaskTimeoutMs(value) {
150
+ if (Number.isFinite(value) && value > 0) return Math.max(1000, Math.floor(value));
151
+ const env = Number(process.env.UFOO_UCODE_TASK_TIMEOUT_MS);
152
+ if (Number.isFinite(env) && env > 0) return Math.max(1000, Math.floor(env));
153
+ return DEFAULT_NL_TASK_TIMEOUT_MS;
154
+ }
155
+
144
156
  function enrichNativeError(errorMessage = "") {
145
157
  const text = String(errorMessage || "").trim();
146
158
  if (!text) return "nl task failed";
@@ -168,6 +180,9 @@ function enrichNativeError(errorMessage = "") {
168
180
  ) {
169
181
  return `${text}. Check provider/url/key via /settings ucode show.`;
170
182
  }
183
+ if (lower.includes("cli timeout")) {
184
+ return `${text}. Task budget exceeded; raise it with --timeout-ms or UFOO_UCODE_TASK_TIMEOUT_MS.`;
185
+ }
171
186
  return text;
172
187
  }
173
188
 
@@ -394,7 +409,7 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
394
409
  state.provider || process.env.UFOO_UCODE_PROVIDER || ""
395
410
  );
396
411
  const model = String(state.model || process.env.UFOO_UCODE_MODEL || "").trim();
397
- const timeoutMs = Number.isFinite(state.timeoutMs) ? state.timeoutMs : 600000;
412
+ const timeoutMs = resolveNlTaskTimeoutMs(state.timeoutMs);
398
413
  let streamed = false;
399
414
  let streamLastChar = "";
400
415
  let toolEventsThisAttempt = 0;
@@ -715,6 +730,8 @@ module.exports = {
715
730
  resolvePlannerProvider,
716
731
  extractJsonSummary,
717
732
  enrichNativeError,
733
+ resolveNlTaskTimeoutMs,
734
+ DEFAULT_NL_TASK_TIMEOUT_MS,
718
735
  resolveUcodeProviderModel,
719
736
  buildSessionSnapshotFromState,
720
737
  persistSessionState,
@@ -5,6 +5,7 @@ const {
5
5
  resolveKimiUpstreamCredentials,
6
6
  } = require("../agents/providers/credentials/kimi");
7
7
  const { runToolCall } = require("./dispatch");
8
+ const { appendUsageRecord } = require("./usageStore");
8
9
  const { getReadToolDescription } = require("../agents/prompts/native/toolDescriptions/read");
9
10
  const { getWriteToolDescription } = require("../agents/prompts/native/toolDescriptions/write");
10
11
  const { getEditToolDescription } = require("../agents/prompts/native/toolDescriptions/edit");
@@ -25,6 +26,11 @@ const DEFAULT_MAX_NATIVE_TOOL_ERRORS = 5;
25
26
  // via UFOO_UCODE_MAX_TOKENS (positive integer).
26
27
  const DEFAULT_OPENAI_MAX_TOKENS = 131072;
27
28
  const DEFAULT_ANTHROPIC_MAX_TOKENS = 64000;
29
+ // Prompt caching is GA on the current Messages API: cache_control blocks need
30
+ // no anthropic-beta header. Kept as a constant so the marker shape stays in
31
+ // one place (system block + last history message, 2 of the 4 allowed
32
+ // breakpoints).
33
+ const ANTHROPIC_CACHE_CONTROL = Object.freeze({ type: "ephemeral" });
28
34
 
29
35
  function nowMs() {
30
36
  return Date.now();
@@ -53,6 +59,58 @@ function resolveMaxTokens(fallback) {
53
59
  return normalizePositiveInt(process.env.UFOO_UCODE_MAX_TOKENS, fallback);
54
60
  }
55
61
 
62
+ function toUsageInt(value) {
63
+ const parsed = Number(value);
64
+ if (!Number.isFinite(parsed) || parsed <= 0) return 0;
65
+ return Math.floor(parsed);
66
+ }
67
+
68
+ function createUsageTotals() {
69
+ return {
70
+ turns: 0,
71
+ input: 0,
72
+ output: 0,
73
+ cacheRead: 0,
74
+ cacheCreation: 0,
75
+ };
76
+ }
77
+
78
+ function addUsageTotals(totals, usage = null) {
79
+ if (!usage || typeof usage !== "object") return totals;
80
+ totals.input += toUsageInt(usage.input);
81
+ totals.output += toUsageInt(usage.output);
82
+ totals.cacheRead += toUsageInt(usage.cacheRead);
83
+ totals.cacheCreation += toUsageInt(usage.cacheCreation);
84
+ return totals;
85
+ }
86
+
87
+ // OpenAI-compatible streams end with one usage chunk carrying whole-turn
88
+ // totals (prompt_tokens_details.cached_tokens counts the cache hits).
89
+ function readOpenAiUsage(raw) {
90
+ if (!raw || typeof raw !== "object") return null;
91
+ const details = raw.prompt_tokens_details && typeof raw.prompt_tokens_details === "object"
92
+ ? raw.prompt_tokens_details
93
+ : {};
94
+ return {
95
+ input: toUsageInt(raw.prompt_tokens),
96
+ output: toUsageInt(raw.completion_tokens),
97
+ cacheRead: toUsageInt(details.cached_tokens),
98
+ cacheCreation: 0,
99
+ };
100
+ }
101
+
102
+ // Anthropic reports input/cache tokens once on message_start; output tokens
103
+ // arrive per message_delta. The non-streaming body carries the full totals.
104
+ function readAnthropicUsage(raw, { includeOutput = false } = {}) {
105
+ if (!raw || typeof raw !== "object") return null;
106
+ return {
107
+ input: toUsageInt(raw.input_tokens),
108
+ output: includeOutput ? toUsageInt(raw.output_tokens) : 0,
109
+ cacheRead: toUsageInt(raw.cache_read_input_tokens),
110
+ cacheCreation: toUsageInt(raw.cache_creation_input_tokens),
111
+ };
112
+ }
113
+
56
114
  function enforceNativeToolBudget({
57
115
  toolCallsExecuted = 0,
58
116
  toolErrors = 0,
@@ -576,6 +634,8 @@ async function runOpenAiLikeTurn({
576
634
  tools: buildCoreToolSpecs(),
577
635
  tool_choice: "auto",
578
636
  stream: true,
637
+ // Ask for the terminal usage chunk so token/cache accounting works.
638
+ stream_options: { include_usage: true },
579
639
  // Kimi k3 rejects any temperature other than 1.
580
640
  temperature: normalizeProvider(provider) === "kimi" ? 1 : 0,
581
641
  };
@@ -592,6 +652,7 @@ async function runOpenAiLikeTurn({
592
652
  let responseText = "";
593
653
  let nextSyntheticIndex = 0;
594
654
  let lastSyntheticIndex = -1;
655
+ let streamUsage = null;
595
656
 
596
657
  return runSseRequest({
597
658
  url,
@@ -612,12 +673,18 @@ async function runOpenAiLikeTurn({
612
673
  return {
613
674
  text,
614
675
  toolCalls,
676
+ usage: readOpenAiUsage(data && data.usage),
615
677
  };
616
678
  },
617
679
  onEvent: ({ data }) => {
618
680
  const chunk = parseJsonSafe(data, null);
619
681
  if (!chunk || typeof chunk !== "object") return;
620
682
 
683
+ // The usage chunk carries empty choices, so read it before the
684
+ // choice guard below; latest wins (it reports whole-turn totals).
685
+ const chunkUsage = readOpenAiUsage(chunk.usage);
686
+ if (chunkUsage) streamUsage = chunkUsage;
687
+
621
688
  const choice = chunk.choices && chunk.choices[0] ? chunk.choices[0] : null;
622
689
  if (!choice || typeof choice !== "object") return;
623
690
 
@@ -695,6 +762,8 @@ async function runOpenAiLikeTurn({
695
762
  const fallbackBlock = parseSseDataBlock(rawBuffer);
696
763
  if (fallbackBlock && fallbackBlock !== "[DONE]") {
697
764
  const chunk = parseJsonSafe(fallbackBlock, null);
765
+ const tailUsage = readOpenAiUsage(chunk && chunk.usage);
766
+ if (tailUsage) streamUsage = tailUsage;
698
767
  const choice = chunk && chunk.choices && chunk.choices[0] ? chunk.choices[0] : null;
699
768
  if (choice && choice.delta && typeof choice.delta.content === "string" && choice.delta.content) {
700
769
  responseText += choice.delta.content;
@@ -709,6 +778,7 @@ async function runOpenAiLikeTurn({
709
778
  toolCalls: Array.from(toolCallMap.entries())
710
779
  .sort((a, b) => a[0] - b[0])
711
780
  .map((entry) => entry[1]),
781
+ usage: streamUsage,
712
782
  }),
713
783
  });
714
784
  }
@@ -751,6 +821,48 @@ function extractAnthropicToolCalls(content = []) {
751
821
  }));
752
822
  }
753
823
 
824
+ // Mark the newest message with a cache breakpoint so the append-only history
825
+ // prefix is served from the prompt cache. The payload gets a copy: stamping
826
+ // cache_control onto the shared history array would leave stale breakpoints
827
+ // behind as later turns append, eventually exceeding the 4-breakpoint limit.
828
+ function withAnthropicCacheBreakpoint(messages = []) {
829
+ if (!Array.isArray(messages) || messages.length === 0) return messages;
830
+ const copy = messages.slice();
831
+ const lastIndex = copy.length - 1;
832
+ const last = copy[lastIndex];
833
+ if (!last || typeof last !== "object" || Array.isArray(last)) return copy;
834
+ if (typeof last.content === "string") {
835
+ if (!last.content) return copy;
836
+ copy[lastIndex] = {
837
+ ...last,
838
+ content: [
839
+ {
840
+ type: "text",
841
+ text: last.content,
842
+ cache_control: { ...ANTHROPIC_CACHE_CONTROL },
843
+ },
844
+ ],
845
+ };
846
+ return copy;
847
+ }
848
+ if (Array.isArray(last.content) && last.content.length > 0) {
849
+ const blocks = last.content.slice();
850
+ const blockIndex = blocks.length - 1;
851
+ const block = blocks[blockIndex];
852
+ if (block && typeof block === "object" && !Array.isArray(block)) {
853
+ blocks[blockIndex] = {
854
+ ...block,
855
+ cache_control: { ...ANTHROPIC_CACHE_CONTROL },
856
+ };
857
+ copy[lastIndex] = {
858
+ ...last,
859
+ content: blocks,
860
+ };
861
+ }
862
+ }
863
+ return copy;
864
+ }
865
+
754
866
  async function runAnthropicTurn({
755
867
  url = "",
756
868
  apiKey = "",
@@ -766,13 +878,21 @@ async function runAnthropicTurn({
766
878
  const payload = {
767
879
  model,
768
880
  max_tokens: resolveMaxTokens(DEFAULT_ANTHROPIC_MAX_TOKENS),
769
- messages,
881
+ messages: withAnthropicCacheBreakpoint(messages),
770
882
  tools: buildAnthropicToolSpecs(),
771
883
  stream: true,
772
884
  };
773
885
  const systemText = String(systemPrompt || "").trim();
774
886
  if (systemText) {
775
- payload.system = systemText;
887
+ // Block form with a cache breakpoint; the system prompt is the most
888
+ // stable prefix of every request.
889
+ payload.system = [
890
+ {
891
+ type: "text",
892
+ text: systemText,
893
+ cache_control: { ...ANTHROPIC_CACHE_CONTROL },
894
+ },
895
+ ];
776
896
  }
777
897
 
778
898
  const headers = {
@@ -787,6 +907,7 @@ async function runAnthropicTurn({
787
907
  let responseText = "";
788
908
  let nextSyntheticBlockIndex = 0;
789
909
  let lastBlockIndex = -1;
910
+ const turnUsage = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
790
911
 
791
912
  return runSseRequest({
792
913
  url,
@@ -804,10 +925,12 @@ async function runAnthropicTurn({
804
925
  if (text && typeof onTextDelta === "function") {
805
926
  onTextDelta(text);
806
927
  }
928
+ addUsageTotals(turnUsage, readAnthropicUsage(data && data.usage, { includeOutput: true }));
807
929
  return {
808
930
  text,
809
931
  assistantContent: content,
810
932
  toolCalls: extractAnthropicToolCalls(content),
933
+ usage: turnUsage,
811
934
  };
812
935
  },
813
936
  onEvent: ({ event, data }) => {
@@ -821,6 +944,28 @@ async function runAnthropicTurn({
821
944
  throw new Error(errMsg);
822
945
  }
823
946
 
947
+ if (event === "message_start") {
948
+ const messageUsage = readAnthropicUsage(
949
+ payloadChunk.message && typeof payloadChunk.message === "object"
950
+ ? payloadChunk.message.usage
951
+ : null
952
+ );
953
+ if (messageUsage) {
954
+ turnUsage.input = messageUsage.input;
955
+ turnUsage.cacheRead = messageUsage.cacheRead;
956
+ turnUsage.cacheCreation = messageUsage.cacheCreation;
957
+ }
958
+ return;
959
+ }
960
+
961
+ if (event === "message_delta") {
962
+ const deltaUsage = payloadChunk.usage && typeof payloadChunk.usage === "object"
963
+ ? payloadChunk.usage
964
+ : {};
965
+ turnUsage.output += toUsageInt(deltaUsage.output_tokens);
966
+ return;
967
+ }
968
+
824
969
  if (event === "content_block_start") {
825
970
  let index;
826
971
  if (Number.isFinite(payloadChunk.index)) {
@@ -957,6 +1102,7 @@ async function runAnthropicTurn({
957
1102
  text: responseText,
958
1103
  assistantContent,
959
1104
  toolCalls: extractAnthropicToolCalls(assistantContent),
1105
+ usage: turnUsage,
960
1106
  };
961
1107
  },
962
1108
  });
@@ -1133,6 +1279,7 @@ async function runNativeLoop({
1133
1279
  let toolCallsExecuted = 0;
1134
1280
  let toolErrors = 0;
1135
1281
  const toolBudget = resolveNativeToolBudget();
1282
+ const usage = createUsageTotals();
1136
1283
 
1137
1284
  while (true) {
1138
1285
  guards.ensureActive();
@@ -1159,6 +1306,9 @@ async function runNativeLoop({
1159
1306
  },
1160
1307
  });
1161
1308
 
1309
+ usage.turns += 1;
1310
+ addUsageTotals(usage, turnResult && turnResult.usage);
1311
+
1162
1312
  const toolCalls = transport.getToolCalls(turnResult);
1163
1313
 
1164
1314
  if (toolCalls.length === 0) {
@@ -1172,6 +1322,7 @@ async function runNativeLoop({
1172
1322
  streamed,
1173
1323
  toolCallsExecuted,
1174
1324
  messages,
1325
+ usage,
1175
1326
  };
1176
1327
  }
1177
1328
 
@@ -1182,6 +1333,7 @@ async function runNativeLoop({
1182
1333
  streamed,
1183
1334
  toolCallsExecuted,
1184
1335
  messages,
1336
+ usage,
1185
1337
  };
1186
1338
  }
1187
1339
 
@@ -1312,12 +1464,27 @@ async function runNativeAgentTask({
1312
1464
  : ""
1313
1465
  );
1314
1466
 
1467
+ const usage = runResult.usage && typeof runResult.usage === "object"
1468
+ ? runResult.usage
1469
+ : createUsageTotals();
1470
+ appendUsageRecord(workspaceRoot, {
1471
+ sessionId: nextSessionId,
1472
+ model: runtime.model,
1473
+ provider: runtime.provider,
1474
+ turns: usage.turns,
1475
+ input: usage.input,
1476
+ output: usage.output,
1477
+ cacheRead: usage.cacheRead,
1478
+ cacheCreation: usage.cacheCreation,
1479
+ });
1480
+
1315
1481
  return {
1316
1482
  ok: true,
1317
1483
  error: "",
1318
1484
  output: outputText,
1319
1485
  messages: cloneMessageList(runResult.messages),
1320
1486
  sessionId: nextSessionId,
1487
+ usage,
1321
1488
  // The loop marks streamed=true whenever it receives a stream callback;
1322
1489
  // only report it when the caller actually registered one.
1323
1490
  streamed: Boolean(runResult.streamed) && typeof onStreamDelta === "function",
package/src/code/repl.js CHANGED
@@ -21,6 +21,7 @@ const {
21
21
  getPendingBusCount,
22
22
  shouldAutoConsumeBus,
23
23
  } = require("./busConsumer");
24
+ const { summarizeSessionUsage } = require("./usageStore");
24
25
 
25
26
  function printPrompt(stdout = process.stdout) {
26
27
  stdout.write("> ");
@@ -74,6 +75,20 @@ function extractAgentNickname(agentId = "") {
74
75
  return base;
75
76
  }
76
77
 
78
+ function formatSessionUsageStatus(summary = {}) {
79
+ const source = summary && typeof summary === "object" ? summary : {};
80
+ const input = Number(source.input) || 0;
81
+ const output = Number(source.output) || 0;
82
+ const cacheRead = Number(source.cacheRead) || 0;
83
+ const cacheCreation = Number(source.cacheCreation) || 0;
84
+ const denominator = cacheRead + input;
85
+ const hitRate = denominator > 0 ? (cacheRead / denominator) * 100 : 0;
86
+ return [
87
+ `Session tokens: input=${input} output=${output} cache_read=${cacheRead} cache_creation=${cacheCreation}`,
88
+ `Cache hit rate: ${hitRate.toFixed(1)}% (cache_read/(cache_read+input))`,
89
+ ].join("\n");
90
+ }
91
+
77
92
  function runSingleCommand(line = "", workspaceRoot = process.cwd()) {
78
93
  const text = normalizeLine(line);
79
94
  if (!text) return { kind: "empty" };
@@ -86,6 +101,7 @@ function runSingleCommand(line = "", workspaceRoot = process.cwd()) {
86
101
  " help",
87
102
  " exit|quit",
88
103
  " ubus|/ubus",
104
+ " status|/status",
89
105
  " skills [list]",
90
106
  " skills show <name>",
91
107
  " bg|/bg <task>",
@@ -107,6 +123,11 @@ function runSingleCommand(line = "", workspaceRoot = process.cwd()) {
107
123
  kind: "ubus",
108
124
  };
109
125
  }
126
+ if (text === "status" || text === "/status") {
127
+ return {
128
+ kind: "status",
129
+ };
130
+ }
110
131
  const skillsMatch = text.match(/^(?:\/skills|skills)(?:\s+(.*))?$/i);
111
132
  if (skillsMatch) {
112
133
  const args = String(skillsMatch[1] || "").trim().split(/\s+/).filter(Boolean);
@@ -228,7 +249,7 @@ async function runUcodeCoreAgent({
228
249
  appendSystemPrompt = "",
229
250
  systemPrompt = "",
230
251
  sessionId = "",
231
- timeoutMs = 600000,
252
+ timeoutMs = 0,
232
253
  jsonOutput = false,
233
254
  forceTui = false,
234
255
  disableTui = false,
@@ -240,6 +261,7 @@ async function runUcodeCoreAgent({
240
261
  formatNlResult,
241
262
  persistSessionState,
242
263
  resumeSessionState,
264
+ resolveNlTaskTimeoutMs,
243
265
  resolveUcodeProviderModel,
244
266
  runNaturalLanguageTask,
245
267
  } = require("./agent");
@@ -263,7 +285,7 @@ async function runUcodeCoreAgent({
263
285
  }),
264
286
  nlMessages: [],
265
287
  sessionId: resolveSessionId(String(sessionId || "").trim()),
266
- timeoutMs,
288
+ timeoutMs: resolveNlTaskTimeoutMs(Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : NaN),
267
289
  jsonOutput,
268
290
  };
269
291
  persistSessionState(state);
@@ -410,6 +432,13 @@ async function runUcodeCoreAgent({
410
432
  if (result.kind === "help" || result.kind === "tool" || result.kind === "skills" || result.kind === "error") {
411
433
  stdout.write(`${result.output}\n`);
412
434
  }
435
+ if (result.kind === "status") {
436
+ const usageSummary = summarizeSessionUsage({
437
+ workspaceRoot: runtimeWorkspace,
438
+ sessionId: state.sessionId,
439
+ });
440
+ stdout.write(`${formatSessionUsageStatus(usageSummary)}\n`);
441
+ }
413
442
  if (result.kind === "ubus") {
414
443
  const ubusResult = await runUbusCommand(state, {
415
444
  workspaceRoot: runtimeWorkspace,
@@ -542,7 +571,7 @@ function parseAgentArgs(argv = []) {
542
571
  appendSystemPrompt: "",
543
572
  systemPrompt: "",
544
573
  sessionId: "",
545
- timeoutMs: 600000,
574
+ timeoutMs: 0,
546
575
  jsonOutput: false,
547
576
  forceTui: false,
548
577
  disableTui: false,
@@ -607,4 +636,5 @@ module.exports = {
607
636
  runSingleCommand,
608
637
  extractAgentNickname,
609
638
  parseAgentArgs,
639
+ formatSessionUsageStatus,
610
640
  };
@@ -0,0 +1,101 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ function getUsageFilePath(workspaceRoot = process.cwd()) {
5
+ const root = path.resolve(workspaceRoot || process.cwd());
6
+ return path.join(root, ".ufoo", "agent", "ucode", "usage.jsonl");
7
+ }
8
+
9
+ function toUsageCount(value) {
10
+ const parsed = Number(value);
11
+ if (!Number.isFinite(parsed) || parsed <= 0) return 0;
12
+ return Math.floor(parsed);
13
+ }
14
+
15
+ function buildUsageRecord(input = {}) {
16
+ const source = input && typeof input === "object" ? input : {};
17
+ return {
18
+ ts: String(source.ts || "").trim() || new Date().toISOString(),
19
+ sessionId: String(source.sessionId || "").trim(),
20
+ model: String(source.model || "").trim(),
21
+ provider: String(source.provider || "").trim(),
22
+ turns: toUsageCount(source.turns),
23
+ input: toUsageCount(source.input),
24
+ output: toUsageCount(source.output),
25
+ cacheRead: toUsageCount(source.cacheRead),
26
+ cacheCreation: toUsageCount(source.cacheCreation),
27
+ };
28
+ }
29
+
30
+ function appendUsageRecord(workspaceRoot = process.cwd(), record = {}) {
31
+ const row = buildUsageRecord(record);
32
+ const filePath = getUsageFilePath(workspaceRoot);
33
+ try {
34
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
35
+ fs.appendFileSync(filePath, `${JSON.stringify(row)}\n`, "utf8");
36
+ return {
37
+ ok: true,
38
+ error: "",
39
+ filePath,
40
+ record: row,
41
+ };
42
+ } catch (err) {
43
+ // Usage accounting is observability only: never let a write failure
44
+ // break the agent loop.
45
+ return {
46
+ ok: false,
47
+ error: err && err.message ? err.message : "failed to append usage",
48
+ filePath,
49
+ record: row,
50
+ };
51
+ }
52
+ }
53
+
54
+ function createUsageSummary() {
55
+ return {
56
+ records: 0,
57
+ turns: 0,
58
+ input: 0,
59
+ output: 0,
60
+ cacheRead: 0,
61
+ cacheCreation: 0,
62
+ };
63
+ }
64
+
65
+ function summarizeSessionUsage({ workspaceRoot = process.cwd(), sessionId = "" } = {}) {
66
+ const summary = createUsageSummary();
67
+ const targetSessionId = String(sessionId || "").trim();
68
+ let raw = "";
69
+ try {
70
+ raw = fs.readFileSync(getUsageFilePath(workspaceRoot), "utf8");
71
+ } catch {
72
+ return summary;
73
+ }
74
+ for (const line of String(raw).split(/\r?\n/)) {
75
+ const text = line.trim();
76
+ if (!text) continue;
77
+ let row = null;
78
+ try {
79
+ row = JSON.parse(text);
80
+ } catch {
81
+ continue;
82
+ }
83
+ if (!row || typeof row !== "object" || Array.isArray(row)) continue;
84
+ if (targetSessionId && String(row.sessionId || "").trim() !== targetSessionId) continue;
85
+ summary.records += 1;
86
+ summary.turns += toUsageCount(row.turns);
87
+ summary.input += toUsageCount(row.input);
88
+ summary.output += toUsageCount(row.output);
89
+ summary.cacheRead += toUsageCount(row.cacheRead);
90
+ summary.cacheCreation += toUsageCount(row.cacheCreation);
91
+ }
92
+ return summary;
93
+ }
94
+
95
+ module.exports = {
96
+ getUsageFilePath,
97
+ buildUsageRecord,
98
+ appendUsageRecord,
99
+ createUsageSummary,
100
+ summarizeSessionUsage,
101
+ };
@@ -74,6 +74,7 @@ function formatLoopSummary(loopSummary) {
74
74
  const rounds = Number(loopSummary.rounds) || 0;
75
75
  const toolCalls = Number(loopSummary.tool_calls) || 0;
76
76
  const totalTokens = Number(loopSummary.total_tokens) || 0;
77
+ const inputTokens = Number(loopSummary.input_tokens) || 0;
77
78
  const cacheReadTokens = Number(loopSummary.cache_read_tokens) || 0;
78
79
  const cacheCreationTokens = Number(loopSummary.cache_creation_tokens) || 0;
79
80
  const terminalReason = String(loopSummary.terminal_reason || "").trim();
@@ -81,7 +82,12 @@ function formatLoopSummary(loopSummary) {
81
82
  if (rounds <= 0 && toolCalls <= 0 && totalTokens <= 0 && !terminalReason && !toolDistribution) return "";
82
83
  const parts = [`r${rounds}`, `tc${toolCalls}`, `tok${totalTokens}`];
83
84
  if (cacheReadTokens > 0 || cacheCreationTokens > 0) {
84
- parts.push(`cache${cacheReadTokens}/${cacheCreationTokens}`);
85
+ let cachePart = `cache${cacheReadTokens}/${cacheCreationTokens}`;
86
+ if (cacheReadTokens > 0) {
87
+ const hitRate = Math.round((cacheReadTokens / (cacheReadTokens + inputTokens)) * 100);
88
+ cachePart += `(${hitRate}%)`;
89
+ }
90
+ parts.push(cachePart);
85
91
  }
86
92
  if (toolDistribution) parts.push(toolDistribution);
87
93
  if (terminalReason) parts.push(terminalReason);