omkit 0.4.8 → 0.8.0
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/CHANGELOG.md +153 -0
- package/README.md +168 -12
- package/dist/actions/chrome-page.d.ts +41 -0
- package/dist/actions/chrome-page.js +77 -29
- package/dist/actions/drive-page.d.ts +50 -0
- package/dist/actions/drive-page.js +68 -0
- package/dist/actions/index.d.ts +5 -3
- package/dist/actions/index.js +2 -1
- package/dist/actions/prompt.d.ts +32 -5
- package/dist/actions/prompt.js +92 -8
- package/dist/cli/commands/help.js +6 -0
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/ls.d.ts +11 -4
- package/dist/cli/commands/ls.js +35 -15
- package/dist/cli/commands/mcp.d.ts +10 -0
- package/dist/cli/commands/mcp.js +13 -0
- package/dist/cli/commands/skill.d.ts +17 -0
- package/dist/cli/commands/skill.js +56 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +71 -29
- package/dist/client/discover-child.d.ts +12 -0
- package/dist/client/discover-child.js +71 -0
- package/dist/client/discovery.d.ts +21 -1
- package/dist/client/discovery.js +139 -2
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +3 -1
- package/dist/client/outline.d.ts +35 -0
- package/dist/client/outline.js +100 -0
- package/dist/client/registry.d.ts +59 -0
- package/dist/client/runner.d.ts +12 -0
- package/dist/client/runner.js +16 -7
- package/dist/client/types.d.ts +9 -1
- package/dist/core/ActionRun.d.ts +5 -0
- package/dist/core/ActionRun.js +9 -0
- package/dist/core/ExecutionTree.d.ts +40 -2
- package/dist/core/ExecutionTree.js +66 -2
- package/dist/core/action.d.ts +2 -1
- package/dist/core/action.js +93 -9
- package/dist/core/args.d.ts +50 -0
- package/dist/core/args.js +166 -0
- package/dist/core/discovery-mode.d.ts +37 -0
- package/dist/core/discovery-mode.js +54 -0
- package/dist/core/interaction.d.ts +3 -1
- package/dist/core/om.d.ts +11 -9
- package/dist/core/om.js +182 -13
- package/dist/core/schema-hint.d.ts +20 -0
- package/dist/core/schema-hint.js +73 -0
- package/dist/core/schema-json.d.ts +12 -0
- package/dist/core/schema-json.js +12 -0
- package/dist/core/types.d.ts +113 -1
- package/dist/foundation/LogEntry.d.ts +1 -1
- package/dist/foundation/canonicalPath.d.ts +26 -0
- package/dist/foundation/canonicalPath.js +34 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/action-host.d.ts +1 -0
- package/dist/mcp/action-host.js +65 -0
- package/dist/mcp/action-identity.d.ts +23 -0
- package/dist/mcp/action-identity.js +35 -0
- package/dist/mcp/progress.d.ts +61 -0
- package/dist/mcp/progress.js +92 -0
- package/dist/mcp/resources.d.ts +55 -0
- package/dist/mcp/resources.js +208 -0
- package/dist/mcp/runs.d.ts +37 -0
- package/dist/mcp/runs.js +60 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +38 -0
- package/dist/mcp/tail.d.ts +37 -0
- package/dist/mcp/tail.js +64 -0
- package/dist/mcp/tools.d.ts +54 -0
- package/dist/mcp/tools.js +374 -0
- package/dist/mcp/validate.d.ts +14 -0
- package/dist/mcp/validate.js +54 -0
- package/dist/mcp/verdict.d.ts +31 -0
- package/dist/mcp/verdict.js +47 -0
- package/dist/output/artifact/ArtifactStore.d.ts +24 -0
- package/dist/output/artifact/ArtifactStore.js +49 -0
- package/dist/output/folder/RunFolder.d.ts +23 -1
- package/dist/output/folder/RunFolder.js +55 -11
- package/dist/output/writers/views.d.ts +10 -0
- package/dist/skill/file.d.ts +26 -0
- package/dist/skill/file.js +58 -0
- package/dist/skill/index.d.ts +11 -0
- package/dist/skill/index.js +9 -0
- package/dist/skill/model.d.ts +55 -0
- package/dist/skill/model.js +116 -0
- package/dist/skill/render.d.ts +18 -0
- package/dist/skill/render.js +133 -0
- package/package.json +5 -2
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const typeName = (value) => Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
|
|
2
|
+
/** True when `value` satisfies a JSON Schema `type` keyword. Unknown types always pass. */
|
|
3
|
+
function matches(type, value) {
|
|
4
|
+
switch (type) {
|
|
5
|
+
case "string":
|
|
6
|
+
return typeof value === "string";
|
|
7
|
+
case "number":
|
|
8
|
+
return typeof value === "number";
|
|
9
|
+
case "integer":
|
|
10
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
11
|
+
case "boolean":
|
|
12
|
+
return typeof value === "boolean";
|
|
13
|
+
case "array":
|
|
14
|
+
return Array.isArray(value);
|
|
15
|
+
case "object":
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
default:
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Required fields the caller left out. `null` counts as supplied — and wrongly typed. */
|
|
22
|
+
function missingRequired(schema, args) {
|
|
23
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
24
|
+
return required
|
|
25
|
+
.filter((key) => args[key] === undefined)
|
|
26
|
+
.map((key) => `missing required argument "${key}"`);
|
|
27
|
+
}
|
|
28
|
+
/** Supplied fields whose value contradicts the schema's `type` keyword. */
|
|
29
|
+
function wrongTypes(schema, args) {
|
|
30
|
+
const properties = (schema.properties ?? {});
|
|
31
|
+
const problems = [];
|
|
32
|
+
for (const [key, definition] of Object.entries(properties)) {
|
|
33
|
+
const value = args[key];
|
|
34
|
+
if (value === undefined)
|
|
35
|
+
continue;
|
|
36
|
+
const type = definition?.type;
|
|
37
|
+
if (typeof type !== "string")
|
|
38
|
+
continue; // unions, $ref, anyOf — the child's problem
|
|
39
|
+
if (!matches(type, value)) {
|
|
40
|
+
problems.push(`argument "${key}" must be a ${type}, got ${typeName(value)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return problems;
|
|
44
|
+
}
|
|
45
|
+
/** Every problem found, in schema order. Empty means "worth spawning". */
|
|
46
|
+
export function checkArgs(schema, args) {
|
|
47
|
+
if (!schema)
|
|
48
|
+
return [];
|
|
49
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
50
|
+
return ["arguments must be a JSON object"];
|
|
51
|
+
}
|
|
52
|
+
const supplied = args;
|
|
53
|
+
return [...missingRequired(schema, supplied), ...wrongTypes(schema, supplied)];
|
|
54
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Verdict } from "../client/types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The structured outcome of a settled run — the verdict a client can act on.
|
|
4
|
+
*
|
|
5
|
+
* A `type`, not an `interface`, on purpose: the SDK types `structuredContent` as
|
|
6
|
+
* `{ [x: string]: unknown }`, and TypeScript gives an implicit index signature to type
|
|
7
|
+
* aliases but not to interfaces. As an interface this is not assignable and the tool
|
|
8
|
+
* cannot return it.
|
|
9
|
+
*/
|
|
10
|
+
export type RunVerdict = {
|
|
11
|
+
readonly ok: boolean;
|
|
12
|
+
/** Absolute path of the run folder, or "" when the child died before settling. */
|
|
13
|
+
readonly folder: string;
|
|
14
|
+
readonly assertions: {
|
|
15
|
+
readonly passed: number;
|
|
16
|
+
readonly failed: number;
|
|
17
|
+
};
|
|
18
|
+
readonly failures: ReadonlyArray<{
|
|
19
|
+
action: string;
|
|
20
|
+
error: string;
|
|
21
|
+
}>;
|
|
22
|
+
/** The recap block the terminal would have printed. */
|
|
23
|
+
readonly summary: readonly string[];
|
|
24
|
+
};
|
|
25
|
+
export declare function readVerdict(verdict: Verdict): Promise<RunVerdict>;
|
|
26
|
+
/**
|
|
27
|
+
* The verdict of a run this process never supervised — read entirely from disk. There is
|
|
28
|
+
* no channel to trust here, so `ok` comes from the file; a folder without a readable
|
|
29
|
+
* `result.json` is reported as not ok, because a run that never wrote one did not finish.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readVerdictAt(folder: string): Promise<RunVerdict>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/** The run's own `result.json`, or undefined when there isn't a readable one. */
|
|
4
|
+
async function readResult(folder) {
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(await readFile(path.join(folder, "result.json"), "utf8"));
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function readVerdict(verdict) {
|
|
13
|
+
const base = {
|
|
14
|
+
// The channel's `ok` wins over the file's: it also covers a child that died before
|
|
15
|
+
// writing one, which `result.json` by definition cannot report.
|
|
16
|
+
ok: verdict.ok,
|
|
17
|
+
folder: verdict.folder,
|
|
18
|
+
summary: verdict.summary,
|
|
19
|
+
assertions: { passed: 0, failed: 0 },
|
|
20
|
+
failures: [],
|
|
21
|
+
};
|
|
22
|
+
if (!verdict.folder)
|
|
23
|
+
return base;
|
|
24
|
+
const parsed = await readResult(verdict.folder);
|
|
25
|
+
if (!parsed)
|
|
26
|
+
return base;
|
|
27
|
+
return {
|
|
28
|
+
...base,
|
|
29
|
+
assertions: parsed.assertions ?? base.assertions,
|
|
30
|
+
failures: parsed.failures ?? base.failures,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The verdict of a run this process never supervised — read entirely from disk. There is
|
|
35
|
+
* no channel to trust here, so `ok` comes from the file; a folder without a readable
|
|
36
|
+
* `result.json` is reported as not ok, because a run that never wrote one did not finish.
|
|
37
|
+
*/
|
|
38
|
+
export async function readVerdictAt(folder) {
|
|
39
|
+
const parsed = await readResult(folder);
|
|
40
|
+
return {
|
|
41
|
+
ok: parsed?.ok ?? false,
|
|
42
|
+
folder,
|
|
43
|
+
summary: [],
|
|
44
|
+
assertions: parsed?.assertions ?? { passed: 0, failed: 0 },
|
|
45
|
+
failures: parsed?.failures ?? [],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** One curated artifact — a file the om chose to label. */
|
|
2
|
+
export interface ArtifactRecord {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
/** Absolute path to the file. */
|
|
5
|
+
readonly file: string;
|
|
6
|
+
readonly description?: string;
|
|
7
|
+
readonly mime: string;
|
|
8
|
+
/** The node that registered it. */
|
|
9
|
+
readonly nodeId: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The run's curated artifacts — the files an om labelled via `ctx.artifact`, in
|
|
13
|
+
* registration order. Holds no I/O: the om already wrote the file, this records what it
|
|
14
|
+
* means. Unregistered files stay in the run folder and stay listed by anything
|
|
15
|
+
* enumerating it; curation raises signal, it does not gate access.
|
|
16
|
+
*/
|
|
17
|
+
export declare class ArtifactStore {
|
|
18
|
+
private readonly records;
|
|
19
|
+
register(name: string, file: string, nodeId: string, opts?: {
|
|
20
|
+
description?: string;
|
|
21
|
+
mime?: string;
|
|
22
|
+
}): ArtifactRecord;
|
|
23
|
+
all(): readonly ArtifactRecord[];
|
|
24
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
const MIME_BY_EXT = {
|
|
3
|
+
".json": "application/json",
|
|
4
|
+
".jsonl": "application/x-ndjson",
|
|
5
|
+
".log": "text/plain",
|
|
6
|
+
".txt": "text/plain",
|
|
7
|
+
".md": "text/markdown",
|
|
8
|
+
".html": "text/html",
|
|
9
|
+
".csv": "text/csv",
|
|
10
|
+
".png": "image/png",
|
|
11
|
+
".jpg": "image/jpeg",
|
|
12
|
+
".jpeg": "image/jpeg",
|
|
13
|
+
".gif": "image/gif",
|
|
14
|
+
".webp": "image/webp",
|
|
15
|
+
".svg": "image/svg+xml",
|
|
16
|
+
".pdf": "application/pdf",
|
|
17
|
+
".zip": "application/zip",
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The run's curated artifacts — the files an om labelled via `ctx.artifact`, in
|
|
21
|
+
* registration order. Holds no I/O: the om already wrote the file, this records what it
|
|
22
|
+
* means. Unregistered files stay in the run folder and stay listed by anything
|
|
23
|
+
* enumerating it; curation raises signal, it does not gate access.
|
|
24
|
+
*/
|
|
25
|
+
export class ArtifactStore {
|
|
26
|
+
records = [];
|
|
27
|
+
register(name, file, nodeId, opts = {}) {
|
|
28
|
+
// `file` is documented absolute; a caller that passes a relative path still gets
|
|
29
|
+
// one back — resolved against process.cwd(), same base `path.resolve` uses by
|
|
30
|
+
// default — so every consumer (the return value, the timeline message, the
|
|
31
|
+
// on-disk rollups) carries a path that means the same thing without them.
|
|
32
|
+
const resolved = path.resolve(file);
|
|
33
|
+
const record = {
|
|
34
|
+
name,
|
|
35
|
+
file: resolved,
|
|
36
|
+
nodeId,
|
|
37
|
+
mime: opts.mime ?? mimeOf(resolved),
|
|
38
|
+
...(opts.description === undefined ? {} : { description: opts.description }),
|
|
39
|
+
};
|
|
40
|
+
this.records.push(record);
|
|
41
|
+
return record;
|
|
42
|
+
}
|
|
43
|
+
all() {
|
|
44
|
+
return this.records;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function mimeOf(file) {
|
|
48
|
+
return MIME_BY_EXT[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
49
|
+
}
|
|
@@ -12,10 +12,32 @@ export declare class RunFolder {
|
|
|
12
12
|
constructor(runName: string, hash: string);
|
|
13
13
|
/** `<name>-<hash8>` — the run's folder name, unique per (defining file, name). */
|
|
14
14
|
name(): string;
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* The absolute run directory — claimed on first access, stable thereafter.
|
|
17
|
+
*
|
|
18
|
+
* Claiming and knowing the path are the same act: the directory is what makes a run's
|
|
19
|
+
* identity exclusive, so there is no meaningful "what would the path be" to answer
|
|
20
|
+
* before it exists.
|
|
21
|
+
*/
|
|
16
22
|
path(): string;
|
|
17
23
|
/** Absolute path to `name` inside the run folder; ensures the folder exists. */
|
|
18
24
|
file(name: string): string;
|
|
19
25
|
/** Create the run folder synchronously (idempotent). */
|
|
20
26
|
ensure(): void;
|
|
27
|
+
/**
|
|
28
|
+
* Take exclusive ownership of a `<date>/<time>` directory.
|
|
29
|
+
*
|
|
30
|
+
* `hms` has second resolution, so two runs of the same om starting within one second
|
|
31
|
+
* would otherwise share a folder — and that is not merely "the later one wins":
|
|
32
|
+
* `RawStream` opens `raw.jsonl` with `flags: "w"`, so two live runs both truncate it and
|
|
33
|
+
* then write at their own offsets, leaving bytes that are not valid JSONL. The MCP
|
|
34
|
+
* server makes this easy to reach, since `start_om` can put two children in flight.
|
|
35
|
+
*
|
|
36
|
+
* Finer timestamps would only lower the odds. `mkdirSync` without `recursive` throws
|
|
37
|
+
* `EEXIST` **atomically**, and that atomicity holds between processes — so the directory
|
|
38
|
+
* creation is itself the lock. The common case keeps a clean `16-39-29`; only a real
|
|
39
|
+
* collision produces `16-39-29-02`, zero-padded so lexical order stays chronological
|
|
40
|
+
* (which is what `latest` relies on).
|
|
41
|
+
*/
|
|
42
|
+
private claim;
|
|
21
43
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { ymd, hms } from "../../foundation/format.js";
|
|
3
|
+
import { ymd, hms, pad } from "../../foundation/format.js";
|
|
4
4
|
import { fsSafe } from "../../foundation/fsSafe.js";
|
|
5
|
+
import { newUuid, shortId } from "../../foundation/ids.js";
|
|
6
|
+
/** Suffixed attempts (`-02` … `-99`) before falling back to a random, certainly-unique one. */
|
|
7
|
+
const MAX_NUMBERED_ATTEMPTS = 99;
|
|
5
8
|
/**
|
|
6
9
|
* Owns this run's single output directory — `logs/<name>-<hash>/<date>/<time>/` —
|
|
7
10
|
* where `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all
|
|
@@ -21,24 +24,65 @@ export class RunFolder {
|
|
|
21
24
|
name() {
|
|
22
25
|
return `${fsSafe(this.runName)}-${this.hash}`;
|
|
23
26
|
}
|
|
24
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* The absolute run directory — claimed on first access, stable thereafter.
|
|
29
|
+
*
|
|
30
|
+
* Claiming and knowing the path are the same act: the directory is what makes a run's
|
|
31
|
+
* identity exclusive, so there is no meaningful "what would the path be" to answer
|
|
32
|
+
* before it exists.
|
|
33
|
+
*/
|
|
25
34
|
path() {
|
|
26
|
-
if (this.dir)
|
|
27
|
-
|
|
28
|
-
const now = new Date();
|
|
29
|
-
this.dir = path.resolve("logs", this.name(), ymd(now), hms(now, "-"));
|
|
35
|
+
if (!this.dir)
|
|
36
|
+
this.claim();
|
|
30
37
|
return this.dir;
|
|
31
38
|
}
|
|
32
39
|
/** Absolute path to `name` inside the run folder; ensures the folder exists. */
|
|
33
40
|
file(name) {
|
|
34
|
-
this.ensure();
|
|
35
41
|
return path.join(this.path(), name);
|
|
36
42
|
}
|
|
37
43
|
/** Create the run folder synchronously (idempotent). */
|
|
38
44
|
ensure() {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
this.path();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Take exclusive ownership of a `<date>/<time>` directory.
|
|
49
|
+
*
|
|
50
|
+
* `hms` has second resolution, so two runs of the same om starting within one second
|
|
51
|
+
* would otherwise share a folder — and that is not merely "the later one wins":
|
|
52
|
+
* `RawStream` opens `raw.jsonl` with `flags: "w"`, so two live runs both truncate it and
|
|
53
|
+
* then write at their own offsets, leaving bytes that are not valid JSONL. The MCP
|
|
54
|
+
* server makes this easy to reach, since `start_om` can put two children in flight.
|
|
55
|
+
*
|
|
56
|
+
* Finer timestamps would only lower the odds. `mkdirSync` without `recursive` throws
|
|
57
|
+
* `EEXIST` **atomically**, and that atomicity holds between processes — so the directory
|
|
58
|
+
* creation is itself the lock. The common case keeps a clean `16-39-29`; only a real
|
|
59
|
+
* collision produces `16-39-29-02`, zero-padded so lexical order stays chronological
|
|
60
|
+
* (which is what `latest` relies on).
|
|
61
|
+
*/
|
|
62
|
+
claim() {
|
|
63
|
+
const now = new Date();
|
|
64
|
+
const parent = path.resolve("logs", this.name(), ymd(now));
|
|
65
|
+
mkdirSync(parent, { recursive: true });
|
|
66
|
+
const base = hms(now, "-");
|
|
67
|
+
for (let attempt = 0; attempt <= MAX_NUMBERED_ATTEMPTS; attempt += 1) {
|
|
68
|
+
// A random suffix as the last resort: bounded loops beat an unbounded one, and a
|
|
69
|
+
// hundred collisions inside one second means something stranger is going on.
|
|
70
|
+
const suffix = attempt === 0
|
|
71
|
+
? ""
|
|
72
|
+
: attempt === MAX_NUMBERED_ATTEMPTS
|
|
73
|
+
? `-${shortId(newUuid())}`
|
|
74
|
+
: `-${pad(attempt + 1)}`;
|
|
75
|
+
const candidate = path.join(parent, `${base}${suffix}`);
|
|
76
|
+
try {
|
|
77
|
+
mkdirSync(candidate); // deliberately not recursive — EEXIST is the lock
|
|
78
|
+
this.dir = candidate;
|
|
79
|
+
this.ensured = true;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
if (e.code !== "EEXIST")
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
43
87
|
}
|
|
44
88
|
}
|
|
@@ -21,6 +21,14 @@ export interface NodeView {
|
|
|
21
21
|
readonly logFile: string;
|
|
22
22
|
readonly children: readonly NodeView[];
|
|
23
23
|
}
|
|
24
|
+
/** A curated artifact, as projected into `result.json`. Data-only — no core import. */
|
|
25
|
+
export interface ArtifactView {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
/** Absolute path to the file. */
|
|
28
|
+
readonly file: string;
|
|
29
|
+
readonly description?: string;
|
|
30
|
+
readonly mime: string;
|
|
31
|
+
}
|
|
24
32
|
export interface RunView {
|
|
25
33
|
readonly ok: boolean;
|
|
26
34
|
readonly failures: ReadonlyArray<{
|
|
@@ -37,4 +45,6 @@ export interface RunView {
|
|
|
37
45
|
/** Absolute path to `raw.jsonl`. */
|
|
38
46
|
readonly rawStream: string;
|
|
39
47
|
readonly root: NodeView;
|
|
48
|
+
/** Files the run labelled via `ctx.artifact`, in registration order. */
|
|
49
|
+
readonly artifacts: readonly ArtifactView[];
|
|
40
50
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Where the skill lands, relative to the project root that owns the tsconfig. */
|
|
2
|
+
export declare const SKILL_RELATIVE_PATH: string;
|
|
3
|
+
/**
|
|
4
|
+
* The `description` written into a file that does not exist yet.
|
|
5
|
+
*
|
|
6
|
+
* This field is the skill's loading trigger, so its wording decides whether an assistant reads
|
|
7
|
+
* the file at the right moment. A generated summary of contents ("lists 2 oms…") would be the
|
|
8
|
+
* wrong shape — the field has to say *when to use this*. So it is authored, and this is only
|
|
9
|
+
* the starting point: {@link readExisting} preserves whatever the author changes it to.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DEFAULT_DESCRIPTION = "Runnable workflows in this repo (oms and actions) \u2014 what each does, its arguments, and how to run it. Load before running or debugging any project workflow.";
|
|
12
|
+
/** What a previously generated file still has to say for itself. */
|
|
13
|
+
export interface ExistingSkill {
|
|
14
|
+
/** The `description:` value, verbatim — continuation lines and all. */
|
|
15
|
+
readonly description?: string;
|
|
16
|
+
/** The `registry-hash` from the generated-by marker, when the file carries one. */
|
|
17
|
+
readonly hash?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Read back the two things a regeneration must not lose: the authored description, and the
|
|
21
|
+
* hash to compare against. A missing or unreadable file is not an error — it is the "generate
|
|
22
|
+
* fresh" case, and returns an empty result.
|
|
23
|
+
*/
|
|
24
|
+
export declare function readExisting(file: string): ExistingSkill;
|
|
25
|
+
/** Write the skill, creating its directory. The parent may not exist on a first generation. */
|
|
26
|
+
export declare function writeSkill(file: string, content: string): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SKILL_NAME } from "./render.js";
|
|
4
|
+
/** Where the skill lands, relative to the project root that owns the tsconfig. */
|
|
5
|
+
export const SKILL_RELATIVE_PATH = path.join(".claude", "skills", SKILL_NAME, "SKILL.md");
|
|
6
|
+
/**
|
|
7
|
+
* The `description` written into a file that does not exist yet.
|
|
8
|
+
*
|
|
9
|
+
* This field is the skill's loading trigger, so its wording decides whether an assistant reads
|
|
10
|
+
* the file at the right moment. A generated summary of contents ("lists 2 oms…") would be the
|
|
11
|
+
* wrong shape — the field has to say *when to use this*. So it is authored, and this is only
|
|
12
|
+
* the starting point: {@link readExisting} preserves whatever the author changes it to.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_DESCRIPTION = "Runnable workflows in this repo (oms and actions) — what each does, its arguments, and how to run it. Load before running or debugging any project workflow.";
|
|
15
|
+
/**
|
|
16
|
+
* Read back the two things a regeneration must not lose: the authored description, and the
|
|
17
|
+
* hash to compare against. A missing or unreadable file is not an error — it is the "generate
|
|
18
|
+
* fresh" case, and returns an empty result.
|
|
19
|
+
*/
|
|
20
|
+
export function readExisting(file) {
|
|
21
|
+
let text;
|
|
22
|
+
try {
|
|
23
|
+
text = fs.readFileSync(file, "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
const hash = /registry-hash:\s*([0-9a-f]{8})/.exec(text)?.[1];
|
|
29
|
+
const description = frontmatterDescription(text);
|
|
30
|
+
return { ...(description !== undefined ? { description } : {}), ...(hash ? { hash } : {}) };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Pull `description` out of the leading frontmatter, keeping any folded continuation lines
|
|
34
|
+
* exactly as written — they are re-emitted verbatim, so a wrapped description survives
|
|
35
|
+
* regeneration byte-for-byte instead of being reflowed into one long line.
|
|
36
|
+
*/
|
|
37
|
+
function frontmatterDescription(text) {
|
|
38
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1];
|
|
39
|
+
if (block === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
const lines = block.split(/\r?\n/);
|
|
42
|
+
const start = lines.findIndex((l) => l.startsWith("description:"));
|
|
43
|
+
if (start === -1)
|
|
44
|
+
return undefined;
|
|
45
|
+
const value = [lines[start].slice("description:".length).trimStart()];
|
|
46
|
+
// A continuation is any indented line; the next unindented line starts a new key.
|
|
47
|
+
for (const line of lines.slice(start + 1)) {
|
|
48
|
+
if (!/^\s+\S/.test(line))
|
|
49
|
+
break;
|
|
50
|
+
value.push(line);
|
|
51
|
+
}
|
|
52
|
+
return value.join("\n");
|
|
53
|
+
}
|
|
54
|
+
/** Write the skill, creating its directory. The parent may not exist on a first generation. */
|
|
55
|
+
export function writeSkill(file, content) {
|
|
56
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
57
|
+
fs.writeFileSync(file, content, "utf8");
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated-skill layer: turn a project's registrations into `.claude/skills/omkit-runs/`.
|
|
3
|
+
*
|
|
4
|
+
* Split so that everything except {@link module:./file.ts} is pure — the model and the renderer
|
|
5
|
+
* take data and return data, which is what makes determinism testable without touching a disk.
|
|
6
|
+
*/
|
|
7
|
+
export { buildSkillModel, registryHash } from "./model.ts";
|
|
8
|
+
export type { SkillCall, SkillEntry, SkillModel } from "./model.ts";
|
|
9
|
+
export { renderSkill, generatedBy, SKILL_NAME } from "./render.ts";
|
|
10
|
+
export { readExisting, writeSkill, DEFAULT_DESCRIPTION, SKILL_RELATIVE_PATH } from "./file.ts";
|
|
11
|
+
export type { ExistingSkill } from "./file.ts";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated-skill layer: turn a project's registrations into `.claude/skills/omkit-runs/`.
|
|
3
|
+
*
|
|
4
|
+
* Split so that everything except {@link module:./file.ts} is pure — the model and the renderer
|
|
5
|
+
* take data and return data, which is what makes determinism testable without touching a disk.
|
|
6
|
+
*/
|
|
7
|
+
export { buildSkillModel, registryHash } from "./model.js";
|
|
8
|
+
export { renderSkill, generatedBy, SKILL_NAME } from "./render.js";
|
|
9
|
+
export { readExisting, writeSkill, DEFAULT_DESCRIPTION, SKILL_RELATIVE_PATH } from "./file.js";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { McpMode } from "../core/types.ts";
|
|
2
|
+
import type { JsonSchema } from "../core/schema-json.ts";
|
|
3
|
+
import type { OmCall, RegistrationSet, Registry } from "../client/registry.ts";
|
|
4
|
+
/** One step in a rendered outline. Structurally an {@link OmCall}; named for the skill's vocabulary. */
|
|
5
|
+
export type SkillCall = OmCall;
|
|
6
|
+
/** One runnable thing the generated skill documents. */
|
|
7
|
+
export interface SkillEntry {
|
|
8
|
+
readonly kind: "om" | "action";
|
|
9
|
+
readonly name: string;
|
|
10
|
+
/** Project-relative, forward slashes on every platform — so the file does not churn per-OS. */
|
|
11
|
+
readonly file: string;
|
|
12
|
+
readonly mode: McpMode;
|
|
13
|
+
/** The `.describe({ summary })` line. Absent when the author wrote none; never invented. */
|
|
14
|
+
readonly summary?: string;
|
|
15
|
+
/** A one-line sketch of the declared args, from {@link shapeHint}. */
|
|
16
|
+
readonly argHint?: string;
|
|
17
|
+
/** The raw schema, present only when `shapeHint` could not sketch it (per C7). */
|
|
18
|
+
readonly argSchema?: JsonSchema;
|
|
19
|
+
/** The static outline of the body. Absent when the walk found nothing recognisable. */
|
|
20
|
+
readonly calls?: readonly SkillCall[];
|
|
21
|
+
/** Why this entry cannot be called — carried through from discovery, rendered as a warning. */
|
|
22
|
+
readonly unavailable?: string;
|
|
23
|
+
/** Actions only: the exported binding a host om imports. */
|
|
24
|
+
readonly exportName?: string;
|
|
25
|
+
/** Actions only: true when the chain includes `.ref<…>()`, so it publishes a capability. */
|
|
26
|
+
readonly publishesCapability?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Everything the renderer needs, plus the hash that detects drift against it. */
|
|
29
|
+
export interface SkillModel {
|
|
30
|
+
readonly oms: readonly SkillEntry[];
|
|
31
|
+
readonly actions: readonly SkillEntry[];
|
|
32
|
+
/** First 8 hex of sha256 over the registry data below — never over the rendered markdown. */
|
|
33
|
+
readonly hash: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Fold a project's registrations and its AST scan into the model the skill renders from.
|
|
37
|
+
*
|
|
38
|
+
* The two inputs answer different halves: the fork knows summaries, modes, and arg schemas
|
|
39
|
+
* (values that only exist once a module has been evaluated), while the AST scan knows the
|
|
40
|
+
* body outline and whether an action publishes a capability. Neither alone is enough.
|
|
41
|
+
*
|
|
42
|
+
* Only `.mcp()`-marked entries survive — exposure is opt-in, exactly as it is for the MCP
|
|
43
|
+
* server, so generating a skill can never widen what an assistant will attempt.
|
|
44
|
+
*/
|
|
45
|
+
export declare function buildSkillModel(registrations: RegistrationSet, registry: Registry, root: string): SkillModel;
|
|
46
|
+
/**
|
|
47
|
+
* The staleness hash: first 8 hex of sha256 over the registry data, deliberately **not** over
|
|
48
|
+
* the rendered markdown, so reformatting the file does not read as drift.
|
|
49
|
+
*
|
|
50
|
+
* The outline is included even though the spec's list of inputs predates it: an edited om body
|
|
51
|
+
* changes what the file claims the run does, and leaving that undetected is exactly the silent
|
|
52
|
+
* drift the hash exists to catch. Keys are sorted before hashing so a schema serialised in a
|
|
53
|
+
* different property order cannot masquerade as a change.
|
|
54
|
+
*/
|
|
55
|
+
export declare function registryHash(entries: readonly SkillEntry[]): string;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { shapeHint } from "../core/schema-hint.js";
|
|
4
|
+
/**
|
|
5
|
+
* Fold a project's registrations and its AST scan into the model the skill renders from.
|
|
6
|
+
*
|
|
7
|
+
* The two inputs answer different halves: the fork knows summaries, modes, and arg schemas
|
|
8
|
+
* (values that only exist once a module has been evaluated), while the AST scan knows the
|
|
9
|
+
* body outline and whether an action publishes a capability. Neither alone is enough.
|
|
10
|
+
*
|
|
11
|
+
* Only `.mcp()`-marked entries survive — exposure is opt-in, exactly as it is for the MCP
|
|
12
|
+
* server, so generating a skill can never widen what an assistant will attempt.
|
|
13
|
+
*/
|
|
14
|
+
export function buildSkillModel(registrations, registry, root) {
|
|
15
|
+
const outlines = new Map(registry.oms.map((o) => [key(o.name, o.file), o.calls]));
|
|
16
|
+
const capabilities = new Map(registry.actions.map((a) => [key(a.name, a.file), a.publishesCapability]));
|
|
17
|
+
const oms = registrations.oms
|
|
18
|
+
.filter(isExposed)
|
|
19
|
+
.map((om) => omEntry(om, outlines.get(key(om.name, om.file)), root))
|
|
20
|
+
.sort(byName);
|
|
21
|
+
const actions = registrations.actions
|
|
22
|
+
.filter(isExposed)
|
|
23
|
+
.map((a) => actionEntry(a, capabilities.get(key(a.name, a.file)), root))
|
|
24
|
+
.sort(byName);
|
|
25
|
+
return { oms, actions, hash: registryHash([...oms, ...actions]) };
|
|
26
|
+
}
|
|
27
|
+
function isExposed(r) {
|
|
28
|
+
return r.mcp !== undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Identity for cross-referencing the two discovery passes: an om is its name in its file. */
|
|
31
|
+
function key(name, file) {
|
|
32
|
+
return `${name}\0${path.resolve(file)}`;
|
|
33
|
+
}
|
|
34
|
+
/** Alphabetical by name, then by file — so two same-named oms in different files stay ordered. */
|
|
35
|
+
function byName(a, b) {
|
|
36
|
+
return a.name.localeCompare(b.name) || a.file.localeCompare(b.file);
|
|
37
|
+
}
|
|
38
|
+
function omEntry(om, calls, root) {
|
|
39
|
+
return {
|
|
40
|
+
kind: "om",
|
|
41
|
+
name: om.name,
|
|
42
|
+
file: relative(om.file, root),
|
|
43
|
+
mode: om.mcp?.mode ?? "settling",
|
|
44
|
+
...describeArgs(om.inputSchema),
|
|
45
|
+
...(om.summary ? { summary: om.summary } : {}),
|
|
46
|
+
...(calls?.length ? { calls } : {}),
|
|
47
|
+
...(om.unavailable ? { unavailable: om.unavailable } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function actionEntry(action, publishesCapability, root) {
|
|
51
|
+
return {
|
|
52
|
+
kind: "action",
|
|
53
|
+
name: action.name,
|
|
54
|
+
file: relative(action.file, root),
|
|
55
|
+
mode: action.mcp?.mode ?? "settling",
|
|
56
|
+
exportName: action.exportName,
|
|
57
|
+
...(publishesCapability ? { publishesCapability: true } : {}),
|
|
58
|
+
...describeArgs(action.inputSchema),
|
|
59
|
+
...(action.summary ? { summary: action.summary } : {}),
|
|
60
|
+
...(action.unavailable ? { unavailable: action.unavailable } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Sketch a schema, falling back to the schema itself. `shapeHint` is all-or-nothing by design,
|
|
65
|
+
* so a `undefined` return means the shape would have been misleading — the honest answer then
|
|
66
|
+
* is the raw JSON Schema the caller actually has to satisfy, not a partial sketch.
|
|
67
|
+
*/
|
|
68
|
+
function describeArgs(schema) {
|
|
69
|
+
if (!schema)
|
|
70
|
+
return {};
|
|
71
|
+
const hint = shapeHint(schema);
|
|
72
|
+
return hint === undefined ? { argSchema: schema } : { argHint: hint };
|
|
73
|
+
}
|
|
74
|
+
/** Project-relative with forward slashes; absolute only if the file lies outside the root. */
|
|
75
|
+
function relative(file, root) {
|
|
76
|
+
const rel = path.relative(root, file);
|
|
77
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
|
|
78
|
+
return posix(file);
|
|
79
|
+
return posix(rel);
|
|
80
|
+
}
|
|
81
|
+
const posix = (p) => p.split(path.sep).join("/").split("\\").join("/");
|
|
82
|
+
/**
|
|
83
|
+
* The staleness hash: first 8 hex of sha256 over the registry data, deliberately **not** over
|
|
84
|
+
* the rendered markdown, so reformatting the file does not read as drift.
|
|
85
|
+
*
|
|
86
|
+
* The outline is included even though the spec's list of inputs predates it: an edited om body
|
|
87
|
+
* changes what the file claims the run does, and leaving that undetected is exactly the silent
|
|
88
|
+
* drift the hash exists to catch. Keys are sorted before hashing so a schema serialised in a
|
|
89
|
+
* different property order cannot masquerade as a change.
|
|
90
|
+
*/
|
|
91
|
+
export function registryHash(entries) {
|
|
92
|
+
const material = entries.map((e) => [
|
|
93
|
+
e.kind,
|
|
94
|
+
e.name,
|
|
95
|
+
e.file,
|
|
96
|
+
e.mode,
|
|
97
|
+
e.summary ?? "",
|
|
98
|
+
e.exportName ?? "",
|
|
99
|
+
e.unavailable ?? "",
|
|
100
|
+
e.publishesCapability === true,
|
|
101
|
+
e.argSchema ?? e.argHint ?? "",
|
|
102
|
+
(e.calls ?? []).map((c) => [c.name, c.tag ?? ""]),
|
|
103
|
+
]);
|
|
104
|
+
return createHash("sha256").update(canonical(material)).digest("hex").slice(0, 8);
|
|
105
|
+
}
|
|
106
|
+
/** JSON with object keys sorted at every depth, so key order can never change the hash. */
|
|
107
|
+
function canonical(value) {
|
|
108
|
+
if (value === null || typeof value !== "object")
|
|
109
|
+
return JSON.stringify(value) ?? "null";
|
|
110
|
+
if (Array.isArray(value))
|
|
111
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
112
|
+
const entries = Object.entries(value)
|
|
113
|
+
.filter(([, v]) => v !== undefined)
|
|
114
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
115
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
|
|
116
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SkillModel } from "./model.ts";
|
|
2
|
+
/** The skill's directory name, and so the `name:` in its frontmatter. */
|
|
3
|
+
export declare const SKILL_NAME = "omkit-runs";
|
|
4
|
+
/**
|
|
5
|
+
* The marker line that carries the staleness hash. Written by {@link renderSkill} and read
|
|
6
|
+
* back by the file layer, so the two agree on one format rather than two regexes.
|
|
7
|
+
*/
|
|
8
|
+
export declare const generatedBy: (hash: string) => string;
|
|
9
|
+
/**
|
|
10
|
+
* Render the model as the skill's markdown.
|
|
11
|
+
*
|
|
12
|
+
* Deterministic by construction: no timestamps, no absolute paths, no iteration over unordered
|
|
13
|
+
* collections — the model arrives sorted. A generated file that is committed and churns on
|
|
14
|
+
* every run is a file people stop reading.
|
|
15
|
+
*/
|
|
16
|
+
export declare function renderSkill(model: SkillModel, opts: {
|
|
17
|
+
description: string;
|
|
18
|
+
}): string;
|