runline 0.21.0 → 0.22.0

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.
@@ -0,0 +1,118 @@
1
+ import { openAsBlob } from "node:fs";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { extname } from "node:path";
4
+ /**
5
+ * The Shift cloud signed-grant upload arc, shared by every plugin that
6
+ * moves local file bytes into the cloud (shiftTranscription assets,
7
+ * shiftObjects objects): stat + size-gate the file, create the server
8
+ * record with { contentType, sizeBytes }, PUT the bytes to the grant
9
+ * URL with the grant's headers, then POST /complete. The subtle parts
10
+ * live here once: the openAsBlob streaming fallback, grant-header
11
+ * merging, and the size ceilings.
12
+ *
13
+ * shiftOcr deliberately does not use this arc — it inlines small files
14
+ * as base64 data URLs instead of uploading through a grant.
15
+ */
16
+ /** Mirrors the public service contracts' 5 GiB upload ceiling. */
17
+ export const SIGNED_UPLOAD_MAX_BYTES = 5 * 1024 * 1024 * 1024;
18
+ /**
19
+ * Runtimes without fs.openAsBlob fall back to buffering; refuse to
20
+ * buffer files that would strain the host process.
21
+ */
22
+ const MAX_BUFFERED_BYTES = 512 * 1024 * 1024;
23
+ /** Audio and video formats the transcription service accepts. */
24
+ export const AUDIO_VIDEO_MEDIA_TYPES = {
25
+ aac: "audio/aac",
26
+ flac: "audio/flac",
27
+ m4a: "audio/m4a",
28
+ mov: "video/quicktime",
29
+ mp3: "audio/mpeg",
30
+ mp4: "video/mp4",
31
+ mpeg: "video/mpeg",
32
+ mpg: "video/mpeg",
33
+ oga: "audio/ogg",
34
+ ogg: "audio/ogg",
35
+ opus: "audio/ogg",
36
+ wav: "audio/wav",
37
+ wave: "audio/wav",
38
+ webm: "video/webm",
39
+ };
40
+ /** Common document, image, and media formats for durable objects. */
41
+ export const GENERAL_MEDIA_TYPES = {
42
+ csv: "text/csv",
43
+ doc: "application/msword",
44
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
45
+ gif: "image/gif",
46
+ html: "text/html",
47
+ jpeg: "image/jpeg",
48
+ jpg: "image/jpeg",
49
+ json: "application/json",
50
+ md: "text/markdown",
51
+ mp3: "audio/mpeg",
52
+ mp4: "video/mp4",
53
+ pdf: "application/pdf",
54
+ png: "image/png",
55
+ txt: "text/plain",
56
+ wav: "audio/wav",
57
+ webp: "image/webp",
58
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
59
+ zip: "application/zip",
60
+ };
61
+ /** Extension-based media type lookup; undefined when unknown. */
62
+ export function mediaTypeFromPath(path, types) {
63
+ return types[extname(path).slice(1).toLowerCase()];
64
+ }
65
+ /** Like {@link mediaTypeFromPath}, but unknown extensions are an error. */
66
+ export function requireMediaType(path, types) {
67
+ const mediaType = mediaTypeFromPath(path, types);
68
+ if (!mediaType) {
69
+ throw new Error(`Unsupported media extension for ${path}. Supported: ${Object.keys(types).join(", ")}`);
70
+ }
71
+ return mediaType;
72
+ }
73
+ /**
74
+ * Gate a local file before any network call: it must exist, be a real
75
+ * file, be non-empty, and fit the service ceiling. Returns its size.
76
+ * The label keeps each plugin's established error vocabulary
77
+ * ("File is empty" vs "Media file is empty").
78
+ */
79
+ export async function statUploadFile(path, maxBytes, label = "File") {
80
+ const file = await stat(path);
81
+ if (!file.isFile())
82
+ throw new Error(`${path} is not a file`);
83
+ if (file.size === 0)
84
+ throw new Error(`${label} is empty`);
85
+ if (file.size > maxBytes) {
86
+ throw new Error(`${label} is larger than the ${maxBytes}-byte service limit`);
87
+ }
88
+ return file.size;
89
+ }
90
+ async function fileBody(path, sizeBytes) {
91
+ try {
92
+ return await openAsBlob(path);
93
+ }
94
+ catch {
95
+ if (sizeBytes > MAX_BUFFERED_BYTES) {
96
+ throw new Error("This runtime cannot stream uploads and the file is too large to buffer.");
97
+ }
98
+ return await readFile(path);
99
+ }
100
+ }
101
+ /**
102
+ * PUT a local file's bytes to a signed grant. Grant headers win;
103
+ * content-type is filled in only when the grant leaves it unset.
104
+ */
105
+ export async function putThroughGrant(grant, path, contentType, sizeBytes, label = "Upload") {
106
+ const headers = new Headers(grant.headers);
107
+ if (!headers.has("content-type")) {
108
+ headers.set("content-type", contentType);
109
+ }
110
+ const response = await fetch(grant.url, {
111
+ method: grant.method,
112
+ headers,
113
+ body: await fileBody(path, sizeBytes),
114
+ });
115
+ if (!response.ok) {
116
+ throw new Error(`${label} failed: HTTP ${response.status}`);
117
+ }
118
+ }
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { ATLAS_BASE, cursorSchema, enumSchema, GRAPH_ACTOR_KIND, GRAPH_ASSIGNMENT_ROLE, GRAPH_BINDING_EXECUTOR, GRAPH_BINDING_STATUS, GRAPH_CHANGE_ENTITY_TYPE, GRAPH_EVIDENCE_KIND, GRAPH_JOURNEY_STATUS, GRAPH_RELATION_KIND, GRAPH_TASK_MODE, idSchema, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, } from "./shared.js";
2
+ import { ATLAS_BASE, cursorSchema, enumSchema, GRAPH_ACTOR_KIND, GRAPH_ASSIGNMENT_ROLE, GRAPH_BINDING_EXECUTOR, GRAPH_BINDING_STATUS, GRAPH_CHANGE_ENTITY_TYPE, GRAPH_EVIDENCE_KIND, GRAPH_JOURNEY_STATUS, GRAPH_RELATION_KIND, GRAPH_TASK_MODE, idSchema, listParams, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, withQuery, } from "./shared.js";
3
3
  /**
4
4
  * The operational graph behind the Atlas view: a business spec of how
5
5
  * a company operates, in its own language. Four fixed levels — line →
@@ -121,12 +121,7 @@ export function registerGraphActions(rl) {
121
121
  limit: t.Optional(t.Integer({ minimum: 1, maximum: 200 })),
122
122
  }, STRICT_OBJECT),
123
123
  async execute(input, ctx) {
124
- const params = new URLSearchParams();
125
- for (const [key, value] of Object.entries(input)) {
126
- if (value !== undefined)
127
- params.set(key, String(value));
128
- }
129
- return request(ctx, `${ATLAS_BASE}/changes?${params}`);
124
+ return request(ctx, withQuery(`${ATLAS_BASE}/changes`, listParams(input)));
130
125
  },
131
126
  });
132
127
  /* -------------------------------------------------------------- */
@@ -1,4 +1,4 @@
1
- export { cursorSchema, enumSchema, idSchema, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, } from "../../_shared/shiftCloud.js";
1
+ export { cursorSchema, enumSchema, idSchema, listParams, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, withQuery, } from "../../_shared/shiftCloud.js";
2
2
  /** Base path of the operational graph service in the Shift cloud API. */
3
3
  export const ATLAS_BASE = "/v1/services/operational-graph";
4
4
  export const GRAPH_ACTOR_KIND = ["agent", "human_role"];
@@ -0,0 +1,20 @@
1
+ import * as t from "typebox";
2
+ import { registerObjectActions } from "./objects.js";
3
+ /**
4
+ * Shift Objects — the organization's durable object bucket in the
5
+ * Shift cloud: upload binary files through signed grants, mint
6
+ * download grants, and record append-only provenance links. The API
7
+ * key is the tenant authority; the cloud derives the organization from
8
+ * it (SHFT-852), so no org ID is sent.
9
+ */
10
+ export default function shiftObjects(rl) {
11
+ rl.setName("shiftObjects");
12
+ rl.setVersion("0.1.0");
13
+ rl.setConnectionSchema(t.Object({
14
+ apiKey: t.String({
15
+ description: "Shift Labs API key",
16
+ env: "SHIFT_LABS_API_KEY",
17
+ }),
18
+ }));
19
+ registerObjectActions(rl);
20
+ }
@@ -1,8 +1,8 @@
1
- import { openAsBlob } from "node:fs";
2
- import { readFile, stat, writeFile } from "node:fs/promises";
3
- import { basename, extname } from "node:path";
1
+ import { writeFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
4
3
  import * as t from "typebox";
5
- import { enumSchema, pathSegment, request } from "./shared.js";
4
+ import { enumSchema, listParams, pathSegment, request, withQuery, } from "../../_shared/shiftCloud.js";
5
+ import { GENERAL_MEDIA_TYPES, mediaTypeFromPath, putThroughGrant, SIGNED_UPLOAD_MAX_BYTES, statUploadFile, } from "../../_shared/shiftUpload.js";
6
6
  const OBJECTS_BASE = "/v1/services/objects";
7
7
  export const OBJECT_STATUS = ["pending_upload", "ready", "archived"];
8
8
  export const OBJECT_LINK_TARGET = [
@@ -11,49 +11,8 @@ export const OBJECT_LINK_TARGET = [
11
11
  "eval_case",
12
12
  "db_record",
13
13
  ];
14
- /** Mirrors the public service contract's 5 GiB object ceiling. */
15
- const MAX_OBJECT_BYTES = 5 * 1024 * 1024 * 1024;
16
14
  const STRICT_OBJECT = { additionalProperties: false };
17
15
  const Id = t.String({ minLength: 1, pattern: "\\S" });
18
- const MEDIA_TYPES = {
19
- csv: "text/csv",
20
- doc: "application/msword",
21
- docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
22
- gif: "image/gif",
23
- html: "text/html",
24
- jpeg: "image/jpeg",
25
- jpg: "image/jpeg",
26
- json: "application/json",
27
- md: "text/markdown",
28
- mp3: "audio/mpeg",
29
- mp4: "video/mp4",
30
- pdf: "application/pdf",
31
- png: "image/png",
32
- txt: "text/plain",
33
- wav: "audio/wav",
34
- webp: "image/webp",
35
- xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
36
- zip: "application/zip",
37
- };
38
- function mediaTypeFor(path, override) {
39
- if (override)
40
- return override;
41
- return (MEDIA_TYPES[extname(path).slice(1).toLowerCase()] ??
42
- "application/octet-stream");
43
- }
44
- async function fileBody(path, sizeBytes) {
45
- try {
46
- return await openAsBlob(path);
47
- }
48
- catch {
49
- // Runtimes without fs.openAsBlob fall back to buffering; refuse to
50
- // buffer files that would strain the host process.
51
- if (sizeBytes > 512 * 1024 * 1024) {
52
- throw new Error("This runtime cannot stream uploads and the file is too large to buffer.");
53
- }
54
- return await readFile(path);
55
- }
56
- }
57
16
  function linkSchema() {
58
17
  return t.Object({
59
18
  targetType: enumSchema("Link target type", OBJECT_LINK_TARGET),
@@ -108,21 +67,16 @@ export function registerObjectActions(rl) {
108
67
  }, STRICT_OBJECT),
109
68
  async execute(input, ctx) {
110
69
  const fields = input;
111
- const contentType = mediaTypeFor(fields.path, fields.contentType);
112
- const file = await stat(fields.path);
113
- if (!file.isFile())
114
- throw new Error(`${fields.path} is not a file`);
115
- if (file.size === 0)
116
- throw new Error("File is empty");
117
- if (file.size > MAX_OBJECT_BYTES) {
118
- throw new Error(`File is larger than the ${MAX_OBJECT_BYTES}-byte service limit`);
119
- }
70
+ const contentType = fields.contentType ??
71
+ mediaTypeFromPath(fields.path, GENERAL_MEDIA_TYPES) ??
72
+ "application/octet-stream";
73
+ const sizeBytes = await statUploadFile(fields.path, SIGNED_UPLOAD_MAX_BYTES);
120
74
  const created = await request(ctx, `${OBJECTS_BASE}/objects`, {
121
75
  method: "POST",
122
76
  // The API key is the tenant authority; no organizationId is sent.
123
77
  body: JSON.stringify({
124
78
  contentType,
125
- sizeBytes: file.size,
79
+ sizeBytes,
126
80
  filename: fields.filename ?? basename(fields.path),
127
81
  workspaceId: fields.workspaceId,
128
82
  sessionId: fields.sessionId,
@@ -130,18 +84,7 @@ export function registerObjectActions(rl) {
130
84
  metadata: fields.metadata,
131
85
  }),
132
86
  });
133
- const uploadHeaders = new Headers(created.upload.headers);
134
- if (!uploadHeaders.has("content-type")) {
135
- uploadHeaders.set("content-type", contentType);
136
- }
137
- const upload = await fetch(created.upload.url, {
138
- method: created.upload.method,
139
- headers: uploadHeaders,
140
- body: await fileBody(fields.path, file.size),
141
- });
142
- if (!upload.ok) {
143
- throw new Error(`Object upload failed: HTTP ${upload.status}`);
144
- }
87
+ await putThroughGrant(created.upload, fields.path, contentType, sizeBytes, "Object upload");
145
88
  return request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(created.object.id)}/complete`, { method: "POST" });
146
89
  },
147
90
  });
@@ -155,13 +98,7 @@ export function registerObjectActions(rl) {
155
98
  ...provenanceFields,
156
99
  }, STRICT_OBJECT),
157
100
  async execute(input, ctx) {
158
- const params = new URLSearchParams();
159
- for (const [key, value] of Object.entries(input)) {
160
- if (value !== undefined)
161
- params.set(key, String(value));
162
- }
163
- const query = params.toString();
164
- const body = await request(ctx, `${OBJECTS_BASE}/objects${query ? `?${query}` : ""}`);
101
+ const body = await request(ctx, withQuery(`${OBJECTS_BASE}/objects`, listParams(input)));
165
102
  return body.objects;
166
103
  },
167
104
  });
@@ -1,11 +1,17 @@
1
- import { readFile, stat } from "node:fs/promises";
2
- import { extname } from "node:path";
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, extname } from "node:path";
3
3
  import * as t from "typebox";
4
4
  import { request, STRICT_OBJECT } from "../../_shared/shiftCloud.js";
5
+ import { putThroughGrant, SIGNED_UPLOAD_MAX_BYTES, statUploadFile, } from "../../_shared/shiftUpload.js";
5
6
  /**
6
7
  * Shift OCR — text and structured-field extraction from images and
7
8
  * PDFs through the Shift cloud OCR service. The API key is the tenant
8
9
  * authority; no organization ID is ever sent (SHFT-852).
10
+ *
11
+ * Local files route by size: small ones inline as base64 data URLs
12
+ * (one round-trip); larger ones ride the signed-grant arc into the
13
+ * organization's durable object bucket and extract by objectId
14
+ * (SHFT-924), so bytes go to S3, never through the API.
9
15
  */
10
16
  const IMAGE_TYPES = {
11
17
  avif: "image/avif",
@@ -20,10 +26,11 @@ const DOCUMENT_TYPES = {
20
26
  };
21
27
  /**
22
28
  * Raw-file ceiling for inlining as a base64 data URL. Base64 inflates
23
- * by 4/3, so 20 MiB raw stays well inside provider request limits.
29
+ * by 4/3, so 20 MiB raw stays well inside provider request limits;
30
+ * anything larger uploads to the object bucket instead.
24
31
  */
25
- const MAX_FILE_BYTES = 20 * 1024 * 1024;
26
- async function documentFromPath(path) {
32
+ const MAX_INLINE_BYTES = 20 * 1024 * 1024;
33
+ async function documentFromPath(ctx, path) {
27
34
  const extension = extname(path).slice(1).toLowerCase();
28
35
  const imageType = IMAGE_TYPES[extension];
29
36
  const documentType = DOCUMENT_TYPES[extension];
@@ -33,19 +40,27 @@ async function documentFromPath(path) {
33
40
  ...Object.keys(DOCUMENT_TYPES),
34
41
  ].join(", ")}`);
35
42
  }
36
- const file = await stat(path);
37
- if (!file.isFile())
38
- throw new Error(`${path} is not a file`);
39
- if (file.size === 0)
40
- throw new Error("File is empty");
41
- if (file.size > MAX_FILE_BYTES) {
42
- throw new Error(`File is ${file.size} bytes; inline OCR uploads are capped at ${MAX_FILE_BYTES} bytes. Host the file and pass url instead.`);
43
+ const mediaType = (imageType ?? documentType);
44
+ const sizeBytes = await statUploadFile(path, SIGNED_UPLOAD_MAX_BYTES);
45
+ if (sizeBytes <= MAX_INLINE_BYTES) {
46
+ const url = `data:${mediaType};base64,${(await readFile(path)).toString("base64")}`;
47
+ return imageType
48
+ ? { type: "image", url }
49
+ : { type: "document", url, name: basename(path) };
43
50
  }
44
- const mediaType = imageType ?? documentType;
45
- const url = `data:${mediaType};base64,${(await readFile(path)).toString("base64")}`;
46
- return imageType
47
- ? { type: "image", url }
48
- : { type: "document", url, name: path.replace(/.*\//, "") };
51
+ // Large file: into the durable bucket through a signed grant, then
52
+ // extract by reference — the object persists as provenance.
53
+ const created = await request(ctx, "/v1/services/objects/objects", {
54
+ method: "POST",
55
+ body: JSON.stringify({
56
+ contentType: mediaType,
57
+ sizeBytes,
58
+ filename: basename(path),
59
+ }),
60
+ });
61
+ await putThroughGrant(created.upload, path, mediaType, sizeBytes);
62
+ await request(ctx, `/v1/services/objects/objects/${created.object.id}/complete`, { method: "POST" });
63
+ return { type: "object", objectId: created.object.id };
49
64
  }
50
65
  function documentFromUrl(url, kind) {
51
66
  if (kind)
@@ -75,9 +90,10 @@ export default function shiftOcr(rl) {
75
90
  rl.registerAction("ocr.extract", {
76
91
  access: "write",
77
92
  description: "Extract markdown text — and optionally schema-constrained JSON — " +
78
- "from an image or PDF. Pass a local file path (inlined as a data " +
79
- "URL) or an https URL. Provide schema to get structured fields " +
80
- "back instead of parsing the text yourself.",
93
+ "from an image or PDF. Pass a local file path (small files inline; " +
94
+ "large ones upload to the org's object bucket automatically), an " +
95
+ "https URL, or the objectId of a stored object. Provide schema to " +
96
+ "get structured fields back instead of parsing the text yourself.",
81
97
  inputSchema: t.Object({
82
98
  path: t.Optional(t.String({
83
99
  minLength: 1,
@@ -89,6 +105,11 @@ export default function shiftOcr(rl) {
89
105
  pattern: "\\S",
90
106
  description: "https or data URL of the image or PDF",
91
107
  })),
108
+ objectId: t.Optional(t.String({
109
+ minLength: 1,
110
+ pattern: "\\S",
111
+ description: "ID of a ready object in the organization's durable bucket",
112
+ })),
92
113
  kind: t.Optional(t.Union([t.Literal("image"), t.Literal("document")], {
93
114
  description: "Override for URLs whose extension does not reveal the type. " +
94
115
  "Inferred from the extension otherwise.",
@@ -114,15 +135,18 @@ export default function shiftOcr(rl) {
114
135
  }, STRICT_OBJECT),
115
136
  async execute(input, ctx) {
116
137
  const fields = input;
117
- if (!fields.path === !fields.url) {
118
- throw new Error("Provide exactly one of path or url");
138
+ const sources = [fields.path, fields.url, fields.objectId].filter((value) => value !== undefined);
139
+ if (sources.length !== 1) {
140
+ throw new Error("Provide exactly one of path, url, or objectId");
119
141
  }
120
142
  if (fields.prompt && !fields.schema) {
121
143
  throw new Error("prompt requires schema");
122
144
  }
123
- const document = fields.path
124
- ? await documentFromPath(fields.path)
125
- : documentFromUrl(fields.url, fields.kind);
145
+ const document = fields.objectId
146
+ ? { type: "object", objectId: fields.objectId }
147
+ : fields.path
148
+ ? await documentFromPath(ctx, fields.path)
149
+ : documentFromUrl(fields.url, fields.kind);
126
150
  return await request(ctx, "/v1/services/ocr/extract", {
127
151
  method: "POST",
128
152
  body: JSON.stringify({
@@ -0,0 +1,19 @@
1
+ import * as t from "typebox";
2
+ import { registerPageActions } from "./pages.js";
3
+ /**
4
+ * Shift Pages — hosted HTML pages in the Shift cloud: draft, publish,
5
+ * archive, share with invited viewers, and resolve authenticated
6
+ * render URLs. The API key is the tenant authority; the cloud derives
7
+ * the organization from it (SHFT-852), so no org ID is sent.
8
+ */
9
+ export default function shiftPages(rl) {
10
+ rl.setName("shiftPages");
11
+ rl.setVersion("0.1.0");
12
+ rl.setConnectionSchema(t.Object({
13
+ apiKey: t.String({
14
+ description: "Shift Labs API key",
15
+ env: "SHIFT_LABS_API_KEY",
16
+ }),
17
+ }));
18
+ registerPageActions(rl);
19
+ }
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { enumSchema, idSchema, PAGE_STATUS, PAGE_VISIBILITY, pageRenderUrl, pathSegment, request, STRICT_OBJECT, timestampSchema, } from "./shared.js";
2
+ import { enumSchema, idSchema, listParams, PAGE_STATUS, PAGE_VISIBILITY, pageRenderUrl, pathSegment, request, STRICT_OBJECT, timestampSchema, withQuery, } from "./shared.js";
3
3
  const PageSlug = t.String({
4
4
  minLength: 1,
5
5
  maxLength: 120,
@@ -19,13 +19,7 @@ export function registerPageActions(rl) {
19
19
  })),
20
20
  }, STRICT_OBJECT),
21
21
  async execute(input, ctx) {
22
- const fields = (input ?? {});
23
- const params = new URLSearchParams();
24
- if (fields.status)
25
- params.set("status", String(fields.status));
26
- if (fields.limit)
27
- params.set("limit", String(fields.limit));
28
- const body = await request(ctx, `/v1/pages?${params}`);
22
+ const body = await request(ctx, withQuery("/v1/pages", listParams(input)));
29
23
  return body.pages;
30
24
  },
31
25
  });
@@ -0,0 +1,7 @@
1
+ import { baseUrl, pathSegment } from "../../_shared/shiftCloud.js";
2
+ export { enumSchema, idSchema, listParams, pathSegment, request, STRICT_OBJECT, timestampSchema, withQuery, } from "../../_shared/shiftCloud.js";
3
+ export const PAGE_STATUS = ["draft", "published", "archived"];
4
+ export const PAGE_VISIBILITY = ["org", "invited"];
5
+ export function pageRenderUrl(organizationId, slug) {
6
+ return new URL(`/pages/${pathSegment(organizationId)}/${pathSegment(slug)}`, baseUrl()).toString();
7
+ }
@@ -0,0 +1,20 @@
1
+ import * as t from "typebox";
2
+ import { registerTranscriptionActions } from "./transcription.js";
3
+ /**
4
+ * Shift Transcription — audio and video transcription through the
5
+ * Shift cloud: upload media, run jobs (optionally diarized), and read
6
+ * transcripts as text, subtitles, or timestamped JSON. The API key is
7
+ * the tenant authority; the cloud derives the organization from it
8
+ * (SHFT-852), so no org ID is sent.
9
+ */
10
+ export default function shiftTranscription(rl) {
11
+ rl.setName("shiftTranscription");
12
+ rl.setVersion("0.1.0");
13
+ rl.setConnectionSchema(t.Object({
14
+ apiKey: t.String({
15
+ description: "Shift Labs API key",
16
+ env: "SHIFT_LABS_API_KEY",
17
+ }),
18
+ }));
19
+ registerTranscriptionActions(rl);
20
+ }
@@ -0,0 +1,3 @@
1
+ export { enumSchema, pathSegment, request, } from "../../_shared/shiftCloud.js";
2
+ export const TRANSCRIPTION_LANGUAGE = ["auto", "en", "he"];
3
+ export const TRANSCRIPT_FORMAT = ["txt", "srt", "vtt", "json"];
@@ -1,50 +1,10 @@
1
- import { openAsBlob } from "node:fs";
2
- import { readFile, stat } from "node:fs/promises";
3
- import { extname } from "node:path";
4
1
  import * as t from "typebox";
2
+ import { AUDIO_VIDEO_MEDIA_TYPES, putThroughGrant, requireMediaType, SIGNED_UPLOAD_MAX_BYTES, statUploadFile, } from "../../_shared/shiftUpload.js";
5
3
  import { enumSchema, pathSegment, request, TRANSCRIPT_FORMAT, TRANSCRIPTION_LANGUAGE, } from "./shared.js";
6
- const MEDIA_TYPES = {
7
- aac: "audio/aac",
8
- flac: "audio/flac",
9
- m4a: "audio/m4a",
10
- mov: "video/quicktime",
11
- mp3: "audio/mpeg",
12
- mp4: "video/mp4",
13
- mpeg: "video/mpeg",
14
- mpg: "video/mpeg",
15
- oga: "audio/ogg",
16
- ogg: "audio/ogg",
17
- opus: "audio/ogg",
18
- wav: "audio/wav",
19
- wave: "audio/wav",
20
- webm: "video/webm",
21
- };
22
- /** Mirrors the public service contract's 5 GiB source-object ceiling. */
23
- const MAX_MEDIA_BYTES = 5 * 1024 * 1024 * 1024;
24
4
  /** Transcript previews returned into the sandbox are capped, not truncated silently. */
25
5
  const MAX_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
26
6
  const STRICT_OBJECT = { additionalProperties: false };
27
7
  const Id = t.String({ minLength: 1, pattern: "\\S" });
28
- function mediaTypeFor(path) {
29
- const mediaType = MEDIA_TYPES[extname(path).slice(1).toLowerCase()];
30
- if (!mediaType) {
31
- throw new Error(`Unsupported media extension for ${path}. Supported: ${Object.keys(MEDIA_TYPES).join(", ")}`);
32
- }
33
- return mediaType;
34
- }
35
- async function fileBody(path, sizeBytes) {
36
- try {
37
- return await openAsBlob(path);
38
- }
39
- catch {
40
- // Runtimes without fs.openAsBlob fall back to buffering; refuse to
41
- // buffer files that would strain the host process.
42
- if (sizeBytes > 512 * 1024 * 1024) {
43
- throw new Error("This runtime cannot stream uploads and the file is too large to buffer.");
44
- }
45
- return await readFile(path);
46
- }
47
- }
48
8
  export function registerTranscriptionActions(rl) {
49
9
  rl.registerAction("transcription.transcribe", {
50
10
  access: "write",
@@ -76,35 +36,17 @@ export function registerTranscriptionActions(rl) {
76
36
  }, STRICT_OBJECT),
77
37
  async execute(input, ctx) {
78
38
  const fields = input;
79
- const contentType = mediaTypeFor(fields.path);
80
- const file = await stat(fields.path);
81
- if (!file.isFile())
82
- throw new Error(`${fields.path} is not a file`);
83
- if (file.size === 0)
84
- throw new Error("Media file is empty");
85
- if (file.size > MAX_MEDIA_BYTES) {
86
- throw new Error(`Media file is larger than the ${MAX_MEDIA_BYTES}-byte service limit`);
87
- }
39
+ const contentType = requireMediaType(fields.path, AUDIO_VIDEO_MEDIA_TYPES);
40
+ const sizeBytes = await statUploadFile(fields.path, SIGNED_UPLOAD_MAX_BYTES, "Media file");
88
41
  const created = await request(ctx, "/v1/services/transcription/assets", {
89
42
  method: "POST",
90
43
  // The API key is the tenant authority; no organizationId is sent.
91
44
  body: JSON.stringify({
92
45
  contentType,
93
- sizeBytes: file.size,
46
+ sizeBytes,
94
47
  }),
95
48
  });
96
- const uploadHeaders = new Headers(created.upload.headers);
97
- if (!uploadHeaders.has("content-type")) {
98
- uploadHeaders.set("content-type", contentType);
99
- }
100
- const upload = await fetch(created.upload.url, {
101
- method: created.upload.method,
102
- headers: uploadHeaders,
103
- body: await fileBody(fields.path, file.size),
104
- });
105
- if (!upload.ok) {
106
- throw new Error(`Media upload failed: HTTP ${upload.status}`);
107
- }
49
+ await putThroughGrant(created.upload, fields.path, contentType, sizeBytes, "Media upload");
108
50
  await request(ctx, `/v1/services/transcription/assets/${pathSegment(created.asset.id)}/complete`, { method: "POST" });
109
51
  const job = await request(ctx, "/v1/services/transcription/jobs", {
110
52
  method: "POST",
@@ -1,16 +1,17 @@
1
1
  import * as t from "typebox";
2
2
  import { registerIssueViewActions } from "./issue-views.js";
3
3
  import { registerIssueActions } from "./issues.js";
4
- import { registerObjectActions } from "./objects.js";
5
- import { registerPageActions } from "./pages.js";
6
4
  import { registerProjectActions } from "./projects.js";
7
- import { registerTranscriptionActions } from "./transcription.js";
8
- export default function shiftLabs(rl) {
9
- rl.setName("shiftLabs");
5
+ /**
6
+ * Shift Work — the work-tracking domain of the Shift cloud: Projects,
7
+ * Issues (with dependencies and comments), and saved Issue Views.
8
+ * The API key is the tenant authority for every action; the cloud
9
+ * derives the organization from it (SHFT-852), so no org ID is sent.
10
+ */
11
+ export default function shiftWork(rl) {
12
+ rl.setName("shiftWork");
10
13
  rl.setVersion("0.1.0");
11
14
  rl.setConnectionSchema(t.Object({
12
- // The API key is the tenant authority for every action; the cloud
13
- // derives the organization from it (SHFT-852), so no org ID is needed.
14
15
  apiKey: t.String({
15
16
  description: "Shift Labs API key",
16
17
  env: "SHIFT_LABS_API_KEY",
@@ -19,7 +20,4 @@ export default function shiftLabs(rl) {
19
20
  registerProjectActions(rl);
20
21
  registerIssueActions(rl);
21
22
  registerIssueViewActions(rl);
22
- registerPageActions(rl);
23
- registerTranscriptionActions(rl);
24
- registerObjectActions(rl);
25
23
  }
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { cursorSchema, enumSchema, ISSUE_PRIORITY, ISSUE_STATUS, ISSUE_VIEW_GROUP, ISSUE_VIEW_LAYOUT, ISSUE_VIEW_SORT, ISSUE_VIEW_VISIBILITY, idSchema, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, } from "./shared.js";
2
+ import { cursorSchema, enumSchema, ISSUE_PRIORITY, ISSUE_STATUS, ISSUE_VIEW_GROUP, ISSUE_VIEW_LAYOUT, ISSUE_VIEW_SORT, ISSUE_VIEW_VISIBILITY, idSchema, listParams, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, withQuery, } from "./shared.js";
3
3
  const viewFields = {
4
4
  name: t.String({
5
5
  minLength: 1,
@@ -143,18 +143,6 @@ export function registerIssueViewActions(rl) {
143
143
  },
144
144
  });
145
145
  }
146
- function listParams(input) {
147
- const params = new URLSearchParams();
148
- for (const [key, value] of Object.entries((input ?? {}))) {
149
- if (value !== undefined)
150
- params.set(key, String(value));
151
- }
152
- return params;
153
- }
154
- function withQuery(path, params) {
155
- const query = params.toString();
156
- return query ? `${path}?${query}` : path;
157
- }
158
146
  function assertDateOrder(startAfter, dueBefore) {
159
147
  if (typeof startAfter === "string" &&
160
148
  typeof dueBefore === "string" &&
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { cursorSchema, enumSchema, ISSUE_PRIORITY, ISSUE_SOURCE, ISSUE_STATUS, idSchema, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, } from "./shared.js";
2
+ import { cursorSchema, enumSchema, ISSUE_PRIORITY, ISSUE_SOURCE, ISSUE_STATUS, idSchema, listParams, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, withQuery, } from "./shared.js";
3
3
  const issueListFields = {
4
4
  status: t.Optional(enumSchema("Issue status", ISSUE_STATUS)),
5
5
  projectId: t.Optional(idSchema("Project ID")),
@@ -14,25 +14,13 @@ const issueListFields = {
14
14
  description: "Max results, default 50",
15
15
  })),
16
16
  };
17
- function issueListParams(input) {
18
- const params = new URLSearchParams();
19
- for (const [key, value] of Object.entries((input ?? {}))) {
20
- if (value !== undefined)
21
- params.set(key, String(value));
22
- }
23
- return params;
24
- }
25
- function withQuery(path, params) {
26
- const query = params.toString();
27
- return query ? `${path}?${query}` : path;
28
- }
29
17
  export function registerIssueActions(rl) {
30
18
  rl.registerAction("issue.list", {
31
19
  access: "read",
32
20
  description: "List the first page of Shift Labs Issues for the API key's organization.",
33
21
  inputSchema: t.Object(issueListFields, STRICT_OBJECT),
34
22
  async execute(input, ctx) {
35
- const body = await request(ctx, `/v1/issues?${issueListParams(input)}`);
23
+ const body = await request(ctx, `/v1/issues?${listParams(input)}`);
36
24
  return body.issues;
37
25
  },
38
26
  });
@@ -41,7 +29,7 @@ export function registerIssueActions(rl) {
41
29
  description: "List a cursor-paginated page of Shift Labs Issues.",
42
30
  inputSchema: t.Object(issueListFields, STRICT_OBJECT),
43
31
  async execute(input, ctx) {
44
- return request(ctx, `/v1/issues?${issueListParams(input)}`);
32
+ return request(ctx, `/v1/issues?${listParams(input)}`);
45
33
  },
46
34
  });
47
35
  rl.registerAction("issue.get", {
@@ -148,7 +136,7 @@ export function registerIssueActions(rl) {
148
136
  }, STRICT_OBJECT),
149
137
  async execute(input, ctx) {
150
138
  const { id, ...pagination } = input;
151
- const body = await request(ctx, withQuery(`/v1/issues/${pathSegment(id)}/dependencies`, issueListParams(pagination)));
139
+ const body = await request(ctx, withQuery(`/v1/issues/${pathSegment(id)}/dependencies`, listParams(pagination)));
152
140
  return body.dependencies;
153
141
  },
154
142
  });
@@ -162,7 +150,7 @@ export function registerIssueActions(rl) {
162
150
  }, STRICT_OBJECT),
163
151
  async execute(input, ctx) {
164
152
  const { id, ...pagination } = input;
165
- return request(ctx, withQuery(`/v1/issues/${pathSegment(id)}/dependencies`, issueListParams(pagination)));
153
+ return request(ctx, withQuery(`/v1/issues/${pathSegment(id)}/dependencies`, listParams(pagination)));
166
154
  },
167
155
  });
168
156
  rl.registerAction("issue.dependency.add", {
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { cursorSchema, enumSchema, idSchema, PROJECT_STATUS, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, } from "./shared.js";
2
+ import { cursorSchema, enumSchema, idSchema, listParams, PROJECT_STATUS, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, } from "./shared.js";
3
3
  const projectListFields = {
4
4
  status: t.Optional(enumSchema("Project status", PROJECT_STATUS)),
5
5
  leadUserId: t.Optional(idSchema("Lead user ID")),
@@ -7,14 +7,6 @@ const projectListFields = {
7
7
  cursor: t.Optional(cursorSchema()),
8
8
  limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })),
9
9
  };
10
- function listParams(input) {
11
- const params = new URLSearchParams();
12
- for (const [key, value] of Object.entries((input ?? {}))) {
13
- if (value !== undefined)
14
- params.set(key, String(value));
15
- }
16
- return params;
17
- }
18
10
  export function registerProjectActions(rl) {
19
11
  rl.registerAction("project.list", {
20
12
  access: "read",
@@ -1,8 +1,4 @@
1
- import { baseUrl, pathSegment } from "../../_shared/shiftCloud.js";
2
- export { baseUrl, cursorSchema, enumDescription, enumSchema, idSchema, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, } from "../../_shared/shiftCloud.js";
3
- export function pageRenderUrl(organizationId, slug) {
4
- return new URL(`/pages/${pathSegment(organizationId)}/${pathSegment(slug)}`, baseUrl()).toString();
5
- }
1
+ export { cursorSchema, enumDescription, enumSchema, idSchema, listParams, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, withQuery, } from "../../_shared/shiftCloud.js";
6
2
  export const ISSUE_STATUS = [
7
3
  "backlog",
8
4
  "todo",
@@ -45,7 +41,3 @@ export const ISSUE_VIEW_SORT = [
45
41
  "dueAt",
46
42
  "updatedAt",
47
43
  ];
48
- export const PAGE_STATUS = ["draft", "published", "archived"];
49
- export const PAGE_VISIBILITY = ["org", "invited"];
50
- export const TRANSCRIPTION_LANGUAGE = ["auto", "en", "he"];
51
- export const TRANSCRIPT_FORMAT = ["txt", "srt", "vtt", "json"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Code mode for agents \u2014 turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",