total-recall-brain 3.1.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/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/antigravity.mjs +212 -0
- package/bin/init.mjs +36 -0
- package/bin/oci-sniper.sh +69 -0
- package/bin/total-recall.mjs +229 -0
- package/docs/ARCHITECTURE.md +251 -0
- package/docs/IDE_INTEGRATION.md +112 -0
- package/package.json +71 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
- package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
- package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
- package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
- package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
- package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
- package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
- package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
- package/src/cli/agent-dir.mjs +105 -0
- package/src/cli/backup.mjs +313 -0
- package/src/cli/brain.mjs +224 -0
- package/src/cli/chat.mjs +74 -0
- package/src/cli/config.mjs +247 -0
- package/src/cli/connect.mjs +872 -0
- package/src/cli/daemon.mjs +228 -0
- package/src/cli/deploy-ui.mjs +1793 -0
- package/src/cli/deploy.mjs +1726 -0
- package/src/cli/doctor.mjs +254 -0
- package/src/cli/dream.mjs +80 -0
- package/src/cli/forget.mjs +92 -0
- package/src/cli/friction.mjs +21 -0
- package/src/cli/generate-pat.mjs +65 -0
- package/src/cli/hash-password.mjs +22 -0
- package/src/cli/help.mjs +153 -0
- package/src/cli/import-rules.mjs +102 -0
- package/src/cli/ingest/index.mjs +318 -0
- package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
- package/src/cli/ingest/parsers/google-keep.mjs +147 -0
- package/src/cli/ingest/parsers/search-history.mjs +142 -0
- package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
- package/src/cli/ingest/utils/dedup.mjs +69 -0
- package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
- package/src/cli/ingest.mjs +102 -0
- package/src/cli/init.mjs +736 -0
- package/src/cli/integration-dispatcher.mjs +128 -0
- package/src/cli/lint.mjs +166 -0
- package/src/cli/map.mjs +194 -0
- package/src/cli/migrate.mjs +87 -0
- package/src/cli/rebuild.mjs +88 -0
- package/src/cli/recall.mjs +219 -0
- package/src/cli/relay.mjs +518 -0
- package/src/cli/remember.mjs +306 -0
- package/src/cli/research.mjs +381 -0
- package/src/cli/reset-password.mjs +75 -0
- package/src/cli/restore.mjs +135 -0
- package/src/cli/setup.mjs +616 -0
- package/src/cli/share.mjs +180 -0
- package/src/cli/skill.mjs +313 -0
- package/src/cli/snapshot.mjs +108 -0
- package/src/cli/start.mjs +63 -0
- package/src/cli/status.mjs +296 -0
- package/src/cli/sync.mjs +383 -0
- package/src/cli/uninstall.mjs +613 -0
- package/src/cli/upgrade.mjs +141 -0
- package/src/cli/wizard.html +3682 -0
- package/src/core/blackboard.mjs +54 -0
- package/src/core/clarity-rewriter.mjs +770 -0
- package/src/core/conclusion-writer.mjs +259 -0
- package/src/core/config.mjs +277 -0
- package/src/core/conflict-detector.mjs +536 -0
- package/src/core/crypto.mjs +107 -0
- package/src/core/daemon-control.mjs +158 -0
- package/src/core/daemon-loop.mjs +364 -0
- package/src/core/dream.mjs +347 -0
- package/src/core/drift-detector.mjs +143 -0
- package/src/core/embeddings.mjs +632 -0
- package/src/core/emergency-alerts.mjs +137 -0
- package/src/core/evolution.mjs +107 -0
- package/src/core/fact-seeker.mjs +1602 -0
- package/src/core/friction.mjs +95 -0
- package/src/core/import-rules.mjs +308 -0
- package/src/core/inference-engine.mjs +367 -0
- package/src/core/logger.mjs +75 -0
- package/src/core/memory-layers.mjs +96 -0
- package/src/core/migrate.mjs +116 -0
- package/src/core/notifications.mjs +77 -0
- package/src/core/operation-validator.mjs +272 -0
- package/src/core/optimizer.mjs +330 -0
- package/src/core/pattern_detector.mjs +113 -0
- package/src/core/post-mortem.mjs +413 -0
- package/src/core/protect-instructions.mjs +148 -0
- package/src/core/quick-capture.mjs +145 -0
- package/src/core/research-queue.mjs +431 -0
- package/src/core/research.mjs +461 -0
- package/src/core/runtime.mjs +515 -0
- package/src/core/sandbox.mjs +163 -0
- package/src/core/scheduler.mjs +569 -0
- package/src/core/schema.mjs +333 -0
- package/src/core/search.mjs +219 -0
- package/src/core/semantic-index.mjs +276 -0
- package/src/core/session-watcher.mjs +850 -0
- package/src/core/snapshot.mjs +133 -0
- package/src/core/source-adapters.mjs +1038 -0
- package/src/core/steering.mjs +137 -0
- package/src/core/surface.mjs +444 -0
- package/src/core/task_runner.mjs +125 -0
- package/src/core/tts.mjs +142 -0
- package/src/core/usage-tracker.mjs +349 -0
- package/src/core/vault-cache.mjs +76 -0
- package/src/core/vault.mjs +273 -0
- package/src/core/watchdog.mjs +236 -0
- package/src/server/api.mjs +944 -0
- package/src/server/auth.mjs +410 -0
- package/src/server/index.mjs +868 -0
- package/src/server/keys.mjs +181 -0
- package/src/server/rest.mjs +1652 -0
- package/src/server/route-manifest.json +77 -0
- package/src/server/routes/_shared.mjs +93 -0
- package/src/server/routes/keys.mjs +55 -0
- package/src/server/routes/memory.mjs +249 -0
- package/src/server/routes/sessions.mjs +336 -0
- package/src/server/routes/share.mjs +138 -0
- package/src/server/tools.mjs +618 -0
- package/templates/Caddyfile +37 -0
- package/templates/com.totalrecall.backup.plist +42 -0
- package/templates/com.totalrecall.daemon.plist +40 -0
- package/templates/com.totalrecall.duckdns.plist +23 -0
- package/templates/com.totalrecall.server.plist +46 -0
- package/templates/default-config/budget.yml +6 -0
- package/templates/default-config/runtime.yml +11 -0
- package/templates/default-config/security.yml +53 -0
- package/templates/default-config/voice.yml +34 -0
- package/templates/obsidian-queries/conflicts.md +26 -0
- package/templates/obsidian-queries/daily.md +17 -0
- package/templates/obsidian-queries/index.md +38 -0
- package/templates/obsidian-queries/skills.md +14 -0
- package/templates/onboarding-interview.md +72 -0
- package/templates/skill_engineering.md +40 -0
- package/templates/total-recall-daemon.service +21 -0
- package/templates/total-recall-server.service +20 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/embeddings.mjs
|
|
3
|
+
*
|
|
4
|
+
* Google AI embedding generation + cosine similarity for semantic vault search.
|
|
5
|
+
*
|
|
6
|
+
* Index file: .agent/memory-derived/embeddings.json
|
|
7
|
+
* { [slug]: { embedding: number[], model: string, generated_at: string } }
|
|
8
|
+
*
|
|
9
|
+
* Embedding model: gemini-embedding-2 (Google, 768 dims, free tier)
|
|
10
|
+
* Auth: GOOGLE_API_KEY env var
|
|
11
|
+
*
|
|
12
|
+
* Fallback: If GOOGLE_API_KEY is not set, tries OpenAI's text-embedding-3-small
|
|
13
|
+
* via OPENAI_API_KEY env var.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
import { googleApiKey, embedModel, brainDir } from './config.mjs';
|
|
21
|
+
import { logger } from './logger.mjs';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_EMBED_MODEL = embedModel;
|
|
24
|
+
const GOOGLE_API_KEY = googleApiKey || process.env.GOOGLE_API_KEY;
|
|
25
|
+
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
|
26
|
+
|
|
27
|
+
const BRAIN_DIR = brainDir;
|
|
28
|
+
const DERIVED_DIR = path.join(BRAIN_DIR, 'memory-derived');
|
|
29
|
+
const CACHE_DIR = path.join(DERIVED_DIR, 'embeddings-cache');
|
|
30
|
+
|
|
31
|
+
// ─── Hot process-level in-memory cache Map (max size 500) ────────────────────
|
|
32
|
+
const IN_MEMORY_CACHE = new Map();
|
|
33
|
+
const MAX_IN_MEMORY_SIZE = 500;
|
|
34
|
+
|
|
35
|
+
function getInMemory(key) {
|
|
36
|
+
if (!IN_MEMORY_CACHE.has(key)) return null;
|
|
37
|
+
const value = IN_MEMORY_CACHE.get(key);
|
|
38
|
+
// Refresh position for LRU
|
|
39
|
+
IN_MEMORY_CACHE.delete(key);
|
|
40
|
+
IN_MEMORY_CACHE.set(key, value);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function setInMemory(key, embedding) {
|
|
45
|
+
if (IN_MEMORY_CACHE.has(key)) {
|
|
46
|
+
IN_MEMORY_CACHE.delete(key);
|
|
47
|
+
} else if (IN_MEMORY_CACHE.size >= MAX_IN_MEMORY_SIZE) {
|
|
48
|
+
const oldestKey = IN_MEMORY_CACHE.keys().next().value;
|
|
49
|
+
IN_MEMORY_CACHE.delete(oldestKey);
|
|
50
|
+
}
|
|
51
|
+
IN_MEMORY_CACHE.set(key, embedding);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Query Embedding Cache Helpers (Partitioned 256-Dir Layout) ──────────────
|
|
55
|
+
|
|
56
|
+
function sha256(text) {
|
|
57
|
+
return crypto.createHash('sha256').update(text).digest('hex');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getCachePartitionPath(key) {
|
|
61
|
+
const hash = sha256(key);
|
|
62
|
+
const prefix = hash.slice(0, 2);
|
|
63
|
+
const suffix = hash.slice(2, 4);
|
|
64
|
+
return {
|
|
65
|
+
dir: path.join(CACHE_DIR, prefix),
|
|
66
|
+
file: path.join(CACHE_DIR, prefix, `${suffix}.json`),
|
|
67
|
+
hash
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getCachedEmbedding(key) {
|
|
72
|
+
if (process.env.NODE_ENV === 'test' && !global.__TEST_CACHE_OVERRIDE__) return null;
|
|
73
|
+
// Tier 1: Hot In-Memory LRU Cache
|
|
74
|
+
const hit = getInMemory(key);
|
|
75
|
+
if (hit) return hit;
|
|
76
|
+
|
|
77
|
+
// Tier 2: Partitioned Disk Cache
|
|
78
|
+
try {
|
|
79
|
+
const { file, hash } = getCachePartitionPath(key);
|
|
80
|
+
if (fs.existsSync(file)) {
|
|
81
|
+
const partition = JSON.parse(fs.readFileSync(file, 'utf8') || '{}');
|
|
82
|
+
const entry = partition[hash];
|
|
83
|
+
if (!entry) return null;
|
|
84
|
+
|
|
85
|
+
let embedding;
|
|
86
|
+
if (Array.isArray(entry)) {
|
|
87
|
+
embedding = entry;
|
|
88
|
+
} else if (entry && typeof entry === 'object' && Array.isArray(entry.embedding)) {
|
|
89
|
+
embedding = entry.embedding;
|
|
90
|
+
// Non-destructively update lastUsed time
|
|
91
|
+
entry.lastUsed = Date.now();
|
|
92
|
+
fs.writeFileSync(file, JSON.stringify(partition), 'utf8');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (embedding) {
|
|
96
|
+
setInMemory(key, embedding);
|
|
97
|
+
return embedding;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function saveCachedEmbedding(key, embedding) {
|
|
105
|
+
if (process.env.NODE_ENV === 'test' && !global.__TEST_CACHE_OVERRIDE__) return;
|
|
106
|
+
// Tier 1 sync
|
|
107
|
+
setInMemory(key, embedding);
|
|
108
|
+
|
|
109
|
+
// Tier 2 sync
|
|
110
|
+
try {
|
|
111
|
+
const { dir, file, hash } = getCachePartitionPath(key);
|
|
112
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
113
|
+
const partition = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8') || '{}') : {};
|
|
114
|
+
|
|
115
|
+
// Save in upgraded object format non-destructively
|
|
116
|
+
partition[hash] = {
|
|
117
|
+
embedding,
|
|
118
|
+
lastUsed: Date.now()
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Partition Cache Auto-Pruning
|
|
122
|
+
const keys = Object.keys(partition);
|
|
123
|
+
if (keys.length > 500) {
|
|
124
|
+
const entries = keys.map(k => {
|
|
125
|
+
const entry = partition[k];
|
|
126
|
+
let lastUsed = 0;
|
|
127
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
128
|
+
lastUsed = entry.lastUsed || 0;
|
|
129
|
+
}
|
|
130
|
+
return { k, lastUsed };
|
|
131
|
+
});
|
|
132
|
+
// Sort oldest first
|
|
133
|
+
entries.sort((a, b) => a.lastUsed - b.lastUsed);
|
|
134
|
+
// Evict oldest, keeping 400 keys
|
|
135
|
+
const toPrune = entries.slice(0, keys.length - 400);
|
|
136
|
+
for (const p of toPrune) {
|
|
137
|
+
delete partition[p.k];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fs.writeFileSync(file, JSON.stringify(partition), 'utf8');
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Embedding generation ────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the embedding model dynamically from the active API registry list.
|
|
149
|
+
*/
|
|
150
|
+
async function resolveEmbeddingModel(selectedModel = embedModel) {
|
|
151
|
+
if (selectedModel !== 'gemini-embedding-2' && selectedModel !== 'default') {
|
|
152
|
+
return selectedModel;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!GOOGLE_API_KEY) {
|
|
156
|
+
return 'gemini-embedding-2';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${GOOGLE_API_KEY}`, {
|
|
161
|
+
signal: AbortSignal.timeout(5000)
|
|
162
|
+
});
|
|
163
|
+
if (res.ok) {
|
|
164
|
+
const data = await res.json();
|
|
165
|
+
const models = data.models || [];
|
|
166
|
+
const preferences = [
|
|
167
|
+
'gemini-embedding-2',
|
|
168
|
+
'gemini-embedding-2-preview',
|
|
169
|
+
'gemini-embedding-001',
|
|
170
|
+
'text-embedding-004'
|
|
171
|
+
];
|
|
172
|
+
for (const p of preferences) {
|
|
173
|
+
if (models.some(m => m.name === `models/${p}`)) {
|
|
174
|
+
return p;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// Silent failover
|
|
180
|
+
}
|
|
181
|
+
return 'gemini-embedding-2';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get an embedding from Google's embedding API.
|
|
186
|
+
*/
|
|
187
|
+
async function getGoogleEmbedding(text, model) {
|
|
188
|
+
if (!GOOGLE_API_KEY) {
|
|
189
|
+
throw new Error('GOOGLE_API_KEY not set. Cannot generate embeddings.');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const resolved = await resolveEmbeddingModel(model);
|
|
193
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${resolved}:embedContent?key=${GOOGLE_API_KEY}`;
|
|
194
|
+
|
|
195
|
+
const res = await fetch(url, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: { 'Content-Type': 'application/json' },
|
|
198
|
+
body: JSON.stringify({
|
|
199
|
+
model: `models/${resolved}`,
|
|
200
|
+
content: { parts: [{ text }] },
|
|
201
|
+
}),
|
|
202
|
+
signal: AbortSignal.timeout(30000),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (!res.ok) {
|
|
206
|
+
const body = await res.text().catch(() => '');
|
|
207
|
+
throw new Error(`Google embedding API error ${res.status}: ${body || 'no response body'}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const data = await res.json();
|
|
211
|
+
if (!data.embedding?.values || !Array.isArray(data.embedding.values)) {
|
|
212
|
+
throw new Error('Google returned no embedding vector');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return data.embedding.values;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Fallback: Get an embedding from OpenAI's text-embedding-3-small API.
|
|
220
|
+
*/
|
|
221
|
+
async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
|
|
222
|
+
if (!OPENAI_API_KEY) {
|
|
223
|
+
throw new Error('Neither GOOGLE_API_KEY nor OPENAI_API_KEY is set. Cannot generate embeddings.');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const res = await fetch('https://api.openai.com/v1/embeddings', {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: {
|
|
229
|
+
'Content-Type': 'application/json',
|
|
230
|
+
'Authorization': `Bearer ${OPENAI_API_KEY}`,
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify({ model, input: text }),
|
|
233
|
+
signal: AbortSignal.timeout(30000),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (!res.ok) {
|
|
237
|
+
const body = await res.text().catch(() => '');
|
|
238
|
+
throw new Error(`OpenAI embedding API error ${res.status}: ${body || 'no response body'}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const data = await res.json();
|
|
242
|
+
if (!data.data?.[0]?.embedding) {
|
|
243
|
+
throw new Error('OpenAI returned no embedding vector');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return data.data[0].embedding;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get an embedding vector for the given text.
|
|
251
|
+
* Tries Google first, falls back to OpenAI.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} text
|
|
254
|
+
* @param {string} [_unused] — kept for backward compatibility (was ollamaUrl)
|
|
255
|
+
* @param {string} [model]
|
|
256
|
+
* @returns {Promise<number[]>}
|
|
257
|
+
*/
|
|
258
|
+
export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
|
|
259
|
+
const input = String(text).slice(0, 8000); // cap at 8k chars
|
|
260
|
+
|
|
261
|
+
const cacheKey = `${model}:${sha256(input)}`;
|
|
262
|
+
const cached = getCachedEmbedding(cacheKey);
|
|
263
|
+
if (cached) {
|
|
264
|
+
return cached;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let embedding;
|
|
268
|
+
|
|
269
|
+
// Try Google first
|
|
270
|
+
if (GOOGLE_API_KEY) {
|
|
271
|
+
try {
|
|
272
|
+
embedding = await getGoogleEmbedding(input, model);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
logger.debug('embeddings: Google API failed, trying OpenAI fallback', { err: err.message });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Fallback to OpenAI
|
|
279
|
+
if (!embedding && OPENAI_API_KEY) {
|
|
280
|
+
try {
|
|
281
|
+
embedding = await getOpenAIEmbedding(input);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
logger.debug('embeddings: OpenAI fallback also failed', { err: err.message });
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!embedding) {
|
|
289
|
+
throw new Error('No embedding provider available. Set GOOGLE_API_KEY or OPENAI_API_KEY.');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Save to cache
|
|
293
|
+
saveCachedEmbedding(cacheKey, embedding);
|
|
294
|
+
|
|
295
|
+
return embedding;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─── Cosine similarity ───────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Compute cosine similarity between two equal-length vectors.
|
|
302
|
+
* Returns a value in [-1, 1] where 1 = identical direction.
|
|
303
|
+
*
|
|
304
|
+
* @param {number[]} a
|
|
305
|
+
* @param {number[]} b
|
|
306
|
+
* @returns {number}
|
|
307
|
+
*/
|
|
308
|
+
export function cosineSimilarity(a, b) {
|
|
309
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
310
|
+
let dot = 0, magA = 0, magB = 0;
|
|
311
|
+
for (let i = 0; i < a.length; i++) {
|
|
312
|
+
dot += a[i] * b[i];
|
|
313
|
+
magA += a[i] * a[i];
|
|
314
|
+
magB += b[i] * b[i];
|
|
315
|
+
}
|
|
316
|
+
const denom = Math.sqrt(magA) * Math.sqrt(magB);
|
|
317
|
+
return denom === 0 ? 0 : dot / denom;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── Index file helpers ──────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
function indexPath(derivedDir) {
|
|
323
|
+
return path.join(derivedDir, 'embeddings.json');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let cachedIndex = null;
|
|
327
|
+
let cachedIndexPath = null;
|
|
328
|
+
let cachedIndexMtime = 0;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Load the full embeddings index from disk.
|
|
332
|
+
* Returns {} if the file doesn't exist or is corrupt.
|
|
333
|
+
*
|
|
334
|
+
* @param {string} derivedDir
|
|
335
|
+
* @returns {Record<string, { embedding: number[], model: string, generated_at: string }>}
|
|
336
|
+
*/
|
|
337
|
+
export function loadEmbeddingsIndex(derivedDir) {
|
|
338
|
+
const p = indexPath(derivedDir);
|
|
339
|
+
if (!fs.existsSync(p)) return {};
|
|
340
|
+
try {
|
|
341
|
+
const stat = fs.statSync(p);
|
|
342
|
+
if (cachedIndex && cachedIndexPath === p && cachedIndexMtime === stat.mtimeMs) {
|
|
343
|
+
return cachedIndex;
|
|
344
|
+
}
|
|
345
|
+
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
346
|
+
cachedIndex = data;
|
|
347
|
+
cachedIndexPath = p;
|
|
348
|
+
cachedIndexMtime = stat.mtimeMs;
|
|
349
|
+
return data;
|
|
350
|
+
}
|
|
351
|
+
catch { return {}; }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Write one slug's embedding into the index (merges, does not replace whole file).
|
|
356
|
+
*
|
|
357
|
+
* @param {string} derivedDir
|
|
358
|
+
* @param {string} slug
|
|
359
|
+
* @param {number[]} embedding
|
|
360
|
+
* @param {string} [model]
|
|
361
|
+
*/
|
|
362
|
+
export function saveEmbeddingToIndex(derivedDir, slug, embedding, model = DEFAULT_EMBED_MODEL) {
|
|
363
|
+
fs.mkdirSync(derivedDir, { recursive: true });
|
|
364
|
+
const index = loadEmbeddingsIndex(derivedDir);
|
|
365
|
+
index[slug] = { embedding, model, generated_at: new Date().toISOString() };
|
|
366
|
+
fs.writeFileSync(indexPath(derivedDir), JSON.stringify(index), 'utf8');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Remove a slug from the embeddings index (called when a node is deleted).
|
|
371
|
+
*
|
|
372
|
+
* @param {string} derivedDir
|
|
373
|
+
* @param {string} slug
|
|
374
|
+
*/
|
|
375
|
+
export function removeEmbeddingFromIndex(derivedDir, slug) {
|
|
376
|
+
const p = indexPath(derivedDir);
|
|
377
|
+
if (!fs.existsSync(p)) return;
|
|
378
|
+
const index = loadEmbeddingsIndex(derivedDir);
|
|
379
|
+
delete index[slug];
|
|
380
|
+
fs.writeFileSync(p, JSON.stringify(index), 'utf8');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── Text representation ─────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Build the rich text string used for embedding a vault node.
|
|
387
|
+
* Combines title, category, tags, and body for best semantic representation.
|
|
388
|
+
*
|
|
389
|
+
* @param {{ slug: string, title?: string, category?: string, tags?: string[], body?: string }} node
|
|
390
|
+
* @returns {string}
|
|
391
|
+
*/
|
|
392
|
+
export function nodeToEmbedText(node) {
|
|
393
|
+
const parts = [node.title || node.slug];
|
|
394
|
+
if (node.category) parts.push(`Category: ${node.category}`);
|
|
395
|
+
if (node.tags?.length) parts.push(`Tags: ${node.tags.join(', ')}`);
|
|
396
|
+
if (node.body) parts.push(node.body.slice(0, 2000)); // cap body at 2k chars
|
|
397
|
+
return parts.join('\n\n');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ─── Bulk index builder ──────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Build or refresh the embeddings index for a set of vault nodes.
|
|
404
|
+
* Skips nodes that already have an entry in the index (incremental by default).
|
|
405
|
+
* Pass force=true to re-embed all nodes.
|
|
406
|
+
*
|
|
407
|
+
* @param {object[]} nodes
|
|
408
|
+
* @param {string} derivedDir
|
|
409
|
+
* @param {{ model?: string, force?: boolean, onProgress?: Function }} [opts]
|
|
410
|
+
* @returns {Promise<{ built: number, skipped: number, failed: number }>}
|
|
411
|
+
*/
|
|
412
|
+
export async function buildEmbeddingsIndex(nodes, derivedDir, opts = {}) {
|
|
413
|
+
const {
|
|
414
|
+
model = DEFAULT_EMBED_MODEL,
|
|
415
|
+
force = false,
|
|
416
|
+
onProgress,
|
|
417
|
+
} = opts;
|
|
418
|
+
|
|
419
|
+
const existing = force ? {} : loadEmbeddingsIndex(derivedDir);
|
|
420
|
+
const index = { ...existing };
|
|
421
|
+
|
|
422
|
+
// Remove any stale keys in the index that are not present in nodes (stale clean-up)
|
|
423
|
+
const activeSlugs = new Set(nodes.map(n => n.slug));
|
|
424
|
+
for (const slug of Object.keys(index)) {
|
|
425
|
+
if (!activeSlugs.has(slug)) {
|
|
426
|
+
delete index[slug];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let built = 0, skipped = 0, failed = 0;
|
|
431
|
+
|
|
432
|
+
for (const node of nodes) {
|
|
433
|
+
if (!force && existing[node.slug]) {
|
|
434
|
+
skipped++;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
const text = nodeToEmbedText(node);
|
|
439
|
+
const embedding = await getEmbedding(text, undefined, model);
|
|
440
|
+
index[node.slug] = { embedding, model, generated_at: new Date().toISOString() };
|
|
441
|
+
built++;
|
|
442
|
+
onProgress?.({ slug: node.slug, built, skipped, failed });
|
|
443
|
+
} catch (err) {
|
|
444
|
+
failed++;
|
|
445
|
+
onProgress?.({ slug: node.slug, error: err.message, built, skipped, failed });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
fs.mkdirSync(derivedDir, { recursive: true });
|
|
450
|
+
fs.writeFileSync(indexPath(derivedDir), JSON.stringify(index), 'utf8');
|
|
451
|
+
return { built, skipped, failed };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ─── Session index ───────────────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
const SESSION_INDEX_FILE = 'session-embeddings.json';
|
|
457
|
+
|
|
458
|
+
function sessionIndexPath(derivedDir) {
|
|
459
|
+
return path.join(derivedDir, SESSION_INDEX_FILE);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
let cachedSessionIndex = null;
|
|
463
|
+
let cachedSessionIndexPath = null;
|
|
464
|
+
let cachedSessionIndexMtime = 0;
|
|
465
|
+
|
|
466
|
+
export function loadSessionEmbeddingsIndex(derivedDir) {
|
|
467
|
+
const p = sessionIndexPath(derivedDir);
|
|
468
|
+
if (!fs.existsSync(p)) return {};
|
|
469
|
+
try {
|
|
470
|
+
const stat = fs.statSync(p);
|
|
471
|
+
if (cachedSessionIndex && cachedSessionIndexPath === p && cachedSessionIndexMtime === stat.mtimeMs) {
|
|
472
|
+
return cachedSessionIndex;
|
|
473
|
+
}
|
|
474
|
+
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
475
|
+
cachedSessionIndex = data;
|
|
476
|
+
cachedSessionIndexPath = p;
|
|
477
|
+
cachedSessionIndexMtime = stat.mtimeMs;
|
|
478
|
+
return data;
|
|
479
|
+
}
|
|
480
|
+
catch { return {}; }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function saveSessionIndex(derivedDir, index) {
|
|
484
|
+
fs.mkdirSync(derivedDir, { recursive: true });
|
|
485
|
+
fs.writeFileSync(sessionIndexPath(derivedDir), JSON.stringify(index), 'utf8');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function saveSessionEmbeddingToIndex(derivedDir, key, snippet, embedding, model = DEFAULT_EMBED_MODEL) {
|
|
489
|
+
const index = loadSessionEmbeddingsIndex(derivedDir);
|
|
490
|
+
index[key] = { embedding, snippet: snippet.slice(0, 300), model, generated_at: new Date().toISOString() };
|
|
491
|
+
saveSessionIndex(derivedDir, index);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export function removeSessionEmbeddingFromIndex(derivedDir, sessionId) {
|
|
495
|
+
const index = loadSessionEmbeddingsIndex(derivedDir);
|
|
496
|
+
for (const key of Object.keys(index)) {
|
|
497
|
+
if (key === sessionId || key.startsWith(`${sessionId}:chunk-`)) {
|
|
498
|
+
delete index[key];
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
saveSessionIndex(derivedDir, index);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Build a readable text representation of a session for embedding.
|
|
506
|
+
* Concatenates role:content pairs. Returns array of chunks (each ≤ 6000 chars).
|
|
507
|
+
*/
|
|
508
|
+
export function sessionToEmbedChunks(messages) {
|
|
509
|
+
const CHUNK_SIZE = 6000;
|
|
510
|
+
const full = messages
|
|
511
|
+
.filter(m => m && m.content)
|
|
512
|
+
.map(m => `${(m.role || 'unknown').toUpperCase()}: ${String(m.content).slice(0, 2000)}`)
|
|
513
|
+
.join('\n');
|
|
514
|
+
|
|
515
|
+
if (full.length === 0) return [];
|
|
516
|
+
|
|
517
|
+
const chunks = [];
|
|
518
|
+
for (let i = 0; i < full.length; i += CHUNK_SIZE) {
|
|
519
|
+
chunks.push(full.slice(i, i + CHUNK_SIZE));
|
|
520
|
+
}
|
|
521
|
+
return chunks;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Parse a session JSONL file into an array of message objects.
|
|
526
|
+
*/
|
|
527
|
+
export function parseSessionFile(filePath) {
|
|
528
|
+
try {
|
|
529
|
+
const raw = fs.readFileSync(filePath, 'utf8').trim();
|
|
530
|
+
const lines = raw.split('\n').filter(Boolean)
|
|
531
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
532
|
+
.filter(Boolean);
|
|
533
|
+
if (lines.length === 1 && lines[0] && Array.isArray(lines[0].messages)) {
|
|
534
|
+
return lines[0].messages;
|
|
535
|
+
}
|
|
536
|
+
return lines;
|
|
537
|
+
} catch { return []; }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Embed all sessions in sessionsDir that are not yet in the index.
|
|
542
|
+
*
|
|
543
|
+
* @param {string} sessionsDir
|
|
544
|
+
* @param {string} derivedDir
|
|
545
|
+
* @param {{ force?: boolean, model?: string, onProgress?: Function }} [opts]
|
|
546
|
+
* @returns {Promise<{ indexed: number, skipped: number, failed: number, chunks: number }>}
|
|
547
|
+
*/
|
|
548
|
+
export async function buildSessionEmbeddingsIndex(sessionsDir, derivedDir, opts = {}) {
|
|
549
|
+
const {
|
|
550
|
+
force = false,
|
|
551
|
+
model = DEFAULT_EMBED_MODEL,
|
|
552
|
+
onProgress,
|
|
553
|
+
} = opts;
|
|
554
|
+
|
|
555
|
+
if (!fs.existsSync(sessionsDir)) return { indexed: 0, skipped: 0, failed: 0, chunks: 0 };
|
|
556
|
+
|
|
557
|
+
const files = fs.readdirSync(sessionsDir)
|
|
558
|
+
.filter(f => f.endsWith('.jsonl') || f.endsWith('.json'));
|
|
559
|
+
|
|
560
|
+
if (files.length === 0) return { indexed: 0, skipped: 0, failed: 0, chunks: 0 };
|
|
561
|
+
|
|
562
|
+
let existing = force ? {} : loadSessionEmbeddingsIndex(derivedDir);
|
|
563
|
+
|
|
564
|
+
// Probe resolved model embedding dimension
|
|
565
|
+
let newDim = 0;
|
|
566
|
+
try {
|
|
567
|
+
const probe = await getEmbedding("test probe", undefined, model);
|
|
568
|
+
newDim = probe.length;
|
|
569
|
+
} catch (err) {
|
|
570
|
+
// If the probe fails, we cannot resolve/access the API, so skip gracefully
|
|
571
|
+
return { indexed: 0, skipped: files.length, failed: files.length, chunks: 0 };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Check for dimension mismatch against existing cached session embeddings
|
|
575
|
+
if (Object.keys(existing).length > 0) {
|
|
576
|
+
const firstKey = Object.keys(existing)[0];
|
|
577
|
+
const firstVal = existing[firstKey];
|
|
578
|
+
const existingDim = firstVal && Array.isArray(firstVal.embedding) ? firstVal.embedding.length : 0;
|
|
579
|
+
|
|
580
|
+
if (existingDim > 0 && existingDim !== newDim) {
|
|
581
|
+
// Dimension mismatch detected! Wipe the old index to re-embed everything
|
|
582
|
+
existing = {};
|
|
583
|
+
try {
|
|
584
|
+
const file = sessionIndexPath(derivedDir);
|
|
585
|
+
if (fs.existsSync(file)) {
|
|
586
|
+
fs.unlinkSync(file);
|
|
587
|
+
}
|
|
588
|
+
} catch {}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const index = { ...existing };
|
|
593
|
+
let indexed = 0, skipped = 0, failed = 0, totalChunks = 0;
|
|
594
|
+
|
|
595
|
+
for (const file of files) {
|
|
596
|
+
const sessionId = file.replace(/\.(jsonl|json)$/, '');
|
|
597
|
+
if (!force && (existing[sessionId] || existing[`${sessionId}:chunk-0`])) {
|
|
598
|
+
skipped++;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
const messages = parseSessionFile(path.join(sessionsDir, file));
|
|
604
|
+
const chunks = sessionToEmbedChunks(messages);
|
|
605
|
+
if (chunks.length === 0) { skipped++; continue; }
|
|
606
|
+
|
|
607
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
608
|
+
const key = chunks.length === 1 ? sessionId : `${sessionId}:chunk-${i}`;
|
|
609
|
+
const embedding = await getEmbedding(chunks[i], undefined, model);
|
|
610
|
+
index[key] = {
|
|
611
|
+
embedding,
|
|
612
|
+
snippet: chunks[i].slice(0, 300),
|
|
613
|
+
model,
|
|
614
|
+
session_id: sessionId,
|
|
615
|
+
chunk: i,
|
|
616
|
+
total_chunks: chunks.length,
|
|
617
|
+
generated_at: new Date().toISOString(),
|
|
618
|
+
};
|
|
619
|
+
totalChunks++;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
indexed++;
|
|
623
|
+
onProgress?.({ sessionId, indexed, skipped, failed, chunks: totalChunks });
|
|
624
|
+
} catch (err) {
|
|
625
|
+
failed++;
|
|
626
|
+
onProgress?.({ sessionId, error: err.message, indexed, skipped, failed });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
saveSessionIndex(derivedDir, index);
|
|
631
|
+
return { indexed, skipped, failed, chunks: totalChunks };
|
|
632
|
+
}
|