Initial commit: Cortex — AI project memory & knowledge graph

SQLite-backed knowledge graph with CLI interface. Supports nodes (memory, component, task, decision) connected by typed edges, with hybrid search (BM25 + Ollama embeddings).
This commit is contained in:
2026-02-02 14:53:26 +01:00
commit 21107443a7
21 changed files with 1624 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.memory/

619
package-lock.json generated Normal file
View File

@@ -0,0 +1,619 @@
{
"name": "cortex",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cortex",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"better-sqlite3": "^12.6.2",
"chalk": "^4.1.2",
"commander": "^14.0.3",
"uuid": "^13.0.0"
},
"bin": {
"memory": "dist/cli/index.js"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.2.0",
"@types/uuid": "^10.0.0",
"typescript": "^5.9.3"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "25.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz",
"integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.6.2",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.87.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
"integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "cortex",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "AI project memory - knowledge graph backed by SQLite + Ollama embeddings",
"bin": {
"memory": "./dist/cli/index.js"
},
"dependencies": {
"better-sqlite3": "^12.6.2",
"chalk": "^4.1.2",
"commander": "^14.0.3",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.2.0",
"@types/uuid": "^10.0.0",
"typescript": "^5.9.3"
}
}

37
src/cli/commands/add.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { addNode } from '../../core/store';
import { NodeKind } from '../../types';
const VALID_KINDS: NodeKind[] = ['memory', 'component', 'task', 'decision'];
export const addCommand = new Command('add')
.argument('<kind>', `Node kind: ${VALID_KINDS.join(', ')}`)
.requiredOption('-t, --title <title>', 'Node title')
.option('-c, --content <content>', 'Node content/description', '')
.option('--tags <tags>', 'Comma-separated tags')
.option('--status <status>', 'Status (e.g. todo, doing, done, active, deprecated)')
.description('Add a node to the knowledge graph')
.action(async (kind: string, opts) => {
if (!VALID_KINDS.includes(kind as NodeKind)) {
console.error(chalk.red(`Invalid kind "${kind}". Must be one of: ${VALID_KINDS.join(', ')}`));
process.exit(1);
}
const tags = opts.tags ? opts.tags.split(',').map((t: string) => t.trim()) : [];
const node = await addNode({
kind: kind as NodeKind,
title: opts.title,
content: opts.content,
status: opts.status,
tags,
});
console.log(chalk.green('✓ Added node'));
console.log(` ID: ${chalk.cyan(node.id)}`);
console.log(` Kind: ${node.kind}`);
console.log(` Title: ${node.title}`);
if (node.status) console.log(` Status: ${node.status}`);
if (node.tags.length) console.log(` Tags: ${node.tags.join(', ')}`);
if (node.embedding) console.log(` ${chalk.dim('(embedded)')}`);
});

27
src/cli/commands/graph.ts Normal file
View File

@@ -0,0 +1,27 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { findNodeByPrefix } from '../../core/store';
import { buildTree, renderTree } from '../../core/graph';
export const graphCommand = new Command('graph')
.argument('[id]', 'Root node ID (or prefix). Omit for full graph.')
.description('Visualize the knowledge graph as a tree')
.action(async (idRaw?: string) => {
let rootId: string | undefined;
if (idRaw) {
const node = findNodeByPrefix(idRaw);
if (!node) {
console.error(chalk.red(`Node not found: ${idRaw}`));
process.exit(1);
}
rootId = node.id;
}
const trees = buildTree(rootId);
if (trees.length === 0) {
console.log(chalk.yellow('Graph is empty.'));
return;
}
console.log(renderTree(trees));
});

29
src/cli/commands/link.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { addEdge, findNodeByPrefix } from '../../core/store';
import { EdgeType } from '../../types';
const VALID_TYPES: EdgeType[] = ['depends_on', 'contains', 'implements', 'blocked_by', 'subtask_of', 'relates_to', 'supersedes', 'about'];
export const linkCommand = new Command('link')
.argument('<fromId>', 'Source node ID (or prefix)')
.argument('<toId>', 'Target node ID (or prefix)')
.requiredOption('--type <type>', `Edge type: ${VALID_TYPES.join(', ')}`)
.description('Create a link between two nodes')
.action(async (fromIdRaw: string, toIdRaw: string, opts) => {
if (!VALID_TYPES.includes(opts.type)) {
console.error(chalk.red(`Invalid edge type "${opts.type}". Must be one of: ${VALID_TYPES.join(', ')}`));
process.exit(1);
}
const fromNode = findNodeByPrefix(fromIdRaw);
const toNode = findNodeByPrefix(toIdRaw);
if (!fromNode) { console.error(chalk.red(`Node not found: ${fromIdRaw}`)); process.exit(1); }
if (!toNode) { console.error(chalk.red(`Node not found: ${toIdRaw}`)); process.exit(1); }
const edge = addEdge(fromNode.id, toNode.id, opts.type as EdgeType);
console.log(chalk.green('✓ Linked'));
console.log(` ${fromNode.title} ${chalk.dim(`-[${edge.type}]->`)} ${toNode.title}`);
});

42
src/cli/commands/list.ts Normal file
View File

@@ -0,0 +1,42 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { listNodes } from '../../core/store';
import { NodeKind } from '../../types';
export const listCommand = new Command('list')
.option('--kind <kind>', 'Filter by kind')
.option('--status <status>', 'Filter by status')
.option('--tags <tags>', 'Comma-separated tags to filter')
.option('--limit <n>', 'Max results')
.option('--stale', 'Include stale nodes')
.option('--format <fmt>', 'Output format: text or json', 'text')
.description('List nodes')
.action(async (opts) => {
const tags = opts.tags ? opts.tags.split(',').map((t: string) => t.trim()) : undefined;
const nodes = listNodes({
kind: opts.kind as NodeKind | undefined,
status: opts.status,
tags,
limit: opts.limit ? parseInt(opts.limit) : undefined,
includeStale: opts.stale,
});
if (nodes.length === 0) {
console.log(chalk.yellow('No nodes found.'));
return;
}
if (opts.format === 'json') {
console.log(JSON.stringify(nodes.map(n => ({ ...n, embedding: undefined })), null, 2));
return;
}
for (const n of nodes) {
const status = n.status ? chalk.yellow(` [${n.status}]`) : '';
const tags = n.tags.length ? chalk.dim(` (${n.tags.join(', ')})`) : '';
const stale = n.isStale ? chalk.red(' STALE') : '';
console.log(`${chalk.cyan(n.id.slice(0, 8))} [${chalk.magenta(n.kind)}] ${n.title}${status}${tags}${stale}`);
}
console.log(chalk.dim(`\n${nodes.length} node(s)`));
});

34
src/cli/commands/query.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { query } from '../../core/store';
import { NodeKind } from '../../types';
export const queryCommand = new Command('query')
.argument('<text>', 'Natural language search query')
.option('--kind <kind>', 'Filter by node kind')
.option('--limit <n>', 'Max results', '10')
.option('--format <fmt>', 'Output format: text or json', 'text')
.description('Search the knowledge graph')
.action(async (text: string, opts) => {
const results = await query(text, {
kind: opts.kind as NodeKind | undefined,
limit: parseInt(opts.limit),
});
if (results.length === 0) {
console.log(chalk.yellow('No results found.'));
return;
}
if (opts.format === 'json') {
console.log(JSON.stringify(results.map(r => ({ ...r.node, score: r.score, embedding: undefined })), null, 2));
return;
}
for (const r of results) {
const n = r.node;
console.log(`${chalk.cyan(n.id.slice(0, 8))} [${chalk.magenta(n.kind)}] ${chalk.bold(n.title)} ${chalk.dim(`(${r.score.toFixed(3)})`)}`);
if (n.content) console.log(` ${chalk.dim(n.content.slice(0, 120))}`);
if (n.tags.length) console.log(` ${chalk.yellow(n.tags.join(', '))}`);
}
});

View File

@@ -0,0 +1,23 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { findNodeByPrefix, removeNode } from '../../core/store';
export const removeCommand = new Command('remove')
.argument('<id>', 'Node ID (or prefix)')
.option('--hard', 'Permanently delete (default: soft delete / mark stale)')
.description('Remove a node')
.action(async (idRaw: string, opts) => {
const node = findNodeByPrefix(idRaw);
if (!node) {
console.error(chalk.red(`Node not found: ${idRaw}`));
process.exit(1);
}
const success = removeNode(node.id, opts.hard);
if (success) {
const method = opts.hard ? 'Deleted' : 'Marked stale';
console.log(chalk.green(`${method}: [${node.kind}] ${node.title}`));
} else {
console.error(chalk.red('Remove failed.'));
}
});

46
src/cli/commands/show.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { findNodeByPrefix } from '../../core/store';
import { getConnections } from '../../core/graph';
export const showCommand = new Command('show')
.argument('<id>', 'Node ID (or prefix)')
.option('--format <fmt>', 'Output format: text or json', 'text')
.description('Show a node and its connections')
.action(async (idRaw: string, opts) => {
const node = findNodeByPrefix(idRaw);
if (!node) {
console.error(chalk.red(`Node not found: ${idRaw}`));
process.exit(1);
}
const conns = getConnections(node.id);
if (opts.format === 'json') {
console.log(JSON.stringify({ ...node, embedding: undefined, connections: conns }, null, 2));
return;
}
console.log(chalk.bold.cyan(`[${node.kind}] ${node.title}`));
console.log(`ID: ${node.id}`);
if (node.status) console.log(`Status: ${node.status}`);
if (node.tags.length) console.log(`Tags: ${node.tags.join(', ')}`);
console.log(`Created: ${new Date(node.createdAt).toLocaleString()}`);
console.log(`Updated: ${new Date(node.updatedAt).toLocaleString()}`);
if (node.isStale) console.log(chalk.red('STALE'));
if (node.content) console.log(`\n${node.content}`);
if (conns.outgoing.length) {
console.log(chalk.bold('\nOutgoing:'));
for (const c of conns.outgoing) {
console.log(` ${chalk.dim(`-[${c.type}]->`)} [${c.node.kind}] ${c.node.title} (${c.node.id.slice(0, 8)})`);
}
}
if (conns.incoming.length) {
console.log(chalk.bold('\nIncoming:'));
for (const c of conns.incoming) {
console.log(` [${c.node.kind}] ${c.node.title} (${c.node.id.slice(0, 8)}) ${chalk.dim(`-[${c.type}]->`)} this`);
}
}
});

View File

@@ -0,0 +1,41 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { findNodeByPrefix, updateNode } from '../../core/store';
export const updateCommand = new Command('update')
.argument('<id>', 'Node ID (or prefix)')
.option('-t, --title <title>', 'New title')
.option('-c, --content <content>', 'New content')
.option('--status <status>', 'New status')
.option('--tags <tags>', 'Replace tags (comma-separated)')
.option('--stale', 'Mark as stale')
.description('Update a node')
.action(async (idRaw: string, opts) => {
const node = findNodeByPrefix(idRaw);
if (!node) {
console.error(chalk.red(`Node not found: ${idRaw}`));
process.exit(1);
}
const input: any = {};
if (opts.title !== undefined) input.title = opts.title;
if (opts.content !== undefined) input.content = opts.content;
if (opts.status !== undefined) input.status = opts.status;
if (opts.tags !== undefined) input.tags = opts.tags.split(',').map((t: string) => t.trim());
if (opts.stale) input.isStale = true;
if (Object.keys(input).length === 0) {
console.log(chalk.yellow('Nothing to update. Use --title, --content, --status, --tags, or --stale.'));
return;
}
const updated = await updateNode(node.id, input);
if (!updated) {
console.error(chalk.red('Update failed.'));
process.exit(1);
}
console.log(chalk.green('✓ Updated'));
console.log(` [${updated.kind}] ${updated.title}`);
if (updated.status) console.log(` Status: ${updated.status}`);
});

37
src/cli/index.ts Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { addCommand } from './commands/add';
import { queryCommand } from './commands/query';
import { linkCommand } from './commands/link';
import { showCommand } from './commands/show';
import { listCommand } from './commands/list';
import { updateCommand } from './commands/update';
import { removeCommand } from './commands/remove';
import { graphCommand } from './commands/graph';
import { closeDb } from '../core/db';
const program = new Command();
program
.name('memory')
.description('Cortex — AI project memory & knowledge graph')
.version('1.0.0');
program.addCommand(addCommand);
program.addCommand(queryCommand);
program.addCommand(linkCommand);
program.addCommand(showCommand);
program.addCommand(listCommand);
program.addCommand(updateCommand);
program.addCommand(removeCommand);
program.addCommand(graphCommand);
program.hook('postAction', () => {
closeDb();
});
program.parseAsync(process.argv).catch((err) => {
console.error(err);
closeDb();
process.exit(1);
});

72
src/core/db.ts Normal file
View File

@@ -0,0 +1,72 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
status TEXT,
tags TEXT DEFAULT '[]',
metadata TEXT DEFAULT '{}',
embedding BLOB,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
is_stale INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS edges (
id TEXT PRIMARY KEY,
from_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
to_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
type TEXT NOT NULL,
metadata TEXT DEFAULT '{}',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS node_tags (
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (node_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status);
CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_nodes_stale ON nodes(is_stale);
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_id);
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_id);
CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
CREATE INDEX IF NOT EXISTS idx_tags_tag ON node_tags(tag);
`;
let _db: Database.Database | null = null;
export function getMemoryDir(): string {
return path.join(process.cwd(), '.memory');
}
export function getDb(): Database.Database {
if (_db) return _db;
const dir = getMemoryDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
_db = new Database(path.join(dir, 'cortex.db'));
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
_db.exec(SCHEMA);
return _db;
}
export function closeDb(): void {
if (_db) {
_db.close();
_db = null;
}
}

131
src/core/graph.ts Normal file
View File

@@ -0,0 +1,131 @@
import { getDb } from './db';
import { Node, Edge } from '../types';
interface TreeNode {
id: string;
title: string;
kind: string;
edgeType?: string;
children: TreeNode[];
}
function rowToEdge(row: any): Edge {
return {
id: row.id,
fromId: row.from_id,
toId: row.to_id,
type: row.type,
metadata: JSON.parse(row.metadata || '{}'),
createdAt: row.created_at,
};
}
export function getConnections(nodeId: string): { incoming: (Edge & { node: Node })[], outgoing: (Edge & { node: Node })[] } {
const db = getDb();
const outRows = db.prepare(`
SELECT e.*, n.id as n_id, n.kind, n.title, n.content, n.status, n.tags, n.metadata as n_metadata, n.created_at as n_created, n.updated_at as n_updated, n.is_stale
FROM edges e JOIN nodes n ON e.to_id = n.id WHERE e.from_id = ?
`).all(nodeId) as any[];
const inRows = db.prepare(`
SELECT e.*, n.id as n_id, n.kind, n.title, n.content, n.status, n.tags, n.metadata as n_metadata, n.created_at as n_created, n.updated_at as n_updated, n.is_stale
FROM edges e JOIN nodes n ON e.from_id = n.id WHERE e.to_id = ?
`).all(nodeId) as any[];
const mapRow = (row: any) => ({
...rowToEdge(row),
node: {
id: row.n_id,
kind: row.kind,
title: row.title,
content: row.content,
status: row.status,
tags: JSON.parse(row.tags || '[]'),
metadata: JSON.parse(row.n_metadata || '{}'),
embedding: null,
createdAt: row.n_created,
updatedAt: row.n_updated,
isStale: !!row.is_stale,
},
});
return {
outgoing: outRows.map(mapRow),
incoming: inRows.map(mapRow),
};
}
export function buildTree(rootId?: string): TreeNode[] {
const db = getDb();
const edges = db.prepare('SELECT * FROM edges').all() as any[];
const nodes = db.prepare('SELECT id, title, kind FROM nodes WHERE is_stale = 0').all() as any[];
const nodeMap = new Map(nodes.map((n: any) => [n.id, n]));
// Build adjacency: from -> [{toId, type}]
const children = new Map<string, { toId: string; type: string }[]>();
const hasParent = new Set<string>();
for (const e of edges) {
const list = children.get(e.from_id) || [];
list.push({ toId: e.to_id, type: e.type });
children.set(e.from_id, list);
hasParent.add(e.to_id);
}
function build(id: string, visited: Set<string>): TreeNode | null {
if (visited.has(id)) return null;
const node = nodeMap.get(id);
if (!node) return null;
visited.add(id);
const kids = (children.get(id) || [])
.map(c => {
const child = build(c.toId, visited);
if (child) child.edgeType = c.type;
return child;
})
.filter(Boolean) as TreeNode[];
return { id: node.id, title: node.title, kind: node.kind, children: kids };
}
if (rootId) {
const tree = build(rootId, new Set());
return tree ? [tree] : [];
}
// All roots: nodes with no incoming edges
const roots = nodes.filter((n: any) => !hasParent.has(n.id));
// Also include orphans (no edges at all)
const result: TreeNode[] = [];
const visited = new Set<string>();
for (const r of roots) {
const tree = build(r.id, visited);
if (tree) result.push(tree);
}
// Add any remaining unvisited nodes as standalone
for (const n of nodes) {
if (!visited.has(n.id)) {
result.push({ id: n.id, title: n.title, kind: n.kind, children: [] });
}
}
return result;
}
export function renderTree(trees: TreeNode[], indent: string = ''): string {
const lines: string[] = [];
for (let i = 0; i < trees.length; i++) {
const t = trees[i];
const isLast = i === trees.length - 1;
const prefix = indent + (indent ? (isLast ? '└── ' : '├── ') : '');
const edgeLabel = t.edgeType ? ` -[${t.edgeType}]` : '';
lines.push(`${prefix}[${t.kind}] ${t.title} (${t.id.slice(0, 8)})${edgeLabel}`);
const childIndent = indent + (indent ? (isLast ? ' ' : '│ ') : ' ');
if (t.children.length > 0) {
lines.push(renderTree(t.children, childIndent));
}
}
return lines.join('\n');
}

57
src/core/search/bm25.ts Normal file
View File

@@ -0,0 +1,57 @@
import { Node, SearchResult } from '../../types';
const K1 = 1.2;
const B = 0.75;
function tokenize(text: string): string[] {
return text.toLowerCase().replace(/[^\w\s]/g, ' ').split(/\s+/).filter(t => t.length > 1);
}
export function bm25Search(nodes: Node[], query: string, limit: number = 10): SearchResult[] {
const queryTokens = tokenize(query);
if (queryTokens.length === 0) return [];
// Build corpus stats
const N = nodes.length;
const docs = nodes.map(n => tokenize(`${n.title} ${n.content}`));
const avgDl = docs.reduce((s, d) => s + d.length, 0) / (N || 1);
// Document frequency for each query term
const df: Record<string, number> = {};
for (const token of queryTokens) {
df[token] = 0;
for (const doc of docs) {
if (doc.includes(token)) df[token]++;
}
}
// Score each document
const scored: SearchResult[] = [];
for (let i = 0; i < N; i++) {
const doc = docs[i];
const dl = doc.length;
let score = 0;
// Term frequencies in this doc
const tf: Record<string, number> = {};
for (const token of doc) {
tf[token] = (tf[token] || 0) + 1;
}
for (const token of queryTokens) {
const termFreq = tf[token] || 0;
if (termFreq === 0) continue;
const idf = Math.log((N - (df[token] || 0) + 0.5) / ((df[token] || 0) + 0.5) + 1);
const tfNorm = (termFreq * (K1 + 1)) / (termFreq + K1 * (1 - B + B * dl / avgDl));
score += idf * tfNorm;
}
if (score > 0) {
scored.push({ node: nodes[i], score });
}
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, limit);
}

73
src/core/search/index.ts Normal file
View File

@@ -0,0 +1,73 @@
import { Node, SearchResult, QueryOptions } from '../../types';
import { bm25Search } from './bm25';
import { cosineSimilarity } from './vector';
import { getEmbedding, isOllamaAvailable } from './ollama';
const VECTOR_WEIGHT = 0.7;
const BM25_WEIGHT = 0.3;
function deserializeEmbedding(blob: Buffer | null): number[] | null {
if (!blob || blob.length === 0) return null;
const float32 = new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);
return Array.from(float32);
}
export async function hybridSearch(
nodes: Node[],
query: string,
options: QueryOptions = {}
): Promise<SearchResult[]> {
const limit = options.limit ?? 10;
// Filter stale unless requested
let candidates = options.includeStale ? nodes : nodes.filter(n => !n.isStale);
if (options.kind) candidates = candidates.filter(n => n.kind === options.kind);
if (options.tags?.length) {
candidates = candidates.filter(n => options.tags!.some(t => n.tags.includes(t)));
}
const bm25Results = bm25Search(candidates, query, limit);
if (!(await isOllamaAvailable())) {
return bm25Results;
}
// Try vector search
const queryEmbedding = await getEmbedding(query);
if (!queryEmbedding) return bm25Results;
// Score all candidates with embeddings
const vectorScored: SearchResult[] = [];
for (const node of candidates) {
if (!node.embedding) continue;
const sim = cosineSimilarity(queryEmbedding, node.embedding);
if (sim > 0) vectorScored.push({ node, score: sim });
}
if (vectorScored.length === 0) return bm25Results;
// Normalize scores
const maxBm25 = Math.max(...bm25Results.map(r => r.score), 1e-10);
const maxVector = Math.max(...vectorScored.map(r => r.score), 1e-10);
// Merge by node ID
const merged = new Map<string, number>();
for (const r of bm25Results) {
merged.set(r.node.id, (merged.get(r.node.id) ?? 0) + BM25_WEIGHT * (r.score / maxBm25));
}
for (const r of vectorScored) {
merged.set(r.node.id, (merged.get(r.node.id) ?? 0) + VECTOR_WEIGHT * (r.score / maxVector));
}
const nodeMap = new Map(candidates.map(n => [n.id, n]));
const results: SearchResult[] = [];
for (const [id, score] of merged) {
const node = nodeMap.get(id);
if (node) results.push({ node, score });
}
results.sort((a, b) => b.score - a.score);
return results.slice(0, limit);
}
export { deserializeEmbedding };

38
src/core/search/ollama.ts Normal file
View File

@@ -0,0 +1,38 @@
const OLLAMA_URL = 'http://localhost:11434';
const MODEL = 'nomic-embed-text';
let _available: boolean | null = null;
export async function isOllamaAvailable(): Promise<boolean> {
if (_available !== null) return _available;
try {
const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(2000) });
_available = res.ok;
} catch {
_available = false;
}
return _available;
}
export async function getEmbedding(text: string): Promise<number[] | null> {
if (!(await isOllamaAvailable())) return null;
try {
const res = await fetch(`${OLLAMA_URL}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: MODEL, prompt: text }),
signal: AbortSignal.timeout(30000),
});
if (!res.ok) return null;
const data = await res.json() as { embedding?: number[] };
return data.embedding ?? null;
} catch {
return null;
}
}
export async function getEmbeddings(texts: string[]): Promise<(number[] | null)[]> {
return Promise.all(texts.map(getEmbedding));
}

13
src/core/search/vector.ts Normal file
View File

@@ -0,0 +1,13 @@
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length || a.length === 0) return 0;
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}

184
src/core/store.ts Normal file
View File

@@ -0,0 +1,184 @@
import { v4 as uuid } from 'uuid';
import { getDb } from './db';
import { Node, Edge, AddNodeInput, UpdateNodeInput, ListOptions, QueryOptions, SearchResult, EdgeType } from '../types';
import { hybridSearch, deserializeEmbedding } from './search/index';
import { getEmbedding } from './search/ollama';
function rowToNode(row: any): Node {
return {
id: row.id,
kind: row.kind,
title: row.title,
content: row.content,
status: row.status ?? undefined,
tags: JSON.parse(row.tags || '[]'),
metadata: JSON.parse(row.metadata || '{}'),
embedding: row.embedding ? deserializeEmbedding(row.embedding) : null,
createdAt: row.created_at,
updatedAt: row.updated_at,
isStale: !!row.is_stale,
};
}
function serializeEmbedding(embedding: number[]): Buffer {
return Buffer.from(new Float32Array(embedding).buffer);
}
export async function addNode(input: AddNodeInput): Promise<Node> {
const db = getDb();
const id = uuid();
const now = Date.now();
const tags = input.tags ?? [];
const metadata = input.metadata ?? {};
const content = input.content ?? '';
// Try to get embedding
const embedding = await getEmbedding(`${input.title} ${content}`);
db.prepare(`
INSERT INTO nodes (id, kind, title, content, status, tags, metadata, embedding, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id, input.kind, input.title, content, input.status ?? null,
JSON.stringify(tags), JSON.stringify(metadata),
embedding ? serializeEmbedding(embedding) : null,
now, now
);
// Insert tags
const insertTag = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?, ?)');
for (const tag of tags) {
insertTag.run(id, tag);
}
return {
id, kind: input.kind, title: input.title, content, status: input.status,
tags, metadata, embedding, createdAt: now, updatedAt: now, isStale: false,
};
}
export function getNode(id: string): Node | null {
const db = getDb();
const row = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id) as any;
return row ? rowToNode(row) : null;
}
export function findNodeByPrefix(prefix: string): Node | null {
const db = getDb();
const row = db.prepare('SELECT * FROM nodes WHERE id LIKE ?').get(`${prefix}%`) as any;
return row ? rowToNode(row) : null;
}
export function listNodes(options: ListOptions = {}): Node[] {
const db = getDb();
const conditions: string[] = [];
const params: any[] = [];
if (!options.includeStale) {
conditions.push('is_stale = 0');
}
if (options.kind) {
conditions.push('kind = ?');
params.push(options.kind);
}
if (options.status) {
conditions.push('status = ?');
params.push(options.status);
}
let sql = 'SELECT * FROM nodes';
if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
sql += ' ORDER BY created_at DESC';
if (options.limit) {
sql += ' LIMIT ?';
params.push(options.limit);
}
let nodes = (db.prepare(sql).all(...params) as any[]).map(rowToNode);
if (options.tags?.length) {
nodes = nodes.filter(n => options.tags!.some(t => n.tags.includes(t)));
}
return nodes;
}
export async function updateNode(id: string, input: UpdateNodeInput): Promise<Node | null> {
const db = getDb();
const existing = getNode(id);
if (!existing) return null;
const now = Date.now();
const sets: string[] = ['updated_at = ?'];
const params: any[] = [now];
if (input.title !== undefined) { sets.push('title = ?'); params.push(input.title); }
if (input.content !== undefined) { sets.push('content = ?'); params.push(input.content); }
if (input.status !== undefined) { sets.push('status = ?'); params.push(input.status); }
if (input.isStale !== undefined) { sets.push('is_stale = ?'); params.push(input.isStale ? 1 : 0); }
if (input.tags !== undefined) { sets.push('tags = ?'); params.push(JSON.stringify(input.tags)); }
if (input.metadata !== undefined) {
const merged = { ...existing.metadata, ...input.metadata };
sets.push('metadata = ?');
params.push(JSON.stringify(merged));
}
// Re-embed if title or content changed
if (input.title !== undefined || input.content !== undefined) {
const newTitle = input.title ?? existing.title;
const newContent = input.content ?? existing.content;
const embedding = await getEmbedding(`${newTitle} ${newContent}`);
if (embedding) {
sets.push('embedding = ?');
params.push(serializeEmbedding(embedding));
}
}
params.push(id);
db.prepare(`UPDATE nodes SET ${sets.join(', ')} WHERE id = ?`).run(...params);
// Update tags if changed
if (input.tags !== undefined) {
db.prepare('DELETE FROM node_tags WHERE node_id = ?').run(id);
const insertTag = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?, ?)');
for (const tag of input.tags) {
insertTag.run(id, tag);
}
}
return getNode(id);
}
export function removeNode(id: string, hard: boolean = false): boolean {
const db = getDb();
if (hard) {
const result = db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
return result.changes > 0;
} else {
const result = db.prepare('UPDATE nodes SET is_stale = 1, updated_at = ? WHERE id = ?').run(Date.now(), id);
return result.changes > 0;
}
}
export function addEdge(fromId: string, toId: string, type: EdgeType, metadata: Record<string, any> = {}): Edge {
const db = getDb();
const id = uuid();
const now = Date.now();
db.prepare(`
INSERT INTO edges (id, from_id, to_id, type, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?)
`).run(id, fromId, toId, type, JSON.stringify(metadata), now);
return { id, fromId, toId, type, metadata, createdAt: now };
}
export function removeEdge(id: string): boolean {
const db = getDb();
const result = db.prepare('DELETE FROM edges WHERE id = ?').run(id);
return result.changes > 0;
}
export async function query(text: string, options: QueryOptions = {}): Promise<SearchResult[]> {
const nodes = listNodes({ kind: options.kind, tags: options.tags, includeStale: options.includeStale });
return hybridSearch(nodes, text, options);
}

72
src/types.ts Normal file
View File

@@ -0,0 +1,72 @@
export type NodeKind = 'memory' | 'component' | 'task' | 'decision';
export type EdgeType =
| 'depends_on'
| 'contains'
| 'implements'
| 'blocked_by'
| 'subtask_of'
| 'relates_to'
| 'supersedes'
| 'about';
export interface Node {
id: string;
kind: NodeKind;
title: string;
content: string;
status?: string;
tags: string[];
metadata: Record<string, any>;
embedding: number[] | null;
createdAt: number;
updatedAt: number;
isStale: boolean;
}
export interface Edge {
id: string;
fromId: string;
toId: string;
type: EdgeType;
metadata: Record<string, any>;
createdAt: number;
}
export interface SearchResult {
node: Node;
score: number;
}
export interface AddNodeInput {
kind: NodeKind;
title: string;
content?: string;
status?: string;
tags?: string[];
metadata?: Record<string, any>;
}
export interface UpdateNodeInput {
title?: string;
content?: string;
status?: string;
tags?: string[];
metadata?: Record<string, any>;
isStale?: boolean;
}
export interface QueryOptions {
kind?: NodeKind;
tags?: string[];
limit?: number;
includeStale?: boolean;
}
export interface ListOptions {
kind?: NodeKind;
status?: string;
tags?: string[];
limit?: number;
includeStale?: boolean;
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}