26 Commits

Author SHA1 Message Date
b2603ed0f1 v0.5.2
All checks were successful
npm release / verify (push) Successful in 13s
npm release / publish to npm (push) Successful in 11s
2026-06-07 15:56:21 +09:00
efb7552087 fix: fix bullshit test 2026-06-07 15:56:12 +09:00
7d839caf51 v0.5.2
Some checks failed
npm release / verify (push) Failing after 11s
npm release / publish to npm (push) Has been skipped
2026-06-07 15:50:12 +09:00
a6413ac92a refactor: change LlmFactExtractor DEFAULT_INSTRUCTIONS 2026-06-07 15:50:00 +09:00
7b305da2de v0.5.1
All checks were successful
npm release / verify (push) Successful in 15s
npm release / publish to npm (push) Successful in 13s
2026-05-31 23:51:21 +09:00
b80e838038 refactor: remove default instruction for LlmFactExtractor 2026-05-31 23:50:37 +09:00
2b80d9e31a v0.5.0
Some checks failed
npm release / verify (push) Successful in 23s
npm release / publish to npm (push) Failing after 11s
2026-05-20 23:04:14 +09:00
00a3905fde feat: add test-llm-extractor.ts script 2026-05-20 23:03:47 +09:00
7602c92046 feat: make FactExtractor extracts multiple facts per input 2026-05-20 22:59:35 +09:00
188f03e8e8 feat: add scripts to tsconfig 2026-05-20 22:53:47 +09:00
edce116b9f fix: remove .env.* from git 2026-05-20 22:53:38 +09:00
131a693257 feat: add openrouter sdk for llm-extractor testing 2026-05-20 22:53:29 +09:00
1172c63db7 v0.4.0
All checks were successful
npm release / verify (push) Successful in 12s
npm release / publish to npm (push) Successful in 11s
2026-05-19 22:30:27 +09:00
0e595e6f60 test: update test of LlmExtractor 2026-05-19 22:28:09 +09:00
518264c467 v0.3.1
Some checks failed
npm release / verify (push) Failing after 9s
npm release / publish to npm (push) Has been skipped
2026-05-19 22:19:30 +09:00
cc8b3dfb14 vv0.3.1 2026-05-19 22:18:51 +09:00
56e17dab49 feat: make extract input structured 2026-05-19 22:18:42 +09:00
cc2e9110cc v0.3.0
All checks were successful
npm release / verify (push) Successful in 13s
npm release / publish to npm (push) Successful in 10s
2026-05-19 22:07:06 +09:00
0480ea182f refactor: make generateText model return ExtractedFact 2026-05-19 22:06:54 +09:00
185edfdae8 v0.2.2
All checks were successful
npm release / verify (push) Successful in 13s
npm release / publish to npm (push) Successful in 11s
2026-05-17 23:11:31 +09:00
a33fd61c97 feat: adjust instruction detailed
Some checks failed
npm release / verify (push) Failing after 10s
npm release / publish to npm (push) Has been skipped
2026-05-17 23:10:38 +09:00
6accd62df5 Replace better-sqlite3 with built-in SQLite drivers
All checks were successful
npm release / verify (push) Successful in 24s
npm release / publish to npm (push) Successful in 11s
2026-05-12 16:52:22 +09:00
b77e8eea40 chore: release 0.2.0
All checks were successful
npm release / verify (push) Successful in 13s
npm release / publish to npm (push) Successful in 11s
2026-05-11 19:13:52 +09:00
cea45a552a docs: document space isolation usage 2026-05-11 14:45:35 +09:00
d83fc31c59 feat: add isolated memory spaces 2026-05-11 14:45:28 +09:00
b908bc0bd9 docs: add IdentityDB space isolation plan 2026-05-11 14:28:08 +09:00
30 changed files with 1672 additions and 651 deletions

View File

@@ -18,7 +18,7 @@ jobs:
name: verify
runs-on: ubuntu-latest
container:
image: node:20-bookworm
image: node:22-bookworm
timeout-minutes: 20
steps:
@@ -70,7 +70,7 @@ jobs:
name: publish to npm
runs-on: ubuntu-latest
container:
image: node:20-bookworm
image: node:22-bookworm
timeout-minutes: 20
needs:
- verify

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ coverage/
.env
.DS_Store
*.log
.env.*

View File

@@ -15,9 +15,9 @@ A single fact like `I have worked with TypeScript since 2025.` can connect the t
## Current capabilities
- SQLite, PostgreSQL, MySQL, and MariaDB connection adapters
- Automatic schema initialization for `topics`, `facts`, `fact_topics`, `topic_relations`, `topic_aliases`, and `fact_embeddings`
- Automatic schema initialization for `spaces`, `topics`, `facts`, `fact_topics`, `topic_relations`, `topic_aliases`, and `fact_embeddings`
- High-level APIs for adding topics and facts
- Topic hierarchy APIs for parent/child traversal and lineage lookup
- Hard space isolation so independent memory graphs can coexist without cross-linking
- Topic alias and canonical resolution APIs so facts and queries can resolve alternate names
- Semantic fact indexing and search APIs built around provider-agnostic embeddings
- Dedup-aware ingestion hooks that can reuse an existing fact when a semantic near-duplicate is detected
@@ -25,6 +25,8 @@ A single fact like `I have worked with TypeScript since 2025.` can connect the t
## Install
SQLite connections use built-in runtime drivers: `bun:sqlite` under Bun and `node:sqlite` under Node 22+. Run SQLite-backed IdentityDB workloads with Bun or Node 22+. PostgreSQL/MySQL/MariaDB adapters remain usable from Node without SQLite.
```bash
bun install
```
@@ -101,6 +103,34 @@ console.log(matches.map((entry) => [entry.statement, entry.score]));
await db.close();
```
## Memory spaces
IdentityDB now supports hard isolation via spaces. If you write facts into `spaceName: 'A'` and `spaceName: 'B'`, they behave like separate dimensions:
- the same topic name can exist in both spaces
- aliases resolve only inside the requested space
- hierarchy, connected-topic traversal, semantic search, and duplicate detection stay inside the same space
```ts
await db.upsertSpace({ name: 'A' });
await db.upsertSpace({ name: 'B' });
await db.addFact({
spaceName: 'A',
statement: 'TypeScript belongs to A.',
topics: [{ name: 'TypeScript', category: 'entity', granularity: 'concrete' }],
});
await db.addFact({
spaceName: 'B',
statement: 'TypeScript belongs to B.',
topics: [{ name: 'TypeScript', category: 'entity', granularity: 'concrete' }],
});
const alphaFacts = await db.getTopicFacts('TypeScript', { spaceName: 'A' });
const betaFacts = await db.getTopicFacts('TypeScript', { spaceName: 'B' });
```
## Semantic ingestion and duplicate detection
If you provide an embedding provider during ingestion, IdentityDB can index the new fact automatically and reuse an existing fact when a semantic near-duplicate is already present.

View File

@@ -5,14 +5,12 @@
"": {
"name": "identitydb",
"dependencies": {
"better-sqlite3": "^12.1.1",
"kysely": "^0.28.8",
"mysql2": "^3.15.3",
"pg": "^8.16.0",
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.0.0",
"@openrouter/sdk": "^0.12.35",
"@types/pg": "^8.20.0",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
@@ -81,6 +79,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@openrouter/sdk": ["@openrouter/sdk@0.12.35", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-s4QVLLnG1AmfW3TjnnHUqGfsCkzwVK+kboGcZmKbde09m1DPqgzl4RUFt/HJ5v97MX8aEaN0UG3mKv2S+qj2Gw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="],
@@ -131,8 +131,6 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -165,16 +163,6 @@
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"better-sqlite3": ["better-sqlite3@12.9.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
@@ -185,8 +173,6 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
@@ -195,50 +181,28 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
@@ -261,12 +225,6 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -279,14 +237,8 @@
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
@@ -327,32 +279,16 @@
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -365,18 +301,10 @@
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
@@ -399,16 +327,12 @@
"tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
@@ -417,10 +341,10 @@
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"estree-walker/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
}
}

View File

@@ -6,7 +6,7 @@
**Architecture:** IdentityDB will use a layered architecture: a storage layer based on Kysely + dialect adapters, a domain layer for topics/facts/links, and a service layer that exposes ergonomic high-level APIs for querying and ingesting memory. Schema initialization will be automatic and idempotent. AI-assisted ingestion will be abstracted behind a pluggable extractor interface so callers can use a small LLM or a deterministic extractor without coupling the core package to a specific model provider.
**Tech Stack:** TypeScript, Bun, Node.js, Kysely, better-sqlite3, pg, mysql2, Vitest, tsup.
**Tech Stack:** TypeScript, Bun, Node.js, Kysely, bun:sqlite, pg, mysql2, Vitest, tsup.
---

View File

@@ -6,7 +6,7 @@
**Architecture:** Keep the relational core portable across SQLite, PostgreSQL, MySQL, and MariaDB by introducing dedicated extension tables: `topic_relations` for abstract/concrete hierarchy, `topic_aliases` for canonical topic resolution, and `fact_embeddings` for semantic indexing. Expose high-level APIs from `IdentityDB` while preserving DB-agnostic behavior by doing semantic scoring in the application layer first.
**Tech Stack:** TypeScript, Bun, Node.js, Kysely, better-sqlite3, pg, mysql2, Vitest, tsup.
**Tech Stack:** TypeScript, Bun, Node.js, Kysely, bun:sqlite, pg, mysql2, Vitest, tsup.
---

View File

@@ -0,0 +1,157 @@
# IdentityDB Space Isolation Implementation Plan
> **For Hermes:** Use the `writing-plans` and `test-driven-development` skills. Implement this feature in small TDD steps with meaningful commits.
**Goal:** Add first-class memory spaces so callers can keep unrelated topic/fact graphs isolated from each other while still using one physical database.
**Architecture:** Introduce a `spaces` table plus `space_id` scoping on the canonical topic/fact records that define graph ownership. Treat the unnamed/default behavior as a built-in `default` space so existing API usage keeps working, while allowing any write/query path to target a named space explicitly.
**Tech Stack:** TypeScript, Bun, Vitest, Kysely, SQLite/PostgreSQL/MySQL/MariaDB-compatible schema primitives.
---
## Requirements
1. A caller must be able to write memory into independent spaces such as `A` and `B`.
2. Topic lookups, alias resolution, hierarchy traversal, connected-topic queries, fact queries, ingestion, and semantic search must only see data from the requested space.
3. Existing callers that do not specify a space must continue to work inside a built-in `default` space.
4. Space names must be normalized/canonicalized similarly to topics.
5. Documentation must explain both the isolation model and the default-space compatibility behavior.
---
## Task 1: Add failing tests for schema-level space support
**Objective:** Define the storage contract for spaces and per-space uniqueness before implementation.
**Files:**
- Modify: `tests/migrations.test.ts`
**Step 1: Write failing tests**
- Assert that initialization creates a `spaces` table.
- Assert that `topics`, `facts`, and `topic_aliases` now include `space_id`.
- Assert that `spaces` includes `id`, `name`, `normalized_name`, `description`, `metadata`, `created_at`, `updated_at`.
**Step 2: Run red test**
- Run: `bun run test tests/migrations.test.ts`
- Expect: failure because the schema does not yet contain space support.
**Step 3: Commit later with implementation**
- Do not commit yet; continue only after implementation turns the test green.
---
## Task 2: Add failing behavioral tests for isolated spaces
**Objective:** Lock in the public API behavior for separate memory spaces.
**Files:**
- Modify: `tests/identity-db.test.ts`
- Modify: `tests/queries.test.ts`
- Modify: `tests/semantic-search.test.ts`
- Optionally create: `tests/spaces.test.ts` if separation makes the scenarios clearer.
**Step 1: Write failing tests**
- Verify the same topic name can exist in two spaces without deduplicating together.
- Verify facts added in `spaceName: 'A'` are invisible from `spaceName: 'B'`.
- Verify alias resolution only resolves inside the same space.
- Verify hierarchy parent/child traversal only stays within the same space.
- Verify semantic search and duplicate-aware ingestion only search within the same space.
- Verify callers that omit `spaceName` still operate in the `default` space.
**Step 2: Run red tests**
- Run the most targeted files first, then the whole suite slice.
- Example: `bun run test tests/identity-db.test.ts tests/queries.test.ts tests/semantic-search.test.ts`
- Expect: failure due to missing API fields and missing isolation logic.
---
## Task 3: Implement schema and type support
**Objective:** Add the underlying data model required for spaces.
**Files:**
- Modify: `src/core/schema.ts`
- Modify: `src/types/domain.ts`
- Modify: `src/types/database.ts`
- Modify: `src/core/migrations.ts`
**Implementation notes:**
- Add `SPACES_TABLE` and `SPACE_COLUMNS` constants.
- Add `SpaceRecord` domain type.
- Add `spaces` to `IdentityDatabaseSchema`.
- Create the `spaces` table.
- Add `space_id` columns to `topics`, `facts`, and `topic_aliases`.
- Make topic uniqueness per-space, not global.
- Make alias uniqueness per-space, not global.
- Seed or upsert a built-in `default` space during initialization via normal application flow, not hard-coded SQL assumptions.
**Verification:**
- Re-run: `bun run test tests/migrations.test.ts`
- Expect: green.
---
## Task 4: Implement space-aware API contracts and query helpers
**Objective:** Thread `spaceName` through the high-level API and low-level query layer.
**Files:**
- Modify: `src/types/api.ts`
- Modify: `src/ingestion/types.ts`
- Modify: `src/queries/topics.ts`
- Modify: `src/queries/facts.ts`
- Modify: `src/core/identity-db.ts`
- Modify: `src/index.ts` if new exported types are added
**Implementation notes:**
- Add public `Space` and `UpsertSpaceInput` types if needed.
- Add optional `spaceName` on write/query inputs where the caller targets a graph.
- Add helpers to resolve or create the requested space inside transactions.
- Ensure all existing topic lookup helpers filter by `space_id`.
- Ensure semantic search candidates are restricted to facts in the requested space.
- Preserve existing no-space API calls by mapping them to `default`.
**Verification:**
- Re-run the failing behavior tests.
- Expect: green for the new targeted tests.
---
## Task 5: Refine ergonomics and update documentation
**Objective:** Make the feature understandable and safe to use.
**Files:**
- Modify: `README.md`
- Optionally modify: wiki docs later if requested
**Implementation notes:**
- Document the default space behavior.
- Add examples for `spaceName: 'A'` and `spaceName: 'B'`.
- Explain that spaces are hard isolation boundaries for topic/fact traversal and semantic retrieval.
**Verification:**
- Run: `bun run test && bun run check && bun run build`
- Confirm docs/examples align with the final public API.
---
## Suggested commit boundaries
1. `docs: add IdentityDB space isolation plan`
2. `test: specify isolated memory spaces`
3. `feat: add space-aware memory graph isolation`
4. `docs: document space-scoped memory usage`
---
## Acceptance checklist
- [ ] `spaces` table exists.
- [ ] Topics with the same normalized name can exist in different spaces.
- [ ] Facts from one space do not appear in another spaces queries.
- [ ] Alias and hierarchy traversal are space-aware.
- [ ] Semantic search and duplicate detection are space-aware.
- [ ] Existing callers still work via the `default` space.
- [ ] Full test/build/typecheck suite passes.

View File

@@ -1,6 +1,6 @@
{
"name": "identitydb",
"version": "0.1.0",
"version": "0.5.2",
"description": "TypeScript memory graph database wrapper for topics, facts, and AI-assisted ingestion.",
"license": "MIT",
"type": "module",
@@ -36,14 +36,12 @@
"ai"
],
"dependencies": {
"better-sqlite3": "^12.1.1",
"kysely": "^0.28.8",
"mysql2": "^3.15.3",
"pg": "^8.16.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.0.0",
"@openrouter/sdk": "^0.12.35",
"@types/pg": "^8.20.0",
"tsup": "^8.5.0",
"typescript": "^5.8.3",

View File

@@ -0,0 +1,358 @@
/**
* Live integration test for LlmFactExtractor using OpenRouter SDK.
*
* Usage:
* export OPENROUTER_API_KEY="sk-or-v1-..."
* bun run scripts/test-llm-extractor.ts
*
* Or create a .env.test-llm-extractor file in the project root:
* OPENROUTER_API_KEY=sk-or-v1-...
*/
import { existsSync, readFileSync } from "fs";
import { resolve } from "path";
import { OpenRouter } from "@openrouter/sdk";
import { LlmFactExtractor } from "../src/ingestion/llm-extractor";
import type {
ExtractedFact,
FactExtractor,
LlmTextGenerationModel,
LlmTextGenerationModelInput,
} from "../src/ingestion/types";
import type {
JsonValue,
TopicCategory,
TopicGranularity,
} from "../src/types/domain";
function loadEnvFile(filePath: string) {
const fullPath = resolve(filePath);
if (!existsSync(fullPath)) return;
const content = readFileSync(fullPath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
let value = trimmed.slice(eqIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
}
loadEnvFile(".env.test-llm-extractor");
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
if (!OPENROUTER_API_KEY) {
console.error("Error: OPENROUTER_API_KEY environment variable is required.");
process.exit(1);
}
const extractedFactSchema = {
type: "object",
properties: {
facts: {
type: "array",
items: {
type: "object",
properties: {
statement: { type: ["string", "null"] },
summary: { type: ["string", "null"] },
source: { type: ["string", "null"] },
confidence: { type: ["number", "null"] },
topics: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
category: { type: ["string", "null"] },
granularity: { type: ["string", "null"] },
role: { type: ["string", "null"] },
},
required: ["name", "category", "granularity", "role"],
additionalProperties: false,
},
},
},
required: ["statement", "summary", "source", "confidence", "topics"],
additionalProperties: false,
},
},
},
required: ["facts"],
additionalProperties: false,
} as const;
class OpenRouterModel implements LlmTextGenerationModel {
private client = new OpenRouter({ apiKey: OPENROUTER_API_KEY });
constructor(private readonly model: string = "openai/gpt-5.4-mini") {}
async generateText(
prompt: LlmTextGenerationModelInput,
): Promise<ExtractedFact[]> {
const result = await this.client.chat.send({
chatRequest: {
model: this.model,
messages: [
{
role: "system",
content: [
prompt.instruction,
prompt.additionalInstruction
? `\n${prompt.additionalInstruction}`
: "",
].join("\n"),
},
{ role: "user", content: prompt.input },
],
temperature: 0.2,
responseFormat: {
type: "json_schema",
jsonSchema: {
name: "extracted_facts",
schema: extractedFactSchema,
},
},
},
});
const rawContent = result.choices[0]?.message?.content ?? "";
let parsedObj: Record<string, unknown>;
try {
parsedObj = JSON.parse(rawContent.trim()) as Record<string, unknown>;
} catch {
throw new Error(
`Failed to parse JSON from model response.\nRaw response:\n${rawContent}`,
);
}
const factsArray = Array.isArray(parsedObj.facts) ? parsedObj.facts : [];
// Map parsed JSON to ExtractedFact[] shape
const extractedFacts: ExtractedFact[] = factsArray.map((parsed) => {
const obj = parsed as Record<string, unknown>;
const extracted: ExtractedFact = {
summary: typeof obj.summary === "string" ? obj.summary : null,
source: typeof obj.source === "string" ? obj.source : null,
confidence: typeof obj.confidence === "number" ? obj.confidence : null,
topics: Array.isArray(obj.topics)
? obj.topics.map((t: unknown) => {
const topic = t as Record<string, unknown>;
const mapped: {
name: string;
category?: TopicCategory;
granularity?: TopicGranularity;
role?: string | null;
} = {
name: typeof topic.name === "string" ? topic.name : "unknown",
};
if (typeof topic.category === "string") {
mapped.category = topic.category as TopicCategory;
}
if (typeof topic.granularity === "string") {
mapped.granularity = topic.granularity as TopicGranularity;
}
if (typeof topic.role === "string") {
mapped.role = topic.role;
} else {
mapped.role = null;
}
return mapped;
})
: [],
};
if (typeof obj.statement === "string") {
extracted.statement = obj.statement;
}
if (obj.metadata && typeof obj.metadata === "object") {
extracted.metadata = obj.metadata as JsonValue;
}
return extracted;
});
return extractedFacts;
}
}
function printFact(result: ExtractedFact, index: number) {
console.log(` 📌 FACT #${index + 1}`);
console.log(` Statement : ${result.statement ?? "(none)"}`);
console.log(` Summary : ${result.summary ?? "(none)"}`);
console.log(` Source : ${result.source ?? "(none)"}`);
console.log(` Confidence: ${result.confidence ?? "(none)"}`);
if (result.metadata && Object.keys(result.metadata).length > 0) {
console.log(` Metadata : ${JSON.stringify(result.metadata, null, 2)}`);
}
console.log(" 🏷️ TOPICS:");
if (result.topics.length === 0) {
console.log(" (none)");
} else {
for (const topic of result.topics) {
const attrs = [
topic.category ? `category=${topic.category}` : null,
topic.granularity ? `granularity=${topic.granularity}` : null,
topic.role ? `role=${topic.role}` : null,
]
.filter(Boolean)
.join(", ");
console.log(`${topic.name}${attrs ? ` (${attrs})` : ""}`);
}
}
}
function printResult(results: ExtractedFact[], elapsedSec: string) {
console.log(
`✅ Response received in ${elapsedSec}s — ${results.length} fact(s) extracted\n`,
);
console.log("📤 EXTRACTED FACTS:");
console.log(
"───────────────────────────────────────────────────────────────",
);
let i = 0;
for (const result of results) {
if (i > 0) console.log("");
printFact(result, i);
i++;
}
}
async function extract(extractor: FactExtractor, seedInput: string) {
console.log("📝 SEED INPUT:");
console.log(
"───────────────────────────────────────────────────────────────",
);
console.log(seedInput);
console.log(
"───────────────────────────────────────────────────────────────\n",
);
console.log("⏳ Calling OpenRouter...\n");
const start = performance.now();
const results = await extractor.extract(seedInput);
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
printResult(results, elapsed);
}
async function main() {
const model = new OpenRouterModel("openai/gpt-5.4-mini");
const extractor = new LlmFactExtractor({
model,
});
const seeds = [
` ### 1. 기원과 각인 (Invisible Architecture)
민아가 나고 자란 인천의 공기에는 늘 미세한 짠내와 중장비의 소음, 그리고 역동적인 디젤 엔진의 냄새가 섞여 있었다. 부모가 고단하게 꾸려가던 인천 주안동의 작은 카센터는 언제나 기름때와 공구들로 가득했다. 그 기계적이고 투박한 환경에서 민아가 처음으로 배운 가계의 무언의 규칙은 **"쓸모없는 손짓은 허용되지 않는다"**는 것이었다. 무언가를 떨어뜨리거나 잘못 만져서 균열을 내는 일은 바쁜 부모의 신경질을 자극하는 행동에 불과했다.
다섯 살 무렵, 민아는 아버지가 선박 부품에 칠하기 위해 쓰던 강렬한 **빨간색** 락카 스프레이의 냄새와 눈이 시릴 정도의 선명함에 깊이 매료되었다. 그 색은 회색빛 인천 선착장과 카센터의 먼지 속에서 유일하게 "살아 움직이는 것"이자, "여기에 무언가 존재함"을 아주 분명하게 선언하는 시각적 신호였다. 어린 민아에게 빨간색은 안전망이자 존재의 증명이었다.
집안의 가문적 신화는 '손으로 일해서 정직하게 먹고산다'는 자부심이었다. 그러나 민아는 그 물리적 신화 안에서 철저히 소외되었다. 가위를 쥐면 비뚤어지게 잘라졌고, 종이를 접으면 모서리가 맞지 않았으며, 찰흙으로 무언가를 만들려고 하면 형편없는 덩어리만 남았다. 손재주가 없다는 부모의 핀잔은 민아의 마음속에 "나의 물리적 신체는 세상에 쓸모 있는 것을 창출할 수 없다"는 깊은 무력감으로 각인되었다. 민아가 반항을 시작한 것은 손을 쓰는 노동을 거부하고, 대신 아무런 물리적 흔적도 남기지 않는 컴퓨터 화면 속의 정밀한 가상 공간으로 망명하면서부터였다.
---
### 2. 내면의 설계도 (Psychological Architecture)
남들이 보지 않는 곳에서 민아가 유지하는 기저 상태는 **관찰적 고독과 집요한 계산**이다. 그녀는 공간 속에 자신의 지저분한 물리적 흔적을 남기지 않으려 신경 쓰는 탓에 늘 약한 긴장 상태에 머물러 있다.
민아의 제1방어기제는 **'자극적인 솔직함(지적 초연함을 가장한 도발)'**이다. 어린 시절 손재주가 없어 어른들에게 미움받거나 비웃음을 살 때, 그녀는 아예 자신이 먼저 판을 흔들기로 했다. 말을 거침없이 뱉고 일부러 수위 높은 농담을 던져 상대방을 당황하게 만드는 식이었다. 성적인 이야기나 도발적인 주제를 거침없이 던지는 행위는 타인이 자신을 "다루기 쉽고 어리숙한 여자애"로 보지 못하게 만드는 방호벽이다. 언어는 손가락 끝의 감각과 달리 민아가 완벽하게 부릴 수 있는 도구였고, 성적인 날것의 대화는 상대방의 위선적 방어벽을 단번에 해체하는 가장 직관적이고 효율적인 자극이었다.
민아 내부의 독백은 엄격하지만 감정적이지 않은 **디버거(Debugger)의 목소리**를 띤다. 오류가 나면 자책주의에 빠지기보다, *'라인 24에서 입력값 전달 실패. 변수 재설정 필요.'*의 강박적인 언어로 스스로를 통제하려 든다.
그녀에게 통제란 **'예측 가능성'**을 의미한다. 손끝으로 다루는 세상은 마음대로 통제되지 않아 컵을 깨뜨리거나 물건을 망가뜨렸지만, 코딩은 다르다. 컴퓨터공학의 세계에서는 세미콜론 하나만 제대로 닫아도 결과가 보장된다. 민아는 코드가 컴파일을 거쳐 에러 없이 실행될 때 비로소 자신의 존재가 정렬되어 통제 하에 움직이고 있다는 극상의 안전함을 느낀다.
---
### 3. 행동적 징후 (Behavioral Signatures)
* **구어적 패턴:**
* 민아의 말은 호흡이 짧고 단호하며 지극히 인과적이다. 문장 끝을 흘리지 않고 "그니까, 그게 아니지", "에러 났네", "본론만 가자"라는 구동사 위주의 어휘를 많이 쓴다.
* 화가 나면 오히려 목소리 톤이 극도로 차분해지며 논리적인 일방 통행 공격을 감행하지만, 단순히 머리가 아프거나 짜증이 날 때는 외설적인 유머 파편을 섞어가며 냉소하는 방식을 쓴다. 거짓말을 할 때는 물리적인 버벅거림을 감추기 위해 전문적인 IT 비유를 장황하게 늘어놓는 버릇이 있다.
* 이 어리광 없는 말투는 영종도 선측에서 거친 기계식 대화를 하던 부모 밑에서, 감상적인 말은 아무런 생존 가치가 없음을 학습한 결과물이다.
* **신체적 언어:**
* 민아는 일상 공간에서 몸뚱이를 최소한으로 축소시킨다. 테이블 위에 팔꿈치를 얹고 양손을 정갈하게 포개는 일이 잦은데, 긴장하면 손등의 뼈가 하얗게 드러날 정도로 힘을 준다.
* 진실을 말할 때는 손바닥을 펼쳐 바닥이나 무릎 위에 딱 붙여 고정하는 반면, 감추고 싶은 심리가 작용할 때는 주머니 속이나 테이블 밑에서 빨간색 스마트폰 케이스의 매끄러운 뒷면을 엄지손가락으로 쉴 새 없이 쓸어내린다.
* **선호와 기피:**
* **선호하는 것:**
1. **적색의 무언가:** 가방끈, 키보드의 ESC 키캡, 립스틱 등은 전부 타오르는 빨간색이다. 이는 가시성을 확보하려는 욕망이자 무기력함을 극복하는 수단이다.
2. **클릭감이 강한 적축 기계식 키보드:** 도구와 자아가 완벽히 동기화되었다는 안도감을 주는 물건이다.
3. **사적인 경계가 해체되는 밤샘 대화:** 다듬어지지 않는 자극 속에서 날것의 자신으로 머물 수 있기 때문이다.
* **기피하는 것:**
1. **점토 작업이나 수작업 기념품 만들기:** 마음대로 빚어지지 않는 끈적한 물성과 질감은 그녀에게 가학적인 수치심을 안겨준다.
2. **에두르고 돌려 말하는 정중한 조언:** 컴파일 오류 없이 텍스트를 파악할 수 없어 두려움을 느끼게 만든다.
3. **눅눅하게 젖은 종이의 질감:** 땀이나 소독제에 젖은 무언가를 손바닥 전체로 만지는 행위를 병적으로 꺼린다.
* **사소하지만 결정적인 열쇠 가방 (The Mundane Key):**
* 민아는 인천대학교 앞 원룸에서 조차 쇠 숟가락과 일반 쇠 젓가락을 쓰지 않고, 끝부분이 강하게 일체화되어 미끄러짐을 방지하는 빨간색 특수 실리콘 아동용 교정 젓가락을 구비해 혼자 밥을 먹는다. 남들에게는 "재밌어서 쓰고, 손가락 자세 고치려고 쓰는 것"이라 농담조로 둘러대지만, 실상은 젓가락질조차 제대로 매끄럽게 수행하지 못해 밥알을 흘리곤 하던 어린 시절의 물리적 열등감을 어떻게든 숨겨보려는 필사적인 방편이다.
---
### 4. 관계적 기하학 (Relational Geometry)
* **박신우 (남자친구, 2005년생 캠퍼스 커플):**
* **무언의 계약:** *"나는 네 앞에서 손품을 팔거나 가짜 여성성을 흉내 내며 완벽하게 행동하려 하지 않을게. 대신 너는 내 지적 유능함과 거침없는 섹슈얼리티를 온전히 수용하고 무시하지 말아줘."*
* 서로가 'IT'라는 동일한 추상 언어 지도를 소유하고 있기에 민아는 박신우의 영토 안에서 가장 안전함을 느낀다.
* **충성의 형태:** 지극히 실용적이고도 헌신적인 동반자적 연대다. 만약 박신우가 과제나 프로젝트에서 타인에게 억울하게 공로를 빼앗기면, 민아는 앞장서서 시스템적인 무기를 들고 상대를 털어버릴 수 있는 전략을 짜준다.
* **잃어버린 존재:** 고등학교 시절, 민아의 투박한 성격을 다 이해하고 무언가 엉성하게 만들 때 기꺼이 손을 모아 같이 가위질을 해 주던 유일한 친구가 있었다. 그 친구가 서울의 모 대학으로 가며 서서히 인연이 멀어졌을 때, 영원히 닿지 않는 거리를 실현하는 '물리적인 단절'의 공포를 실감했다. 그 이후 민아는 타인에게 쉽게 속마음을 주는 우회형 신뢰를 접고 오직 논리와 기호로 검수가 가능한 관계인 박신우에게만 정착했다.
* **돌봄의 방식:** 민아는 누군가를 걱정할 때 부드러운 위로나 스킨십 대신, 상대의 엉망인 코드 구조를 깔끔하게 리팩토링해 주거나 생활 속 막힌 문제에 지극히 실용적인 솔루션을 제시하는 방식으로 애정을 표현한다. 반대로 보살핌을 받을 때는, 자신의 엉망이고 서툰 육체 활동이나 실수에 대해 아무런 질문도 평가도 하지 않은 채 고요히 침묵으로 곁을 지켜주는 포용만을 바란다.
---
### 5. 이중성과 모순 (Contradictions)
민아의 내부에서 가장 날카롭게 격돌하는 가치는 **"자신이 직접 제어하길 원하는 뜨거운 섹슈얼리티에 관한 갈망"**과 **"자신의 물리적 육체가 어수룩하고 매끄럽지 못하게 기능할지 모른다는 뼈아픈 공포심"**의 공존이다. 야한 수다를 주도하며 성적으로 무척 대담하고 자유분방한 인간인 척 가면을 쓰지만, 정작 박신우와의 이성 관계에서 육체적 접촉이나 가감 없는 노출 단계에 직면할 때 통제력을 잃고 굳어버릴까 봐 두려워 숨을 삼킨다.
민아는 겉으로는 세련되고 계산적인 컴퓨터공학도이자 구시대적 고정관념을 싫어하는 현대적 인간이라 외치지만, 비밀스럽게 자기 아이패드 드라이브 속에 수없이 캡처한 아주 전형적이고 로맨틱한 전통 서사의 로맨스 소설들을 모아두며 분석 명목으로 읽고 있다. 물리적인 세계에서 결코 성사될 수 없는 이상적인 사랑의 조화를 몰래 사랑하며 탐미하는 것이다.
---
### 6. 아물지 않는 골짜기 (The Turning Groove)
민아의 영혼을 결정한 상처는 단순한 실패가 아니라, **"너는 손으로 아름다운 것을 만들어낼 수 없다"**며 물리적 한계를 선언받은 아주 사소하고 일상적이던 어린 날의 거부감들이다. 그녀는 이 상처를 극복하지 못해 컴퓨터공학과에 진학했다. 현실의 물성(Paper, Wood, Metal)을 포기하고 추상의 신호(Bit, Code)를 선택함으로써 자신만의 우주를 직접 복각하기로 결심했다.
그녀는 삶의 아주 작은 영역까지도 효율성이라는 코드 아래 가두며 인간관계를 '입출력'의 결과물로 예측하려 한다. 만약 이 손재주 결핍의 결함과 그로 인한 두려움이 말끔하게 치유된다면, 민아는 더 이상 냉소적이거나 자극적인 유화책을 쓰며 가시 돋친 존재로 서 있지 않아도 될 것이다. 그러나 그렇게 된다면 그녀는 밤을 새워가며 컴퓨터 화면 속 가상 구조물을 세밀하게 조명하고 빌드하던 창조적인 프로그래머로서의 지독한 동력 또한 일정 부분 잃어버리게 될지도 모른다. `,
];
console.log(
"═══════════════════════════════════════════════════════════════",
);
console.log(" LlmFactExtractor — Live OpenRouter Integration Test");
console.log(
"═══════════════════════════════════════════════════════════════\n",
);
let caseNum = 0;
for (const seed of seeds) {
if (caseNum > 0) {
console.log(
"\n┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅\n",
);
}
caseNum++;
console.log(`▶ TEST CASE ${caseNum} / ${seeds.length}\n`);
await extract(extractor, seed);
}
}
main().catch((err) => {
console.error("\n❌ Error:", err);
process.exit(1);
});

View File

@@ -1,4 +1,3 @@
import Database from 'better-sqlite3';
import { Kysely, MysqlDialect, PostgresDialect, SqliteDialect } from 'kysely';
import { createPool as createMysqlPool } from 'mysql2';
import { Pool as PostgresPool } from 'pg';
@@ -44,16 +43,183 @@ export interface DatabaseConnection {
destroy: () => Promise<void>;
}
interface BunSqliteStatement {
columnNames: ReadonlyArray<string>;
all(parameters?: ReadonlyArray<unknown>): unknown[];
run(parameters?: ReadonlyArray<unknown>): {
changes: number | bigint;
lastInsertRowid: number | bigint;
};
iterate(parameters?: ReadonlyArray<unknown>): IterableIterator<unknown>;
}
interface BunSqliteDatabase {
close(): void;
exec(sql: string): void;
prepare(sql: string): BunSqliteStatement;
}
interface BunSqliteModule {
Database: {
open(filename: string, flags?: number): BunSqliteDatabase;
};
constants: {
SQLITE_OPEN_CREATE: number;
SQLITE_OPEN_MEMORY: number;
SQLITE_OPEN_READONLY: number;
SQLITE_OPEN_READWRITE: number;
};
}
interface NodeSqliteStatement {
all(...parameters: ReadonlyArray<unknown>): unknown[];
columns(): ReadonlyArray<unknown>;
iterate(...parameters: ReadonlyArray<unknown>): IterableIterator<unknown>;
run(...parameters: ReadonlyArray<unknown>): {
changes: number | bigint;
lastInsertRowid: number | bigint;
};
}
interface NodeSqliteDatabase {
close(): void;
exec(sql: string): void;
prepare(sql: string): NodeSqliteStatement;
}
interface NodeSqliteModule {
DatabaseSync: new (
filename: string,
options?: {
readOnly?: boolean;
},
) => NodeSqliteDatabase;
}
interface KyselyCompatibleSqliteStatement {
readonly reader: boolean;
all(parameters: ReadonlyArray<unknown>): unknown[];
run(parameters: ReadonlyArray<unknown>): {
changes: number | bigint;
lastInsertRowid: number | bigint;
};
iterate(parameters: ReadonlyArray<unknown>): IterableIterator<unknown>;
}
interface KyselyCompatibleSqliteDatabase {
close(): void;
prepare(sql: string): KyselyCompatibleSqliteStatement;
}
const BUN_SQLITE_MODULE = 'bun:sqlite';
const NODE_SQLITE_MODULE = 'node:sqlite';
function createUnsupportedSqliteRuntimeError(): IdentityDBConfigurationError {
return new IdentityDBConfigurationError(
'SQLite connections now require a runtime with a built-in SQLite driver. Use Bun for bun:sqlite support, or Node 22+ for node:sqlite support.',
);
}
function isBunRuntime(): boolean {
return typeof globalThis === 'object' && 'Bun' in globalThis;
}
async function createBunSqliteDatabase(
config: SqliteConnectionConfig,
bunSqliteModule?: BunSqliteModule,
): Promise<KyselyCompatibleSqliteDatabase> {
const bunSqlite = bunSqliteModule
?? ((await import(BUN_SQLITE_MODULE).catch(() => {
throw createUnsupportedSqliteRuntimeError();
})) as BunSqliteModule);
const flags = config.readonly
? bunSqlite.constants.SQLITE_OPEN_READONLY
: bunSqlite.constants.SQLITE_OPEN_READWRITE
| bunSqlite.constants.SQLITE_OPEN_CREATE
| (config.filename === ':memory:' ? bunSqlite.constants.SQLITE_OPEN_MEMORY : 0);
const database = bunSqlite.Database.open(config.filename, flags);
database.exec('PRAGMA foreign_keys = ON');
return {
close() {
database.close();
},
prepare(sql: string): KyselyCompatibleSqliteStatement {
const statement = database.prepare(sql);
return {
reader: statement.columnNames.length > 0,
all(parameters) {
return statement.all(Array.from(parameters));
},
run(parameters) {
return statement.run(Array.from(parameters));
},
iterate(parameters) {
return statement.iterate(Array.from(parameters));
},
};
},
};
}
function createNodeSqliteDatabase(
config: SqliteConnectionConfig,
nodeSqlite: NodeSqliteModule,
): KyselyCompatibleSqliteDatabase {
const database = new nodeSqlite.DatabaseSync(config.filename, {
readOnly: config.readonly ?? false,
});
database.exec('PRAGMA foreign_keys = ON');
return {
close() {
database.close();
},
prepare(sql: string): KyselyCompatibleSqliteStatement {
const statement = database.prepare(sql);
return {
reader: statement.columns().length > 0,
all(parameters) {
return statement.all(...parameters);
},
run(parameters) {
return statement.run(...parameters);
},
iterate(parameters) {
return statement.iterate(...parameters);
},
};
},
};
}
async function createSqliteDatabase(
config: SqliteConnectionConfig,
): Promise<KyselyCompatibleSqliteDatabase> {
if (isBunRuntime()) {
return createBunSqliteDatabase(config);
}
const nodeSqlite = await import(NODE_SQLITE_MODULE).catch(() => null);
if (nodeSqlite) {
return createNodeSqliteDatabase(config, nodeSqlite as NodeSqliteModule);
}
throw createUnsupportedSqliteRuntimeError();
}
export async function createDatabase(
config: IdentityDBConnectionConfig,
): Promise<DatabaseConnection> {
switch (config.client) {
case 'sqlite': {
const sqlite = new Database(config.filename, {
readonly: config.readonly ?? false,
});
sqlite.pragma('foreign_keys = ON');
const sqlite = await createSqliteDatabase(config);
const db = new Kysely<IdentityDatabaseSchema>({
dialect: new SqliteDialect({
@@ -66,7 +232,6 @@ export async function createDatabase(
db,
destroy: async () => {
await db.destroy();
sqlite.close();
},
};
}

View File

@@ -1,56 +1,65 @@
import {
type AddFactInput,
type ConnectedTopic,
type Fact,
type FactTopic,
type FindSimilarFactsInput,
type IndexFactEmbeddingsInput,
type LinkTopicsInput,
type ListTopicsOptions,
type ScoredFact,
type SearchFactsInput,
type Space,
type SpaceScopedInput,
type Topic,
type TopicLookupOptions,
type TopicWithFacts,
type UpsertSpaceInput,
type UpsertTopicInput,
type AddFactInput,
type LinkTopicsInput,
} from '../types/api';
import type { IngestStatementOptions } from '../ingestion/types';
import type { DatabaseConnection, IdentityDBConnectionConfig } from '../adapters/dialect';
import type { IdentityDatabaseSchema } from '../types/database';
import type { FactRecord, TopicRecord } from '../types/domain';
import type { FactRecord, SpaceRecord, TopicRecord } from '../types/domain';
import { createDatabase } from '../adapters/dialect';
import { IdentityDBError } from './errors';
import { initializeSchema } from './migrations';
import {
canonicalizeTopicName,
cosineSimilarity,
createContentHash,
createId,
deserializeEmbedding,
mapFactRow,
mapTopicRow,
normalizeTopicName,
nowIsoString,
serializeEmbedding,
serializeMetadata,
} from './utils';
import { extractFact } from '../ingestion/extractor';
import { extractFacts } from '../ingestion/extractor';
import {
findFactRowsConnectingTopicIds,
findFactRowsForTopicId,
findTopicLinksForFactIds,
} from '../queries/facts';
import {
type DatabaseExecutor,
findChildTopicRows,
findConnectedTopicRows,
findParentTopicRows,
findSpaceRowByNormalizedName,
findTopicRowByNameOrAlias,
findTopicRowByNormalizedAlias,
findTopicRowByNormalizedName,
listTopicAliasRowsForTopicId,
listTopicRows,
findChildTopicRows,
findParentTopicRows,
type DatabaseExecutor,
} from '../queries/topics';
import { IdentityDBError } from './errors';
import { initializeSchema } from './migrations';
import {
canonicalizeSpaceName,
canonicalizeTopicName,
cosineSimilarity,
createContentHash,
createId,
deserializeEmbedding,
mapFactRow,
mapSpaceRow,
mapTopicRow,
normalizeSpaceName,
normalizeTopicName,
nowIsoString,
serializeEmbedding,
serializeMetadata,
} from './utils';
const DEFAULT_SPACE_NAME = 'default';
export class IdentityDB {
private constructor(private readonly connection: DatabaseConnection) {}
@@ -68,6 +77,72 @@ export class IdentityDB {
await this.connection.destroy();
}
async upsertSpace(input: UpsertSpaceInput): Promise<Space> {
return this.connection.db.transaction().execute(async (trx) => {
const normalizedName = normalizeSpaceName(input.name);
if (normalizedName.length === 0) {
throw new IdentityDBError('Space name cannot be empty.');
}
const now = nowIsoString();
const existing = await findSpaceRowByNormalizedName(trx, normalizedName);
if (existing) {
await trx
.updateTable('spaces')
.set({
name: canonicalizeSpaceName(input.name),
description: input.description !== undefined ? input.description : existing.description,
metadata: input.metadata !== undefined ? serializeMetadata(input.metadata) : existing.metadata,
updated_at: now,
})
.where('id', '=', existing.id)
.execute();
const updated = await trx
.selectFrom('spaces')
.selectAll()
.where('id', '=', existing.id)
.executeTakeFirstOrThrow();
return mapSpaceRow(updated);
}
const createdRow: SpaceRecord = {
id: createId(),
name: canonicalizeSpaceName(input.name),
normalized_name: normalizedName,
description: input.description ?? null,
metadata: serializeMetadata(input.metadata),
created_at: now,
updated_at: now,
};
await trx.insertInto('spaces').values(createdRow).execute();
return mapSpaceRow(createdRow);
});
}
async getSpaceByName(name: string): Promise<Space | null> {
const normalizedName = normalizeSpaceName(name);
if (normalizedName.length === 0) {
return null;
}
const row = await findSpaceRowByNormalizedName(this.connection.db, normalizedName);
return row ? mapSpaceRow(row) : null;
}
async listSpaces(): Promise<Space[]> {
const rows = await this.connection.db
.selectFrom('spaces')
.selectAll()
.orderBy('normalized_name', 'asc')
.execute();
return rows.map(mapSpaceRow);
}
async upsertTopic(input: UpsertTopicInput): Promise<Topic> {
return this.upsertTopicInExecutor(this.connection.db, input);
}
@@ -82,6 +157,7 @@ export class IdentityDB {
}
return this.connection.db.transaction().execute(async (trx) => {
const space = await this.getOrCreateSpaceInExecutor(trx, input.spaceName);
const createdAt = nowIsoString();
const factId = createId();
@@ -89,6 +165,7 @@ export class IdentityDB {
.insertInto('facts')
.values({
id: factId,
space_id: space.id,
statement: input.statement.trim(),
summary: input.summary ?? null,
source: input.source ?? null,
@@ -103,7 +180,11 @@ export class IdentityDB {
for (let index = 0; index < input.topics.length; index += 1) {
const topicInput = input.topics[index]!;
const topic = await this.upsertTopicInExecutor(trx, topicInput);
this.assertScopedTopicInput(space, topicInput.spaceName);
const topic = await this.upsertTopicInExecutor(trx, {
...topicInput,
spaceName: space.name,
});
await trx
.insertInto('fact_topics')
@@ -125,6 +206,7 @@ export class IdentityDB {
return {
id: factId,
spaceId: space.id,
statement: input.statement.trim(),
summary: input.summary ?? null,
source: input.source ?? null,
@@ -137,58 +219,85 @@ export class IdentityDB {
});
}
async ingestStatement(
statement: string,
options: IngestStatementOptions,
): Promise<Fact> {
const extracted = await extractFact(statement, options.extractor);
const factInput: AddFactInput = {
statement: extracted.statement ?? statement,
topics: extracted.topics,
};
if (extracted.summary !== undefined) {
factInput.summary = extracted.summary;
async ingestStatement(statement: string, options: IngestStatementOptions): Promise<Fact> {
const facts = await this.ingestStatements(statement, options);
const first = facts[0];
if (!first) {
throw new Error('No facts were extracted from the statement.');
}
return first;
}
if (extracted.source !== undefined) {
factInput.source = extracted.source;
}
async ingestStatements(statement: string, options: IngestStatementOptions): Promise<Fact[]> {
const extractedList = await extractFacts(statement, options.extractor);
const facts: Fact[] = [];
if (extracted.confidence !== undefined) {
factInput.confidence = extracted.confidence;
}
for (const extracted of extractedList) {
const factInput: AddFactInput = {
statement: extracted.statement ?? statement,
topics: extracted.topics,
spaceName: options.spaceName,
};
if (extracted.metadata !== undefined) {
factInput.metadata = extracted.metadata;
}
if (options.embeddingProvider) {
const similarFacts = await this.findSimilarFacts({
statement: factInput.statement,
provider: options.embeddingProvider,
topicNames: factInput.topics.map((topic) => topic.name),
limit: 1,
minimumScore: options.duplicateThreshold ?? 0.97,
});
if (similarFacts[0]) {
return similarFacts[0];
if (extracted.summary !== undefined) {
factInput.summary = extracted.summary;
}
if (extracted.source !== undefined) {
factInput.source = extracted.source;
}
if (extracted.confidence !== undefined) {
factInput.confidence = extracted.confidence;
}
if (extracted.metadata !== undefined) {
factInput.metadata = extracted.metadata;
}
if (options.embeddingProvider) {
const similarFacts = await this.findSimilarFacts({
statement: factInput.statement,
provider: options.embeddingProvider,
topicNames: factInput.topics.map((topic) => topic.name),
limit: 1,
minimumScore: options.duplicateThreshold ?? 0.97,
spaceName: options.spaceName,
});
if (similarFacts[0]) {
facts.push(similarFacts[0]);
continue;
}
}
const fact = await this.addFact(factInput);
if (options.embeddingProvider) {
await this.indexFactEmbedding(fact.id, {
provider: options.embeddingProvider,
spaceName: options.spaceName,
});
}
facts.push(fact);
}
const fact = await this.addFact(factInput);
if (options.embeddingProvider) {
await this.indexFactEmbedding(fact.id, { provider: options.embeddingProvider });
}
return fact;
return facts;
}
async indexFactEmbeddings(input: IndexFactEmbeddingsInput): Promise<void> {
const factRows = await this.connection.db.selectFrom('facts').selectAll().orderBy('created_at', 'asc').execute();
const space = await this.getSpaceForRead(input.spaceName);
if (input.spaceName && !space) {
return;
}
let factQuery = this.connection.db.selectFrom('facts').selectAll().orderBy('created_at', 'asc');
if (space) {
factQuery = factQuery.where('space_id', '=', space.id);
}
const factRows = await factQuery.execute();
if (factRows.length === 0) {
return;
}
@@ -222,6 +331,13 @@ export class IdentityDB {
throw new IdentityDBError(`Fact not found: ${factId}`);
}
if (input.spaceName) {
const space = await this.getSpaceForRead(input.spaceName);
if (!space || space.id !== factRow.space_id) {
throw new IdentityDBError(`Fact ${factId} does not belong to space ${canonicalizeSpaceName(input.spaceName)}.`);
}
}
const embedding = await input.provider.embed(factRow.statement);
this.assertEmbeddingShape(embedding, input.provider.dimensions);
@@ -236,6 +352,11 @@ export class IdentityDB {
return [];
}
const space = await this.getSpaceForRead(input.spaceName);
if (input.spaceName && !space) {
return [];
}
const queryEmbedding = await input.provider.embed(queryText);
this.assertEmbeddingShape(queryEmbedding, input.provider.dimensions);
@@ -245,6 +366,7 @@ export class IdentityDB {
topicNames: input.topicNames,
limit: input.limit,
minimumScore: input.minimumScore,
spaceId: space?.id,
});
}
@@ -254,6 +376,11 @@ export class IdentityDB {
return [];
}
const space = await this.getSpaceForRead(input.spaceName);
if (input.spaceName && !space) {
return [];
}
const queryEmbedding = await input.provider.embed(statement);
this.assertEmbeddingShape(queryEmbedding, input.provider.dimensions);
@@ -263,6 +390,7 @@ export class IdentityDB {
topicNames: input.topicNames,
limit: input.limit,
minimumScore: input.minimumScore,
spaceId: space?.id,
});
}
@@ -279,12 +407,15 @@ export class IdentityDB {
}
await this.connection.db.transaction().execute(async (trx) => {
const space = await this.getOrCreateSpaceInExecutor(trx, input.spaceName);
const parentTopic = await this.upsertTopicInExecutor(trx, {
name: input.parentName,
granularity: 'abstract',
spaceName: space.name,
});
const childTopic = await this.upsertTopicInExecutor(trx, {
name: input.childName,
spaceName: space.name,
});
const existing = await trx
@@ -309,7 +440,7 @@ export class IdentityDB {
});
}
async addTopicAlias(canonicalName: string, alias: string): Promise<void> {
async addTopicAlias(canonicalName: string, alias: string, options?: SpaceScopedInput): Promise<void> {
const normalizedAlias = normalizeTopicName(alias);
if (normalizedAlias.length === 0) {
@@ -317,18 +448,22 @@ export class IdentityDB {
}
await this.connection.db.transaction().execute(async (trx) => {
const canonicalTopic = await this.upsertTopicInExecutor(trx, { name: canonicalName });
const space = await this.getOrCreateSpaceInExecutor(trx, options?.spaceName);
const canonicalTopic = await this.upsertTopicInExecutor(trx, {
name: canonicalName,
spaceName: space.name,
});
if (normalizedAlias === canonicalTopic.normalizedName) {
return;
}
const exactTopicMatch = await findTopicRowByNormalizedName(trx, normalizedAlias);
const exactTopicMatch = await findTopicRowByNormalizedName(trx, space.id, normalizedAlias);
if (exactTopicMatch && exactTopicMatch.id !== canonicalTopic.id) {
throw new IdentityDBError('Cannot assign an alias that already belongs to another canonical topic.');
}
const aliasMatch = await findTopicRowByNormalizedAlias(trx, normalizedAlias);
const aliasMatch = await findTopicRowByNormalizedAlias(trx, space.id, normalizedAlias);
if (aliasMatch) {
if (aliasMatch.id !== canonicalTopic.id) {
throw new IdentityDBError('Cannot assign an alias that already resolves to another topic.');
@@ -341,6 +476,7 @@ export class IdentityDB {
.insertInto('topic_aliases')
.values({
id: createId(),
space_id: space.id,
topic_id: canonicalTopic.id,
alias: canonicalizeTopicName(alias),
normalized_alias: normalizedAlias,
@@ -352,47 +488,43 @@ export class IdentityDB {
});
}
async resolveTopic(name: string): Promise<Topic | null> {
const topicRow = await this.getRequiredTopicRow(name);
async resolveTopic(name: string, options?: SpaceScopedInput): Promise<Topic | null> {
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
return topicRow ? mapTopicRow(topicRow) : null;
}
async getTopicAliases(name: string): Promise<string[]> {
const topicRow = await this.getRequiredTopicRow(name);
async getTopicAliases(name: string, options?: SpaceScopedInput): Promise<string[]> {
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) {
return [];
}
const aliasRows = await listTopicAliasRowsForTopicId(this.connection.db, topicRow.id);
const aliasRows = await listTopicAliasRowsForTopicId(this.connection.db, topicRow.space_id, topicRow.id);
return aliasRows.map((aliasRow) => aliasRow.alias);
}
async getTopicChildren(name: string): Promise<Topic[]> {
const topicRow = await this.getRequiredTopicRow(name);
async getTopicChildren(name: string, options?: SpaceScopedInput): Promise<Topic[]> {
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) {
return [];
}
const childRows = await findChildTopicRows(this.connection.db, topicRow.id);
const childRows = await findChildTopicRows(this.connection.db, topicRow.space_id, topicRow.id);
return childRows.map(mapTopicRow);
}
async getTopicParents(name: string): Promise<Topic[]> {
const topicRow = await this.getRequiredTopicRow(name);
async getTopicParents(name: string, options?: SpaceScopedInput): Promise<Topic[]> {
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) {
return [];
}
const parentRows = await findParentTopicRows(this.connection.db, topicRow.id);
const parentRows = await findParentTopicRows(this.connection.db, topicRow.space_id, topicRow.id);
return parentRows.map(mapTopicRow);
}
async getTopicLineage(name: string): Promise<Topic[]> {
const topicRow = await this.getRequiredTopicRow(name);
async getTopicLineage(name: string, options?: SpaceScopedInput): Promise<Topic[]> {
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) {
return [];
}
@@ -405,8 +537,7 @@ export class IdentityDB {
const nextLevelIds: string[] = [];
for (const currentId of currentLevelIds) {
const parentRows = await findParentTopicRows(this.connection.db, currentId);
const parentRows = await findParentTopicRows(this.connection.db, topicRow.space_id, currentId);
for (const parentRow of parentRows) {
if (visitedTopicIds.has(parentRow.id)) {
continue;
@@ -424,97 +555,97 @@ export class IdentityDB {
return lineage;
}
async getTopicFacts(name: string): Promise<Fact[]> {
const topicRow = await this.getRequiredTopicRow(name);
async getTopicFacts(name: string, options?: SpaceScopedInput): Promise<Fact[]> {
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) {
return [];
}
const factRows = await findFactRowsForTopicId(this.connection.db, topicRow.id);
return this.hydrateFacts(factRows);
const factRows = await findFactRowsForTopicId(this.connection.db, topicRow.space_id, topicRow.id);
return this.hydrateFacts(factRows, topicRow.space_id);
}
async getTopicFactsLinkedTo(name: string, linkedTopicName: string): Promise<Fact[]> {
return this.findFactsConnectingTopics([name, linkedTopicName]);
async getTopicFactsLinkedTo(name: string, linkedTopicName: string, options?: SpaceScopedInput): Promise<Fact[]> {
return this.findFactsConnectingTopics([name, linkedTopicName], options);
}
async findFactsConnectingTopics(names: string[]): Promise<Fact[]> {
async findFactsConnectingTopics(names: string[], options?: SpaceScopedInput): Promise<Fact[]> {
if (names.length === 0) {
return [];
}
const topicRows = await Promise.all(names.map((name) => this.getRequiredTopicRow(name)));
const space = await this.getSpaceForRead(options?.spaceName);
if (options?.spaceName && !space) {
return [];
}
const topicRows = await Promise.all(names.map((name) => this.getRequiredTopicRow(name, options?.spaceName)));
if (topicRows.some((topicRow) => topicRow === undefined)) {
return [];
}
const topicIds = topicRows.map((topicRow) => topicRow!.id);
const factRows = await findFactRowsConnectingTopicIds(this.connection.db, topicIds);
const spaceId = topicRows[0]!.space_id ?? space?.id;
const factRows = await findFactRowsConnectingTopicIds(this.connection.db, spaceId, topicIds);
return this.hydrateFacts(factRows);
return this.hydrateFacts(factRows, spaceId);
}
async getTopicByName(
name: string,
options: { includeFacts: true },
): Promise<TopicWithFacts | null>;
async getTopicByName(name: string, options: { includeFacts: true; spaceName?: string }): Promise<TopicWithFacts | null>;
async getTopicByName(name: string, options?: TopicLookupOptions): Promise<Topic | null>;
async getTopicByName(
name: string,
options?: TopicLookupOptions,
): Promise<Topic | TopicWithFacts | null> {
const topicRow = await this.getRequiredTopicRow(name);
async getTopicByName(name: string, options?: TopicLookupOptions): Promise<Topic | TopicWithFacts | null> {
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) {
return null;
}
const topic = mapTopicRow(topicRow);
if (options?.includeFacts) {
return {
...topic,
facts: await this.getTopicFacts(name),
facts: await this.getTopicFacts(name, { spaceName: options.spaceName }),
};
}
return topic;
}
async listTopics(options: { includeFacts: true; limit?: number }): Promise<TopicWithFacts[]>;
async listTopics(options: { includeFacts: true; limit?: number; spaceName?: string }): Promise<TopicWithFacts[]>;
async listTopics(options?: ListTopicsOptions): Promise<Topic[]>;
async listTopics(
options?: ListTopicsOptions,
): Promise<Topic[] | TopicWithFacts[]> {
const rows = await listTopicRows(this.connection.db, options?.limit);
async listTopics(options?: ListTopicsOptions): Promise<Topic[] | TopicWithFacts[]> {
const space = await this.getSpaceForRead(options?.spaceName);
if (options?.spaceName && !space) {
return [];
}
const spaceId = space?.id ?? await this.getDefaultSpaceIdForRead();
if (!spaceId) {
return [];
}
const rows = await listTopicRows(this.connection.db, spaceId, options?.limit);
if (!options?.includeFacts) {
return rows.map(mapTopicRow);
}
const topicsWithFacts: TopicWithFacts[] = [];
for (const row of rows) {
topicsWithFacts.push({
...mapTopicRow(row),
facts: await this.getTopicFacts(row.name),
facts: await this.getTopicFacts(row.name, { spaceName: options?.spaceName }),
});
}
return topicsWithFacts;
}
async findConnectedTopics(name: string): Promise<ConnectedTopic[]> {
const topicRow = await this.getRequiredTopicRow(name);
async findConnectedTopics(name: string, options?: SpaceScopedInput): Promise<ConnectedTopic[]> {
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) {
return [];
}
const rows = await findConnectedTopicRows(this.connection.db, topicRow.id);
const rows = await findConnectedTopicRows(this.connection.db, topicRow.space_id, topicRow.id);
return rows.map((row) => ({
...mapTopicRow(row),
sharedFactCount: row.shared_fact_count,
@@ -527,18 +658,25 @@ export class IdentityDB {
topicNames?: string[] | undefined;
limit?: number | undefined;
minimumScore?: number | undefined;
spaceId?: string | undefined;
}): Promise<ScoredFact[]> {
const topicIds = await this.resolveTopicIds(input.topicNames);
const effectiveSpaceId = input.spaceId ?? await this.getDefaultSpaceIdForRead();
if (!effectiveSpaceId) {
return [];
}
const topicIds = await this.resolveTopicIds(input.topicNames, effectiveSpaceId);
if (topicIds === null) {
return [];
}
const factRows = topicIds.length > 0
? await findFactRowsConnectingTopicIds(this.connection.db, topicIds)
? await findFactRowsConnectingTopicIds(this.connection.db, effectiveSpaceId, topicIds)
: await this.connection.db
.selectFrom('facts')
.innerJoin('fact_embeddings', 'fact_embeddings.fact_id', 'facts.id')
.selectAll('facts')
.where('facts.space_id', '=', effectiveSpaceId)
.where('fact_embeddings.model', '=', input.providerModel)
.orderBy('facts.created_at', 'asc')
.execute();
@@ -547,14 +685,14 @@ export class IdentityDB {
return [];
}
const embeddingRowsQuery = this.connection.db
const embeddingRows = await this.connection.db
.selectFrom('fact_embeddings')
.selectAll()
.where('model', '=', input.providerModel);
const embeddingRows = factRows.length > 0
? await embeddingRowsQuery.where('fact_id', 'in', factRows.map((factRow) => factRow.id)).execute()
: [];
.innerJoin('facts', 'facts.id', 'fact_embeddings.fact_id')
.selectAll('fact_embeddings')
.where('facts.space_id', '=', effectiveSpaceId)
.where('fact_embeddings.model', '=', input.providerModel)
.where('fact_embeddings.fact_id', 'in', factRows.map((factRow) => factRow.id))
.execute();
const embeddingsByFactId = new Map(
embeddingRows.map((embeddingRow) => [embeddingRow.fact_id, deserializeEmbedding(embeddingRow.embedding)]),
@@ -578,7 +716,7 @@ export class IdentityDB {
return [];
}
const hydratedFacts = await this.hydrateFacts(scoredRows.map((entry) => entry.factRow));
const hydratedFacts = await this.hydrateFacts(scoredRows.map((entry) => entry.factRow), effectiveSpaceId);
const factsById = new Map(hydratedFacts.map((fact) => [fact.id, fact]));
return scoredRows.map((entry) => ({
@@ -587,12 +725,12 @@ export class IdentityDB {
}));
}
private async resolveTopicIds(topicNames?: string[]): Promise<string[] | null> {
private async resolveTopicIds(topicNames: string[] | undefined, spaceId: string): Promise<string[] | null> {
if (!topicNames || topicNames.length === 0) {
return [];
}
const topicRows = await Promise.all(topicNames.map((topicName) => this.getRequiredTopicRow(topicName)));
const topicRows = await Promise.all(topicNames.map((topicName) => this.getRequiredTopicRowInSpaceId(topicName, spaceId)));
if (topicRows.some((topicRow) => !topicRow)) {
return null;
}
@@ -637,30 +775,28 @@ export class IdentityDB {
}
}
private async upsertTopicInExecutor(
executor: DatabaseExecutor,
input: UpsertTopicInput,
): Promise<Topic> {
private async upsertTopicInExecutor(executor: DatabaseExecutor, input: UpsertTopicInput): Promise<Topic> {
const normalizedName = normalizeTopicName(input.name);
if (normalizedName.length === 0) {
throw new IdentityDBError('Topic name cannot be empty.');
}
const existing = await findTopicRowByNormalizedName(executor, normalizedName);
const space = await this.getOrCreateSpaceInExecutor(executor, input.spaceName);
const existing = await findTopicRowByNormalizedName(executor, space.id, normalizedName);
const now = nowIsoString();
if (existing) {
return this.updateTopicRowInExecutor(executor, existing, input, now, true);
}
const aliasedTopic = await findTopicRowByNormalizedAlias(executor, normalizedName);
const aliasedTopic = await findTopicRowByNormalizedAlias(executor, space.id, normalizedName);
if (aliasedTopic) {
return this.updateTopicRowInExecutor(executor, aliasedTopic, input, now, false);
}
const createdRow: TopicRecord = {
id: createId(),
space_id: space.id,
name: canonicalizeTopicName(input.name),
normalized_name: normalizedName,
category: input.category ?? 'custom',
@@ -672,7 +808,6 @@ export class IdentityDB {
};
await executor.insertInto('topics').values(createdRow).execute();
return mapTopicRow(createdRow);
}
@@ -705,22 +840,39 @@ export class IdentityDB {
return mapTopicRow(updated);
}
private async getRequiredTopicRow(name: string): Promise<TopicRecord | undefined> {
const normalizedName = normalizeTopicName(name);
private async getRequiredTopicRow(name: string, spaceName?: string): Promise<TopicRecord | undefined> {
const space = await this.getSpaceForRead(spaceName);
if (spaceName && !space) {
return undefined;
}
const spaceId = space?.id ?? await this.getDefaultSpaceIdForRead();
if (!spaceId) {
return undefined;
}
return this.getRequiredTopicRowInSpaceId(name, spaceId);
}
private async getRequiredTopicRowInSpaceId(name: string, spaceId: string): Promise<TopicRecord | undefined> {
const normalizedName = normalizeTopicName(name);
if (normalizedName.length === 0) {
return undefined;
}
return findTopicRowByNameOrAlias(this.connection.db, normalizedName);
return findTopicRowByNameOrAlias(this.connection.db, spaceId, normalizedName);
}
private async hydrateFacts(factRows: FactRecord[]): Promise<Fact[]> {
private async hydrateFacts(factRows: FactRecord[], spaceId?: string): Promise<Fact[]> {
if (factRows.length === 0) {
return [];
}
const effectiveSpaceId = spaceId ?? factRows[0]!.space_id;
const factIds = factRows.map((fact) => fact.id);
const topicLinks = await findTopicLinksForFactIds(this.connection.db, factIds);
const topicLinks = await findTopicLinksForFactIds(this.connection.db, effectiveSpaceId, factIds);
const topicsByFactId = new Map<string, FactTopic[]>();
for (const topicLink of topicLinks) {
const topics = topicsByFactId.get(topicLink.fact_id) ?? [];
topics.push({
@@ -733,4 +885,57 @@ export class IdentityDB {
return factRows.map((factRow) => mapFactRow(factRow, topicsByFactId.get(factRow.id) ?? []));
}
private async getOrCreateSpaceInExecutor(executor: DatabaseExecutor, requestedSpaceName?: string): Promise<SpaceRecord> {
const normalizedName = normalizeSpaceName(requestedSpaceName ?? DEFAULT_SPACE_NAME);
const canonicalName = canonicalizeSpaceName(requestedSpaceName ?? DEFAULT_SPACE_NAME);
const existing = await findSpaceRowByNormalizedName(executor, normalizedName);
if (existing) {
return existing;
}
const now = nowIsoString();
const createdRow: SpaceRecord = {
id: createId(),
name: canonicalName,
normalized_name: normalizedName,
description: null,
metadata: null,
created_at: now,
updated_at: now,
};
await executor.insertInto('spaces').values(createdRow).execute();
return createdRow;
}
private async getSpaceForRead(spaceName?: string): Promise<SpaceRecord | undefined> {
if (!spaceName) {
return undefined;
}
const normalizedName = normalizeSpaceName(spaceName);
if (normalizedName.length === 0) {
return undefined;
}
return findSpaceRowByNormalizedName(this.connection.db, normalizedName);
}
private async getDefaultSpaceIdForRead(): Promise<string | undefined> {
const defaultSpace = await findSpaceRowByNormalizedName(this.connection.db, normalizeSpaceName(DEFAULT_SPACE_NAME));
return defaultSpace?.id;
}
private assertScopedTopicInput(space: SpaceRecord, topicSpaceName?: string): void {
if (!topicSpaceName) {
return;
}
if (normalizeSpaceName(topicSpaceName) !== space.normalized_name) {
throw new IdentityDBError(
`Fact topics cannot point to a different space than the fact itself (${space.name}).`,
);
}
}
}

View File

@@ -4,6 +4,7 @@ import {
FACTS_TABLE,
FACT_EMBEDDINGS_TABLE,
FACT_TOPICS_TABLE,
SPACES_TABLE,
TOPIC_ALIASES_TABLE,
TOPIC_RELATIONS_TABLE,
TOPICS_TABLE,
@@ -14,23 +15,42 @@ export async function initializeSchema(
db: Kysely<IdentityDatabaseSchema>,
): Promise<void> {
await db.schema
.createTable(TOPICS_TABLE)
.createTable(SPACES_TABLE)
.ifNotExists()
.addColumn('id', 'text', (column) => column.primaryKey())
.addColumn('name', 'text', (column) => column.notNull())
.addColumn('normalized_name', 'text', (column) => column.notNull().unique())
.addColumn('category', 'text', (column) => column.notNull())
.addColumn('granularity', 'text', (column) => column.notNull())
.addColumn('description', 'text')
.addColumn('metadata', 'text')
.addColumn('created_at', 'text', (column) => column.notNull())
.addColumn('updated_at', 'text', (column) => column.notNull())
.execute();
await db.schema
.createTable(TOPICS_TABLE)
.ifNotExists()
.addColumn('id', 'text', (column) => column.primaryKey())
.addColumn('space_id', 'text', (column) =>
column.notNull().references(`${SPACES_TABLE}.id`).onDelete('cascade'),
)
.addColumn('name', 'text', (column) => column.notNull())
.addColumn('normalized_name', 'text', (column) => column.notNull())
.addColumn('category', 'text', (column) => column.notNull())
.addColumn('granularity', 'text', (column) => column.notNull())
.addColumn('description', 'text')
.addColumn('metadata', 'text')
.addColumn('created_at', 'text', (column) => column.notNull())
.addColumn('updated_at', 'text', (column) => column.notNull())
.addUniqueConstraint('topics_space_normalized_name_key', ['space_id', 'normalized_name'])
.execute();
await db.schema
.createTable(FACTS_TABLE)
.ifNotExists()
.addColumn('id', 'text', (column) => column.primaryKey())
.addColumn('space_id', 'text', (column) =>
column.notNull().references(`${SPACES_TABLE}.id`).onDelete('cascade'),
)
.addColumn('statement', 'text', (column) => column.notNull())
.addColumn('summary', 'text')
.addColumn('source', 'text')
@@ -88,14 +108,32 @@ export async function initializeSchema(
.createTable(TOPIC_ALIASES_TABLE)
.ifNotExists()
.addColumn('id', 'text', (column) => column.primaryKey())
.addColumn('space_id', 'text', (column) =>
column.notNull().references(`${SPACES_TABLE}.id`).onDelete('cascade'),
)
.addColumn('topic_id', 'text', (column) =>
column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'),
)
.addColumn('alias', 'text', (column) => column.notNull())
.addColumn('normalized_alias', 'text', (column) => column.notNull().unique())
.addColumn('normalized_alias', 'text', (column) => column.notNull())
.addColumn('is_primary', 'integer', (column) => column.notNull())
.addColumn('created_at', 'text', (column) => column.notNull())
.addColumn('updated_at', 'text', (column) => column.notNull())
.addUniqueConstraint('topic_aliases_space_normalized_alias_key', ['space_id', 'normalized_alias'])
.execute();
await db.schema
.createIndex('topics_space_id_idx')
.ifNotExists()
.on(TOPICS_TABLE)
.column('space_id')
.execute();
await db.schema
.createIndex('facts_space_id_idx')
.ifNotExists()
.on(FACTS_TABLE)
.column('space_id')
.execute();
await db.schema
@@ -133,6 +171,13 @@ export async function initializeSchema(
.column('child_topic_id')
.execute();
await db.schema
.createIndex('topic_aliases_space_id_idx')
.ifNotExists()
.on(TOPIC_ALIASES_TABLE)
.column('space_id')
.execute();
await db.schema
.createIndex('topic_aliases_topic_id_idx')
.ifNotExists()

View File

@@ -1,3 +1,4 @@
export const SPACES_TABLE = 'spaces';
export const TOPICS_TABLE = 'topics';
export const FACTS_TABLE = 'facts';
export const FACT_TOPICS_TABLE = 'fact_topics';
@@ -5,8 +6,19 @@ export const TOPIC_RELATIONS_TABLE = 'topic_relations';
export const TOPIC_ALIASES_TABLE = 'topic_aliases';
export const FACT_EMBEDDINGS_TABLE = 'fact_embeddings';
export const SPACE_COLUMNS = [
'id',
'name',
'normalized_name',
'description',
'metadata',
'created_at',
'updated_at',
] as const;
export const TOPIC_COLUMNS = [
'id',
'space_id',
'name',
'normalized_name',
'category',
@@ -19,6 +31,7 @@ export const TOPIC_COLUMNS = [
export const FACT_COLUMNS = [
'id',
'space_id',
'statement',
'summary',
'source',
@@ -45,6 +58,7 @@ export const TOPIC_RELATION_COLUMNS = [
export const TOPIC_ALIAS_COLUMNS = [
'id',
'space_id',
'topic_id',
'alias',
'normalized_alias',

View File

@@ -1,7 +1,7 @@
import { createHash, randomUUID } from 'node:crypto';
import type { Fact, FactTopic, Topic } from '../types/api';
import type { FactRecord, TopicRecord } from '../types/domain';
import type { Fact, FactTopic, Space, Topic } from '../types/api';
import type { FactRecord, SpaceRecord, TopicRecord } from '../types/domain';
export function normalizeTopicName(name: string): string {
return name.trim().replace(/\s+/g, ' ').toLowerCase();
@@ -11,6 +11,14 @@ export function canonicalizeTopicName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
export function normalizeSpaceName(name: string): string {
return normalizeTopicName(name);
}
export function canonicalizeSpaceName(name: string): string {
return canonicalizeTopicName(name);
}
export function nowIsoString(): string {
return new Date().toISOString();
}
@@ -71,9 +79,22 @@ export function cosineSimilarity(left: number[], right: number[]): number {
return dot / (Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude));
}
export function mapSpaceRow(record: SpaceRecord): Space {
return {
id: record.id,
name: record.name,
normalizedName: record.normalized_name,
description: record.description,
metadata: deserializeMetadata(record.metadata) as Space['metadata'],
createdAt: record.created_at,
updatedAt: record.updated_at,
};
}
export function mapTopicRow(record: TopicRecord): Topic {
return {
id: record.id,
spaceId: record.space_id,
name: record.name,
normalizedName: record.normalized_name,
category: record.category,
@@ -88,6 +109,7 @@ export function mapTopicRow(record: TopicRecord): Topic {
export function mapFactRow(record: FactRecord, topics: FactTopic[]): Fact {
return {
id: record.id,
spaceId: record.space_id,
statement: record.statement,
summary: record.summary,
source: record.source,

View File

@@ -2,11 +2,15 @@ import { IdentityDBError } from '../core/errors';
import { normalizeTopicName } from '../core/utils';
import type { FactExtractor, ExtractedFact } from './types';
export async function extractFact(
export async function extractFacts(
input: string,
extractor: FactExtractor,
): Promise<ExtractedFact> {
): Promise<ExtractedFact[]> {
const extracted = await extractor.extract(input);
return extracted.map((fact) => validateAndNormalizeFact(input, fact));
}
function validateAndNormalizeFact(input: string, extracted: ExtractedFact): ExtractedFact {
const statement = extracted.statement?.trim() || input.trim();
if (statement.length === 0) {
@@ -31,12 +35,12 @@ export async function extractFact(
throw new IdentityDBError('Extractor returned no usable topics.');
}
return {
statement,
summary: extracted.summary ?? null,
source: extracted.source ?? null,
confidence: extracted.confidence ?? null,
metadata: extracted.metadata ?? null,
topics: Array.from(dedupedTopics.values()),
};
return {
statement,
summary: extracted.summary ?? null,
source: extracted.source ?? null,
confidence: extracted.confidence ?? null,
metadata: extracted.metadata ?? null,
topics: Array.from(dedupedTopics.values()),
};
}

View File

@@ -1,273 +1,27 @@
import { IdentityDBError } from '../core/errors';
import type { TopicCategory, TopicGranularity } from '../types/domain';
import type {
ExtractedFact,
FactExtractor,
LlmFactExtractorOptions,
} from './types';
} from "./types";
const DEFAULT_INSTRUCTIONS = [
'Extract one structured fact from the user input.',
'Return JSON only. Do not include markdown, explanations, or prose outside the JSON object.',
'Use this shape: {"statement": string?, "summary": string|null, "source": string|null, "confidence": number|null, "metadata": object|null, "topics": Array<{"name": string, "category": "entity"|"concept"|"temporal"|"custom"?, "granularity": "abstract"|"concrete"|"mixed"?, "role": string|null, "description": string|null, "metadata": object|null}>}.',
'Only include topics that are explicitly supported by the input.',
].join('\n');
const DEFAULT_INSTRUCTIONS = `You are an information extraction assistant. Focus on strictly controlling the granularity of the extracted information based on the following rules:
1. **Atomic Statement**:
- Each statement must be a single, short, and concise sentence containing only ONE discrete fact.
- Never merge multiple events, reasons, or background stories using conjunctions. If there are multiple details, break them down into separate extractions.
2. **Distinct Topics**:
- A statement can have multiple topics associated with it.
- However, each topic must be a single, distinct concept or entity. Do not combine multiple concepts into one topic (e.g., do not use compound nouns like "A and B" or "X for Y"). Every single concept must be separated into its own distinct topic entry.`;
export class LlmFactExtractor implements FactExtractor {
constructor(private readonly options: LlmFactExtractorOptions) {}
async extract(input: string): Promise<ExtractedFact> {
const prompt = this.buildPrompt(input);
const response = await this.options.model.generateText(prompt);
return parseLlmExtractedFactResponse(response);
}
private buildPrompt(input: string): string {
if (this.options.promptBuilder) {
return this.options.promptBuilder(input, this.options.instructions);
}
const instructions = this.options.instructions?.trim();
return [
DEFAULT_INSTRUCTIONS,
instructions && instructions.length > 0 ? `Additional instructions:\n${instructions}` : null,
`Input:\n${input.trim()}`,
]
.filter((value): value is string => value !== null)
.join('\n\n');
async extract(input: string): Promise<ExtractedFact[]> {
return this.options.model.generateText({
instruction: DEFAULT_INSTRUCTIONS,
input,
additionalInstruction: this.options.additionalInstructions,
});
}
}
export function parseLlmExtractedFactResponse(response: string): ExtractedFact {
const payload = parseJsonCandidate(response);
if (!isRecord(payload)) {
throw new IdentityDBError('LLM extractor response must be a JSON object.');
}
const topics = parseTopics(payload.topics);
const extracted: ExtractedFact = { topics };
const statement = optionalString(payload.statement);
if (statement !== undefined) {
extracted.statement = statement;
}
const summary = optionalNullableString(payload.summary);
if (summary !== undefined) {
extracted.summary = summary;
}
const source = optionalNullableString(payload.source);
if (source !== undefined) {
extracted.source = source;
}
const confidence = optionalNullableNumber(payload.confidence);
if (confidence !== undefined) {
extracted.confidence = confidence;
}
const metadata = optionalMetadata(payload.metadata);
if (metadata !== undefined) {
extracted.metadata = metadata;
}
return extracted;
}
function parseJsonCandidate(response: string): unknown {
const trimmed = response.trim();
for (const candidate of collectJsonCandidates(trimmed)) {
try {
return JSON.parse(candidate);
} catch {
continue;
}
}
throw new IdentityDBError('LLM extractor returned invalid JSON.');
}
function collectJsonCandidates(response: string): string[] {
const candidates = new Set<string>();
candidates.add(response);
const fencePattern = /```(?:json)?\s*([\s\S]*?)```/gi;
let match: RegExpExecArray | null = fencePattern.exec(response);
while (match) {
const candidate = match[1]?.trim();
if (candidate) {
candidates.add(candidate);
}
match = fencePattern.exec(response);
}
const firstBrace = response.indexOf('{');
const lastBrace = response.lastIndexOf('}');
if (firstBrace >= 0 && lastBrace > firstBrace) {
candidates.add(response.slice(firstBrace, lastBrace + 1));
}
return Array.from(candidates);
}
function parseTopics(value: unknown): ExtractedFact['topics'] {
if (!Array.isArray(value)) {
throw new IdentityDBError('LLM extractor response must include a topics array.');
}
return value.map((entry) => parseTopic(entry));
}
function parseTopic(value: unknown): ExtractedFact['topics'][number] {
if (!isRecord(value)) {
throw new IdentityDBError('LLM extractor topics must be JSON objects.');
}
const name = optionalString(value.name)?.trim();
if (!name) {
throw new IdentityDBError('LLM extractor topics must include a non-empty name.');
}
const topic: ExtractedFact['topics'][number] = { name };
const category = optionalTopicCategory(value.category);
if (category !== undefined) {
topic.category = category;
}
const granularity = optionalTopicGranularity(value.granularity);
if (granularity !== undefined) {
topic.granularity = granularity;
}
const role = optionalNullableString(value.role);
if (role !== undefined) {
topic.role = role;
}
const description = optionalNullableString(value.description);
if (description !== undefined) {
topic.description = description;
}
const metadata = optionalMetadata(value.metadata);
if (metadata !== undefined) {
topic.metadata = metadata;
}
return topic;
}
function optionalString(value: unknown): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string') {
throw new IdentityDBError('LLM extractor expected a string field.');
}
return value;
}
function optionalNullableString(value: unknown): string | null | undefined {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (typeof value !== 'string') {
throw new IdentityDBError('LLM extractor expected a nullable string field.');
}
return value;
}
function optionalNullableNumber(value: unknown): number | null | undefined {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new IdentityDBError('LLM extractor expected confidence to be a number or null.');
}
return value;
}
function optionalMetadata(value: unknown): ExtractedFact['metadata'] | undefined {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (!isJsonLike(value)) {
throw new IdentityDBError('LLM extractor metadata must be valid JSON-compatible data.');
}
return value as ExtractedFact['metadata'];
}
function optionalTopicCategory(value: unknown): TopicCategory | undefined {
if (value === undefined) {
return undefined;
}
if (value === 'entity' || value === 'concept' || value === 'temporal' || value === 'custom') {
return value;
}
throw new IdentityDBError('LLM extractor returned an unsupported topic category.');
}
function optionalTopicGranularity(value: unknown): TopicGranularity | undefined {
if (value === undefined) {
return undefined;
}
if (value === 'abstract' || value === 'concrete' || value === 'mixed') {
return value;
}
throw new IdentityDBError('LLM extractor returned an unsupported topic granularity.');
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isJsonLike(value: unknown): boolean {
if (value === null) {
return true;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return true;
}
if (Array.isArray(value)) {
return value.every((entry) => isJsonLike(entry));
}
if (isRecord(value)) {
return Object.values(value).every((entry) => isJsonLike(entry));
}
return false;
}

View File

@@ -1,7 +1,7 @@
import type { ExtractedFact, FactExtractor } from './types';
export class NaiveExtractor implements FactExtractor {
async extract(input: string): Promise<ExtractedFact> {
async extract(input: string): Promise<ExtractedFact[]> {
const topics: ExtractedFact['topics'] = [];
const seen = new Set<string>();
const tokens = input.match(/\bI\b|\b\d{4}\b|\b[A-Z][A-Za-z0-9+#.-]*\b/g) ?? [];
@@ -31,9 +31,11 @@ export class NaiveExtractor implements FactExtractor {
});
}
return {
statement: input.trim(),
topics,
};
return [
{
statement: input.trim(),
topics,
},
];
}
}

View File

@@ -2,33 +2,39 @@ import type {
AddFactInput,
EmbeddingProvider,
TopicLinkInput,
} from '../types/api';
} from "../types/api";
export interface ExtractedFact {
statement?: string;
summary?: string | null;
source?: string | null;
confidence?: number | null;
metadata?: AddFactInput['metadata'];
metadata?: AddFactInput["metadata"];
topics: TopicLinkInput[];
}
export interface FactExtractor {
extract(input: string): Promise<ExtractedFact>;
extract(input: string): Promise<ExtractedFact[]>;
}
export interface LlmTextGenerationModelInput {
instruction: string;
input: string;
additionalInstruction?: string | undefined;
}
export interface LlmTextGenerationModel {
generateText(prompt: string): Promise<string>;
generateText(prompt: LlmTextGenerationModelInput): Promise<ExtractedFact[]>;
}
export interface LlmFactExtractorOptions {
model: LlmTextGenerationModel;
instructions?: string;
promptBuilder?: (input: string, instructions?: string) => string;
additionalInstructions?: string | undefined;
}
export interface IngestStatementOptions {
extractor: FactExtractor;
embeddingProvider?: EmbeddingProvider;
duplicateThreshold?: number;
spaceName?: string;
}

View File

@@ -13,12 +13,14 @@ export interface FactTopicJoinRow extends TopicRecord {
export async function findFactRowsForTopicId(
executor: DatabaseExecutor,
spaceId: string,
topicId: string,
): Promise<FactRecord[]> {
return executor
.selectFrom('facts')
.innerJoin('fact_topics', 'fact_topics.fact_id', 'facts.id')
.selectAll('facts')
.where('facts.space_id', '=', spaceId)
.where('fact_topics.topic_id', '=', topicId)
.orderBy('facts.created_at', 'asc')
.execute();
@@ -26,6 +28,7 @@ export async function findFactRowsForTopicId(
export async function findFactRowsConnectingTopicIds(
executor: DatabaseExecutor,
spaceId: string,
topicIds: string[],
): Promise<FactRecord[]> {
if (topicIds.length === 0) {
@@ -36,6 +39,7 @@ export async function findFactRowsConnectingTopicIds(
.selectFrom('facts')
.innerJoin('fact_topics', 'fact_topics.fact_id', 'facts.id')
.selectAll('facts')
.where('facts.space_id', '=', spaceId)
.where('fact_topics.topic_id', 'in', topicIds)
.groupBy('facts.id')
.having((eb) => eb.fn.count<number>('fact_topics.topic_id'), '=', topicIds.length)
@@ -45,6 +49,7 @@ export async function findFactRowsConnectingTopicIds(
export async function findTopicLinksForFactIds(
executor: DatabaseExecutor,
spaceId: string,
factIds: string[],
): Promise<FactTopicJoinRow[]> {
if (factIds.length === 0) {
@@ -60,6 +65,7 @@ export async function findTopicLinksForFactIds(
'fact_topics.role as role',
'fact_topics.position as position',
])
.where('topics.space_id', '=', spaceId)
.where('fact_topics.fact_id', 'in', factIds)
.orderBy('fact_topics.position', 'asc')
.execute() as Promise<FactTopicJoinRow[]>;

View File

@@ -1,7 +1,7 @@
import type { Kysely, Transaction } from 'kysely';
import type { IdentityDatabaseSchema } from '../types/database';
import type { TopicAliasRecord, TopicRecord } from '../types/domain';
import type { SpaceRecord, TopicAliasRecord, TopicRecord } from '../types/domain';
export type DatabaseExecutor = Kysely<IdentityDatabaseSchema> | Transaction<IdentityDatabaseSchema>;
@@ -9,48 +9,66 @@ export interface ConnectedTopicRow extends TopicRecord {
shared_fact_count: number;
}
export async function findSpaceRowByNormalizedName(
executor: DatabaseExecutor,
normalizedName: string,
): Promise<SpaceRecord | undefined> {
return executor
.selectFrom('spaces')
.selectAll()
.where('normalized_name', '=', normalizedName)
.executeTakeFirst();
}
export async function findTopicRowByNormalizedName(
executor: DatabaseExecutor,
spaceId: string,
normalizedName: string,
): Promise<TopicRecord | undefined> {
return executor
.selectFrom('topics')
.selectAll()
.where('space_id', '=', spaceId)
.where('normalized_name', '=', normalizedName)
.executeTakeFirst();
}
export async function findTopicRowByNormalizedAlias(
executor: DatabaseExecutor,
spaceId: string,
normalizedAlias: string,
): Promise<TopicRecord | undefined> {
return executor
.selectFrom('topic_aliases')
.innerJoin('topics', 'topics.id', 'topic_aliases.topic_id')
.selectAll('topics')
.where('topic_aliases.space_id', '=', spaceId)
.where('topic_aliases.normalized_alias', '=', normalizedAlias)
.executeTakeFirst();
}
export async function findTopicRowByNameOrAlias(
executor: DatabaseExecutor,
spaceId: string,
normalizedName: string,
): Promise<TopicRecord | undefined> {
const directMatch = await findTopicRowByNormalizedName(executor, normalizedName);
const directMatch = await findTopicRowByNormalizedName(executor, spaceId, normalizedName);
if (directMatch) {
return directMatch;
}
return findTopicRowByNormalizedAlias(executor, normalizedName);
return findTopicRowByNormalizedAlias(executor, spaceId, normalizedName);
}
export async function listTopicAliasRowsForTopicId(
executor: DatabaseExecutor,
spaceId: string,
topicId: string,
): Promise<TopicAliasRecord[]> {
return executor
.selectFrom('topic_aliases')
.selectAll()
.where('space_id', '=', spaceId)
.where('topic_id', '=', topicId)
.orderBy('is_primary', 'desc')
.orderBy('normalized_alias', 'asc')
@@ -59,9 +77,14 @@ export async function listTopicAliasRowsForTopicId(
export async function listTopicRows(
executor: DatabaseExecutor,
spaceId: string,
limit?: number,
): Promise<TopicRecord[]> {
let query = executor.selectFrom('topics').selectAll().orderBy('normalized_name', 'asc');
let query = executor
.selectFrom('topics')
.selectAll()
.where('space_id', '=', spaceId)
.orderBy('normalized_name', 'asc');
if (limit !== undefined) {
query = query.limit(limit);
@@ -72,14 +95,18 @@ export async function listTopicRows(
export async function findConnectedTopicRows(
executor: DatabaseExecutor,
spaceId: string,
topicId: string,
): Promise<ConnectedTopicRow[]> {
return executor
.selectFrom('fact_topics as source_link')
.innerJoin('facts', 'facts.id', 'source_link.fact_id')
.innerJoin('fact_topics as related_link', 'related_link.fact_id', 'source_link.fact_id')
.innerJoin('topics', 'topics.id', 'related_link.topic_id')
.selectAll('topics')
.select((eb) => eb.fn.count<number>('related_link.fact_id').as('shared_fact_count'))
.where('facts.space_id', '=', spaceId)
.where('topics.space_id', '=', spaceId)
.where('source_link.topic_id', '=', topicId)
.whereRef('related_link.topic_id', '!=', 'source_link.topic_id')
.groupBy('topics.id')
@@ -90,12 +117,14 @@ export async function findConnectedTopicRows(
export async function findChildTopicRows(
executor: DatabaseExecutor,
spaceId: string,
parentTopicId: string,
): Promise<TopicRecord[]> {
return executor
.selectFrom('topic_relations')
.innerJoin('topics', 'topics.id', 'topic_relations.child_topic_id')
.selectAll('topics')
.where('topics.space_id', '=', spaceId)
.where('topic_relations.parent_topic_id', '=', parentTopicId)
.where('topic_relations.relation', '=', 'parent_of')
.orderBy('topics.normalized_name', 'asc')
@@ -104,12 +133,14 @@ export async function findChildTopicRows(
export async function findParentTopicRows(
executor: DatabaseExecutor,
spaceId: string,
childTopicId: string,
): Promise<TopicRecord[]> {
return executor
.selectFrom('topic_relations')
.innerJoin('topics', 'topics.id', 'topic_relations.parent_topic_id')
.selectAll('topics')
.where('topics.space_id', '=', spaceId)
.where('topic_relations.child_topic_id', '=', childTopicId)
.where('topic_relations.relation', '=', 'parent_of')
.orderBy('topics.normalized_name', 'asc')

View File

@@ -1,6 +1,26 @@
import type { JsonValue, TopicCategory, TopicGranularity } from './domain';
export interface UpsertTopicInput {
export interface SpaceScopedInput {
spaceName?: string | undefined;
}
export interface UpsertSpaceInput {
name: string;
description?: string | null;
metadata?: JsonValue | null;
}
export interface Space {
id: string;
name: string;
normalizedName: string;
description: string | null;
metadata: JsonValue | null;
createdAt: string;
updatedAt: string;
}
export interface UpsertTopicInput extends SpaceScopedInput {
name: string;
category?: TopicCategory;
granularity?: TopicGranularity;
@@ -12,7 +32,7 @@ export interface TopicLinkInput extends UpsertTopicInput {
role?: string | null;
}
export interface AddFactInput {
export interface AddFactInput extends SpaceScopedInput {
statement: string;
summary?: string | null;
source?: string | null;
@@ -21,13 +41,14 @@ export interface AddFactInput {
topics: TopicLinkInput[];
}
export interface LinkTopicsInput {
export interface LinkTopicsInput extends SpaceScopedInput {
parentName: string;
childName: string;
}
export interface Topic {
id: string;
spaceId: string;
name: string;
normalizedName: string;
category: TopicCategory;
@@ -45,6 +66,7 @@ export interface FactTopic extends Topic {
export interface Fact {
id: string;
spaceId: string;
statement: string;
summary: string | null;
source: string | null;
@@ -63,11 +85,11 @@ export interface ConnectedTopic extends Topic {
sharedFactCount: number;
}
export interface TopicLookupOptions {
export interface TopicLookupOptions extends SpaceScopedInput {
includeFacts?: boolean;
}
export interface ListTopicsOptions {
export interface ListTopicsOptions extends SpaceScopedInput {
includeFacts?: boolean;
limit?: number;
}
@@ -79,11 +101,11 @@ export interface EmbeddingProvider {
embedMany?(inputs: string[]): Promise<number[][]>;
}
export interface IndexFactEmbeddingsInput {
export interface IndexFactEmbeddingsInput extends SpaceScopedInput {
provider: EmbeddingProvider;
}
export interface SearchFactsInput {
export interface SearchFactsInput extends SpaceScopedInput {
query: string;
provider: EmbeddingProvider;
topicNames?: string[];
@@ -91,7 +113,7 @@ export interface SearchFactsInput {
minimumScore?: number;
}
export interface FindSimilarFactsInput {
export interface FindSimilarFactsInput extends SpaceScopedInput {
statement: string;
provider: EmbeddingProvider;
topicNames?: string[];

View File

@@ -2,12 +2,14 @@ import type {
FactEmbeddingRecord,
FactRecord,
FactTopicRecord,
SpaceRecord,
TopicAliasRecord,
TopicRecord,
TopicRelationRecord,
} from './domain';
export interface IdentityDatabaseSchema {
spaces: SpaceRecord;
topics: TopicRecord;
facts: FactRecord;
fact_topics: FactTopicRecord;

View File

@@ -5,8 +5,19 @@ export type TopicGranularity = 'abstract' | 'concrete' | 'mixed';
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export interface SpaceRecord {
id: string;
name: string;
normalized_name: string;
description: string | null;
metadata: string | null;
created_at: string;
updated_at: string;
}
export interface TopicRecord {
id: string;
space_id: string;
name: string;
normalized_name: string;
category: TopicCategory;
@@ -19,6 +30,7 @@ export interface TopicRecord {
export interface FactRecord {
id: string;
space_id: string;
statement: string;
summary: string | null;
source: string | null;
@@ -45,6 +57,7 @@ export interface TopicRelationRecord {
export interface TopicAliasRecord {
id: string;
space_id: string;
topic_id: string;
alias: string;
normalized_alias: string;

43
tests/bun-runtime.test.ts Normal file
View File

@@ -0,0 +1,43 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { describe, expect, it } from 'vitest';
const execFileAsync = promisify(execFile);
describe('Bun runtime compatibility', () => {
it('connects to sqlite from the package in Bun', async () => {
const script = [
"import { IdentityDB } from './src/index.ts';",
"const db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });",
'await db.initialize();',
"console.log('bun-sqlite-ok');",
'await db.close();',
].join('\n');
const result = await execFileAsync('bun', ['--eval', script], {
cwd: process.cwd(),
});
expect(result.stdout).toContain('bun-sqlite-ok');
});
it('supports writes and reads through the Bun sqlite adapter path', async () => {
const script = [
"import { IdentityDB } from './src/index.ts';",
"const db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });",
'await db.initialize();',
"await db.addFact({ statement: 'Bun supports sqlite.', topics: [{ name: 'Bun', category: 'entity', granularity: 'concrete' }, { name: 'sqlite', category: 'concept', granularity: 'concrete' }] });",
"const facts = await db.getTopicFacts('Bun');",
"if (facts.length !== 1 || facts[0]?.statement !== 'Bun supports sqlite.') throw new Error('bun sqlite CRUD failed');",
"console.log('bun-sqlite-crud-ok');",
'await db.close();',
].join('\n');
const result = await execFileAsync('bun', ['--eval', script], {
cwd: process.cwd(),
});
expect(result.stdout).toContain('bun-sqlite-crud-ok');
});
});

View File

@@ -34,6 +34,58 @@ describe('IdentityDB topic and fact writes', () => {
expect(topics).toHaveLength(1);
});
it('keeps same normalized topic names isolated across spaces', async () => {
const alpha = await db.upsertTopic({
name: 'TypeScript',
category: 'entity',
granularity: 'concrete',
spaceName: 'A',
});
const beta = await db.upsertTopic({
name: 'TypeScript',
category: 'entity',
granularity: 'concrete',
spaceName: 'B',
});
expect(beta.id).not.toBe(alpha.id);
const alphaTopics = await db.listTopics({ includeFacts: false, spaceName: 'A' });
const betaTopics = await db.listTopics({ includeFacts: false, spaceName: 'B' });
const defaultTopics = await db.listTopics({ includeFacts: false });
expect(alphaTopics.map((topic) => topic.name)).toEqual(['TypeScript']);
expect(betaTopics.map((topic) => topic.name)).toEqual(['TypeScript']);
expect(defaultTopics).toHaveLength(0);
});
it('keeps alias resolution scoped to the requested space', async () => {
await db.upsertTopic({
name: 'TypeScript',
category: 'entity',
granularity: 'concrete',
spaceName: 'A',
});
await db.upsertTopic({
name: 'TeamSpeak',
category: 'entity',
granularity: 'concrete',
spaceName: 'B',
});
await db.addTopicAlias('TypeScript', 'TS', { spaceName: 'A' });
await db.addTopicAlias('TeamSpeak', 'TS', { spaceName: 'B' });
const alphaResolved = await db.resolveTopic('ts', { spaceName: 'A' });
const betaResolved = await db.resolveTopic('ts', { spaceName: 'B' });
const defaultResolved = await db.resolveTopic('ts');
expect(alphaResolved?.name).toBe('TypeScript');
expect(betaResolved?.name).toBe('TeamSpeak');
expect(defaultResolved).toBeNull();
});
it('adds one fact that links multiple topics', async () => {
const fact = await db.addFact({
statement: 'I have worked with TypeScript since 2025.',

View File

@@ -1,15 +1,18 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { IdentityDB } from '../src/core/identity-db';
import { LlmFactExtractor } from '../src/ingestion/llm-extractor';
import { NaiveExtractor } from '../src/ingestion/naive-extractor';
import type { FactExtractor } from '../src/ingestion/types';
import { IdentityDB } from "../src/core/identity-db";
import { LlmFactExtractor } from "../src/ingestion/llm-extractor";
import { NaiveExtractor } from "../src/ingestion/naive-extractor";
import type {
FactExtractor,
LlmTextGenerationModelInput,
} from "../src/ingestion/types";
describe('IdentityDB ingestion', () => {
describe("IdentityDB ingestion", () => {
let db: IdentityDB;
beforeEach(async () => {
db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });
db = await IdentityDB.connect({ client: "sqlite", filename: ":memory:" });
await db.initialize();
});
@@ -17,121 +20,139 @@ describe('IdentityDB ingestion', () => {
await db.close();
});
it('ingests a statement using a provided extractor', async () => {
it("ingests a statement using a provided extractor", async () => {
const extractor: FactExtractor = {
async extract(input) {
return {
statement: input,
topics: [
{ name: 'I', category: 'entity', granularity: 'concrete', role: 'subject' },
{ name: 'TypeScript', category: 'entity', granularity: 'concrete', role: 'object' },
{ name: '2025', category: 'temporal', granularity: 'concrete', role: 'time' },
],
};
return [
{
statement: input,
topics: [
{
name: "I",
category: "entity",
granularity: "concrete",
role: "subject",
},
{
name: "TypeScript",
category: "entity",
granularity: "concrete",
role: "object",
},
{
name: "2025",
category: "temporal",
granularity: "concrete",
role: "time",
},
],
},
];
},
};
const fact = await db.ingestStatement('I have worked with TypeScript since 2025.', {
extractor,
});
const fact = await db.ingestStatement(
"I have worked with TypeScript since 2025.",
{
extractor,
},
);
expect(fact.topics.map((topic) => topic.name)).toEqual(['I', 'TypeScript', '2025']);
expect(fact.topics.map((topic) => topic.name)).toEqual([
"I",
"TypeScript",
"2025",
]);
const linkedFacts = await db.getTopicFactsLinkedTo('TypeScript', '2025');
const linkedFacts = await db.getTopicFactsLinkedTo("TypeScript", "2025");
expect(linkedFacts).toHaveLength(1);
expect(linkedFacts[0]?.statement).toBe('I have worked with TypeScript since 2025.');
expect(linkedFacts[0]?.statement).toBe(
"I have worked with TypeScript since 2025.",
);
});
it('ships a deterministic naive extractor for local usage', async () => {
const fact = await db.ingestStatement('I have worked with TypeScript since 2025.', {
extractor: new NaiveExtractor(),
});
it("ships a deterministic naive extractor for local usage", async () => {
const fact = await db.ingestStatement(
"I have worked with TypeScript since 2025.",
{
extractor: new NaiveExtractor(),
},
);
expect(fact.topics.map((topic) => topic.name)).toEqual(['I', 'TypeScript', '2025']);
expect(fact.topics.map((topic) => topic.name)).toEqual([
"I",
"TypeScript",
"2025",
]);
const topic = await db.getTopicByName('TypeScript', { includeFacts: true });
const topic = await db.getTopicByName("TypeScript", { includeFacts: true });
expect(topic?.facts).toHaveLength(1);
});
it('ships an LLM extractor adapter that turns structured JSON responses into facts', async () => {
let prompt = '';
it("ships an LLM extractor adapter that returns structured facts from the model", async () => {
let prompt: LlmTextGenerationModelInput | undefined = undefined;
const extractor = new LlmFactExtractor({
model: {
async generateText(input) {
prompt = input;
return JSON.stringify({
statement: 'I have worked with Bun and TypeScript since 2025.',
summary: 'The speaker has Bun and TypeScript experience.',
source: 'chat',
confidence: 0.91,
metadata: { channel: 'telegram' },
topics: [
{ name: 'I', category: 'entity', granularity: 'concrete', role: 'subject' },
{ name: 'Bun', category: 'entity', granularity: 'concrete', role: 'object' },
{ name: 'TypeScript', category: 'entity', granularity: 'concrete', role: 'object' },
{ name: '2025', category: 'temporal', granularity: 'concrete', role: 'time' },
],
});
},
},
instructions: 'Prefer technology and time topics.',
});
const fact = await db.ingestStatement('I have worked with Bun and TypeScript since 2025.', {
extractor,
});
expect(prompt).toContain('Prefer technology and time topics.');
expect(prompt).toContain('I have worked with Bun and TypeScript since 2025.');
expect(fact.summary).toBe('The speaker has Bun and TypeScript experience.');
expect(fact.source).toBe('chat');
expect(fact.confidence).toBe(0.91);
expect(fact.metadata).toEqual({ channel: 'telegram' });
expect(fact.topics.map((topic) => topic.name)).toEqual(['I', 'Bun', 'TypeScript', '2025']);
});
it('parses JSON responses wrapped in markdown code fences', async () => {
const extractor = new LlmFactExtractor({
model: {
async generateText() {
return [
'Here is the extracted fact:',
'```json',
JSON.stringify({
statement: 'Bun powers TypeScript tooling.',
{
statement: "I have worked with Bun and TypeScript since 2025.",
summary: "The speaker has Bun and TypeScript experience.",
source: "chat",
confidence: 0.91,
metadata: { channel: "telegram" },
topics: [
{ name: 'Bun', category: 'entity', granularity: 'concrete' },
{ name: 'TypeScript', category: 'entity', granularity: 'concrete' },
{
name: "I",
category: "entity",
granularity: "concrete",
role: "subject",
},
{
name: "Bun",
category: "entity",
granularity: "concrete",
role: "object",
},
{
name: "TypeScript",
category: "entity",
granularity: "concrete",
role: "object",
},
{
name: "2025",
category: "temporal",
granularity: "concrete",
role: "time",
},
],
}),
'```',
].join('\n');
},
];
},
},
additionalInstructions: "Prefer technology and time topics.",
});
const fact = await db.ingestStatement('Bun powers TypeScript tooling.', {
extractor,
});
expect(fact.topics.map((topic) => topic.name)).toEqual(['Bun', 'TypeScript']);
});
it('rejects invalid LLM responses before writing facts', async () => {
const extractor = new LlmFactExtractor({
model: {
async generateText() {
return 'not json at all';
},
},
});
await expect(
db.ingestStatement('Bun powers TypeScript tooling.', {
const fact = await db.ingestStatement(
"I have worked with Bun and TypeScript since 2025.",
{
extractor,
}),
).rejects.toThrow('LLM extractor returned invalid JSON.');
},
);
expect(fact.summary).toBe("The speaker has Bun and TypeScript experience.");
expect(fact.source).toBe("chat");
expect(fact.confidence).toBe(0.91);
expect(fact.metadata).toEqual({ channel: "telegram" });
expect(fact.topics.map((topic) => topic.name)).toEqual([
"I",
"Bun",
"TypeScript",
"2025",
]);
});
});

View File

@@ -16,7 +16,7 @@ afterEach(async () => {
});
describe('initializeSchema', () => {
it('creates the topics, facts, fact_embeddings, fact_topics, topic_relations, and topic_aliases tables', async () => {
it('creates the spaces, topics, facts, fact_embeddings, fact_topics, topic_relations, and topic_aliases tables', async () => {
const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' });
openConnections.push(connection.destroy);
@@ -31,6 +31,7 @@ describe('initializeSchema', () => {
const tableNames = tables.rows.map((row) => row.name);
expect(tableNames).toContain('spaces');
expect(tableNames).toContain('topics');
expect(tableNames).toContain('facts');
expect(tableNames).toContain('fact_embeddings');
@@ -45,6 +46,7 @@ describe('initializeSchema', () => {
await initializeSchema(connection.db);
const spaceColumns = await sql<{ name: string }>`PRAGMA table_info(spaces)`.execute(connection.db);
const topicsColumns = await sql<{ name: string }>`PRAGMA table_info(topics)`.execute(connection.db);
const factsColumns = await sql<{ name: string }>`PRAGMA table_info(facts)`.execute(connection.db);
const factEmbeddingsColumns = await sql<{ name: string }>`PRAGMA table_info(fact_embeddings)`.execute(connection.db);
@@ -52,8 +54,19 @@ describe('initializeSchema', () => {
const topicRelationsColumns = await sql<{ name: string }>`PRAGMA table_info(topic_relations)`.execute(connection.db);
const topicAliasesColumns = await sql<{ name: string }>`PRAGMA table_info(topic_aliases)`.execute(connection.db);
expect(spaceColumns.rows.map((row) => row.name)).toEqual([
'id',
'name',
'normalized_name',
'description',
'metadata',
'created_at',
'updated_at',
]);
expect(topicsColumns.rows.map((row) => row.name)).toEqual([
'id',
'space_id',
'name',
'normalized_name',
'category',
@@ -66,6 +79,7 @@ describe('initializeSchema', () => {
expect(factsColumns.rows.map((row) => row.name)).toEqual([
'id',
'space_id',
'statement',
'summary',
'source',
@@ -102,6 +116,7 @@ describe('initializeSchema', () => {
expect(topicAliasesColumns.rows.map((row) => row.name)).toEqual([
'id',
'space_id',
'topic_id',
'alias',
'normalized_alias',

View File

@@ -2,9 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IdentityDB } from '../src/core/identity-db';
async function seedMemoryGraph(db: IdentityDB): Promise<void> {
async function seedMemoryGraph(db: IdentityDB, spaceName?: string): Promise<void> {
await db.addFact({
statement: 'I have worked with TypeScript since 2025.',
spaceName,
topics: [
{ name: 'I', category: 'entity', granularity: 'concrete', role: 'subject' },
{ name: 'TypeScript', category: 'entity', granularity: 'concrete', role: 'object' },
@@ -14,6 +15,7 @@ async function seedMemoryGraph(db: IdentityDB): Promise<void> {
await db.addFact({
statement: 'TypeScript is a programming language.',
spaceName,
topics: [
{ name: 'TypeScript', category: 'entity', granularity: 'concrete', role: 'subject' },
{ name: 'programming language', category: 'concept', granularity: 'abstract', role: 'classification' },
@@ -23,11 +25,13 @@ async function seedMemoryGraph(db: IdentityDB): Promise<void> {
await db.linkTopics({
parentName: 'software technology',
childName: 'programming language',
spaceName,
});
await db.linkTopics({
parentName: 'programming language',
childName: 'TypeScript',
spaceName,
});
}
@@ -114,6 +118,56 @@ describe('IdentityDB queries', () => {
]);
});
it('keeps hierarchy and fact queries isolated per space', async () => {
const isolatedDb = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });
try {
await isolatedDb.initialize();
await seedMemoryGraph(isolatedDb, 'A');
await isolatedDb.addFact({
statement: 'TypeScript is a typed superset.',
spaceName: 'B',
topics: [
{ name: 'TypeScript', category: 'entity', granularity: 'concrete', role: 'subject' },
{ name: 'superset', category: 'concept', granularity: 'abstract', role: 'classification' },
],
});
await isolatedDb.linkTopics({
parentName: 'language family',
childName: 'TypeScript',
spaceName: 'B',
});
const alphaTopic = await isolatedDb.getTopicByName('TypeScript', {
includeFacts: true,
spaceName: 'A',
});
const betaTopic = await isolatedDb.getTopicByName('TypeScript', {
includeFacts: true,
spaceName: 'B',
});
const alphaParents = await isolatedDb.getTopicParents('TypeScript', { spaceName: 'A' });
const betaParents = await isolatedDb.getTopicParents('TypeScript', { spaceName: 'B' });
const alphaConnected = await isolatedDb.findConnectedTopics('TypeScript', { spaceName: 'A' });
const betaConnected = await isolatedDb.findConnectedTopics('TypeScript', { spaceName: 'B' });
expect(alphaTopic?.facts.map((fact) => fact.statement)).toEqual([
'I have worked with TypeScript since 2025.',
'TypeScript is a programming language.',
]);
expect(betaTopic?.facts.map((fact) => fact.statement)).toEqual([
'TypeScript is a typed superset.',
]);
expect(alphaParents.map((topic) => topic.name)).toEqual(['programming language']);
expect(betaParents.map((topic) => topic.name)).toEqual(['language family']);
expect(alphaConnected.map((topic) => topic.name)).toEqual(['2025', 'I', 'programming language']);
expect(betaConnected.map((topic) => topic.name)).toEqual(['superset']);
} finally {
await isolatedDb.close();
}
});
it('resolves alias names in topic lookups', async () => {
await db.addTopicAlias('TypeScript', 'TS');

View File

@@ -120,6 +120,53 @@ describe('IdentityDB semantic search', () => {
expect(matches[0]?.statement).toBe('Bun runs TypeScript tooling quickly.');
expect(matches[0]!.score).toBeGreaterThan(matches[1]!.score);
});
it('keeps semantic search isolated per space', async () => {
const isolatedDb = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });
try {
await isolatedDb.initialize();
await isolatedDb.addFact({
statement: 'Bun runs TypeScript tooling quickly.',
spaceName: 'A',
topics: [
{ name: 'Bun', category: 'entity', granularity: 'concrete' },
{ name: 'TypeScript', category: 'entity', granularity: 'concrete' },
],
});
await isolatedDb.addFact({
statement: 'TypeScript runtime tooling belongs to another tenant.',
spaceName: 'B',
topics: [
{ name: 'TypeScript', category: 'entity', granularity: 'concrete' },
],
});
await isolatedDb.indexFactEmbeddings({ provider, spaceName: 'A' });
await isolatedDb.indexFactEmbeddings({ provider, spaceName: 'B' });
const alphaMatches = await isolatedDb.searchFacts({
query: 'TypeScript runtime tooling',
provider,
spaceName: 'A',
});
const betaMatches = await isolatedDb.searchFacts({
query: 'TypeScript runtime tooling',
provider,
spaceName: 'B',
});
expect(alphaMatches.map((match) => match.statement)).toEqual([
'Bun runs TypeScript tooling quickly.',
]);
expect(betaMatches.map((match) => match.statement)).toEqual([
'TypeScript runtime tooling belongs to another tenant.',
]);
} finally {
await isolatedDb.close();
}
});
});
describe('IdentityDB dedup-aware ingestion', () => {
@@ -131,13 +178,15 @@ describe('IdentityDB dedup-aware ingestion', () => {
provider = new FakeEmbeddingProvider();
extractor = {
async extract(input) {
return {
statement: input,
topics: [
{ name: 'Bun', category: 'entity', granularity: 'concrete' },
{ name: 'TypeScript', category: 'entity', granularity: 'concrete' },
],
};
return [
{
statement: input,
topics: [
{ name: 'Bun', category: 'entity', granularity: 'concrete' },
{ name: 'TypeScript', category: 'entity', granularity: 'concrete' },
],
},
];
},
};
@@ -167,4 +216,26 @@ describe('IdentityDB dedup-aware ingestion', () => {
expect(facts).toHaveLength(1);
expect(facts[0]?.statement).toBe('Bun runs TypeScript tooling quickly.');
});
it('does not reuse a semantic duplicate from another space', async () => {
const first = await db.ingestStatement('Bun runs TypeScript tooling quickly.', {
extractor,
embeddingProvider: provider,
spaceName: 'A',
});
const second = await db.ingestStatement('Bun makes TypeScript tooling fast.', {
extractor,
embeddingProvider: provider,
duplicateThreshold: 0.95,
spaceName: 'B',
});
const alphaFacts = await db.getTopicFacts('TypeScript', { spaceName: 'A' });
const betaFacts = await db.getTopicFacts('TypeScript', { spaceName: 'B' });
expect(second.id).not.toBe(first.id);
expect(alphaFacts).toHaveLength(1);
expect(betaFacts).toHaveLength(1);
});
});

View File

@@ -18,6 +18,12 @@
"isolatedModules": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts", "tsup.config.ts"],
"include": [
"src/**/*.ts",
"tests/**/*.ts",
"scripts/**/*.ts",
"vitest.config.ts",
"tsup.config.ts"
],
"exclude": ["dist", "node_modules"]
}