@zenera/neo 1.1.19 → 1.1.20

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.
@@ -134,6 +134,42 @@ function textBlock(title, body, tag) {
134
134
  return s;
135
135
  }
136
136
 
137
+ // A prompt is assembled from tagged sections — <house_rules src="...">, <role>,
138
+ // <skills> — and unhighlighted they read as one wall of text, so the reader
139
+ // cannot see which file put which paragraph in front of the model. A section
140
+ // delimiter always stands alone on its line, which is what keeps prose and
141
+ // placeholders (/memory/<id>.<ext>) from lighting up as structure.
142
+ const TAG_LINE_RE = /^(\\s*)(<\\/?[A-Za-z][A-Za-z0-9._:-]*(?:\\s[^<>]*)?\\/?>)(\\s*)$/;
143
+
144
+ /** A <pre> with every section delimiter lifted out as its own span. */
145
+ function taggedPre(text) {
146
+ const s = String(text);
147
+ const pre = el('pre', 'text');
148
+ if (s.indexOf('<') < 0) { pre.textContent = s; return pre; }
149
+ const lines = s.split('\\n');
150
+ let buf = '';
151
+ function flush() {
152
+ if (buf) { pre.appendChild(document.createTextNode(buf)); buf = ''; }
153
+ }
154
+ for (let i = 0; i < lines.length; i++) {
155
+ const m = TAG_LINE_RE.exec(lines[i]);
156
+ if (!m) { buf += (i ? '\\n' : '') + lines[i]; continue; }
157
+ buf += (i ? '\\n' : '') + m[1];
158
+ flush();
159
+ pre.appendChild(el('span', m[2].indexOf('</') === 0 ? 'xtag end' : 'xtag', m[2]));
160
+ buf = m[3];
161
+ }
162
+ flush();
163
+ return pre;
164
+ }
165
+
166
+ function promptBlock(title, body, tag) {
167
+ const s = block(title, tag);
168
+ if (body) s.appendChild(taggedPre(body));
169
+ else s.appendChild(el('div', 'empty', '(empty)'));
170
+ return s;
171
+ }
172
+
137
173
  function foldBlock(title, body) {
138
174
  const s = block(title);
139
175
  const d = document.createElement('details');
@@ -423,12 +459,14 @@ function message(m) {
423
459
 
424
460
  if (m.role === 'user' && Array.isArray(m.content)) {
425
461
  m.content.forEach(function (p) {
426
- if (p.type === 'text') { wrap.appendChild(el('pre', 'text', p.text)); return; }
462
+ if (p.type === 'text') { wrap.appendChild(taggedPre(p.text)); return; }
427
463
  const img = imageEl(p, true);
428
464
  wrap.appendChild(img || el('pre', 'text', '[' + p.type + '] ' + mediaUrl(p.url)));
429
465
  });
430
466
  } else if (typeof m.content === 'string' && m.content) {
431
- wrap.appendChild(el('pre', 'text', m.role === 'tool' ? pretty(m.content) : m.content));
467
+ wrap.appendChild(m.role === 'tool'
468
+ ? el('pre', 'text', pretty(m.content))
469
+ : taggedPre(m.content));
432
470
  } else if (!m.toolCalls) {
433
471
  wrap.appendChild(el('div', 'empty', '(no content)'));
434
472
  }
@@ -448,7 +486,7 @@ function requestBlocks(raw, into) {
448
486
  into.appendChild(textBlock('Request (unparsed)', raw));
449
487
  return;
450
488
  }
451
- if (req.system) into.appendChild(textBlock('System prompt', req.system)).classList.add('sys');
489
+ if (req.system) into.appendChild(promptBlock('System prompt', req.system)).classList.add('sys');
452
490
 
453
491
  const conv = block('Messages', (req.messages || []).length + ' messages');
454
492
  (req.messages || []).forEach(function (m) { conv.appendChild(message(m)); });
@@ -496,10 +534,10 @@ function detailFor(e) {
496
534
 
497
535
  switch (n.type) {
498
536
  case 'system_prompt':
499
- out.appendChild(textBlock('Prompt', blob(n.prompt)));
537
+ out.appendChild(promptBlock('Prompt', blob(n.prompt)));
500
538
  // The editable half: which file to open when the instruction is wrong.
501
539
  (n.sources || []).forEach(function (s) {
502
- out.appendChild(textBlock(s.path, blob(s.content), 'prompt file'));
540
+ out.appendChild(promptBlock(s.path, blob(s.content), 'prompt file'));
503
541
  });
504
542
  break;
505
543
 
@@ -516,7 +554,7 @@ function detailFor(e) {
516
554
  return s.name + (s.version ? '@' + s.version : '') + ' ' + s.contentHash.slice(0, 12)
517
555
  + (s.file ? ' ' + s.file : '');
518
556
  }).join('\\n'), n.provider));
519
- out.appendChild(textBlock('Injected instructions', blob(n.content)));
557
+ out.appendChild(promptBlock('Injected instructions', blob(n.content)));
520
558
  out.appendChild(textBlock('Tools unlocked', n.toolNames.join('\\n')));
521
559
  break;
522
560
 
@@ -1,2 +1,2 @@
1
- export declare const CSS = "\n:root {\n --bg: #0f1115; --panel: #161922; --line: #262b36; --fg: #d7dbe3; --dim: #8a92a6;\n --accent: #6ea8fe; --ok: #4ec9a0; --err: #f0776c; --warn: #e2b341; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n* { box-sizing: border-box; }\nbody { margin: 0; background: var(--bg); color: var(--fg);\n font: 14px/1.5 system-ui, -apple-system, Segoe UI, sans-serif; }\nheader { padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel);\n display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: baseline; }\nheader h1 { font-size: 15px; margin: 0 12px 0 0; font-weight: 600; }\nheader .kv { color: var(--dim); font-size: 12px; }\nheader .kv b { color: var(--fg); font-weight: 600; }\nmain { display: grid; grid-template-columns: 340px 1fr; height: calc(100vh - 49px); }\naside { border-right: 1px solid var(--line); overflow: auto; background: var(--panel); }\n.pad { padding: 10px; position: sticky; top: 0; background: var(--panel); border-bottom: 1px solid var(--line); }\n#filter { width: 100%; padding: 6px 8px; background: var(--bg); color: var(--fg);\n border: 1px solid var(--line); border-radius: 6px; }\n#timeline { list-style: none; margin: 0; padding: 4px 0; }\n#timeline li { padding: 6px 10px; border-left: 3px solid transparent; cursor: pointer; }\n#timeline li:hover { background: #1c2130; }\n#timeline li.sel { background: #1f2637; border-left-color: var(--accent); }\n#timeline li.covered { opacity: .45; }\n#timeline .row { display: flex; gap: 6px; align-items: baseline; }\n#timeline .t { color: var(--fg); font: 11px var(--mono); min-width: 38px; text-align: right; }\n#timeline .t.same { color: #3d4453; }\n#timeline .dur { margin-left: auto; color: var(--dim); font: 11px var(--mono); }\n#timeline .idx { color: var(--dim); font: 11px var(--mono); min-width: 22px; }\n#timeline .prev { color: var(--dim); font-size: 11px; white-space: nowrap;\n overflow: hidden; text-overflow: ellipsis; padding-left: 44px; }\n/* Branch histories are contiguous in this list, so a coloured edge plus a\n header is enough to stop ten parallel branches reading as one long run. */\n#timeline li.sep { cursor: default; padding: 10px 10px 4px; display: flex; gap: 7px;\n align-items: center; font-size: 11px; letter-spacing: .06em; text-transform: uppercase;\n color: var(--dim); border-left-color: transparent; }\n#timeline li.sep:hover { background: none; }\n#timeline li.sep .dot { width: 8px; height: 8px; border-radius: 2px;\n background: var(--bc, var(--dim)); }\n/* The group header shouts in uppercase; its elapsed time should not. */\n#timeline li.sep .dur { text-transform: none; letter-spacing: 0; }\n/* The branch the join actually waited for. */\n#timeline li.sep.slow { background: #232a38; color: var(--fg); }\n#timeline li.sep.slow .dur { color: var(--fg); }\n#timeline li.branch { padding-left: 20px; border-left-color: var(--bc, var(--line)); }\n#timeline li.branch.sel { border-left-color: var(--bc, var(--accent)); }\n#middle { display: flex; flex-direction: column; overflow: hidden; }\n.tabs { display: flex; gap: 4px; padding: 8px 12px; border-bottom: 1px solid var(--line); }\n.tabs button { background: none; border: 1px solid transparent; color: var(--dim);\n padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }\n.tabs button.on { color: var(--fg); background: var(--panel); border-color: var(--line); }\n#detail { overflow: auto; padding: 16px; flex: 1; }\n#stats { overflow: auto; padding: 16px; flex: 1; }\ntable.st { width: 100%; border-collapse: collapse; font: 12px var(--mono); }\ntable.st th { text-align: right; color: var(--dim); font-weight: 600; padding: 6px 12px;\n border-bottom: 1px solid var(--line); white-space: nowrap; }\ntable.st td { text-align: right; padding: 5px 12px; border-bottom: 1px solid #1d2230;\n white-space: nowrap; }\ntable.st th:first-child, table.st td:first-child { text-align: left; }\ntable.st tbody tr:hover { background: #1c2130; }\ntable.st tbody tr:last-child td { border-bottom: none; }\n/* The total is a different kind of row, not one more of the same: it gets its\n own band so nobody reads it as another model or another agent. */\ntable.st tfoot td { color: var(--fg); font-weight: 600; background: #191e2a;\n border-top: 1px solid #2b3346; border-bottom: none; padding: 7px 12px; }\ntable.st tfoot td:first-child { color: var(--dim); text-transform: uppercase;\n letter-spacing: .07em; font-size: 11px; }\n/* Two panes hold a zoomable diagram (the run graph and the architecture), so\n everything about one is written for both. */\n.pane { display: flex; flex-direction: column; overflow: hidden; flex: 1; }\n/* An explicit display on the pane would otherwise beat the hidden attribute\n and both views would show at once. */\n.pane[hidden], #detail[hidden], #stats[hidden] { display: none; }\n.pane svg { max-width: none; display: block; }\n.pane .node { cursor: pointer; }\n/* A node with nothing behind it in this run is not clickable, and says so. */\n.pane .node.idle { cursor: default; }\n.gtools { display: flex; align-items: center; gap: 6px; padding: 6px 10px;\n border-bottom: 1px solid var(--line); background: var(--panel); }\n.gtools button { background: var(--bg); border: 1px solid var(--line); color: var(--fg);\n border-radius: 6px; padding: 2px 10px; cursor: pointer; font: 12px var(--mono); }\n.gtools button:hover { border-color: var(--accent); }\n.gtools .hint:first-of-type { margin-left: 8px; }\n.gtools .lvl { margin-left: auto; font-family: var(--mono); }\n.gtools label.check { display: flex; align-items: center; gap: 5px; margin-left: 8px;\n color: var(--dim); font-size: 12px; cursor: pointer; user-select: none; }\n/* The memory view reads left to right: the graph is the index, the panel is\n the page. Putting the body under a tab of its own would mean losing the\n picture every time you asked what a node actually says. */\n#memory .split { display: flex; flex: 1; overflow: hidden; }\n#mdetail { width: 380px; flex: none; padding: 16px; overflow: auto;\n border-right: none; border-left: 1px solid var(--line); }\n#mdetail .dt { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 10px; }\n#mdetail .idrow { display: flex; gap: 8px; align-items: center; margin: 0 0 14px; }\n#mdetail .idrow code { flex: 1; min-width: 0; font: 12px var(--mono); color: var(--fg);\n background: var(--bg); border: 1px solid var(--line); border-radius: 6px;\n padding: 4px 8px; word-break: break-all; }\n#mdetail .idrow button { flex: 0 0 auto; background: var(--bg); border: 1px solid var(--line);\n color: var(--dim); border-radius: 6px; padding: 4px 9px; font: 11px var(--mono);\n cursor: pointer; }\n#mdetail .idrow button:hover { border-color: var(--accent); color: var(--fg); }\n#mdetail dl.props { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 5px 14px;\n margin: 0 0 14px; font: 12px var(--mono); }\n#mdetail dl.props dt { color: var(--dim); }\n#mdetail dl.props dd { margin: 0; word-break: break-word; }\n/* A block that shares the panel's own background has no edges to show. */\n#mdetail section.blk { background: var(--bg); }\n#mdetail ul.links { list-style: none; margin: 0; padding: 0; }\n#mdetail ul.links li { display: grid; grid-template-columns: 96px auto minmax(0, 1fr);\n gap: 8px; align-items: baseline; padding: 6px 12px; }\n#mdetail ul.links li + li { border-top: 1px solid var(--line); }\n#mdetail ul.links .rel { font: 11px var(--mono); color: var(--warn); white-space: nowrap; }\n#mdetail button.link { font: 12px/1.4 system-ui, sans-serif; padding: 0; text-align: left;\n overflow-wrap: anywhere; }\n#mdetail button.link:hover { text-decoration: underline; }\n#mcount .ok { color: var(--ok); }\n#mcount .bad { color: var(--err); }\n/* The kind palette is the memory page's, so a node reads the same in both. */\n.kind { font-size: 11px; letter-spacing: .04em; text-transform: uppercase;\n padding: 1px 6px; border-radius: 4px; background: #232a38; color: var(--dim); }\n.kind.task { background: #1e3357; color: #9dc1ff; }\n.kind.plan { background: #3a2450; color: #d1a6ff; }\n.kind.fact { background: #14392c; color: #8ee0c2; }\n.kind.snippet { background: #1e3d33; color: #8ee0c2; }\n.kind.file { background: #40331a; color: #f0dfb4; }\n.kind.operation { background: #3d2a1b; color: #f2d9c2; }\n.kind.preference { background: #3d1f2c; color: #f5b8cd; }\n/* Selection is a class rather than attributes written onto the shapes: the\n previous pick has to lose its outline without a redraw. White, because the\n accent already means \"the recall matched this one\". */\n#memory .node.picked rect, #memory .node.picked polygon,\n#memory .node.picked path, #memory .node.picked circle {\n stroke: var(--fg) !important; stroke-width: 4px !important; }\n/* The canvas is transformed, not scrolled: panning has to work past the edges\n of the diagram, which overflow:auto would forbid. */\n.viewport { flex: 1; overflow: hidden; position: relative; cursor: grab; touch-action: none;\n user-select: none; -webkit-user-select: none; }\n.viewport.drag { cursor: grabbing; }\n.canvas { position: absolute; top: 0; left: 0; transform-origin: 0 0; }\n.hint { color: var(--dim); font-size: 12px; }\n.hint.pad { padding: 16px; }\n.type { font-size: 11px; letter-spacing: .04em; text-transform: uppercase;\n padding: 1px 6px; border-radius: 4px; background: #232a38; color: var(--dim); }\n.type.llm_call { background: #1e3357; color: #9dc1ff; }\n.type.tool_call, .type.tool_result { background: #1e3d33; color: #8ee0c2; }\n.type.compaction { background: #40331a; color: var(--warn); }\n.type.fork, .type.join { background: #3a2450; color: #d1a6ff; }\n.type.final_output { background: #14392c; color: var(--ok); }\n.type.err { background: #4a1f1c; color: var(--err); }\nh2.dt { font-size: 16px; margin: 0 0 4px; display: flex; gap: 10px; align-items: center; }\n.meta { color: var(--dim); font-size: 12px; font-family: var(--mono); margin-bottom: 14px;\n display: flex; flex-wrap: wrap; gap: 4px 14px; }\nsection.blk { border: 1px solid var(--line); border-radius: 8px; margin-bottom: 12px;\n background: var(--panel); overflow: hidden; }\nsection.blk > h3 { margin: 0; padding: 7px 12px; font-size: 12px; font-weight: 600;\n color: var(--dim); border-bottom: 1px solid var(--line); letter-spacing: .03em; }\nsection.blk > h3 .tag { float: right; font-weight: 400; }\nsection.blk.sys { border-left: 3px solid var(--warn); }\n/* An LLM call has three things a reader wants to tell apart at a glance: what\n went in, what came back, and what it cost. Groups carry the first two. */\n.group { border: 1px solid var(--line); border-radius: 10px; margin-bottom: 14px;\n background: #12151d; overflow: hidden; }\n.group > .ghdr { display: flex; align-items: center; gap: 10px; padding: 8px 12px;\n font-size: 11px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase;\n background: #1b2130; border-bottom: 1px solid var(--line); }\n.group > .ghdr .tag { margin-left: auto; font: 11px var(--mono); font-weight: 400;\n letter-spacing: 0; text-transform: none; color: var(--dim); }\n.group > .body { padding: 10px; }\n.group > .body > .blk:last-child { margin-bottom: 0; }\n.group.req { border-left: 3px solid var(--accent); }\n.group.req > .ghdr { color: #9dc1ff; }\n.group.res { border-left: 3px solid var(--ok); }\n.group.res > .ghdr { color: #8ee0c2; }\n.stats { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }\n.stat { background: var(--panel); border: 1px solid var(--line); border-radius: 999px;\n padding: 3px 12px; font: 12px var(--mono); color: var(--dim); }\n.stat b { color: var(--fg); font-weight: 600; }\npre { margin: 0; padding: 10px 12px; font: 12px/1.55 var(--mono); white-space: pre-wrap;\n word-break: break-word; overflow-wrap: anywhere; }\npre.text { white-space: pre-wrap; }\nimg.media { display: block; max-width: 100%; max-height: 420px; margin: 10px 12px;\n border: 1px solid var(--line); border-radius: 6px; background: #0d1017; }\nimg.media.thumb { max-height: 88px; margin: 8px 12px; cursor: zoom-in; }\n.msg { border-top: 1px solid var(--line); border-left: 3px solid transparent; }\n.msg:first-of-type { border-top: none; }\n.msg > .role { padding: 5px 12px; font-size: 11px; text-transform: uppercase;\n letter-spacing: .05em; color: var(--dim); background: #1b202c;\n display: flex; justify-content: space-between; }\n.msg.user > .role { color: #9dc1ff; }\n.msg.user { border-left-color: #35507f; }\n.msg.assistant > .role { color: #c3a6ff; }\n.msg.assistant { border-left-color: #5a4380; }\n.msg.tool > .role { color: #8ee0c2; }\n.msg.tool { border-left-color: #2f6b58; }\n.msg.system > .role { color: var(--warn); }\n.msg.system { border-left-color: #7a6535; }\n.msg.err > .role { color: var(--err); }\n.msg.err { border-left-color: #8a3a33; }\n.call { border-top: 1px dashed var(--line); }\n.call .name { padding: 5px 12px; font: 12px var(--mono); color: #8ee0c2; }\ndetails > summary { cursor: pointer; padding: 6px 12px; color: var(--dim); font-size: 12px; }\n.badge { font: 11px var(--mono); color: var(--dim); }\n.badge.ok { color: var(--ok); } .badge.bad { color: var(--err); }\n.empty { padding: 10px 12px; color: var(--dim); font-size: 12px; font-style: italic; }\nbutton.link { background: none; border: none; color: var(--accent); cursor: pointer;\n font: 12px var(--mono); padding: 0 6px 0 0; }\n";
1
+ export declare const CSS = "\n:root {\n --bg: #0f1115; --panel: #161922; --line: #262b36; --fg: #d7dbe3; --dim: #8a92a6;\n --accent: #6ea8fe; --ok: #4ec9a0; --err: #f0776c; --warn: #e2b341; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n* { box-sizing: border-box; }\nbody { margin: 0; background: var(--bg); color: var(--fg);\n font: 14px/1.5 system-ui, -apple-system, Segoe UI, sans-serif; }\nheader { padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel);\n display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: baseline; }\nheader h1 { font-size: 15px; margin: 0 12px 0 0; font-weight: 600; }\nheader .kv { color: var(--dim); font-size: 12px; }\nheader .kv b { color: var(--fg); font-weight: 600; }\nmain { display: grid; grid-template-columns: 340px 1fr; height: calc(100vh - 49px); }\naside { border-right: 1px solid var(--line); overflow: auto; background: var(--panel); }\n.pad { padding: 10px; position: sticky; top: 0; background: var(--panel); border-bottom: 1px solid var(--line); }\n#filter { width: 100%; padding: 6px 8px; background: var(--bg); color: var(--fg);\n border: 1px solid var(--line); border-radius: 6px; }\n#timeline { list-style: none; margin: 0; padding: 4px 0; }\n#timeline li { padding: 6px 10px; border-left: 3px solid transparent; cursor: pointer; }\n#timeline li:hover { background: #1c2130; }\n#timeline li.sel { background: #1f2637; border-left-color: var(--accent); }\n#timeline li.covered { opacity: .45; }\n#timeline .row { display: flex; gap: 6px; align-items: baseline; }\n#timeline .t { color: var(--fg); font: 11px var(--mono); min-width: 38px; text-align: right; }\n#timeline .t.same { color: #3d4453; }\n#timeline .dur { margin-left: auto; color: var(--dim); font: 11px var(--mono); }\n#timeline .idx { color: var(--dim); font: 11px var(--mono); min-width: 22px; }\n#timeline .prev { color: var(--dim); font-size: 11px; white-space: nowrap;\n overflow: hidden; text-overflow: ellipsis; padding-left: 44px; }\n/* Branch histories are contiguous in this list, so a coloured edge plus a\n header is enough to stop ten parallel branches reading as one long run. */\n#timeline li.sep { cursor: default; padding: 10px 10px 4px; display: flex; gap: 7px;\n align-items: center; font-size: 11px; letter-spacing: .06em; text-transform: uppercase;\n color: var(--dim); border-left-color: transparent; }\n#timeline li.sep:hover { background: none; }\n#timeline li.sep .dot { width: 8px; height: 8px; border-radius: 2px;\n background: var(--bc, var(--dim)); }\n/* The group header shouts in uppercase; its elapsed time should not. */\n#timeline li.sep .dur { text-transform: none; letter-spacing: 0; }\n/* The branch the join actually waited for. */\n#timeline li.sep.slow { background: #232a38; color: var(--fg); }\n#timeline li.sep.slow .dur { color: var(--fg); }\n#timeline li.branch { padding-left: 20px; border-left-color: var(--bc, var(--line)); }\n#timeline li.branch.sel { border-left-color: var(--bc, var(--accent)); }\n#middle { display: flex; flex-direction: column; overflow: hidden; }\n.tabs { display: flex; gap: 4px; padding: 8px 12px; border-bottom: 1px solid var(--line); }\n.tabs button { background: none; border: 1px solid transparent; color: var(--dim);\n padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }\n.tabs button.on { color: var(--fg); background: var(--panel); border-color: var(--line); }\n#detail { overflow: auto; padding: 16px; flex: 1; }\n#stats { overflow: auto; padding: 16px; flex: 1; }\ntable.st { width: 100%; border-collapse: collapse; font: 12px var(--mono); }\ntable.st th { text-align: right; color: var(--dim); font-weight: 600; padding: 6px 12px;\n border-bottom: 1px solid var(--line); white-space: nowrap; }\ntable.st td { text-align: right; padding: 5px 12px; border-bottom: 1px solid #1d2230;\n white-space: nowrap; }\ntable.st th:first-child, table.st td:first-child { text-align: left; }\ntable.st tbody tr:hover { background: #1c2130; }\ntable.st tbody tr:last-child td { border-bottom: none; }\n/* The total is a different kind of row, not one more of the same: it gets its\n own band so nobody reads it as another model or another agent. */\ntable.st tfoot td { color: var(--fg); font-weight: 600; background: #191e2a;\n border-top: 1px solid #2b3346; border-bottom: none; padding: 7px 12px; }\ntable.st tfoot td:first-child { color: var(--dim); text-transform: uppercase;\n letter-spacing: .07em; font-size: 11px; }\n/* Two panes hold a zoomable diagram (the run graph and the architecture), so\n everything about one is written for both. */\n.pane { display: flex; flex-direction: column; overflow: hidden; flex: 1; }\n/* An explicit display on the pane would otherwise beat the hidden attribute\n and both views would show at once. */\n.pane[hidden], #detail[hidden], #stats[hidden] { display: none; }\n.pane svg { max-width: none; display: block; }\n.pane .node { cursor: pointer; }\n/* A node with nothing behind it in this run is not clickable, and says so. */\n.pane .node.idle { cursor: default; }\n.gtools { display: flex; align-items: center; gap: 6px; padding: 6px 10px;\n border-bottom: 1px solid var(--line); background: var(--panel); }\n.gtools button { background: var(--bg); border: 1px solid var(--line); color: var(--fg);\n border-radius: 6px; padding: 2px 10px; cursor: pointer; font: 12px var(--mono); }\n.gtools button:hover { border-color: var(--accent); }\n.gtools .hint:first-of-type { margin-left: 8px; }\n.gtools .lvl { margin-left: auto; font-family: var(--mono); }\n.gtools label.check { display: flex; align-items: center; gap: 5px; margin-left: 8px;\n color: var(--dim); font-size: 12px; cursor: pointer; user-select: none; }\n/* The memory view reads left to right: the graph is the index, the panel is\n the page. Putting the body under a tab of its own would mean losing the\n picture every time you asked what a node actually says. */\n#memory .split { display: flex; flex: 1; overflow: hidden; }\n#mdetail { width: 380px; flex: none; padding: 16px; overflow: auto;\n border-right: none; border-left: 1px solid var(--line); }\n#mdetail .dt { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 10px; }\n#mdetail .idrow { display: flex; gap: 8px; align-items: center; margin: 0 0 14px; }\n#mdetail .idrow code { flex: 1; min-width: 0; font: 12px var(--mono); color: var(--fg);\n background: var(--bg); border: 1px solid var(--line); border-radius: 6px;\n padding: 4px 8px; word-break: break-all; }\n#mdetail .idrow button { flex: 0 0 auto; background: var(--bg); border: 1px solid var(--line);\n color: var(--dim); border-radius: 6px; padding: 4px 9px; font: 11px var(--mono);\n cursor: pointer; }\n#mdetail .idrow button:hover { border-color: var(--accent); color: var(--fg); }\n#mdetail dl.props { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 5px 14px;\n margin: 0 0 14px; font: 12px var(--mono); }\n#mdetail dl.props dt { color: var(--dim); }\n#mdetail dl.props dd { margin: 0; word-break: break-word; }\n/* A block that shares the panel's own background has no edges to show. */\n#mdetail section.blk { background: var(--bg); }\n#mdetail ul.links { list-style: none; margin: 0; padding: 0; }\n#mdetail ul.links li { display: grid; grid-template-columns: 96px auto minmax(0, 1fr);\n gap: 8px; align-items: baseline; padding: 6px 12px; }\n#mdetail ul.links li + li { border-top: 1px solid var(--line); }\n#mdetail ul.links .rel { font: 11px var(--mono); color: var(--warn); white-space: nowrap; }\n#mdetail button.link { font: 12px/1.4 system-ui, sans-serif; padding: 0; text-align: left;\n overflow-wrap: anywhere; }\n#mdetail button.link:hover { text-decoration: underline; }\n#mcount .ok { color: var(--ok); }\n#mcount .bad { color: var(--err); }\n/* The kind palette is the memory page's, so a node reads the same in both. */\n.kind { font-size: 11px; letter-spacing: .04em; text-transform: uppercase;\n padding: 1px 6px; border-radius: 4px; background: #232a38; color: var(--dim); }\n.kind.task { background: #1e3357; color: #9dc1ff; }\n.kind.plan { background: #3a2450; color: #d1a6ff; }\n.kind.fact { background: #14392c; color: #8ee0c2; }\n.kind.snippet { background: #1e3d33; color: #8ee0c2; }\n.kind.file { background: #40331a; color: #f0dfb4; }\n.kind.operation { background: #3d2a1b; color: #f2d9c2; }\n.kind.preference { background: #3d1f2c; color: #f5b8cd; }\n/* Selection is a class rather than attributes written onto the shapes: the\n previous pick has to lose its outline without a redraw. White, because the\n accent already means \"the recall matched this one\". */\n#memory .node.picked rect, #memory .node.picked polygon,\n#memory .node.picked path, #memory .node.picked circle {\n stroke: var(--fg) !important; stroke-width: 4px !important; }\n/* The canvas is transformed, not scrolled: panning has to work past the edges\n of the diagram, which overflow:auto would forbid. */\n.viewport { flex: 1; overflow: hidden; position: relative; cursor: grab; touch-action: none;\n user-select: none; -webkit-user-select: none; }\n.viewport.drag { cursor: grabbing; }\n.canvas { position: absolute; top: 0; left: 0; transform-origin: 0 0; }\n.hint { color: var(--dim); font-size: 12px; }\n.hint.pad { padding: 16px; }\n.type { font-size: 11px; letter-spacing: .04em; text-transform: uppercase;\n padding: 1px 6px; border-radius: 4px; background: #232a38; color: var(--dim); }\n.type.llm_call { background: #1e3357; color: #9dc1ff; }\n.type.tool_call, .type.tool_result { background: #1e3d33; color: #8ee0c2; }\n.type.compaction { background: #40331a; color: var(--warn); }\n.type.fork, .type.join { background: #3a2450; color: #d1a6ff; }\n.type.final_output { background: #14392c; color: var(--ok); }\n.type.err { background: #4a1f1c; color: var(--err); }\nh2.dt { font-size: 16px; margin: 0 0 4px; display: flex; gap: 10px; align-items: center; }\n.meta { color: var(--dim); font-size: 12px; font-family: var(--mono); margin-bottom: 14px;\n display: flex; flex-wrap: wrap; gap: 4px 14px; }\nsection.blk { border: 1px solid var(--line); border-radius: 8px; margin-bottom: 12px;\n background: var(--panel); overflow: hidden; }\nsection.blk > h3 { margin: 0; padding: 7px 12px; font-size: 12px; font-weight: 600;\n color: var(--dim); border-bottom: 1px solid var(--line); letter-spacing: .03em; }\nsection.blk > h3 .tag { float: right; font-weight: 400; }\nsection.blk.sys { border-left: 3px solid var(--warn); }\n/* An LLM call has three things a reader wants to tell apart at a glance: what\n went in, what came back, and what it cost. Groups carry the first two. */\n.group { border: 1px solid var(--line); border-radius: 10px; margin-bottom: 14px;\n background: #12151d; overflow: hidden; }\n.group > .ghdr { display: flex; align-items: center; gap: 10px; padding: 8px 12px;\n font-size: 11px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase;\n background: #1b2130; border-bottom: 1px solid var(--line); }\n.group > .ghdr .tag { margin-left: auto; font: 11px var(--mono); font-weight: 400;\n letter-spacing: 0; text-transform: none; color: var(--dim); }\n.group > .body { padding: 10px; }\n.group > .body > .blk:last-child { margin-bottom: 0; }\n.group.req { border-left: 3px solid var(--accent); }\n.group.req > .ghdr { color: #9dc1ff; }\n.group.res { border-left: 3px solid var(--ok); }\n.group.res > .ghdr { color: #8ee0c2; }\n.stats { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }\n.stat { background: var(--panel); border: 1px solid var(--line); border-radius: 999px;\n padding: 3px 12px; font: 12px var(--mono); color: var(--dim); }\n.stat b { color: var(--fg); font-weight: 600; }\npre { margin: 0; padding: 10px 12px; font: 12px/1.55 var(--mono); white-space: pre-wrap;\n word-break: break-word; overflow-wrap: anywhere; }\npre.text { white-space: pre-wrap; }\n/* Section tags inside a prompt (<house_rules src=\"...\">, <role>, ...): the only\n structure a composed prompt has, and invisible without this. */\npre .xtag { color: #ffb0cd; background: #2d1b27; border: 1px solid #4e2c40;\n border-radius: 4px; padding: 0 4px; font-weight: 600; }\npre .xtag.end { color: #a07f92; background: #201a1e; border-color: #3b2b35;\n font-weight: 400; }\nimg.media { display: block; max-width: 100%; max-height: 420px; margin: 10px 12px;\n border: 1px solid var(--line); border-radius: 6px; background: #0d1017; }\nimg.media.thumb { max-height: 88px; margin: 8px 12px; cursor: zoom-in; }\n.msg { border-top: 1px solid var(--line); border-left: 3px solid transparent; }\n.msg:first-of-type { border-top: none; }\n.msg > .role { padding: 5px 12px; font-size: 11px; text-transform: uppercase;\n letter-spacing: .05em; color: var(--dim); background: #1b202c;\n display: flex; justify-content: space-between; }\n.msg.user > .role { color: #9dc1ff; }\n.msg.user { border-left-color: #35507f; }\n.msg.assistant > .role { color: #c3a6ff; }\n.msg.assistant { border-left-color: #5a4380; }\n.msg.tool > .role { color: #8ee0c2; }\n.msg.tool { border-left-color: #2f6b58; }\n.msg.system > .role { color: var(--warn); }\n.msg.system { border-left-color: #7a6535; }\n.msg.err > .role { color: var(--err); }\n.msg.err { border-left-color: #8a3a33; }\n.call { border-top: 1px dashed var(--line); }\n.call .name { padding: 5px 12px; font: 12px var(--mono); color: #8ee0c2; }\ndetails > summary { cursor: pointer; padding: 6px 12px; color: var(--dim); font-size: 12px; }\n.badge { font: 11px var(--mono); color: var(--dim); }\n.badge.ok { color: var(--ok); } .badge.bad { color: var(--err); }\n.empty { padding: 10px 12px; color: var(--dim); font-size: 12px; font-style: italic; }\nbutton.link { background: none; border: none; color: var(--accent); cursor: pointer;\n font: 12px var(--mono); padding: 0 6px 0 0; }\n";
2
2
  //# sourceMappingURL=css.d.ts.map
@@ -183,6 +183,12 @@ section.blk.sys { border-left: 3px solid var(--warn); }
183
183
  pre { margin: 0; padding: 10px 12px; font: 12px/1.55 var(--mono); white-space: pre-wrap;
184
184
  word-break: break-word; overflow-wrap: anywhere; }
185
185
  pre.text { white-space: pre-wrap; }
186
+ /* Section tags inside a prompt (<house_rules src="...">, <role>, ...): the only
187
+ structure a composed prompt has, and invisible without this. */
188
+ pre .xtag { color: #ffb0cd; background: #2d1b27; border: 1px solid #4e2c40;
189
+ border-radius: 4px; padding: 0 4px; font-weight: 600; }
190
+ pre .xtag.end { color: #a07f92; background: #201a1e; border-color: #3b2b35;
191
+ font-weight: 400; }
186
192
  img.media { display: block; max-width: 100%; max-height: 420px; margin: 10px 12px;
187
193
  border: 1px solid var(--line); border-radius: 6px; background: #0d1017; }
188
194
  img.media.thumb { max-height: 88px; margin: 8px 12px; cursor: zoom-in; }
package/dist/kernel.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { Agent, AgentRegistry, handoffTarget, handoffTool } from "./agent.js";
3
- import { FORK_DESCRIPTION, forkInstructions, forkParameters } from "./fork.js";
3
+ import { FORK_DESCRIPTION, forkParameters } from "./fork.js";
4
4
  import { systemClock } from "./ids.js";
5
- import { memoryInstructions, renderPreferences } from "./memory/instructions.js";
5
+ import { renderPreferences } from "./memory/render.js";
6
6
  import { memoryTools } from "./memory/tools.js";
7
7
  import { hash } from "./payload.js";
8
8
  import { composePrompt } from "./prompt.js";
@@ -194,7 +194,13 @@ function forkAgents(state, reg) {
194
194
  const known = allowed.filter((n) => reg.find(n));
195
195
  return known.length ? known : [state.agentName];
196
196
  }
197
- /** One condition for the tool and for the prompt block that explains it. */
197
+ /**
198
+ * The tool is withdrawn at the depth cap, so a branch cannot spawn branches
199
+ * forever. `agents/fork-instructions.md` is gated on the same two conditions,
200
+ * from the other side: `requires: [fork]` for the binding, and a `when` on the
201
+ * depth, because a rule about a tool the model does not have is a rule it can
202
+ * only be confused by.
203
+ */
198
204
  function canFork(agent, state) {
199
205
  return Boolean(agent.fork) && state.spec.forkDepth < state.spec.maxForkDepth;
200
206
  }
@@ -251,12 +257,17 @@ function toSchema(t) {
251
257
  return { name: t.name, description: t.description, parameters: t.parameters };
252
258
  }
253
259
  /**
254
- * Prompt text the runtime owns rather than the author: how to use memory, the
255
- * user's standing preferences, the skill index and the `final_output`
256
- * instruction. Most used to be appended inside `buildRequest`, which meant part
257
- * of the system prompt existed in no node and showed up in no audit. Composed
258
- * in now, always last, so the volatile tail never invalidates the cacheable
259
- * prefix.
260
+ * Prompt text the runtime owns rather than the author: the user's standing
261
+ * preferences, the skill index and the `final_output` instruction. Most used to
262
+ * be appended inside `buildRequest`, which meant part of the system prompt
263
+ * existed in no node and showed up in no audit. Composed in now, always last,
264
+ * so the volatile tail never invalidates the cacheable prefix.
265
+ *
266
+ * How to *use* memory is not here: it is the project's own house rules, in
267
+ * `agents/memory-instructions.md`, so that one document is the only place it is
268
+ * written. An agent with memory tools and no such file gets the tool schemas
269
+ * and nothing else - `zen check` warns about it, the runtime does not invent a
270
+ * replacement. Forking is the same, in `agents/fork-instructions.md`.
260
271
  */
261
272
  async function derivedPrompt(agent, state, env) {
262
273
  const out = [];
@@ -266,7 +277,6 @@ async function derivedPrompt(agent, state, env) {
266
277
  // because preferences change at most once in a run and
267
278
  // `applySystemPrompt` is keyed on the rendered bytes, so a commit
268
279
  // re-renders the prefix once rather than every turn.
269
- out.push(memoryInstructions(memory));
270
280
  out.push(renderPreferences(env.services.memory.preferences(memory.sees)));
271
281
  }
272
282
  if (agent.skills?.discovery === 'index') {
@@ -284,9 +294,6 @@ async function derivedPrompt(agent, state, env) {
284
294
  out.push(rendered);
285
295
  }
286
296
  }
287
- if (canFork(agent, state)) {
288
- out.push(forkInstructions());
289
- }
290
297
  if (state.spec.outputSchema) {
291
298
  out.push(FINAL_OUTPUT_INSTRUCTIONS);
292
299
  }
@@ -1,5 +1,6 @@
1
- import { type Recollection } from './types.ts';
1
+ import { type MemoryNode, type Recollection } from './types.ts';
2
2
  export declare const RECOLLECTION_TAG = "memory-recollection";
3
+ export declare const PREFERENCES_TAG = "memory-preferences";
3
4
  export interface RenderOptions {
4
5
  /** wrap in the <memory-recollection> element; off for CLI output */
5
6
  tagged?: boolean;
@@ -7,4 +8,5 @@ export interface RenderOptions {
7
8
  now?: number;
8
9
  }
9
10
  export declare function renderRecollection(rec: Recollection, opts?: RenderOptions): string;
11
+ export declare function renderPreferences(nodes: readonly MemoryNode[]): string;
10
12
  //# sourceMappingURL=render.d.ts.map
@@ -22,10 +22,17 @@ import { SPINE_RELATIONS, } from "./types.js";
22
22
  //
23
23
  // No SUPERSEDES arrow can appear on the ordinary path: recall drops the node
24
24
  // one points at, so the edge loses an endpoint before it reaches here. It shows
25
- // up only under `stale`, which is the audit view, and `memory/instructions.ts`
26
- // therefore does not teach the model to look for one.
25
+ // up only under `stale`, which is the audit view, and the house rules in
26
+ // `agents/memory-instructions.md` therefore do not teach the model to look for
27
+ // one.
28
+ //
29
+ // The preferences block is here too. It is the other thing the runtime renders
30
+ // out of the graph for a model to read, and it exists for the one reason a
31
+ // static document cannot cover it: each line carries a live id, without which a
32
+ // preference can be read but not superseded.
27
33
  // ---------------------------------------------------------------------------
28
34
  export const RECOLLECTION_TAG = 'memory-recollection';
35
+ export const PREFERENCES_TAG = 'memory-preferences';
29
36
  /** Enough to judge relevance; the model calls `memory_load` for the rest. */
30
37
  const TEXT_CLIP = 160;
31
38
  /** Two columns per level of nesting, so the tree is the indentation. */
@@ -225,4 +232,22 @@ function used(node, now) {
225
232
  const ago = days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days}d ago`;
226
233
  return `used ${node.useCount}\u00d7, ${ago}`;
227
234
  }
235
+ export function renderPreferences(nodes) {
236
+ if (!nodes.length) {
237
+ return '';
238
+ }
239
+ const lines = nodes.map((n) => `- ${oneLine(n.text)} [${n.id}]`);
240
+ return [
241
+ `<${PREFERENCES_TAG}>`,
242
+ "Standing instructions from earlier runs. They apply unless this run's",
243
+ 'request contradicts them; to change one, commit the replacement and link',
244
+ 'it to the id here with SUPERSEDES.',
245
+ ...lines,
246
+ `</${PREFERENCES_TAG}>`,
247
+ ].join('\n');
248
+ }
249
+ /** Never clipped: half an instruction is a different instruction. */
250
+ function oneLine(text) {
251
+ return text.replace(/\s+/g, ' ').trim();
252
+ }
228
253
  //# sourceMappingURL=render.js.map
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { basename, join, relative, resolve, sep } from 'node:path';
3
3
  import { Agent, AgentRegistry } from "../agent.js";
4
4
  import { RunStream } from "../events.js";
5
+ import { frontmatter, toList } from "../frontmatter.js";
5
6
  import { MemoryIndex } from "../memory/index.js";
6
7
  import { MemoryStore } from "../memory/store.js";
7
8
  import { ModelRegistry, } from "../models/factory.js";
@@ -199,6 +200,11 @@ function within(root, path) {
199
200
  * nothing to declare. Order is the filename's, which is the only ordering that
200
201
  * is the same on every machine and is visible without opening another file.
201
202
  *
203
+ * The exception a document declares for itself: `requires:` in its frontmatter
204
+ * names capabilities, and the document then reaches only the agents that have
205
+ * them. That stays a property of the document rather than of `agents.yaml`
206
+ * because the condition and the prose it guards are the same edit.
207
+ *
202
208
  * All of it is optional: a one-agent project whose whole prompt is its role
203
209
  * file should not need a second, empty document.
204
210
  */
@@ -206,9 +212,21 @@ function readHouseRules(root) {
206
212
  const rules = [];
207
213
  const take = (rel) => {
208
214
  const path = join(root, rel);
209
- if (existsSync(path)) {
210
- rules.push(promptFile(path, 'house_rules', rel));
215
+ if (!existsSync(path)) {
216
+ return;
211
217
  }
218
+ const { data, body } = frontmatter(readFileSync(path, 'utf8'));
219
+ const requires = toList(data.requires);
220
+ for (const cap of requires) {
221
+ if (!(cap in CAPABILITIES)) {
222
+ throw new Error(`${rel}: unknown capability "${cap}" in requires: ` +
223
+ `(known: ${Object.keys(CAPABILITIES).join(', ')})`);
224
+ }
225
+ }
226
+ // One object per document, shared by every agent it reaches, so the
227
+ // report says "one document, five prompts" rather than showing five
228
+ // identical blobs.
229
+ rules.push({ rel, requires, part: { text: body, path, section: 'house_rules', src: rel } });
212
230
  };
213
231
  take(HOUSE_RULES);
214
232
  for (const name of topics(root)) {
@@ -216,6 +234,46 @@ function readHouseRules(root) {
216
234
  }
217
235
  return rules;
218
236
  }
237
+ /**
238
+ * What `requires:` may name, and what each one means. A closed vocabulary
239
+ * rather than an expression language: these are the capabilities the runtime
240
+ * can turn on and off, so the set of useful conditions is finite and knowable,
241
+ * and a typo is a load error instead of a rule that silently never fires.
242
+ *
243
+ * Every entry reads `agents.yaml` and nothing else. In particular none of them
244
+ * consults the resolved tool set, which is a function of the *state* — a prompt
245
+ * that varied with it would move the provider's cache prefix mid-run.
246
+ *
247
+ * A capability may also contribute a run-time half, returned as `when`: `fork`
248
+ * does, because the tool is withdrawn at the depth cap and a rule about a tool
249
+ * the model does not have is a rule it can only be confused by.
250
+ */
251
+ const CAPABILITIES = {
252
+ fork: (spec) => Boolean(spec.fork) && ((_ctx, run) => run.forkDepth < run.maxForkDepth),
253
+ memory: (spec) => Boolean(spec.memory),
254
+ 'memory-write': (spec) => spec.memory?.access === 'read-write' || spec.memory?.access === 'full',
255
+ 'memory-forget': (spec) => spec.memory?.access === 'full',
256
+ };
257
+ /**
258
+ * The document as this agent gets it, or `undefined` when its conditions rule
259
+ * the agent out. Several run-time conditions are met by meeting all of them.
260
+ */
261
+ function ruleFor(rule, spec) {
262
+ const gates = [];
263
+ for (const cap of rule.requires) {
264
+ const resolved = CAPABILITIES[cap](spec);
265
+ if (!resolved) {
266
+ return undefined;
267
+ }
268
+ if (typeof resolved === 'function') {
269
+ gates.push(resolved);
270
+ }
271
+ }
272
+ if (!gates.length) {
273
+ return rule.part;
274
+ }
275
+ return { ...rule.part, when: (ctx, run) => gates.every((g) => g(ctx, run)) };
276
+ }
219
277
  /** `agents/<topic>-instructions.md`, sorted. Subdirectories are not searched. */
220
278
  function topics(root) {
221
279
  const dir = join(root, AGENTS_DIR);
@@ -233,7 +291,13 @@ function topics(root) {
233
291
  * can reuse it.
234
292
  */
235
293
  function instructionsFor(root, spec, houseRules) {
236
- const parts = [...houseRules];
294
+ const parts = [];
295
+ for (const rule of houseRules) {
296
+ const part = ruleFor(rule, spec);
297
+ if (part) {
298
+ parts.push(part);
299
+ }
300
+ }
237
301
  const role = spec.system
238
302
  ? projectFile(root, spec.system, `agents.${spec.name}.system`)
239
303
  : join(root, PROMPTS_DIR, `${spec.name}.md`);
package/dist/prompt.d.ts CHANGED
@@ -20,6 +20,17 @@ export interface PromptText {
20
20
  * different cache prefixes for the same project.
21
21
  */
22
22
  src?: string;
23
+ /**
24
+ * Whether this block belongs in *this* run's prompt at all. False and the
25
+ * part is skipped entirely — no text, and no entry in `sources`, because a
26
+ * document the model never read is not a document that fed the prompt.
27
+ *
28
+ * It takes the same arguments as the function form of `PromptPart` and for
29
+ * the same reason: a condition on mutable state would move the cache prefix
30
+ * mid-run. This is how one shared document can be conditional per agent
31
+ * while still being read once.
32
+ */
33
+ when?: (ctx: unknown, spec: RunSpec) => boolean;
23
34
  }
24
35
  /**
25
36
  * One contributor to a system prompt: literal text, text computed from the run
package/dist/prompt.js CHANGED
@@ -67,6 +67,9 @@ export async function composePrompt(instructions, ctx, spec, put, derived = [])
67
67
  chunks.push(part(ctx, spec));
68
68
  }
69
69
  else {
70
+ if (part.when && !part.when(ctx, spec)) {
71
+ continue;
72
+ }
70
73
  if (part.path && part.text) {
71
74
  sources.push({ path: part.path, content: await put(part.text) });
72
75
  }
@@ -1,4 +1,4 @@
1
- import type { Skill, SkillProvider, SkillSummary } from '../skills.ts';
1
+ import { type Skill, type SkillProvider, type SkillSummary } from '../skills.ts';
2
2
  import { type AnyTool } from '../types.ts';
3
3
  /**
4
4
  * A directory of skills, and the name the *agent* reaches it by — which is not
@@ -1,5 +1,7 @@
1
1
  import { readFile, readdir } from 'node:fs/promises';
2
2
  import { basename, join, relative, resolve } from 'node:path';
3
+ import { frontmatter, toList } from "../frontmatter.js";
4
+ import { unknownSkill } from "../skills.js";
3
5
  import { selectTools } from "../types.js";
4
6
  // ---------------------------------------------------------------------------
5
7
  // Filesystem-backed skill provider
@@ -69,7 +71,7 @@ export class FileSkillProvider {
69
71
  async load(name, version) {
70
72
  const entry = (await this.#scan()).get(name);
71
73
  if (!entry) {
72
- throw new Error(`unknown skill: ${name}`);
74
+ throw new Error(unknownSkill(name, await this.list()));
73
75
  }
74
76
  const { version: found } = entry.summary;
75
77
  if (version && found && found !== version) {
@@ -163,41 +165,6 @@ async function readOptional(path) {
163
165
  throw err;
164
166
  }
165
167
  }
166
- /**
167
- * Deliberately not a YAML parser: `key: value` and flow lists cover what a
168
- * skill header needs, and a real dependency would buy nothing.
169
- */
170
- function frontmatter(raw) {
171
- const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(raw);
172
- if (!m) {
173
- return { data: {}, body: raw.trim() };
174
- }
175
- const data = {};
176
- for (const line of m[1].split(/\r?\n/)) {
177
- const i = line.indexOf(':');
178
- const key = i < 0 ? '' : line.slice(0, i).trim();
179
- if (!key || key.startsWith('#')) {
180
- continue;
181
- }
182
- data[key] = unquote(line.slice(i + 1).trim());
183
- }
184
- return { data, body: raw.slice(m[0].length).trim() };
185
- }
186
- function toList(value) {
187
- if (!value) {
188
- return [];
189
- }
190
- const inner = value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
191
- return inner
192
- .split(',')
193
- .map((v) => unquote(v.trim()))
194
- .filter(Boolean);
195
- }
196
- function unquote(value) {
197
- const quoted = (value.startsWith('"') && value.endsWith('"')) ||
198
- (value.startsWith("'") && value.endsWith("'"));
199
- return quoted && value.length > 1 ? value.slice(1, -1) : value;
200
- }
201
168
  function firstLine(body) {
202
169
  return (body
203
170
  .split(/\r?\n/)
@@ -1,4 +1,4 @@
1
- import type { Skill, SkillProvider, SkillSummary } from '../skills.ts';
1
+ import { type Skill, type SkillProvider, type SkillSummary } from '../skills.ts';
2
2
  import type { AnyTool } from '../types.ts';
3
3
  /** Skills handed over in code, held in a map. No I/O, no catalog to scan. */
4
4
  export declare class StaticSkillProvider implements SkillProvider {
@@ -1,3 +1,4 @@
1
+ import { unknownSkill } from "../skills.js";
1
2
  /** Skills handed over in code, held in a map. No I/O, no catalog to scan. */
2
3
  export class StaticSkillProvider {
3
4
  id;
@@ -33,7 +34,7 @@ export class StaticSkillProvider {
33
34
  async load(name, version) {
34
35
  const s = this.#skills.get(name);
35
36
  if (!s) {
36
- throw new Error(`unknown skill: ${name}`);
37
+ throw new Error(unknownSkill(name, await this.list()));
37
38
  }
38
39
  if (version && s.version && s.version !== version) {
39
40
  throw new Error(`skill ${name} is at ${s.version}, ${version} was requested`);
package/dist/skills.d.ts CHANGED
@@ -113,4 +113,11 @@ export declare function lockedSkillTools<TCtx>(binding: SkillBinding, provider:
113
113
  * are not duplicated in the tool result and can be compacted independently.
114
114
  */
115
115
  export declare function skillTools<TCtx>(binding: SkillBinding): AnyTool<TCtx>[];
116
+ /**
117
+ * A model that misses does not usually misspell: it invents a plausible name
118
+ * out of something it read, so `memory-policy-instructions` is reaching for
119
+ * `zen-memory`. Shared words therefore rank above edit distance, and the
120
+ * catalog is listed either way so the retry needs no further guessing.
121
+ */
122
+ export declare function unknownSkill(name: string, catalog: SkillSummary[]): string;
116
123
  //# sourceMappingURL=skills.d.ts.map
package/dist/skills.js CHANGED
@@ -213,7 +213,12 @@ export function skillTools(binding) {
213
213
  if (summary && !allows(binding, summary)) {
214
214
  throw new Error(`skill "${name}" is not available to this agent`);
215
215
  }
216
- loaded.push(await provider.load(name, summary?.version));
216
+ if (!summary) {
217
+ // Only over what this agent may see: a suggestion must
218
+ // not name a skill the index and `skill_search` hide.
219
+ throw new Error(unknownSkill(name, index.filter((s) => allows(binding, s))));
220
+ }
221
+ loaded.push(await provider.load(name, summary.version));
217
222
  }
218
223
  // Two skills may declare the same tool; it is unlocked once.
219
224
  const unlocked = [
@@ -246,4 +251,64 @@ export function skillTools(binding) {
246
251
  }
247
252
  return tools;
248
253
  }
254
+ // ---------------------------------------------------------------------------
255
+ // Unknown names
256
+ // ---------------------------------------------------------------------------
257
+ const CATALOG_IN_ERROR = 30;
258
+ /**
259
+ * A model that misses does not usually misspell: it invents a plausible name
260
+ * out of something it read, so `memory-policy-instructions` is reaching for
261
+ * `zen-memory`. Shared words therefore rank above edit distance, and the
262
+ * catalog is listed either way so the retry needs no further guessing.
263
+ */
264
+ export function unknownSkill(name, catalog) {
265
+ const names = catalog.map((s) => s.name);
266
+ const scored = names
267
+ .map((n) => ({ n, score: closeness(name, n) }))
268
+ .filter((c) => c.score > 0)
269
+ .sort((a, b) => b.score - a.score || a.n.localeCompare(b.n));
270
+ // Only the equal-best ones: a shared `zen-` prefix makes weak matches of
271
+ // the whole catalog, and three of those are worse than one good guess.
272
+ const near = scored
273
+ .filter((c) => c.score === scored[0]?.score)
274
+ .slice(0, 3)
275
+ .map((c) => c.n);
276
+ const list = names.slice(0, CATALOG_IN_ERROR).join(', ') || 'none';
277
+ const more = names.length > CATALOG_IN_ERROR ? `, … (${names.length} in all)` : '';
278
+ return (`unknown skill "${name}"` +
279
+ (near.length ? ` — did you mean ${near.map((n) => `"${n}"`).join(' or ')}?` : '') +
280
+ ` (the catalog has: ${list}${more})`);
281
+ }
282
+ function closeness(query, candidate) {
283
+ const q = query.toLowerCase();
284
+ const c = candidate.toLowerCase();
285
+ if (c.includes(q) || q.includes(c)) {
286
+ return 4;
287
+ }
288
+ const wanted = new Set(words(q));
289
+ const shared = words(c).filter((w) => wanted.has(w)).length;
290
+ if (shared) {
291
+ return 1 + shared;
292
+ }
293
+ return distance(q, c) <= 2 ? 1 : 0;
294
+ }
295
+ function words(s) {
296
+ return s.split(/[^a-z0-9]+/).filter((w) => w.length > 2);
297
+ }
298
+ /** Levenshtein, one row at a time, giving up as soon as the lengths rule it out. */
299
+ function distance(a, b) {
300
+ if (Math.abs(a.length - b.length) > 2) {
301
+ return 3;
302
+ }
303
+ let row = Array.from({ length: b.length + 1 }, (_, i) => i);
304
+ for (let i = 1; i <= a.length; i++) {
305
+ const next = [i];
306
+ for (let j = 1; j <= b.length; j++) {
307
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
308
+ next[j] = Math.min(next[j - 1] + 1, row[j] + 1, row[j - 1] + cost);
309
+ }
310
+ row = next;
311
+ }
312
+ return row[b.length];
313
+ }
249
314
  //# sourceMappingURL=skills.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/neo",
3
- "version": "1.1.19",
3
+ "version": "1.1.20",
4
4
  "description": "A multi-agent runtime for Node.js: agents, models, tools, skills, memory and an append-only trajectory.",
5
5
  "keywords": [
6
6
  "agents",
@@ -1,15 +0,0 @@
1
- import { type MemoryNode, type ResolvedMemoryBinding } from './types.ts';
2
- export declare const PREFERENCES_TAG = "memory-preferences";
3
- /**
4
- * Deliberately not a function of `sees`: those labels come from the run's
5
- * context, and naming them here would rewrite the prompt whenever the context
6
- * moved. It also tells an agent the shape of a slice it was not given.
7
- */
8
- export declare function memoryInstructions(binding: ResolvedMemoryBinding): string;
9
- /**
10
- * The id on each line is what makes a preference revisable rather than merely
11
- * readable: without it the model can read the instruction but cannot name the
12
- * thing it would supersede.
13
- */
14
- export declare function renderPreferences(nodes: readonly MemoryNode[]): string;
15
- //# sourceMappingURL=instructions.d.ts.map