Compare commits

..

8 Commits

Author SHA1 Message Date
a7aec4ee29 Improved memory prompt slightly
All checks were successful
Publish Library / Build NPM Project (push) Successful in 1m9s
Publish Library / Tag Version (push) Successful in 19s
2026-07-30 16:00:03 -04:00
dda2d4c2a3 Bump 1.2.8
All checks were successful
Publish Library / Build NPM Project (push) Successful in 49s
Publish Library / Tag Version (push) Successful in 7s
2026-07-29 22:35:29 -04:00
58e0e488e4 Added Geo, FS and flarescraperr tools
Some checks failed
Publish Library / Tag Version (push) Has been cancelled
Publish Library / Build NPM Project (push) Has been cancelled
2026-07-29 22:34:51 -04:00
8dfcd06752 More memory fixes
All checks were successful
Publish Library / Build NPM Project (push) Successful in 43s
Publish Library / Tag Version (push) Successful in 14s
2026-07-29 22:11:09 -04:00
14f6cdd313 Personal file memory organization instructions
All checks were successful
Publish Library / Build NPM Project (push) Successful in 33s
Publish Library / Tag Version (push) Successful in 12s
2026-07-27 22:47:48 -04:00
73d6ee0f2a Personal file memory organization instructions
All checks were successful
Publish Library / Build NPM Project (push) Successful in 45s
Publish Library / Tag Version (push) Successful in 12s
2026-07-27 22:39:06 -04:00
bee4085666 updatememory awaits full result
Some checks failed
Publish Library / Tag Version (push) Has been cancelled
Publish Library / Build NPM Project (push) Has been cancelled
2026-07-27 22:34:36 -04:00
3b5c71de7c Improved memory management
All checks were successful
Publish Library / Build NPM Project (push) Successful in 40s
Publish Library / Tag Version (push) Successful in 14s
2026-07-27 20:10:09 -04:00
9 changed files with 1190 additions and 595 deletions

234
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@ztimson/ai-utils", "name": "@ztimson/ai-utils",
"version": "1.0.6", "version": "1.2.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@ztimson/ai-utils", "name": "@ztimson/ai-utils",
"version": "1.0.6", "version": "1.2.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.102.0", "@anthropic-ai/sdk": "^0.102.0",
@@ -57,34 +57,38 @@
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "2.0.0-alpha.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.2", "@emnapi/wasi-threads": "2.0.1",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.2", "version": "2.0.0-alpha.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@@ -573,6 +577,16 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/sharp-win32-arm64": { "node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
@@ -681,22 +695,25 @@
} }
}, },
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@tybys/wasm-util": "^0.10.3" "@tybys/wasm-util": "^0.10.3"
}, },
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
},
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/Brooooooklyn" "url": "https://github.com/sponsors/Brooooooklyn"
}, },
"peerDependencies": { "peerDependencies": {
"@emnapi/core": "^1.7.1", "@emnapi/core": "^2.0.0-alpha.3",
"@emnapi/runtime": "^1.7.1" "@emnapi/runtime": "^2.0.0-alpha.3"
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
@@ -1007,6 +1024,18 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
@@ -1018,6 +1047,17 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
@@ -1408,18 +1448,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@ztimson/utils": { "node_modules/@ztimson/utils": {
"version": "0.29.5", "version": "0.29.7",
"resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.29.5.tgz", "resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.29.7.tgz",
"integrity": "sha512-8mUuhi//3agwrueR006emOvJu1JXxVEryJmD3nkEmK4yQ9qS24oZijdoaiLRid/6xc75j3Fk6YjmnYmjq61iPQ==", "integrity": "sha512-cjQ9+RjC5X7gKNA/hJHDf7OtyYCa+5E0PDc76lIaATwNAxXCSx2IO9r2wHiHtZGV5bldjnFmw7aOV8Jmq7SgKQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"var-persist": "^1.0.1" "var-persist": "^1.0.1"
} }
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.17.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -1504,9 +1544,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.1.2", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2009,9 +2049,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/exsolve": { "node_modules/exsolve": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2382,9 +2422,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true, "dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
@@ -2398,23 +2438,23 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
}, },
"optionalDependencies": { "optionalDependencies": {
"lightningcss-android-arm64": "1.32.0", "lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.32.0", "lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.32.0", "lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.32.0", "lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.32.0" "lightningcss-win32-x64-msvc": "1.33.0"
} }
}, },
"node_modules/lightningcss-android-arm64": { "node_modules/lightningcss-android-arm64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2433,9 +2473,9 @@
} }
}, },
"node_modules/lightningcss-darwin-arm64": { "node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2454,9 +2494,9 @@
} }
}, },
"node_modules/lightningcss-darwin-x64": { "node_modules/lightningcss-darwin-x64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2475,9 +2515,9 @@
} }
}, },
"node_modules/lightningcss-freebsd-x64": { "node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2496,9 +2536,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm-gnueabihf": { "node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2517,9 +2557,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-gnu": { "node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2541,9 +2581,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-musl": { "node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2565,9 +2605,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-gnu": { "node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2589,9 +2629,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-musl": { "node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2613,9 +2653,9 @@
} }
}, },
"node_modules/lightningcss-win32-arm64-msvc": { "node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2634,9 +2674,9 @@
} }
}, },
"node_modules/lightningcss-win32-x64-msvc": { "node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2794,9 +2834,9 @@
} }
}, },
"node_modules/mdurl": { "node_modules/mdurl": {
"version": "2.0.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2971,9 +3011,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3092,9 +3132,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/openai": { "node_modules/openai": {
"version": "6.46.0", "version": "6.49.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.46.0.tgz", "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz",
"integrity": "sha512-DFg6jEPT2RO+oAyXtddeUJU8zkGy1OQ1AjGzNIJUMQG03TTqvCpy9tBpQ+2VVVnvrl3E56F8GEin2JYtWpITtA==", "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
"@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@aws-sdk/credential-provider-node": ">=3.972.0 <4",
@@ -3232,9 +3272,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.17", "version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3252,7 +3292,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.16",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -3787,9 +3827,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.28.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20.18.1" "node": ">=20.18.1"
@@ -3981,16 +4021,16 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.1.4", "version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.5", "picomatch": "^4.0.5",
"postcss": "^8.5.16", "postcss": "^8.5.17",
"rolldown": "~1.1.4", "rolldown": "~1.1.5",
"tinyglobby": "^0.2.17" "tinyglobby": "^0.2.17"
}, },
"bin": { "bin": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/ai-utils", "name": "@ztimson/ai-utils",
"version": "1.2.3", "version": "1.2.10",
"description": "AI Utility library", "description": "AI Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",

View File

@@ -3,6 +3,8 @@ export * from './antrhopic';
export * from './audio'; export * from './audio';
export * from './llm'; export * from './llm';
export * from './memory'; export * from './memory';
export * from './memory-cache';
export * from './memory-graph';
export * from './open-ai'; export * from './open-ai';
export * from './provider'; export * from './provider';
export * from './tools'; export * from './tools';

View File

@@ -1,12 +1,13 @@
import {AbortablePromise, Ai} from './ai.ts'; import {AbortablePromise, Ai} from './ai.ts';
import {Anthropic} from './antrhopic.ts'; import {Anthropic} from './antrhopic.ts';
import {MemoryCache} from './memory-cache.ts';
import {OpenAi} from './open-ai.ts'; import {OpenAi} from './open-ai.ts';
import {LLMProvider} from './provider.ts'; import {LLMProvider} from './provider.ts';
import {AiTool, AiToolArg} from './tools.ts'; import {AiTool, AiToolArg} from './tools.ts';
import {fileURLToPath} from 'url'; import {fileURLToPath} from 'url';
import {dirname, join} from 'path'; import {dirname, join} from 'path';
import {spawn} from 'node:child_process'; import {spawn} from 'node:child_process';
import {Memory, MemoryCache, MemoryManager} from './memory.ts'; import {Memory, MemoryManager} from './memory.ts';
export type AnthropicConfig = {proto: 'anthropic', token: string}; export type AnthropicConfig = {proto: 'anthropic', token: string};
export type OpenAiConfig = {proto: 'openai', host?: string, token: string}; export type OpenAiConfig = {proto: 'openai', host?: string, token: string};
@@ -143,7 +144,7 @@ class LLM {
return { return {
prompt: `You have access to the following skill documents, use \`read_skill\` to access them:\n${list}`, prompt: `You have access to the following skill documents, use \`read_skill\` to access them:\n${list}`,
tools: [{ tools: [{
name: 'read_skill', name: 'skill_read',
description: 'Read the full content of a skill/knowledge document', description: 'Read the full content of a skill/knowledge document',
args: { args: {
name: {type: 'string', description: 'Exact skill name', required: true} name: {type: 'string', description: 'Exact skill name', required: true}
@@ -167,8 +168,14 @@ class LLM {
} }
const m = options.model || this.defaultModel; const m = options.model || this.defaultModel;
if(!this.models[m]) throw new Error(`Model does not exist: ${m}`); if(!this.models[m]) throw new Error(`Model does not exist: ${m}`);
let abort = () => {}; let request: AbortablePromise<string> | null = null;
return Object.assign(new Promise<string>(async res => { let aborted = false;
const abort = () => {
aborted = true;
request?.abort?.();
};
const promise = (async () => {
let tools: AiTool[] = options.tools || this.ai.options.llm?.tools || []; let tools: AiTool[] = options.tools || this.ai.options.llm?.tools || [];
const prompts: string[] = []; const prompts: string[] = [];
let history = options.history || []; let history = options.history || [];
@@ -192,6 +199,7 @@ class LLM {
// Memory // Memory
if (options.memory) { if (options.memory) {
const mems = options.memory instanceof MemoryCache ? options.memory.memories : options.memory; const mems = options.memory instanceof MemoryCache ? options.memory.memories : options.memory;
if(mems.length) {
const relevant = await this.memoryManager.recollect(message, options.memory, 5); const relevant = await this.memoryManager.recollect(message, options.memory, 5);
prompts.unshift(`You have access to the following memory files: prompts.unshift(`You have access to the following memory files:
${mems.map(m => `- ${m.name}: ${m.description}`).join('\n')} ${mems.map(m => `- ${m.name}: ${m.description}`).join('\n')}
@@ -205,9 +213,13 @@ ${r.content}
` : ''}`.trim()); ` : ''}`.trim());
tools.push(this.memoryManager.tools.read(options.memory)); tools.push(this.memoryManager.tools.read(options.memory));
} }
}
if(aborted) throw Object.assign(new Error('Aborted'), {name: 'AbortError'});
prompts.unshift(options.system || this.ai.options.llm?.system || ''); prompts.unshift(options.system || this.ai.options.llm?.system || '');
const resp = await this.models[m].ask(message, {...options, tools, system: prompts.filter(Boolean).join('\n\n')}); request = this.models[m].ask(message, {...options, tools, system: prompts.filter(Boolean).join('\n\n')});
const resp = await request;
// Trim memory injections from history // Trim memory injections from history
if(options.memory) { if(options.memory) {
@@ -221,16 +233,18 @@ ${r.content}
if(options.history) options.history.splice(0, options.history.length, ...compressed); if(options.history) options.history.splice(0, options.history.length, ...compressed);
} }
return res(resp); return resp;
}), {abort}); })();
return Object.assign(promise, {abort});
} }
/** /**
* Digest full conversation history into memory documents. * Digest full conversation history into memory documents.
* Call on session end to persist the conversation. * Call on session end to persist the conversation.
*/ */
async updateMemory(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest = {}): Promise<void> { async updateMemory(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest = {}): Promise<Memory[]> {
await this.memoryManager.memorize(history, memories, {model: this.defaultModel, ...options}); return this.memoryManager.memorize(history, memories, {model: this.defaultModel, ...options});
} }
/** /**

59
src/memory-cache.ts Normal file
View File

@@ -0,0 +1,59 @@
import {KDPoint, KDTree} from './kd-tree.ts';
import {Memory, MemoryRef} from './memory.ts';
export class MemoryCache {
private tree: KDTree<MemoryRef>;
public memories: Memory[];
get length() { return this.memories.length; }
constructor(memories: Memory[]) {
this.memories = memories;
this.tree = this.buildTree();
}
private buildTree(): KDTree<MemoryRef> {
const embedded = this.memories.filter(m => m.embedding?.length);
if (!embedded.length) return new KDTree<MemoryRef>(0);
const dims = embedded[0].embedding.length;
const points: KDPoint<MemoryRef>[] = embedded.map(m => ({
vector: m.embedding,
payload: {name: m.name, description: m.description},
}));
return new KDTree<MemoryRef>(dims, 'cosine', points);
}
search(query: number[], limit: number): MemoryRef[] {
const results = this.tree.knn(query, limit);
return results.map(r => r.point.payload);
}
add(memory: Memory): void {
this.memories.push(memory);
this.rebuild();
}
update(memory: Memory): void {
const idx = this.memories.findIndex(m => m.name === memory.name);
if (idx !== -1) {
this.memories[idx] = memory;
} else {
this.memories.push(memory);
}
this.rebuild();
}
remove(name: string): void {
const idx = this.memories.findIndex(m => m.name === name);
if (idx !== -1) {
this.memories.splice(idx, 1);
this.rebuild();
}
}
rebuild(): void {
this.tree = this.buildTree();
}
}

69
src/memory-graph.ts Normal file
View File

@@ -0,0 +1,69 @@
import {MemoryCache} from './memory-cache.ts';
import {extractMetadata, Memory, MemoryNode} from './memory.ts';
export function buildMemoryGraph(memories: Memory[] | MemoryCache): MemoryNode[] {
const mems = memories instanceof MemoryCache ? memories.memories : memories;
const nameSet = new Set(mems.map(m => m.name));
const ghosts = new Set<string>();
const nodes: MemoryNode[] = mems.map(m => {
const {links, backlinks} = extractMetadata(m.content);
return {
name: m.name,
missing: false,
links,
backlinks,
};
});
for (const node of nodes) {
for (const link of node.links) {
if (!nameSet.has(link)) ghosts.add(link);
}
}
return [
...nodes,
...[...ghosts].map(name => ({
name,
missing: true,
links: [],
backlinks: nodes
.filter(n => n.links.includes(name))
.map(n => n.name),
}))
];
}
export function renderMemoryGraph(nodes) {
if (!nodes.length) return 'No memories yet.';
const groups = new Map();
for (const node of nodes) {
const [prefix, ...rest] = node.name.split('/');
const group = rest.length ? prefix : 'Root';
const label = rest.length ? rest.join('/') : node.name;
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push({...node, label});
}
const ghostCount = nodes.filter(n => n.missing).length;
const lines = [`Memory Graph (${nodes.length} nodes, ${ghostCount} ghost${ghostCount === 1 ? '' : 's'})`, ''];
for (const group of [...groups.keys()].sort()) {
const items = groups.get(group).sort((a, b) => a.label.localeCompare(b.label));
lines.push(`${group}/`);
items.forEach((n, i) => {
const last = i === items.length - 1;
const branch = last ? '└─' : '├─';
const pad = last ? ' ' : '│ ';
const tag = n.missing ? ' (ghost)' : '';
lines.push(` ${branch} ${n.label}${tag}`);
if (n.links.length) lines.push(` ${pad}${n.links.join(', ')}`);
if (n.backlinks.length) lines.push(` ${pad}${n.backlinks.join(', ')}`);
});
lines.push('');
}
return lines.join('\n').trimEnd();
}

View File

@@ -1,22 +1,20 @@
import {LLMRequest, LLMMessage} from './llm.ts'; import {LLMRequest, LLMMessage} from './llm.ts';
import {MemoryCache} from './memory-cache.ts';
import {AiTool} from './tools.ts'; import {AiTool} from './tools.ts';
import {KDTree, KDPoint} from './kd-tree.ts';
export type Memory = { export type Memory = {
name: string; name: string;
description: string; description: string;
content: string; content: string;
embedding: number[]; embedding: number[];
links: string[];
backlinks: string[];
} }
type MemoryRef = { export type MemoryRef = {
name: string; name: string;
description: string; description: string;
} }
type FactBucket = { export type FactBucket = {
subject: string; subject: string;
facts: string[]; facts: string[];
} }
@@ -28,48 +26,36 @@ export type MemoryNode = {
backlinks: string[]; backlinks: string[];
} }
export function buildMemoryGraph(memories: Memory[] | MemoryCache): MemoryNode[] {
const mems = memories instanceof MemoryCache ? memories.memories : memories;
const nameSet = new Set(mems.map(m => m.name));
const ghosts = new Set<string>();
for (const m of mems) {
for (const link of m.links) {
if (!nameSet.has(link)) ghosts.add(link);
}
}
return [
...mems.map(m => ({
name: m.name,
missing: false,
links: m.links,
backlinks: m.backlinks,
})),
...[...ghosts].map(name => ({
name,
missing: true,
links: [],
backlinks: mems
.filter(m => m.links.includes(name))
.map(m => m.name),
}))
];
}
function extractLinks(content: string): string[] { function extractLinks(content: string): string[] {
if(!content) return [];
const matches = content.matchAll(/\[\[([^\]]+)\]\]/g); const matches = content.matchAll(/\[\[([^\]]+)\]\]/g);
return [...new Set([...matches].map(m => m[1].trim()))]; return [...new Set([...matches].map(m => m[1].trim()))];
} }
function rebuildBacklinks(memories: Memory[]): void { export function extractMetadata(content: string): {links: string[], backlinks: string[]} {
for (const m of memories) m.backlinks = []; const match = content.match(/^---\n([\s\S]*?)\n---/);
for (const m of memories) { if (!match) return {links: [], backlinks: []};
for (const link of m.links) {
const target = memories.find(t => t.name === link); const fm = match[1];
if (target) target.backlinks.push(m.name); const getList = (key: string): string[] => {
const m = fm.match(new RegExp(`^${key}:\\s*\\[(.*)\\]$`, 'm'));
if (!m || !m[1].trim()) return [];
return m[1].split(',').map(s => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
};
return {
links: getList('links'),
backlinks: getList('backlinks'),
};
} }
function dedupeFacts(facts: string[]): string[] {
const seen = new Map<string, string>();
for (const f of facts) {
const clean = f.trim();
if (clean) seen.set(clean.toLowerCase(), clean);
} }
return [...seen.values()];
} }
function cosineDistance(a: number[], b: number[]): number { function cosineDistance(a: number[], b: number[]): number {
@@ -83,62 +69,38 @@ function cosineDistance(a: number[], b: number[]): number {
return denom === 0 ? 1 : 1 - dot / denom; return denom === 0 ? 1 : 1 - dot / denom;
} }
export class MemoryCache { function getWeekMonday(date: Date = new Date()): string {
private tree: KDTree<MemoryRef>; const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
public memories: Memory[]; const day = d.getUTCDay();
const diff = day === 0 ? -6 : 1 - day;
constructor(memories: Memory[]) { d.setUTCDate(d.getUTCDate() + diff);
this.memories = memories; return d.toISOString().slice(0, 10);
this.tree = this.buildTree();
} }
private buildTree(): KDTree<MemoryRef> { function getWeekSunday(monday: string): string {
const embedded = this.memories.filter(m => m.embedding?.length); const d = new Date(`${monday}T00:00:00Z`);
if(!embedded.length) return new KDTree<MemoryRef>(0); d.setUTCDate(d.getUTCDate() + 6);
return d.toISOString().slice(0, 10);
const dims = embedded[0].embedding.length;
const points: KDPoint<MemoryRef>[] = embedded.map(m => ({
vector: m.embedding,
payload: {name: m.name, description: m.description},
}));
return new KDTree<MemoryRef>(dims, 'cosine', points);
} }
search(query: number[], limit: number): MemoryRef[] {
const results = this.tree.knn(query, limit);
return results.map(r => r.point.payload);
}
add(memory: Memory): void {
this.memories.push(memory);
this.rebuild();
}
update(memory: Memory): void {
const idx = this.memories.findIndex(m => m.name === memory.name);
if (idx !== -1) {
this.memories[idx] = memory;
} else {
this.memories.push(memory);
}
this.rebuild();
}
rebuild(): void {
this.tree = this.buildTree();
}
rebuildLinks(): void {
rebuildBacklinks(this.memories);
}
}
export class MemoryManager { export class MemoryManager {
private pendingMemorizations = new Map<string, {
memories: Memory[] | MemoryCache,
tempMemoryName: string,
timestamp: number,
}>();
private queues = new Map<string, {
pending: string[],
request: {abort?: () => void} | null,
task: Promise<void>,
}>();
tools = { tools = {
read: (memories: Memory[] | MemoryCache): AiTool => ({ read: (memories: Memory[] | MemoryCache): AiTool => ({
name: 'read_memory', name: 'memory_recall',
description: 'Read the full content of a memory document', description: 'Read the full content of a memory document',
args: { args: {
name: {type: 'string', description: 'Exact memory name', required: true}, name: {type: 'string', description: 'Exact memory name', required: true},
@@ -147,49 +109,84 @@ export class MemoryManager {
const mems = memories instanceof MemoryCache ? memories.memories : memories; const mems = memories instanceof MemoryCache ? memories.memories : memories;
const mem = mems.find(m => m.name === args.name); const mem = mems.find(m => m.name === args.name);
if (!mem) return 'Document not found'; if (!mem) return 'Document not found';
return this.formatMemory(mem); return mem.content;
} },
}),
forget: (memories: Memory[] | MemoryCache): AiTool => ({
name: 'memory_forget',
description: 'Permanently delete a memory document and clean up all references to it',
args: {
name: {type: 'string', description: 'Exact memory name to forget', required: true}
},
fn: (args: any) => {
const result = this.forget(args.name, memories);
return result ? `Forgotten: ${args.name}` : `Not found: ${args.name}`;
},
}), }),
}; };
constructor(private llm: any) {} constructor(private llm: any) {}
private async createTempMemory(conversation: string): Promise<Memory> {
const timestamp = Date.now();
const content = `---
name: _temp_${timestamp}
description: Temporary memory - processing in background
tags: [_temporary]
links: []
backlinks: []
modified: ${new Date().toISOString()}
---
# Recent Conversation (Processing)
${conversation}`;
const [e] = await this.llm.embedding(content);
return {
name: `_temp_${timestamp}`,
description: 'Temporary memory - processing in background',
content,
embedding: e?.embedding || [],
};
}
forget(name: string, memories: Memory[] | MemoryCache): boolean {
const mem = memories instanceof MemoryCache ? memories.memories : memories;
const idx = mem.findIndex(m => m.name === name);
if (idx === -1) return false;
for (const node of mem) {
const {links, backlinks} = extractMetadata(node.content);
const newBacklinks = backlinks.filter(b => b !== name);
const newLinks = links.filter(l => l !== name);
if (newBacklinks.length !== backlinks.length || newLinks.length !== links.length) {
node.content = this.updateFrontmatter(node.content, {
links: newLinks,
backlinks: newBacklinks,
});
}
}
mem.splice(idx, 1);
if (memories instanceof MemoryCache) memories.rebuild();
return true;
}
private cosineSearch(query: number[], memories: Memory[], limit: number): MemoryRef[] { private cosineSearch(query: number[], memories: Memory[], limit: number): MemoryRef[] {
const scored = memories const scored = memories
.filter(m => m.embedding?.length) .filter(m => m.embedding?.length)
.map(m => ({ .map(m => ({
ref: {name: m.name, description: m.description}, ref: {name: m.name, description: m.description},
distance: cosineDistance(query, m.embedding) distance: cosineDistance(query, m.embedding),
})) }))
.sort((a, b) => a.distance - b.distance) .sort((a, b) => a.distance - b.distance)
.slice(0, limit); .slice(0, limit);
return scored.map(s => s.ref); return scored.map(s => s.ref);
} }
private createNode(name: string, memories: Memory[]): Memory {
const existing = memories.find(m => m.name === name);
if(existing) return existing;
return {
name,
description: '',
content: '',
embedding: [],
links: [],
backlinks: [],
};
}
private formatMemory(mem: Memory): string {
return [
`# ${mem.name}`,
mem.description ? `> ${mem.description}` : '',
mem.links.length ? `**Links:** ${mem.links.map(l => `[[${l}]]`).join(', ')}` : '',
mem.backlinks.length ? `**Referenced by:** ${mem.backlinks.map(l => `[[${l}]]`).join(', ')}` : '',
'',
mem.content,
].filter(l => l !== undefined).join('\n');
}
private listNodes(memories: Memory[]): MemoryRef[] { private listNodes(memories: Memory[]): MemoryRef[] {
return memories.map(m => ({name: m.name, description: m.description})); return memories.map(m => ({name: m.name, description: m.description}));
} }
@@ -197,6 +194,7 @@ export class MemoryManager {
async recollect(query: string, memories: Memory[] | MemoryCache, limit = 5, graphDepth = 1): Promise<Memory[]> { async recollect(query: string, memories: Memory[] | MemoryCache, limit = 5, graphDepth = 1): Promise<Memory[]> {
const mem: Memory[] = memories instanceof MemoryCache ? memories.memories : memories; const mem: Memory[] = memories instanceof MemoryCache ? memories.memories : memories;
if (!mem.length) return []; if (!mem.length) return [];
const [e] = await this.llm.embedding(query); const [e] = await this.llm.embedding(query);
if (!e) return []; if (!e) return [];
@@ -212,7 +210,8 @@ export class MemoryManager {
for (const name of frontier) { for (const name of frontier) {
const node = mem.find(m => m.name === name); const node = mem.find(m => m.name === name);
if (!node) continue; if (!node) continue;
for(const link of node.links) { const {links} = extractMetadata(node.content);
for (const link of links) {
if (!found.has(link) && mem.find(m => m.name === link)) { if (!found.has(link) && mem.find(m => m.name === link)) {
found.add(link); found.add(link);
next.push(link); next.push(link);
@@ -230,272 +229,255 @@ export class MemoryManager {
return ordered.map(n => mem.find(m => m.name === n)!).filter(Boolean); return ordered.map(n => mem.find(m => m.name === n)!).filter(Boolean);
} }
async memorize(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest): Promise<void> { async memorize(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest): Promise<Memory[]> {
const mem = memories instanceof MemoryCache ? memories.memories : memories;
const conversation = history const conversation = history
.filter(h => h.role === 'user' || h.role === 'assistant') .filter(h => h.role === 'user' || h.role === 'assistant')
.map(h => `[${h.role}]: ${h.content}`).join('\n\n').trim(); .map(h => `[${h.role}]: ${h.content}`).join('\n\n').trim();
if(!conversation) return [];
if(conversation) { const trackingId = `${Date.now()}_${Math.random()}`;
const buckets = await this.factAgent(conversation, mem, options); const tempMemory = await this.createTempMemory(conversation);
if(buckets.length) { const mem = memories instanceof MemoryCache ? memories.memories : memories;
await Promise.all(buckets.map(async bucket => { mem.push(tempMemory);
const node = await this.organizingAgent(bucket, mem, options); if (memories instanceof MemoryCache) memories.rebuild();
if(!mem.find(m => m.name === node.name)) mem.push(node); this.pendingMemorizations.set(trackingId, {
await this.docAgent(node, bucket, mem, options); memories,
})); tempMemoryName: tempMemory.name,
} timestamp: Date.now(),
}
// Auto-compress old journals
const weekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
const oldDailies = mem.filter(m => {
const journal = /^Journal\/(\d{4}-\d{2}-\d{2}$)/.exec(m.name);
return journal && new Date(journal[1]).getTime() < weekAgo;
}); });
if(oldDailies.length) { try {
const byMonth = new Map<string, Memory[]>(); await this._memorizeBackground(conversation, memories, options, tempMemory.name);
for(const daily of oldDailies) { const finalMem = memories instanceof MemoryCache ? memories.memories : memories;
const match = daily.name.match(/^Journal\/(\d{4}-\d{2})-\d{2}$/); return finalMem.filter(m => !m.name.startsWith('_temp_'));
if(!match) continue; } finally {
const monthKey = match[1]; const pending = this.pendingMemorizations.get(trackingId);
if(!byMonth.has(monthKey)) byMonth.set(monthKey, []); if (pending) {
byMonth.get(monthKey)!.push(daily); const cleanMem = pending.memories instanceof MemoryCache
? pending.memories.memories
: pending.memories;
const idx = cleanMem.findIndex(m => m.name === pending.tempMemoryName);
if (idx !== -1) cleanMem.splice(idx, 1);
if (pending.memories instanceof MemoryCache) pending.memories.rebuild();
} }
this.pendingMemorizations.delete(trackingId);
for(const [monthKey, entries] of byMonth) {
const monthlyPath = `Journal/${monthKey}`;
let monthly = mem.find(m => m.name === monthlyPath);
if(!monthly) {
monthly = this.createNode(monthlyPath, mem);
mem.push(monthly);
}
const bucket: FactBucket = {
subject: monthlyPath,
facts: entries.flatMap(e => e.content.split('\n').filter(line => line.trim())),
};
await this.docAgent(monthly, bucket, mem, options);
for(const daily of entries) {
const idx = mem.indexOf(daily);
if(idx !== -1) mem.splice(idx, 1);
}
}
}
if (memories instanceof MemoryCache) {
memories.rebuildLinks();
memories.rebuild();
} else {
rebuildBacklinks(mem);
} }
} }
private async docAgent(node: Memory, bucket: FactBucket, memories: Memory[], options: LLMRequest): Promise<void> { private async _memorizeBackground(conversation: string, memories: Memory[] | MemoryCache, options: LLMRequest, tempName: string): Promise<void> {
let finalContent = node.content; const mem = memories instanceof MemoryCache ? memories.memories : memories;
const isJournalCompression = node.name.match(/^Journal\/\d{4}-\d{2}$/); const monday = getWeekMonday();
const systemPrompt = isJournalCompression const sunday = getWeekSunday(monday);
? `You are a journal compressor. Condense the daily entries below into a monthly summary. const buckets = await this.factAgent(conversation, mem, options, monday);
if(!buckets.length) return;
const jobs = [...buckets].map(({subject, facts}) => {
let node = mem.find(m => m.name === subject);
if(!node) {
node = {name: subject, description: '', content: '', embedding: [],};
mem.push(node);
}
const week = subject.startsWith('Journal/') ? {monday, sunday} : undefined;
return this.enqueue(node, facts, mem, options, tempName, week);
});
await Promise.all(jobs);
}
Format: /**
# ${node.name} * Coalescing queue: if a doc is already compiling, abort the in-flight run, merge its
* facts with the new ones and restart. Never blocks a pending update, never drops facts.
*/
private enqueue(node: Memory, facts: string[], memories: Memory[] | MemoryCache, options: LLMRequest, tempName: string, week?: {monday: string, sunday: string}): Promise<void> {
const key = node.name;
const existing = this.queues.get(key);
if (existing) {
existing.pending.push(...facts);
existing.request?.abort?.();
return existing.task;
}
## Themes const entry: {pending: string[], request: {abort?: () => void} | null, task: Promise<void>} = {pending: [...facts], request: null, task: Promise.resolve()};
(Recurring topics, moods, patterns) this.queues.set(key, entry);
const m = memories instanceof MemoryCache ? memories.memories : memories;
entry.task = (async () => {
while (entry.pending.length) {
const batch = dedupeFacts(entry.pending.splice(0, entry.pending.length));
const written = await this.docAgent(node, batch, m, options, tempName, week, entry);
if (!written) entry.pending.unshift(...batch);
}
})().finally(() => {
this.queues.delete(key);
if(!this.queues.size && memories instanceof MemoryCache) memories.rebuild();
});
return entry.task;
}
## Key Events private buildHeader(node: Memory, week?: {monday: string, sunday: string}, links: string[] = [], backlinks: string[] = []): string {
(Important moments, decisions, milestones) const tags = node.name.split('/')[0]?.toLowerCase();
const lines = [
'---',
`name: ${node.name}`,
`description: ${node.description || ''}`,
tags ? `tags: [${tags}]` : '',
links.length ? `links: [${links.map(l => `"${l}"`).join(', ')}]` : 'links: []',
backlinks.length ? `backlinks: [${backlinks.map(l => `"${l}"`).join(', ')}]` : 'backlinks: []',
week ? `week: ${week.monday} ${week.sunday}` : '',
`modified: ${new Date().toISOString()}`,
'---',
].filter(Boolean);
return lines.join('\n');
}
## Notable Conversations private applyHeader(content: string, header: string): string {
(Significant discussions or revelations) return `${header}\n\n${this.stripHeader(content)}`;
}
Rules: private updateFrontmatter(content: string, updates: {links?: string[], backlinks?: string[]}): string {
- Use [[WikiLinks]] to reference permanent notes using full paths like [[People/Sarah]] or [[Projects/Website]] const match = content.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
- Keep it concise but preserve emotional/temporal context if (!match) return content;
- Discard filler but keep things the user vented about or cared about
- If a fact belongs in a permanent note, link to it instead of duplicating
Current monthly summary: const [, fm, body] = match;
\`\`\`markdown let newFm = fm;
${node.content || '(empty — first compression for this month)'}
\`\`\`` if (updates.links !== undefined) {
: `You are a knowledge base editor. Integrate the provided facts into the document below. const linksList = updates.links.length ? `[${updates.links.map(l => `"${l}"`).join(', ')}]` : '[]';
newFm = newFm.replace(/^links:.*$/m, `links: ${linksList}`);
}
if (updates.backlinks !== undefined) {
const backlinksList = updates.backlinks.length ? `[${updates.backlinks.map(l => `"${l}"`).join(', ')}]` : '[]';
newFm = newFm.replace(/^backlinks:.*$/m, `backlinks: ${backlinksList}`);
}
newFm = newFm.replace(/^modified:.*$/m, `modified: ${new Date().toISOString()}`);
return `---\n${newFm}\n---\n\n${body}`;
}
private stripHeader(content: string): string {
return content.replace(/^---[\s\S]*?\n---\n?/, '').trimStart();
}
private async docAgent(node: Memory, facts: string[], memories: Memory[], options: LLMRequest, tempName: string, week: {monday: string, sunday: string} | undefined, entry: {request: {abort?: () => void} | null}): Promise<boolean> {
const {links: oldLinks} = extractMetadata(node.content);
const currentBody = this.stripHeader(node.content);
let update;
try {
for(let i = 0; i < 3 && !update?.content; i++) {
const request = this.llm.ask(`New Facts:\n${facts.map(f => `- ${f}`).join('\n')}`, {
model: options.model,
temperature: 0.3,
schema: {
description: {type: 'string', description: 'One-line description of what this document covers, no formatting or emojis', required: true},
content: {type: 'string', description: 'Rewritten document in markdown, without the frontmatter block', required: true},
},
system: `You are a knowledge base editor. Rewrite the current document below so it incorporates the new facts.
Formatting rules: Formatting rules:
- Use Obsidian-style markdown: # headings, **bold** for key terms, bullet lists for facts - Use Obsidian-style markdown: # headings, **bold** to add emphasis, __italics__ for titles, terms, etc, bullet & numbered lists for grouped 1D data and tables for 2D data
- Link related concepts with [[WikiLink]] notation using full paths like [[People/Sarah]] or [[Projects/Website]] - Link related concepts with [[WikiLink]] notation using full paths like [[People/Sarah]] or [[Projects/Website]]
- You may create links to nodes that don't exist yet if the concept is important - Create links for specific entities (person, place, project, program) and abstract concepts (quantum mechanics, entropy) but skip generics (car, red, dog)
- Keep the document concise, factual, and human-readable - Keep the document concise, factual, and human-readable
- Resolve any contradictions between old content and new facts (new facts win) - Resolve contradictions: the new facts always win — delete the outdated statement entirely, never keep both
- Do not add filler, preamble, or AI commentary — just clean knowledge documents - Later facts in the list override earlier ones
- Do not add frontmatter blocks, filler, preamble, or AI commentary
${week ? '- This is a weekly journal entry.\n' : ''}
All nodes: All nodes:
${this.listNodes(memories).map(n => n.name).join(', ') || 'none'} ${this.listNodes(memories).map(n => n.name).join(', ') || 'none'}
Current document: Current document:
\`\`\`markdown \`\`\`markdown
${node.content || '(empty — this is a new document)'} ${currentBody}
\`\`\``; \`\`\``}
await this.llm.ask(
`New facts to integrate:\n${bucket.facts.map(f => `- ${f}`).join('\n')}`,
{
model: options.model,
temperature: 0.3,
system: systemPrompt,
tools: [{
name: 'update_document',
description: 'Write the complete updated document content',
args: {
description: {type: 'string', description: 'One-line description of what this document covers, no formatting or emojis', required: true},
content: {type: 'string', description: 'Fully updated document in markdown', required: true},
},
fn:(args: any) => {
node.description = args.description;
finalContent = args.content;
return 'Saved';
}
}]
}
); );
entry.request = request;
update = await request;
}
} catch (err: any) {
if (err?.name === 'AbortError') return false;
throw err;
} finally {
entry.request = null;
}
node.content = finalContent; if(!update?.content) return false;
node.links = extractLinks(finalContent); const newLinks = extractLinks(update.content).filter(l => l !== node.name && l !== tempName);
const needsEmbed = !node.embedding?.length || node.description !== memories.find(m => m.name === node.name)?.description; const newLinkSet = new Set(newLinks);
if (needsEmbed) { const oldLinkSet = new Set(oldLinks);
const [e] = await this.llm.embedding(node.description);
for (const added of newLinkSet) {
if (!oldLinkSet.has(added)) {
const target = memories.find(m => m.name === added);
if (target) {
const {backlinks} = extractMetadata(target.content);
if (!backlinks.includes(node.name)) {
target.content = this.updateFrontmatter(target.content, {
backlinks: [...backlinks, node.name],
});
}
}
}
}
for (const removed of oldLinkSet) {
if (!newLinkSet.has(removed)) {
const target = memories.find(m => m.name === removed);
if (target) {
const {backlinks} = extractMetadata(target.content);
target.content = this.updateFrontmatter(target.content, {
backlinks: backlinks.filter(b => b !== node.name),
});
}
}
}
const {backlinks} = extractMetadata(node.content);
node.description = node.name !== 'Person/User' ? update.description : 'All information about the current user';
node.content = this.applyHeader(update.content, this.buildHeader(node, week, newLinks, backlinks));
const [e] = await this.llm.embedding(node.content);
if(e) node.embedding = e.embedding; if(e) node.embedding = e.embedding;
} return true;
} }
private async factAgent(conversation: string, memories: Memory[], options: LLMRequest): Promise<FactBucket[]> { private async factAgent(conversation: string, memories: Memory[], options: LLMRequest, weekKey: string): Promise<FactBucket[]> {
const buckets: FactBucket[] = []; const buckets = new Map<string, string[]>();
const today = new Date().toISOString().split('T')[0];
await this.llm.ask(conversation, { await this.llm.ask(conversation, {
model: options.model, model: options.model,
temperature: 0.2, temperature: 0.2,
system: `You are a fact extractor. Analyze this conversation and extract facts worth remembering long-term. system: `You are a fact extractor. Analyze this conversation and extract facts worth remembering long-term.
Rules: Rules:
- ONLY extract facts the USER explicitly stated about themselves, their work, or their projects - ONLY extract current facts the USER explicitly stated about themselves, their work, or their projects
- ONLY extract decisions that were MADE during this conversation - ONLY extract decisions that were MADE during this conversation
- DO NOT extract anything the AI said, its capabilities, or meta-conversation about the AI - DO NOT extract anything the AI said, its capabilities, or meta-conversation about the AI
- DO NOT extract greetings, pleasantries, or generic exchanges - DO NOT extract greetings, pleasantries, or generic exchanges
- DO NOT extract deltas or changes in facts; ONLY the end fact
- If nothing worth remembering was said, do not call any tools - If nothing worth remembering was said, do not call any tools
**Organizational patterns:** When extracting facts, you MUST also decide the exact destination path:
- Journal entries use paths like: Journal/${today} - Use an existing node name if the facts clearly belong there
- People use paths like: People/Name - All information primary about the user should go under "People/User"
- Projects use paths like: Projects/Name - When required, create a new path following collection/subject format (e.g., People/Sarah, Projects/Oxide)
- Personal info uses paths like: Personal/Goals, Personal/Tasks, etc. - For journal entries, use "Journal"
- General knowledge uses paths like: Biology/Topic, History/Topic, etc.
Learn from existing nodes and follow the same pattern when extracting.
Group facts by subject. For each group call \`extract_facts\` once with the FULL PATH.
Known nodes (name: description):
${this.listNodes(memories).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}`,
tools: [{
name: 'extract_facts',
description: 'Submit a group of related facts for a specific subject',
args: {
subject: {type: 'string', description: 'Full path for the subject (e.g., "Journal/2025-01-27", "People/Sarah", "Projects/Website")', required: true},
facts: {type: 'string', description: 'Comma-separated list of extracted facts', required: true},
},
fn: (args: any) => {
buckets.push({
subject: args.subject,
facts: args.facts.split(',').map((f: string) => f.trim()).filter(Boolean),
});
return 'Recorded';
}
}]
});
return buckets;
}
private async organizingAgent(bucket: FactBucket, memories: Memory[], options: LLMRequest): Promise<Memory> {
let candidates = this.listNodes(memories);
let attempts = 0;
const maxAttempts = 3;
while (attempts++ < maxAttempts) {
let home = '', mode: string | null = null;
const resp = await this.llm.ask(`Subject: ${bucket.subject}\n\nFacts:\n${bucket.facts.map(f => `- ${f}`).join('\n')}`, {
model: options.model,
temperature: 0.1,
system: `You are a knowledge organizer. Your job is to find the correct home for the supplied facts.
1. Review the facts and the node list below. Pick the most likely match or decide if a new node is needed.
2. If you picked an existing node, use \`read\` to verify it's the right place.
- After reading, call either \`confirm\` (correct node) or \`mismatched\` (wrong node).
3. If none of the nodes match, call \`create\` to make a new node.
**Organizational patterns:**
- Journal entries: Journal/YYYY-MM-DD
- People: People/Name
- Projects: Projects/Name
- Personal: Personal/Goals, Personal/Tasks, etc.
- Knowledge: Biology/Topic, History/Topic, etc.
Available nodes: Available nodes:
${candidates.map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None — create a new node.'}`, - Journal
${this.listNodes(memories).filter(n => !n.name.includes('_temp_') && !n.name.includes('Journal')).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}`,
tools: [{ tools: [{
name: 'read', name: 'facts_extract',
description: 'Read a node file to verify it is the right home for these facts', description: 'Submit facts with their destination',
args: {name: {type: 'string', description: 'Exact node name (full path)', required: true}},
fn: ({name}) => {
const mem = memories.find(m => m.name === name);
if (!mem) return 'Node not found';
home = name;
return this.formatMemory(mem);
}
}, {
name: 'confirm',
description: 'Confirm this is the correct node for the facts',
args: {},
fn: () => {
mode = 'success';
resp.abort();
}
}, {
name: 'mismatched',
description: 'This is not the node you are looking for',
args: {},
fn: () => {
mode = 'failed';
resp.abort();
}
}, {
name: 'create',
description: 'No existing node fits — create a new one',
args: { args: {
name: {type: 'string', description: 'Full path for the new node (e.g., "People/Sarah", "Journal/2025-01-27")', required: true} destination: {type: 'string', description: 'Exact existing node name OR new path (e.g. "People/Sarah", "Projects/Oxide")', required: true},
facts: {type: 'string', description: 'Comma-separated facts', required: true},
}, },
fn: ({name}) => { fn: (args: any) => {
home = name; const subject = args.destination.trim().toLowerCase() === 'journal'
mode = 'create'; ? `Journal/${weekKey}` : args.destination.trim();
resp.abort(); const facts = buckets.get(subject) ?? [];
} facts.push(...dedupeFacts(String(args.facts).split(',')));
}] buckets.set(subject, facts);
return 'Recorded';
},
}],
}); });
return buckets.entries().toArray().map(([subject, facts]) => ({subject, facts}));
if(mode === 'create') {
return this.createNode(home, memories);
} else if (mode === 'failed') {
candidates = candidates.filter(c => c.name !== home);
if(!candidates.length) return this.createNode(bucket.subject, memories);
} else if (mode === 'success') {
const existing = memories.find(m => m.name === home);
return existing || this.createNode(home, memories);
}
}
return this.createNode(bucket.subject, memories);
} }
} }

View File

@@ -1,5 +1,5 @@
import {AbortablePromise} from './ai.ts'; import {AbortablePromise} from './ai.ts';
import {LLMMessage, LLMRequest} from './llm.ts'; import {LLMRequest} from './llm.ts';
export abstract class LLMProvider { export abstract class LLMProvider {
abstract ask(message: string, options: LLMRequest): AbortablePromise<string>; abstract ask(message: string, options: LLMRequest): AbortablePromise<string>;

View File

@@ -91,20 +91,33 @@ export function convertSchema(schema: any): any {
}; };
} }
export const CliTool: AiTool = { export const ExecCliTool: AiTool = {
name: 'cli', name: 'cli',
description: 'Use the command line interface, returns any output', description: 'Use the command line interface, returns any output',
args: {command: {type: 'string', description: 'Command to run', required: true}}, args: {command: {type: 'string', description: 'Command to run', required: true}},
fn: (args: {command: string}) => $Sync`${args.command}` fn: (args: {command: string}) => $Sync`${args.command}`
} }
export const DateTimeTool: AiTool = { export const ExecJSTool: AiTool = {
name: 'get_datetime', name: 'exec_javascript',
description: 'Get local/UTC date/time', description: 'Execute commonjs javascript',
args: { args: {
timezone: {type: 'string', description: 'Which timezone to return, defaults to local', enum: ['local', 'utc'], default: 'local'} code: {type: 'string', description: 'CommonJS javascript', required: true}
}, },
fn: ({timezone}) => new Date()[timezone === 'local' ? 'toString' : 'toUTCString']() fn: async (args: {code: string}) => {
const c = consoleInterceptor(null);
const resp = await Fn<any>({console: c}, args.code, true).catch((err: any) => c.output.error.push(err));
return {...c.output, return: resp, stdout: undefined, stderr: undefined};
}
}
export const ExecPythonTool: AiTool = {
name: 'exec_python',
description: 'Execute commonjs javascript',
args: {
code: {type: 'string', description: 'CommonJS javascript', required: true}
},
fn: async (args: {code: string}) => ({result: $Sync`python -c "${args.code}"`})
} }
export const ExecTool: AiTool = { export const ExecTool: AiTool = {
@@ -118,11 +131,11 @@ export const ExecTool: AiTool = {
try { try {
switch(args.language) { switch(args.language) {
case 'cli': case 'cli':
return await CliTool.fn({command: args.code}, stream, ai); return await ExecCliTool.fn({command: args.code}, stream, ai);
case 'node': case 'node':
return await JSTool.fn({code: args.code}, stream, ai); return await ExecJSTool.fn({code: args.code}, stream, ai);
case 'python': case 'python':
return await PythonTool.fn({code: args.code}, stream, ai); return await ExecPythonTool.fn({code: args.code}, stream, ai);
default: default:
throw new Error(`Unsupported language: ${args.language}`); throw new Error(`Unsupported language: ${args.language}`);
} }
@@ -132,8 +145,483 @@ export const ExecTool: AiTool = {
} }
} }
export const FetchTool: AiTool = { export const FsDeleteTool = (whitelist: null | string[] = null): AiTool => {
name: 'fetch', return {
name: 'fs_delete',
description: 'Delete a file or directory',
args: {
path: {type: 'string', description: 'Path to file or directory', required: true},
recursive: {type: 'boolean', description: 'Delete all children', required: false}
},
fn: async ({path, recursive = false}) => {
const {existsSync, rmSync} = await import('fs');
const normalizePath = p => p.replace(/\\/g, '/');
path = normalizePath(path);
if(whitelist && !whitelist.some(p => path.startsWith(p))) return {error: 'Permission denied'};
if(!existsSync(path)) return {error: 'Path does not exist'};
rmSync(path, {recursive, force: true});
return {success: true, path};
}
}
}
export const FsMoveTool = (whitelist: null | string[] = null): AiTool => {
return {
name: 'fs_move',
description: 'Move or rename a file or directory',
args: {
source: {type: 'string', description: 'Path to source file or directory', required: true},
destination: {type: 'string', description: 'Path to destination file or directory', required: true}
},
fn: async ({source, destination}) => {
const {existsSync, renameSync} = await import('fs');
const normalizePath = p => p.replace(/\\/g, '/');
source = normalizePath(source);
destination = normalizePath(destination);
if(whitelist && !whitelist.some(p => source.startsWith(p) && destination.startsWith(p))) return {error: 'Permission denied'};
if(!existsSync(source)) return {error: 'Source path does not exist'};
if(existsSync(destination)) return {error: 'Destination path already exists'};
renameSync(source, destination);
return {success: true, source, destination};
}
}
}
export const FsReadTool = (whitelist: null | string[] = null): AiTool => {
return {
name: 'fs_read',
description: 'Read the contents of a provided path. Works with files and directories',
args: {path: {type: 'string', description: 'Path to file or directory', required: true}},
fn: async ({path}) => {
const {existsSync, lstatSync, readdirSync, readFileSync} = await import('fs');
const {join} = await import('path');
const normalizePath = p => p.replace(/\\/g, '/');
path = normalizePath(path);
if(whitelist && !whitelist.some(p => path.startsWith(p))) return {error: 'Permission denied'};
if(!existsSync(path)) return {error: 'Path does not exist'};
const stats = lstatSync(path);
if(stats.isDirectory()) {
const children = readdirSync(path).map(name => {
const childPath = normalizePath(join(path, name));
const childStats = lstatSync(childPath);
return {name, type: childStats.isDirectory() ? 'directory' : 'file', size: childStats.size};
});
return {type: 'directory', children};
}
const content = readFileSync(path, 'utf-8');
return {type: 'file', content};
}
}
}
export const FsSearchTool = (whitelist: null | string[] = null): AiTool => {
return {
name: 'fs_search',
description: 'Scan a directory for matching glob patterns (e.g. "**/*.js", "src/**/*.test.ts")',
args: {
pattern: {type: 'string', description: 'Glob pattern to match against paths', required: true},
root: {type: 'string', description: 'Directory to search from', required: false, default: '.'}
},
fn: async ({pattern, root = '.'}) => {
const {existsSync, lstatSync, readdirSync} = await import('fs');
const {join, relative} = await import('path');
const normalizePath = p => p.replace(/\\/g, '/');
root = normalizePath(root);
if(!existsSync(root)) return {error: 'Root path does not exist'};
if(!lstatSync(root).isDirectory()) return {error: 'Root path is not a directory'};
if(whitelist && !whitelist.some(p => root.startsWith(p))) return {error: 'Permission denied'};
const globToRegex = (glob) => {
let re = '';
for(let i = 0; i < glob.length; i++) {
const c = glob[i];
if(c === '*') {
if(glob[i + 1] === '*') {
const isSlash = glob[i + 2] === '/';
re += '.*';
i += isSlash ? 2 : 1;
} else {
re += '[^/]*';
}
} else if(c === '?') {
re += '[^/]';
} else if('.+^$(){}|[]\\'.includes(c)) {
re += '\\' + c;
} else {
re += c;
}
}
return new RegExp('^' + re + '$');
};
const regex = globToRegex(pattern);
const results: any = [];
const walk = (dir) => {
for(const name of readdirSync(dir)) {
const fullPath = normalizePath(join(dir, name));
const stats = lstatSync(fullPath);
const relPath = normalizePath(relative(root, fullPath));
if(regex.test(relPath)) {
results.push({path: relPath, type: stats.isDirectory() ? 'directory' : 'file', size: stats.size});
}
if(stats.isDirectory()) walk(fullPath);
}
};
walk(root);
return results;
}
}
}
export const FsWriteTool = (whitelist: null | string[] = null): AiTool => {
return {
name: 'fs_write',
description: 'Create a directory, write content to a file or preform a find & replace',
args: {
path: {type: 'string', description: 'Path to file or directory', required: true},
content: {type: 'string', description: 'Content to write or replace (Omit to create a directory)'},
find: {type: 'string', description: 'Text or regex pattern to match (regex must match pattern: "/pattern/g")'}
},
fn: async ({path, content, find}) => {
const {existsSync, mkdirSync, readFileSync, writeFileSync} = await import('fs');
const {dirname} = await import('path');
const normalizePath = p => p.replace(/\\/g, '/');
path = normalizePath(path);
if(whitelist && !whitelist.some(p => path.startsWith(p))) return {error: 'Permission denied'};
if(content === undefined) {
mkdirSync(path, {recursive: true});
return {success: true, type: 'directory', path};
}
const dir = normalizePath(dirname(path));
if(!existsSync(dir)) mkdirSync(dir, {recursive: true});
if(find && existsSync(path)) {
const existing = readFileSync(path, 'utf-8');
const regexMatch = find.match(/^\/(.+)\/([gimuy]*)$/);
const pattern = regexMatch ? new RegExp(regexMatch[1], regexMatch[2]) : find;
if(!existing.match(pattern)) return {error: 'Find pattern not found in file'};
const updated = existing.replace(pattern, content);
writeFileSync(path, updated, 'utf-8');
return {success: true, type: 'file', path, replaced: true, content: updated};
}
writeFileSync(path, content, 'utf-8');
return {success: true, type: 'file', path, content};
}
}
}
export const GetPathsTool: AiTool = {
name: 'get_paths',
description: 'Get the current working directory, and paths to the users home directory',
fn: async () => {
return {
home: os.homedir(),
cwd: process.cwd()
};
}
}
export const GetDatetimeTool: AiTool = {
name: 'get_datetime',
description: 'Get local/UTC timestamp',
args: {
timezone: {type: 'string', description: 'Which timezone to return, defaults to local', enum: ['local', 'utc'], default: 'local'}
},
fn: ({timezone}) => new Date()[timezone === 'local' ? 'toString' : 'toUTCString']()
}
export const GetDevice: AiTool = {
name: 'get_device',
description: 'Get comprehensive system information including hostname, specs, load, storage, and network status',
args: {},
fn: async () => {
const platform = os.platform();
const hostname = os.hostname();
// CPU Info
const cpus = os.cpus();
const cpuModel = cpus[0].model;
const cpuCores = cpus.length;
// Memory Info
const totalMem: any = (os.totalmem() / 1024 / 1024 / 1024).toFixed(2);
const freeMem: any = (os.freemem() / 1024 / 1024 / 1024).toFixed(2);
const usedMem: any = (totalMem - freeMem).toFixed(2);
const memUsage: any = ((usedMem / totalMem) * 100).toFixed(1);
// Load Average (not available on Windows)
const loadAvg = platform === 'win32' ? ['N/A', 'N/A', 'N/A'] : os.loadavg().map(l => l.toFixed(2));
// Storage Usage
let storage = {};
if(platform === 'win32') {
const ps = $Sync`powershell "Get-PSDrive C | Select-Object Used,Free | ConvertTo-Json"`.trim();
const drive = JSON.parse(ps);
const used: any = (drive.Used / 1024 / 1024 / 1024).toFixed(2);
const free: any = (drive.Free / 1024 / 1024 / 1024).toFixed(2);
const total: any = (parseFloat(used) + parseFloat(free)).toFixed(2);
const usage: any = ((used / total) * 100).toFixed(1);
storage = {
filesystem: 'C:',
size: `${total} GB`,
used: `${used} GB`,
available: `${free} GB`,
usage: `${usage}%`
};
} else {
const df = $Sync`df -h / | tail -1`.trim();
const s = df.split(/\s+/);
storage = {
filesystem: s[0],
size: s[1],
used: s[2],
available: s[3],
usage: s[4]
};
}
// Network Status
const interfaces = os.networkInterfaces();
const activeIfaces = Object.entries(interfaces)
.filter(([name]) => name !== 'lo' && !name.includes('Loopback'))
.map(([name, addrs]) => {
const ipv4 = addrs?.find(a => a.family === 'IPv4');
return ipv4 ? {name, ip: ipv4.address} : null;
})
.filter(Boolean);
// Internet connectivity check
let internet = false;
try {
if(platform === 'win32') {
$Sync`powershell "Test-Connection -ComputerName 8.8.8.8 -Count 1 -Quiet"`;
} else {
$Sync`ping -c 1 -W 2 8.8.8.8 > /dev/null 2>&1`;
}
internet = true;
} catch {}
// Uptime
const uptime = os.uptime();
const days = Math.floor(uptime / 86400);
const hours = Math.floor((uptime % 86400) / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
return {
hostname,
cpu: {
model: cpuModel,
cores: cpuCores
},
memory: {
total: `${totalMem} GB`,
used: `${usedMem} GB`,
free: `${freeMem} GB`,
usage: `${memUsage}%`
},
load: {
'1min': loadAvg[0],
'5min': loadAvg[1],
'15min': loadAvg[2]
},
storage,
network: {
interfaces: activeIfaces,
internet: internet ? 'connected' : 'disconnected'
},
uptime: `${days}d ${hours}h ${minutes}m`,
platform: `${os.type()} ${os.release()}`
};
}
}
export const GetWikipediaTool: AiTool = {
name: 'get_wikipedia',
description: 'Search Wikipedia for matching articles',
args: {
query: {type: 'string', description: 'Search term or article title', required: true},
mode: {type: 'string', description: 'search - look for articles, summary - intro of first found article (default), full - complete first found article', enum: ['search', 'summary', 'full'], default: 'summary'},
ua: {type: 'string', description: 'User Agent'},
},
fn: async ({query, mode, ua}) => {
class WikipediaClient {
useragent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
constructor(useragent: string) {
this.useragent = useragent;
}
async get(url) {
const resp = await fetch(url, {headers: {'User-Agent': this.useragent}});
return resp.json();
}
api(params) {
const qs = new URLSearchParams({...params, format: 'json', utf8: '1'}).toString();
return this.get(`https://en.wikipedia.org/w/api.php?${qs}`);
}
clean(text) {
const cutoffs = ['== See also ==', '== References ==', '== Bibliography ==', '== External links =='];
for (const marker of cutoffs) {
const idx = text.indexOf(marker);
if (idx !== -1) text = text.slice(0, idx);
}
return text
.replace(/^={4}\s*(.+?)\s*={4}$/gm, '#### $1')
.replace(/^={3}\s*(.+?)\s*={3}$/gm, '### $1')
.replace(/^={2}\s*(.+?)\s*={2}$/gm, '## $1')
.replace(/\n{3,}/g, '\n\n')
.replace(/ {2,}/g, ' ')
.replace(/\[\d+]/g, '')
.trim();
}
async searchTitles(query: string, limit = 6) {
const data = await this.api({action: 'query', list: 'search', srsearch: query, srlimit: limit, srprop: 'snippet'});
return data.query?.search || [];
}
async fetchExtract(title: string, introOnly = false) {
const params: any = {action: 'query', prop: 'extracts', titles: title, explaintext: 1, redirects: 1};
if(introOnly) params.exintro = 1;
const data = await this.api(params);
const page: any = Object.values(data.query?.pages || {})[0];
return this.clean(page?.extract || '');
}
pageUrl(title: string) {
return `https://en.wikipedia.org/wiki/${encodeURIComponent(title.replace(/ /g, '_'))}`;
}
stripHtml(text: string) {
return text.replace(/<[^>]+>/g, '');
}
async lookup(query: string, detail = 'summary') {
const results = await this.searchTitles(query, 6);
if(!results.length) return `❌ No Wikipedia articles found for "${query}"`;
const title = results[0].title;
const url = this.pageUrl(title);
const introOnly = detail !== 'full';
const content = await this.fetchExtract(title, introOnly);
return `## ${title}\n🔗 ${url}\n\n${content}`;
}
async search(query: string) {
const results = await this.searchTitles(query, 8);
if(!results.length) return `❌ No results for "${query}"`;
const lines = [`### Search results for "${query}"\n`];
for(let i = 0; i < results.length; i++) {
const r = results[i];
const snippet = this.stripHtml(r.snippet || '').trim();
lines.push(`**${i + 1}. ${r.title}**\n${snippet}\n${this.pageUrl(r.title)}`);
}
return lines.join('\n\n');
}
}
const wiki = new WikipediaClient(ua);
if(mode === 'search') return wiki.search(query);
return wiki.lookup(query, mode || 'summary');
}
};
export const GeoCodeTool: AiTool = {
name: 'geo_code',
description: 'Converts coordinates to address OR vice versa',
args: {
query: {type: 'string', description: 'Search query - coordinates (lat,lon) or address string', required: true},
},
fn: async ({query}) => {
const coordinates = /(-?\d+(?:\.\d+)?).*?,.*?(-?\d+(?:\.\d+)?)/.exec(query);
if(coordinates) { // Geolocate
const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${encodeURIComponent(coordinates[1])}&lon=${encodeURIComponent(coordinates[2])}`;
const response = await fetch(url, {headers: {'User-Agent': 'OpenSight/1.0', 'Accept-Language': 'en'}});
const data = await response.json();
if(data.display_name) return {address: data.display_name, mode: 'geolocate'};
} else { // Geocode
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`;
const response = await fetch(url, {headers: {'User-Agent': 'OpenSight/1.0'}});
const data = await response.json();
if(data[0]) return {latitude: parseFloat(data[0].lat), longitude: parseFloat(data[0].lon), mode: 'geocode'};
}
return {error: 'Not found'};
},
}
export const GeoWeatherTool: AiTool = {
name: 'geo_weather',
description: 'Gets weather and air quality info for a location and time',
args: {
query: {type: 'string', description: 'Location - address or place name', required: true},
day: {type: 'string', description: 'Date to retrieve (YYYY-MM-DD), defaults to today'},
},
fn: async ({query, day}) => {
day = day || new Date().toISOString().slice(0, 10);
const geoUrl = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`;
const geoResponse = await fetch(geoUrl, {headers: {'User-Agent': 'OpenSight/1.0'}});
const geoData = await geoResponse.json();
if(!geoData[0]) return {error: 'Location not found'};
const lat = parseFloat(geoData[0].lat);
const lon = parseFloat(geoData[0].lon);
const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&start_date=${day}&end_date=${day}&daily=weathercode,temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,precipitation_sum,precipitation_probability_max,windspeed_10m_max,winddirection_10m_dominant,uv_index_max,sunrise,sunset&timezone=auto`;
const airUrl = `https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lat}&longitude=${lon}&start_date=${day}&end_date=${day}&hourly=us_aqi,european_aqi,pm10,pm2_5&timezone=auto`;
const [weatherResponse, airResponse] = await Promise.all([fetch(weatherUrl), fetch(airUrl)]);
const weatherData = await weatherResponse.json();
const airData = await airResponse.json();
const avg = arr => (arr && arr.length) ? arr.reduce((a, b) => a + b, 0) / arr.length : null;
return {
location: geoData[0].display_name,
latitude: lat,
longitude: lon,
elevation: weatherData.elevation,
date: day,
weatherCode: weatherData.daily?.weathercode?.[0],
tempMax: weatherData.daily?.temperature_2m_max?.[0],
tempMin: weatherData.daily?.temperature_2m_min?.[0],
feelsLikeMax: weatherData.daily?.apparent_temperature_max?.[0],
feelsLikeMin: weatherData.daily?.apparent_temperature_min?.[0],
precipitation: weatherData.daily?.precipitation_sum?.[0],
precipitationChance: weatherData.daily?.precipitation_probability_max?.[0],
windSpeedMax: weatherData.daily?.windspeed_10m_max?.[0],
windDirection: weatherData.daily?.winddirection_10m_dominant?.[0],
uvIndexMax: weatherData.daily?.uv_index_max?.[0],
sunrise: weatherData.daily?.sunrise?.[0],
sunset: weatherData.daily?.sunset?.[0],
usAqi: avg(airData.hourly?.us_aqi),
europeanAqi: avg(airData.hourly?.european_aqi),
pm10: avg(airData.hourly?.pm10),
pm2_5: avg(airData.hourly?.pm2_5),
};
},
}
export const WebFetchTool: AiTool = {
name: 'web_fetch',
description: 'Make HTTP request to URL', description: 'Make HTTP request to URL',
args: { args: {
url: {type: 'string', description: 'URL to fetch', required: true}, url: {type: 'string', description: 'URL to fetch', required: true},
@@ -149,30 +637,59 @@ export const FetchTool: AiTool = {
}) => new Http({url: args.url, headers: args.headers}).request({method: args.method || 'GET', body: args.body}) }) => new Http({url: args.url, headers: args.headers}).request({method: args.method || 'GET', body: args.body})
} }
export const JSTool: AiTool = { export const WebFlareSolverTool = (host: string) => {
name: 'exec_javascript', return {
description: 'Execute commonjs javascript', name: 'web_flaresolverr',
description: 'Use a flaresolverr proxy to bypass cloudflare bot detection',
args: { args: {
code: {type: 'string', description: 'CommonJS javascript', required: true} url: {type: 'string', description: 'URL to fetch', required: true},
cmd: {type: 'string', description: 'Flaresolverr cmd', enum: ['request.get', 'request.post'], default: 'request.get'},
maxTimeout: {type: 'number', description: 'Fetch time limit', default: 60_000},
postData: {type: 'object', description: 'Data to send during request.post requests'},
}, },
fn: async (args: {code: string}) => { fn: async ({url, cmd, maxTimeout, postData}) => {
const c = consoleInterceptor(null); function toFormUrlEncoded(obj, prefix = '') {
const resp = await Fn<any>({console: c}, args.code, true).catch((err: any) => c.output.error.push(err)); const pairs: any = [];
return {...c.output, return: resp, stdout: undefined, stderr: undefined}; for (const key in obj) {
if (!obj.hasOwnProperty(key)) continue;
const value = obj[key];
const encodedKey = prefix
? `${prefix}[${encodeURIComponent(key)}]`
: encodeURIComponent(key);
if (value === null || value === undefined) {
pairs.push(`${encodedKey}=`);
} else if (typeof value === 'object' && !Array.isArray(value)) {
pairs.push(toFormUrlEncoded(value, encodedKey));
} else if (Array.isArray(value)) {
value.forEach(item => {
pairs.push(`${encodedKey}[]=${encodeURIComponent(item)}`);
});
} else {
pairs.push(`${encodedKey}=${encodeURIComponent(value)}`);
} }
} }
export const PythonTool: AiTool = { return pairs.join('&');
name: 'exec_python',
description: 'Execute commonjs javascript',
args: {
code: {type: 'string', description: 'CommonJS javascript', required: true}
},
fn: async (args: {code: string}) => ({result: $Sync`python -c "${args.code}"`})
} }
export const ReadWebpageTool: AiTool = { const res = await fetch(host + '/v1', {
name: 'read_webpage', method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({cmd, url, maxTimeout, postData: postData ? toFormUrlEncoded(postData) : undefined}),
});
if(!res.ok) throw new Error(`FlareSolverr HTTP error: ${res.status} ${res.statusText}`);
const data = await res.json();
if(data.status !== 'ok') throw new Error(`FlareSolverr error: ${data.message ?? data.status}`);
return data.solution.response;
}
}
}
export const WebReadTool: AiTool = {
name: 'web_read',
description: 'Extract clean content from webpages, or convert media/documents to accessible formats', description: 'Extract clean content from webpages, or convert media/documents to accessible formats',
args: { args: {
url: {type: 'string', description: 'URL to read', required: true}, url: {type: 'string', description: 'URL to read', required: true},
@@ -300,91 +817,3 @@ export const WebSearchTool: AiTool = {
return results; return results;
} }
} }
export const WikipediaTool: AiTool = {
name: 'wikipedia_search',
description: 'Search Wikipedia for matching articles',
args: {
query: {type: 'string', description: 'Search term or article title', required: true},
mode: {type: 'string', description: 'search - look for articles, summary - intro of first found article (default), full - complete first found article', enum: ['search', 'summary', 'full'], default: 'summary'}
},
fn: async (args: {query: string, mode: 'search' | 'summary' | 'full'}) => {
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
class WikipediaClient {
async get(url: string) {
const resp = await fetch(url, {headers: {'User-Agent': UA}});
return resp.json();
}
api(params: any) {
const qs = new URLSearchParams({...params, format: 'json', utf8: '1'}).toString();
return this.get(`https://en.wikipedia.org/w/api.php?${qs}`);
}
clean(text: string) {
const cutoffs = ['== See also ==', '== References ==', '== Bibliography ==', '== External links =='];
for (const marker of cutoffs) {
const idx = text.indexOf(marker);
if (idx !== -1) text = text.slice(0, idx);
}
return text
.replace(/^={4}\s*(.+?)\s*={4}$/gm, '#### $1')
.replace(/^={3}\s*(.+?)\s*={3}$/gm, '### $1')
.replace(/^={2}\s*(.+?)\s*={2}$/gm, '## $1')
.replace(/\n{3,}/g, '\n\n')
.replace(/ {2,}/g, ' ')
.replace(/\[\d+\]/g, '')
.trim();
}
async searchTitles(query: string, limit = 6) {
const data = await this.api({action: 'query', list: 'search', srsearch: query, srlimit: limit, srprop: 'snippet'});
return data.query?.search || [];
}
async fetchExtract(title: string, introOnly = false) {
const params: any = {action: 'query', prop: 'extracts', titles: title, explaintext: 1, redirects: 1};
if(introOnly) params.exintro = 1;
const data = await this.api(params);
const page: any = Object.values(data.query?.pages || {})[0];
return this.clean(page?.extract || '');
}
pageUrl(title: string) {
return `https://en.wikipedia.org/wiki/${encodeURIComponent(title.replace(/ /g, '_'))}`;
}
stripHtml(text: string) {
return text.replace(/<[^>]+>/g, '');
}
async lookup(query: string, detail = 'summary') {
const results = await this.searchTitles(query, 6);
if(!results.length) return `❌ No Wikipedia articles found for "${query}"`;
const title = results[0].title;
const url = this.pageUrl(title);
const introOnly = detail !== 'full';
const content = await this.fetchExtract(title, introOnly);
return `## ${title}\n🔗 ${url}\n\n${content}`;
}
async search(query: string) {
const results = await this.searchTitles(query, 8);
if(!results.length) return `❌ No results for "${query}"`;
const lines = [`### Search results for "${query}"\n`];
for(let i = 0; i < results.length; i++) {
const r = results[i];
const snippet = this.stripHtml(r.snippet || '').trim();
lines.push(`**${i + 1}. ${r.title}**\n${snippet}\n${this.pageUrl(r.title)}`);
}
return lines.join('\n\n');
}
}
const wiki = new WikipediaClient();
if(args.mode == 'search') return wiki.search(args.query);
return wiki.lookup(args.query, args.mode || 'summary');
}
};