@zenera/cli 1.1.11 → 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 +20 -0
- package/dist/banner.js +69 -47
- package/dist/commands/index.js +3 -0
- package/dist/commands/init.js +8 -4
- package/dist/commands/memory.d.ts +3 -0
- package/dist/commands/memory.js +375 -0
- package/dist/engine.js +14 -9
- package/dist/main.js +9 -0
- package/dist/podman.js +43 -9
- package/dist/projects.js +10 -2
- package/dist/scaffold.js +22 -1
- package/dist/tui/app.js +19 -11
- package/dist/tui/theme.d.ts +7 -0
- package/dist/tui/theme.js +18 -11
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +151 -43
- package/templates/editor/.github/prompts/review-project.prompt.md +5 -0
- package/templates/editor/.github/prompts/sync-with-spec.prompt.md +7 -4
- package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -0
- 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 +16 -16
- package/templates/editor/.github/skills/zen-rag-schema/SKILL.md +2 -2
- 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/README.md
CHANGED
|
@@ -134,17 +134,27 @@ Credentials live in `$HOME`, so the project never contains a secret.
|
|
|
134
134
|
|
|
135
135
|
```
|
|
136
136
|
my-project/
|
|
137
|
+
SPECIFICATION.md what this is for — the intent
|
|
137
138
|
INSTRUCTIONS.md house rules, prepended to every agent
|
|
138
139
|
agents.yaml who exists, what they may reach for
|
|
139
140
|
agents/
|
|
140
141
|
prompts/<name>.md each agent's own brief
|
|
141
142
|
skills/<name>/ knowledge loaded on demand, not always-on
|
|
143
|
+
assets/ reference material, read-only at /assets
|
|
144
|
+
sandbox/Dockerfile the container commands run in
|
|
145
|
+
scripts/_setup.sh the one command that initialises the project
|
|
142
146
|
sessions/ one workspace, memory and trajectory each
|
|
143
147
|
<id>/
|
|
144
148
|
workspace/ what the agents can read and write
|
|
145
149
|
runs/<id>/ input, output, state, report.html, meta
|
|
146
150
|
```
|
|
147
151
|
|
|
152
|
+
`SPECIFICATION.md` comes first because the rest implements it. The one `zen init`
|
|
153
|
+
writes is a true specification of the project it just scaffolded; replace it with
|
|
154
|
+
what you are building, then run `/sync-with-spec` in your editor to make the
|
|
155
|
+
files match. See
|
|
156
|
+
[docs/specification.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/specification.md).
|
|
157
|
+
|
|
148
158
|
`zen init` also writes `.github/copilot-instructions.md` — a standing brief that
|
|
149
159
|
explains this runtime to whatever coding agent you have open in that folder, so
|
|
150
160
|
you are not expected to hand-author `agents.yaml`.
|
|
@@ -160,6 +170,7 @@ you are not expected to hand-author `agents.yaml`.
|
|
|
160
170
|
| `models` | What this machine can use — list, search, test, pick. |
|
|
161
171
|
| `run` | Runs the project — the TUI on a terminal, a single answer otherwise. |
|
|
162
172
|
| `inspect` | Opens or rebuilds a run's `report.html`. |
|
|
173
|
+
| `memory` | What the agents remember — size, listing, one node, or a whole page. |
|
|
163
174
|
| `check` | Validates the project and every file it names, and asks the models. |
|
|
164
175
|
| `sandbox` | Checks and prepares the container that command-line tools run in. |
|
|
165
176
|
| `cache` | What work has been kept, and getting rid of it. |
|
|
@@ -459,11 +470,20 @@ terminal:
|
|
|
459
470
|
|
|
460
471
|
## Documentation
|
|
461
472
|
|
|
473
|
+
[docs/specification.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/specification.md)
|
|
474
|
+
— writing the specification the project implements ·
|
|
462
475
|
[docs/projects.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/projects.md)
|
|
463
476
|
— the folder a project is ·
|
|
464
477
|
[docs/agents-yaml.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/agents-yaml.md)
|
|
465
478
|
— every key in the configuration file.
|
|
466
479
|
|
|
480
|
+
[docs/knowledge.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/knowledge.md)
|
|
481
|
+
— a searchable document index ·
|
|
482
|
+
[docs/integrations.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/integrations.md)
|
|
483
|
+
— an API description, searchable and mockable ·
|
|
484
|
+
[docs/memory.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/memory.md)
|
|
485
|
+
— what agents keep between sessions.
|
|
486
|
+
|
|
467
487
|
## License
|
|
468
488
|
|
|
469
489
|
Early days and moving fast — issues, questions and pull requests are welcome.
|
package/dist/banner.js
CHANGED
|
@@ -8,64 +8,80 @@ import { dim, note } from "./term.js";
|
|
|
8
8
|
// terminal — a pipeline asking for the answer gets the answer, and a CI log is
|
|
9
9
|
// not decorated with block letters.
|
|
10
10
|
//
|
|
11
|
-
// The font is here rather than pulled in as a dependency because it is a
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
11
|
+
// The font is here rather than pulled in as a dependency because it is a couple
|
|
12
|
+
// of kilobytes of constant and the whole CLI is otherwise Node's own. It is the
|
|
13
|
+
// shadowed block face: a solid stroke lit from the top left with a box-drawing
|
|
14
|
+
// bevel down its right side and along its foot, so the letters read as raised
|
|
15
|
+
// rather than as a texture. Glyphs are variable width and carry their own
|
|
16
|
+
// spacing, so they abut directly — the gap belongs to the letter, not the join.
|
|
17
17
|
//
|
|
18
18
|
// A terminal too narrow for the art gets the wordmark on one line. A banner
|
|
19
19
|
// that wraps is worse than no banner.
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
|
-
const HEIGHT =
|
|
22
|
-
|
|
21
|
+
const HEIGHT = 6;
|
|
22
|
+
/** Between the two words. Wide enough that they read as two, not as one. */
|
|
23
|
+
const GAP = 3;
|
|
24
|
+
/** A left margin, so the art does not start against the edge of the screen. */
|
|
25
|
+
const INDENT = 2;
|
|
23
26
|
const FONT = {
|
|
24
|
-
A: ['
|
|
25
|
-
B: ['
|
|
26
|
-
C: ['
|
|
27
|
-
D: ['
|
|
28
|
-
E: ['
|
|
29
|
-
F: ['
|
|
30
|
-
G: ['
|
|
31
|
-
H: ['
|
|
32
|
-
I: ['
|
|
33
|
-
J: ['
|
|
34
|
-
K: ['
|
|
35
|
-
L: ['
|
|
36
|
-
M: ['
|
|
37
|
-
N: ['
|
|
38
|
-
O: ['
|
|
39
|
-
P: ['
|
|
40
|
-
Q: ['
|
|
41
|
-
R: ['
|
|
42
|
-
S: ['
|
|
43
|
-
T: ['
|
|
44
|
-
U: ['
|
|
45
|
-
V: ['
|
|
46
|
-
W: ['
|
|
47
|
-
X: ['
|
|
48
|
-
Y: ['
|
|
49
|
-
Z: ['
|
|
27
|
+
A: [' █████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
|
28
|
+
B: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
|
|
29
|
+
C: [' ██████╗', '██╔════╝', '██║ ', '██║ ', '╚██████╗', ' ╚═════╝'],
|
|
30
|
+
D: ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
|
|
31
|
+
E: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
|
|
32
|
+
F: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '██║ ', '╚═╝ '],
|
|
33
|
+
G: [' ██████╗ ', '██╔════╝ ', '██║ ███╗', '██║ ██║', '╚██████╔╝', ' ╚═════╝ '],
|
|
34
|
+
H: ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
|
35
|
+
I: ['██╗', '██║', '██║', '██║', '██║', '╚═╝'],
|
|
36
|
+
J: [' ██╗', ' ██║', ' ██║', '██ ██║', '╚█████╔╝', ' ╚════╝ '],
|
|
37
|
+
K: ['██╗ ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║ ██╗', '╚═╝ ╚═╝'],
|
|
38
|
+
L: ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
|
|
39
|
+
M: ['███╗ ███╗', '████╗ ████║', '██╔████╔██║', '██║╚██╔╝██║', '██║ ╚═╝ ██║', '╚═╝ ╚═╝'],
|
|
40
|
+
N: ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'],
|
|
41
|
+
O: [' ██████╗ ', '██╔═══██╗', '██║ ██║', '██║ ██║', '╚██████╔╝', ' ╚═════╝ '],
|
|
42
|
+
P: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔═══╝ ', '██║ ', '╚═╝ '],
|
|
43
|
+
Q: [' ██████╗ ', '██╔═══██╗', '██║ ██║', '██║▄▄ ██║', '╚██████╔╝', ' ╚══▀▀═╝ '],
|
|
44
|
+
R: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
|
|
45
|
+
S: ['███████╗', '██╔════╝', '███████╗', '╚════██║', '███████║', '╚══════╝'],
|
|
46
|
+
T: ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
|
|
47
|
+
U: ['██╗ ██╗', '██║ ██║', '██║ ██║', '██║ ██║', '╚██████╔╝', ' ╚═════╝ '],
|
|
48
|
+
V: ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
|
|
49
|
+
W: ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝ '],
|
|
50
|
+
X: ['██╗ ██╗', '╚██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'],
|
|
51
|
+
Y: ['██╗ ██╗', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '],
|
|
52
|
+
Z: ['███████╗', '╚══███╔╝', ' ███╔╝ ', ' ███╔╝ ', '███████╗', '╚══════╝'],
|
|
50
53
|
};
|
|
51
|
-
const BLANK = ' '
|
|
52
|
-
/** One word as
|
|
54
|
+
const BLANK = [' ', ' ', ' ', ' ', ' ', ' '];
|
|
55
|
+
/** One word as six rows of equal length — so two words line up when joined. */
|
|
53
56
|
function big(word) {
|
|
54
57
|
const rows = new Array(HEIGHT).fill('');
|
|
55
58
|
for (const ch of word.toUpperCase()) {
|
|
56
|
-
const glyph = FONT[ch];
|
|
59
|
+
const glyph = FONT[ch] ?? BLANK;
|
|
57
60
|
for (let r = 0; r < HEIGHT; r++) {
|
|
58
|
-
rows[r] +=
|
|
61
|
+
rows[r] += glyph[r];
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
return rows;
|
|
62
65
|
}
|
|
63
66
|
/** A wordmark rather than a sentence, so the letters are set apart. */
|
|
64
67
|
const spaced = (s) => [...s.toUpperCase()].join(' ');
|
|
65
|
-
/**
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
const
|
|
68
|
+
/** Whether anything should be coloured at all — `styleText`'s own answer. */
|
|
69
|
+
const styling = () => styleText('dim', '.') !== '.';
|
|
70
|
+
const RESET = '\u001b[0m';
|
|
71
|
+
const HEAD = { face: '\u001b[1;38;5;231m', shade: '\u001b[0;38;5;244m' };
|
|
72
|
+
const ACCENT = { face: '\u001b[1;38;5;208m', shade: '\u001b[0;38;5;130m' };
|
|
73
|
+
/** Runs of stroke and runs of bevel, alternating; spaces stay unstyled. */
|
|
74
|
+
const RUNS = /[█▀▄]+|[^█▀▄ ]+/g;
|
|
75
|
+
const isStroke = (run) => /[█▀▄]/.test(run);
|
|
76
|
+
/**
|
|
77
|
+
* Two tones per word — without the darker bevel the letter is a flat outline
|
|
78
|
+
* and the shadow reads as part of the stroke.
|
|
79
|
+
*/
|
|
80
|
+
const paint = (text, tone) => styling()
|
|
81
|
+
? text.replace(RUNS, (run) => `${isStroke(run) ? tone.face : tone.shade}${run}${RESET}`)
|
|
82
|
+
: text;
|
|
83
|
+
/** The wordmark has no bevel to shade, so it is all face. */
|
|
84
|
+
const flat = (text, tone) => styling() ? `${tone.face}${text}${RESET}` : text;
|
|
69
85
|
export const NEO_BANNER = {
|
|
70
86
|
head: 'Zenera',
|
|
71
87
|
accent: 'Neo',
|
|
@@ -76,17 +92,23 @@ export function bannerLines(text, columns = process.stderr.columns || 80) {
|
|
|
76
92
|
// Trimmed: the widest accent row is the banner's right edge, and a pad left
|
|
77
93
|
// inside a styled string cannot be trimmed away later.
|
|
78
94
|
const accent = big(text.accent).map((row) => row.trimEnd());
|
|
79
|
-
const
|
|
80
|
-
const
|
|
95
|
+
const margin = ' '.repeat(INDENT);
|
|
96
|
+
const width = INDENT + head[0].length + GAP + Math.max(...accent.map((row) => row.length));
|
|
81
97
|
if (width > columns) {
|
|
82
|
-
return [
|
|
98
|
+
return [
|
|
99
|
+
`${margin}${flat(text.head.toUpperCase(), HEAD)} ${flat(text.accent.toUpperCase(), ACCENT)}`,
|
|
100
|
+
`${margin}${dim(spaced(text.subtitle))}`,
|
|
101
|
+
];
|
|
83
102
|
}
|
|
84
103
|
const lines = [];
|
|
85
104
|
for (let r = 0; r < HEIGHT; r++) {
|
|
86
|
-
lines.push(
|
|
105
|
+
lines.push(`${margin}${paint(head[r], HEAD)}${' '.repeat(GAP)}${paint(accent[r], ACCENT)}`);
|
|
87
106
|
}
|
|
107
|
+
// Rules on both sides, centred under the art: the subtitle is a caption,
|
|
108
|
+
// and without them a short line of spaced capitals floats.
|
|
109
|
+
const caption = `─── ${spaced(text.subtitle)} ───`;
|
|
88
110
|
lines.push('');
|
|
89
|
-
lines.push(
|
|
111
|
+
lines.push(`${' '.repeat(Math.max(INDENT, Math.round((width - caption.length) / 2)))}${dim(caption)}`);
|
|
90
112
|
return lines;
|
|
91
113
|
}
|
|
92
114
|
/** Narration, and only for someone watching. */
|
package/dist/commands/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { init } from "./init.js";
|
|
|
4
4
|
import { inspect } from "./inspect.js";
|
|
5
5
|
import { key } from "./key.js";
|
|
6
6
|
import { list } from "./list.js";
|
|
7
|
+
import { memory } from "./memory.js";
|
|
7
8
|
import { models } from "./models.js";
|
|
8
9
|
import { open } from "./open.js";
|
|
9
10
|
import { run } from "./run.js";
|
|
@@ -23,6 +24,7 @@ export const COMMANDS = {
|
|
|
23
24
|
models,
|
|
24
25
|
check,
|
|
25
26
|
inspect,
|
|
27
|
+
memory,
|
|
26
28
|
sandbox,
|
|
27
29
|
cache,
|
|
28
30
|
version,
|
|
@@ -36,6 +38,7 @@ export const ALIASES = {
|
|
|
36
38
|
validate: 'check',
|
|
37
39
|
doctor: 'check',
|
|
38
40
|
report: 'inspect',
|
|
41
|
+
mem: 'memory',
|
|
39
42
|
edit: 'open',
|
|
40
43
|
code: 'open',
|
|
41
44
|
mock: 'faker',
|
package/dist/commands/init.js
CHANGED
|
@@ -96,10 +96,14 @@ export const init = {
|
|
|
96
96
|
summary: 'Create a project here, or in <dir>, and register it.',
|
|
97
97
|
usage: USAGE,
|
|
98
98
|
details: [
|
|
99
|
-
'Writes INSTRUCTIONS.md, agents.yaml
|
|
100
|
-
'directory so `zen list` and `zen open` can
|
|
101
|
-
'(.vscode/settings.json and the .github/
|
|
102
|
-
'alongside, replacing any already there.',
|
|
99
|
+
'Writes SPECIFICATION.md, INSTRUCTIONS.md, agents.yaml, agents/ and',
|
|
100
|
+
'scripts/, then records the directory so `zen list` and `zen open` can',
|
|
101
|
+
'find it by name. Editor files (.vscode/settings.json and the .github/',
|
|
102
|
+
'tree) are written alongside, replacing any already there.',
|
|
103
|
+
'',
|
|
104
|
+
'SPECIFICATION.md states what the project is for and what it may do,',
|
|
105
|
+
'and describes the one that was just written. Say what you are building',
|
|
106
|
+
'there, then run /sync-with-spec in your editor to make the rest match.',
|
|
103
107
|
'',
|
|
104
108
|
'The default agent gets the file tools and a sandboxed shell, plus',
|
|
105
109
|
'`exa:*` when the keyring holds an Exa key. Without --model, the',
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { ALL_AGENTS, buildMemoryReport, hostPath, memoryDir, MemoryIndex, MemoryStore, readProjectConfig, renderMemoryHtml, } from '@zenera/neo';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { writeFile } from 'node:fs/promises';
|
|
5
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
6
|
+
import { parse } from "../args.js";
|
|
7
|
+
import { project as resolveProject } from "../resolve.js";
|
|
8
|
+
import { ago, bold, bytes, confirm, count, cyan, dim, green, isInteractive, json, note, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
9
|
+
const USAGE = 'zen memory [stats|ls|show|export|forget] [args] [options]';
|
|
10
|
+
const SUBCOMMANDS = ['stats', 'ls', 'show', 'export', 'forget'];
|
|
11
|
+
/** Enough to see the shape of it; the rest is a number. */
|
|
12
|
+
const LISTED = 30;
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// zen memory
|
|
15
|
+
//
|
|
16
|
+
// The graph from outside the agents, and the reason it needs a command is that
|
|
17
|
+
// there is otherwise no way to answer the question memory always eventually
|
|
18
|
+
// raises: *why did it recall that?* Recall is masked, ranked and truncated by
|
|
19
|
+
// design, so what an agent sees is never the whole picture — and when the
|
|
20
|
+
// picture is what is wrong, you need the part that was hidden.
|
|
21
|
+
//
|
|
22
|
+
// So everything here reads the graph unmasked. That is safe because it is
|
|
23
|
+
// local: this command is a person at a terminal in the project directory, not
|
|
24
|
+
// an agent inside a run, and the mask exists to keep agents apart rather than
|
|
25
|
+
// to keep secrets from the person who owns the files.
|
|
26
|
+
//
|
|
27
|
+
// Nothing here contacts a model. The store is opened without an embedder, so
|
|
28
|
+
// inspection is free and works offline; the price is that `ls` filters by text
|
|
29
|
+
// rather than by meaning, which is the right trade for a debugging tool.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
export const memory = {
|
|
32
|
+
summary: 'What the agents remember, and getting rid of it.',
|
|
33
|
+
usage: USAGE,
|
|
34
|
+
details: [
|
|
35
|
+
' stats Size, vocabulary, and whether it is embedded.',
|
|
36
|
+
' ls Nodes, newest first. Changes nothing.',
|
|
37
|
+
' show <id> One node in full, with what it links to.',
|
|
38
|
+
' export [file] The whole graph as one HTML page.',
|
|
39
|
+
' forget <id...> Remove nodes, their vectors and their files.',
|
|
40
|
+
'',
|
|
41
|
+
' --project <name|dir> Which project. Defaults to the one you are in.',
|
|
42
|
+
' --kind <name> Only this kind of node.',
|
|
43
|
+
' --audience <name> Only nodes committed under this label.',
|
|
44
|
+
' --files Only nodes that remember a file.',
|
|
45
|
+
' --stale Only nodes something has superseded.',
|
|
46
|
+
' --limit <n> Rows to list. Default 30.',
|
|
47
|
+
' --out <file> Where `export` writes. Default memory.html.',
|
|
48
|
+
' --open Open the exported page.',
|
|
49
|
+
' --yes Do not ask before removing.',
|
|
50
|
+
'',
|
|
51
|
+
'Everything here reads the graph *unmasked* — every audience, including',
|
|
52
|
+
'what no agent can see. That is the point: when a mask is the thing',
|
|
53
|
+
'that is wrong, the hidden part is what you need to look at.',
|
|
54
|
+
'',
|
|
55
|
+
'`export` is the one to reach for. It writes a single self-contained',
|
|
56
|
+
'page: the node list on the left, the graph in the middle, and whatever',
|
|
57
|
+
'you click on the right, file contents and all.',
|
|
58
|
+
],
|
|
59
|
+
run: async (ctx) => {
|
|
60
|
+
const { values, positionals } = parse(ctx.args, {
|
|
61
|
+
project: { type: 'string' },
|
|
62
|
+
kind: { type: 'string' },
|
|
63
|
+
audience: { type: 'string' },
|
|
64
|
+
files: { type: 'boolean' },
|
|
65
|
+
stale: { type: 'boolean' },
|
|
66
|
+
limit: { type: 'string' },
|
|
67
|
+
out: { type: 'string' },
|
|
68
|
+
open: { type: 'boolean' },
|
|
69
|
+
yes: { type: 'boolean' },
|
|
70
|
+
}, USAGE);
|
|
71
|
+
const what = positionals[0] ?? 'stats';
|
|
72
|
+
if (!SUBCOMMANDS.includes(what)) {
|
|
73
|
+
throw usageError(`unknown subcommand: ${what}`, USAGE);
|
|
74
|
+
}
|
|
75
|
+
const rest = positionals.slice(1);
|
|
76
|
+
const opened = await open(ctx.cwd, values.project);
|
|
77
|
+
try {
|
|
78
|
+
switch (what) {
|
|
79
|
+
case 'stats':
|
|
80
|
+
return stats(opened, ctx.json);
|
|
81
|
+
case 'ls':
|
|
82
|
+
return list(opened, values, ctx.json);
|
|
83
|
+
case 'show':
|
|
84
|
+
return show(opened, rest, ctx.json);
|
|
85
|
+
case 'export':
|
|
86
|
+
return await write_(opened, rest, values, ctx.cwd, ctx.json);
|
|
87
|
+
default:
|
|
88
|
+
return await forget(opened, rest, values, ctx.json);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
opened.store.release();
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* The store, opened directly rather than through `loadProject`. Loading the
|
|
98
|
+
* project would resolve models and read every prompt file to inspect a graph
|
|
99
|
+
* that needs none of it — and would fail on a project whose credentials are
|
|
100
|
+
* missing, which is exactly when someone is debugging.
|
|
101
|
+
*/
|
|
102
|
+
async function open(cwd, want) {
|
|
103
|
+
const project = await resolveProject({ cwd, project: want });
|
|
104
|
+
const { config } = readProjectConfig(project.dir);
|
|
105
|
+
const dir = memoryDir(project.dir, config);
|
|
106
|
+
if (!dir) {
|
|
107
|
+
throw usageError(`${project.name} has no memory`, 'give an agent `memory: true` in agents.yaml, or add a `memory:` block');
|
|
108
|
+
}
|
|
109
|
+
if (!existsSync(join(dir, 'manifest.json'))) {
|
|
110
|
+
throw usageError(`${project.name} has a memory configured but nothing in it yet`, 'it is written the first time an agent commits something');
|
|
111
|
+
}
|
|
112
|
+
return { store: await MemoryStore.open(dir), dir, project: project.name };
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// stats
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
function stats(o, asJson) {
|
|
118
|
+
const nodes = o.store.graph.nodes();
|
|
119
|
+
const edges = o.store.graph.edges();
|
|
120
|
+
const stale = o.store.graph.superseded();
|
|
121
|
+
const vectors = o.store.vectors;
|
|
122
|
+
const files = nodes.filter((n) => n.file);
|
|
123
|
+
const byKind = tally(nodes.map((n) => n.kind));
|
|
124
|
+
const byRelation = tally(edges.map((e) => e.relation));
|
|
125
|
+
const byAudience = tally(nodes.flatMap((n) => n.audience));
|
|
126
|
+
if (asJson) {
|
|
127
|
+
json({
|
|
128
|
+
dir: o.dir,
|
|
129
|
+
nodes: nodes.length,
|
|
130
|
+
edges: edges.length,
|
|
131
|
+
superseded: stale.size,
|
|
132
|
+
files: {
|
|
133
|
+
count: files.length,
|
|
134
|
+
bytes: files.reduce((t, n) => t + (n.file?.bytes ?? 0), 0),
|
|
135
|
+
},
|
|
136
|
+
embedding: o.store.embedding,
|
|
137
|
+
vectors: vectors ? { rows: vectors.rows, dims: vectors.dims } : undefined,
|
|
138
|
+
kinds: byKind,
|
|
139
|
+
relations: byRelation,
|
|
140
|
+
audiences: byAudience,
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!nodes.length) {
|
|
145
|
+
note(`nothing remembered yet ${dim(o.dir)}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
write(bold(o.project) + dim(' · ' + o.dir));
|
|
149
|
+
write();
|
|
150
|
+
writeAll(table([
|
|
151
|
+
[dim('nodes'), String(nodes.length)],
|
|
152
|
+
[dim('edges'), String(edges.length)],
|
|
153
|
+
[dim('superseded'), String(stale.size)],
|
|
154
|
+
[
|
|
155
|
+
dim('files'),
|
|
156
|
+
files.length
|
|
157
|
+
? `${files.length} · ${bytes(files.reduce((t, n) => t + (n.file?.bytes ?? 0), 0))}`
|
|
158
|
+
: '0',
|
|
159
|
+
],
|
|
160
|
+
[
|
|
161
|
+
dim('embedding'),
|
|
162
|
+
o.store.embedding
|
|
163
|
+
? `${o.store.embedding.model} ${dim(o.store.embedding.dimensions + 'd')}`
|
|
164
|
+
: yellow('none — recall falls back to term overlap'),
|
|
165
|
+
],
|
|
166
|
+
[dim('vectors'), vectors ? `${vectors.rows} rows` : dim('—')],
|
|
167
|
+
]));
|
|
168
|
+
// A node without a vector cannot be found by meaning, only by luck. It is
|
|
169
|
+
// the one silent failure this store has, so it is called out by name.
|
|
170
|
+
if (vectors && vectors.rows < nodes.length) {
|
|
171
|
+
write();
|
|
172
|
+
note(yellow(`${count(nodes.length - vectors.rows, 'node')} with no vector`));
|
|
173
|
+
}
|
|
174
|
+
section('kinds', byKind);
|
|
175
|
+
section('relations', byRelation);
|
|
176
|
+
section('audiences', byAudience);
|
|
177
|
+
}
|
|
178
|
+
function section(title, counts) {
|
|
179
|
+
const rows = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
|
180
|
+
if (!rows.length) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
write();
|
|
184
|
+
write(dim(title));
|
|
185
|
+
writeAll(table(rows.map(([k, n]) => [` ${cyan(k)}`, String(n)])));
|
|
186
|
+
}
|
|
187
|
+
function tally(values) {
|
|
188
|
+
const out = {};
|
|
189
|
+
for (const v of values) {
|
|
190
|
+
out[v] = (out[v] ?? 0) + 1;
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// ls
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
function list(o, flags, asJson) {
|
|
198
|
+
const stale = o.store.graph.superseded();
|
|
199
|
+
const limit = Number(flags.limit ?? LISTED);
|
|
200
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
201
|
+
throw usageError(`--limit wants a positive whole number, got ${flags.limit}`, USAGE);
|
|
202
|
+
}
|
|
203
|
+
const all = o.store.graph
|
|
204
|
+
.nodes()
|
|
205
|
+
.filter((n) => !flags.kind || n.kind === flags.kind)
|
|
206
|
+
.filter((n) => !flags.audience || n.audience.includes(flags.audience))
|
|
207
|
+
.filter((n) => !flags.files || n.file)
|
|
208
|
+
.filter((n) => !flags.stale || stale.has(n.id))
|
|
209
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
210
|
+
if (asJson) {
|
|
211
|
+
json({ total: all.length, nodes: all.slice(0, limit) });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!all.length) {
|
|
215
|
+
note('nothing matches');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
writeAll(table(all
|
|
219
|
+
.slice(0, limit)
|
|
220
|
+
.map((n) => [
|
|
221
|
+
cyan(n.id),
|
|
222
|
+
n.kind,
|
|
223
|
+
n.file ? dim('⎘ ' + n.file.format) : '',
|
|
224
|
+
clip(n.text, 56),
|
|
225
|
+
dim(ago(n.createdAt)),
|
|
226
|
+
stale.has(n.id) ? yellow('superseded') : '',
|
|
227
|
+
])));
|
|
228
|
+
if (all.length > limit) {
|
|
229
|
+
write();
|
|
230
|
+
note(dim(`${all.length - limit} more — raise --limit, or use \`zen memory export\``));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// show
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
function show(o, ids, asJson) {
|
|
237
|
+
const id = ids[0];
|
|
238
|
+
if (!id || ids.length > 1) {
|
|
239
|
+
throw usageError('show takes exactly one id', 'zen memory show <id>');
|
|
240
|
+
}
|
|
241
|
+
const node = o.store.graph.get(id);
|
|
242
|
+
if (!node) {
|
|
243
|
+
throw usageError(`no node ${id}`, 'list them with `zen memory ls`');
|
|
244
|
+
}
|
|
245
|
+
const links = o.store.graph.neighbors(id);
|
|
246
|
+
if (asJson) {
|
|
247
|
+
json({ node, links, file: node.file ? hostPath(o.dir, node.file) : undefined });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
write(bold(node.id) + ' ' + cyan(node.kind));
|
|
251
|
+
write();
|
|
252
|
+
writeAll(table([
|
|
253
|
+
[dim('revision'), String(node.revision)],
|
|
254
|
+
[dim('used'), `${node.useCount}× · last ${ago(node.lastUsedAt)}`],
|
|
255
|
+
[dim('created'), `${node.createdAt} ${dim(ago(node.createdAt))}`],
|
|
256
|
+
[dim('updated'), node.updatedAt],
|
|
257
|
+
[dim('audience'), node.audience.join(', ')],
|
|
258
|
+
]));
|
|
259
|
+
write();
|
|
260
|
+
write(node.text);
|
|
261
|
+
if (node.metadata) {
|
|
262
|
+
write();
|
|
263
|
+
write(dim('metadata'));
|
|
264
|
+
write(JSON.stringify(node.metadata, null, 2));
|
|
265
|
+
}
|
|
266
|
+
if (node.file) {
|
|
267
|
+
write();
|
|
268
|
+
write(dim('file'));
|
|
269
|
+
writeAll(table([
|
|
270
|
+
[dim(' path'), hostPath(o.dir, node.file)],
|
|
271
|
+
[dim(' size'), bytes(node.file.bytes)],
|
|
272
|
+
[dim(' sha256'), node.file.sha256],
|
|
273
|
+
]));
|
|
274
|
+
}
|
|
275
|
+
write();
|
|
276
|
+
write(dim(`links (${links.length})`));
|
|
277
|
+
if (!links.length) {
|
|
278
|
+
note(dim(' nothing points here and it points nowhere'));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
writeAll(table(links.map((e) => {
|
|
282
|
+
const out = e.source === id;
|
|
283
|
+
const other = out ? e.target : e.source;
|
|
284
|
+
return [
|
|
285
|
+
` ${out ? '→' : '←'} ${yellow(e.relation)}`,
|
|
286
|
+
cyan(other),
|
|
287
|
+
clip(o.store.graph.get(other)?.text ?? '', 48),
|
|
288
|
+
];
|
|
289
|
+
})));
|
|
290
|
+
}
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// export
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
async function write_(o, rest, flags, cwd, asJson) {
|
|
295
|
+
if (rest.length > 1) {
|
|
296
|
+
throw usageError('export takes at most one file name', 'zen memory export [file]');
|
|
297
|
+
}
|
|
298
|
+
const named = rest[0] ?? flags.out ?? 'memory.html';
|
|
299
|
+
const target = isAbsolute(named) ? named : resolve(cwd, named);
|
|
300
|
+
const report = await buildMemoryReport(o.store, { title: `${o.project} · memory` });
|
|
301
|
+
await writeFile(target, renderMemoryHtml(report), 'utf8');
|
|
302
|
+
if (asJson) {
|
|
303
|
+
json({ path: target, nodes: report.nodes.length, edges: report.edges.length });
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
note(`${green('wrote')} ${target} ${dim(`· ${count(report.nodes.length, 'node')}, ${count(report.edges.length, 'edge')}`)}`);
|
|
307
|
+
}
|
|
308
|
+
if (flags.open) {
|
|
309
|
+
reveal(target);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Handing a path to the platform opener. `spawn` without a shell, so the path
|
|
314
|
+
* is an argument rather than something a shell gets to interpret.
|
|
315
|
+
*/
|
|
316
|
+
function reveal(target) {
|
|
317
|
+
const command = process.platform === 'darwin'
|
|
318
|
+
? 'open'
|
|
319
|
+
: process.platform === 'win32'
|
|
320
|
+
? 'explorer'
|
|
321
|
+
: 'xdg-open';
|
|
322
|
+
spawn(command, [target], { stdio: 'ignore', detached: true }).unref();
|
|
323
|
+
}
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// forget
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
/**
|
|
328
|
+
* The only destructive thing here, and it is deliberately not the way to fix a
|
|
329
|
+
* memory that turned out wrong — that is a new node and a `SUPERSEDES` edge,
|
|
330
|
+
* which keeps the reason it changed. This is for what should never have been
|
|
331
|
+
* written down: it takes the node, its vector and its file bytes together.
|
|
332
|
+
*/
|
|
333
|
+
async function forget(o, ids, flags, asJson) {
|
|
334
|
+
if (!ids.length) {
|
|
335
|
+
throw usageError('forget takes at least one id', 'zen memory forget <id...>');
|
|
336
|
+
}
|
|
337
|
+
const found = [];
|
|
338
|
+
for (const id of ids) {
|
|
339
|
+
const node = o.store.graph.get(id);
|
|
340
|
+
if (!node) {
|
|
341
|
+
throw usageError(`no node ${id}`, 'list them with `zen memory ls`');
|
|
342
|
+
}
|
|
343
|
+
found.push(node);
|
|
344
|
+
}
|
|
345
|
+
if (!flags.yes) {
|
|
346
|
+
if (!isInteractive()) {
|
|
347
|
+
throw usageError('refusing to remove without confirmation', 'pass --yes');
|
|
348
|
+
}
|
|
349
|
+
writeAll(table(found.map((n) => [cyan(n.id), n.kind, clip(n.text, 56)])));
|
|
350
|
+
write();
|
|
351
|
+
if (!(await confirm(`Forget ${count(found.length, 'node')}?`))) {
|
|
352
|
+
note('nothing removed');
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// The vector and the bytes go with the node, which `MemoryIndex` already
|
|
357
|
+
// guarantees; `sees` is every audience in the graph, because this command
|
|
358
|
+
// is the person who owns the files rather than an agent inside a run.
|
|
359
|
+
const index = new MemoryIndex({ store: o.store });
|
|
360
|
+
await index.forget(found.map((n) => n.id), [ALL_AGENTS, ...new Set(o.store.graph.nodes().flatMap((n) => n.audience))]);
|
|
361
|
+
if (asJson) {
|
|
362
|
+
json({ forgotten: found.map((n) => n.id) });
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
note(`${green('forgot')} ${count(found.length, 'node')}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Shared
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
function clip(text, n) {
|
|
372
|
+
const one = text.replace(/\s+/g, ' ').trim();
|
|
373
|
+
return one.length > n ? one.slice(0, n - 1) + '…' : one;
|
|
374
|
+
}
|
|
375
|
+
//# sourceMappingURL=memory.js.map
|