@thenavidm/slipway 0.1.12 → 0.1.14
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 +11 -0
- package/README.md +3 -0
- package/dist/check.js +7 -4
- package/dist/cli/flags.d.ts +1 -1
- package/dist/cli/flags.js +11 -9
- package/dist/doctor.js +14 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/schema.d.ts +30 -1
- package/dist/schema.js +210 -3
- package/dist/search.js +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
What changed in Slipway, newest first.
|
|
4
4
|
|
|
5
|
+
## 0.1.14, 2026-10-05: what Buffer needed
|
|
6
|
+
|
|
7
|
+
- **`which` and the search surface read what a tool takes.** Argument names count for a little less than the title and more than the description, and camelCase is split, so `channelId` reads as "channel" and `createPost` as "create post". Buffer's "schedule a post to a channel" never listed `create-post`, whose description says neither word but whose `channelId` and `schedulingType` say both; a Codex run that asked `which` then read the whole command list as well.
|
|
8
|
+
|
|
9
|
+
## 0.1.13, 2026-10-05: what Beehiiv needed
|
|
10
|
+
|
|
11
|
+
- **`jsonSchema(schema, { shareRepeats: true })` writes each repeated part of a contract schema once.** A schema generated from an API contract often spells one definition out everywhere it is used. Beehiiv's post body repeats its block styling in each of 33 block types and carries the body twice, as its own fields and as `payload`, so its create-post tool advertised 387 KB. With each repeated part under `$defs` and referred to with `$ref`, it is 40 KB, and Beehiiv's 117 tools together go from 1,684 KB to 308 KB. Nothing is lost: each part reads the same once the references are followed, Claude Code and Codex both read fields that appear only under `$defs`, and validation accepts and refuses the same arguments. Definitions take the name of the property or block type they came from, such as `paragraph` or `visual_settings`, and parts under 200 bytes stay inline. The work is linear in the schema's size and happens when the tool is first listed. `shareRepeats()` is exported for a schema built some other way.
|
|
12
|
+
- **`slipway check` names that fix wherever it would help.** A schema over the size budget whose shared form is at least a fifth smaller says how big it would be.
|
|
13
|
+
- **CLI flags read through `$ref`.** An array whose items were a reference became a repeatable text flag, so a list of objects could not be passed; it is a JSON flag again, and a property that is only a reference takes its type, choices and description from the definition. `slipway check` no longer warns that such a property has no description.
|
|
14
|
+
- **`doctor` tells a server that only reads apart.** "Writes: on" read as if it could change something; it now says every tool only reads. A setting doctor cannot read points at `login` for what to set, where it told the person to run the doctor they were running, and the verdict says a setting needs fixing rather than that nothing is configured.
|
|
15
|
+
|
|
5
16
|
## 0.1.12, 2026-10-05: what the Meta Ad Library needed
|
|
6
17
|
|
|
7
18
|
- **The write switches appear only where they act.** `<PREFIX>_READ_ONLY` and `<PREFIX>_AUDIT_LOG` are listed in the general help, `agent-context` and the generated settings table only when a tool writes, and `<PREFIX>_ALLOW_DESTRUCTIVE` and `<PREFIX>_CONFIRM` only when a tool can be irreversible, spends money or needs confirming. The Meta Ad Library server only reads, and its help offered to "refuse the irreversible writes"; it is now 40 tokens shorter, 399 against 439. The switches still work if set.
|
package/README.md
CHANGED
|
@@ -255,6 +255,8 @@ export const renameCourse = defineTool({
|
|
|
255
255
|
});
|
|
256
256
|
```
|
|
257
257
|
|
|
258
|
+
A contract schema often spells one definition out everywhere it is used. `jsonSchema(schema, { shareRepeats: true })` advertises it with each repeated part written once under `$defs` and referred to with `$ref`. Beehiiv's create-post tool went from 387 KB to 40 KB this way, and nothing is lost: Claude Code and Codex both read fields that appear only under `$defs`, validation accepts and refuses the same arguments, and the CLI's flags read through the references. `slipway check` says when it would help, and by how much.
|
|
259
|
+
|
|
258
260
|
## 4. Safety
|
|
259
261
|
|
|
260
262
|
Shipping no writes is not safety: it hands the work back to a person. Shipping them unguarded is worse. So every write works, and the irreversible ones need a confirmation the caller gives on purpose.
|
|
@@ -574,6 +576,7 @@ See [CHANGELOG.md](CHANGELOG.md).
|
|
|
574
576
|
| --- | --- | --- |
|
|
575
577
|
| [Apple Podcasts](https://github.com/thenavidm/apple-podcasts-mcp-cli) | [`@thenavidm/apple-podcasts-mcp-cli`](https://www.npmjs.com/package/@thenavidm/apple-podcasts-mcp-cli) 2.0.0 | The catalog, charts per country, reviews and feeds, your library on this Mac with its transcript excerpts, and analytics for a show you own |
|
|
576
578
|
| [Bluesky](https://github.com/thenavidm/bluesky-mcp-cli) | [`@thenavidm/bluesky-mcp-cli`](https://www.npmjs.com/package/@thenavidm/bluesky-mcp-cli) 2.0.0 | Posting, threads, replies, the timeline, search, feeds, lists, notifications and the social graph |
|
|
579
|
+
| [Meta Ad Library](https://github.com/thenavidm/facebook-ad-library-mcp-cli) | [`@thenavidm/facebook-ad-library-mcp-cli`](https://www.npmjs.com/package/@thenavidm/facebook-ad-library-mcp-cli) 0.6.0 | Every ad running on Facebook, Instagram and Threads for any advertiser: copy, creatives, how long each has run, what changed, and EU spend and reach |
|
|
577
580
|
| [Google Photos](https://github.com/thenavidm/google-photos-mcp-cli) | [`@thenavidm/google-photos-mcp-cli`](https://www.npmjs.com/package/@thenavidm/google-photos-mcp-cli) 2.0.0 | The photo picker, uploads, albums and their captions, places and maps, and media this server uploaded, across several Google accounts |
|
|
578
581
|
| [Mastodon](https://github.com/thenavidm/mastodon-mcp-cli) | [`@thenavidm/mastodon-mcp-cli`](https://www.npmjs.com/package/@thenavidm/mastodon-mcp-cli) 2.0.0 | Posting, editing, threads, timelines, search, lists, notifications and following, on any instance |
|
|
579
582
|
| [Midjourney](https://github.com/thenavidm/midjourney-mcp-cli) | [`@thenavidm/midjourney-mcp-cli`](https://www.npmjs.com/package/@thenavidm/midjourney-mcp-cli) 2.0.0 | Generating images and video, following jobs, downloads, moodboards, the account's library and the explore feeds, through a signed-in browser |
|
package/dist/check.js
CHANGED
|
@@ -15,7 +15,7 @@ import { agentContext } from "./cli/context.js";
|
|
|
15
15
|
import { flagsFor } from "./cli/flags.js";
|
|
16
16
|
import { BUILTINS, GLOBAL_FLAGS, renderToolHelp } from "./cli/help.js";
|
|
17
17
|
import { connectInMemory } from "./rpc.js";
|
|
18
|
-
import { repeatedDefinitions, schemaBytes, validate, formatIssues } from "./schema.js";
|
|
18
|
+
import { repeatedDefinitions, resolveLocalRef, schemaBytes, shareRepeats, validate, formatIssues } from "./schema.js";
|
|
19
19
|
import { REQUIRES_USER_INTERACTION } from "./server.js";
|
|
20
20
|
const PROPERTY = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
21
21
|
const DEFAULT_BUDGET = { warnBytes: 16 * 1024, errorBytes: 128 * 1024 };
|
|
@@ -98,7 +98,7 @@ export async function checkApp(app, options = {}) {
|
|
|
98
98
|
if (!PROPERTY.test(name))
|
|
99
99
|
add("error", "schema", `Property '${name}' must be 1-64 letters, digits, '_', '.' or '-'.`, tool.name);
|
|
100
100
|
}
|
|
101
|
-
const undocumented = Object.entries(properties).filter(([name, prop]) => name !== "confirm" && !prop.description).map(([name]) => name);
|
|
101
|
+
const undocumented = Object.entries(properties).filter(([name, prop]) => name !== "confirm" && !resolveLocalRef(schema, prop).description).map(([name]) => name);
|
|
102
102
|
if (undocumented.length)
|
|
103
103
|
add("warn", "descriptions", `No description for: ${undocumented.join(", ")}.`, tool.name);
|
|
104
104
|
const problem = meta?.(schema);
|
|
@@ -117,10 +117,13 @@ export async function checkApp(app, options = {}) {
|
|
|
117
117
|
totalBytes += bytes;
|
|
118
118
|
if (!largest || bytes > largest.bytes)
|
|
119
119
|
largest = { name: tool.name, bytes };
|
|
120
|
+
// Sharing the repeated parts is the fix that loses nothing, so it is named first wherever it would help.
|
|
121
|
+
const shared = bytes > budget.warnBytes ? schemaBytes(shareRepeats(schema)) : bytes;
|
|
122
|
+
const share = shared < bytes * 0.8 ? ` jsonSchema(schema, { shareRepeats: true }) writes each repeated part once and brings it to ${Math.round(shared / 1024)} KB, with nothing lost.` : "";
|
|
120
123
|
if (bytes > budget.errorBytes)
|
|
121
|
-
add("error", "size", `The schema is ${Math.round(bytes / 1024)} KB
|
|
124
|
+
add("error", "size", `The schema is ${Math.round(bytes / 1024)} KB.${share} ${share ? "Or advertise" : "Advertise"} a short schema and validate the full one in the handler.`, tool.name);
|
|
122
125
|
else if (bytes > budget.warnBytes)
|
|
123
|
-
add("warn", "size", `The schema is ${Math.round(bytes / 1024)} KB, which a model pays for every time it loads this tool
|
|
126
|
+
add("warn", "size", `The schema is ${Math.round(bytes / 1024)} KB, which a model pays for every time it loads this tool.${share}`, tool.name);
|
|
124
127
|
const repeated = repeatedDefinitions(schema);
|
|
125
128
|
if (repeated.length)
|
|
126
129
|
add("warn", "size", `Definitions appear more than once: ${repeated.slice(0, 5).join(", ")}${repeated.length > 5 ? "…" : ""}.`, tool.name);
|
package/dist/cli/flags.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* one surface and not the other, and its help text is the description the
|
|
6
6
|
* model reads.
|
|
7
7
|
*/
|
|
8
|
-
import type
|
|
8
|
+
import { type JsonSchema } from "../schema.js";
|
|
9
9
|
export type FlagKind = "string" | "number" | "integer" | "boolean" | "enum" | "json";
|
|
10
10
|
export type Flag = {
|
|
11
11
|
/** The property name: `reply_to`. */
|
package/dist/cli/flags.js
CHANGED
|
@@ -7,28 +7,30 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import { UsageError } from "../errors.js";
|
|
10
|
+
import { resolveLocalRef } from "../schema.js";
|
|
10
11
|
import { didYouMean } from "../search.js";
|
|
11
|
-
/** The first concrete type, looking through nullable unions and type lists. */
|
|
12
|
-
function concrete(
|
|
12
|
+
/** The first concrete type, looking through references, nullable unions and type lists. */
|
|
13
|
+
function concrete(raw, root) {
|
|
14
|
+
const node = resolveLocalRef(root, raw);
|
|
13
15
|
const union = node.anyOf ?? node.oneOf;
|
|
14
16
|
if (union) {
|
|
15
|
-
const options = union.filter((option) => option.type !== "null");
|
|
17
|
+
const options = union.map((option) => resolveLocalRef(root, option)).filter((option) => option.type !== "null");
|
|
16
18
|
// A union of plain literals is an enum in disguise, which a person types as a word.
|
|
17
19
|
if (options.length > 1 && options.every((option) => option.const !== undefined)) {
|
|
18
20
|
return { ...node, enum: options.map((option) => option.const) };
|
|
19
21
|
}
|
|
20
|
-
return concrete({ ...(options[0] ?? {}), description: node.description ?? options[0]?.description });
|
|
22
|
+
return concrete({ ...(options[0] ?? {}), description: node.description ?? options[0]?.description }, root);
|
|
21
23
|
}
|
|
22
24
|
if (Array.isArray(node.type))
|
|
23
25
|
return { ...node, type: node.type.find((type) => type !== "null") ?? "string" };
|
|
24
26
|
return node;
|
|
25
27
|
}
|
|
26
|
-
function kindOf(node) {
|
|
27
|
-
const n = concrete(node);
|
|
28
|
+
function kindOf(node, root) {
|
|
29
|
+
const n = concrete(node, root);
|
|
28
30
|
if (n.enum)
|
|
29
31
|
return { kind: "enum", repeatable: false, choices: n.enum.map(String) };
|
|
30
32
|
if (n.type === "array") {
|
|
31
|
-
const item = concrete(n.items ?? {});
|
|
33
|
+
const item = concrete(n.items ?? {}, root);
|
|
32
34
|
if (item.enum)
|
|
33
35
|
return { kind: "enum", repeatable: true, choices: item.enum.map(String) };
|
|
34
36
|
if (item.type === "object" || item.type === "array")
|
|
@@ -50,11 +52,11 @@ export function flagsFor(schema) {
|
|
|
50
52
|
const properties = schema.properties ?? {};
|
|
51
53
|
const required = new Set(schema.required ?? []);
|
|
52
54
|
return Object.entries(properties).map(([key, node]) => {
|
|
53
|
-
const n = concrete(node);
|
|
55
|
+
const n = concrete(node, schema);
|
|
54
56
|
return {
|
|
55
57
|
key,
|
|
56
58
|
flag: flagName(key),
|
|
57
|
-
...kindOf(node),
|
|
59
|
+
...kindOf(node, schema),
|
|
58
60
|
required: required.has(key),
|
|
59
61
|
help: (node.description ?? n.description ?? "").trim(),
|
|
60
62
|
...(node.default !== undefined ? { default: node.default } : n.default !== undefined ? { default: n.default } : {}),
|
package/dist/doctor.js
CHANGED
|
@@ -17,7 +17,13 @@ export async function runDoctor(app, io, options) {
|
|
|
17
17
|
checks.push({ name: "Node.js", ok: major >= 22, detail: `v${process.versions.node}`, ...(major >= 22 ? {} : { fix: "Install Node.js 22 or later." }) });
|
|
18
18
|
checks.push({ name: "Version", ok: true, detail: `${app.name} ${app.version}` });
|
|
19
19
|
const paid = app.allTools.some((tool) => tool.spends) ? " and paid" : "";
|
|
20
|
-
const writes =
|
|
20
|
+
const writes = !app.allTools.some((tool) => tool.risk !== "read")
|
|
21
|
+
? "none: every tool only reads"
|
|
22
|
+
: policy.readOnly
|
|
23
|
+
? "off (read-only)"
|
|
24
|
+
: policy.allowDestructive
|
|
25
|
+
? "on"
|
|
26
|
+
: `on, irreversible${paid} ones refused`;
|
|
21
27
|
checks.push({ name: "Writes", ok: true, detail: writes });
|
|
22
28
|
checks.push({
|
|
23
29
|
name: "Tools",
|
|
@@ -51,14 +57,18 @@ export async function runDoctor(app, io, options) {
|
|
|
51
57
|
});
|
|
52
58
|
}
|
|
53
59
|
let configured = true;
|
|
60
|
+
let unreadable = false;
|
|
54
61
|
let ctx;
|
|
55
62
|
try {
|
|
56
63
|
ctx = await app.context(io.env);
|
|
57
64
|
}
|
|
58
65
|
catch (error) {
|
|
59
66
|
configured = false;
|
|
67
|
+
unreadable = true;
|
|
60
68
|
const e = error instanceof SlipwayError ? error : new NotConfiguredError(String(error?.message ?? error));
|
|
61
|
-
|
|
69
|
+
// Outside doctor, a setting that cannot be read points here; in here, it points at how to set it.
|
|
70
|
+
const fix = e.hint && !e.hint.includes(`${app.bins.cli} doctor`) ? e.hint : `Run \`${app.bins.cli} login\` for what to set.`;
|
|
71
|
+
checks.push({ name: "Setup", ok: false, detail: e.message, fix });
|
|
62
72
|
}
|
|
63
73
|
if (ctx !== undefined && app.definition.configured) {
|
|
64
74
|
configured = await app.definition.configured(ctx);
|
|
@@ -95,7 +105,8 @@ export async function runDoctor(app, io, options) {
|
|
|
95
105
|
if (!check.ok && check.fix)
|
|
96
106
|
lines.push(` ${" ".repeat(width)}${check.fix}`);
|
|
97
107
|
}
|
|
98
|
-
|
|
108
|
+
const verdict = code === EXIT.ok ? "Ready." : unreadable ? "A setting needs fixing." : code === EXIT.notConfigured ? "Nothing is configured yet." : "Something needs fixing.";
|
|
109
|
+
lines.push(``, ` ${verdict}`, ``);
|
|
99
110
|
io.stdout(lines.join("\n"));
|
|
100
111
|
}
|
|
101
112
|
return code;
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { slipway, stderrLogger } from "./app.js";
|
|
|
5
5
|
export type { App, AppDefinition, CliIO, DoctorCheck, DryRun, InvokeOptions, PromptDefinition, ResourceDefinition, ServiceSetting, } from "./app.js";
|
|
6
6
|
export { defineTool, isTool, toolkit } from "./tool.js";
|
|
7
7
|
export type { CacheOptions, Logger, Paginate, Risk, RunContext, Surface, SyncOptions, Tool, ToolContext, ToolDefinition, ToolExample } from "./tool.js";
|
|
8
|
-
export { emptyInput, inputJsonSchema, jsonSchema, outputJsonSchema, validate, CONFIRM_DESCRIPTION } from "./schema.js";
|
|
8
|
+
export { emptyInput, inputJsonSchema, jsonSchema, outputJsonSchema, shareRepeats, validate, CONFIRM_DESCRIPTION } from "./schema.js";
|
|
9
9
|
export type { InferInput, InferOutput, JsonSchema, Schema } from "./schema.js";
|
|
10
10
|
export { ApiError, AuthError, CanceledError, EXIT, NotConfiguredError, NotFoundError, RateLimitError, RefusedError, SlipwayError, TimeoutError, UsageError, httpError, toSlipwayError, } from "./errors.js";
|
|
11
11
|
export type { ErrorCode, ErrorPayload } from "./errors.js";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export { slipway, stderrLogger } from "./app.js";
|
|
5
5
|
export { defineTool, isTool, toolkit } from "./tool.js";
|
|
6
|
-
export { emptyInput, inputJsonSchema, jsonSchema, outputJsonSchema, validate, CONFIRM_DESCRIPTION } from "./schema.js";
|
|
6
|
+
export { emptyInput, inputJsonSchema, jsonSchema, outputJsonSchema, shareRepeats, validate, CONFIRM_DESCRIPTION } from "./schema.js";
|
|
7
7
|
export { ApiError, AuthError, CanceledError, EXIT, NotConfiguredError, NotFoundError, RateLimitError, RefusedError, SlipwayError, TimeoutError, UsageError, httpError, toSlipwayError, } from "./errors.js";
|
|
8
8
|
export { audio, content, file, image, resourceLink, text } from "./result.js";
|
|
9
9
|
export { readPolicy, policyEnvNames } from "./policy.js";
|
package/dist/schema.d.ts
CHANGED
|
@@ -38,8 +38,13 @@ export type Issue = {
|
|
|
38
38
|
* Compiling all 123 schemas of one server up front held its first answer back
|
|
39
39
|
* by 118 ms, for tools most sessions never call. `slipway check` compiles every
|
|
40
40
|
* one, so a schema that cannot compile still fails before release.
|
|
41
|
+
*
|
|
42
|
+
* `shareRepeats: true` advertises the schema with each repeated part written
|
|
43
|
+
* once, as `shareRepeats()` describes.
|
|
41
44
|
*/
|
|
42
|
-
export declare function jsonSchema<T = Record<string, unknown>>(schema: JsonSchema
|
|
45
|
+
export declare function jsonSchema<T = Record<string, unknown>>(schema: JsonSchema, options?: {
|
|
46
|
+
shareRepeats?: boolean;
|
|
47
|
+
}): Schema<T, T>;
|
|
43
48
|
/** The input of a tool that takes nothing. */
|
|
44
49
|
export declare function emptyInput(): Schema<Record<string, never>>;
|
|
45
50
|
export declare function isSchema(value: unknown): value is Schema;
|
|
@@ -101,4 +106,28 @@ export declare function schemaBytes(schema: JsonSchema): number;
|
|
|
101
106
|
* often half repetition.
|
|
102
107
|
*/
|
|
103
108
|
export declare function repeatedDefinitions(schema: JsonSchema): string[];
|
|
109
|
+
/**
|
|
110
|
+
* The same schema with each part that repeats written once, under `$defs`, and
|
|
111
|
+
* referred to with `$ref` everywhere it appeared.
|
|
112
|
+
*
|
|
113
|
+
* A schema generated from an API contract often spells one definition out
|
|
114
|
+
* everywhere it is used: one newsletter API's post body repeats the same
|
|
115
|
+
* styling block 33 times, and its create-post tool came to 378 KB, where
|
|
116
|
+
* sharing the repeats leaves 37 KB. Nothing is lost: each part reads the
|
|
117
|
+
* same, and Claude Code and Codex both read fields that appear only under
|
|
118
|
+
* `$defs`. Parts under 200 bytes stay inline, so the schema still reads top
|
|
119
|
+
* to bottom.
|
|
120
|
+
*
|
|
121
|
+
* A schema with a `$ref` to anything but its own definitions is returned as
|
|
122
|
+
* it is, since moving a part could break that reference. The work is linear in
|
|
123
|
+
* the schema's size: each distinct part is serialized once.
|
|
124
|
+
*/
|
|
125
|
+
export declare function shareRepeats(schema: JsonSchema, options?: {
|
|
126
|
+
minBytes?: number;
|
|
127
|
+
}): JsonSchema;
|
|
128
|
+
/**
|
|
129
|
+
* A property that is only a reference into the schema's own definitions, read
|
|
130
|
+
* as the definition it points to, with its own words first.
|
|
131
|
+
*/
|
|
132
|
+
export declare function resolveLocalRef<T extends Record<string, unknown>>(root: JsonSchema, node: T): T;
|
|
104
133
|
export {};
|
package/dist/schema.js
CHANGED
|
@@ -19,15 +19,21 @@ const TARGET = { target: "draft-2020-12" };
|
|
|
19
19
|
* Compiling all 123 schemas of one server up front held its first answer back
|
|
20
20
|
* by 118 ms, for tools most sessions never call. `slipway check` compiles every
|
|
21
21
|
* one, so a schema that cannot compile still fails before release.
|
|
22
|
+
*
|
|
23
|
+
* `shareRepeats: true` advertises the schema with each repeated part written
|
|
24
|
+
* once, as `shareRepeats()` describes.
|
|
22
25
|
*/
|
|
23
|
-
export function jsonSchema(schema) {
|
|
26
|
+
export function jsonSchema(schema, options = {}) {
|
|
24
27
|
let compiled;
|
|
28
|
+
let shared;
|
|
29
|
+
// Shared on first use, like the validator, so a command that never lists this tool never pays for it.
|
|
30
|
+
const advertisedSchema = () => (options.shareRepeats ? (shared ??= shareRepeats(schema)) : schema);
|
|
25
31
|
return {
|
|
26
32
|
"~standard": {
|
|
27
33
|
version: 1,
|
|
28
34
|
vendor: "mcp",
|
|
29
|
-
jsonSchema: { input:
|
|
30
|
-
validate: (value) => (compiled ??= fromJsonSchema(
|
|
35
|
+
jsonSchema: { input: advertisedSchema, output: advertisedSchema },
|
|
36
|
+
validate: (value) => (compiled ??= fromJsonSchema(advertisedSchema()))["~standard"].validate(value),
|
|
31
37
|
},
|
|
32
38
|
};
|
|
33
39
|
}
|
|
@@ -231,3 +237,204 @@ export function repeatedDefinitions(schema) {
|
|
|
231
237
|
visit(schema);
|
|
232
238
|
return [...seen.entries()].filter(([, count]) => count > 1).map(([fingerprint]) => fingerprint.split(":")[0]);
|
|
233
239
|
}
|
|
240
|
+
/** Keywords whose value is one subschema. */
|
|
241
|
+
const ONE_SCHEMA = new Set(["items", "additionalItems", "additionalProperties", "contains", "not", "if", "then", "else", "propertyNames", "unevaluatedItems", "unevaluatedProperties", "contentSchema"]);
|
|
242
|
+
/** Keywords whose value is a list of subschemas. */
|
|
243
|
+
const SCHEMA_LIST = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
|
|
244
|
+
/** Keywords whose value maps names to subschemas. */
|
|
245
|
+
const SCHEMA_MAP = new Set(["properties", "patternProperties", "dependentSchemas", "$defs", "definitions"]);
|
|
246
|
+
/** A part that carries one of these stays where it is: something may point into it, or it changes how references inside it resolve. */
|
|
247
|
+
const ANCHORED = ["$id", "$anchor", "$dynamicAnchor", "$defs", "definitions"];
|
|
248
|
+
/** Smaller parts stay inline, so the schema still reads top to bottom. */
|
|
249
|
+
const SHARE_MIN_BYTES = 200;
|
|
250
|
+
const CHILD = Symbol("child");
|
|
251
|
+
function isRecord(value) {
|
|
252
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
253
|
+
}
|
|
254
|
+
/** Every `$ref` in the schema points into its own definitions, so moving any other part breaks none of them. */
|
|
255
|
+
function refsOnlyInto(node, prefix) {
|
|
256
|
+
if (Array.isArray(node))
|
|
257
|
+
return node.every((item) => refsOnlyInto(item, prefix));
|
|
258
|
+
if (!isRecord(node))
|
|
259
|
+
return true;
|
|
260
|
+
if (node.$ref !== undefined && !(typeof node.$ref === "string" && node.$ref.startsWith(prefix)))
|
|
261
|
+
return false;
|
|
262
|
+
return Object.values(node).every((value) => refsOnlyInto(value, prefix));
|
|
263
|
+
}
|
|
264
|
+
/** A definition name a JSON pointer carries as is: letters, digits, `_`, `.` and `-`. */
|
|
265
|
+
function definitionName(label) {
|
|
266
|
+
return label.replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "shared";
|
|
267
|
+
}
|
|
268
|
+
/** What to call one of several options: its title, or the value its `type` property is fixed to, such as `paragraph`. */
|
|
269
|
+
function optionLabel(option, parent) {
|
|
270
|
+
if (!isRecord(option))
|
|
271
|
+
return `${parent}_option`;
|
|
272
|
+
if (typeof option.title === "string")
|
|
273
|
+
return option.title;
|
|
274
|
+
const discriminator = isRecord(option.properties) && isRecord(option.properties.type) ? option.properties.type : undefined;
|
|
275
|
+
const fixed = discriminator?.const ?? (Array.isArray(discriminator?.enum) && discriminator.enum.length === 1 ? discriminator.enum[0] : undefined);
|
|
276
|
+
return typeof fixed === "string" ? fixed : `${parent}_option`;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The same schema with each part that repeats written once, under `$defs`, and
|
|
280
|
+
* referred to with `$ref` everywhere it appeared.
|
|
281
|
+
*
|
|
282
|
+
* A schema generated from an API contract often spells one definition out
|
|
283
|
+
* everywhere it is used: one newsletter API's post body repeats the same
|
|
284
|
+
* styling block 33 times, and its create-post tool came to 378 KB, where
|
|
285
|
+
* sharing the repeats leaves 37 KB. Nothing is lost: each part reads the
|
|
286
|
+
* same, and Claude Code and Codex both read fields that appear only under
|
|
287
|
+
* `$defs`. Parts under 200 bytes stay inline, so the schema still reads top
|
|
288
|
+
* to bottom.
|
|
289
|
+
*
|
|
290
|
+
* A schema with a `$ref` to anything but its own definitions is returned as
|
|
291
|
+
* it is, since moving a part could break that reference. The work is linear in
|
|
292
|
+
* the schema's size: each distinct part is serialized once.
|
|
293
|
+
*/
|
|
294
|
+
export function shareRepeats(schema, options = {}) {
|
|
295
|
+
const minBytes = options.minBytes ?? SHARE_MIN_BYTES;
|
|
296
|
+
const key = !("$defs" in schema) && "definitions" in schema ? "definitions" : "$defs";
|
|
297
|
+
if ("$defs" in schema && "definitions" in schema)
|
|
298
|
+
return schema;
|
|
299
|
+
if (!refsOnlyInto(schema, `#/${key}/`))
|
|
300
|
+
return schema;
|
|
301
|
+
// Each distinct part once, by a signature built from its children's ids, so serializing is linear.
|
|
302
|
+
const parts = [];
|
|
303
|
+
const ids = new Map();
|
|
304
|
+
const build = (node, label) => {
|
|
305
|
+
if (!isRecord(node)) {
|
|
306
|
+
const json = JSON.stringify(node);
|
|
307
|
+
const known = ids.get(json);
|
|
308
|
+
if (known !== undefined)
|
|
309
|
+
return known;
|
|
310
|
+
parts.push({ template: node, bytes: Buffer.byteLength(json), label, movable: false, parents: new Map() });
|
|
311
|
+
ids.set(json, parts.length - 1);
|
|
312
|
+
return parts.length - 1;
|
|
313
|
+
}
|
|
314
|
+
const own = typeof node.title === "string" ? node.title : label;
|
|
315
|
+
const template = {};
|
|
316
|
+
const signature = [];
|
|
317
|
+
const children = [];
|
|
318
|
+
let bytes = 2 + Math.max(0, Object.keys(node).length - 1);
|
|
319
|
+
const child = (value, childLabel) => {
|
|
320
|
+
const id = build(value, childLabel);
|
|
321
|
+
children.push(id);
|
|
322
|
+
return { mark: { [CHILD]: id }, sig: `#${id}`, bytes: parts[id].bytes };
|
|
323
|
+
};
|
|
324
|
+
for (const [name, value] of Object.entries(node)) {
|
|
325
|
+
let entry;
|
|
326
|
+
if (ONE_SCHEMA.has(name) && (isRecord(value) || typeof value === "boolean")) {
|
|
327
|
+
entry = child(value, name === "items" ? `${own}_item` : own);
|
|
328
|
+
}
|
|
329
|
+
else if ((SCHEMA_LIST.has(name) || name === "items") && Array.isArray(value)) {
|
|
330
|
+
const list = value.map((item) => child(item, optionLabel(item, own)));
|
|
331
|
+
entry = { mark: list.map((item) => item.mark), sig: `[${list.map((item) => item.sig).join(",")}]`, bytes: 2 + Math.max(0, list.length - 1) + list.reduce((sum, item) => sum + item.bytes, 0) };
|
|
332
|
+
}
|
|
333
|
+
else if (SCHEMA_MAP.has(name) && isRecord(value)) {
|
|
334
|
+
const map = Object.entries(value).map(([field, sub]) => [JSON.stringify(field), child(sub, field)]);
|
|
335
|
+
entry = {
|
|
336
|
+
mark: Object.fromEntries(map.map(([field, item]) => [JSON.parse(field), item.mark])),
|
|
337
|
+
sig: `{${map.map(([field, item]) => `${field}:${item.sig}`).join(",")}}`,
|
|
338
|
+
bytes: 2 + Math.max(0, map.length - 1) + map.reduce((sum, [field, item]) => sum + Buffer.byteLength(field) + 1 + item.bytes, 0),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const json = JSON.stringify(value);
|
|
343
|
+
entry = { mark: value, sig: json, bytes: Buffer.byteLength(json) };
|
|
344
|
+
}
|
|
345
|
+
template[name] = entry.mark;
|
|
346
|
+
signature.push(`${JSON.stringify(name)}:${entry.sig}`);
|
|
347
|
+
bytes += Buffer.byteLength(JSON.stringify(name)) + 1 + entry.bytes;
|
|
348
|
+
}
|
|
349
|
+
const sig = `{${signature.join(",")}}`;
|
|
350
|
+
const known = ids.get(sig);
|
|
351
|
+
if (known !== undefined)
|
|
352
|
+
return known;
|
|
353
|
+
const id = parts.length;
|
|
354
|
+
parts.push({ template, bytes, label: own, movable: !ANCHORED.some((anchor) => anchor in node), parents: new Map() });
|
|
355
|
+
ids.set(sig, id);
|
|
356
|
+
for (const c of children)
|
|
357
|
+
parts[c].parents.set(id, (parts[c].parents.get(id) ?? 0) + 1);
|
|
358
|
+
return id;
|
|
359
|
+
};
|
|
360
|
+
const { [key]: existingDefs, ...rest } = schema;
|
|
361
|
+
const root = build(rest, "");
|
|
362
|
+
const names = new Map();
|
|
363
|
+
const taken = new Set();
|
|
364
|
+
const defsOrder = [];
|
|
365
|
+
// Two definitions with the same body keep both names: references in the schema use either.
|
|
366
|
+
const aliases = [];
|
|
367
|
+
for (const [name, body] of Object.entries(isRecord(existingDefs) ? existingDefs : {})) {
|
|
368
|
+
const id = build(body, name);
|
|
369
|
+
if (names.has(id))
|
|
370
|
+
aliases.push([name, id]);
|
|
371
|
+
else {
|
|
372
|
+
names.set(id, name);
|
|
373
|
+
defsOrder.push(id);
|
|
374
|
+
}
|
|
375
|
+
taken.add(name);
|
|
376
|
+
}
|
|
377
|
+
// How many times each part would be written out, deciding the larger ones first: a part is always larger than
|
|
378
|
+
// anything inside it, so every part that could contain this one is already decided.
|
|
379
|
+
const appear = new Array(parts.length).fill(0);
|
|
380
|
+
appear[root] = 1;
|
|
381
|
+
for (const id of defsOrder)
|
|
382
|
+
appear[id] = Math.max(appear[id], 1);
|
|
383
|
+
const order = parts.map((_, id) => id).sort((a, b) => parts[b].bytes - parts[a].bytes);
|
|
384
|
+
for (const id of order) {
|
|
385
|
+
const part = parts[id];
|
|
386
|
+
for (const [parent, times] of part.parents)
|
|
387
|
+
appear[id] += times * (names.has(parent) ? 1 : appear[parent]);
|
|
388
|
+
if (id === root || names.has(id) || !part.movable || part.bytes < minBytes || appear[id] < 2)
|
|
389
|
+
continue;
|
|
390
|
+
let name = definitionName(part.label);
|
|
391
|
+
for (let n = 2; taken.has(name); n++)
|
|
392
|
+
name = `${definitionName(part.label)}_${n}`;
|
|
393
|
+
const ref = Buffer.byteLength(JSON.stringify({ $ref: `#/${key}/${name}` }));
|
|
394
|
+
if ((appear[id] - 1) * part.bytes - appear[id] * ref - name.length - 4 <= 0)
|
|
395
|
+
continue;
|
|
396
|
+
names.set(id, name);
|
|
397
|
+
taken.add(name);
|
|
398
|
+
defsOrder.push(id);
|
|
399
|
+
}
|
|
400
|
+
if (!names.size)
|
|
401
|
+
return schema;
|
|
402
|
+
const fill = (template) => {
|
|
403
|
+
if (Array.isArray(template))
|
|
404
|
+
return template.map(fill);
|
|
405
|
+
if (!isRecord(template))
|
|
406
|
+
return template;
|
|
407
|
+
const marked = template[CHILD];
|
|
408
|
+
if (marked !== undefined)
|
|
409
|
+
return emit(marked, false);
|
|
410
|
+
return Object.fromEntries(Object.entries(template).map(([name, value]) => [name, fill(value)]));
|
|
411
|
+
};
|
|
412
|
+
const emit = (id, asDefinition) => {
|
|
413
|
+
const name = names.get(id);
|
|
414
|
+
return name !== undefined && !asDefinition ? { $ref: `#/${key}/${name}` } : fill(parts[id].template);
|
|
415
|
+
};
|
|
416
|
+
const out = fill(parts[root].template);
|
|
417
|
+
out[key] = Object.fromEntries([
|
|
418
|
+
...defsOrder.map((id) => [names.get(id), emit(id, true)]),
|
|
419
|
+
...aliases.map(([name, id]) => [name, { $ref: `#/${key}/${names.get(id)}` }]),
|
|
420
|
+
]);
|
|
421
|
+
// Nothing repeated, or only a definition that every use already refers to: the schema stays the object it was.
|
|
422
|
+
return schemaBytes(out) < schemaBytes(schema) ? out : schema;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* A property that is only a reference into the schema's own definitions, read
|
|
426
|
+
* as the definition it points to, with its own words first.
|
|
427
|
+
*/
|
|
428
|
+
export function resolveLocalRef(root, node) {
|
|
429
|
+
let current = node;
|
|
430
|
+
for (let hop = 0; hop < 8 && typeof current.$ref === "string"; hop++) {
|
|
431
|
+
const match = /^#\/(\$defs|definitions)\/([^/]+)$/.exec(current.$ref);
|
|
432
|
+
const defs = match ? root[match[1]] : undefined;
|
|
433
|
+
const target = match && isRecord(defs) ? defs[match[2].replace(/~1/g, "/").replace(/~0/g, "~")] : undefined;
|
|
434
|
+
if (!isRecord(target))
|
|
435
|
+
break;
|
|
436
|
+
const { $ref: _ref, ...own } = current;
|
|
437
|
+
current = { ...target, ...own };
|
|
438
|
+
}
|
|
439
|
+
return current;
|
|
440
|
+
}
|
package/dist/search.js
CHANGED
|
@@ -12,6 +12,8 @@ const STOP_WORDS = new Set([
|
|
|
12
12
|
]);
|
|
13
13
|
function words(text) {
|
|
14
14
|
return text
|
|
15
|
+
// camelCase splits, so `channelId` reads as "channel id" and `createPost` as "create post".
|
|
16
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
15
17
|
.toLowerCase()
|
|
16
18
|
.split(/[^a-z0-9]+/)
|
|
17
19
|
.filter((word) => word.length > 1)
|
|
@@ -91,11 +93,13 @@ export function searchTools(tools, query, limit = 10, synonyms = {}) {
|
|
|
91
93
|
const title = words(tool.title);
|
|
92
94
|
const tags = tool.tags.flatMap(words);
|
|
93
95
|
const description = words(tool.description);
|
|
96
|
+
// What a tool takes says what it is for: `channelId` and `schedulingType` find the post tool for "schedule a post to a channel".
|
|
97
|
+
const args = Object.keys(tool.jsonSchema.properties ?? {}).filter((key) => key !== "confirm").flatMap(words);
|
|
94
98
|
let score = 0;
|
|
95
99
|
let matched = 0;
|
|
96
100
|
for (const term of terms) {
|
|
97
101
|
// A term that is the tool's name is not a hint, it is the answer.
|
|
98
|
-
const got = (named(tool, term) ? 20 : 0) + hits(term, name, extra) * 5 + hits(term, title, extra) * 4 + hits(term, tags, extra) * 3 + hits(term, description, extra);
|
|
102
|
+
const got = (named(tool, term) ? 20 : 0) + hits(term, name, extra) * 5 + hits(term, title, extra) * 4 + hits(term, tags, extra) * 3 + hits(term, args, extra) * 2 + hits(term, description, extra);
|
|
99
103
|
if (got > 0)
|
|
100
104
|
matched++;
|
|
101
105
|
score += got;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thenavidm/slipway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Slipway, the TypeScript framework for MCP servers and agent-native CLIs. One tool definition ships an MCP server and a CLI, with write safety, typed results and release checks built in.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|