@vellumai/assistant 0.12.2-staging.6 → 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 (120) hide show
  1. package/docs/architecture/memory.md +11 -2
  2. package/docs/desktop-browser-cli.md +4 -2
  3. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  4. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  5. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  6. package/openapi.yaml +6 -2
  7. package/package.json +2 -2
  8. package/scripts/postinstall.ts +33 -0
  9. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  10. package/src/__tests__/agent-loop.test.ts +124 -0
  11. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  12. package/src/__tests__/channel-approval.test.ts +9 -14
  13. package/src/__tests__/conversation-agent-loop.test.ts +25 -0
  14. package/src/__tests__/db-conversation-tool-surface.test.ts +144 -0
  15. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  16. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  17. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  18. package/src/__tests__/subagent-tool-gate-mode.test.ts +169 -0
  19. package/src/__tests__/terminal-tools.test.ts +8 -0
  20. package/src/__tests__/unicode.test.ts +36 -0
  21. package/src/agent/loop.ts +19 -0
  22. package/src/api/events/desktop-activity-changed.ts +10 -0
  23. package/src/api/index.ts +6 -0
  24. package/src/approvals/approval-primitive.ts +5 -2
  25. package/src/approvals/scoped-approval-grants.ts +6 -2
  26. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  27. package/src/daemon/conversation-agent-loop.ts +2 -0
  28. package/src/daemon/conversation-tool-setup.ts +57 -1
  29. package/src/daemon/conversation.ts +17 -0
  30. package/src/daemon/daemon-control.ts +2 -6
  31. package/src/daemon/orphan-reaper.ts +4 -3
  32. package/src/daemon/tool-setup-types.ts +6 -0
  33. package/src/daemon/wake-conversation-ops.ts +38 -15
  34. package/src/desktop/desktop-automation-lease.test.ts +143 -0
  35. package/src/desktop/desktop-automation-lease.ts +39 -3
  36. package/src/messaging/provider-message-metadata.ts +3 -3
  37. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  38. package/src/notifications/copy-composer.ts +11 -3
  39. package/src/persistence/conversation-plugin-facade.ts +13 -0
  40. package/src/persistence/conversation-tool-surface.ts +86 -0
  41. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  42. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  43. package/src/persistence/schema/conversation-tool-surfaces.ts +22 -0
  44. package/src/persistence/schema/index.ts +1 -0
  45. package/src/persistence/steps.ts +2 -0
  46. package/src/plugin-api/conversation-turn.ts +31 -7
  47. package/src/plugin-api/index.ts +9 -1
  48. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  49. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  50. package/src/plugins/defaults/memory/AGENTS.md +14 -2
  51. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +320 -0
  52. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  53. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  54. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +46 -0
  55. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  56. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  57. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  58. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  59. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  60. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  61. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  62. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  63. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  64. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  65. package/src/plugins/defaults/memory/injectors.ts +4 -3
  66. package/src/plugins/defaults/memory/memory-retrospective-job.ts +55 -181
  67. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  68. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  69. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  70. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  71. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  72. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  73. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  74. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  75. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  76. package/src/plugins/defaults/memory/tools.ts +1 -1
  77. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  78. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  79. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  80. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  81. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  82. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  83. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  84. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  85. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +21 -0
  86. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  87. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  88. package/src/plugins/defaults/memory/v3/orchestrate.ts +86 -22
  89. package/src/plugins/defaults/memory/v3/pool-select.ts +10 -7
  90. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  91. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -1
  92. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  93. package/src/runtime/AGENTS.md +1 -1
  94. package/src/runtime/__tests__/agent-wake.test.ts +86 -1
  95. package/src/runtime/agent-wake.ts +20 -4
  96. package/src/runtime/guardian-action-service.ts +2 -17
  97. package/src/runtime/guardian-reply-router.ts +1 -8
  98. package/src/runtime/routes/channel-route-shared.ts +1 -9
  99. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  100. package/src/runtime/routes/desktop-setup-routes.ts +7 -3
  101. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  102. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  103. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  104. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  105. package/src/schedule/run-script.ts +2 -2
  106. package/src/tools/host-terminal/host-shell.ts +12 -6
  107. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  108. package/src/tools/shared/shell-output.test.ts +10 -0
  109. package/src/tools/shared/shell-output.ts +14 -2
  110. package/src/tools/skills/sandbox-runner.ts +13 -2
  111. package/src/tools/skills/scaffold-managed.ts +2 -1
  112. package/src/tools/terminal/__tests__/safe-env.test.ts +29 -0
  113. package/src/tools/terminal/safe-env.ts +30 -1
  114. package/src/tools/terminal/sanitized-bash.ts +15 -2
  115. package/src/tools/terminal/shell-launch.test.ts +162 -0
  116. package/src/tools/terminal/shell.test.ts +29 -0
  117. package/src/tools/terminal/shell.ts +13 -7
  118. package/src/util/host-process.test.ts +17 -1
  119. package/src/util/host-process.ts +24 -0
  120. package/src/util/unicode.ts +29 -0
@@ -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.
@@ -0,0 +1,133 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ type InboundTrustReadResult =
4
+ | {
5
+ ok: true;
6
+ verdict: {
7
+ trustClass: string;
8
+ canonicalSenderId: string | null;
9
+ contactId?: string;
10
+ status?: string;
11
+ resolutionFailed?: boolean;
12
+ };
13
+ admissionPolicy: string | null;
14
+ }
15
+ | { ok: false };
16
+
17
+ let nextRead: InboundTrustReadResult = { ok: false };
18
+
19
+ mock.module("../calls/inbound-trust-reader.js", () => ({
20
+ readInboundTrust: async () => nextRead,
21
+ }));
22
+
23
+ import {
24
+ PluginTurnNotAdmittedError,
25
+ resolvePluginChannelTurnTrust,
26
+ } from "./plugin-channel-turn-trust.js";
27
+
28
+ const CHANNEL = {
29
+ sourceChannel: "plugin" as const,
30
+ externalChatId: "imessage:+12025550142",
31
+ externalUserId: "imessage:+12025550142",
32
+ displayName: "Ada",
33
+ };
34
+
35
+ beforeEach(() => {
36
+ nextRead = { ok: false };
37
+ });
38
+
39
+ describe("resolvePluginChannelTurnTrust", () => {
40
+ test("returns the gateway verdict as the turn trust context", async () => {
41
+ nextRead = {
42
+ ok: true,
43
+ verdict: {
44
+ trustClass: "trusted_contact",
45
+ canonicalSenderId: "imessage:+12025550142",
46
+ contactId: "c-ada",
47
+ status: "active",
48
+ },
49
+ admissionPolicy: "trusted_contacts",
50
+ };
51
+
52
+ const trust = await resolvePluginChannelTurnTrust(CHANNEL);
53
+ expect(trust.trustClass).toBe("trusted_contact");
54
+ expect(trust.sourceChannel).toBe("plugin");
55
+ expect(trust.requesterChatId).toBe(CHANNEL.externalChatId);
56
+ });
57
+
58
+ test("fails closed when the gateway could not vouch for the sender", async () => {
59
+ nextRead = {
60
+ ok: true,
61
+ verdict: {
62
+ trustClass: "unknown",
63
+ canonicalSenderId: null,
64
+ resolutionFailed: true,
65
+ },
66
+ admissionPolicy: "strangers",
67
+ };
68
+
69
+ await expect(resolvePluginChannelTurnTrust(CHANNEL)).rejects.toMatchObject({
70
+ name: "PluginTurnNotAdmittedError",
71
+ reason: "trust_resolution_failed",
72
+ });
73
+ });
74
+
75
+ test("fails closed when the gateway trust read fails", async () => {
76
+ nextRead = { ok: false };
77
+ await expect(resolvePluginChannelTurnTrust(CHANNEL)).rejects.toMatchObject({
78
+ name: "PluginTurnNotAdmittedError",
79
+ reason: "trust_resolution_failed",
80
+ });
81
+ });
82
+
83
+ test("rejects a sender below the channel admission floor", async () => {
84
+ nextRead = {
85
+ ok: true,
86
+ verdict: { trustClass: "unknown", canonicalSenderId: null },
87
+ admissionPolicy: "guardian_only",
88
+ };
89
+
90
+ await expect(resolvePluginChannelTurnTrust(CHANNEL)).rejects.toEqual(
91
+ expect.objectContaining({
92
+ name: "PluginTurnNotAdmittedError",
93
+ reason: "admission_policy_guardian_only",
94
+ }),
95
+ );
96
+ });
97
+
98
+ test("rejects a blocked member regardless of floor", async () => {
99
+ nextRead = {
100
+ ok: true,
101
+ verdict: {
102
+ trustClass: "trusted_contact",
103
+ canonicalSenderId: "imessage:+12025550142",
104
+ contactId: "c-ada",
105
+ status: "blocked",
106
+ },
107
+ admissionPolicy: "strangers",
108
+ };
109
+
110
+ await expect(resolvePluginChannelTurnTrust(CHANNEL)).rejects.toMatchObject({
111
+ name: "PluginTurnNotAdmittedError",
112
+ reason: "member_blocked",
113
+ });
114
+ });
115
+
116
+ test("admits when the gateway reports no admission policy", async () => {
117
+ nextRead = {
118
+ ok: true,
119
+ verdict: { trustClass: "unknown", canonicalSenderId: null },
120
+ admissionPolicy: null,
121
+ };
122
+
123
+ const trust = await resolvePluginChannelTurnTrust(CHANNEL);
124
+ expect(trust.trustClass).toBe("unknown");
125
+ });
126
+
127
+ test("PluginTurnNotAdmittedError names the deny reason", () => {
128
+ const err = new PluginTurnNotAdmittedError("admission_policy_no_one");
129
+ expect(err).toBeInstanceOf(Error);
130
+ expect(err.reason).toBe("admission_policy_no_one");
131
+ expect(err.message).toContain("admission_policy_no_one");
132
+ });
133
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Gateway admission and trust for plugin turns addressed by a channel chat.
3
+ *
4
+ * A channel address means an inbound sender, not an internal plugin job.
5
+ * Trust comes from the gateway verdict (`resolve_inbound_trust`); the turn
6
+ * runs only when that sender clears the channel admission floor.
7
+ */
8
+
9
+ import { meetsAdmissionFloor } from "@vellumai/gateway-client";
10
+
11
+ import { readInboundTrust } from "../calls/inbound-trust-reader.js";
12
+ import type { ChannelId } from "../channels/types.js";
13
+ import type { TrustContext } from "../daemon/trust-context-types.js";
14
+ import { trustContextFromVerdict } from "../runtime/trust-verdict-consumer.js";
15
+
16
+ interface PluginChannelTurnAddress {
17
+ sourceChannel: ChannelId;
18
+ externalChatId: string;
19
+ externalUserId?: string | null;
20
+ displayName?: string | null;
21
+ username?: string | null;
22
+ }
23
+
24
+ export class PluginTurnNotAdmittedError extends Error {
25
+ readonly reason: string;
26
+
27
+ constructor(reason: string) {
28
+ super(`This inbound plugin turn was not admitted (${reason}).`);
29
+ this.name = "PluginTurnNotAdmittedError";
30
+ this.reason = reason;
31
+ }
32
+ }
33
+
34
+ export async function resolvePluginChannelTurnTrust(
35
+ channel: PluginChannelTurnAddress,
36
+ ): Promise<TrustContext> {
37
+ const actorExternalId = channel.externalUserId?.trim() || undefined;
38
+ const read = await readInboundTrust({
39
+ channelType: channel.sourceChannel,
40
+ actorExternalId,
41
+ });
42
+ if (!read.ok) {
43
+ throw new PluginTurnNotAdmittedError("trust_resolution_failed");
44
+ }
45
+
46
+ if (read.verdict.resolutionFailed) {
47
+ throw new PluginTurnNotAdmittedError("trust_resolution_failed");
48
+ }
49
+
50
+ const memberStatus = read.verdict.status;
51
+ if (memberStatus === "blocked" || memberStatus === "revoked") {
52
+ throw new PluginTurnNotAdmittedError(`member_${memberStatus}`);
53
+ }
54
+
55
+ const trustClass = read.verdict.trustClass;
56
+ if (
57
+ read.admissionPolicy != null &&
58
+ !meetsAdmissionFloor(read.admissionPolicy, trustClass)
59
+ ) {
60
+ throw new PluginTurnNotAdmittedError(
61
+ `admission_policy_${read.admissionPolicy}`,
62
+ );
63
+ }
64
+
65
+ return trustContextFromVerdict(read.verdict, {
66
+ sourceChannel: channel.sourceChannel,
67
+ conversationExternalId: channel.externalChatId,
68
+ actorDisplayName: channel.displayName ?? undefined,
69
+ actorUsername: channel.username ?? undefined,
70
+ });
71
+ }
@@ -68,6 +68,13 @@ Everything else under the plugin root is **spine**:
68
68
  the writer plus the one matcher every reader uses. It lives at the root
69
69
  precisely so `substrate/`, `graph/`, and `graph-topology/` can all reach it
70
70
  without a tier importing spine. Do not add a second matcher anywhere),
71
+ `buffer-file` (the two writers of `memory/buffer.md`: the append every
72
+ `remember()`-shaped path uses and the consume the consolidation job runs
73
+ after a pass. Do not write the buffer from anywhere else, and never
74
+ rewrite it from a read that is not inside `consumeBufferEntries`'s
75
+ synchronous critical section), `memory-run-evidence` (readers of what a
76
+ background memory run durably produced from its persisted messages; every
77
+ job that gates a state transition on a verified write uses them),
71
78
  `segmenter`, `message-media`, `worker`, `worker-control`,
72
79
  `memory-recall-log-store`, `activation-session-store` (the onboarding
73
80
  activation rail — **not** a memory tier despite the name),
@@ -407,8 +414,13 @@ per distinct matched section, at most `memory.v3.finderSectionsPerPage` in
407
414
  surfacing order (needle, dense, reply, span) plus its entity and rare-term
408
415
  lines, and selecting a line selects that section; the selection log keeps
409
416
  one row per slug, so the pool row is where the per-section verdicts live. A
410
- turn whose selector never judged a pool (the injection gate hard-skipped it,
411
- or nothing was pooled) persists an empty pool with `selector_ran = 0`, and a
417
+ turn whose selector never judged a pool persists it with `selector_ran = 0`:
418
+ an empty pool when the injection gate hard-skipped it or nothing was pooled,
419
+ and the pool as the selector was given it, stable-prefix cards chosen and
420
+ finder lines not, when the selector's provider failed and the orchestrator
421
+ kept that prefix unjudged. That turn writes no `memory_v3_selections` rows:
422
+ the hot set's frecency and the learned-edge graph read that table as
423
+ judgments, and an unjudged page is not one. A
412
424
  turn that logged no selections is still reachable by its stamped
413
425
  `message_id`, so the inspector shows negative verdicts too. The pool row and
414
426
  the turn's `memory_v3_selections` rows are