@zenera/cli 1.1.25 → 1.1.27

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.
Files changed (96) hide show
  1. package/README.md +13 -1
  2. package/dist/catalog.d.ts +20 -0
  3. package/dist/catalog.js +60 -0
  4. package/dist/commands/index.js +2 -0
  5. package/dist/commands/inspect.d.ts +5 -1
  6. package/dist/commands/inspect.js +54 -14
  7. package/dist/commands/memory.js +28 -3
  8. package/dist/commands/meta.js +24 -5
  9. package/dist/commands/models.js +2 -1
  10. package/dist/commands/sandbox.js +73 -3
  11. package/dist/commands/view.d.ts +8 -0
  12. package/dist/commands/view.js +114 -0
  13. package/dist/engine.d.ts +3 -1
  14. package/dist/engine.js +6 -0
  15. package/dist/eventlog.d.ts +5 -0
  16. package/dist/eventlog.js +25 -1
  17. package/dist/liveness.js +20 -2
  18. package/dist/main.js +0 -0
  19. package/dist/meta/editor.d.ts +1 -1
  20. package/dist/meta/editor.js +4 -21
  21. package/dist/meta/finetune/analyze-brief.d.ts +26 -0
  22. package/dist/meta/finetune/analyze-brief.js +131 -0
  23. package/dist/meta/finetune/apply-brief.d.ts +23 -0
  24. package/dist/meta/finetune/apply-brief.js +247 -0
  25. package/dist/meta/finetune/brief.d.ts +45 -0
  26. package/dist/meta/finetune/brief.js +158 -0
  27. package/dist/meta/finetune/command.d.ts +1 -1
  28. package/dist/meta/finetune/command.js +224 -46
  29. package/dist/meta/finetune/cost.d.ts +46 -0
  30. package/dist/meta/finetune/cost.js +105 -0
  31. package/dist/meta/finetune/dataset/types.d.ts +3 -1
  32. package/dist/meta/finetune/feedback.d.ts +14 -2
  33. package/dist/meta/finetune/feedback.js +22 -3
  34. package/dist/meta/finetune/guard.d.ts +29 -0
  35. package/dist/meta/finetune/guard.js +104 -0
  36. package/dist/meta/finetune/improvements.d.ts +50 -3
  37. package/dist/meta/finetune/improvements.js +290 -33
  38. package/dist/meta/finetune/live.d.ts +17 -7
  39. package/dist/meta/finetune/live.js +56 -10
  40. package/dist/meta/finetune/loop.js +59 -10
  41. package/dist/meta/finetune/recheck.d.ts +58 -0
  42. package/dist/meta/finetune/recheck.js +311 -0
  43. package/dist/meta/finetune/report.d.ts +9 -2
  44. package/dist/meta/finetune/report.js +551 -438
  45. package/dist/meta/finetune/sampler.js +8 -2
  46. package/dist/meta/finetune/train.d.ts +6 -2
  47. package/dist/meta/finetune/train.js +55 -21
  48. package/dist/meta/finetune/tui.d.ts +3 -3
  49. package/dist/meta/finetune/tui.js +37 -12
  50. package/dist/meta/finetune/tuning.d.ts +27 -1
  51. package/dist/meta/finetune/tuning.js +21 -2
  52. package/dist/meta/finetune/usage.d.ts +4 -0
  53. package/dist/meta/finetune/usage.js +35 -0
  54. package/dist/meta/finetune/view.d.ts +9 -0
  55. package/dist/meta/finetune/view.js +27 -0
  56. package/dist/meta/host.d.ts +2 -1
  57. package/dist/meta/host.js +2 -1
  58. package/dist/meta.d.ts +291 -0
  59. package/dist/meta.js +981 -0
  60. package/dist/resources.d.ts +215 -0
  61. package/dist/resources.js +856 -0
  62. package/dist/scaffold.d.ts +3 -4
  63. package/dist/scaffold.js +21 -9
  64. package/dist/trace.d.ts +5 -0
  65. package/dist/trace.js +54 -5
  66. package/dist/tui/app.js +19 -5
  67. package/dist/tui/links.d.ts +27 -0
  68. package/dist/tui/links.js +142 -0
  69. package/dist/tui/models.d.ts +3 -1
  70. package/dist/tui/models.js +65 -7
  71. package/dist/tui/sandbox.d.ts +10 -0
  72. package/dist/tui/sandbox.js +131 -0
  73. package/package.json +3 -2
  74. package/templates/editor/.github/copilot-instructions.md +29 -43
  75. package/templates/editor/.github/prompts/analyze.prompt.md +3 -1
  76. package/templates/editor/.github/prompts/finetune-apply.prompt.md +221 -42
  77. package/templates/editor/.github/prompts/project-review.prompt.md +1 -1
  78. package/templates/editor/.github/skills/zen-analyze-run/SKILL.md +162 -57
  79. package/templates/editor/.github/skills/zen-cli/references/finetune.md +74 -14
  80. package/templates/editor/.github/skills/zen-cli/references/inspect.md +16 -2
  81. package/templates/editor/.github/skills/zen-cli/references/projects.md +4 -6
  82. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +25 -13
  83. package/templates/editor/.github/skills/zen-finetune/SKILL.md +74 -33
  84. package/templates/editor/.github/skills/zen-inspect/SKILL.md +14 -1
  85. package/templates/editor/.github/skills/zen-inspect-ask/SKILL.md +4 -3
  86. package/templates/editor/.github/skills/zen-instructions/SKILL.md +4 -12
  87. package/templates/editor/.github/skills/zen-memory/SKILL.md +64 -39
  88. package/templates/editor/.github/skills/zen-review/SKILL.md +6 -31
  89. package/templates/editor/.github/skills/zen-review/scripts/check-paths.sh +1 -2
  90. package/templates/editor/.github/skills/zen-review/scripts/review.sh +3 -12
  91. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +2 -1
  92. package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +2 -4
  93. package/templates/project/agents/fork-instructions.md +78 -28
  94. package/templates/project/agents/memory-instructions.md +222 -493
  95. package/templates/project/agents/tools-instructions.md +7 -0
  96. package/templates/editor/.github/skills/zen-memory/scripts/check-instructions.sh +0 -116
@@ -1,6 +1,4 @@
1
1
  export declare const TEMPLATES: string;
2
- /** How to use the memory graph: a house rules file, landing under `agents/`. */
3
- export declare const MEMORY_RULES: string;
4
2
  /**
5
3
  * The project files that are ours rather than the project's.
6
4
  *
@@ -32,13 +30,14 @@ export interface CopyOptions {
32
30
  /**
33
31
  * Copies one template directory into `dir` at `rel`, depth first, sorted so the
34
32
  * list it returns is the same on every machine. Only a `.tmpl` is read as text;
35
- * everything else is copied byte for byte.
33
+ * everything else is copied byte for byte. A file already holding those bytes
34
+ * is left alone and not listed.
36
35
  */
37
36
  export declare function copyTree(from: string, dir: string, rel: string, opts: CopyOptions): string[];
38
37
  /**
39
38
  * Replaces every file in a project that nobody is meant to be maintaining:
40
39
  * `SHARED_RULES` in the project tree, and the editor tree whole. Returns the
41
- * relative paths written, project files first.
40
+ * relative paths that actually changed, project files first.
42
41
  *
43
42
  * This is the other half of `keep: true`. A scaffold never overwrites, which is
44
43
  * right for the files a project goes on to make its own and wrong for the ones
package/dist/scaffold.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
2
2
  import { dirname, join, sep } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { editorFiles } from "./meta/editor.js";
@@ -19,7 +19,6 @@ import { editorFiles } from "./meta/editor.js";
19
19
  //
20
20
  // The trees are copied whole and nothing enumerates them, so adding a file to
21
21
  // a new project is adding a file to `templates/project/` and nothing else.
22
- // `MEMORY_RULES` is the single exception, and it is named below.
23
22
  //
24
23
  // What is there is deliberately close to empty: a template full of
25
24
  // commented-out options is a template nobody reads and everybody deletes. The
@@ -37,7 +36,7 @@ const TEMPLATE = '.tmpl';
37
36
  /** How to use file tools: a house rules file, landing under `agents/`. */
38
37
  const FILE_RULES = join('agents', 'files-instructions.md');
39
38
  /** How to use the memory graph: a house rules file, landing under `agents/`. */
40
- export const MEMORY_RULES = join('agents', 'memory-instructions.md');
39
+ const MEMORY_RULES = join('agents', 'memory-instructions.md');
41
40
  /** How tools are called: the same, and equally not the project's to maintain. */
42
41
  const TOOL_RULES = join('agents', 'tools-instructions.md');
43
42
  /** How forking behaves: the same again, reaching only agents that declare `fork:`. */
@@ -143,10 +142,20 @@ function target(name) {
143
142
  }
144
143
  return DOTFILES[name] ?? name;
145
144
  }
145
+ /** Writes `body` to `path` unless those are already its bytes; true if it wrote. */
146
+ function writeIfChanged(path, body) {
147
+ const bytes = typeof body === 'string' ? Buffer.from(body) : body;
148
+ if (existsSync(path) && readFileSync(path).equals(bytes)) {
149
+ return false;
150
+ }
151
+ writeFileSync(path, bytes);
152
+ return true;
153
+ }
146
154
  /**
147
155
  * Copies one template directory into `dir` at `rel`, depth first, sorted so the
148
156
  * list it returns is the same on every machine. Only a `.tmpl` is read as text;
149
- * everything else is copied byte for byte.
157
+ * everything else is copied byte for byte. A file already holding those bytes
158
+ * is left alone and not listed.
150
159
  */
151
160
  export function copyTree(from, dir, rel, opts) {
152
161
  const written = [];
@@ -166,19 +175,21 @@ export function copyTree(from, dir, rel, opts) {
166
175
  const body = entry.name.endsWith(TEMPLATE)
167
176
  ? render(readFileSync(source, 'utf8'), opts.vars ?? {})
168
177
  : readFileSync(source);
169
- writeFileSync(join(dir, child), body);
178
+ const changed = writeIfChanged(join(dir, child), body);
170
179
  // The bytes are copied, not the file, so a script arrives unrunnable.
171
180
  if (child.endsWith('.sh') || child.endsWith('.mjs')) {
172
181
  chmodSync(join(dir, child), 0o755);
173
182
  }
174
- written.push(child);
183
+ if (changed) {
184
+ written.push(child);
185
+ }
175
186
  }
176
187
  return written;
177
188
  }
178
189
  /**
179
190
  * Replaces every file in a project that nobody is meant to be maintaining:
180
191
  * `SHARED_RULES` in the project tree, and the editor tree whole. Returns the
181
- * relative paths written, project files first.
192
+ * relative paths that actually changed, project files first.
182
193
  *
183
194
  * This is the other half of `keep: true`. A scaffold never overwrites, which is
184
195
  * right for the files a project goes on to make its own and wrong for the ones
@@ -190,8 +201,9 @@ export function refreshShared(dir) {
190
201
  for (const rel of SHARED_RULES) {
191
202
  const path = join(dir, rel);
192
203
  mkdirSync(dirname(path), { recursive: true });
193
- copyFileSync(join(TEMPLATES, 'project', rel), path);
194
- written.push(rel);
204
+ if (writeIfChanged(path, readFileSync(join(TEMPLATES, 'project', rel)))) {
205
+ written.push(rel);
206
+ }
195
207
  }
196
208
  written.push(...editorFiles(dir));
197
209
  return written;
package/dist/trace.d.ts CHANGED
@@ -81,4 +81,9 @@ export interface NodeDetail {
81
81
  */
82
82
  export declare function parseNodeIds(words: readonly string[], known: Map<string, unknown>): string[];
83
83
  export declare function nodeDetail(trace: Trace, ids: readonly string[], payloads: PayloadResolver): Promise<NodeDetail[]>;
84
+ /**
85
+ * The two questions a recorded request is usually opened for, answered without
86
+ * printing it: which tools the call was offered, and what conversation it saw.
87
+ */
88
+ export declare function requestParts(json: string): NodePart[];
84
89
  //# sourceMappingURL=trace.d.ts.map
package/dist/trace.js CHANGED
@@ -1,4 +1,4 @@
1
- import { coveredIds, totalUsage, } from '@zenera/neo';
1
+ import { contentToText, coveredIds, totalUsage, } from '@zenera/neo';
2
2
  import { format } from "./narrate.js";
3
3
  import { describeCall, summarise } from "./tui/wrap.js";
4
4
  // ---------------------------------------------------------------------------
@@ -719,12 +719,61 @@ export async function nodeDetail(trace, ids, payloads) {
719
719
  ts: e.node.ts,
720
720
  label: labelOf(e.node, {}),
721
721
  facts: factsOf(e.node),
722
- parts: (named.get(e) ?? []).map((part) => ({
723
- name: part.name,
724
- text: part.literal ?? blobs.get(part.payload?.sha256 ?? '') ?? '(unresolved)',
725
- })),
722
+ parts: (named.get(e) ?? []).flatMap((part) => {
723
+ const text = part.literal ?? blobs.get(part.payload?.sha256 ?? '') ?? '(unresolved)';
724
+ return part.name === 'request'
725
+ ? [{ name: part.name, text }, ...requestParts(text)]
726
+ : [{ name: part.name, text }];
727
+ }),
726
728
  }));
727
729
  }
730
+ /** Longest a message or a tool result runs in `messages` before it is clipped. */
731
+ const MESSAGE_CLIP = 400;
732
+ const clip = (s, n = MESSAGE_CLIP) => {
733
+ const flat = s.replace(/\s+/g, ' ').trim();
734
+ return flat.length <= n ? flat : `${flat.slice(0, n)} … (+${flat.length - n} chars)`;
735
+ };
736
+ /**
737
+ * The two questions a recorded request is usually opened for, answered without
738
+ * printing it: which tools the call was offered, and what conversation it saw.
739
+ */
740
+ export function requestParts(json) {
741
+ let req;
742
+ try {
743
+ req = JSON.parse(json);
744
+ }
745
+ catch {
746
+ return [];
747
+ }
748
+ const out = [];
749
+ if (Array.isArray(req.messages)) {
750
+ const lines = req.messages.map((m) => {
751
+ switch (m.role) {
752
+ case 'system':
753
+ return `[system] ${clip(m.content, 200)}`;
754
+ case 'user':
755
+ return `[user] ${clip(contentToText(m.content))}`;
756
+ case 'assistant':
757
+ return [
758
+ `[assistant${m.agent ? ` @${m.agent}` : ''}] ${clip(m.content) || '(no text)'}`,
759
+ ...(m.toolCalls ?? []).map((c) => ` -> ${c.name} ${clip(c.args, 300)} #${c.id}`),
760
+ ].join('\n');
761
+ case 'tool':
762
+ return `[tool ${m.name} #${m.callId}${m.isError ? ' ERROR' : ''}] ${clip(m.content)}`;
763
+ default:
764
+ return `[${m.role ?? '?'}]`;
765
+ }
766
+ });
767
+ out.push({ name: 'messages', text: lines.join('\n') });
768
+ }
769
+ if (Array.isArray(req.tools)) {
770
+ const names = req.tools
771
+ .map((t) => t?.name)
772
+ .filter((n) => typeof n === 'string');
773
+ out.push({ name: 'tools', text: names.join('\n') });
774
+ }
775
+ return out;
776
+ }
728
777
  const GONE = '(the blob is not in this session’s store; only the preview survives)';
729
778
  async function resolve(payloads, p) {
730
779
  try {
package/dist/tui/app.js CHANGED
@@ -9,6 +9,7 @@ import { History } from "../history.js";
9
9
  import { format } from "../narrate.js";
10
10
  import { display } from "../session.js";
11
11
  import { CliError } from "../term.js";
12
+ import { hostUrls, hyperlink, hyperlinks, linkBlock, mountsOf } from "./links.js";
12
13
  import { resolveTheme, THEMES } from "./theme.js";
13
14
  import { ACTIVITY_ROWS, answerWidth, blocksOf, BOX_CHROME, boxWidth, BRANCH_ROWS, branchRows, budgetOf, CHROME_ROWS, clip, describeCall, gistOf, gridOf, summarise, THINKING_ROWS, TIME_COL, unmarked, windowOf, } from "./wrap.js";
14
15
  const BANNER = { key: 'banner' };
@@ -30,6 +31,9 @@ const FRAME_MS = 100;
30
31
  // A context rather than props only because every part of the view wants it.
31
32
  const ThemeContext = React.createContext(THEMES.dark);
32
33
  const useTheme = () => useContext(ThemeContext);
34
+ const LinksContext = React.createContext(undefined);
35
+ /** Without hyperlinks the only way to reach the file is to print where it is. */
36
+ const shownText = (text, links) => links && !links.osc8 ? hostUrls(text, links.mounts) : text;
33
37
  function Row({ line }) {
34
38
  const theme = useTheme();
35
39
  const style = theme.line[line.kind];
@@ -87,7 +91,10 @@ function Call({ line }) {
87
91
  */
88
92
  function Spans({ spans }) {
89
93
  const theme = useTheme();
90
- return (_jsx(_Fragment, { children: spans.map((s, i) => (_jsx(Text, { bold: s.bold === true, italic: s.italic === true, underline: s.href !== undefined && s.href !== '', ...(s.code ? { color: theme.code.color } : {}), children: s.text }, i))) }));
94
+ const links = useContext(LinksContext);
95
+ return (_jsx(_Fragment, { children: spans.map((s, i) => (_jsx(Text, { bold: s.bold === true, italic: s.italic === true, underline: s.href !== undefined && s.href !== '', ...(s.code ? { color: theme.code.color } : {}), children: links?.osc8 && s.href?.startsWith('file:')
96
+ ? hyperlink(s.href, s.text)
97
+ : s.text }, i))) }));
91
98
  }
92
99
  /**
93
100
  * One block of an answer.
@@ -128,11 +135,13 @@ function BlockView({ block, gap, width, }) {
128
135
  * answer and boxing each of them turns the transcript into a stack of frames;
129
136
  * the box means *this is the answer*, and it only means that if it is rare.
130
137
  */
131
- function Answer({ text, boxed }) {
138
+ function Answer({ text: said, boxed }) {
132
139
  const theme = useTheme();
140
+ const links = useContext(LinksContext);
133
141
  const { stdout } = useStdout();
142
+ const text = shownText(said, links);
134
143
  const width = boxWidth(text, stdout?.columns ?? 80);
135
- const blocks = blocksOf(text);
144
+ const blocks = blocksOf(text).map((b) => (links?.osc8 ? linkBlock(b, links.mounts) : b));
136
145
  const inner = width - (boxed ? 4 : GUTTER);
137
146
  const body = blocks.map((block, i) => (
138
147
  // Items of one list are one thing; a blank row between them is two
@@ -145,6 +154,10 @@ function Answer({ text, boxed }) {
145
154
  function App({ engine, options, theme }) {
146
155
  const { exit } = useApp();
147
156
  const { stdout } = useStdout();
157
+ const [links] = useState(() => ({
158
+ mounts: mountsOf(engine.files),
159
+ osc8: hyperlinks(),
160
+ }));
148
161
  const [lines, setLines] = useState([]);
149
162
  const [live, setLive] = useState('');
150
163
  const [thinking, setThinking] = useState('');
@@ -651,7 +664,7 @@ function App({ engine, options, theme }) {
651
664
  recall(1);
652
665
  }
653
666
  });
654
- return (_jsx(ThemeContext.Provider, { value: theme, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [BANNER, ...lines], children: (item) => isBanner(item) ? (_jsx(Header, { engine: engine, readOnly: options.readOnly, started: options.started }, item.key)) : (_jsx(Row, { line: item }, item.key)) }), _jsxs(Box, { flexDirection: "column", justifyContent: "flex-end", height: busy ? grown.current : undefined, overflow: "hidden", children: [_jsx(Branches, { boxes: fitted.boxes, spin: spin, frame: frame, columns: columns }), streaming && budget.thinking ? (_jsx(Reasoning, { text: thinking, columns: columns, frame: frame, live: musing })) : null, _jsx(Activity, { rows: fitted.trunk, hidden: fitted.hidden, frame: frame }), live ? _jsx(Streaming, { text: live, columns: columns, rows: textRows }) : null] }), _jsx(Footer, { agent: agent, busy: busy, spin: spin, frame: frame, running: mine, forked: branches.current.size, step: step, model: model, stats: stats, inflight: inflight, reasoning: musing && streaming }), busy ? null : (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "\u203A " }), _jsx(TextInput, { defaultValue: draft, placeholder: "Ask something\u2026 (\u2191 for history, /help for commands)", onChange: remember, onSubmit: submit }, generation)] }))] }) }));
667
+ return (_jsx(ThemeContext.Provider, { value: theme, children: _jsx(LinksContext.Provider, { value: links, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [BANNER, ...lines], children: (item) => isBanner(item) ? (_jsx(Header, { engine: engine, readOnly: options.readOnly, started: options.started }, item.key)) : (_jsx(Row, { line: item }, item.key)) }), _jsxs(Box, { flexDirection: "column", justifyContent: "flex-end", height: busy ? grown.current : undefined, overflow: "hidden", children: [_jsx(Branches, { boxes: fitted.boxes, spin: spin, frame: frame, columns: columns }), streaming && budget.thinking ? (_jsx(Reasoning, { text: thinking, columns: columns, frame: frame, live: musing })) : null, _jsx(Activity, { rows: fitted.trunk, hidden: fitted.hidden, frame: frame }), live ? _jsx(Streaming, { text: live, columns: columns, rows: textRows }) : null] }), _jsx(Footer, { agent: agent, busy: busy, spin: spin, frame: frame, running: mine, forked: branches.current.size, step: step, model: model, stats: stats, inflight: inflight, reasoning: musing && streaming }), busy ? null : (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "\u203A " }), _jsx(TextInput, { defaultValue: draft, placeholder: "Ask something\u2026 (\u2191 for history, /help for commands)", onChange: remember, onSubmit: submit }, generation)] }))] }) }) }));
655
668
  }
656
669
  function Header({ engine, readOnly, started, }) {
657
670
  const theme = useTheme();
@@ -952,7 +965,8 @@ function Reasoning({ text, columns, frame, live, }) {
952
965
  const gist = gistOf(text, columns - GUTTER - 2);
953
966
  return (_jsx(Box, { height: 1, paddingLeft: GUTTER, overflow: "hidden", children: _jsx(Text, { wrap: "truncate-end", children: live ? (_jsx(Shimmer, { text: gist, frame: frame, color: theme.thinking.color })) : (_jsx(Text, { color: theme.thinking.color, children: gist })) }) }));
954
967
  }
955
- function Streaming({ text, columns, rows }) {
968
+ function Streaming({ text: said, columns, rows }) {
969
+ const text = shownText(said, useContext(LinksContext));
956
970
  // The same width the finished answer will take, so landing it reflows
957
971
  // nothing: what is on screen is what stays there.
958
972
  //
@@ -0,0 +1,27 @@
1
+ import type { Workspace } from '@zenera/neo';
2
+ import type { Block, Span } from './wrap.ts';
3
+ export interface Mounts {
4
+ /** every name a tree is reached by, longest first */
5
+ names: readonly string[];
6
+ /** the host path an agent path names, or nothing when it is in no tree */
7
+ resolve(path: string): string | undefined;
8
+ }
9
+ /** Exactly the trees the file tools reach, under the same containment rule. */
10
+ export declare function mountsOf(files: Workspace): Mounts;
11
+ /** The host `file:` url a mounted path names, or nothing. */
12
+ export declare function mountUrl(token: string, mounts: Mounts): string | undefined;
13
+ /**
14
+ * The same spans with the same text, mounted paths carrying a host `href`.
15
+ *
16
+ * Only `href` changes, so every width measured from `span.text` still holds.
17
+ */
18
+ export declare function linkSpans(spans: readonly Span[], mounts: Mounts): Span[];
19
+ /** A block whose prose and cells link their mounted paths; a fence is left alone. */
20
+ export declare function linkBlock(block: Block, mounts: Mounts): Block;
21
+ /** For a terminal with no hyperlinks: every `file:///<mount>/…` url, rewritten to the host. */
22
+ export declare function hostUrls(text: string, mounts: Mounts): string;
23
+ /** `text` as an OSC 8 hyperlink to `href`. */
24
+ export declare function hyperlink(href: string, text: string): string;
25
+ /** Whether this terminal turns OSC 8 into a link; an unknown one is assumed not to. */
26
+ export declare function hyperlinks(env?: NodeJS.ProcessEnv): boolean;
27
+ //# sourceMappingURL=links.d.ts.map
@@ -0,0 +1,142 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ /** Exactly the trees the file tools reach, under the same containment rule. */
3
+ export function mountsOf(files) {
4
+ const names = [...(files.mount ? [files.mount] : []), ...files.mounts.map((m) => m.at)];
5
+ return {
6
+ names: names.sort((a, b) => b.length - a.length),
7
+ resolve: (path) => {
8
+ try {
9
+ return files.within(path);
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ },
15
+ };
16
+ }
17
+ const FILE = 'file://';
18
+ /** Prose punctuation that ends a sentence, not a path. */
19
+ const TRAILING = /[.,;:!?'"]+$/;
20
+ /** The host `file:` url a mounted path names, or nothing. */
21
+ export function mountUrl(token, mounts) {
22
+ let path = token;
23
+ let fragment = '';
24
+ if (path.startsWith(FILE)) {
25
+ path = path.slice(FILE.length);
26
+ const cut = path.search(/[?#]/);
27
+ if (cut >= 0) {
28
+ fragment = path.slice(cut);
29
+ path = path.slice(0, cut);
30
+ }
31
+ try {
32
+ path = decodeURIComponent(path);
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ // `within` also takes relative paths and `/`; only a mount name counts here.
39
+ if (!mounts.names.some((n) => path === n || path.startsWith(`${n}/`))) {
40
+ return undefined;
41
+ }
42
+ const host = mounts.resolve(path);
43
+ return host ? pathToFileURL(host).href + fragment : undefined;
44
+ }
45
+ const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, (c) => `\\${c}`);
46
+ /** Every spelling of a mounted path in running text. */
47
+ function pattern(mounts) {
48
+ const names = mounts.names.map(escaped).join('|');
49
+ return new RegExp(`(?<![\\w/.:-])(?:${FILE})?(?:${names})(?![\\w-]|\\.\\w)(?:/[^\\s<>"'\`()[\\]{}|\\\\^]*)?`, 'g');
50
+ }
51
+ /** Each mounted path in `text`, without the punctuation that follows it. */
52
+ function* matches(text, mounts) {
53
+ if (mounts.names.length === 0) {
54
+ return;
55
+ }
56
+ for (const m of text.matchAll(pattern(mounts))) {
57
+ const token = m[0].replace(TRAILING, '');
58
+ if (token) {
59
+ yield { at: m.index, token };
60
+ }
61
+ }
62
+ }
63
+ /**
64
+ * The same spans with the same text, mounted paths carrying a host `href`.
65
+ *
66
+ * Only `href` changes, so every width measured from `span.text` still holds.
67
+ */
68
+ export function linkSpans(spans, mounts) {
69
+ const out = [];
70
+ for (const span of spans) {
71
+ if (span.href) {
72
+ const url = mountUrl(span.href, mounts);
73
+ out.push(url ? { ...span, href: url } : span);
74
+ continue;
75
+ }
76
+ let from = 0;
77
+ for (const { at, token } of matches(span.text, mounts)) {
78
+ const url = mountUrl(token, mounts);
79
+ if (!url) {
80
+ continue;
81
+ }
82
+ if (at > from) {
83
+ out.push({ ...span, text: span.text.slice(from, at) });
84
+ }
85
+ out.push({ ...span, text: token, href: url });
86
+ from = at + token.length;
87
+ }
88
+ if (from === 0) {
89
+ out.push(span);
90
+ }
91
+ else if (from < span.text.length) {
92
+ out.push({ ...span, text: span.text.slice(from) });
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+ /** A block whose prose and cells link their mounted paths; a fence is left alone. */
98
+ export function linkBlock(block, mounts) {
99
+ return {
100
+ ...block,
101
+ ...(block.spans ? { spans: linkSpans(block.spans, mounts) } : {}),
102
+ ...(block.cells
103
+ ? { cells: block.cells.map((row) => row.map((cell) => linkSpans(cell, mounts))) }
104
+ : {}),
105
+ };
106
+ }
107
+ /** For a terminal with no hyperlinks: every `file:///<mount>/…` url, rewritten to the host. */
108
+ export function hostUrls(text, mounts) {
109
+ let out = '';
110
+ let from = 0;
111
+ for (const { at, token } of matches(text, mounts)) {
112
+ const url = token.startsWith(FILE) ? mountUrl(token, mounts) : undefined;
113
+ if (!url) {
114
+ continue;
115
+ }
116
+ out += text.slice(from, at) + url;
117
+ from = at + token.length;
118
+ }
119
+ return out + text.slice(from);
120
+ }
121
+ /** `text` as an OSC 8 hyperlink to `href`. */
122
+ export function hyperlink(href, text) {
123
+ return `\u001b]8;;${href}\u001b\\${text}\u001b]8;;\u001b\\`;
124
+ }
125
+ /** Whether this terminal turns OSC 8 into a link; an unknown one is assumed not to. */
126
+ export function hyperlinks(env = process.env) {
127
+ const forced = env.FORCE_HYPERLINK;
128
+ if (forced !== undefined && forced !== '') {
129
+ return forced !== '0' && forced !== 'false';
130
+ }
131
+ if (env.WT_SESSION || env.KITTY_WINDOW_ID || env.KONSOLE_VERSION) {
132
+ return true;
133
+ }
134
+ if (['iTerm.app', 'vscode', 'WezTerm', 'ghostty'].includes(env.TERM_PROGRAM ?? '')) {
135
+ return true;
136
+ }
137
+ if (env.TERM === 'xterm-kitty' || env.TERM === 'xterm-ghostty') {
138
+ return true;
139
+ }
140
+ return Number(env.VTE_VERSION ?? 0) >= 5000;
141
+ }
142
+ //# sourceMappingURL=links.js.map
@@ -1,4 +1,4 @@
1
- import { type Catalog, type CatalogEntry } from '../catalog.ts';
1
+ import { type Balance, type Catalog, type CatalogEntry } from '../catalog.ts';
2
2
  import type { Provider } from '../keys.ts';
3
3
  export interface ProviderRow {
4
4
  provider: Provider;
@@ -23,6 +23,8 @@ export interface BrowseOptions {
23
23
  initial?: Provider;
24
24
  theme?: string;
25
25
  load(provider: Provider, refresh: boolean): Promise<Catalog>;
26
+ /** what the account has left to spend, for providers that say */
27
+ balance?(provider: Provider): Promise<Balance | undefined>;
26
28
  test(entry: CatalogEntry): Promise<Verdict>;
27
29
  }
28
30
  /**
@@ -17,7 +17,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
17
17
  // ---------------------------------------------------------------------------
18
18
  import { Box, render, Text, useApp, useInput, useStdout } from 'ink';
19
19
  import React, { useEffect, useMemo, useRef, useState } from 'react';
20
- import { byGroup, isPartner, matches, } from "../catalog.js";
20
+ import { byGroup, isPartner, matches, perMillion, } from "../catalog.js";
21
21
  import { ago } from "../term.js";
22
22
  import { resolveTheme } from "./theme.js";
23
23
  import { clip, scrollTop, wrap } from "./wrap.js";
@@ -25,6 +25,25 @@ const ROLES = ['chat', 'embedding', 'image', 'audio'];
25
25
  const message = (err) => (err instanceof Error ? err.message : String(err));
26
26
  const thousands = (n) => n === undefined ? '' : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
27
27
  const ms = (n) => (n < 1000 ? `${n}ms` : `${(n / 1000).toFixed(1)}s`);
28
+ const money = (n) => {
29
+ const abs = Math.abs(n);
30
+ return `${n < 0 ? '-' : ''}$${abs !== 0 && abs < 0.01 ? abs.toFixed(4) : abs.toFixed(2)}`;
31
+ };
32
+ /** The balance as one line; `short` for the providers screen. */
33
+ function account(b, short) {
34
+ const parts = [];
35
+ if (b.credits !== undefined) {
36
+ parts.push(`balance ${money(b.credits)}`);
37
+ }
38
+ if (b.cap && !short) {
39
+ const reset = b.cap.reset ? ` ${b.cap.reset}` : '';
40
+ parts.push(`key cap ${money(b.cap.remaining)} left of ${money(b.cap.limit)}${reset}`);
41
+ }
42
+ if (b.free && !short) {
43
+ parts.push(`free ${b.free.remaining}/${b.free.limit} requests left today`);
44
+ }
45
+ return parts.join(' · ');
46
+ }
28
47
  function freshness(origin, fetchedAt) {
29
48
  switch (origin) {
30
49
  case 'curated':
@@ -87,6 +106,7 @@ function Browser({ options, theme, onPick }) {
87
106
  const [role, setRole] = useState(0);
88
107
  const [selected, setSelected] = useState(0);
89
108
  const [verdicts, setVerdicts] = useState(new Map());
109
+ const [balances, setBalances] = useState(new Map());
90
110
  const [closing, setClosing] = useState(false);
91
111
  const top = useRef(0);
92
112
  const provider = options.providers[at].provider;
@@ -102,10 +122,18 @@ function Browser({ options, theme, onPick }) {
102
122
  setLoading((now) => (now === p ? undefined : now));
103
123
  });
104
124
  };
125
+ const fetchBalance = (p) => {
126
+ options.balance?.(p).then((b) => b && setBalances((prev) => new Map(prev).set(p, b)), (err) => setBalances((prev) => new Map(prev).set(p, message(err))));
127
+ };
105
128
  useEffect(() => {
106
129
  if (start >= 0) {
107
130
  load(provider, false);
108
131
  }
132
+ for (const p of options.providers) {
133
+ if (p.credential) {
134
+ fetchBalance(p.provider);
135
+ }
136
+ }
109
137
  // Once, for `zen models browse <provider>`; every later open is a key.
110
138
  }, []);
111
139
  useEffect(() => {
@@ -153,7 +181,14 @@ function Browser({ options, theme, onPick }) {
153
181
  // what is left. A row is kept in hand: a frame as tall as the viewport is
154
182
  // the one Ink cannot erase (see tui/app.tsx).
155
183
  const problem = catalog?.problem;
156
- const chrome = 6 + (problem ? 1 : 0);
184
+ const balance = balances.get(provider);
185
+ const funds = balance === undefined
186
+ ? []
187
+ : wrap(typeof balance === 'string'
188
+ ? `balance could not be read: ${balance}`
189
+ : account(balance, false) || 'balance: nothing reported', Math.max(20, size.columns - 1));
190
+ const short = typeof balance === 'string' || (balance?.credits !== undefined && balance.credits <= 0);
191
+ const chrome = 7 + (problem ? 1 : 0) + funds.length;
157
192
  const height = Math.max(3, size.rows - 1 - chrome);
158
193
  top.current = scrollTop(index, top.current, height, rows.length);
159
194
  useInput((input, key) => {
@@ -266,6 +301,7 @@ function Browser({ options, theme, onPick }) {
266
301
  }
267
302
  else if (input === 'r') {
268
303
  load(provider, true);
304
+ fetchBalance(provider);
269
305
  }
270
306
  else if (key.escape && query) {
271
307
  setQuery('');
@@ -293,7 +329,8 @@ function Browser({ options, theme, onPick }) {
293
329
  const fresh = cat
294
330
  ? freshness(cat.origin, cat.fetchedAt)
295
331
  : freshness(p.origin, p.fetchedAt);
296
- return (_jsxs(Row, { children: [_jsxs(Text, { color: here ? theme.accent : undefined, bold: here, children: [here ? '› ' : ' ', p.provider.padEnd(nameW)] }), p.credential ? (_jsx(Text, { color: theme.code.color, children: p.credential.padEnd(15) })) : (_jsx(Text, { color: theme.line.error.color, children: 'no credential'.padEnd(15) })), _jsxs(Text, { color: theme.chrome.color, children: [`${count} model${count === 1 ? '' : 's'}`.padEnd(13), p.credential ? fresh : `zen key add ${p.provider}`] })] }, p.provider));
332
+ const b = balances.get(p.provider);
333
+ return (_jsxs(Row, { children: [_jsxs(Text, { color: here ? theme.accent : undefined, bold: here, children: [here ? '› ' : ' ', p.provider.padEnd(nameW)] }), p.credential ? (_jsx(Text, { color: theme.code.color, children: p.credential.padEnd(15) })) : (_jsx(Text, { color: theme.line.error.color, children: 'no credential'.padEnd(15) })), _jsxs(Text, { color: theme.chrome.color, children: [`${count} model${count === 1 ? '' : 's'}`.padEnd(13), p.credential ? fresh : `zen key add ${p.provider}`] }), b && typeof b !== 'string' ? (_jsx(Text, { color: theme.code.color, children: ` ${account(b, true)}` })) : null] }, p.provider));
297
334
  }), _jsx(Text, { children: " " }), _jsx(Keys, { theme: theme, text: "\u2191\u2193 move \u00B7 enter open \u00B7 q quit" })] }));
298
335
  }
299
336
  if (screen === 'detail' && current) {
@@ -302,9 +339,24 @@ function Browser({ options, theme, onPick }) {
302
339
  const header = catalog
303
340
  ? `${provider} · ${catalog.entries.length} models · ${freshness(catalog.origin, catalog.fetchedAt)}`
304
341
  : provider;
305
- const idW = Math.min(44, Math.max(8, ...rows.map((e) => e.id.length))) + 2;
342
+ const withMaker = rows.some((e) => e.publisher);
343
+ const priced = rows.some((e) => e.pricing);
344
+ const ROLE_W = Math.max(4, ...rows.map((e) => e.roles.join('+').length)) + 2;
345
+ const CTX_W = 9;
346
+ const PRICE_W = 10;
347
+ const fixed = (withMaker ? 12 : 0) + ROLE_W + CTX_W + (priced ? PRICE_W * 2 : 0) + 'test'.length + 2;
348
+ const idW = Math.max(10, Math.min(46, Math.max(8, ...rows.map((e) => e.id.length)) + 2, width - fixed));
306
349
  const shown = rows.slice(top.current, top.current + height);
307
350
  const filter = typing ? `/${query}▌` : query ? `/${query}` : '';
351
+ const columns = [
352
+ ` ${'model'.padEnd(idW)}`,
353
+ withMaker ? 'maker'.padEnd(12) : '',
354
+ 'role'.padEnd(ROLE_W),
355
+ 'context'.padStart(CTX_W - 2).padEnd(CTX_W),
356
+ priced ? 'in $/1M'.padStart(PRICE_W - 2).padEnd(PRICE_W) : '',
357
+ priced ? 'out $/1M'.padStart(PRICE_W - 2).padEnd(PRICE_W) : '',
358
+ 'test',
359
+ ].join('');
308
360
  let body;
309
361
  if (failure) {
310
362
  body = _jsx(Row, { color: theme.line.error.color, children: failure });
@@ -319,11 +371,17 @@ function Browser({ options, theme, onPick }) {
319
371
  body = (_jsx(_Fragment, { children: shown.map((e, i) => {
320
372
  const here = top.current + i === index;
321
373
  const v = said(verdicts.get(e.ref), theme);
322
- return (_jsxs(Row, { children: [_jsxs(Text, { color: here ? theme.accent : undefined, bold: here, children: [here ? '› ' : ' ', clip(e.id, idW - 2).padEnd(idW)] }), e.publisher ? (_jsx(Text, { color: isPartner(e) ? theme.warn : theme.code.color, children: clip(e.publisher, 11).padEnd(12) })) : null, _jsxs(Text, { color: theme.chrome.color, children: [e.roles.join('+').padEnd(16), thousands(e.contextLength).padStart(6), (e.pricing?.free ? ' free' : '').padEnd(7)] }), v ? _jsx(Text, { color: v.color, children: v.text }) : null] }, e.ref));
374
+ return (_jsxs(Row, { children: [_jsxs(Text, { color: here ? theme.accent : undefined, bold: here, children: [here ? '› ' : ' ', clip(e.id, idW - 2).padEnd(idW)] }), withMaker ? (_jsx(Text, { color: isPartner(e) ? theme.warn : theme.code.color, children: clip(e.publisher ?? '', 11).padEnd(12) })) : null, _jsxs(Text, { color: theme.chrome.color, children: [e.roles.join('+').padEnd(ROLE_W), thousands(e.contextLength)
375
+ .padStart(CTX_W - 2)
376
+ .padEnd(CTX_W)] }), priced ? (_jsxs(Text, { color: e.pricing?.free ? theme.code.color : undefined, children: [perMillion(e.pricing?.prompt)
377
+ .padStart(PRICE_W - 2)
378
+ .padEnd(PRICE_W), (e.pricing?.free ? '' : perMillion(e.pricing?.completion))
379
+ .padStart(PRICE_W - 2)
380
+ .padEnd(PRICE_W)] })) : null, v ? _jsx(Text, { color: v.color, children: v.text }) : null] }, e.ref));
323
381
  }) }));
324
382
  }
325
383
  const preview = current ? [current.name, current.description].filter(Boolean).join(' — ') : '';
326
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Row, { children: [_jsx(Text, { bold: true, color: theme.accent, children: header }), _jsxs(Text, { color: theme.chrome.color, children: [' role: ', only ?? 'all', filter ? ' ' : ''] }), _jsx(Text, { color: theme.code.color, children: filter })] }), problem ? (_jsxs(Row, { color: theme.warn, children: ["could not be listed: ", problem.detail ?? 'no reason given'] })) : null, _jsx(Text, { children: " " }), _jsx(Box, { flexDirection: "column", height: height, children: body }), _jsx(Row, { color: theme.chrome.color, children: clip(preview, width) || ' ' }), _jsx(Row, { color: theme.chrome.color, children: rows.length === 0
384
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Row, { children: [_jsx(Text, { bold: true, color: theme.accent, children: header }), _jsxs(Text, { color: theme.chrome.color, children: [' role: ', only ?? 'all', filter ? ' ' : ''] }), _jsx(Text, { color: theme.code.color, children: filter })] }), problem ? (_jsxs(Row, { color: theme.warn, children: ["could not be listed: ", problem.detail ?? 'no reason given'] })) : null, funds.map((line, i) => (_jsx(Row, { color: short ? theme.warn : theme.code.color, children: line }, `funds${i}`))), _jsx(Text, { children: " " }), _jsx(Row, { bold: true, color: theme.chrome.color, children: columns }), _jsx(Box, { flexDirection: "column", height: height, children: body }), _jsx(Row, { color: theme.chrome.color, children: clip(preview, width) || ' ' }), _jsx(Row, { color: theme.chrome.color, children: rows.length === 0
327
385
  ? ' '
328
386
  : `${index + 1} of ${rows.length}${rows.length < (catalog?.entries.length ?? 0) ? ` (${catalog.entries.length} in all)` : ''}` }), _jsx(Keys, { theme: theme, text: typing
329
387
  ? 'type to filter · enter keep · esc clear · ↑↓ move'
@@ -350,7 +408,7 @@ function Detail({ entry, catalog, verdict, theme, width, rows }) {
350
408
  add('pricing', entry.pricing?.free
351
409
  ? 'free'
352
410
  : entry.pricing?.prompt
353
- ? `$${entry.pricing.prompt}/token in, $${entry.pricing.completion ?? '?'}/token out`
411
+ ? `${perMillion(entry.pricing.prompt)} per 1M input tokens, ${perMillion(entry.pricing.completion) || '?'} per 1M output tokens`
354
412
  : undefined);
355
413
  add('released', entry.created);
356
414
  add('source', entry.source === 'live' && catalog
@@ -0,0 +1,10 @@
1
+ import { type ResourceSampler } from '../resources.ts';
2
+ export interface WatchOptions {
3
+ sampler: ResourceSampler;
4
+ intervalMs: number;
5
+ explain: boolean;
6
+ theme?: string;
7
+ }
8
+ /** Runs the live screen until the person leaves it. */
9
+ export declare function watch(options: WatchOptions): Promise<void>;
10
+ //# sourceMappingURL=sandbox.d.ts.map