runline 0.18.0 → 0.19.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.
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
3
  import { Worker } from "node:worker_threads";
4
4
  import { applyEnvOverrides, updateConnectionConfig } from "../config/loader.js";
5
5
  import { formatValidationError, helpInputs, isTypedInputSchema, validateTypedInput, } from "../plugin/schema.js";
@@ -385,14 +385,12 @@ function formatError(cause) {
385
385
  }
386
386
  return String(cause);
387
387
  }
388
- // MiniSearch UMD bundle, vendored at the package root and inlined into the
389
- // worker source. Inside the worker it is evaluated in a local scope with a
390
- // fresh `module`/`exports` pair so the UMD takes its CommonJS branch.
391
- //
392
- // `../../vendor/...` resolves identically from src/core/engine.ts (dev) and
393
- // dist/core/engine.js (published) because tsc preserves the `core/` subdir.
394
- // See vendor/README.md for the upgrade procedure.
395
- const minisearchSource = readFileSync(new URL("../../vendor/minisearch.umd.js", import.meta.url), "utf8");
388
+ // ferrosearch is a Rust native addon, so unlike the old minisearch UMD it
389
+ // cannot be inlined into the worker source as text. Resolve its entry point
390
+ // host-side and require it by absolute path inside the worker: eval'd worker
391
+ // code gets a cwd-relative `require`, so a bare specifier would resolve
392
+ // against the caller's cwd instead of runline's own node_modules.
393
+ const ferrosearchPath = createRequire(import.meta.url).resolve("@shift-labs/ferrosearch");
396
394
  function buildHelpData(plugins) {
397
395
  const data = {};
398
396
  for (const p of plugins) {
@@ -432,7 +430,7 @@ function buildWorkerSource(pluginNames = [], helpData = {}) {
432
430
  "console",
433
431
  "fetch",
434
432
  "require",
435
- "MiniSearch",
433
+ "FerroSearch",
436
434
  ];
437
435
  const wrapped = `"use strict";
438
436
  const { parentPort: __port } = require("node:worker_threads");
@@ -522,14 +520,11 @@ const __fmtErr = (e) => {
522
520
  // structured-clone-safe and preserves JSON value semantics.
523
521
  const __toJson = (v) => v === undefined ? undefined : JSON.parse(JSON.stringify(v));
524
522
 
525
- // Inlined MiniSearch UMD, evaluated with a local module/exports so the UMD
526
- // takes its CommonJS branch regardless of the worker's module scope.
527
- const MiniSearch = (function () {
528
- const module = { exports: {} };
529
- const exports = module.exports;
530
- ${minisearchSource}
531
- return module.exports;
532
- })();
523
+ // ferrosearch native addon, resolved to an absolute path by the host. Its
524
+ // index memory lives outside the V8 heap, which is the point — minisearch's
525
+ // JS index counted against maxOldGenerationSizeMb and grew worker RSS — but
526
+ // also means an index built by agent code is invisible to that limit.
527
+ const { FerroSearch } = require(${JSON.stringify(ferrosearchPath)});
533
528
 
534
529
  const __help = ${JSON.stringify(helpData)};
535
530
 
@@ -563,7 +558,7 @@ const __formatSignature = (plugin, entry) => {
563
558
  return plugin + '.' + entry.action + (fields ? '({ ' + fields + ' })' : '()');
564
559
  };
565
560
 
566
- // Build a MiniSearch index over every action path. Indexed at worker
561
+ // Build a FerroSearch index over every action path. Indexed at worker
567
562
  // startup, queried by actions.find().
568
563
  const __search = (() => {
569
564
  const docs = [];
@@ -577,7 +572,7 @@ const __search = (() => {
577
572
  description: entry.description || '',
578
573
  });
579
574
  }
580
- const ms = new MiniSearch({
575
+ const index = new FerroSearch({
581
576
  fields: ['path', 'plugin', 'action', 'description'],
582
577
  storeFields: ['path', 'description'],
583
578
  searchOptions: {
@@ -586,8 +581,8 @@ const __search = (() => {
586
581
  boost: { path: 3, action: 2, plugin: 2 },
587
582
  },
588
583
  });
589
- ms.addAll(docs);
590
- return ms;
584
+ index.addAll(docs);
585
+ return index;
591
586
  })();
592
587
 
593
588
  const __actionsApi = {
@@ -813,7 +808,7 @@ const __injectValues = __injectNames.map((n) => {
813
808
  case "console": return console;
814
809
  case "fetch": return fetch;
815
810
  case "require": return __bodyRequire;
816
- case "MiniSearch": return MiniSearch;
811
+ case "FerroSearch": return FerroSearch;
817
812
  default: return __makeProxy([n]);
818
813
  }
819
814
  });
@@ -1,6 +1,7 @@
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";
4
5
  import { registerPageActions } from "./pages.js";
5
6
  import { registerProjectActions } from "./projects.js";
6
7
  import { registerTranscriptionActions } from "./transcription.js";
@@ -8,18 +9,17 @@ export default function shiftLabs(rl) {
8
9
  rl.setName("shiftLabs");
9
10
  rl.setVersion("0.1.0");
10
11
  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.
11
14
  apiKey: t.String({
12
15
  description: "Shift Labs API key",
13
16
  env: "SHIFT_LABS_API_KEY",
14
17
  }),
15
- organizationId: t.Optional(t.String({
16
- description: "Shift Labs organization ID (required for transcription actions)",
17
- env: "SHIFT_LABS_ORG_ID",
18
- })),
19
18
  }));
20
19
  registerProjectActions(rl);
21
20
  registerIssueActions(rl);
22
21
  registerIssueViewActions(rl);
23
22
  registerPageActions(rl);
24
23
  registerTranscriptionActions(rl);
24
+ registerObjectActions(rl);
25
25
  }
@@ -0,0 +1,249 @@
1
+ import { openAsBlob } from "node:fs";
2
+ import { readFile, stat, writeFile } from "node:fs/promises";
3
+ import { basename, extname } from "node:path";
4
+ import * as t from "typebox";
5
+ import { enumSchema, pathSegment, request } from "./shared.js";
6
+ const OBJECTS_BASE = "/v1/services/objects";
7
+ export const OBJECT_STATUS = ["pending_upload", "ready", "archived"];
8
+ export const OBJECT_LINK_TARGET = [
9
+ "session",
10
+ "artifact",
11
+ "eval_case",
12
+ "db_record",
13
+ ];
14
+ /** Mirrors the public service contract's 5 GiB object ceiling. */
15
+ const MAX_OBJECT_BYTES = 5 * 1024 * 1024 * 1024;
16
+ const STRICT_OBJECT = { additionalProperties: false };
17
+ 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
+ function linkSchema() {
58
+ return t.Object({
59
+ targetType: enumSchema("Link target type", OBJECT_LINK_TARGET),
60
+ targetId: t.String({
61
+ minLength: 1,
62
+ maxLength: 500,
63
+ pattern: "\\S",
64
+ description: "Target ID; db_record targets use source_system/table/row style ids, e.g. R2M_Sys/GoodsReceiptLog/42",
65
+ }),
66
+ role: t.Optional(t.String({
67
+ minLength: 1,
68
+ maxLength: 200,
69
+ pattern: "\\S",
70
+ description: "Link role, e.g. source",
71
+ })),
72
+ }, STRICT_OBJECT);
73
+ }
74
+ const provenanceFields = {
75
+ workspaceId: t.Optional(Id),
76
+ sessionId: t.Optional(Id),
77
+ artifactId: t.Optional(Id),
78
+ };
79
+ export function registerObjectActions(rl) {
80
+ rl.registerAction("object.upload", {
81
+ access: "write",
82
+ description: "Persist a local binary file (image, PDF, document, export) in the " +
83
+ "organization's durable object bucket. Uploads through a short-lived " +
84
+ "signed grant and returns the ready object with its stable ID — use " +
85
+ "that ID as a durable sourceObjectId in DB rows and artifact links.",
86
+ inputSchema: t.Object({
87
+ path: t.String({
88
+ minLength: 1,
89
+ pattern: "\\S",
90
+ description: "Host path of the file to store",
91
+ }),
92
+ contentType: t.Optional(t.String({
93
+ minLength: 1,
94
+ maxLength: 200,
95
+ pattern: "\\S",
96
+ description: "Explicit MIME type; inferred from the extension when omitted",
97
+ })),
98
+ filename: t.Optional(t.String({
99
+ minLength: 1,
100
+ maxLength: 500,
101
+ pattern: "\\S",
102
+ description: "Original filename; defaults to the path's basename",
103
+ })),
104
+ metadata: t.Optional(t.Record(t.String(), t.Unknown(), {
105
+ description: "Small caller-defined metadata object",
106
+ })),
107
+ ...provenanceFields,
108
+ }, STRICT_OBJECT),
109
+ async execute(input, ctx) {
110
+ 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
+ }
120
+ const created = await request(ctx, `${OBJECTS_BASE}/objects`, {
121
+ method: "POST",
122
+ // The API key is the tenant authority; no organizationId is sent.
123
+ body: JSON.stringify({
124
+ contentType,
125
+ sizeBytes: file.size,
126
+ filename: fields.filename ?? basename(fields.path),
127
+ workspaceId: fields.workspaceId,
128
+ sessionId: fields.sessionId,
129
+ artifactId: fields.artifactId,
130
+ metadata: fields.metadata,
131
+ }),
132
+ });
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
+ }
145
+ return request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(created.object.id)}/complete`, { method: "POST" });
146
+ },
147
+ });
148
+ rl.registerAction("object.list", {
149
+ access: "read",
150
+ description: "List stored objects, newest first. Archived objects appear only " +
151
+ "when filtered for by status.",
152
+ inputSchema: t.Object({
153
+ status: t.Optional(enumSchema("Object status", OBJECT_STATUS)),
154
+ contentType: t.Optional(t.String({ minLength: 1, maxLength: 200, pattern: "\\S" })),
155
+ ...provenanceFields,
156
+ }, STRICT_OBJECT),
157
+ 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}` : ""}`);
165
+ return body.objects;
166
+ },
167
+ });
168
+ rl.registerAction("object.get", {
169
+ access: "read",
170
+ description: "Get a stored object's metadata by ID.",
171
+ inputSchema: t.Object({ id: Id }, STRICT_OBJECT),
172
+ async execute(input, ctx) {
173
+ const { id } = input;
174
+ return request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(id)}`);
175
+ },
176
+ });
177
+ rl.registerAction("object.download", {
178
+ access: "read",
179
+ description: "Mint a short-lived signed download URL for a ready object. Set " +
180
+ "savePath to also download the bytes to a host file.",
181
+ inputSchema: t.Object({
182
+ id: Id,
183
+ savePath: t.Optional(t.String({
184
+ minLength: 1,
185
+ pattern: "\\S",
186
+ description: "Host path to write the downloaded bytes to",
187
+ })),
188
+ }, STRICT_OBJECT),
189
+ async execute(input, ctx) {
190
+ const fields = input;
191
+ const grant = await request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(fields.id)}/download`, {
192
+ method: "POST",
193
+ });
194
+ if (!fields.savePath)
195
+ return grant;
196
+ const response = await fetch(grant.url, { method: grant.method });
197
+ if (!response.ok) {
198
+ throw new Error(`Object download failed: HTTP ${response.status}`);
199
+ }
200
+ const bytes = Buffer.from(await response.arrayBuffer());
201
+ await writeFile(fields.savePath, bytes);
202
+ return { savedTo: fields.savePath, sizeBytes: bytes.byteLength };
203
+ },
204
+ });
205
+ rl.registerAction("object.attach", {
206
+ access: "write",
207
+ description: "Link an object to what produced or consumed it: a session, " +
208
+ "artifact, eval case, or customer DB record. Links are append-only " +
209
+ "provenance; duplicates are rejected.",
210
+ inputSchema: t.Object({
211
+ id: Id,
212
+ links: t.Array(linkSchema(), { minItems: 1, maxItems: 20 }),
213
+ }, STRICT_OBJECT),
214
+ async execute(input, ctx) {
215
+ const fields = input;
216
+ const body = await request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(fields.id)}/links`, { method: "POST", body: JSON.stringify({ links: fields.links }) });
217
+ return body.links;
218
+ },
219
+ });
220
+ rl.registerAction("object.links", {
221
+ access: "read",
222
+ description: "List an object's provenance links.",
223
+ inputSchema: t.Object({ id: Id }, STRICT_OBJECT),
224
+ async execute(input, ctx) {
225
+ const { id } = input;
226
+ const body = await request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(id)}/links`);
227
+ return body.links;
228
+ },
229
+ });
230
+ rl.registerAction("object.archive", {
231
+ access: "write",
232
+ description: "Archive a stored object (idempotent). Archived objects drop out of " +
233
+ "default listings and refuse download grants; nothing is deleted.",
234
+ inputSchema: t.Object({ id: Id }, STRICT_OBJECT),
235
+ async execute(input, ctx) {
236
+ const { id } = input;
237
+ return request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(id)}/archive`, { method: "POST" });
238
+ },
239
+ });
240
+ rl.registerAction("object.bucket", {
241
+ access: "read",
242
+ description: "Get the organization's durable object bucket, provisioning it on " +
243
+ "first use.",
244
+ inputSchema: t.Object({}, STRICT_OBJECT),
245
+ async execute(_input, ctx) {
246
+ return request(ctx, `${OBJECTS_BASE}/bucket`);
247
+ },
248
+ });
249
+ }
@@ -25,13 +25,6 @@ const MAX_MEDIA_BYTES = 5 * 1024 * 1024 * 1024;
25
25
  const MAX_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
26
26
  const STRICT_OBJECT = { additionalProperties: false };
27
27
  const Id = t.String({ minLength: 1, pattern: "\\S" });
28
- function organizationId(ctx) {
29
- const value = ctx.connection.config.organizationId;
30
- if (typeof value !== "string" || !value) {
31
- throw new Error("SHIFT_LABS_ORG_ID is not set. Transcription actions need the organization ID that owns the API key.");
32
- }
33
- return value;
34
- }
35
28
  function mediaTypeFor(path) {
36
29
  const mediaType = MEDIA_TYPES[extname(path).slice(1).toLowerCase()];
37
30
  if (!mediaType) {
@@ -83,7 +76,6 @@ export function registerTranscriptionActions(rl) {
83
76
  }, STRICT_OBJECT),
84
77
  async execute(input, ctx) {
85
78
  const fields = input;
86
- const org = organizationId(ctx);
87
79
  const contentType = mediaTypeFor(fields.path);
88
80
  const file = await stat(fields.path);
89
81
  if (!file.isFile())
@@ -95,8 +87,8 @@ export function registerTranscriptionActions(rl) {
95
87
  }
96
88
  const created = await request(ctx, "/v1/services/transcription/assets", {
97
89
  method: "POST",
90
+ // The API key is the tenant authority; no organizationId is sent.
98
91
  body: JSON.stringify({
99
- organizationId: org,
100
92
  contentType,
101
93
  sizeBytes: file.size,
102
94
  }),
@@ -117,7 +109,6 @@ export function registerTranscriptionActions(rl) {
117
109
  const job = await request(ctx, "/v1/services/transcription/jobs", {
118
110
  method: "POST",
119
111
  body: JSON.stringify({
120
- organizationId: org,
121
112
  assetId: created.asset.id,
122
113
  language: fields.language,
123
114
  diarize: fields.diarize,
@@ -135,7 +126,7 @@ export function registerTranscriptionActions(rl) {
135
126
  description: "List transcription jobs for the API key's organization, newest first.",
136
127
  inputSchema: t.Object({}, STRICT_OBJECT),
137
128
  async execute(_input, ctx) {
138
- const body = await request(ctx, `/v1/services/transcription/jobs?organizationId=${pathSegment(organizationId(ctx))}`);
129
+ const body = await request(ctx, "/v1/services/transcription/jobs");
139
130
  return body.jobs;
140
131
  },
141
132
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Code mode for agents — turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,8 +25,7 @@
25
25
  "runline": "./dist/main.js"
26
26
  },
27
27
  "files": [
28
- "dist",
29
- "vendor"
28
+ "dist"
30
29
  ],
31
30
  "scripts": {
32
31
  "build": "tsc && bun --filter runline-plugins build && rm -rf dist/plugins && cp -R ../runline-plugins/dist dist/plugins",
@@ -59,7 +58,6 @@
59
58
  "@types/bun": "^1.2.17",
60
59
  "@types/node": "^25.5.0",
61
60
  "@types/proper-lockfile": "^4.1.4",
62
- "minisearch": "^7.2.0",
63
61
  "typescript": "^5.8.0"
64
62
  },
65
63
  "peerDependencies": {
@@ -91,6 +89,7 @@
91
89
  }
92
90
  },
93
91
  "dependencies": {
92
+ "@shift-labs/ferrosearch": "^0.1.0",
94
93
  "chalk": "^5.6.2",
95
94
  "commander": "^14.0.3",
96
95
  "jiti": "^2.7.0",
package/vendor/README.md DELETED
@@ -1,19 +0,0 @@
1
- # vendor
2
-
3
- Third-party bundles inlined into the QuickJS sandbox at runtime.
4
-
5
- These files are committed deliberately: the sandbox eval'd source is part of runline's behavior, and PR diffs should show exactly what changes when a bundle is bumped.
6
-
7
- ## minisearch.umd.js
8
-
9
- UMD build of [minisearch](https://github.com/lucaong/minisearch). Read by `src/core/engine.ts` and inlined into the sandbox so `MiniSearch` is available as a global to agent code (powers `actions.find()`).
10
-
11
- To upgrade:
12
-
13
- ```bash
14
- npm i -D minisearch@latest
15
- cp node_modules/minisearch/dist/umd/index.js vendor/minisearch.umd.js
16
- git commit -am "vendor: bump minisearch"
17
- ```
18
-
19
- The `minisearch` devDependency exists only as a convenience for that `cp` — nothing imports it at runtime.