omkit 0.8.10 → 0.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -185,7 +185,7 @@ flakyBackgroundJob().result.catch((e) => console.warn("job failed, carrying on",
185
185
 
186
186
  Every action's output, events, asserts, snapshots, and lifecycle land on **one timeline** — streamed live to the terminal (curated milestones) and written to a per-run folder:
187
187
 
188
- - `result.json` — the run's tree, per-node status, verdict, and curated artifacts.
188
+ - `result.json` — the run's tree, per-node status, verdict, and curated artifacts. Each node also carries what its definition declared about itself: the `.describe()` summary, the `.mcp()` exposure, and the `.args()` schema as JSON Schema.
189
189
  - `raw.jsonl` — every entry, machine-readable.
190
190
  - `main.log` + one `.log` per action — the human-readable timelines.
191
191
  - `events.log` / `asserts.log` / `snapshots.log` / `artifacts.log` — cross-cutting rollups.
@@ -3,7 +3,7 @@ import type { ActionRef } from "../foundation/ActionRef.ts";
3
3
  import type { LogStore } from "../output/log/LogStore.ts";
4
4
  import type { SnapshotStore } from "../output/snapshot/SnapshotStore.ts";
5
5
  import type { ArtifactStore } from "../output/artifact/ArtifactStore.ts";
6
- import type { Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
6
+ import type { ActionMeta, Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
7
7
  /** Everything a node needs to exist, minus its behavior. */
8
8
  export interface NodeInit {
9
9
  readonly store: LogStore;
@@ -45,6 +45,8 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
45
45
  readonly path: string;
46
46
  readonly parentId: string | null;
47
47
  readonly tags: string[];
48
+ /** What the definition said about itself (`.describe()`, `.mcp()`, `.args()`); for the record. */
49
+ meta: ActionMeta | undefined;
48
50
  /** The captured args this node ran with (for the log header); set at run start. */
49
51
  args: readonly unknown[];
50
52
  readonly children: ActionRun[];
@@ -18,6 +18,8 @@ export class ActionRun {
18
18
  path;
19
19
  parentId;
20
20
  tags = [];
21
+ /** What the definition said about itself (`.describe()`, `.mcp()`, `.args()`); for the record. */
22
+ meta;
21
23
  /** The captured args this node ran with (for the log header); set at run start. */
22
24
  args = [];
23
25
  children = [];
@@ -41,6 +41,7 @@ export declare class ExecutionTree {
41
41
  private readonly snapshotStore;
42
42
  private readonly artifactStore;
43
43
  private readonly registry;
44
+ private readonly schemaCache;
44
45
  private readonly unsettled;
45
46
  private readonly originalLog;
46
47
  private streaming;
@@ -101,6 +102,12 @@ export declare class ExecutionTree {
101
102
  private printSummary;
102
103
  private verdictOk;
103
104
  private nodeView;
105
+ /**
106
+ * The node's self-description, projected for the record. The root's comes from the om
107
+ * builder; a child's from its action definition. The args schema is converted here, once per
108
+ * definition, so a step launched a thousand times converts its schema once.
109
+ */
110
+ private metaView;
104
111
  /** Test seam: the projected run view (same object written to result.json). */
105
112
  runViewForTest(): RunView;
106
113
  /** Test seam: the run's curated artifacts. */
@@ -61,6 +61,7 @@ export class ExecutionTree {
61
61
  snapshotStore;
62
62
  artifactStore = new ArtifactStore();
63
63
  registry = [];
64
+ schemaCache = new Map();
64
65
  unsettled = new Set();
65
66
  originalLog = console.log.bind(console);
66
67
  streaming;
@@ -137,6 +138,7 @@ export class ExecutionTree {
137
138
  // into the parent's log, attributed to this child's id.
138
139
  bubble: (message) => this.append(parent, "child", id, message),
139
140
  });
141
+ node.meta = spec.meta;
140
142
  parent.children.push(node);
141
143
  this.registry.push(node);
142
144
  // Launch reference — the parent's pointer to the child's own (absolute) log, plus where
@@ -386,9 +388,34 @@ export class ExecutionTree {
386
388
  endedAt: node.endedAt,
387
389
  duration: node.duration,
388
390
  logFile: this.logFile(node.path),
391
+ ...this.metaView(node),
389
392
  children: node.children.map((child) => this.nodeView(child)),
390
393
  };
391
394
  }
395
+ /**
396
+ * The node's self-description, projected for the record. The root's comes from the om
397
+ * builder; a child's from its action definition. The args schema is converted here, once per
398
+ * definition, so a step launched a thousand times converts its schema once.
399
+ */
400
+ metaView(node) {
401
+ const meta = node.parentId === null
402
+ ? { description: this.description, mcp: this.mcp, describeArgs: node.meta?.describeArgs }
403
+ : node.meta;
404
+ if (!meta)
405
+ return {};
406
+ let inputSchema;
407
+ if (meta.describeArgs) {
408
+ if (!this.schemaCache.has(meta.describeArgs)) {
409
+ this.schemaCache.set(meta.describeArgs, meta.describeArgs().inputSchema);
410
+ }
411
+ inputSchema = this.schemaCache.get(meta.describeArgs);
412
+ }
413
+ return {
414
+ ...(meta.description === undefined ? {} : { description: { ...meta.description } }),
415
+ ...(meta.mcp === undefined ? {} : { mcp: { ...meta.mcp } }),
416
+ ...(inputSchema === undefined ? {} : { inputSchema }),
417
+ };
418
+ }
392
419
  /** Test seam: the projected run view (same object written to result.json). */
393
420
  runViewForTest() {
394
421
  return this.runView();
@@ -49,9 +49,13 @@ class Builder {
49
49
  // No `.args()` on this path, so there is nothing to describe — or to resolve against.
50
50
  const describeArgs = () => describeSchema(undefined);
51
51
  const parseArgs = (supplied) => supplied;
52
+ const meta = {
53
+ ...(description === undefined ? {} : { description }),
54
+ ...(mcp === undefined ? {} : { mcp }),
55
+ };
52
56
  const exec = body;
53
57
  const create = (...args) => {
54
- const spec = { name, args, tags: [], body: exec, definedAt };
58
+ const spec = { name, args, tags: [], body: exec, definedAt, meta };
55
59
  return ExecutionTree.require().launch(spec);
56
60
  };
57
61
  return Object.assign(create, {
@@ -102,6 +106,11 @@ class ArgsBuilder {
102
106
  // that built it — rather than in whichever copy the discovery child is running.
103
107
  const schema = this.schema;
104
108
  const describeArgs = () => describeSchema(schema);
109
+ const meta = {
110
+ ...(description === undefined ? {} : { description }),
111
+ ...(mcp === undefined ? {} : { mcp }),
112
+ describeArgs,
113
+ };
105
114
  // `safeParse` is what applies `.default()`s, so this is where an advertised default
106
115
  // becomes the effective one. A refusal names the action and every failing field.
107
116
  const parseArgs = (supplied) => {
@@ -112,7 +121,7 @@ class ArgsBuilder {
112
121
  };
113
122
  const exec = body;
114
123
  const create = (...args) => {
115
- const spec = { name, args, tags: [], body: exec, definedAt };
124
+ const spec = { name, args, tags: [], body: exec, definedAt, meta };
116
125
  return ExecutionTree.require().launch(spec);
117
126
  };
118
127
  return Object.assign(create, {
package/dist/core/om.js CHANGED
@@ -37,6 +37,9 @@ function launch(name, site, description, exposure, body, schema) {
37
37
  }
38
38
  const tree = new ExecutionTree(name, site, description, exposure);
39
39
  ExecutionTree.current = tree;
40
+ // The root's description and exposure live on the tree; its schema rides on the node.
41
+ if (schema !== undefined)
42
+ tree.root.meta = { describeArgs: () => describeSchema(schema) };
40
43
  const rootBody = (ctx) => body({
41
44
  signal: ctx.signal,
42
45
  logs: ctx.logs,
@@ -239,4 +239,17 @@ export interface LaunchSpec {
239
239
  readonly body: Exec<object, unknown, unknown>;
240
240
  /** `file:line` where the action was defined; carried onto the node for its log header. */
241
241
  readonly definedAt: string | undefined;
242
+ /** What the definition declared about itself — carried onto the node for the run record. */
243
+ readonly meta?: ActionMeta;
244
+ }
245
+ /**
246
+ * A definition's self-description, as it travels onto each node it launches: what
247
+ * `.describe()` and `.mcp()` said, and the `.args()` schema as JSON Schema. Read only by the
248
+ * run record (`result.json`, `report.html`) — the runtime never branches on it.
249
+ */
250
+ export interface ActionMeta {
251
+ readonly description?: OmDescription;
252
+ readonly mcp?: ResolvedMcpExposure;
253
+ /** Converts the declared args schema; called once, at finalize. */
254
+ readonly describeArgs?: () => DescribedArgs;
242
255
  }
@@ -33,10 +33,13 @@ export async function writeReport(file, input) {
33
33
  const assets = [];
34
34
  const files = await collectFiles(input, assets);
35
35
  const snapshots = await collectSnapshots(input);
36
+ const root = await projectRoot(input.folder);
36
37
  const payload = {
37
38
  version: 1,
38
39
  name: input.name,
39
40
  folder: input.folder,
41
+ projectRoot: root,
42
+ sources: definitionSources(input.run.root, root),
40
43
  generatedAt: Date.now(),
41
44
  run: input.run,
42
45
  entries: input.entries,
@@ -183,6 +186,40 @@ function artifactTimes(entries) {
183
186
  }
184
187
  return out;
185
188
  }
189
+ /**
190
+ * The project the run belongs to, for project-relative paths: the nearest ancestor of the run
191
+ * folder holding `.git`, else the folder `logs/` sits in (the root omkit was given), else cwd.
192
+ */
193
+ async function projectRoot(folder) {
194
+ let dir = path.resolve(folder);
195
+ for (;;) {
196
+ try {
197
+ await stat(path.join(dir, ".git"));
198
+ return dir;
199
+ }
200
+ catch {
201
+ /* keep climbing */
202
+ }
203
+ const up = path.dirname(dir);
204
+ if (up === dir)
205
+ break;
206
+ dir = up;
207
+ }
208
+ return process.env.OMKIT_ROOT ? path.resolve(process.env.OMKIT_ROOT) : process.cwd();
209
+ }
210
+ /** Each distinct `definedAt` site → its file, relative to the project root, forward-slashed, no line. */
211
+ function definitionSources(root, projectDir) {
212
+ const out = {};
213
+ const visit = (n) => {
214
+ if (n.definedAt && !(n.definedAt in out)) {
215
+ const file = n.definedAt.replace(/:\d+(:\d+)?$/, "");
216
+ out[n.definedAt] = path.relative(projectDir, file).split(path.sep).join("/") || file;
217
+ }
218
+ n.children.forEach(visit);
219
+ };
220
+ visit(root);
221
+ return out;
222
+ }
186
223
  async function walk(dir, depth = 0) {
187
224
  if (depth > 4)
188
225
  return [];
@@ -153,10 +153,13 @@ button { font: inherit; color: inherit; }
153
153
  .guide::before { content: ""; position: absolute; left: 7px; top: 0; bottom: 0; border-left: 1px solid var(--line); }
154
154
 
155
155
  .chart { position: relative; min-width: 0; }
156
- .chart svg { display: block; }
157
- .chart .bar-label { font-size: 12px; fill: var(--bar-text); font-weight: 500; pointer-events: none; }
156
+ .chart svg { display: block; overflow: hidden; }
157
+ .chart .bar-label { font-size: 12px; fill: var(--bar-text); font-weight: 500; cursor: pointer; }
158
+ .chart .hit { fill: transparent; cursor: pointer; }
159
+ .chart .hit:hover + .bar { filter: brightness(1.15); }
158
160
  .chart .bar-label.outside { fill: var(--text); }
159
- .chart .mark-label { font-size: 11px; fill: var(--text-2); pointer-events: none; }
161
+ .chart .mark-label { font-size: 11px; fill: var(--text-2); cursor: pointer; }
162
+ .chart .mark-label:hover { fill: var(--text); }
160
163
  .chart .grid line { stroke: var(--line-soft); }
161
164
  .chart .cursor { stroke: var(--text-2); stroke-dasharray: 3 3; pointer-events: none; }
162
165
  .chart .link { fill: none; stroke: var(--text-3); stroke-width: 1.2; }
@@ -175,7 +178,7 @@ button { font: inherit; color: inherit; }
175
178
  .flow-link { fill: none; stroke: var(--text-3); stroke-width: 1.3; }
176
179
  .flame-rect { cursor: pointer; stroke: var(--panel); stroke-width: 1; }
177
180
  .flame-rect.selected { stroke: var(--text); stroke-width: 2; }
178
- .flame-text { font-size: 11px; fill: var(--bar-text); pointer-events: none; }
181
+ .flame-text { font-size: 11px; fill: var(--bar-text); cursor: pointer; }
179
182
 
180
183
  /* ── details ────────────────────────────────────────────── */
181
184
  .details { display: grid; grid-template-rows: auto auto minmax(0, 1fr); overflow: hidden; }
@@ -252,6 +255,31 @@ table.list tr:hover td { background: var(--raise); }
252
255
  }
253
256
  .tip .tt { font-weight: 600; margin-bottom: 2px; }
254
257
  .tip .ts { color: var(--text-2); }
258
+ .tip.wide { max-width: 420px; min-width: 260px; }
259
+ .tip .mono { font-family: var(--mono); font-size: 11px; overflow-wrap: anywhere; }
260
+ .tip .small { color: var(--text-2); }
261
+ .tip .badges { display: flex; flex-wrap: wrap; gap: 5px; margin: 6px 0; }
262
+ .tip .badge { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; border-radius: 999px; background: var(--raise); font-size: 11px; color: var(--text-2); }
263
+ .tip .badge.mcp { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
264
+ .tip .summary { margin: 4px 0 8px; line-height: 1.45; }
265
+ .tip .tkv { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 10px; font-variant-numeric: tabular-nums; }
266
+ .tip .tkv .k, .tip .sec { color: var(--text-3); }
267
+ .tip .sec { margin: 8px 0 3px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
268
+ .tip .sec.fail { color: var(--fail); }
269
+ .params { display: grid; gap: 3px; }
270
+ .param { font: 11px/1.4 var(--mono); }
271
+ .param .pn { color: var(--text); }
272
+ .param .pt { color: var(--accent); margin-left: 6px; }
273
+ .param .pd { color: var(--text-3); margin-left: 6px; }
274
+ .param .pdesc { font-family: var(--sans); color: var(--text-2); margin-left: 10px; }
275
+ .kv .v.defined { display: flex; align-items: center; gap: 6px; }
276
+ .kv .v.defined a { min-width: 0; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; }
277
+ .icon-btn { flex: none; border: 1px solid var(--line); background: var(--panel-2); color: var(--text-2); border-radius: 6px; width: 24px; height: 22px; display: grid; place-items: center; cursor: pointer; padding: 0; }
278
+ .icon-btn:hover { color: var(--text); border-color: var(--accent); }
279
+ .icon-btn svg { width: 13px; height: 13px; }
280
+ .tip .hint { margin-top: 8px; color: var(--text-3); font-size: 11px; }
281
+ .tip kbd { font: 600 10px var(--mono); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; padding: 0 4px; color: var(--text-2); }
282
+ .badge-inline { display: inline-block; padding: 0 7px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-size: 11px; font-weight: 600; }
255
283
  .tip img { display: block; max-width: 240px; max-height: 150px; border-radius: 4px; margin: 6px 0; border: 1px solid var(--line); }
256
284
  .modal { position: fixed; inset: 0; background: rgba(5, 8, 18, 0.72); z-index: 60; display: grid; place-items: center; padding: 28px; }
257
285
  .modal .box { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); max-width: min(1200px, 100%); max-height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
@@ -393,6 +393,9 @@
393
393
  select(n.id);
394
394
  });
395
395
  r.addEventListener("dblclick", () => focusNode(n));
396
+ r.addEventListener("mouseenter", (ev) => showTip(ev, nodeTip(n), true, 280));
397
+ r.addEventListener("mousemove", (ev) => moveTip(ev));
398
+ r.addEventListener("mouseleave", hideTip);
396
399
  });
397
400
  }
398
401
  function treeRowHtml(n) {
@@ -488,6 +491,8 @@
488
491
  .data(rowsNow, (n) => n.id)
489
492
  .join((enter) => {
490
493
  const r = enter.append("g").attr("class", "r");
494
+ // A transparent target spanning the bar *and* its label, so a 2px bar is still hoverable.
495
+ r.append("rect").attr("class", "hit").attr("y", BAR_Y - 4).attr("height", BAR_H + 8);
491
496
  r.append("rect").attr("class", "bar").attr("rx", 5).attr("y", BAR_Y).attr("height", BAR_H);
492
497
  r.append("text").attr("class", "bar-label").attr("y", BAR_Y + BAR_H / 2 + 4);
493
498
  r.append("g").attr("class", "marks");
@@ -504,17 +509,7 @@
504
509
  .attr("stroke-width", (n) => (n.status === "failed" ? 2 : null))
505
510
  .attr("stroke-dasharray", (n) => (n.status === "cancelled" ? "4 3" : null))
506
511
  .classed("selected", (n) => n.id === state.selected)
507
- .on("click", (ev, n) => {
508
- ev.stopPropagation();
509
- select(n.id);
510
- })
511
- .on("dblclick", (ev, n) => {
512
- ev.stopPropagation();
513
- focusNode(n);
514
- })
515
- .on("mouseenter", (ev, n) => showTip(ev, nodeTip(n)))
516
- .on("mousemove", (ev) => moveTip(ev))
517
- .on("mouseleave", hideTip);
512
+ .call(bindNode);
518
513
 
519
514
  g.select("text.bar-label").each(function (n) {
520
515
  const t = d3.select(this);
@@ -524,10 +519,19 @@
524
519
  const need = text.length * 6.6 + 14;
525
520
  const inside = w >= need;
526
521
  const clipX = Math.max(x0, 0);
527
- t.text(inside ? text : w > 60 ? truncate(text, (w - 14) / 6.6) : text)
528
- .classed("outside", !inside && w <= 60)
529
- .attr("x", !inside && w <= 60 ? x0 + Math.max(w, 3) + 6 : Math.min(clipX + 8, x0 + w - 20));
522
+ const outside = !inside && w <= 60;
523
+ const shown = inside || outside ? text : truncate(text, (w - 14) / 6.6);
524
+ const tw = shown.length * 6.6;
525
+ // an outside label that would run off the chart hangs left of its bar instead
526
+ const flip = outside && x0 + Math.max(w, 3) + 6 + tw > W;
527
+ const lx = flip ? x0 - 6 : outside ? x0 + Math.max(w, 3) + 6 : Math.min(clipX + 8, x0 + w - 20);
528
+ t.text(shown).classed("outside", outside).attr("x", lx).attr("text-anchor", flip ? "end" : "start");
529
+ // the hit target covers bar + label, padded so a sliver of a bar is still easy to reach
530
+ const left = Math.min(x0, flip ? lx - tw : lx) - 6;
531
+ const right = Math.max(x0 + Math.max(w, 3), flip ? x0 : lx + tw) + 6;
532
+ d3.select(this.parentNode).select("rect.hit").attr("x", left).attr("width", Math.max(16, right - left));
530
533
  });
534
+ g.selectAll("rect.hit, text.bar-label").call(bindNode);
531
535
 
532
536
  g.select("g.marks").each(function (n) {
533
537
  drawMarks(d3.select(this), n, x);
@@ -584,7 +588,14 @@
584
588
  .attr("x", (l) => (flip(l) ? l.d.px + 4 : l.d.px - 4))
585
589
  .attr("text-anchor", (l) => (flip(l) ? "end" : "start"))
586
590
  .attr("y", BAR_Y + BAR_H + 13)
587
- .text((l) => l.text);
591
+ .text((l) => l.text)
592
+ .on("click", (ev, l) => {
593
+ ev.stopPropagation();
594
+ openItem(l.d.m);
595
+ })
596
+ .on("mouseenter", (ev, l) => showTip(ev, markTip(l.d)))
597
+ .on("mousemove", (ev) => moveTip(ev))
598
+ .on("mouseleave", hideTip);
588
599
  }
589
600
  function worst(d) {
590
601
  const all = [d.m, ...d.more].map((m) => m.kind);
@@ -669,21 +680,15 @@
669
680
  .attr("fill", (n) => colorOf(n))
670
681
  .attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
671
682
  .classed("selected", (n) => n.id === state.selected)
672
- .on("click", (ev, n) => select(n.id))
673
- .on("dblclick", (ev, n) => {
674
- ev.stopPropagation();
675
- focusNode(n);
676
- })
677
- .on("mouseenter", (ev, n) => showTip(ev, nodeTip(n)))
678
- .on("mousemove", (ev) => moveTip(ev))
679
- .on("mouseleave", hideTip);
683
+ .call(bindNode);
680
684
  cells
681
685
  .select("text")
682
686
  .attr("x", (n) => Math.max(x(n.start), 0) + 5)
683
687
  .text((n) => {
684
688
  const w = x(n.end) - Math.max(x(n.start), 0);
685
689
  return w < 34 ? "" : truncate(`${displayName(n)} ${fmtDur(n.duration)}`, (w - 10) / 6.2);
686
- });
690
+ })
691
+ .call(bindNode);
687
692
  }
688
693
 
689
694
  // ── flow view ──
@@ -858,14 +863,18 @@
858
863
  <div class="k">Duration</div><div class="v">${fmtDur(n.duration)}</div>
859
864
  <div class="k">Start</div><div class="v">${fmtRel(n.start)} (${fmtClock(n.startAbs)})</div>
860
865
  <div class="k">End</div><div class="v">${fmtRel(n.end)} (${fmtClock(n.endAbs)})</div>
866
+ ${n.v.description ? `<div class="k">Summary</div><div class="v">${esc(n.v.description.summary)}</div>` : ""}
867
+ ${n.v.mcp ? `<div class="k">MCP</div><div class="v"><span class="badge-inline">${esc(n.v.mcp.mode)}</span></div>` : ""}
861
868
  <div class="k">Path</div><div class="v">${esc(n.v.path)}</div>
862
- ${n.v.definedAt ? `<div class="k">Defined at</div><div class="v"><a href="${esc(editorUrl(n.v.definedAt))}" title="${esc(n.v.definedAt)}">${esc(basename(n.v.definedAt))}</a></div>` : ""}
869
+ ${n.v.definedAt ? `<div class="k">Defined at</div><div class="v defined"><a href="${esc(editorUrl(n.v.definedAt))}" title="Open ${esc(n.v.definedAt)} in VS Code">${esc(sourceOf(n))}</a><button class="icon-btn" data-copy-source title="Copy relative path (C)">${svgIcon("copy")}</button></div>` : ""}
863
870
  </div>
864
871
  <div class="tabs">${tabs
865
872
  .map(([k, l]) => `<button class="tab ${state.detailTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
866
873
  .join("")}</div>
867
874
  <div class="tabbody">${detailTabHtml(n)}</div>
868
875
  </div>`;
876
+ const copySrc = el.details.querySelector("[data-copy-source]");
877
+ if (copySrc) copySrc.addEventListener("click", () => copySource(n));
869
878
  el.details.querySelector(".x").addEventListener("click", () => {
870
879
  state.details = false;
871
880
  el.main.classList.add("no-details");
@@ -883,7 +892,9 @@
883
892
  function detailTabHtml(n) {
884
893
  const tab = state.detailTab;
885
894
  if (tab === "args") {
886
- return n.v.args && n.v.args.length ? `<pre class="code">${esc(json(n.v.args.length === 1 ? n.v.args[0] : n.v.args))}</pre>` : '<div class="muted">No arguments.</div>';
895
+ const params = schemaFields(n.v.inputSchema);
896
+ const schema = params.length ? `<div class="section-title">Schema</div><div class="params">${params.map(paramHtml).join("")}</div><div class="section-title">Values</div>` : "";
897
+ return schema + (n.v.args && n.v.args.length ? `<pre class="code">${esc(json(n.v.args.length === 1 ? n.v.args[0] : n.v.args))}</pre>` : '<div class="muted">No arguments.</div>');
887
898
  }
888
899
  if (tab === "events") {
889
900
  const evs = n.entries.filter((i) => i.kind !== "info");
@@ -1115,11 +1126,50 @@
1115
1126
  }
1116
1127
 
1117
1128
  // ── tooltips ──
1129
+ /** Click selects, double-click focuses, hover explains — the same on every node mark. */
1130
+ function bindNode(sel) {
1131
+ sel
1132
+ .on("click", (ev, n) => {
1133
+ ev.stopPropagation();
1134
+ select(n.id);
1135
+ })
1136
+ .on("dblclick", (ev, n) => {
1137
+ ev.stopPropagation();
1138
+ focusNode(n);
1139
+ })
1140
+ .on("mouseenter", (ev, n) => showTip(ev, nodeTip(n), true))
1141
+ .on("mousemove", (ev) => moveTip(ev))
1142
+ .on("mouseleave", hideTip);
1143
+ }
1118
1144
  function nodeTip(n) {
1119
- return `<div class="tt">${esc(n.name)}</div>
1120
- <div class="ts"><span class="dot ${n.status}"></span> ${esc(n.status)} · ${fmtDur(n.duration)}</div>
1121
- <div class="ts">${fmtRel(n.start)} → ${fmtRel(n.end)}</div>
1122
- ${n.v.tags.length ? `<div class="ts">${esc(n.v.tags.join(" · "))}</div>` : ""}`;
1145
+ hovered = n; // the node `C` copies while its tooltip is up
1146
+ const v = n.v;
1147
+ const shown = displayName(n);
1148
+ const badges = [
1149
+ `<span class="badge"><span class="dot ${n.status}"></span>${esc(n.status)}</span>`,
1150
+ v.mcp ? `<span class="badge mcp" title="Exposed over MCP">MCP · ${esc(v.mcp.mode)}</span>` : "",
1151
+ n.depth === 0 ? '<span class="badge">om</span>' : "",
1152
+ ].join("");
1153
+ const rows = [
1154
+ ["Duration", fmtDur(n.duration)],
1155
+ ["When", `${fmtRel(n.start)} → ${fmtRel(n.end)}`],
1156
+ v.tags.length ? ["Tags", esc(v.tags.join(" · "))] : null,
1157
+ v.definedAt ? ["Defined", `<span class="mono">${esc(sourceOf(n))}</span>`] : null,
1158
+ n.children.length ? ["Children", String(n.children.length)] : null,
1159
+ n.marks.length ? ["Events", String(n.marks.length)] : null,
1160
+ ].filter(Boolean);
1161
+ const args = v.args && v.args.length ? truncate(oneLine(v.args.length === 1 ? v.args[0] : v.args), 220) : "";
1162
+ const params = schemaFields(v.inputSchema);
1163
+ const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === v.path);
1164
+ return `<div class="tt">${esc(shown)}</div>
1165
+ ${shown !== n.name ? `<div class="ts mono">${esc(truncate(n.name, 140))}</div>` : ""}
1166
+ <div class="badges">${badges}</div>
1167
+ ${v.description ? `<div class="summary">${esc(v.description.summary)}</div>` : '<div class="summary muted">No description.</div>'}
1168
+ <div class="tkv">${rows.map(([k, val]) => `<span class="k">${k}</span><span>${val}</span>`).join("")}</div>
1169
+ ${params.length ? `<div class="sec">Args schema</div><div class="params">${params.map(paramHtml).join("")}</div>` : ""}
1170
+ ${args ? `<div class="sec">Args</div><div class="mono small">${esc(args)}</div>` : ""}
1171
+ ${v.definedAt ? '<div class="hint">Press <kbd>C</kbd> to copy the path</div>' : ""}
1172
+ ${fails.length ? `<div class="sec fail">Failed</div><div class="mono small">${esc(truncate(String(fails[0].error).split(String.fromCharCode(10))[0], 200))}</div>` : ""}`;
1123
1173
  }
1124
1174
  function markTip(d) {
1125
1175
  const m = d.m;
@@ -1129,12 +1179,34 @@
1129
1179
  const extra = m.gallery && m.gallery.image ? `<div class="ts">${esc(m.gallery.f.rel)} · ${fmtSize(m.gallery.f.size)}</div>` : "";
1130
1180
  return `<div class="tt">${esc(mk.label)} · ${fmtRel(m.t)}</div>${img}<div>${esc(truncate(m.kind === "snapshot" || m.kind === "artifact" ? m.label : m.e.message, 260))}</div>${extra}${more}`;
1131
1181
  }
1132
- function showTip(ev, html) {
1133
- tip.innerHTML = html;
1134
- tip.hidden = false;
1135
- moveTip(ev);
1182
+ let tipTimer = 0;
1183
+ let hovered = null;
1184
+ /** The file where `n` is defined, relative to the project root — no line. */
1185
+ function sourceOf(n) {
1186
+ const site = n.v.definedAt;
1187
+ if (!site) return "";
1188
+ return (model.data.sources && model.data.sources[site]) || site.replace(/:\d+(:\d+)?$/, "");
1189
+ }
1190
+ function copySource(n) {
1191
+ const src = n && sourceOf(n);
1192
+ if (src) copy(src, `Copied ${src}`);
1193
+ }
1194
+ let lastEv = null;
1195
+ function showTip(ev, html, wide, delay) {
1196
+ clearTimeout(tipTimer);
1197
+ lastEv = ev;
1198
+ const open = () => {
1199
+ tip.innerHTML = html;
1200
+ tip.classList.toggle("wide", !!wide);
1201
+ tip.hidden = false;
1202
+ moveTip(lastEv);
1203
+ };
1204
+ if (delay && tip.hidden) tipTimer = setTimeout(open, delay);
1205
+ else open();
1136
1206
  }
1137
1207
  function moveTip(ev) {
1208
+ lastEv = ev;
1209
+ if (tip.hidden) return;
1138
1210
  const pad = 14;
1139
1211
  const r = tip.getBoundingClientRect();
1140
1212
  let x = ev.clientX + pad;
@@ -1145,6 +1217,8 @@
1145
1217
  tip.style.top = `${y}px`;
1146
1218
  }
1147
1219
  function hideTip() {
1220
+ hovered = null;
1221
+ clearTimeout(tipTimer);
1148
1222
  tip.hidden = true;
1149
1223
  }
1150
1224
 
@@ -1172,6 +1246,9 @@
1172
1246
  if (e.key === "+" || e.key === "=") zoomBy(1.6);
1173
1247
  else if (e.key === "-") zoomBy(1 / 1.6);
1174
1248
  else if (e.key === "0") fit();
1249
+ else if ((e.key === "c" || e.key === "C") && !e.ctrlKey && !e.metaKey && !e.altKey) {
1250
+ copySource(hovered || model.byId.get(state.selected));
1251
+ }
1175
1252
  else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
1176
1253
  const rows = visible();
1177
1254
  const i = rows.findIndex((n) => n.id === state.selected);
@@ -1281,6 +1358,40 @@
1281
1358
  c.opacity = a;
1282
1359
  return c.formatRgb();
1283
1360
  }
1361
+ function oneLine(v) {
1362
+ try {
1363
+ return typeof v === "string" ? v : JSON.stringify(v);
1364
+ } catch {
1365
+ return String(v);
1366
+ }
1367
+ }
1368
+ /** Top-level fields of a JSON Schema object: name, type, required, default, description. */
1369
+ function schemaFields(schema) {
1370
+ if (!schema || typeof schema !== "object" || !schema.properties) return [];
1371
+ const req = new Set(schema.required || []);
1372
+ return Object.entries(schema.properties).map(([name, p]) => ({
1373
+ name,
1374
+ type: typeOf(p),
1375
+ required: req.has(name) && !(p && "default" in p),
1376
+ def: p && "default" in p ? oneLine(p.default) : undefined,
1377
+ desc: p && p.description,
1378
+ }));
1379
+ }
1380
+ function typeOf(p) {
1381
+ if (!p || typeof p !== "object") return "any";
1382
+ if (p.enum) return p.enum.map((e) => JSON.stringify(e)).join(" | ");
1383
+ if (p.const !== undefined) return JSON.stringify(p.const);
1384
+ if (Array.isArray(p.type)) return p.type.join(" | ");
1385
+ if (p.type === "array") return `${typeOf(p.items)}[]`;
1386
+ if (p.type) return p.type;
1387
+ if (p.anyOf || p.oneOf) return (p.anyOf || p.oneOf).map(typeOf).join(" | ");
1388
+ return "any";
1389
+ }
1390
+ function paramHtml(f) {
1391
+ return `<div class="param"><span class="pn">${esc(f.name)}${f.required ? "" : "?"}</span><span class="pt">${esc(truncate(f.type, 40))}</span>${
1392
+ f.def !== undefined ? `<span class="pd">= ${esc(truncate(f.def, 24))}</span>` : ""
1393
+ }${f.desc ? `<div class="pdesc">${esc(f.desc)}</div>` : ""}</div>`;
1394
+ }
1284
1395
  function truncate(s, n) {
1285
1396
  n = Math.max(1, Math.floor(n));
1286
1397
  return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s;
@@ -1306,19 +1417,19 @@
1306
1417
  if (/build|compile|bundle/.test(s)) return "box";
1307
1418
  return "play";
1308
1419
  }
1309
- function copy(text) {
1310
- const done = () => toast("Copied");
1311
- if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text));
1312
- else fallbackCopy(text);
1420
+ function copy(text, message) {
1421
+ const done = () => toast(message || "Copied");
1422
+ if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, message));
1423
+ else fallbackCopy(text, message);
1313
1424
  }
1314
- function fallbackCopy(text) {
1425
+ function fallbackCopy(text, message) {
1315
1426
  const t = document.createElement("textarea");
1316
1427
  t.value = text;
1317
1428
  document.body.appendChild(t);
1318
1429
  t.select();
1319
1430
  try {
1320
1431
  document.execCommand("copy");
1321
- toast("Copied");
1432
+ toast(message || "Copied");
1322
1433
  } catch {
1323
1434
  toast(text);
1324
1435
  }
@@ -19,6 +19,16 @@ export interface NodeView {
19
19
  readonly duration: number;
20
20
  /** Absolute path to this node's own `.log`. */
21
21
  readonly logFile: string;
22
+ /** What the definition's `.describe()` said; absent when it said nothing. */
23
+ readonly description?: {
24
+ readonly summary: string;
25
+ };
26
+ /** The definition's `.mcp()` exposure; absent when it is not exposed. */
27
+ readonly mcp?: {
28
+ readonly mode: string;
29
+ };
30
+ /** The definition's `.args()` schema as JSON Schema; absent without one. */
31
+ readonly inputSchema?: unknown;
22
32
  readonly children: readonly NodeView[];
23
33
  }
24
34
  /** A curated artifact, as projected into `result.json`. Data-only — no core import. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.8.10",
3
+ "version": "0.8.11",
4
4
  "description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",