@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.
- package/README.md +69 -15
- package/dist/banner.js +69 -47
- 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 +5 -0
- package/dist/commands/init.js +8 -4
- package/dist/commands/key.js +68 -14
- package/dist/commands/memory.d.ts +3 -0
- package/dist/commands/memory.js +375 -0
- package/dist/commands/models.js +17 -1
- package/dist/commands/run.js +11 -3
- package/dist/commands/sandbox.js +70 -23
- package/dist/engine.js +14 -9
- 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/main.js +9 -0
- package/dist/podman.js +43 -9
- package/dist/projects.js +10 -2
- package/dist/resolve.d.ts +4 -0
- package/dist/resolve.js +43 -17
- package/dist/scaffold.js +22 -1
- package/dist/term.d.ts +23 -2
- package/dist/term.js +215 -8
- package/dist/tui/app.d.ts +10 -0
- package/dist/tui/app.js +478 -54
- package/dist/tui/theme.d.ts +13 -2
- package/dist/tui/theme.js +26 -13
- 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 +200 -55
- package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
- package/templates/editor/.github/prompts/review-project.prompt.md +5 -0
- package/templates/editor/.github/prompts/sync-with-spec.prompt.md +11 -4
- package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -0
- 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/memory.md +113 -0
- package/templates/editor/.github/skills/zen-cli/references/rag.md +2 -2
- package/templates/editor/.github/skills/zen-cli/references/run.md +3 -1
- package/templates/editor/.github/skills/zen-memory/SKILL.md +555 -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 +4 -4
- package/templates/editor/.vscode/settings.json +4 -2
- package/templates/parts/exa.prompt.md.tmpl +4 -0
- package/templates/parts/exa.spec.md.tmpl +2 -0
- package/templates/project/INSTRUCTIONS.md +8 -0
- package/templates/project/SPECIFICATION.md.tmpl +47 -0
- package/templates/project/agents/prompts/{default.md → default.md.tmpl} +4 -0
- package/templates/project/agents.yaml.tmpl +4 -0
- package/templates/project/gitignore +7 -1
- package/templates/project/scripts/_setup.sh +101 -0
- 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 {
|
|
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
|
|
@@ -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
|
package/dist/commands/check.js
CHANGED
|
@@ -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,
|
|
39
|
-
'a catalog that holds them, and the
|
|
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),
|