wizards 0.2.8 → 0.2.10

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 (60) hide show
  1. package/apps/runtime/dist/agents/project-assistant.js +16 -2
  2. package/apps/runtime/dist/agents/website.js +48 -10
  3. package/apps/runtime/dist/app.js +2 -0
  4. package/apps/runtime/dist/db/control-schema.js +18 -1
  5. package/apps/runtime/dist/db/migrations/control/0007_common_the_hand.sql +15 -0
  6. package/apps/runtime/dist/db/migrations/control/meta/0007_snapshot.json +460 -0
  7. package/apps/runtime/dist/db/migrations/control/meta/_journal.json +7 -0
  8. package/apps/runtime/dist/db/migrations/tenant/0006_hard_war_machine.sql +30 -0
  9. package/apps/runtime/dist/db/migrations/tenant/meta/0006_snapshot.json +1696 -0
  10. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  11. package/apps/runtime/dist/db/schema.js +12 -6
  12. package/apps/runtime/dist/engine/steps.js +3 -0
  13. package/apps/runtime/dist/env.js +2 -0
  14. package/apps/runtime/dist/index.js +2 -0
  15. package/apps/runtime/dist/plugins/events.js +37 -14
  16. package/apps/runtime/dist/plugins/jobs.js +64 -0
  17. package/apps/runtime/dist/plugins/loader.js +67 -4
  18. package/apps/runtime/dist/plugins/public.js +31 -0
  19. package/apps/runtime/dist/plugins/registry.js +5 -0
  20. package/apps/runtime/dist/routes/plugin-public.js +59 -0
  21. package/apps/runtime/dist/routes/plugins.js +1 -1
  22. package/apps/runtime/dist/routes/studio.js +3 -0
  23. package/apps/runtime/dist/services/project-files.js +4 -0
  24. package/apps/runtime/dist/services/project-index.js +69 -9
  25. package/apps/runtime/dist/services/projects.js +7 -1
  26. package/apps/runtime/dist/services/spaces.js +2 -0
  27. package/apps/runtime/dist/tools/index.js +3 -2
  28. package/apps/runtime/dist/tools/plugin.js +89 -17
  29. package/apps/runtime/dist/tools/project.js +39 -31
  30. package/apps/web/dist/assets/Account-6cwwWOU3.js +1 -0
  31. package/apps/web/dist/assets/{ChangelogDialog-BNQ9yup8.js → ChangelogDialog-DewAXwoQ.js} +1 -1
  32. package/apps/web/dist/assets/{ChatPanel-CtE1wcUD.js → ChatPanel-CCNgem_v.js} +1 -1
  33. package/apps/web/dist/assets/{EditorPage-CAxamSak.js → EditorPage-DqVuS-JG.js} +2 -2
  34. package/apps/web/dist/assets/{FlowPreview-B3IxmikV.js → FlowPreview-BAVCgsXg.js} +1 -1
  35. package/apps/web/dist/assets/{Harness-Bo-kwm1H.js → Harness-BZCoZ4Z1.js} +1 -1
  36. package/apps/web/dist/assets/{HomePage-BdIa2zcg.js → HomePage-BsuuOXbB.js} +1 -1
  37. package/apps/web/dist/assets/{ImportWizard-DMea5ZH1.js → ImportWizard-BG28_HzC.js} +1 -1
  38. package/apps/web/dist/assets/{LocalRuntime-DFswQjxc.js → LocalRuntime-DyJJVozN.js} +1 -1
  39. package/apps/web/dist/assets/{NewWizardPage-Cnb6UYme.js → NewWizardPage-8B-d3T4k.js} +2 -2
  40. package/apps/web/dist/assets/ProjectPage-CkO8ndix.js +2 -0
  41. package/apps/web/dist/assets/{PublicRunner-CIHEMLlN.js → PublicRunner-DF960weZ.js} +1 -1
  42. package/apps/web/dist/assets/{RunnerView-COKGppwq.js → RunnerView-Z3VVOwVR.js} +1 -1
  43. package/apps/web/dist/assets/SettingsPage-BOYAZS08.js +1 -0
  44. package/apps/web/dist/assets/{SetupPage-Bk4qXYZV.js → SetupPage-Cl4Yc7A2.js} +1 -1
  45. package/apps/web/dist/assets/{SharePage-ChEtCkhn.js → SharePage-BFRWRmOF.js} +1 -1
  46. package/apps/web/dist/assets/changelog-DhleQ4p1.js +1 -0
  47. package/apps/web/dist/assets/{esm-Rtjq9ReB.js → esm-CLbOcpCO.js} +1 -1
  48. package/apps/web/dist/assets/index-Bl5vhqT3.js +10 -0
  49. package/apps/web/dist/assets/{meta-MVWkLr9H.js → meta-DxGh33r8.js} +1 -1
  50. package/apps/web/dist/assets/{outputs-s8BpXH6V.js → outputs-BDP8UsZT.js} +1 -1
  51. package/apps/web/dist/index.html +1 -1
  52. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +183 -0
  53. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +48 -3
  54. package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +7 -1
  55. package/package.json +1 -1
  56. package/apps/web/dist/assets/Account-YJEQEo5k.js +0 -1
  57. package/apps/web/dist/assets/ProjectPage-CR_FIUya.js +0 -2
  58. package/apps/web/dist/assets/SettingsPage-BqAogRSI.js +0 -1
  59. package/apps/web/dist/assets/changelog-C03GWYid.js +0 -1
  60. package/apps/web/dist/assets/index-BAQ2e4is.js +0 -10
@@ -43,6 +43,13 @@
43
43
  "when": 1791230045311,
44
44
  "tag": "0005_reflective_forgotten_one",
45
45
  "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "6",
50
+ "when": 1791283092075,
51
+ "tag": "0006_hard_war_machine",
52
+ "breakpoints": true
46
53
  }
47
54
  ]
48
55
  }
@@ -198,18 +198,23 @@ export const projectFile = sqliteTable("project_file", {
198
198
  updatedAt: updatedAt(),
199
199
  }, (t) => [index("project_file_project").on(t.projectId, t.kind, t.position)]);
200
200
  /**
201
- * The document index: passages of a project's documents. Keywords live in the FTS5 table
202
- * `project_chunk_fts`, which triggers keep in step (see the migration); `embedding` is the
203
- * passage's vector as float32 bytes, compared with libSQL's `vector_distance_cos`.
201
+ * The document index: passages of a project's documents, and of the texts plugins put in
202
+ * (`server.index`). Keywords live in the FTS5 table `project_chunk_fts`, which triggers keep
203
+ * in step (see the migrations); `embedding` is the passage's vector as float32 bytes, compared
204
+ * with libSQL's `vector_distance_cos`.
204
205
  */
205
206
  export const projectChunk = sqliteTable("project_chunk", {
206
207
  id: integer("id").primaryKey({ autoIncrement: true }),
207
208
  projectId: text("project_id")
208
209
  .notNull()
209
210
  .references(() => project.id, { onDelete: "cascade" }),
210
- fileId: text("file_id")
211
- .notNull()
212
- .references(() => projectFile.id, { onDelete: "cascade" }),
211
+ /** The document the passage is of; null for a plugin's text. */
212
+ fileId: text("file_id").references(() => projectFile.id, { onDelete: "cascade" }),
213
+ /** A plugin's text: the plugin, its key for the text, its title and where the studio shows it. */
214
+ plugin: text("plugin"),
215
+ ref: text("ref"),
216
+ title: text("title"),
217
+ link: text("link"),
213
218
  idx: integer("idx").notNull(),
214
219
  text: text("text").notNull(),
215
220
  embedding: blob("embedding", { mode: "buffer" }),
@@ -218,6 +223,7 @@ export const projectChunk = sqliteTable("project_chunk", {
218
223
  }, (t) => [
219
224
  index("project_chunk_project").on(t.projectId, t.model),
220
225
  index("project_chunk_file").on(t.fileId),
226
+ index("project_chunk_ref").on(t.projectId, t.plugin, t.ref),
221
227
  ]);
222
228
  // --- The wizard's store ------------------------------------------------------
223
229
  // What a wizard keeps between runs for one person. `holder` is that person: "u:<userId>" for a
@@ -7,6 +7,7 @@ import { generateImageMedia, generateSpeechMedia, generateVideoMedia, } from "..
7
7
  import { markMedia } from "../media/marking.js";
8
8
  import { attachTools, costOf, isHarnessVendor, textModel } from "../models.js";
9
9
  import { buildStepTools } from "../tools/index.js";
10
+ import { spaceContextOf } from "../tools/plugin.js";
10
11
  import { personUploads } from "../tools/store.js";
11
12
  import { runWidgetStep } from "../widgets/step.js";
12
13
  import { prepareInputs } from "./prepare.js";
@@ -168,6 +169,8 @@ export async function runAgentStep(step, ctx) {
168
169
  `Today is ${today()}.`,
169
170
  brandBlock(ctx),
170
171
  documentsBlock(ctx),
172
+ // What the space's plugins hold for its wizards: a wiki, questions and answers.
173
+ ...(await spaceContextOf(ctx)).blocks,
171
174
  formatHint,
172
175
  ]
173
176
  .filter(Boolean)
@@ -218,6 +218,8 @@ export const env = {
218
218
  * alone. A runtime of a Manage-App never reloads: it would hit every tenant.
219
219
  */
220
220
  watch: !manageUrl && str("PLUGINS_WATCH", "1") === "1",
221
+ /** Plugins' jobs (`server.every`) run. Off (`PLUGIN_JOBS=0`) where another process runs them. */
222
+ jobs: str("PLUGIN_JOBS", "1") === "1",
221
223
  },
222
224
  limits: {
223
225
  visitorRunsPerHour: num("LIMIT_VISITOR_RUNS_PER_HOUR", 6),
@@ -6,6 +6,7 @@ import { resumeInterruptedRuns } from "./engine/runner.js";
6
6
  import { basePath, env } from "./env.js";
7
7
  import { managed } from "./manage.js";
8
8
  import { loadCatalog, loadLocalModels } from "./models.js";
9
+ import { startJobs } from "./plugins/jobs.js";
9
10
  import { loadPlugins } from "./plugins/loader.js";
10
11
  import { closeBrowser } from "./render/chromium.js";
11
12
  import { clearRunning, writeRunning } from "./running.js";
@@ -17,6 +18,7 @@ import { onRestart } from "./update.js";
17
18
  await migrateControlDb();
18
19
  // Before any tenant database opens: each gets the plugins' own tables as it does.
19
20
  await loadPlugins();
21
+ startJobs();
20
22
  if (managed) {
21
23
  // Every tenant database is brought to this release's schema; a new one migrates when first opened.
22
24
  void migrateAllTenants().then(({ migrated, failed }) => {
@@ -3,30 +3,24 @@ import { db, schema } from "../db/client.js";
3
3
  import { currentTenant } from "../tenants/tenant.js";
4
4
  import { pluginsOf } from "./registry.js";
5
5
  /**
6
- * Tells the tenant's plugins that a run reached its end. A plugin cannot fail a run: what a
7
- * listener throws is logged and nothing more.
6
+ * Tells the current tenant's plugins what happened. A plugin cannot fail what the runtime does:
7
+ * what a listener throws is logged and nothing more.
8
8
  */
9
- export async function runEnded(runId, status) {
9
+ export async function emitPluginEvent(event, payload) {
10
10
  try {
11
- const event = `run.${status}`;
12
11
  const listening = (await pluginsOf(currentTenant())).filter((p) => p.listeners[event]?.length);
13
12
  if (!listening.length) {
14
13
  return;
15
14
  }
16
- const run = await db.query.run.findFirst({ where: eq(schema.run.id, runId) });
17
- const wizard = run && (await db.query.wizard.findFirst({ where: eq(schema.wizard.id, run.wizardId) }));
18
- if (!run || !wizard) {
15
+ // What is costly to gather is gathered only when someone listens.
16
+ const told = typeof payload === "function" ? await payload() : payload;
17
+ if (!told) {
19
18
  return;
20
19
  }
21
- const payload = {
22
- run: { id: run.id, wizardId: run.wizardId, mode: run.mode, status },
23
- wizard: { id: wizard.id, title: run.definition.title, projectId: wizard.projectId },
24
- values: run.state.values,
25
- };
26
20
  for (const plugin of listening) {
27
21
  for (const listener of plugin.listeners[event] ?? []) {
28
22
  try {
29
- await listener(payload);
23
+ await listener(told);
30
24
  }
31
25
  catch (err) {
32
26
  console.error(`[plugin ${plugin.source.id}] ${event}:`, err);
@@ -35,6 +29,35 @@ export async function runEnded(runId, status) {
35
29
  }
36
30
  }
37
31
  catch (err) {
38
- console.error("[plugins] run event:", err);
32
+ console.error(`[plugins] ${event}:`, err);
39
33
  }
40
34
  }
35
+ /** A run reached its end. */
36
+ export function runEnded(runId, status) {
37
+ return emitPluginEvent(`run.${status}`, async () => {
38
+ const run = await db.query.run.findFirst({ where: eq(schema.run.id, runId) });
39
+ const wizard = run && (await db.query.wizard.findFirst({ where: eq(schema.wizard.id, run.wizardId) }));
40
+ if (!run || !wizard) {
41
+ return null;
42
+ }
43
+ return {
44
+ run: { id: run.id, wizardId: run.wizardId, mode: run.mode, status },
45
+ wizard: { id: wizard.id, title: run.definition.title, projectId: wizard.projectId },
46
+ values: run.state.values,
47
+ };
48
+ });
49
+ }
50
+ /**
51
+ * A space was made, changed or deleted. Not waited for: the request that changed the space
52
+ * answers without its plugins.
53
+ */
54
+ export function spaceChanged(event, spaceId) {
55
+ void emitPluginEvent(event, { space: { id: spaceId } });
56
+ }
57
+ /** A file of a space was read and is ready, or was removed. Not waited for either. */
58
+ export function spaceFileChanged(event, file) {
59
+ void emitPluginEvent(event, {
60
+ space: { id: file.projectId },
61
+ file: { id: file.id, name: file.name, kind: file.kind, mime: file.mime },
62
+ });
63
+ }
@@ -0,0 +1,64 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { control, controlDb, withTenant } from "../db/client.js";
3
+ import { env } from "../env.js";
4
+ import { loadedPlugins, pluginIdsOf } from "./registry.js";
5
+ /**
6
+ * Plugins' jobs (`server.every`). A tick looks once a minute which job is due for which tenant
7
+ * and runs it inside that tenant. When a job ran is kept in the control database, so a restart
8
+ * does not start the count again; a runtime that was off runs a due job once when it starts,
9
+ * not once per turn it missed.
10
+ */
11
+ /** Jobs that run right now, by tenant, plugin and name: one turn at a time each. */
12
+ const running = new Set();
13
+ /** Starts every job that is due, and is done when they are. */
14
+ export async function runDueJobs(now = new Date()) {
15
+ const jobs = loadedPlugins().flatMap((plugin) => [...plugin.jobs].map(([name, job]) => ({ plugin, name, job })));
16
+ if (!jobs.length) {
17
+ return;
18
+ }
19
+ const tenants = await controlDb.query.tenant.findMany({
20
+ where: eq(control.tenant.status, "active"),
21
+ });
22
+ const started = [];
23
+ for (const tenant of tenants) {
24
+ const has = await pluginIdsOf(tenant.id);
25
+ for (const { plugin, name, job } of jobs) {
26
+ const id = plugin.source.id;
27
+ const key = `${tenant.id}/${id}/${name}`;
28
+ if (!has.has(id) || running.has(key)) {
29
+ continue;
30
+ }
31
+ const where = and(eq(control.pluginJob.tenantId, tenant.id), eq(control.pluginJob.plugin, id), eq(control.pluginJob.name, name));
32
+ const last = (await controlDb.query.pluginJob.findFirst({ where }))?.lastRunAt ?? null;
33
+ if (last && now.getTime() - last.getTime() < job.everyMs) {
34
+ continue;
35
+ }
36
+ running.add(key);
37
+ // The start counts as the run: a turn that throws or hangs is not repeated every minute.
38
+ await controlDb
39
+ .insert(control.pluginJob)
40
+ .values({ tenantId: tenant.id, plugin: id, name, lastRunAt: now })
41
+ .onConflictDoUpdate({
42
+ target: [control.pluginJob.tenantId, control.pluginJob.plugin, control.pluginJob.name],
43
+ set: { lastRunAt: now },
44
+ });
45
+ started.push(withTenant(tenant.id, () => job.handler({ tenantId: tenant.id, lastRun: last, signal: plugin.stopped.signal }))
46
+ .catch((err) => console.error(`[plugin ${id}] job ${name}:`, err))
47
+ .finally(() => running.delete(key)));
48
+ }
49
+ }
50
+ await Promise.all(started);
51
+ }
52
+ const TICK_MS = 60_000;
53
+ let timer = null;
54
+ /** Looks for due jobs now and then once a minute, while the runtime runs. */
55
+ export function startJobs() {
56
+ if (timer || !env.plugins.jobs) {
57
+ return;
58
+ }
59
+ const tick = () => void runDueJobs().catch((err) => console.error("[plugins] jobs:", err));
60
+ // Soon after the start, not during it: tenant databases open first.
61
+ setTimeout(tick, 5_000).unref();
62
+ timer = setInterval(tick, TICK_MS);
63
+ timer.unref();
64
+ }
@@ -4,12 +4,17 @@ import { fileURLToPath } from "node:url";
4
4
  import { PLUGIN_TOOL_NAME } from "@engenty-wizards/shared/definition";
5
5
  import { generateText, Output } from "ai";
6
6
  import { createJiti } from "jiti";
7
+ import { readPage } from "../agents/website.js";
7
8
  import { packageRoot } from "../cli/home.js";
8
9
  import { db, onTenantOpen } from "../db/client.js";
10
+ import { parseDocument } from "../documents/parse.js";
9
11
  import { env } from "../env.js";
10
12
  import { ModelUnavailableError, textModel } from "../models.js";
13
+ import { putIndexEntry, removeIndexEntries } from "../services/project-index.js";
14
+ import { safeFetch } from "../tools/net-guard.js";
11
15
  import { discoverPlugins } from "./discovery.js";
12
16
  import { migrateOpenTenants, migratePlugins } from "./migrations.js";
17
+ import { publicUrlOf } from "./public.js";
13
18
  import { announceChange, compileRoute, dropPlugin, emptyRecord, loadedPlugin, loadedPlugins, setPlugin, } from "./registry.js";
14
19
  /**
15
20
  * Loading plugins (docs/content/dev/plugins). A plugin's server half is TypeScript or JavaScript read
@@ -40,9 +45,21 @@ async function importFactory(entry) {
40
45
  }
41
46
  return factory;
42
47
  }
48
+ /** A job runs at most this often. */
49
+ const MIN_EVERY_MS = 60_000;
43
50
  function apiFor(record) {
44
51
  const { source } = record;
45
52
  const tag = `[plugin ${source.id}]`;
53
+ /** The names a step's model sees: the plugin's step tools and its context tools share them. */
54
+ const stepToolNames = () => [
55
+ ...record.tools.keys(),
56
+ ...record.contexts.flatMap((c) => (c.tools ?? []).map((t) => t.name)),
57
+ ];
58
+ const named = (kind, name) => {
59
+ if (!PLUGIN_TOOL_NAME.test(name)) {
60
+ throw new Error(`${kind} "${name}": a name is lower case, digits and "_".`);
61
+ }
62
+ };
46
63
  return {
47
64
  plugin: source.manifest,
48
65
  resolvePath: (relative) => resolve(source.root, relative),
@@ -57,14 +74,54 @@ function apiFor(record) {
57
74
  record.routes.push(compileRoute(route));
58
75
  },
59
76
  registerTool(tool) {
60
- if (!PLUGIN_TOOL_NAME.test(tool.name)) {
61
- throw new Error(`Tool "${tool.name}": a name is lower case, digits and "_".`);
62
- }
63
- if (record.tools.has(tool.name)) {
77
+ named("Tool", tool.name);
78
+ if (stepToolNames().includes(tool.name)) {
64
79
  throw new Error(`Tool "${tool.name}" is registered twice.`);
65
80
  }
66
81
  record.tools.set(tool.name, tool);
67
82
  },
83
+ registerSpaceContext(context) {
84
+ const names = (context.tools ?? []).map((t) => t.name);
85
+ for (const name of names) {
86
+ named("Tool", name);
87
+ if (stepToolNames().includes(name) || names.indexOf(name) !== names.lastIndexOf(name)) {
88
+ throw new Error(`Tool "${name}" is registered twice.`);
89
+ }
90
+ }
91
+ record.contexts.push(context);
92
+ },
93
+ registerAssistantTool(tool) {
94
+ named("Assistant tool", tool.name);
95
+ if (record.assistantTools.has(tool.name)) {
96
+ throw new Error(`Assistant tool "${tool.name}" is registered twice.`);
97
+ }
98
+ record.assistantTools.set(tool.name, tool);
99
+ },
100
+ registerPublicRoute(route) {
101
+ record.publicRoutes.push(compileRoute(route));
102
+ },
103
+ publicUrl: (path) => publicUrlOf(source.id, path),
104
+ every(name, everyMs, handler) {
105
+ named("Job", name);
106
+ if (record.jobs.has(name)) {
107
+ throw new Error(`Job "${name}" is registered twice.`);
108
+ }
109
+ record.jobs.set(name, { everyMs: Math.max(MIN_EVERY_MS, everyMs), handler });
110
+ },
111
+ index: {
112
+ put: (entry) => putIndexEntry(source.id, entry),
113
+ remove: (space, key) => removeIndexEntries(source.id, space, key),
114
+ },
115
+ web: {
116
+ fetch: (url, init) => safeFetch(url, init),
117
+ read: (url, options) => readPage(url, options?.signal),
118
+ },
119
+ documents: {
120
+ async parse(file, options) {
121
+ const parsed = await parseDocument(file, { signal: options?.signal });
122
+ return { markdown: parsed.markdown, pages: parsed.pages };
123
+ },
124
+ },
68
125
  registerMigrations(folder) {
69
126
  record.migrations.push(resolve(source.root, folder));
70
127
  },
@@ -102,6 +159,8 @@ function apiFor(record) {
102
159
  }
103
160
  /** Runs what the plugin asked to have undone, the last first. */
104
161
  async function dispose(record) {
162
+ // Its jobs are told to stop; the runtime stops starting them with the record gone.
163
+ record.stopped.abort();
105
164
  for (const undo of record.disposers.splice(0).reverse()) {
106
165
  try {
107
166
  await undo();
@@ -123,6 +182,10 @@ async function load(source) {
123
182
  await dispose(record);
124
183
  record.routes = [];
125
184
  record.tools.clear();
185
+ record.contexts = [];
186
+ record.assistantTools.clear();
187
+ record.publicRoutes = [];
188
+ record.jobs.clear();
126
189
  record.listeners = {};
127
190
  record.migrations = [];
128
191
  record.error = err?.message || String(err);
@@ -0,0 +1,31 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import { control, controlDb } from "../db/client.js";
4
+ import { env } from "../env.js";
5
+ import { currentTenant } from "../tenants/tenant.js";
6
+ /**
7
+ * Public addresses of plugins (`server.publicUrl`): `/api/public/plugins/<plugin>/<ref>/…`. The
8
+ * ref names the tenant without giving its id away; one per tenant and plugin, kept for good.
9
+ */
10
+ /** The full address of a plugin's public route for the current tenant. */
11
+ export async function publicUrlOf(plugin, path) {
12
+ const tenantId = currentTenant();
13
+ const where = and(eq(control.pluginAddress.tenantId, tenantId), eq(control.pluginAddress.plugin, plugin));
14
+ let row = await controlDb.query.pluginAddress.findFirst({ where });
15
+ if (!row) {
16
+ await controlDb
17
+ .insert(control.pluginAddress)
18
+ .values({ ref: nanoid(24), tenantId, plugin })
19
+ .onConflictDoNothing();
20
+ row = (await controlDb.query.pluginAddress.findFirst({ where }));
21
+ }
22
+ const rest = path.startsWith("/") ? path : `/${path}`;
23
+ return `${env.appUrl}/api/public/plugins/${plugin}/${row.ref}${rest === "/" ? "" : rest}`;
24
+ }
25
+ /** The tenant a plugin's public address belongs to. */
26
+ export async function tenantOfAddress(plugin, ref) {
27
+ const row = await controlDb.query.pluginAddress.findFirst({
28
+ where: eq(control.pluginAddress.ref, ref),
29
+ });
30
+ return row?.plugin === plugin ? row.tenantId : null;
31
+ }
@@ -15,9 +15,14 @@ export function emptyRecord(source) {
15
15
  error: null,
16
16
  routes: [],
17
17
  tools: new Map(),
18
+ contexts: [],
19
+ assistantTools: new Map(),
20
+ publicRoutes: [],
21
+ jobs: new Map(),
18
22
  listeners: {},
19
23
  migrations: [],
20
24
  disposers: [],
25
+ stopped: new AbortController(),
21
26
  };
22
27
  }
23
28
  export function setPlugin(record) {
@@ -0,0 +1,59 @@
1
+ import { Hono } from "hono";
2
+ import { withTenant } from "../db/client.js";
3
+ import { tenantOfAddress } from "../plugins/public.js";
4
+ import { loadedPlugin, pluginIdsOf } from "../plugins/registry.js";
5
+ import { tenantStatus } from "../tenants/control.js";
6
+ import { answerOf } from "./plugins.js";
7
+ /**
8
+ * Plugins' public routes (`server.registerPublicRoute`), below `/api/public/plugins/<id>/<ref>`.
9
+ * Nobody is signed in: the ref names the tenant, and the plugin checks a secret of its own.
10
+ */
11
+ const notFound = (c) => c.json({ error: "not found", code: "not_found" }, 404);
12
+ async function dispatch(c) {
13
+ const id = c.req.param("id") ?? "";
14
+ const ref = c.req.param("ref") ?? "";
15
+ const tenantId = await tenantOfAddress(id, ref);
16
+ if (!tenantId || (await tenantStatus(tenantId)) !== "active") {
17
+ return notFound(c);
18
+ }
19
+ const plugin = (await pluginIdsOf(tenantId)).has(id) ? loadedPlugin(id) : undefined;
20
+ if (!plugin) {
21
+ return notFound(c);
22
+ }
23
+ const url = new URL(c.req.url);
24
+ const marker = `/plugins/${id}/${ref}`;
25
+ const path = url.pathname.slice(url.pathname.indexOf(marker) + marker.length);
26
+ for (const { route, pattern, params } of plugin.publicRoutes) {
27
+ const match = route.method === c.req.method ? pattern.exec(path) : null;
28
+ if (!match) {
29
+ continue;
30
+ }
31
+ const request = {
32
+ request: c.req.raw,
33
+ path: path || "/",
34
+ params: Object.fromEntries(params.map((name, i) => [name, decodeURIComponent(match[i + 1])])),
35
+ query: url.searchParams,
36
+ tenantId,
37
+ json: async () => {
38
+ const text = await c.req.text();
39
+ return (text ? JSON.parse(text) : {});
40
+ },
41
+ };
42
+ try {
43
+ const result = await withTenant(tenantId, () => route.handler(request));
44
+ return result instanceof Response ? result : c.json(result ?? null);
45
+ }
46
+ catch (err) {
47
+ const answer = answerOf(err);
48
+ if (answer) {
49
+ return c.json(answer.body, answer.status);
50
+ }
51
+ console.error(`[plugin ${id}] public ${route.method} ${path}:`, err);
52
+ return c.json({ error: "The plugin failed.", code: "plugin_failed" }, 500);
53
+ }
54
+ }
55
+ return notFound(c);
56
+ }
57
+ export const publicPluginRoutes = new Hono()
58
+ .all("/:id/:ref", dispatch)
59
+ .all("/:id/:ref/*", dispatch);
@@ -29,7 +29,7 @@ function view(plugin) {
29
29
  };
30
30
  }
31
31
  /** A thrown error that says how to answer: `{ status, code }`, as the runtime's own errors do. */
32
- function answerOf(err) {
32
+ export function answerOf(err) {
33
33
  const e = err;
34
34
  return typeof e?.status === "number" &&
35
35
  e.status >= 400 &&
@@ -385,6 +385,9 @@ export const studio = new Hono()
385
385
  onChanged: () => {
386
386
  void stream.writeSSE({ event: "changed", data: "1" });
387
387
  },
388
+ onCard: (card) => {
389
+ void stream.writeSSE({ event: "card", data: JSON.stringify(card) });
390
+ },
388
391
  });
389
392
  await stream.writeSSE({
390
393
  event: "done",
@@ -5,6 +5,7 @@ import { db, schema } from "../db/client.js";
5
5
  import { documentMime, UnreadableDocument } from "../documents/parse.js";
6
6
  import { loadAsset, removeAsset, saveAsset } from "../files/storage.js";
7
7
  import { textModel } from "../models.js";
8
+ import { spaceFileChanged } from "../plugins/events.js";
8
9
  import { dropLinks, putLink } from "../tenants/control.js";
9
10
  import { requireWritableProject } from "./access.js";
10
11
  import { notFound, ServiceError } from "./errors.js";
@@ -192,11 +193,13 @@ async function prepare(row) {
192
193
  if (row.kind !== "document") {
193
194
  const description = await describeImage(row, data).catch(() => "");
194
195
  await set({ status: "ready", ...(description ? { description } : {}) });
196
+ spaceFileChanged("space.file.ready", row);
195
197
  return;
196
198
  }
197
199
  const { text, ...indexed } = await indexDocument(row, data);
198
200
  const description = row.description || (text ? await describeDocument(row, text).catch(() => "") : "");
199
201
  await set({ ...indexed, status: "ready", error: null, description });
202
+ spaceFileChanged("space.file.ready", row);
200
203
  }
201
204
  catch (err) {
202
205
  console.error("[project-file]", row.name, err);
@@ -259,6 +262,7 @@ async function removeRow(row) {
259
262
  if (row.kind === "logo") {
260
263
  await dropLinks([row.id]);
261
264
  }
265
+ spaceFileChanged("space.file.removed", row);
262
266
  }
263
267
  export async function removeProjectFile(projectId, fileId) {
264
268
  await requireWritableProject(projectId);
@@ -1,10 +1,13 @@
1
1
  import { embedMany } from "ai";
2
- import { eq, inArray, sql } from "drizzle-orm";
2
+ import { and, eq, inArray, sql } from "drizzle-orm";
3
3
  import { db, schema } from "../db/client.js";
4
4
  import { parseDocument } from "../documents/parse.js";
5
5
  import { createSearchChunks } from "../engenty/search-index/chunks.js";
6
6
  import { getBlob, putBlob } from "../files/blobs.js";
7
7
  import { embeddingModel } from "../models.js";
8
+ import { pluginIdsOf } from "../plugins/registry.js";
9
+ import { currentTenant } from "../tenants/tenant.js";
10
+ import { ServiceError } from "./errors.js";
8
11
  const CHUNK = { mode: "paragraph", max_chunk_length: 1200, overlap: 120 };
9
12
  /** Passages of one document; a longer one keeps its start. */
10
13
  const MAX_CHUNKS = 2000;
@@ -53,6 +56,51 @@ export async function indexDocument(file, data) {
53
56
  text,
54
57
  };
55
58
  }
59
+ /** Puts a plugin's text into the space's index, in the place of what its key held. */
60
+ export async function putIndexEntry(plugin, entry) {
61
+ const space = await db.query.project.findFirst({ where: eq(schema.project.id, entry.space) });
62
+ if (!space) {
63
+ throw new ServiceError("not_found", `There is no space "${entry.space}".`);
64
+ }
65
+ const text = `${entry.title.trim()}\n\n${entry.text.trim()}`;
66
+ const chunks = createSearchChunks({ doc_id: `${plugin}:${entry.key}`, text, ...CHUNK }).slice(0, MAX_CHUNKS);
67
+ const embedded = await embed(chunks.map((c) => c.text)).catch((err) => {
68
+ console.error("[project-index] embedding failed", err);
69
+ return null;
70
+ });
71
+ await removeIndexEntries(plugin, entry.space, entry.key);
72
+ for (let i = 0; i < chunks.length; i += 100) {
73
+ await db.insert(schema.projectChunk).values(chunks.slice(i, i + 100).map((c, j) => ({
74
+ projectId: entry.space,
75
+ plugin,
76
+ ref: entry.key,
77
+ title: entry.title.trim().slice(0, 300),
78
+ link: entry.link ?? null,
79
+ idx: c.chunk_index,
80
+ text: c.text,
81
+ embedding: embedded ? vectorBytes(embedded.vectors[i + j]) : null,
82
+ model: embedded?.model ?? null,
83
+ })));
84
+ }
85
+ }
86
+ /** Takes a plugin's text out of the space's index; without a key, all of the plugin's texts. */
87
+ export async function removeIndexEntries(plugin, space, key) {
88
+ await db
89
+ .delete(schema.projectChunk)
90
+ .where(and(eq(schema.projectChunk.projectId, space), eq(schema.projectChunk.plugin, plugin), key === undefined ? undefined : eq(schema.projectChunk.ref, key)));
91
+ }
92
+ /** Whether a plugin the tenant has put a text into the space's index. */
93
+ export async function hasIndexEntries(projectId) {
94
+ const plugins = [...(await pluginIdsOf(currentTenant()))];
95
+ if (!plugins.length) {
96
+ return false;
97
+ }
98
+ const found = await db.query.projectChunk.findFirst({
99
+ columns: { id: true },
100
+ where: and(eq(schema.projectChunk.projectId, projectId), inArray(schema.projectChunk.plugin, plugins)),
101
+ });
102
+ return Boolean(found);
103
+ }
56
104
  export async function documentText(file) {
57
105
  return file.textHash ? (await getBlob(file.textHash)).toString("utf8") : "";
58
106
  }
@@ -64,23 +112,32 @@ function keywordQuery(query) {
64
112
  .slice(0, 16);
65
113
  return words.length ? words.map((w) => `"${w}"*`).join(" OR ") : null;
66
114
  }
67
- /** The passages of the project's documents that fit a question best. */
115
+ /** A passage of a document, or of a plugin's text while the tenant has that plugin. */
116
+ function searchable(plugins, alias = "") {
117
+ const column = sql.raw(`${alias}plugin`);
118
+ return plugins.length
119
+ ? sql `(${column} IS NULL OR ${column} IN (${sql.join(plugins.map((p) => sql `${p}`), sql `, `)}))`
120
+ : sql `${column} IS NULL`;
121
+ }
122
+ /** The passages of the project's documents, and of the texts its plugins put in, that fit a question best. */
68
123
  export async function searchProject(projectId, query, limit = 6) {
124
+ const plugins = [...(await pluginIdsOf(currentTenant()))];
69
125
  const lists = [];
70
126
  const keywords = keywordQuery(query);
71
127
  if (keywords) {
72
128
  lists.push(await db.all(sql `
73
- SELECT c.id, c.file_id, c.idx, c.text
129
+ SELECT c.id, c.file_id, c.plugin, c.ref, c.title, c.link, c.idx, c.text
74
130
  FROM project_chunk_fts f JOIN project_chunk c ON c.id = f.rowid
75
131
  WHERE project_chunk_fts MATCH ${keywords} AND c.project_id = ${projectId}
132
+ AND ${searchable(plugins, "c.")}
76
133
  ORDER BY bm25(project_chunk_fts) LIMIT 24`));
77
134
  }
78
135
  const embedded = await embed([query]).catch(() => null);
79
136
  if (embedded) {
80
137
  lists.push(await db.all(sql `
81
- SELECT id, file_id, idx, text
138
+ SELECT id, file_id, plugin, ref, title, link, idx, text
82
139
  FROM project_chunk
83
- WHERE project_id = ${projectId} AND model = ${embedded.model}
140
+ WHERE project_id = ${projectId} AND model = ${embedded.model} AND ${searchable(plugins)}
84
141
  ORDER BY vector_distance_cos(embedding, ${vectorBytes(embedded.vectors[0])}) LIMIT 24`));
85
142
  }
86
143
  // Reciprocal rank fusion: a passage both lists rank high comes first.
@@ -96,12 +153,15 @@ export async function searchProject(projectId, query, limit = 6) {
96
153
  if (!top.length) {
97
154
  return [];
98
155
  }
99
- const files = await db.query.projectFile.findMany({
100
- where: inArray(schema.projectFile.id, [...new Set(top.map((t) => t.hit.file_id))]),
101
- });
156
+ const fileIds = [...new Set(top.map((t) => t.hit.file_id).filter((id) => id !== null))];
157
+ const files = fileIds.length
158
+ ? await db.query.projectFile.findMany({ where: inArray(schema.projectFile.id, fileIds) })
159
+ : [];
102
160
  return top.map(({ hit, score }) => ({
103
161
  fileId: hit.file_id,
104
- name: files.find((f) => f.id === hit.file_id)?.name ?? "",
162
+ plugin: hit.plugin,
163
+ name: hit.file_id ? (files.find((f) => f.id === hit.file_id)?.name ?? "") : (hit.title ?? ""),
164
+ link: hit.link,
105
165
  index: hit.idx,
106
166
  text: hit.text,
107
167
  score: Math.round(score * 10_000) / 10_000,