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.
- package/README.md +1 -1
- package/extension/background.js +14 -2
- package/extension/content-script.js +18 -1
- package/extension/lib/brain-client.js +86 -52
- package/extension/lib/preconfigured.js +5 -0
- package/extension/manifest.json +1 -1
- package/extension/options/options.html +1 -0
- package/extension/options/options.js +12 -6
- package/extension/popup/popup.html +1 -0
- package/extension/popup/popup.js +29 -4
- package/extension/sidepanel/sidepanel.html +1 -0
- package/extension/sidepanel/sidepanel.js +24 -6
- package/frontend/index.html +8 -0
- package/frontend/src/api.ts +1 -2
- package/frontend/src/pages/ChatPage.tsx +9 -6
- package/frontend/src/pages/IntegrationsPage.tsx +144 -3
- package/package.json +2 -2
- package/scaffold/.agent/skills/total-recall/SKILL.md +55 -6
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +2 -2
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +1 -6
- package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +19 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +33 -5
- package/src/cli/forget.mjs +18 -12
- package/src/cli/ingest/index.mjs +3 -2
- package/src/cli/init.mjs +30 -0
- package/src/cli/map.mjs +2 -2
- package/src/cli/rebuild.mjs +1 -1
- package/src/cli/remember.mjs +21 -11
- package/src/cli/setup.mjs +10 -1
- package/src/cli/share.mjs +7 -2
- package/src/core/append-log.mjs +131 -0
- package/src/core/clarity-rewriter.mjs +10 -6
- package/src/core/conclusion-writer.mjs +3 -2
- package/src/core/config.mjs +6 -0
- package/src/core/conflict-detector.mjs +4 -2
- package/src/core/context-compiler.mjs +412 -0
- package/src/core/daemon-loop.mjs +15 -0
- package/src/core/dream.mjs +4 -4
- package/src/core/drift-detector.mjs +3 -3
- package/src/core/embeddings.mjs +60 -10
- package/src/core/fact-seeker.mjs +70 -44
- package/src/core/import-rules.mjs +3 -2
- package/src/core/inference-engine.mjs +5 -4
- package/src/core/migrate.mjs +4 -3
- package/src/core/optimizer.mjs +6 -5
- package/src/core/parallel-context.mjs +288 -0
- package/src/core/post-mortem.mjs +4 -3
- package/src/core/research-queue.mjs +4 -3
- package/src/core/runtime.mjs +2 -2
- package/src/core/sandbox.mjs +56 -26
- package/src/core/scheduler.mjs +6 -5
- package/src/core/schema.mjs +2 -2
- package/src/core/search.mjs +167 -101
- package/src/core/surface.mjs +203 -53
- package/src/core/validated-write.mjs +100 -0
- package/src/core/vault-cache.mjs +46 -18
- package/src/core/vault.mjs +17 -29
- package/src/core/vector-field.mjs +659 -0
- package/src/core/vector-store.mjs +85 -0
- package/src/server/api.mjs +4 -3
- package/src/server/index.mjs +13 -61
- package/src/server/rest.mjs +238 -29
- package/src/server/route-manifest.json +344 -77
- package/src/server/routes/memory.mjs +85 -39
- package/src/server/routes/sessions.mjs +6 -2
- package/src/server/routes/share.mjs +9 -2
package/src/core/sandbox.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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,
|
|
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
|
|
package/src/core/scheduler.mjs
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
}
|
package/src/core/schema.mjs
CHANGED
|
@@ -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.
|
|
89
|
+
priority: z.enum(['absolute', 'high', 'normal', 'low']).optional(),
|
|
90
90
|
immutable: z.boolean().optional()
|
|
91
91
|
});
|
|
92
92
|
|
package/src/core/search.mjs
CHANGED
|
@@ -7,13 +7,70 @@
|
|
|
7
7
|
* MCP and REST are thin wrappers; all logic lives here.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
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
|
-
}
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
let filteredNodes = loadNodes(vaultDir);
|
|
184
|
+
}
|
|
185
|
+
lexicalRanked.sort((a, b) => b.score - a.score);
|
|
107
186
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
}
|