@vellumai/assistant 0.3.3 → 0.3.5

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 (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -129,6 +129,7 @@ export const memoryEmbeddings = sqliteTable('memory_embeddings', {
129
129
  model: text('model').notNull(),
130
130
  dimensions: integer('dimensions').notNull(),
131
131
  vectorJson: text('vector_json').notNull(),
132
+ contentHash: text('content_hash'),
132
133
  createdAt: integer('created_at').notNull(),
133
134
  updatedAt: integer('updated_at').notNull(),
134
135
  });
@@ -679,3 +680,101 @@ export const channelGuardianRateLimits = sqliteTable('channel_guardian_rate_limi
679
680
  createdAt: integer('created_at').notNull(),
680
681
  updatedAt: integer('updated_at').notNull(),
681
682
  });
683
+
684
+ // ── Media Assets ─────────────────────────────────────────────────────
685
+
686
+ export const mediaAssets = sqliteTable('media_assets', {
687
+ id: text('id').primaryKey(),
688
+ title: text('title').notNull(),
689
+ filePath: text('file_path').notNull(),
690
+ mimeType: text('mime_type').notNull(),
691
+ durationSeconds: real('duration_seconds'),
692
+ fileHash: text('file_hash').notNull(),
693
+ status: text('status').notNull().default('registered'), // registered | processing | indexed | failed
694
+ mediaType: text('media_type').notNull(), // video | audio | image
695
+ metadata: text('metadata'), // JSON
696
+ createdAt: integer('created_at').notNull(),
697
+ updatedAt: integer('updated_at').notNull(),
698
+ });
699
+
700
+ export const processingStages = sqliteTable('processing_stages', {
701
+ id: text('id').primaryKey(),
702
+ assetId: text('asset_id').notNull()
703
+ .references(() => mediaAssets.id, { onDelete: 'cascade' }),
704
+ stage: text('stage').notNull(),
705
+ status: text('status').notNull().default('pending'), // pending | running | completed | failed
706
+ progress: integer('progress').notNull().default(0), // 0-100
707
+ lastError: text('last_error'),
708
+ startedAt: integer('started_at'),
709
+ completedAt: integer('completed_at'),
710
+ });
711
+
712
+ export const mediaKeyframes = sqliteTable('media_keyframes', {
713
+ id: text('id').primaryKey(),
714
+ assetId: text('asset_id').notNull()
715
+ .references(() => mediaAssets.id, { onDelete: 'cascade' }),
716
+ timestamp: real('timestamp').notNull(),
717
+ filePath: text('file_path').notNull(),
718
+ metadata: text('metadata'), // JSON
719
+ createdAt: integer('created_at').notNull(),
720
+ });
721
+
722
+ export const mediaVisionOutputs = sqliteTable('media_vision_outputs', {
723
+ id: text('id').primaryKey(),
724
+ assetId: text('asset_id').notNull()
725
+ .references(() => mediaAssets.id, { onDelete: 'cascade' }),
726
+ keyframeId: text('keyframe_id').notNull()
727
+ .references(() => mediaKeyframes.id, { onDelete: 'cascade' }),
728
+ analysisType: text('analysis_type').notNull(),
729
+ output: text('output').notNull(), // JSON
730
+ confidence: real('confidence'),
731
+ createdAt: integer('created_at').notNull(),
732
+ });
733
+
734
+ export const mediaTimelines = sqliteTable('media_timelines', {
735
+ id: text('id').primaryKey(),
736
+ assetId: text('asset_id').notNull()
737
+ .references(() => mediaAssets.id, { onDelete: 'cascade' }),
738
+ startTime: real('start_time').notNull(),
739
+ endTime: real('end_time').notNull(),
740
+ segmentType: text('segment_type').notNull(),
741
+ attributes: text('attributes'), // JSON
742
+ confidence: real('confidence'),
743
+ createdAt: integer('created_at').notNull(),
744
+ });
745
+
746
+ export const mediaEvents = sqliteTable('media_events', {
747
+ id: text('id').primaryKey(),
748
+ assetId: text('asset_id').notNull()
749
+ .references(() => mediaAssets.id, { onDelete: 'cascade' }),
750
+ eventType: text('event_type').notNull(),
751
+ startTime: real('start_time').notNull(),
752
+ endTime: real('end_time').notNull(),
753
+ confidence: real('confidence').notNull(),
754
+ reasons: text('reasons').notNull(), // JSON array
755
+ metadata: text('metadata'), // JSON
756
+ createdAt: integer('created_at').notNull(),
757
+ });
758
+
759
+ export const mediaTrackingProfiles = sqliteTable('media_tracking_profiles', {
760
+ id: text('id').primaryKey(),
761
+ assetId: text('asset_id').notNull()
762
+ .references(() => mediaAssets.id, { onDelete: 'cascade' }),
763
+ capabilities: text('capabilities').notNull(), // JSON: { [capName]: { enabled, tier } }
764
+ createdAt: integer('created_at').notNull(),
765
+ });
766
+
767
+ export const mediaEventFeedback = sqliteTable('media_event_feedback', {
768
+ id: text('id').primaryKey(),
769
+ assetId: text('asset_id').notNull()
770
+ .references(() => mediaAssets.id, { onDelete: 'cascade' }),
771
+ eventId: text('event_id').notNull()
772
+ .references(() => mediaEvents.id, { onDelete: 'cascade' }),
773
+ feedbackType: text('feedback_type').notNull(), // correct | incorrect | boundary_edit | missed
774
+ originalStartTime: real('original_start_time'),
775
+ originalEndTime: real('original_end_time'),
776
+ correctedStartTime: real('corrected_start_time'),
777
+ correctedEndTime: real('corrected_end_time'),
778
+ notes: text('notes'),
779
+ createdAt: integer('created_at').notNull(),
780
+ });
@@ -8,7 +8,7 @@ import {
8
8
  memoryItems,
9
9
  memoryItemSources,
10
10
  } from '../schema.js';
11
- import type { Candidate, CandidateSource, CandidateType, EntitySearchResult, MatchedEntityRow } from './types.js';
11
+ import type { Candidate, CandidateSource, CandidateType, EntitySearchResult, MatchedEntityRow, TraversalOptions, TraversalResult, TraversalStep } from './types.js';
12
12
  import { computeRecencyScore } from './ranking.js';
13
13
 
14
14
  const log = getLogger('memory-retriever');
@@ -51,15 +51,16 @@ export function entitySearch(
51
51
  }
52
52
 
53
53
  const relationSeedEntityCount = seedEntityIds.length;
54
+
54
55
  const {
55
56
  neighborEntityIds,
56
57
  traversedEdgeCount: relationTraversedEdgeCount,
57
- } = findNeighborEntities(
58
- seedEntityIds,
59
- relationConfig.maxEdges,
60
- relationConfig.maxNeighborEntities,
61
- relationConfig.maxDepth,
62
- );
58
+ neighborDepths,
59
+ } = findNeighborEntities(seedEntityIds, {
60
+ maxEdges: relationConfig.maxEdges,
61
+ maxNeighborEntities: relationConfig.maxNeighborEntities,
62
+ maxDepth: relationConfig.maxDepth,
63
+ });
63
64
  const relationNeighborEntityCount = neighborEntityIds.length;
64
65
  const directItemIds = new Set(directCandidates.map((candidate) => candidate.id));
65
66
  const relationCandidates = getEntityLinkedItemCandidates(neighborEntityIds, {
@@ -70,12 +71,47 @@ export function entitySearch(
70
71
  });
71
72
  const relationExpandedItemCount = relationCandidates.length;
72
73
 
74
+ // Build candidate key → BFS depth map so ranking can apply distance-based decay
75
+ const candidateDepths = new Map<string, number>();
76
+ if (relationCandidates.length > 0 && neighborDepths.size > 0) {
77
+ const db = getDb();
78
+ const itemIds = relationCandidates.map((c) => c.id);
79
+ const links = db
80
+ .select({
81
+ memoryItemId: memoryItemEntities.memoryItemId,
82
+ entityId: memoryItemEntities.entityId,
83
+ })
84
+ .from(memoryItemEntities)
85
+ .where(inArray(memoryItemEntities.memoryItemId, itemIds))
86
+ .all();
87
+
88
+ // For each item, find the minimum depth among its linked neighbor entities
89
+ const itemDepthMap = new Map<string, number>();
90
+ for (const link of links) {
91
+ const depth = neighborDepths.get(link.entityId);
92
+ if (depth !== undefined) {
93
+ const existing = itemDepthMap.get(link.memoryItemId);
94
+ if (existing === undefined || depth < existing) {
95
+ itemDepthMap.set(link.memoryItemId, depth);
96
+ }
97
+ }
98
+ }
99
+
100
+ for (const candidate of relationCandidates) {
101
+ const depth = itemDepthMap.get(candidate.id);
102
+ if (depth !== undefined) {
103
+ candidateDepths.set(candidate.key, depth);
104
+ }
105
+ }
106
+ }
107
+
73
108
  return {
74
109
  candidates: [...directCandidates, ...relationCandidates],
75
110
  relationSeedEntityCount,
76
111
  relationTraversedEdgeCount,
77
112
  relationNeighborEntityCount,
78
113
  relationExpandedItemCount,
114
+ candidateDepths,
79
115
  };
80
116
  }
81
117
 
@@ -86,6 +122,7 @@ export function emptyEntitySearchResult(): EntitySearchResult {
86
122
  relationTraversedEdgeCount: 0,
87
123
  relationNeighborEntityCount: 0,
88
124
  relationExpandedItemCount: 0,
125
+ candidateDepths: new Map(),
89
126
  };
90
127
  }
91
128
 
@@ -152,18 +189,19 @@ export function findMatchedEntities(query: string, maxMatches: number): MatchedE
152
189
  */
153
190
  export function findNeighborEntities(
154
191
  seedEntityIds: string[],
155
- maxEdges: number,
156
- maxNeighborEntities: number,
157
- maxDepth: number = 3,
158
- ): { neighborEntityIds: string[]; traversedEdgeCount: number } {
192
+ opts: TraversalOptions,
193
+ ): TraversalResult {
194
+ const { maxEdges, maxNeighborEntities, maxDepth = 3, relationTypes, entityTypes, directed } = opts;
159
195
  if (seedEntityIds.length === 0 || maxEdges <= 0 || maxNeighborEntities <= 0 || maxDepth <= 0) {
160
- return { neighborEntityIds: [], traversedEdgeCount: 0 };
196
+ return { neighborEntityIds: [], traversedEdgeCount: 0, neighborDepths: new Map() };
161
197
  }
162
198
 
163
199
  const db = getDb();
164
200
  const visited = new Set<string>(seedEntityIds);
165
201
  const neighbors: string[] = [];
202
+ const neighborDepths = new Map<string, number>();
166
203
  let totalEdgesTraversed = 0;
204
+ const filterByEntityType = entityTypes && entityTypes.length > 0;
167
205
 
168
206
  // BFS frontier starts with seed entities
169
207
  let frontier = [...seedEntityIds];
@@ -174,19 +212,91 @@ export function findNeighborEntities(
174
212
  const edgeBudget = maxEdges - totalEdgesTraversed;
175
213
  if (edgeBudget <= 0) break;
176
214
 
177
- const rows = db
178
- .select({
179
- sourceEntityId: memoryEntityRelations.sourceEntityId,
180
- targetEntityId: memoryEntityRelations.targetEntityId,
181
- })
182
- .from(memoryEntityRelations)
183
- .where(or(
184
- inArray(memoryEntityRelations.sourceEntityId, frontier),
185
- inArray(memoryEntityRelations.targetEntityId, frontier),
186
- ))
187
- .orderBy(desc(memoryEntityRelations.lastSeenAt))
188
- .limit(Math.max(1, edgeBudget))
189
- .all();
215
+ let rows: Array<{ sourceEntityId: string; targetEntityId: string }>;
216
+
217
+ if (filterByEntityType) {
218
+ // When filtering by entity type, JOIN with memoryEntities on the neighbor
219
+ // side so non-matching edges are excluded at the SQL level and don't
220
+ // consume the edge budget.
221
+ const relationTypeCondition = relationTypes && relationTypes.length > 0
222
+ ? `AND r.relation IN (${relationTypes.map(() => '?').join(',')})`
223
+ : '';
224
+ const entityTypeFilter = `AND me.type IN (${entityTypes.map(() => '?').join(',')})`;
225
+ const frontierPlaceholders = frontier.map(() => '?').join(',');
226
+ const limit = Math.max(1, edgeBudget);
227
+
228
+ const raw = (db as unknown as { $client: { query: (q: string) => { all: (...params: unknown[]) => unknown[] } } }).$client;
229
+ const relationParams = relationTypes && relationTypes.length > 0 ? relationTypes : [];
230
+
231
+ if (directed) {
232
+ // GROUP BY deduplicates entity pairs that have multiple relation rows
233
+ const q1 = `
234
+ SELECT r.source_entity_id AS sourceEntityId, r.target_entity_id AS targetEntityId
235
+ FROM memory_entity_relations r
236
+ INNER JOIN memory_entities me ON me.id = r.target_entity_id
237
+ WHERE r.source_entity_id IN (${frontierPlaceholders})
238
+ ${relationTypeCondition} ${entityTypeFilter}
239
+ GROUP BY r.source_entity_id, r.target_entity_id
240
+ ORDER BY MAX(r.last_seen_at) DESC
241
+ LIMIT ?
242
+ `;
243
+ const params1 = [...frontier, ...relationParams, ...entityTypes, limit];
244
+ rows = raw.query(q1).all(...params1) as Array<{ sourceEntityId: string; targetEntityId: string }>;
245
+ } else {
246
+ // Combine both directions in a single query with global recency
247
+ // ordering so the edge budget isn't direction-biased.
248
+ const q = `
249
+ SELECT sourceEntityId, targetEntityId FROM (
250
+ SELECT r.source_entity_id AS sourceEntityId, r.target_entity_id AS targetEntityId, r.last_seen_at
251
+ FROM memory_entity_relations r
252
+ INNER JOIN memory_entities me ON me.id = r.target_entity_id
253
+ WHERE r.source_entity_id IN (${frontierPlaceholders})
254
+ ${relationTypeCondition} ${entityTypeFilter}
255
+ UNION ALL
256
+ SELECT r.source_entity_id AS sourceEntityId, r.target_entity_id AS targetEntityId, r.last_seen_at
257
+ FROM memory_entity_relations r
258
+ INNER JOIN memory_entities me ON me.id = r.source_entity_id
259
+ WHERE r.target_entity_id IN (${frontierPlaceholders})
260
+ ${relationTypeCondition} ${entityTypeFilter}
261
+ )
262
+ GROUP BY sourceEntityId, targetEntityId
263
+ ORDER BY MAX(last_seen_at) DESC
264
+ LIMIT ?
265
+ `;
266
+ const params = [
267
+ ...frontier,
268
+ ...relationParams,
269
+ ...entityTypes,
270
+ ...frontier,
271
+ ...relationParams,
272
+ ...entityTypes,
273
+ limit,
274
+ ];
275
+
276
+ rows = raw.query(q).all(...params) as Array<{ sourceEntityId: string; targetEntityId: string }>;
277
+ }
278
+ } else {
279
+ const frontierCondition = directed
280
+ ? inArray(memoryEntityRelations.sourceEntityId, frontier)
281
+ : or(
282
+ inArray(memoryEntityRelations.sourceEntityId, frontier),
283
+ inArray(memoryEntityRelations.targetEntityId, frontier),
284
+ );
285
+ const whereCondition = relationTypes && relationTypes.length > 0
286
+ ? and(frontierCondition, inArray(memoryEntityRelations.relation, relationTypes))
287
+ : frontierCondition;
288
+
289
+ rows = db
290
+ .select({
291
+ sourceEntityId: memoryEntityRelations.sourceEntityId,
292
+ targetEntityId: memoryEntityRelations.targetEntityId,
293
+ })
294
+ .from(memoryEntityRelations)
295
+ .where(whereCondition)
296
+ .orderBy(desc(memoryEntityRelations.lastSeenAt))
297
+ .limit(Math.max(1, edgeBudget))
298
+ .all();
299
+ }
190
300
 
191
301
  totalEdgesTraversed += rows.length;
192
302
 
@@ -194,16 +304,20 @@ export function findNeighborEntities(
194
304
  const frontierSet = new Set(frontier);
195
305
  for (const row of rows) {
196
306
  if (neighbors.length >= maxNeighborEntities) break;
307
+ // In directed mode, only follow source→target (frontier is always on source side)
197
308
  if (frontierSet.has(row.sourceEntityId) && !visited.has(row.targetEntityId)) {
198
309
  visited.add(row.targetEntityId);
199
310
  neighbors.push(row.targetEntityId);
200
311
  nextFrontier.push(row.targetEntityId);
312
+ neighborDepths.set(row.targetEntityId, depth + 1);
201
313
  }
314
+ if (directed) continue;
202
315
  if (neighbors.length >= maxNeighborEntities) break;
203
316
  if (frontierSet.has(row.targetEntityId) && !visited.has(row.sourceEntityId)) {
204
317
  visited.add(row.sourceEntityId);
205
318
  neighbors.push(row.sourceEntityId);
206
319
  nextFrontier.push(row.sourceEntityId);
320
+ neighborDepths.set(row.sourceEntityId, depth + 1);
207
321
  }
208
322
  }
209
323
 
@@ -213,6 +327,7 @@ export function findNeighborEntities(
213
327
  return {
214
328
  neighborEntityIds: neighbors.slice(0, maxNeighborEntities),
215
329
  traversedEdgeCount: totalEdgesTraversed,
330
+ neighborDepths,
216
331
  };
217
332
  }
218
333
 
@@ -296,3 +411,71 @@ export function getEntityLinkedItemCandidates(
296
411
  finalScore: 0,
297
412
  }));
298
413
  }
414
+
415
+ /**
416
+ * Multi-step typed traversal: each step expands the frontier through
417
+ * edges matching the step's relation/entity type filters.
418
+ * Returns entity IDs reachable after all steps are applied in sequence.
419
+ */
420
+ export function collectTypedNeighbors(
421
+ seedEntityIds: string[],
422
+ steps: TraversalStep[],
423
+ opts?: { maxResultsPerStep?: number; maxEdgesPerStep?: number },
424
+ ): string[] {
425
+ if (seedEntityIds.length === 0 || steps.length === 0) return [];
426
+
427
+ const maxResults = opts?.maxResultsPerStep ?? 20;
428
+ const maxEdges = opts?.maxEdgesPerStep ?? 40;
429
+
430
+ let currentSeeds = seedEntityIds;
431
+
432
+ for (const step of steps) {
433
+ if (currentSeeds.length === 0) break;
434
+
435
+ const result = findNeighborEntities(currentSeeds, {
436
+ maxEdges,
437
+ maxNeighborEntities: maxResults,
438
+ maxDepth: 1,
439
+ relationTypes: step.relationTypes,
440
+ entityTypes: step.entityTypes,
441
+ directed: true,
442
+ });
443
+
444
+ currentSeeds = result.neighborEntityIds;
445
+ }
446
+
447
+ return currentSeeds;
448
+ }
449
+
450
+ /**
451
+ * Find entities reachable from ALL given seeds via their respective
452
+ * typed traversal steps, then return the intersection.
453
+ */
454
+ export function intersectReachable(
455
+ queries: Array<{
456
+ seedEntityIds: string[];
457
+ steps: TraversalStep[];
458
+ }>,
459
+ opts?: { maxResultsPerStep?: number; maxEdgesPerStep?: number },
460
+ ): string[] {
461
+ if (queries.length === 0) return [];
462
+
463
+ const resultSets: Set<string>[] = [];
464
+ for (const query of queries) {
465
+ const result = collectTypedNeighbors(
466
+ query.seedEntityIds,
467
+ query.steps,
468
+ opts,
469
+ );
470
+ resultSets.push(new Set(result));
471
+ }
472
+
473
+ if (resultSets.length === 0) return [];
474
+
475
+ // Intersect all sets: keep only entities present in ALL sets
476
+ const intersection = [...resultSets[0]].filter(id =>
477
+ resultSets.every(set => set.has(id)),
478
+ );
479
+
480
+ return intersection;
481
+ }
@@ -53,6 +53,7 @@ export function mergeCandidates(
53
53
  entity: Candidate[] = [],
54
54
  freshnessConfig?: { enabled: boolean; maxAgeDays: Record<string, number>; staleDecay: number; reinforcementShieldDays: number },
55
55
  relationScoreMultiplier?: number,
56
+ candidateDepthMap?: Map<string, number>,
56
57
  ): Candidate[] {
57
58
  // Build effective weight map that reflects the actual scoring weight for
58
59
  // each source. For entity_relation the static SOURCE_WEIGHTS entry is 1.0
@@ -126,7 +127,11 @@ export function mergeCandidates(
126
127
  const lastUsedAt = meta?.lastUsedAt ?? null;
127
128
  const freshnessWeight = computeFreshnessWeight(row, accessCount, lastUsedAt, freshnessConfig);
128
129
 
129
- const sourceWeight = effectiveWeights[row.source] ?? 1.0;
130
+ let sourceWeight = effectiveWeights[row.source] ?? 1.0;
131
+ if (row.source === 'entity_relation' && candidateDepthMap && relationScoreMultiplier != null) {
132
+ const depth = candidateDepthMap.get(row.key) ?? 1;
133
+ sourceWeight = Math.pow(relationScoreMultiplier, depth);
134
+ }
130
135
  row.finalScore = rrfScore * (0.5 + 0.5 * effectiveImportance) * trustWeight * freshnessWeight * sourceWeight;
131
136
  }
132
137
 
@@ -94,6 +94,8 @@ export interface CollectedCandidates {
94
94
  relationNeighborEntityCount: number;
95
95
  relationExpandedItemCount: number;
96
96
  earlyTerminated: boolean;
97
+ /** True when semantic search was attempted but threw an error. */
98
+ semanticSearchFailed: boolean;
97
99
  merged: Candidate[];
98
100
  }
99
101
 
@@ -103,6 +105,7 @@ export interface EntitySearchResult {
103
105
  relationTraversedEdgeCount: number;
104
106
  relationNeighborEntityCount: number;
105
107
  relationExpandedItemCount: number;
108
+ candidateDepths?: Map<string, number>; // candidate key → BFS hop depth (1-based)
106
109
  }
107
110
 
108
111
  export interface MatchedEntityRow {
@@ -135,3 +138,26 @@ export interface MemorySearchResult {
135
138
  recency: number;
136
139
  };
137
140
  }
141
+
142
+ import type { EntityRelationType, EntityType } from '../entity-extractor.js';
143
+
144
+ export interface TraversalOptions {
145
+ maxEdges: number;
146
+ maxNeighborEntities: number;
147
+ maxDepth?: number; // default 3
148
+ relationTypes?: EntityRelationType[];
149
+ entityTypes?: EntityType[];
150
+ /** When true, only follow source→target edges (frontier must be on source side). */
151
+ directed?: boolean;
152
+ }
153
+
154
+ export interface TraversalResult {
155
+ neighborEntityIds: string[];
156
+ traversedEdgeCount: number;
157
+ neighborDepths: Map<string, number>; // entityId → depth (1-based)
158
+ }
159
+
160
+ export interface TraversalStep {
161
+ relationTypes?: EntityRelationType[];
162
+ entityTypes?: EntityType[];
163
+ }
@@ -77,4 +77,6 @@ export interface SendOptions {
77
77
  subject?: string;
78
78
  /** For email: in-reply-to message ID */
79
79
  inReplyTo?: string;
80
+ /** Optional assistant scope for multi-assistant channels. */
81
+ assistantId?: string;
80
82
  }