total-recall-brain 3.2.3 → 3.4.0

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 (66) hide show
  1. package/README.md +1 -1
  2. package/extension/background.js +14 -2
  3. package/extension/content-script.js +18 -1
  4. package/extension/lib/brain-client.js +86 -52
  5. package/extension/lib/preconfigured.js +5 -0
  6. package/extension/manifest.json +1 -1
  7. package/extension/options/options.html +1 -0
  8. package/extension/options/options.js +12 -6
  9. package/extension/popup/popup.html +1 -0
  10. package/extension/popup/popup.js +29 -4
  11. package/extension/sidepanel/sidepanel.html +1 -0
  12. package/extension/sidepanel/sidepanel.js +24 -6
  13. package/frontend/index.html +8 -0
  14. package/frontend/src/api.ts +1 -2
  15. package/frontend/src/pages/ChatPage.tsx +9 -6
  16. package/frontend/src/pages/IntegrationsPage.tsx +144 -3
  17. package/package.json +2 -2
  18. package/scaffold/.agent/skills/total-recall/SKILL.md +55 -6
  19. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +2 -2
  20. package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +1 -6
  21. package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +19 -0
  22. package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +33 -5
  23. package/src/cli/forget.mjs +18 -12
  24. package/src/cli/ingest/index.mjs +3 -2
  25. package/src/cli/init.mjs +30 -0
  26. package/src/cli/map.mjs +2 -2
  27. package/src/cli/rebuild.mjs +1 -1
  28. package/src/cli/remember.mjs +21 -11
  29. package/src/cli/setup.mjs +10 -1
  30. package/src/cli/share.mjs +7 -2
  31. package/src/core/append-log.mjs +131 -0
  32. package/src/core/clarity-rewriter.mjs +10 -6
  33. package/src/core/conclusion-writer.mjs +3 -2
  34. package/src/core/config.mjs +6 -0
  35. package/src/core/conflict-detector.mjs +4 -2
  36. package/src/core/context-compiler.mjs +412 -0
  37. package/src/core/daemon-loop.mjs +15 -0
  38. package/src/core/dream.mjs +4 -4
  39. package/src/core/drift-detector.mjs +3 -3
  40. package/src/core/embeddings.mjs +60 -10
  41. package/src/core/fact-seeker.mjs +70 -44
  42. package/src/core/import-rules.mjs +3 -2
  43. package/src/core/inference-engine.mjs +5 -4
  44. package/src/core/migrate.mjs +4 -3
  45. package/src/core/optimizer.mjs +6 -5
  46. package/src/core/parallel-context.mjs +288 -0
  47. package/src/core/post-mortem.mjs +4 -3
  48. package/src/core/research-queue.mjs +4 -3
  49. package/src/core/runtime.mjs +2 -2
  50. package/src/core/sandbox.mjs +56 -26
  51. package/src/core/scheduler.mjs +6 -5
  52. package/src/core/schema.mjs +2 -2
  53. package/src/core/search.mjs +167 -101
  54. package/src/core/surface.mjs +203 -53
  55. package/src/core/validated-write.mjs +100 -0
  56. package/src/core/vault-cache.mjs +46 -18
  57. package/src/core/vault.mjs +17 -29
  58. package/src/core/vector-field.mjs +659 -0
  59. package/src/core/vector-store.mjs +85 -0
  60. package/src/server/api.mjs +4 -3
  61. package/src/server/index.mjs +13 -61
  62. package/src/server/rest.mjs +238 -29
  63. package/src/server/route-manifest.json +344 -77
  64. package/src/server/routes/memory.mjs +85 -39
  65. package/src/server/routes/sessions.mjs +6 -2
  66. package/src/server/routes/share.mjs +9 -2
@@ -1,10 +1,52 @@
1
- import { spawn } from 'child_process';
1
+ import { spawn, spawnSync } from 'child_process';
2
2
  import path from 'path';
3
3
  import fs from 'fs';
4
4
  import os from 'os';
5
5
  import crypto from "node:crypto";
6
6
  import { logger } from './logger.mjs';
7
7
 
8
+ let macSandboxProbe = null;
9
+
10
+ function escapeSandboxPath(value) {
11
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
12
+ }
13
+
14
+ function buildMacSandboxProfile({ allowNetwork }) {
15
+ return [
16
+ '(version 1)',
17
+ '(allow default)',
18
+ '(deny file-write*)',
19
+ '(allow file-write*',
20
+ ` (subpath "/private/tmp")`,
21
+ ` (subpath "/tmp")`,
22
+ ` (subpath "${escapeSandboxPath(process.cwd())}")`,
23
+ ` (subpath "${escapeSandboxPath(os.tmpdir())}")`,
24
+ `)`,
25
+ allowNetwork ? '' : '(deny network-outbound)'
26
+ ].filter(Boolean).join('\n');
27
+ }
28
+
29
+ function canUseMacSandbox(profile) {
30
+ if (process.platform !== 'darwin') return false;
31
+ if (macSandboxProbe !== null) return macSandboxProbe;
32
+ const result = spawnSync('sandbox-exec', [
33
+ '-p',
34
+ profile,
35
+ process.execPath,
36
+ '--no-warnings',
37
+ '-e',
38
+ 'process.exit(0)'
39
+ ], {
40
+ encoding: 'utf8',
41
+ timeout: 1500
42
+ });
43
+ macSandboxProbe = result.status === 0;
44
+ if (!macSandboxProbe) {
45
+ logger.warn('sandbox', 'macOS sandbox-exec probe failed; falling back to Node process limits.');
46
+ }
47
+ return macSandboxProbe;
48
+ }
49
+
8
50
  /**
9
51
  * Command Sanitizer / Whitelist Execution Validator
10
52
  * Blocks high-risk actions (destructive deletes, reverse shells, shell pipes)
@@ -62,33 +104,21 @@ export async function runInSandbox(scriptPath, timeoutMs = 5000, options = {}) {
62
104
  safeEnv.NODE_OPTIONS = '--experimental-vm-modules';
63
105
 
64
106
  // OS-specific sandboxing wrappers
65
- let cmd = 'node';
107
+ let cmd = process.execPath;
66
108
  let args = ['--no-warnings', scriptPath];
67
109
  let tempSbProfilePath = null;
68
110
 
69
111
  try {
70
112
  if (process.platform === 'darwin') {
71
113
  // macOS sandbox-exec integration
72
- const profile = [
73
- '(version 1)',
74
- '(allow default)',
75
- '(deny file-write*)',
76
- '(allow file-write*',
77
- ` (subpath "/private/tmp")`,
78
- ` (subpath "/tmp")`,
79
- ` (subpath "${process.cwd()}")`,
80
- ` (subpath "${os.tmpdir()}")`,
81
- `)`,
82
- allowNetwork
83
- ? ''
84
- : '(deny network-outbound)'
85
- ].filter(Boolean).join('\n');
86
-
87
- tempSbProfilePath = path.join(os.tmpdir(), `tr-sandbox-${Date.now()}-${crypto.randomBytes(4).toString("hex")}.sb`);
88
- fs.writeFileSync(tempSbProfilePath, profile, 'utf8');
89
-
90
- cmd = 'sandbox-exec';
91
- args = ['-f', tempSbProfilePath, 'node', '--no-warnings', scriptPath];
114
+ const profile = buildMacSandboxProfile({ allowNetwork });
115
+ if (canUseMacSandbox(profile)) {
116
+ tempSbProfilePath = path.join(os.tmpdir(), `tr-sandbox-${Date.now()}-${crypto.randomBytes(4).toString("hex")}.sb`);
117
+ fs.writeFileSync(tempSbProfilePath, profile, 'utf8');
118
+
119
+ cmd = 'sandbox-exec';
120
+ args = ['-f', tempSbProfilePath, process.execPath, '--no-warnings', scriptPath];
121
+ }
92
122
  } else if (process.platform === 'linux') {
93
123
  // Linux namespace isolation check via unshare
94
124
  const hasUnshare = fs.existsSync('/usr/bin/unshare') || fs.existsSync('/bin/unshare');
@@ -98,7 +128,7 @@ export async function runInSandbox(scriptPath, timeoutMs = 5000, options = {}) {
98
128
  if (!allowNetwork) {
99
129
  unshareArgs.push('--net');
100
130
  }
101
- args = [...unshareArgs, 'node', '--no-warnings', scriptPath];
131
+ args = [...unshareArgs, process.execPath, '--no-warnings', scriptPath];
102
132
  }
103
133
  }
104
134
  } catch (err) {
@@ -117,14 +147,14 @@ export async function runInSandbox(scriptPath, timeoutMs = 5000, options = {}) {
117
147
  proc.stdout.on('data', (data) => { stdout += data.toString(); });
118
148
  proc.stderr.on('data', (data) => { stderr += data.toString(); });
119
149
 
120
- proc.on('close', (code) => {
150
+ proc.on('close', (code, signal) => {
121
151
  if (tempSbProfilePath && fs.existsSync(tempSbProfilePath)) {
122
152
  try { fs.unlinkSync(tempSbProfilePath); } catch {}
123
153
  }
124
154
  if (code === 0) {
125
- resolve({ success: true, output: stdout });
155
+ resolve({ success: true, output: stdout, code, signal });
126
156
  } else {
127
- resolve({ success: false, output: stderr || stdout, code });
157
+ resolve({ success: false, output: stderr || stdout, code, signal });
128
158
  }
129
159
  });
130
160
 
@@ -2,7 +2,8 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import matter from 'gray-matter';
4
4
  import crypto from 'crypto';
5
- import { loadNodes, atomicWrite, safeStringify } from './vault.mjs';
5
+ import { atomicWrite, safeStringify } from './vault.mjs';
6
+ import { getNodes } from './vault-cache.mjs';
6
7
  import { logger } from './logger.mjs';
7
8
  import { loadQueue, updateQueueItem } from './research-queue.mjs';
8
9
 
@@ -243,7 +244,7 @@ function generateProactiveResearchTask() {
243
244
  * Pick a random active memory node and create a clarity review task.
244
245
  */
245
246
  function generateClarityReviewTask(vaultDir) {
246
- const nodes = loadNodes(vaultDir).filter((n) => n.status === 'active');
247
+ const nodes = getNodes(vaultDir).filter((n) => n.status === 'active');
247
248
  if (nodes.length === 0) {
248
249
  return makeFallbackTask('memory-maintenance', 'No active nodes to review');
249
250
  }
@@ -266,7 +267,7 @@ function generateClarityReviewTask(vaultDir) {
266
267
  * Pick the oldest fact node and create a staleness check task.
267
268
  */
268
269
  function generateStalenessCheckTask(vaultDir) {
269
- const nodes = loadNodes(vaultDir)
270
+ const nodes = getNodes(vaultDir)
270
271
  .filter((n) => n.status === 'active' && n.category === 'facts')
271
272
  .sort((a, b) => {
272
273
  const aDate = new Date(a.last_accessed || a.updated || 0).getTime();
@@ -276,7 +277,7 @@ function generateStalenessCheckTask(vaultDir) {
276
277
 
277
278
  if (nodes.length === 0) {
278
279
  // No facts? Pick any old node
279
- const allNodes = loadNodes(vaultDir).filter((n) => n.status === 'active');
280
+ const allNodes = getNodes(vaultDir).filter((n) => n.status === 'active');
280
281
  if (allNodes.length === 0) {
281
282
  return makeFallbackTask('research-acquisition', 'No facts to verify');
282
283
  }
@@ -314,7 +315,7 @@ function generateStalenessCheckTask(vaultDir) {
314
315
  * Pick a cluster of related nodes and create an inference task.
315
316
  */
316
317
  function generateInferenceTask(vaultDir) {
317
- const nodes = loadNodes(vaultDir).filter((n) => n.status === 'active');
318
+ const nodes = getNodes(vaultDir).filter((n) => n.status === 'active');
318
319
  if (nodes.length < 3) {
319
320
  return makeFallbackTask('system2-deliberation', 'Not enough nodes for inference');
320
321
  }
@@ -43,7 +43,7 @@ export const MemoryNodeSchema = z.object({
43
43
  routes_to_skills: z.array(z.string()),
44
44
  sentiment_polarity: z.enum(['directive_must', 'directive_must_not', 'descriptive', 'preference']),
45
45
  sentiment_target: z.string(),
46
- modality: z.enum(['must', 'must_not', 'should', 'should_not']),
46
+ modality: z.enum(['must', 'must_not', 'should', 'should_not', 'descriptive', 'preference']),
47
47
  subject: z.string().regex(/^[a-zA-Z0-9_\s.-]+$/),
48
48
  predicate: z.string().regex(/^[a-zA-Z0-9_\s.-]+$/),
49
49
  object: z.string(),
@@ -86,7 +86,7 @@ export const MemoryNodeSchema = z.object({
86
86
  // Temporal rule expiration
87
87
  expires_at: ssssDatetimeNullable().optional(),
88
88
  // Absolute Invariant Extensions
89
- priority: z.literal('absolute').optional(),
89
+ priority: z.enum(['absolute', 'high', 'normal', 'low']).optional(),
90
90
  immutable: z.boolean().optional()
91
91
  });
92
92
 
@@ -7,13 +7,70 @@
7
7
  * MCP and REST are thin wrappers; all logic lives here.
8
8
  */
9
9
 
10
- import { loadNodes } from './vault.mjs';
10
+ import { getNodes } from './vault-cache.mjs';
11
11
  import {
12
12
  getEmbedding,
13
13
  cosineSimilarity,
14
14
  loadEmbeddingsIndex,
15
15
  loadSessionEmbeddingsIndex,
16
16
  } from './embeddings.mjs';
17
+ import { inferMemoryLayer, memoryLayerRoutingWeight } from './memory-layers.mjs';
18
+ import { VectorStore } from './vector-store.mjs';
19
+
20
+ /**
21
+ * Compute a temporal relevance boost for a vault node.
22
+ * Blends recency, confidence, access frequency, importance,
23
+ * memory layer, and priority into a multiplier applied to
24
+ * cosine similarity scores.
25
+ *
26
+ * Range: ~0.2 (cold/stale/research-tier) to ~2.0 (hot/absolute/conscious)
27
+ * Neutral (no temporal data): 1.0 (pure cosine similarity)
28
+ *
29
+ * @param {object|null} node - The vault node with frontmatter fields
30
+ * @returns {number} Temporal boost multiplier
31
+ */
32
+ function computeTemporalBoost(node) {
33
+ if (!node) return 1.0;
34
+ const now = Date.now();
35
+
36
+ // ── Recency: exponential decay from last_accessed ──
37
+ // Uses the node's own half_life_days if set, otherwise 7 days default
38
+ let recency = 1.0;
39
+ const lastAccessed = node.last_accessed || node.updated || node.created;
40
+ if (lastAccessed) {
41
+ const ageMs = now - new Date(lastAccessed).getTime();
42
+ const halfLifeDays = node.decay?.half_life_days || 7;
43
+ const halfLifeMs = halfLifeDays * 24 * 60 * 60 * 1000;
44
+ recency = Math.pow(0.5, ageMs / halfLifeMs);
45
+ recency = Math.max(recency, 0.05); // floor: never fully invisible
46
+ }
47
+
48
+ // ── Confidence: linear weight ──
49
+ // confidence 1.0 → 1.0, confidence 0.5 → 0.75, confidence 0 → 0.5
50
+ const confidence = typeof node.confidence === 'number' ? node.confidence : 1.0;
51
+ const confidenceWeight = 0.5 + (confidence * 0.5);
52
+
53
+ // ── Access frequency: log scale (from decay.access_count) ──
54
+ // 0 accesses → 1.0, 10 accesses → 1.23, 100 accesses → 1.46
55
+ const accessCount = node.decay?.access_count ?? 0;
56
+ const frequencySignal = 1.0 + (Math.log10(accessCount + 1) * 0.2);
57
+
58
+ // ── Importance tier: linear boost ──
59
+ // importance 1 → 0.8, 3 → 1.0, 5 → 1.2
60
+ const importance = typeof node.importance === 'number' ? node.importance : 3;
61
+ const importanceBoost = 0.8 + ((importance - 1) * 0.1);
62
+
63
+ // ── Priority override ──
64
+ // absolute priority nodes get a fixed boost to ensure they rank highly
65
+ const priorityBoost = node.priority === 'absolute' ? 1.3 : 1.0;
66
+
67
+ // ── Memory layer routing weight ──
68
+ // conscious=1.25, system2=1.0, research=0.75
69
+ const layer = inferMemoryLayer(node);
70
+ const layerWeight = memoryLayerRoutingWeight(layer);
71
+
72
+ return recency * confidenceWeight * frequencySignal * importanceBoost * priorityBoost * layerWeight;
73
+ }
17
74
 
18
75
  /**
19
76
  * Run a semantic search across vault nodes and/or session history.
@@ -44,125 +101,134 @@ export async function semanticSearch(query, {
44
101
  priority = null
45
102
  } = {}) {
46
103
  const k = Math.min(Number(top_k) || 5, 20);
104
+
105
+ // 1. Load nodes and apply filters
106
+ const allNodes = getNodes(vaultDir);
107
+ let filteredNodes = allNodes;
108
+
109
+ if (category) {
110
+ const catLower = category.toLowerCase();
111
+ filteredNodes = filteredNodes.filter(n => n.category && n.category.toLowerCase() === catLower);
112
+ }
113
+ if (tags && tags.length > 0) {
114
+ filteredNodes = filteredNodes.filter(n => n.tags && tags.some(t => n.tags.includes(t)));
115
+ }
116
+ if (modality) {
117
+ const modLower = modality.toLowerCase();
118
+ filteredNodes = filteredNodes.filter(n => n.modality && n.modality.toLowerCase() === modLower);
119
+ }
120
+ if (importance !== null && importance !== undefined) {
121
+ const minImp = parseInt(importance, 10);
122
+ filteredNodes = filteredNodes.filter(n => {
123
+ const val = parseInt(n.importance, 10);
124
+ return !isNaN(val) && val >= minImp;
125
+ });
126
+ }
127
+ if (priority) {
128
+ const prioLower = priority.toLowerCase();
129
+ filteredNodes = filteredNodes.filter(n => n.priority && n.priority.toLowerCase() === prioLower);
130
+ }
131
+
132
+ const filteredSlugs = new Set(filteredNodes.map(n => n.slug));
133
+ const results = [];
134
+
135
+ // Get query embedding
47
136
  let queryEmbedding = null;
48
137
  try {
49
138
  queryEmbedding = await getEmbedding(String(query));
50
139
  } catch (err) {
51
- // Gracefully ignore embedding generation errors to allow text search fallback
140
+ // Gracefully ignore embedding generation errors
52
141
  }
53
- const results = [];
54
142
 
55
- // If metadata filters are active, session log search is automatically bypassed
56
- const hasMetaFilters = category || (tags && tags.length > 0) || modality || importance !== null || priority;
57
- const activeIncludeSessions = hasMetaFilters ? false : includeSessions;
58
-
59
- // ── Vault nodes ────────────────────────────────────────────────────────────
60
143
  const vaultIndex = loadEmbeddingsIndex(derivedDir);
61
144
  const vaultEntries = Object.entries(vaultIndex);
62
145
 
146
+ // 2. Perform Semantic Search Channel (if embedding & index available)
147
+ let semanticRanked = [];
63
148
  if (queryEmbedding && vaultEntries.length > 0) {
64
- let filteredNodes = loadNodes(vaultDir);
65
-
66
- if (category) {
67
- const catLower = category.toLowerCase();
68
- filteredNodes = filteredNodes.filter(n => n.category && n.category.toLowerCase() === catLower);
69
- }
70
- if (tags && tags.length > 0) {
71
- filteredNodes = filteredNodes.filter(n => n.tags && tags.some(t => n.tags.includes(t)));
72
- }
73
- if (modality) {
74
- const modLower = modality.toLowerCase();
75
- filteredNodes = filteredNodes.filter(n => n.modality && n.modality.toLowerCase() === modLower);
76
- }
77
- if (importance !== null && importance !== undefined) {
78
- const minImp = parseInt(importance, 10);
79
- filteredNodes = filteredNodes.filter(n => {
80
- const val = parseInt(n.importance, 10);
81
- return !isNaN(val) && val >= minImp;
82
- });
83
- }
84
- if (priority) {
85
- const prioLower = priority.toLowerCase();
86
- filteredNodes = filteredNodes.filter(n => n.priority && n.priority.toLowerCase() === prioLower);
87
- }
149
+ const store = new VectorStore();
150
+ store.load(vaultIndex);
151
+ const matches = store.search(queryEmbedding, filteredNodes.length, filteredSlugs, cosineSimilarity);
152
+
153
+ semanticRanked = matches.map(({ slug, similarity }) => {
154
+ const node = filteredNodes.find(n => n.slug === slug);
155
+ const temporal = computeTemporalBoost(node);
156
+ return { slug, score: similarity * temporal };
157
+ });
158
+ }
88
159
 
89
- const filteredSlugs = new Set(filteredNodes.map(n => n.slug));
160
+ // 3. Perform Lexical Search Channel (Keyword matcher)
161
+ const qLower = String(query).toLowerCase();
162
+ let lexicalRanked = [];
163
+ for (const node of filteredNodes) {
164
+ let rawLexicalScore = 0;
165
+ const slug = String(node.slug || '').toLowerCase();
166
+ const title = String(node.title || '').toLowerCase();
167
+ const body = String(node.body || '').toLowerCase();
168
+ const nTags = Array.isArray(node.tags) ? node.tags.map(t => String(t).toLowerCase()) : [];
90
169
 
91
- const scored = vaultEntries
92
- .filter(([slug]) => filteredSlugs.has(slug))
93
- .map(([slug, { embedding }]) => ({ slug, score: cosineSimilarity(queryEmbedding, embedding) }))
94
- .sort((a, b) => b.score - a.score)
95
- .slice(0, k);
170
+ if (slug === qLower || title === qLower) {
171
+ rawLexicalScore = 1.0;
172
+ } else if (slug.includes(qLower) || title.includes(qLower)) {
173
+ rawLexicalScore = 0.8;
174
+ } else if (nTags.some(t => t.includes(qLower))) {
175
+ rawLexicalScore = 0.7;
176
+ } else if (body.includes(qLower)) {
177
+ rawLexicalScore = 0.5;
178
+ }
96
179
 
97
- for (const { slug, score } of scored) {
98
- const node = filteredNodes.find(n => n.slug === slug);
99
- if (node) {
100
- results.push({ ...node, type: 'vault', score: Math.round(score * 1000) / 1000 });
101
- }
180
+ if (rawLexicalScore > 0) {
181
+ const temporal = computeTemporalBoost(node);
182
+ lexicalRanked.push({ slug: node.slug, score: rawLexicalScore * temporal });
102
183
  }
103
- } else {
104
- // ── Text Search Fallback for Vault Nodes ──
105
- try {
106
- let filteredNodes = loadNodes(vaultDir);
184
+ }
185
+ lexicalRanked.sort((a, b) => b.score - a.score);
107
186
 
108
- if (category) {
109
- const catLower = category.toLowerCase();
110
- filteredNodes = filteredNodes.filter(n => n.category && n.category.toLowerCase() === catLower);
111
- }
112
- if (tags && tags.length > 0) {
113
- filteredNodes = filteredNodes.filter(n => n.tags && tags.some(t => n.tags.includes(t)));
114
- }
115
- if (modality) {
116
- const modLower = modality.toLowerCase();
117
- filteredNodes = filteredNodes.filter(n => n.modality && n.modality.toLowerCase() === modLower);
118
- }
119
- if (importance !== null && importance !== undefined) {
120
- const minImp = parseInt(importance, 10);
121
- filteredNodes = filteredNodes.filter(n => {
122
- const val = parseInt(n.importance, 10);
123
- return !isNaN(val) && val >= minImp;
124
- });
125
- }
126
- if (priority) {
127
- const prioLower = priority.toLowerCase();
128
- filteredNodes = filteredNodes.filter(n => n.priority && n.priority.toLowerCase() === prioLower);
129
- }
187
+ // 4. Blend Rankings via Reciprocal Rank Fusion (RRF)
188
+ let blendedRanked = [];
189
+ if (semanticRanked.length > 0) {
190
+ const rrfMap = new Map();
191
+ const RRF_K = 60; // Standard RRF parameter constant
130
192
 
131
- const qLower = String(query).toLowerCase();
132
- const scoredText = [];
133
-
134
- for (const node of filteredNodes) {
135
- let score = 0;
136
- const slug = String(node.slug || '').toLowerCase();
137
- const title = String(node.title || '').toLowerCase();
138
- const body = String(node.body || '').toLowerCase();
139
- const nTags = Array.isArray(node.tags) ? node.tags.map(t => String(t).toLowerCase()) : [];
140
-
141
- if (slug === qLower || title === qLower) {
142
- score = 0.9;
143
- } else if (slug.includes(qLower) || title.includes(qLower)) {
144
- score = 0.7;
145
- } else if (nTags.some(t => t.includes(qLower))) {
146
- score = 0.6;
147
- } else if (body.includes(qLower)) {
148
- score = 0.5;
149
- }
193
+ // Index rank positions (1-indexed)
194
+ semanticRanked.forEach((item, index) => {
195
+ rrfMap.set(item.slug, 1 / (RRF_K + (index + 1)));
196
+ });
150
197
 
151
- if (score > 0) {
152
- scoredText.push({ node, score });
153
- }
154
- }
198
+ lexicalRanked.forEach((item, index) => {
199
+ const semScore = rrfMap.get(item.slug) || 0;
200
+ rrfMap.set(item.slug, semScore + (1 / (RRF_K + (index + 1))));
201
+ });
155
202
 
156
- scoredText.sort((a, b) => b.score - a.score);
157
- for (const { node, score } of scoredText.slice(0, k)) {
158
- results.push({ ...node, type: 'vault', score });
159
- }
160
- } catch {
161
- // Gracefully ignore filesystem reading issues
203
+ // Convert back, score, and sort
204
+ blendedRanked = Array.from(rrfMap.entries())
205
+ .map(([slug, rrfScore]) => {
206
+ const node = filteredNodes.find(n => n.slug === slug);
207
+ // Normalize RRF score to [0, 1] relative to theoretical max of 2/60 (rank 1 in both)
208
+ const maxPossibleRrf = 2 / RRF_K;
209
+ const normalizedScore = rrfScore / maxPossibleRrf;
210
+ return { node, score: Math.round(normalizedScore * 1000) / 1000 };
211
+ })
212
+ .sort((a, b) => b.score - a.score);
213
+ } else {
214
+ // If semantic search is unavailable, fallback entirely to lexical ranking
215
+ blendedRanked = lexicalRanked.map(item => {
216
+ const node = filteredNodes.find(n => n.slug === item.slug);
217
+ return { node, score: Math.round(item.score * 1000) / 1000 };
218
+ });
219
+ }
220
+
221
+ // Slice top results
222
+ for (const { node, score } of blendedRanked.slice(0, k)) {
223
+ if (node) {
224
+ results.push({ ...node, type: 'vault', score });
162
225
  }
163
226
  }
164
227
 
165
- // ── Sessions ───────────────────────────────────────────────────────────────
228
+ // 5. Sessions Search Channel
229
+ const hasMetaFilters = category || (tags && tags.length > 0) || modality || importance !== null || priority;
230
+ const activeIncludeSessions = hasMetaFilters ? false : includeSessions;
231
+
166
232
  if (activeIncludeSessions) {
167
233
  const sessionIndex = loadSessionEmbeddingsIndex(derivedDir);
168
234
  const sessionEntries = Object.entries(sessionIndex);
@@ -184,9 +250,8 @@ export async function semanticSearch(query, {
184
250
  results.push({ type: 'session', key, session_id, snippet, chunk, total_chunks, score: Math.round(score * 1000) / 1000 });
185
251
  }
186
252
  } else {
187
- // ── Text Search Fallback for Sessions ──
253
+ // Lexical Fallback for Sessions
188
254
  try {
189
- const qLower = String(query).toLowerCase();
190
255
  const scoredText = [];
191
256
  for (const [key, entry] of sessionEntries) {
192
257
  const snippet = String(entry.snippet || '').toLowerCase();
@@ -215,5 +280,6 @@ export async function semanticSearch(query, {
215
280
  if (!queryEmbedding || vaultEntries.length === 0) {
216
281
  finalResults.degradedTextSearch = true;
217
282
  }
283
+
218
284
  return finalResults;
219
285
  }