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 +1 -1
- package/src/code/agent.js +42 -0
- package/src/code/context/planProjection.js +163 -62
- package/src/code/contextWindow.js +117 -0
- package/src/code/nativeRunner.js +116 -29
- package/src/code/runtime/index.js +1 -0
- package/src/code/runtime/taskFocusContext.js +190 -0
- package/src/code/sessionStore.js +9 -0
- package/src/ui/format/index.js +8 -14
- package/src/ui/ink/ChatApp.js +20 -8
- package/src/ui/ink/UcodeApp.js +75 -4
- package/src/ui/ink/chatLogModel.js +4 -2
package/package.json
CHANGED
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
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
return
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
313
|
-
}
|
|
314
|
-
return
|
|
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
|
-
*
|
|
345
|
+
* Default auto band: progress + current task(s) + next titles.
|
|
346
|
+
* No ASCII tree, no 4a/4b labels.
|
|
319
347
|
*/
|
|
320
|
-
function
|
|
348
|
+
function buildFocusRoadmap(planGraph = {}, {
|
|
321
349
|
cols = 80,
|
|
322
350
|
taskRunLine = "",
|
|
323
|
-
maxRows =
|
|
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 =
|
|
331
|
-
const
|
|
332
|
-
const lines = [
|
|
358
|
+
const titleMax = titleMaxForCols(cols, 8);
|
|
359
|
+
const { done, total } = countDagProgress(dag);
|
|
360
|
+
const lines = [`**Plan** · ${done}/${total}`];
|
|
333
361
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
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
|
-
: (
|
|
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 (
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
bandLines
|
|
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
|
+
};
|