omkit 0.4.7 → 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 +170 -13
- package/dist/actions/browser.d.ts +1 -1
- package/dist/actions/browser.js +1 -1
- package/dist/actions/chrome-page.d.ts +46 -4
- package/dist/actions/chrome-page.js +81 -33
- package/dist/actions/drive-page.d.ts +50 -0
- package/dist/actions/drive-page.js +68 -0
- package/dist/actions/index.d.ts +7 -3
- package/dist/actions/index.js +3 -1
- package/dist/actions/port-free.d.ts +29 -0
- package/dist/actions/port-free.js +78 -0
- 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,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The name the host om runs under: `<action>@<hash8 of the action's defining file>`.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the host (which passes it to `om(...)`) and the server (which has to predict
|
|
5
|
+
* the resulting folder before the run exists). One function so the two cannot drift —
|
|
6
|
+
* they are the same rule applied from opposite sides of a fork.
|
|
7
|
+
*/
|
|
8
|
+
export declare function hostOmName(action: string, actionFile: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Absolute path of the shipped action host, with the extension this build actually uses —
|
|
11
|
+
* `.ts` under tsx and vitest, `.js` once compiled. `rewriteRelativeImportExtensions`
|
|
12
|
+
* rewrites import specifiers; this path is data (a fork target), so it does not.
|
|
13
|
+
*/
|
|
14
|
+
export declare function hostFile(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Where a hosted action's runs land: `<fsSafe(hostName)>-<hash8>`.
|
|
17
|
+
*
|
|
18
|
+
* Derived rather than observed, because `start_om` has to answer before the child has
|
|
19
|
+
* created anything. It reproduces `RunFolder.name()` exactly — including `fsSafe`, which
|
|
20
|
+
* collapses the `@` to a `-`, so the folder reads `seed-a1b2c3d4-<hash8>`. A test pins
|
|
21
|
+
* this against a real run: if the derivation ever drifts from the runtime, it fails.
|
|
22
|
+
*/
|
|
23
|
+
export declare function hostFolderName(action: string, actionFile: string): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { omHash } from "../foundation/ids.js";
|
|
4
|
+
import { fsSafe } from "../foundation/fsSafe.js";
|
|
5
|
+
/**
|
|
6
|
+
* The name the host om runs under: `<action>@<hash8 of the action's defining file>`.
|
|
7
|
+
*
|
|
8
|
+
* Shared by the host (which passes it to `om(...)`) and the server (which has to predict
|
|
9
|
+
* the resulting folder before the run exists). One function so the two cannot drift —
|
|
10
|
+
* they are the same rule applied from opposite sides of a fork.
|
|
11
|
+
*/
|
|
12
|
+
export function hostOmName(action, actionFile) {
|
|
13
|
+
return `${action}@${omHash(action, actionFile)}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Absolute path of the shipped action host, with the extension this build actually uses —
|
|
17
|
+
* `.ts` under tsx and vitest, `.js` once compiled. `rewriteRelativeImportExtensions`
|
|
18
|
+
* rewrites import specifiers; this path is data (a fork target), so it does not.
|
|
19
|
+
*/
|
|
20
|
+
export function hostFile() {
|
|
21
|
+
const here = fileURLToPath(import.meta.url);
|
|
22
|
+
return path.join(path.dirname(here), `action-host${path.extname(here)}`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Where a hosted action's runs land: `<fsSafe(hostName)>-<hash8>`.
|
|
26
|
+
*
|
|
27
|
+
* Derived rather than observed, because `start_om` has to answer before the child has
|
|
28
|
+
* created anything. It reproduces `RunFolder.name()` exactly — including `fsSafe`, which
|
|
29
|
+
* collapses the `@` to a `-`, so the folder reads `seed-a1b2c3d4-<hash8>`. A test pins
|
|
30
|
+
* this against a real run: if the derivation ever drifts from the runtime, it fails.
|
|
31
|
+
*/
|
|
32
|
+
export function hostFolderName(action, actionFile) {
|
|
33
|
+
const hostName = hostOmName(action, actionFile);
|
|
34
|
+
return `${fsSafe(hostName)}-${omHash(hostName, hostFile())}`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { LogEntry } from "../foundation/LogEntry.ts";
|
|
3
|
+
import type { PromptSpec } from "../core/interaction.ts";
|
|
4
|
+
import type { RunSession } from "../client/types.ts";
|
|
5
|
+
export declare function isMilestone(entry: LogEntry): boolean;
|
|
6
|
+
/** The slice of the SDK's request context this module needs — narrow, so tests can fake it. */
|
|
7
|
+
export interface ProgressExtra {
|
|
8
|
+
readonly signal: AbortSignal;
|
|
9
|
+
readonly _meta?: {
|
|
10
|
+
progressToken?: string | number;
|
|
11
|
+
};
|
|
12
|
+
readonly sendNotification: (n: {
|
|
13
|
+
method: "notifications/progress";
|
|
14
|
+
params: {
|
|
15
|
+
progressToken: string | number;
|
|
16
|
+
progress: number;
|
|
17
|
+
message?: string;
|
|
18
|
+
};
|
|
19
|
+
}) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Wire a live run to the request that started it: milestones become progress
|
|
23
|
+
* notifications, and the client cancelling the request tears the run down.
|
|
24
|
+
*
|
|
25
|
+
* `progress` is the log store's run-global sequence, which is monotonic by construction —
|
|
26
|
+
* exactly what the spec requires, with no counter to keep. `total` is omitted, which the
|
|
27
|
+
* spec permits, because a run does not know how many steps it has left.
|
|
28
|
+
*
|
|
29
|
+
* Progress is a courtesy, not a deadline extension: whether a client resets its timeout on
|
|
30
|
+
* progress is implementation-dependent, so a long run still belongs in `start_om`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function attachProgress(session: RunSession, extra: ProgressExtra): void;
|
|
33
|
+
/** MCP's elicitation schema: top-level primitives only, no nesting. */
|
|
34
|
+
export interface RequestedSchema {
|
|
35
|
+
type: "object";
|
|
36
|
+
properties: Record<string, {
|
|
37
|
+
type: "string";
|
|
38
|
+
enum?: string[];
|
|
39
|
+
description: string;
|
|
40
|
+
}>;
|
|
41
|
+
required: string[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Turn one of omkit's prompt specs into an elicitation request. All three kinds become a
|
|
45
|
+
* single `value` string: a choice narrows it with `enum` so the client cannot answer
|
|
46
|
+
* invalidly, and a multiline prompt stays a string because MCP's restricted schema has
|
|
47
|
+
* nowhere to say "several lines".
|
|
48
|
+
*/
|
|
49
|
+
export declare function toRequestedSchema(spec: PromptSpec): RequestedSchema;
|
|
50
|
+
/**
|
|
51
|
+
* Forward the run's prompts to the client as elicitations, and its answers back.
|
|
52
|
+
*
|
|
53
|
+
* prompt.ts already describes its supervisor as "whoever owns the terminal (Ink app / MCP
|
|
54
|
+
* client)" — this is that. A declined or cancelled elicitation answers with the prompt's
|
|
55
|
+
* own default rather than leaving the run to time out three times over: the run has to
|
|
56
|
+
* make progress either way, and the default is what a timeout would have produced.
|
|
57
|
+
*
|
|
58
|
+
* `promptDone` means the child gave up first; the in-flight elicitation is aborted so the
|
|
59
|
+
* client's dialog does not linger with nothing behind it.
|
|
60
|
+
*/
|
|
61
|
+
export declare function attachElicitation(server: McpServer, session: RunSession): void;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which log levels count as "something happened". Forwarding every entry would turn a
|
|
3
|
+
* chatty run into a notification flood; these are the levels that mark a step, an event,
|
|
4
|
+
* an assertion, a failure, or a file — the same shape the terminal's live renderer treats
|
|
5
|
+
* as worth showing.
|
|
6
|
+
*/
|
|
7
|
+
const MILESTONE_LEVELS = new Set(["run", "event", "assert", "error", "artifact", "child"]);
|
|
8
|
+
export function isMilestone(entry) {
|
|
9
|
+
return MILESTONE_LEVELS.has(entry.level);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Wire a live run to the request that started it: milestones become progress
|
|
13
|
+
* notifications, and the client cancelling the request tears the run down.
|
|
14
|
+
*
|
|
15
|
+
* `progress` is the log store's run-global sequence, which is monotonic by construction —
|
|
16
|
+
* exactly what the spec requires, with no counter to keep. `total` is omitted, which the
|
|
17
|
+
* spec permits, because a run does not know how many steps it has left.
|
|
18
|
+
*
|
|
19
|
+
* Progress is a courtesy, not a deadline extension: whether a client resets its timeout on
|
|
20
|
+
* progress is implementation-dependent, so a long run still belongs in `start_om`.
|
|
21
|
+
*/
|
|
22
|
+
export function attachProgress(session, extra) {
|
|
23
|
+
const token = extra._meta?.progressToken;
|
|
24
|
+
if (token !== undefined) {
|
|
25
|
+
session.on("log", (entry) => {
|
|
26
|
+
if (!isMilestone(entry))
|
|
27
|
+
return;
|
|
28
|
+
void extra
|
|
29
|
+
.sendNotification({
|
|
30
|
+
method: "notifications/progress",
|
|
31
|
+
params: { progressToken: token, progress: entry.sequence, message: entry.message },
|
|
32
|
+
})
|
|
33
|
+
.catch(() => {
|
|
34
|
+
// The client went away mid-run. It is cancelled through `signal` anyway; a
|
|
35
|
+
// rejected notification must not fault the run itself.
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
extra.signal.addEventListener("abort", () => session.cancel(), { once: true });
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Turn one of omkit's prompt specs into an elicitation request. All three kinds become a
|
|
43
|
+
* single `value` string: a choice narrows it with `enum` so the client cannot answer
|
|
44
|
+
* invalidly, and a multiline prompt stays a string because MCP's restricted schema has
|
|
45
|
+
* nowhere to say "several lines".
|
|
46
|
+
*/
|
|
47
|
+
export function toRequestedSchema(spec) {
|
|
48
|
+
const description = spec.hint ? `${spec.message} — ${spec.hint}` : spec.message;
|
|
49
|
+
const value = spec.kind === "choice" && spec.choices
|
|
50
|
+
? { type: "string", enum: spec.choices.map((c) => c.value), description }
|
|
51
|
+
: { type: "string", description };
|
|
52
|
+
return { type: "object", properties: { value }, required: ["value"] };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Forward the run's prompts to the client as elicitations, and its answers back.
|
|
56
|
+
*
|
|
57
|
+
* prompt.ts already describes its supervisor as "whoever owns the terminal (Ink app / MCP
|
|
58
|
+
* client)" — this is that. A declined or cancelled elicitation answers with the prompt's
|
|
59
|
+
* own default rather than leaving the run to time out three times over: the run has to
|
|
60
|
+
* make progress either way, and the default is what a timeout would have produced.
|
|
61
|
+
*
|
|
62
|
+
* `promptDone` means the child gave up first; the in-flight elicitation is aborted so the
|
|
63
|
+
* client's dialog does not linger with nothing behind it.
|
|
64
|
+
*/
|
|
65
|
+
export function attachElicitation(server, session) {
|
|
66
|
+
const pending = new Map();
|
|
67
|
+
session.on("promptDone", (id) => {
|
|
68
|
+
pending.get(id)?.abort();
|
|
69
|
+
pending.delete(id);
|
|
70
|
+
});
|
|
71
|
+
session.on("prompt", (request) => {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
pending.set(request.id, controller);
|
|
74
|
+
void (async () => {
|
|
75
|
+
try {
|
|
76
|
+
const result = await server.server.elicitInput({ message: request.spec.message, requestedSchema: toRequestedSchema(request.spec) }, { signal: controller.signal });
|
|
77
|
+
const answer = result.content?.value;
|
|
78
|
+
const value = result.action === "accept" && typeof answer === "string" ? answer : request.spec.default;
|
|
79
|
+
session.answer(request.id, value, result.action === "accept" ? "elicitation" : "default");
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Aborted, or a client with no elicitation capability. Either way the run must not
|
|
83
|
+
// hang on us: hand it the default and let it carry on.
|
|
84
|
+
if (!controller.signal.aborted)
|
|
85
|
+
session.answer(request.id, request.spec.default, "default");
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
pending.delete(request.id);
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
export declare function mimeOf(file: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a path and refuse anything that is not strictly inside `<root>/logs`.
|
|
5
|
+
*
|
|
6
|
+
* URIs are untrusted input — a model can propose any string — so this runs on every read,
|
|
7
|
+
* not just at the template. Two checks, because `..` and a symlink escape by different
|
|
8
|
+
* routes: the lexical one catches `logs/../secret`, and the realpath one catches
|
|
9
|
+
* `logs/link → /etc`. `logs/` itself is refused: it is the root, not a run.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveUnderLogs(root: string, target: string): string | undefined;
|
|
12
|
+
/** A run folder the caller named directly (a path from `get_run`, or one a client kept). */
|
|
13
|
+
export declare function resolveRunFolder(root: string, candidate: string): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* The newest run of one om. Folder names are `<date>/<time>` with zero-padded, fixed-width
|
|
16
|
+
* components (`2026-08-04/11-30-00`), so lexical sort is chronological sort — no parsing,
|
|
17
|
+
* and no dependence on filesystem mtime.
|
|
18
|
+
*
|
|
19
|
+
* This is what the run-folder-identity constraint is *for*: "a script or an assistant can
|
|
20
|
+
* always find the latest run of X without parsing scrollback".
|
|
21
|
+
*/
|
|
22
|
+
export declare function latestRun(root: string, folderName: string): string | undefined;
|
|
23
|
+
export interface RunFileRead {
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly mimeType: string;
|
|
26
|
+
readonly text?: string;
|
|
27
|
+
readonly blob?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Read one file from a run folder, confined, size-capped, and typed by extension. */
|
|
30
|
+
export declare function readRunFile(root: string, segments: string[]): RunFileRead | undefined;
|
|
31
|
+
export interface ResourceLink {
|
|
32
|
+
readonly type: "resource_link";
|
|
33
|
+
readonly uri: string;
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly description?: string;
|
|
36
|
+
readonly mimeType: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The run's files as `resource_link` blocks rather than inlined content, so a model reads
|
|
40
|
+
* only what it needs — `raw.jsonl` on a long run is megabytes. Curated artifacts come
|
|
41
|
+
* first, carrying the name and description the om gave them; the rest of the folder
|
|
42
|
+
* follows, unlabelled. Curation raises signal; it does not gate access.
|
|
43
|
+
*/
|
|
44
|
+
export declare function runFileLinks(root: string, folder: string): ResourceLink[];
|
|
45
|
+
/**
|
|
46
|
+
* Publish the two templates. Read-only by construction: there is no write path here, and
|
|
47
|
+
* no way to delete a run folder through MCP.
|
|
48
|
+
*
|
|
49
|
+
* `list` is `undefined` deliberately — enumerating every file of every historical run
|
|
50
|
+
* would be a large, mostly-useless listing. `get_run` returns resource links for the run a
|
|
51
|
+
* client actually asked about, which is the path that matters.
|
|
52
|
+
*/
|
|
53
|
+
export declare function registerResources(server: McpServer, opts: {
|
|
54
|
+
root: string;
|
|
55
|
+
}): void;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
/** Files above this are truncated (text) or described rather than inlined (binary). */
|
|
5
|
+
const MAX_BYTES = 256 * 1024;
|
|
6
|
+
const MIME_BY_EXT = {
|
|
7
|
+
".json": "application/json",
|
|
8
|
+
".jsonl": "application/x-ndjson",
|
|
9
|
+
".log": "text/plain",
|
|
10
|
+
".txt": "text/plain",
|
|
11
|
+
".md": "text/markdown",
|
|
12
|
+
".html": "text/html",
|
|
13
|
+
".csv": "text/csv",
|
|
14
|
+
".png": "image/png",
|
|
15
|
+
".jpg": "image/jpeg",
|
|
16
|
+
".jpeg": "image/jpeg",
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
".webp": "image/webp",
|
|
19
|
+
".svg": "image/svg+xml",
|
|
20
|
+
".pdf": "application/pdf",
|
|
21
|
+
};
|
|
22
|
+
const TEXT_MIME = /^(text\/|application\/(json|x-ndjson|xml))/;
|
|
23
|
+
export function mimeOf(file) {
|
|
24
|
+
return MIME_BY_EXT[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
25
|
+
}
|
|
26
|
+
/** True when `candidate` sits strictly inside `root` — not equal to it, not above it. */
|
|
27
|
+
function inside(root, candidate) {
|
|
28
|
+
const rel = path.relative(root, candidate);
|
|
29
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a path and refuse anything that is not strictly inside `<root>/logs`.
|
|
33
|
+
*
|
|
34
|
+
* URIs are untrusted input — a model can propose any string — so this runs on every read,
|
|
35
|
+
* not just at the template. Two checks, because `..` and a symlink escape by different
|
|
36
|
+
* routes: the lexical one catches `logs/../secret`, and the realpath one catches
|
|
37
|
+
* `logs/link → /etc`. `logs/` itself is refused: it is the root, not a run.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveUnderLogs(root, target) {
|
|
40
|
+
const logs = path.resolve(root, "logs");
|
|
41
|
+
if (!inside(logs, path.resolve(target)))
|
|
42
|
+
return undefined;
|
|
43
|
+
try {
|
|
44
|
+
const realLogs = fs.realpathSync.native(logs);
|
|
45
|
+
const real = fs.realpathSync.native(path.resolve(target));
|
|
46
|
+
return inside(realLogs, real) ? real : undefined;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined; // does not exist — nothing to read either way
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** A run folder the caller named directly (a path from `get_run`, or one a client kept). */
|
|
53
|
+
export function resolveRunFolder(root, candidate) {
|
|
54
|
+
const resolved = resolveUnderLogs(root, candidate);
|
|
55
|
+
if (!resolved)
|
|
56
|
+
return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return fs.statSync(resolved).isDirectory() ? resolved : undefined;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The newest child directory of `dir`, by name. */
|
|
65
|
+
function newestChild(dir) {
|
|
66
|
+
return fs
|
|
67
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
68
|
+
.filter((e) => e.isDirectory())
|
|
69
|
+
.map((e) => e.name)
|
|
70
|
+
.sort()
|
|
71
|
+
.pop();
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The newest run of one om. Folder names are `<date>/<time>` with zero-padded, fixed-width
|
|
75
|
+
* components (`2026-08-04/11-30-00`), so lexical sort is chronological sort — no parsing,
|
|
76
|
+
* and no dependence on filesystem mtime.
|
|
77
|
+
*
|
|
78
|
+
* This is what the run-folder-identity constraint is *for*: "a script or an assistant can
|
|
79
|
+
* always find the latest run of X without parsing scrollback".
|
|
80
|
+
*/
|
|
81
|
+
export function latestRun(root, folderName) {
|
|
82
|
+
const base = resolveUnderLogs(root, path.join(root, "logs", folderName));
|
|
83
|
+
if (!base)
|
|
84
|
+
return undefined;
|
|
85
|
+
try {
|
|
86
|
+
const date = newestChild(base);
|
|
87
|
+
if (!date)
|
|
88
|
+
return undefined;
|
|
89
|
+
const time = newestChild(path.join(base, date));
|
|
90
|
+
return time ? path.join(base, date, time) : undefined;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Read one file from a run folder, confined, size-capped, and typed by extension. */
|
|
97
|
+
export function readRunFile(root, segments) {
|
|
98
|
+
const target = resolveUnderLogs(root, path.join(root, "logs", ...segments));
|
|
99
|
+
if (!target)
|
|
100
|
+
return undefined;
|
|
101
|
+
let stat;
|
|
102
|
+
try {
|
|
103
|
+
stat = fs.statSync(target);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
if (!stat.isFile())
|
|
109
|
+
return undefined;
|
|
110
|
+
const mimeType = mimeOf(target);
|
|
111
|
+
if (!TEXT_MIME.test(mimeType)) {
|
|
112
|
+
// Binary is all-or-nothing: half a PNG is not a smaller PNG. Too large comes back as a
|
|
113
|
+
// plain-text explanation rather than an error — the client asked a reasonable question
|
|
114
|
+
// and deserves an answer, and a read with neither `text` nor `blob` is not a valid
|
|
115
|
+
// MCP result.
|
|
116
|
+
if (stat.size > MAX_BYTES) {
|
|
117
|
+
return {
|
|
118
|
+
path: target,
|
|
119
|
+
mimeType: "text/plain",
|
|
120
|
+
text: `${path.basename(target)} is ${stat.size} bytes, over the ${MAX_BYTES}-byte inline cap. Read it from disk at ${target}.`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { path: target, mimeType, blob: fs.readFileSync(target).toString("base64") };
|
|
124
|
+
}
|
|
125
|
+
const raw = fs.readFileSync(target, "utf8");
|
|
126
|
+
const text = stat.size > MAX_BYTES
|
|
127
|
+
? `${raw.slice(0, MAX_BYTES)}\n\n… truncated at ${MAX_BYTES} bytes of ${stat.size}. ` +
|
|
128
|
+
`Use tail_run with a cursor to page through this run's log.`
|
|
129
|
+
: raw;
|
|
130
|
+
return { path: target, mimeType, text };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The run's files as `resource_link` blocks rather than inlined content, so a model reads
|
|
134
|
+
* only what it needs — `raw.jsonl` on a long run is megabytes. Curated artifacts come
|
|
135
|
+
* first, carrying the name and description the om gave them; the rest of the folder
|
|
136
|
+
* follows, unlabelled. Curation raises signal; it does not gate access.
|
|
137
|
+
*/
|
|
138
|
+
export function runFileLinks(root, folder) {
|
|
139
|
+
const resolved = resolveRunFolder(root, folder);
|
|
140
|
+
if (!resolved)
|
|
141
|
+
return [];
|
|
142
|
+
const logs = fs.realpathSync.native(path.resolve(root, "logs"));
|
|
143
|
+
const rel = path.relative(logs, resolved).split(path.sep);
|
|
144
|
+
const uriFor = (file) => `omkit://runs/${[...rel, file].join("/")}`;
|
|
145
|
+
let curated = [];
|
|
146
|
+
try {
|
|
147
|
+
const result = JSON.parse(fs.readFileSync(path.join(resolved, "result.json"), "utf8"));
|
|
148
|
+
curated = result.artifacts ?? [];
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
curated = [];
|
|
152
|
+
}
|
|
153
|
+
const links = curated.map((a) => ({
|
|
154
|
+
type: "resource_link",
|
|
155
|
+
uri: uriFor(path.basename(a.file)),
|
|
156
|
+
name: a.name,
|
|
157
|
+
...(a.description ? { description: a.description } : {}),
|
|
158
|
+
mimeType: a.mime,
|
|
159
|
+
}));
|
|
160
|
+
const named = new Set(curated.map((a) => path.basename(a.file)));
|
|
161
|
+
for (const item of fs.readdirSync(resolved, { withFileTypes: true })) {
|
|
162
|
+
if (!item.isFile() || named.has(item.name))
|
|
163
|
+
continue;
|
|
164
|
+
links.push({
|
|
165
|
+
type: "resource_link",
|
|
166
|
+
uri: uriFor(item.name),
|
|
167
|
+
name: item.name,
|
|
168
|
+
mimeType: mimeOf(item.name),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return links;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Publish the two templates. Read-only by construction: there is no write path here, and
|
|
175
|
+
* no way to delete a run folder through MCP.
|
|
176
|
+
*
|
|
177
|
+
* `list` is `undefined` deliberately — enumerating every file of every historical run
|
|
178
|
+
* would be a large, mostly-useless listing. `get_run` returns resource links for the run a
|
|
179
|
+
* client actually asked about, which is the path that matters.
|
|
180
|
+
*/
|
|
181
|
+
export function registerResources(server, opts) {
|
|
182
|
+
const respond = (uri, read) => {
|
|
183
|
+
if (!read) {
|
|
184
|
+
throw new Error(`no such run file, or it is outside this project's logs/: ${uri.href}`);
|
|
185
|
+
}
|
|
186
|
+
// A resource's contents are a discriminated union — `text` or `blob`, never both as
|
|
187
|
+
// optionals — so the two shapes are built separately rather than spread conditionally.
|
|
188
|
+
const base = { uri: uri.href, mimeType: read.mimeType };
|
|
189
|
+
return {
|
|
190
|
+
contents: [
|
|
191
|
+
read.blob !== undefined ? { ...base, blob: read.blob } : { ...base, text: read.text ?? "" },
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
server.registerResource("run-latest", new ResourceTemplate("omkit://runs/{run}/latest/{file}", { list: undefined }), {
|
|
196
|
+
title: "The newest run of one om",
|
|
197
|
+
description: "A file from the most recent run of `{run}` (the `folderName` from list_oms). Try " +
|
|
198
|
+
"main.log for the narrative, result.json for the verdict, artifacts.log for files.",
|
|
199
|
+
}, async (uri, { run, file }) => {
|
|
200
|
+
const folder = latestRun(opts.root, String(run));
|
|
201
|
+
if (!folder)
|
|
202
|
+
throw new Error(`no runs recorded for ${String(run)}`);
|
|
203
|
+
const logs = fs.realpathSync.native(path.resolve(opts.root, "logs"));
|
|
204
|
+
const rel = path.relative(logs, folder).split(path.sep);
|
|
205
|
+
return respond(uri, readRunFile(opts.root, [...rel, String(file)]));
|
|
206
|
+
});
|
|
207
|
+
server.registerResource("run-at", new ResourceTemplate("omkit://runs/{run}/{date}/{time}/{file}", { list: undefined }), { title: "A file from one specific run" }, async (uri, { run, date, time, file }) => respond(uri, readRunFile(opts.root, [run, date, time, file].map(String))));
|
|
208
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { LogEntry } from "../foundation/LogEntry.ts";
|
|
2
|
+
import type { RunSession, Verdict } from "../client/types.ts";
|
|
3
|
+
export interface LiveRun {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
/** The om or action name the client asked for. */
|
|
6
|
+
readonly name: string;
|
|
7
|
+
/** `<name>-<hash8>` — known before the run creates its dated folder. */
|
|
8
|
+
readonly folderName: string;
|
|
9
|
+
readonly session: RunSession;
|
|
10
|
+
readonly startedAt: number;
|
|
11
|
+
status: "running" | "settled";
|
|
12
|
+
verdict?: Verdict;
|
|
13
|
+
/** The tail buffer. Entries are appended in sequence order, oldest dropped first. */
|
|
14
|
+
readonly entries: LogEntry[];
|
|
15
|
+
/** The oldest sequence still buffered, so `tail_run` can say it dropped lines. */
|
|
16
|
+
dropped: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Every run this server started, live or settled.
|
|
20
|
+
*
|
|
21
|
+
* Runs are kept after settling so a client that started one can still ask what happened
|
|
22
|
+
* without knowing its folder — the dated folder only becomes knowable once the child has
|
|
23
|
+
* created it, which is why the tools key on a `runId` at all.
|
|
24
|
+
*/
|
|
25
|
+
export declare class RunRegistry {
|
|
26
|
+
private readonly runs;
|
|
27
|
+
/**
|
|
28
|
+
* Take ownership of a session. Subscription happens here, not at the call site, because
|
|
29
|
+
* `session.on("log")` must be attached before the first entry arrives — a tail that
|
|
30
|
+
* starts at the tool boundary has already missed the run's opening lines.
|
|
31
|
+
*/
|
|
32
|
+
start(name: string, folderName: string, session: RunSession): LiveRun;
|
|
33
|
+
get(id: string): LiveRun | undefined;
|
|
34
|
+
all(): LiveRun[];
|
|
35
|
+
/** Cancel every live run — the server is going away and its children must not outlive it. */
|
|
36
|
+
cancelAll(): void;
|
|
37
|
+
}
|
package/dist/mcp/runs.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { newUuid, shortId } from "../foundation/ids.js";
|
|
2
|
+
/**
|
|
3
|
+
* How many log entries a live run keeps for `tail_run`. A watch-mode om can emit for
|
|
4
|
+
* hours, so the buffer is bounded and the oldest entries are dropped; `raw.jsonl` on disk
|
|
5
|
+
* remains the complete record, and a settled run is read from there instead.
|
|
6
|
+
*/
|
|
7
|
+
const BUFFER_LIMIT = 5000;
|
|
8
|
+
/**
|
|
9
|
+
* Every run this server started, live or settled.
|
|
10
|
+
*
|
|
11
|
+
* Runs are kept after settling so a client that started one can still ask what happened
|
|
12
|
+
* without knowing its folder — the dated folder only becomes knowable once the child has
|
|
13
|
+
* created it, which is why the tools key on a `runId` at all.
|
|
14
|
+
*/
|
|
15
|
+
export class RunRegistry {
|
|
16
|
+
runs = new Map();
|
|
17
|
+
/**
|
|
18
|
+
* Take ownership of a session. Subscription happens here, not at the call site, because
|
|
19
|
+
* `session.on("log")` must be attached before the first entry arrives — a tail that
|
|
20
|
+
* starts at the tool boundary has already missed the run's opening lines.
|
|
21
|
+
*/
|
|
22
|
+
start(name, folderName, session) {
|
|
23
|
+
const run = {
|
|
24
|
+
id: shortId(newUuid()),
|
|
25
|
+
name,
|
|
26
|
+
folderName,
|
|
27
|
+
session,
|
|
28
|
+
startedAt: Date.now(),
|
|
29
|
+
status: "running",
|
|
30
|
+
entries: [],
|
|
31
|
+
dropped: 0,
|
|
32
|
+
};
|
|
33
|
+
session.on("log", (entry) => {
|
|
34
|
+
run.entries.push(entry);
|
|
35
|
+
if (run.entries.length > BUFFER_LIMIT) {
|
|
36
|
+
run.entries.splice(0, run.entries.length - BUFFER_LIMIT);
|
|
37
|
+
run.dropped = run.entries[0].sequence;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
void session.result.then((verdict) => {
|
|
41
|
+
run.status = "settled";
|
|
42
|
+
run.verdict = verdict;
|
|
43
|
+
});
|
|
44
|
+
this.runs.set(run.id, run);
|
|
45
|
+
return run;
|
|
46
|
+
}
|
|
47
|
+
get(id) {
|
|
48
|
+
return this.runs.get(id);
|
|
49
|
+
}
|
|
50
|
+
all() {
|
|
51
|
+
return [...this.runs.values()];
|
|
52
|
+
}
|
|
53
|
+
/** Cancel every live run — the server is going away and its children must not outlive it. */
|
|
54
|
+
cancelAll() {
|
|
55
|
+
for (const run of this.runs.values()) {
|
|
56
|
+
if (run.status === "running")
|
|
57
|
+
run.session.cancel();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { OmkitClient } from "../client/index.ts";
|
|
3
|
+
export interface McpServerOptions {
|
|
4
|
+
/** The engine every omkit frontend goes through — the MCP server is the third one. */
|
|
5
|
+
readonly client: OmkitClient;
|
|
6
|
+
/** Project root: run folders resolve under `<root>/logs`. */
|
|
7
|
+
readonly root: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Build the server without binding a transport, so tests can connect it to an in-memory
|
|
11
|
+
* pair and a real launch can hand it stdio. Registration is assembly only: what each tool
|
|
12
|
+
* and resource does lives in its own module.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createMcpServer(opts: McpServerOptions): McpServer;
|
|
15
|
+
/**
|
|
16
|
+
* Bind the server to stdio and serve until the client disconnects. Nothing on this path
|
|
17
|
+
* may write to stdout: it *is* the JSON-RPC stream. Diagnostics go to stderr.
|
|
18
|
+
*/
|
|
19
|
+
export declare function startMcpServer(opts: McpServerOptions): Promise<void>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { registerTools } from "./tools.js";
|
|
4
|
+
import { registerResources } from "./resources.js";
|
|
5
|
+
import { RunRegistry } from "./runs.js";
|
|
6
|
+
/**
|
|
7
|
+
* Kept in step with package.json by hand. It is only the version this server reports to a
|
|
8
|
+
* client in `initialize`, so drifting is cosmetic — but bump both together.
|
|
9
|
+
*/
|
|
10
|
+
const VERSION = "0.7.0";
|
|
11
|
+
/**
|
|
12
|
+
* Build the server without binding a transport, so tests can connect it to an in-memory
|
|
13
|
+
* pair and a real launch can hand it stdio. Registration is assembly only: what each tool
|
|
14
|
+
* and resource does lives in its own module.
|
|
15
|
+
*/
|
|
16
|
+
export function createMcpServer(opts) {
|
|
17
|
+
const server = new McpServer({ name: "omkit", version: VERSION }, { capabilities: { tools: {}, resources: {} } });
|
|
18
|
+
const runs = new RunRegistry();
|
|
19
|
+
registerTools(server, { client: opts.client, root: opts.root, runs });
|
|
20
|
+
registerResources(server, { root: opts.root });
|
|
21
|
+
// The client hung up. Its runs are children of this process and must not outlive it.
|
|
22
|
+
// Chain rather than assign: `onclose` is a single slot on the SDK's Protocol base, so a
|
|
23
|
+
// bare assignment would silently drop whatever the SDK (or a future us) put there.
|
|
24
|
+
const previousOnClose = server.server.onclose;
|
|
25
|
+
server.server.onclose = () => {
|
|
26
|
+
runs.cancelAll();
|
|
27
|
+
previousOnClose?.();
|
|
28
|
+
};
|
|
29
|
+
return server;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Bind the server to stdio and serve until the client disconnects. Nothing on this path
|
|
33
|
+
* may write to stdout: it *is* the JSON-RPC stream. Diagnostics go to stderr.
|
|
34
|
+
*/
|
|
35
|
+
export async function startMcpServer(opts) {
|
|
36
|
+
const server = createMcpServer(opts);
|
|
37
|
+
await server.connect(new StdioServerTransport());
|
|
38
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { LogEntry } from "../foundation/LogEntry.ts";
|
|
2
|
+
export interface TailPage {
|
|
3
|
+
readonly entries: LogEntry[];
|
|
4
|
+
/** Pass this back as `cursor` to continue where this page stopped. */
|
|
5
|
+
readonly nextCursor: number;
|
|
6
|
+
/** The oldest sequence still available, when the live buffer has dropped older lines. */
|
|
7
|
+
readonly dropped: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A page from a live run's buffer. `sequence` is the run-global monotonic key the log
|
|
11
|
+
* store stamps, so it doubles as the cursor with nothing extra to track.
|
|
12
|
+
*/
|
|
13
|
+
export declare function tailLive(run: {
|
|
14
|
+
entries: readonly LogEntry[];
|
|
15
|
+
dropped: number;
|
|
16
|
+
}, cursor: number, limit: number): TailPage;
|
|
17
|
+
/**
|
|
18
|
+
* A page from a settled run's `raw.jsonl` — the complete record, which the bounded live
|
|
19
|
+
* buffer is not. Read whole and filtered: run logs are bounded by the run, and a streaming
|
|
20
|
+
* reader would buy little for a lot of machinery.
|
|
21
|
+
*
|
|
22
|
+
* An unparseable line is skipped rather than fatal: `raw.jsonl` is appended to during the
|
|
23
|
+
* run, so a tail can catch a line mid-write.
|
|
24
|
+
*/
|
|
25
|
+
export declare function tailFile(folder: string, cursor: number, limit: number): TailPage;
|
|
26
|
+
/**
|
|
27
|
+
* Trim an entry to what a client can act on. `nodeId` is an internal attribution key and
|
|
28
|
+
* says nothing a reader can use — `path` already names the node readably.
|
|
29
|
+
*/
|
|
30
|
+
export declare function publicEntry(e: LogEntry): {
|
|
31
|
+
sequence: number;
|
|
32
|
+
ts: number;
|
|
33
|
+
path: string;
|
|
34
|
+
level: string;
|
|
35
|
+
source: string;
|
|
36
|
+
message: string;
|
|
37
|
+
};
|