49 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
283f91ed91 ci: configure npm auth for release publish
All checks were successful
npm release / verify (push) Successful in 13s
npm release / publish to npm (push) Successful in 12s
2026-05-11 13:59:29 +09:00
5991e4f1f0 ci: run Gitea release steps with bash
Some checks failed
npm release / verify (push) Successful in 13s
npm release / publish to npm (push) Failing after 11s
2026-05-11 13:57:45 +09:00
0dc657c97b ci: make Gitea release workflow self-contained
Some checks failed
npm release / verify (push) Failing after 10s
npm release / publish to npm (push) Has been skipped
2026-05-11 13:56:39 +09:00
96d0568197 ci: set writable HOME for Gitea release workflow
Some checks failed
npm release / verify (push) Failing after 2s
npm release / publish to npm (push) Has been skipped
2026-05-11 13:53:04 +09:00
e8adccfbbf ci: add tag-gated npm release workflow
Some checks failed
npm release / verify (push) Failing after 13s
npm release / publish to npm (push) Has been skipped
2026-05-11 13:36:07 +09:00
1c82b63e7a docs: add IdentityDB wiki documentation plan 2026-05-11 12:27:12 +09:00
3e39d3bbd5 docs: document LLM extractor adapter usage 2026-05-11 12:19:58 +09:00
4f877a40fb feat: add provider-agnostic LLM extractor adapter 2026-05-11 12:19:50 +09:00
7a02621e40 docs: add LLM extractor adapter plan 2026-05-11 12:14:55 +09:00
4c418dc39a docs: document topic alias and semantic search APIs 2026-05-11 12:06:48 +09:00
810f4a6bf2 feat: add semantic fact search and embeddings 2026-05-11 12:05:47 +09:00
428f5021e8 feat: add topic alias resolution APIs 2026-05-11 11:53:56 +09:00
ba03ecb85b feat: add topic hierarchy APIs 2026-05-11 11:46:10 +09:00
d95ac8c1a0 docs: add IdentityDB memory expansion plan 2026-05-11 11:41:13 +09:00
21e0b1e897 docs: add IdentityDB usage examples 2026-05-11 10:55:09 +09:00
2c6624beea feat: add pluggable fact extraction pipeline 2026-05-11 10:54:40 +09:00
9f3133a403 test: specify pluggable fact ingestion behavior 2026-05-11 10:50:43 +09:00
9dc529af04 feat: add IdentityDB core memory graph APIs 2026-05-11 10:50:11 +09:00
f4b6548054 test: specify memory graph query APIs 2026-05-11 10:46:38 +09:00
2f8712e1df feat: add multi-dialect schema initialization 2026-05-11 10:45:39 +09:00
fb140d7a50 test: define schema contract for topic fact graph 2026-05-11 10:42:50 +09:00
cadc1b0733 chore: scaffold IdentityDB package tooling 2026-05-11 10:41:48 +09:00
bf1495a4d0 docs: add IdentityDB foundation plan 2026-05-11 10:41:45 +09:00
37 changed files with 5200 additions and 1 deletions

View File

@@ -0,0 +1,117 @@
name: npm release
on:
push:
tags:
- 'v*'
- '[0-9]*'
permissions:
contents: read
defaults:
run:
shell: bash
jobs:
verify:
name: verify
runs-on: ubuntu-latest
container:
image: node:22-bookworm
timeout-minutes: 20
steps:
- name: Install release tools
run: |
set -euo pipefail
apt-get update
apt-get install -y git curl ca-certificates
curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.13
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
node --version
npm --version
bun --version
- name: Clone tagged source
run: |
set -euo pipefail
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
AUTH_HEADER="$(printf '%s' '${{ gitea.actor }}:${{ secrets.GITEA_TOKEN }}' | base64 -w0)"
git -c http.extraHeader="Authorization: Basic $AUTH_HEADER" clone --depth 1 --branch "${{ gitea.ref_name }}" "$REPO_URL" repo
git -C repo rev-parse HEAD
- name: Verify release tag matches package version
working-directory: repo
shell: bash
run: |
set -euo pipefail
TAG_NAME="${{ gitea.ref_name }}"
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
if [ "$TAG_NAME" = "v$PACKAGE_VERSION" ] || [ "$TAG_NAME" = "$PACKAGE_VERSION" ]; then
echo "Release tag $TAG_NAME matches package version $PACKAGE_VERSION"
exit 0
fi
echo "Tag $TAG_NAME does not match package.json version $PACKAGE_VERSION" >&2
exit 1
- name: Run verify pipeline
working-directory: repo
run: |
set -euo pipefail
bun install --frozen-lockfile
bun run test
bun run check
bun run build
release:
name: publish to npm
runs-on: ubuntu-latest
container:
image: node:22-bookworm
timeout-minutes: 20
needs:
- verify
steps:
- name: Install release tools
run: |
set -euo pipefail
apt-get update
apt-get install -y git curl ca-certificates
curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.13
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
node --version
npm --version
bun --version
- name: Clone tagged source
run: |
set -euo pipefail
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
AUTH_HEADER="$(printf '%s' '${{ gitea.actor }}:${{ secrets.GITEA_TOKEN }}' | base64 -w0)"
git -c http.extraHeader="Authorization: Basic $AUTH_HEADER" clone --depth 1 --branch "${{ gitea.ref_name }}" "$REPO_URL" repo
git -C repo rev-parse HEAD
- name: Install dependencies
working-directory: repo
run: |
set -euo pipefail
bun install --frozen-lockfile
- name: Build package
working-directory: repo
run: |
set -euo pipefail
bun run build
- name: Publish package to npm
working-directory: repo
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
printf '//registry.npmjs.org/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > ~/.npmrc
npm publish

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
coverage/
.env
.DS_Store
*.log
.env.*

183
README.md
View File

@@ -1,3 +1,184 @@
# IdentityDB
Memory database for Artificial Personality
IdentityDB is a TypeScript package for building structured AI memory on top of relational databases.
## What it is
IdentityDB stores memory as a graph made of:
- **Topics** — named nodes such as `TypeScript`, `programming language`, `2025`, or `I`
- **Facts** — statements that connect multiple topics
- **Fact-topic links** — the relationships that turn one fact into a bridge between many topics
A single fact like `I have worked with TypeScript since 2025.` can connect the topics `I`, `TypeScript`, and `2025` at the same time.
## Current capabilities
- SQLite, PostgreSQL, MySQL, and MariaDB connection adapters
- Automatic schema initialization for `spaces`, `topics`, `facts`, `fact_topics`, `topic_relations`, `topic_aliases`, and `fact_embeddings`
- High-level APIs for adding topics and facts
- 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
- Pluggable fact extraction so callers can use a small LLM or a deterministic extractor
## 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
```
## Quick start
```ts
import { IdentityDB, NaiveExtractor, type EmbeddingProvider } from 'identitydb';
const db = await IdentityDB.connect({
client: 'sqlite',
filename: ':memory:',
});
await db.initialize();
await db.ingestStatement('I have worked with TypeScript since 2025.', {
extractor: new NaiveExtractor(),
});
await db.addFact({
statement: 'TypeScript is a programming language.',
topics: [
{
name: 'TypeScript',
category: 'entity',
granularity: 'concrete',
},
{
name: 'programming language',
category: 'concept',
granularity: 'abstract',
},
],
});
await db.linkTopics({
parentName: 'programming language',
childName: 'TypeScript',
});
await db.addTopicAlias('TypeScript', 'TS');
const provider: EmbeddingProvider = {
model: 'example-embedding-v1',
dimensions: 3,
async embed(input) {
if (input.toLowerCase().includes('typescript')) {
return [1, 0, 0];
}
return [0, 1, 0];
},
};
await db.indexFactEmbeddings({ provider });
const topic = await db.getTopicByName('TS', { includeFacts: true });
const children = await db.getTopicChildren('programming language');
const lineage = await db.getTopicLineage('TS');
const connected = await db.findConnectedTopics('TypeScript');
const matches = await db.searchFacts({
query: 'TypeScript experience',
provider,
limit: 5,
});
console.log(topic?.name);
console.log(children.map((entry) => entry.name));
console.log(lineage.map((entry) => entry.name));
console.log(connected.map((entry) => [entry.name, entry.sharedFactCount]));
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.
```ts
await db.ingestStatement('Bun makes TypeScript tooling fast.', {
extractor: new NaiveExtractor(),
embeddingProvider: provider,
duplicateThreshold: 0.95,
});
```
## LLM-backed extraction
You can bridge any text-generating model into IdentityDB by wrapping it with `LlmFactExtractor`.
```ts
import { LlmFactExtractor } from 'identitydb';
const extractor = new LlmFactExtractor({
model: {
async generateText(prompt) {
return callYourFavoriteLlm(prompt);
},
},
instructions: 'Prefer technology, product, and time topics over generic nouns.',
});
await db.ingestStatement('I have worked with Bun and TypeScript since 2025.', {
extractor,
});
```
The adapter expects the model to return JSON and will validate the structured response before IdentityDB writes a fact.
## Development
```bash
bun run test
bun run check
bun run build
```
## Current status
This repository is in active MVP expansion development.
See these implementation plans for the current roadmap:
- `docs/plans/2026-05-11-identitydb-foundation.md`
- `docs/plans/2026-05-11-identitydb-memory-expansion.md`

350
bun.lock Normal file
View File

@@ -0,0 +1,350 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "identitydb",
"dependencies": {
"kysely": "^0.28.8",
"mysql2": "^3.15.3",
"pg": "^8.16.0",
},
"devDependencies": {
"@openrouter/sdk": "^0.12.35",
"@types/pg": "^8.20.0",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^3.2.4",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@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=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="],
"@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/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=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@24.12.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ=="],
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
"@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"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=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"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=="],
"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=="],
"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=="],
"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=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"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=="],
"mysql2": ["mysql2@3.22.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.3.3" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
"pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="],
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
"pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="],
"pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"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=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"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=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"sql-escaper": ["sql-escaper@1.3.3", "", {}, "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"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=="],
"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=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"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=="],
"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=="],
"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=="],
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
"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=="],
"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

@@ -0,0 +1,419 @@
# IdentityDB Foundation Implementation Plan
> **For Hermes:** Use the `subagent-driven-development` skill to execute this plan task-by-task. Enforce strict TDD for every production behavior.
**Goal:** Build the first usable version of `IdentityDB`, a TypeScript package that wraps relational databases and exposes a structured API for storing topics, facts, and their many-to-many graph relationships.
**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, bun:sqlite, pg, mysql2, Vitest, tsup.
---
## Product constraints and interpretation
- The package must support SQLite, PostgreSQL, MySQL, and MariaDB.
- The database model must treat a single fact as a connector between multiple topics.
- Topics can represent concrete entities (`TypeScript`), abstract concepts (`programming language`), or temporal anchors (`2025`).
- Topic abstraction should be explicit in the schema so broad topics can store broad facts while specific topics store specific facts.
- The initial release should prioritize correctness, portability, and ergonomic API design over advanced search or embedding features.
- AI-assisted topic extraction should be implemented as an integration point in v1 foundation, not as a hardcoded provider-specific dependency.
---
## Target repository structure
```text
IdentityDB/
├── src/
│ ├── adapters/
│ │ ├── dialect.ts
│ │ └── index.ts
│ ├── core/
│ │ ├── errors.ts
│ │ ├── identity-db.ts
│ │ ├── migrations.ts
│ │ └── schema.ts
│ ├── ingestion/
│ │ ├── extractor.ts
│ │ ├── naive-extractor.ts
│ │ └── types.ts
│ ├── queries/
│ │ ├── topics.ts
│ │ └── facts.ts
│ ├── types/
│ │ ├── api.ts
│ │ ├── domain.ts
│ │ └── database.ts
│ └── index.ts
├── tests/
│ ├── identity-db.test.ts
│ ├── migrations.test.ts
│ ├── queries.test.ts
│ └── ingestion.test.ts
├── docs/
│ └── plans/
│ └── 2026-05-11-identitydb-foundation.md
├── package.json
├── tsconfig.json
├── tsup.config.ts
├── vitest.config.ts
├── .gitignore
└── README.md
```
---
## Data model proposal
### Tables
#### `topics`
- `id` — string UUID
- `name` — canonical display name, unique
- `normalized_name` — lowercase normalized unique key
- `category``entity | concept | temporal | custom`
- `granularity``abstract | concrete | mixed`
- `description` — nullable text
- `metadata` — JSON / JSON-text depending on dialect
- `created_at`
- `updated_at`
#### `facts`
- `id` — string UUID
- `statement` — original fact text
- `summary` — optional normalized/clean summary
- `source` — optional source identifier
- `confidence` — nullable numeric confidence
- `metadata` — JSON / JSON-text depending on dialect
- `created_at`
- `updated_at`
#### `fact_topics`
- `fact_id`
- `topic_id`
- `role` — optional semantic label (`subject`, `object`, `time`, etc.)
- `position` — stable order for fact-topic relationships
- composite unique key on (`fact_id`, `topic_id`, `role`)
### Notes
- The graph is modeled through `fact_topics`; facts are the connective tissue between topics.
- No separate topic-to-topic edge table is needed in the initial version because relationships are derived from shared facts.
- JSON portability should be implemented through small helpers so SQLite stores stringified JSON while Postgres/MySQL can still use text-compatible serialization safely.
---
## Public API proposal
### Construction and lifecycle
```ts
const db = await IdentityDB.connect({
client: 'sqlite',
filename: ':memory:',
});
await db.initialize();
await db.close();
```
### Core write APIs
```ts
await db.upsertTopic({
name: 'TypeScript',
category: 'entity',
granularity: 'concrete',
});
await db.addFact({
statement: 'I have worked with TypeScript since 2025.',
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' },
],
});
```
### Query APIs
```ts
await db.getTopicByName('TypeScript', { includeFacts: true });
await db.getTopicFacts('TypeScript');
await db.getTopicFactsLinkedTo('TypeScript', '2025');
await db.listTopics();
await db.listTopics({ includeFacts: false, limit: 100 });
await db.findConnectedTopics('TypeScript');
await db.findFactsConnectingTopics(['I', 'TypeScript', '2025']);
```
### AI-assisted ingestion API
```ts
await db.ingestStatement('I have worked with TypeScript since 2025.', {
extractor,
});
```
Where `extractor` implements:
```ts
interface FactExtractor {
extract(input: string): Promise<ExtractedFact>;
}
```
The package will ship a simple `NaiveExtractor` for tests/examples, while real deployments can inject an LLM-backed extractor.
---
## Execution plan
### Task 1: Scaffold package tooling and baseline configuration
**Objective:** Create a clean TypeScript package foundation with build and test tooling.
**Files:**
- Create: `package.json`
- Create: `tsconfig.json`
- Create: `tsup.config.ts`
- Create: `vitest.config.ts`
- Create: `.gitignore`
- Modify: `README.md`
**Steps:**
1. Add package metadata, scripts, dependency placeholders, and ESM export configuration.
2. Add TypeScript config for library output.
3. Add tsup config for bundling ESM + type declarations.
4. Add Vitest config targeting Node.
5. Expand README with project direction and current scope.
6. Install dependencies and confirm `bun test` starts correctly.
**Verification:**
- Run: `bun install`
- Run: `bun test`
- Expected: test runner executes successfully even if there are zero or placeholder tests.
**Commit:**
```bash
git add package.json tsconfig.json tsup.config.ts vitest.config.ts .gitignore README.md bun.lock
git commit -m "chore: scaffold IdentityDB package tooling"
```
---
### Task 2: Define domain types and write migration tests first
**Objective:** Lock down the domain model and schema contract before implementing migrations.
**Files:**
- Create: `src/types/domain.ts`
- Create: `src/types/database.ts`
- Create: `src/types/api.ts`
- Create: `src/core/schema.ts`
- Create: `tests/migrations.test.ts`
**Steps:**
1. Write tests that describe the required tables and columns after initialization.
2. Write tests for idempotent initialization (calling twice should not fail).
3. Add domain and API type definitions that match the product model.
4. Add schema description constants used by migrations.
**Verification:**
- Run: `bun test tests/migrations.test.ts`
- Expected before implementation: FAIL because initialization does not exist yet.
**Commit:**
```bash
git add src/types src/core/schema.ts tests/migrations.test.ts
git commit -m "test: define schema contract for topic fact graph"
```
---
### Task 3: Implement dialect adapters and automatic schema initialization
**Objective:** Make the package connect to supported databases and create its schema automatically.
**Files:**
- Create: `src/adapters/dialect.ts`
- Create: `src/adapters/index.ts`
- Create: `src/core/migrations.ts`
- Create: `src/core/errors.ts`
- Modify: `src/core/schema.ts`
- Modify: `tests/migrations.test.ts`
**Steps:**
1. Implement a connection config union for SQLite/Postgres/MySQL-family.
2. Build a dialect factory returning a Kysely instance.
3. Implement `initializeSchema()` with idempotent table creation.
4. Add lightweight helpers for JSON serialization/deserialization portability.
5. Re-run migration tests until green.
**Verification:**
- Run: `bun test tests/migrations.test.ts`
- Expected: PASS
**Commit:**
```bash
git add src/adapters src/core tests/migrations.test.ts
git commit -m "feat: add multi-dialect schema initialization"
```
---
### Task 4: Write failing query tests for topic/fact operations
**Objective:** Specify the behavior of the high-level memory APIs before implementation.
**Files:**
- Create: `tests/identity-db.test.ts`
- Create: `tests/queries.test.ts`
**Steps:**
1. Write tests for `upsertTopic` deduplication by normalized name.
2. Write tests for `addFact` linking multiple topics to one fact.
3. Write tests for `getTopicByName(..., { includeFacts: true })`.
4. Write tests for `getTopicFactsLinkedTo(topicA, topicB)`.
5. Write tests for `listTopics({ includeFacts: false })` returning topic-only records.
6. Write tests for `findConnectedTopics(name)`.
**Verification:**
- Run: `bun test tests/identity-db.test.ts tests/queries.test.ts`
- Expected before implementation: FAIL because `IdentityDB` methods are not implemented.
**Commit:**
```bash
git add tests/identity-db.test.ts tests/queries.test.ts
git commit -m "test: specify memory graph query APIs"
```
---
### Task 5: Implement `IdentityDB` core service and query helpers
**Objective:** Deliver the first usable high-level API for writing and reading memory graph data.
**Files:**
- Create: `src/core/identity-db.ts`
- Create: `src/queries/topics.ts`
- Create: `src/queries/facts.ts`
- Create: `src/index.ts`
- Modify: `src/types/api.ts`
- Modify: `tests/identity-db.test.ts`
- Modify: `tests/queries.test.ts`
**Steps:**
1. Implement `IdentityDB.connect()` and `initialize()`.
2. Implement topic upsert with normalized key handling.
3. Implement fact insertion plus topic linking transactionally.
4. Implement topic lookup with optional fact expansion.
5. Implement topic-to-topic and multi-topic fact queries.
6. Implement topic listing and connected-topic discovery.
7. Re-run the full test suite.
**Verification:**
- Run: `bun test`
- Expected: PASS
**Commit:**
```bash
git add src tests
git commit -m "feat: add IdentityDB core memory graph APIs"
```
---
### Task 6: Add ingestion abstractions and a naive extractor
**Objective:** Support automatic topic/fact ingestion through a pluggable extraction pipeline.
**Files:**
- Create: `src/ingestion/types.ts`
- Create: `src/ingestion/extractor.ts`
- Create: `src/ingestion/naive-extractor.ts`
- Create: `tests/ingestion.test.ts`
- Modify: `src/core/identity-db.ts`
- Modify: `src/index.ts`
**Steps:**
1. Write failing tests for `ingestStatement()` using a fake extractor.
2. Define the extraction contracts and validation rules.
3. Implement `ingestStatement()` by piping extractor output into `addFact()`.
4. Add a deterministic `NaiveExtractor` for examples/tests.
5. Add tests proving extractor-driven topic creation works.
**Verification:**
- Run: `bun test tests/ingestion.test.ts`
- Run: `bun test`
- Expected: PASS
**Commit:**
```bash
git add src/ingestion src/core/identity-db.ts src/index.ts tests/ingestion.test.ts
git commit -m "feat: add pluggable fact ingestion pipeline"
```
---
### Task 7: Polish package docs and publish-ready ergonomics
**Objective:** Make the repository understandable and usable after the foundation lands.
**Files:**
- Modify: `README.md`
- Optionally create: `docs/examples/basic-usage.md`
**Steps:**
1. Document supported databases and the current API surface.
2. Document the topic/fact graph model with a concrete example.
3. Add example code for initialization, querying, and AI-assisted ingestion.
4. Call out current limitations and near-term roadmap.
**Verification:**
- Manually review the README examples against actual exports.
- Run: `bun run build`
- Expected: PASS
**Commit:**
```bash
git add README.md docs/examples/basic-usage.md
git commit -m "docs: document IdentityDB foundation usage"
```
---
## Test strategy
- Use SQLite in-memory for the main automated tests.
- Treat PostgreSQL/MySQL/MariaDB support as adapter-compatibility in the code path, with optional future integration tests behind environment variables.
- Keep all public behavior covered through unit/integration-style tests against the public `IdentityDB` API.
- Add regression tests for normalization, many-to-many fact linking, and topic filtering by connected topic.
---
## Risks and tradeoffs
1. **Cross-dialect JSON handling** — JSON support differs between engines. The initial version should serialize metadata defensively for portability.
2. **Case normalization semantics** — topic uniqueness depends on normalization. The first version should use a simple lowercase-trim normalization and document it.
3. **Temporal topic modeling** — time can be a topic, but richer interval modeling should wait until a later phase.
4. **Abstract vs concrete topic boundaries** — this is partly editorial, so the API should store explicit `granularity` rather than trying to infer it automatically.
5. **LLM extraction variability** — extractor output can be messy. The core package should validate extractor results before writing them.
---
## Out of scope for this foundation pass
- Embeddings or semantic vector search
- Ranking/relevance algorithms
- Full-text search indices
- Topic merging/synonym resolution workflows
- Multi-user authorization / remote HTTP service layer
- Hosted API server package
---
## Immediate execution target
For the first automated execution pass, implement Tasks 1 through 7 in order, but treat SQLite-backed functionality as the required tested path and the other SQL engines as supported adapter targets in the library surface.

View File

@@ -0,0 +1,87 @@
# IdentityDB LLM Extractor Adapter Implementation Plan
> **For Hermes:** Use the `subagent-driven-development` skill to execute this plan task-by-task. Enforce strict TDD for every production behavior.
**Goal:** Add a provider-agnostic LLM-backed fact extractor adapter so callers can plug a small language model into IdentityDB ingestion without coupling the package to a specific SDK.
**Architecture:** Keep `FactExtractor` as the stable ingestion contract, then add an `LlmFactExtractor` adapter that delegates prompting and text generation to a narrow model interface. The adapter should build a deterministic JSON-only extraction prompt, parse structured JSON from the model response, validate the shape, and return `ExtractedFact` objects that flow through the existing ingestion validation path.
**Tech Stack:** TypeScript, Bun, Node.js, Kysely, Vitest, tsup.
---
## Scope and interpretation
- The new adapter must remain provider-agnostic and must not depend on OpenAI, Anthropic, or any other SDK.
- The adapter should accept a minimal language-model interface that returns text so package consumers can bridge any LLM client they want.
- Structured output must be validated in the adapter before returning it to `extractFact()`.
- The adapter should tolerate common model formatting noise such as fenced ```json blocks around the payload.
- Initial release should focus on correctness and predictable integration, not prompt-optimization or retries.
---
## Public API additions
```ts
const extractor = new LlmFactExtractor({
model: {
async generateText(prompt) {
return jsonStringFromSomeLlm(prompt);
},
},
});
const fact = await db.ingestStatement('I have worked with Bun and TypeScript since 2025.', {
extractor,
});
```
Optional customization:
```ts
const extractor = new LlmFactExtractor({
model,
instructions: 'Prefer product and technology topics over generic nouns.',
});
```
---
## Execution plan
### Task 1: Lock the adapter behavior with failing tests
**Objective:** Define the LLM adapter contract before implementation.
**Files:**
- Modify: `tests/ingestion.test.ts`
- Modify: `src/ingestion/types.ts`
- Modify: `src/index.ts`
**Verification:**
- Run focused ingestion tests and confirm they fail for the missing adapter behavior.
### Task 2: Implement the LLM adapter and response parsing
**Objective:** Add a reusable `LlmFactExtractor` implementation plus robust JSON extraction helpers.
**Files:**
- Create: `src/ingestion/llm-extractor.ts`
- Modify: `src/ingestion/types.ts`
- Modify: `src/ingestion/extractor.ts`
- Modify: `src/index.ts`
**Verification:**
- Run the focused ingestion tests until green.
### Task 3: Document the adapter and run the full suite
**Objective:** Expose the new adapter in docs and ensure the whole package still passes verification.
**Files:**
- Modify: `README.md`
- Modify: `src/index.ts`
**Verification:**
- Run `bun run test && bun run check && bun run build`
- Confirm the README shows how to bridge an arbitrary LLM client into the adapter.

View File

@@ -0,0 +1,181 @@
# IdentityDB Memory Expansion Implementation Plan
> **For Hermes:** Use the `subagent-driven-development` skill to execute this plan task-by-task. Enforce strict TDD for every production behavior.
**Goal:** Extend IdentityDB with explicit topic hierarchy, topic alias/canonicalization controls, and portable semantic fact search with embedding-backed similarity APIs.
**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, bun:sqlite, pg, mysql2, Vitest, tsup.
---
## Scope and interpretation
- Topic hierarchy must be explicit rather than inferred only from shared facts.
- Canonical topics must remain first-class records in `topics`; aliases should resolve into those topics without duplicating canonical rows.
- Semantic search must stay provider-agnostic through a pluggable `EmbeddingProvider` interface.
- The first semantic-search release should favor portability and deterministic testing over ANN/vector-extension optimization.
- Ingestion should be able to detect likely duplicate facts by semantic similarity without forcing automatic merges.
---
## Data model additions
### `topic_relations`
- `parent_topic_id`
- `child_topic_id`
- `relation` — initially `parent_of`
- `created_at`
- composite primary key on (`parent_topic_id`, `child_topic_id`, `relation`)
### `topic_aliases`
- `id`
- `topic_id`
- `alias`
- `normalized_alias`
- `is_primary`
- `created_at`
- `updated_at`
- unique key on `normalized_alias`
### `fact_embeddings`
- `fact_id`
- `model`
- `dimensions`
- `embedding`
- `content_hash`
- `created_at`
- `updated_at`
- composite primary key on (`fact_id`, `model`)
---
## Public API additions
### Topic hierarchy
```ts
await db.linkTopics({
parentName: 'programming language',
childName: 'TypeScript',
});
await db.getTopicChildren('programming language');
await db.getTopicParents('TypeScript');
await db.getTopicLineage('TypeScript');
```
### Topic aliases
```ts
await db.addTopicAlias('TypeScript', 'TS');
await db.resolveTopic('ts');
await db.getTopicAliases('TypeScript');
```
### Semantic indexing and search
```ts
await db.indexFactEmbeddings({ provider });
await db.searchFacts({ query: 'When did I start using TS?', provider, limit: 5 });
await db.findSimilarFacts({ statement: 'I started using TypeScript in 2025.', provider, threshold: 0.9 });
```
### Dedup-aware ingestion
```ts
await db.ingestStatement(statement, {
extractor,
dedup: {
provider,
threshold: 0.9,
},
});
```
---
## Execution plan
### Task 1: Lock the extension schema and APIs with failing tests
**Objective:** Define tests for hierarchy, aliases, and semantic search before production code changes.
**Files:**
- Modify: `tests/migrations.test.ts`
- Modify: `tests/identity-db.test.ts`
- Modify: `tests/queries.test.ts`
- Create: `tests/semantic-search.test.ts`
- Modify: `src/types/api.ts`
- Modify: `src/types/domain.ts`
- Modify: `src/types/database.ts`
- Modify: `src/core/schema.ts`
**Verification:**
- Run focused test commands and confirm they fail for missing behavior.
### Task 2: Implement topic hierarchy storage and query APIs
**Objective:** Add `topic_relations` schema support plus parent/child/lineage APIs.
**Files:**
- Modify: `src/core/migrations.ts`
- Modify: `src/core/identity-db.ts`
- Modify: `src/core/utils.ts`
- Modify: `src/queries/topics.ts`
- Modify: `src/types/api.ts`
- Modify: `src/types/domain.ts`
- Modify: `src/types/database.ts`
**Verification:**
- Run hierarchy-focused tests until green.
### Task 3: Implement canonical topic aliases
**Objective:** Add alias storage, alias-aware resolution, and canonical topic lookup semantics.
**Files:**
- Modify: `src/core/migrations.ts`
- Modify: `src/core/identity-db.ts`
- Modify: `src/queries/topics.ts`
- Modify: `src/core/utils.ts`
- Modify: `src/types/api.ts`
- Modify: `src/types/domain.ts`
- Modify: `src/types/database.ts`
**Verification:**
- Run alias-focused tests until green.
### Task 4: Implement embedding-backed indexing and semantic search
**Objective:** Add `EmbeddingProvider`, embedding storage, search APIs, and similarity ranking.
**Files:**
- Create: `src/embeddings/provider.ts`
- Create: `src/queries/embeddings.ts`
- Modify: `src/core/migrations.ts`
- Modify: `src/core/identity-db.ts`
- Modify: `src/core/utils.ts`
- Modify: `src/types/api.ts`
- Modify: `src/types/domain.ts`
- Modify: `src/types/database.ts`
- Modify: `src/index.ts`
- Create: `tests/semantic-search.test.ts`
**Verification:**
- Run semantic-search tests until green.
### Task 5: Add dedup-aware ingestion, docs, and full verification
**Objective:** Surface semantic dedup hints during ingestion, document the new APIs, and run the full suite.
**Files:**
- Modify: `src/ingestion/types.ts`
- Modify: `src/core/identity-db.ts`
- Modify: `README.md`
- Modify: `src/index.ts`
**Verification:**
- Run `bun run test && bun run check && bun run build`
- Update docs to reflect the new public surface.

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

@@ -0,0 +1,65 @@
# IdentityDB Wiki Documentation Implementation Plan
> **For Hermes:** Execute this plan step-by-step. Prefer concrete repository inspection over assumptions, and verify the wiki remote after each major write.
**Goal:** Verify the IdentityDB wiki repository state, create or clone it as needed, and publish concrete wiki documentation covering the project's purpose, usage, and extractor choices including `NaiveExtractor`.
**Architecture:** Treat the Gitea wiki as a separate Git repository. First verify whether the wiki feature is enabled and whether the `.wiki.git` remote already exists. If the remote does not exist yet, bootstrap it with a minimal `Home.md`, then clone the wiki repo into a local working directory and author Markdown pages there. Keep the documentation practical, using the package README and current source files as the canonical content source.
**Tech Stack:** Gitea, tea CLI, Git, Markdown, Bun/TypeScript project docs.
---
## Execution plan
### Task 1: Inspect wiki availability and remote state
**Objective:** Confirm that the repository has wiki support enabled and determine whether the Git-backed wiki repo already exists.
**Files:**
- Inspect: `https://git.psw.kr/p-sw/IdentityDB`
- Read: `/home/hermes-agent/IdentityDB/README.md`
- Read: `/home/hermes-agent/IdentityDB/src/ingestion/naive-extractor.ts`
- Read: `/home/hermes-agent/IdentityDB/src/ingestion/llm-extractor.ts`
**Verification:**
- Check Gitea repo metadata for `has_wiki=true`.
- Check whether `https://git.psw.kr/p-sw/IdentityDB.wiki.git` is readable.
### Task 2: Bootstrap the wiki repo if missing
**Objective:** Create the Git-backed wiki repository if it has not been materialized yet.
**Files:**
- Create temporarily: `/home/hermes-agent/IdentityDB-wiki-bootstrap/Home.md`
**Verification:**
- Push a first commit to `https://git.psw.kr/p-sw/IdentityDB.wiki.git`.
- Confirm the remote becomes cloneable afterward.
### Task 3: Clone the wiki repo and author concrete pages
**Objective:** Write practical docs explaining why IdentityDB exists, how to use it, and where `NaiveExtractor` fits.
**Files:**
- Clone to: `/home/hermes-agent/IdentityDB.wiki`
- Create/modify: `/home/hermes-agent/IdentityDB.wiki/Home.md`
- Create/modify: `/home/hermes-agent/IdentityDB.wiki/Getting-Started.md`
- Create/modify: `/home/hermes-agent/IdentityDB.wiki/Extractors.md`
- Create/modify: `/home/hermes-agent/IdentityDB.wiki/_Sidebar.md`
**Verification:**
- Review the generated Markdown files locally.
- Ensure internal wiki links resolve by page name.
### Task 4: Commit, push, and verify the published wiki state
**Objective:** Publish the wiki docs and verify the remote history reflects the changes.
**Files:**
- Commit within: `/home/hermes-agent/IdentityDB.wiki`
**Verification:**
- Run `git status --short` and `git log --oneline -n 3` in the wiki repo.
- Push to the remote wiki repo.
- Confirm the wiki is cloneable and the latest commit is visible remotely.

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "identitydb",
"version": "0.5.2",
"description": "TypeScript memory graph database wrapper for topics, facts, and AI-assisted ingestion.",
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "tsup",
"check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist coverage"
},
"keywords": [
"memory",
"graph",
"database",
"typescript",
"ai"
],
"dependencies": {
"kysely": "^0.28.8",
"mysql2": "^3.15.3",
"pg": "^8.16.0"
},
"devDependencies": {
"@openrouter/sdk": "^0.12.35",
"@types/pg": "^8.20.0",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
}

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);
});

328
src/adapters/dialect.ts Normal file
View File

@@ -0,0 +1,328 @@
import { Kysely, MysqlDialect, PostgresDialect, SqliteDialect } from 'kysely';
import { createPool as createMysqlPool } from 'mysql2';
import { Pool as PostgresPool } from 'pg';
import type { IdentityDatabaseSchema } from '../types/database';
import { IdentityDBConfigurationError } from '../core/errors';
export interface SqliteConnectionConfig {
client: 'sqlite';
filename: string;
readonly?: boolean;
}
export interface PostgresConnectionConfig {
client: 'postgres';
connectionString?: string;
host?: string;
port?: number;
database?: string;
user?: string;
password?: string;
ssl?: boolean;
}
export interface MysqlConnectionConfig {
client: 'mysql' | 'mariadb';
uri?: string;
host?: string;
port?: number;
database?: string;
user?: string;
password?: string;
}
export type IdentityDBConnectionConfig =
| SqliteConnectionConfig
| PostgresConnectionConfig
| MysqlConnectionConfig;
export interface DatabaseConnection {
client: IdentityDBConnectionConfig['client'];
db: Kysely<IdentityDatabaseSchema>;
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 = await createSqliteDatabase(config);
const db = new Kysely<IdentityDatabaseSchema>({
dialect: new SqliteDialect({
database: sqlite,
}),
});
return {
client: config.client,
db,
destroy: async () => {
await db.destroy();
},
};
}
case 'postgres': {
const pool = new PostgresPool({
connectionString: config.connectionString,
host: config.host,
port: config.port,
database: config.database,
user: config.user,
password: config.password,
ssl: config.ssl ? { rejectUnauthorized: false } : undefined,
});
const db = new Kysely<IdentityDatabaseSchema>({
dialect: new PostgresDialect({ pool }),
});
return {
client: config.client,
db,
destroy: async () => {
await db.destroy();
await pool.end();
},
};
}
case 'mysql':
case 'mariadb': {
const mysqlOptions: {
host?: string;
port?: number;
database?: string;
user?: string;
password?: string;
} = {};
if (config.host !== undefined) {
mysqlOptions.host = config.host;
}
if (config.port !== undefined) {
mysqlOptions.port = config.port;
}
if (config.database !== undefined) {
mysqlOptions.database = config.database;
}
if (config.user !== undefined) {
mysqlOptions.user = config.user;
}
if (config.password !== undefined) {
mysqlOptions.password = config.password;
}
const pool = config.uri
? createMysqlPool(config.uri)
: createMysqlPool(mysqlOptions);
const db = new Kysely<IdentityDatabaseSchema>({
dialect: new MysqlDialect({ pool }),
});
return {
client: config.client,
db,
destroy: async () => {
await db.destroy();
await new Promise<void>((resolve, reject) => {
pool.end((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
};
}
default: {
const neverClient: never = config;
throw new IdentityDBConfigurationError(
`Unsupported database client: ${JSON.stringify(neverClient)}`,
);
}
}
}

1
src/adapters/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './dialect';

13
src/core/errors.ts Normal file
View File

@@ -0,0 +1,13 @@
export class IdentityDBError extends Error {
constructor(message: string) {
super(message);
this.name = 'IdentityDBError';
}
}
export class IdentityDBConfigurationError extends IdentityDBError {
constructor(message: string) {
super(message);
this.name = 'IdentityDBConfigurationError';
}
}

941
src/core/identity-db.ts Normal file
View File

@@ -0,0 +1,941 @@
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,
} 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, SpaceRecord, TopicRecord } from '../types/domain';
import { createDatabase } from '../adapters/dialect';
import { extractFacts } from '../ingestion/extractor';
import {
findFactRowsConnectingTopicIds,
findFactRowsForTopicId,
findTopicLinksForFactIds,
} from '../queries/facts';
import {
type DatabaseExecutor,
findChildTopicRows,
findConnectedTopicRows,
findParentTopicRows,
findSpaceRowByNormalizedName,
findTopicRowByNameOrAlias,
findTopicRowByNormalizedAlias,
findTopicRowByNormalizedName,
listTopicAliasRowsForTopicId,
listTopicRows,
} 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) {}
static async connect(config: IdentityDBConnectionConfig): Promise<IdentityDB> {
const connection = await createDatabase(config);
return new IdentityDB(connection);
}
async initialize(): Promise<void> {
await initializeSchema(this.connection.db);
}
async close(): Promise<void> {
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);
}
async addFact(input: AddFactInput): Promise<Fact> {
if (input.statement.trim().length === 0) {
throw new IdentityDBError('Fact statement cannot be empty.');
}
if (input.topics.length === 0) {
throw new IdentityDBError('A fact must be linked to at least one topic.');
}
return this.connection.db.transaction().execute(async (trx) => {
const space = await this.getOrCreateSpaceInExecutor(trx, input.spaceName);
const createdAt = nowIsoString();
const factId = createId();
await trx
.insertInto('facts')
.values({
id: factId,
space_id: space.id,
statement: input.statement.trim(),
summary: input.summary ?? null,
source: input.source ?? null,
confidence: input.confidence ?? null,
metadata: serializeMetadata(input.metadata),
created_at: createdAt,
updated_at: createdAt,
})
.execute();
const topics: FactTopic[] = [];
for (let index = 0; index < input.topics.length; index += 1) {
const topicInput = input.topics[index]!;
this.assertScopedTopicInput(space, topicInput.spaceName);
const topic = await this.upsertTopicInExecutor(trx, {
...topicInput,
spaceName: space.name,
});
await trx
.insertInto('fact_topics')
.values({
fact_id: factId,
topic_id: topic.id,
role: topicInput.role ?? null,
position: index,
created_at: createdAt,
})
.execute();
topics.push({
...topic,
role: topicInput.role ?? null,
position: index,
});
}
return {
id: factId,
spaceId: space.id,
statement: input.statement.trim(),
summary: input.summary ?? null,
source: input.source ?? null,
confidence: input.confidence ?? null,
metadata: input.metadata ?? null,
createdAt,
updatedAt: createdAt,
topics,
};
});
}
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;
}
async ingestStatements(statement: string, options: IngestStatementOptions): Promise<Fact[]> {
const extractedList = await extractFacts(statement, options.extractor);
const facts: Fact[] = [];
for (const extracted of extractedList) {
const factInput: AddFactInput = {
statement: extracted.statement ?? statement,
topics: extracted.topics,
spaceName: options.spaceName,
};
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);
}
return facts;
}
async indexFactEmbeddings(input: IndexFactEmbeddingsInput): Promise<void> {
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;
}
const embeddings = input.provider.embedMany
? await input.provider.embedMany(factRows.map((factRow) => factRow.statement))
: await Promise.all(factRows.map((factRow) => input.provider.embed(factRow.statement)));
if (embeddings.length !== factRows.length) {
throw new IdentityDBError('Embedding provider returned a mismatched number of embeddings.');
}
await this.connection.db.transaction().execute(async (trx) => {
for (let index = 0; index < factRows.length; index += 1) {
const factRow = factRows[index]!;
const embedding = embeddings[index]!;
this.assertEmbeddingShape(embedding, input.provider.dimensions);
await this.upsertFactEmbeddingRecord(trx, factRow.id, factRow.statement, embedding, input.provider.model);
}
});
}
async indexFactEmbedding(factId: string, input: IndexFactEmbeddingsInput): Promise<void> {
const factRow = await this.connection.db
.selectFrom('facts')
.selectAll()
.where('id', '=', factId)
.executeTakeFirst();
if (!factRow) {
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);
await this.connection.db.transaction().execute(async (trx) => {
await this.upsertFactEmbeddingRecord(trx, factRow.id, factRow.statement, embedding, input.provider.model);
});
}
async searchFacts(input: SearchFactsInput): Promise<ScoredFact[]> {
const queryText = input.query.trim();
if (queryText.length === 0) {
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);
return this.searchFactsByEmbedding({
providerModel: input.provider.model,
queryEmbedding,
topicNames: input.topicNames,
limit: input.limit,
minimumScore: input.minimumScore,
spaceId: space?.id,
});
}
async findSimilarFacts(input: FindSimilarFactsInput): Promise<ScoredFact[]> {
const statement = input.statement.trim();
if (statement.length === 0) {
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);
return this.searchFactsByEmbedding({
providerModel: input.provider.model,
queryEmbedding,
topicNames: input.topicNames,
limit: input.limit,
minimumScore: input.minimumScore,
spaceId: space?.id,
});
}
async linkTopics(input: LinkTopicsInput): Promise<void> {
const parentNormalizedName = normalizeTopicName(input.parentName);
const childNormalizedName = normalizeTopicName(input.childName);
if (parentNormalizedName.length === 0 || childNormalizedName.length === 0) {
throw new IdentityDBError('Topic hierarchy links require both a parent and child topic name.');
}
if (parentNormalizedName === childNormalizedName) {
throw new IdentityDBError('A topic cannot be linked as its own parent.');
}
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
.selectFrom('topic_relations')
.select(['parent_topic_id'])
.where('parent_topic_id', '=', parentTopic.id)
.where('child_topic_id', '=', childTopic.id)
.where('relation', '=', 'parent_of')
.executeTakeFirst();
if (!existing) {
await trx
.insertInto('topic_relations')
.values({
parent_topic_id: parentTopic.id,
child_topic_id: childTopic.id,
relation: 'parent_of',
created_at: nowIsoString(),
})
.execute();
}
});
}
async addTopicAlias(canonicalName: string, alias: string, options?: SpaceScopedInput): Promise<void> {
const normalizedAlias = normalizeTopicName(alias);
if (normalizedAlias.length === 0) {
throw new IdentityDBError('Topic alias cannot be empty.');
}
await this.connection.db.transaction().execute(async (trx) => {
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, 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, space.id, normalizedAlias);
if (aliasMatch) {
if (aliasMatch.id !== canonicalTopic.id) {
throw new IdentityDBError('Cannot assign an alias that already resolves to another topic.');
}
return;
}
const createdAt = nowIsoString();
await trx
.insertInto('topic_aliases')
.values({
id: createId(),
space_id: space.id,
topic_id: canonicalTopic.id,
alias: canonicalizeTopicName(alias),
normalized_alias: normalizedAlias,
is_primary: 0,
created_at: createdAt,
updated_at: createdAt,
})
.execute();
});
}
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, options?: SpaceScopedInput): Promise<string[]> {
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) {
return [];
}
const aliasRows = await listTopicAliasRowsForTopicId(this.connection.db, topicRow.space_id, topicRow.id);
return aliasRows.map((aliasRow) => aliasRow.alias);
}
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.space_id, topicRow.id);
return childRows.map(mapTopicRow);
}
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.space_id, topicRow.id);
return parentRows.map(mapTopicRow);
}
async getTopicLineage(name: string, options?: SpaceScopedInput): Promise<Topic[]> {
const topicRow = await this.getRequiredTopicRow(name, options?.spaceName);
if (!topicRow) {
return [];
}
const lineage: Topic[] = [];
const visitedTopicIds = new Set<string>([topicRow.id]);
let currentLevelIds = [topicRow.id];
while (currentLevelIds.length > 0) {
const nextLevelIds: string[] = [];
for (const currentId of currentLevelIds) {
const parentRows = await findParentTopicRows(this.connection.db, topicRow.space_id, currentId);
for (const parentRow of parentRows) {
if (visitedTopicIds.has(parentRow.id)) {
continue;
}
visitedTopicIds.add(parentRow.id);
nextLevelIds.push(parentRow.id);
lineage.push(mapTopicRow(parentRow));
}
}
currentLevelIds = nextLevelIds;
}
return lineage;
}
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.space_id, topicRow.id);
return this.hydrateFacts(factRows, topicRow.space_id);
}
async getTopicFactsLinkedTo(name: string, linkedTopicName: string, options?: SpaceScopedInput): Promise<Fact[]> {
return this.findFactsConnectingTopics([name, linkedTopicName], options);
}
async findFactsConnectingTopics(names: string[], options?: SpaceScopedInput): Promise<Fact[]> {
if (names.length === 0) {
return [];
}
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 spaceId = topicRows[0]!.space_id ?? space?.id;
const factRows = await findFactRowsConnectingTopicIds(this.connection.db, spaceId, topicIds);
return this.hydrateFacts(factRows, spaceId);
}
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, options?.spaceName);
if (!topicRow) {
return null;
}
const topic = mapTopicRow(topicRow);
if (options?.includeFacts) {
return {
...topic,
facts: await this.getTopicFacts(name, { spaceName: options.spaceName }),
};
}
return topic;
}
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 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, { spaceName: options?.spaceName }),
});
}
return topicsWithFacts;
}
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.space_id, topicRow.id);
return rows.map((row) => ({
...mapTopicRow(row),
sharedFactCount: row.shared_fact_count,
}));
}
private async searchFactsByEmbedding(input: {
providerModel: string;
queryEmbedding: number[];
topicNames?: string[] | undefined;
limit?: number | undefined;
minimumScore?: number | undefined;
spaceId?: string | undefined;
}): Promise<ScoredFact[]> {
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, 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();
if (factRows.length === 0) {
return [];
}
const embeddingRows = await this.connection.db
.selectFrom('fact_embeddings')
.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)]),
);
const scoredRows = factRows
.map((factRow) => ({
factRow,
score: cosineSimilarity(input.queryEmbedding, embeddingsByFactId.get(factRow.id) ?? []),
}))
.filter((entry) => entry.score >= (input.minimumScore ?? 0))
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.factRow.created_at.localeCompare(right.factRow.created_at);
})
.slice(0, input.limit ?? 5);
if (scoredRows.length === 0) {
return [];
}
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) => ({
...factsById.get(entry.factRow.id)!,
score: entry.score,
}));
}
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.getRequiredTopicRowInSpaceId(topicName, spaceId)));
if (topicRows.some((topicRow) => !topicRow)) {
return null;
}
return topicRows.map((topicRow) => topicRow!.id);
}
private async upsertFactEmbeddingRecord(
executor: DatabaseExecutor,
factId: string,
statement: string,
embedding: number[],
model: string,
): Promise<void> {
const timestamp = nowIsoString();
await executor
.deleteFrom('fact_embeddings')
.where('fact_id', '=', factId)
.where('model', '=', model)
.execute();
await executor
.insertInto('fact_embeddings')
.values({
fact_id: factId,
model,
dimensions: embedding.length,
embedding: serializeEmbedding(embedding),
content_hash: createContentHash(statement),
created_at: timestamp,
updated_at: timestamp,
})
.execute();
}
private assertEmbeddingShape(embedding: number[], expectedDimensions: number): void {
if (embedding.length !== expectedDimensions) {
throw new IdentityDBError(
`Embedding dimension mismatch. Expected ${expectedDimensions}, received ${embedding.length}.`,
);
}
}
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 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, 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',
granularity: input.granularity ?? 'mixed',
description: input.description ?? null,
metadata: serializeMetadata(input.metadata),
created_at: now,
updated_at: now,
};
await executor.insertInto('topics').values(createdRow).execute();
return mapTopicRow(createdRow);
}
private async updateTopicRowInExecutor(
executor: DatabaseExecutor,
existing: TopicRecord,
input: UpsertTopicInput,
now: string,
shouldRename: boolean,
): Promise<Topic> {
await executor
.updateTable('topics')
.set({
name: shouldRename ? canonicalizeTopicName(input.name) : existing.name,
category: input.category ?? existing.category,
granularity: input.granularity ?? existing.granularity,
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 executor
.selectFrom('topics')
.selectAll()
.where('id', '=', existing.id)
.executeTakeFirstOrThrow();
return mapTopicRow(updated);
}
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, spaceId, normalizedName);
}
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, effectiveSpaceId, factIds);
const topicsByFactId = new Map<string, FactTopic[]>();
for (const topicLink of topicLinks) {
const topics = topicsByFactId.get(topicLink.fact_id) ?? [];
topics.push({
...mapTopicRow(topicLink),
role: topicLink.role,
position: topicLink.position,
});
topicsByFactId.set(topicLink.fact_id, topics);
}
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}).`,
);
}
}
}

187
src/core/migrations.ts Normal file
View File

@@ -0,0 +1,187 @@
import type { Kysely } from 'kysely';
import {
FACTS_TABLE,
FACT_EMBEDDINGS_TABLE,
FACT_TOPICS_TABLE,
SPACES_TABLE,
TOPIC_ALIASES_TABLE,
TOPIC_RELATIONS_TABLE,
TOPICS_TABLE,
} from './schema';
import type { IdentityDatabaseSchema } from '../types/database';
export async function initializeSchema(
db: Kysely<IdentityDatabaseSchema>,
): Promise<void> {
await db.schema
.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('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')
.addColumn('confidence', 'real')
.addColumn('metadata', 'text')
.addColumn('created_at', 'text', (column) => column.notNull())
.addColumn('updated_at', 'text', (column) => column.notNull())
.execute();
await db.schema
.createTable(FACT_EMBEDDINGS_TABLE)
.ifNotExists()
.addColumn('fact_id', 'text', (column) =>
column.notNull().references(`${FACTS_TABLE}.id`).onDelete('cascade'),
)
.addColumn('model', 'text', (column) => column.notNull())
.addColumn('dimensions', 'integer', (column) => column.notNull())
.addColumn('embedding', 'text', (column) => column.notNull())
.addColumn('content_hash', 'text', (column) => column.notNull())
.addColumn('created_at', 'text', (column) => column.notNull())
.addColumn('updated_at', 'text', (column) => column.notNull())
.addPrimaryKeyConstraint('fact_embeddings_pk', ['fact_id', 'model'])
.execute();
await db.schema
.createTable(FACT_TOPICS_TABLE)
.ifNotExists()
.addColumn('fact_id', 'text', (column) =>
column.notNull().references(`${FACTS_TABLE}.id`).onDelete('cascade'),
)
.addColumn('topic_id', 'text', (column) =>
column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'),
)
.addColumn('role', 'text')
.addColumn('position', 'integer', (column) => column.notNull())
.addColumn('created_at', 'text', (column) => column.notNull())
.addPrimaryKeyConstraint('fact_topics_pk', ['fact_id', 'topic_id', 'position'])
.execute();
await db.schema
.createTable(TOPIC_RELATIONS_TABLE)
.ifNotExists()
.addColumn('parent_topic_id', 'text', (column) =>
column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'),
)
.addColumn('child_topic_id', 'text', (column) =>
column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'),
)
.addColumn('relation', 'text', (column) => column.notNull())
.addColumn('created_at', 'text', (column) => column.notNull())
.addPrimaryKeyConstraint('topic_relations_pk', ['parent_topic_id', 'child_topic_id', 'relation'])
.execute();
await db.schema
.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())
.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
.createIndex('fact_topics_topic_id_idx')
.ifNotExists()
.on(FACT_TOPICS_TABLE)
.column('topic_id')
.execute();
await db.schema
.createIndex('fact_topics_fact_id_idx')
.ifNotExists()
.on(FACT_TOPICS_TABLE)
.column('fact_id')
.execute();
await db.schema
.createIndex('fact_embeddings_model_idx')
.ifNotExists()
.on(FACT_EMBEDDINGS_TABLE)
.column('model')
.execute();
await db.schema
.createIndex('topic_relations_parent_topic_id_idx')
.ifNotExists()
.on(TOPIC_RELATIONS_TABLE)
.column('parent_topic_id')
.execute();
await db.schema
.createIndex('topic_relations_child_topic_id_idx')
.ifNotExists()
.on(TOPIC_RELATIONS_TABLE)
.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()
.on(TOPIC_ALIASES_TABLE)
.column('topic_id')
.execute();
}

78
src/core/schema.ts Normal file
View File

@@ -0,0 +1,78 @@
export const SPACES_TABLE = 'spaces';
export const TOPICS_TABLE = 'topics';
export const FACTS_TABLE = 'facts';
export const FACT_TOPICS_TABLE = 'fact_topics';
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',
'granularity',
'description',
'metadata',
'created_at',
'updated_at',
] as const;
export const FACT_COLUMNS = [
'id',
'space_id',
'statement',
'summary',
'source',
'confidence',
'metadata',
'created_at',
'updated_at',
] as const;
export const FACT_TOPIC_COLUMNS = [
'fact_id',
'topic_id',
'role',
'position',
'created_at',
] as const;
export const TOPIC_RELATION_COLUMNS = [
'parent_topic_id',
'child_topic_id',
'relation',
'created_at',
] as const;
export const TOPIC_ALIAS_COLUMNS = [
'id',
'space_id',
'topic_id',
'alias',
'normalized_alias',
'is_primary',
'created_at',
'updated_at',
] as const;
export const FACT_EMBEDDING_COLUMNS = [
'fact_id',
'model',
'dimensions',
'embedding',
'content_hash',
'created_at',
'updated_at',
] as const;

122
src/core/utils.ts Normal file
View File

@@ -0,0 +1,122 @@
import { createHash, randomUUID } from 'node:crypto';
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();
}
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();
}
export function createId(): string {
return randomUUID();
}
export function serializeMetadata(metadata: unknown): string | null {
if (metadata === undefined || metadata === null) {
return null;
}
return JSON.stringify(metadata);
}
export function deserializeMetadata(metadata: string | null): unknown | null {
if (metadata === null) {
return null;
}
return JSON.parse(metadata);
}
export function serializeEmbedding(embedding: number[]): string {
return JSON.stringify(embedding);
}
export function deserializeEmbedding(embedding: string): number[] {
return JSON.parse(embedding) as number[];
}
export function createContentHash(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
export function cosineSimilarity(left: number[], right: number[]): number {
if (left.length === 0 || left.length !== right.length) {
return 0;
}
let dot = 0;
let leftMagnitude = 0;
let rightMagnitude = 0;
for (let index = 0; index < left.length; index += 1) {
const leftValue = left[index] ?? 0;
const rightValue = right[index] ?? 0;
dot += leftValue * rightValue;
leftMagnitude += leftValue * leftValue;
rightMagnitude += rightValue * rightValue;
}
if (leftMagnitude === 0 || rightMagnitude === 0) {
return 0;
}
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,
granularity: record.granularity,
description: record.description,
metadata: deserializeMetadata(record.metadata) as Topic['metadata'],
createdAt: record.created_at,
updatedAt: record.updated_at,
};
}
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,
confidence: record.confidence,
metadata: deserializeMetadata(record.metadata) as Fact['metadata'],
createdAt: record.created_at,
updatedAt: record.updated_at,
topics,
};
}

10
src/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export * from './adapters';
export * from './core/identity-db';
export * from './core/migrations';
export * from './ingestion/extractor';
export * from './ingestion/llm-extractor';
export * from './ingestion/naive-extractor';
export * from './ingestion/types';
export * from './types/api';
export * from './types/database';
export * from './types/domain';

View File

@@ -0,0 +1,46 @@
import { IdentityDBError } from '../core/errors';
import { normalizeTopicName } from '../core/utils';
import type { FactExtractor, ExtractedFact } from './types';
export async function extractFacts(
input: string,
extractor: FactExtractor,
): 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) {
throw new IdentityDBError('Extractor returned an empty statement.');
}
const dedupedTopics = new Map<string, ExtractedFact['topics'][number]>();
for (const topic of extracted.topics) {
const normalizedName = normalizeTopicName(topic.name);
if (normalizedName.length === 0) {
continue;
}
if (!dedupedTopics.has(normalizedName)) {
dedupedTopics.set(normalizedName, topic);
}
}
if (dedupedTopics.size === 0) {
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()),
};
}

View File

@@ -0,0 +1,27 @@
import type {
ExtractedFact,
FactExtractor,
LlmFactExtractorOptions,
} from "./types";
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[]> {
return this.options.model.generateText({
instruction: DEFAULT_INSTRUCTIONS,
input,
additionalInstruction: this.options.additionalInstructions,
});
}
}

View File

@@ -0,0 +1,41 @@
import type { ExtractedFact, FactExtractor } from './types';
export class NaiveExtractor implements FactExtractor {
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) ?? [];
for (const token of tokens) {
const normalized = token.trim().toLowerCase();
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
if (/^\d{4}$/.test(token)) {
topics.push({
name: token,
category: 'temporal',
granularity: 'concrete',
role: 'time',
});
continue;
}
topics.push({
name: token,
category: 'entity',
granularity: 'concrete',
role: token === 'I' ? 'subject' : 'object',
});
}
return [
{
statement: input.trim(),
topics,
},
];
}
}

40
src/ingestion/types.ts Normal file
View File

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

72
src/queries/facts.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { Kysely, Transaction } from 'kysely';
import type { IdentityDatabaseSchema } from '../types/database';
import type { FactRecord, TopicRecord } from '../types/domain';
export type DatabaseExecutor = Kysely<IdentityDatabaseSchema> | Transaction<IdentityDatabaseSchema>;
export interface FactTopicJoinRow extends TopicRecord {
fact_id: string;
role: string | null;
position: number;
}
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();
}
export async function findFactRowsConnectingTopicIds(
executor: DatabaseExecutor,
spaceId: string,
topicIds: string[],
): Promise<FactRecord[]> {
if (topicIds.length === 0) {
return [];
}
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', 'in', topicIds)
.groupBy('facts.id')
.having((eb) => eb.fn.count<number>('fact_topics.topic_id'), '=', topicIds.length)
.orderBy('facts.created_at', 'asc')
.execute();
}
export async function findTopicLinksForFactIds(
executor: DatabaseExecutor,
spaceId: string,
factIds: string[],
): Promise<FactTopicJoinRow[]> {
if (factIds.length === 0) {
return [];
}
return executor
.selectFrom('fact_topics')
.innerJoin('topics', 'topics.id', 'fact_topics.topic_id')
.selectAll('topics')
.select([
'fact_topics.fact_id as fact_id',
'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[]>;
}

148
src/queries/topics.ts Normal file
View File

@@ -0,0 +1,148 @@
import type { Kysely, Transaction } from 'kysely';
import type { IdentityDatabaseSchema } from '../types/database';
import type { SpaceRecord, TopicAliasRecord, TopicRecord } from '../types/domain';
export type DatabaseExecutor = Kysely<IdentityDatabaseSchema> | Transaction<IdentityDatabaseSchema>;
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, spaceId, normalizedName);
if (directMatch) {
return directMatch;
}
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')
.execute();
}
export async function listTopicRows(
executor: DatabaseExecutor,
spaceId: string,
limit?: number,
): Promise<TopicRecord[]> {
let query = executor
.selectFrom('topics')
.selectAll()
.where('space_id', '=', spaceId)
.orderBy('normalized_name', 'asc');
if (limit !== undefined) {
query = query.limit(limit);
}
return query.execute();
}
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')
.orderBy('shared_fact_count', 'desc')
.orderBy('topics.normalized_name', 'asc')
.execute() as Promise<ConnectedTopicRow[]>;
}
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')
.execute();
}
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')
.execute();
}

126
src/types/api.ts Normal file
View File

@@ -0,0 +1,126 @@
import type { JsonValue, TopicCategory, TopicGranularity } from './domain';
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;
description?: string | null;
metadata?: JsonValue | null;
}
export interface TopicLinkInput extends UpsertTopicInput {
role?: string | null;
}
export interface AddFactInput extends SpaceScopedInput {
statement: string;
summary?: string | null;
source?: string | null;
confidence?: number | null;
metadata?: JsonValue | null;
topics: TopicLinkInput[];
}
export interface LinkTopicsInput extends SpaceScopedInput {
parentName: string;
childName: string;
}
export interface Topic {
id: string;
spaceId: string;
name: string;
normalizedName: string;
category: TopicCategory;
granularity: TopicGranularity;
description: string | null;
metadata: JsonValue | null;
createdAt: string;
updatedAt: string;
}
export interface FactTopic extends Topic {
role: string | null;
position: number;
}
export interface Fact {
id: string;
spaceId: string;
statement: string;
summary: string | null;
source: string | null;
confidence: number | null;
metadata: JsonValue | null;
createdAt: string;
updatedAt: string;
topics: FactTopic[];
}
export interface TopicWithFacts extends Topic {
facts: Fact[];
}
export interface ConnectedTopic extends Topic {
sharedFactCount: number;
}
export interface TopicLookupOptions extends SpaceScopedInput {
includeFacts?: boolean;
}
export interface ListTopicsOptions extends SpaceScopedInput {
includeFacts?: boolean;
limit?: number;
}
export interface EmbeddingProvider {
model: string;
dimensions: number;
embed(input: string): Promise<number[]>;
embedMany?(inputs: string[]): Promise<number[][]>;
}
export interface IndexFactEmbeddingsInput extends SpaceScopedInput {
provider: EmbeddingProvider;
}
export interface SearchFactsInput extends SpaceScopedInput {
query: string;
provider: EmbeddingProvider;
topicNames?: string[];
limit?: number;
minimumScore?: number;
}
export interface FindSimilarFactsInput extends SpaceScopedInput {
statement: string;
provider: EmbeddingProvider;
topicNames?: string[];
limit?: number;
minimumScore?: number;
}
export interface ScoredFact extends Fact {
score: number;
}

19
src/types/database.ts Normal file
View File

@@ -0,0 +1,19 @@
import type {
FactEmbeddingRecord,
FactRecord,
FactTopicRecord,
SpaceRecord,
TopicAliasRecord,
TopicRecord,
TopicRelationRecord,
} from './domain';
export interface IdentityDatabaseSchema {
spaces: SpaceRecord;
topics: TopicRecord;
facts: FactRecord;
fact_topics: FactTopicRecord;
topic_relations: TopicRelationRecord;
topic_aliases: TopicAliasRecord;
fact_embeddings: FactEmbeddingRecord;
}

77
src/types/domain.ts Normal file
View File

@@ -0,0 +1,77 @@
export type TopicCategory = 'entity' | 'concept' | 'temporal' | 'custom';
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;
granularity: TopicGranularity;
description: string | null;
metadata: string | null;
created_at: string;
updated_at: string;
}
export interface FactRecord {
id: string;
space_id: string;
statement: string;
summary: string | null;
source: string | null;
confidence: number | null;
metadata: string | null;
created_at: string;
updated_at: string;
}
export interface FactTopicRecord {
fact_id: string;
topic_id: string;
role: string | null;
position: number;
created_at: string;
}
export interface TopicRelationRecord {
parent_topic_id: string;
child_topic_id: string;
relation: string;
created_at: string;
}
export interface TopicAliasRecord {
id: string;
space_id: string;
topic_id: string;
alias: string;
normalized_alias: string;
is_primary: number;
created_at: string;
updated_at: string;
}
export interface FactEmbeddingRecord {
fact_id: string;
model: string;
dimensions: number;
embedding: string;
content_hash: string;
created_at: string;
updated_at: 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');
});
});

143
tests/identity-db.test.ts Normal file
View File

@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IdentityDB } from '../src/core/identity-db';
describe('IdentityDB topic and fact writes', () => {
let db: IdentityDB;
beforeEach(async () => {
db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });
await db.initialize();
});
afterEach(async () => {
await db.close();
});
it('deduplicates topics by normalized name during upsert', async () => {
const first = await db.upsertTopic({
name: 'TypeScript',
category: 'entity',
granularity: 'concrete',
});
const second = await db.upsertTopic({
name: ' typescript ',
category: 'entity',
granularity: 'concrete',
});
expect(second.id).toBe(first.id);
expect(second.normalizedName).toBe('typescript');
const topics = await db.listTopics({ includeFacts: false });
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.',
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' },
],
});
expect(fact.statement).toBe('I have worked with TypeScript since 2025.');
expect(fact.topics.map((topic) => topic.name)).toEqual(['I', 'TypeScript', '2025']);
const typeScriptFacts = await db.getTopicFacts('TypeScript');
expect(typeScriptFacts).toHaveLength(1);
expect(typeScriptFacts[0]?.statement).toBe('I have worked with TypeScript since 2025.');
});
it('resolves alias names to a canonical topic', async () => {
await db.upsertTopic({
name: 'TypeScript',
category: 'entity',
granularity: 'concrete',
});
await db.addTopicAlias('TypeScript', 'TS');
const resolved = await db.resolveTopic('ts');
const aliases = await db.getTopicAliases('TypeScript');
expect(resolved?.name).toBe('TypeScript');
expect(aliases).toEqual(['TS']);
});
it('reuses the canonical topic when a fact is added through an alias', async () => {
await db.upsertTopic({
name: 'TypeScript',
category: 'entity',
granularity: 'concrete',
});
await db.addTopicAlias('TypeScript', 'TS');
await db.addFact({
statement: 'TS compiles to JavaScript.',
topics: [{ name: 'TS', category: 'entity', granularity: 'concrete' }],
});
const topics = await db.listTopics({ includeFacts: false });
const facts = await db.getTopicFacts('TypeScript');
expect(topics.map((topic) => topic.name)).toEqual(['TypeScript']);
expect(facts.map((fact) => fact.statement)).toEqual(['TS compiles to JavaScript.']);
});
});

158
tests/ingestion.test.ts Normal file
View File

@@ -0,0 +1,158 @@
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,
LlmTextGenerationModelInput,
} from "../src/ingestion/types";
describe("IdentityDB ingestion", () => {
let db: IdentityDB;
beforeEach(async () => {
db = await IdentityDB.connect({ client: "sqlite", filename: ":memory:" });
await db.initialize();
});
afterEach(async () => {
await db.close();
});
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",
},
],
},
];
},
};
const fact = await db.ingestStatement(
"I have worked with TypeScript since 2025.",
{
extractor,
},
);
expect(fact.topics.map((topic) => topic.name)).toEqual([
"I",
"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.",
);
});
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",
]);
const topic = await db.getTopicByName("TypeScript", { includeFacts: true });
expect(topic?.facts).toHaveLength(1);
});
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 [
{
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",
},
],
},
];
},
},
additionalInstructions: "Prefer technology and time topics.",
});
const fact = await db.ingestStatement(
"I have worked with Bun and TypeScript since 2025.",
{
extractor,
},
);
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",
]);
});
});

136
tests/migrations.test.ts Normal file
View File

@@ -0,0 +1,136 @@
import { sql } from 'kysely';
import { afterEach, describe, expect, it } from 'vitest';
import { createDatabase } from '../src/adapters/dialect';
import { initializeSchema } from '../src/core/migrations';
const openConnections: Array<() => Promise<void>> = [];
afterEach(async () => {
while (openConnections.length > 0) {
const close = openConnections.pop();
if (close) {
await close();
}
}
});
describe('initializeSchema', () => {
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);
await initializeSchema(connection.db);
const tables = await sql<{ name: string }>`
SELECT name
FROM sqlite_master
WHERE type = 'table'
ORDER BY name
`.execute(connection.db);
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');
expect(tableNames).toContain('fact_topics');
expect(tableNames).toContain('topic_relations');
expect(tableNames).toContain('topic_aliases');
});
it('creates the expected columns for each table', async () => {
const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' });
openConnections.push(connection.destroy);
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);
const factTopicsColumns = await sql<{ name: string }>`PRAGMA table_info(fact_topics)`.execute(connection.db);
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',
'granularity',
'description',
'metadata',
'created_at',
'updated_at',
]);
expect(factsColumns.rows.map((row) => row.name)).toEqual([
'id',
'space_id',
'statement',
'summary',
'source',
'confidence',
'metadata',
'created_at',
'updated_at',
]);
expect(factEmbeddingsColumns.rows.map((row) => row.name)).toEqual([
'fact_id',
'model',
'dimensions',
'embedding',
'content_hash',
'created_at',
'updated_at',
]);
expect(factTopicsColumns.rows.map((row) => row.name)).toEqual([
'fact_id',
'topic_id',
'role',
'position',
'created_at',
]);
expect(topicRelationsColumns.rows.map((row) => row.name)).toEqual([
'parent_topic_id',
'child_topic_id',
'relation',
'created_at',
]);
expect(topicAliasesColumns.rows.map((row) => row.name)).toEqual([
'id',
'space_id',
'topic_id',
'alias',
'normalized_alias',
'is_primary',
'created_at',
'updated_at',
]);
});
it('is idempotent when called more than once', async () => {
const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' });
openConnections.push(connection.destroy);
await initializeSchema(connection.db);
await expect(initializeSchema(connection.db)).resolves.toBeUndefined();
});
});

178
tests/queries.test.ts Normal file
View File

@@ -0,0 +1,178 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IdentityDB } from '../src/core/identity-db';
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' },
{ name: '2025', category: 'temporal', granularity: 'concrete', role: 'time' },
],
});
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' },
],
});
await db.linkTopics({
parentName: 'software technology',
childName: 'programming language',
spaceName,
});
await db.linkTopics({
parentName: 'programming language',
childName: 'TypeScript',
spaceName,
});
}
describe('IdentityDB queries', () => {
let db: IdentityDB;
beforeEach(async () => {
db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });
await db.initialize();
await seedMemoryGraph(db);
});
afterEach(async () => {
await db.close();
});
it('gets a topic with its facts', async () => {
const topic = await db.getTopicByName('TypeScript', { includeFacts: true });
expect(topic).not.toBeNull();
expect(topic?.name).toBe('TypeScript');
expect(topic?.facts).toHaveLength(2);
expect(topic?.facts.map((fact) => fact.statement)).toEqual([
'I have worked with TypeScript since 2025.',
'TypeScript is a programming language.',
]);
});
it('gets only the facts linked to another topic', async () => {
const facts = await db.getTopicFactsLinkedTo('TypeScript', '2025');
expect(facts).toHaveLength(1);
expect(facts[0]?.statement).toBe('I have worked with TypeScript since 2025.');
});
it('lists topics without expanding facts', async () => {
const topics = await db.listTopics({ includeFacts: false });
expect(topics.map((topic) => topic.name)).toEqual([
'2025',
'I',
'programming language',
'software technology',
'TypeScript',
]);
expect('facts' in topics[0]!).toBe(false);
});
it('finds connected topics with shared fact counts', async () => {
const connectedTopics = await db.findConnectedTopics('TypeScript');
expect(connectedTopics).toEqual([
expect.objectContaining({ name: '2025', sharedFactCount: 1 }),
expect.objectContaining({ name: 'I', sharedFactCount: 1 }),
expect.objectContaining({ name: 'programming language', sharedFactCount: 1 }),
]);
});
it('finds facts that connect all requested topics', async () => {
const facts = await db.findFactsConnectingTopics(['I', 'TypeScript', '2025']);
expect(facts).toHaveLength(1);
expect(facts[0]?.statement).toBe('I have worked with TypeScript since 2025.');
});
it('lists direct child topics for a parent topic', async () => {
const children = await db.getTopicChildren('programming language');
expect(children.map((topic) => topic.name)).toEqual(['TypeScript']);
});
it('lists direct parent topics for a child topic', async () => {
const parents = await db.getTopicParents('TypeScript');
expect(parents.map((topic) => topic.name)).toEqual(['programming language']);
});
it('returns lineage from nearest parent outward', async () => {
const lineage = await db.getTopicLineage('TypeScript');
expect(lineage.map((topic) => topic.name)).toEqual([
'programming language',
'software technology',
]);
});
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');
const topic = await db.getTopicByName('ts');
expect(topic?.name).toBe('TypeScript');
});
});

View File

@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IdentityDB } from '../src/core/identity-db';
import type { FactExtractor } from '../src/ingestion/types';
import type { EmbeddingProvider } from '../src/types/api';
class FakeEmbeddingProvider implements EmbeddingProvider {
model = 'fake-semantic-v1';
dimensions = 3;
async embed(input: string): Promise<number[]> {
return embeddingFor(input);
}
async embedMany(inputs: string[]): Promise<number[][]> {
return Promise.all(inputs.map((input) => this.embed(input)));
}
}
function embeddingFor(input: string): number[] {
const normalized = input.toLowerCase();
if (normalized.includes('bun') && normalized.includes('typescript')) {
return [1, 0, 0];
}
if (normalized.includes('tooling') || normalized.includes('runtime')) {
return [0.98, 0.02, 0];
}
if (normalized.includes('typescript')) {
return [0.9, 0.1, 0];
}
if (normalized.includes('python')) {
return [0, 1, 0];
}
if (normalized.includes('database')) {
return [0, 0.2, 0.8];
}
return [0.1, 0.1, 0.1];
}
describe('IdentityDB semantic search', () => {
let db: IdentityDB;
let provider: FakeEmbeddingProvider;
beforeEach(async () => {
provider = new FakeEmbeddingProvider();
db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });
await db.initialize();
await db.addFact({
statement: 'Bun runs TypeScript tooling quickly.',
topics: [
{ name: 'Bun', category: 'entity', granularity: 'concrete' },
{ name: 'TypeScript', category: 'entity', granularity: 'concrete' },
],
});
await db.addFact({
statement: 'TypeScript compiles to JavaScript.',
topics: [
{ name: 'TypeScript', category: 'entity', granularity: 'concrete' },
{ name: 'JavaScript', category: 'entity', granularity: 'concrete' },
],
});
await db.addFact({
statement: 'Python uses indentation syntax.',
topics: [
{ name: 'Python', category: 'entity', granularity: 'concrete' },
],
});
});
afterEach(async () => {
await db.close();
});
it('indexes facts and returns semantic search matches ordered by score', async () => {
await db.indexFactEmbeddings({ provider });
const matches = await db.searchFacts({
query: 'TypeScript runtime tooling',
provider,
limit: 2,
});
expect(matches).toHaveLength(2);
expect(matches[0]?.statement).toBe('Bun runs TypeScript tooling quickly.');
expect(matches[1]?.statement).toBe('TypeScript compiles to JavaScript.');
expect(matches[0]!.score).toBeGreaterThan(matches[1]!.score);
});
it('filters semantic search candidates by topic names', async () => {
await db.indexFactEmbeddings({ provider });
const matches = await db.searchFacts({
query: 'TypeScript runtime tooling',
provider,
topicNames: ['Python'],
limit: 5,
});
expect(matches.map((match) => match.statement)).toEqual(['Python uses indentation syntax.']);
});
it('finds similar facts from an input statement', async () => {
await db.indexFactEmbeddings({ provider });
const matches = await db.findSimilarFacts({
statement: 'Bun makes TypeScript tooling fast.',
provider,
limit: 2,
});
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', () => {
let db: IdentityDB;
let provider: FakeEmbeddingProvider;
let extractor: FactExtractor;
beforeEach(async () => {
provider = new FakeEmbeddingProvider();
extractor = {
async extract(input) {
return [
{
statement: input,
topics: [
{ name: 'Bun', category: 'entity', granularity: 'concrete' },
{ name: 'TypeScript', category: 'entity', granularity: 'concrete' },
],
},
];
},
};
db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });
await db.initialize();
});
afterEach(async () => {
await db.close();
});
it('returns the existing fact when ingestion detects a semantic duplicate', async () => {
const first = await db.ingestStatement('Bun runs TypeScript tooling quickly.', {
extractor,
embeddingProvider: provider,
});
const second = await db.ingestStatement('Bun makes TypeScript tooling fast.', {
extractor,
embeddingProvider: provider,
duplicateThreshold: 0.95,
});
const facts = await db.getTopicFacts('TypeScript');
expect(second.id).toBe(first.id);
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);
});
});

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": ".",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node", "vitest/globals"]
},
"include": [
"src/**/*.ts",
"tests/**/*.ts",
"scripts/**/*.ts",
"vitest.config.ts",
"tsup.config.ts"
],
"exclude": ["dist", "node_modules"]
}

11
tsup.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
sourcemap: true,
clean: true,
target: 'node20',
treeshake: true,
});

12
vitest.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: true,
passWithNoTests: true,
coverage: {
enabled: false,
},
},
});