dsh-cortex-memory 0.1.0 → 0.2.0-rc.11

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 (56) hide show
  1. package/LICENSE +3 -3
  2. package/README.md +85 -146
  3. package/bench/ab-engine.mjs +60 -0
  4. package/bench/ann-bench.mjs +78 -0
  5. package/bench/hnsw-bench.mjs +79 -0
  6. package/bench/metrics.js +8 -0
  7. package/bench/migration-bench.mjs +69 -0
  8. package/bench/run.js +5 -2
  9. package/bench/synthetic.js +51 -7
  10. package/bench/vector-ab.mjs +76 -0
  11. package/bin/cortex.mjs +2 -3
  12. package/client.js +269 -4
  13. package/cordis.patch.yml +1 -1
  14. package/docs/install-windows.md +115 -27
  15. package/docs/models.md +165 -30
  16. package/docs/multimodal.md +112 -0
  17. package/docs/scopes.md +142 -0
  18. package/examples/README.md +72 -0
  19. package/examples/setup-cortex-bge.ps1 +66 -0
  20. package/examples/uninstall-cortex.ps1 +91 -0
  21. package/index.js +15 -1
  22. package/package.json +6 -13
  23. package/src/adapters/markdown-import.js +20 -1
  24. package/src/adapters/markdown.js +12 -2
  25. package/src/adapters/shiori.js +1 -1
  26. package/src/api/routes.js +144 -4
  27. package/src/api/validate.js +26 -0
  28. package/src/core/attachment-reader.js +31 -0
  29. package/src/core/attachments.js +39 -0
  30. package/src/core/config.js +36 -1
  31. package/src/core/model-config.js +3 -1
  32. package/src/core/profile.js +32 -0
  33. package/src/engines/develop.js +1 -1
  34. package/src/engines/encode.js +68 -30
  35. package/src/engines/extract.js +13 -8
  36. package/src/engines/growth.js +1 -1
  37. package/src/engines/reflect.js +1 -1
  38. package/src/engines/retrieve.js +44 -13
  39. package/src/host/runtime.js +1 -1
  40. package/src/memory/query.js +1 -1
  41. package/src/memory/tokenizer.js +19 -4
  42. package/src/security/scope-grants.js +73 -0
  43. package/src/self/appraisal.js +3 -3
  44. package/src/self/emotion.js +1 -1
  45. package/src/self/identity.js +1 -1
  46. package/src/self/relationship.js +2 -2
  47. package/src/service/cortex.js +191 -3
  48. package/src/store/database.js +33 -1
  49. package/src/store/repositories/edges.js +48 -4
  50. package/src/store/repositories/traces.js +23 -12
  51. package/src/store/schema.sql +1 -0
  52. package/src/tools/definitions.js +62 -5
  53. package/src/vector/external-ann.js +37 -0
  54. package/src/vector/hnsw.js +246 -0
  55. package/src/vector/index.js +85 -2
  56. package/docs/compatibility.md +0 -39
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Small dependency-free HNSW index for Cortex.
3
+ *
4
+ * This is a candidate replacement for the single-layer NSW in ann.js. It is
5
+ * intentionally not wired into the production vector index until a benchmark
6
+ * shows acceptable recall. The implementation uses simple sorted frontiers
7
+ * (not a binary heap) because it is easier to audit; this is a benchmark
8
+ * candidate, not a final large-corpus engine.
9
+ */
10
+ function mulberry32(seed) {
11
+ let state = seed >>> 0;
12
+ return () => {
13
+ state = (state + 0x6d2b79f5) >>> 0;
14
+ let t = state;
15
+ t = Math.imul(t ^ (t >>> 15), t | 1);
16
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
17
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
18
+ };
19
+ }
20
+
21
+ function cosine(a, b) {
22
+ let dot = 0;
23
+ let na = 0;
24
+ let nb = 0;
25
+ const n = Math.min(a.length, b.length);
26
+ for (let i = 0; i < n; i += 1) {
27
+ dot += a[i] * b[i];
28
+ na += a[i] * a[i];
29
+ nb += b[i] * b[i];
30
+ }
31
+ if (!na || !nb) return 0;
32
+ return dot / Math.sqrt(na * nb);
33
+ }
34
+
35
+ function distance(a, b) {
36
+ return 1 - cosine(a, b);
37
+ }
38
+
39
+ function createHeap(compare) {
40
+ const items = [];
41
+ const swap = (a, b) => { const value = items[a]; items[a] = items[b]; items[b] = value; };
42
+ function bubbleUp(index) {
43
+ while (index > 0) {
44
+ const parent = (index - 1) >> 1;
45
+ if (compare(items[index], items[parent]) >= 0) break;
46
+ swap(index, parent);
47
+ index = parent;
48
+ }
49
+ }
50
+ function bubbleDown(index) {
51
+ for (;;) {
52
+ const left = index * 2 + 1;
53
+ const right = left + 1;
54
+ let best = index;
55
+ if (left < items.length && compare(items[left], items[best]) < 0) best = left;
56
+ if (right < items.length && compare(items[right], items[best]) < 0) best = right;
57
+ if (best === index) break;
58
+ swap(index, best);
59
+ index = best;
60
+ }
61
+ }
62
+ return {
63
+ push(item) { items.push(item); bubbleUp(items.length - 1); },
64
+ pop() {
65
+ if (!items.length) return undefined;
66
+ const first = items[0];
67
+ const last = items.pop();
68
+ if (items.length) { items[0] = last; bubbleDown(0); }
69
+ return first;
70
+ },
71
+ peek() { return items[0]; },
72
+ get size() { return items.length; },
73
+ toArray() { return items.slice(); },
74
+ };
75
+ }
76
+
77
+ export function createHnswIndex({ M = 16, efConstruction = 100, efSearch = 64, seed = 42, maxLevel = 16 } = {}) {
78
+ const maxNeighbors = Math.max(2, Math.min(64, Number(M) || 16));
79
+ const constructionWidth = Math.max(maxNeighbors, Number(efConstruction) || 100);
80
+ const searchWidth = Math.max(10, Number(efSearch) || 64);
81
+ const levelCap = Math.max(1, Math.min(32, Number(maxLevel) || 16));
82
+ const levelNormalizer = 1 / Math.log(Math.max(2, maxNeighbors));
83
+ const random = mulberry32(seed);
84
+ const nodes = new Map();
85
+ let entry = null;
86
+ let topLevel = -1;
87
+
88
+ function randomLevel() {
89
+ const value = -Math.log(Math.max(1e-12, random())) * levelNormalizer;
90
+ return Math.min(levelCap, Math.floor(value));
91
+ }
92
+
93
+ function nearestInLayer(query, startId, layer) {
94
+ let current = nodes.get(startId);
95
+ if (!current) return null;
96
+ let currentDistance = distance(query, current.vector);
97
+ let improved = true;
98
+ while (improved) {
99
+ improved = false;
100
+ for (const neighborId of current.neighbors[layer] ?? []) {
101
+ const neighbor = nodes.get(neighborId);
102
+ if (!neighbor) continue;
103
+ const candidateDistance = distance(query, neighbor.vector);
104
+ if (candidateDistance < currentDistance) {
105
+ current = neighbor;
106
+ currentDistance = candidateDistance;
107
+ improved = true;
108
+ }
109
+ }
110
+ }
111
+ return { id: current.id, d: currentDistance };
112
+ }
113
+
114
+ function searchLayer(query, entryPoints, width, layer) {
115
+ const visited = new Set();
116
+ const candidates = createHeap((a, b) => a.d - b.d);
117
+ const results = createHeap((a, b) => b.d - a.d);
118
+ for (const id of entryPoints) {
119
+ if (visited.has(id)) continue;
120
+ const node = nodes.get(id);
121
+ if (!node) continue;
122
+ const d = distance(query, node.vector);
123
+ visited.add(id);
124
+ candidates.push({ id, d });
125
+ results.push({ id, d });
126
+ }
127
+ while (candidates.size) {
128
+ const current = candidates.pop();
129
+ const worst = results.size >= width ? results.peek().d : Infinity;
130
+ if (current.d > worst && results.size >= width) break;
131
+ const node = nodes.get(current.id);
132
+ if (!node) continue;
133
+ for (const neighborId of node.neighbors[layer] ?? []) {
134
+ if (visited.has(neighborId)) continue;
135
+ visited.add(neighborId);
136
+ const neighbor = nodes.get(neighborId);
137
+ if (!neighbor) continue;
138
+ const d = distance(query, neighbor.vector);
139
+ if (results.size >= width && d >= results.peek().d) continue;
140
+ candidates.push({ id: neighborId, d });
141
+ results.push({ id: neighborId, d });
142
+ if (results.size > width) results.pop();
143
+ }
144
+ }
145
+ return results.toArray().sort((a, b) => a.d - b.d);
146
+ }
147
+
148
+ function connect(node, candidates, layer) {
149
+ const selected = candidates
150
+ .filter((item) => item.id !== node.id && nodes.has(item.id))
151
+ .sort((a, b) => a.d - b.d)
152
+ .slice(0, maxNeighbors);
153
+ node.neighbors[layer] = selected.map((item) => item.id);
154
+ for (const candidate of selected) {
155
+ const other = nodes.get(candidate.id);
156
+ if (!other) continue;
157
+ const neighbors = other.neighbors[layer] ?? [];
158
+ if (!neighbors.includes(node.id)) neighbors.push(node.id);
159
+ if (neighbors.length > maxNeighbors) {
160
+ neighbors.sort((a, b) => distance(other.vector, nodes.get(a)?.vector ?? []) - distance(other.vector, nodes.get(b)?.vector ?? []));
161
+ neighbors.length = maxNeighbors;
162
+ }
163
+ other.neighbors[layer] = neighbors;
164
+ }
165
+ }
166
+
167
+ function upsert(id, vector) {
168
+ if (!id || !Array.isArray(vector) || !vector.length) throw new Error('hnsw upsert needs id and vector');
169
+ const existing = nodes.get(id);
170
+ if (existing) {
171
+ existing.vector = vector.slice();
172
+ return existing;
173
+ }
174
+ const node = { id, vector: vector.slice(), level: randomLevel(), neighbors: [] };
175
+ nodes.set(id, node);
176
+ if (!entry) {
177
+ entry = id;
178
+ topLevel = node.level;
179
+ return node;
180
+ }
181
+ let current = entry;
182
+ for (let layer = topLevel; layer > node.level; layer -= 1) {
183
+ const nearest = nearestInLayer(node.vector, current, layer);
184
+ if (nearest) current = nearest.id;
185
+ }
186
+ for (let layer = Math.min(node.level, topLevel); layer >= 0; layer -= 1) {
187
+ const candidates = searchLayer(node.vector, [current], constructionWidth, layer);
188
+ connect(node, candidates, layer);
189
+ if (candidates.length) current = candidates[0].id;
190
+ }
191
+ if (node.level > topLevel) {
192
+ entry = id;
193
+ topLevel = node.level;
194
+ }
195
+ return node;
196
+ }
197
+
198
+ function search(query, k = 10, { ef = searchWidth } = {}) {
199
+ if (!entry || !nodes.size) return [];
200
+ const width = Math.max(Math.max(1, Number(k) || 10), Number(ef) || searchWidth);
201
+ let current = entry;
202
+ for (let layer = topLevel; layer > 0; layer -= 1) {
203
+ const nearest = nearestInLayer(query, current, layer);
204
+ if (nearest) current = nearest.id;
205
+ }
206
+ const ranked = searchLayer(query, [current], width, 0);
207
+ return ranked.slice(0, Math.max(1, Number(k) || 10)).map((item) => ({ id: item.id, score: 1 - item.d }));
208
+ }
209
+
210
+ function remove(id) {
211
+ const node = nodes.get(id);
212
+ if (!node) return false;
213
+ for (const other of nodes.values()) {
214
+ for (const neighbors of other.neighbors) {
215
+ const index = neighbors.indexOf(id);
216
+ if (index >= 0) neighbors.splice(index, 1);
217
+ }
218
+ }
219
+ nodes.delete(id);
220
+ if (entry === id) {
221
+ const first = nodes.values().next().value;
222
+ entry = first?.id ?? null;
223
+ topLevel = -1;
224
+ for (const other of nodes.values()) topLevel = Math.max(topLevel, other.level);
225
+ }
226
+ return true;
227
+ }
228
+
229
+ function rebuild(entries = []) {
230
+ nodes.clear();
231
+ entry = null;
232
+ topLevel = -1;
233
+ for (const item of entries) upsert(item.id, item.vector);
234
+ return nodes.size;
235
+ }
236
+
237
+ return {
238
+ upsert,
239
+ search,
240
+ remove,
241
+ rebuild,
242
+ size: () => nodes.size,
243
+ entry: () => entry,
244
+ config: () => ({ M: maxNeighbors, efConstruction: constructionWidth, efSearch: searchWidth, seed }),
245
+ };
246
+ }
@@ -1,6 +1,9 @@
1
1
  import { hashText } from '../core/ids.js';
2
2
  import { nowIso } from '../core/time.js';
3
3
  import { cosine } from './embedder.js';
4
+ import { createAnnIndex } from './ann.js';
5
+ import { createHnswIndex } from './hnsw.js';
6
+ import { createExternalAnnClient } from './external-ann.js';
4
7
  import { CortexError, ERROR_CODES } from '../core/errors.js';
5
8
 
6
9
  function toBlob(vector) {
@@ -18,11 +21,37 @@ function fromBlob(blob) {
18
21
  * personal memories is small (thousands, not billions), and a full scan keeps
19
22
  * the code auditable. A real ANN backend can replace it behind this interface.
20
23
  */
21
- export function createVectorIndex({ db, embedder, model = null, dim = null } = {}) {
24
+ export function createVectorIndex({
25
+ db,
26
+ embedder,
27
+ model = null,
28
+ dim = null,
29
+ annEnabled = false,
30
+ annMin = 500,
31
+ annEngine = 'hnsw',
32
+ annOptions = {},
33
+ externalAnnEnabled = false,
34
+ externalAnnOptions = {},
35
+ } = {}) {
22
36
  if (!db || !embedder) throw new Error('vector index needs db and embedder');
23
37
  const effectiveModel = model ?? embedder.model ?? 'unknown';
24
38
  const effectiveDim = Number(dim ?? embedder.dim ?? 0);
25
39
  if (!effectiveDim) throw new CortexError(ERROR_CODES.INVALID_INPUT, 'embedder dim is required');
40
+ const annMinCount = Math.max(1, Math.min(100000, Number(annMin) || 500));
41
+ const ann = annEnabled
42
+ ? (annEngine === 'nsw' ? createAnnIndex(annOptions) : createHnswIndex(annOptions))
43
+ : null;
44
+ const externalAnn = externalAnnEnabled && externalAnnOptions?.endpoint
45
+ ? createExternalAnnClient(externalAnnOptions)
46
+ : null;
47
+ let annBuilt = false;
48
+
49
+ function ensureAnn() {
50
+ if (!ann || annBuilt) return;
51
+ const rows = db.prepare('SELECT trace_id, vector FROM embeddings WHERE model = ? AND dim = ?').all(effectiveModel, effectiveDim);
52
+ ann.rebuild(rows.map((row) => ({ id: row.trace_id, vector: Array.from(fromBlob(row.vector)) })));
53
+ annBuilt = true;
54
+ }
26
55
 
27
56
  function upsert(traceId, text) {
28
57
  if (!traceId) return null;
@@ -46,6 +75,10 @@ export function createVectorIndex({ db, embedder, model = null, dim = null } = {
46
75
  model = excluded.model, dim = excluded.dim, vector = excluded.vector,
47
76
  hash = excluded.hash, updated_at = excluded.updated_at
48
77
  `).run(traceId, effectiveModel, effectiveDim, toBlob(vector), hash, nowIso());
78
+ if (ann) {
79
+ ann.upsert(traceId, Array.from(vector));
80
+ annBuilt = true;
81
+ }
49
82
  return { traceId, model: effectiveModel, dim: effectiveDim, hash };
50
83
  }
51
84
 
@@ -57,16 +90,45 @@ export function createVectorIndex({ db, embedder, model = null, dim = null } = {
57
90
  }
58
91
 
59
92
  function remove(traceId) {
93
+ if (ann) ann.remove(traceId);
60
94
  return db.prepare('DELETE FROM embeddings WHERE trace_id = ?').run(traceId).changes > 0;
61
95
  }
62
96
 
63
97
  function status() {
64
98
  const row = db.prepare('SELECT COUNT(*) AS c FROM embeddings').get();
65
- return { model: effectiveModel, dim: effectiveDim, count: Number(row?.c ?? 0), async: embedder.kind === 'openai-embedding' || embedder.async === true };
99
+ return {
100
+ model: effectiveModel,
101
+ dim: effectiveDim,
102
+ count: Number(row?.c ?? 0),
103
+ async: embedder.kind === 'openai-embedding' || embedder.async === true,
104
+ ann: ann ? { enabled: true, engine: annEngine === 'nsw' ? 'nsw' : 'hnsw', size: ann.size(), min: annMinCount, built: annBuilt } : { enabled: false },
105
+ externalAnn: externalAnn ? { enabled: true, endpoint: externalAnn.endpoint } : { enabled: false },
106
+ };
66
107
  }
67
108
 
68
109
  function searchWithVector(queryVector, { scopeId = 'global', limit = 20, minScore = 0.05, includeArchived = false } = {}) {
69
110
  const stateClause = includeArchived ? "t.state IN ('active','archived')" : "t.state = 'active'";
111
+ if (ann) {
112
+ ensureAnn();
113
+ if (ann.size() >= annMinCount) {
114
+ const approx = ann.search(Array.from(queryVector), Math.max(limit * 3, 20));
115
+ const one = db.prepare(`
116
+ SELECT e.vector FROM embeddings e JOIN traces t ON t.id = e.trace_id
117
+ WHERE e.trace_id = ? AND t.scope_id = ? AND ${stateClause} AND e.model = ? AND e.dim = ?
118
+ `);
119
+ const hits = [];
120
+ for (const candidate of approx) {
121
+ const row = one.get(candidate.id, scopeId, effectiveModel, effectiveDim);
122
+ if (!row) continue;
123
+ const score = cosine(queryVector, fromBlob(row.vector));
124
+ if (score < minScore) continue;
125
+ hits.push({ traceId: candidate.id, score: Math.round(score * 10000) / 10000 });
126
+ }
127
+ if (hits.length >= Math.min(limit, 5)) {
128
+ return hits.sort((a, b) => b.score - a.score).slice(0, Math.max(1, Math.min(200, limit)));
129
+ }
130
+ }
131
+ }
70
132
  const rows = db.prepare(`
71
133
  SELECT e.trace_id, e.vector, e.model, e.dim, t.importance, t.confidence
72
134
  FROM embeddings e JOIN traces t ON t.id = e.trace_id
@@ -93,10 +155,30 @@ export function createVectorIndex({ db, embedder, model = null, dim = null } = {
93
155
  if (!text) return [];
94
156
  const queryVector = await embedder.embed(text);
95
157
  if (!queryVector) return [];
158
+ if (externalAnn) {
159
+ try {
160
+ const approximate = await externalAnn.search(Array.from(queryVector), Math.max(20, (Number(options.limit) || 20) * 3));
161
+ const stateClause = options.includeArchived ? "t.state IN ('active','archived')" : "t.state = 'active'";
162
+ const one = db.prepare(`
163
+ SELECT e.trace_id, e.vector FROM embeddings e JOIN traces t ON t.id = e.trace_id
164
+ WHERE e.trace_id = ? AND t.scope_id = ? AND ${stateClause} AND e.model = ? AND e.dim = ?
165
+ `);
166
+ const hits = [];
167
+ for (const candidate of approximate) {
168
+ const row = one.get(candidate.id, options.scopeId ?? 'global', effectiveModel, effectiveDim);
169
+ if (!row) continue;
170
+ hits.push({ traceId: candidate.id, score: Math.round(Number(candidate.score) * 10000) / 10000 });
171
+ }
172
+ if (hits.length >= Math.min(Number(options.limit) || 20, 5)) return hits;
173
+ } catch {
174
+ /* fall through to exact scan */
175
+ }
176
+ }
96
177
  return searchWithVector(queryVector, options);
97
178
  }
98
179
 
99
180
  function rebuild({ scopeId = 'global', limit = 5000 } = {}) {
181
+ if (ann) { ann.rebuild([]); annBuilt = true; }
100
182
  const traces = db.prepare(`
101
183
  SELECT id, title, content, summary FROM traces
102
184
  WHERE scope_id = ? AND state = 'active' ORDER BY recorded_at DESC LIMIT ?
@@ -106,6 +188,7 @@ export function createVectorIndex({ db, embedder, model = null, dim = null } = {
106
188
  }
107
189
 
108
190
  async function rebuildAsync({ scopeId = 'global', limit = 5000 } = {}) {
191
+ if (ann) { ann.rebuild([]); annBuilt = true; }
109
192
  const traces = db.prepare(`
110
193
  SELECT id, title, content, summary FROM traces
111
194
  WHERE scope_id = ? AND state = 'active' ORDER BY recorded_at DESC LIMIT ?
@@ -1,39 +0,0 @@
1
- # Windows / DSH compatibility
2
-
3
- ## Tested here
4
-
5
- | Item | Value |
6
- |---|---|
7
- | OS | Windows 11 |
8
- | DSH | 0.2.2-rc.1 |
9
- | Node | 26.13.0 |
10
- | Public package | `dsh-cortex-memory` 0.1.0 |
11
- | Data root | fresh `~/.dsh/cortex` |
12
- | Native dependencies | none required |
13
- | Tests | 373 passing |
14
- | Package smoke | packed tgz extracted and started |
15
-
16
- ## What was actually checked
17
-
18
- 1. `npm pack` produced a 99-file tgz with `dsh.bundle.patch`.
19
- 2. The tgz was extracted; `createCortex` wrote, searched and health-checked.
20
- 3. The public source tests passed: 373 / 0.
21
- 4. The client bundle is a DSH web client plugin; the host row is a bundle patch.
22
- 5. No absolute personal paths or credentials are required.
23
-
24
- ## Hard requirements
25
-
26
- - **Node.js >= 24** — the engine uses `node:sqlite`.
27
- - **DSH >=0.2.1-rc.2 <0.4.0**.
28
- - A profile may run only one plugin whose host id is `cortex`.
29
-
30
- ## Not yet verified
31
-
32
- - Installation into a brand-new DSH profile from the exact release tgz.
33
- - A click-through browser E2E of every panel control.
34
- - Node 20/22 users.
35
- - Very old DSH releases.
36
- - Running alongside the private development build that uses the same `cortex` id.
37
-
38
- Do not claim “works for every Windows/DSH user” until a clean-profile
39
- installation is done on a machine without the development build.