u-foo 3.0.6 → 3.0.8

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.
@@ -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
  };
@@ -54,6 +54,7 @@ function buildImmutablePrefix() {
54
54
  "- Turning Plan Mode off does not cancel an existing graph or running TaskRuns. Cancel with task_run (standalone) or plan_graph operation=cancel_graph / control.cancel_task (graph-bound).",
55
55
  "- When the user enables Plan Mode and no active graph exists, create a plan_graph before performing side effects.",
56
56
  "- After an accepted plan_graph create or patch, Runtime automatically advances ready tool nodes. Never invent or request an execute_graph tool.",
57
+ "- When the last plan node finishes (graph_terminal), Runtime archives and clears the active plan, exits auto Plan Mode, and returns a completion summary — narrate that summary to the user. Start a new objective with plan_graph create (create replaces any leftover graph); do not patch a finished plan for a new goal.",
57
58
  "- Do not call plan_graph or task_run together with read, read_image, write, edit, bash, or artifact_read in the same assistant turn.",
58
59
  "- When an active graph is waiting on a task, advance that node through plan_graph instead of bypassing it with direct workspace tools: use patch.expand_node for execution.kind=expand, control.complete_task (nodeId) for execution.kind=inline_llm, or control.start_task for execution.kind=task_loop.",
59
60
  "- TaskLoop start returns childGraphId. While that TaskRun is waiting_model on child root, patch with graphId=<childGraphId> and expand_node nodeId=root (add tool children). Do not ask the user to /plan off.",
@@ -131,7 +131,8 @@ function shouldAutoContinuePlan(executionState = null) {
131
131
 
132
132
  /**
133
133
  * Internal reminder injected by runtime when the model ends a turn while the
134
- * plan is still waiting on a task. Same shape as user nudges.
134
+ * plan is still waiting on a task. Must NOT reuse the User reminder label —
135
+ * that is reserved for real mid-run user text and pollutes transcript/TUI.
135
136
  */
136
137
  function buildPlanAutoContinueReminder(executionState = null) {
137
138
  const waiting = executionState
@@ -140,14 +141,17 @@ function buildPlanAutoContinueReminder(executionState = null) {
140
141
  ? executionState.planGraph.waitingFor
141
142
  : null;
142
143
  if (!waiting || !waiting.id) return "";
143
- return formatUserReminderMessage(
144
- [
145
- "Continue the active plan. Serve the waiting task now via plan_graph "
146
- + "(expand_node, control.start_task, or control.complete_task as appropriate). "
147
- + "Do not end the turn with text only while this node is waiting.",
148
- ],
149
- { waitingFor: waiting },
150
- );
144
+ const label = waiting.title || waiting.objective || waiting.reason || waiting.id;
145
+ const lines = [
146
+ "Runtime wake (not a user message): active plan is still waiting.",
147
+ "Continue serving it now via plan_graph "
148
+ + "(expand_node, control.start_task, or control.complete_task as appropriate). "
149
+ + "Do not end the turn with text only while this node is waiting.",
150
+ `Prefer serving the current waiting ${waiting.type || "node"}: ${waiting.id}`
151
+ + (label && label !== waiting.id ? ` — ${label}` : "")
152
+ + ".",
153
+ ];
154
+ return lines.join("\n");
151
155
  }
152
156
 
153
157
  module.exports = {
@@ -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
+ };