@zenera/cli 1.1.24 → 1.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +53 -0
  2. package/dist/ask.js +28 -5
  3. package/dist/banner.js +2 -2
  4. package/dist/batch.d.ts +6 -0
  5. package/dist/batch.js +76 -1
  6. package/dist/catalog.d.ts +11 -1
  7. package/dist/catalog.js +80 -7
  8. package/dist/commands/inspect.d.ts +47 -1
  9. package/dist/commands/inspect.js +167 -18
  10. package/dist/commands/memory.js +128 -29
  11. package/dist/commands/meta.js +311 -21
  12. package/dist/commands/models.js +96 -12
  13. package/dist/commands/open.js +4 -4
  14. package/dist/commands/run.js +38 -4
  15. package/dist/commands/sandbox.js +76 -14
  16. package/dist/engine.d.ts +5 -0
  17. package/dist/engine.js +1 -0
  18. package/dist/eventlog.d.ts +59 -0
  19. package/dist/eventlog.js +146 -0
  20. package/dist/liveness.d.ts +7 -0
  21. package/dist/liveness.js +15 -1
  22. package/dist/meta/editor.d.ts +9 -0
  23. package/dist/meta/editor.js +60 -0
  24. package/dist/meta/finetune/children.d.ts +8 -0
  25. package/dist/meta/finetune/children.js +76 -0
  26. package/dist/meta/finetune/command.d.ts +30 -0
  27. package/dist/meta/finetune/command.js +338 -0
  28. package/dist/meta/finetune/dataset/anchors.d.ts +41 -0
  29. package/dist/meta/finetune/dataset/anchors.js +239 -0
  30. package/dist/meta/finetune/dataset/changes.d.ts +36 -0
  31. package/dist/meta/finetune/dataset/changes.js +319 -0
  32. package/dist/meta/finetune/dataset/command.d.ts +17 -0
  33. package/dist/meta/finetune/dataset/command.js +1011 -0
  34. package/dist/meta/finetune/dataset/drift.d.ts +33 -0
  35. package/dist/meta/finetune/dataset/drift.js +71 -0
  36. package/dist/meta/finetune/dataset/export.d.ts +26 -0
  37. package/dist/meta/finetune/dataset/export.js +51 -0
  38. package/dist/meta/finetune/dataset/index.d.ts +2 -0
  39. package/dist/meta/finetune/dataset/index.js +2 -0
  40. package/dist/meta/finetune/dataset/sample.d.ts +50 -0
  41. package/dist/meta/finetune/dataset/sample.js +197 -0
  42. package/dist/meta/finetune/dataset/schema.d.ts +33 -0
  43. package/dist/meta/finetune/dataset/schema.js +216 -0
  44. package/dist/meta/finetune/dataset/store.d.ts +35 -0
  45. package/dist/meta/finetune/dataset/store.js +177 -0
  46. package/dist/meta/finetune/dataset/types.d.ts +125 -0
  47. package/dist/meta/finetune/dataset/types.js +39 -0
  48. package/dist/meta/finetune/failure.d.ts +15 -0
  49. package/dist/meta/finetune/failure.js +26 -0
  50. package/dist/meta/finetune/feedback.d.ts +45 -0
  51. package/dist/meta/finetune/feedback.js +85 -0
  52. package/dist/meta/finetune/improvements.d.ts +51 -0
  53. package/dist/meta/finetune/improvements.js +244 -0
  54. package/dist/meta/finetune/index.d.ts +2 -0
  55. package/dist/meta/finetune/index.js +2 -0
  56. package/dist/meta/finetune/journal.d.ts +35 -0
  57. package/dist/meta/finetune/journal.js +135 -0
  58. package/dist/meta/finetune/live.d.ts +74 -0
  59. package/dist/meta/finetune/live.js +226 -0
  60. package/dist/meta/finetune/loop.d.ts +3 -0
  61. package/dist/meta/finetune/loop.js +114 -0
  62. package/dist/meta/finetune/memories.d.ts +47 -0
  63. package/dist/meta/finetune/memories.js +154 -0
  64. package/dist/meta/finetune/report.d.ts +33 -0
  65. package/dist/meta/finetune/report.js +1284 -0
  66. package/dist/meta/finetune/retry.d.ts +24 -0
  67. package/dist/meta/finetune/retry.js +96 -0
  68. package/dist/meta/finetune/sampler.d.ts +18 -0
  69. package/dist/meta/finetune/sampler.js +41 -0
  70. package/dist/meta/finetune/system.d.ts +27 -0
  71. package/dist/meta/finetune/system.js +132 -0
  72. package/dist/meta/finetune/train.d.ts +39 -0
  73. package/dist/meta/finetune/train.js +391 -0
  74. package/dist/meta/finetune/tui.d.ts +16 -0
  75. package/dist/meta/finetune/tui.js +291 -0
  76. package/dist/meta/finetune/tuning.d.ts +167 -0
  77. package/dist/meta/finetune/tuning.js +289 -0
  78. package/dist/meta/finetune/usage.d.ts +62 -0
  79. package/dist/meta/finetune/usage.js +225 -0
  80. package/dist/meta/host.d.ts +14 -0
  81. package/dist/meta/host.js +20 -0
  82. package/dist/meta/index.d.ts +11 -0
  83. package/dist/meta/index.js +11 -0
  84. package/dist/meta/launch.d.ts +81 -0
  85. package/dist/meta/launch.js +286 -0
  86. package/dist/meta/model.d.ts +44 -0
  87. package/dist/meta/model.js +119 -0
  88. package/dist/meta/prompts.d.ts +18 -0
  89. package/dist/meta/prompts.js +61 -0
  90. package/dist/meta/relay.d.ts +20 -0
  91. package/dist/meta/relay.js +123 -0
  92. package/dist/meta/tokens.d.ts +52 -0
  93. package/dist/meta/tokens.js +226 -0
  94. package/dist/meta/window.d.ts +61 -0
  95. package/dist/meta/window.js +198 -0
  96. package/dist/meta/wire.d.ts +42 -0
  97. package/dist/meta/wire.js +129 -0
  98. package/dist/modelref.d.ts +16 -0
  99. package/dist/modelref.js +41 -0
  100. package/dist/narrate.js +5 -2
  101. package/dist/podman.d.ts +26 -0
  102. package/dist/podman.js +83 -3
  103. package/dist/projects.d.ts +0 -5
  104. package/dist/projects.js +2 -15
  105. package/dist/sandbox.d.ts +2 -0
  106. package/dist/sandbox.js +10 -3
  107. package/dist/scaffold.d.ts +15 -6
  108. package/dist/scaffold.js +5 -60
  109. package/dist/session.js +4 -29
  110. package/dist/term.d.ts +2 -0
  111. package/dist/term.js +11 -2
  112. package/dist/trace.js +53 -2
  113. package/dist/tui/models.d.ts +33 -0
  114. package/dist/tui/models.js +389 -0
  115. package/dist/tui/wrap.d.ts +5 -0
  116. package/dist/tui/wrap.js +12 -0
  117. package/dist/usage.d.ts +78 -0
  118. package/dist/usage.js +51 -0
  119. package/package.json +3 -2
  120. package/templates/editor/.github/copilot-instructions.md +31 -102
  121. package/templates/editor/.github/prompts/analyze.prompt.md +31 -0
  122. package/templates/editor/.github/prompts/dataset.prompt.md +108 -0
  123. package/templates/editor/.github/prompts/finetune-apply.prompt.md +91 -0
  124. package/templates/editor/.github/prompts/finetune.prompt.md +691 -198
  125. package/templates/editor/.github/prompts/new-agent.prompt.md +3 -1
  126. package/templates/editor/.github/prompts/new-skill.prompt.md +2 -0
  127. package/templates/editor/.github/prompts/project-review.prompt.md +4 -3
  128. package/templates/editor/.github/skills/zen-analyze-run/SKILL.md +483 -0
  129. package/templates/editor/.github/skills/zen-cli/SKILL.md +9 -1
  130. package/templates/editor/.github/skills/zen-cli/references/dataset.md +34 -0
  131. package/templates/editor/.github/skills/zen-cli/references/finetune.md +77 -0
  132. package/templates/editor/.github/skills/zen-cli/references/inspect.md +47 -9
  133. package/templates/editor/.github/skills/zen-cli/references/memory.md +33 -10
  134. package/templates/editor/.github/skills/zen-cli/references/meta.md +61 -0
  135. package/templates/editor/.github/skills/zen-cli/references/models.md +5 -1
  136. package/templates/editor/.github/skills/zen-cli/references/rag.md +24 -6
  137. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +10 -1
  138. package/templates/editor/.github/skills/zen-code-python/SKILL.md +123 -0
  139. package/templates/editor/.github/skills/zen-code-python/references/code_python.md +48 -0
  140. package/templates/editor/.github/skills/zen-dataset/SKILL.md +253 -0
  141. package/templates/editor/.github/skills/zen-finetune/SKILL.md +1449 -658
  142. package/templates/editor/.github/skills/zen-finetune/references/readme-template.md +268 -0
  143. package/templates/editor/.github/skills/zen-finetune/scripts/batch.mjs +390 -0
  144. package/templates/editor/.github/skills/zen-finetune/scripts/difficult.mjs +243 -0
  145. package/templates/editor/.github/skills/zen-finetune/scripts/memory.mjs +280 -0
  146. package/templates/editor/.github/skills/zen-finetune/scripts/next.mjs +398 -0
  147. package/templates/editor/.github/skills/zen-finetune/scripts/report.mjs +797 -0
  148. package/templates/editor/.github/skills/zen-finetune/scripts/select.mjs +354 -0
  149. package/templates/editor/.github/skills/zen-finetune/scripts/usage.mjs +722 -0
  150. package/templates/editor/.github/skills/zen-inspect/SKILL.md +60 -25
  151. package/templates/editor/.github/skills/zen-inspect-ask/SKILL.md +270 -0
  152. package/templates/editor/.github/skills/zen-memory/SKILL.md +18 -14
  153. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +20 -8
  154. package/templates/editor/.github/skills/zen-review/SKILL.md +5 -3
  155. package/templates/editor/.github/skills/zen-review/scripts/check-paths.sh +5 -4
  156. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +7 -3
  157. package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +2 -2
  158. package/templates/editor/.github/skills/zen-topology/SKILL.md +283 -0
  159. package/templates/editor/.vscode/settings.json +1 -0
  160. package/templates/project/agents/files-instructions.md +29 -20
  161. package/templates/project/agents/memory-instructions.md +31 -32
  162. package/templates/project/agents/tools-instructions.md +7 -4
  163. package/templates/project/gitignore +2 -0
  164. package/dist/meta.d.ts +0 -221
  165. package/dist/meta.js +0 -684
  166. package/templates/editor/.github/skills/zen-finetune/scripts/collect.sh +0 -432
  167. package/templates/editor/.github/skills/zen-finetune/scripts/sample.sh +0 -199
@@ -1,4 +1,4 @@
1
- import { assertState, buildRunReport, FilePayloadStore, memoryDir, MemoryStore, PayloadResolver, projectRegistry, readProjectConfig, renderReportHtml, } from '@zenera/neo';
1
+ import { assertState, buildRunReport, FilePayloadStore, memoryDir, MemoryStore, PayloadResolver, projectRegistry, readProjectConfig, renderReportHtml, text, } from '@zenera/neo';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { readFile, writeFile } from 'node:fs/promises';
@@ -11,7 +11,10 @@ import { sessionIds } from "../projects.js";
11
11
  import { project as resolveProject } from "../resolve.js";
12
12
  import { display, listRuns, listSessions, newestRun, readRunMeta, requireSession, runPaths, runPathsAt, sessionPaths, } from "../session.js";
13
13
  import { nodeDetail, parseNodeIds, traceIndex, traceMermaid, traceOf, } from "../trace.js";
14
- import { ago, bold, bytes, choose, cyan, dim, invalidError, isInteractive, json, note, usageError, write, writeAll, yellow, } from "../term.js";
14
+ import { formatMarkdown } from "../tui/markdown.js";
15
+ import { boxWidth } from "../tui/wrap.js";
16
+ import { ago, bold, bytes, choose, cyan, dim, invalidError, isInteractive, json, note, pad, ask as prompt, readStdin, usageError, write, writeAll, yellow, } from "../term.js";
17
+ import { appendUsage } from "../usage.js";
15
18
  const USAGE = 'zen inspect [report|graph|node|ask] [run] [--dir <run dir>] [--open]';
16
19
  const SUBCOMMANDS = ['report', 'graph', 'node', 'ask'];
17
20
  // ---------------------------------------------------------------------------
@@ -44,7 +47,8 @@ export const inspect = {
44
47
  ' report Build and print the path to report.html. Default.',
45
48
  ' graph The whole run as one Mermaid flowchart, on stdout.',
46
49
  ' node <id...> Those nodes of the graph in full. Ranges: n5..n9.',
47
- ' ask <id> <question> Put a question to the model that made that call.',
50
+ ' ask <id> <question> One answer on stdout, then exit. Never prompts.',
51
+ ' ask [<id>] A conversation at a terminal. Prompts for the rest.',
48
52
  '',
49
53
  ' --project <name|dir> Which project. Defaults to the one you are in.',
50
54
  ' --session <id> Which session. Defaults to the newest that ran.',
@@ -52,6 +56,7 @@ export const inspect = {
52
56
  ' --dir <dir> A run directory, as `zen run --json` reports it.',
53
57
  ' --memory <dir> Read this memory instead of the project’s.',
54
58
  ' --model <ref> Answer `ask` with this model instead of the run’s.',
59
+ ' --question-file <path> Read the `ask` question from a file; `-` is stdin.',
55
60
  ' --part <name> Print this part of a node in full. Repeatable.',
56
61
  ' --full Print every part, the recorded `request` included.',
57
62
  ' --rebuild Build report.html again from the run state.',
@@ -59,8 +64,8 @@ export const inspect = {
59
64
  ' --no-timing Leave the clock off the graph.',
60
65
  ' --no-style Leave the colours off the graph. Shorter to read.',
61
66
  '',
62
- 'With no arguments: asks which session and run, or takes the newest of',
63
- 'each when there is nothing to ask on.',
67
+ 'With no run named, report, graph and node ask which session and run at',
68
+ 'a terminal, and take the newest of each when there is nothing to ask on.',
64
69
  '',
65
70
  '`graph` is written to be read by a model. Nodes are declared in the',
66
71
  'order they happened, with ids `n1`, `n2`, …; every edge is collected in',
@@ -82,9 +87,27 @@ export const inspect = {
82
87
  '`ask` replays one `llm_call` — its system prompt, its messages, its tool',
83
88
  'schemas — to a model, with your question on the end and tool calling off.',
84
89
  'It is told the run is over and that it may quote its own instructions, so',
85
- 'the answer names the prompt or skill behind the behaviour:',
90
+ 'the answer names the prompt or skill behind the behaviour. It has two',
91
+ 'modes, and which one is decided by whether the question is given:',
92
+ '',
93
+ 'One-shot — a question on the line, in --question-file, or --json. For a',
94
+ 'script or a model: no banner, no prompt, no picker; stdout is the answer',
95
+ 'and nothing else. Name the run with --dir, --run or --session, or the',
96
+ 'newest is taken.',
86
97
  '',
87
98
  ' zen inspect ask n11 "why run python -c when the skill says npm test?"',
99
+ ' zen inspect ask n11 --question-file q.md --dir <run dir>',
100
+ '',
101
+ 'A question with quotes, backticks or several lines goes in a file, so no',
102
+ 'shell ever parses it.',
103
+ '',
104
+ 'Interactive — no question. For a person at a terminal: asks for whatever',
105
+ 'is not named (session, run, LLM call), then the question, and keeps the',
106
+ 'conversation going until an empty question. Without a terminal it is an',
107
+ 'error, never a wait.',
108
+ '',
109
+ ' zen inspect ask',
110
+ ' zen inspect ask n11 --dir <run dir>',
88
111
  '',
89
112
  'All of it, at length: .github/skills/zen-cli/references/inspect.md',
90
113
  ],
@@ -97,6 +120,7 @@ export const inspect = {
97
120
  memory: { type: 'string' },
98
121
  model: { type: 'string' },
99
122
  part: { type: 'string', multiple: true },
123
+ 'question-file': { type: 'string' },
100
124
  full: { type: 'boolean' },
101
125
  open: { type: 'boolean' },
102
126
  rebuild: { type: 'boolean' },
@@ -117,8 +141,13 @@ export const inspect = {
117
141
  return await nodes(at, values, rest, ctx.json);
118
142
  }
119
143
  if (what === 'ask') {
120
- const at = await locate(ctx.cwd, values, undefined, asking);
121
- return await ask(at, values, rest, ctx.json);
144
+ // Given a question it is one-shot; only a bare `ask` converses.
145
+ const oneShot = ctx.json || values['question-file'] !== undefined || rest.length > 1;
146
+ if (!oneShot && !(asking && process.stdout.isTTY)) {
147
+ throw usageError('interactive `ask` needs a terminal; give the node id and a question', 'zen inspect ask n11 --question-file q.md --dir <run dir>');
148
+ }
149
+ const at = await locate(ctx.cwd, values, undefined, !oneShot);
150
+ return await ask(at, values, rest, ctx.cwd, ctx.json, !oneShot);
122
151
  }
123
152
  const at = await locate(ctx.cwd, values, rest[0], asking);
124
153
  if (what === 'graph') {
@@ -245,14 +274,82 @@ export function wholeParts(found, values) {
245
274
  * it. Everything it sees is what it saw at the time, so an answer naming the
246
275
  * skill that steered it is checkable against the same node.
247
276
  */
248
- async function ask(at, values, rest, asJson) {
277
+ export function replayableCalls(trace) {
278
+ return trace.entries.flatMap((entry) => entry.node.type === 'llm_call' && entry.node.request ? [{ entry, node: entry.node }] : []);
279
+ }
280
+ const ASK_BOX = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' };
281
+ function askPanel(title, text, outer, border) {
282
+ const inner = outer - 4;
283
+ const rule = ASK_BOX.h.repeat(Math.max(1, outer - title.length - 5));
284
+ return [
285
+ border(`${ASK_BOX.tl}${ASK_BOX.h} ${title} ${rule}${ASK_BOX.tr}`),
286
+ ...formatMarkdown(text, inner).map((line) => `${border(ASK_BOX.v)} ${pad(line, inner)} ${border(ASK_BOX.v)}`),
287
+ border(`${ASK_BOX.bl}${ASK_BOX.h.repeat(outer - 2)}${ASK_BOX.br}`),
288
+ ];
289
+ }
290
+ export function renderAskExchange(question, answer, columns = 80) {
291
+ const outer = boxWidth(`${question}\n\n${answer}`, columns);
292
+ return [
293
+ '',
294
+ ...askPanel('Question', question, outer, cyan),
295
+ '',
296
+ ...askPanel('Answer', answer, outer, dim),
297
+ '',
298
+ ];
299
+ }
300
+ export async function repeatQuestions(initial, next, answer) {
301
+ let question = initial;
302
+ while (question) {
303
+ await answer(question);
304
+ question = (await next()).trim();
305
+ }
306
+ }
307
+ /** The question: the words on the line, or the whole of `--question-file` (`-` is stdin). */
308
+ export async function readQuestion(words, file, cwd) {
309
+ const typed = words.join(' ').trim();
310
+ if (file === undefined) {
311
+ return typed;
312
+ }
313
+ if (typed) {
314
+ throw usageError('give the question either as words or as --question-file, not both', 'zen inspect ask n11 --question-file q.md');
315
+ }
316
+ if (file === '-') {
317
+ return (await readStdin()) ?? '';
318
+ }
319
+ try {
320
+ return (await readFile(resolve(cwd, file), 'utf8')).trim();
321
+ }
322
+ catch (err) {
323
+ throw invalidError(`cannot read question file ${file}: ${err.code ?? err}`, 'write the question to that path first');
324
+ }
325
+ }
326
+ async function ask(at, values, rest, cwd, asJson, interactive) {
249
327
  const { project, session, run } = at;
250
- const [id, ...words] = rest;
251
- const query = words.join(' ').trim();
252
- if (!id || !query) {
328
+ const trace = traceOf(await readState(run));
329
+ let [id, ...words] = rest;
330
+ if (!id && interactive) {
331
+ const calls = replayableCalls(trace);
332
+ if (calls.length === 0) {
333
+ throw invalidError(`run ${run.id} has no recorded LLM call to replay`, 'only runs that record requests can be asked about');
334
+ }
335
+ const entry = await choose('Which LLM call?', calls.map((candidate) => ({
336
+ label: `${candidate.entry.key} ${candidate.node.model}`,
337
+ detail: [
338
+ candidate.node.agent,
339
+ candidate.node.toolCalls.length
340
+ ? `calls ${candidate.node.toolCalls.map((call) => call.name).join(', ')}`
341
+ : '',
342
+ candidate.entry.branch ? `branch ${candidate.entry.branch}` : '',
343
+ ]
344
+ .filter(Boolean)
345
+ .join(' '),
346
+ value: candidate.entry,
347
+ })));
348
+ id = entry.key;
349
+ }
350
+ if (!id) {
253
351
  throw usageError('ask takes one node id and a question', 'zen inspect ask n11 "why did you run python -c instead of the tests?"');
254
352
  }
255
- const trace = traceOf(await readState(run));
256
353
  let wanted;
257
354
  try {
258
355
  wanted = parseNodeIds([id], trace.byKey);
@@ -272,6 +369,15 @@ async function ask(at, values, rest, asJson) {
272
369
  throw invalidError(`${entry.key} did not record the request that produced it`, 'only a run made by this CLI records requests; an SDK run needs ' +
273
370
  '`runner({ recordRequests: true })`');
274
371
  }
372
+ const query = interactive
373
+ ? await prompt('Question (empty to finish)?')
374
+ : await readQuestion(words, values['question-file'], cwd);
375
+ if (!query && interactive) {
376
+ return;
377
+ }
378
+ if (!query) {
379
+ throw usageError('ask takes one node id and a question', 'zen inspect ask n11 "why did you run python -c instead of the tests?"');
380
+ }
275
381
  const payloads = new PayloadResolver(new FilePayloadStore({ dir: session.blobs, id: 'file' }));
276
382
  // Deliberately not the preview fallback `node` uses: a truncated request is
277
383
  // not the call that happened, and an answer about it would be about nothing.
@@ -292,8 +398,32 @@ async function ask(at, values, rest, asJson) {
292
398
  'a second opinion, not the model looking at itself'));
293
399
  }
294
400
  const model = projectRegistry(config).model(picked.ref);
295
- const res = await model.generate(buildDiagnostic({ request: recorded, answer, toolCalls, query }));
401
+ const record = async (call) => {
402
+ const startedAt = new Date();
403
+ const res = await call();
404
+ appendUsage(project, {
405
+ kind: 'ask',
406
+ ts: new Date().toISOString(),
407
+ runDir: run.dir,
408
+ node: entry.key,
409
+ nodeAgent: node.agent,
410
+ nodeModel: node.model,
411
+ model: picked.label,
412
+ usage: res.usage,
413
+ startedAt: startedAt.toISOString(),
414
+ durationMs: Date.now() - startedAt.getTime(),
415
+ });
416
+ return res;
417
+ };
418
+ const generate = (question) => record(() => model.generate(buildDiagnostic({ request: recorded, answer, toolCalls, query: question })));
419
+ const usage = (res) => {
420
+ note(dim(`${bold(entry.key)} ${picked.label}` +
421
+ (res.usage
422
+ ? ` · ${res.usage.inputTokens} in, ${res.usage.outputTokens} out`
423
+ : '')));
424
+ };
296
425
  if (asJson) {
426
+ const res = await generate(query);
297
427
  json({
298
428
  session: session.id,
299
429
  run: run.id,
@@ -306,9 +436,28 @@ async function ask(at, values, rest, asJson) {
306
436
  });
307
437
  return;
308
438
  }
309
- write(res.text);
310
- note(dim(`${bold(entry.key)} ${picked.label}` +
311
- (res.usage ? ` · ${res.usage.inputTokens} in, ${res.usage.outputTokens} out` : '')));
439
+ if (!interactive) {
440
+ write((await generate(query)).text);
441
+ return;
442
+ }
443
+ let conversation;
444
+ await repeatQuestions(query, () => prompt('Question (empty to finish)?'), async (question) => {
445
+ if (!conversation) {
446
+ conversation = buildDiagnostic({
447
+ request: recorded,
448
+ answer,
449
+ toolCalls,
450
+ query: question,
451
+ });
452
+ }
453
+ else {
454
+ conversation.messages.push({ role: 'user', content: [text(question)] });
455
+ }
456
+ const res = await record(() => model.generate(conversation));
457
+ conversation.messages.push({ role: 'assistant', content: res.text });
458
+ writeAll(renderAskExchange(question, res.text, process.stdout.columns ?? 80));
459
+ usage(res);
460
+ });
312
461
  }
313
462
  /**
314
463
  * A part name as it can be typed back. The tool-call parts are named
@@ -371,7 +520,7 @@ export function renderNode(node, whole = () => true) {
371
520
  * it can be used again would be work for nothing. A person holds an id, or
372
521
  * nothing at all and gets asked.
373
522
  */
374
- async function locate(cwd, values, positional, asking) {
523
+ export async function locate(cwd, values, positional, asking) {
375
524
  // A run id is a stamp and never has a separator in it, so a positional
376
525
  // with one is unambiguously a path.
377
526
  const asPath = positional?.includes('/') ? positional : undefined;
@@ -1,15 +1,15 @@
1
- import { ALL_AGENTS, buildMemoryReport, createEmbedder, GREP_FIELDS, grepMemory, hostPath, memoryDir, MemoryError, MemoryIndex, MemoryStore, MergeConflicts, mergeMemories, readProjectConfig, renderMemoryHtml, renderRecollection, } from '@zenera/neo';
1
+ import { ALL_AGENTS, buildMemoryReport, createEmbedder, diffMemories, GREP_FIELDS, grepMemory, hostPath, memoryDir, MemoryError, MemoryIndex, MemoryStore, MergeConflicts, mergeMemories, readProjectConfig, renderMemoryHtml, renderRecollection, } from '@zenera/neo';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { writeFile } from 'node:fs/promises';
5
- import { basename, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { basename, isAbsolute, join, resolve } from 'node:path';
6
6
  import { parse } from "../args.js";
7
7
  import { loadProjectEnv } from "../env.js";
8
8
  import { KeyStore } from "../keys.js";
9
9
  import { project as resolveProject } from "../resolve.js";
10
- import { ago, bold, bytes, CliError, confirm, count, cyan, dim, EXIT, green, isInteractive, json, note, table, usageError, write, writeAll, yellow, } from "../term.js";
11
- const USAGE = 'zen memory [stats|ls|search|grep|show|export|merge|forget] [args] [options]';
12
- const SUBCOMMANDS = ['stats', 'ls', 'search', 'grep', 'show', 'export', 'merge', 'forget'];
10
+ import { ago, bold, bytes, CliError, confirm, count, cyan, dim, EXIT, green, isInteractive, json, note, plain, table, usageError, write, writeAll, yellow, } from "../term.js";
11
+ const USAGE = 'zen memory [stats|ls|search|grep|show|diff|export|merge|forget] [args] [options]';
12
+ const SUBCOMMANDS = ['stats', 'ls', 'search', 'grep', 'show', 'diff', 'export', 'merge', 'forget'];
13
13
  /** Enough to see the shape of it; the rest is a number. */
14
14
  const LISTED = 30;
15
15
  // ---------------------------------------------------------------------------
@@ -42,6 +42,7 @@ export const memory = {
42
42
  ' search <text> Recall it, the way an agent does. Ranked.',
43
43
  ' grep <pattern> Every node containing it, with the matching lines.',
44
44
  ' show <id> One node in full, with what it links to.',
45
+ ' diff <base> What this memory holds that <base> did not.',
45
46
  ' export [file] The whole graph as one HTML page.',
46
47
  ' merge <dir...> Fold other memories into this one.',
47
48
  ' forget <id...> Remove nodes, their vectors and their files.',
@@ -104,6 +105,12 @@ export const memory = {
104
105
  ' zen memory merge <batch-dir>/*/memory',
105
106
  '',
106
107
  'which is where `zen run batch` leaves them.',
108
+ '',
109
+ '`diff` is the question after a run that started from a copy: what it',
110
+ 'added, which of those `merge` would fold onto a node <base> already held',
111
+ '(a re-commit), what it revised, and which nodes `memory_load` read:',
112
+ '',
113
+ ' zen memory diff <candidate> --dir <batch-dir>/<id>/memory',
107
114
  ],
108
115
  run: async (ctx) => {
109
116
  const { values, positionals } = parse(ctx.args, {
@@ -156,6 +163,8 @@ export const memory = {
156
163
  return await grep(opened, rest, values, ctx.json);
157
164
  case 'show':
158
165
  return show(opened, rest, ctx.json);
166
+ case 'diff':
167
+ return await diff(opened, rest, ctx.cwd, ctx.json);
159
168
  case 'export':
160
169
  return await write_(opened, rest, values, ctx.cwd, ctx.json);
161
170
  case 'merge':
@@ -309,23 +318,22 @@ function list(o, flags, asJson) {
309
318
  .filter((n) => !flags.stale || stale.has(n.id))
310
319
  .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
311
320
  if (asJson) {
312
- json({ total: all.length, nodes: all.slice(0, limit) });
321
+ json({ total: all.length, nodes: all.slice(0, limit).map((n) => onHost(o, n)) });
313
322
  return;
314
323
  }
315
324
  if (!all.length) {
316
325
  note('nothing matches');
317
326
  return;
318
327
  }
319
- writeAll(table(all
328
+ writeAll(textLast(all
320
329
  .slice(0, limit)
321
330
  .map((n) => [
322
331
  cyan(n.id),
323
- n.kind,
324
- n.file ? dim('⎘ ' + n.file.format) : '',
325
- clip(n.text, 56),
326
- dim(ago(n.createdAt)),
327
- stale.has(n.id) ? yellow('superseded') : '',
328
- ])));
332
+ n.kind + (n.file ? dim(' ⎘ ' + n.file.format) : ''),
333
+ n.audience.join(','),
334
+ dim(`r${n.revision}`),
335
+ dim(ago(n.createdAt)) + (stale.has(n.id) ? ' ' + yellow('superseded') : ''),
336
+ ]), all.slice(0, limit).map((n) => n.text)));
329
337
  if (all.length > limit) {
330
338
  write();
331
339
  note(dim(`${all.length - limit} more — raise --limit, or use \`zen memory export\``));
@@ -552,7 +560,7 @@ async function grep(o, rest, flags, asJson) {
552
560
  hits: m.hits,
553
561
  more: m.more,
554
562
  })),
555
- unsearched: res.skipped,
563
+ unsearched: res.skipped.map((s) => ({ ...s, path: hostPath(o.dir, s) })),
556
564
  });
557
565
  return;
558
566
  }
@@ -562,7 +570,7 @@ async function grep(o, rest, flags, asJson) {
562
570
  }
563
571
  if (!res.found) {
564
572
  note(`nothing matches ${pattern}`);
565
- unsearched(res.skipped);
573
+ unsearched(o, res.skipped);
566
574
  return;
567
575
  }
568
576
  for (const m of res.matches) {
@@ -570,7 +578,7 @@ async function grep(o, rest, flags, asJson) {
570
578
  ' ' +
571
579
  m.node.kind +
572
580
  (m.stale ? ' ' + yellow('superseded') : '') +
573
- (m.node.file ? ' ' + dim(m.node.file.path) : ''));
581
+ (m.node.file ? ' ' + dim(hostPath(o.dir, m.node.file)) : ''));
574
582
  writeAll(table(m.hits.map((h) => [` ${dim(h.where + ':' + h.line)}`, clip(h.text, 120)])));
575
583
  if (m.more) {
576
584
  write(dim(` … ${count(m.more, 'more line')} in this node`));
@@ -580,19 +588,19 @@ async function grep(o, rest, flags, asJson) {
580
588
  if (res.truncated) {
581
589
  note(dim(`${res.found - res.matches.length} more nodes — raise --limit`));
582
590
  }
583
- unsearched(res.skipped);
591
+ unsearched(o, res.skipped);
584
592
  }
585
593
  /**
586
594
  * Named rather than counted. A file that was not read is a hole in an answer
587
595
  * whose whole value is that it has none, so the ids have to be printable.
588
596
  */
589
- function unsearched(skipped) {
597
+ function unsearched(o, skipped) {
590
598
  if (!skipped.length) {
591
599
  return;
592
600
  }
593
601
  write();
594
602
  note(yellow(`${count(skipped.length, 'file')} not searched`));
595
- writeAll(table(skipped.map((s) => [` ${cyan(s.id)}`, dim(s.reason), s.path])));
603
+ writeAll(table(skipped.map((s) => [` ${cyan(s.id)}`, dim(s.reason), hostPath(o.dir, s)])));
596
604
  }
597
605
  // ---------------------------------------------------------------------------
598
606
  // show
@@ -608,7 +616,11 @@ function show(o, ids, asJson) {
608
616
  }
609
617
  const links = o.store.graph.neighbors(id);
610
618
  if (asJson) {
611
- json({ node, links, file: node.file ? hostPath(o.dir, node.file) : undefined });
619
+ json({
620
+ node: onHost(o, node),
621
+ links,
622
+ file: node.file ? hostPath(o.dir, node.file) : undefined,
623
+ });
612
624
  return;
613
625
  }
614
626
  write(bold(node.id) + ' ' + cyan(node.kind));
@@ -653,6 +665,82 @@ function show(o, ids, asJson) {
653
665
  })));
654
666
  }
655
667
  // ---------------------------------------------------------------------------
668
+ // diff
669
+ // ---------------------------------------------------------------------------
670
+ async function diff(o, rest, cwd, asJson) {
671
+ if (rest.length !== 1) {
672
+ throw usageError('diff takes exactly one base memory', 'zen memory diff <base> [--dir <dir>]');
673
+ }
674
+ const at = resolve(cwd, rest[0]);
675
+ if (!existsSync(join(at, 'manifest.json'))) {
676
+ throw usageError(`${at} is not a memory`, 'no manifest.json in it');
677
+ }
678
+ const base = await MemoryStore.open(at, { lock: false });
679
+ try {
680
+ const d = diffMemories(base, o.store);
681
+ const mine = (n) => onHost(o, n);
682
+ const theirs = (n) => n.file ? { ...n, file: { ...n.file, path: hostPath(at, n.file) } } : n;
683
+ if (asJson) {
684
+ json({
685
+ ...d,
686
+ added: d.added.map((a) => ({ ...a, node: mine(a.node) })),
687
+ revised: d.revised.map((r) => ({ before: theirs(r.before), after: mine(r.after) })),
688
+ used: d.used.map((u) => ({ ...u, node: mine(u.node) })),
689
+ removed: d.removed.map(theirs),
690
+ });
691
+ return;
692
+ }
693
+ const twins = d.added.filter((a) => a.twin).length;
694
+ write(bold(o.dir) + dim(` against ${at} · nearest by ${d.by}`));
695
+ write();
696
+ write(`${bold('added')} ${d.added.length}` +
697
+ (twins ? ` · ${yellow(`${twins} already held`)}` : '') +
698
+ ` · ${bold('revised')} ${d.revised.length}` +
699
+ ` · ${bold('used')} ${d.used.length}` +
700
+ ` · ${bold('removed')} ${d.removed.length}` +
701
+ ` · ${bold('edges')} +${d.edges}`);
702
+ if (d.added.length) {
703
+ write();
704
+ write(dim('added'));
705
+ writeAll(textLast(d.added.map((a) => [
706
+ ` ${cyan(a.node.id)}`,
707
+ a.node.kind,
708
+ a.node.audience.join(','),
709
+ a.nearest
710
+ ? `${a.twin ? yellow('twin of') : dim('near')} ${a.nearest.id} ${dim(a.nearest.score.toFixed(2))}`
711
+ : dim('nothing near'),
712
+ ]), d.added.map((a) => a.node.text)));
713
+ }
714
+ if (d.revised.length) {
715
+ write();
716
+ write(dim('revised'));
717
+ writeAll(textLast(d.revised.map((r) => [
718
+ ` ${cyan(r.after.id)}`,
719
+ r.after.kind,
720
+ dim(`r${r.before.revision} → r${r.after.revision}`),
721
+ ]), d.revised.map((r) => r.after.text)));
722
+ }
723
+ if (d.used.length) {
724
+ write();
725
+ write(dim('used — returned by memory_load'));
726
+ writeAll(textLast(d.used.map((u) => [` ${cyan(u.node.id)}`, u.node.kind, `${u.loads}×`]), d.used.map((u) => u.node.text)));
727
+ }
728
+ if (d.removed.length) {
729
+ write();
730
+ write(dim('removed'));
731
+ writeAll(textLast(d.removed.map((n) => [` ${cyan(n.id)}`, n.kind]), d.removed.map((n) => n.text)));
732
+ }
733
+ if (d.supersedes.length) {
734
+ write();
735
+ write(dim('supersedes'));
736
+ writeAll(table(d.supersedes.map((s) => [` ${cyan(s.source)}`, '→', cyan(s.target)])));
737
+ }
738
+ }
739
+ finally {
740
+ base.release();
741
+ }
742
+ }
743
+ // ---------------------------------------------------------------------------
656
744
  // export
657
745
  // ---------------------------------------------------------------------------
658
746
  async function write_(o, rest, flags, cwd, asJson) {
@@ -661,7 +749,8 @@ async function write_(o, rest, flags, cwd, asJson) {
661
749
  }
662
750
  const named = rest[0] ?? flags.out ?? 'memory.html';
663
751
  const target = isAbsolute(named) ? named : resolve(cwd, named);
664
- const report = await buildMemoryReport(o.store, { title: `${o.project} · memory` });
752
+ const built = await buildMemoryReport(o.store, { title: `${o.project} · memory` });
753
+ const report = { ...built, nodes: built.nodes.map((n) => onHost(o, n)) };
665
754
  await writeFile(target, renderMemoryHtml(report), 'utf8');
666
755
  if (asJson) {
667
756
  json({ path: target, nodes: report.nodes.length, edges: report.edges.length });
@@ -720,7 +809,7 @@ async function merge(o, dirs, flags, cwd, asJson) {
720
809
  }
721
810
  catch (err) {
722
811
  if (err instanceof MergeConflicts) {
723
- throw diverged(err, cwd, asJson);
812
+ throw diverged(err, asJson);
724
813
  }
725
814
  throw err;
726
815
  }
@@ -731,7 +820,7 @@ async function merge(o, dirs, flags, cwd, asJson) {
731
820
  writeAll(table([
732
821
  [dim('memory'), dim('nodes'), dim('new'), dim('shared'), dim('folded'), dim('files')],
733
822
  ...report.sources.map((s) => [
734
- near(s.dir, cwd),
823
+ s.dir,
735
824
  String(s.nodes),
736
825
  s.added ? green(String(s.added)) : '0',
737
826
  String(s.shared),
@@ -752,7 +841,7 @@ async function merge(o, dirs, flags, cwd, asJson) {
752
841
  * point of stopping is that a person decides, and they cannot decide from a
753
842
  * count.
754
843
  */
755
- function diverged(err, cwd, asJson) {
844
+ function diverged(err, asJson) {
756
845
  if (asJson) {
757
846
  json({ error: err.message, conflicts: err.conflicts });
758
847
  }
@@ -761,7 +850,7 @@ function diverged(err, cwd, asJson) {
761
850
  cyan(c.id),
762
851
  dim(`r${c.mine} ↔ r${c.theirs}`),
763
852
  clip(c.text, 48),
764
- dim(near(c.dir, cwd)),
853
+ dim(c.dir),
765
854
  ])));
766
855
  write();
767
856
  }
@@ -818,9 +907,19 @@ function clip(text, n) {
818
907
  const one = text.replace(/\s+/g, ' ').trim();
819
908
  return one.length > n ? one.slice(0, n - 1) + '…' : one;
820
909
  }
821
- /** A path as it was probably typed: relative when it is below here, absolute when it is not. */
822
- function near(dir, cwd) {
823
- const rel = relative(cwd, dir);
824
- return rel && !rel.startsWith('..') ? rel : dir;
910
+ /**
911
+ * A table whose last column is the text: cut to what the terminal has left,
912
+ * and whole when nobody is looking at a terminal — a pipe or an agent wants all of it.
913
+ */
914
+ function textLast(rows, texts) {
915
+ const widths = rows[0]?.map((_, c) => Math.max(...rows.map((r) => plain(r[c] ?? '').length)));
916
+ const room = process.stdout.isTTY
917
+ ? Math.max(24, (process.stdout.columns || 120) - (widths ?? []).reduce((t, w) => t + w + 2, 0))
918
+ : Infinity;
919
+ return table(rows.map((r, i) => [...r, clip(texts[i] ?? '', room)]));
920
+ }
921
+ /** The stored `/memory/<id>.<ext>` is the agent's mount path; a terminal needs the host one. */
922
+ function onHost(o, n) {
923
+ return n.file ? { ...n, file: { ...n.file, path: hostPath(o.dir, n.file) } } : n;
825
924
  }
826
925
  //# sourceMappingURL=memory.js.map