@zenera/cli 1.1.10 → 1.1.11

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 (41) hide show
  1. package/README.md +49 -15
  2. package/dist/cache.d.ts +98 -0
  3. package/dist/cache.js +301 -0
  4. package/dist/catalog.d.ts +3 -0
  5. package/dist/catalog.js +35 -11
  6. package/dist/commands/cache.d.ts +7 -0
  7. package/dist/commands/cache.js +245 -0
  8. package/dist/commands/check.js +6 -3
  9. package/dist/commands/index.js +2 -0
  10. package/dist/commands/key.js +68 -14
  11. package/dist/commands/models.js +17 -1
  12. package/dist/commands/run.js +11 -3
  13. package/dist/commands/sandbox.js +70 -23
  14. package/dist/home.d.ts +2 -2
  15. package/dist/home.js +2 -2
  16. package/dist/keys.d.ts +22 -0
  17. package/dist/keys.js +105 -2
  18. package/dist/lib.d.ts +1 -0
  19. package/dist/lib.js +1 -0
  20. package/dist/liveness.js +11 -0
  21. package/dist/resolve.d.ts +4 -0
  22. package/dist/resolve.js +43 -17
  23. package/dist/term.d.ts +23 -2
  24. package/dist/term.js +215 -8
  25. package/dist/tui/app.d.ts +10 -0
  26. package/dist/tui/app.js +470 -54
  27. package/dist/tui/theme.d.ts +6 -2
  28. package/dist/tui/theme.js +14 -8
  29. package/dist/tui/wrap.d.ts +92 -0
  30. package/dist/tui/wrap.js +147 -2
  31. package/dist/validate.d.ts +2 -0
  32. package/dist/validate.js +87 -2
  33. package/package.json +2 -2
  34. package/templates/editor/.github/copilot-instructions.md +50 -13
  35. package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
  36. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +4 -0
  37. package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
  38. package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
  39. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
  40. package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +2 -2
  41. package/templates/editor/.vscode/settings.json +1 -1
package/README.md CHANGED
@@ -97,6 +97,7 @@ agents:
97
97
  description: Turns a summary into a file on disk.
98
98
  system: agents/prompts/writer.md
99
99
  tools: [workspace:*]
100
+ handoffs: [reader] # a hand-off does not return by itself; give it a way back
100
101
  ```
101
102
 
102
103
  `agents/prompts/reader.md`:
@@ -161,6 +162,7 @@ you are not expected to hand-author `agents.yaml`.
161
162
  | `inspect` | Opens or rebuilds a run's `report.html`. |
162
163
  | `check` | Validates the project and every file it names, and asks the models. |
163
164
  | `sandbox` | Checks and prepares the container that command-line tools run in. |
165
+ | `cache` | What work has been kept, and getting rid of it. |
164
166
  | `version` | CLI, library and Node versions. |
165
167
 
166
168
  Commands can also come from a package installed alongside this one, so a new
@@ -254,7 +256,7 @@ zen models test vertex:gemini-embedding-001 # one real call, one verdict
254
256
  zen models pick --embedding # the first ref that answers, on stdout
255
257
  ```
256
258
 
257
- Listings are cached for a day in `~/.zenera/neo/catalog`. When a provider cannot
259
+ Listings are cached for a day in `~/.zenera/neo/cache`. When a provider cannot
258
260
  be asked the last listing is used and said to be stale; only if there was never
259
261
  one does a short built-in list stand in.
260
262
 
@@ -292,7 +294,7 @@ The usual one is a **service-account JSON file** — give its path, not its
292
294
  contents. Run the command with nothing piped and it asks:
293
295
 
294
296
  ```sh
295
- zen key add vertex --location us-central1
297
+ zen key add vertex --gcp-location us-central1
296
298
  # Paste the key, or a path to the file: /Users/you/keys/vertex-sa.json
297
299
  ```
298
300
 
@@ -300,23 +302,29 @@ The prompt is read by `zen`, not by your shell, so give a full path there — `~
300
302
  is not expanded. In a script, pipe the path in instead:
301
303
 
302
304
  ```sh
303
- echo ~/keys/vertex-sa.json | zen key add vertex --location us-central1
305
+ echo ~/keys/vertex-sa.json | zen key add vertex --gcp-location us-central1
304
306
  ```
305
307
 
306
308
  The file is copied into `~/.zenera/neo/keys/`, where only you can read it, so
307
309
  moving or cleaning up the original later cannot break it.
308
310
 
309
- - `--location <region>` is worth setting. It must be `global` or a **concrete
310
- region**; multi-region names like `us` are rejected with a 404. `global`
311
- routes across regions and pays about ten seconds of cold start on the first
312
- request each process makes a region answers in about two.
313
- - `--project <id>` is only needed when the `project_id` inside the file is not
311
+ - `--gcp-location <region>` is worth setting. It takes a concrete region, or one
312
+ of the endpoints that route across regions: `us` and `eu` pool capacity while
313
+ keeping processing inside that territory, `global` takes whatever is free and
314
+ promises no residency. `global` pays about ten seconds of cold start on the
315
+ first request each process makes a region answers in about two.
316
+ - Which models a location serves is per model, and not guessable. In one
317
+ project, `gemini-embedding-2` answered at `us` but 404'd at `us-central1`,
318
+ while `gemini-2.5-flash` did the opposite. New models often reach `global`,
319
+ `us` and `eu` first. `zen models test vertex:<model>` is what settles it —
320
+ `zen key add` only establishes that the credential itself works.
321
+ - `--gcp-project <id>` is only needed when the `project_id` inside the file is not
314
322
  the project you want.
315
323
 
316
324
  The alternative is an **express-mode API key** — a single secret, stored under
317
325
  `VERTEX_API_KEY`. It is the Vertex console's way of handing out access without a
318
- service account, and it needs neither a project nor a region, so `--project` and
319
- `--location` mean nothing there and are not stored.
326
+ service account, and it needs neither a project nor a region, so `--gcp-project`
327
+ and `--gcp-location` mean nothing there and are not stored.
320
328
 
321
329
  ### Gemini, three ways
322
330
 
@@ -336,15 +344,15 @@ and a region, because the file says which project it belongs to but never which
336
344
  region to call:
337
345
 
338
346
  ```sh
339
- echo ~/keys/vertex-sa.json | zen key add vertex --location us-central1
347
+ echo ~/keys/vertex-sa.json | zen key add vertex --gcp-location us-central1
340
348
  ```
341
349
 
342
- Add `--project` only when the `project_id` inside the file is not the one you
350
+ Add `--gcp-project` only when the `project_id` inside the file is not the one you
343
351
  want to bill:
344
352
 
345
353
  ```sh
346
354
  echo ~/keys/vertex-sa.json \
347
- | zen key add vertex --project other-project --location europe-west4
355
+ | zen key add vertex --gcp-project other-project --gcp-location europe-west4
348
356
  ```
349
357
 
350
358
  **Vertex, express mode** — paste the key at the prompt; no flags apply:
@@ -356,8 +364,8 @@ zen key add vertex
356
364
  Holding several at once is the ordinary case. Name them and switch:
357
365
 
358
366
  ```sh
359
- echo ~/keys/prod-sa.json | zen key add vertex/prod --location us-central1
360
- echo ~/keys/dev-sa.json | zen key add vertex/dev --location global
367
+ echo ~/keys/prod-sa.json | zen key add vertex/prod --gcp-location us-central1
368
+ echo ~/keys/dev-sa.json | zen key add vertex/dev --gcp-location global
361
369
  zen key add vertex/express # the express key, same provider
362
370
 
363
371
  zen key use vertex/dev # which one the next run uses
@@ -413,6 +421,32 @@ clear where a working provider actually comes from.
413
421
  - **Keyring** — `~/.zenera/neo`, readable only by you. Keys are copied into the
414
422
  environment just before a run, so an environment variable you set yourself
415
423
  always wins and a project checked out on a machine without `zen` still runs.
424
+ - **Cache** — `~/.zenera/neo/cache`, one place for work already done: vectors,
425
+ parses, model listings, generated mocks. Shared by every project on the
426
+ machine, and never evicted by anything but you.
427
+
428
+ ## What has already been paid for
429
+
430
+ Embedding a paragraph, parsing a document, asking a provider what it serves —
431
+ all expensive, all perfectly repeatable. They are kept in one store,
432
+ `~/.zenera/neo/cache/<kind>/`, keyed by every input that produced them. Change
433
+ an input and you are asking a different question, which is why nothing in there
434
+ is ever invalidated: it is simply never asked for again.
435
+
436
+ ```
437
+ zen cache ls # what is kept, by kind
438
+ zen cache ls --kind vectors # and what is in one of them
439
+ zen cache prune --older-than 30d # drop what has gone unread for a month
440
+ zen cache prune --max-size 2GB # or keep it under a ceiling
441
+ zen cache clear --kind docs-parse # throw one kind away
442
+ ```
443
+
444
+ Age is when an entry was last _used_, not when it was written, so a vector a
445
+ weekly rebuild reads is never old. Nothing evicts on its own — a store that
446
+ quietly deletes things is only ever noticed when it has deleted the wrong one —
447
+ so retention is a decision made out loud, here. Nothing in it is precious
448
+ either: every entry is work that can be done again, and the only cost of
449
+ removing one is paying for it a second time.
416
450
 
417
451
  ## The library underneath
418
452
 
@@ -0,0 +1,98 @@
1
+ export interface CacheOptions {
2
+ /** where the store lives; defaults to `~/.zenera/neo/cache` */
3
+ dir?: string;
4
+ /** file mode, 0600 unless the value is public */
5
+ mode?: number;
6
+ }
7
+ export interface CacheEntry<T> {
8
+ value: T;
9
+ /** when it was written, for callers that have an opinion about freshness */
10
+ storedAt: string;
11
+ }
12
+ /** What a `Cache` does, so that `NO_CACHE` can be one without being one. */
13
+ export interface CacheStore {
14
+ readonly kind: string;
15
+ readonly hits: number;
16
+ readonly misses: number;
17
+ entry<T>(key: string): CacheEntry<T> | undefined;
18
+ get<T>(key: string): T | undefined;
19
+ put(key: string, value: unknown): void;
20
+ delete(key: string): void;
21
+ commit(): void;
22
+ }
23
+ /** Joins the parts of a composite key. NUL cannot occur in any of them. */
24
+ export declare function cacheKey(...parts: readonly (string | number | undefined)[]): string;
25
+ export declare class Cache implements CacheStore {
26
+ #private;
27
+ readonly kind: string;
28
+ constructor(kind: string, options?: CacheOptions);
29
+ get hits(): number;
30
+ get misses(): number;
31
+ entry<T>(key: string): CacheEntry<T> | undefined;
32
+ get<T>(key: string): T | undefined;
33
+ put(key: string, value: unknown): void;
34
+ delete(key: string): void;
35
+ commit(): void;
36
+ }
37
+ /** Remembers nothing, for `--no-cache` and for anywhere a store cannot be opened. */
38
+ export declare const NO_CACHE: CacheStore;
39
+ export interface CacheKind {
40
+ kind: string;
41
+ entries: number;
42
+ bytes: number;
43
+ /** epoch ms of the least and most recently used entry; absent when empty */
44
+ oldest?: number;
45
+ newest?: number;
46
+ }
47
+ export interface CacheItem {
48
+ kind: string;
49
+ key: string;
50
+ bytes: number;
51
+ /** epoch ms, last used */
52
+ usedAt: number;
53
+ storedAt?: string;
54
+ /** what was stored — the file is parsed anyway to recover the key */
55
+ value?: unknown;
56
+ }
57
+ /** A row per kind, for `zen cache ls`. One stat walk; nothing is parsed. */
58
+ export declare function kinds(dir?: string): CacheKind[];
59
+ /**
60
+ * The entries of one kind, most recently used first. Reading a key means
61
+ * parsing the file, so the sort happens on stat data and only what will be
62
+ * shown is opened.
63
+ */
64
+ export declare function items(kind: string, options?: {
65
+ dir?: string;
66
+ limit?: number;
67
+ }): {
68
+ rows: CacheItem[];
69
+ found: number;
70
+ };
71
+ export interface SweepOptions {
72
+ dir?: string;
73
+ /** only this kind */
74
+ kind?: string;
75
+ /** anything unused for longer than this */
76
+ olderThanMs?: number;
77
+ /** a ceiling on what is left, met by removing the least recently used */
78
+ maxBytes?: number;
79
+ }
80
+ export interface Swept {
81
+ kind: string;
82
+ removed: number;
83
+ bytes: number;
84
+ }
85
+ /**
86
+ * Removes what the options describe and reports what went, by kind.
87
+ *
88
+ * `maxBytes` applies to the whole selection rather than to each kind
89
+ * separately: the disk is one thing, and least-recently-used across the store
90
+ * is the only ordering that means anything on it.
91
+ */
92
+ export declare function sweep(options?: SweepOptions): Swept[];
93
+ /** Everything, or one kind of everything. */
94
+ export declare function clear(options?: {
95
+ dir?: string;
96
+ kind?: string;
97
+ }): void;
98
+ //# sourceMappingURL=cache.d.ts.map
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