u-foo 3.0.6 → 3.0.7

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": "3.0.6",
3
+ "version": "3.0.7",
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",
package/src/code/agent.js CHANGED
@@ -610,6 +610,21 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
610
610
  : runNativeAgentTask;
611
611
  const onPhase = typeof options.onPhase === "function" ? options.onPhase : null;
612
612
  const onThinkingDelta = typeof options.onThinkingDelta === "function" ? options.onThinkingDelta : null;
613
+ const onContextUsage = typeof options.onContextUsage === "function" ? options.onContextUsage : null;
614
+ const applyContextMeter = (meter = null) => {
615
+ if (!meter || typeof meter !== "object") return null;
616
+ state.contextMeter = {
617
+ usedTokens: Number(meter.usedTokens) || 0,
618
+ limitTokens: Number(meter.limitTokens) || 0,
619
+ model: String(meter.model || state.model || "").trim(),
620
+ label: String(meter.label || "").trim(),
621
+ updatedAt: String(meter.updatedAt || "").trim() || new Date().toISOString(),
622
+ };
623
+ if (typeof onContextUsage === "function") {
624
+ try { onContextUsage(state.contextMeter); } catch { /* ignore */ }
625
+ }
626
+ return state.contextMeter;
627
+ };
613
628
  let lastTranscriptBaseline = 0;
614
629
  const invokeNative = (sessionIdValue = "", timeoutOverrideMs = timeoutMs) => {
615
630
  toolEventsThisAttempt = 0;
@@ -630,6 +645,7 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
630
645
  onStreamDelta: onStream,
631
646
  onThinkingDelta,
632
647
  onPhase,
648
+ onContextUsage: applyContextMeter,
633
649
  executionState: state.executionState || null,
634
650
  onArtifactPersisted: (persisted) => recordToolCallInSession(state, persisted, workspaceRoot),
635
651
  onToolEvent: (event) => {
@@ -779,6 +795,9 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
779
795
  const artifactIds = Array.isArray(state.workingSet)
780
796
  ? state.workingSet.map((entry) => entry.artifactId).filter(Boolean)
781
797
  : [];
798
+ if (cliRes && cliRes.contextMeter) {
799
+ applyContextMeter(cliRes.contextMeter);
800
+ }
782
801
  if (cliRes && cliRes.waitingUserInteraction) {
783
802
  return {
784
803
  ok: true,
@@ -791,6 +810,8 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
791
810
  streamLastChar,
792
811
  waitingUserInteraction: true,
793
812
  interactionId: cliRes.interactionId || "",
813
+ contextMeter: state.contextMeter || null,
814
+ usage: cliRes.usage || null,
794
815
  };
795
816
  }
796
817
  return {
@@ -802,6 +823,8 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
802
823
  metrics: {},
803
824
  streamed: Boolean(streamed || cliRes.streamed),
804
825
  streamLastChar,
826
+ contextMeter: state.contextMeter || null,
827
+ usage: cliRes.usage || null,
805
828
  };
806
829
  } catch (err) {
807
830
  return {
@@ -925,6 +948,9 @@ function buildSessionSnapshotFromState(state = {}) {
925
948
  ? Math.max(0, Math.floor(source.toolCallsSinceCommit))
926
949
  : 0,
927
950
  activeSkills: Array.isArray(source.activeSkills) ? source.activeSkills : [],
951
+ contextMeter: source.contextMeter && typeof source.contextMeter === "object"
952
+ ? source.contextMeter
953
+ : null,
928
954
  };
929
955
  }
930
956
 
@@ -999,6 +1025,9 @@ function resumeSessionState(state = {}, sessionId = "", workspaceRoot = process.
999
1025
  ? snapshot.toolCallsSinceCommit
1000
1026
  : 0;
1001
1027
  state.activeSkills = Array.isArray(snapshot.activeSkills) ? snapshot.activeSkills : [];
1028
+ state.contextMeter = snapshot.contextMeter && typeof snapshot.contextMeter === "object"
1029
+ ? snapshot.contextMeter
1030
+ : null;
1002
1031
  ensureContextSessionState(state);
1003
1032
  const { ensureTranscript } = require("./context/assembler");
1004
1033
  ensureTranscript(state, state.workspaceRoot);
@@ -1087,6 +1116,13 @@ async function resumeAfterUserInteraction(answerText = "", state = {}, options =
1087
1116
  onStreamDelta: trackingOnDelta,
1088
1117
  onThinkingDelta: typeof options.onThinkingDelta === "function" ? options.onThinkingDelta : null,
1089
1118
  onPhase: typeof options.onPhase === "function" ? options.onPhase : null,
1119
+ onContextUsage: (meter) => {
1120
+ if (!meter || typeof meter !== "object") return;
1121
+ state.contextMeter = meter;
1122
+ if (typeof options.onContextUsage === "function") {
1123
+ try { options.onContextUsage(meter); } catch { /* ignore */ }
1124
+ }
1125
+ },
1090
1126
  executionState: state.executionState,
1091
1127
  signal: options.signal,
1092
1128
  resume: true,
@@ -1098,6 +1134,9 @@ async function resumeAfterUserInteraction(answerText = "", state = {}, options =
1098
1134
  if (cliRes && Array.isArray(cliRes.messages)) {
1099
1135
  state.nlMessages = stripSkillBlocksFromMessages(cliRes.messages);
1100
1136
  }
1137
+ if (cliRes && cliRes.contextMeter) {
1138
+ state.contextMeter = cliRes.contextMeter;
1139
+ }
1101
1140
 
1102
1141
  if (!cliRes || cliRes.ok === false) {
1103
1142
  return {
@@ -1107,6 +1146,7 @@ async function resumeAfterUserInteraction(answerText = "", state = {}, options =
1107
1146
  waitingUserInteraction: false,
1108
1147
  streamed: false,
1109
1148
  streamLastChar: "",
1149
+ contextMeter: state.contextMeter || null,
1110
1150
  };
1111
1151
  }
1112
1152
 
@@ -1119,6 +1159,7 @@ async function resumeAfterUserInteraction(answerText = "", state = {}, options =
1119
1159
  interactionId: cliRes.interactionId || "",
1120
1160
  streamed: Boolean(cliRes.streamed),
1121
1161
  streamLastChar,
1162
+ contextMeter: state.contextMeter || null,
1122
1163
  };
1123
1164
  }
1124
1165
 
@@ -1129,6 +1170,7 @@ async function resumeAfterUserInteraction(answerText = "", state = {}, options =
1129
1170
  waitingUserInteraction: false,
1130
1171
  streamed: Boolean(cliRes.streamed),
1131
1172
  streamLastChar,
1173
+ contextMeter: state.contextMeter || null,
1132
1174
  };
1133
1175
  }
1134
1176
 
@@ -287,78 +287,94 @@ function buildPlanDag(planGraph = {}) {
287
287
  };
288
288
  }
289
289
 
290
- function waveStepLabel(waveIndex = 0, nodeIndex = 0, waveSize = 1) {
291
- const step = Math.max(1, Math.floor(Number(waveIndex) || 0) + 1);
292
- if (waveSize <= 1) return String(step);
293
- const letter = String.fromCharCode(97 + Math.max(0, Math.min(25, Math.floor(Number(nodeIndex) || 0))));
294
- return `${step}${letter}`;
290
+ function countDagProgress(dag = {}) {
291
+ const nodes = Array.isArray(dag.nodes) ? dag.nodes : [];
292
+ const total = nodes.length;
293
+ const done = nodes.filter((node) => node && node.kind === "done").length;
294
+ return { done, total };
295
295
  }
296
296
 
297
- function formatParallelWaveLines(wave = [], waveIndex = 0, titleMax = 40) {
298
- const lines = [];
299
- const size = wave.length;
300
- wave.forEach((node, nodeIndex) => {
301
- const label = waveStepLabel(waveIndex, nodeIndex, size);
302
- const body = `${label} ${node.mark} ${truncate(node.title, titleMax)}`;
303
- if (nodeIndex === 0) {
304
- lines.push(` ┌─ ${body}`);
305
- if (size > 1) lines.push("──┤");
306
- return;
307
- }
308
- if (nodeIndex === size - 1) {
309
- lines.push(` └─ ${body}`);
310
- return;
297
+ function titleMaxForCols(cols = 80, reserved = 12) {
298
+ return Math.max(12, Math.min(48, Math.floor(Number(cols) || 80) - reserved));
299
+ }
300
+
301
+ function pickFocusActiveNodes(dag = {}) {
302
+ const nodes = Array.isArray(dag.nodes) ? dag.nodes : [];
303
+ const active = nodes.filter((node) => node && node.kind === "active");
304
+ if (active.length > 0) return active;
305
+
306
+ const waves = Array.isArray(dag.waves) ? dag.waves : [];
307
+ for (const wave of waves) {
308
+ const incomplete = (Array.isArray(wave) ? wave : []).filter((node) => (
309
+ node
310
+ && node.kind !== "done"
311
+ && node.kind !== "cancelled"
312
+ ));
313
+ if (incomplete.length === 0) continue;
314
+ const ready = incomplete.filter((node) => String(node.status || "").toLowerCase() === "ready");
315
+ return ready.length > 0 ? ready : incomplete.slice(0, 1);
316
+ }
317
+ return [];
318
+ }
319
+
320
+ function pickUpcomingNodes(dag = {}, activeIds = new Set(), limit = 2) {
321
+ const upcoming = [];
322
+ const waves = Array.isArray(dag.waves) ? dag.waves : [];
323
+ for (const wave of waves) {
324
+ for (const node of (Array.isArray(wave) ? wave : [])) {
325
+ if (!node || activeIds.has(node.id)) continue;
326
+ if (node.kind === "done" || node.kind === "cancelled") continue;
327
+ upcoming.push(node);
328
+ if (upcoming.length >= limit) return upcoming;
311
329
  }
312
- lines.push(` ├─ ${body}`);
313
- });
314
- return lines;
330
+ if (upcoming.length >= limit) break;
331
+ }
332
+ return upcoming;
333
+ }
334
+
335
+ function clipRoadmapLines(lines = [], maxRows = 10) {
336
+ const list = Array.isArray(lines) ? lines : [];
337
+ const limit = Number.isFinite(maxRows) && maxRows > 0 ? Math.floor(maxRows) : 10;
338
+ if (list.length <= limit) return list.slice();
339
+ const clipped = list.slice(0, Math.max(1, limit - 1));
340
+ clipped.push(`… +${list.length - clipped.length} more`);
341
+ return clipped;
315
342
  }
316
343
 
317
344
  /**
318
- * Build markdown (linear list) or ASCII flowchart (parallel waves) from planGraph JSON.
345
+ * Default auto band: progress + current task(s) + next titles.
346
+ * No ASCII tree, no 4a/4b labels.
319
347
  */
320
- function buildRoadmapMarkdown(planGraph = {}, {
348
+ function buildFocusRoadmap(planGraph = {}, {
321
349
  cols = 80,
322
350
  taskRunLine = "",
323
- maxRows = 10,
351
+ maxRows = 4,
324
352
  } = {}) {
325
353
  const dag = buildPlanDag(planGraph);
326
354
  if (dag.nodes.length === 0) {
327
355
  return { markdown: "", lines: [], dag };
328
356
  }
329
357
 
330
- const titleMax = Math.max(12, Math.min(48, Math.floor(Number(cols) || 80) - 12));
331
- const objective = truncate(String(planGraph.objective || "").trim(), titleMax);
332
- const lines = [objective ? `**Plan** · ${objective}` : "**Plan**"];
358
+ const titleMax = titleMaxForCols(cols, 8);
359
+ const { done, total } = countDagProgress(dag);
360
+ const lines = [`**Plan** · ${done}/${total}`];
333
361
 
334
- if (dag.linear) {
335
- dag.waves.forEach((wave, waveIndex) => {
336
- const node = wave[0];
337
- lines.push(`${waveIndex + 1}. ${node.mark} ${truncate(node.title, titleMax)}`);
338
- });
339
- } else {
340
- dag.waves.forEach((wave, waveIndex) => {
341
- if (wave.length === 1) {
342
- const node = wave[0];
343
- lines.push(`${waveStepLabel(waveIndex, 0, 1)} ${node.mark} ${truncate(node.title, titleMax)}`);
344
- return;
345
- }
346
- for (const line of formatParallelWaveLines(wave, waveIndex, Math.max(8, titleMax - 4))) {
347
- lines.push(line);
348
- }
349
- });
362
+ const active = pickFocusActiveNodes(dag);
363
+ const activeIds = new Set(active.map((node) => node.id));
364
+ for (const node of active) {
365
+ lines.push(`${node.mark} ${truncate(node.title, titleMax)}`);
350
366
  }
351
367
 
352
- const extra = String(taskRunLine || "").trim();
353
- if (extra) lines.push(extra);
354
-
355
- const limit = Number.isFinite(maxRows) && maxRows > 0 ? Math.floor(maxRows) : 10;
356
- let clipped = lines.slice(0, Math.max(1, limit));
357
- if (lines.length > clipped.length) {
358
- clipped = clipped.slice(0, Math.max(1, limit - 1));
359
- clipped.push(`… +${lines.length - clipped.length} more`);
368
+ const upcoming = pickUpcomingNodes(dag, activeIds, 2);
369
+ if (upcoming.length > 0) {
370
+ const titles = upcoming.map((node) => truncate(node.title, Math.max(8, Math.floor(titleMax / upcoming.length))));
371
+ lines.push(`接下来 · ${titles.join(" · ")}`);
360
372
  }
361
373
 
374
+ const extra = String(taskRunLine || "").trim();
375
+ if (extra) lines.push(truncate(extra, Math.max(24, titleMax + 8)));
376
+
377
+ const clipped = clipRoadmapLines(lines, maxRows);
362
378
  return {
363
379
  markdown: clipped.join("\n"),
364
380
  lines: clipped,
@@ -366,6 +382,89 @@ function buildRoadmapMarkdown(planGraph = {}, {
366
382
  };
367
383
  }
368
384
 
385
+ /**
386
+ * Expanded (/plan focus): flat numbered list; parallel waves share a step number.
387
+ */
388
+ function buildExpandedRoadmap(planGraph = {}, {
389
+ cols = 80,
390
+ taskRunLine = "",
391
+ maxRows = 16,
392
+ } = {}) {
393
+ const dag = buildPlanDag(planGraph);
394
+ if (dag.nodes.length === 0) {
395
+ return { markdown: "", lines: [], dag };
396
+ }
397
+
398
+ const titleMax = titleMaxForCols(cols, 14);
399
+ const { done, total } = countDagProgress(dag);
400
+ const objective = truncate(String(planGraph.objective || "").trim(), Math.max(12, titleMax - 8));
401
+ const header = objective
402
+ ? `**Plan** · ${done}/${total} · ${objective}`
403
+ : `**Plan** · ${done}/${total}`;
404
+ const body = [];
405
+
406
+ dag.waves.forEach((wave, waveIndex) => {
407
+ const step = waveIndex + 1;
408
+ for (const node of wave) {
409
+ body.push(`${node.mark} ${step} ${truncate(node.title, titleMax)}`);
410
+ }
411
+ });
412
+
413
+ const extra = String(taskRunLine || "").trim();
414
+ if (extra) body.push(truncate(extra, Math.max(24, titleMax + 8)));
415
+
416
+ const budget = Math.max(1, (Number.isFinite(maxRows) ? Math.floor(maxRows) : 16) - 1);
417
+ let clippedBody = body;
418
+ if (body.length > budget) {
419
+ let windowStart = 0;
420
+ while (
421
+ windowStart < body.length
422
+ && (body[windowStart].startsWith("✓") || body[windowStart].startsWith("⊘"))
423
+ ) {
424
+ windowStart += 1;
425
+ }
426
+ // Keep one completed row before the live window for context.
427
+ windowStart = Math.max(0, windowStart - 1);
428
+ const window = body.slice(windowStart);
429
+ if (window.length <= budget) {
430
+ clippedBody = windowStart > 0
431
+ ? [`… +${windowStart} more`, ...window]
432
+ : window;
433
+ } else {
434
+ const kept = window.slice(0, Math.max(1, budget - 1));
435
+ const omittedAfter = body.length - (windowStart + kept.length);
436
+ clippedBody = windowStart > 0
437
+ ? [`… +${windowStart} more`, ...kept.slice(0, Math.max(1, budget - 2)), `… +${omittedAfter} more`]
438
+ : [...kept, `… +${omittedAfter} more`];
439
+ // If double ellipsis blew the budget, fall back to simple clip.
440
+ if (clippedBody.length > budget) {
441
+ clippedBody = clipRoadmapLines(body, budget);
442
+ }
443
+ }
444
+ }
445
+
446
+ const lines = [header, ...clippedBody];
447
+ return {
448
+ markdown: lines.join("\n"),
449
+ lines,
450
+ dag,
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Build roadmap markdown from planGraph JSON.
456
+ * variant=focus (default auto band) or expanded (/plan focus).
457
+ */
458
+ function buildRoadmapMarkdown(planGraph = {}, options = {}) {
459
+ const variant = String(options.variant || "focus").trim().toLowerCase() === "expanded"
460
+ ? "expanded"
461
+ : "focus";
462
+ if (variant === "expanded") {
463
+ return buildExpandedRoadmap(planGraph, options);
464
+ }
465
+ return buildFocusRoadmap(planGraph, options);
466
+ }
467
+
369
468
  function buildDebugLines(executionState = null, planGraph = {}) {
370
469
  const lines = [];
371
470
  const pg = planGraph && typeof planGraph === "object" ? planGraph : {};
@@ -491,27 +590,27 @@ function buildPlanUiProjection(executionState = null, options = {}) {
491
590
  )];
492
591
  roadmapMarkdown = "";
493
592
  } else {
494
- // auto + expanded: JSON DAG roadmap markdown
593
+ // auto progress-focus; expanded → flat numbered list (no ASCII tree)
594
+ const variant = bandMode === "expanded" ? "expanded" : "focus";
495
595
  const maxRows = Number.isFinite(options.maxBandRows)
496
596
  ? options.maxBandRows
497
- : (bandMode === "expanded" ? 16 : 10);
597
+ : (variant === "expanded" ? 16 : 4);
498
598
  const roadmap = buildRoadmapMarkdown(pg, {
499
599
  cols,
500
600
  taskRunLine: taskRunSuffix,
501
601
  maxRows,
602
+ variant,
502
603
  });
503
604
  planDag = roadmap.dag;
504
605
  roadmapMarkdown = roadmap.markdown;
505
606
  bandLines = roadmap.lines.slice();
506
- if (bandMode === "expanded" && tree.length > 0) {
507
- // Keep tree as fallback detail only when roadmap empty (shouldn't happen).
508
- if (bandLines.length === 0) {
509
- const title = pg.objective ? `Plan · ${pg.objective}` : "Plan";
510
- bandLines = [truncate(title, Math.max(24, cols - 2))];
511
- for (const row of tree) {
512
- bandLines.push(truncate(formatTreeLine(row), Math.max(24, cols - 2)));
513
- }
607
+ if (bandLines.length === 0 && tree.length > 0) {
608
+ const title = pg.objective ? `Plan · ${pg.objective}` : "Plan";
609
+ bandLines = [truncate(title, Math.max(24, cols - 2))];
610
+ for (const row of tree) {
611
+ bandLines.push(truncate(formatTreeLine(row), Math.max(24, cols - 2)));
514
612
  }
613
+ roadmapMarkdown = "";
515
614
  }
516
615
  }
517
616
  }
@@ -585,5 +684,7 @@ module.exports = {
585
684
  statusToMark,
586
685
  buildPlanDag,
587
686
  buildRoadmapMarkdown,
687
+ buildFocusRoadmap,
688
+ buildExpandedRoadmap,
588
689
  buildPlanUiProjection,
589
690
  };
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Context-window helpers for the ucode TUI meter (used / limit in K).
5
+ *
6
+ * usedTokens comes from the latest model request's prompt occupancy.
7
+ * limitTokens is resolved from the model id (provider catalogs rarely
8
+ * expose a reliable context_window field).
9
+ */
10
+
11
+ function toTokenCount(value) {
12
+ const parsed = Number(value);
13
+ if (!Number.isFinite(parsed) || parsed <= 0) return 0;
14
+ return Math.floor(parsed);
15
+ }
16
+
17
+ /**
18
+ * Prompt-side tokens currently occupying the context window.
19
+ * Anthropic splits input / cache_read / cache_creation; OpenAI folds
20
+ * cache hits into prompt_tokens (cached_tokens is a subset).
21
+ */
22
+ function contextTokensFromUsage(usage = null) {
23
+ if (!usage || typeof usage !== "object") return 0;
24
+ const input = toTokenCount(usage.input);
25
+ const cacheRead = toTokenCount(usage.cacheRead);
26
+ const cacheCreation = toTokenCount(usage.cacheCreation);
27
+ if (cacheCreation > 0) return input + cacheRead + cacheCreation;
28
+ // Anthropic exclusive split: input can be smaller than cache_read alone.
29
+ if (cacheRead > 0 && input < cacheRead) return input + cacheRead + cacheCreation;
30
+ // OpenAI-compatible: prompt_tokens already includes cached tokens.
31
+ return input;
32
+ }
33
+
34
+ function resolveModelContextLimit(model = "", options = {}) {
35
+ const override = toTokenCount(options.limit || options.contextLimit);
36
+ if (override > 0) return override;
37
+
38
+ const id = String(model || "").trim().toLowerCase();
39
+ if (!id) return 200000;
40
+
41
+ if (/\b1m\b|1000000|million|1\.0m/.test(id)) return 1000000;
42
+ if (/256k/.test(id)) return 256000;
43
+ if (/128k/.test(id)) return 128000;
44
+ if (/64k/.test(id)) return 64000;
45
+ if (/32k/.test(id)) return 32000;
46
+
47
+ if (/claude|anthropic|opus|sonnet|haiku/.test(id)) return 200000;
48
+ if (/gemini|gemma/.test(id)) return 1000000;
49
+ if (/kimi|moonshot|k2\.|k2-|k3/.test(id)) return 256000;
50
+ if (/gpt-5|o3|o4|codex/.test(id)) return 200000;
51
+ if (/gpt-4\.1|gpt-4o|gpt-4-turbo|o1/.test(id)) return 128000;
52
+ if (/gpt-4|gpt-3\.5/.test(id)) return 128000;
53
+
54
+ return 200000;
55
+ }
56
+
57
+ function formatTokensK(tokens = 0) {
58
+ const n = Math.max(0, Math.floor(Number(tokens) || 0));
59
+ if (n < 1000) return String(n);
60
+ const k = n / 1000;
61
+ if (k >= 100) return `${Math.round(k)}K`;
62
+ const tenths = Math.round(k * 10) / 10;
63
+ if (Number.isInteger(tenths)) return `${tenths}K`;
64
+ return `${tenths.toFixed(1)}K`;
65
+ }
66
+
67
+ function formatContextMeter({ usedTokens = 0, limitTokens = 0 } = {}) {
68
+ const used = Math.max(0, Math.floor(Number(usedTokens) || 0));
69
+ const limit = Math.max(0, Math.floor(Number(limitTokens) || 0));
70
+ if (limit > 0) return `${formatTokensK(used)} / ${formatTokensK(limit)}`;
71
+ return formatTokensK(used);
72
+ }
73
+
74
+ function buildContextMeter({
75
+ usage = null,
76
+ usedTokens = null,
77
+ model = "",
78
+ limitTokens = null,
79
+ } = {}) {
80
+ const used = usedTokens != null
81
+ ? toTokenCount(usedTokens)
82
+ : contextTokensFromUsage(usage);
83
+ const limit = resolveModelContextLimit(model, { limit: limitTokens });
84
+ return {
85
+ usedTokens: used,
86
+ limitTokens: limit,
87
+ model: String(model || "").trim(),
88
+ label: formatContextMeter({ usedTokens: used, limitTokens: limit }),
89
+ updatedAt: new Date().toISOString(),
90
+ };
91
+ }
92
+
93
+ function normalizeContextMeter(value = null, model = "") {
94
+ const source = value && typeof value === "object" ? value : {};
95
+ const used = toTokenCount(source.usedTokens);
96
+ const limit = resolveModelContextLimit(
97
+ String(source.model || model || "").trim(),
98
+ { limit: source.limitTokens },
99
+ );
100
+ return {
101
+ usedTokens: used,
102
+ limitTokens: limit,
103
+ model: String(source.model || model || "").trim(),
104
+ label: formatContextMeter({ usedTokens: used, limitTokens: limit }),
105
+ updatedAt: String(source.updatedAt || "").trim(),
106
+ };
107
+ }
108
+
109
+ module.exports = {
110
+ toTokenCount,
111
+ contextTokensFromUsage,
112
+ resolveModelContextLimit,
113
+ formatTokensK,
114
+ formatContextMeter,
115
+ buildContextMeter,
116
+ normalizeContextMeter,
117
+ };