@zenera/cli 1.1.10 → 1.1.12

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 (65) hide show
  1. package/README.md +69 -15
  2. package/dist/banner.js +69 -47
  3. package/dist/cache.d.ts +98 -0
  4. package/dist/cache.js +301 -0
  5. package/dist/catalog.d.ts +3 -0
  6. package/dist/catalog.js +35 -11
  7. package/dist/commands/cache.d.ts +7 -0
  8. package/dist/commands/cache.js +245 -0
  9. package/dist/commands/check.js +6 -3
  10. package/dist/commands/index.js +5 -0
  11. package/dist/commands/init.js +8 -4
  12. package/dist/commands/key.js +68 -14
  13. package/dist/commands/memory.d.ts +3 -0
  14. package/dist/commands/memory.js +375 -0
  15. package/dist/commands/models.js +17 -1
  16. package/dist/commands/run.js +11 -3
  17. package/dist/commands/sandbox.js +70 -23
  18. package/dist/engine.js +14 -9
  19. package/dist/home.d.ts +2 -2
  20. package/dist/home.js +2 -2
  21. package/dist/keys.d.ts +22 -0
  22. package/dist/keys.js +105 -2
  23. package/dist/lib.d.ts +1 -0
  24. package/dist/lib.js +1 -0
  25. package/dist/liveness.js +11 -0
  26. package/dist/main.js +9 -0
  27. package/dist/podman.js +43 -9
  28. package/dist/projects.js +10 -2
  29. package/dist/resolve.d.ts +4 -0
  30. package/dist/resolve.js +43 -17
  31. package/dist/scaffold.js +22 -1
  32. package/dist/term.d.ts +23 -2
  33. package/dist/term.js +215 -8
  34. package/dist/tui/app.d.ts +10 -0
  35. package/dist/tui/app.js +478 -54
  36. package/dist/tui/theme.d.ts +13 -2
  37. package/dist/tui/theme.js +26 -13
  38. package/dist/tui/wrap.d.ts +92 -0
  39. package/dist/tui/wrap.js +147 -2
  40. package/dist/validate.d.ts +2 -0
  41. package/dist/validate.js +87 -2
  42. package/package.json +2 -2
  43. package/templates/editor/.github/copilot-instructions.md +200 -55
  44. package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
  45. package/templates/editor/.github/prompts/review-project.prompt.md +5 -0
  46. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +11 -4
  47. package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -0
  48. package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
  49. package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
  50. package/templates/editor/.github/skills/zen-cli/references/memory.md +113 -0
  51. package/templates/editor/.github/skills/zen-cli/references/rag.md +2 -2
  52. package/templates/editor/.github/skills/zen-cli/references/run.md +3 -1
  53. package/templates/editor/.github/skills/zen-memory/SKILL.md +555 -0
  54. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
  55. package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +4 -4
  56. package/templates/editor/.vscode/settings.json +4 -2
  57. package/templates/parts/exa.prompt.md.tmpl +4 -0
  58. package/templates/parts/exa.spec.md.tmpl +2 -0
  59. package/templates/project/INSTRUCTIONS.md +8 -0
  60. package/templates/project/SPECIFICATION.md.tmpl +47 -0
  61. package/templates/project/agents/prompts/{default.md → default.md.tmpl} +4 -0
  62. package/templates/project/agents.yaml.tmpl +4 -0
  63. package/templates/project/gitignore +7 -1
  64. package/templates/project/scripts/_setup.sh +101 -0
  65. package/templates/project/SPECIFICATION.md +0 -6
package/dist/cache.js ADDED
@@ -0,0 +1,301 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readdirSync, readFileSync, rmdirSync, rmSync, statSync, utimesSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { paths, writeJson } from "./home.js";
5
+ // ---------------------------------------------------------------------------
6
+ // One place for work already done
7
+ //
8
+ // Embedding a paragraph, parsing a document, asking a provider what models it
9
+ // serves: all expensive, all perfectly repeatable, and all previously cached by
10
+ // a different hand-rolled file format sitting in a different directory. This is
11
+ // the one store they share, at `~/.zenera/neo/cache/<kind>/<ab>/<sha>.json`,
12
+ // one file per object.
13
+ //
14
+ // Three rules hold the whole thing up.
15
+ //
16
+ // **Nothing here throws.** A corrupt file, a full disk, an unreadable
17
+ // directory, a key that does not match: every one of them is a miss. A cache
18
+ // that cannot work costs time and must never cost correctness, so there is no
19
+ // error path for a caller to get wrong.
20
+ //
21
+ // **The key carries every input.** Not the text alone but the model, the
22
+ // dimensions, the version of whatever produced the value — all of it. That is
23
+ // why there is no invalidation step anywhere: change any input and you are
24
+ // asking a different question, which has no answer yet. Vectors from another
25
+ // model are not evicted, they are simply never found.
26
+ //
27
+ // **The key is written into the entry and checked on the way out.** The path is
28
+ // only a hash of it. Reading proves the file is the one that was asked for
29
+ // rather than trusting sha256 to be injective, and — much more likely to
30
+ // actually happen — it catches a key derivation that changed shape without
31
+ // anyone bumping a version.
32
+ // ---------------------------------------------------------------------------
33
+ /** Characters of the digest that name the subdirectory, so no one directory grows huge. */
34
+ const SHARD = 2;
35
+ /**
36
+ * How stale a mtime has to be before reading an entry rewrites it.
37
+ *
38
+ * `commit` is what makes "older than 30 days" mean *unused* for 30 days rather
39
+ * than *unwritten* for 30 days. Doing it on every read would be a write per
40
+ * read; a day's granularity costs nothing and is far finer than any retention
41
+ * anyone will ask for.
42
+ */
43
+ const TOUCH_AFTER_MS = 24 * 60 * 60 * 1000;
44
+ /** Joins the parts of a composite key. NUL cannot occur in any of them. */
45
+ export function cacheKey(...parts) {
46
+ return parts.map((part) => part ?? '').join('\u0000');
47
+ }
48
+ export class Cache {
49
+ kind;
50
+ #dir;
51
+ #mode;
52
+ /** entries read this run, so `commit` can say they are still wanted */
53
+ #used = new Set();
54
+ #hits = 0;
55
+ #misses = 0;
56
+ constructor(kind, options = {}) {
57
+ this.kind = kind;
58
+ this.#dir = join(options.dir ?? paths.cache(), kind);
59
+ this.#mode = options.mode;
60
+ }
61
+ get hits() {
62
+ return this.#hits;
63
+ }
64
+ get misses() {
65
+ return this.#misses;
66
+ }
67
+ #path(key) {
68
+ const sha = createHash('sha256')
69
+ .update(this.kind, 'utf8')
70
+ .update('\u0000', 'utf8')
71
+ .update(key, 'utf8')
72
+ .digest('hex');
73
+ return join(this.#dir, sha.slice(0, SHARD), `${sha}.json`);
74
+ }
75
+ entry(key) {
76
+ const path = this.#path(key);
77
+ let found;
78
+ try {
79
+ found = JSON.parse(readFileSync(path, 'utf8'));
80
+ }
81
+ catch {
82
+ this.#misses++;
83
+ return undefined;
84
+ }
85
+ if (found?.kind !== this.kind || found.key !== key) {
86
+ this.#misses++;
87
+ return undefined;
88
+ }
89
+ this.#hits++;
90
+ this.#used.add(path);
91
+ return { value: found.value, storedAt: found.storedAt };
92
+ }
93
+ get(key) {
94
+ return this.entry(key)?.value;
95
+ }
96
+ put(key, value) {
97
+ const stored = {
98
+ kind: this.kind,
99
+ key,
100
+ storedAt: new Date().toISOString(),
101
+ value,
102
+ };
103
+ try {
104
+ writeJson(this.#path(key), stored, this.#mode);
105
+ }
106
+ catch {
107
+ // Out of disk, or the directory went away. Nothing depends on this.
108
+ }
109
+ }
110
+ delete(key) {
111
+ try {
112
+ rmSync(this.#path(key), { force: true });
113
+ }
114
+ catch {
115
+ // Still cached, then. It will be pruned by age eventually.
116
+ }
117
+ }
118
+ commit() {
119
+ const now = new Date();
120
+ for (const path of this.#used) {
121
+ try {
122
+ if (now.getTime() - statSync(path).mtimeMs > TOUCH_AFTER_MS) {
123
+ utimesSync(path, now, now);
124
+ }
125
+ }
126
+ catch {
127
+ // Gone, or read-only. Neither is worth a word.
128
+ }
129
+ }
130
+ this.#used.clear();
131
+ }
132
+ }
133
+ /** Remembers nothing, for `--no-cache` and for anywhere a store cannot be opened. */
134
+ export const NO_CACHE = {
135
+ kind: 'none',
136
+ hits: 0,
137
+ misses: 0,
138
+ entry: () => undefined,
139
+ get: () => undefined,
140
+ put: () => { },
141
+ delete: () => { },
142
+ commit: () => { },
143
+ };
144
+ const dirents = (dir) => {
145
+ try {
146
+ return readdirSync(dir, { withFileTypes: true }).map((e) => ({
147
+ name: e.name,
148
+ dir: e.isDirectory(),
149
+ file: e.isFile(),
150
+ }));
151
+ }
152
+ catch {
153
+ return [];
154
+ }
155
+ };
156
+ /** Every entry file, kind by kind. Half-written `.tmp` siblings are not entries. */
157
+ function* walk(dir, only) {
158
+ for (const kind of dirents(dir)) {
159
+ if (!kind.dir || (only !== undefined && kind.name !== only)) {
160
+ continue;
161
+ }
162
+ const kindDir = join(dir, kind.name);
163
+ for (const shard of dirents(kindDir)) {
164
+ if (!shard.dir) {
165
+ continue;
166
+ }
167
+ const shardDir = join(kindDir, shard.name);
168
+ for (const file of dirents(shardDir)) {
169
+ if (!file.file || !file.name.endsWith('.json')) {
170
+ continue;
171
+ }
172
+ const path = join(shardDir, file.name);
173
+ try {
174
+ const info = statSync(path);
175
+ yield { kind: kind.name, path, bytes: info.size, usedAt: info.mtimeMs };
176
+ }
177
+ catch {
178
+ continue;
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+ /** A row per kind, for `zen cache ls`. One stat walk; nothing is parsed. */
185
+ export function kinds(dir = paths.cache()) {
186
+ const rows = new Map();
187
+ for (const found of walk(dir)) {
188
+ const row = rows.get(found.kind) ?? { kind: found.kind, entries: 0, bytes: 0 };
189
+ row.entries++;
190
+ row.bytes += found.bytes;
191
+ row.oldest = Math.min(row.oldest ?? found.usedAt, found.usedAt);
192
+ row.newest = Math.max(row.newest ?? found.usedAt, found.usedAt);
193
+ rows.set(found.kind, row);
194
+ }
195
+ return [...rows.values()].sort((a, b) => a.kind.localeCompare(b.kind));
196
+ }
197
+ /**
198
+ * The entries of one kind, most recently used first. Reading a key means
199
+ * parsing the file, so the sort happens on stat data and only what will be
200
+ * shown is opened.
201
+ */
202
+ export function items(kind, options = {}) {
203
+ const all = [...walk(options.dir ?? paths.cache(), kind)].sort((a, b) => b.usedAt - a.usedAt);
204
+ const take = options.limit === undefined ? all : all.slice(0, options.limit);
205
+ const rows = take.map((found) => {
206
+ const row = {
207
+ kind: found.kind,
208
+ key: '',
209
+ bytes: found.bytes,
210
+ usedAt: found.usedAt,
211
+ };
212
+ try {
213
+ const stored = JSON.parse(readFileSync(found.path, 'utf8'));
214
+ row.key = stored.key ?? '';
215
+ row.storedAt = stored.storedAt;
216
+ row.value = stored.value;
217
+ }
218
+ catch {
219
+ // An unreadable entry is still an entry taking up room.
220
+ }
221
+ return row;
222
+ });
223
+ return { rows, found: all.length };
224
+ }
225
+ /**
226
+ * Removes what the options describe and reports what went, by kind.
227
+ *
228
+ * `maxBytes` applies to the whole selection rather than to each kind
229
+ * separately: the disk is one thing, and least-recently-used across the store
230
+ * is the only ordering that means anything on it.
231
+ */
232
+ export function sweep(options = {}) {
233
+ const dir = options.dir ?? paths.cache();
234
+ const cutoff = options.olderThanMs === undefined ? undefined : Date.now() - options.olderThanMs;
235
+ const kept = [];
236
+ const doomed = [];
237
+ for (const found of walk(dir, options.kind)) {
238
+ (cutoff !== undefined && found.usedAt < cutoff ? doomed : kept).push(found);
239
+ }
240
+ if (options.maxBytes !== undefined) {
241
+ kept.sort((a, b) => a.usedAt - b.usedAt);
242
+ let total = kept.reduce((n, f) => n + f.bytes, 0);
243
+ while (total > options.maxBytes && kept.length > 0) {
244
+ const found = kept.shift();
245
+ total -= found.bytes;
246
+ doomed.push(found);
247
+ }
248
+ }
249
+ const swept = new Map();
250
+ for (const found of doomed) {
251
+ try {
252
+ rmSync(found.path, { force: true });
253
+ }
254
+ catch {
255
+ continue;
256
+ }
257
+ const row = swept.get(found.kind) ?? { kind: found.kind, removed: 0, bytes: 0 };
258
+ row.removed++;
259
+ row.bytes += found.bytes;
260
+ swept.set(found.kind, row);
261
+ }
262
+ tidy(dir);
263
+ return [...swept.values()].sort((a, b) => a.kind.localeCompare(b.kind));
264
+ }
265
+ /** Everything, or one kind of everything. */
266
+ export function clear(options = {}) {
267
+ const dir = options.dir ?? paths.cache();
268
+ try {
269
+ rmSync(options.kind === undefined ? dir : join(dir, options.kind), {
270
+ recursive: true,
271
+ force: true,
272
+ });
273
+ }
274
+ catch {
275
+ // Whatever survived will be reported by the next `ls`.
276
+ }
277
+ }
278
+ /** Drops the shard and kind directories a sweep emptied. */
279
+ function tidy(dir) {
280
+ for (const kind of dirents(dir)) {
281
+ if (!kind.dir) {
282
+ continue;
283
+ }
284
+ const kindDir = join(dir, kind.name);
285
+ for (const shard of dirents(kindDir)) {
286
+ if (shard.dir) {
287
+ rmdir(join(kindDir, shard.name));
288
+ }
289
+ }
290
+ rmdir(kindDir);
291
+ }
292
+ }
293
+ function rmdir(dir) {
294
+ try {
295
+ rmdirSync(dir);
296
+ }
297
+ catch {
298
+ // Not empty, which is the usual answer.
299
+ }
300
+ }
301
+ //# sourceMappingURL=cache.js.map
package/dist/catalog.d.ts CHANGED
@@ -51,6 +51,7 @@ export interface Catalog {
51
51
  }
52
52
  /** A day. Model lists change on the scale of weeks; a stale row costs a retry. */
53
53
  export declare const CATALOG_TTL_MS: number;
54
+ export declare const CATALOG_KIND = "catalog";
54
55
  /**
55
56
  * Enough to work with when the provider cannot be asked — offline, no
56
57
  * credential, or a listing endpoint that is down. Deliberately short: this is
@@ -80,6 +81,8 @@ export declare const PREFERRED: Record<Provider, {
80
81
  * `ProviderSpec.client`, which exists in the library for the same reason.
81
82
  */
82
83
  export declare function fetchCatalog(provider: Provider, client?: unknown): Promise<CatalogEntry[]>;
84
+ /** The key one provider's listing is filed under, version and all. */
85
+ export declare const catalogKey: (provider: Provider) => string;
83
86
  export interface CatalogOptions {
84
87
  /** ignore a fresh cache and ask the provider again */
85
88
  refresh?: boolean;
package/dist/catalog.js CHANGED
@@ -16,14 +16,15 @@
16
16
  // `source` for exactly that reason: a curated guess must never be mistaken for
17
17
  // the vendor's own word.
18
18
  // ---------------------------------------------------------------------------
19
- import { join } from 'node:path';
20
19
  import { ModelRegistry } from '@zenera/neo';
21
- import { paths, readJson, writeJson } from "./home.js";
20
+ import { Cache, cacheKey } from "./cache.js";
22
21
  import { PROVIDERS } from "./keys.js";
23
22
  import { classify } from "./liveness.js";
24
23
  /** A day. Model lists change on the scale of weeks; a stale row costs a retry. */
25
24
  export const CATALOG_TTL_MS = 24 * 60 * 60 * 1000;
26
- const CACHE_VERSION = 1;
25
+ export const CATALOG_KIND = 'catalog';
26
+ /** Bumped when the stored shape changes, which makes every old entry a miss. */
27
+ const CATALOG_VERSION = 1;
27
28
  // ---------------------------------------------------------------------------
28
29
  // The fallback table
29
30
  // ---------------------------------------------------------------------------
@@ -341,10 +342,35 @@ function enrich(provider, live) {
341
342
  };
342
343
  });
343
344
  }
344
- const cachePath = (provider) => join(paths.catalog(), `${provider}.json`);
345
- async function readCache(provider) {
346
- const file = await readJson(cachePath(provider), undefined);
347
- return file?.version === CACHE_VERSION && Array.isArray(file.entries) ? file : undefined;
345
+ /**
346
+ * Listings are public data, so the file is left readable — someone wondering
347
+ * where a model row came from can go and look at it. Opened per call rather
348
+ * than once, because the home directory is an environment variable and a test
349
+ * is allowed to move it.
350
+ */
351
+ const store = () => new Cache(CATALOG_KIND, { mode: 0o644 });
352
+ /**
353
+ * What else decides a listing's contents. Vertex answers per project and per
354
+ * location — one account was offered eight models at `us` and forty at
355
+ * `us-central1` — so a key naming only the provider serves one of those lists
356
+ * to both. A project left to the service-account file is not seen here.
357
+ */
358
+ function scope(provider) {
359
+ if (provider !== 'vertex') {
360
+ return undefined;
361
+ }
362
+ return cacheKey(process.env.GOOGLE_CLOUD_PROJECT, process.env.GOOGLE_CLOUD_LOCATION ?? 'global');
363
+ }
364
+ /** The key one provider's listing is filed under, version and all. */
365
+ export const catalogKey = (provider) => {
366
+ const extra = scope(provider);
367
+ return extra ? cacheKey(CATALOG_VERSION, provider, extra) : cacheKey(CATALOG_VERSION, provider);
368
+ };
369
+ function readCache(provider) {
370
+ const file = store().get(catalogKey(provider));
371
+ return file && Array.isArray(file.entries) && typeof file.fetchedAt === 'string'
372
+ ? file
373
+ : undefined;
348
374
  }
349
375
  /**
350
376
  * The listing for one provider, from the cheapest source that can answer.
@@ -356,7 +382,7 @@ async function readCache(provider) {
356
382
  * model list to four rows.
357
383
  */
358
384
  export async function loadCatalog(provider, opts = {}) {
359
- const cached = await readCache(provider);
385
+ const cached = readCache(provider);
360
386
  const fresh = cached && Date.now() - new Date(cached.fetchedAt).getTime() < CATALOG_TTL_MS
361
387
  ? cached
362
388
  : undefined;
@@ -367,9 +393,7 @@ export async function loadCatalog(provider, opts = {}) {
367
393
  try {
368
394
  const entries = await fetchCatalog(provider);
369
395
  const fetchedAt = new Date().toISOString();
370
- writeJson(cachePath(provider), { version: CACHE_VERSION, provider, fetchedAt, entries },
371
- // Public data, and readable so a human can look at what was cached.
372
- 0o644);
396
+ store().put(catalogKey(provider), { provider, fetchedAt, entries });
373
397
  return { provider, entries, origin: 'live', fetchedAt };
374
398
  }
375
399
  catch (err) {
@@ -0,0 +1,7 @@
1
+ import type { Command } from '../command.ts';
2
+ export declare const cache: Command;
3
+ /** `30d`, `12h`, `2w`. A bare number is days: nobody means milliseconds. */
4
+ export declare function duration(text: string | undefined): number | undefined;
5
+ /** `500MB`, `2GB`. Powers of 1000, the way `bytes()` prints them back. */
6
+ export declare function size(text: string | undefined): number | undefined;
7
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1,245 @@
1
+ import { parse } from "../args.js";
2
+ import { clear, items, kinds, sweep } from "../cache.js";
3
+ import { paths } from "../home.js";
4
+ import { ago, bold, bytes, confirm, count, cyan, dim, green, isInteractive, json, note, table, usageError, writeAll, } from "../term.js";
5
+ const USAGE = 'zen cache [ls|prune|clear] [options]';
6
+ /** Enough to see what is in there; the rest is a number. */
7
+ const LISTED = 20;
8
+ // ---------------------------------------------------------------------------
9
+ // The cache, from outside
10
+ //
11
+ // Everything expensive and repeatable this machine has done is kept in one
12
+ // place, and the reason it needs a command at all is that nothing evicts from
13
+ // it on its own. That is deliberate: a store that quietly deletes things is
14
+ // only ever noticed when it has deleted the wrong one. So retention is a
15
+ // decision someone makes out loud, here.
16
+ //
17
+ // Nothing in it is precious. Every entry can be recomputed, which is why
18
+ // `clear` is safe to reach for and why the only cost of being wrong is time.
19
+ // ---------------------------------------------------------------------------
20
+ export const cache = {
21
+ summary: 'What work has been kept, and getting rid of it.',
22
+ usage: USAGE,
23
+ details: [
24
+ ' ls What is stored, by kind. Changes nothing.',
25
+ ' prune Remove what the filters name.',
26
+ ' clear Remove everything, or one kind of everything.',
27
+ '',
28
+ ' --kind <name> Just this kind. With `ls`, list its entries.',
29
+ ' --older-than <age> Unused for longer than e.g. 30d, 12h, 2w.',
30
+ ' --max-size <size> Ceiling on what is left, e.g. 500MB, 2GB.',
31
+ ' --limit <n> Entries to list. Default 20.',
32
+ ' --yes Do not ask before removing.',
33
+ '',
34
+ 'Age is when an entry was last *used*, not when it was written, so a',
35
+ 'vector a rebuild reads every week is never old.',
36
+ '',
37
+ '`prune` with no filter is a usage error: deleting everything is what',
38
+ '`clear` is for, and it should have to be typed.',
39
+ '',
40
+ 'Nothing here is precious. Every entry is work that can be done again,',
41
+ 'so the only cost of removing one is paying for it a second time.',
42
+ ],
43
+ run: async (ctx) => {
44
+ const { values, positionals } = parse(ctx.args, {
45
+ kind: { type: 'string' },
46
+ 'older-than': { type: 'string' },
47
+ 'max-size': { type: 'string' },
48
+ limit: { type: 'string' },
49
+ yes: { type: 'boolean' },
50
+ }, USAGE);
51
+ const what = positionals[0] ?? 'ls';
52
+ if (!['ls', 'prune', 'clear'].includes(what)) {
53
+ throw usageError(`unknown subcommand: ${what}`, USAGE);
54
+ }
55
+ if (positionals.length > 1) {
56
+ throw usageError('one subcommand at a time', USAGE);
57
+ }
58
+ switch (what) {
59
+ case 'ls':
60
+ return list(values, ctx.json);
61
+ case 'prune':
62
+ return prune(values, ctx.json);
63
+ default:
64
+ return wipe(values, ctx.json);
65
+ }
66
+ },
67
+ };
68
+ // ---------------------------------------------------------------------------
69
+ // ls
70
+ // ---------------------------------------------------------------------------
71
+ function list(values, asJson) {
72
+ if (values.kind) {
73
+ return listOne(values, asJson);
74
+ }
75
+ const rows = kinds();
76
+ if (asJson) {
77
+ json({ dir: paths.cache(), kinds: rows, ...totals(rows) });
78
+ return;
79
+ }
80
+ if (rows.length === 0) {
81
+ note(`nothing cached yet ${dim(paths.cache())}`);
82
+ return;
83
+ }
84
+ writeAll(table([
85
+ [bold('KIND'), bold('ENTRIES'), bold('SIZE'), bold('OLDEST'), bold('NEWEST')],
86
+ ...rows.map((row) => [
87
+ cyan(row.kind),
88
+ String(row.entries),
89
+ bytes(row.bytes),
90
+ dim(since(row.oldest)),
91
+ dim(since(row.newest)),
92
+ ]),
93
+ ]));
94
+ const all = totals(rows);
95
+ note('');
96
+ note(dim(`${count(all.entries, 'entry', 'entries')}, ${bytes(all.bytes)} in ${paths.cache()}`));
97
+ }
98
+ function listOne(values, asJson) {
99
+ const kind = values.kind;
100
+ const limit = number(values.limit, '--limit') ?? LISTED;
101
+ const { rows, found } = items(kind, { limit });
102
+ if (asJson) {
103
+ json({ kind, found, entries: rows });
104
+ return;
105
+ }
106
+ if (found === 0) {
107
+ note(`nothing cached under ${cyan(kind)}`);
108
+ return;
109
+ }
110
+ writeAll(table([
111
+ [bold('KEY'), bold('SIZE'), bold('USED')],
112
+ ...rows.map((row) => [shorten(row.key), bytes(row.bytes), dim(since(row.usedAt))]),
113
+ ]));
114
+ if (found > rows.length) {
115
+ note('');
116
+ note(dim(`${found - rows.length} more — raise --limit to see them`));
117
+ }
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // prune and clear
121
+ // ---------------------------------------------------------------------------
122
+ function prune(values, asJson) {
123
+ const olderThanMs = duration(values['older-than']);
124
+ const maxBytes = size(values['max-size']);
125
+ if (olderThanMs === undefined && maxBytes === undefined) {
126
+ throw usageError('prune needs something to go on', 'pass --older-than <age> or --max-size <size>, or run: zen cache clear');
127
+ }
128
+ report(sweep({ kind: values.kind, olderThanMs, maxBytes }), asJson);
129
+ }
130
+ async function wipe(values, asJson) {
131
+ const before = kinds().filter((row) => !values.kind || row.kind === values.kind);
132
+ const all = totals(before);
133
+ if (all.entries === 0) {
134
+ if (asJson) {
135
+ json({ removed: [], entries: 0, bytes: 0 });
136
+ return;
137
+ }
138
+ note(values.kind ? `nothing cached under ${cyan(values.kind)}` : 'nothing cached yet');
139
+ return;
140
+ }
141
+ if (!values.yes && !asJson && isInteractive()) {
142
+ const what = values.kind
143
+ ? `${count(all.entries, 'entry', 'entries')} under ${values.kind}`
144
+ : count(all.entries, 'entry', 'entries');
145
+ if (!(await confirm(`Remove ${what} (${bytes(all.bytes)})?`))) {
146
+ note('left alone');
147
+ return;
148
+ }
149
+ }
150
+ clear({ kind: values.kind });
151
+ report(before.map((row) => ({ kind: row.kind, removed: row.entries, bytes: row.bytes })), asJson);
152
+ }
153
+ function report(swept, asJson) {
154
+ const entries = swept.reduce((n, row) => n + row.removed, 0);
155
+ const freed = swept.reduce((n, row) => n + row.bytes, 0);
156
+ if (asJson) {
157
+ json({ removed: swept, entries, bytes: freed });
158
+ return;
159
+ }
160
+ if (entries === 0) {
161
+ note('nothing to remove');
162
+ return;
163
+ }
164
+ for (const row of swept) {
165
+ note(`${green('removed')} ${count(row.removed, 'entry', 'entries')} ${dim(`${row.kind} · ${bytes(row.bytes)}`)}`);
166
+ }
167
+ if (swept.length > 1) {
168
+ note('');
169
+ note(dim(`${count(entries, 'entry', 'entries')}, ${bytes(freed)} freed`));
170
+ }
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // Reading the filters
174
+ // ---------------------------------------------------------------------------
175
+ const DURATIONS = {
176
+ s: 1000,
177
+ m: 60_000,
178
+ h: 3_600_000,
179
+ d: 86_400_000,
180
+ w: 604_800_000,
181
+ };
182
+ /** `30d`, `12h`, `2w`. A bare number is days: nobody means milliseconds. */
183
+ export function duration(text) {
184
+ if (text === undefined) {
185
+ return undefined;
186
+ }
187
+ const match = /^(\d+(?:\.\d+)?)\s*([a-z]*)$/i.exec(text.trim());
188
+ const unit = match ? (match[2] || 'd').toLowerCase() : '';
189
+ const scale = DURATIONS[unit];
190
+ if (!match || scale === undefined) {
191
+ throw usageError(`"${text}" is not an age`, 'try: 30d, 12h, 2w');
192
+ }
193
+ return Number(match[1]) * scale;
194
+ }
195
+ const SIZES = {
196
+ b: 1,
197
+ kb: 1000,
198
+ mb: 1000 ** 2,
199
+ gb: 1000 ** 3,
200
+ tb: 1000 ** 4,
201
+ };
202
+ /** `500MB`, `2GB`. Powers of 1000, the way `bytes()` prints them back. */
203
+ export function size(text) {
204
+ if (text === undefined) {
205
+ return undefined;
206
+ }
207
+ const match = /^(\d+(?:\.\d+)?)\s*([a-z]*)$/i.exec(text.trim());
208
+ const unit = match ? (match[2] || 'mb').toLowerCase() : '';
209
+ const scale = SIZES[unit];
210
+ if (!match || scale === undefined) {
211
+ throw usageError(`"${text}" is not a size`, 'try: 500MB, 2GB');
212
+ }
213
+ return Number(match[1]) * scale;
214
+ }
215
+ function number(text, flag) {
216
+ if (text === undefined) {
217
+ return undefined;
218
+ }
219
+ const value = Number(text);
220
+ if (!Number.isInteger(value) || value < 1) {
221
+ throw usageError(`${flag} takes a whole number of at least 1`, `got "${text}"`);
222
+ }
223
+ return value;
224
+ }
225
+ // ---------------------------------------------------------------------------
226
+ // Small formatting
227
+ // ---------------------------------------------------------------------------
228
+ const totals = (rows) => ({
229
+ entries: rows.reduce((n, row) => n + row.entries, 0),
230
+ bytes: rows.reduce((n, row) => n + row.bytes, 0),
231
+ });
232
+ const since = (at) => at === undefined ? 'never' : ago(new Date(at).toISOString());
233
+ /**
234
+ * A key holds every input that produced the value, so an embedding's key is a
235
+ * whole paragraph. One line of it says which entry this is; the rest is noise
236
+ * in a table.
237
+ */
238
+ function shorten(key) {
239
+ const flat = key
240
+ .replace(/\u0000/g, ' · ')
241
+ .replace(/\s+/g, ' ')
242
+ .trim();
243
+ return flat.length > 72 ? `${flat.slice(0, 71)}…` : flat || dim('(unreadable)');
244
+ }
245
+ //# sourceMappingURL=cache.js.map
@@ -35,9 +35,9 @@ export const check = {
35
35
  details: [
36
36
  'Checks the whole project: the configuration parses and satisfies the',
37
37
  'schema, every prompt, skill and catalog it names is on disk, hand-offs',
38
- 'and forks name agents that exist, tool selectors resolve, skills bind to',
39
- 'a catalog that holds them, and the models it declares have a credential',
40
- 'on this machine.',
38
+ 'and forks name agents that exist, every hand-off has a way back, tool',
39
+ 'selectors resolve, skills bind to a catalog that holds them, and the',
40
+ 'models it declares have a credential on this machine.',
41
41
  '',
42
42
  'It also builds the sandbox image and runs one command in it, against a',
43
43
  'temporary directory rather than your workspace, and --no-sandbox skips',
@@ -199,6 +199,9 @@ function render(report) {
199
199
  if (report.models.length) {
200
200
  push(...table(report.models.map((m) => [
201
201
  ` ${m.name}`,
202
+ // An alias names nothing the provider knows, so what it
203
+ // stands for is printed next to it.
204
+ dim(m.ref ?? ''),
202
205
  dim(m.provider ? `${m.provider} (${m.kind})` : red('unresolved')),
203
206
  dim(m.env ?? ''),
204
207
  credential(m.credential),