@kosuke-ai/kosuke 1.0.14 → 1.0.16

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.16
4
+
5
+ - New `kosuke sensei fyi move` command: Sensei moves a conversation out of the FYI digest so it gets a drafted reply in its own thread.
6
+
7
+ ## 1.0.15
8
+
9
+ - `kosuke integrations call --file <argument>=<path>` uploads a local file for a tool's file argument, such as a YouTube video or a Gmail attachment.
10
+
3
11
  ## 1.0.14
4
12
 
5
13
  - `kosuke devices list` says whether another session or Sensei already uses each device, and `kosuke sensei device-requests list` says it for each pending request.
package/README.md CHANGED
@@ -165,6 +165,7 @@ kosuke integrations list --workspace <id> # a block per toolk
165
165
  kosuke integrations list --workspace <id> --toolkit gmail # that toolkit's tools
166
166
  kosuke integrations tools get <tool> --workspace <id> # one tool's input schema
167
167
  kosuke integrations call <tool> --workspace <id> --account notion-personal --args '{"query":"roadmap"}'
168
+ kosuke integrations call youtube_upload_video --workspace <id> --args-file video.json --file videoFilePath=./clip.mp4
168
169
  ```
169
170
 
170
171
  `list` with no filter prints each toolkit's connections (alias, scope, label,
@@ -174,7 +175,10 @@ and how to narrow it. `--account` takes a connection's alias such as
174
175
  `notion-personal`, its short id or `scope:label`; case, curly quotes and extra
175
176
  spaces do not matter, and it is needed when several connections can run a tool.
176
177
  `call` prints the tool's text on stdout and the connection it ran on on stderr,
177
- and exits 1 when the tool reports an error.
178
+ and exits 1 when the tool reports an error. A tool's file argument takes a file
179
+ reference: `--file <argument>=<path>` uploads the local file and fills it in,
180
+ once per file, and a file inside a list or object names its path, such as
181
+ `attachment.0`. The file goes straight to storage, up to 5 GiB.
178
182
 
179
183
  With an API key, `--workspace` is required (you must belong to it), and `call`
180
184
  runs only your own personal connections. Inside an agent's turn the credential
@@ -55,7 +55,7 @@ export const HAND_WRITTEN = {
55
55
  integrationsCall: {
56
56
  command: ['integrations', 'call'],
57
57
  positionals: ['name', 'stdin'],
58
- flags: ['--account', '--args', '--args-file', '--workspace'],
58
+ flags: ['--account', '--args', '--args-file', '--file', '--workspace'],
59
59
  agents: 'all',
60
60
  workspace: true,
61
61
  },
@@ -4,6 +4,7 @@ import { leafAt } from '../runtime/attach.js';
4
4
  import { HAND_WRITTEN } from './hand-written.js';
5
5
  import { isTurnToken } from '../runtime/credentials.js';
6
6
  import { connectionOf, failureOf, isRow, oneLine, readOverview, rowsOf, TOOLS_PATH, transportFailure, } from '../integrations/api.js';
7
+ import { FILE_UPLOAD_PATH, fileSize, MAX_FILE_BYTES, md5Of, mimetypeOf, parseFileArgument, putFile, setAt, uploadName, } from '../integrations/file-upload.js';
7
8
  import { choiceLines, renderOverview } from '../integrations/render.js';
8
9
  /**
9
10
  * `kosuke integrations list|call` and `kosuke integrations tools get`: the connected tools,
@@ -24,6 +25,8 @@ const READ_TIMEOUT_MS = 30_000;
24
25
  * 60 s budget, and wait no longer than their answer takes.
25
26
  */
26
27
  const CALL_TIMEOUT_MS = 330_000;
28
+ /** One file's PUT to storage; the storage URL lets an upload start for an hour. */
29
+ const UPLOAD_TIMEOUT_MS = 60 * 60_000;
27
30
  const EXIT_FAILED = 1;
28
31
  const EXIT_USAGE = 2;
29
32
  const TOOLKIT_PATTERN = /^[a-z0-9_]{1,64}$/;
@@ -57,6 +60,13 @@ function accountText(raw) {
57
60
  }
58
61
  return raw;
59
62
  }
63
+ function fileArguments(raw, previous = []) {
64
+ const parsed = parseFileArgument(raw);
65
+ if (!parsed) {
66
+ throw new InvalidArgumentError('Expected <argument>=<path>, such as videoFilePath=./clip.mp4 or attachment.0=./report.pdf.');
67
+ }
68
+ return [...previous, parsed];
69
+ }
60
70
  function limitNumber(raw) {
61
71
  if (!/^\d+$/.test(raw) || Number(raw) < 1 || Number(raw) > 100) {
62
72
  throw new InvalidArgumentError('Expected a whole number from 1 to 100.');
@@ -111,10 +121,36 @@ function failureText(text, choices, tool, given) {
111
121
  }
112
122
  return body.message;
113
123
  }
124
+ /** The reserved upload a successful file-upload answer carries in its text. */
125
+ function uploadOf(answer) {
126
+ const block = rowsOf(answer.content)[0];
127
+ if (!block || typeof block.text !== 'string')
128
+ return null;
129
+ let body;
130
+ try {
131
+ body = JSON.parse(block.text);
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ if (!isRow(body) || typeof body.file !== 'string' || !isRow(body.upload))
137
+ return null;
138
+ const { url, headers } = body.upload;
139
+ if (typeof url !== 'string' || !url.startsWith('https://'))
140
+ return null;
141
+ const kept = {};
142
+ if (isRow(headers)) {
143
+ for (const [key, value] of Object.entries(headers)) {
144
+ if (typeof value === 'string')
145
+ kept[key] = value;
146
+ }
147
+ }
148
+ return { file: body.file, url, headers: kept };
149
+ }
114
150
  export function attachIntegrationsCommands(root, runtime, deps = {}) {
115
151
  const { stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), exit = (code) => {
116
152
  process.exitCode = code;
117
- }, readStdin = readAllStdin, readFile: readText = (path) => readFile(path, 'utf8'), readTimeoutMs = READ_TIMEOUT_MS, callTimeoutMs = CALL_TIMEOUT_MS, } = deps;
153
+ }, readStdin = readAllStdin, readFile: readText = (path) => readFile(path, 'utf8'), readTimeoutMs = READ_TIMEOUT_MS, callTimeoutMs = CALL_TIMEOUT_MS, uploadFetch = (input, init) => globalThis.fetch(input, init), } = deps;
118
154
  const fail = (failure, code = EXIT_FAILED) => {
119
155
  stderr(`${typeof failure === 'string' ? failure : failure.message}\n`);
120
156
  exit(code);
@@ -214,6 +250,8 @@ export function attachIntegrationsCommands(root, runtime, deps = {}) {
214
250
  .option('--account <account>', 'The connection to run on: an alias, id or scope:label. Needed when several can run the tool.', accountText)
215
251
  .option('--args <json>', 'The tool arguments, a JSON object. Default {}.')
216
252
  .option('--args-file <path>', 'Read the arguments from a file, or - for stdin.')
253
+ .option('--file <argument=path>', 'Upload a local file for a file argument and pass its reference there. Repeat it for ' +
254
+ 'several files; one inside a list or object names its path, such as attachment.0.', fileArguments)
217
255
  .option('--workspace <id>', WORKSPACE_HELP)
218
256
  .addHelpText('after', '\nWith an API key, a call runs only on your own personal connections; one on a workspace\n' +
219
257
  "connection is refused (an agent's turn in the workspace can run it).\n" +
@@ -224,6 +262,14 @@ export function attachIntegrationsCommands(root, runtime, deps = {}) {
224
262
  const toolArguments = await readArguments(flags, dash);
225
263
  const client = runtime.client({ answerTimeoutMs: callTimeoutMs });
226
264
  const workspace = workspaceFor(runtime, flags.workspace);
265
+ for (const file of flags.file ?? []) {
266
+ const placed = await uploadFile(client, name, file, flags.account, workspace);
267
+ if (placed === null)
268
+ return;
269
+ if (!setAt(toolArguments, file.at, placed)) {
270
+ throw new UsageError(`--file ${file.at.join('.')} does not fit the arguments: something else is already there.`);
271
+ }
272
+ }
227
273
  let data;
228
274
  try {
229
275
  const result = await client.POST(EXECUTE_PATH, {
@@ -245,6 +291,72 @@ export function attachIntegrationsCommands(root, runtime, deps = {}) {
245
291
  }
246
292
  report(name, flags.account, data);
247
293
  }));
294
+ /**
295
+ * Reserves one file's upload, sends the file to storage and answers its reference, or reports
296
+ * the failure and answers null. Nothing is called yet, so a failure here changes nothing.
297
+ */
298
+ async function uploadFile(client, name, file, account, workspace) {
299
+ const size = await fileSize(file.path);
300
+ if (size === null)
301
+ throw new UsageError(`--file: ${file.path} is not a file this can read.`);
302
+ if (size === 0)
303
+ throw new UsageError(`--file: ${file.path} is empty.`);
304
+ if (size > MAX_FILE_BYTES)
305
+ throw new UsageError(`--file: ${file.path} is larger than 5 GiB.`);
306
+ const filename = uploadName(file.path);
307
+ if (filename === null) {
308
+ throw new UsageError(`--file: rename ${file.path} to a plain name of up to 255 characters.`);
309
+ }
310
+ const mimetype = mimetypeOf(filename);
311
+ let md5;
312
+ try {
313
+ md5 = await md5Of(file.path);
314
+ }
315
+ catch {
316
+ throw new UsageError(`--file: could not read ${file.path}.`);
317
+ }
318
+ let data;
319
+ try {
320
+ const result = await client.POST(FILE_UPLOAD_PATH, {
321
+ params: { query: workspace ? { workspace } : {} },
322
+ body: { name, filename, mimetype, md5, ...(account ? { account } : {}) },
323
+ signal: AbortSignal.timeout(readTimeoutMs),
324
+ });
325
+ if (!result.response.ok) {
326
+ fail(failureOf(result.response.status, result.error, 'GET'));
327
+ return null;
328
+ }
329
+ data = isRow(result.data) ? result.data.data : undefined;
330
+ }
331
+ catch (error) {
332
+ // Reserving an upload changes nothing a retry could repeat, so it reads like a lookup.
333
+ fail(transportFailure(error, 'GET'));
334
+ return null;
335
+ }
336
+ const answer = isRow(data) ? data : {};
337
+ if (answer.isError === true) {
338
+ report(name, account, data);
339
+ return null;
340
+ }
341
+ const upload = uploadOf(answer);
342
+ if (!upload) {
343
+ fail('Integration tools returned an unexpected response.');
344
+ return null;
345
+ }
346
+ let status;
347
+ try {
348
+ status = await putFile(uploadFetch, upload, file.path, size, mimetype, AbortSignal.timeout(UPLOAD_TIMEOUT_MS));
349
+ }
350
+ catch {
351
+ fail(`Could not upload ${file.path}; the tool was not called. Try again.`);
352
+ return null;
353
+ }
354
+ if (status < 200 || status > 299) {
355
+ fail(`The upload of ${file.path} was refused (HTTP ${status}); the tool was not called.`);
356
+ return null;
357
+ }
358
+ return upload.file;
359
+ }
248
360
  /** Reads `--args`, `--args-file` or a lone `-`: one source at most, and a JSON object. */
249
361
  async function readArguments(flags, dash) {
250
362
  if (dash !== undefined && dash !== '-')