@vellumai/assistant 0.4.49 → 0.4.50

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 (239) hide show
  1. package/ARCHITECTURE.md +24 -33
  2. package/README.md +3 -3
  3. package/docs/architecture/memory.md +180 -119
  4. package/package.json +2 -2
  5. package/src/__tests__/agent-loop.test.ts +3 -1
  6. package/src/__tests__/anthropic-provider.test.ts +114 -23
  7. package/src/__tests__/approval-cascade.test.ts +1 -15
  8. package/src/__tests__/approval-routes-http.test.ts +2 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +0 -23
  10. package/src/__tests__/canonical-guardian-store.test.ts +95 -0
  11. package/src/__tests__/checker.test.ts +13 -0
  12. package/src/__tests__/config-schema.test.ts +1 -68
  13. package/src/__tests__/context-memory-e2e.test.ts +11 -100
  14. package/src/__tests__/conversation-routes-guardian-reply.test.ts +8 -0
  15. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  16. package/src/__tests__/credential-security-e2e.test.ts +1 -0
  17. package/src/__tests__/credential-vault-unit.test.ts +4 -0
  18. package/src/__tests__/credential-vault.test.ts +13 -1
  19. package/src/__tests__/cu-unified-flow.test.ts +532 -0
  20. package/src/__tests__/date-context.test.ts +93 -77
  21. package/src/__tests__/deterministic-verification-control-plane.test.ts +64 -0
  22. package/src/__tests__/guardian-routing-invariants.test.ts +93 -0
  23. package/src/__tests__/history-repair.test.ts +245 -0
  24. package/src/__tests__/host-cu-proxy.test.ts +165 -3
  25. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  26. package/src/__tests__/invite-redemption-service.test.ts +65 -1
  27. package/src/__tests__/keychain-broker-client.test.ts +4 -4
  28. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +56 -18
  29. package/src/__tests__/memory-lifecycle-e2e.test.ts +244 -387
  30. package/src/__tests__/memory-recall-quality.test.ts +244 -407
  31. package/src/__tests__/memory-regressions.experimental.test.ts +126 -101
  32. package/src/__tests__/memory-regressions.test.ts +477 -2841
  33. package/src/__tests__/memory-retrieval.benchmark.test.ts +33 -150
  34. package/src/__tests__/memory-upsert-concurrency.test.ts +5 -244
  35. package/src/__tests__/mime-builder.test.ts +28 -0
  36. package/src/__tests__/native-web-search.test.ts +1 -0
  37. package/src/__tests__/oauth-cli.test.ts +572 -5
  38. package/src/__tests__/oauth-store.test.ts +120 -6
  39. package/src/__tests__/qdrant-collection-migration.test.ts +53 -8
  40. package/src/__tests__/registry.test.ts +0 -1
  41. package/src/__tests__/relay-server.test.ts +46 -1
  42. package/src/__tests__/schedule-tools.test.ts +32 -0
  43. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  44. package/src/__tests__/secret-onetime-send.test.ts +1 -0
  45. package/src/__tests__/secure-keys.test.ts +7 -2
  46. package/src/__tests__/send-endpoint-busy.test.ts +3 -0
  47. package/src/__tests__/session-abort-tool-results.test.ts +1 -14
  48. package/src/__tests__/session-agent-loop-overflow.test.ts +1583 -0
  49. package/src/__tests__/session-agent-loop.test.ts +19 -15
  50. package/src/__tests__/session-confirmation-signals.test.ts +1 -15
  51. package/src/__tests__/session-error.test.ts +124 -2
  52. package/src/__tests__/session-history-web-search.test.ts +918 -0
  53. package/src/__tests__/session-pre-run-repair.test.ts +1 -14
  54. package/src/__tests__/session-provider-retry-repair.test.ts +25 -28
  55. package/src/__tests__/session-queue.test.ts +37 -27
  56. package/src/__tests__/session-runtime-assembly.test.ts +54 -0
  57. package/src/__tests__/session-slash-known.test.ts +1 -15
  58. package/src/__tests__/session-slash-queue.test.ts +1 -15
  59. package/src/__tests__/session-slash-unknown.test.ts +1 -15
  60. package/src/__tests__/session-workspace-cache-state.test.ts +3 -33
  61. package/src/__tests__/session-workspace-injection.test.ts +3 -37
  62. package/src/__tests__/session-workspace-tool-tracking.test.ts +3 -37
  63. package/src/__tests__/skills-install-extract.test.ts +93 -0
  64. package/src/__tests__/skillssh-registry.test.ts +451 -0
  65. package/src/__tests__/trust-store.test.ts +15 -0
  66. package/src/__tests__/voice-invite-redemption.test.ts +32 -1
  67. package/src/agent/ax-tree-compaction.test.ts +51 -0
  68. package/src/agent/loop.ts +39 -12
  69. package/src/approvals/AGENTS.md +1 -1
  70. package/src/approvals/guardian-request-resolvers.ts +14 -2
  71. package/src/bundler/compiler-tools.ts +66 -2
  72. package/src/calls/call-domain.ts +132 -0
  73. package/src/calls/call-store.ts +6 -0
  74. package/src/calls/relay-server.ts +43 -5
  75. package/src/calls/relay-setup-router.ts +17 -1
  76. package/src/calls/twilio-config.ts +1 -1
  77. package/src/calls/types.ts +3 -1
  78. package/src/cli/commands/doctor.ts +4 -3
  79. package/src/cli/commands/mcp.ts +46 -59
  80. package/src/cli/commands/memory.ts +16 -165
  81. package/src/cli/commands/oauth/apps.ts +31 -2
  82. package/src/cli/commands/oauth/connections.ts +431 -97
  83. package/src/cli/commands/oauth/providers.ts +15 -1
  84. package/src/cli/commands/sessions.ts +5 -2
  85. package/src/cli/commands/skills.ts +173 -1
  86. package/src/cli/http-client.ts +0 -20
  87. package/src/cli/main-screen.tsx +2 -2
  88. package/src/cli/program.ts +5 -6
  89. package/src/cli.ts +4 -10
  90. package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
  91. package/src/config/bundled-skills/computer-use/tools/computer-use-observe.ts +12 -0
  92. package/src/config/bundled-tool-registry.ts +2 -5
  93. package/src/config/schema.ts +1 -12
  94. package/src/config/schemas/memory-lifecycle.ts +0 -9
  95. package/src/config/schemas/memory-processing.ts +0 -180
  96. package/src/config/schemas/memory-retrieval.ts +32 -104
  97. package/src/config/schemas/memory.ts +0 -10
  98. package/src/config/types.ts +0 -4
  99. package/src/context/window-manager.ts +4 -1
  100. package/src/daemon/config-watcher.ts +61 -3
  101. package/src/daemon/daemon-control.ts +1 -1
  102. package/src/daemon/date-context.ts +114 -31
  103. package/src/daemon/handlers/sessions.ts +18 -13
  104. package/src/daemon/handlers/skills.ts +20 -1
  105. package/src/daemon/history-repair.ts +72 -8
  106. package/src/daemon/host-cu-proxy.ts +55 -26
  107. package/src/daemon/lifecycle.ts +31 -3
  108. package/src/daemon/mcp-reload-service.ts +2 -2
  109. package/src/daemon/message-types/computer-use.ts +1 -12
  110. package/src/daemon/message-types/memory.ts +4 -16
  111. package/src/daemon/message-types/messages.ts +1 -0
  112. package/src/daemon/message-types/sessions.ts +4 -0
  113. package/src/daemon/server.ts +12 -1
  114. package/src/daemon/session-agent-loop-handlers.ts +38 -0
  115. package/src/daemon/session-agent-loop.ts +334 -48
  116. package/src/daemon/session-error.ts +89 -6
  117. package/src/daemon/session-history.ts +17 -7
  118. package/src/daemon/session-media-retry.ts +6 -2
  119. package/src/daemon/session-memory.ts +69 -149
  120. package/src/daemon/session-process.ts +10 -1
  121. package/src/daemon/session-runtime-assembly.ts +49 -19
  122. package/src/daemon/session-surfaces.ts +4 -1
  123. package/src/daemon/session-tool-setup.ts +7 -1
  124. package/src/daemon/session.ts +12 -2
  125. package/src/instrument.ts +61 -1
  126. package/src/memory/admin.ts +2 -191
  127. package/src/memory/canonical-guardian-store.ts +38 -2
  128. package/src/memory/conversation-crud.ts +0 -33
  129. package/src/memory/conversation-queries.ts +22 -3
  130. package/src/memory/db-init.ts +28 -0
  131. package/src/memory/embedding-backend.ts +84 -8
  132. package/src/memory/embedding-types.ts +9 -1
  133. package/src/memory/indexer.ts +7 -46
  134. package/src/memory/items-extractor.ts +274 -76
  135. package/src/memory/job-handlers/backfill.ts +2 -127
  136. package/src/memory/job-handlers/cleanup.ts +2 -16
  137. package/src/memory/job-handlers/extraction.ts +2 -138
  138. package/src/memory/job-handlers/index-maintenance.ts +1 -6
  139. package/src/memory/job-handlers/summarization.ts +3 -148
  140. package/src/memory/job-utils.ts +21 -59
  141. package/src/memory/jobs-store.ts +1 -159
  142. package/src/memory/jobs-worker.ts +9 -52
  143. package/src/memory/migrations/104-core-indexes.ts +3 -3
  144. package/src/memory/migrations/149-oauth-tables.ts +2 -0
  145. package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +98 -0
  146. package/src/memory/migrations/151-oauth-providers-ping-url.ts +11 -0
  147. package/src/memory/migrations/152-memory-item-supersession.ts +44 -0
  148. package/src/memory/migrations/153-drop-entity-tables.ts +15 -0
  149. package/src/memory/migrations/154-drop-fts.ts +20 -0
  150. package/src/memory/migrations/155-drop-conflicts.ts +7 -0
  151. package/src/memory/migrations/156-call-session-invite-metadata.ts +24 -0
  152. package/src/memory/migrations/index.ts +7 -0
  153. package/src/memory/qdrant-client.ts +148 -51
  154. package/src/memory/raw-query.ts +1 -1
  155. package/src/memory/retriever.test.ts +294 -273
  156. package/src/memory/retriever.ts +421 -645
  157. package/src/memory/schema/calls.ts +2 -0
  158. package/src/memory/schema/memory-core.ts +3 -48
  159. package/src/memory/schema/oauth.ts +2 -0
  160. package/src/memory/search/formatting.ts +263 -176
  161. package/src/memory/search/lexical.ts +1 -254
  162. package/src/memory/search/ranking.ts +0 -455
  163. package/src/memory/search/semantic.ts +100 -14
  164. package/src/memory/search/staleness.ts +47 -0
  165. package/src/memory/search/tier-classifier.ts +21 -0
  166. package/src/memory/search/types.ts +15 -77
  167. package/src/memory/task-memory-cleanup.ts +4 -6
  168. package/src/messaging/providers/gmail/mime-builder.ts +17 -7
  169. package/src/oauth/byo-connection.test.ts +8 -1
  170. package/src/oauth/oauth-store.ts +113 -27
  171. package/src/oauth/seed-providers.ts +6 -0
  172. package/src/oauth/token-persistence.ts +11 -3
  173. package/src/permissions/defaults.ts +1 -0
  174. package/src/permissions/trust-store.ts +23 -1
  175. package/src/playbooks/playbook-compiler.ts +1 -1
  176. package/src/prompts/system-prompt.ts +18 -2
  177. package/src/providers/anthropic/client.ts +56 -126
  178. package/src/providers/types.ts +7 -1
  179. package/src/runtime/AGENTS.md +9 -0
  180. package/src/runtime/auth/route-policy.ts +6 -3
  181. package/src/runtime/guardian-reply-router.ts +24 -22
  182. package/src/runtime/http-server.ts +2 -2
  183. package/src/runtime/invite-redemption-service.ts +19 -1
  184. package/src/runtime/invite-service.ts +25 -0
  185. package/src/runtime/pending-interactions.ts +2 -2
  186. package/src/runtime/routes/brain-graph-routes.ts +10 -90
  187. package/src/runtime/routes/conversation-routes.ts +9 -1
  188. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -12
  189. package/src/runtime/routes/memory-item-routes.test.ts +754 -0
  190. package/src/runtime/routes/memory-item-routes.ts +503 -0
  191. package/src/runtime/routes/session-management-routes.ts +3 -3
  192. package/src/runtime/routes/settings-routes.ts +2 -2
  193. package/src/runtime/routes/trust-rules-routes.ts +14 -0
  194. package/src/runtime/routes/workspace-routes.ts +2 -1
  195. package/src/security/keychain-broker-client.ts +17 -4
  196. package/src/security/secure-keys.ts +25 -3
  197. package/src/security/token-manager.ts +36 -36
  198. package/src/skills/catalog-install.ts +74 -18
  199. package/src/skills/skillssh-registry.ts +503 -0
  200. package/src/tools/assets/search.ts +5 -1
  201. package/src/tools/computer-use/definitions.ts +0 -10
  202. package/src/tools/computer-use/registry.ts +1 -1
  203. package/src/tools/credentials/vault.ts +1 -3
  204. package/src/tools/memory/definitions.ts +4 -13
  205. package/src/tools/memory/handlers.test.ts +83 -103
  206. package/src/tools/memory/handlers.ts +50 -85
  207. package/src/tools/schedule/create.ts +8 -1
  208. package/src/tools/schedule/update.ts +8 -1
  209. package/src/tools/skills/load.ts +25 -2
  210. package/src/__tests__/clarification-resolver.test.ts +0 -193
  211. package/src/__tests__/conflict-intent-tokenization.test.ts +0 -160
  212. package/src/__tests__/conflict-policy.test.ts +0 -269
  213. package/src/__tests__/conflict-store.test.ts +0 -372
  214. package/src/__tests__/contradiction-checker.test.ts +0 -361
  215. package/src/__tests__/entity-extractor.test.ts +0 -211
  216. package/src/__tests__/entity-search.test.ts +0 -1117
  217. package/src/__tests__/profile-compiler.test.ts +0 -392
  218. package/src/__tests__/session-conflict-gate.test.ts +0 -1228
  219. package/src/__tests__/session-profile-injection.test.ts +0 -557
  220. package/src/config/bundled-skills/knowledge-graph/SKILL.md +0 -25
  221. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +0 -66
  222. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +0 -211
  223. package/src/daemon/session-conflict-gate.ts +0 -167
  224. package/src/daemon/session-dynamic-profile.ts +0 -77
  225. package/src/memory/clarification-resolver.ts +0 -417
  226. package/src/memory/conflict-intent.ts +0 -205
  227. package/src/memory/conflict-policy.ts +0 -127
  228. package/src/memory/conflict-store.ts +0 -410
  229. package/src/memory/contradiction-checker.ts +0 -508
  230. package/src/memory/entity-extractor.ts +0 -535
  231. package/src/memory/format-recall.ts +0 -47
  232. package/src/memory/fts-reconciler.ts +0 -165
  233. package/src/memory/job-handlers/conflict.ts +0 -200
  234. package/src/memory/profile-compiler.ts +0 -195
  235. package/src/memory/recall-cache.ts +0 -117
  236. package/src/memory/search/entity.ts +0 -535
  237. package/src/memory/search/query-expansion.test.ts +0 -70
  238. package/src/memory/search/query-expansion.ts +0 -118
  239. package/src/runtime/routes/mcp-routes.ts +0 -20
@@ -1,325 +1,3 @@
1
- import { inArray, sql } from "drizzle-orm";
2
-
3
- import type {
4
- AssistantConfig,
5
- MemoryRerankingConfig,
6
- } from "../../config/types.js";
7
- import { estimateTextTokens } from "../../context/token-estimator.js";
8
- import {
9
- extractText,
10
- getConfiguredProvider,
11
- userMessage,
12
- } from "../../providers/provider-send-message.js";
13
- import { getLogger } from "../../util/logger.js";
14
- import { getDb } from "../db.js";
15
- import { memoryItems } from "../schema.js";
16
- import { buildInjectedText } from "./formatting.js";
17
- import type { Candidate, CandidateSource, ItemMetadata } from "./types.js";
18
-
19
- const log = getLogger("memory-retriever");
20
-
21
- /**
22
- * Trust weight by verification state. Higher = more trusted.
23
- * Bounded: lowest weight is 0.7, never zero -- low-trust items are
24
- * down-ranked but not suppressed.
25
- */
26
- const TRUST_WEIGHTS: Record<string, number> = {
27
- user_confirmed: 1.0,
28
- user_reported: 0.9,
29
- assistant_inferred: 0.7,
30
- };
31
- const DEFAULT_TRUST_WEIGHT = 0.85;
32
-
33
- export const SOURCE_WEIGHTS: Record<CandidateSource, number> = {
34
- lexical: 1.0,
35
- semantic: 1.0,
36
- recency: 1.0,
37
- entity_direct: 1.0,
38
- item_direct: 0.95,
39
- entity_relation: 1.0,
40
- };
41
-
42
- const MS_PER_DAY = 86_400_000;
43
-
44
- /**
45
- * Reciprocal Rank Fusion (RRF) -- merge candidates from independent ranking
46
- * lists without assuming comparable score scales.
47
- *
48
- * Each candidate's RRF contribution from a list is `1 / (k + rank)` where
49
- * rank is 1-based position in that list sorted by its native score.
50
- * The final score is further modulated by importance so that high-importance
51
- * memories surface more readily.
52
- *
53
- * For item-type candidates we also apply retrieval reinforcement: access_count
54
- * from the DB boosts effective importance via `min(1, importance + 0.03 * accessCount)`.
55
- */
56
- export function mergeCandidates(
57
- lexical: Candidate[],
58
- semantic: Candidate[],
59
- recency: Candidate[],
60
- entity: Candidate[] = [],
61
- freshnessConfig?: {
62
- enabled: boolean;
63
- maxAgeDays: Record<string, number>;
64
- staleDecay: number;
65
- reinforcementShieldDays: number;
66
- },
67
- relationScoreMultiplier?: number,
68
- candidateDepthMap?: Map<string, number>,
69
- ): Candidate[] {
70
- // Build effective weight map that reflects the actual scoring weight for
71
- // each source. For entity_relation the static SOURCE_WEIGHTS entry is 1.0
72
- // (a neutral placeholder) but the real multiplier comes from the config
73
- // (relationScoreMultiplier). Using the effective weight in the dedup
74
- // upgrade comparison ensures item_direct (0.95) correctly outranks
75
- // entity_relation (e.g. 0.7) when both sources return the same candidate.
76
- const effectiveWeights: Record<string, number> = { ...SOURCE_WEIGHTS };
77
- if (relationScoreMultiplier != null) {
78
- effectiveWeights["entity_relation"] = relationScoreMultiplier;
79
- }
80
-
81
- // Build merged candidate map (dedup by key, keep best metadata)
82
- const merged = new Map<string, Candidate>();
83
- for (const candidate of [...lexical, ...semantic, ...recency, ...entity]) {
84
- const existing = merged.get(candidate.key);
85
- if (!existing) {
86
- merged.set(candidate.key, { ...candidate });
87
- continue;
88
- }
89
- existing.lexical = Math.max(existing.lexical, candidate.lexical);
90
- existing.semantic = Math.max(existing.semantic, candidate.semantic);
91
- existing.recency = Math.max(existing.recency, candidate.recency);
92
- existing.confidence = Math.max(existing.confidence, candidate.confidence);
93
- existing.importance = Math.max(existing.importance, candidate.importance);
94
- if (candidate.text.length > existing.text.length) {
95
- existing.text = candidate.text;
96
- }
97
- // Upgrade source to whichever has the higher effective weight so scoring
98
- // and caps reflect the strongest retrieval signal for this candidate.
99
- const existingWeight = effectiveWeights[existing.source] ?? 1.0;
100
- const candidateWeight = effectiveWeights[candidate.source] ?? 1.0;
101
- if (candidateWeight > existingWeight) {
102
- existing.source = candidate.source;
103
- }
104
- }
105
-
106
- // Build 1-based rank maps from each list (sorted by native score desc)
107
- const lexicalRanks = buildRankMap(lexical, (c) => c.lexical);
108
- const semanticRanks = buildRankMap(semantic, (c) => c.semantic);
109
- const recencyRanks = buildRankMap(recency, (c) => c.recency);
110
- const entityRanks = buildRankMap(entity, (c) => c.confidence);
111
-
112
- // Look up access_count and verification_state for item-type candidates
113
- const itemIds = [...merged.values()]
114
- .filter((c) => c.type === "item")
115
- .map((c) => c.id);
116
- const itemMetadata = lookupItemMetadata(itemIds);
117
-
118
- const rows = [...merged.values()];
119
- for (const row of rows) {
120
- const ranks: number[] = [];
121
- if (lexicalRanks.has(row.key)) ranks.push(lexicalRanks.get(row.key)!);
122
- if (semanticRanks.has(row.key)) ranks.push(semanticRanks.get(row.key)!);
123
- if (recencyRanks.has(row.key)) ranks.push(recencyRanks.get(row.key)!);
124
- if (entityRanks.has(row.key)) ranks.push(entityRanks.get(row.key)!);
125
-
126
- const rrfScore = rrf(ranks);
127
-
128
- // Retrieval reinforcement: boost importance by accessCount
129
- const meta = itemMetadata.get(row.id);
130
- const accessCount = meta?.accessCount ?? 0;
131
- const effectiveImportance = Math.min(
132
- 1,
133
- row.importance + 0.03 * accessCount,
134
- );
135
-
136
- // Trust-aware ranking: only apply to item candidates (segments/summaries have no metadata)
137
- const trustWeight =
138
- row.type === "item" && meta
139
- ? (TRUST_WEIGHTS[meta.verificationState] ?? DEFAULT_TRUST_WEIGHT)
140
- : 1.0;
141
-
142
- // Freshness decay: down-rank stale items unless recently reinforced
143
- const lastUsedAt = meta?.lastUsedAt ?? null;
144
- const freshnessWeight = computeFreshnessWeight(
145
- row,
146
- accessCount,
147
- lastUsedAt,
148
- freshnessConfig,
149
- );
150
-
151
- let sourceWeight = effectiveWeights[row.source] ?? 1.0;
152
- if (
153
- row.source === "entity_relation" &&
154
- candidateDepthMap &&
155
- relationScoreMultiplier != null
156
- ) {
157
- const depth = candidateDepthMap.get(row.key) ?? 1;
158
- sourceWeight = Math.pow(relationScoreMultiplier, depth);
159
- }
160
- row.finalScore =
161
- rrfScore *
162
- (0.5 + 0.5 * effectiveImportance) *
163
- trustWeight *
164
- freshnessWeight *
165
- sourceWeight;
166
- }
167
-
168
- rows.sort((a, b) => {
169
- const scoreDelta = b.finalScore - a.finalScore;
170
- if (scoreDelta !== 0) return scoreDelta;
171
- const createdAtDelta = b.createdAt - a.createdAt;
172
- if (createdAtDelta !== 0) return createdAtDelta;
173
- return a.key.localeCompare(b.key);
174
- });
175
- return rows;
176
- }
177
-
178
- export function applySourceCaps(
179
- candidates: Candidate[],
180
- config: AssistantConfig,
181
- ): Candidate[] {
182
- if (candidates.length === 0) return candidates;
183
- const sourceCaps = buildSourceCaps(config);
184
- const counts: Partial<Record<CandidateSource, number>> = {};
185
- const capped: Candidate[] = [];
186
-
187
- for (const candidate of candidates) {
188
- const cap = sourceCaps[candidate.source];
189
- const current = counts[candidate.source] ?? 0;
190
- if (current >= cap) continue;
191
- counts[candidate.source] = current + 1;
192
- capped.push(candidate);
193
- }
194
-
195
- return capped;
196
- }
197
-
198
- function buildSourceCaps(
199
- config: AssistantConfig,
200
- ): Record<CandidateSource, number> {
201
- const lexicalTopK = Math.max(1, config.memory.retrieval.lexicalTopK);
202
- const semanticTopK = Math.max(1, config.memory.retrieval.semanticTopK);
203
- const relationLimit = Math.max(
204
- 3,
205
- Math.floor(
206
- Math.min(
207
- config.memory.entity.relationRetrieval.maxNeighborEntities,
208
- config.memory.entity.relationRetrieval.maxEdges,
209
- semanticTopK,
210
- ) * 0.4,
211
- ),
212
- );
213
-
214
- return {
215
- lexical: Math.max(12, lexicalTopK),
216
- semantic: Math.max(8, semanticTopK),
217
- recency: Math.max(6, Math.floor(semanticTopK / 2)),
218
- entity_direct: Math.max(6, Math.floor(semanticTopK / 2)),
219
- item_direct: Math.max(8, Math.floor(lexicalTopK / 2)),
220
- entity_relation: relationLimit,
221
- };
222
- }
223
-
224
- /** Reciprocal Rank Fusion score: sum of 1/(k+rank) across all lists. */
225
- function rrf(ranks: number[], k = 60): number {
226
- return ranks.reduce((sum, rank) => sum + 1 / (k + rank), 0);
227
- }
228
-
229
- /**
230
- * Build a map from candidate key to 1-based rank within a list,
231
- * sorted descending by the given score accessor.
232
- */
233
- function buildRankMap(
234
- candidates: Candidate[],
235
- scoreAccessor: (c: Candidate) => number,
236
- ): Map<string, number> {
237
- const sorted = [...candidates].sort(
238
- (a, b) => scoreAccessor(b) - scoreAccessor(a),
239
- );
240
- const rankMap = new Map<string, number>();
241
- for (let i = 0; i < sorted.length; i++) {
242
- rankMap.set(sorted[i].key, i + 1);
243
- }
244
- return rankMap;
245
- }
246
-
247
- /**
248
- * Look up access_count and verification_state from memory_items for a batch of item IDs.
249
- */
250
- function lookupItemMetadata(itemIds: string[]): Map<string, ItemMetadata> {
251
- const metadata = new Map<string, ItemMetadata>();
252
- if (itemIds.length === 0) return metadata;
253
- try {
254
- const db = getDb();
255
- const rows = db
256
- .select({
257
- id: memoryItems.id,
258
- accessCount: memoryItems.accessCount,
259
- lastUsedAt: memoryItems.lastUsedAt,
260
- verificationState: memoryItems.verificationState,
261
- })
262
- .from(memoryItems)
263
- .where(inArray(memoryItems.id, itemIds))
264
- .all();
265
- for (const row of rows) {
266
- metadata.set(row.id, {
267
- accessCount: row.accessCount,
268
- lastUsedAt: row.lastUsedAt,
269
- verificationState: row.verificationState,
270
- });
271
- }
272
- } catch (err) {
273
- log.warn({ err }, "Failed to look up item metadata for retrieval ranking");
274
- }
275
- return metadata;
276
- }
277
-
278
- /**
279
- * Compute a freshness weight for a candidate based on its kind and age.
280
- * Returns 1.0 for fresh items and `staleDecay` for items past their window.
281
- * Items with recent reinforcement (accessed via lastUsedAt within the shield
282
- * window) are shielded from decay.
283
- */
284
- export function computeFreshnessWeight(
285
- candidate: { type: string; kind: string; createdAt: number },
286
- accessCount: number,
287
- lastUsedAt: number | null,
288
- config?: {
289
- enabled: boolean;
290
- maxAgeDays: Record<string, number>;
291
- staleDecay: number;
292
- reinforcementShieldDays: number;
293
- },
294
- ): number {
295
- if (!config?.enabled) return 1.0;
296
-
297
- // Only apply freshness to item-type candidates
298
- if (candidate.type !== "item") return 1.0;
299
-
300
- const maxAgeDays = config.maxAgeDays[candidate.kind] ?? 0;
301
- // maxAgeDays of 0 means no expiry for this kind
302
- if (maxAgeDays <= 0) return 1.0;
303
-
304
- const now = Date.now();
305
- const ageMs = now - candidate.createdAt;
306
- const ageDays = ageMs / MS_PER_DAY;
307
-
308
- if (ageDays <= maxAgeDays) return 1.0;
309
-
310
- // Check reinforcement shield: items retrieved within the shield window are protected
311
- if (
312
- accessCount > 0 &&
313
- lastUsedAt != null &&
314
- config.reinforcementShieldDays > 0
315
- ) {
316
- const shieldCutoff = now - config.reinforcementShieldDays * MS_PER_DAY;
317
- if (lastUsedAt >= shieldCutoff) return 1.0;
318
- }
319
-
320
- return config.staleDecay;
321
- }
322
-
323
1
  /**
324
2
  * Logarithmic recency decay (ACT-R inspired).
325
3
  *
@@ -335,136 +13,3 @@ export function computeRecencyScore(createdAt: number): number {
335
13
  const ageDays = ageMs / (24 * 60 * 60 * 1000);
336
14
  return 1 / (1 + Math.log2(1 + ageDays));
337
15
  }
338
-
339
- /**
340
- * LLM re-ranking: send candidate memories to Haiku for relevance scoring.
341
- * Returns candidates re-sorted by LLM-assigned relevance score.
342
- */
343
- export async function rerankWithLLM(
344
- query: string,
345
- candidates: Candidate[],
346
- rerankingConfig: MemoryRerankingConfig,
347
- ): Promise<Candidate[]> {
348
- const provider = getConfiguredProvider();
349
- if (!provider) {
350
- log.debug("Configured provider unavailable for LLM re-ranking, skipping");
351
- return candidates;
352
- }
353
-
354
- const candidateList = candidates.map((c, i) => ({
355
- index: i,
356
- id: c.key,
357
- text: truncate(c.text, 200),
358
- }));
359
-
360
- const response = await provider.sendMessage(
361
- [
362
- userMessage(
363
- `Query: ${truncate(query, 200)}\n\nCandidates:\n${candidateList
364
- .map((c) => `[${c.index}] ${c.text}`)
365
- .join("\n")}`,
366
- ),
367
- ],
368
- undefined,
369
- 'You are a relevance scoring assistant. Given a query and a list of memory candidates, rate each candidate\'s relevance to the query on a scale of 0-10. Return ONLY a JSON array of objects with "index" (the candidate index) and "score" (0-10 integer). No explanation.',
370
- {
371
- config: {
372
- modelIntent: rerankingConfig.modelIntent,
373
- max_tokens: 1024,
374
- },
375
- },
376
- );
377
-
378
- // Extract text from the response
379
- const responseText = extractText(response);
380
- if (!responseText) {
381
- log.warn("LLM re-ranking returned no text block, skipping");
382
- return candidates;
383
- }
384
-
385
- // Parse the JSON array from the response
386
- const jsonMatch = responseText.match(/\[[\s\S]*\]/);
387
- if (!jsonMatch) {
388
- log.warn("LLM re-ranking response did not contain JSON array, skipping");
389
- return candidates;
390
- }
391
-
392
- let scores: Array<{ index: number; score: number }>;
393
- try {
394
- scores = JSON.parse(jsonMatch[0]) as Array<{
395
- index: number;
396
- score: number;
397
- }>;
398
- } catch {
399
- log.warn("Failed to parse LLM re-ranking JSON response, skipping");
400
- return candidates;
401
- }
402
-
403
- // Build a score map from LLM results
404
- const scoreMap = new Map<number, number>();
405
- for (const entry of scores) {
406
- if (typeof entry.index === "number" && typeof entry.score === "number") {
407
- scoreMap.set(entry.index, Math.max(0, Math.min(10, entry.score)));
408
- }
409
- }
410
-
411
- // Re-sort candidates by LLM score (desc); unscored candidates keep original order after scored ones
412
- const reranked = candidates.map((c, i) => ({
413
- candidate: c,
414
- llmScore: scoreMap.has(i) ? scoreMap.get(i)! : null,
415
- originalIndex: i,
416
- }));
417
-
418
- reranked.sort((a, b) => {
419
- // Scored items come before unscored items
420
- if (a.llmScore != null && b.llmScore == null) return -1;
421
- if (a.llmScore == null && b.llmScore != null) return 1;
422
- // Both scored: sort by score descending
423
- if (a.llmScore != null && b.llmScore != null) {
424
- const scoreDelta = b.llmScore - a.llmScore;
425
- if (scoreDelta !== 0) return scoreDelta;
426
- }
427
- // Both unscored or tie: preserve original RRF order
428
- return a.originalIndex - b.originalIndex;
429
- });
430
-
431
- return reranked.map((r) => r.candidate);
432
- }
433
-
434
- export function trimToTokenBudget(
435
- candidates: Candidate[],
436
- maxTokens: number,
437
- format: string = "markdown",
438
- ): Candidate[] {
439
- if (maxTokens <= 0) return [];
440
- const selected: Candidate[] = [];
441
- for (const candidate of candidates) {
442
- const tentativeText = buildInjectedText([...selected, candidate], format);
443
- const cost = estimateTextTokens(tentativeText);
444
- if (cost > maxTokens) continue;
445
- selected.push(candidate);
446
- if (cost >= maxTokens) break;
447
- }
448
- return selected;
449
- }
450
-
451
- export function markItemUsage(candidates: Candidate[]): void {
452
- const itemIds = candidates
453
- .filter((candidate) => candidate.type === "item")
454
- .map((candidate) => candidate.id);
455
- if (itemIds.length === 0) return;
456
- const db = getDb();
457
- const now = Date.now();
458
- db.update(memoryItems)
459
- .set({
460
- lastUsedAt: now,
461
- accessCount: sql`${memoryItems.accessCount} + 1`,
462
- })
463
- .where(inArray(memoryItems.id, itemIds))
464
- .run();
465
- }
466
-
467
- function truncate(text: string, max: number): string {
468
- if (text.length <= max) return text;
469
- return `${text.slice(0, max - 3)}...`;
470
- }
@@ -7,7 +7,10 @@ import {
7
7
  _resetQdrantBreaker,
8
8
  withQdrantBreaker,
9
9
  } from "../qdrant-circuit-breaker.js";
10
- import type { QdrantSearchResult } from "../qdrant-client.js";
10
+ import type {
11
+ QdrantSearchResult,
12
+ QdrantSparseVector,
13
+ } from "../qdrant-client.js";
11
14
  import { getQdrantClient } from "../qdrant-client.js";
12
15
  import {
13
16
  conversations,
@@ -31,6 +34,7 @@ export async function semanticSearch(
31
34
  limit: number,
32
35
  excludedMessageIds: string[] = [],
33
36
  scopeIds?: string[],
37
+ sparseVector?: QdrantSparseVector,
34
38
  ): Promise<Candidate[]> {
35
39
  if (limit <= 0) return [];
36
40
 
@@ -40,14 +44,33 @@ export async function semanticSearch(
40
44
  // Use 3x when exclusions are active to ensure enough results survive filtering
41
45
  const overfetchMultiplier = excludedMessageIds.length > 0 ? 3 : 2;
42
46
  const fetchLimit = limit * overfetchMultiplier;
43
- const results: QdrantSearchResult[] = await withQdrantBreaker(() =>
44
- qdrant.searchWithFilter(
45
- queryVector,
46
- fetchLimit,
47
- ["item", "summary", "segment", "media"],
48
- excludedMessageIds,
49
- ),
50
- );
47
+
48
+ // When a sparse vector is available, use hybrid search (dense + sparse RRF fusion)
49
+ // for better recall; otherwise fall back to dense-only search.
50
+ let results: QdrantSearchResult[];
51
+ let isHybrid = false;
52
+ if (sparseVector && sparseVector.indices.length > 0) {
53
+ isHybrid = true;
54
+ const filter = buildHybridFilter(excludedMessageIds, scopeIds);
55
+ results = await withQdrantBreaker(() =>
56
+ qdrant.hybridSearch({
57
+ denseVector: queryVector,
58
+ sparseVector,
59
+ filter,
60
+ limit: fetchLimit,
61
+ prefetchLimit: fetchLimit,
62
+ }),
63
+ );
64
+ } else {
65
+ results = await withQdrantBreaker(() =>
66
+ qdrant.searchWithFilter(
67
+ queryVector,
68
+ fetchLimit,
69
+ ["item", "summary", "segment", "media"],
70
+ excludedMessageIds,
71
+ ),
72
+ );
73
+ }
51
74
 
52
75
  const db = getDb();
53
76
 
@@ -137,7 +160,8 @@ export async function semanticSearch(
137
160
  const candidates: Candidate[] = [];
138
161
  for (const result of results) {
139
162
  const { payload, score } = result;
140
- const semantic = mapCosineToUnit(score);
163
+ // Store raw score; hybrid RRF normalization happens after filtering
164
+ const semantic = isHybrid ? score : mapCosineToUnit(score);
141
165
  const createdAt = payload.created_at ?? Date.now();
142
166
 
143
167
  if (payload.target_type === "item") {
@@ -160,7 +184,6 @@ export async function semanticSearch(
160
184
  confidence: item.confidence,
161
185
  importance: item.importance ?? 0.5,
162
186
  createdAt: item.lastSeenAt,
163
- lexical: 0,
164
187
  semantic,
165
188
  recency: computeRecencyScore(item.lastSeenAt),
166
189
  finalScore: 0,
@@ -181,7 +204,6 @@ export async function semanticSearch(
181
204
  confidence: 0.6,
182
205
  importance: 0.6,
183
206
  createdAt: payload.last_seen_at ?? createdAt,
184
- lexical: 0,
185
207
  semantic,
186
208
  recency: computeRecencyScore(payload.last_seen_at ?? createdAt),
187
209
  finalScore: 0,
@@ -214,7 +236,6 @@ export async function semanticSearch(
214
236
  confidence: 0.7,
215
237
  importance: 0.6,
216
238
  createdAt,
217
- lexical: 0,
218
239
  semantic,
219
240
  recency: computeRecencyScore(createdAt),
220
241
  finalScore: 0,
@@ -234,7 +255,6 @@ export async function semanticSearch(
234
255
  confidence: 0.55,
235
256
  importance: 0.5,
236
257
  createdAt,
237
- lexical: 0,
238
258
  semantic,
239
259
  recency: computeRecencyScore(createdAt),
240
260
  finalScore: 0,
@@ -242,9 +262,75 @@ export async function semanticSearch(
242
262
  }
243
263
  if (candidates.length >= limit) break;
244
264
  }
265
+
266
+ // For hybrid search (RRF fusion), normalize semantic scores relative to
267
+ // the surviving candidates' maximum — not the raw Qdrant batch. Filtered-out
268
+ // high-scoring hits must not anchor normalization and deflate survivors.
269
+ if (isHybrid && candidates.length > 0) {
270
+ const maxScore = Math.max(...candidates.map((c) => c.semantic));
271
+ if (maxScore > 0) {
272
+ for (const c of candidates) {
273
+ c.semantic = c.semantic / maxScore;
274
+ }
275
+ }
276
+ }
277
+
245
278
  return candidates;
246
279
  }
247
280
 
281
+ /**
282
+ * Build a Qdrant filter for hybrid search. Mirrors the logic in
283
+ * `searchWithFilter` but as a standalone object for the query API.
284
+ *
285
+ * Scope filtering: items and media store `memory_scope_id` on the Qdrant
286
+ * point payload, so we can filter at the Qdrant level. Segments and
287
+ * summaries rely on post-query DB filtering (same as dense-only search).
288
+ */
289
+ function buildHybridFilter(
290
+ excludeMessageIds: string[],
291
+ _scopeIds?: string[],
292
+ ): Record<string, unknown> {
293
+ const mustConditions: Array<Record<string, unknown>> = [
294
+ {
295
+ key: "target_type",
296
+ match: { any: ["item", "summary", "segment", "media"] },
297
+ },
298
+ ];
299
+
300
+ if (excludeMessageIds.length > 0) {
301
+ // Only require status=active for items; segments and summaries don't have a status field
302
+ mustConditions.push({
303
+ should: [
304
+ {
305
+ must: [
306
+ { key: "target_type", match: { value: "item" } },
307
+ { key: "status", match: { value: "active" } },
308
+ ],
309
+ },
310
+ {
311
+ key: "target_type",
312
+ match: { any: ["segment", "summary", "media"] },
313
+ },
314
+ ],
315
+ });
316
+ }
317
+
318
+ const mustNotConditions: Array<Record<string, unknown>> = [
319
+ { key: "_meta", match: { value: true } },
320
+ ];
321
+ if (excludeMessageIds.length > 0) {
322
+ mustNotConditions.push({
323
+ key: "message_id",
324
+ match: { any: excludeMessageIds },
325
+ });
326
+ }
327
+
328
+ return {
329
+ must: mustConditions,
330
+ must_not: mustNotConditions,
331
+ };
332
+ }
333
+
248
334
  export function mapCosineToUnit(value: number): number {
249
335
  return Math.max(0, Math.min(1, (value + 1) / 2));
250
336
  }
@@ -0,0 +1,47 @@
1
+ import type { TieredCandidate } from "./tier-classifier.js";
2
+ import type { StalenessLevel } from "./types.js";
3
+
4
+ const BASE_LIFETIME_MS: Record<string, number> = {
5
+ identity: 180 * 86_400_000, // 6 months
6
+ preference: 90 * 86_400_000, // 3 months
7
+ constraint: 30 * 86_400_000, // 1 month
8
+ project: 14 * 86_400_000, // 2 weeks
9
+ decision: 14 * 86_400_000, // 2 weeks
10
+ event: 3 * 86_400_000, // 3 days
11
+ };
12
+
13
+ const DEFAULT_LIFETIME_MS = 30 * 86_400_000;
14
+
15
+ export function computeStaleness(
16
+ item: {
17
+ kind: string;
18
+ firstSeenAt: number;
19
+ sourceConversationCount: number;
20
+ },
21
+ now: number,
22
+ ): { level: StalenessLevel; ratio: number } {
23
+ const baseLifetime = BASE_LIFETIME_MS[item.kind] ?? DEFAULT_LIFETIME_MS;
24
+ const reinforcement = Math.max(1, 1 + 0.3 * (item.sourceConversationCount - 1));
25
+ const effectiveLifetime = baseLifetime * reinforcement;
26
+ const age = now - item.firstSeenAt;
27
+ const ratio = age / effectiveLifetime;
28
+
29
+ if (ratio < 0.5) return { level: "fresh", ratio };
30
+ if (ratio <= 1) return { level: "aging", ratio };
31
+ if (ratio <= 2) return { level: "stale", ratio };
32
+ return { level: "very_stale", ratio };
33
+ }
34
+
35
+ /**
36
+ * Demote very_stale tier-1 candidates to tier 2.
37
+ */
38
+ export function applyStaleDemotion(
39
+ candidates: TieredCandidate[],
40
+ ): TieredCandidate[] {
41
+ return candidates.map((c) => {
42
+ if (c.tier === 1 && c.staleness === "very_stale") {
43
+ return { ...c, tier: 2 as const };
44
+ }
45
+ return c;
46
+ });
47
+ }
@@ -0,0 +1,21 @@
1
+ import type { Candidate } from "./types.js";
2
+
3
+ export type Tier = 1 | 2;
4
+
5
+ export interface TieredCandidate extends Candidate {
6
+ tier: Tier;
7
+ /** Human-readable label for the source conversation/summary (e.g. conversation title). */
8
+ sourceLabel?: string;
9
+ }
10
+
11
+ export function classifyTier(score: number): Tier | null {
12
+ if (score > 0.8) return 1;
13
+ if (score > 0.6) return 2;
14
+ return null;
15
+ }
16
+
17
+ export function classifyTiers(candidates: Candidate[]): TieredCandidate[] {
18
+ return candidates
19
+ .map((c) => ({ ...c, tier: classifyTier(c.finalScore) }))
20
+ .filter((c): c is TieredCandidate => c.tier != null);
21
+ }