omkit 0.4.8 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/README.md +169 -13
  3. package/dist/actions/chrome-page.d.ts +41 -0
  4. package/dist/actions/chrome-page.js +77 -29
  5. package/dist/actions/drive-page.d.ts +50 -0
  6. package/dist/actions/drive-page.js +68 -0
  7. package/dist/actions/index.d.ts +5 -3
  8. package/dist/actions/index.js +2 -1
  9. package/dist/actions/prompt.d.ts +32 -5
  10. package/dist/actions/prompt.js +92 -8
  11. package/dist/cli/commands/help.js +7 -0
  12. package/dist/cli/commands/init.js +1 -1
  13. package/dist/cli/commands/ls.d.ts +11 -4
  14. package/dist/cli/commands/ls.js +35 -15
  15. package/dist/cli/commands/mcp.d.ts +10 -0
  16. package/dist/cli/commands/mcp.js +13 -0
  17. package/dist/cli/commands/skill.d.ts +18 -0
  18. package/dist/cli/commands/skill.js +62 -0
  19. package/dist/cli/index.d.ts +8 -0
  20. package/dist/cli/index.js +78 -30
  21. package/dist/client/discover-child.d.ts +12 -0
  22. package/dist/client/discover-child.js +71 -0
  23. package/dist/client/discovery.d.ts +21 -1
  24. package/dist/client/discovery.js +139 -2
  25. package/dist/client/index.d.ts +2 -1
  26. package/dist/client/index.js +15 -5
  27. package/dist/client/outline.d.ts +35 -0
  28. package/dist/client/outline.js +100 -0
  29. package/dist/client/registry.d.ts +59 -0
  30. package/dist/client/runner.d.ts +12 -0
  31. package/dist/client/runner.js +16 -7
  32. package/dist/client/types.d.ts +14 -1
  33. package/dist/client/types.js +5 -1
  34. package/dist/core/ActionRun.d.ts +5 -0
  35. package/dist/core/ActionRun.js +9 -0
  36. package/dist/core/ExecutionTree.d.ts +40 -2
  37. package/dist/core/ExecutionTree.js +66 -2
  38. package/dist/core/action.d.ts +2 -1
  39. package/dist/core/action.js +93 -9
  40. package/dist/core/args.d.ts +50 -0
  41. package/dist/core/args.js +166 -0
  42. package/dist/core/discovery-mode.d.ts +37 -0
  43. package/dist/core/discovery-mode.js +54 -0
  44. package/dist/core/interaction.d.ts +3 -1
  45. package/dist/core/om.d.ts +11 -9
  46. package/dist/core/om.js +182 -13
  47. package/dist/core/schema-hint.d.ts +20 -0
  48. package/dist/core/schema-hint.js +73 -0
  49. package/dist/core/schema-json.d.ts +12 -0
  50. package/dist/core/schema-json.js +12 -0
  51. package/dist/core/types.d.ts +113 -1
  52. package/dist/foundation/LogEntry.d.ts +1 -1
  53. package/dist/foundation/canonicalPath.d.ts +26 -0
  54. package/dist/foundation/canonicalPath.js +34 -0
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.js +1 -1
  57. package/dist/mcp/action-host.d.ts +1 -0
  58. package/dist/mcp/action-host.js +65 -0
  59. package/dist/mcp/action-identity.d.ts +23 -0
  60. package/dist/mcp/action-identity.js +35 -0
  61. package/dist/mcp/progress.d.ts +61 -0
  62. package/dist/mcp/progress.js +92 -0
  63. package/dist/mcp/resources.d.ts +55 -0
  64. package/dist/mcp/resources.js +208 -0
  65. package/dist/mcp/runs.d.ts +37 -0
  66. package/dist/mcp/runs.js +60 -0
  67. package/dist/mcp/server.d.ts +19 -0
  68. package/dist/mcp/server.js +38 -0
  69. package/dist/mcp/tail.d.ts +37 -0
  70. package/dist/mcp/tail.js +64 -0
  71. package/dist/mcp/tools.d.ts +54 -0
  72. package/dist/mcp/tools.js +378 -0
  73. package/dist/mcp/validate.d.ts +14 -0
  74. package/dist/mcp/validate.js +54 -0
  75. package/dist/mcp/verdict.d.ts +31 -0
  76. package/dist/mcp/verdict.js +47 -0
  77. package/dist/output/artifact/ArtifactStore.d.ts +24 -0
  78. package/dist/output/artifact/ArtifactStore.js +49 -0
  79. package/dist/output/folder/RunFolder.d.ts +23 -1
  80. package/dist/output/folder/RunFolder.js +70 -11
  81. package/dist/output/writers/views.d.ts +10 -0
  82. package/dist/skill/file.d.ts +26 -0
  83. package/dist/skill/file.js +58 -0
  84. package/dist/skill/index.d.ts +12 -0
  85. package/dist/skill/index.js +10 -0
  86. package/dist/skill/model.d.ts +69 -0
  87. package/dist/skill/model.js +200 -0
  88. package/dist/skill/render.d.ts +18 -0
  89. package/dist/skill/render.js +159 -0
  90. package/dist/skill/root.d.ts +15 -0
  91. package/dist/skill/root.js +27 -0
  92. package/package.json +5 -2
@@ -0,0 +1,50 @@
1
+ import type { z } from "zod";
2
+ import { type JsonSchema } from "./schema-json.ts";
3
+ /** Thrown when args cannot be resolved and nobody can be asked. */
4
+ export declare class MissingArgsError extends Error {
5
+ readonly fields: string[];
6
+ /**
7
+ * @param fields Top-level field names that blocked resolution.
8
+ * @param detail Why, when at least one of them *was* supplied — a value that is present
9
+ * but wrong is not missing, and must not be reported as though it were absent.
10
+ */
11
+ constructor(fields: string[], detail?: string);
12
+ }
13
+ /** What the caller must render to get one value from the user. */
14
+ export interface AskSpec {
15
+ /** Dotted path of the field, e.g. `rows` or `config`. */
16
+ readonly path: string;
17
+ readonly kind: "scalar" | "json";
18
+ /** One-line type sketch for the prompt, or `undefined` when the shape cannot be sketched. */
19
+ readonly hint: string | undefined;
20
+ /** The field's JSON Schema, for printing when there is no hint. */
21
+ readonly jsonSchema: JsonSchema;
22
+ }
23
+ export interface ResolveOptions {
24
+ /** Values already supplied (from `OMKIT_ARGS`). */
25
+ readonly supplied: unknown;
26
+ /** False when there is no supervisor and stdin is not a TTY. */
27
+ readonly interactive: boolean;
28
+ readonly ask: (spec: AskSpec) => Promise<string>;
29
+ }
30
+ /**
31
+ * Fill a schema's shape from supplied values, then defaults, then prompting — and fail if
32
+ * that is not enough. Defaults need no work of their own: `safeParse` applies them, so a
33
+ * defaulted field is never blocking and is never asked about.
34
+ *
35
+ * Interactivity is checked **before any prompting**: if nobody can be asked, every blocking
36
+ * field is collected into a single error rather than asking for one and then discovering
37
+ * the second is unanswerable.
38
+ */
39
+ export declare function resolveArgs(schema: z.ZodType, opts: ResolveOptions): Promise<unknown>;
40
+ /**
41
+ * True once a JSON-kind answer is finished. A `{`/`[` opener is a pasted blob and is done
42
+ * when it parses; anything else is a path to a JSON file, and one line is the whole answer.
43
+ *
44
+ * Exported because the prompt that *collects* the answer has to stop on exactly the inputs
45
+ * {@link coerce} knows how to read — `om.ts` passes this as the multiline prompt's `until`.
46
+ * Let the two rules drift apart and the documented file-path answer becomes untypable: a
47
+ * bare path is not JSON, so a JSON-only terminator keeps reading, and a real terminal never
48
+ * reaches the EOF that would otherwise end it.
49
+ */
50
+ export declare function jsonAnswerComplete(text: string): boolean;
@@ -0,0 +1,166 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { toJsonSchema } from "./schema-json.js";
3
+ import { shapeHint } from "./schema-hint.js";
4
+ /** Thrown when args cannot be resolved and nobody can be asked. */
5
+ export class MissingArgsError extends Error {
6
+ fields;
7
+ /**
8
+ * @param fields Top-level field names that blocked resolution.
9
+ * @param detail Why, when at least one of them *was* supplied — a value that is present
10
+ * but wrong is not missing, and must not be reported as though it were absent.
11
+ */
12
+ constructor(fields, detail) {
13
+ super(`could not resolve arg${fields.length === 1 ? "" : "s"}: ${fields.join(", ")} — ` +
14
+ `pass them via OMKIT_ARGS, or run interactively` +
15
+ (detail ? ` (${detail})` : ""));
16
+ this.fields = fields;
17
+ this.name = "MissingArgsError";
18
+ }
19
+ }
20
+ /** How many rounds of asking before resolution gives up. */
21
+ const MAX_ATTEMPTS = 3;
22
+ /**
23
+ * Fill a schema's shape from supplied values, then defaults, then prompting — and fail if
24
+ * that is not enough. Defaults need no work of their own: `safeParse` applies them, so a
25
+ * defaulted field is never blocking and is never asked about.
26
+ *
27
+ * Interactivity is checked **before any prompting**: if nobody can be asked, every blocking
28
+ * field is collected into a single error rather than asking for one and then discovering
29
+ * the second is unanswerable.
30
+ */
31
+ export async function resolveArgs(schema, opts) {
32
+ // Converted lazily, and only once something actually has to be asked. `toJSONSchema`
33
+ // throws for types it cannot represent (`z.date()`, and anything built on it), and only
34
+ // the prompt needs the conversion — a run whose args all arrived supplied or defaulted
35
+ // must not be failed by work it never required.
36
+ let converted;
37
+ const root = () => (converted ??= toJsonSchema(schema));
38
+ let current = asRecord(opts.supplied);
39
+ for (let attempt = 0;; attempt++) {
40
+ const parsed = schema.safeParse(current);
41
+ if (parsed.success)
42
+ return parsed.data;
43
+ const blocking = blockingFields(parsed.error);
44
+ // Nothing nameable to ask for — the schema rejected the object as a whole (a top-level
45
+ // refinement, or a schema that isn't an object at all). No question would make progress,
46
+ // so fail now with the real reason instead of looping silently to the attempt cap.
47
+ if (blocking.length === 0)
48
+ throw new Error(`could not resolve args: ${message(parsed.error)}`);
49
+ if (!opts.interactive) {
50
+ const anySupplied = blocking.some((field) => current[field] !== undefined);
51
+ throw new MissingArgsError(blocking, anySupplied ? message(parsed.error) : undefined);
52
+ }
53
+ if (attempt >= MAX_ATTEMPTS) {
54
+ throw new Error(`could not resolve args after ${MAX_ATTEMPTS} attempts: ${message(parsed.error)}`);
55
+ }
56
+ for (const field of blocking) {
57
+ const fieldSchema = propertySchema(root(), field);
58
+ const kind = isComplex(fieldSchema) ? "json" : "scalar";
59
+ const answer = await opts.ask({
60
+ path: field,
61
+ kind,
62
+ hint: shapeHint(fieldSchema),
63
+ jsonSchema: fieldSchema,
64
+ });
65
+ // An empty answer is *no answer*, not a value. The prompt battery resolves an empty
66
+ // line (and a timeout) to its default (""), and `Number("")` is 0 — so coercing here
67
+ // would silently turn a bare Enter on a required number into a real zero. Leave the
68
+ // field as it was and let the loop re-ask.
69
+ if (answer.trim() === "")
70
+ continue;
71
+ current = { ...current, [field]: coerce(answer, fieldSchema, kind) };
72
+ }
73
+ }
74
+ }
75
+ function asRecord(value) {
76
+ return typeof value === "object" && value !== null ? { ...value } : {};
77
+ }
78
+ /**
79
+ * The top-level field names the error blames, first-seen order, deduplicated. Both an
80
+ * absent field and a present-but-wrong one block resolution and are asked about the same
81
+ * way, so they are one list — whether a field was supplied is read off `current`, which
82
+ * is the direct observation, rather than inferred from zod's issue shape.
83
+ */
84
+ function blockingFields(error) {
85
+ const names = error.issues.map((i) => String(i.path[0] ?? "")).filter((n) => n !== "");
86
+ return [...new Set(names)];
87
+ }
88
+ function message(error) {
89
+ return error.issues.map((i) => `${i.path.join(".") || "(root)"} — ${i.message}`).join("; ");
90
+ }
91
+ function propertySchema(root, field) {
92
+ const properties = root.properties;
93
+ if (typeof properties === "object" && properties !== null) {
94
+ const found = properties[field];
95
+ if (typeof found === "object" && found !== null)
96
+ return found;
97
+ }
98
+ return {};
99
+ }
100
+ function isComplex(schema) {
101
+ return schema.type === "object" || schema.type === "array";
102
+ }
103
+ /**
104
+ * True once a JSON-kind answer is finished. A `{`/`[` opener is a pasted blob and is done
105
+ * when it parses; anything else is a path to a JSON file, and one line is the whole answer.
106
+ *
107
+ * Exported because the prompt that *collects* the answer has to stop on exactly the inputs
108
+ * {@link coerce} knows how to read — `om.ts` passes this as the multiline prompt's `until`.
109
+ * Let the two rules drift apart and the documented file-path answer becomes untypable: a
110
+ * bare path is not JSON, so a JSON-only terminator keeps reading, and a real terminal never
111
+ * reaches the EOF that would otherwise end it.
112
+ */
113
+ export function jsonAnswerComplete(text) {
114
+ const trimmed = text.trim();
115
+ if (trimmed === "")
116
+ return false; // a bare Enter is not an answer — keep reading
117
+ return isJsonBlob(trimmed) ? parsesAsJson(trimmed) : true;
118
+ }
119
+ /** Whether an answer is a pasted JSON blob rather than a path to one. */
120
+ function isJsonBlob(text) {
121
+ return text.startsWith("{") || text.startsWith("[");
122
+ }
123
+ function parsesAsJson(text) {
124
+ try {
125
+ JSON.parse(text);
126
+ return true;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
132
+ /**
133
+ * Turn a prompt answer into a value the schema can accept. Scalars coerce from their
134
+ * string form; complex fields parse as JSON, or are read from a file path when the answer
135
+ * does not start with `{` or `[` — pointing at a file beats pasting a large config.
136
+ */
137
+ function coerce(answer, schema, kind) {
138
+ const text = answer.trim();
139
+ if (kind === "json") {
140
+ try {
141
+ return JSON.parse(isJsonBlob(text) ? text : readFileArg(text));
142
+ }
143
+ catch {
144
+ // Unparseable JSON *or* an unreadable path: hand the raw text back so the schema
145
+ // rejects it and the loop re-asks, naming the field. A mistyped filename must not
146
+ // escape as an ENOENT and take the whole run down.
147
+ return text;
148
+ }
149
+ }
150
+ if (schema.type === "number" || schema.type === "integer") {
151
+ const n = Number(text);
152
+ return Number.isNaN(n) ? text : n;
153
+ }
154
+ if (schema.type === "boolean") {
155
+ if (/^(true|yes|y|1)$/i.test(text))
156
+ return true;
157
+ if (/^(false|no|n|0)$/i.test(text))
158
+ return false;
159
+ return text;
160
+ }
161
+ return text;
162
+ }
163
+ /** Read a JSON file named by an answer; a leading `@` is optional sugar. */
164
+ function readFileArg(pathish) {
165
+ return readFileSync(pathish.replace(/^@/, ""), "utf8");
166
+ }
@@ -0,0 +1,37 @@
1
+ import type { DescribedArgs, OmDescription, ResolvedMcpExposure } from "./types.ts";
2
+ /**
3
+ * What an om reports in discovery mode, posted the moment `.run(...)` is reached.
4
+ *
5
+ * Sent over the fork's IPC channel rather than collected in a module variable: a user's
6
+ * file resolves `"omkit"` to the installed package while the discovery child runs omkit's
7
+ * own module graph, so the two can hold different copies of omkit — and a shared variable
8
+ * would be written in one and read in the other, coming back empty with no error and no
9
+ * clue why. `process.send` is copy-independent.
10
+ */
11
+ export interface OmRegistrationMessage {
12
+ readonly kind: "om-registration";
13
+ readonly name: string;
14
+ /** The defining file — the same input the run's identity hashes. No `:line`. */
15
+ readonly file: string | undefined;
16
+ readonly description: OmDescription | undefined;
17
+ readonly mcp: ResolvedMcpExposure | undefined;
18
+ /** The declared args as JSON Schema; undefined when the om declares none. */
19
+ readonly inputSchema: DescribedArgs["inputSchema"];
20
+ /** Set when `.args(schema)` would not convert — the entry lists as unavailable. */
21
+ readonly schemaError: DescribedArgs["schemaError"];
22
+ }
23
+ export declare function isDiscovering(): boolean;
24
+ /** Test seam: enter or leave discovery mode without forking a child. */
25
+ export declare function setDiscovering(on: boolean): void;
26
+ /** Where a registration goes. */
27
+ export type RegistrationSink = (registration: OmRegistrationMessage) => void;
28
+ /** Test seam: collect registrations in-process. Pass `null` to restore the IPC sink. */
29
+ export declare function setRegistrationSink(next: RegistrationSink | null): void;
30
+ export declare function reportOm(registration: OmRegistrationMessage): void;
31
+ /**
32
+ * Convert a declared schema, never throwing. `z.toJSONSchema` rejects schemas it cannot
33
+ * represent (`z.date()`, `z.custom()`, anything built on them), and one such entry must
34
+ * not take the whole tool list down with it — so the failure is carried as data on that
35
+ * entry alone. `undefined` in (no `.args()`) means nothing to report either way.
36
+ */
37
+ export declare function describeSchema(schema: unknown): DescribedArgs;
@@ -0,0 +1,54 @@
1
+ import { toJsonSchema } from "./schema-json.js";
2
+ /**
3
+ * Discovery mode. The child forked by `discoverRegistrations()` sets `OMKIT_DISCOVER=1`
4
+ * and imports om files purely to read what they declare. In that mode `launch()` posts a
5
+ * registration and returns: no ExecutionTree, no log folder, no body.
6
+ *
7
+ * The flag is read once at import and then deleted from the environment, so a subprocess
8
+ * an om happens to spawn does not inherit it — the same discipline `OMKIT_SUPERVISED` and
9
+ * `OMKIT_ARGS` already follow.
10
+ */
11
+ function detect() {
12
+ const discovering = process.env.OMKIT_DISCOVER === "1";
13
+ delete process.env.OMKIT_DISCOVER;
14
+ return discovering;
15
+ }
16
+ let discovering = detect();
17
+ export function isDiscovering() {
18
+ return discovering;
19
+ }
20
+ /** Test seam: enter or leave discovery mode without forking a child. */
21
+ export function setDiscovering(on) {
22
+ discovering = on;
23
+ }
24
+ /** The real sink: straight up the fork's IPC channel. A no-op with no channel. */
25
+ const ipcSink = (registration) => {
26
+ process.send?.(registration);
27
+ };
28
+ let sink = ipcSink;
29
+ /** Test seam: collect registrations in-process. Pass `null` to restore the IPC sink. */
30
+ export function setRegistrationSink(next) {
31
+ sink = next ?? ipcSink;
32
+ }
33
+ export function reportOm(registration) {
34
+ sink(registration);
35
+ }
36
+ /**
37
+ * Convert a declared schema, never throwing. `z.toJSONSchema` rejects schemas it cannot
38
+ * represent (`z.date()`, `z.custom()`, anything built on them), and one such entry must
39
+ * not take the whole tool list down with it — so the failure is carried as data on that
40
+ * entry alone. `undefined` in (no `.args()`) means nothing to report either way.
41
+ */
42
+ export function describeSchema(schema) {
43
+ if (schema === undefined)
44
+ return { inputSchema: undefined, schemaError: undefined };
45
+ try {
46
+ return { inputSchema: toJsonSchema(schema), schemaError: undefined };
47
+ }
48
+ catch (e) {
49
+ return {
50
+ inputSchema: undefined,
51
+ schemaError: e instanceof Error ? e.message : String(e),
52
+ };
53
+ }
54
+ }
@@ -5,13 +5,15 @@ import type { LogEntry } from "../foundation/LogEntry.ts";
5
5
  * so there is no core→actions import cycle).
6
6
  */
7
7
  export interface PromptSpec {
8
- kind: "input" | "choice";
8
+ kind: "input" | "choice" | "multiline";
9
9
  message: string;
10
10
  default: string;
11
11
  choices?: {
12
12
  label: string;
13
13
  value: string;
14
14
  }[];
15
+ /** A one-line type sketch shown with the message — set for `multiline` JSON prompts. */
16
+ hint?: string;
15
17
  }
16
18
  export type ChildMessage = {
17
19
  kind: "prompt";
package/dist/core/om.d.ts CHANGED
@@ -1,11 +1,13 @@
1
- import type { Awaitable, OmContext } from "./types.ts";
1
+ import type { OmBuilder } from "./types.ts";
2
2
  /**
3
- * Runs a linear orchestration as the root of a fresh {@link ExecutionTree}. The
4
- * required `name` plus the absolute path of the file calling `om()` form the run's
5
- * identity — its log folder is `logs/<name>-<hash8>/…`, so same-named oms defined
6
- * in different files never share a folder. The body runs inside the root node's
7
- * ambient scope, so any action call / `step(...)` / `console.*` it reaches
8
- * attributes correctly. Resolves once the run has torn down and produced its
9
- * artifacts; never rejects (failures are recorded in the tree and set the exit code).
3
+ * Define a run. `om(name)` returns a builder — chain `.describe(…)` and/or `.args(schema)`
4
+ * and finish with `.run(body)`, which launches it as the root of a fresh
5
+ * {@link ExecutionTree}. The name plus the absolute path of the calling file identify the
6
+ * run; its log folder is `logs/<name>-<hash8>/…`, so same-named oms in different files
7
+ * never share a folder.
8
+ *
9
+ * The call site is captured **here**, not in `.run()` (nor in `.args()`): `om(name)` is
10
+ * where a run is defined, so a builder handed to (and run from) another file still hashes
11
+ * to the file that defined it — see the run-folder-identity constraint doc.
10
12
  */
11
- export declare function om(name: string, body: (ctx: OmContext) => Awaitable<void>): Promise<void>;
13
+ export declare function om(name: string): OmBuilder;
package/dist/core/om.js CHANGED
@@ -1,22 +1,41 @@
1
1
  import { ExecutionTree } from "./ExecutionTree.js";
2
- import { callerSite } from "../foundation/callsite.js";
3
- /**
4
- * Runs a linear orchestration as the root of a fresh {@link ExecutionTree}. The
5
- * required `name` plus the absolute path of the file calling `om()` form the run's
6
- * identity — its log folder is `logs/<name>-<hash8>/…`, so same-named oms defined
7
- * in different files never share a folder. The body runs inside the root node's
8
- * ambient scope, so any action call / `step(...)` / `console.*` it reaches
9
- * attributes correctly. Resolves once the run has torn down and produced its
10
- * artifacts; never rejects (failures are recorded in the tree and set the exit code).
11
- */
12
- export function om(name, body) {
2
+ import { callerSite, siteFile } from "../foundation/callsite.js";
3
+ import { describeSchema, isDiscovering, reportOm } from "./discovery-mode.js";
4
+ import { activeSupervisor } from "./interaction.js";
5
+ import { jsonAnswerComplete, resolveArgs } from "./args.js";
6
+ import { OPAQUE } from "./schema-hint.js";
7
+ function assertName(name) {
13
8
  if (typeof name !== "string" || name.trim() === "") {
14
- throw new Error("om(name, body) requires a non-empty name — it identifies the run in logs/");
9
+ throw new Error("om(name) requires a non-empty name — it identifies the run in logs/");
10
+ }
11
+ }
12
+ /** Start the run: build the tree with the captured site and drive the body as the root. */
13
+ function launch(name, site, description, exposure, body, schema) {
14
+ // Discovery: report what this om declares and start nothing.
15
+ //
16
+ // The guard belongs *here*, at the single choke point both `run()` methods funnel
17
+ // through, for two reasons. `OmBuilderArgs.run` resolves args inside the run body, so a
18
+ // guard one level up would reach `resolveArgs` first and prompt into a child nobody can
19
+ // answer (or throw MissingArgsError) — neither is a discovery outcome. And
20
+ // `new ExecutionTree(...)` creates the run's log folder in its constructor, so the
21
+ // guard has to precede it or discovery litters `logs/` with empty runs.
22
+ if (isDiscovering()) {
23
+ const { inputSchema, schemaError } = describeSchema(schema);
24
+ reportOm({
25
+ kind: "om-registration",
26
+ name,
27
+ file: siteFile(site),
28
+ description,
29
+ mcp: exposure,
30
+ inputSchema,
31
+ schemaError,
32
+ });
33
+ return Promise.resolve();
15
34
  }
16
35
  if (ExecutionTree.current) {
17
36
  throw new Error("an om() run is already active in this process");
18
37
  }
19
- const tree = new ExecutionTree(name, callerSite()); // callerSite: where om() was called — the run's script
38
+ const tree = new ExecutionTree(name, site, description, exposure);
20
39
  ExecutionTree.current = tree;
21
40
  const rootBody = (ctx) => body({
22
41
  signal: ctx.signal,
@@ -25,7 +44,157 @@ export function om(name, body) {
25
44
  cancel: () => tree.cancel(),
26
45
  assert: ctx.assert,
27
46
  snapshot: ctx.snapshot,
47
+ artifact: ctx.artifact,
28
48
  artifactsFolder: ctx.artifactsFolder,
29
49
  });
30
50
  return tree.runRoot(rootBody);
31
51
  }
52
+ class Builder {
53
+ name;
54
+ site;
55
+ /** Carried into `ArgsBuilder` by `.args()`, which otherwise rebuilds the builder. */
56
+ description;
57
+ /** Carried the same way, for the same reason — `.args()` returns a new object. */
58
+ exposure;
59
+ constructor(name,
60
+ /** Captured in `om()`, not here — the defining file must be the caller's, not om.ts. */
61
+ site) {
62
+ this.name = name;
63
+ this.site = site;
64
+ }
65
+ describe(description) {
66
+ this.description = description;
67
+ return this;
68
+ }
69
+ mcp(exposure = {}) {
70
+ // The default is filled here, not at the reader, so every consumer sees one shape.
71
+ this.exposure = { mode: exposure.mode ?? "settling" };
72
+ return this;
73
+ }
74
+ args(schema) {
75
+ return new ArgsBuilder(this.name, this.site, schema, this.description, this.exposure);
76
+ }
77
+ run(body) {
78
+ // Carried onto the run for `omkit ls` and the MCP server; not read by the runtime yet.
79
+ return launch(this.name, this.site, this.description, this.exposure, body);
80
+ }
81
+ }
82
+ /**
83
+ * The builder after `.args(schema)`. Unlike an action's, the schema here is live: `.run`
84
+ * resolves it before calling the body, so an om declares what it needs and omkit fills it
85
+ * in. The site is the one `om()` captured — threaded through, never re-taken.
86
+ */
87
+ class ArgsBuilder {
88
+ name;
89
+ site;
90
+ schema;
91
+ description;
92
+ exposure;
93
+ constructor(name, site, schema, description, exposure) {
94
+ this.name = name;
95
+ this.site = site;
96
+ this.schema = schema;
97
+ this.description = description;
98
+ this.exposure = exposure;
99
+ }
100
+ describe(description) {
101
+ this.description = description;
102
+ return this;
103
+ }
104
+ mcp(exposure = {}) {
105
+ this.exposure = { mode: exposure.mode ?? "settling" };
106
+ return this;
107
+ }
108
+ run(body) {
109
+ // Resolution happens inside the run, not before it: prompting is async, and the run
110
+ // must already exist for the prompt and its answer to land on the timeline. A failure
111
+ // to resolve is therefore an ordinary failure of the root node — logged, and the run
112
+ // is marked failed — rather than a throw out of `om(...)`.
113
+ return launch(this.name, this.site, this.description, this.exposure, async (ctx) => {
114
+ const args = (await resolveArgs(this.schema, {
115
+ supplied: parseSuppliedArgs(),
116
+ interactive: isInteractive(),
117
+ ask: askForArg,
118
+ }));
119
+ ExecutionTree.current?.setRootArgs(args);
120
+ await body(ctx, args);
121
+ },
122
+ // Handed to `launch` for discovery only: in a real run the schema is resolved above,
123
+ // inside the body. Discovery never reaches that body, so it converts the schema here.
124
+ this.schema);
125
+ }
126
+ }
127
+ /** `OMKIT_ARGS` carries JSON — the only channel readable synchronously at `.run()`. */
128
+ function parseSuppliedArgs() {
129
+ const raw = process.env.OMKIT_ARGS;
130
+ // Read it once, then clear it from the environment so any subprocess this run spawns
131
+ // (`command("npx omkit run other-om")`, a nested npm script, …) does not inherit this
132
+ // run's args and resolve them against a completely different schema. Whoever launches an
133
+ // omkit child sets that child's args explicitly.
134
+ delete process.env.OMKIT_ARGS;
135
+ if (!raw)
136
+ return {};
137
+ try {
138
+ return JSON.parse(raw);
139
+ }
140
+ catch {
141
+ throw new Error("OMKIT_ARGS is not valid JSON");
142
+ }
143
+ }
144
+ /**
145
+ * Supervised, or a real terminal. Otherwise nobody can answer a prompt and resolution must
146
+ * fail fast instead of blocking on a question no one will see.
147
+ */
148
+ function isInteractive() {
149
+ return activeSupervisor() !== null || process.stdin.isTTY === true;
150
+ }
151
+ /**
152
+ * Ask the user for one arg, via the `prompt` battery.
153
+ *
154
+ * The import is dynamic so that `import { om }` does not pull readline in, and because
155
+ * `core` is otherwise not allowed to import `actions`. Be clear about what that costs: this
156
+ * is a **deliberate, unenforced** runtime edge, not a permitted one. ESLint's
157
+ * `no-restricted-imports` has no `ImportExpression` visitor, so a dynamic import is
158
+ * *invisible* to the rule rather than allowed by it — the linter will not catch the next one
159
+ * either, and nothing but this comment stands between here and a real dependency cycle.
160
+ */
161
+ async function askForArg(spec) {
162
+ const { prompt } = await import("../actions/prompt.js");
163
+ if (spec.kind === "json") {
164
+ return prompt({
165
+ kind: "multiline",
166
+ message: `${spec.path} — JSON`,
167
+ // No sketch means `shapeHint` could not express the shape; a placeholder would tell
168
+ // the user nothing about what to type, so the schema itself goes in its place.
169
+ hint: spec.hint ?? JSON.stringify(spec.jsonSchema),
170
+ // Not `"json"`: a JSON-kind arg may also be answered with the path to a JSON file, and
171
+ // a path is not JSON, so a JSON-only terminator would never end the read. At a bare
172
+ // terminal there is no EOF to fall back on, so the user would wait out the timeout —
173
+ // three times. This is the same rule `coerce` reads the answer back with.
174
+ until: jsonAnswerComplete,
175
+ timeoutMs: 120_000,
176
+ }).result;
177
+ }
178
+ // One line has no room for a raw schema, so an unsketchable scalar shows the placeholder.
179
+ return prompt({ message: `${spec.path} (${spec.hint ?? OPAQUE})`, timeoutMs: 120_000 }).result;
180
+ }
181
+ /**
182
+ * Define a run. `om(name)` returns a builder — chain `.describe(…)` and/or `.args(schema)`
183
+ * and finish with `.run(body)`, which launches it as the root of a fresh
184
+ * {@link ExecutionTree}. The name plus the absolute path of the calling file identify the
185
+ * run; its log folder is `logs/<name>-<hash8>/…`, so same-named oms in different files
186
+ * never share a folder.
187
+ *
188
+ * The call site is captured **here**, not in `.run()` (nor in `.args()`): `om(name)` is
189
+ * where a run is defined, so a builder handed to (and run from) another file still hashes
190
+ * to the file that defined it — see the run-folder-identity constraint doc.
191
+ */
192
+ export function om(name) {
193
+ assertName(name);
194
+ // The removed two-argument form: JS callers would otherwise silently lose their body
195
+ // to a builder nobody runs. TypeScript callers get a compile error before this.
196
+ if (arguments.length > 1) {
197
+ throw new Error("om(name, body) was removed — use om(name).run(body). See the omkit CHANGELOG.");
198
+ }
199
+ return new Builder(name, callerSite()); // the om() call site — the run's defining script
200
+ }
@@ -0,0 +1,20 @@
1
+ import type { JsonSchema } from "./schema-json.ts";
2
+ /**
3
+ * Placeholder text for a schema that cannot be sketched, for the one-line prompts where
4
+ * printing the raw JSON Schema instead would not fit. Callers do **not** detect degradation
5
+ * by comparing against it — {@link shapeHint} returns `undefined` for that.
6
+ */
7
+ export declare const OPAQUE = "see schema";
8
+ /**
9
+ * Render a JSON Schema as a one-line type sketch for a prompt — `{ host: string,
10
+ * port?: number }` — or `undefined` when it cannot express the shape (deep unions,
11
+ * tuples, recursion).
12
+ *
13
+ * Deliberately partial, and **all-or-nothing**: an unsketchable type anywhere in the tree
14
+ * degrades the whole sketch rather than leaving a placeholder inside an otherwise-valid
15
+ * one. `{ mode: see schema }` reads as a field whose type is the string "see schema", which
16
+ * is exactly the misleading output this is supposed to refuse to print. Degradation is
17
+ * signalled by the return type so a caller cannot miss it by string-matching the wrong
18
+ * shape of sentinel.
19
+ */
20
+ export declare function shapeHint(schema: JsonSchema): string | undefined;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Placeholder text for a schema that cannot be sketched, for the one-line prompts where
3
+ * printing the raw JSON Schema instead would not fit. Callers do **not** detect degradation
4
+ * by comparing against it — {@link shapeHint} returns `undefined` for that.
5
+ */
6
+ export const OPAQUE = "see schema";
7
+ /**
8
+ * Render a JSON Schema as a one-line type sketch for a prompt — `{ host: string,
9
+ * port?: number }` — or `undefined` when it cannot express the shape (deep unions,
10
+ * tuples, recursion).
11
+ *
12
+ * Deliberately partial, and **all-or-nothing**: an unsketchable type anywhere in the tree
13
+ * degrades the whole sketch rather than leaving a placeholder inside an otherwise-valid
14
+ * one. `{ mode: see schema }` reads as a field whose type is the string "see schema", which
15
+ * is exactly the misleading output this is supposed to refuse to print. Degradation is
16
+ * signalled by the return type so a caller cannot miss it by string-matching the wrong
17
+ * shape of sentinel.
18
+ */
19
+ export function shapeHint(schema) {
20
+ return render(schema);
21
+ }
22
+ function render(node) {
23
+ if (typeof node !== "object" || node === null)
24
+ return undefined;
25
+ const s = node;
26
+ const enumValues = s.enum;
27
+ if (Array.isArray(enumValues) && enumValues.length > 0) {
28
+ return enumValues.map((v) => JSON.stringify(v)).join(" | ");
29
+ }
30
+ switch (s.type) {
31
+ case "string":
32
+ case "number":
33
+ case "integer":
34
+ case "boolean":
35
+ case "null":
36
+ return String(s.type);
37
+ case "array":
38
+ return renderArray(s);
39
+ case "object":
40
+ return renderObject(s);
41
+ default:
42
+ return undefined;
43
+ }
44
+ }
45
+ /**
46
+ * A tuple's `items` is absent (it uses `prefixItems`), and an opaque element type renders
47
+ * to nothing — either way there is no element sketch, so there is no array sketch.
48
+ * Propagating `undefined` here is what stops `see schema[]` being printed.
49
+ */
50
+ function renderArray(s) {
51
+ const items = render(s.items);
52
+ return items === undefined ? undefined : `${items}[]`;
53
+ }
54
+ function renderObject(s) {
55
+ const properties = s.properties;
56
+ if (typeof properties !== "object" || properties === null)
57
+ return undefined;
58
+ const required = new Set(Array.isArray(s.required) ? s.required : []);
59
+ const entries = Object.entries(properties);
60
+ if (entries.length === 0)
61
+ return undefined;
62
+ const fields = [];
63
+ for (const [key, value] of entries) {
64
+ const rendered = render(value);
65
+ // One field we cannot sketch makes the whole line untrustworthy: the reader has no way
66
+ // to tell the interpolated placeholder from a real type. Degrade the object instead, so
67
+ // the caller falls back to printing the schema the user actually has to satisfy.
68
+ if (rendered === undefined)
69
+ return undefined;
70
+ fields.push(`${key}${required.has(key) ? "" : "?"}: ${rendered}`);
71
+ }
72
+ return `{ ${fields.join(", ")} }`;
73
+ }