@qvac/harness 0.3.0 → 0.3.2
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 +262 -0
- package/dist/harness.bundle +15 -11
- package/dist-lib/index.d.ts +1 -1
- package/dist-lib/lib/capabilities/completion.d.ts +5 -1
- package/dist-lib/lib/capabilities/completion.js +20 -8
- package/dist-lib/lib/helpers/tool-call-markup.js +15 -7
- package/dist-lib/lib/no-redirect-fetch-impl.d.ts +3 -2
- package/dist-lib/lib/no-redirect-fetch-impl.js +4 -1
- package/dist-lib/lib/tools/exec/adapters/adapter.d.ts +5 -1
- package/dist-lib/lib/tools/exec/adapters/python/distributions.d.ts +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/distributions.js +4 -0
- package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.d.ts +72 -0
- package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.js +222 -0
- package/dist-lib/lib/tools/exec/adapters/python/interrupt-timer.d.ts +5 -0
- package/dist-lib/lib/tools/exec/adapters/python/interrupt-timer.js +29 -0
- package/dist-lib/lib/tools/exec/adapters/python/loader.js +2 -1
- package/dist-lib/lib/tools/exec/adapters/python/module-distributions.d.ts +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/module-distributions.js +52 -0
- package/dist-lib/lib/tools/exec/adapters/python/named-hosts.d.ts +2 -0
- package/dist-lib/lib/tools/exec/adapters/python/named-hosts.js +15 -0
- package/dist-lib/lib/tools/exec/adapters/python/net-shim.d.ts +2 -0
- package/dist-lib/lib/tools/exec/adapters/python/net-shim.js +148 -0
- package/dist-lib/lib/tools/exec/adapters/python/output-policy.js +13 -1
- package/dist-lib/lib/tools/exec/adapters/python/pyodide.d.ts +4 -1
- package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +42 -7
- package/dist-lib/lib/tools/exec/adapters/python/python-adapter.d.ts +9 -3
- package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +137 -21
- package/dist-lib/lib/tools/exec/adapters/python/run-session.d.ts +5 -0
- package/dist-lib/lib/tools/exec/adapters/python/run-session.js +33 -8
- package/dist-lib/lib/tools/exec/adapters/python/wheels.d.ts +6 -0
- package/dist-lib/lib/tools/exec/adapters/python/wheels.js +17 -3
- package/dist-lib/lib/tools/exec/adapters/python/worker/client.d.ts +2 -1
- package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +16 -3
- package/dist-lib/lib/tools/exec/adapters/python/worker/entry.js +15 -2
- package/dist-lib/lib/tools/exec/adapters/python/worker/protocol.d.ts +6 -0
- package/dist-lib/lib/tools/exec/adapters/python/worker/protocol.js +6 -2
- package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-bare.d.ts +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-bare.js +8 -0
- package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-node.d.ts +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-node.js +8 -0
- package/dist-lib/lib/tools/exec/adapters/shell/shell-adapter.js +7 -1
- package/dist-lib/lib/tools/exec/shell-guidance.d.ts +10 -0
- package/dist-lib/lib/tools/exec/shell-guidance.js +123 -0
- package/dist-lib/lib/tools/exec/video-tools.d.ts +5 -0
- package/dist-lib/lib/tools/exec/video-tools.js +40 -0
- package/dist-lib/lib/tools/exec.d.ts +2 -1
- package/dist-lib/lib/tools/exec.js +93 -33
- package/dist-lib/lib/tools/http-request/truncate-response.d.ts +1 -0
- package/dist-lib/lib/tools/http-request/truncate-response.js +1 -1
- package/dist-lib/lib/tools/http-request.js +4 -1
- package/dist-lib/lib/tools/operations/builders.d.ts +1 -1
- package/dist-lib/lib/tools/operations/builders.js +9 -0
- package/dist-lib/lib/tools/operations/compile.js +41 -18
- package/dist-lib/lib/tools/operations/decoders.d.ts +6 -0
- package/dist-lib/lib/tools/operations/decoders.js +82 -0
- package/dist-lib/lib/tools/operations/manifest.d.ts +16 -2
- package/dist-lib/lib/tools/operations/manifest.js +6 -1
- package/dist-lib/lib/tools/skill.d.ts +3 -0
- package/dist-lib/lib/tools/skill.js +38 -5
- package/dist-lib/lib/tools/skills/manifest-parse.js +43 -1
- package/dist-lib/lib/tools/skills/manifest.d.ts +13 -0
- package/dist-lib/schema/harness.js +20 -1
- package/dist-lib/spec/hrpc/messages.js +93 -5
- package/dist-lib/spec/hyperschema/index.js +93 -5
- package/dist-lib/spec/hyperschema/schema.json +66 -1
- package/dist-lib/spec/hyperschema/types.d.ts +18 -1
- package/package.json +40 -2
- package/wheels/manifest.json +15 -0
|
@@ -2,12 +2,15 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { AbortedError } from '../helpers/race-abort.js';
|
|
4
4
|
import { spawnEnv } from '../helpers/spawn-path.js';
|
|
5
|
-
import { checkPdfBuildSource, checkBareLengthArgs, checkPptxBuildSource, checkPythonSource, checkUnstagedRead, createPythonAdapter, isPythonLanguage,
|
|
5
|
+
import { checkPdfBuildSource, checkBareLengthArgs, checkPptxBuildSource, checkPythonSource, checkUnstagedRead, createPythonAdapter, isPythonLanguage, parseInputs, parseOutputs, parsePackages, parseWorkdir, pinned, runsThroughPyodide, unwrapPythonSource } from './exec/adapters/python/python-adapter.js';
|
|
6
6
|
import { pythonCreateEffect } from './exec/adapters/python/create-effect.js';
|
|
7
|
+
import { namedHosts } from './exec/adapters/python/named-hosts.js';
|
|
8
|
+
import { pythonReachesNetwork } from './exec/adapters/python/net-shim.js';
|
|
7
9
|
import { createShellAdapter } from './exec/adapters/shell/shell-adapter.js';
|
|
8
10
|
import { routeObsidianCommand } from './exec/obsidian/route.js';
|
|
9
11
|
import { MAX_OUTPUT_CHARS_LIMIT } from './exec/runner.js';
|
|
10
12
|
import { bareBin, firstCommandToken, sanitizeCommand } from './exec/scope.js';
|
|
13
|
+
import { FILES_NEED_PYTHON_ERROR, isPythonSourceShaped, PACKAGES_NEED_PYTHON_ERROR, pythonRedirectError, startsLikePython, videoToolError } from './exec/shell-guidance.js';
|
|
11
14
|
import { shellArgv } from './exec/shell-argv.js';
|
|
12
15
|
import { canonicalDenyRoots, defaultRealpath, hostPath, resolveKey } from './fs/scope.js';
|
|
13
16
|
import { unquoteArg } from './model-args.js';
|
|
@@ -64,7 +67,7 @@ function isPresent(value) {
|
|
|
64
67
|
}
|
|
65
68
|
function checkFileArgs(args, python) {
|
|
66
69
|
if (!python && (isPresent(args.inputs) || isPresent(args.outputs))) {
|
|
67
|
-
return
|
|
70
|
+
return FILES_NEED_PYTHON_ERROR;
|
|
68
71
|
}
|
|
69
72
|
const inputs = parseInputs(args.inputs);
|
|
70
73
|
if (inputs && 'error' in inputs)
|
|
@@ -80,6 +83,34 @@ function checkFileArgs(args, python) {
|
|
|
80
83
|
function truncateLabel(text) {
|
|
81
84
|
return text.length > 120 ? `${text.slice(0, 120)}…` : text;
|
|
82
85
|
}
|
|
86
|
+
const CONNECT_ANSWER_MS = 60_000;
|
|
87
|
+
function connectAsker(askUser) {
|
|
88
|
+
return async (host) => {
|
|
89
|
+
let timer;
|
|
90
|
+
const late = new Promise((resolve) => {
|
|
91
|
+
timer = setTimeout(() => resolve(null), CONNECT_ANSWER_MS);
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
const answer = await Promise.race([
|
|
95
|
+
askUser({
|
|
96
|
+
question: `This Python run wants to connect to ${host}, which its approved code does not name. Allow it for this run?`,
|
|
97
|
+
options: ['Allow', 'Deny']
|
|
98
|
+
}),
|
|
99
|
+
late
|
|
100
|
+
]);
|
|
101
|
+
return /^allow/i.test((answer ?? '').trim());
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function networkPrefix(source) {
|
|
109
|
+
const hosts = namedHosts(source);
|
|
110
|
+
if (hosts.length === 0)
|
|
111
|
+
return ' · network: no host named in the code';
|
|
112
|
+
return ` · network: ${hosts.join(', ')}`;
|
|
113
|
+
}
|
|
83
114
|
function packageSuffix(args) {
|
|
84
115
|
if (!Array.isArray(args.packages) || args.packages.length === 0)
|
|
85
116
|
return '';
|
|
@@ -100,13 +131,25 @@ function fileSuffix(args) {
|
|
|
100
131
|
const to = Array.isArray(outputs) && outputs.length > 0 ? ` → ${truncateLabel(outputs.join(', '))}` : '';
|
|
101
132
|
return `${from}${to}`;
|
|
102
133
|
}
|
|
103
|
-
|
|
104
|
-
|
|
134
|
+
function stringList(value) {
|
|
135
|
+
if (!Array.isArray(value))
|
|
136
|
+
return undefined;
|
|
137
|
+
return value.filter((v) => typeof v === 'string');
|
|
138
|
+
}
|
|
139
|
+
function commandRequiredError(args, network) {
|
|
105
140
|
const staged = Array.isArray(args.inputs) || Array.isArray(args.outputs);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
'python source) or script + scriptArgs exactly as the loaded skill reference shows'
|
|
109
|
-
|
|
141
|
+
if (staged) {
|
|
142
|
+
return ('command is required: keep these same inputs/outputs and add either command (the bare ' +
|
|
143
|
+
'python source) or script + scriptArgs exactly as the loaded skill reference shows');
|
|
144
|
+
}
|
|
145
|
+
const packages = stringList(args.packages);
|
|
146
|
+
const impossible = videoToolError('', packages, true, network);
|
|
147
|
+
if (impossible)
|
|
148
|
+
return impossible;
|
|
149
|
+
if (packages?.length) {
|
|
150
|
+
return `command is required: call exec again with language "python", packages: ${JSON.stringify(packages.map(pinned))} and the Python source that uses them in command. Installing and running are the same one call.`;
|
|
151
|
+
}
|
|
152
|
+
return 'command is required';
|
|
110
153
|
}
|
|
111
154
|
function parseScriptArgs(value) {
|
|
112
155
|
if (Array.isArray(value))
|
|
@@ -207,18 +250,27 @@ function scriptLabel(args) {
|
|
|
207
250
|
}
|
|
208
251
|
const WORKDIR_NO_ROOTS_ERROR = 'workdir is not available: no directories are granted to this device, so a working directory ' +
|
|
209
252
|
'cannot be validated. Omit workdir and use absolute paths in the command.';
|
|
210
|
-
function
|
|
253
|
+
function withInferredLanguage(args, disablePyodide) {
|
|
254
|
+
if (disablePyodide || isPythonLanguage(args.language))
|
|
255
|
+
return args;
|
|
256
|
+
const command = String(args.command ?? '').trim();
|
|
257
|
+
const declared = (stringList(args.packages) ?? []).length > 0 && startsLikePython(command);
|
|
258
|
+
const wrapped = unwrapPythonSource(command) !== null;
|
|
259
|
+
return isPythonSourceShaped(command) || declared || wrapped
|
|
260
|
+
? { ...args, language: 'python' }
|
|
261
|
+
: args;
|
|
262
|
+
}
|
|
263
|
+
function checkExecutionScope(args, toolOps, disablePyodide, sandbox, network) {
|
|
211
264
|
const command = String(args.command ?? '').trim();
|
|
265
|
+
const video = videoToolError(command, stringList(args.packages), runsThroughPyodide(args.language, disablePyodide), network);
|
|
266
|
+
if (video)
|
|
267
|
+
return video;
|
|
212
268
|
if (runsThroughPyodide(args.language, disablePyodide))
|
|
213
269
|
return null;
|
|
214
|
-
if (!disablePyodide
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const sourceShaped = (/^from\s+\w+\s+import\b/.test(command) || command.includes('\n')) &&
|
|
219
|
-
looksLikePythonSource(command);
|
|
220
|
-
if (!disablePyodide && (callsFirst || sourceShaped)) {
|
|
221
|
-
return 'this command is Python source: pass language "python" with the bare source in command (dependencies in packages, produced files in outputs)';
|
|
270
|
+
if (!disablePyodide) {
|
|
271
|
+
const redirect = pythonRedirectError(command, sandbox);
|
|
272
|
+
if (redirect)
|
|
273
|
+
return redirect;
|
|
222
274
|
}
|
|
223
275
|
return checkShellScope(command, toolOps);
|
|
224
276
|
}
|
|
@@ -232,8 +284,8 @@ async function skillsEnv(skillSettings, ctx) {
|
|
|
232
284
|
}
|
|
233
285
|
return Object.keys(env).length > 0 ? env : undefined;
|
|
234
286
|
}
|
|
235
|
-
export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide = false, credentialStore, skillSettings, realpath = defaultRealpath, deniedRoots, skillsDir } = {}) {
|
|
236
|
-
const adapters = [createPythonAdapter(disablePyodide, loadPyodide)];
|
|
287
|
+
export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide = false, pythonNetwork = !disablePyodide && pythonReachesNetwork(), credentialStore, skillSettings, realpath = defaultRealpath, deniedRoots, skillsDir } = {}) {
|
|
288
|
+
const adapters = [createPythonAdapter(disablePyodide, loadPyodide, pythonNetwork)];
|
|
237
289
|
const fallback = createShellAdapter({ spawn, killProcess, loadRuntime: bareRuntime });
|
|
238
290
|
return {
|
|
239
291
|
name: 'exec',
|
|
@@ -347,7 +399,8 @@ export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide
|
|
|
347
399
|
}
|
|
348
400
|
},
|
|
349
401
|
autoApprove: false,
|
|
350
|
-
effect(
|
|
402
|
+
effect(rawArgs, ctx) {
|
|
403
|
+
const args = withInferredLanguage(rawArgs, disablePyodide);
|
|
351
404
|
if (typeof args.script === 'string' && args.script.trim() && !args.script.includes('\n')) {
|
|
352
405
|
return 'read';
|
|
353
406
|
}
|
|
@@ -359,11 +412,11 @@ export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide
|
|
|
359
412
|
const scriptRun = resolveScriptRun(args, skillsDir);
|
|
360
413
|
if (scriptRun && 'error' in scriptRun)
|
|
361
414
|
return scriptRun.error;
|
|
362
|
-
const rawArgs = scriptRun ? scriptRun.args : args;
|
|
415
|
+
const rawArgs = withInferredLanguage(scriptRun ? scriptRun.args : args, disablePyodide);
|
|
363
416
|
const command = String(rawArgs.command ?? '').trim();
|
|
364
417
|
if (!command)
|
|
365
|
-
return commandRequiredError(rawArgs);
|
|
366
|
-
const scopeError = checkExecutionScope(rawArgs, ctx?.toolOps, disablePyodide);
|
|
418
|
+
return commandRequiredError(rawArgs, pythonNetwork && ctx?.sandboxMode !== true);
|
|
419
|
+
const scopeError = checkExecutionScope(rawArgs, ctx?.toolOps, disablePyodide, ctx?.sandboxMode === true, pythonNetwork && ctx?.sandboxMode !== true);
|
|
367
420
|
if (scopeError)
|
|
368
421
|
return scopeError;
|
|
369
422
|
const python = runsThroughPyodide(rawArgs.language, disablePyodide);
|
|
@@ -400,12 +453,12 @@ export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide
|
|
|
400
453
|
const parsed = parsePackages(rawArgs.packages);
|
|
401
454
|
if (parsed && 'error' in parsed)
|
|
402
455
|
return parsed.error;
|
|
403
|
-
if (parsed && !python)
|
|
404
|
-
return
|
|
405
|
-
}
|
|
456
|
+
if (parsed && !python)
|
|
457
|
+
return PACKAGES_NEED_PYTHON_ERROR;
|
|
406
458
|
return checkFileArgs(rawArgs, python);
|
|
407
459
|
},
|
|
408
|
-
resource(
|
|
460
|
+
resource(rawArgs, ctx) {
|
|
461
|
+
const args = withInferredLanguage(rawArgs, disablePyodide);
|
|
409
462
|
const script = typeof args.script === 'string' ? args.script : '';
|
|
410
463
|
if (script.trim() && !script.includes('\n')) {
|
|
411
464
|
return { kind: 'command', label: scriptLabel(args) };
|
|
@@ -414,8 +467,10 @@ export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide
|
|
|
414
467
|
if (!command)
|
|
415
468
|
return null;
|
|
416
469
|
const workdir = typeof args.workdir === 'string' && args.workdir.trim() ? args.workdir.trim() : null;
|
|
470
|
+
const source = unwrapPythonSource(command) ?? command;
|
|
471
|
+
const online = pythonNetwork && ctx?.sandboxMode !== true;
|
|
417
472
|
const body = runsThroughPyodide(args.language, disablePyodide)
|
|
418
|
-
? `python${packageSuffix(args)}${fileSuffix(args)}: ${
|
|
473
|
+
? `python${online ? networkPrefix(source) : ''}${packageSuffix(args)}${fileSuffix(args)}: ${source.slice(0, 120)}`
|
|
419
474
|
: command;
|
|
420
475
|
return {
|
|
421
476
|
kind: 'command',
|
|
@@ -426,11 +481,12 @@ export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide
|
|
|
426
481
|
const scriptRun = resolveScriptRun(args, skillsDir);
|
|
427
482
|
if (scriptRun && 'error' in scriptRun)
|
|
428
483
|
return { error: scriptRun.error };
|
|
429
|
-
const rawArgs = scriptRun ? scriptRun.args : args;
|
|
484
|
+
const rawArgs = withInferredLanguage(scriptRun ? scriptRun.args : args, disablePyodide);
|
|
430
485
|
const command = String(rawArgs.command ?? '').trim();
|
|
431
|
-
if (!command)
|
|
432
|
-
return { error: commandRequiredError(rawArgs) };
|
|
433
|
-
|
|
486
|
+
if (!command) {
|
|
487
|
+
return { error: commandRequiredError(rawArgs, pythonNetwork && ctx?.sandboxMode !== true) };
|
|
488
|
+
}
|
|
489
|
+
const scopeError = checkExecutionScope(rawArgs, ctx?.toolOps, disablePyodide, ctx?.sandboxMode === true, pythonNetwork && ctx?.sandboxMode !== true);
|
|
434
490
|
if (scopeError)
|
|
435
491
|
return { error: scopeError };
|
|
436
492
|
const workdir = rawArgs.workdir === undefined || rawArgs.workdir === null
|
|
@@ -488,7 +544,11 @@ export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide
|
|
|
488
544
|
attach: ctx?.attach,
|
|
489
545
|
fetchAttachment: ctx?.fetchAttachment,
|
|
490
546
|
turnImages: ctx?.turnImages,
|
|
491
|
-
runBudget: ctx?.runBudget
|
|
547
|
+
runBudget: ctx?.runBudget,
|
|
548
|
+
sandboxMode: ctx?.sandboxMode,
|
|
549
|
+
toolNames: ctx?.toolNames,
|
|
550
|
+
approval: ctx?.approval,
|
|
551
|
+
confirmConnect: ctx?.askUser ? connectAsker(ctx.askUser) : undefined
|
|
492
552
|
});
|
|
493
553
|
return await execution.run({
|
|
494
554
|
cwd,
|
|
@@ -20,7 +20,7 @@ function shrinkable(value) {
|
|
|
20
20
|
return true;
|
|
21
21
|
return typeof value === 'object' && value !== null;
|
|
22
22
|
}
|
|
23
|
-
const ELISION = '… [truncated]';
|
|
23
|
+
export const ELISION = '… [truncated]';
|
|
24
24
|
function fitString(value, budget) {
|
|
25
25
|
const room = budget - ELISION.length - 2;
|
|
26
26
|
if (room <= 0)
|
|
@@ -159,7 +159,10 @@ export function createHttpRequestTool({ fetch: fetchImpl, credentialStore, crede
|
|
|
159
159
|
credentialKey = next.credentialKey;
|
|
160
160
|
}
|
|
161
161
|
const text = await response.text();
|
|
162
|
-
return {
|
|
162
|
+
return {
|
|
163
|
+
status: response.status,
|
|
164
|
+
body: ctx?.uncappedResponse ? text : truncateResponseBody(text)
|
|
165
|
+
};
|
|
163
166
|
}
|
|
164
167
|
catch (err) {
|
|
165
168
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
@@ -12,6 +12,6 @@ export type BuilderOutcome = {
|
|
|
12
12
|
export interface OperationBuilder {
|
|
13
13
|
build: (args: Record<string, unknown>) => BuilderOutcome;
|
|
14
14
|
}
|
|
15
|
-
export declare const BUILDER_NAMES: readonly ['gmail-send', 'gmail-draft', 'calendar-meet-event', 'calendar-add-meet', 'docs-create', 'docs-append-text', 'sheets-create', 'sheets-write-values', 'sheets-append-values', 'drive-create-folder', 'drive-trash', 'notion-create-page', 'notion-insert-content'];
|
|
15
|
+
export declare const BUILDER_NAMES: readonly ['gmail-send', 'gmail-draft', 'gmail-get', 'calendar-meet-event', 'calendar-add-meet', 'docs-create', 'docs-append-text', 'sheets-create', 'sheets-write-values', 'sheets-append-values', 'drive-create-folder', 'drive-trash', 'notion-create-page', 'notion-insert-content'];
|
|
16
16
|
export type BuilderName = (typeof BUILDER_NAMES)[number];
|
|
17
17
|
export declare const BUILDERS: Record<BuilderName, OperationBuilder>;
|
|
@@ -5,6 +5,7 @@ import { buildGmailMessage, GmailEnvelopeSchema } from './gmail-message.js';
|
|
|
5
5
|
export const BUILDER_NAMES = [
|
|
6
6
|
'gmail-send',
|
|
7
7
|
'gmail-draft',
|
|
8
|
+
'gmail-get',
|
|
8
9
|
'calendar-meet-event',
|
|
9
10
|
'calendar-add-meet',
|
|
10
11
|
'docs-create',
|
|
@@ -40,6 +41,13 @@ const gmailDraft = builder(GmailMessageSchema, (args) => ({
|
|
|
40
41
|
body: { message: { raw: buildGmailMessage(args), threadId: args.threadId } },
|
|
41
42
|
encode: { 'message.raw': 'base64url' }
|
|
42
43
|
}));
|
|
44
|
+
const GmailGetSchema = z.object({
|
|
45
|
+
id: z.string().min(1),
|
|
46
|
+
format: z.enum(['metadata', 'full']).optional()
|
|
47
|
+
});
|
|
48
|
+
const gmailGet = builder(GmailGetSchema, (args) => ({
|
|
49
|
+
query: { format: args.format ?? 'metadata' }
|
|
50
|
+
}));
|
|
43
51
|
function createRequest() {
|
|
44
52
|
return {
|
|
45
53
|
createRequest: {
|
|
@@ -165,6 +173,7 @@ const notionInsertContent = builder(NotionInsertSchema, (args) => ({
|
|
|
165
173
|
export const BUILDERS = {
|
|
166
174
|
'gmail-send': gmailSend,
|
|
167
175
|
'gmail-draft': gmailDraft,
|
|
176
|
+
'gmail-get': gmailGet,
|
|
168
177
|
'calendar-meet-event': calendarMeetEvent,
|
|
169
178
|
'calendar-add-meet': calendarAddMeet,
|
|
170
179
|
'docs-create': docsCreate,
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { READ_METHODS } from '../http-methods.js';
|
|
3
|
+
import { truncateResponseBody } from '../http-request/truncate-response.js';
|
|
1
4
|
import { BUILDERS } from './builders.js';
|
|
5
|
+
import { DECODERS } from './decoders.js';
|
|
2
6
|
export function compileOperationTools(catalog, { httpRequestTool, mcpCallTool }) {
|
|
3
7
|
return catalog.flatMap((entry) => (entry.operations ?? []).flatMap((operation) => {
|
|
4
8
|
const transport = operation.request.transport === 'mcp' ? mcpCallTool : httpRequestTool;
|
|
@@ -8,18 +12,19 @@ export function compileOperationTools(catalog, { httpRequestTool, mcpCallTool })
|
|
|
8
12
|
function operationTool(owningSkill, operation, transportTool) {
|
|
9
13
|
const builder = BUILDERS[operation.request.builder];
|
|
10
14
|
const mcp = operation.request.transport === 'mcp';
|
|
15
|
+
const read = !mcp && READ_METHODS.has(operation.request.method);
|
|
11
16
|
return {
|
|
12
17
|
name: operation.tool,
|
|
13
18
|
owningSkill,
|
|
14
19
|
description: operation.description,
|
|
15
20
|
parameters: operation.parameters,
|
|
16
|
-
autoApprove:
|
|
21
|
+
autoApprove: read,
|
|
17
22
|
skillGated: true,
|
|
18
|
-
externalWrite:
|
|
23
|
+
externalWrite: !read,
|
|
19
24
|
toolset: mcp ? 'mcp-call' : 'http-request',
|
|
20
25
|
compiled: true,
|
|
21
26
|
effect() {
|
|
22
|
-
return 'write';
|
|
27
|
+
return read ? 'read' : 'write';
|
|
23
28
|
},
|
|
24
29
|
resource() {
|
|
25
30
|
return hostOf(operation.request.url);
|
|
@@ -29,6 +34,9 @@ function operationTool(owningSkill, operation, transportTool) {
|
|
|
29
34
|
const built = builder.build(args);
|
|
30
35
|
if ('error' in built)
|
|
31
36
|
return { error: `${operation.tool}: ${built.error}` };
|
|
37
|
+
if (read && (built.request.body !== undefined || built.request.encode !== undefined)) {
|
|
38
|
+
return { error: `${operation.tool}: a read operation cannot send a body` };
|
|
39
|
+
}
|
|
32
40
|
const request = operation.request;
|
|
33
41
|
if (request.transport === 'mcp') {
|
|
34
42
|
return transportTool.execute({ url: request.url, method: request.method, params: built.request.params }, skillContext);
|
|
@@ -36,38 +44,53 @@ function operationTool(owningSkill, operation, transportTool) {
|
|
|
36
44
|
const url = fillTemplate(request.url, args, request.urlDefaults);
|
|
37
45
|
if ('error' in url)
|
|
38
46
|
return { error: `${operation.tool}: ${url.error}` };
|
|
47
|
+
const { response } = operation;
|
|
39
48
|
const result = await transportTool.execute({
|
|
40
49
|
url: url.value,
|
|
41
50
|
method: request.method,
|
|
42
51
|
query: { ...request.query, ...built.request.query },
|
|
43
52
|
body: built.request.body,
|
|
44
53
|
encode: built.request.encode
|
|
45
|
-
}, skillContext);
|
|
46
|
-
return
|
|
54
|
+
}, response ? { ...skillContext, uncappedResponse: true } : skillContext);
|
|
55
|
+
return response ? capBody(shapeResponse(result, response)) : result;
|
|
47
56
|
}
|
|
48
57
|
};
|
|
49
58
|
}
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
const JsonResult = z.looseObject({ status: z.number().optional(), body: z.string() });
|
|
60
|
+
function capBody(result) {
|
|
61
|
+
const parsed = JsonResult.safeParse(result);
|
|
62
|
+
if (!parsed.success)
|
|
63
|
+
return result;
|
|
64
|
+
return { ...parsed.data, body: truncateResponseBody(parsed.data.body) };
|
|
65
|
+
}
|
|
66
|
+
function shapeResponse(result, response) {
|
|
67
|
+
const parsed = JsonResult.safeParse(result);
|
|
68
|
+
if (!parsed.success)
|
|
52
69
|
return result;
|
|
53
|
-
const {
|
|
54
|
-
if (
|
|
70
|
+
const { status } = parsed.data;
|
|
71
|
+
if (status !== undefined && status >= 400)
|
|
55
72
|
return result;
|
|
56
|
-
|
|
73
|
+
const body = jsonObject(parsed.data.body);
|
|
74
|
+
if (!body)
|
|
75
|
+
return result;
|
|
76
|
+
const shaped = 'pick' in response ? pick(body, response.pick) : DECODERS[response.decode].decode(body);
|
|
77
|
+
return { ...parsed.data, body: JSON.stringify(shaped) };
|
|
78
|
+
}
|
|
79
|
+
function jsonObject(body) {
|
|
57
80
|
try {
|
|
58
|
-
parsed = JSON.parse(body);
|
|
81
|
+
const parsed = z.record(z.string(), z.unknown()).safeParse(JSON.parse(body));
|
|
82
|
+
return parsed.success ? parsed.data : null;
|
|
59
83
|
}
|
|
60
84
|
catch {
|
|
61
|
-
return
|
|
85
|
+
return null;
|
|
62
86
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const record = parsed;
|
|
87
|
+
}
|
|
88
|
+
function pick(body, keys) {
|
|
66
89
|
const picked = {};
|
|
67
90
|
for (const key of keys)
|
|
68
|
-
if (key in
|
|
69
|
-
picked[key] =
|
|
70
|
-
return
|
|
91
|
+
if (key in body)
|
|
92
|
+
picked[key] = body[key];
|
|
93
|
+
return picked;
|
|
71
94
|
}
|
|
72
95
|
const PLACEHOLDER = /\{(\w+)\}/g;
|
|
73
96
|
function fillTemplate(template, args, defaults) {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface ResponseDecoder {
|
|
2
|
+
decode: (body: Record<string, unknown>) => Record<string, unknown>;
|
|
3
|
+
}
|
|
4
|
+
export declare const DECODER_NAMES: readonly ['gmail-message'];
|
|
5
|
+
export type DecoderName = (typeof DECODER_NAMES)[number];
|
|
6
|
+
export declare const DECODERS: Record<DecoderName, ResponseDecoder>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import b4a from 'b4a';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { extractPageText } from '../../web-fetch/extract.js';
|
|
4
|
+
import { decodeHtmlEntities } from '../../web-search/html-helpers.js';
|
|
5
|
+
import { ELISION } from '../http-request/truncate-response.js';
|
|
6
|
+
export const DECODER_NAMES = ['gmail-message'];
|
|
7
|
+
const KEPT_HEADERS = ['From', 'To', 'Subject', 'Date', 'Message-ID', 'References'];
|
|
8
|
+
const MAX_TEXT_CHARS = 4000;
|
|
9
|
+
const PartSchema = z.lazy(() => z.object({
|
|
10
|
+
mimeType: z.string().optional(),
|
|
11
|
+
filename: z.string().optional(),
|
|
12
|
+
headers: z.array(z.object({ name: z.string(), value: z.string() })).optional(),
|
|
13
|
+
body: z.object({ data: z.string().optional() }).optional(),
|
|
14
|
+
parts: z.array(PartSchema).optional()
|
|
15
|
+
}));
|
|
16
|
+
const MessageSchema = z.object({
|
|
17
|
+
id: z.string().optional(),
|
|
18
|
+
threadId: z.string().optional(),
|
|
19
|
+
labelIds: z.array(z.string()).optional(),
|
|
20
|
+
snippet: z.string().optional(),
|
|
21
|
+
payload: PartSchema.optional()
|
|
22
|
+
});
|
|
23
|
+
const gmailMessage = {
|
|
24
|
+
decode(body) {
|
|
25
|
+
const parsed = MessageSchema.safeParse(body);
|
|
26
|
+
if (!parsed.success)
|
|
27
|
+
return body;
|
|
28
|
+
const { id, threadId, labelIds, snippet, payload } = parsed.data;
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
threadId,
|
|
32
|
+
labelIds,
|
|
33
|
+
headers: payload && keptHeaders(payload),
|
|
34
|
+
snippet: snippet && decodeHtmlEntities(snippet),
|
|
35
|
+
text: payload && capped(textOf(payload))
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
function keptHeaders(part) {
|
|
40
|
+
const kept = {};
|
|
41
|
+
for (const header of part.headers ?? []) {
|
|
42
|
+
const name = KEPT_HEADERS.find((known) => known.toLowerCase() === header.name.toLowerCase());
|
|
43
|
+
if (name && !(name in kept))
|
|
44
|
+
kept[name] = header.value;
|
|
45
|
+
}
|
|
46
|
+
return kept;
|
|
47
|
+
}
|
|
48
|
+
function textOf(part) {
|
|
49
|
+
const plain = leaves(part, 'text/plain');
|
|
50
|
+
if (plain.length)
|
|
51
|
+
return plain.join('\n\n');
|
|
52
|
+
const html = leaves(part, 'text/html')
|
|
53
|
+
.map((leaf) => extractPageText(leaf).text)
|
|
54
|
+
.filter((text) => text.length > 0);
|
|
55
|
+
if (html.length)
|
|
56
|
+
return html.join('\n\n');
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
function leaves(part, mimeType) {
|
|
60
|
+
if (part.parts?.length)
|
|
61
|
+
return part.parts.flatMap((child) => leaves(child, mimeType));
|
|
62
|
+
if (part.mimeType?.toLowerCase() !== mimeType || part.filename || !part.body?.data)
|
|
63
|
+
return [];
|
|
64
|
+
const text = decodeData(part.body.data);
|
|
65
|
+
return text ? [text] : [];
|
|
66
|
+
}
|
|
67
|
+
function decodeData(data) {
|
|
68
|
+
try {
|
|
69
|
+
return b4a.toString(b4a.from(data, 'base64url'), 'utf8').replace(/\r\n/g, '\n').trim();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function capped(text) {
|
|
76
|
+
if (text === undefined || text.length <= MAX_TEXT_CHARS)
|
|
77
|
+
return text;
|
|
78
|
+
return `${text.slice(0, MAX_TEXT_CHARS)}${ELISION}`;
|
|
79
|
+
}
|
|
80
|
+
export const DECODERS = {
|
|
81
|
+
'gmail-message': gmailMessage
|
|
82
|
+
};
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ToolParameters } from '../../capabilities/completion.ts';
|
|
3
|
+
declare const ResponseSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
4
|
+
pick: z.ZodArray<z.ZodString>;
|
|
5
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
6
|
+
decode: z.ZodEnum<{
|
|
7
|
+
"gmail-message": "gmail-message";
|
|
8
|
+
}>;
|
|
9
|
+
}, z.core.$strict>]>;
|
|
3
10
|
declare const OperationSchema: z.ZodObject<{
|
|
4
11
|
tool: z.ZodString;
|
|
5
12
|
description: z.ZodString;
|
|
@@ -16,6 +23,7 @@ declare const OperationSchema: z.ZodObject<{
|
|
|
16
23
|
"drive-create-folder": "drive-create-folder";
|
|
17
24
|
"drive-trash": "drive-trash";
|
|
18
25
|
"gmail-draft": "gmail-draft";
|
|
26
|
+
"gmail-get": "gmail-get";
|
|
19
27
|
"gmail-send": "gmail-send";
|
|
20
28
|
"notion-create-page": "notion-create-page";
|
|
21
29
|
"notion-insert-content": "notion-insert-content";
|
|
@@ -43,6 +51,7 @@ declare const OperationSchema: z.ZodObject<{
|
|
|
43
51
|
"drive-create-folder": "drive-create-folder";
|
|
44
52
|
"drive-trash": "drive-trash";
|
|
45
53
|
"gmail-draft": "gmail-draft";
|
|
54
|
+
"gmail-get": "gmail-get";
|
|
46
55
|
"gmail-send": "gmail-send";
|
|
47
56
|
"notion-create-page": "notion-create-page";
|
|
48
57
|
"notion-insert-content": "notion-insert-content";
|
|
@@ -51,10 +60,15 @@ declare const OperationSchema: z.ZodObject<{
|
|
|
51
60
|
"sheets-write-values": "sheets-write-values";
|
|
52
61
|
}>;
|
|
53
62
|
}, z.core.$strip>]>;
|
|
54
|
-
response: z.ZodOptional<z.ZodObject<{
|
|
63
|
+
response: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
55
64
|
pick: z.ZodArray<z.ZodString>;
|
|
56
|
-
}, z.core.$
|
|
65
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
66
|
+
decode: z.ZodEnum<{
|
|
67
|
+
"gmail-message": "gmail-message";
|
|
68
|
+
}>;
|
|
69
|
+
}, z.core.$strict>]>>;
|
|
57
70
|
}, z.core.$strip>;
|
|
58
71
|
export type OperationSpec = z.infer<typeof OperationSchema>;
|
|
72
|
+
export type OperationResponse = z.infer<typeof ResponseSchema>;
|
|
59
73
|
export declare function parseOperationsManifest(json: string): OperationSpec[] | null;
|
|
60
74
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { BUILDER_NAMES } from './builders.js';
|
|
3
|
+
import { DECODER_NAMES } from './decoders.js';
|
|
3
4
|
const ParameterType = z.enum(['string', 'number', 'boolean', 'object', 'integer', 'array', 'null']);
|
|
4
5
|
const ParameterSchema = z.lazy(() => z.object({
|
|
5
6
|
type: z.union([ParameterType, z.array(ParameterType).min(1)]),
|
|
@@ -29,12 +30,16 @@ const McpRequestSchema = z.object({
|
|
|
29
30
|
builder: z.enum(BUILDER_NAMES)
|
|
30
31
|
});
|
|
31
32
|
const RequestSchema = z.union([McpRequestSchema, HttpRequestSchema]);
|
|
33
|
+
const ResponseSchema = z.union([
|
|
34
|
+
z.strictObject({ pick: z.array(z.string().min(1)).min(1) }),
|
|
35
|
+
z.strictObject({ decode: z.enum(DECODER_NAMES) })
|
|
36
|
+
]);
|
|
32
37
|
const OperationSchema = z.object({
|
|
33
38
|
tool: z.string().min(1),
|
|
34
39
|
description: z.string().min(1),
|
|
35
40
|
parameters: ParametersSchema,
|
|
36
41
|
request: RequestSchema,
|
|
37
|
-
response:
|
|
42
|
+
response: ResponseSchema.optional()
|
|
38
43
|
});
|
|
39
44
|
const ManifestSchema = z.object({ operations: z.array(OperationSchema) });
|
|
40
45
|
export function parseOperationsManifest(json) {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../capabilities/completion.ts';
|
|
2
2
|
import { type CredentialStoreFn } from './skills/auth-resolver.ts';
|
|
3
3
|
export type ReadSkillFn = (name: string, file: string) => Promise<string> | string;
|
|
4
|
+
export declare function mainFileRequested(name: string, file: unknown): boolean;
|
|
5
|
+
export declare function skillFileArg(args: Record<string, unknown> | null | undefined): unknown;
|
|
6
|
+
export declare function skillFileStem(file: string): string;
|
|
4
7
|
export interface SkillToolOptions {
|
|
5
8
|
skillsDir?: string;
|
|
6
9
|
overlayDir?: string | null;
|
|
@@ -10,6 +10,25 @@ import { parseToolGrant } from './skills/tool-grants.js';
|
|
|
10
10
|
import { shellEscapeArg } from './exec/shell-argv.js';
|
|
11
11
|
import { isObsidianVaultPath, OBSIDIAN_SKILL_NAME, OBSIDIAN_VAULT_PATH_CREDENTIAL_KEY } from './skills/obsidian-readiness.js';
|
|
12
12
|
const MAIN_FILE = 'SKILL.md';
|
|
13
|
+
const NO_FILE = /^(?:none|null|undefined|main)$/i;
|
|
14
|
+
export function mainFileRequested(name, file) {
|
|
15
|
+
if (file === undefined || file === null)
|
|
16
|
+
return true;
|
|
17
|
+
const asked = unquoteArg(file).toLowerCase();
|
|
18
|
+
return (asked === '' ||
|
|
19
|
+
NO_FILE.test(asked) ||
|
|
20
|
+
asked === name.toLowerCase() ||
|
|
21
|
+
asked === MAIN_FILE.toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
export function skillFileArg(args) {
|
|
24
|
+
return args?.file ?? args?.path ?? args?.filename;
|
|
25
|
+
}
|
|
26
|
+
export function skillFileStem(file) {
|
|
27
|
+
return file
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/^references\//, '')
|
|
30
|
+
.replace(/\.md$/, '');
|
|
31
|
+
}
|
|
13
32
|
function resolveSkillDir(root, name) {
|
|
14
33
|
if (!root)
|
|
15
34
|
return null;
|
|
@@ -47,6 +66,10 @@ async function listSkillFiles(drives, name) {
|
|
|
47
66
|
}
|
|
48
67
|
return [...files].sort();
|
|
49
68
|
}
|
|
69
|
+
function nearestFile(wanted, available) {
|
|
70
|
+
const matches = available.filter((file) => skillFileStem(file) === skillFileStem(wanted));
|
|
71
|
+
return matches.length === 1 ? matches[0] : null;
|
|
72
|
+
}
|
|
50
73
|
async function vaultModeNote(name, credentialStore, ctx) {
|
|
51
74
|
if (name !== OBSIDIAN_SKILL_NAME || !credentialStore)
|
|
52
75
|
return null;
|
|
@@ -107,13 +130,13 @@ export function createSkillTool({ skillsDir, overlayDir = resolveSkillsOverlayDi
|
|
|
107
130
|
autoApprove: true,
|
|
108
131
|
async execute(args, ctx) {
|
|
109
132
|
const name = unquoteArg(args.name ?? args.skill_name ?? args.skill);
|
|
110
|
-
const fileArg = args
|
|
111
|
-
const file =
|
|
133
|
+
const fileArg = skillFileArg(args);
|
|
134
|
+
const file = mainFileRequested(name, fileArg) ? MAIN_FILE : unquoteArg(fileArg);
|
|
112
135
|
if (file.startsWith('/') || file.includes('\\') || file.split('/').includes('..')) {
|
|
113
136
|
return { error: `file not found in skill ${name}: ${file}` };
|
|
114
137
|
}
|
|
115
|
-
|
|
116
|
-
const { raw, root } = await resolveSkill(name,
|
|
138
|
+
const load = async (target) => {
|
|
139
|
+
const { raw, root } = await resolveSkill(name, target);
|
|
117
140
|
const { body, meta } = parseSkillFrontMatter(raw);
|
|
118
141
|
const dir = resolveSkillDir(root, name);
|
|
119
142
|
const content = dir ? body.replaceAll('{{SKILL_DIR}}', shellEscapeArg(dir)) : body;
|
|
@@ -123,14 +146,24 @@ export function createSkillTool({ skillsDir, overlayDir = resolveSkillsOverlayDi
|
|
|
123
146
|
].filter(Boolean);
|
|
124
147
|
return {
|
|
125
148
|
name,
|
|
126
|
-
file:
|
|
149
|
+
file: target === MAIN_FILE ? undefined : target,
|
|
127
150
|
content: notes.length ? [content.trim(), ...notes].join('\n\n') : content
|
|
128
151
|
};
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
return await load(file);
|
|
129
155
|
}
|
|
130
156
|
catch {
|
|
131
157
|
if (file === MAIN_FILE)
|
|
132
158
|
return { error: `skill not found: ${name}` };
|
|
133
159
|
const available = await listSkillFiles([overlayDrive, baseDrive], name);
|
|
160
|
+
const near = nearestFile(file, available);
|
|
161
|
+
if (near !== null) {
|
|
162
|
+
try {
|
|
163
|
+
return await load(near);
|
|
164
|
+
}
|
|
165
|
+
catch { }
|
|
166
|
+
}
|
|
134
167
|
const rest = available.length
|
|
135
168
|
? ` — files this skill has: ${available.join(', ')}`
|
|
136
169
|
: '. This skill has no other files; you already have its full instructions — act now.';
|