@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +262 -0
  2. package/dist/harness.bundle +15 -11
  3. package/dist-lib/index.d.ts +1 -1
  4. package/dist-lib/lib/capabilities/completion.d.ts +5 -1
  5. package/dist-lib/lib/capabilities/completion.js +20 -8
  6. package/dist-lib/lib/helpers/tool-call-markup.js +15 -7
  7. package/dist-lib/lib/no-redirect-fetch-impl.d.ts +3 -2
  8. package/dist-lib/lib/no-redirect-fetch-impl.js +4 -1
  9. package/dist-lib/lib/tools/exec/adapters/adapter.d.ts +5 -1
  10. package/dist-lib/lib/tools/exec/adapters/python/distributions.d.ts +1 -0
  11. package/dist-lib/lib/tools/exec/adapters/python/distributions.js +4 -0
  12. package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.d.ts +72 -0
  13. package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.js +222 -0
  14. package/dist-lib/lib/tools/exec/adapters/python/interrupt-timer.d.ts +5 -0
  15. package/dist-lib/lib/tools/exec/adapters/python/interrupt-timer.js +29 -0
  16. package/dist-lib/lib/tools/exec/adapters/python/loader.js +2 -1
  17. package/dist-lib/lib/tools/exec/adapters/python/module-distributions.d.ts +1 -0
  18. package/dist-lib/lib/tools/exec/adapters/python/module-distributions.js +52 -0
  19. package/dist-lib/lib/tools/exec/adapters/python/named-hosts.d.ts +2 -0
  20. package/dist-lib/lib/tools/exec/adapters/python/named-hosts.js +15 -0
  21. package/dist-lib/lib/tools/exec/adapters/python/net-shim.d.ts +2 -0
  22. package/dist-lib/lib/tools/exec/adapters/python/net-shim.js +148 -0
  23. package/dist-lib/lib/tools/exec/adapters/python/output-policy.js +13 -1
  24. package/dist-lib/lib/tools/exec/adapters/python/pyodide.d.ts +4 -1
  25. package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +42 -7
  26. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.d.ts +9 -3
  27. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +137 -21
  28. package/dist-lib/lib/tools/exec/adapters/python/run-session.d.ts +5 -0
  29. package/dist-lib/lib/tools/exec/adapters/python/run-session.js +33 -8
  30. package/dist-lib/lib/tools/exec/adapters/python/wheels.d.ts +6 -0
  31. package/dist-lib/lib/tools/exec/adapters/python/wheels.js +17 -3
  32. package/dist-lib/lib/tools/exec/adapters/python/worker/client.d.ts +2 -1
  33. package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +16 -3
  34. package/dist-lib/lib/tools/exec/adapters/python/worker/entry.js +15 -2
  35. package/dist-lib/lib/tools/exec/adapters/python/worker/protocol.d.ts +6 -0
  36. package/dist-lib/lib/tools/exec/adapters/python/worker/protocol.js +6 -2
  37. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-bare.d.ts +1 -0
  38. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-bare.js +8 -0
  39. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-node.d.ts +1 -0
  40. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-node.js +8 -0
  41. package/dist-lib/lib/tools/exec/adapters/shell/shell-adapter.js +7 -1
  42. package/dist-lib/lib/tools/exec/shell-guidance.d.ts +10 -0
  43. package/dist-lib/lib/tools/exec/shell-guidance.js +123 -0
  44. package/dist-lib/lib/tools/exec/video-tools.d.ts +5 -0
  45. package/dist-lib/lib/tools/exec/video-tools.js +40 -0
  46. package/dist-lib/lib/tools/exec.d.ts +2 -1
  47. package/dist-lib/lib/tools/exec.js +93 -33
  48. package/dist-lib/lib/tools/http-request/truncate-response.d.ts +1 -0
  49. package/dist-lib/lib/tools/http-request/truncate-response.js +1 -1
  50. package/dist-lib/lib/tools/http-request.js +4 -1
  51. package/dist-lib/lib/tools/operations/builders.d.ts +1 -1
  52. package/dist-lib/lib/tools/operations/builders.js +9 -0
  53. package/dist-lib/lib/tools/operations/compile.js +41 -18
  54. package/dist-lib/lib/tools/operations/decoders.d.ts +6 -0
  55. package/dist-lib/lib/tools/operations/decoders.js +82 -0
  56. package/dist-lib/lib/tools/operations/manifest.d.ts +16 -2
  57. package/dist-lib/lib/tools/operations/manifest.js +6 -1
  58. package/dist-lib/lib/tools/skill.d.ts +3 -0
  59. package/dist-lib/lib/tools/skill.js +38 -5
  60. package/dist-lib/lib/tools/skills/manifest-parse.js +43 -1
  61. package/dist-lib/lib/tools/skills/manifest.d.ts +13 -0
  62. package/dist-lib/schema/harness.js +20 -1
  63. package/dist-lib/spec/hrpc/messages.js +93 -5
  64. package/dist-lib/spec/hyperschema/index.js +93 -5
  65. package/dist-lib/spec/hyperschema/schema.json +66 -1
  66. package/dist-lib/spec/hyperschema/types.d.ts +18 -1
  67. package/package.json +40 -2
  68. 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, looksLikePythonSource, parseInputs, parseOutputs, parsePackages, parseWorkdir, runsThroughPyodide, unwrapPythonSource } from './exec/adapters/python/python-adapter.js';
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 'inputs and outputs require language "python" with Pyodide enabled';
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
- const PYTHON_BINS = new Set(['python', 'python3', 'py', 'pip', 'pip3']);
104
- function commandRequiredError(args) {
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
- return staged
107
- ? 'command is required: keep these same inputs/outputs and add either command (the bare ' +
108
- 'python source) or script + scriptArgs exactly as the loaded skill reference shows'
109
- : 'command is required';
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 checkExecutionScope(args, toolOps, disablePyodide) {
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 && PYTHON_BINS.has(bareBin(firstCommandToken(command)) ?? '')) {
215
- return 'run Python by passing language="python" with the bare Python source in command; the exec shell path does not run python or pip';
216
- }
217
- const callsFirst = /^[A-Za-z_][\w.]*\s*\(/.test(command);
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(args, ctx) {
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 'packages require language "python" with Pyodide enabled';
405
- }
456
+ if (parsed && !python)
457
+ return PACKAGES_NEED_PYTHON_ERROR;
406
458
  return checkFileArgs(rawArgs, python);
407
459
  },
408
- resource(args) {
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)}: ${(unwrapPythonSource(command) ?? command).slice(0, 120)}`
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
- const scopeError = checkExecutionScope(rawArgs, ctx?.toolOps, disablePyodide);
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,
@@ -1 +1,2 @@
1
1
  export declare function truncateResponseBody(body: string): string;
2
+ export declare const ELISION = "\u2026 [truncated]";
@@ -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 { status: response.status, body: truncateResponseBody(text) };
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: false,
21
+ autoApprove: read,
17
22
  skillGated: true,
18
- externalWrite: true,
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 operation.response ? pickResponse(result, operation.response.pick) : result;
54
+ }, response ? { ...skillContext, uncappedResponse: true } : skillContext);
55
+ return response ? capBody(shapeResponse(result, response)) : result;
47
56
  }
48
57
  };
49
58
  }
50
- function pickResponse(result, keys) {
51
- if (!result || typeof result !== 'object')
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 { body, status } = result;
54
- if (typeof body !== 'string' || (typeof status === 'number' && status >= 400))
70
+ const { status } = parsed.data;
71
+ if (status !== undefined && status >= 400)
55
72
  return result;
56
- let parsed;
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 result;
85
+ return null;
62
86
  }
63
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
64
- return result;
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 record)
69
- picked[key] = record[key];
70
- return { ...result, body: JSON.stringify(picked) };
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.$strip>>;
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: z.object({ pick: z.array(z.string().min(1)).min(1) }).optional()
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.file ?? args.path ?? args.filename;
111
- const file = fileArg === undefined || fileArg === null ? MAIN_FILE : unquoteArg(fileArg);
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
- try {
116
- const { raw, root } = await resolveSkill(name, file === '' ? MAIN_FILE : file);
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: file === MAIN_FILE || file === '' ? undefined : 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.';