Compare commits

..

23 Commits
1.2.3 ... 1.4.0

Author SHA1 Message Date
4230b534fc bump 1.4.0
All checks were successful
Publish Library / Build NPM Project (push) Successful in 1m17s
Publish Library / Tag Version (push) Successful in 14s
2026-08-04 12:44:41 -04:00
119f8472f2 token pools
Some checks failed
Publish Library / Tag Version (push) Has been cancelled
Publish Library / Build NPM Project (push) Has been cancelled
2026-08-04 12:44:21 -04:00
9c04e58c63 Pass deligate subagents full history, improved memory managment 2026-08-04 12:24:23 -04:00
7fbb42c26a improved subagent instructions 2026-08-04 12:03:31 -04:00
be08db8e2c Attach tps to response promise
All checks were successful
Publish Library / Build NPM Project (push) Successful in 42s
Publish Library / Tag Version (push) Successful in 9s
2026-08-04 09:48:20 -04:00
497f051c62 bump 1.3.5
All checks were successful
Publish Library / Build NPM Project (push) Successful in 52s
Publish Library / Tag Version (push) Successful in 7s
2026-08-04 09:30:45 -04:00
62fbe73b22 Added tps + duration to AI history
All checks were successful
Publish Library / Build NPM Project (push) Successful in 52s
Publish Library / Tag Version (push) Successful in 11s
2026-08-04 09:26:57 -04:00
d53b1c6328 Removed <tool> blocks from responses
All checks were successful
Publish Library / Build NPM Project (push) Successful in 39s
Publish Library / Tag Version (push) Successful in 11s
2026-08-03 20:23:22 -04:00
89619e211e Fixed message history and response
All checks were successful
Publish Library / Build NPM Project (push) Successful in 59s
Publish Library / Tag Version (push) Successful in 22s
2026-08-03 19:30:39 -04:00
afc6653364 fixed openai system calls in history breaking anthropic calls
All checks were successful
Publish Library / Build NPM Project (push) Successful in 55s
Publish Library / Tag Version (push) Successful in 21s
2026-08-02 22:35:17 -04:00
68e72445a2 Keep recent memories in context
All checks were successful
Publish Library / Build NPM Project (push) Successful in 53s
Publish Library / Tag Version (push) Successful in 17s
2026-08-01 21:42:05 -04:00
1aa6cdf329 Agent/subagent support
All checks were successful
Publish Library / Build NPM Project (push) Successful in 45s
Publish Library / Tag Version (push) Successful in 15s
2026-08-01 18:28:16 -04:00
d022a5ef4d Improved levenshtein fuzzy match
All checks were successful
Publish Library / Build NPM Project (push) Successful in 52s
Publish Library / Tag Version (push) Successful in 17s
2026-08-01 12:00:26 -04:00
a1d438a20a Tools can now emit "done" event and end chat early gracefully
All checks were successful
Publish Library / Build NPM Project (push) Successful in 1m0s
Publish Library / Tag Version (push) Successful in 9s
2026-07-31 17:49:06 -04:00
52a9e3aaa4 Fixed history poisoning on empty tool response
All checks were successful
Publish Library / Build NPM Project (push) Successful in 51s
Publish Library / Tag Version (push) Successful in 13s
2026-07-30 22:12:49 -04:00
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
12 changed files with 1853 additions and 677 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.4.0",
"description": "AI Utility library", "description": "AI Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",

View File

@@ -1,16 +1,27 @@
import {Anthropic as anthropic} from '@anthropic-ai/sdk'; import {Anthropic as anthropic} from '@anthropic-ai/sdk';
import {findByProp, objectMap, JSONSanitize, JSONAttemptParse} from '@ztimson/utils'; import {findByProp, objectMap, JSONSanitize, JSONAttemptParse, makeArray} from '@ztimson/utils';
import {AbortablePromise, Ai} from './ai.ts'; import {AbortablePromise, Ai} from './ai.ts';
import {LLMMessage, LLMRequest} from './llm.ts'; import {LLMMessage, LLMRequest} from './llm.ts';
import {LLMProvider} from './provider.ts'; import {LLMProvider} from './provider.ts';
import {TokenPool} from './token-pool.ts';
import {convertSchema} from './tools.ts'; import {convertSchema} from './tools.ts';
export class Anthropic extends LLMProvider { export class Anthropic extends LLMProvider {
client!: anthropic; private clients = new Map<string, anthropic>();
tokenPool!: TokenPool;
constructor(public readonly ai: Ai, public readonly apiToken: string, public model: string) { constructor(public readonly ai: Ai, public readonly apiToken: string | string[], public model: string) {
super(); super();
this.client = new anthropic({apiKey: apiToken}); this.tokenPool = new TokenPool(...makeArray(apiToken).filter(Boolean));
}
private getClient(token: string): anthropic {
let client = this.clients.get(token);
if(!client) {
client = new anthropic({apiKey: token});
this.clients.set(token, client);
}
return client;
} }
private toStandard(history: any[]): LLMMessage[] { private toStandard(history: any[]): LLMMessage[] {
@@ -21,10 +32,10 @@ export class Anthropic extends LLMProvider {
messages.push(<any>{timestamp, ...h}); messages.push(<any>{timestamp, ...h});
} else { } else {
const textContent = h.content?.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n'); const textContent = h.content?.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n');
if(textContent) messages.push({timestamp, role: h.role, content: textContent}); if(textContent) messages.push({role: h.role, content: textContent, timestamp: timestamp, duration: h.duration, tps: h.tps});
h.content.forEach((c: any) => { h.content.forEach((c: any) => {
if(c.type == 'tool_use') { if(c.type == 'tool_use') {
messages.push({timestamp, role: 'tool', id: c.id, name: c.name, args: c.input, content: undefined}); messages.push({role: 'tool', id: c.id, name: c.name, args: c.input, timestamp: h.timestamp, content: undefined, duration: h.duration, tps: h.tps});
} else if(c.type == 'tool_result') { } else if(c.type == 'tool_result') {
const m: any = messages.findLast(m => (<any>m).id == c.tool_use_id); const m: any = messages.findLast(m => (<any>m).id == c.tool_use_id);
if(m) m[c.is_error ? 'error' : 'content'] = c.content; if(m) m[c.is_error ? 'error' : 'content'] = c.content;
@@ -46,13 +57,16 @@ export class Anthropic extends LLMProvider {
i++; i++;
} }
} }
return history.map(({timestamp, ...h}) => h); return history;
} }
ask(message: string, options: LLMRequest = {}): AbortablePromise<string | any> { ask(message: string, options: LLMRequest = {}): AbortablePromise<string | any> {
const controller = new AbortController(); const controller = new AbortController();
return Object.assign(new Promise<any>(async (res) => { return Object.assign(new Promise<any>(async (res) => {
let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]); let history = this.fromStandard([
...(options.history || []).filter(h => h.role !== 'system'),
{role: 'user', content: message, timestamp: Date.now()}
]);
const tools = options.tools || this.ai.options.llm?.tools || []; const tools = options.tools || this.ai.options.llm?.tools || [];
const requestParams: any = { const requestParams: any = {
model: options.model || this.model, model: options.model || this.model,
@@ -83,17 +97,17 @@ export class Anthropic extends LLMProvider {
}; };
} }
let resp: any, isFirstMessage = true; let resp: any, terminal = false, duration = 0, tps = 0;
do { do {
resp = await this.client.messages.create(requestParams).catch(err => { requestParams.messages = history.map(({timestamp, ...m}) => m);
const callStart = Date.now();
resp = await this.tokenPool.run(token => this.getClient(token).messages.create(requestParams)).catch(err => {
err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`; err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`;
throw err; throw err;
}); });
// Streaming mode let usage: any;
if(options.stream) { if(options.stream) {
if(!isFirstMessage) options.stream({text: '\n\n'});
else isFirstMessage = false;
resp.content = []; resp.content = [];
for await (const chunk of resp) { for await (const chunk of resp) {
if(controller.signal.aborted) break; if(controller.signal.aborted) break;
@@ -113,42 +127,57 @@ export class Anthropic extends LLMProvider {
} }
} else if(chunk.type === 'content_block_stop') { } else if(chunk.type === 'content_block_stop') {
const last = resp.content.at(-1); const last = resp.content.at(-1);
if(last.input != null) last.input = last.input ? JSONAttemptParse(last.input, {}) : {}; if(last?.input != null) last.input = last.input ? JSONAttemptParse(last.input, {}) : {};
} else if(chunk.type === 'message_delta') {
if(chunk.usage) usage = chunk.usage;
} else if(chunk.type === 'message_stop') { } else if(chunk.type === 'message_stop') {
break; break;
} }
} }
} else {
usage = resp.usage;
} }
duration = Date.now() - callStart;
tps = usage?.output_tokens && duration > 0 ? usage.output_tokens / (duration / 1000) : 0;
// Run tools
const toolCalls = resp.content.filter((c: any) => c.type === 'tool_use'); const toolCalls = resp.content.filter((c: any) => c.type === 'tool_use');
if(toolCalls.length && !controller.signal.aborted) { if(toolCalls.length && !controller.signal.aborted) {
history.push({role: 'assistant', content: resp.content}); history.push({role: 'assistant', content: resp.content, timestamp: Date.now(), duration, tps});
const results = await Promise.all(toolCalls.map(async (toolCall: any) => { const results = await Promise.all(toolCalls.map(async (toolCall: any) => {
const tool = tools.find(findByProp('name', toolCall.name)); const tool = tools.find(findByProp('name', toolCall.name));
if(options.stream) options.stream({tool: toolCall.name}); if(options.stream) options.stream({tool: toolCall.name});
if(!tool) return {tool_use_id: toolCall.id, is_error: true, content: 'Tool not found'}; if(!tool) return {tool_use_id: toolCall.id, is_error: true, content: 'Tool not found'};
try { try {
const result = await tool.fn(toolCall.input, options?.stream, this.ai); const toolStream = options.stream && ((chunk: any) => {
if(chunk.done) { terminal = true; return; }
options.stream!(chunk);
});
const result = await tool.fn(toolCall.input, toolStream, this.ai, toolCall.id);
return {type: 'tool_result', tool_use_id: toolCall.id, content: typeof result == 'object' ? JSONSanitize(result) : result}; return {type: 'tool_result', tool_use_id: toolCall.id, content: typeof result == 'object' ? JSONSanitize(result) : result};
} catch (err: any) { } catch (err: any) {
return {type: 'tool_result', tool_use_id: toolCall.id, is_error: true, content: err?.message || err?.toString() || 'Unknown'}; return {type: 'tool_result', tool_use_id: toolCall.id, is_error: true, content: err?.message || err?.toString() || 'Unknown'};
} }
})); }));
history.push({role: 'user', content: results}); history.push({role: 'user', content: results, timestamp: Date.now()});
requestParams.messages = history; requestParams.messages = history;
} }
} while (!controller.signal.aborted && resp.content.some((c: any) => c.type === 'tool_use')); } while (!terminal && !controller.signal.aborted && resp.content.some((c: any) => c.type === 'tool_use'));
if(!terminal) {
const textContent = resp.content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n'); const textContent = resp.content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n');
history.push({role: 'assistant', content: textContent}); history.push({role: 'assistant', content: textContent.trim(), timestamp: Date.now(), duration, tps});
}
history = this.toStandard(history); history = this.toStandard(history);
if(options.stream) options.stream({done: true});
if(options.history) options.history.splice(0, options.history.length, ...history); if(options.history) options.history.splice(0, options.history.length, ...history);
if(options.stream) options.stream({done: true});
const turnStart = history.map(h => h.role).lastIndexOf('user');
const finalContent = history.slice(turnStart + 1).reduce((str, h) => {
if(h.role === 'assistant') return str + (h.content || '');
return str;
}, '').trim();
// Return parsed JSON if schema provided
const finalContent = history.at(-1)?.content;
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent); res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);
}), {abort: () => controller.abort()}); }), {abort: () => controller.abort()});
} }

View File

@@ -5,5 +5,6 @@ export * from './llm';
export * from './memory'; export * from './memory';
export * from './open-ai'; export * from './open-ai';
export * from './provider'; export * from './provider';
export * from './token-pool'
export * from './tools'; export * from './tools';
export * from './vision'; export * from './vision';

View File

@@ -1,3 +1,4 @@
import {snakeCase} from '@ztimson/utils';
import {AbortablePromise, Ai} from './ai.ts'; import {AbortablePromise, Ai} from './ai.ts';
import {Anthropic} from './antrhopic.ts'; import {Anthropic} from './antrhopic.ts';
import {OpenAi} from './open-ai.ts'; import {OpenAi} from './open-ai.ts';
@@ -6,10 +7,25 @@ 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, MemoryCache, MemoryManager, MemoryOptions} from './memory.ts';
export type AnthropicConfig = {proto: 'anthropic', token: string}; const MAX_AGENT_DEPTH = 5;
export type OpenAiConfig = {proto: 'openai', host?: string, token: string};
export type AnthropicConfig = {proto: 'anthropic', token: string | string[]};
export type OpenAiConfig = {proto: 'openai', host?: string, token: string | string[]};
export type Agent = {
name: string;
description?: string;
model?: string | null;
temperature?: number;
system: string;
delegate?: boolean;
skills?: Skill[] | null;
tools?: AiTool[] | null;
mcp?: McpServer[] | null;
agents?: string[] | null;
}
export type LLMMessage = { export type LLMMessage = {
/** Message originator */ /** Message originator */
@@ -33,6 +49,10 @@ export type LLMMessage = {
error?: undefined | string; error?: undefined | string;
/** Timestamp */ /** Timestamp */
timestamp?: number; timestamp?: number;
/** Response duration in ms */
duration?: number;
/** Tokens per second */
tps?: number;
} }
export type LLMRequest = { export type LLMRequest = {
@@ -55,13 +75,17 @@ export type LLMRequest = {
/** Compress old messages in the chat to free up context */ /** Compress old messages in the chat to free up context */
compress?: {max: number; min: number}; compress?: {max: number; min: number};
/** User's memory documents - RAG injected automatically each turn */ /** User's memory documents - RAG injected automatically each turn */
memory?: Memory[] | MemoryCache; memory?: Memory[] | MemoryCache | MemoryOptions;
/** Model to use for memory operations */ /** Model to use for memory operations */
memoryModel?: string; memoryModel?: string;
/** Skill documents the AI can browse and read on demand */ /** Skill documents the AI can browse and read on demand */
skills?: Skill[]; skills?: Skill[];
/** MCP servers to connect and expose as tools */ /** MCP servers to connect and expose as tools */
mcp?: McpServer[]; mcp?: McpServer[];
/** Subagents exposed as delegatable/wrapped tools */
agents?: Agent[];
/** @internal recursion guard for nested agent delegation */
_agentDepth?: number;
} }
export type McpServer = { export type McpServer = {
@@ -82,7 +106,6 @@ export type Skill = {
content: string; content: string;
} }
class LLM { class LLM {
private memoryManager!: MemoryManager; private memoryManager!: MemoryManager;
@@ -99,6 +122,52 @@ class LLM {
this.memoryManager = new MemoryManager(this); this.memoryManager = new MemoryManager(this);
} }
private setupAgent(agents: Agent[] = [], allAgents: Agent[], history: LLMMessage[], aborts: (() => void)[], depth = 0, delegateState: {resp: string | null}): AiTool[] {
return agents.map(a => {
const toolName = `${a.delegate ? '' : 'sub'}agent_${snakeCase(a.name)}`;
return {
name: toolName,
description: `${a.delegate ? 'Delegate to ' : ''}Subagent: ${a.description || a.name}`,
args: <any>(a.delegate ? {} : {
context: {type: 'string', description: 'Summary of related messages, samples, files, etc...', required: true},
instructions: {type: 'string', description: 'Detailed instructions for subagent to complete', required: true},
}),
fn: async (args: any, stream: any, ai: any, id?: string) => {
if(depth >= MAX_AGENT_DEPTH) return 'Max agent delegation depth exceeded';
// Opt-in only, self always excluded regardless of whitelist
const nested = (a.agents || [])
.map(name => allAgents.find(x => x.name === name))
.filter((x): x is Agent => !!x && x.name !== a.name);
const request = this.ask(a.delegate ? '' : `${args.instructions}${args.context ? `\n\n<context>${args.context}</context>` : ''}`, {
system: `You are a specialized subagent. ${a.delegate ? 'Your output streams directly to the user for the remainder of this turn. You are mid conversation - dispense with greetings.' : 'You are wrapped in a tool call that will be analysis by an LLM - dispense with conversation'}
As a subagent, focus on executing your task completely using available tools and returning only the final result - no commentary, questions, or dialogue.
${a.system}`,
model: a.model || undefined,
temperature: a.temperature,
stream: a.delegate ? stream : undefined,
history: a.delegate ? history : [],
mcp: a.mcp || undefined,
skills: a.skills || undefined,
tools: a.tools || undefined,
agents: nested,
_agentDepth: depth + 1,
} as any);
aborts.push(request.abort);
const resp = await request;
if(a.delegate) {
delegateState.resp = resp;
return '';
}
return resp;
}
};
});
}
private async setupMcp(servers: McpServer[] = []): Promise<{prompt: string, tools: AiTool[]}> { private async setupMcp(servers: McpServer[] = []): Promise<{prompt: string, tools: AiTool[]}> {
if(!servers?.length) return {prompt: '', tools: []}; if(!servers?.length) return {prompt: '', tools: []};
const allTools: AiTool[] = []; const allTools: AiTool[] = [];
@@ -143,7 +212,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}
@@ -157,6 +226,20 @@ class LLM {
} }
} }
private wrapToolTiming(tools: AiTool[], timings: Map<string, {duration: number, tps: number}>): AiTool[] {
return tools.map(t => ({
...t,
fn: async (args: any, stream: any, ai: any, id?: string) => {
const start = Date.now();
const result = await t.fn(args, stream, ai, id);
const duration = Date.now() - start;
const tps = duration > 0 ? this.estimateTokens(result) / (duration / 1000) : 0;
if(id) timings.set(id, {duration, tps});
return result;
}
}));
}
ask(message: string, options: LLMRequest = {}): AbortablePromise<string> { ask(message: string, options: LLMRequest = {}): AbortablePromise<string> {
options = <any>{ options = <any>{
system: '', system: '',
@@ -167,8 +250,19 @@ 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 nestedAborts: (() => void)[] = [];
const abort = () => {
aborted = true;
request?.abort?.();
nestedAborts.forEach(a => a());
};
let promise: any;
const requestStart = Date.now();
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 || [];
@@ -189,48 +283,93 @@ class LLM {
tools.push(...s.tools); tools.push(...s.tools);
} }
// Agents
const agents = options.agents || this.ai.options?.llm?.agents;
const delegateState: {resp: string | null} = {resp: null};
if(agents?.length) tools.push(...this.setupAgent(agents, agents, history, nestedAborts, options._agentDepth || 0, delegateState));
// Memory // Memory
if (options.memory) { const mem = MemoryManager.normalize(options.memory);
const mems = options.memory instanceof MemoryCache ? options.memory.memories : options.memory; if(mem) {
const relevant = await this.memoryManager.recollect(message, options.memory, 5); const mems = mem.memory instanceof MemoryCache ? mem.memory.memories : mem.memory;
if(mems.length) {
if(mem.inject) {
const pool = 15; // candidates considered, cheap since only refs are listed
const budget = mem.maxTokens ?? 2000; // actual content injected
const relevant = await this.memoryManager.recollect(message, mem.memory, pool);
let used = 0;
const preloaded: typeof relevant = [];
const listed: typeof relevant = [];
for(const r of relevant) {
const t = this.estimateTokens(r.content);
if(used + t <= budget || preloaded.length === 0) {
preloaded.push(r);
used += t;
} else listed.push(r);
}
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')}
${relevant.length ? ` ${preloaded.length ? `
Relevant memories have been preloaded: Relevant memories have been preloaded:
${relevant.map(r => ` ${preloaded.map(r => `
**${r.name}** **${r.name}**
${r.description} ${r.description}
${r.content} ${r.content}
`).join('\n---\n')} `).join('\n---\n')}
` : ''}${listed.length ? `
Also relevant but not preloaded (use \`memory_recall\`): ${listed.map(r => r.name).join(', ')}
` : ''}`.trim()); ` : ''}`.trim());
tools.push(this.memoryManager.tools.read(options.memory));
} }
if(mem.tool) tools.push(this.memoryManager.tools.read(mem.memory));
}
}
if(aborted) throw Object.assign(new Error('Aborted'), {name: 'AbortError'});
const toolTimings = new Map<string, {duration: number, tps: number}>();
tools = this.wrapToolTiming(tools, toolTimings);
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')});
let resp = await request;
// Trim memory injections from history // Capture meta (duration / tps)
if(options.memory) { for(const h of history) {
history.splice(0, history.length, ...history.filter(h => h.role !== 'tool' || h.name !== 'recall')); if(h.role === 'tool' && toolTimings.has(h.id)) Object.assign(h, toolTimings.get(h.id));
} }
// Auto-memorize before compressing if(typeof resp === 'string' && !resp.trim() && delegateState.resp !== null) resp = delegateState.resp;
if(mem?.tool) history.splice(0, history.length, ...history.filter(h => h.role !== 'tool' || h.name !== 'memory_recall'));
if(options.compress && this.estimateTokens(history) >= options.compress.max) { if(options.compress && this.estimateTokens(history) >= options.compress.max) {
if(options.memory) await this.memoryManager.memorize(history, options.memory, {model: options.memoryModel || this.defaultModel, ...options}); if(mem?.update) await this.memoryManager.memorize(history, mem.memory, {model: options.memoryModel || this.defaultModel, ...options});
const compressed = await this.compressHistory(history, options.compress.max, options.compress.min, options); const compressed = await this.compressHistory(history, options.compress.max, options.compress.min, options);
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); const requestDuration = Date.now() - requestStart;
}), {abort}); const totalTokens = history
.filter((h: any) => h.role === 'assistant' && h.duration && h.tps)
.reduce((sum: number, h: any) => sum + h.tps * (h.duration / 1000), 0);
const requestTps = requestDuration > 0 ? totalTokens / (requestDuration / 1000) : 0;
Object.assign(promise, {duration: requestDuration, tps: requestTps});
return resp;
})();
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});
} }
/** /**
@@ -383,15 +522,33 @@ ${r.content}
* @param {string} searchTerms Multiple search terms to check against target * @param {string} searchTerms Multiple search terms to check against target
* @returns {{avg: number, max: number, similarities: number[]}} Similarity values 0-1: 0 = unique, 1 = identical * @returns {{avg: number, max: number, similarities: number[]}} Similarity values 0-1: 0 = unique, 1 = identical
*/ */
fuzzyMatch(target: string, ...searchTerms: string[]) { fuzzyMatch(target, ...searchTerms) {
if(searchTerms.length < 2) throw new Error('Requires at least 2 strings to compare'); if (searchTerms.length < 2) throw new Error('Requires at least 2 strings to compare');
const vector = (text: string, dimensions: number = 10): number[] => { const levenshtein = (a, b) => {
return text.toLowerCase().split('').map((char, index) => const m = a.length, n = b.length;
(char.charCodeAt(0) * (index + 1)) % dimensions / dimensions).slice(0, dimensions); if (!m) return n;
if (!n) return m;
const dp = Array.from({length: m + 1}, (_, i) => [i, ...Array(n).fill(0)]);
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
} }
const v = vector(target); }
const similarities = searchTerms.map(t => vector(t)).map(refVector => this.cosineSimilarity(v, refVector)); return dp[m][n];
return {avg: similarities.reduce((acc, s) => acc + s, 0) / similarities.length, max: Math.max(...similarities), similarities}; };
const similarity = (a, b) => {
a = a.toLowerCase(); b = b.toLowerCase();
return 1 - levenshtein(a, b) / Math.max(a.length, b.length, 1);
};
const similarities = searchTerms.map(t => similarity(target, t));
return {
avg: similarities.reduce((acc, s) => acc + s, 0) / similarities.length,
max: Math.max(...similarities),
similarities
};
} }
/** /**

View File

@@ -1,92 +1,23 @@
import {LLMRequest, LLMMessage} from './llm.ts'; import {LLMRequest, LLMMessage} from './llm.ts';
import {AiTool} from './tools.ts'; import {AiTool} from './tools.ts';
import {KDTree, KDPoint} from './kd-tree.ts'; import {KDPoint, KDTree} from './kd-tree.ts';
export type Memory = { const FACTS_HEADING = '## Facts';
name: string;
description: string;
content: string;
embedding: number[];
links: string[];
backlinks: string[];
}
type MemoryRef = { const GENERIC_TEMPLATE = `# {{Title}}
name: string;
description: string;
}
type FactBucket = { ## Summary
subject: string;
facts: string[];
}
export type MemoryNode = { ## Details
name: string;
missing: boolean;
links: string[];
backlinks: string[];
}
export function buildMemoryGraph(memories: Memory[] | MemoryCache): MemoryNode[] { ## Related`;
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[] {
const matches = content.matchAll(/\[\[([^\]]+)\]\]/g);
return [...new Set([...matches].map(m => m[1].trim()))];
}
function rebuildBacklinks(memories: Memory[]): void {
for (const m of memories) m.backlinks = [];
for (const m of memories) {
for (const link of m.links) {
const target = memories.find(t => t.name === link);
if (target) target.backlinks.push(m.name);
}
}
}
function cosineDistance(a: number[], b: number[]): number {
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 ? 1 : 1 - dot / denom;
}
export class MemoryCache { export class MemoryCache {
private tree: KDTree<MemoryRef>; private tree: KDTree<MemoryRef>;
public memories: Memory[]; public memories: Memory[];
get length() { return this.memories.length; }
constructor(memories: Memory[]) { constructor(memories: Memory[]) {
this.memories = memories; this.memories = memories;
this.tree = this.buildTree(); this.tree = this.buildTree();
@@ -94,7 +25,7 @@ export class MemoryCache {
private buildTree(): KDTree<MemoryRef> { private buildTree(): KDTree<MemoryRef> {
const embedded = this.memories.filter(m => m.embedding?.length); const embedded = this.memories.filter(m => m.embedding?.length);
if(!embedded.length) return new KDTree<MemoryRef>(0); if (!embedded.length) return new KDTree<MemoryRef>(0);
const dims = embedded[0].embedding.length; const dims = embedded[0].embedding.length;
const points: KDPoint<MemoryRef>[] = embedded.map(m => ({ const points: KDPoint<MemoryRef>[] = embedded.map(m => ({
@@ -125,102 +56,250 @@ export class MemoryCache {
this.rebuild(); 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 { rebuild(): void {
this.tree = this.buildTree(); this.tree = this.buildTree();
} }
}
rebuildLinks(): void { export type MemoryOptions = {
rebuildBacklinks(this.memories); /** Memory object */
memory: Memory[] | MemoryCache;
/** Inject N memories into the system prompt */
inject?: boolean;
/** expose recall tool to LLM */
tool?: boolean;
/** Update memory on compression */
update?: boolean;
/** Max context size of memories to inject to each call (removed immediately after use) */
maxTokens?: number;
}
export type Memory = {
name: string;
description: string;
content: string;
embedding: number[];
links: string[];
backlinks: string[];
}
type MemoryRef = {
name: string;
description: string;
}
type FactBucket = {
subject: string;
facts: string[];
}
function extractLinks(content: string): string[] {
if (!content) return [];
const matches = content.matchAll(/\[\[([^\]]+)\]\]/g);
return [...new Set([...matches].map(m => m[1].trim()))];
}
export function rebuildGraph(memories: Memory[]): void {
for (const m of memories) m.links = extractLinks(m.content).filter(l => l !== m.name);
for (const m of memories) m.backlinks = [];
for (const m of memories) {
for (const link of m.links) {
const target = memories.find(t => t.name === link);
if (target) target.backlinks.push(m.name);
}
} }
} }
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 {
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 ? 1 : 1 - dot / denom;
}
function getWeekMonday(date: Date = new Date()): string {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const day = d.getUTCDay();
const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff);
return d.toISOString().slice(0, 10);
}
export class MemoryManager { export class MemoryManager {
private recentlyTouched = new Map<string, number>();
private queues = new Map<string, {
dirty: boolean,
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},
}, },
fn:(args: any) => { fn: (args: any) => {
const mems = memories instanceof MemoryCache ? memories.memories : memories; const mems = this.unwrap(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); this.touch(mem.name);
} 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 cosineSearch(query: number[], memories: Memory[], limit: number): MemoryRef[] { static normalize(m?: Memory[] | MemoryCache | MemoryOptions) {
const scored = memories if(!m) return null;
.filter(m => m.embedding?.length) const raw = m instanceof MemoryCache || Array.isArray(m);
.map(m => ({ return raw ? {memory: <Memory[] | MemoryCache>m, inject: true, tool: true, update: true} : {inject: true, tool: true, update: true, ...m};
ref: {name: m.name, description: m.description},
distance: cosineDistance(query, m.embedding)
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, limit);
return scored.map(s => s.ref);
} }
private createNode(name: string, memories: Memory[]): Memory { private unwrap(memories: Memory[] | MemoryCache): Memory[] {
const existing = memories.find(m => m.name === name); return memories instanceof MemoryCache ? memories.memories : memories;
if(existing) return existing;
return {
name,
description: '',
content: '',
embedding: [],
links: [],
backlinks: [],
};
} }
private formatMemory(mem: Memory): string { private sync(memories: Memory[] | MemoryCache): void {
return [ if (memories instanceof MemoryCache) memories.rebuild();
`# ${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 parseFrontmatter(content: string): {fm: Map<string, string>, body: string} {
return memories.map(m => ({name: m.name, description: m.description})); const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return {fm: new Map(), body: content};
const fm = new Map<string, string>();
for (const line of match[1].split('\n')) {
const i = line.indexOf(':');
if (i === -1) continue;
fm.set(line.slice(0, i).trim(), line.slice(i + 1).trim());
}
return {fm, body: match[2]};
}
private writeFrontmatter(fm: Map<string, string>, body: string): string {
const lines = [...fm.entries()].map(([k, v]) => `${k}: ${v}`);
return `---\n${lines.join('\n')}\n---\n\n${body.trimStart()}`;
}
private stripHeader(content: string): string {
return content.replace(/^---[\s\S]*?\n---\n?/, '').trimStart();
}
private touchHeader(node: Memory, body: string): string {
const {fm} = this.parseFrontmatter(node.content);
fm.set('name', node.name);
fm.set('description', node.description || '');
fm.set('modified', new Date().toISOString());
return this.writeFrontmatter(fm, body);
}
private ensureDoc(node: Memory): void {
if (node.content) return;
const title = node.name.split('/').pop() ?? node.name;
node.content = this.touchHeader(node, `# ${title}\n`);
}
private appendFacts(node: Memory, facts: string[]): void {
this.ensureDoc(node);
const body = this.stripHeader(node.content);
const bullets = facts.map(f => `- ${f}`).join('\n');
const idx = body.indexOf(FACTS_HEADING);
const newBody = idx === -1
? `${body.trimEnd()}\n\n${FACTS_HEADING}\n${bullets}\n`
: `${body.slice(0, idx + FACTS_HEADING.length)}\n${bullets}${body.slice(idx + FACTS_HEADING.length)}`;
node.content = this.touchHeader(node, newBody);
}
decay() {
for(const [name, ttl] of this.recentlyTouched) {
if(ttl <= 1) this.recentlyTouched.delete(name);
else this.recentlyTouched.set(name, ttl - 1);
}
}
touch(name: string, ttl = 2) {
this.recentlyTouched.set(name, ttl);
}
getTouched(): string[] {
return [...this.recentlyTouched.keys()];
}
forget(name: string, memories: Memory[] | MemoryCache): boolean {
const mem = this.unwrap(memories);
const idx = mem.findIndex(m => m.name === name);
if (idx === -1) return false;
mem.splice(idx, 1);
rebuildGraph(mem);
this.sync(memories);
return true;
} }
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 = this.unwrap(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 [];
let vectorResults: MemoryRef[]; let vectorResults: MemoryRef[];
if(memories instanceof MemoryCache) vectorResults = memories.search(e.embedding, limit); if (memories instanceof MemoryCache) vectorResults = memories.search(e.embedding, limit);
else vectorResults = this.cosineSearch(e.embedding, mem, limit); else vectorResults = this.cosineSearch(e.embedding, mem, limit);
const found = new Set<string>(vectorResults.map(r => r.name)); const found = new Set<string>(vectorResults.map(r => r.name));
if(graphDepth > 0) { if (graphDepth > 0) {
const frontier = [...found]; const frontier = [...found];
for(let depth = 0; depth < graphDepth; depth++) { for (let depth = 0; depth < graphDepth; depth++) {
const next: string[] = []; const next: string[] = [];
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) { for (const link of node.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);
} }
} }
} }
frontier.splice(0, frontier.length, ...next); frontier.splice(0, frontier.length, ...next);
if(!frontier.length) break; if (!frontier.length) break;
} }
} }
@@ -230,272 +309,194 @@ 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> { private cosineSearch(query: number[], memories: Memory[], limit: number): MemoryRef[] {
const mem = memories instanceof MemoryCache ? memories.memories : memories; const scored = memories
.filter(m => m.embedding?.length)
.map(m => ({
ref: {name: m.name, description: m.description},
distance: cosineDistance(query, m.embedding),
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, limit);
return scored.map(s => s.ref);
}
private listNodes(memories: Memory[]): MemoryRef[] {
return memories.map(m => ({name: m.name, description: m.description}));
}
async memorize(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest): Promise<Memory[]> {
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 uid = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
const buckets = await this.factAgent(conversation, mem, options); // NOTE: adjust field names below (id/tool_call_id/name) to match your LLMMessage/tool-call schema.
if(buckets.length) { const pending = {role: 'tool', name: 'memory_process', id: uid, content: 'Processing…'} as unknown as LLMMessage;
await Promise.all(buckets.map(async bucket => { history.push(pending);
const node = await this.organizingAgent(bucket, mem, options);
if(!mem.find(m => m.name === node.name)) mem.push(node); const mem = this.unwrap(memories);
await this.docAgent(node, bucket, mem, options); const buckets = await this.factAgent(conversation, mem, options, getWeekMonday());
})); const touched: Memory[] = [];
for (const {subject, facts} of buckets) {
let node = mem.find(m => m.name === subject);
if (!node) {
node = {name: subject, description: '', content: '', embedding: [], links: [], backlinks: []};
mem.push(node);
} }
this.appendFacts(node, facts);
const [e] = await this.llm.embedding(node.content);
if (e) node.embedding = e.embedding;
this.touch(node.name);
touched.push(node);
} }
// Auto-compress old journals if (touched.length) {
const weekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); rebuildGraph(mem);
const oldDailies = mem.filter(m => { this.sync(memories);
const journal = /^Journal\/(\d{4}-\d{2}-\d{2}$)/.exec(m.name); (pending as any).content = `Saved to ${touched.map(n => `[[${n.name}]]`).join(', ')}`;
return journal && new Date(journal[1]).getTime() < weekAgo; for (const node of touched) this.reconcile(node, memories, options).catch(() => {});
});
if(oldDailies.length) {
const byMonth = new Map<string, Memory[]>();
for(const daily of oldDailies) {
const match = daily.name.match(/^Journal\/(\d{4}-\d{2})-\d{2}$/);
if(!match) continue;
const monthKey = match[1];
if(!byMonth.has(monthKey)) byMonth.set(monthKey, []);
byMonth.get(monthKey)!.push(daily);
}
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 { } else {
rebuildBacklinks(mem); (pending as any).content = 'Nothing worth remembering.';
}
} }
private async docAgent(node: Memory, bucket: FactBucket, memories: Memory[], options: LLMRequest): Promise<void> { return touched;
let finalContent = node.content; }
const isJournalCompression = node.name.match(/^Journal\/\d{4}-\d{2}$/);
const systemPrompt = isJournalCompression
? `You are a journal compressor. Condense the daily entries below into a monthly summary.
Format: /** Manual/cron entry point. scope 'touched' only reconciles docs with a pending Facts inbox. */
# ${node.name} async reconcileVault(memories: Memory[] | MemoryCache, options: LLMRequest, scope: 'touched' | 'all' = 'touched'): Promise<void> {
const mem = this.unwrap(memories);
const targets = scope === 'all' ? mem : mem.filter(m => m.content.includes(FACTS_HEADING));
await Promise.all(targets.map(node => this.reconcile(node, memories, options)));
this.sync(memories);
}
## Themes /**
(Recurring topics, moods, patterns) * Coalescing queue: if a doc is already reconciling, mark it dirty and abort the in-flight
* request. The loop below always re-reads node.content fresh, so nothing is ever dropped.
*/
private reconcile(node: Memory, memories: Memory[] | MemoryCache, options: LLMRequest): Promise<void> {
const key = node.name;
const existing = this.queues.get(key);
if (existing) {
existing.dirty = true;
existing.request?.abort?.();
return existing.task;
}
## Key Events const entry = {dirty: false, request: null, task: Promise.resolve()};
(Important moments, decisions, milestones) this.queues.set(key, entry);
const mem = this.unwrap(memories);
entry.task = (async () => {
do {
entry.dirty = false;
await this.reconcileDoc(node, mem, options, entry);
} while (entry.dirty);
})().finally(() => {
this.queues.delete(key);
rebuildGraph(mem);
this.sync(memories);
});
return entry.task;
}
## Notable Conversations private async reconcileDoc(node: Memory, memories: Memory[], options: LLMRequest, entry: {request: {abort?: () => void} | null}): Promise<void> {
(Significant discussions or revelations) const currentBody = this.stripHeader(node.content);
let update;
try {
for (let i = 0; i < 2 && !update?.content; i++) {
const request = this.llm.ask(currentBody, {
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 body in markdown, without the frontmatter block', required: true},
},
system: `You are a knowledge base editor maintaining one document in an Obsidian-style vault.
Rules: If the document has a "${FACTS_HEADING}" section, integrate every bullet under it into the appropriate part of the document, then remove the "${FACTS_HEADING}" section entirely. If there is no such section, just tidy the document per the rules below.
- Use [[WikiLinks]] to reference permanent notes using full paths like [[People/Sarah]] or [[Projects/Website]]
- Keep it concise but preserve emotional/temporal context
- 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: Structure: follow this generic shape loosely, adapting section names/order to what the content actually needs (e.g. journal-style docs may want a timeline instead of "Details"):
\`\`\`markdown \`\`\`markdown
${node.content || '(empty — first compression for this month)'} ${GENERIC_TEMPLATE}
\`\`\`` \`\`\`
: `You are a knowledge base editor. Integrate the provided facts into the document below.
Formatting rules: Formatting rules:
- Use Obsidian-style markdown: # headings, **bold** for key terms, bullet lists for facts - Use Obsidian-style markdown: # headings, **bold** for emphasis, bullet & numbered lists for grouped 1D data, 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, 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: newer facts always win — delete the outdated statement entirely, never keep both
- Do not add filler, preamble, or AI commentary — just clean knowledge documents - Do not add frontmatter blocks, filler, preamble, or AI commentary
All nodes: Other nodes in the vault (link to these instead of duplicating their content):
${this.listNodes(memories).map(n => n.name).join(', ') || 'none'} ${this.listNodes(memories).filter(n => n.name !== node.name).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( entry.request = request;
`New facts to integrate:\n${bucket.facts.map(f => `- ${f}`).join('\n')}`, update = await request;
{
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';
} }
}] } catch (err: any) {
if (err?.name === 'AbortError') return;
throw err;
} finally {
entry.request = null;
} }
);
node.content = finalContent; if (!update?.content) return;
node.links = extractLinks(finalContent); node.description = node.name !== 'People/User' ? update.description : 'All information about the current user';
const needsEmbed = !node.embedding?.length || node.description !== memories.find(m => m.name === node.name)?.description; node.content = this.touchHeader(node, update.content);
if (needsEmbed) { const [e] = await this.llm.embedding(node.content);
const [e] = await this.llm.embedding(node.description);
if (e) node.embedding = e.embedding; if (e) node.embedding = e.embedding;
} }
}
private async factAgent(conversation: string, memories: Memory[], options: LLMRequest): Promise<FactBucket[]> {
const buckets: FactBucket[] = [];
const today = new Date().toISOString().split('T')[0];
private async factAgent(conversation: string, memories: Memory[], options: LLMRequest, weekKey: string): Promise<FactBucket[]> {
const buckets = new Map<string, string[]>();
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 primarily 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) — you are not limited to any fixed list of collections, use whatever fits
- 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('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,39 +1,52 @@
import {OpenAI as openAI} from 'openai'; import {OpenAI as openAI} from 'openai';
import {findByProp, objectMap, JSONSanitize, JSONAttemptParse, clean} from '@ztimson/utils'; import {findByProp, objectMap, JSONSanitize, JSONAttemptParse, clean, makeArray} from '@ztimson/utils';
import {AbortablePromise, Ai} from './ai.ts'; import {AbortablePromise, Ai} from './ai.ts';
import {LLMMessage, LLMRequest} from './llm.ts'; import {LLMMessage, LLMRequest} from './llm.ts';
import {LLMProvider} from './provider.ts'; import {LLMProvider} from './provider.ts';
import {TokenPool} from './token-pool.ts';
import {convertSchema} from './tools.ts'; import {convertSchema} from './tools.ts';
export class OpenAi extends LLMProvider { export class OpenAi extends LLMProvider {
client!: openAI; tokenPool!: TokenPool;
private clients = new Map<string, openAI>();
constructor(public readonly ai: Ai, public readonly host: string | null, public readonly token: string, public model: string) { constructor(public readonly ai: Ai, public readonly host: string | null, public readonly token: string | string[], public model: string) {
super(); super();
this.client = new openAI(clean({ const tokens = makeArray(token).filter(Boolean);
baseURL: host, this.tokenPool = new TokenPool(...(tokens.length ? tokens : [host ? 'ignored' : '']));
apiKey: token || (host ? 'ignored' : undefined) }
}));
private getClient(token: string): openAI {
let client = this.clients.get(token);
if(!client) {
client = new openAI(clean({baseURL: this.host, apiKey: token || undefined}));
this.clients.set(token, client);
}
return client;
} }
private toStandard(history: any[]): LLMMessage[] { private toStandard(history: any[]): LLMMessage[] {
for(let i = 0; i < history.length; i++) { for(let i = 0; i < history.length; i++) {
const h = history[i]; const h = history[i];
if(h.role === 'assistant' && h.tool_calls) { if(h.role === 'assistant' && h.tool_calls) {
const tools = h.tool_calls.map((tc: any) => ({ const items: any[] = [];
if(h.content) items.push({role: 'assistant', content: h.content, timestamp: h.timestamp, duration: h.duration, tps: h.tps});
items.push(...h.tool_calls.map((tc: any) => ({
role: 'tool', role: 'tool',
id: tc.id, id: tc.id,
name: tc.function.name, name: tc.function.name,
args: JSONAttemptParse(tc.function.arguments, {}), args: JSONAttemptParse(tc.function.arguments, {}),
timestamp: h.timestamp timestamp: h.timestamp,
})); duration: h.duration,
history.splice(i, 1, ...tools); tps: h.tps
i += tools.length - 1; })));
} else if(h.role === 'tool' && h.content) { history.splice(i, 1, ...items);
i += items.length - 1;
} else if(h.role === 'tool') {
const record = history.find(h2 => h.tool_call_id == h2.id); const record = history.find(h2 => h.tool_call_id == h2.id);
if(record) { if(record) {
if(h.content.includes('"error":')) record.error = h.content; if(h.content?.includes('"error":')) record.error = h.content;
else record.content = h.content; else record.content = h.content || '';
} }
history.splice(i, 1); history.splice(i, 1);
i--; i--;
@@ -51,15 +64,16 @@ export class OpenAi extends LLMProvider {
content: null, content: null,
tool_calls: [{ id: h.id, type: 'function', function: { name: h.name, arguments: JSON.stringify(h.args) } }], tool_calls: [{ id: h.id, type: 'function', function: { name: h.name, arguments: JSON.stringify(h.args) } }],
refusal: null, refusal: null,
annotations: [] annotations: [],
timestamp: h.timestamp,
}, { }, {
role: 'tool', role: 'tool',
tool_call_id: h.id, tool_call_id: h.id,
content: h.error || h.content content: h.error || h.content,
timestamp: h.timestamp,
}); });
} else { } else {
const {timestamp, ...rest} = h; result.push(h);
result.push(rest);
} }
return result; return result;
}, [] as any[]); }, [] as any[]);
@@ -68,11 +82,12 @@ export class OpenAi extends LLMProvider {
ask(message: string, options: LLMRequest = {}): AbortablePromise<string | any> { ask(message: string, options: LLMRequest = {}): AbortablePromise<string | any> {
const controller = new AbortController(); const controller = new AbortController();
return Object.assign(new Promise<any>(async (res, rej) => { return Object.assign(new Promise<any>(async (res, rej) => {
if(options.system) { const base = (options.history || []).filter(h => h.role !== 'system');
if(options.history?.[0]?.role != 'system') options.history?.splice(0, 0, {role: 'system', content: options.system, timestamp: Date.now()}); let history = this.fromStandard([
else options.history[0].content = options.system; ...(options.system ? [{role: <any>'system', content: options.system, timestamp: Date.now()}] : []),
} ...base,
let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]); {role: 'user', content: message, timestamp: Date.now()}
]);
const tools = options.tools || this.ai.options.llm?.tools || []; const tools = options.tools || this.ai.options.llm?.tools || [];
const requestParams: any = { const requestParams: any = {
model: options.model || this.model, model: options.model || this.model,
@@ -106,25 +121,27 @@ export class OpenAi extends LLMProvider {
}; };
} }
let resp: any, isFirstMessage = true; if(options.stream) requestParams.stream_options = {include_usage: true};
let resp: any, terminal = false, duration = 0, tps = 0;
do { do {
resp = await this.client.chat.completions.create(requestParams).catch(err => { requestParams.messages = history.map(({timestamp, ...m}) => m);
const callStart = Date.now();
resp = await this.tokenPool.run(token => this.getClient(token).chat.completions.create(requestParams)).catch(err => {
err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`; err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`;
throw err; throw err;
}); });
let usage: any;
if(options.stream) { if(options.stream) {
if(!isFirstMessage) options.stream({text: '\n\n'}); resp.choices = [{message: {role: 'assistant', content: '', tool_calls: [], timestamp: Date.now()}}];
else isFirstMessage = false;
resp.choices = [{message: {role: 'assistant', content: '', tool_calls: []}}];
for await (const chunk of resp) { for await (const chunk of resp) {
if(controller.signal.aborted) break; if(controller.signal.aborted) break;
if(chunk.choices[0].delta.content) { if(chunk.usage) usage = chunk.usage;
if(chunk.choices[0]?.delta?.content) {
resp.choices[0].message.content += chunk.choices[0].delta.content; resp.choices[0].message.content += chunk.choices[0].delta.content;
options.stream({text: chunk.choices[0].delta.content}); options.stream({text: chunk.choices[0].delta.content});
} }
if(chunk.choices[0]?.delta?.tool_calls) {
if(chunk.choices[0].delta.tool_calls) {
for(const deltaTC of chunk.choices[0].delta.tool_calls) { for(const deltaTC of chunk.choices[0].delta.tool_calls) {
const existing = resp.choices[0].message.tool_calls.find(tc => tc.index === deltaTC.index); const existing = resp.choices[0].message.tool_calls.find(tc => tc.index === deltaTC.index);
if(existing) { if(existing) {
@@ -149,38 +166,52 @@ export class OpenAi extends LLMProvider {
} }
} }
} }
} else {
usage = resp.usage;
} }
duration = Date.now() - callStart;
tps = usage?.completion_tokens && duration > 0 ? usage.completion_tokens / (duration / 1000) : 0;
if(resp.error) throw new Error(resp.error); if(resp.error) throw new Error(resp.error);
const toolCalls = resp.choices[0].message.tool_calls || []; const toolCalls = resp.choices[0].message.tool_calls || [];
if(toolCalls.length && !controller.signal.aborted) { if(toolCalls.length && !controller.signal.aborted) {
history.push(resp.choices[0].message); history.push({...resp.choices[0].message, duration, tps});
const results = await Promise.all(toolCalls.map(async (toolCall: any) => { const results = await Promise.all(toolCalls.map(async (toolCall: any) => {
const tool = tools?.find(findByProp('name', toolCall.function.name)); const tool = tools?.find(findByProp('name', toolCall.function.name));
if(options.stream) options.stream({tool: toolCall.function.name}); if(options.stream) options.stream({tool: toolCall.function.name});
if(!tool) return {role: 'tool', tool_call_id: toolCall.id, content: '{"error": "Tool not found"}'}; if(!tool) return {role: 'tool', tool_call_id: toolCall.id, content: '{"error": "Tool not found"}', timestamp: Date.now()};
try { try {
const args = JSONAttemptParse(toolCall.function.arguments, {}); const args = JSONAttemptParse(toolCall.function.arguments, {});
const result = await tool.fn(args, options.stream, this.ai); const toolStream = options.stream && ((chunk: any) => {
return {role: 'tool', tool_call_id: toolCall.id, content: typeof result == 'object' ? JSONSanitize(result) : result}; if(chunk.done) { terminal = true; return; }
options.stream!(chunk);
});
const result = await tool.fn(args, toolStream, this.ai, toolCall.id);
return {role: 'tool', tool_call_id: toolCall.id, content: typeof result == 'object' ? JSONSanitize(result) : result, timestamp: Date.now()};
} catch (err: any) { } catch (err: any) {
return {role: 'tool', tool_call_id: toolCall.id, content: JSONSanitize({error: err?.message || err?.toString() || 'Unknown'})}; return {role: 'tool', tool_call_id: toolCall.id, content: JSONSanitize({error: err?.message || err?.toString() || 'Unknown'}), timestamp: Date.now()};
} }
})); }));
history.push(...results); history.push(...results);
requestParams.messages = history; requestParams.messages = history;
} }
} while (!controller.signal.aborted && resp.choices?.[0]?.message?.tool_calls?.length); } while (!terminal && !controller.signal.aborted && resp.choices?.[0]?.message?.tool_calls?.length);
if(!terminal) {
const textContent = resp.choices[0].message.content || '';
history.push({role: 'assistant', content: textContent.trim(), timestamp: Date.now(), duration, tps});
}
const textContent = resp.choices[0].message.content?.trim() || '';
history.push({role: 'assistant', content: textContent});
history = this.toStandard(history); history = this.toStandard(history);
if(options.history) options.history.splice(0, options.history.length, ...history.filter(h => h.role !== 'system'));
if(options.stream) options.stream({done: true}); if(options.stream) options.stream({done: true});
if(options.history) options.history.splice(0, options.history.length, ...history);
// Return parsed JSON if schema provided const turnStart = history.map(h => h.role).lastIndexOf('user');
const finalContent = history.at(-1)?.content; const finalContent = history.slice(turnStart + 1).reduce((str, h) => {
if(h.role === 'assistant') return str + (h.content || '');
return str;
}, '').trim();
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent); res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);
}), {abort: () => controller.abort()}); }), {abort: () => controller.abort()});
} }

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

65
src/token-pool.ts Normal file
View File

@@ -0,0 +1,65 @@
const DEFAULT_COOLDOWN = 15 * 60 * 1000;
type TokenState = {
token: string;
cooldownUntil: number; // 0 = available now
lastError?: {code: number, message: string};
};
export class TokenPoolExhaustedError extends Error {
constructor(public tokens: Record<string, {code: number, message: string}>) {
super(`All tokens exhausted:\n${Object.entries(tokens).map(([t, e]) => `${t}: [${e.code}] ${e.message}`).join('\n')}`);
this.name = 'TokenPoolExhaustedError';
}
}
export class TokenPool {
private states: TokenState[];
constructor(...tokens: string[]) {
this.states = tokens.map(token => ({token, cooldownUntil: 0}));
}
private preview(token: string): string {
return token.length <= 8 ? '****' : `${token.slice(0, 4)}...${token.slice(-4)}`;
}
/** Anthropic & OpenAI SDKs both attach `status` to thrown errors */
private statusCode(err: any): number {
return err?.status ?? err?.response?.status ?? err?.statusCode;
}
private retryAfter(err: any): number {
const headers = err?.headers || err?.response?.headers;
const raw = headers?.get?.('retry-after') ?? headers?.['retry-after'];
if(raw) {
const seconds = Number(raw);
if(!isNaN(seconds)) return Date.now() + seconds * 1000;
const date = new Date(raw).getTime();
if(!isNaN(date)) return date;
}
return Date.now() + DEFAULT_COOLDOWN;
}
async run<T>(fn: (token: string) => Promise<T>): Promise<T> {
const now = Date.now();
for(const state of this.states) {
if(state.cooldownUntil > now) continue;
try {
const result = await fn(state.token);
state.cooldownUntil = 0;
state.lastError = undefined;
return result;
} catch(err: any) {
const code = this.statusCode(err);
if(![401, 403, 429].includes(code)) throw err;
state.cooldownUntil = code === 429 ? this.retryAfter(err) : Date.now() + DEFAULT_COOLDOWN;
state.lastError = {code, message: err?.message || 'Unknown error'};
}
}
const failures: Record<string, {code: number, message: string}> = {};
this.states.forEach(s => { if(s.lastError) failures[this.preview(s.token)] = s.lastError; });
throw new TokenPoolExhaustedError(failures);
}
}

View File

@@ -41,7 +41,7 @@ export type AiTool = {
/** Tool arguments */ /** Tool arguments */
args?: AiToolArg, args?: AiToolArg,
/** Callback function */ /** Callback function */
fn: (args: any, stream: LLMRequest['stream'], ai: Ai) => any | Promise<any>, fn: (args: any, stream: LLMRequest['stream'], ai: Ai, toolId?: string) => any | Promise<any>,
}; };
export function convertSchema(schema: any): any { export function convertSchema(schema: any): any {
@@ -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)}`);
}
}
return pairs.join('&');
}
const res = await fetch(host + '/v1', {
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 PythonTool: AiTool = { export const WebReadTool: AiTool = {
name: 'exec_python', name: 'web_read',
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 = {
name: 'read_webpage',
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');
}
};

167
tests/llm.spec.ts Normal file
View File

@@ -0,0 +1,167 @@
import {describe, it, expect, vi, beforeEach} from 'vitest';
import LLM from '../src/llm';
const {FakeProvider, providerLog} = vi.hoisted(() => {
const providerLog: any[] = [];
class FakeProvider {
model: string;
constructor(...args: any[]) { this.model = args[args.length - 1]; }
ask(message: string, opts: any) {
let aborted = false;
const p = (async () => {
const script = (globalThis as any).__scripts?.[this.model];
const plan = script ? script(message, opts) : {text: ''};
providerLog.push({model: this.model, message, system: opts.system, tools: (opts.tools || []).map((t: any) => t.name)});
for (const c of plan.calls || []) {
if (aborted) break;
const tool = (opts.tools || []).find((t: any) => t.name === c.tool);
const id = c.id || `${c.tool}_${Math.random()}`;
const content = await tool.fn(c.args, opts.stream, null, id);
opts.history.push({role: 'tool', id, name: c.tool, args: c.args, content, timestamp: Date.now()});
}
const text = plan.text ?? '';
if (opts.stream && text) opts.stream({text, done: true});
opts.history.push({role: 'assistant', content: text, timestamp: Date.now(), duration: 10, tps: 5});
return text;
})();
return Object.assign(p, {abort: () => { aborted = true; }});
}
}
return {FakeProvider, providerLog};
});
vi.mock('../src/antrhopic.ts', () => ({Anthropic: FakeProvider}));
vi.mock('../src/open-ai.ts', () => ({OpenAi: FakeProvider}));
function makeAi(models: any) {
return {options: {llm: {models}}} as any;
}
beforeEach(() => {
providerLog.length = 0;
(globalThis as any).__scripts = {};
});
describe('LLM cross-provider interchangeability', () => {
it('runs identical tool calls the same way on an anthropic-backed model and an openai-backed model', async () => {
const ai = makeAi({
claude: {proto: 'anthropic', token: 'x'},
gpt: {proto: 'openai', token: 'y', host: 'http://local'},
});
const llm = new LLM(ai);
const calc = {
name: 'calc_add',
description: 'Add two numbers',
args: {a: {type: 'number', required: true}, b: {type: 'number', required: true}},
fn: (args: any) => String(args.a + args.b),
};
(globalThis as any).__scripts.claude = () => ({calls: [{tool: 'calc_add', args: {a: 2, b: 3}}], text: 'Result: 5'});
(globalThis as any).__scripts.gpt = () => ({calls: [{tool: 'calc_add', args: {a: 2, b: 3}}], text: 'Result: 5'});
const historyA: any[] = [], historyB: any[] = [];
const respA = await llm.ask('add 2 and 3', {model: 'claude', tools: [calc], history: historyA});
const respB = await llm.ask('add 2 and 3', {model: 'gpt', tools: [calc], history: historyB});
expect(respA).toBe('Result: 5');
expect(respB).toBe('Result: 5');
expect(providerLog.find(l => l.model === 'claude')!.tools).toContain('calc_add');
expect(providerLog.find(l => l.model === 'gpt')!.tools).toContain('calc_add');
// tool timing gets recomputed from real execution regardless of proto
for (const h of [historyA.find(h => h.name === 'calc_add'), historyB.find(h => h.name === 'calc_add')]) {
expect(h.content).toBe('5');
expect(typeof h.duration).toBe('number');
expect(typeof h.tps).toBe('number');
}
});
it('lets the same shared history flow across model + proto swaps with different system prompts', async () => {
const ai = makeAi({
claude: {proto: 'anthropic', token: 'x'},
gpt: {proto: 'openai', token: 'y', host: 'http://local'},
});
const llm = new LLM(ai);
const history: any[] = [];
(globalThis as any).__scripts.claude = () => ({text: 'Hi from claude'});
(globalThis as any).__scripts.gpt = () => ({text: 'Hi from gpt'});
const r1 = await llm.ask('hello', {model: 'claude', system: 'You are terse.', history});
const r2 = await llm.ask('follow up', {model: 'gpt', system: 'You are verbose.', history});
expect(r1).toBe('Hi from claude');
expect(r2).toBe('Hi from gpt');
expect(history.filter(h => h.role === 'assistant').map(h => h.content)).toEqual(['Hi from claude', 'Hi from gpt']);
expect(providerLog[0].system).toContain('You are terse.');
expect(providerLog[1].system).toContain('You are verbose.');
});
it('exposes MCP tools the same way no matter which proto backs the model', async () => {
const ai = makeAi({claude: {proto: 'anthropic', token: 'x'}, gpt: {proto: 'openai', token: 'y', host: 'http://local'}});
const llm = new LLM(ai);
const mcp = [{name: 'weather', host: 'http://mcp.local'}];
global.fetch = vi.fn(async (url: string, opts?: any) => {
if (url.endsWith('/tools')) {
return {json: async () => ({tools: [{name: 'lookup', description: 'Look up weather', inputSchema: {properties: {city: {type: 'string'}}, required: ['city']}}]})} as any;
}
const body = JSON.parse(opts.body);
return {json: async () => ({content: [{text: `Sunny in ${body.arguments.city}`}]})} as any;
}) as any;
for (const model of ['claude', 'gpt']) {
(globalThis as any).__scripts[model] = () => ({calls: [{tool: 'weather_lookup', args: {city: 'Rome'}}], text: 'done'});
const history: any[] = [];
await llm.ask('weather?', {model, mcp, history});
expect(history.find(h => h.name === 'weather_lookup')?.content).toBe('Sunny in Rome');
}
});
it('exposes and resolves skill documents identically across protos', async () => {
const ai = makeAi({claude: {proto: 'anthropic', token: 'x'}, gpt: {proto: 'openai', token: 'y', host: 'http://local'}});
const llm = new LLM(ai);
const skills = [{name: 'Onboarding', description: 'How to onboard a user', content: 'Step 1...'}];
for (const model of ['claude', 'gpt']) {
(globalThis as any).__scripts[model] = () => ({calls: [{tool: 'skill_read', args: {name: 'Onboarding'}}], text: 'done'});
const history: any[] = [];
await llm.ask('onboard me', {model, skills, history});
expect(history.find(h => h.name === 'skill_read')?.content).toContain('Step 1...');
}
});
it('delegate agent mutates the shared history directly and backfills the orchestrator response, across protos', async () => {
const ai = makeAi({claude: {proto: 'anthropic', token: 'x'}, gpt: {proto: 'openai', token: 'y', host: 'http://local'}});
const llm = new LLM(ai);
const history: any[] = [{role: 'user', content: 'research quantum computing'}];
const researcher = {name: 'researcher', system: 'You research topics.', delegate: true, model: 'gpt'};
(globalThis as any).__scripts.claude = () => ({calls: [{tool: 'agent_researcher', args: {}}], text: ''});
(globalThis as any).__scripts.gpt = () => ({text: 'Quantum computers use qubits.'});
const resp = await llm.ask('go', {model: 'claude', agents: [researcher], history});
expect(resp).toBe('Quantum computers use qubits.');
expect(history.some(h => h.role === 'assistant' && h.content === 'Quantum computers use qubits.')).toBe(true);
expect(history.find(h => h.name === 'agent_researcher')?.content).toBe('');
});
it('regular (non-delegate) subagent keeps its own isolated history separate from the parent, across protos', async () => {
const ai = makeAi({claude: {proto: 'anthropic', token: 'x'}, gpt: {proto: 'openai', token: 'y', host: 'http://local'}});
const llm = new LLM(ai);
const history: any[] = [];
const summarizer = {name: 'summarizer', system: 'You summarize text.', model: 'gpt'};
(globalThis as any).__scripts.claude = () => ({calls: [{tool: 'subagent_summarizer', args: {context: 'a long article', instructions: 'summarize it'}}], text: 'Summary: short version'});
(globalThis as any).__scripts.gpt = () => ({text: 'short version'});
const resp = await llm.ask('summarize this', {model: 'claude', agents: [summarizer], history});
expect(resp).toBe('Summary: short version');
expect(history.find(h => h.name === 'subagent_summarizer')?.content).toBe('short version');
// isolated history - subagent's own assistant turn never leaks into the parent
expect(history.some(h => h.role === 'assistant' && h.content === 'short version')).toBe(false);
});
});

256
tests/memory.spec.ts Normal file
View File

@@ -0,0 +1,256 @@
import {describe, it, expect, vi, beforeEach} from 'vitest';
import {MemoryManager, MemoryCache, rebuildGraph, Memory} from '../src/memory';
function makeMemory(overrides: Partial<Memory> = {}): Memory {
return {
name: 'Test/Doc',
description: '',
content: '',
embedding: [],
links: [],
backlinks: [],
...overrides,
};
}
function makeLLM() {
return {
embedding: vi.fn(async (_text: string) => [{embedding: [1, 0, 0]}]),
ask: vi.fn(async () => undefined),
};
}
describe('rebuildGraph', () => {
it('extracts [[WikiLinks]] from content, excluding self-links', () => {
const a = makeMemory({name: 'A', content: '[[B]] and [[A]] and [[C]]'});
const b = makeMemory({name: 'B', content: 'no links here'});
const mem = [a, b];
rebuildGraph(mem);
expect(a.links).toEqual(['B', 'C']);
expect(b.links).toEqual([]);
});
it('computes backlinks only for links that resolve to a real node', () => {
const a = makeMemory({name: 'A', content: '[[B]] [[Missing]]'});
const b = makeMemory({name: 'B', content: ''});
const mem = [a, b];
rebuildGraph(mem);
expect(b.backlinks).toEqual(['A']);
expect(mem.find(m => m.name === 'Missing')).toBeUndefined();
});
it('resets stale backlinks on every rebuild (no leftover from a removed link)', () => {
const a = makeMemory({name: 'A', content: '[[B]]'});
const b = makeMemory({name: 'B', content: ''});
const mem = [a, b];
rebuildGraph(mem);
expect(b.backlinks).toEqual(['A']);
a.content = 'no more links';
rebuildGraph(mem);
expect(b.backlinks).toEqual([]);
});
});
describe('MemoryCache', () => {
it('finds nearest neighbor by embedding via KD-tree search', () => {
const close = makeMemory({name: 'Close', embedding: [1, 0, 0]});
const far = makeMemory({name: 'Far', embedding: [0, 0, 1]});
const cache = new MemoryCache([close, far]);
const results = cache.search([1, 0, 0], 1);
expect(results[0].name).toBe('Close');
});
it('rebuilds the tree on add/update/remove', () => {
const cache = new MemoryCache([makeMemory({name: 'A', embedding: [1, 0, 0]})]);
cache.add(makeMemory({name: 'B', embedding: [0, 1, 0]}));
expect(cache.search([0, 1, 0], 1)[0].name).toBe('B');
cache.remove('B');
expect(cache.search([0, 1, 0], 1)[0]?.name).not.toBe('B');
});
});
describe('MemoryManager.forget', () => {
it('removes the node and recomputes backlinks for the rest of the graph', () => {
const llm = makeLLM();
const mgr = new MemoryManager(llm);
const a = makeMemory({name: 'A', content: '[[B]]'});
const b = makeMemory({name: 'B', content: '[[C]]'});
const c = makeMemory({name: 'C', content: ''});
const mem = [a, b, c];
rebuildGraph(mem);
expect(c.backlinks).toEqual(['B']);
const ok = mgr.forget('B', mem);
expect(ok).toBe(true);
expect(mem.find(m => m.name === 'B')).toBeUndefined();
expect(a.links).toEqual(['B']);
expect(c.backlinks).toEqual([]);
});
it('returns false for an unknown name', () => {
const mgr = new MemoryManager(makeLLM());
expect(mgr.forget('Nope', [makeMemory({name: 'A'})])).toBe(false);
});
});
describe('MemoryManager.recollect', () => {
it('orders vector matches first, then expands one hop via links', async () => {
const llm = makeLLM();
llm.embedding.mockResolvedValue([{embedding: [1, 0, 0]}]);
const mgr = new MemoryManager(llm);
const near = makeMemory({name: 'Near', embedding: [1, 0, 0], content: '[[Linked]]'});
const linked = makeMemory({name: 'Linked', embedding: [0, 0, 1], content: ''});
const far = makeMemory({name: 'Far', embedding: [0, 1, 0], content: ''});
const mem = [near, linked, far];
rebuildGraph(mem);
const result = await mgr.recollect('query', mem, 1, 1);
expect(result.map(r => r.name)).toEqual(['Near', 'Linked']);
});
it('returns [] when there are no memories', async () => {
const mgr = new MemoryManager(makeLLM());
expect(await mgr.recollect('q', [])).toEqual([]);
});
});
describe('MemoryManager.memorize (fast path)', () => {
let llm: ReturnType<typeof makeLLM>;
let mgr: MemoryManager;
beforeEach(() => {
llm = makeLLM();
mgr = new MemoryManager(llm);
});
it('pushes a pending tool message, then resolves it to links once facts land', async () => {
llm.ask.mockImplementation(async (_prompt: string, opts: any) => {
if (opts.tools) {
opts.tools[0].fn({destination: 'Projects/Oxide', facts: 'Uses a hybrid memory system'});
return undefined;
}
return {description: 'd', content: '# doc'};
});
const history: any[] = [{role: 'user', content: 'we use a hybrid memory system'}];
const touched = await mgr.memorize(history, [], {model: 'test'} as any);
const pending = history.find(h => h.name === 'memory_process');
expect(pending).toBeDefined();
expect(pending.content).toContain('[[Projects/Oxide]]');
expect(touched.map(t => t.name)).toEqual(['Projects/Oxide']);
});
it('creates a new node and appends facts under "## Facts" without calling the doc LLM', async () => {
llm.ask.mockImplementation(async (_prompt: string, opts: any) => {
if (opts.tools) opts.tools[0].fn({destination: 'People/Sarah', facts: 'Works at Acme, Likes hiking'});
return undefined;
});
const mem: Memory[] = [];
await mgr.memorize([{role: 'user', content: 'Sarah works at Acme and likes hiking'}] as any, mem, {model: 'test'} as any);
const node = mem.find(m => m.name === 'People/Sarah')!;
expect(node).toBeDefined();
expect(node.content).toContain('## Facts');
expect(node.content).toContain('- Works at Acme');
expect(node.content).toContain('- Likes hiking');
// doc reconciler LLM (schema call) should NOT have been awaited synchronously in this fast path assertion
});
it('routes "journal" destination to Journal/{weekMonday}', async () => {
llm.ask.mockImplementation(async (_prompt: string, opts: any) => {
if (opts.tools) opts.tools[0].fn({destination: 'journal', facts: 'Shipped v1'});
return undefined;
});
const mem: Memory[] = [];
const touched = await mgr.memorize([{role: 'user', content: 'shipped v1 today'}] as any, mem, {model: 'test'} as any);
expect(touched[0].name).toMatch(/^Journal\/\d{4}-\d{2}-\d{2}$/);
});
it('reports nothing to remember when no facts are extracted', async () => {
llm.ask.mockResolvedValue(undefined); // tools present but fn never called
const history: any[] = [{role: 'user', content: 'hey'}];
const touched = await mgr.memorize(history, [], {model: 'test'} as any);
expect(touched).toEqual([]);
expect(history.find(h => h.name === 'memory_process').content).toBe('Nothing worth remembering.');
});
it('returns [] and does nothing for an empty conversation', async () => {
const touched = await mgr.memorize([], [], {model: 'test'} as any);
expect(touched).toEqual([]);
expect(llm.ask).not.toHaveBeenCalled();
});
});
describe('MemoryManager reconcileVault', () => {
it('integrates the "## Facts" section via the doc LLM and removes it', async () => {
const llm = makeLLM();
llm.ask.mockResolvedValue({description: 'Tidy summary', content: '# Doc\n\nIntegrated fact.'});
const mgr = new MemoryManager(llm);
const node = makeMemory({
name: 'Projects/Oxide',
content: '---\nname: Projects/Oxide\n---\n\n# Doc\n\n## Facts\n- some raw fact\n',
});
const mem = [node];
await mgr.reconcileVault(mem, {model: 'test'} as any, 'all');
expect(node.content).not.toContain('## Facts');
expect(node.content).toContain('Integrated fact.');
expect(node.description).toBe('Tidy summary');
});
it('only targets docs with a pending Facts inbox when scope is "touched"', async () => {
const llm = makeLLM();
llm.ask.mockResolvedValue({description: 'd', content: '# clean'});
const mgr = new MemoryManager(llm);
const dirty = makeMemory({name: 'A', content: '## Facts\n- x'});
const clean = makeMemory({name: 'B', content: '# already tidy'});
await mgr.reconcileVault([dirty, clean], {model: 'test'} as any, 'touched');
expect(dirty.content).toContain('# clean'); // rewritten (frontmatter now wraps it)
expect(clean.content).toBe('# already tidy'); // untouched, never queued
});
});
describe('MemoryManager reconcile coalescing', () => {
it('coalesces a second call while one is in-flight: marks dirty, aborts, reuses the same task promise', () => {
const llm = makeLLM();
const abort = vi.fn();
let calls = 0;
llm.ask.mockImplementation(() => {
calls++;
const pending: any = new Promise(() => {}); // never resolves in this test
pending.abort = abort;
return pending;
});
const mgr: any = new MemoryManager(llm);
const node = makeMemory({name: 'Q', content: '# Q\n\n## Facts\n- f'});
const mem = [node];
const p1 = mgr.reconcile(node, mem, {model: 'test'});
const p2 = mgr.reconcile(node, mem, {model: 'test'});
expect(p2).toBe(p1); // same in-flight task, not a new queue entry
expect(abort).toHaveBeenCalledTimes(1); // second call aborted the in-flight request
expect(calls).toBe(1); // no second ask() fired synchronously — it'll rerun via the dirty loop
});
});