@lumoai/cli 1.32.0 → 1.33.0

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.
@@ -49,7 +49,7 @@ The command catalog below is a **map**: it lists every command grouped by domain
49
49
  - `lumo task slack show <id> <contextId>` — full stored Slack thread
50
50
  - `lumo task web show <id> <linkId>` — fetched web link body
51
51
  - `lumo task figma context <id> <linkId>` — Figma link metadata (v1)
52
- - `lumo task comments list <id>` — full comment thread (read-only; ≠ `task comment`)
52
+ - `lumo task comments list <id>` — comment thread, capped to the output budget (`--full` prints every comment; read-only; ≠ `task comment`)
53
53
  - `lumo task pr show <id> <number>` — synced PR metadata (v1)
54
54
  - `lumo task lineage <id>` — show the causal trail: fragments that fed the task + each one's outcome + the run's token/loop cost (read-only audit view); `lumo task lineage <id> --signal` also appends workspace-level usage signal-health (used distribution, per-session variance, used-vs-base merge rate)
55
55
 
@@ -139,6 +139,8 @@ lumo doc show cmd_xxx --section "D 状态表" > sec.md # one section only (revi
139
139
 
140
140
  Note: the markdown rendered by **default-mode** `doc show` is still best-effort (tables flatten). Round-trip via `doc show > tmp.md && doc update --file tmp.md` is NOT a no-op — use `--raw` as the edit base instead.
141
141
 
142
+ Output budget (LUM-428): **default-mode** `doc show` caps the rendered body to the output-token budget (25,000 tokens) and, when truncated, ends in a pointer to `--section "<heading>"` (just-in-time slice) / `--raw` (full source). `--raw` and `--section` are **never** capped — they are byte-faithful edit bases.
143
+
142
144
  **`--section <heading>` (LUM-409)** prints just one heading-addressed section of the markdown source — a byte-faithful slice from the heading line through (not including) the next same-or-higher-level heading, subsections included. No header on stdout (the slice is a legal `doc patch` base); the current revision is printed to **stderr** as `Revision: N`. Mutually exclusive with `--raw`.
143
145
 
144
146
  - Section addressing: pass the heading text (`--section "D 状态表"`), case-insensitive fallback after an exact pass. Prefix with `#…` to pin the level when the same text exists at several depths (`--section "## Status"`).
@@ -48,6 +48,12 @@ or the PR detail — run the matching command below. Pass the same identifier
48
48
  (`LUM-N`) plus the id the card shows for that source (a Slack `contextId`, a web
49
49
  `linkId`, a Figma `linkId`, or a PR `number`).
50
50
 
51
+ **Output budget (LUM-428):** the whole `task context` handoff is capped to the
52
+ output-token budget (25,000 tokens). If a memory-rich task with a long thread
53
+ overflows, the output is truncated and ends in a pointer to the precise
54
+ sub-commands (`lumo task status` / `task comments list --full` / `task lineage`
55
+ / `doc show`) to pull any dropped section just-in-time.
56
+
51
57
  All five are **read-only** (no live Slack/GitHub/Figma calls except the web body
52
58
  fetch). Web/Figma/PR are v1 metadata-degraded: they print a `note:` explaining
53
59
  that live content needs an external integration.
@@ -83,15 +89,24 @@ server, so the command ends with a `note:` saying so.
83
89
  lumo task figma context LUM-42 cfl_abc123
84
90
  ```
85
91
 
86
- ### `lumo task comments list <identifier>` — full comment thread
92
+ ### `lumo task comments list <identifier>` — comment thread
93
+
94
+ Prints the comment thread: each comment as `author · createdAt` followed by its
95
+ plain-text body (comment bodies are stored as HTML and stripped to text).
96
+ Replies are indented two spaces under their parent. Author falls back to
97
+ `unknown`. No comments prints `(no comments)`.
87
98
 
88
- Prints the **entire** comment thread: each comment as `author · createdAt`
89
- followed by its plain-text body (comment bodies are stored as HTML and stripped
90
- to text). Replies are indented two spaces under their parent. Author falls back
91
- to `unknown`. No comments prints `(no comments)`.
99
+ **Output budget (LUM-428).** By default the thread is capped to the
100
+ output-token budget (25,000 tokens every line you print spends from your
101
+ context). When it overflows, the output is truncated to the budget and ends in
102
+ a fetch-more pointer (`… +N more comments not shown (output capped at 25,000
103
+ tokens) — read the whole thread with: lumo task comments list <id> --full`).
104
+ Pass **`--full`** to print every comment uncapped — only when you actually need
105
+ the whole thread.
92
106
 
93
107
  ```bash
94
- lumo task comments list LUM-42
108
+ lumo task comments list LUM-42 # capped to the output budget
109
+ lumo task comments list LUM-42 --full # every comment, no cap
95
110
  ```
96
111
 
97
112
  **Plural, and distinct from `task comment`.** `task comments list` _reads_ the
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatShowOutput = formatShowOutput;
4
+ exports.selectDocShowOutput = selectDocShowOutput;
4
5
  exports.docShow = docShow;
5
6
  const config_1 = require("../lib/config");
6
7
  const api_1 = require("../lib/api");
@@ -8,6 +9,7 @@ const markdown_tiptap_1 = require("../lib/markdown-tiptap");
8
9
  const markdown_sections_1 = require("../lib/markdown-sections");
9
10
  const resolve_doc_id_1 = require("../lib/resolve-doc-id");
10
11
  const sanitize_1 = require("../lib/sanitize");
12
+ const output_budget_1 = require("../../../shared/src/output-budget");
11
13
  function scopeLabel(s) {
12
14
  if (s === 'PRIVATE')
13
15
  return 'personal';
@@ -27,43 +29,21 @@ function formatShowOutput(vm) {
27
29
  `Mentioned tasks: ${vm.mentionedTasks.length ? vm.mentionedTasks.join(', ') : '-'}`,
28
30
  ];
29
31
  const header = lines.join('\n');
30
- return vm.bodyMarkdown ? `${header}\n\n${(0, sanitize_1.sanitizeField)(vm.bodyMarkdown)}` : header;
32
+ if (!vm.bodyMarkdown)
33
+ return header;
34
+ const full = `${header}\n\n${(0, sanitize_1.sanitizeField)(vm.bodyMarkdown)}`;
35
+ // The *rendered* view is the only doc-show path with a budget (LUM-428): a
36
+ // book-length doc would otherwise swallow the agent's context. The pointer
37
+ // routes to the byte-faithful reads — `--section` for a just-in-time slice,
38
+ // `--raw` for the whole source — which are NEVER capped (they are edit bases).
39
+ return (0, output_budget_1.capRenderedOutput)(full, {
40
+ fetchHint: `read one section with: lumo doc show ${vm.id} --section "<heading>", ` +
41
+ `or the full markdown source with: lumo doc show ${vm.id} --raw`,
42
+ unitNoun: 'lines',
43
+ }).text;
31
44
  }
32
- async function docShow(reference, opts = {}) {
33
- if (!reference) {
34
- console.error('Error: missing <doc>. Usage: lumo doc show <doc> [--raw | --section <heading>]');
35
- return 1;
36
- }
37
- if (opts.raw && opts.section !== undefined) {
38
- console.error('Error: --raw and --section are mutually exclusive (--raw prints the whole source)');
39
- return 1;
40
- }
41
- const creds = (0, config_1.readCredentials)();
42
- if (!creds) {
43
- console.error('Error: not logged in. Run `lumo auth login` first.');
44
- return 1;
45
- }
46
- const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
47
- const id = await (0, resolve_doc_id_1.lookupDocId)(apiUrl, creds.token, reference);
48
- if (!id) {
49
- console.error(`Error: Document not found: ${reference}`);
50
- return 1;
51
- }
52
- const res = await fetch(`${(0, api_1.trimTrailingSlash)(apiUrl)}/api/documents/${id}`, {
53
- headers: { Authorization: `Bearer ${creds.token}` },
54
- });
55
- if (!res.ok) {
56
- const text = await res.text();
57
- console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(text)}`);
58
- return 1;
59
- }
60
- // The exact shape of the GET response is not guaranteed; defensively unwrap.
61
- const data = (await res.json());
62
- const d = data.document;
63
- if (!d) {
64
- console.error('Error: server returned an empty document response');
65
- return 1;
66
- }
45
+ function selectDocShowOutput(d, opts) {
46
+ const revision = typeof d.contentRevision === 'number' ? d.contentRevision : null;
67
47
  if (opts.raw) {
68
48
  // Byte-identical markdown source of the last markdown upload (LUM-408).
69
49
  // Written verbatim (no header, no sanitization, no added newline) so the
@@ -71,30 +51,28 @@ async function docShow(reference, opts = {}) {
71
51
  // No silent fallback to the lossy HTML→markdown reverse render — that
72
52
  // fallback is exactly what flattened tables in LUM-349.
73
53
  if (typeof d.sourceMarkdown !== 'string') {
74
- console.error(`Error: ${d.id} has no stored markdown source (last edit was HTML-direct ` +
75
- `or predates markdown source storage). --raw refuses to fall back to the ` +
76
- `lossy HTML→markdown render. Rebuild a base instead: run \`lumo doc show ${d.id}\`, ` +
77
- `reconstruct the markdown faithfully, then \`lumo doc update ${d.id} --file <rebuilt.md>\` ` +
78
- `— from then on --raw works.`);
79
- return 1;
54
+ return {
55
+ kind: 'error',
56
+ message: `Error: ${d.id} has no stored markdown source (last edit was HTML-direct ` +
57
+ `or predates markdown source storage). --raw refuses to fall back to the ` +
58
+ `lossy HTML→markdown render. Rebuild a base instead: run \`lumo doc show ${d.id}\`, ` +
59
+ `reconstruct the markdown faithfully, then \`lumo doc update ${d.id} --file <rebuilt.md>\` ` +
60
+ `— from then on --raw works.`,
61
+ };
80
62
  }
81
- process.stdout.write(d.sourceMarkdown);
82
- // Revision goes to stderr so stdout stays a byte-pure edit base while
83
- // the agent still sees the number to pass back as --if-revision.
84
- if (typeof d.contentRevision === 'number') {
85
- console.error(`Revision: ${d.contentRevision}`);
86
- }
87
- return;
63
+ return { kind: 'bytes', text: d.sourceMarkdown, revision };
88
64
  }
89
65
  if (opts.section !== undefined) {
90
66
  // Section reads slice the stored markdown source — same no-fallback rule
91
67
  // as --raw: a rendered-output slice would not be a legal edit base.
92
68
  if (typeof d.sourceMarkdown !== 'string') {
93
- console.error(`Error: ${d.id} has no stored markdown source (last edit was HTML-direct ` +
94
- `or predates markdown source storage), so --section cannot slice it. ` +
95
- `Rebuild a base first: run \`lumo doc show ${d.id}\`, reconstruct the markdown ` +
96
- `faithfully, then \`lumo doc update ${d.id} --file <rebuilt.md>\`.`);
97
- return 1;
69
+ return {
70
+ kind: 'error',
71
+ message: `Error: ${d.id} has no stored markdown source (last edit was HTML-direct ` +
72
+ `or predates markdown source storage), so --section cannot slice it. ` +
73
+ `Rebuild a base first: run \`lumo doc show ${d.id}\`, reconstruct the markdown ` +
74
+ `faithfully, then \`lumo doc update ${d.id} --file <rebuilt.md>\`.`,
75
+ };
98
76
  }
99
77
  const match = (0, markdown_sections_1.findSection)(d.sourceMarkdown, opts.section);
100
78
  if (match.kind === 'not-found') {
@@ -102,24 +80,27 @@ async function docShow(reference, opts = {}) {
102
80
  .slice(0, 30)
103
81
  .map(s => ` ${'#'.repeat(s.depth)} ${(0, sanitize_1.sanitizeField)(s.heading)}`)
104
82
  .join('\n');
105
- console.error(`Error: section not found: "${opts.section}". Available headings:\n${list || ' (none)'}`);
106
- return 1;
83
+ return {
84
+ kind: 'error',
85
+ message: `Error: section not found: "${opts.section}". Available headings:\n${list || ' (none)'}`,
86
+ };
107
87
  }
108
88
  if (match.kind === 'ambiguous') {
109
89
  const list = match.candidates
110
90
  .map(s => ` ${'#'.repeat(s.depth)} ${(0, sanitize_1.sanitizeField)(s.heading)} (line ${s.line + 1})`)
111
91
  .join('\n');
112
- console.error(`Error: section "${opts.section}" is ambiguous — ${match.candidates.length} headings match:\n${list}\n` +
113
- `Disambiguate by depth, e.g. --section "${'#'.repeat(match.candidates[0].depth)} ${match.candidates[0].heading}"`);
114
- return 1;
115
- }
116
- // Byte-faithful slice, verbatim on stdout (legal patch base); revision on
117
- // stderr for the follow-up `doc patch --if-revision`.
118
- process.stdout.write((0, markdown_sections_1.extractSection)(d.sourceMarkdown, match.section));
119
- if (typeof d.contentRevision === 'number') {
120
- console.error(`Revision: ${d.contentRevision}`);
92
+ return {
93
+ kind: 'error',
94
+ message: `Error: section "${opts.section}" is ambiguous — ${match.candidates.length} headings match:\n${list}\n` +
95
+ `Disambiguate by depth, e.g. --section "${'#'.repeat(match.candidates[0].depth)} ${match.candidates[0].heading}"`,
96
+ };
121
97
  }
122
- return;
98
+ // Byte-faithful slice, verbatim (legal patch base).
99
+ return {
100
+ kind: 'bytes',
101
+ text: (0, markdown_sections_1.extractSection)(d.sourceMarkdown, match.section),
102
+ revision,
103
+ };
123
104
  }
124
105
  // Server returns `contentMarkdown` derived from the HTML body (LUM-83+).
125
106
  // Fall back to parsing the raw content as legacy Tiptap JSON for docs
@@ -149,15 +130,71 @@ async function docShow(reference, opts = {}) {
149
130
  return null;
150
131
  })
151
132
  .filter((x) => x !== null);
152
- console.log(formatShowOutput({
153
- id: d.id,
154
- title: d.title,
155
- scope: d.visibility ?? 'PRIVATE',
156
- projectName: d.project?.name ?? null,
157
- createdAt: d.createdAt ?? '',
158
- updatedAt: d.updatedAt ?? '',
159
- revision: typeof d.contentRevision === 'number' ? d.contentRevision : null,
160
- mentionedTasks,
161
- bodyMarkdown: body,
162
- }));
133
+ return {
134
+ kind: 'rendered',
135
+ text: formatShowOutput({
136
+ id: d.id,
137
+ title: d.title,
138
+ scope: d.visibility ?? 'PRIVATE',
139
+ projectName: d.project?.name ?? null,
140
+ createdAt: d.createdAt ?? '',
141
+ updatedAt: d.updatedAt ?? '',
142
+ revision,
143
+ mentionedTasks,
144
+ bodyMarkdown: body,
145
+ }),
146
+ };
147
+ }
148
+ async function docShow(reference, opts = {}) {
149
+ if (!reference) {
150
+ console.error('Error: missing <doc>. Usage: lumo doc show <doc> [--raw | --section <heading>]');
151
+ return 1;
152
+ }
153
+ if (opts.raw && opts.section !== undefined) {
154
+ console.error('Error: --raw and --section are mutually exclusive (--raw prints the whole source)');
155
+ return 1;
156
+ }
157
+ const creds = (0, config_1.readCredentials)();
158
+ if (!creds) {
159
+ console.error('Error: not logged in. Run `lumo auth login` first.');
160
+ return 1;
161
+ }
162
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
163
+ const id = await (0, resolve_doc_id_1.lookupDocId)(apiUrl, creds.token, reference);
164
+ if (!id) {
165
+ console.error(`Error: Document not found: ${reference}`);
166
+ return 1;
167
+ }
168
+ const res = await fetch(`${(0, api_1.trimTrailingSlash)(apiUrl)}/api/documents/${id}`, {
169
+ headers: { Authorization: `Bearer ${creds.token}` },
170
+ });
171
+ if (!res.ok) {
172
+ const text = await res.text();
173
+ console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(text)}`);
174
+ return 1;
175
+ }
176
+ // The exact shape of the GET response is not guaranteed; defensively unwrap.
177
+ const data = (await res.json());
178
+ const d = data.document;
179
+ if (!d) {
180
+ console.error('Error: server returned an empty document response');
181
+ return 1;
182
+ }
183
+ const sel = selectDocShowOutput(d, opts);
184
+ if (sel.kind === 'error') {
185
+ console.error(sel.message);
186
+ return 1;
187
+ }
188
+ if (sel.kind === 'bytes') {
189
+ // Byte-faithful read (--raw / --section): written verbatim, NEVER routed
190
+ // through the output cap — it is a legal edit base for `doc patch`
191
+ // (LUM-408/409/428). Revision goes to stderr so stdout stays byte-pure
192
+ // while the agent still sees the number to pass back as --if-revision.
193
+ process.stdout.write(sel.text);
194
+ if (sel.revision !== null) {
195
+ console.error(`Revision: ${sel.revision}`);
196
+ }
197
+ return;
198
+ }
199
+ console.log(sel.text);
163
200
  }
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatCommentThread = formatCommentThread;
3
4
  exports.taskCommentList = taskCommentList;
4
5
  const config_1 = require("../lib/config");
5
6
  const api_1 = require("../lib/api");
6
7
  const sanitize_1 = require("../lib/sanitize");
8
+ const output_budget_1 = require("../../../shared/src/output-budget");
7
9
  /**
8
10
  * Strip TipTap/HTML markup to readable plain text. Comment bodies are stored as
9
11
  * HTML; the CLI only needs the visible text. We collapse block tags to newlines
@@ -23,17 +25,42 @@ function htmlToPlainText(html) {
23
25
  .replace(/\n{3,}/g, '\n\n')
24
26
  .trim();
25
27
  }
26
- function printComment(c, indent) {
28
+ /** Render one comment (and its nested replies) as an indented text block. */
29
+ function renderCommentBlock(c, indent) {
30
+ const lines = [];
27
31
  const author = (0, sanitize_1.sanitizeField)(c.authorActorId ?? 'unknown');
28
- console.log(`${indent}${author} · ${c.createdAt}`);
32
+ lines.push(`${indent}${author} · ${c.createdAt}`);
29
33
  const text = (0, sanitize_1.sanitizeField)(htmlToPlainText(c.body));
30
34
  for (const line of (text || '(empty)').split('\n')) {
31
- console.log(`${indent}${line}`);
35
+ lines.push(`${indent}${line}`);
32
36
  }
33
- console.log('');
34
37
  for (const reply of c.replies ?? []) {
35
- printComment(reply, indent + ' ');
38
+ lines.push('');
39
+ lines.push(renderCommentBlock(reply, indent + ' '));
36
40
  }
41
+ return lines.join('\n');
42
+ }
43
+ /**
44
+ * Render the full comment thread as agent-facing text. One block per top-level
45
+ * comment (replies indented underneath). Capped to the output-token budget by
46
+ * default (LUM-428) — a long-running task's thread is one of the easiest ways
47
+ * to blow the agent's context — with a fetch-more pointer to `--full`. Pass
48
+ * `{ full: true }` to print everything.
49
+ */
50
+ function formatCommentThread(comments, opts = {}) {
51
+ if (comments.length === 0)
52
+ return '(no comments)';
53
+ const blocks = comments.map(c => renderCommentBlock(c, ''));
54
+ if (opts.full)
55
+ return blocks.join('\n\n');
56
+ const id = opts.identifier ?? '<LUM-N>';
57
+ return (0, output_budget_1.truncateUnitsToBudget)({
58
+ units: blocks,
59
+ maxTokens: opts.maxTokens,
60
+ unitNoun: 'comments',
61
+ fetchHint: `read the whole thread with: lumo task comments list ${id} --full`,
62
+ separator: '\n\n',
63
+ }).text;
37
64
  }
38
65
  /**
39
66
  * `lumo task comments list <LUM-N>`
@@ -43,7 +70,7 @@ function printComment(c, indent) {
43
70
  * thread from `/api/tasks/:id/comments` and prints each top-level comment
44
71
  * (replies indented) as `author · time` followed by the plain-text body.
45
72
  */
46
- async function taskCommentList(identifier) {
73
+ async function taskCommentList(identifier, opts = {}) {
47
74
  if (!identifier) {
48
75
  console.error('Error: usage: lumo task comments list <LUM-42>');
49
76
  return 1;
@@ -105,7 +132,8 @@ async function taskCommentList(identifier) {
105
132
  console.log('(no comments)');
106
133
  return;
107
134
  }
108
- for (const c of comments) {
109
- printComment(c, '');
110
- }
135
+ console.log(formatCommentThread(comments, {
136
+ full: opts.full,
137
+ identifier: resolved.identifier,
138
+ }));
111
139
  }
@@ -6,6 +6,7 @@ const config_1 = require("../lib/config");
6
6
  const api_1 = require("../lib/api");
7
7
  const sanitize_1 = require("../lib/sanitize");
8
8
  const format_1 = require("../lib/format");
9
+ const output_budget_1 = require("../../../shared/src/output-budget");
9
10
  async function taskContext(identifier) {
10
11
  if (!identifier) {
11
12
  console.error('Error: missing <identifier>. Usage: lumo task context <LUM-42>');
@@ -144,5 +145,15 @@ function formatTaskContextMarkdown(data, now) {
144
145
  lines.push((0, sanitize_1.sanitizeField)(data.lineageSection.trimEnd()));
145
146
  lines.push('');
146
147
  }
147
- return lines.join('\n');
148
+ // A memory-rich task with a long comment thread and many sessions is the
149
+ // single most context-hungry agent-facing command (LUM-428). Cap the
150
+ // assembled handoff to the output budget; the pointer routes the agent to the
151
+ // precise sub-commands to pull any dropped section just-in-time.
152
+ const id = data.task.identifier;
153
+ return (0, output_budget_1.capRenderedOutput)(lines.join('\n'), {
154
+ fetchHint: `pull the dropped sections individually: lumo task status ${id} · ` +
155
+ `lumo task comments list ${id} --full · lumo task lineage ${id} · ` +
156
+ `lumo doc show <doc>`,
157
+ unitNoun: 'lines',
158
+ }).text;
148
159
  }
@@ -344,8 +344,9 @@ const taskComments = task
344
344
  .description('Inspect the full task comment thread');
345
345
  taskComments
346
346
  .command('list <identifier>')
347
- .description('List the full task comment thread')
348
- .action(wrap(id => (0, task_comment_list_1.taskCommentList)(id)));
347
+ .description('List the task comment thread (capped to the output budget; --full prints every comment)')
348
+ .option('--full', 'Print every comment, bypassing the output-token cap')
349
+ .action(wrap((id, opts) => (0, task_comment_list_1.taskCommentList)(id, opts)));
349
350
  const taskDeps = task
350
351
  .command('deps')
351
352
  .description('Task dependency edges — detected candidates + confirmed blockers');
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  // ── Agent Error types ────────────────────────────────────────────────────────
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.buildCmdEvidencePointer = exports.isValidEvidencePointer = exports.EVIDENCE_POINTER_MAX = exports.EVIDENCE_POINTER_FORMAT_HINT = exports.EVIDENCE_POINTER_PATTERNS = exports.sanitizeField = exports.parseStreamJsonUsage = exports.tiptapToMarkdown = exports.markdownToTiptap = exports.AgentError = void 0;
4
+ exports.capRenderedOutput = exports.truncateUnitsToBudget = exports.OUTPUT_TOKEN_BUDGET = exports.estimateTokens = exports.buildCmdEvidencePointer = exports.isValidEvidencePointer = exports.EVIDENCE_POINTER_MAX = exports.EVIDENCE_POINTER_FORMAT_HINT = exports.EVIDENCE_POINTER_PATTERNS = exports.sanitizeField = exports.parseStreamJsonUsage = exports.tiptapToMarkdown = exports.markdownToTiptap = exports.AgentError = void 0;
5
5
  exports.userFriendlyError = userFriendlyError;
6
6
  class AgentError extends Error {
7
7
  code;
@@ -46,3 +46,9 @@ Object.defineProperty(exports, "EVIDENCE_POINTER_FORMAT_HINT", { enumerable: tru
46
46
  Object.defineProperty(exports, "EVIDENCE_POINTER_MAX", { enumerable: true, get: function () { return acceptance_evidence_1.EVIDENCE_POINTER_MAX; } });
47
47
  Object.defineProperty(exports, "isValidEvidencePointer", { enumerable: true, get: function () { return acceptance_evidence_1.isValidEvidencePointer; } });
48
48
  Object.defineProperty(exports, "buildCmdEvidencePointer", { enumerable: true, get: function () { return acceptance_evidence_1.buildCmdEvidencePointer; } });
49
+ // ── Agent-facing CLI output-token budget (LUM-428) ───────────────────────────
50
+ var output_budget_1 = require("./output-budget");
51
+ Object.defineProperty(exports, "estimateTokens", { enumerable: true, get: function () { return output_budget_1.estimateTokens; } });
52
+ Object.defineProperty(exports, "OUTPUT_TOKEN_BUDGET", { enumerable: true, get: function () { return output_budget_1.OUTPUT_TOKEN_BUDGET; } });
53
+ Object.defineProperty(exports, "truncateUnitsToBudget", { enumerable: true, get: function () { return output_budget_1.truncateUnitsToBudget; } });
54
+ Object.defineProperty(exports, "capRenderedOutput", { enumerable: true, get: function () { return output_budget_1.capRenderedOutput; } });
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ /**
3
+ * LUM-428 — output-token budget for agent-facing CLI stdout.
4
+ *
5
+ * Anthropic's "Writing effective tools for AI agents" + "Effective context
6
+ * engineering": a tool's response is a contract that spends from the agent's
7
+ * context budget, and Claude Code defaults to truncating a tool result to
8
+ * 25,000 tokens. Lumo's agent-facing commands (`task comments list`,
9
+ * `task context`, `doc show`, …) print straight into that budget, so a single
10
+ * fat comment thread / huge doc must not be allowed to blow it.
11
+ *
12
+ * This module is the CLI-side primitive: a token estimate, the budget anchor,
13
+ * and two cappers that truncate to the budget and append a *lightweight
14
+ * fetch-more pointer* — a just-in-time identifier telling the agent how to pull
15
+ * the rest on demand instead of receiving it all up front.
16
+ *
17
+ * It lives in `shared/` (not the app's `lib/context/budget.ts`) because the CLI
18
+ * imports `shared/` and never the app `lib/`. It is deliberately decoupled from
19
+ * the LUM-402 *injection-side* budget (session-start additionalContext): that
20
+ * one allocates the context window; this one caps command stdout.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.OUTPUT_TOKEN_BUDGET = void 0;
24
+ exports.estimateTokens = estimateTokens;
25
+ exports.truncateUnitsToBudget = truncateUnitsToBudget;
26
+ exports.capRenderedOutput = capRenderedOutput;
27
+ /**
28
+ * Rough token estimate: char-count / 4, rounded up. Stable proxy that avoids a
29
+ * tokenizer dependency — the same heuristic the canonical injection-side budget
30
+ * (`lib/context/budget.ts`) uses, duplicated here only because `shared/` must
31
+ * stay free of any app-`lib/` import.
32
+ */
33
+ function estimateTokens(text) {
34
+ return Math.ceil(text.length / 4);
35
+ }
36
+ /**
37
+ * Default per-command output ceiling for agent-facing CLI stdout, in tokens.
38
+ * Anchored to Anthropic's published Claude Code default (tool results truncated
39
+ * to 25,000 tokens). Generous on purpose: normal outputs sit far below it, so
40
+ * the cap only ever engages on a pathological input (a thousand-comment thread,
41
+ * a book-length doc) — exactly the case that would otherwise silently swallow
42
+ * the agent's whole budget.
43
+ */
44
+ exports.OUTPUT_TOKEN_BUDGET = 25_000;
45
+ /**
46
+ * Chars reserved for the appended pointer line so the final string (body +
47
+ * pointer) still fits the budget. Comfortably larger than any pointer this
48
+ * module builds.
49
+ */
50
+ const POINTER_RESERVE_CHARS = 320;
51
+ function buildPointer(omitted, unitNoun, maxTokens, fetchHint) {
52
+ return (`… +${omitted.toLocaleString('en-US')} more ${unitNoun} not shown ` +
53
+ `(output capped at ${maxTokens.toLocaleString('en-US')} tokens) — ${fetchHint}`);
54
+ }
55
+ /**
56
+ * Truncate an explicit list of repeatable units (e.g. one comment each) to the
57
+ * budget, appending the fetch-more pointer when anything is dropped. Always
58
+ * keeps at least one unit so the output is never just a pointer. The returned
59
+ * `text` is guaranteed to estimate at or under `maxTokens`.
60
+ */
61
+ function truncateUnitsToBudget(params) {
62
+ const maxTokens = params.maxTokens ?? exports.OUTPUT_TOKEN_BUDGET;
63
+ const separator = params.separator ?? '\n\n';
64
+ const { units, unitNoun, fetchHint } = params;
65
+ const full = units.join(separator);
66
+ if (units.length === 0 || estimateTokens(full) <= maxTokens) {
67
+ return {
68
+ text: full,
69
+ truncated: false,
70
+ shownUnits: units.length,
71
+ omittedUnits: 0,
72
+ };
73
+ }
74
+ const charBudget = Math.max(0, maxTokens * 4 - POINTER_RESERVE_CHARS);
75
+ const kept = [];
76
+ let used = 0;
77
+ for (const unit of units) {
78
+ const add = (kept.length === 0 ? 0 : separator.length) + unit.length;
79
+ if (kept.length > 0 && used + add > charBudget)
80
+ break;
81
+ kept.push(unit);
82
+ used += add;
83
+ if (used >= charBudget)
84
+ break;
85
+ }
86
+ let body = kept.join(separator);
87
+ // A single oversized leading unit can still bust the budget — hard-slice it.
88
+ if (body.length > charBudget)
89
+ body = body.slice(0, charBudget);
90
+ const omitted = units.length - kept.length;
91
+ const pointer = buildPointer(omitted, unitNoun, maxTokens, fetchHint);
92
+ return {
93
+ text: `${body}${separator}${pointer}`,
94
+ truncated: true,
95
+ shownUnits: kept.length,
96
+ omittedUnits: omitted,
97
+ };
98
+ }
99
+ /**
100
+ * Cap an already-rendered multi-line string to the budget at a line boundary,
101
+ * appending the fetch-more pointer when truncated. For commands that assemble a
102
+ * single body (a doc render, the task-context handoff) rather than an explicit
103
+ * unit list. The returned `text` estimates at or under `maxTokens`.
104
+ */
105
+ function capRenderedOutput(text, opts) {
106
+ const maxTokens = opts.maxTokens ?? exports.OUTPUT_TOKEN_BUDGET;
107
+ const unitNoun = opts.unitNoun ?? 'lines';
108
+ if (estimateTokens(text) <= maxTokens) {
109
+ return { text, truncated: false, shownUnits: 0, omittedUnits: 0 };
110
+ }
111
+ const charBudget = Math.max(0, maxTokens * 4 - POINTER_RESERVE_CHARS);
112
+ // Fill the budget with a hard char-slice (so a single very long line is
113
+ // sliced, not dropped wholesale), then back up to a line boundary when one
114
+ // sits in the kept tail — keeps the output from ending mid-line.
115
+ let body = text.slice(0, charBudget);
116
+ const lastNewline = body.lastIndexOf('\n');
117
+ if (lastNewline > charBudget / 2)
118
+ body = body.slice(0, lastNewline);
119
+ const totalLines = text.split('\n').length;
120
+ const keptLines = body.length === 0 ? 0 : body.split('\n').length;
121
+ const omitted = Math.max(0, totalLines - keptLines);
122
+ const pointer = buildPointer(omitted, unitNoun, maxTokens, opts.fetchHint);
123
+ return {
124
+ text: `${body}\n\n${pointer}`,
125
+ truncated: true,
126
+ shownUnits: keptLines,
127
+ omittedUnits: omitted,
128
+ };
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",