@zenera/cli 1.1.9 → 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.
- package/README.md +49 -15
- package/dist/cache.d.ts +98 -0
- package/dist/cache.js +301 -0
- package/dist/catalog.d.ts +3 -0
- package/dist/catalog.js +35 -11
- package/dist/commands/cache.d.ts +7 -0
- package/dist/commands/cache.js +245 -0
- package/dist/commands/check.js +6 -3
- package/dist/commands/index.js +3 -1
- package/dist/commands/key.js +68 -14
- package/dist/commands/models.js +17 -1
- package/dist/commands/run.js +11 -3
- package/dist/commands/sandbox.js +70 -23
- package/dist/history.d.ts +18 -0
- package/dist/history.js +93 -0
- package/dist/home.d.ts +2 -2
- package/dist/home.js +2 -2
- package/dist/keys.d.ts +22 -0
- package/dist/keys.js +105 -2
- package/dist/lib.d.ts +1 -0
- package/dist/lib.js +1 -0
- package/dist/liveness.js +11 -0
- package/dist/resolve.d.ts +4 -0
- package/dist/resolve.js +43 -17
- package/dist/term.d.ts +25 -2
- package/dist/term.js +224 -10
- package/dist/tui/app.d.ts +10 -0
- package/dist/tui/app.js +542 -58
- package/dist/tui/theme.d.ts +6 -2
- package/dist/tui/theme.js +14 -8
- package/dist/tui/wrap.d.ts +92 -0
- package/dist/tui/wrap.js +147 -2
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +87 -2
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +50 -13
- package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
- package/templates/editor/.github/prompts/sync-with-spec.prompt.md +202 -0
- package/templates/editor/.github/skills/zen-cli/SKILL.md +2 -1
- package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
- package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
- package/templates/editor/.github/skills/zen-cli/references/rag.md +104 -0
- package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
- package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +28 -20
- 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/
|
|
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
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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`
|
|
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
|
|
package/dist/cache.d.ts
ADDED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|