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

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 +286 -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 +5 -12
  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 +148 -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 +64 -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
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
- MIT License
1
+ MIT License
2
2
 
3
- Copyright (c) 2026 dsh-cortex-memory contributors
3
+ Copyright (c) 2026 本鲸 / 凌寒
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
18
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,154 +1,93 @@
1
- # Cortex Memory for DeepSeek Harness
1
+ # dsh-cortex
2
2
 
3
- A layered, temporal, self-developing memory engine for DeepSeek Harness (DSH).
4
- It is designed for long-lived personal agents: memory that can be recalled,
5
- audited, consolidated and changed **without silently rewriting identity**.
3
+ > 鏈哺鐨勬柊澶ц剳銆傚垎灞傘€佹椂搴忋€佸彲鑷垜宸╁浐鐨勮蹇嗗紩鎿庛€?> 鐘舵€侊細**鍙墦鍖呫€佸彲瀹夎**锛涙湰鏈哄紑鍙戝壇鏈粛鏈畨瑁咃紝涔熸病鏈夋帴 profile銆?> Windows 鐩存帴瀹夎/鍥炴粴瑙?[docs/install-windows.md](docs/install-windows.md)銆?
4
+ `dsh-cortex` is a local-first memory engine for DeepSeek Harness. It is designed
5
+ as a successor to the `dsh-shiori` + `dsh-shiori-companion` pair, not a patch on
6
+ top of them.
6
7
 
7
- > Current public version: **0.1.0**
8
- > Package: `dsh-cortex-memory` (the npm name `dsh-cortex` is already taken by another project)
9
- > License: MIT
8
+ ## Why another memory plugin
10
9
 
11
- ## Why
10
+ The shiori stack is good at four things: a stable persona, a compact core,
11
+ triggered spark cards and on-demand topics. It is weaker at temporal facts,
12
+ episodic provenance, procedural learning, consolidation and calibrated
13
+ forgetting. `dsh-cortex` keeps the good parts and adds the missing layers:
12
14
 
13
- Most memory plugins stop at "store and recall". Cortex adds four things that are
14
- hard to bolt on later:
15
-
16
- 1. **Layered memory** — L0 identity, L1 working, L1.5 playbook, L2 episodic,
17
- L3 semantic/temporal graph, L4 procedural, L5 reflection, archive.
18
- 2. **Temporal truth** — event ledger, bitemporal facts, `strict / historical /
19
- belief / associative` queries, DSR forgetting.
20
- 3. **Gated growth** — consolidation, reflection, playbook candidates, policy
21
- proposals; identity and core changes require approval and can be rolled back.
22
- 4. **Guardian** — health audit, budgets, circuit breaker, backup/validate/restore,
23
- privacy cascade, secret/injection scanning.
24
-
25
- ## Install
26
-
27
- Requirements:
28
-
29
- - Node.js **>= 24** (uses the built-in `node:sqlite`)
30
- - DSH `>=0.2.1-rc.2 <0.4.0`
31
-
32
- ### From a GitHub release tgz
33
-
34
- ```powershell
35
- dsh plugin --profile desktop add "C:\path\to\dsh-cortex-memory-0.1.0.tgz"
36
- ```
37
-
38
- ### From npm (after publishing)
39
-
40
- ```powershell
41
- dsh plugin --profile desktop add dsh-cortex-memory
42
- ```
43
-
44
- The plugin owns `~/.dsh/cortex` and does not read or write another memory
45
- plugin's data.
46
-
47
- ## Models
48
-
49
- Cortex works with **no model** by default:
50
-
51
- | Mode | What it uses | Key |
15
+ | Layer | What it holds | Lifetime |
52
16
  |---|---|---|
53
- | `hash` | local lexical fallback | no |
54
- | `local` | OpenAI-compatible local service, default Ollama `bge-m3` | no |
55
- | `remote` | OpenAI-compatible `/embeddings` | **required** |
56
-
57
- For local semantic search:
58
-
59
- ```powershell
60
- ollama pull bge-m3
61
- ```
62
-
63
- Then in **Settings → Embedding / rerank models** choose `local`, keep
64
- `http://127.0.0.1:11434/v1`, model `bge-m3`, dimension `1024`.
65
-
66
- Remote mode refuses to save without an API key. Keys are stored in the local
67
- data directory and are never returned by the settings API.
68
-
69
- Optional rerank uses an OpenAI-compatible `/rerank` endpoint; if it is off or
70
- fails, fusion order is kept.
71
-
72
- ## UI
73
-
74
- The client panel includes:
75
-
76
- - Self / working memory / search / timeline / graph
77
- - Emotion and life views
78
- - Guardian, evolution, audit, retention
79
- - **Playbook** with candidate review before activation
80
- - Settings: feature switches, embedding/rerank configuration, permission
81
- overrides and rollback
82
-
83
- All settings changes that affect runtime take effect after a plugin reload or
84
- DSH restart.
85
-
86
- ## CLI
87
-
88
- ```powershell
89
- node bin/cortex.mjs --data C:\path\to\data health
90
- node bin/cortex.mjs --data C:\path\to\data search --query "米食"
91
- node bin/cortex.mjs --data C:\path\to\data export-md --dir C:\path\to\mirror
92
- ```
93
-
94
- ## Compatibility
95
-
96
- Tested in our environment:
97
-
98
- - Windows 11
99
- - DSH 0.2.2-rc.1
100
- - Node.js 26.13.0
101
- - Fresh `~/.dsh/cortex` data root
102
- - No native runtime dependencies
103
-
104
- Requirements:
105
-
106
- - **Node.js >= 24** (the engine uses `node:sqlite`)
107
- - DSH `>=0.2.1-rc.2 <0.4.0`
108
- - One profile should run only one `cortex` host entry
109
-
110
- It is **not** guaranteed to work on Node 20/22, older DSH releases, or a
111
- profile that already has another plugin using the same `cortex` id. This is a
112
- real compatibility boundary, not a marketing detail.
113
-
114
- ## Testing
115
-
116
- ```powershell
117
- npm test
118
- ```
119
-
120
- The repository includes unit and integration tests for storage, retrieval,
121
- temporal facts, playbook, consolidation, guardian, API, client contract,
122
- installation packaging and edge cases. Public copy test result: **373 passing**.
123
-
124
- A package-level smoke test is also run from the packed tgz before release.
125
-
126
-
127
- ## Architecture
128
-
129
- ```text
130
- Host events -> capture -> encode -> event ledger + trace + facts + FTS
131
- |
132
- prompt <- budgets <- tiered recall <- hybrid retrieval (FTS + graph + vector)
133
- |
134
- reflection / consolidation / playbook / growth
135
- |
136
- identity versions / policies / guardian / audit
137
- ```
138
-
139
- See [docs/install-windows.md](docs/install-windows.md),
140
- [docs/models.md](docs/models.md) and
141
- [docs/compatibility.md](docs/compatibility.md).
142
-
143
- ## Data and privacy
144
-
145
- - Default data root: `~/.dsh/cortex`
146
- - No telemetry and no network calls by default
147
- - `hash` mode is fully offline
148
- - `local` mode talks only to the configured local service
149
- - `remote` mode sends text to the configured embedding endpoint
150
- - Do not put API keys in prompts or issue reports
17
+ | L0 identity | self-model, values, boundaries, stable traits | long, explicit promotion |
18
+ | L1 working | current goals, focus, pinned context, recent turn buffer | minutes鈥揾ours |
19
+ | L2 episodic | what happened, when, with whom, provenance, emotion | days鈥搚ears |
20
+ | L3 semantic | entities, facts with validity windows, preferences | until invalidated |
21
+ | L4 procedural | skills, workflows, preconditions, success/failure stats | until superseded |
22
+ | L5 reflective | episode summaries, insights, narratives | derived, regenerable |
23
+ | archive | cold but searchable, wake-word recall | until privacy deletion |
24
+
25
+ ## Engines
26
+
27
+ - **event log** 鈥?append-only, hash-chained, `event_id` / `operation_id`; the
28
+ only source of truth.
29
+ - **write pipeline** 鈥?scan secrets/injection 鈫?classify 鈫?dedupe 鈫? conflict-resolve 鈫?link 鈫?persist; async jobs with idempotent operation IDs.
30
+ - **temporal graph** 鈥?entities, facts and edges with `valid_from` /
31
+ `valid_to`; old facts are invalidated, never silently overwritten.
32
+ - **hybrid retrieval** 鈥?FTS5 BM25 + optional vector + graph activation +
33
+ recency / importance / feedback, fused with RRF, diversified with a greedy
34
+ MMR-style marginal-gain pass; query filters (`kind:`, `tag:`, `after:`,
35
+ `importance:>`, 鈥?.
36
+ - **DSR forgetting** 鈥?FSRS-inspired stability / difficulty / retrievability;
37
+ archive instead of delete, revive on successful recall.
38
+ - **compression / retention** 鈥?archive only when a derived model covers the
39
+ trace, or when a reviewed retention plan says it is old/low-value; important
40
+ memories are never auto-archived.
41
+ - **reflection** 鈥?episode segmentation, summaries, narrative threads,
42
+ duplicate/contradiction detection, optional LLM dream/summary.
43
+ - **self model** 鈥?drives, mood, attention, appraisal, relationships,
44
+ initiatives, 24-category emotion/affect, identity versions.
45
+ - **development** 鈥?maturity/stage from memory depth, feedback, calibration,
46
+ skills, narrative and relationships.
47
+ - **evolution** 鈥?policy proposals 鈫?replay evaluation 鈫?explicit approval 鈫? rollback; identity changes are separate and approval-only.
48
+ - **guardian** 鈥?invariants, circuit breaker, budget, backup/validate/offline
49
+ restore, FTS repair, privacy cascade, secret/injection scanner.
50
+ - **host runtime** 鈥?`session/event` turn capture + `user/message` /
51
+ `agent/pre-step` recall prefetch into the systemPrompt section.
52
+ - **API/UI/CLI** 鈥?loopback-only HTTP API, sidebar panel, `bin/cortex.mjs`.
53
+
54
+ ## Status
55
+
56
+ This is a source-only development tree. `npm test` uses `node:test` and the
57
+ built-in `node:sqlite`; no native dependency and no network are required for
58
+ the core suite. Profiles install a built tgz explicitly; this tree is not
59
+ auto-linked into them.
60
+
61
+ - **395** tests passing (unit + integration).
62
+ - 21 model-facing tools.
63
+ - **`cortex_as_of`**锛歴trict / historical / belief / associative銆?- **`cortex_playbook`**锛歀1.5 宸ヤ綔鍑嗗垯锛屽甫 helpful/harmful銆乿alidTo銆佽繃鏈熶笉娉ㄥ叆銆?- **`cortex_consolidate`**锛歀LM 鍐崇瓥娓呭崟 + 鏍¢獙 + `dream_runs` 鍙洖鏀捐褰曪紱鏃?LLM 鏃惰褰?skipped 骞堕浂鏀瑰姩銆?- **`cortex_search({tiered:true})`**锛歱erm/hot/cold 鍒嗗眰棰勭畻锛宑old 鍙粰绱㈠紩銆?- **寮傛娣峰悎妫€绱?*锛歚GET /search?async=true` / QA 璧?`searchAsync`锛涜繙绋?embedder 鍙储寮曪紝涓嶉樆濉炲啓銆?- 5k synthetic benchmark (needle, vector off): ~2.6-2.8k writes/s, p50 ~20-35ms; 100k migration ~2.3s. These are local numbers, not an official score.
64
+ - Source/tests/docs 鈮?33k lines (includes `docs/book/`); source-only, not installed here.
65
+ - Windows tgz: `dist/dsh-cortex-0.2.0-rc.11.tgz` (zero runtime deps; Node >= 24).
66
+
67
+ ## Design docs
68
+
69
+ - [research notes](docs/research/README.md)
70
+ - [architecture](docs/architecture.md)
71
+ - [Anima: from memory engine to a growing individual](docs/anima.md)
72
+ - [modules and invariants](docs/modules.md)
73
+ - [operations / CLI / backup](docs/operations.md)
74
+ - [security and privacy](docs/security.md)
75
+ - [scope isolation and read-only sharing](docs/scopes.md)
76
+ - [multimodal attachment metadata](docs/multimodal.md)
77
+ - [benchmark](docs/benchmark.md)
78
+ - [OpenAPI 3.1](docs/openapi.yaml)
79
+ - [API client](src/api/client.js)
80
+ - [UI guide](docs/ui.md)
81
+ - [design book](docs/book/README.md)
82
+ - [four-plugin comparison](docs/comparison-2026-11-11.md)
83
+ - [Windows install and rollback](docs/install-windows.md)
84
+ - [optional models](docs/models.md)
85
+ - [shiori companion review](docs/companion-feature-review.md)
86
+ - [ecosystem comparison 2026-11-11](docs/ecosystem-comparison-2026-11-11.md)
87
+ - [roadmap](docs/roadmap.md)
151
88
 
152
89
  ## License
153
90
 
154
- MIT. See [LICENSE](LICENSE).
91
+ MIT. External ideas are cited in the research notes; no third-party source
92
+ code is copied into this package.
93
+
@@ -0,0 +1,60 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { generateSynthetic } from './synthetic.js';
6
+ import { evaluateQueries, percentile } from './metrics.js';
7
+
8
+ const engineRoot = process.argv[2];
9
+ const task = process.argv[3] ?? 'paraphrase';
10
+ const count = Number(process.argv[4] ?? 5000);
11
+ const queryCount = Number(process.argv[5] ?? 100);
12
+
13
+ if (!engineRoot) {
14
+ console.error('usage: node bench/ab-engine.mjs <engine-root> [needle|theme|paraphrase] [count] [queries]');
15
+ process.exit(2);
16
+ }
17
+
18
+ const { createCortex } = await import(pathToFileURL(path.join(engineRoot, 'src', 'service', 'cortex.js')).href);
19
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'cortex-ab-'));
20
+ const cortex = createCortex({ config: { dataRoot: dir, dbFile: path.join(dir, 'cortex.sqlite'), contextBudget: 2200 } });
21
+ try {
22
+ const dataset = generateSynthetic({ count, queries: queryCount, seed: 42, task });
23
+ const written = cortex.writeMany(dataset.traces.map((trace) => ({
24
+ text: trace.text,
25
+ title: trace.title,
26
+ tags: trace.tags,
27
+ importance: trace.importance,
28
+ explicit: true,
29
+ operationId: `ab:${trace.id}`,
30
+ now: Date.parse(trace.recordedAt),
31
+ })), { chunkSize: 250 });
32
+ const idMap = new Map();
33
+ for (let i = 0; i < written.length; i += 1) {
34
+ if (written[i].trace) idMap.set(dataset.traces[i].id, written[i].trace.id);
35
+ }
36
+ const results = [];
37
+ const latencies = [];
38
+ for (const query of dataset.queries) {
39
+ const start = performance.now();
40
+ const result = cortex.search(query.query, { limit: 10, budget: 2200 });
41
+ latencies.push(performance.now() - start);
42
+ results.push({
43
+ items: result.items,
44
+ relevant: query.relevant.map((id) => idMap.get(id)).filter(Boolean),
45
+ });
46
+ }
47
+ const metrics = evaluateQueries(results, { k: 10 });
48
+ console.log(JSON.stringify({
49
+ engine: engineRoot,
50
+ task,
51
+ count,
52
+ queries: dataset.queries.length,
53
+ p50Ms: Math.round((percentile(latencies, 50) ?? 0) * 100) / 100,
54
+ p95Ms: Math.round((percentile(latencies, 95) ?? 0) * 100) / 100,
55
+ ...metrics,
56
+ }, null, 2));
57
+ } finally {
58
+ cortex.close();
59
+ rmSync(dir, { recursive: true, force: true });
60
+ }
@@ -0,0 +1,78 @@
1
+ import { createAnnIndex } from '../src/vector/ann.js';
2
+
3
+ function randomFactory(seed) {
4
+ let state = seed >>> 0;
5
+ return () => {
6
+ state = (state * 1664525 + 1013904223) >>> 0;
7
+ return state / 4294967296;
8
+ };
9
+ }
10
+
11
+ function makeVector(random, dim = 64) {
12
+ const value = Array.from({ length: dim }, () => random() * 2 - 1);
13
+ const norm = Math.sqrt(value.reduce((sum, item) => sum + item * item, 0)) || 1;
14
+ return value.map((item) => item / norm);
15
+ }
16
+
17
+ function cosine(a, b) {
18
+ let dot = 0;
19
+ for (let i = 0; i < a.length; i += 1) dot += a[i] * b[i];
20
+ return dot;
21
+ }
22
+
23
+ function percentile(values, p) {
24
+ const sorted = [...values].sort((a, b) => a - b);
25
+ if (!sorted.length) return null;
26
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))];
27
+ }
28
+
29
+ const count = Number(process.argv[2] ?? 5000);
30
+ const queries = Number(process.argv[3] ?? 100);
31
+ const ef = Number(process.argv[4] ?? 120);
32
+ const random = randomFactory(20261111);
33
+ const items = Array.from({ length: count }, (_, index) => ({ id: `a${index}`, vector: makeVector(random) }));
34
+ const queryVectors = Array.from({ length: queries }, () => makeVector(random));
35
+ const ann = createAnnIndex({ M: 24, efConstruction: 200, efSearch: ef, seed: 20261111 });
36
+ const buildStart = performance.now();
37
+ ann.rebuild(items);
38
+ const buildMs = performance.now() - buildStart;
39
+
40
+ const exactTimes = [];
41
+ const annTimes = [];
42
+ const exactResults = [];
43
+ const annResults = [];
44
+ for (const query of queryVectors) {
45
+ const exactStart = performance.now();
46
+ const exact = items
47
+ .map((item) => ({ id: item.id, score: cosine(item.vector, query) }))
48
+ .sort((a, b) => b.score - a.score)
49
+ .slice(0, 10);
50
+ exactTimes.push(performance.now() - exactStart);
51
+ exactResults.push(exact);
52
+
53
+ const annStart = performance.now();
54
+ const approx = ann.search(query, 10, { ef }).slice(0, 10);
55
+ annTimes.push(performance.now() - annStart);
56
+ annResults.push(approx);
57
+ }
58
+
59
+ let hits = 0;
60
+ let expectedTotal = 0;
61
+ for (let index = 0; index < exactResults.length; index += 1) {
62
+ const expected = new Set(exactResults[index].map((item) => item.id));
63
+ for (const item of annResults[index]) if (expected.has(item.id)) hits += 1;
64
+ expectedTotal += expected.size;
65
+ }
66
+
67
+ console.log(JSON.stringify({
68
+ count,
69
+ queries,
70
+ dim: 64,
71
+ ef,
72
+ buildMs: Math.round(buildMs * 100) / 100,
73
+ recallAt10: expectedTotal ? Math.round((hits / expectedTotal) * 10000) / 10000 : null,
74
+ exactP50Ms: Math.round((percentile(exactTimes, 50) ?? 0) * 1000) / 1000,
75
+ annP50Ms: Math.round((percentile(annTimes, 50) ?? 0) * 1000) / 1000,
76
+ exactP95Ms: Math.round((percentile(exactTimes, 95) ?? 0) * 1000) / 1000,
77
+ annP95Ms: Math.round((percentile(annTimes, 95) ?? 0) * 1000) / 1000,
78
+ }, null, 2));
@@ -0,0 +1,79 @@
1
+ import { createHnswIndex } from '../src/vector/hnsw.js';
2
+
3
+ function randomFactory(seed) {
4
+ let state = seed >>> 0;
5
+ return () => {
6
+ state = (state * 1664525 + 1013904223) >>> 0;
7
+ return state / 4294967296;
8
+ };
9
+ }
10
+
11
+ function makeVector(random, dim = 64) {
12
+ const value = Array.from({ length: dim }, () => random() * 2 - 1);
13
+ const norm = Math.sqrt(value.reduce((sum, item) => sum + item * item, 0)) || 1;
14
+ return value.map((item) => item / norm);
15
+ }
16
+
17
+ function cosine(a, b) {
18
+ let dot = 0;
19
+ for (let i = 0; i < a.length; i += 1) dot += a[i] * b[i];
20
+ return dot;
21
+ }
22
+
23
+ function percentile(values, p) {
24
+ const sorted = [...values].sort((a, b) => a - b);
25
+ if (!sorted.length) return null;
26
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))];
27
+ }
28
+
29
+ const count = Number(process.argv[2] ?? 5000);
30
+ const queries = Number(process.argv[3] ?? 100);
31
+ const ef = Number(process.argv[4] ?? 200);
32
+ const random = randomFactory(20261111);
33
+ const items = Array.from({ length: count }, (_, index) => ({ id: `h${index}`, vector: makeVector(random) }));
34
+ const queryVectors = Array.from({ length: queries }, () => makeVector(random));
35
+ const index = createHnswIndex({ M: 24, efConstruction: 200, efSearch: ef, seed: 20261111 });
36
+ const buildStart = performance.now();
37
+ index.rebuild(items);
38
+ const buildMs = performance.now() - buildStart;
39
+
40
+ const exactTimes = [];
41
+ const hnswTimes = [];
42
+ const exactResults = [];
43
+ const hnswResults = [];
44
+ for (const query of queryVectors) {
45
+ const exactStart = performance.now();
46
+ const exact = items
47
+ .map((item) => ({ id: item.id, score: cosine(item.vector, query) }))
48
+ .sort((a, b) => b.score - a.score)
49
+ .slice(0, 10);
50
+ exactTimes.push(performance.now() - exactStart);
51
+ exactResults.push(exact);
52
+
53
+ const hnswStart = performance.now();
54
+ const approx = index.search(query, 10, { ef }).slice(0, 10);
55
+ hnswTimes.push(performance.now() - hnswStart);
56
+ hnswResults.push(approx);
57
+ }
58
+
59
+ let hits = 0;
60
+ let expectedTotal = 0;
61
+ for (let index = 0; index < exactResults.length; index += 1) {
62
+ const expected = new Set(exactResults[index].map((item) => item.id));
63
+ for (const item of hnswResults[index]) if (expected.has(item.id)) hits += 1;
64
+ expectedTotal += expected.size;
65
+ }
66
+
67
+ console.log(JSON.stringify({
68
+ engine: 'hnsw',
69
+ count,
70
+ queries,
71
+ dim: 64,
72
+ ef,
73
+ buildMs: Math.round(buildMs * 100) / 100,
74
+ recallAt10: expectedTotal ? Math.round((hits / expectedTotal) * 10000) / 10000 : null,
75
+ exactP50Ms: Math.round((percentile(exactTimes, 50) ?? 0) * 1000) / 1000,
76
+ hnswP50Ms: Math.round((percentile(hnswTimes, 50) ?? 0) * 1000) / 1000,
77
+ exactP95Ms: Math.round((percentile(exactTimes, 95) ?? 0) * 1000) / 1000,
78
+ hnswP95Ms: Math.round((percentile(hnswTimes, 95) ?? 0) * 1000) / 1000,
79
+ }, null, 2));
package/bench/metrics.js CHANGED
@@ -4,6 +4,12 @@ export function recallAtK(ranked, relevant, k = 10) {
4
4
  return rel.size === 0 ? 0 : top.filter((id) => rel.has(id)).length / rel.size;
5
5
  }
6
6
 
7
+ export function hitAtK(ranked, relevant, k = 10) {
8
+ const rel = new Set(relevant);
9
+ if (rel.size === 0) return 0;
10
+ return ranked.slice(0, Math.max(1, k)).some((id) => rel.has(id)) ? 1 : 0;
11
+ }
12
+
7
13
  export function precisionAtK(ranked, relevant, k = 10) {
8
14
  const rel = new Set(relevant);
9
15
  const top = ranked.slice(0, Math.max(1, k));
@@ -43,6 +49,7 @@ export function evaluateQueries(results, { k = 10 } = {}) {
43
49
  const relevant = result.relevant ?? [];
44
50
  return {
45
51
  recall: recallAtK(ranked, relevant, k),
52
+ hit: hitAtK(ranked, relevant, k),
46
53
  precision: precisionAtK(ranked, relevant, k),
47
54
  mrr: reciprocalRank(ranked, relevant, k),
48
55
  ndcg: ndcgAtK(ranked, relevant, k),
@@ -52,6 +59,7 @@ export function evaluateQueries(results, { k = 10 } = {}) {
52
59
  return {
53
60
  n: rows.length,
54
61
  recallAtK: mean('recall'),
62
+ hitAtK: mean('hit'),
55
63
  precisionAtK: mean('precision'),
56
64
  mrrAtK: mean('mrr'),
57
65
  ndcgAtK: mean('ndcg'),
@@ -0,0 +1,69 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createCortex } from '../src/service/cortex.js';
5
+ import { openDatabase, migrate, getMeta, closeDatabase, FTS_INDEX_VERSION } from '../src/store/database.js';
6
+ import { createTraceRepository } from '../src/store/repositories/traces.js';
7
+ import { ftsText } from '../src/memory/tokenizer.js';
8
+
9
+ const count = Number(process.argv[2] ?? 5000);
10
+ const dir = mkdtempSync(path.join(os.tmpdir(), `cortex-migrate-${count}-`));
11
+ const dbFile = path.join(dir, 'cortex.sqlite');
12
+ const totalStart = performance.now();
13
+ const phases = {};
14
+ let cortex;
15
+ try {
16
+ cortex = createCortex({ config: { dataRoot: dir, dbFile, contextBudget: 2200 } });
17
+ console.error(`phase create done (${Math.round(performance.now() - totalStart)}ms)`);
18
+ const texts = [];
19
+ for (let i = 0; i < count; i += 1) {
20
+ texts.push({
21
+ text: i === 0 ? '白饭:升级后仍然应该能通过“米饭”的 soft 查询找到。' : `老库迁移样本 ${i}:普通记忆内容。`,
22
+ title: `迁移样本 #${i}`,
23
+ explicit: true,
24
+ operationId: `migrate:${count}:${i}`,
25
+ now: Date.UTC(2026, 0, 1) + i * 1000,
26
+ });
27
+ }
28
+ const writeStart = performance.now();
29
+ cortex.writeMany(texts, { chunkSize: 500 });
30
+ phases.writeMs = Math.round((performance.now() - writeStart) * 100) / 100;
31
+ console.error(`phase write done (${phases.writeMs}ms)`);
32
+ cortex.close();
33
+ cortex = null;
34
+
35
+ let opened = openDatabase({ dataRoot: dir, dbFile });
36
+ opened.db.exec('DELETE FROM traces_fts_index');
37
+ opened.db.prepare('DELETE FROM meta WHERE key = ?').run('fts_index_version');
38
+ const rows = opened.db.prepare('SELECT id, title, content, summary FROM traces').all();
39
+ const insert = opened.db.prepare('INSERT INTO traces_fts_index(trace_id, text) VALUES (?, ?)');
40
+ for (const row of rows) {
41
+ insert.run(row.id, ftsText(`${row.title ?? ''} ${row.content ?? ''} ${row.summary ?? ''}`));
42
+ }
43
+ closeDatabase(opened.db);
44
+ phases.oldIndexMs = Math.round((performance.now() - writeStart) * 100) / 100;
45
+ console.error(`phase old-index done (${phases.oldIndexMs}ms total from write)`);
46
+
47
+ const started = performance.now();
48
+ opened = openDatabase({ dataRoot: dir, dbFile });
49
+ const result = migrate(opened.db);
50
+ const elapsedMs = performance.now() - started;
51
+ phases.migrateMs = Math.round(elapsedMs * 100) / 100;
52
+ console.error(`phase migrate done (${phases.migrateMs}ms)`);
53
+ const softHits = createTraceRepository(opened.db).searchFts('米饭', { soft: true });
54
+ phases.totalMs = Math.round((performance.now() - totalStart) * 100) / 100;
55
+ console.log(JSON.stringify({
56
+ count,
57
+ ftsRebuilt: result.ftsRebuilt,
58
+ elapsedMs: Math.round(elapsedMs * 100) / 100,
59
+ phases,
60
+ ftsVersion: getMeta(opened.db, 'fts_index_version'),
61
+ expectedVersion: String(FTS_INDEX_VERSION),
62
+ softHits: softHits.length,
63
+ dbBytes: opened.db.prepare('PRAGMA page_count').get().page_count * opened.db.prepare('PRAGMA page_size').get().page_size,
64
+ }, null, 2));
65
+ closeDatabase(opened.db);
66
+ } finally {
67
+ if (cortex) cortex.close();
68
+ rmSync(dir, { recursive: true, force: true });
69
+ }
package/bench/run.js CHANGED
@@ -11,11 +11,12 @@ export function runBenchmark({
11
11
  seed = 42,
12
12
  vector = process.env.CORTEX_BENCH_VECTOR === '1',
13
13
  limit = 10,
14
+ task = 'needle',
14
15
  } = {}) {
15
16
  const dir = mkdtempSync(path.join(os.tmpdir(), 'cortex-bench-'));
16
17
  const cortex = createCortex({ config: { dataRoot: dir, dbFile: path.join(dir, 'cortex.sqlite'), vectorEnabled: vector, contextBudget: 2200 } });
17
18
  try {
18
- const dataset = generateSynthetic({ count, queries, seed });
19
+ const dataset = generateSynthetic({ count, queries, seed, task });
19
20
  const idMap = new Map();
20
21
  const writeStart = performance.now();
21
22
  const inputs = dataset.traces.map((trace) => ({
@@ -45,6 +46,7 @@ export function runBenchmark({
45
46
  generatedAt: dataset.generatedAt,
46
47
  seed,
47
48
  count,
49
+ task: dataset.task,
48
50
  queries: dataset.queries.length,
49
51
  vector,
50
52
  writeMs: Math.round(writeMs),
@@ -69,6 +71,7 @@ if (isMain) {
69
71
  const args = process.argv.slice(2);
70
72
  const count = Number(args.find((arg) => arg.startsWith('--count='))?.split('=')[1] ?? 10000);
71
73
  const queries = Number(args.find((arg) => arg.startsWith('--queries='))?.split('=')[1] ?? 100);
72
- const result = runBenchmark({ count, queries, vector: args.includes('--vector') });
74
+ const task = args.find((arg) => arg.startsWith('--task='))?.split('=')[1] ?? 'needle';
75
+ const result = runBenchmark({ count, queries, task, vector: args.includes('--vector') });
73
76
  console.log(JSON.stringify(result, null, 2));
74
77
  }