@zenera/cli 1.1.18 → 1.1.19

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.
@@ -1,10 +1,17 @@
1
- export type Appearance = 'dark' | 'light';
2
- /** The roles a line can play in the transcript. */
3
- export type Kind = 'you' | 'agent' | 'tool' | 'note' | 'error';
1
+ import { type Appearance } from '../term.ts';
2
+ export type { Appearance };
3
+ /**
4
+ * The roles a line can play in the transcript.
5
+ *
6
+ * `text` is what the agent said on its way somewhere — prose between one batch
7
+ * of calls and the next — and `agent` is the answer it finished on, which is
8
+ * the only one that gets a box. Reasoning is not among them: it is progress,
9
+ * and progress that has finished is worth no rows at all.
10
+ */
11
+ export type Kind = 'you' | 'text' | 'agent' | 'tool' | 'note' | 'error';
4
12
  export interface LineStyle {
5
13
  /** `undefined` means the terminal's own foreground. */
6
14
  readonly color?: string;
7
- readonly dim?: boolean;
8
15
  }
9
16
  export interface Theme {
10
17
  readonly appearance: Appearance;
@@ -13,13 +20,25 @@ export interface Theme {
13
20
  readonly accent: string;
14
21
  /** Read-only badge, busy label. */
15
22
  readonly warn: string;
23
+ /**
24
+ * Box rules, clocks, the footer's numbers, the call rows. One step quieter
25
+ * than the prose and never two — stated as a grey because `dim` is a
26
+ * request the terminal is free to over-honour, and some do.
27
+ */
28
+ readonly chrome: LineStyle;
16
29
  /**
17
30
  * Reasoning. It is not a tool call and it is not the answer, and drawn in
18
- * the same dim foreground as the rows around it there was nothing to say
19
- * which — a wall of one weight reads as one thing. Its own hue is what
20
- * separates the model's thinking from the work it went on to do.
31
+ * the same grey as the rows around it there was nothing to say which — a
32
+ * wall of one weight reads as one thing. Its own hue is what separates the
33
+ * model's thinking from the work it went on to do.
21
34
  */
22
35
  readonly thinking: LineStyle;
36
+ /**
37
+ * The three things a turn spends time on. One word in the footer says which,
38
+ * and it is read at a glance from across a desk — so the word changing is
39
+ * backed by the colour changing, not left to be spelled out.
40
+ */
41
+ readonly phase: Record<'reasoning' | 'waiting' | 'working', string>;
23
42
  /**
24
43
  * Branch colours, cycled in order of first sight. A fan-out is the one
25
44
  * place where colour carries information rather than decoration: eight
package/dist/tui/theme.js CHANGED
@@ -13,47 +13,71 @@
13
13
  // unreadable on dark and invisible on paper. What is left is the handful of
14
14
  // accents that ANSI *does* let a light theme get wrong, so those swap.
15
15
  //
16
- // Dim is spent on *chrome* — box rules, clocks, the footer's numbers — and no
17
- // longer on content. Tool rows are most of what is on screen, and dimming them
18
- // put the bulk of the transcript one step from unreadable: the whole thing read
19
- // as too dark. They are content, so they get the foreground like content does.
16
+ // A second weight is spent on *chrome* and on the machinery: box rules,
17
+ // clocks, the footer's numbers, and the call rows that say how an answer was
18
+ // arrived at. What the agent actually said is drawn at full weight in the
19
+ // terminal's own foreground, so a transcript read top to bottom reads as prose
20
+ // with the work annotated beside it — not as one undifferentiated wall.
20
21
  //
21
- // Note what is *not* here: no hex, no 256-colour ramps, no attempt at a brand.
22
- // A palette that ignores the user's scheme is worse on both schemes than one
23
- // that mostly defers to it.
22
+ // That weight used to be SGR 2 (`dim`), and it was the third version of the
23
+ // same bug. `dim` is not a colour, it is an instruction, and how far it is
24
+ // obeyed is the terminal's business: iTerm2 takes a step, Apple Terminal
25
+ // blends most of the way to the background. On Terminal.app the entire margin
26
+ // — every rule, every call row, every number — went out with it.
27
+ //
28
+ // So there is exactly one ramp here, `chrome`, and it is a stated grey rather
29
+ // than an attribute. It is the only number in the file, and it buys the one
30
+ // thing deference cannot: a floor under the quiet half of the screen.
31
+ // Everything else is still named by role and still defers to the user's own
32
+ // scheme — no brand, no second hue, no 256-colour palette.
24
33
  // ---------------------------------------------------------------------------
34
+ import { appearance, CHROME } from "../term.js";
35
+ // The two greys, shared with every one-shot command so a `zen check` table and
36
+ // a TUI call row are the same weight. Chalk steps them down for a 16-colour
37
+ // terminal, which is the only place the old `gray` problem is still reachable.
38
+ const DARK_CHROME = { color: `ansi256(${CHROME.dark})` };
39
+ const LIGHT_CHROME = { color: `ansi256(${CHROME.light})` };
25
40
  const DARK = {
26
41
  appearance: 'dark',
27
42
  line: {
28
43
  you: { color: 'cyan' },
44
+ text: {},
29
45
  agent: {},
30
- // Never dim, and never `gray`: bright black is already the faintest
31
- // colour a terminal has, and either one puts the bulk of the
32
- // transcript a step from unreadable.
33
- tool: {},
46
+ // A call is the machinery, not the work: the transcript is read for
47
+ // what the agent said, and the rows saying how it found out are a
48
+ // margin note beside that.
49
+ tool: DARK_CHROME,
34
50
  note: { color: 'cyan' },
35
51
  error: { color: 'red' },
36
52
  },
37
53
  accent: 'cyan',
38
54
  warn: 'yellow',
39
- thinking: { color: 'magenta' },
55
+ chrome: DARK_CHROME,
56
+ // Bright: the gist is one line among many and plain `magenta` on black is
57
+ // the darkest of the six.
58
+ thinking: { color: 'magentaBright' },
59
+ phase: { reasoning: 'magenta', waiting: 'yellow', working: 'cyan' },
40
60
  lanes: ['cyan', 'green', 'yellow', 'blue', 'red', 'magenta'],
41
61
  };
42
62
  // On a light background `cyan` and `yellow` are barely darker than the paper.
43
- // Dimmed default foreground and `blue`/`magenta` are the same information,
44
- // still legible.
63
+ // Grey and `blue`/`magenta` are the same information, still legible.
45
64
  const LIGHT = {
46
65
  appearance: 'light',
47
66
  line: {
48
67
  you: { color: 'blue' },
68
+ text: {},
49
69
  agent: {},
50
- tool: {},
70
+ tool: LIGHT_CHROME,
51
71
  note: { color: 'blue' },
52
72
  error: { color: 'red' },
53
73
  },
54
74
  accent: 'blue',
55
75
  warn: 'magenta',
76
+ chrome: LIGHT_CHROME,
56
77
  thinking: { color: 'magenta' },
78
+ // No yellow: `waiting` takes blue and `working` green, which are the two
79
+ // that survive paper.
80
+ phase: { reasoning: 'magenta', waiting: 'blue', working: 'green' },
57
81
  // No cyan or yellow: on paper they are barely darker than the paper.
58
82
  lanes: ['blue', 'green', 'red', 'magenta'],
59
83
  };
@@ -67,23 +91,17 @@ export async function resolveTheme(choice) {
67
91
  if (asked !== 'auto') {
68
92
  return THEMES[asked];
69
93
  }
70
- return THEMES[(await queryBackground()) ?? fromColorFgBg() ?? 'dark'];
71
- }
72
- /**
73
- * `COLORFGBG` is `fg;bg` or `fg;<something>;bg`; the background is the last
74
- * field. Anything non-numeric (notably `default`) tells us nothing.
75
- */
76
- function fromColorFgBg() {
77
- const parts = process.env['COLORFGBG']?.split(';');
78
- const bg = parts?.[parts.length - 1];
79
- if (bg === undefined || !/^\d+$/.test(bg)) {
80
- return undefined;
81
- }
82
- const n = Number(bg);
83
- return n === 7 || n >= 9 ? 'light' : 'dark';
94
+ return THEMES[(await queryBackground()) ?? appearance()];
84
95
  }
85
- const QUERY = '\u001b]11;?\u0007';
96
+ // The DA1 behind the colour query is what makes the answer conclusive. Every
97
+ // terminal replies to DA1, and none replies to it ahead of an OSC 11 it
98
+ // supports — so a DA1 arriving alone means "I do not do that", known in one
99
+ // round trip instead of guessed at the end of a timeout. What is left for the
100
+ // timeout is the terminal that answers neither, which is the only case worth
101
+ // waiting on and now the only one that does.
102
+ const QUERY = '\u001b]11;?\u0007\u001b[c';
86
103
  const REPLY = /\u001b\]11;rgb:([\da-f]{1,4})\/([\da-f]{1,4})\/([\da-f]{1,4})/i;
104
+ const DA1 = /\u001b\[\?[\d;]*c/;
87
105
  /**
88
106
  * Ask the terminal for its background colour and read the answer off stdin.
89
107
  *
@@ -93,7 +111,7 @@ const REPLY = /\u001b\]11;rgb:([\da-f]{1,4})\/([\da-f]{1,4})\/([\da-f]{1,4})/i;
93
111
  * and nothing else. Anything the user typed in that window is put back, so
94
112
  * the first keystroke of a fast start is not eaten by the handshake.
95
113
  */
96
- async function queryBackground(timeoutMs = 120) {
114
+ async function queryBackground(timeoutMs = 300) {
97
115
  const { stdin, stdout } = process;
98
116
  if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== 'function') {
99
117
  return undefined;
@@ -102,6 +120,7 @@ async function queryBackground(timeoutMs = 120) {
102
120
  let timer;
103
121
  let settled = false;
104
122
  let seen = '';
123
+ let found;
105
124
  return await new Promise((resolve) => {
106
125
  const finish = (result) => {
107
126
  if (settled) {
@@ -114,26 +133,33 @@ async function queryBackground(timeoutMs = 120) {
114
133
  if (!wasRaw) {
115
134
  stdin.pause();
116
135
  }
117
- const typed = seen.replace(REPLY, '').replace(/\u001b\\|\u0007/g, '');
136
+ const typed = seen
137
+ .replace(REPLY, '')
138
+ .replace(DA1, '')
139
+ .replace(/\u001b\\|\u0007/g, '');
118
140
  if (typed) {
119
141
  stdin.unshift(Buffer.from(typed, 'latin1'));
120
142
  }
121
143
  resolve(result);
122
144
  };
145
+ // The DA1 is sent second and answered second, so it is the end of the
146
+ // conversation whether or not the colour came back. Settling on the
147
+ // colour alone leaves the DA1 in the pipe, and it surfaces a moment
148
+ // later as `[?64;1;2;...c` typed into the prompt.
123
149
  const onData = (chunk) => {
124
150
  seen += chunk.toString('latin1');
125
151
  const m = REPLY.exec(seen);
126
152
  if (m) {
127
- finish(appearanceOf(m[1], m[2], m[3]));
153
+ found = appearanceOf(m[1], m[2], m[3]);
128
154
  }
129
- else if (seen.length > 256) {
130
- finish(undefined);
155
+ if (DA1.test(seen) || seen.length > 256) {
156
+ finish(found);
131
157
  }
132
158
  };
133
159
  stdin.setRawMode(true);
134
160
  stdin.resume();
135
161
  stdin.on('data', onData);
136
- timer = setTimeout(() => finish(undefined), timeoutMs);
162
+ timer = setTimeout(() => finish(found), timeoutMs);
137
163
  timer.unref?.();
138
164
  stdout.write(QUERY);
139
165
  });
@@ -17,26 +17,33 @@ export declare function clip(text: string, width: number): string;
17
17
  export declare function wrap(text: string, width: number): string[];
18
18
  /** The three footer rows, its margin, the prompt, and a row in hand. */
19
19
  export declare const CHROME_ROWS = 7;
20
- /** How much of the reasoning stream is worth showing. It is a progress bar. */
21
- export declare const THINKING_ROWS = 6;
20
+ /**
21
+ * How much of the reasoning stream is worth showing.
22
+ *
23
+ * One row. A reasoning summary is not a document to be read while it arrives —
24
+ * it is the model saying what it is about to do, and the sentence that says so
25
+ * is the heading it writes for itself. Six rows of it pushed the answer and the
26
+ * work down the screen to make room for a paragraph nobody finishes reading,
27
+ * and the whole chain is in the trajectory either way.
28
+ */
29
+ export declare const THINKING_ROWS = 1;
22
30
  /** How much of the frame the work in flight may take. A branch is a box now,
23
31
  * not a row, so a fan-out of two costs fourteen of these. */
24
32
  export declare const ACTIVITY_ROWS = 16;
25
- /** The rule that opens a reasoning block and the one that closes it. */
26
- export declare const THINKING_CHROME = 2;
33
+ /** The width of the elapsed-time column every call row is drawn behind. */
34
+ export declare const TIME_COL = 5;
27
35
  export interface Budget {
28
36
  /** rows for the list of what is in flight */
29
37
  activity: number;
30
- /** rows for the tail of the reasoning stream */
38
+ /** rows for the gist of what the model is reasoning about */
31
39
  thinking: number;
32
40
  /** rows for the answer as it arrives */
33
41
  live: number;
34
42
  /**
35
- * What the three of them occupy together, rules included — a constant for
36
- * a given terminal, whatever is happening inside it. The region grows to
37
- * this and stops, and never shrinks back while the turn runs, which is what
38
- * keeps the footer on one row instead of riding up and down on the
39
- * reasoning block.
43
+ * What the three of them occupy together — a constant for a given terminal,
44
+ * whatever is happening inside it. The region grows to this and stops, and
45
+ * never shrinks back while the turn runs, which is what keeps the footer on
46
+ * one row instead of riding up and down on the work.
40
47
  */
41
48
  total: number;
42
49
  }
@@ -47,11 +54,8 @@ export interface Budget {
47
54
  * arrives matters more than the machinery producing it. Reasoning yields to it
48
55
  * in turn, because it is a progress indicator and the answer is the point.
49
56
  *
50
- * `thinking` is how many rows of reasoning are wanted, counting text only: none
51
- * when there is no reasoning, fewer than `THINKING_ROWS` once the stream has
52
- * settled and its tail is history rather than progress. The two rules are
53
- * charged on top, so a block too cramped to be worth boxing is not drawn at all
54
- * rather than drawn as a border with a line in it.
57
+ * `thinking` is how many rows of reasoning are wanted — one while the model is
58
+ * reasoning, none otherwise — and it never takes the answer's last row.
55
59
  */
56
60
  export declare function budgetOf(rows: number, activity: number, thinking: number): Budget;
57
61
  /** The rows a branch box may spend on its own calls and its reasoning. */
@@ -87,6 +91,27 @@ export declare function answerWidth(columns: number): number;
87
91
  * that got cut.
88
92
  */
89
93
  export declare function readable(preview: string): string;
94
+ /** Every field of a preview, first mention winning. */
95
+ export declare function fieldsOf(preview: string): Map<string, string>;
96
+ /** What a call is doing, and what it is doing it to. */
97
+ export interface CallView {
98
+ /** The verb, drawn first: what kind of thing this is. */
99
+ verb: string;
100
+ /** What identifies this call: a path, a command, a query. May be empty. */
101
+ subject: string;
102
+ }
103
+ export declare function describeCall(name: string, preview: string): CallView;
104
+ export declare function summarise(name: string, preview: string): string;
105
+ /**
106
+ * One line saying where the model has got to.
107
+ *
108
+ * Reasoning summaries arrive as markdown and are structured by their own bold
109
+ * headings — Gemini emits one every few sentences, and they are the shortest
110
+ * true account of the step. The last one is where the model is now. Failing
111
+ * that the raw text is clipped, which is a fragment, but a moving fragment is
112
+ * still proof that something is happening.
113
+ */
114
+ export declare function gistOf(text: string, width: number): string;
90
115
  /** A run of lines from an answer, and whether it was fenced as code. */
91
116
  export interface Segment {
92
117
  code: boolean;
package/dist/tui/wrap.js CHANGED
@@ -82,13 +82,21 @@ export function wrap(text, width) {
82
82
  // ---------------------------------------------------------------------------
83
83
  /** The three footer rows, its margin, the prompt, and a row in hand. */
84
84
  export const CHROME_ROWS = 7;
85
- /** How much of the reasoning stream is worth showing. It is a progress bar. */
86
- export const THINKING_ROWS = 6;
85
+ /**
86
+ * How much of the reasoning stream is worth showing.
87
+ *
88
+ * One row. A reasoning summary is not a document to be read while it arrives —
89
+ * it is the model saying what it is about to do, and the sentence that says so
90
+ * is the heading it writes for itself. Six rows of it pushed the answer and the
91
+ * work down the screen to make room for a paragraph nobody finishes reading,
92
+ * and the whole chain is in the trajectory either way.
93
+ */
94
+ export const THINKING_ROWS = 1;
87
95
  /** How much of the frame the work in flight may take. A branch is a box now,
88
96
  * not a row, so a fan-out of two costs fourteen of these. */
89
97
  export const ACTIVITY_ROWS = 16;
90
- /** The rule that opens a reasoning block and the one that closes it. */
91
- export const THINKING_CHROME = 2;
98
+ /** The width of the elapsed-time column every call row is drawn behind. */
99
+ export const TIME_COL = 5;
92
100
  /**
93
101
  * How many rows each repainting block may draw in a terminal `rows` tall.
94
102
  *
@@ -96,24 +104,15 @@ export const THINKING_CHROME = 2;
96
104
  * arrives matters more than the machinery producing it. Reasoning yields to it
97
105
  * in turn, because it is a progress indicator and the answer is the point.
98
106
  *
99
- * `thinking` is how many rows of reasoning are wanted, counting text only: none
100
- * when there is no reasoning, fewer than `THINKING_ROWS` once the stream has
101
- * settled and its tail is history rather than progress. The two rules are
102
- * charged on top, so a block too cramped to be worth boxing is not drawn at all
103
- * rather than drawn as a border with a line in it.
107
+ * `thinking` is how many rows of reasoning are wanted — one while the model is
108
+ * reasoning, none otherwise — and it never takes the answer's last row.
104
109
  */
105
110
  export function budgetOf(rows, activity, thinking) {
106
111
  const total = Math.max(2, rows - CHROME_ROWS);
107
112
  const shown = Math.min(Math.max(0, activity), ACTIVITY_ROWS, Math.max(0, total - 2));
108
113
  const rest = total - shown;
109
- const room = rest - 1 - THINKING_CHROME;
110
- const tail = room >= 1 ? Math.min(Math.max(0, thinking), THINKING_ROWS, room) : 0;
111
- return {
112
- activity: shown,
113
- thinking: tail,
114
- live: rest - tail - (tail ? THINKING_CHROME : 0),
115
- total,
116
- };
114
+ const tail = Math.min(Math.max(0, thinking), THINKING_ROWS, Math.max(0, rest - 1));
115
+ return { activity: shown, thinking: tail, live: rest - tail, total };
117
116
  }
118
117
  /** The rows a branch box may spend on its own calls and its reasoning. */
119
118
  export const BRANCH_ROWS = 5;
@@ -176,6 +175,180 @@ function unquote(raw) {
176
175
  const body = raw.length > 1 && raw.endsWith('"') ? raw.slice(1, -1) : raw.slice(1);
177
176
  return body.replace(/\\[nrt]/g, ' ').replace(/\\(["\\/])/g, '$1');
178
177
  }
178
+ /** Every field of a preview, first mention winning. */
179
+ export function fieldsOf(preview) {
180
+ const out = new Map();
181
+ for (const m of preview.matchAll(FIELD)) {
182
+ const key = m[1];
183
+ if (!out.has(key)) {
184
+ out.set(key, unquote(m[2]));
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+ /** The text between `"key":[` and the first `]`, or nothing if there is no such array. */
190
+ function arrayBody(preview, key) {
191
+ const at = preview.indexOf(`"${key}"`);
192
+ if (at < 0) {
193
+ return undefined;
194
+ }
195
+ const open = preview.indexOf('[', at);
196
+ if (open < 0) {
197
+ return undefined;
198
+ }
199
+ const close = preview.indexOf(']', open);
200
+ return preview.slice(open + 1, close < 0 ? undefined : close);
201
+ }
202
+ /**
203
+ * An array argument, as the two things worth knowing about it: what is in it,
204
+ * when its items say their own names, and how many there are either way.
205
+ */
206
+ function itemsOf(preview, key) {
207
+ const body = arrayBody(preview, key);
208
+ if (body === undefined || !body.trim()) {
209
+ return { names: [], total: 0 };
210
+ }
211
+ if (/^\s*\{/.test(body)) {
212
+ const names = [...body.matchAll(/"name"\s*:\s*"([^"]*)"/g)].map((m) => m[1]);
213
+ return { names, total: Math.max(names.length, (body.match(/\{/g) ?? []).length) };
214
+ }
215
+ const names = [...body.matchAll(/"([^"]*)"/g)].map((m) => m[1]).filter(Boolean);
216
+ return { names, total: names.length };
217
+ }
218
+ /** A few things are worth naming; more than a few are worth counting. */
219
+ function named(items, unit) {
220
+ if (!items.total) {
221
+ return '';
222
+ }
223
+ if (items.names.length && items.names.length <= 2) {
224
+ return items.names.join(', ');
225
+ }
226
+ return `${items.total} ${items.total === 1 ? unit : `${unit}s`}`;
227
+ }
228
+ const CALLS = {
229
+ read_file: { verb: 'read', of: ['path'] },
230
+ list_dir: { verb: 'list', of: ['path'] },
231
+ find_files: { verb: 'find', of: ['pattern'] },
232
+ write_file: { verb: 'write', of: ['path'] },
233
+ apply_patch: { verb: 'patch' },
234
+ move_file: { verb: 'move', pair: ['from', 'to'] },
235
+ delete_file: { verb: 'delete', of: ['path'] },
236
+ run_command: { verb: 'run', of: ['command'] },
237
+ run_command_background: { verb: 'start', of: ['command'] },
238
+ read_command_output: { verb: 'output', of: ['job_id'] },
239
+ stop_command: { verb: 'stop', of: ['job_id'] },
240
+ memory_search: { verb: 'recall', of: ['query'] },
241
+ memory_load: { verb: 'read memory', count: ['ids', 'node'] },
242
+ memory_commit: { verb: 'remember', count: ['nodes', 'node'] },
243
+ memory_forget: { verb: 'forget', count: ['ids', 'node'] },
244
+ skill_search: { verb: 'find skill', of: ['query'] },
245
+ skill_load: { verb: 'load skill', count: ['names', 'skill'] },
246
+ fork: { verb: 'fork', count: ['branches', 'branch'] },
247
+ final_output: { verb: 'answer' },
248
+ search_api: { verb: 'search api', of: ['query'] },
249
+ list_api: { verb: 'list api', of: ['name', 'kind'] },
250
+ grep_api: { verb: 'grep api', of: ['pattern'] },
251
+ trace_api: { verb: 'trace api', of: ['pattern'] },
252
+ };
253
+ /** The handoff tools are minted per agent, so they are matched by prefix. */
254
+ const TRANSFER = 'transfer_to_';
255
+ /** A patch names its own files, and that is the only part of it worth a row. */
256
+ const PATCHED = /\*\*\* (?:Add|Update|Delete) File:\s*(.*?)(?:\\[nr]|"|$)/g;
257
+ export function describeCall(name, preview) {
258
+ if (name.startsWith(TRANSFER)) {
259
+ return { verb: 'hand off to', subject: name.slice(TRANSFER.length) };
260
+ }
261
+ const shape = CALLS[name];
262
+ if (!shape) {
263
+ return { verb: name, subject: readable(preview) };
264
+ }
265
+ if (name === 'apply_patch') {
266
+ const files = [...preview.matchAll(PATCHED)].map((m) => m[1].trim());
267
+ return { verb: shape.verb, subject: named({ names: files, total: files.length }, 'file') };
268
+ }
269
+ if (shape.pair) {
270
+ const fields = fieldsOf(preview);
271
+ const [from, to] = shape.pair;
272
+ return { verb: shape.verb, subject: `${fields.get(from) ?? ''} → ${fields.get(to) ?? ''}` };
273
+ }
274
+ if (shape.count) {
275
+ const [key, unit] = shape.count;
276
+ return { verb: shape.verb, subject: named(itemsOf(preview, key), unit) };
277
+ }
278
+ const fields = fieldsOf(preview);
279
+ for (const key of shape.of ?? []) {
280
+ const value = fields.get(key);
281
+ if (value) {
282
+ return { verb: shape.verb, subject: value };
283
+ }
284
+ }
285
+ return { verb: shape.verb, subject: '' };
286
+ }
287
+ /**
288
+ * What a finished call has to say for itself, in the few words a row has left.
289
+ *
290
+ * A `[]` suffix means "count it": the interesting thing about a listing is how
291
+ * much came back, and printing the first two of forty entries says less than
292
+ * the number does.
293
+ */
294
+ const RESULTS = {
295
+ read_file: ['lines'],
296
+ list_dir: ['entries[]'],
297
+ find_files: ['matches[]'],
298
+ write_file: ['bytes'],
299
+ apply_patch: ['files[]'],
300
+ move_file: ['to'],
301
+ delete_file: ['path'],
302
+ run_command: ['exit_code'],
303
+ run_command_background: ['job_id'],
304
+ };
305
+ export function summarise(name, preview) {
306
+ const fields = fieldsOf(preview);
307
+ // An error is the whole of what happened, and it is what the next turn
308
+ // will be about. Nothing else on the row competes with it.
309
+ const failed = fields.get('error');
310
+ if (failed) {
311
+ return failed;
312
+ }
313
+ const parts = [];
314
+ for (const key of RESULTS[name] ?? []) {
315
+ if (key.endsWith('[]')) {
316
+ const of = key.slice(0, -2);
317
+ const { total } = itemsOf(preview, of);
318
+ if (total) {
319
+ parts.push(`${total} ${of}`);
320
+ }
321
+ continue;
322
+ }
323
+ const value = fields.get(key);
324
+ if (value !== undefined && value !== '') {
325
+ parts.push(`${key.replace(/_/g, ' ')} ${value}`);
326
+ }
327
+ }
328
+ return parts.length ? parts.join(' · ') : readable(preview);
329
+ }
330
+ // ---------------------------------------------------------------------------
331
+ // The gist of a reasoning stream
332
+ // ---------------------------------------------------------------------------
333
+ /** How much of a stream is looked at for a heading. Its end is where it got to. */
334
+ const GIST_TAIL = 4000;
335
+ /** A reasoning summary writes its own headings, in bold. */
336
+ const BOLD = /\*\*([^*\n]{2,}?)\*\*/g;
337
+ /**
338
+ * One line saying where the model has got to.
339
+ *
340
+ * Reasoning summaries arrive as markdown and are structured by their own bold
341
+ * headings — Gemini emits one every few sentences, and they are the shortest
342
+ * true account of the step. The last one is where the model is now. Failing
343
+ * that the raw text is clipped, which is a fragment, but a moving fragment is
344
+ * still proof that something is happening.
345
+ */
346
+ export function gistOf(text, width) {
347
+ const tail = text.slice(-GIST_TAIL);
348
+ const heads = [...tail.matchAll(BOLD)];
349
+ const last = heads.at(-1)?.[1];
350
+ return clip(last ?? tail, width);
351
+ }
179
352
  /**
180
353
  * Splits an answer on ``` fences. Prose is left exactly as it was — the common
181
354
  * answer has no fence in it and comes back in one piece — but a fenced block is
package/dist/validate.js CHANGED
@@ -14,7 +14,6 @@ const CONFIG_NAMES = ['agents.yaml', 'agents.yml', 'agents/agents.yaml', 'agents
14
14
  const AGENTS_DIR = 'agents';
15
15
  const HOUSE_RULES = 'agents/instructions.md';
16
16
  const INSTRUCTIONS_SUFFIX = '-instructions.md';
17
- const LEGACY_HOUSE_RULES = 'INSTRUCTIONS.md';
18
17
  const PROMPTS_DIR = 'agents/prompts';
19
18
  const SKILLS_DIR = 'agents/skills';
20
19
  const SKILL_FILE = 'SKILL.md';
@@ -22,9 +21,9 @@ const ASSETS_DIR = 'assets';
22
21
  /** What `allow:` and `preload:` accept, so a skill outside it cannot be named. */
23
22
  const REFERABLE = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
24
23
  /**
25
- * Mirrors `readHouseRules` in the loader, in the same order: the legacy file
26
- * first, then `agents/instructions.md`, then every
27
- * `agents/<topic>-instructions.md` by filename.
24
+ * Mirrors `readHouseRules` in the loader, in the same order:
25
+ * `agents/instructions.md`, then every `agents/<topic>-instructions.md` by
26
+ * filename.
28
27
  */
29
28
  function houseRules(root) {
30
29
  const found = [];
@@ -33,7 +32,6 @@ function houseRules(root) {
33
32
  found.push(rel);
34
33
  }
35
34
  };
36
- take(LEGACY_HOUSE_RULES);
37
35
  take(HOUSE_RULES);
38
36
  const dir = join(root, AGENTS_DIR);
39
37
  if (!existsSync(dir) || !statSync(dir).isDirectory()) {
@@ -193,17 +191,6 @@ export async function validateProject(opts) {
193
191
  'prompt alone, with nothing shared between them',
194
192
  });
195
193
  }
196
- if (rules.includes(LEGACY_HOUSE_RULES)) {
197
- add({
198
- severity: 'warning',
199
- code: 'house-rules.legacy',
200
- where: LEGACY_HOUSE_RULES,
201
- message: `${LEGACY_HOUSE_RULES} is where the house rules used to live. It is still ` +
202
- `read, and still first, but the layout now keeps them under ${AGENTS_DIR}/ ` +
203
- 'so a project can have more than one of them',
204
- fix: `move it to ${HOUSE_RULES}, or fold it into the ${AGENTS_DIR}/*${INSTRUCTIONS_SUFFIX} files already there`,
205
- });
206
- }
207
194
  // -----------------------------------------------------------------------
208
195
  // Agents
209
196
  // -----------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/cli",
3
- "version": "1.1.18",
3
+ "version": "1.1.19",
4
4
  "description": "Command-line front end for @zenera/neo: agentic projects you can run, share and commit.",
5
5
  "keywords": [
6
6
  "agents",
@@ -51,7 +51,7 @@
51
51
  "@inkjs/ui": "^2.0.0",
52
52
  "ink": "^7.1.1",
53
53
  "react": "^19.2.8",
54
- "@zenera/neo": "^1.1.18",
54
+ "@zenera/neo": "^1.1.19",
55
55
  "@anthropic-ai/sdk": "^0.120.0",
56
56
  "@google/genai": "^2.18.0",
57
57
  "@openrouter/sdk": "^1.2.80",
@@ -223,6 +223,7 @@ my-project/
223
223
  ├── agents/
224
224
  │ ├── instructions.md house rules, prepended to every agent
225
225
  │ ├── memory-instructions.md more of them - any `<topic>-instructions.md`
226
+ │ ├── tools-instructions.md and more - how tools are called
226
227
  │ ├── prompts/
227
228
  │ │ ├── intake.md one agent's own brief
228
229
  │ │ └── adjuster.md
@@ -247,8 +248,7 @@ The config is found by name, in order: `agents.yaml`, `agents.yml`,
247
248
 
248
249
  The house rules are `agents/instructions.md` plus every
249
250
  `agents/<topic>-instructions.md`, read in filename order and prepended to every
250
- agent (§3.2). A project written before the move keeps its `INSTRUCTIONS.md` in
251
- the root; that still loads, and `zen check` says to move it.
251
+ agent (§3.2).
252
252
 
253
253
  ### 2.2 Variants
254
254
 
@@ -33,8 +33,6 @@ checked too. A word that is neither is a usage error (exit 2), not a report.
33
33
  rather than ignored.
34
34
  - Every file the configuration names is on disk and non-empty: the house rules
35
35
  under `agents/`, each agent's prompt, each skill's `SKILL.md`, each asset glob.
36
- A root `INSTRUCTIONS.md` is still read, and reported as something to move to
37
- `agents/instructions.md`.
38
36
  - Hand-offs and forks name agents that exist, and no agent hands off to itself.
39
37
  - Tool selectors resolve against the real tool set, including a skill's own
40
38
  `tools:` frontmatter.
@@ -22,6 +22,7 @@ zenera.json { version, name }
22
22
  agents.yaml the configuration
23
23
  agents/instructions.md house rules, prepended to every agent
24
24
  agents/memory-instructions.md more of them, about the memory store
25
+ agents/tools-instructions.md more of them, about how tools are called
25
26
  agents/prompts/default.md the default agent's prompt
26
27
  agents/skills/ empty, for skills
27
28
  assets/README.md