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.
- package/CHANGELOG.md +188 -0
- package/README.md +169 -13
- 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 +7 -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 +18 -0
- package/dist/cli/commands/skill.js +62 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +78 -30
- 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 +15 -5
- 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 +14 -1
- package/dist/client/types.js +5 -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 +378 -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 +70 -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 +12 -0
- package/dist/skill/index.js +10 -0
- package/dist/skill/model.d.ts +69 -0
- package/dist/skill/model.js +200 -0
- package/dist/skill/render.d.ts +18 -0
- package/dist/skill/render.js +159 -0
- package/dist/skill/root.d.ts +15 -0
- package/dist/skill/root.js +27 -0
- package/package.json +5 -2
|
@@ -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
|
+
};
|
package/dist/mcp/tail.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const page = (entries, cursor, dropped) => ({
|
|
4
|
+
entries,
|
|
5
|
+
nextCursor: entries.length ? entries[entries.length - 1].sequence : cursor,
|
|
6
|
+
dropped,
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* A page from a live run's buffer. `sequence` is the run-global monotonic key the log
|
|
10
|
+
* store stamps, so it doubles as the cursor with nothing extra to track.
|
|
11
|
+
*/
|
|
12
|
+
export function tailLive(run, cursor, limit) {
|
|
13
|
+
const after = run.entries.filter((e) => e.sequence > cursor).slice(0, limit);
|
|
14
|
+
return page(after, cursor, run.dropped);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A page from a settled run's `raw.jsonl` — the complete record, which the bounded live
|
|
18
|
+
* buffer is not. Read whole and filtered: run logs are bounded by the run, and a streaming
|
|
19
|
+
* reader would buy little for a lot of machinery.
|
|
20
|
+
*
|
|
21
|
+
* An unparseable line is skipped rather than fatal: `raw.jsonl` is appended to during the
|
|
22
|
+
* run, so a tail can catch a line mid-write.
|
|
23
|
+
*/
|
|
24
|
+
export function tailFile(folder, cursor, limit) {
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = fs.readFileSync(path.join(folder, "raw.jsonl"), "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { entries: [], nextCursor: cursor, dropped: 0 };
|
|
31
|
+
}
|
|
32
|
+
const entries = [];
|
|
33
|
+
for (const line of raw.split("\n")) {
|
|
34
|
+
if (!line.trim())
|
|
35
|
+
continue;
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(line);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (typeof parsed.sequence !== "number" || parsed.sequence <= cursor)
|
|
44
|
+
continue;
|
|
45
|
+
entries.push(parsed);
|
|
46
|
+
if (entries.length >= limit)
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
return page(entries, cursor, 0);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Trim an entry to what a client can act on. `nodeId` is an internal attribution key and
|
|
53
|
+
* says nothing a reader can use — `path` already names the node readably.
|
|
54
|
+
*/
|
|
55
|
+
export function publicEntry(e) {
|
|
56
|
+
return {
|
|
57
|
+
sequence: e.sequence,
|
|
58
|
+
ts: e.ts,
|
|
59
|
+
path: e.path,
|
|
60
|
+
level: e.level,
|
|
61
|
+
source: e.source,
|
|
62
|
+
message: e.message,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { RunRegistry } from "./runs.ts";
|
|
3
|
+
import type { OmkitClient } from "../client/index.ts";
|
|
4
|
+
import type { RegistrationSet } from "../client/registry.ts";
|
|
5
|
+
/** Everything the tools need. Passed in so the server stays assembly and these stay logic. */
|
|
6
|
+
export interface ToolContext {
|
|
7
|
+
readonly client: OmkitClient;
|
|
8
|
+
/** Project root — run folders resolve under `<root>/logs`. */
|
|
9
|
+
readonly root: string;
|
|
10
|
+
/** Every run this server started, live or settled. */
|
|
11
|
+
readonly runs: RunRegistry;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* One entry in `list_oms`: an om or an exposed action, flattened into a single list so a
|
|
15
|
+
* client has one place to look for "what can I call". `kind` says which it is, because an
|
|
16
|
+
* action's caveats differ — see the action-host notes in the spec.
|
|
17
|
+
*/
|
|
18
|
+
export interface ToolEntry {
|
|
19
|
+
readonly kind: "om" | "action";
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly mode: "settling" | "long-lived";
|
|
22
|
+
readonly file: string;
|
|
23
|
+
readonly summary?: string;
|
|
24
|
+
/** `<name>-<hash8>` — where this entry's runs land under `logs/`. Oms only. */
|
|
25
|
+
readonly folderName?: string;
|
|
26
|
+
/** The exported binding an action-backed tool is invoked through. Actions only. */
|
|
27
|
+
readonly exportName?: string;
|
|
28
|
+
readonly inputSchema?: Record<string, unknown>;
|
|
29
|
+
/** Set when the entry cannot be called, with the reason. */
|
|
30
|
+
readonly unavailable?: string;
|
|
31
|
+
}
|
|
32
|
+
/** Flatten a registration set into the entries a client may call, dropping the rest. */
|
|
33
|
+
export declare function toEntries(set: RegistrationSet): ToolEntry[];
|
|
34
|
+
/**
|
|
35
|
+
* Every tool result carries a text block as well as `structuredContent`: a client that
|
|
36
|
+
* ignores structured output still sees the answer. Return types are inferred so they stay
|
|
37
|
+
* assignable to the SDK's `CallToolResult` union — annotating them as tuples does not.
|
|
38
|
+
*/
|
|
39
|
+
export declare function both<T>(structuredContent: T): {
|
|
40
|
+
content: {
|
|
41
|
+
type: "text";
|
|
42
|
+
text: string;
|
|
43
|
+
}[];
|
|
44
|
+
structuredContent: T;
|
|
45
|
+
};
|
|
46
|
+
/** A tool failure, in MCP's shape: a message the model can act on, not a thrown stack. */
|
|
47
|
+
export declare function toolError(message: string): {
|
|
48
|
+
content: {
|
|
49
|
+
type: "text";
|
|
50
|
+
text: string;
|
|
51
|
+
}[];
|
|
52
|
+
isError: true;
|
|
53
|
+
};
|
|
54
|
+
export declare function registerTools(server: McpServer, ctx: ToolContext): void;
|