vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Pure, deterministic policy helpers for memory conflict eligibility.
3
+ * Used by contradiction checker, session conflict gate, and background resolver.
4
+ */
5
+
6
+ export interface ConflictPolicyConfig {
7
+ conflictableKinds: readonly string[];
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ /**
12
+ * Returns true when the given memory item kind is eligible to participate
13
+ * in conflict detection according to the current policy.
14
+ */
15
+ export function isConflictKindEligible(kind: string, config: ConflictPolicyConfig): boolean {
16
+ return config.conflictableKinds.includes(kind);
17
+ }
18
+
19
+ /**
20
+ * Returns true when both sides of a potential conflict pair are kind-eligible.
21
+ */
22
+ export function isConflictKindPairEligible(
23
+ existingKind: string,
24
+ candidateKind: string,
25
+ config: ConflictPolicyConfig,
26
+ ): boolean {
27
+ return isConflictKindEligible(existingKind, config) && isConflictKindEligible(candidateKind, config);
28
+ }
29
+
30
+ // ── Transient statement classification ─────────────────────────────────
31
+
32
+ const PR_URL_PATTERN = /github\.com\/[^/]+\/[^/]+\/pull\/\d+/i;
33
+ const ISSUE_TICKET_PATTERN = /\b(?:issue|pr|ticket|pull request)\s*#?\d+/i;
34
+ const TRACKING_LANGUAGE_PATTERN = /\b(?:this pr|that issue|while we wait|currently tracking)\b/i;
35
+
36
+ /**
37
+ * Returns true when a statement looks like a transient tracking note
38
+ * (PR URLs, issue references, short-lived progress notes) rather than
39
+ * a durable user preference or instruction.
40
+ */
41
+ export function isTransientTrackingStatement(statement: string): boolean {
42
+ if (PR_URL_PATTERN.test(statement)) return true;
43
+ if (ISSUE_TICKET_PATTERN.test(statement)) return true;
44
+ if (TRACKING_LANGUAGE_PATTERN.test(statement)) return true;
45
+ return false;
46
+ }
47
+
48
+ const DURABLE_INSTRUCTION_CUES = /\b(?:always|never|default|every time|by default|style|format|tone|convention|standard)\b/i;
49
+
50
+ /**
51
+ * Returns true when a statement contains strong durable instruction cues,
52
+ * suggesting it represents a persistent user preference or style rule.
53
+ */
54
+ export function isDurableInstructionStatement(statement: string): boolean {
55
+ return DURABLE_INSTRUCTION_CUES.test(statement);
56
+ }
57
+
58
+ /**
59
+ * Returns true when a statement of the given kind is eligible to participate
60
+ * in conflict detection at the statement level. This combines kind eligibility
61
+ * with statement-level durability heuristics.
62
+ *
63
+ * For instruction/style kinds: requires positive durable cues and no transient cues.
64
+ * For other eligible kinds: rejects if transient tracking cues dominate.
65
+ */
66
+ export function isStatementConflictEligible(kind: string, statement: string, config?: ConflictPolicyConfig): boolean {
67
+ if (config && !isConflictKindEligible(kind, config)) return false;
68
+ if (isTransientTrackingStatement(statement)) return false;
69
+ if (kind === 'instruction' || kind === 'style') {
70
+ return isDurableInstructionStatement(statement);
71
+ }
72
+ return true;
73
+ }
@@ -50,6 +50,8 @@ export interface ResolveConflictInput {
50
50
  export interface PendingConflictDetail extends MemoryItemConflict {
51
51
  existingStatement: string;
52
52
  candidateStatement: string;
53
+ existingKind: string;
54
+ candidateKind: string;
53
55
  }
54
56
 
55
57
  export type ConflictResolutionAction = 'keep_existing' | 'keep_candidate' | 'merge';
@@ -170,7 +172,9 @@ export function listPendingConflictDetails(scopeId: string, limit = 100): Pendin
170
172
  c.created_at,
171
173
  c.updated_at,
172
174
  existing_item.statement AS existing_statement,
173
- candidate_item.statement AS candidate_statement
175
+ candidate_item.statement AS candidate_statement,
176
+ existing_item.kind AS existing_kind,
177
+ candidate_item.kind AS candidate_kind
174
178
  FROM memory_item_conflicts c
175
179
  INNER JOIN memory_items existing_item ON existing_item.id = c.existing_item_id
176
180
  INNER JOIN memory_items candidate_item ON candidate_item.id = c.candidate_item_id
@@ -193,6 +197,8 @@ export function listPendingConflictDetails(scopeId: string, limit = 100): Pendin
193
197
  updated_at: number;
194
198
  existing_statement: string;
195
199
  candidate_statement: string;
200
+ existing_kind: string;
201
+ candidate_kind: string;
196
202
  }>;
197
203
 
198
204
  return rows.map((row) => ({
@@ -210,6 +216,8 @@ export function listPendingConflictDetails(scopeId: string, limit = 100): Pendin
210
216
  updatedAt: row.updated_at,
211
217
  existingStatement: row.existing_statement,
212
218
  candidateStatement: row.candidate_statement,
219
+ existingKind: row.existing_kind,
220
+ candidateKind: row.candidate_kind,
213
221
  }));
214
222
  }
215
223
 
@@ -3,6 +3,8 @@ import { eq } from 'drizzle-orm';
3
3
  import { getConfig } from '../config/loader.js';
4
4
  import { getLogger } from '../util/logger.js';
5
5
  import { truncate } from '../util/truncate.js';
6
+ import { areStatementsCoherent } from './conflict-intent.js';
7
+ import { isConflictKindEligible, isStatementConflictEligible } from './conflict-policy.js';
6
8
  import { createOrUpdatePendingConflict } from './conflict-store.js';
7
9
  import { getDb } from './db.js';
8
10
  import { enqueueMemoryJob } from './jobs-store.js';
@@ -61,7 +63,33 @@ export async function checkContradictions(newItemId: string): Promise<void> {
61
63
  return;
62
64
  }
63
65
 
66
+ if (!isConflictKindEligible(newItem.kind, config.memory.conflicts)) {
67
+ log.debug({ newItemId, kind: newItem.kind }, 'Skipping contradiction check — kind not eligible for conflicts');
68
+ return;
69
+ }
70
+
71
+ // Skip if the new item's statement is transient/non-durable
72
+ if (!isStatementConflictEligible(newItem.kind, newItem.statement, config.memory.conflicts)) {
73
+ log.debug({ newItemId, kind: newItem.kind }, 'Skipping contradiction check — statement is transient or non-durable');
74
+ return;
75
+ }
76
+
64
77
  for (const existing of candidates) {
78
+ // Skip candidate if its statement is transient/non-durable
79
+ if (!isStatementConflictEligible(existing.kind, existing.statement, config.memory.conflicts)) {
80
+ log.debug({ existingId: existing.id }, 'Skipping candidate — statement is transient or non-durable');
81
+ continue;
82
+ }
83
+
84
+ // Skip pairs with zero topical overlap — they are not real contradictions
85
+ if (!areStatementsCoherent(existing.statement, newItem.statement)) {
86
+ log.debug(
87
+ { existingId: existing.id, newId: newItem.id },
88
+ 'Skipping candidate — zero statement overlap (incoherent pair)',
89
+ );
90
+ continue;
91
+ }
92
+
65
93
  try {
66
94
  const result = await classifyRelationship(apiKey, existing, newItem);
67
95
  await handleRelationship(result, existing, newItem);
@@ -52,6 +52,21 @@ export function deleteConversationKey(
52
52
  .run();
53
53
  }
54
54
 
55
+ /**
56
+ * Map a conversation key to an existing conversation ID (no creation).
57
+ */
58
+ export function setConversationKey(conversationKey: string, conversationId: string): void {
59
+ const db = getDb();
60
+ db.insert(conversationKeys)
61
+ .values({
62
+ id: uuid(),
63
+ conversationKey,
64
+ conversationId,
65
+ createdAt: Date.now(),
66
+ })
67
+ .run();
68
+ }
69
+
55
70
  /**
56
71
  * Get or create a conversation for the given conversationKey.
57
72
  *
package/src/memory/db.ts CHANGED
@@ -790,6 +790,10 @@ export function initializeDb(): void {
790
790
  // Add claim ownership token to prevent cross-handler claim interference
791
791
  try { database.run(/*sql*/ `ALTER TABLE processed_callbacks ADD COLUMN claim_id TEXT`); } catch { /* already exists */ }
792
792
 
793
+ // Caller identity persistence for auditability
794
+ try { database.run(/*sql*/ `ALTER TABLE call_sessions ADD COLUMN caller_identity_mode TEXT`); } catch { /* already exists */ }
795
+ try { database.run(/*sql*/ `ALTER TABLE call_sessions ADD COLUMN caller_identity_source TEXT`); } catch { /* already exists */ }
796
+
793
797
  // Unique constraint: at most one non-null provider_call_sid per (provider, provider_call_sid).
794
798
  // On upgraded databases that pre-date this constraint, duplicate rows may exist; deduplicate
795
799
  // them first to avoid a UNIQUE constraint failure that would prevent startup.
@@ -900,6 +904,28 @@ export function initializeDb(): void {
900
904
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs(status)`);
901
905
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_task_candidates_promoted ON task_candidates(promoted_task_id)`);
902
906
 
907
+ // ── External Conversation Bindings ──────────────────────────────────
908
+
909
+ database.run(/*sql*/ `
910
+ CREATE TABLE IF NOT EXISTS external_conversation_bindings (
911
+ conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
912
+ source_channel TEXT NOT NULL,
913
+ external_chat_id TEXT NOT NULL,
914
+ external_user_id TEXT,
915
+ display_name TEXT,
916
+ username TEXT,
917
+ created_at INTEGER NOT NULL,
918
+ updated_at INTEGER NOT NULL,
919
+ last_inbound_at INTEGER,
920
+ last_outbound_at INTEGER
921
+ )
922
+ `);
923
+
924
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_ext_conv_bindings_channel_chat ON external_conversation_bindings(source_channel, external_chat_id)`);
925
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_ext_conv_bindings_channel ON external_conversation_bindings(source_channel)`);
926
+
927
+ migrateExtConvBindingsChannelChatUnique(database);
928
+
903
929
  migrateMemoryFtsBackfill(database);
904
930
  }
905
931
 
@@ -1764,6 +1790,61 @@ function migrateLlmUsageEventsDropAssistantId(database: ReturnType<typeof drizzl
1764
1790
  }
1765
1791
  }
1766
1792
 
1793
+ /**
1794
+ * One-shot migration: deduplicate external_conversation_bindings rows that
1795
+ * share the same (source_channel, external_chat_id), then create a unique
1796
+ * index to enforce the invariant at DB level.
1797
+ *
1798
+ * For each duplicate group, the binding with the newest updatedAt (then
1799
+ * createdAt) is kept; older duplicates are deleted.
1800
+ */
1801
+ function migrateExtConvBindingsChannelChatUnique(database: ReturnType<typeof drizzle<typeof schema>>): void {
1802
+ const raw = (database as unknown as { $client: Database }).$client;
1803
+
1804
+ // If the unique index already exists, nothing to do.
1805
+ const idxExists = raw.query(
1806
+ `SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_ext_conv_bindings_channel_chat_unique'`,
1807
+ ).get();
1808
+ if (idxExists) return;
1809
+
1810
+ // Check if the table exists (first boot edge case).
1811
+ const tableExists = raw.query(
1812
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'external_conversation_bindings'`,
1813
+ ).get();
1814
+ if (!tableExists) return;
1815
+
1816
+ // Remove duplicates: keep the row with the newest updatedAt, then createdAt.
1817
+ // Since conversation_id is the PK (rowid alias), we use it for ordering ties.
1818
+ try {
1819
+ raw.exec('BEGIN');
1820
+
1821
+ raw.exec(/*sql*/ `
1822
+ DELETE FROM external_conversation_bindings
1823
+ WHERE rowid NOT IN (
1824
+ SELECT rowid FROM (
1825
+ SELECT rowid,
1826
+ ROW_NUMBER() OVER (
1827
+ PARTITION BY source_channel, external_chat_id
1828
+ ORDER BY updated_at DESC, created_at DESC, rowid DESC
1829
+ ) AS rn
1830
+ FROM external_conversation_bindings
1831
+ )
1832
+ WHERE rn = 1
1833
+ )
1834
+ `);
1835
+
1836
+ raw.exec(/*sql*/ `
1837
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ext_conv_bindings_channel_chat_unique
1838
+ ON external_conversation_bindings(source_channel, external_chat_id)
1839
+ `);
1840
+
1841
+ raw.exec('COMMIT');
1842
+ } catch (e) {
1843
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
1844
+ throw e;
1845
+ }
1846
+ }
1847
+
1767
1848
  /**
1768
1849
  * One-shot migration: remove duplicate (provider, provider_call_sid) rows from
1769
1850
  * call_sessions so that the unique index can be created safely on upgraded databases
@@ -1,5 +1,6 @@
1
1
  import type { EmbeddingBackend, EmbeddingRequestOptions } from './embedding-backend.js';
2
2
  import { getLogger } from '../util/logger.js';
3
+ import { PromiseGuard } from '../util/promise-guard.js';
3
4
 
4
5
  const log = getLogger('memory-embedding-local');
5
6
 
@@ -19,7 +20,7 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
19
20
  readonly provider = 'local' as const;
20
21
  readonly model: string;
21
22
  private extractor: FeatureExtractionPipeline | null = null;
22
- private initPromise: Promise<void> | null = null;
23
+ private readonly initGuard = new PromiseGuard<void>();
23
24
 
24
25
  constructor(model: string) {
25
26
  this.model = model;
@@ -50,18 +51,7 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
50
51
 
51
52
  private async ensureInitialized(): Promise<void> {
52
53
  if (this.extractor) return;
53
- if (this.initPromise) {
54
- await this.initPromise;
55
- return;
56
- }
57
-
58
- this.initPromise = this.initialize();
59
- try {
60
- await this.initPromise;
61
- } catch (err) {
62
- this.initPromise = null;
63
- throw err;
64
- }
54
+ await this.initGuard.run(() => this.initialize());
65
55
  }
66
56
 
67
57
  private async initialize(): Promise<void> {
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Store for external conversation bindings — maps internal conversation IDs
3
+ * to external channel identifiers (e.g. Telegram chat ID, SMS thread ID).
4
+ *
5
+ * This enables the system to track which conversations originated from
6
+ * external channels and expose channel metadata in session/conversation
7
+ * list APIs.
8
+ */
9
+
10
+ import { eq, and, inArray } from 'drizzle-orm';
11
+ import { getDb } from './db.js';
12
+ import { externalConversationBindings } from './schema.js';
13
+
14
+ export interface ExternalConversationBinding {
15
+ conversationId: string;
16
+ sourceChannel: string;
17
+ externalChatId: string;
18
+ externalUserId?: string | null;
19
+ displayName?: string | null;
20
+ username?: string | null;
21
+ createdAt: number;
22
+ updatedAt: number;
23
+ lastInboundAt?: number | null;
24
+ lastOutboundAt?: number | null;
25
+ }
26
+
27
+ export interface UpsertBindingInput {
28
+ conversationId: string;
29
+ sourceChannel: string;
30
+ externalChatId: string;
31
+ externalUserId?: string | null;
32
+ displayName?: string | null;
33
+ username?: string | null;
34
+ }
35
+
36
+ /**
37
+ * Insert or update an external conversation binding on conflict (conversationId).
38
+ * On conflict, updates channel metadata and timestamps.
39
+ */
40
+ export function upsertBinding(input: UpsertBindingInput): void {
41
+ const db = getDb();
42
+ const now = Date.now();
43
+
44
+ // If a stale binding exists for this (sourceChannel, externalChatId) under a
45
+ // different conversationId, remove it first so the unique index is not violated.
46
+ const existing = getBindingByChannelChat(input.sourceChannel, input.externalChatId);
47
+ if (existing && existing.conversationId !== input.conversationId) {
48
+ db.delete(externalConversationBindings)
49
+ .where(eq(externalConversationBindings.conversationId, existing.conversationId))
50
+ .run();
51
+ }
52
+
53
+ db.insert(externalConversationBindings)
54
+ .values({
55
+ conversationId: input.conversationId,
56
+ sourceChannel: input.sourceChannel,
57
+ externalChatId: input.externalChatId,
58
+ externalUserId: input.externalUserId ?? null,
59
+ displayName: input.displayName ?? null,
60
+ username: input.username ?? null,
61
+ createdAt: now,
62
+ updatedAt: now,
63
+ lastInboundAt: now,
64
+ })
65
+ .onConflictDoUpdate({
66
+ target: externalConversationBindings.conversationId,
67
+ set: {
68
+ sourceChannel: input.sourceChannel,
69
+ externalChatId: input.externalChatId,
70
+ externalUserId: input.externalUserId ?? null,
71
+ displayName: input.displayName ?? null,
72
+ username: input.username ?? null,
73
+ updatedAt: now,
74
+ lastInboundAt: now,
75
+ },
76
+ })
77
+ .run();
78
+ }
79
+
80
+ /**
81
+ * Upsert an external conversation binding for outbound sends.
82
+ * Similar to upsertBinding but touches lastOutboundAt instead of lastInboundAt,
83
+ * and only requires channel identifiers (no sender metadata needed).
84
+ */
85
+ export function upsertOutboundBinding(input: {
86
+ conversationId: string;
87
+ sourceChannel: string;
88
+ externalChatId: string;
89
+ }): void {
90
+ const db = getDb();
91
+ const now = Date.now();
92
+
93
+ // If a stale binding exists for this (sourceChannel, externalChatId) under a
94
+ // different conversationId, remove it first so the unique index is not violated.
95
+ const existing = getBindingByChannelChat(input.sourceChannel, input.externalChatId);
96
+ if (existing && existing.conversationId !== input.conversationId) {
97
+ db.delete(externalConversationBindings)
98
+ .where(eq(externalConversationBindings.conversationId, existing.conversationId))
99
+ .run();
100
+ }
101
+
102
+ db.insert(externalConversationBindings)
103
+ .values({
104
+ conversationId: input.conversationId,
105
+ sourceChannel: input.sourceChannel,
106
+ externalChatId: input.externalChatId,
107
+ externalUserId: null,
108
+ displayName: null,
109
+ username: null,
110
+ createdAt: now,
111
+ updatedAt: now,
112
+ lastOutboundAt: now,
113
+ })
114
+ .onConflictDoUpdate({
115
+ target: externalConversationBindings.conversationId,
116
+ set: {
117
+ sourceChannel: input.sourceChannel,
118
+ externalChatId: input.externalChatId,
119
+ updatedAt: now,
120
+ lastOutboundAt: now,
121
+ },
122
+ })
123
+ .run();
124
+ }
125
+
126
+ /**
127
+ * Look up an external binding by conversation ID.
128
+ */
129
+ export function getBindingByConversation(
130
+ conversationId: string,
131
+ ): ExternalConversationBinding | null {
132
+ const db = getDb();
133
+ const row = db
134
+ .select()
135
+ .from(externalConversationBindings)
136
+ .where(eq(externalConversationBindings.conversationId, conversationId))
137
+ .get();
138
+ return row ?? null;
139
+ }
140
+
141
+ /**
142
+ * Look up an external binding by channel + external chat ID.
143
+ */
144
+ export function getBindingByChannelChat(
145
+ sourceChannel: string,
146
+ externalChatId: string,
147
+ ): ExternalConversationBinding | null {
148
+ const db = getDb();
149
+ const row = db
150
+ .select()
151
+ .from(externalConversationBindings)
152
+ .where(
153
+ and(
154
+ eq(externalConversationBindings.sourceChannel, sourceChannel),
155
+ eq(externalConversationBindings.externalChatId, externalChatId),
156
+ ),
157
+ )
158
+ .get();
159
+ return row ?? null;
160
+ }
161
+
162
+ /**
163
+ * Remove an external binding for a conversation.
164
+ */
165
+ export function deleteBinding(conversationId: string): void {
166
+ const db = getDb();
167
+ db.delete(externalConversationBindings)
168
+ .where(eq(externalConversationBindings.conversationId, conversationId))
169
+ .run();
170
+ }
171
+
172
+ /**
173
+ * Remove an external binding by channel + external chat ID.
174
+ * Used when disconnecting a synced thread by its channel identifiers.
175
+ */
176
+ export function deleteBindingByChannelChat(
177
+ sourceChannel: string,
178
+ externalChatId: string,
179
+ ): void {
180
+ const db = getDb();
181
+ db.delete(externalConversationBindings)
182
+ .where(
183
+ and(
184
+ eq(externalConversationBindings.sourceChannel, sourceChannel),
185
+ eq(externalConversationBindings.externalChatId, externalChatId),
186
+ ),
187
+ )
188
+ .run();
189
+ }
190
+
191
+ /**
192
+ * List all external bindings, optionally filtered by sourceChannel.
193
+ */
194
+ export function listBindings(options?: {
195
+ sourceChannel?: string;
196
+ }): ExternalConversationBinding[] {
197
+ const db = getDb();
198
+ const query = db
199
+ .select()
200
+ .from(externalConversationBindings);
201
+
202
+ if (options?.sourceChannel) {
203
+ return query
204
+ .where(eq(externalConversationBindings.sourceChannel, options.sourceChannel))
205
+ .all();
206
+ }
207
+
208
+ return query.all();
209
+ }
210
+
211
+ /**
212
+ * Get bindings for multiple conversation IDs at once.
213
+ * Returns a map of conversationId -> binding for efficient lookup.
214
+ */
215
+ export function getBindingsForConversations(
216
+ conversationIds: string[],
217
+ ): Map<string, ExternalConversationBinding> {
218
+ if (conversationIds.length === 0) return new Map();
219
+
220
+ const db = getDb();
221
+ const result = new Map<string, ExternalConversationBinding>();
222
+
223
+ const all = db
224
+ .select()
225
+ .from(externalConversationBindings)
226
+ .where(inArray(externalConversationBindings.conversationId, conversationIds))
227
+ .all();
228
+
229
+ for (const row of all) {
230
+ result.set(row.conversationId, row);
231
+ }
232
+
233
+ return result;
234
+ }
@@ -6,9 +6,10 @@ import {
6
6
  looksLikeClarificationReply,
7
7
  shouldAttemptConflictResolution,
8
8
  } from '../conflict-intent.js';
9
+ import { isConflictKindPairEligible, isStatementConflictEligible } from '../conflict-policy.js';
9
10
  import { getDb } from '../db.js';
10
11
  import { resolveConflictClarification } from '../clarification-resolver.js';
11
- import { applyConflictResolution, listPendingConflictDetails } from '../conflict-store.js';
12
+ import { applyConflictResolution, listPendingConflictDetails, resolveConflict } from '../conflict-store.js';
12
13
  import { enqueueMemoryJob, type MemoryJob } from '../jobs-store.js';
13
14
  import { asPositiveMs, asString } from '../job-utils.js';
14
15
  import { extractTextFromStoredMessageContent } from '../message-content.js';
@@ -43,7 +44,26 @@ export async function resolvePendingConflictsForMessageJob(job: MemoryJob, confi
43
44
  if (!clarificationReply) return;
44
45
 
45
46
  const pending = listPendingConflictDetails(scopeId, 25);
46
- const eligible = pending.filter((conflict) => conflict.createdAt <= message.createdAt);
47
+
48
+ // Dismiss non-actionable conflicts (kind or statement policy)
49
+ const conflictableKinds = config.memory.conflicts.conflictableKinds;
50
+ for (const conflict of pending) {
51
+ const kindEligible = isConflictKindPairEligible(
52
+ conflict.existingKind, conflict.candidateKind, { conflictableKinds },
53
+ );
54
+ if (!kindEligible
55
+ || !isStatementConflictEligible(conflict.existingKind, conflict.existingStatement, { conflictableKinds })
56
+ || !isStatementConflictEligible(conflict.candidateKind, conflict.candidateStatement, { conflictableKinds })) {
57
+ resolveConflict(conflict.id, {
58
+ status: 'dismissed',
59
+ resolutionNote: 'Dismissed by conflict policy (transient/non-durable).',
60
+ });
61
+ }
62
+ }
63
+
64
+ // Re-fetch after dismissal
65
+ const actionablePending = listPendingConflictDetails(scopeId, 25);
66
+ const eligible = actionablePending.filter((conflict) => conflict.createdAt <= message.createdAt);
47
67
  if (eligible.length === 0) return;
48
68
  const candidates = eligible.filter((conflict) => {
49
69
  const askedAt = conflict.lastAskedAt;