From 21107443a7a2f0259cf244a07b41097d32e7e3f2 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Mon, 2 Feb 2026 14:53:26 +0100 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Cortex=20=E2=80=94=20AI=20p?= =?UTF-8?q?roject=20memory=20&=20knowledge=20graph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite-backed knowledge graph with CLI interface. Supports nodes (memory, component, task, decision) connected by typed edges, with hybrid search (BM25 + Ollama embeddings). --- .gitignore | 3 + package-lock.json | 619 +++++++++++++++++++++++++++++++++++++ package.json | 28 ++ src/cli/commands/add.ts | 37 +++ src/cli/commands/graph.ts | 27 ++ src/cli/commands/link.ts | 29 ++ src/cli/commands/list.ts | 42 +++ src/cli/commands/query.ts | 34 ++ src/cli/commands/remove.ts | 23 ++ src/cli/commands/show.ts | 46 +++ src/cli/commands/update.ts | 41 +++ src/cli/index.ts | 37 +++ src/core/db.ts | 72 +++++ src/core/graph.ts | 131 ++++++++ src/core/search/bm25.ts | 57 ++++ src/core/search/index.ts | 73 +++++ src/core/search/ollama.ts | 38 +++ src/core/search/vector.ts | 13 + src/core/store.ts | 184 +++++++++++ src/types.ts | 72 +++++ tsconfig.json | 18 ++ 21 files changed, 1624 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli/commands/add.ts create mode 100644 src/cli/commands/graph.ts create mode 100644 src/cli/commands/link.ts create mode 100644 src/cli/commands/list.ts create mode 100644 src/cli/commands/query.ts create mode 100644 src/cli/commands/remove.ts create mode 100644 src/cli/commands/show.ts create mode 100644 src/cli/commands/update.ts create mode 100644 src/cli/index.ts create mode 100644 src/core/db.ts create mode 100644 src/core/graph.ts create mode 100644 src/core/search/bm25.ts create mode 100644 src/core/search/index.ts create mode 100644 src/core/search/ollama.ts create mode 100644 src/core/search/vector.ts create mode 100644 src/core/store.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a7e8fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.memory/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..064e9de --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fc7796c --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/cli/commands/add.ts b/src/cli/commands/add.ts new file mode 100644 index 0000000..70dd143 --- /dev/null +++ b/src/cli/commands/add.ts @@ -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('', `Node kind: ${VALID_KINDS.join(', ')}`) + .requiredOption('-t, --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)')}`); + }); diff --git a/src/cli/commands/graph.ts b/src/cli/commands/graph.ts new file mode 100644 index 0000000..6b5f20d --- /dev/null +++ b/src/cli/commands/graph.ts @@ -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)); + }); diff --git a/src/cli/commands/link.ts b/src/cli/commands/link.ts new file mode 100644 index 0000000..045730a --- /dev/null +++ b/src/cli/commands/link.ts @@ -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}`); + }); diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts new file mode 100644 index 0000000..37269cd --- /dev/null +++ b/src/cli/commands/list.ts @@ -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)`)); + }); diff --git a/src/cli/commands/query.ts b/src/cli/commands/query.ts new file mode 100644 index 0000000..32bf727 --- /dev/null +++ b/src/cli/commands/query.ts @@ -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(', '))}`); + } + }); diff --git a/src/cli/commands/remove.ts b/src/cli/commands/remove.ts new file mode 100644 index 0000000..ebadeab --- /dev/null +++ b/src/cli/commands/remove.ts @@ -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.')); + } + }); diff --git a/src/cli/commands/show.ts b/src/cli/commands/show.ts new file mode 100644 index 0000000..69688bb --- /dev/null +++ b/src/cli/commands/show.ts @@ -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`); + } + } + }); diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts new file mode 100644 index 0000000..3dd3f74 --- /dev/null +++ b/src/cli/commands/update.ts @@ -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}`); + }); diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..8a7c357 --- /dev/null +++ b/src/cli/index.ts @@ -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); +}); diff --git a/src/core/db.ts b/src/core/db.ts new file mode 100644 index 0000000..849253f --- /dev/null +++ b/src/core/db.ts @@ -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; + } +} diff --git a/src/core/graph.ts b/src/core/graph.ts new file mode 100644 index 0000000..ce0af5f --- /dev/null +++ b/src/core/graph.ts @@ -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'); +} diff --git a/src/core/search/bm25.ts b/src/core/search/bm25.ts new file mode 100644 index 0000000..9b05d5e --- /dev/null +++ b/src/core/search/bm25.ts @@ -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); +} diff --git a/src/core/search/index.ts b/src/core/search/index.ts new file mode 100644 index 0000000..aeb0271 --- /dev/null +++ b/src/core/search/index.ts @@ -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 }; diff --git a/src/core/search/ollama.ts b/src/core/search/ollama.ts new file mode 100644 index 0000000..d7b9396 --- /dev/null +++ b/src/core/search/ollama.ts @@ -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)); +} diff --git a/src/core/search/vector.ts b/src/core/search/vector.ts new file mode 100644 index 0000000..3b8ccae --- /dev/null +++ b/src/core/search/vector.ts @@ -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; +} diff --git a/src/core/store.ts b/src/core/store.ts new file mode 100644 index 0000000..499f1f1 --- /dev/null +++ b/src/core/store.ts @@ -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); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4706b86 --- /dev/null +++ b/src/types.ts @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..87cde83 --- /dev/null +++ b/tsconfig.json @@ -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"] +}