@vellumai/assistant 0.12.2-staging.5 → 0.12.2-staging.7

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 (169) hide show
  1. package/Dockerfile +7 -7
  2. package/docs/architecture/memory.md +11 -2
  3. package/docs/desktop-browser-cli.md +4 -2
  4. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  5. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  6. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  7. package/openapi.yaml +157 -55
  8. package/package.json +5 -4
  9. package/scripts/bundled-plugin-packages.ts +154 -0
  10. package/scripts/generate-bundled-plugin-packages.ts +29 -0
  11. package/scripts/postinstall.ts +33 -0
  12. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  13. package/scripts/test.ts +15 -13
  14. package/src/__tests__/agent-loop.test.ts +124 -0
  15. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  16. package/src/__tests__/channel-approval.test.ts +9 -14
  17. package/src/__tests__/conversation-agent-loop.test.ts +25 -0
  18. package/src/__tests__/db-conversation-tool-surface.test.ts +144 -0
  19. package/src/__tests__/managed-store.test.ts +121 -0
  20. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  21. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  22. package/src/__tests__/scaffold-managed-skill-tool.test.ts +88 -0
  23. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  24. package/src/__tests__/subagent-tool-gate-mode.test.ts +169 -0
  25. package/src/__tests__/terminal-tools.test.ts +8 -0
  26. package/src/__tests__/unicode.test.ts +36 -0
  27. package/src/agent/loop.ts +19 -0
  28. package/src/api/events/desktop-activity-changed.ts +10 -0
  29. package/src/api/index.ts +6 -0
  30. package/src/approvals/approval-primitive.ts +5 -2
  31. package/src/approvals/scoped-approval-grants.ts +6 -2
  32. package/src/calls/__tests__/voice-control-protocol.test.ts +24 -2
  33. package/src/calls/__tests__/voice-session-bridge.test.ts +22 -2
  34. package/src/calls/voice-control-protocol.ts +13 -4
  35. package/src/calls/voice-session-bridge.ts +27 -6
  36. package/src/cli/commands/__tests__/plugins.test.ts +66 -0
  37. package/src/cli/commands/plugins.ts +50 -18
  38. package/src/cli/lib/__tests__/install-from-github.test.ts +67 -0
  39. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +169 -0
  40. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +57 -0
  41. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +14 -0
  42. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +27 -1
  43. package/src/cli/lib/__tests__/plugin-details.test.ts +9 -2
  44. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +20 -0
  45. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +43 -0
  46. package/src/cli/lib/__tests__/search-plugins.test.ts +31 -0
  47. package/src/cli/lib/bundled-plugin-packages.json +4 -0
  48. package/src/cli/lib/bundled-plugin-packages.ts +87 -0
  49. package/src/cli/lib/diff-plugin.ts +1 -1
  50. package/src/cli/lib/inspect-plugin.ts +80 -12
  51. package/src/cli/lib/install-from-github.ts +133 -76
  52. package/src/cli/lib/plugin-catalog-cache.ts +22 -3
  53. package/src/cli/lib/plugin-catalog-local.ts +13 -3
  54. package/src/cli/lib/plugin-catalog-platform.ts +6 -1
  55. package/src/cli/lib/plugin-catalog-resolve.ts +20 -0
  56. package/src/cli/lib/plugin-details.ts +12 -0
  57. package/src/cli/lib/plugin-marketplace.ts +121 -21
  58. package/src/cli/lib/plugin-pin-history.ts +5 -2
  59. package/src/cli/lib/search-plugins.ts +58 -16
  60. package/src/cli/lib/upgrade-plugin.ts +48 -3
  61. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  62. package/src/config/bundled-skills/skill-management/TOOLS.json +6 -6
  63. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  64. package/src/daemon/conversation-agent-loop.ts +2 -0
  65. package/src/daemon/conversation-tool-setup.ts +61 -23
  66. package/src/daemon/conversation.ts +17 -0
  67. package/src/daemon/daemon-control.ts +2 -6
  68. package/src/daemon/orphan-reaper.ts +4 -3
  69. package/src/daemon/tool-setup-types.ts +6 -0
  70. package/src/daemon/wake-conversation-ops.ts +38 -15
  71. package/src/desktop/desktop-automation-lease.test.ts +143 -0
  72. package/src/desktop/desktop-automation-lease.ts +39 -3
  73. package/src/live-voice/__tests__/live-voice-vad.test.ts +624 -3
  74. package/src/live-voice/__tests__/session-controls.test.ts +18 -0
  75. package/src/live-voice/live-voice-session.ts +575 -53
  76. package/src/live-voice/session-controls.ts +7 -3
  77. package/src/messaging/provider-message-metadata.ts +3 -3
  78. package/src/monitoring/plugin-auto-update.ts +6 -0
  79. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  80. package/src/notifications/copy-composer.ts +11 -3
  81. package/src/persistence/conversation-plugin-facade.ts +13 -0
  82. package/src/persistence/conversation-tool-surface.ts +86 -0
  83. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  84. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  85. package/src/persistence/schema/conversation-tool-surfaces.ts +22 -0
  86. package/src/persistence/schema/index.ts +1 -0
  87. package/src/persistence/steps.ts +2 -0
  88. package/src/plugin-api/conversation-turn.ts +31 -7
  89. package/src/plugin-api/index.ts +9 -1
  90. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  91. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  92. package/src/plugins/defaults/memory/AGENTS.md +14 -2
  93. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +320 -0
  94. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  95. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  96. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +46 -0
  97. package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +5 -0
  98. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  99. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  100. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  101. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  102. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  103. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  104. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  105. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  106. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  107. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  108. package/src/plugins/defaults/memory/injectors.ts +4 -3
  109. package/src/plugins/defaults/memory/memory-retrospective-job.ts +55 -181
  110. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  111. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  112. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  113. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  114. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  115. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  116. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  117. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  118. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  119. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  120. package/src/plugins/defaults/memory/tools.ts +1 -1
  121. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  122. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  123. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  124. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  125. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  126. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  127. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  128. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  129. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +21 -0
  130. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  131. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  132. package/src/plugins/defaults/memory/v3/orchestrate.ts +86 -22
  133. package/src/plugins/defaults/memory/v3/pool-select.ts +10 -7
  134. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  135. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -1
  136. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  137. package/src/runtime/AGENTS.md +1 -1
  138. package/src/runtime/__tests__/agent-wake.test.ts +86 -1
  139. package/src/runtime/agent-wake.ts +20 -4
  140. package/src/runtime/guardian-action-service.ts +2 -17
  141. package/src/runtime/guardian-reply-router.ts +1 -8
  142. package/src/runtime/routes/__tests__/plugins-routes.test.ts +102 -0
  143. package/src/runtime/routes/channel-route-shared.ts +1 -9
  144. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  145. package/src/runtime/routes/desktop-setup-routes.ts +7 -3
  146. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  147. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  148. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  149. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  150. package/src/runtime/routes/plugins-routes.ts +69 -49
  151. package/src/schedule/run-script.ts +2 -2
  152. package/src/skills/managed-store.ts +98 -32
  153. package/src/tools/host-terminal/host-shell.ts +12 -6
  154. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  155. package/src/tools/shared/shell-output.test.ts +10 -0
  156. package/src/tools/shared/shell-output.ts +14 -2
  157. package/src/tools/skills/find-similar-skills.test.ts +3 -0
  158. package/src/tools/skills/resolve-execute-invocation.ts +24 -0
  159. package/src/tools/skills/sandbox-runner.ts +13 -2
  160. package/src/tools/skills/scaffold-managed.ts +18 -14
  161. package/src/tools/terminal/__tests__/safe-env.test.ts +29 -0
  162. package/src/tools/terminal/safe-env.ts +30 -1
  163. package/src/tools/terminal/sanitized-bash.ts +15 -2
  164. package/src/tools/terminal/shell-launch.test.ts +162 -0
  165. package/src/tools/terminal/shell.test.ts +29 -0
  166. package/src/tools/terminal/shell.ts +13 -7
  167. package/src/util/host-process.test.ts +17 -1
  168. package/src/util/host-process.ts +24 -0
  169. package/src/util/unicode.ts +29 -0
@@ -8,6 +8,7 @@ import {
8
8
  NORMAL_UPDATES_MARKER,
9
9
  parseTerminalSessionControl,
10
10
  type SessionControlRequest,
11
+ TASK_STOP_MARKER,
11
12
  } from "../calls/voice-control-protocol.js";
12
13
  import type { VoiceProgressConfig } from "../config/schemas/voice.js";
13
14
  import type { LiveVoiceSessionControl } from "./protocol.js";
@@ -86,6 +87,8 @@ export function lookFollowUpNote(action: LookSessionControl): string {
86
87
  // to carry it out.
87
88
  const UPDATES_LINE = `- To hear fewer spoken progress updates while you work (for example "don't give me updates so often"), confirm that you will only check in now and then and will tell them when it is done, then end your reply with ${FEWER_UPDATES_MARKER}. If they later want regular updates back, confirm and end with ${NORMAL_UPDATES_MARKER}.`;
88
89
 
90
+ const TASK_STOP_LINE = `- To abandon the unfinished task while keeping the call open (for example "stop that" or "never mind about that task"), confirm briefly, then end your reply with ${TASK_STOP_MARKER}. Use this only when they want the task abandoned, not when they interrupt with a question or correction.`;
91
+
89
92
  /**
90
93
  * The control-prompt block that teaches the session controls: the ones the
91
94
  * client declared plus the progress-update cadence. Every leg gets it,
@@ -98,7 +101,7 @@ const UPDATES_LINE = `- To hear fewer spoken progress updates while you work (fo
98
101
  export function sessionControlTeaching(
99
102
  controls: readonly LiveVoiceSessionControl[],
100
103
  leg: { frontDoor?: boolean },
101
- client: { lookFrames?: boolean } = {},
104
+ client: { lookFrames?: boolean; unfinishedTaskPending?: boolean } = {},
102
105
  ): string {
103
106
  const lines =
104
107
  client.lookFrames === true
@@ -109,6 +112,7 @@ export function sessionControlTeaching(
109
112
  ...controls.map((control) => lines[control]),
110
113
  ...lookGuidance(controls),
111
114
  UPDATES_LINE,
115
+ ...(client.unfinishedTaskPending === true ? [TASK_STOP_LINE] : []),
112
116
  `The marker must be the very last thing in your reply. It is never spoken and does nothing anywhere else.${leg.frontDoor === true ? "" : " Never emit any other bracketed marker."}`,
113
117
  ].join("\n");
114
118
  }
@@ -137,7 +141,7 @@ function lookGuidance(controls: readonly LiveVoiceSessionControl[]): string[] {
137
141
  /** A session control the client carries out, sent as a `session_control` frame. */
138
142
  export type ClientSessionControlRequest = Exclude<
139
143
  SessionControlRequest,
140
- { action: "updates" }
144
+ { action: "updates" } | { action: "task_stop" }
141
145
  >;
142
146
 
143
147
  /**
@@ -154,7 +158,7 @@ export function requestedSessionControl(
154
158
  if (request === null) {
155
159
  return null;
156
160
  }
157
- if (request.action === "updates") {
161
+ if (request.action === "updates" || request.action === "task_stop") {
158
162
  return request;
159
163
  }
160
164
  return controls.includes(request.action) ? request : null;
@@ -30,9 +30,9 @@ import { CHANNEL_IDS } from "../channels/types.js";
30
30
  * The schema passes through what it does not name, so a provider carries its
31
31
  * own fields (Slack's file markers and timezone labels) on this same object
32
32
  * and validates them with its own schema. This is how `SourceMetadataSchema`
33
- * already carries `slackBotMentioned` and the email fields on the wire: one
34
- * object per row, no per-provider sub-envelope, and no second copy of
35
- * anything named here.
33
+ * carries `botMentioned` and the email fields on the wire: one object per
34
+ * row, no per-provider sub-envelope, and no second copy of anything named
35
+ * here.
36
36
  */
37
37
 
38
38
  const providerReactionMetadataSchema = z.object({
@@ -155,6 +155,12 @@ function tracksCuratedSource(inspection: PluginInspection): boolean {
155
155
  if (!remote) {
156
156
  return false;
157
157
  }
158
+ if (source.kind === "local") {
159
+ return remote.kind === "local" && source.path === remote.path;
160
+ }
161
+ if (remote.kind === "local") {
162
+ return false;
163
+ }
158
164
  return (
159
165
  `${source.owner}/${source.repo}`.toLowerCase() ===
160
166
  remote.repo.toLowerCase() && (source.path ?? "") === remote.path
@@ -503,6 +503,76 @@ describe("composeFallbackCopy plugin schedule templates", () => {
503
503
  });
504
504
  });
505
505
 
506
+ // ── activity.complete rendering ───────────────────────────────────────
507
+
508
+ describe("activity.complete copy", () => {
509
+ function completeSignal(
510
+ contextPayload: Record<string, unknown>,
511
+ ): NotificationSignal {
512
+ return makeSignal({
513
+ sourceEventName: "activity.complete",
514
+ contextPayload,
515
+ });
516
+ }
517
+
518
+ test("a producer-authored title survives the fallback path", () => {
519
+ // The home feed reads the payload title first; the popup must say the
520
+ // same thing when the classifier was unreachable.
521
+ const copy = composeFallbackCopy(
522
+ completeSignal({
523
+ title: "Skill updated: Weekly Report Export",
524
+ summary: "Added the retry after an expired session.",
525
+ }),
526
+ CHANNELS,
527
+ );
528
+ expect(copy.vellum?.title).toBe("Skill updated: Weekly Report Export");
529
+ expect(copy.vellum?.body).toBe("Added the retry after an expired session.");
530
+ });
531
+
532
+ test("the pass-through's requestedTitle is honored when no title is set", () => {
533
+ const copy = composeFallbackCopy(
534
+ completeSignal({
535
+ requestedTitle: "Nightly export finished",
536
+ summary: "Wrote 12 files to the reports folder.",
537
+ }),
538
+ CHANNELS,
539
+ );
540
+ expect(copy.vellum?.title).toBe("Nightly export finished");
541
+ });
542
+
543
+ test("without an authored title the headline is the summary's first sentence", () => {
544
+ const copy = composeFallbackCopy(
545
+ completeSignal({
546
+ summary:
547
+ "Added the retry after an expired session. Dropped the login step.",
548
+ }),
549
+ CHANNELS,
550
+ );
551
+ expect(copy.vellum?.title).toBe(
552
+ "Added the retry after an expired session.",
553
+ );
554
+ });
555
+
556
+ test("an authored title the normalizer rejects falls back to the derived one", () => {
557
+ const copy = composeFallbackCopy(
558
+ completeSignal({
559
+ title: "Skill\nupdated",
560
+ summary: "Added the retry after an expired session.",
561
+ }),
562
+ CHANNELS,
563
+ );
564
+ expect(copy.vellum?.title).toBe(
565
+ "Added the retry after an expired session.",
566
+ );
567
+ });
568
+
569
+ test("neither a title nor a summary still says something happened", () => {
570
+ const copy = composeFallbackCopy(completeSignal({}), CHANNELS);
571
+ expect(copy.vellum?.title).toBe("Activity complete");
572
+ expect(copy.vellum?.body).toBe("An activity has completed");
573
+ });
574
+ });
575
+
506
576
  // ── deriveTitle ───────────────────────────────────────────────────────
507
577
 
508
578
  describe("deriveTitle", () => {
@@ -346,14 +346,22 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
346
346
 
347
347
  // Titled by what was done rather than by the kind of event: the summary's
348
348
  // first sentence is the outcome ("Finished the fuel-system diagnostic app"),
349
- // which is what a reader scanning the bell wants to see. A summary without
350
- // one still gets a title that says something happened.
349
+ // which is what a reader scanning the bell wants to see. A producer that
350
+ // names its subject in the payload keeps that name here, read in the same
351
+ // order the home feed reads it, so the headline is the same whether or not
352
+ // the classifier ran. A summary with neither still gets a title that says
353
+ // something happened.
351
354
  "activity.complete": (payload) => {
352
355
  const summary = nonEmpty(
353
356
  typeof payload.summary === "string" ? payload.summary : undefined,
354
357
  );
358
+ const authored = normalizeTitle(
359
+ readPayloadString(payload, "title") ??
360
+ readPayloadString(payload, "requestedTitle") ??
361
+ "",
362
+ );
355
363
  return {
356
- title: summary ? deriveTitle(summary) : "Activity complete",
364
+ title: authored || (summary ? deriveTitle(summary) : "Activity complete"),
357
365
  body: summary ?? "An activity has completed",
358
366
  };
359
367
  },
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from "../providers/types.js";
1
2
  import type {
2
3
  AddMessageOptions,
3
4
  ConversationRow,
@@ -52,6 +53,18 @@ export async function getMessages(
52
53
  return fn(conversationId);
53
54
  }
54
55
 
56
+ /**
57
+ * The tool definitions the conversation's most recent live turn sent to the
58
+ * provider, or `null` when none has been recorded.
59
+ */
60
+ export async function getConversationToolSurface(
61
+ conversationId: string,
62
+ ): Promise<ToolDefinition[] | null> {
63
+ const { getConversationToolSurface: fn } =
64
+ await import("./conversation-tool-surface.js");
65
+ return fn(conversationId);
66
+ }
67
+
55
68
  /** Whether the conversation currently has a turn in flight. */
56
69
  export async function isConversationProcessing(id: string): Promise<boolean> {
57
70
  const { isConversationProcessing: fn } =
@@ -0,0 +1,86 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { eq, sql } from "drizzle-orm";
4
+
5
+ import type { ToolDefinition } from "../providers/types.js";
6
+ import { getDb } from "./db-connection.js";
7
+ import { conversationToolSurfaces } from "./schema/index.js";
8
+
9
+ /**
10
+ * Per-conversation record of the tool definitions the most recent live turn
11
+ * sent to the provider (`conversation_tool_surfaces`).
12
+ *
13
+ * The provider prompt cache is a byte-exact prefix match over
14
+ * `tools -> system -> messages`, so a background wake that forks a
15
+ * conversation can only reuse the source's cached prefix by sending the SAME
16
+ * tools array. Re-deriving the array on the fork cannot guarantee that: the
17
+ * fork may run in another process (a different tool registry), with no
18
+ * connected clients (different host-tool gates), or under a presence the
19
+ * persisted message stamps do not encode. Recording the resolved array and
20
+ * replaying it verbatim can.
21
+ */
22
+
23
+ /** Content hash of the serialized tools array. */
24
+ export function hashConversationToolSurface(
25
+ tools: readonly ToolDefinition[],
26
+ ): string {
27
+ return hashToolsJson(JSON.stringify(tools));
28
+ }
29
+
30
+ function hashToolsJson(toolsJson: string): string {
31
+ return createHash("sha256").update(toolsJson).digest("hex").slice(0, 32);
32
+ }
33
+
34
+ /**
35
+ * Persist `tools` as the conversation's current wire tool surface and return
36
+ * its hash. `knownHash` is the hash the caller last recorded for this
37
+ * conversation (undefined when it has recorded nothing this process
38
+ * lifetime): a matching hash skips the statement outright. Otherwise the
39
+ * upsert only rewrites a row whose stored hash differs, so a conversation
40
+ * reloaded from disk never rewrites an unchanged surface either.
41
+ */
42
+ export function recordConversationToolSurface(
43
+ conversationId: string,
44
+ tools: readonly ToolDefinition[],
45
+ knownHash?: string,
46
+ ): string {
47
+ const toolsJson = JSON.stringify(tools);
48
+ const toolsHash = hashToolsJson(toolsJson);
49
+ if (knownHash === toolsHash) {
50
+ return toolsHash;
51
+ }
52
+ const updatedAt = Date.now();
53
+ getDb()
54
+ .insert(conversationToolSurfaces)
55
+ .values({ conversationId, toolsJson, toolsHash, updatedAt })
56
+ .onConflictDoUpdate({
57
+ target: conversationToolSurfaces.conversationId,
58
+ set: { toolsJson, toolsHash, updatedAt },
59
+ setWhere: sql`${conversationToolSurfaces.toolsHash} <> excluded.tools_hash`,
60
+ })
61
+ .run();
62
+ return toolsHash;
63
+ }
64
+
65
+ /**
66
+ * The tool definitions the conversation's most recent live turn sent, or
67
+ * `null` when no turn has recorded one (or the stored JSON is unreadable).
68
+ */
69
+ export function getConversationToolSurface(
70
+ conversationId: string,
71
+ ): ToolDefinition[] | null {
72
+ const row = getDb()
73
+ .select({ toolsJson: conversationToolSurfaces.toolsJson })
74
+ .from(conversationToolSurfaces)
75
+ .where(eq(conversationToolSurfaces.conversationId, conversationId))
76
+ .get();
77
+ if (!row) {
78
+ return null;
79
+ }
80
+ try {
81
+ const parsed: unknown = JSON.parse(row.toolsJson);
82
+ return Array.isArray(parsed) ? (parsed as ToolDefinition[]) : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
@@ -0,0 +1,78 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { describe, expect, test } from "bun:test";
3
+
4
+ import { drizzle } from "drizzle-orm/bun-sqlite";
5
+
6
+ import * as schema from "../schema.js";
7
+ import { migrateCreateConversationToolSurfaces } from "./378-create-conversation-tool-surfaces.js";
8
+
9
+ function createTestDb() {
10
+ const sqlite = new Database(":memory:");
11
+ sqlite.run("PRAGMA foreign_keys = ON");
12
+ // Only the parent table the FK points at.
13
+ sqlite.run(/*sql*/ `CREATE TABLE conversations (id TEXT PRIMARY KEY)`);
14
+ return { sqlite, db: drizzle(sqlite, { schema }) };
15
+ }
16
+
17
+ function tableDdl(sqlite: Database): string | undefined {
18
+ const row = sqlite
19
+ .query(
20
+ `SELECT sql FROM sqlite_master WHERE type='table' AND name='conversation_tool_surfaces'`,
21
+ )
22
+ .get() as { sql: string } | null;
23
+ return row?.sql;
24
+ }
25
+
26
+ function insertSurface(sqlite: Database, conversationId: string): void {
27
+ sqlite
28
+ .query(
29
+ /*sql*/ `INSERT INTO conversation_tool_surfaces (conversation_id, tools_json, tools_hash, updated_at) VALUES (?, ?, ?, ?)`,
30
+ )
31
+ .run(conversationId, "[]", "hash", 1);
32
+ }
33
+
34
+ function surfaceCount(sqlite: Database): number {
35
+ const row = sqlite
36
+ .query(`SELECT COUNT(*) AS n FROM conversation_tool_surfaces`)
37
+ .get() as { n: number };
38
+ return row.n;
39
+ }
40
+
41
+ describe("migration 378: conversation_tool_surfaces", () => {
42
+ test("creates the table keyed by conversation with a cascading FK", () => {
43
+ const { sqlite, db } = createTestDb();
44
+ expect(tableDdl(sqlite)).toBeUndefined();
45
+
46
+ migrateCreateConversationToolSurfaces(db);
47
+
48
+ const ddl = tableDdl(sqlite);
49
+ expect(ddl).toContain("conversation_id TEXT PRIMARY KEY");
50
+ expect(ddl).toContain("REFERENCES conversations(id) ON DELETE CASCADE");
51
+ expect(ddl).toContain("tools_json TEXT NOT NULL");
52
+ expect(ddl).toContain("tools_hash TEXT NOT NULL");
53
+ expect(ddl).toContain("updated_at INTEGER NOT NULL");
54
+ });
55
+
56
+ test("deleting a conversation cascades to its surface row", () => {
57
+ const { sqlite, db } = createTestDb();
58
+ migrateCreateConversationToolSurfaces(db);
59
+ sqlite.query(`INSERT INTO conversations (id) VALUES (?)`).run("conv-1");
60
+ insertSurface(sqlite, "conv-1");
61
+ expect(surfaceCount(sqlite)).toBe(1);
62
+
63
+ sqlite.query(`DELETE FROM conversations WHERE id = ?`).run("conv-1");
64
+
65
+ expect(surfaceCount(sqlite)).toBe(0);
66
+ });
67
+
68
+ test("is idempotent: a second run keeps the table and its rows", () => {
69
+ const { sqlite, db } = createTestDb();
70
+ migrateCreateConversationToolSurfaces(db);
71
+ sqlite.query(`INSERT INTO conversations (id) VALUES (?)`).run("conv-1");
72
+ insertSurface(sqlite, "conv-1");
73
+
74
+ expect(() => migrateCreateConversationToolSurfaces(db)).not.toThrow();
75
+
76
+ expect(surfaceCount(sqlite)).toBe(1);
77
+ });
78
+ });
@@ -0,0 +1,29 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+
3
+ const TABLE = "conversation_tool_surfaces";
4
+
5
+ /**
6
+ * Create `conversation_tool_surfaces`: one row per conversation holding the
7
+ * tool definitions its most recent live turn sent to the provider, keyed by
8
+ * conversation with a content hash so an unchanged surface is never rewritten.
9
+ *
10
+ * Its own table rather than a column on `conversations` because the payload
11
+ * is a JSON array of every tool definition (tens of kilobytes) that only fork
12
+ * wakes read, while `conversations` rows are read whole on every hot path.
13
+ * The row cascades with its conversation, so a deleted conversation leaves
14
+ * no surface behind.
15
+ *
16
+ * Idempotent via IF NOT EXISTS.
17
+ */
18
+ export function migrateCreateConversationToolSurfaces(
19
+ database: DrizzleDb,
20
+ ): void {
21
+ database.run(
22
+ `CREATE TABLE IF NOT EXISTS ${TABLE} (
23
+ conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
24
+ tools_json TEXT NOT NULL,
25
+ tools_hash TEXT NOT NULL,
26
+ updated_at INTEGER NOT NULL
27
+ )`,
28
+ );
29
+ }
@@ -0,0 +1,22 @@
1
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+
3
+ import { conversations } from "./conversations.js";
4
+
5
+ /**
6
+ * The tool definitions a conversation's most recent live turn sent to the
7
+ * provider, serialized exactly as resolved. Written by the per-turn tool
8
+ * resolver when the array changes; read by fork wakes that replay it so their
9
+ * provider prompt-cache prefix matches the source's live turns. Cascades with
10
+ * its conversation.
11
+ */
12
+ export const conversationToolSurfaces = sqliteTable(
13
+ "conversation_tool_surfaces",
14
+ {
15
+ conversationId: text("conversation_id")
16
+ .primaryKey()
17
+ .references(() => conversations.id, { onDelete: "cascade" }),
18
+ toolsJson: text("tools_json").notNull(),
19
+ toolsHash: text("tools_hash").notNull(),
20
+ updatedAt: integer("updated_at").notNull(),
21
+ },
22
+ );
@@ -5,6 +5,7 @@ export * from "./calls.js";
5
5
  export * from "./contacts.js";
6
6
  export * from "./conversation-groups.js";
7
7
  export * from "./conversation-starters.js";
8
+ export * from "./conversation-tool-surfaces.js";
8
9
  export * from "./conversations.js";
9
10
  export * from "./documents.js";
10
11
  export * from "./guardian.js";
@@ -485,6 +485,7 @@ import { migrateChannelInboundMessageIdIndex } from "./migrations/374-channel-in
485
485
  import { migrateCreateChannelOutboundPosts } from "./migrations/375-create-channel-outbound-posts.js";
486
486
  import { migrateNotificationDeliveriesCanonicalMessageId } from "./migrations/376-notification-deliveries-canonical-message-id.js";
487
487
  import { migrateAddSubagentBudgetStopReason } from "./migrations/377-add-subagent-budget-stop-reason.js";
488
+ import { migrateCreateConversationToolSurfaces } from "./migrations/378-create-conversation-tool-surfaces.js";
488
489
  import type { MigrationStep } from "./migrations/run-migrations.js";
489
490
 
490
491
  export const migrationSteps: MigrationStep[] = [
@@ -1611,4 +1612,5 @@ export const migrationSteps: MigrationStep[] = [
1611
1612
  // this as a no-op.
1612
1613
  dependsOn: ["migrateCreateSubagentsTable"],
1613
1614
  },
1615
+ migrateCreateConversationToolSurfaces,
1614
1616
  ];
@@ -284,13 +284,20 @@ export async function runConversationTurn(
284
284
  const { resolveChannelCapabilities } =
285
285
  await import("../daemon/conversation-runtime-assembly.js");
286
286
 
287
- // Plugin-driven turns run as the guardian: plugins are installed by the
288
- // guardian, so their conversations inherit guardian trust. This lets the
289
- // existing non-interactive auto-approve machinery handle tool permissions
290
- // (the conversation is already non-interactive via `isInteractive: false`
291
- // below) without requiring a client to approve prompts.
287
+ // Channel-addressed turns are inbound: the gateway classifies the sender
288
+ // and the turn runs only if that actor clears the channel admission floor.
289
+ // Internal plugin jobs (no channel address) use guardian trust because they
290
+ // are assistant-owned work, not an external sender.
292
291
  const { INTERNAL_GUARDIAN_TRUST_CONTEXT } =
293
292
  await import("../daemon/trust-context.js");
293
+ const { resolvePluginChannelTurnTrust } =
294
+ await import("./plugin-channel-turn-trust.js");
295
+ const { prepareChannelInboundContent } =
296
+ await import("../runtime/routes/inbound-stages/inbound-content-prep.js");
297
+
298
+ const trustContext = options.channel
299
+ ? await resolvePluginChannelTurnTrust(options.channel)
300
+ : INTERNAL_GUARDIAN_TRUST_CONTEXT;
294
301
 
295
302
  // A channel address resolves through the same binding an inbound message
296
303
  // uses, so a turn addressed by chat lands in that chat's conversation
@@ -311,7 +318,7 @@ export async function runConversationTurn(
311
318
  ? !channelConversation.created
312
319
  : getConversation(conversationId) != null;
313
320
  const conversation = await getOrCreateConversation(conversationId, {
314
- trustContext: INTERNAL_GUARDIAN_TRUST_CONTEXT,
321
+ trustContext,
315
322
  ...(options.conversationType
316
323
  ? { conversationType: options.conversationType }
317
324
  : {}),
@@ -336,10 +343,23 @@ export async function runConversationTurn(
336
343
 
337
344
  // Convert ContentBlock[] input to the text + attachments shape the
338
345
  // conversation's processMessage path expects.
339
- const { text, attachments } = extractContentAndAttachments(
346
+ const { text: rawText, attachments } = extractContentAndAttachments(
340
347
  options.content,
341
348
  resolveMediaSourceData,
342
349
  );
350
+ const inboundContent = options.channel
351
+ ? prepareChannelInboundContent({
352
+ trimmedContent: rawText,
353
+ trustClass: trustContext.trustClass,
354
+ sourceChannel: options.channel.sourceChannel,
355
+ requesterIdentifier:
356
+ options.channel.username ??
357
+ options.channel.externalUserId ??
358
+ undefined,
359
+ })
360
+ : { content: rawText, displayContent: undefined };
361
+ const text = inboundContent.content;
362
+ const displayContent = inboundContent.displayContent;
343
363
 
344
364
  // The channel this turn speaks on. Runtime assembly reads the per-turn
345
365
  // context first and falls back to the conversation's `originChannel`, then
@@ -409,6 +429,8 @@ export async function runConversationTurn(
409
429
  requestId,
410
430
  isInteractive: false,
411
431
  metadata,
432
+ trustContext,
433
+ ...(displayContent ? { displayContent } : {}),
412
434
  });
413
435
  if (enqueueResult.rejected) {
414
436
  throw new Error(
@@ -432,6 +454,8 @@ export async function runConversationTurn(
432
454
  onEvent,
433
455
  isInteractive: false,
434
456
  metadata,
457
+ trustContext,
458
+ ...(displayContent ? { displayContent } : {}),
435
459
  ...(options.callSite ? { callSite: options.callSite } : {}),
436
460
  });
437
461
 
@@ -286,6 +286,11 @@ export {
286
286
  // writes files under the workspace (e.g. its own `plugins/<name>/data/`
287
287
  // directory) resolves them against this instead of hardcoding a base path.
288
288
  export { getWorkspaceDir } from "../util/platform.js";
289
+ // `String.prototype.slice` that never cuts a UTF-16 surrogate pair in half.
290
+ // Any text a plugin truncates by character budget and hands to a model must
291
+ // go through this: an orphaned half is invalid UTF-16 that strict provider
292
+ // parsers reject.
293
+ export { safeStringSlice } from "../util/unicode.js";
289
294
  // Declarative help for the top-level `assistant` CLI commands that have adopted
290
295
  // the static-help split. Plugins (e.g. the memory capability indexer) read this
291
296
  // to embed CLI command capabilities without importing the CLI action graph.
@@ -359,7 +364,8 @@ export {
359
364
  stringifyMessageContent,
360
365
  } from "../persistence/message-content.js";
361
366
  // Conversation history — reads and writes on the host conversation store
362
- // (rows, message history, processing state, disk-view paths) plus the lexical
367
+ // (rows, message history, processing state, the recorded wire tool surface,
368
+ // disk-view paths) plus the lexical
363
369
  // message-search surface. Every operation takes explicit parameters; nothing
364
370
  // is resolved from config. Async because the facade loads the DB store graph
365
371
  // lazily on first call.
@@ -371,6 +377,7 @@ export {
371
377
  getConversation,
372
378
  getConversationDirPath,
373
379
  getConversationProcessingStartedAt,
380
+ getConversationToolSurface,
374
381
  getMessages,
375
382
  hasLexicalTokens,
376
383
  isConversationProcessing,
@@ -438,6 +445,7 @@ export type {
438
445
  RunConversationTurnResult,
439
446
  } from "./conversation-turn.js";
440
447
  export { runConversationTurn } from "./conversation-turn.js";
448
+ export { PluginTurnNotAdmittedError } from "./plugin-channel-turn-trust.js";
441
449
  // Live voice — drive a single client's real-time voice session (STT → agent
442
450
  // turn → TTS, with server-VAD turn-taking, pauses, and barge-in) over a
443
451
  // transport the plugin owns. The plugin brings only a `send` callback (e.g.