neoagent 2.4.4-beta.0 → 2.4.4-beta.4

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 (47) hide show
  1. package/README.md +5 -3
  2. package/docs/capabilities.md +16 -7
  3. package/docs/index.md +1 -0
  4. package/docs/security-boundaries.md +122 -0
  5. package/docs/supermemory-memory-review.md +852 -0
  6. package/flutter_app/lib/features/memory/views/retrieval_inspector_view.dart +128 -0
  7. package/flutter_app/lib/main.dart +3 -0
  8. package/flutter_app/lib/main_app_shell.dart +22 -0
  9. package/flutter_app/lib/main_controller.dart +36 -1
  10. package/flutter_app/lib/main_operations.dart +13 -0
  11. package/flutter_app/lib/main_security.dart +971 -0
  12. package/flutter_app/lib/main_settings.dart +61 -0
  13. package/flutter_app/lib/src/backend_client.dart +60 -3
  14. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +2 -0
  15. package/flutter_app/pubspec.lock +32 -0
  16. package/flutter_app/pubspec.yaml +1 -0
  17. package/lib/schema_migrations.js +237 -0
  18. package/package.json +4 -2
  19. package/server/db/database.js +3 -0
  20. package/server/http/routes.js +2 -1
  21. package/server/public/.last_build_id +1 -1
  22. package/server/public/assets/NOTICES +86 -0
  23. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  24. package/server/public/flutter_bootstrap.js +1 -1
  25. package/server/public/main.dart.js +80911 -79117
  26. package/server/routes/memory.js +39 -2
  27. package/server/routes/security.js +112 -0
  28. package/server/services/ai/engine.js +267 -10
  29. package/server/services/ai/systemPrompt.js +13 -2
  30. package/server/services/cli/shell_worker.js +135 -0
  31. package/server/services/cli/shell_worker_pool.js +125 -0
  32. package/server/services/manager.js +20 -1
  33. package/server/services/memory/consolidation.js +111 -0
  34. package/server/services/memory/embedding_index.js +175 -0
  35. package/server/services/memory/embeddings.js +22 -2
  36. package/server/services/memory/evaluation.js +187 -0
  37. package/server/services/memory/ingestion_chunking.js +191 -0
  38. package/server/services/memory/ingestion_documents.js +96 -26
  39. package/server/services/memory/intelligence.js +3 -1
  40. package/server/services/memory/manager.js +855 -40
  41. package/server/services/memory/policy.js +0 -40
  42. package/server/services/memory/retrieval_reasoning.js +191 -0
  43. package/server/services/runtime/manager.js +7 -0
  44. package/server/services/security/approval_gate_service.js +93 -0
  45. package/server/services/security/tool_categories.js +105 -0
  46. package/server/services/security/tool_policy_service.js +92 -0
  47. package/server/services/security/tool_security_hook.js +77 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * shell_worker.js — Forked shell execution worker
3
+ *
4
+ * This file is intentionally self-contained with zero imports from server/.
5
+ * It runs as a child_process.fork() child with no access to the server's
6
+ * database, JWT secrets, or any app state. The blast radius of a compromised
7
+ * command result is limited to this process.
8
+ *
9
+ * Protocol (IPC):
10
+ * Request: { requestId, command, options: { cwd, timeout, env, stdinInput } }
11
+ * Response: { requestId, result: { stdout, stderr, exitCode, timedOut, durationMs } }
12
+ * | { requestId, error: string }
13
+ */
14
+ 'use strict';
15
+
16
+ const { spawn, execFileSync } = require('child_process');
17
+
18
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
19
+ const FORCE_KILL_GRACE_MS = 5000;
20
+ const MAX_STDOUT_CHARS = 50000;
21
+ const MAX_STDERR_CHARS = 10000;
22
+
23
+ let _defaultShell = null;
24
+
25
+ function resolveShell() {
26
+ if (_defaultShell) return _defaultShell;
27
+ const candidates = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
28
+ for (const candidate of candidates) {
29
+ try {
30
+ execFileSync(candidate, ['-lc', 'printf ok'], {
31
+ timeout: 3000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
32
+ });
33
+ _defaultShell = candidate;
34
+ return _defaultShell;
35
+ } catch {}
36
+ }
37
+ _defaultShell = '/bin/sh';
38
+ return _defaultShell;
39
+ }
40
+
41
+ function clampTimeout(value) {
42
+ const n = Number(value);
43
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_TIMEOUT_MS;
44
+ }
45
+
46
+ function truncate(str, max) {
47
+ if (typeof str !== 'string') str = String(str ?? '');
48
+ return str.length > max ? str.slice(0, max) + `\n...[truncated, ${str.length} total chars]` : str;
49
+ }
50
+
51
+ function supportsPipefail(shell) {
52
+ return /(?:^|\/)(?:bash|zsh|ksh|mksh|yash)$/.test(String(shell || ''));
53
+ }
54
+
55
+ function execute(command, options, callback) {
56
+ const shell = resolveShell();
57
+ const cwd = options.cwd || process.env.HOME;
58
+ const timeout = clampTimeout(options.timeout);
59
+ const wrapped = supportsPipefail(shell) ? `set -o pipefail; ${command}` : command;
60
+
61
+ let stdout = '';
62
+ let stderr = '';
63
+ let killed = false;
64
+ let timedOut = false;
65
+ const startedAt = Date.now();
66
+
67
+ let env = { ...process.env };
68
+ if (options.env && typeof options.env === 'object') {
69
+ env = { ...env, ...options.env };
70
+ }
71
+
72
+ const proc = spawn(shell, ['-l', '-c', wrapped], {
73
+ cwd,
74
+ env,
75
+ detached: process.platform !== 'win32',
76
+ stdio: ['pipe', 'pipe', 'pipe'],
77
+ });
78
+
79
+ const timer = setTimeout(() => {
80
+ killed = true;
81
+ timedOut = true;
82
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); }
83
+ setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, FORCE_KILL_GRACE_MS);
84
+ }, timeout);
85
+
86
+ proc.stdout.on('data', (d) => { stdout += d; if (stdout.length > 500000) stdout = stdout.slice(-250000); });
87
+ proc.stderr.on('data', (d) => { stderr += d; if (stderr.length > 100000) stderr = stderr.slice(-50000); });
88
+
89
+ if (options.stdinInput) {
90
+ proc.stdin.write(options.stdinInput);
91
+ proc.stdin.end();
92
+ }
93
+
94
+ proc.on('close', (code) => {
95
+ clearTimeout(timer);
96
+ callback(null, {
97
+ exitCode: typeof code === 'number' ? code : null,
98
+ stdout: truncate(stdout.trim(), MAX_STDOUT_CHARS),
99
+ stderr: truncate(stderr.trim(), MAX_STDERR_CHARS),
100
+ killed,
101
+ timedOut,
102
+ durationMs: Date.now() - startedAt,
103
+ command,
104
+ cwd,
105
+ });
106
+ });
107
+
108
+ proc.on('error', (err) => {
109
+ clearTimeout(timer);
110
+ callback(null, {
111
+ exitCode: -1,
112
+ stdout: '',
113
+ stderr: err.message,
114
+ killed: false,
115
+ timedOut: false,
116
+ durationMs: Date.now() - startedAt,
117
+ command,
118
+ cwd,
119
+ error: err.message,
120
+ });
121
+ });
122
+ }
123
+
124
+ process.on('message', ({ requestId, command, options = {} }) => {
125
+ try {
126
+ execute(command, options, (err, result) => {
127
+ process.send({ requestId, result });
128
+ });
129
+ } catch (err) {
130
+ process.send({ requestId, error: String(err?.message || err) });
131
+ }
132
+ });
133
+
134
+ // Signal readiness
135
+ process.send?.({ type: 'ready' });
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ const { fork } = require('child_process');
4
+ const { randomUUID } = require('crypto');
5
+ const path = require('path');
6
+
7
+ const WORKER_SCRIPT = path.resolve(__dirname, 'shell_worker.js');
8
+
9
+ class ShellWorkerPool {
10
+ /**
11
+ * @param {object} [options]
12
+ * @param {number} [options.size=4] Number of worker processes to keep alive
13
+ * @param {string} [options.workerScript] Override worker script path (for tests)
14
+ */
15
+ constructor({ size = 4, workerScript = WORKER_SCRIPT } = {}) {
16
+ this._size = size;
17
+ this._workerScript = workerScript;
18
+ /** @type {Array<{ proc: ChildProcess, busy: boolean, pendingRequestId: string|null }>} */
19
+ this._workers = [];
20
+ /** @type {Array<{ requestId: string, command: string, options: object, resolve: Function, reject: Function }>} */
21
+ this._queue = [];
22
+ /** @type {Map<string, Function>} requestId → resolve */
23
+ this._pending = new Map();
24
+
25
+ for (let i = 0; i < this._size; i++) {
26
+ this._spawnWorker();
27
+ }
28
+ }
29
+
30
+ _spawnWorker() {
31
+ const proc = fork(this._workerScript, [], {
32
+ detached: false,
33
+ stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
34
+ env: { ...process.env },
35
+ });
36
+
37
+ const workerEntry = { proc, busy: false, pendingRequestId: null };
38
+ this._workers.push(workerEntry);
39
+
40
+ proc.on('message', (msg) => {
41
+ if (msg?.type === 'ready') return;
42
+
43
+ const { requestId, result, error } = msg || {};
44
+ const resolve = this._pending.get(requestId);
45
+ if (resolve) {
46
+ this._pending.delete(requestId);
47
+ resolve(error ? { exitCode: -1, stdout: '', stderr: error, killed: false, timedOut: false } : result);
48
+ }
49
+ workerEntry.busy = false;
50
+ workerEntry.pendingRequestId = null;
51
+ this._drain();
52
+ });
53
+
54
+ proc.on('exit', (code) => {
55
+ console.warn(`[ShellWorkerPool] Worker exited (code=${code}), respawning`);
56
+ const idx = this._workers.indexOf(workerEntry);
57
+ if (idx !== -1) this._workers.splice(idx, 1);
58
+
59
+ // Reject the in-flight request on this worker, if any
60
+ if (workerEntry.pendingRequestId) {
61
+ const resolve = this._pending.get(workerEntry.pendingRequestId);
62
+ if (resolve) {
63
+ this._pending.delete(workerEntry.pendingRequestId);
64
+ resolve({ exitCode: -1, stdout: '', stderr: 'Worker process crashed', killed: true, timedOut: false });
65
+ }
66
+ }
67
+
68
+ this._spawnWorker();
69
+ });
70
+
71
+ proc.on('error', (err) => {
72
+ console.error('[ShellWorkerPool] Worker error:', err.message);
73
+ });
74
+
75
+ return workerEntry;
76
+ }
77
+
78
+ _drain() {
79
+ if (this._queue.length === 0) return;
80
+ const idleWorker = this._workers.find((w) => !w.busy);
81
+ if (!idleWorker) return;
82
+
83
+ const job = this._queue.shift();
84
+ this._dispatch(idleWorker, job);
85
+ }
86
+
87
+ _dispatch(workerEntry, job) {
88
+ const { requestId, command, options, resolve } = job;
89
+ workerEntry.busy = true;
90
+ workerEntry.pendingRequestId = requestId;
91
+ this._pending.set(requestId, resolve);
92
+ workerEntry.proc.send({ requestId, command, options });
93
+ }
94
+
95
+ /**
96
+ * Execute a shell command in an isolated worker process.
97
+ * @param {string} command
98
+ * @param {object} [options]
99
+ * @returns {Promise<object>} result with { stdout, stderr, exitCode, killed, timedOut, durationMs }
100
+ */
101
+ execute(command, options = {}) {
102
+ return new Promise((resolve) => {
103
+ const requestId = randomUUID();
104
+ const job = { requestId, command, options, resolve };
105
+ const idleWorker = this._workers.find((w) => !w.busy);
106
+ if (idleWorker) {
107
+ this._dispatch(idleWorker, job);
108
+ } else {
109
+ this._queue.push(job);
110
+ }
111
+ });
112
+ }
113
+
114
+ /** Gracefully terminate all workers. */
115
+ shutdown() {
116
+ for (const w of this._workers) {
117
+ try { w.proc.kill(); } catch {}
118
+ }
119
+ this._workers = [];
120
+ this._queue = [];
121
+ this._pending.clear();
122
+ }
123
+ }
124
+
125
+ module.exports = { ShellWorkerPool };
@@ -26,6 +26,9 @@ const { StructuredDataService } = require('./workspace/structured_data');
26
26
  const { TaskWebhookService } = require('./tasks/webhooks');
27
27
  const { LearningManager } = require('./ai/learning');
28
28
  const { CapabilityAuditService } = require('./security/capability_audit');
29
+ const { ToolPolicyService } = require('./security/tool_policy_service');
30
+ const { ApprovalGateService } = require('./security/approval_gate_service');
31
+ const { registerToolSecurityHooks } = require('./security/tool_security_hook');
29
32
  const { BrowserExtensionRegistry } = require('./browser/extension/registry');
30
33
  const { DesktopCompanionRegistry } = require('./desktop/registry');
31
34
  const { DesktopProvider } = require('./desktop/provider');
@@ -95,6 +98,7 @@ function createDesktopCompanionRegistry(app) {
95
98
 
96
99
  function createMemoryManager(app) {
97
100
  const memoryManager = registerLocal(app, 'memoryManager', new MemoryManager());
101
+ memoryManager.startEmbeddingIndexBackfill();
98
102
  const reconcile = () => {
99
103
  const users = db.prepare('SELECT id FROM users').all();
100
104
  for (const user of users) {
@@ -248,15 +252,23 @@ function createBrowserController(app, artifactStore) {
248
252
  }
249
253
 
250
254
  function createRuntimeManager(app) {
255
+ const { ShellWorkerPool } = require('./cli/shell_worker_pool');
256
+ const shellWorkerPool = registerLocal(
257
+ app,
258
+ 'shellWorkerPool',
259
+ new ShellWorkerPool({ size: 4 }),
260
+ );
251
261
  const runtimeManager = registerLocal(
252
262
  app,
253
263
  'runtimeManager',
254
264
  new RuntimeManager({
255
265
  artifactStore: app.locals.artifactStore,
256
266
  browserExtensionRegistry: app.locals.browserExtensionRegistry,
267
+ desktopCompanionRegistry: app.locals.desktopCompanionRegistry,
268
+ shellWorkerPool,
257
269
  }),
258
270
  );
259
- logServiceReady('Runtime manager ready');
271
+ logServiceReady('Runtime manager + shell worker pool ready');
260
272
  return runtimeManager;
261
273
  }
262
274
 
@@ -501,6 +513,10 @@ async function startServices(app, io) {
501
513
  mcpClient,
502
514
  skillRunner,
503
515
  }));
516
+ const toolPolicyService = registerLocal(app, 'toolPolicyService', new ToolPolicyService());
517
+ const approvalGateService = registerLocal(app, 'approvalGateService', new ApprovalGateService({ io }));
518
+ registerToolSecurityHooks(toolPolicyService, approvalGateService);
519
+ logServiceReady('Tool security hooks registered');
504
520
  agentEngine.learningManager = app.locals.learningManager;
505
521
 
506
522
  createMultiStep(app, agentEngine, io);
@@ -555,6 +571,9 @@ async function startServices(app, io) {
555
571
  async function stopServices(app) {
556
572
  const tasks = [];
557
573
  console.log('[Services] Stopping services');
574
+ if (app.locals.memoryManager) {
575
+ app.locals.memoryManager.stopEmbeddingIndexBackfill();
576
+ }
558
577
  if (app.locals.memoryReconciliationTimer) {
559
578
  clearInterval(app.locals.memoryReconciliationTimer);
560
579
  app.locals.memoryReconciliationTimer = null;
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const MEMORY_RELATIONS = new Set(['new', 'updates', 'extends', 'derives']);
4
+ const MEMORY_CATEGORIES = new Set([
5
+ 'identity',
6
+ 'preferences',
7
+ 'projects',
8
+ 'contacts',
9
+ 'events',
10
+ 'tasks',
11
+ 'episodic',
12
+ 'assistant_self',
13
+ ]);
14
+
15
+ function cleanText(value, maxLength) {
16
+ return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength);
17
+ }
18
+
19
+ function normalizeDate(value) {
20
+ const text = cleanText(value, 80);
21
+ if (!text) return null;
22
+ const timestamp = Date.parse(text);
23
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
24
+ }
25
+
26
+ function normalizeConfidence(value) {
27
+ const confidence = Number(value);
28
+ if (!Number.isFinite(confidence)) return 0.7;
29
+ return Math.max(0, Math.min(1, confidence));
30
+ }
31
+
32
+ function normalizeMemoryCandidate(candidate) {
33
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
34
+ return null;
35
+ }
36
+
37
+ const subject = cleanText(candidate.subject, 180);
38
+ const predicate = cleanText(candidate.predicate, 120).toLowerCase();
39
+ const object = cleanText(candidate.object, 900);
40
+ const memory = cleanText(candidate.memory, 1200);
41
+ if (!subject || !predicate || !object || !memory) return null;
42
+
43
+ const relation = cleanText(candidate.relation, 24).toLowerCase();
44
+ const category = cleanText(candidate.category, 40).toLowerCase();
45
+
46
+ const finalRelation = MEMORY_RELATIONS.has(relation) ? relation : 'new';
47
+ let finalConfidence = normalizeConfidence(candidate.confidence);
48
+ if (finalRelation === 'derives') {
49
+ finalConfidence = Math.min(finalConfidence, 0.6);
50
+ }
51
+
52
+ return {
53
+ memory,
54
+ subject,
55
+ predicate,
56
+ object,
57
+ relation: finalRelation,
58
+ category: MEMORY_CATEGORIES.has(category) ? category : 'episodic',
59
+ confidence: finalConfidence,
60
+ importance: Math.max(1, Math.min(10, Number(candidate.importance) || 5)),
61
+ isStatic: candidate.is_static === true || candidate.isStatic === true,
62
+ validFrom: normalizeDate(candidate.valid_from || candidate.validFrom),
63
+ validTo: normalizeDate(candidate.valid_to || candidate.validTo),
64
+ forgetAfter: normalizeDate(candidate.forget_after || candidate.forgetAfter),
65
+ evidence: cleanText(candidate.evidence, 500),
66
+ };
67
+ }
68
+
69
+ function normalizeMemoryCandidates(value, limit = 12) {
70
+ if (!Array.isArray(value)) return [];
71
+ const candidates = [];
72
+ const seen = new Set();
73
+
74
+ for (const item of value) {
75
+ const candidate = normalizeMemoryCandidate(item);
76
+ if (!candidate) continue;
77
+ const key = [
78
+ candidate.subject.toLowerCase(),
79
+ candidate.predicate,
80
+ candidate.object.toLowerCase(),
81
+ ].join('\u0000');
82
+ if (seen.has(key)) continue;
83
+ seen.add(key);
84
+ candidates.push(candidate);
85
+ if (candidates.length >= limit) break;
86
+ }
87
+
88
+ return candidates;
89
+ }
90
+
91
+ function buildMemoryConsolidationInstructions(currentDateTime) {
92
+ return [
93
+ 'Also extract durable memory candidates from the thread.',
94
+ `Current date/time: ${currentDateTime}. Resolve relative dates against this value.`,
95
+ 'A memory candidate must be an atomic fact that will improve a future conversation.',
96
+ 'Prefer explicit user statements and verified outcomes. Do not turn assistant guesses, suggestions, questions, or unverified claims into user facts.',
97
+ 'Exclude secrets, credentials, private tokens, raw tool output, routine task narration, and facts useful only inside this thread.',
98
+ 'Use relation="updates" when a fact replaces an older value for the same subject and predicate.',
99
+ 'Use relation="extends" when it adds compatible detail without replacing the prior fact.',
100
+ 'Use relation="derives" only for a strongly supported inference and lower its confidence.',
101
+ 'Use is_static=true only for stable identity or durable preference facts.',
102
+ 'Set valid_from, valid_to, or forget_after as ISO-8601 timestamps when the thread provides temporal boundaries.',
103
+ 'Return an empty memory_candidates array when nothing is worth retaining.',
104
+ ].join(' ');
105
+ }
106
+
107
+ module.exports = {
108
+ buildMemoryConsolidationInstructions,
109
+ normalizeMemoryCandidate,
110
+ normalizeMemoryCandidates,
111
+ };
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ const { deserializeEmbedding } = require('./embeddings');
4
+
5
+ const BAND_COUNT = 10;
6
+ const BITS_PER_BAND = 14;
7
+ const INDEX_VERSION = 3;
8
+ const DEFAULT_CANDIDATE_LIMIT = 600;
9
+
10
+ function sampledCoordinate(dimension, bandIndex, bitIndex) {
11
+ let value = Math.imul(bandIndex + 1, 0x9e3779b1) ^ Math.imul(bitIndex + 1, 0x85ebca6b);
12
+ value ^= value >>> 16;
13
+ value = Math.imul(value, 0x7feb352d);
14
+ value ^= value >>> 15;
15
+ return (value >>> 0) % dimension;
16
+ }
17
+
18
+ function createEmbeddingBands(vector) {
19
+ if (!vector || !Number.isInteger(vector.length) || vector.length < BITS_PER_BAND) {
20
+ return [];
21
+ }
22
+
23
+ const bands = [];
24
+ for (let bandIndex = 0; bandIndex < BAND_COUNT; bandIndex += 1) {
25
+ let bandValue = 0;
26
+ for (let bitIndex = 0; bitIndex < BITS_PER_BAND; bitIndex += 1) {
27
+ const coordinate = sampledCoordinate(vector.length, bandIndex, bitIndex);
28
+ if (Number(vector[coordinate]) >= 0) {
29
+ bandValue |= 1 << bitIndex;
30
+ }
31
+ }
32
+ bands.push({
33
+ bandIndex,
34
+ bandValue,
35
+ dimension: vector.length,
36
+ });
37
+ }
38
+ return bands;
39
+ }
40
+
41
+ function replaceMemoryEmbeddingIndex(db, {
42
+ memoryId,
43
+ userId,
44
+ agentId,
45
+ embedding,
46
+ }) {
47
+ const vector = typeof embedding === 'string'
48
+ ? deserializeEmbedding(embedding)
49
+ : embedding;
50
+ const bands = createEmbeddingBands(vector);
51
+
52
+ const replace = db.transaction(() => {
53
+ db.prepare('DELETE FROM memory_embedding_bands WHERE memory_id = ?').run(memoryId);
54
+ if (!bands.length) return;
55
+ const insert = db.prepare(
56
+ `INSERT INTO memory_embedding_bands (
57
+ memory_id, user_id, agent_id, dimension, index_version, band_index, band_value, updated_at
58
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))`
59
+ );
60
+ for (const band of bands) {
61
+ insert.run(
62
+ memoryId,
63
+ userId,
64
+ agentId || '',
65
+ band.dimension,
66
+ INDEX_VERSION,
67
+ band.bandIndex,
68
+ band.bandValue,
69
+ );
70
+ }
71
+ });
72
+ replace();
73
+ return bands.length;
74
+ }
75
+
76
+ function buildBandProbes(vector) {
77
+ const probesByBand = [];
78
+ for (const band of createEmbeddingBands(vector)) {
79
+ const values = [band.bandValue];
80
+ for (let bitIndex = 0; bitIndex < BITS_PER_BAND; bitIndex += 1) {
81
+ values.push(band.bandValue ^ (1 << bitIndex));
82
+ for (let secondBit = bitIndex + 1; secondBit < BITS_PER_BAND; secondBit += 1) {
83
+ values.push(band.bandValue ^ (1 << bitIndex) ^ (1 << secondBit));
84
+ }
85
+ }
86
+ probesByBand.push({
87
+ bandIndex: band.bandIndex,
88
+ values,
89
+ });
90
+ }
91
+ return probesByBand;
92
+ }
93
+
94
+ function findEmbeddingCandidates(db, {
95
+ userId,
96
+ agentId,
97
+ embedding,
98
+ limit = DEFAULT_CANDIDATE_LIMIT,
99
+ }) {
100
+ const vector = typeof embedding === 'string'
101
+ ? deserializeEmbedding(embedding)
102
+ : embedding;
103
+ const probesByBand = buildBandProbes(vector);
104
+ if (!probesByBand.length) return [];
105
+
106
+ const matches = new Map();
107
+ for (const band of probesByBand) {
108
+ const placeholders = band.values.map(() => '?').join(', ');
109
+ const rows = db.prepare(
110
+ `SELECT memory_id
111
+ FROM memory_embedding_bands
112
+ WHERE user_id = ?
113
+ AND agent_id = ?
114
+ AND dimension = ?
115
+ AND index_version = ?
116
+ AND band_index = ?
117
+ AND band_value IN (${placeholders})`
118
+ ).all(
119
+ userId,
120
+ agentId || '',
121
+ vector.length,
122
+ INDEX_VERSION,
123
+ band.bandIndex,
124
+ ...band.values,
125
+ );
126
+ for (const row of rows) {
127
+ matches.set(row.memory_id, (matches.get(row.memory_id) || 0) + 1);
128
+ }
129
+ }
130
+
131
+ return [...matches.entries()]
132
+ .sort((left, right) => right[1] - left[1])
133
+ .slice(0, Math.max(1, Math.min(Number(limit) || DEFAULT_CANDIDATE_LIMIT, 2000)))
134
+ .map(([memoryId, bandMatches]) => ({
135
+ memory_id: memoryId,
136
+ band_matches: bandMatches,
137
+ }));
138
+ }
139
+
140
+ function backfillEmbeddingIndex(db, { limit = 500 } = {}) {
141
+ const rows = db.prepare(
142
+ `SELECT m.id, m.user_id, m.agent_id, m.embedding
143
+ FROM memories m
144
+ LEFT JOIN memory_embedding_bands idx
145
+ ON idx.memory_id = m.id AND idx.index_version = ?
146
+ WHERE m.archived = 0
147
+ AND m.embedding IS NOT NULL
148
+ AND idx.memory_id IS NULL
149
+ ORDER BY m.updated_at DESC
150
+ LIMIT ?`
151
+ ).all(
152
+ INDEX_VERSION,
153
+ Math.max(1, Math.min(Number(limit) || 500, 5000)),
154
+ );
155
+
156
+ for (const row of rows) {
157
+ replaceMemoryEmbeddingIndex(db, {
158
+ memoryId: row.id,
159
+ userId: row.user_id,
160
+ agentId: row.agent_id,
161
+ embedding: row.embedding,
162
+ });
163
+ }
164
+ return rows.length;
165
+ }
166
+
167
+ module.exports = {
168
+ BAND_COUNT,
169
+ BITS_PER_BAND,
170
+ INDEX_VERSION,
171
+ backfillEmbeddingIndex,
172
+ createEmbeddingBands,
173
+ findEmbeddingCandidates,
174
+ replaceMemoryEmbeddingIndex,
175
+ };
@@ -141,12 +141,31 @@ async function getOpenAIEmbedding(text) {
141
141
  * @returns {Float32Array|null}
142
142
  */
143
143
  async function getEmbedding(text, provider) {
144
+ const result = await getEmbeddingWithMetadata(text, provider);
145
+ return result?.vector || null;
146
+ }
147
+
148
+ async function getEmbeddingWithMetadata(text, provider) {
144
149
  if (!text || !text.trim()) return null;
145
150
  if (provider === 'google' && process.env.GOOGLE_AI_KEY) {
146
151
  const vec = await getGeminiEmbedding(text);
147
- if (vec) return vec;
152
+ if (vec) {
153
+ return {
154
+ vector: vec,
155
+ provider: 'google',
156
+ model: GOOGLE_MODEL,
157
+ dimensions: vec.length,
158
+ };
159
+ }
148
160
  }
149
- return getOpenAIEmbedding(text);
161
+ const vec = await getOpenAIEmbedding(text);
162
+ if (!vec) return null;
163
+ return {
164
+ vector: vec,
165
+ provider: 'openai',
166
+ model: OPENAI_MODEL,
167
+ dimensions: vec.length,
168
+ };
150
169
  }
151
170
 
152
171
  /**
@@ -203,6 +222,7 @@ function keywordSimilarity(query, text) {
203
222
 
204
223
  module.exports = {
205
224
  getEmbedding,
225
+ getEmbeddingWithMetadata,
206
226
  cosineSimilarity,
207
227
  serializeEmbedding,
208
228
  deserializeEmbedding,