omkit 0.8.3 → 0.8.5
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/dist/cli/commands/ls.d.ts +12 -1
- package/dist/cli/commands/ls.js +18 -5
- package/dist/cli/ui/app.js +43 -3
- package/dist/cli/ui/views/OmList.d.ts +7 -1
- package/dist/cli/ui/views/OmList.js +15 -6
- package/dist/client/channel.d.ts +5 -0
- package/dist/client/channel.js +9 -2
- package/dist/client/order.d.ts +21 -0
- package/dist/client/order.js +26 -0
- package/dist/client/runner.js +33 -0
- package/dist/skill/model.js +5 -2
- package/package.json +1 -1
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { RegistrationSet, Registry } from "../../client/registry.ts";
|
|
2
2
|
export interface FormatOptions {
|
|
3
3
|
readonly json?: boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Where om depth is measured from for the listing order. Defaults to the working directory —
|
|
6
|
+
* the same root the interactive picker measures from, so `ls` and the picker agree.
|
|
7
|
+
*/
|
|
8
|
+
readonly root?: string;
|
|
4
9
|
/**
|
|
5
10
|
* What the oms and actions declare, from the discovery fork. Present only under
|
|
6
11
|
* `--describe`: reading it costs an import of every candidate file, which is why plain
|
|
@@ -8,5 +13,11 @@ export interface FormatOptions {
|
|
|
8
13
|
*/
|
|
9
14
|
readonly registrations?: RegistrationSet;
|
|
10
15
|
}
|
|
11
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Render a {@link Registry} for the terminal (plain text) or as JSON.
|
|
18
|
+
*
|
|
19
|
+
* Oms are listed with {@link byNesting} — the core workflows near the root first — in both
|
|
20
|
+
* modes, and in the registrations too, since under `--describe` the JSON `oms` key is theirs.
|
|
21
|
+
* Actions keep discovery order: they are looked up by name, not browsed.
|
|
22
|
+
*/
|
|
12
23
|
export declare function formatRegistry(registry: Registry, opts?: FormatOptions): string;
|
package/dist/cli/commands/ls.js
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
|
|
2
|
+
import { byNesting } from "../../client/order.js";
|
|
3
|
+
/**
|
|
4
|
+
* Render a {@link Registry} for the terminal (plain text) or as JSON.
|
|
5
|
+
*
|
|
6
|
+
* Oms are listed with {@link byNesting} — the core workflows near the root first — in both
|
|
7
|
+
* modes, and in the registrations too, since under `--describe` the JSON `oms` key is theirs.
|
|
8
|
+
* Actions keep discovery order: they are looked up by name, not browsed.
|
|
9
|
+
*/
|
|
3
10
|
export function formatRegistry(registry, opts = {}) {
|
|
11
|
+
const order = byNesting(opts.root ?? process.cwd());
|
|
12
|
+
const oms = [...registry.oms].sort(order);
|
|
13
|
+
const registrations = opts.registrations && {
|
|
14
|
+
...opts.registrations,
|
|
15
|
+
oms: [...opts.registrations.oms].sort(order),
|
|
16
|
+
};
|
|
4
17
|
if (opts.json)
|
|
5
|
-
return renderJson(registry,
|
|
6
|
-
const declared = index(
|
|
18
|
+
return renderJson({ ...registry, oms }, registrations);
|
|
19
|
+
const declared = index(registrations);
|
|
7
20
|
const lines = [
|
|
8
|
-
`oms (${
|
|
9
|
-
...
|
|
21
|
+
`oms (${oms.length})`,
|
|
22
|
+
...oms.flatMap((om) => row(`${om.name} ${path.basename(om.file)}:${om.line}`, [], declared?.get(key(om.name, om.file)))),
|
|
10
23
|
"",
|
|
11
24
|
`actions (${registry.actions.length})`,
|
|
12
25
|
...registry.actions.flatMap((a) => row(`${a.name} ${path.basename(a.file)}`, [a.publishesCapability ? "ref" : "", a.events ? "events" : ""], declared?.get(key(a.name, a.file)))),
|
package/dist/cli/ui/app.js
CHANGED
|
@@ -5,6 +5,17 @@ import { RunModel } from "../../output/RunModel.js";
|
|
|
5
5
|
import { OmList } from "./views/OmList.js";
|
|
6
6
|
import { RunView } from "./views/RunView.js";
|
|
7
7
|
import { Spinner } from "./Spinner.js";
|
|
8
|
+
/**
|
|
9
|
+
* How often the run screen redraws while the log streams in.
|
|
10
|
+
*
|
|
11
|
+
* An Ink frame costs ~25ms at minimum and grows with the milestone tail, so redrawing per log
|
|
12
|
+
* entry caps the app at roughly 40 entries/second — far under what a build, a test suite, or a
|
|
13
|
+
* dev server emits. The app then falls permanently behind: `settled` is the *last* message on
|
|
14
|
+
* the channel, so it only arrives once every backlogged entry has been drawn, and the run looks
|
|
15
|
+
* hung long after the child has finished. Folding into the model is cheap; drawing is not, so
|
|
16
|
+
* the two run at different rates.
|
|
17
|
+
*/
|
|
18
|
+
const REDRAW_MS = 80;
|
|
8
19
|
/** The interactive app: discover → list/search → run → live milestones + prompts. */
|
|
9
20
|
export function App({ client, onExit, oms: initialOms, }) {
|
|
10
21
|
const { exit } = useApp();
|
|
@@ -21,6 +32,16 @@ export function App({ client, onExit, oms: initialOms, }) {
|
|
|
21
32
|
const sessionRef = useRef(null);
|
|
22
33
|
const verdictRef = useRef(undefined);
|
|
23
34
|
const tearingRef = useRef(false);
|
|
35
|
+
const redrawTimer = useRef(undefined);
|
|
36
|
+
const stopRedraw = () => {
|
|
37
|
+
if (redrawTimer.current === undefined)
|
|
38
|
+
return;
|
|
39
|
+
clearInterval(redrawTimer.current);
|
|
40
|
+
redrawTimer.current = undefined;
|
|
41
|
+
};
|
|
42
|
+
// The redraw timer holds the event loop open, so it must not outlive the app — an unmount
|
|
43
|
+
// for any reason other than the run settling would otherwise keep the CLI from exiting.
|
|
44
|
+
useEffect(() => stopRedraw, []);
|
|
24
45
|
useEffect(() => {
|
|
25
46
|
if (initialOms)
|
|
26
47
|
return; // provided by the caller's pre-flight discovery
|
|
@@ -37,17 +58,34 @@ export function App({ client, onExit, oms: initialOms, }) {
|
|
|
37
58
|
const model = new RunModel();
|
|
38
59
|
const s = client.run(om.file);
|
|
39
60
|
sessionRef.current = s;
|
|
40
|
-
|
|
41
|
-
|
|
61
|
+
// Every entry lands in the model immediately; the screen catches up on its own cadence.
|
|
62
|
+
// `dirty` keeps an idle run from redrawing over nothing — see REDRAW_MS for why the two
|
|
63
|
+
// are separated at all.
|
|
64
|
+
let dirty = false;
|
|
65
|
+
const redraw = () => {
|
|
66
|
+
if (!dirty)
|
|
67
|
+
return;
|
|
68
|
+
dirty = false;
|
|
42
69
|
setLines(model.milestones());
|
|
43
70
|
setStatus(model.summary());
|
|
71
|
+
};
|
|
72
|
+
redrawTimer.current = setInterval(redraw, REDRAW_MS);
|
|
73
|
+
s.on("log", (entry) => {
|
|
74
|
+
model.apply(entry);
|
|
75
|
+
dirty = true;
|
|
76
|
+
});
|
|
77
|
+
// A prompt blocks the child until it's answered, so it must not wait for the next tick —
|
|
78
|
+
// and the milestones that led to it are the context the question is read in.
|
|
79
|
+
s.on("prompt", (req) => {
|
|
80
|
+
redraw();
|
|
81
|
+
setPrompt(req);
|
|
44
82
|
});
|
|
45
|
-
s.on("prompt", (req) => setPrompt(req));
|
|
46
83
|
// The child gave up on the prompt (timed out or torn down). Withdraw its box so a stale,
|
|
47
84
|
// unanswerable prompt doesn't linger — but only if it's still the one on screen, so a late
|
|
48
85
|
// withdrawal can't clobber a fresh prompt.
|
|
49
86
|
s.on("promptDone", (id) => setPrompt((p) => (p?.id === id ? undefined : p)));
|
|
50
87
|
s.on("settled", (v) => {
|
|
88
|
+
redraw(); // the run's closing milestones, before its verdict
|
|
51
89
|
setVerdict(v);
|
|
52
90
|
setPrompt(undefined);
|
|
53
91
|
});
|
|
@@ -57,6 +95,8 @@ export function App({ client, onExit, oms: initialOms, }) {
|
|
|
57
95
|
// so it can print the durable summary block, and unmount. Without this a self-completing run
|
|
58
96
|
// would show its verdict line but never exit or render the summary.
|
|
59
97
|
void s.result.then((v) => {
|
|
98
|
+
stopRedraw();
|
|
99
|
+
redraw(); // last pass: whatever arrived since the final tick
|
|
60
100
|
verdictRef.current = v;
|
|
61
101
|
setVerdict(v);
|
|
62
102
|
onExit?.(v);
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
2
|
import type { DiscoveredOm } from "../../../client/registry.ts";
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* A searchable, arrow-navigable list of oms; Enter selects the highlighted one.
|
|
5
|
+
*
|
|
6
|
+
* Rows are ordered by {@link byNesting} from the working directory — the same root the path
|
|
7
|
+
* column is rendered from, so the order matches what the column shows: the oms nearest the
|
|
8
|
+
* top of the project first, deeper ones after, and any shown as an absolute path last.
|
|
9
|
+
*/
|
|
4
10
|
export declare function OmList({ oms, onSelect, }: {
|
|
5
11
|
oms: DiscoveredOm[];
|
|
6
12
|
onSelect: (om: DiscoveredOm) => void;
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {} from "react";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
4
|
import { Text } from "ink";
|
|
5
5
|
import { SearchableList } from "./SearchableList.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import { byNesting } from "../../../client/order.js";
|
|
7
|
+
/** Show the om's path relative to `root` when it's inside it; else the absolute path. */
|
|
8
|
+
function displayPath(file, root) {
|
|
9
|
+
const rel = path.relative(root, file);
|
|
9
10
|
return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : file;
|
|
10
11
|
}
|
|
11
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* A searchable, arrow-navigable list of oms; Enter selects the highlighted one.
|
|
14
|
+
*
|
|
15
|
+
* Rows are ordered by {@link byNesting} from the working directory — the same root the path
|
|
16
|
+
* column is rendered from, so the order matches what the column shows: the oms nearest the
|
|
17
|
+
* top of the project first, deeper ones after, and any shown as an absolute path last.
|
|
18
|
+
*/
|
|
12
19
|
export function OmList({ oms, onSelect, }) {
|
|
20
|
+
const root = process.cwd();
|
|
21
|
+
const sorted = useMemo(() => [...oms].sort(byNesting(root)), [oms, root]);
|
|
13
22
|
// Column width so paths line up; capped so one long name can't push paths off-screen.
|
|
14
23
|
const nameWidth = Math.min(30, oms.reduce((w, o) => Math.max(w, o.name.length), 0));
|
|
15
|
-
return (_jsx(SearchableList, { items:
|
|
24
|
+
return (_jsx(SearchableList, { items: sorted, getKey: (o) => o.name, getSearchText: (o) => o.name, onSelect: onSelect, renderRow: (o, selected) => (_jsxs(Text, { children: [_jsx(Text, { color: selected ? "cyan" : undefined, children: selected ? "❯ " : " " }), _jsx(Text, { bold: selected, children: o.name.padEnd(nameWidth) }), " ", _jsx(Text, { dimColor: true, children: displayPath(o.file, root) })] })) }));
|
|
16
25
|
}
|
package/dist/client/channel.d.ts
CHANGED
|
@@ -5,6 +5,11 @@ export interface Transport {
|
|
|
5
5
|
send(message: SupervisorMessage): void;
|
|
6
6
|
onMessage(handler: (message: ChildMessage) => void): void;
|
|
7
7
|
onClose(handler: (code: number | null) => void): void;
|
|
8
|
+
/**
|
|
9
|
+
* Whatever the child printed outside the channel, for a close that never reached `settled`.
|
|
10
|
+
* Read only on that path — a run that reports for itself needs no second account.
|
|
11
|
+
*/
|
|
12
|
+
diagnostics?(): string[];
|
|
8
13
|
}
|
|
9
14
|
/**
|
|
10
15
|
* Turn a child transport into a {@link RunSession}: fan `log`/`prompt`/`prompt-done` child
|
package/dist/client/channel.js
CHANGED
|
@@ -35,8 +35,15 @@ export function createChannel(transport) {
|
|
|
35
35
|
}
|
|
36
36
|
});
|
|
37
37
|
transport.onClose(() => {
|
|
38
|
-
if (
|
|
39
|
-
|
|
38
|
+
if (settled)
|
|
39
|
+
return;
|
|
40
|
+
// The child died before it could report: there is no run folder and no summary of its own.
|
|
41
|
+
// What it printed on its way out — a module that failed to load, a crash during import — is
|
|
42
|
+
// the only account of why, so it stands in as the summary. Without it the frontend shows a
|
|
43
|
+
// bare "✗ failed" and the reason is lost with the process.
|
|
44
|
+
const output = transport.diagnostics?.() ?? [];
|
|
45
|
+
const summary = output.length ? ["the run ended before it could report:", ...output] : [];
|
|
46
|
+
resolveResult({ ok: false, folder: "", summary });
|
|
40
47
|
});
|
|
41
48
|
return {
|
|
42
49
|
on(event, handler) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** What the listing order needs from an entry: its name, and where it is defined. */
|
|
2
|
+
export interface Located {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly file: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* How many directories lie between `root` and `file`: `oms/build.ts` is 1, `build.ts` is 0.
|
|
8
|
+
* A file outside the root is `Infinity`, so it lists after everything the root contains rather
|
|
9
|
+
* than at a depth invented from its `..` segments.
|
|
10
|
+
*/
|
|
11
|
+
export declare function nestingDepth(file: string, root: string): number;
|
|
12
|
+
/**
|
|
13
|
+
* The order a project's oms are presented in: shallowest first, then by name, then by file.
|
|
14
|
+
*
|
|
15
|
+
* Where an om sits says what it is for. The workflows at the top of a project are the ones
|
|
16
|
+
* people reach for every day; one three folders down is a specialised variant or a helper
|
|
17
|
+
* behind them. Listing by depth puts the core oms first without asking authors to rank them.
|
|
18
|
+
* Depth is measured from the root the listing renders its paths from, so the order matches
|
|
19
|
+
* the column it sits beside. The file tie-break keeps two same-named oms in a fixed order.
|
|
20
|
+
*/
|
|
21
|
+
export declare function byNesting(root: string): (a: Located, b: Located) => number;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* How many directories lie between `root` and `file`: `oms/build.ts` is 1, `build.ts` is 0.
|
|
4
|
+
* A file outside the root is `Infinity`, so it lists after everything the root contains rather
|
|
5
|
+
* than at a depth invented from its `..` segments.
|
|
6
|
+
*/
|
|
7
|
+
export function nestingDepth(file, root) {
|
|
8
|
+
const rel = path.relative(root, file);
|
|
9
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
|
|
10
|
+
return Infinity;
|
|
11
|
+
return rel.split(path.sep).length - 1;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The order a project's oms are presented in: shallowest first, then by name, then by file.
|
|
15
|
+
*
|
|
16
|
+
* Where an om sits says what it is for. The workflows at the top of a project are the ones
|
|
17
|
+
* people reach for every day; one three folders down is a specialised variant or a helper
|
|
18
|
+
* behind them. Listing by depth puts the core oms first without asking authors to rank them.
|
|
19
|
+
* Depth is measured from the root the listing renders its paths from, so the order matches
|
|
20
|
+
* the column it sits beside. The file tie-break keeps two same-named oms in a fixed order.
|
|
21
|
+
*/
|
|
22
|
+
export function byNesting(root) {
|
|
23
|
+
return (a, b) => nestingDepth(a.file, root) - nestingDepth(b.file, root) ||
|
|
24
|
+
a.name.localeCompare(b.name) ||
|
|
25
|
+
a.file.localeCompare(b.file);
|
|
26
|
+
}
|
package/dist/client/runner.js
CHANGED
|
@@ -18,6 +18,30 @@ export function inspectArgs(inspect) {
|
|
|
18
18
|
return [];
|
|
19
19
|
return [`--inspect=${inspect.port}`];
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* How much of a child's raw output to keep. Enough for a module-resolution failure or a stack
|
|
23
|
+
* trace, small enough that a run which prints megabytes still costs nothing to supervise.
|
|
24
|
+
*/
|
|
25
|
+
const TAIL_BYTES = 8 * 1024;
|
|
26
|
+
/**
|
|
27
|
+
* Drain a child's raw streams while keeping the last {@link TAIL_BYTES} of them. Attaching a
|
|
28
|
+
* `data` handler is what puts each stream in flowing mode — that, not the tail, is what stops
|
|
29
|
+
* the child wedging on a full pipe. Returns a reader for the kept lines, oldest first; stdout
|
|
30
|
+
* and stderr share one buffer so a message split across them stays in order.
|
|
31
|
+
*/
|
|
32
|
+
function keepTail(...streams) {
|
|
33
|
+
let text = "";
|
|
34
|
+
for (const stream of streams) {
|
|
35
|
+
stream?.setEncoding("utf8");
|
|
36
|
+
stream?.on("data", (chunk) => {
|
|
37
|
+
text = (text + chunk).slice(-TAIL_BYTES);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return () => text
|
|
41
|
+
.split("\n")
|
|
42
|
+
.map((line) => line.replace(/\r$/, "").trimEnd())
|
|
43
|
+
.filter((line) => line !== "");
|
|
44
|
+
}
|
|
21
45
|
/**
|
|
22
46
|
* Fork `omFile` as a supervised child: the `tsx` loader runs the TypeScript directly,
|
|
23
47
|
* `OMKIT_SUPERVISED=1` flips the child into channel mode (see core/interaction.ts), and its
|
|
@@ -38,10 +62,19 @@ export function runOm(omFile, opts = {}) {
|
|
|
38
62
|
env: { ...process.env, OMKIT_SUPERVISED: "1", ...opts.env },
|
|
39
63
|
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
40
64
|
});
|
|
65
|
+
// Piped, not read: nothing in the supervisor consumes these streams, so once the OS pipe
|
|
66
|
+
// buffer fills (~64 KB) the child blocks on its next write — permanently, since the only
|
|
67
|
+
// thing the supervisor ever reads is the IPC channel. `patch-console` swaps the console
|
|
68
|
+
// methods but not `process.stdout.write`, so any direct write in an om (or in a library it
|
|
69
|
+
// uses) lands here and wedges the run before it can settle. Draining is what keeps the child
|
|
70
|
+
// moving; the tail is kept because a child that dies before the channel opens has nowhere
|
|
71
|
+
// else to say why (see the Transport's `diagnostics`).
|
|
72
|
+
const tail = keepTail(child.stdout, child.stderr);
|
|
41
73
|
const transport = {
|
|
42
74
|
send: (message) => void child.send(message),
|
|
43
75
|
onMessage: (handler) => void child.on("message", (m) => handler(m)),
|
|
44
76
|
onClose: (handler) => void child.on("close", (code) => handler(code)),
|
|
77
|
+
diagnostics: tail,
|
|
45
78
|
};
|
|
46
79
|
const session = createChannel(transport);
|
|
47
80
|
// When the run settles, the child has already written its logs and reaped its process tree —
|
package/dist/skill/model.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { shapeHint } from "../core/schema-hint.js";
|
|
4
4
|
import { DEFAULT_TSCONFIG } from "../client/types.js";
|
|
5
|
+
import { byNesting } from "../client/order.js";
|
|
5
6
|
/**
|
|
6
7
|
* Fold a project's registrations and its AST scan into the model the skill renders from.
|
|
7
8
|
*
|
|
@@ -16,10 +17,12 @@ export function buildSkillModel(registrations, registry, project) {
|
|
|
16
17
|
const { root } = project;
|
|
17
18
|
const outlines = new Map(registry.oms.map((o) => [key(o.name, o.file), o.calls]));
|
|
18
19
|
const capabilities = new Map(registry.actions.map((a) => [key(a.name, a.file), a.publishesCapability]));
|
|
20
|
+
// Oms are what a reader picks from, so the core ones — those nearest the root — lead.
|
|
21
|
+
// Actions are looked up by name from an om's outline, so alphabetical is their lookup order.
|
|
19
22
|
const oms = registrations.oms
|
|
20
23
|
.filter(isExposed)
|
|
21
|
-
.
|
|
22
|
-
.
|
|
24
|
+
.sort(byNesting(root))
|
|
25
|
+
.map((om) => omEntry(om, outlines.get(key(om.name, om.file)), root));
|
|
23
26
|
const actions = registrations.actions
|
|
24
27
|
.filter(isExposed)
|
|
25
28
|
.map((a) => actionEntry(a, capabilities.get(key(a.name, a.file)), root))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omkit",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
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",
|