diff --git a/torch/eslint.config.mjs b/torch/eslint.config.mjs new file mode 100644 index 00000000..e65162b3 --- /dev/null +++ b/torch/eslint.config.mjs @@ -0,0 +1,21 @@ +import js from '@eslint/js'; +import globals from 'globals'; + +export default [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + ...globals.node, + ...globals.es2021, + }, + }, + rules: { + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'no-undef': 'error', + }, + ignores: ['node_modules/', 'task-logs/', 'package.json', 'package-lock.json', 'torch-config.json'], + }, +]; diff --git a/torch/package-lock.json b/torch/package-lock.json index 1e5d25aa..2aef0e32 100644 --- a/torch/package-lock.json +++ b/torch/package-lock.json @@ -10,12 +10,205 @@ "license": "ISC", "dependencies": { "torch-lock": "https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.0.0", + "globals": "^17.3.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.1.tgz", + "integrity": "sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.1", + "debug": "^4.3.1", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", + "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", + "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.1.tgz", + "integrity": "sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", + "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" } }, "node_modules/@noble/ciphers": { "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", - "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" @@ -23,8 +216,6 @@ }, "node_modules/@noble/curves": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", "license": "MIT", "dependencies": { "@noble/hashes": "1.3.2" @@ -35,8 +226,6 @@ }, "node_modules/@noble/curves/node_modules/@noble/hashes": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", "license": "MIT", "engines": { "node": ">= 16" @@ -47,8 +236,6 @@ }, "node_modules/@noble/hashes": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", - "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", "license": "MIT", "engines": { "node": ">= 16" @@ -59,8 +246,6 @@ }, "node_modules/@scure/base": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", - "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", "funding": [ { "type": "individual", @@ -71,8 +256,6 @@ }, "node_modules/@scure/bip32": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz", - "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", "license": "MIT", "dependencies": { "@noble/curves": "~1.1.0", @@ -85,8 +268,6 @@ }, "node_modules/@scure/bip32/node_modules/@noble/curves": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", - "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", "license": "MIT", "dependencies": { "@noble/hashes": "1.3.1" @@ -97,8 +278,6 @@ }, "node_modules/@scure/bip39": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", - "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", "license": "MIT", "dependencies": { "@noble/hashes": "~1.3.0", @@ -108,10 +287,555 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", + "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jackspeak": "^4.2.3" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.0.tgz", + "integrity": "sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.0", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.0", + "@eslint/plugin-kit": "^0.6.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.0", + "eslint-visitor-keys": "^5.0.0", + "espree": "^11.1.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.1.1", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.0.tgz", + "integrity": "sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.0.tgz", + "integrity": "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", + "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/nostr-tools": { "version": "2.19.4", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz", - "integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==", "license": "Unlicense", "dependencies": { "@noble/ciphers": "^0.5.1", @@ -133,14 +857,125 @@ }, "node_modules/nostr-wasm": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", - "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", "license": "MIT" }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/torch-lock": { "version": "0.1.0", "resolved": "https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz", - "integrity": "sha512-xfgSmFmjugNUFGB51IZRmGY6kgEMLFCqlH5gpMlns3eJJqjvVKGqGlb6UU+e4jcB8BxmexiUuAfkB+sHvEp3NQ==", + "integrity": "sha512-b+eKOtFosfEC5MzaMpnxKur8L6qLkVzCRxQxTIioN9Sg/9/NcxJj/wtEAwVjme9Vx8jzwJcVP1XGecdzIo1qOw==", "dependencies": { "nostr-tools": "2.19.4", "ws": "8.19.0" @@ -149,10 +984,57 @@ "torch-lock": "bin/torch-lock.mjs" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ws": { "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -169,6 +1051,19 @@ "optional": true } } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/torch/package.json b/torch/package.json index 90757a99..8ac08a01 100644 --- a/torch/package.json +++ b/torch/package.json @@ -14,5 +14,10 @@ "type": "commonjs", "dependencies": { "torch-lock": "https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.0.0", + "globals": "^17.3.0" } } diff --git a/torch/src/cli-parser.mjs b/torch/src/cli-parser.mjs new file mode 100644 index 00000000..21e20b53 --- /dev/null +++ b/torch/src/cli-parser.mjs @@ -0,0 +1,137 @@ +import { DEFAULT_DASHBOARD_PORT } from './constants.mjs'; + +/** + * Parses command-line arguments into a structured object. + * + * @param {string[]} argv - Arguments from process.argv.slice(2) + * @returns {Object} - Parsed arguments (command, flags, values) + */ +export function parseArgs(argv) { + const args = { + command: null, + agent: null, + cadence: null, + json: false, + jsonFile: null, + quiet: false, + dryRun: false, + force: false, + port: DEFAULT_DASHBOARD_PORT, + host: null, + logDir: 'task-logs', + ignoreLogs: false, + id: null, + type: null, + tags: [], + pinned: null, + limit: null, + offset: null, + retentionMs: null, + windowMs: null, + timeoutMs: null, + allRelaysDownMinutes: null, + minSuccessRate: null, + windowMinutes: null, + platform: null, + model: null, + }; + let i = 0; + + if (argv.length > 0 && !argv[0].startsWith('-')) { + args.command = argv[0]; + i = 1; + } + + for (; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--agent' || arg === '-a') { + args.agent = argv[++i]; + } else if (arg === '--cadence' || arg === '-c') { + args.cadence = argv[++i]; + } else if (arg === '--json') { + args.json = true; + } else if (arg === '--json-file') { + args.jsonFile = argv[++i]; + } else if (arg.startsWith('--json-file=')) { + args.jsonFile = arg.split('=')[1]; + } else if (arg === '--quiet') { + args.quiet = true; + } else if (arg.startsWith('--agent=')) { + args.agent = arg.split('=')[1]; + } else if (arg.startsWith('--cadence=')) { + args.cadence = arg.split('=')[1]; + } else if (arg === '--dry-run') { + args.dryRun = true; + } else if (arg === '--force') { + args.force = true; + } else if (arg === '--port') { + args.port = parseInt(argv[++i], 10) || DEFAULT_DASHBOARD_PORT; + } else if (arg === '--host') { + args.host = argv[++i]; + } else if (arg === '--log-dir') { + args.logDir = argv[++i]; + } else if (arg === '--ignore-logs') { + args.ignoreLogs = true; + } else if (arg === '--id') { + args.id = argv[++i]; + } else if (arg.startsWith('--id=')) { + args.id = arg.split('=')[1]; + } else if (arg === '--type') { + args.type = argv[++i]; + } else if (arg.startsWith('--type=')) { + args.type = arg.split('=')[1]; + } else if (arg === '--tags') { + args.tags = String(argv[++i]).split(',').map((tag) => tag.trim()).filter(Boolean); + } else if (arg.startsWith('--tags=')) { + args.tags = String(arg.split('=')[1]).split(',').map((tag) => tag.trim()).filter(Boolean); + } else if (arg === '--pinned') { + const value = String(argv[++i]).toLowerCase(); + args.pinned = value === 'true' ? true : value === 'false' ? false : null; + } else if (arg.startsWith('--pinned=')) { + const value = String(arg.split('=')[1]).toLowerCase(); + args.pinned = value === 'true' ? true : value === 'false' ? false : null; + } else if (arg === '--limit') { + args.limit = parseInt(argv[++i], 10); + } else if (arg.startsWith('--limit=')) { + args.limit = parseInt(arg.split('=')[1], 10); + } else if (arg === '--offset') { + args.offset = parseInt(argv[++i], 10); + } else if (arg.startsWith('--offset=')) { + args.offset = parseInt(arg.split('=')[1], 10); + } else if (arg === '--retention-ms') { + args.retentionMs = parseInt(argv[++i], 10); + } else if (arg.startsWith('--retention-ms=')) { + args.retentionMs = parseInt(arg.split('=')[1], 10); + } else if (arg === '--window-ms') { + args.windowMs = parseInt(argv[++i], 10); + } else if (arg.startsWith('--window-ms=')) { + args.windowMs = parseInt(arg.split('=')[1], 10); + } else if (arg === '--timeout-ms') { + args.timeoutMs = parseInt(argv[++i], 10); + } else if (arg.startsWith('--timeout-ms=')) { + args.timeoutMs = parseInt(arg.split('=')[1], 10); + } else if (arg === '--all-relays-down-minutes') { + args.allRelaysDownMinutes = parseInt(argv[++i], 10); + } else if (arg.startsWith('--all-relays-down-minutes=')) { + args.allRelaysDownMinutes = parseInt(arg.split('=')[1], 10); + } else if (arg === '--min-success-rate') { + args.minSuccessRate = parseFloat(argv[++i]); + } else if (arg.startsWith('--min-success-rate=')) { + args.minSuccessRate = parseFloat(arg.split('=')[1]); + } else if (arg === '--window-minutes') { + args.windowMinutes = parseInt(argv[++i], 10); + } else if (arg.startsWith('--window-minutes=')) { + args.windowMinutes = parseInt(arg.split('=')[1], 10); + } else if (arg === '--platform') { + args.platform = argv[++i]; + } else if (arg.startsWith('--platform=')) { + args.platform = arg.split('=')[1]; + } else if (arg === '--model') { + args.model = argv[++i]; + } else if (arg.startsWith('--model=')) { + args.model = arg.split('=')[1]; + } + } + + return args; +} diff --git a/torch/src/cmd-check.mjs b/torch/src/cmd-check.mjs new file mode 100644 index 00000000..a0d16f4f --- /dev/null +++ b/torch/src/cmd-check.mjs @@ -0,0 +1,61 @@ +import { + loadTorchConfig, + getRelays, + getNamespace, +} from './torch-config.mjs'; +import { getRoster } from './roster.mjs'; +import { queryLocks } from './lock-ops.mjs'; +import { todayDateStr } from './utils.mjs'; + +export async function cmdCheck(cadence, deps = {}) { + const { + loadTorchConfigFn = loadTorchConfig, + getRelaysFn = getRelays, + getNamespaceFn = getNamespace, + getRosterFn = getRoster, + queryLocksFn = queryLocks, + todayDateStrFn = todayDateStr, + } = deps; + + const relays = await getRelaysFn(); + const namespace = await getNamespaceFn(); + const dateStr = todayDateStrFn(); + const config = await loadTorchConfigFn(); + const pausedAgents = (cadence === 'daily' ? config.scheduler.paused.daily : config.scheduler.paused.weekly) || []; + + console.error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`); + console.error(`Relays: ${relays.join(', ')}`); + if (pausedAgents.length > 0) { + console.error(`Paused agents: ${pausedAgents.join(', ')}`); + } + + const locks = await queryLocksFn(relays, cadence, dateStr, namespace); + const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))]; + const roster = await getRosterFn(cadence); + + const excludedAgents = [...new Set([...lockedAgents, ...pausedAgents])]; + const unknownLockedAgents = lockedAgents.filter((agent) => !roster.includes(agent)); + const available = roster.filter((a) => !excludedAgents.includes(a)); + + const result = { + namespace, + cadence, + date: dateStr, + locked: lockedAgents.sort(), + paused: pausedAgents.sort(), + excluded: excludedAgents.sort(), + available: available.sort(), + lockCount: locks.length, + unknownLockedAgents: unknownLockedAgents.sort(), + locks: locks.map((l) => ({ + agent: l.agent, + eventId: l.eventId, + createdAt: l.createdAtIso, + expiresAt: l.expiresAtIso, + platform: l.platform, + })), + }; + + console.log(JSON.stringify(result, null, 2)); + return result; +} diff --git a/torch/src/cmd-list.mjs b/torch/src/cmd-list.mjs new file mode 100644 index 00000000..65a9166e --- /dev/null +++ b/torch/src/cmd-list.mjs @@ -0,0 +1,62 @@ +import { + getRelays, + getNamespace, +} from './torch-config.mjs'; +import { getRoster } from './roster.mjs'; +import { queryLocks } from './lock-ops.mjs'; +import { todayDateStr, nowUnix } from './utils.mjs'; + +export async function cmdList(cadence) { + const relays = await getRelays(); + const namespace = await getNamespace(); + const dateStr = todayDateStr(); + const cadences = cadence ? [cadence] : ['daily', 'weekly']; + + console.error(`Listing active locks: namespace=${namespace}, cadences=${cadences.join(', ')}`); + + const results = await Promise.all( + cadences.map(async (c) => { + const locks = await queryLocks(relays, c, dateStr, namespace); + return { c, locks }; + }), + ); + + for (const { c, locks } of results) { + console.log(`\n${'='.repeat(72)}`); + console.log(`Active ${namespace} ${c} locks (${dateStr})`); + console.log('='.repeat(72)); + + if (locks.length === 0) { + console.log(' (no active locks)'); + continue; + } + + const sorted = locks.sort((a, b) => a.createdAt - b.createdAt); + for (const lock of sorted) { + const age = nowUnix() - lock.createdAt; + const ageMin = Math.round(age / 60); + const remaining = lock.expiresAt ? lock.expiresAt - nowUnix() : null; + const remainMin = remaining ? Math.round(remaining / 60) : '?'; + + console.log( + ` ${(lock.agent ?? 'unknown').padEnd(30)} ` + + `age: ${String(ageMin).padStart(4)}m ` + + `ttl: ${String(remainMin).padStart(4)}m ` + + `platform: ${lock.platform ?? '?'} ` + + `event: ${lock.eventId?.slice(0, 12)}...`, + ); + } + + const roster = await getRoster(c); + const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean)); + const unknownLockedAgents = [...lockedAgents].filter((agent) => !roster.includes(agent)); + const available = roster.filter((a) => !lockedAgents.has(a)); + + if (unknownLockedAgents.length > 0) { + console.log(` Warning: lock events found with non-roster agent names: ${unknownLockedAgents.join(', ')}`); + } + + console.log(`\n Locked: ${lockedAgents.size}/${roster.length}`); + console.log(` Available: ${available.join(', ') || '(none)'}`); + } +} diff --git a/torch/src/cmd-lock.mjs b/torch/src/cmd-lock.mjs new file mode 100644 index 00000000..195baa56 --- /dev/null +++ b/torch/src/cmd-lock.mjs @@ -0,0 +1,122 @@ +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { + getRelays, + getNamespace, + getTtl, +} from './torch-config.mjs'; +import { + RACE_CHECK_DELAY_MS, + KIND_APP_DATA, +} from './constants.mjs'; +import { getRoster } from './roster.mjs'; +import { queryLocks, publishLock } from './lock-ops.mjs'; +import { todayDateStr, nowUnix } from './utils.mjs'; +import { ExitError } from './errors.mjs'; + +export async function cmdLock(agent, cadence, dryRun = false) { + const relays = await getRelays(); + const namespace = await getNamespace(); + const dateStr = todayDateStr(); + const ttl = await getTtl(); + const now = nowUnix(); + const expiresAt = now + ttl; + + console.error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`); + console.error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`); + console.error(`Relays: ${relays.join(', ')}`); + + const roster = await getRoster(cadence); + if (!roster.includes(agent)) { + console.error(`ERROR: agent "${agent}" is not in the ${cadence} roster`); + console.error(`Allowed ${cadence} agents: ${roster.join(', ')}`); + throw new ExitError(1, 'Agent not in roster'); + } + + console.error('Step 1: Checking for existing locks...'); + const existingLocks = await queryLocks(relays, cadence, dateStr, namespace); + const conflicting = existingLocks.filter((l) => l.agent === agent); + + if (conflicting.length > 0) { + const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0]; + console.error( + `LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` + + `(created ${earliest.createdAtIso}, platform: ${earliest.platform})`, + ); + console.log('LOCK_STATUS=denied'); + console.log('LOCK_REASON=already_locked'); + console.log(`LOCK_EXISTING_EVENT=${earliest.eventId}`); + throw new ExitError(3, 'Lock denied'); + } + + console.error('Step 2: Generating ephemeral keypair...'); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + console.error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`); + + console.error('Step 3: Building lock event...'); + const event = finalizeEvent( + { + kind: KIND_APP_DATA, + created_at: now, + tags: [ + ['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`], + ['t', `${namespace}-agent-lock`], + ['t', `${namespace}-lock-${cadence}`], + ['t', `${namespace}-lock-${cadence}-${dateStr}`], + ['expiration', String(expiresAt)], + ], + content: JSON.stringify({ + agent, + cadence, + status: 'started', + namespace, + date: dateStr, + platform: process.env.AGENT_PLATFORM || 'unknown', + lockedAt: new Date(now * 1000).toISOString(), + expiresAt: new Date(expiresAt * 1000).toISOString(), + }), + }, + sk, + ); + + console.error(` Event ID: ${event.id}`); + + if (dryRun) { + console.error('Step 4: [DRY RUN] Skipping publish — event built but not sent'); + console.error('RACE CHECK: won (dry run — no real contention possible)'); + } else { + console.error('Step 4: Publishing to relays...'); + await publishLock(relays, event); + + console.error('Step 5: Race check...'); + await new Promise((resolve) => setTimeout(resolve, RACE_CHECK_DELAY_MS)); + + const postLocks = await queryLocks(relays, cadence, dateStr, namespace); + const racingLocks = postLocks + .filter((l) => l.agent === agent) + .sort((a, b) => a.createdAt - b.createdAt); + + if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) { + const winner = racingLocks[0]; + console.error( + `RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`, + ); + console.log('LOCK_STATUS=race_lost'); + console.log('LOCK_REASON=earlier_claim_exists'); + console.log(`LOCK_WINNER_EVENT=${winner.eventId}`); + throw new ExitError(3, 'Race check lost'); + } + + console.error('RACE CHECK: won'); + } + + console.log('LOCK_STATUS=ok'); + console.log(`LOCK_EVENT_ID=${event.id}`); + console.log(`LOCK_PUBKEY=${pk}`); + console.log(`LOCK_AGENT=${agent}`); + console.log(`LOCK_CADENCE=${cadence}`); + console.log(`LOCK_DATE=${dateStr}`); + console.log(`LOCK_EXPIRES=${expiresAt}`); + console.log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`); + return { status: 'ok', eventId: event.id }; +} diff --git a/torch/src/constants.mjs b/torch/src/constants.mjs new file mode 100644 index 00000000..8e5a6534 --- /dev/null +++ b/torch/src/constants.mjs @@ -0,0 +1,67 @@ +export const DEFAULT_DASHBOARD_PORT = 4173; +export const RACE_CHECK_DELAY_MS = 1500; +export const DEFAULT_RELAYS = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.primal.net', +]; +export const DEFAULT_TTL = 7200; +export const DEFAULT_NAMESPACE = 'torch'; +export const DEFAULT_QUERY_TIMEOUT_MS = 30_000; +export const DEFAULT_PUBLISH_TIMEOUT_MS = 15_000; +export const DEFAULT_MIN_SUCCESSFUL_PUBLISHES = 1; +export const DEFAULT_MIN_ACTIVE_RELAY_POOL = 1; +export const DEFAULT_RETRY_ATTEMPTS = 4; +export const DEFAULT_RETRY_BASE_DELAY_MS = 500; +export const DEFAULT_RETRY_CAP_DELAY_MS = 8000; +export const DEFAULT_ROLLING_WINDOW_SIZE = 25; +export const DEFAULT_FAILURE_THRESHOLD = 3; +export const DEFAULT_QUARANTINE_COOLDOWN_MS = 30_000; +export const DEFAULT_MAX_QUARANTINE_COOLDOWN_MS = 300_000; +export const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000; +export const VALID_CADENCES = new Set(['daily', 'weekly']); +export const KIND_APP_DATA = 30078; + +export const USAGE_TEXT = `Usage: torch-lock [options] + +Commands: + check --cadence Check locked agents (JSON) + lock --agent --cadence Claim a lock + complete --agent --cadence Mark task as completed (permanent) + list [--cadence ] Print active lock table + health --cadence Probe relay websocket + publish/read health + dashboard [--port ] [--host ] Serve the dashboard (default: ${DEFAULT_DASHBOARD_PORT}) + init [--force] Initialize torch/ directory in current project + update [--force] Update torch/ configuration (backups, merges) + + list-memories [--agent ] [--type ] [--tags ] [--pinned ] [--full] + inspect-memory --id + pin-memory --id + unpin-memory --id + trigger-prune-dry-run [--retention-ms ] + memory-stats [--window-ms ] + +Options: + --dry-run Build and sign the event but do not publish + --force Overwrite existing files (for init) or all files (for update) + --log-dir Path to task logs directory (default: task-logs) + --ignore-logs Skip checking local logs for completed tasks + --json Emit compact single-line JSON + --json-file Write JSON output to a file path + --quiet Suppress stderr progress logs (pairs well with --json) + +Environment: + NOSTR_LOCK_NAMESPACE Namespace prefix for lock tags (default: torch) + NOSTR_LOCK_RELAYS Comma-separated relay WSS URLs + NOSTR_LOCK_TTL Lock TTL in seconds (default: 7200) + NOSTR_LOCK_QUERY_TIMEOUT_MS Relay query timeout in milliseconds (default: 15000) + NOSTR_LOCK_DAILY_ROSTER Comma-separated daily roster (optional) + NOSTR_LOCK_WEEKLY_ROSTER Comma-separated weekly roster (optional) + TORCH_CONFIG_PATH Optional path to torch-config.json (default: ./torch-config.json) + AGENT_PLATFORM Platform identifier (e.g., codex) + +Exit codes: + 0 Success + 1 Usage error + 2 Relay/network error + 3 Lock denied (already locked or race lost)`; diff --git a/torch/src/dashboard.mjs b/torch/src/dashboard.mjs new file mode 100644 index 00000000..740357ac --- /dev/null +++ b/torch/src/dashboard.mjs @@ -0,0 +1,210 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import http from 'node:http'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { DEFAULT_DASHBOARD_PORT } from './constants.mjs'; +import { getDashboardAuth, parseTorchConfig } from './torch-config.mjs'; + +function timingSafeCompare(a, b) { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) { + // Still do a comparison to avoid some timing leaks, though length leak is hard to avoid entirely + crypto.timingSafeEqual(bufA, bufA); + return false; + } + return crypto.timingSafeEqual(bufA, bufB); +} + +export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.0.1') { + // Resolve package root relative to this file (src/dashboard.mjs) + // this file is in /src/dashboard.mjs, so '..' goes to + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const auth = await getDashboardAuth(); + + const server = http.createServer(async (req, res) => { + try { + // Basic Auth check + if (auth) { + const authHeader = req.headers.authorization; + if (!authHeader) { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); + res.end('Authentication required'); + return; + } + const parts = authHeader.split(' '); + const type = parts[0]; + const credentials = parts[1]; + let isValid = false; + if (type === 'Basic' && credentials) { + try { + const decoded = Buffer.from(credentials, 'base64').toString(); + isValid = timingSafeCompare(decoded, auth); + } catch { + isValid = false; + } + } + + if (!isValid) { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); + res.end('Invalid credentials'); + return; + } + } + + // URL parsing + const url = new URL(req.url, `http://${req.headers.host}`); + let pathname = url.pathname; + + // Redirect / to /dashboard/ + if (pathname === '/' || pathname === '/dashboard') { + res.writeHead(302, { 'Location': '/dashboard/' }); + res.end(); + return; + } + + async function statSafe(p) { + try { + return await fsp.stat(p); + } catch { + return null; + } + } + + // Special case: /torch-config.json + // Priority: Env Var > User's CWD > Package default + if (pathname === '/torch-config.json') { + let configContent = null; + + if (process.env.TORCH_CONFIG_PATH) { + const envPath = path.resolve(process.cwd(), process.env.TORCH_CONFIG_PATH); + try { + configContent = await fsp.readFile(envPath, 'utf8'); + } catch { + // Explicitly provided config path not found, do not fall back + } + } else { + const userConfigPath = path.resolve(process.cwd(), 'torch-config.json'); + try { + configContent = await fsp.readFile(userConfigPath, 'utf8'); + } catch { + // Try package default + const packageConfigPath = path.join(packageRoot, 'torch-config.json'); + try { + configContent = await fsp.readFile(packageConfigPath, 'utf8'); + } catch { + // Not found in either location + } + } + } + + if (configContent) { + try { + const rawConfig = JSON.parse(configContent); + const safeConfig = parseTorchConfig(rawConfig); + + // Security: Sanitize sensitive fields + if (safeConfig.dashboard) { + delete safeConfig.dashboard.auth; + } + delete safeConfig.configPath; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(safeConfig, null, 2)); + return; + } catch (err) { + console.error('Error parsing torch-config.json:', err); + res.writeHead(500); + res.end('Internal Server Error'); + return; + } + } else { + res.writeHead(404); + res.end('Not Found'); + return; + } + } + + // Security check: prevent directory traversal + const safePath = path.normalize(pathname).replace(new RegExp('^(\\.\\.[\\/\\\\])+'), ''); + let filePath = path.join(packageRoot, safePath); + + // Security check: restrict access to allowed paths + const allowedPaths = [ + path.join(packageRoot, 'dashboard'), + path.join(packageRoot, 'assets'), + path.join(packageRoot, 'src', 'docs'), + path.join(packageRoot, 'src', 'constants.mjs'), + path.join(packageRoot, 'torch-config.json') + ]; + + const isAllowed = allowedPaths.some(allowedPath => { + const rel = path.relative(allowedPath, filePath); + return !rel.startsWith('..') && !path.isAbsolute(rel); + }); + + if (!isAllowed) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + + // If directory, try index.html + let fileStat = await statSafe(filePath); + if (fileStat && fileStat.isDirectory()) { + filePath = path.join(filePath, 'index.html'); + fileStat = await statSafe(filePath); + } + + // Check if file exists and is a file + if (!fileStat || !fileStat.isFile()) { + res.writeHead(404); + res.end('Not Found'); + return; + } + + // MIME types + const extname = path.extname(filePath); + let contentType = 'text/plain'; + switch (extname) { + case '.html': contentType = 'text/html'; break; + case '.js': contentType = 'text/javascript'; break; + case '.mjs': contentType = 'text/javascript'; break; + case '.css': contentType = 'text/css'; break; + case '.json': contentType = 'application/json'; break; + case '.png': contentType = 'image/png'; break; + case '.jpg': contentType = 'image/jpeg'; break; + case '.svg': contentType = 'image/svg+xml'; break; + case '.ico': contentType = 'image/x-icon'; break; + case '.md': contentType = 'text/markdown'; break; + } + + res.writeHead(200, { 'Content-Type': contentType }); + fs.createReadStream(filePath).pipe(res); + } catch (err) { + console.error('Dashboard Server Error:', err); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal Server Error'); + } + } + }); + + return new Promise((resolve, reject) => { + server.listen(port, host, () => { + const listenUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/dashboard/`; + console.log(`Dashboard running at ${listenUrl}`); + if (auth) { + console.log('Authentication: enabled (Basic Auth)'); + } else { + console.warn('Authentication: DISABLED (Dashboard is public)'); + } + console.log(`Serving files from ${packageRoot}`); + console.log(`Using configuration from ${process.cwd()}`); + resolve(server); + }); + server.on('error', reject); + }); +} diff --git a/torch/src/errors.mjs b/torch/src/errors.mjs new file mode 100644 index 00000000..c997a9f8 --- /dev/null +++ b/torch/src/errors.mjs @@ -0,0 +1,7 @@ +// Custom Error class for controlled exits +export class ExitError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} diff --git a/torch/src/lib.mjs b/torch/src/lib.mjs new file mode 100644 index 00000000..ec97d947 --- /dev/null +++ b/torch/src/lib.mjs @@ -0,0 +1,684 @@ +#!/usr/bin/env node + +// TORCH — Task Orchestration via Relay-Coordinated Handoff +// Generic Nostr-based task locking for multi-agent development. + +import { generateSecretKey as _generateSecretKey, getPublicKey as _getPublicKey, finalizeEvent as _finalizeEvent } from 'nostr-tools/pure'; +import { useWebSocketImplementation } from 'nostr-tools/relay'; +import WebSocket from 'ws'; +import { + loadTorchConfig as _loadTorchConfig, + getRelays as _getRelays, + getNamespace as _getNamespace, + getTtl as _getTtl, + getHashtag as _getHashtag, +} from './torch-config.mjs'; +import { + VALID_CADENCES, + KIND_APP_DATA, + RACE_CHECK_DELAY_MS, + USAGE_TEXT, +} from './constants.mjs'; +import { cmdInit, cmdUpdate } from './ops.mjs'; +import { parseArgs } from './cli-parser.mjs'; +import { getRoster as _getRoster } from './roster.mjs'; +import { queryLocks as _queryLocks, publishLock as _publishLock, parseLockEvent } from './lock-ops.mjs'; +import { cmdDashboard } from './dashboard.mjs'; +import { + inspectMemory, + listMemories, + memoryStats, + pinMemory, + triggerPruneDryRun, + unpinMemory, +} from './services/memory/index.js'; +import { ExitError } from './errors.mjs'; +import { todayDateStr, nowUnix, detectPlatform } from './utils.mjs'; +import { runRelayHealthCheck } from './relay-health.mjs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { getCompletedAgents } from './lock-utils.mjs'; + +useWebSocketImplementation(WebSocket); + +// Re-export for backward compatibility/library usage +export { parseLockEvent, cmdDashboard, _queryLocks as queryLocks, _publishLock as publishLock }; + +/** + * Checks the status of the repository locks for a given cadence. + * + * It aggregates data from three sources: + * 1. Configuration (paused agents) + * 2. Local logs (completed agents) + * 3. Nostr relays (current active locks) + * + * It outputs a JSON object describing the state (locked, available, excluded agents). + * + * @param {string} cadence - 'daily' or 'weekly' + * @param {Object} [deps] - Dependency injection + * @returns {Promise} - The check result object + */ +export async function cmdCheck(cadence, deps = {}) { + const { + getRelays = _getRelays, + getNamespace = _getNamespace, + loadTorchConfig = _loadTorchConfig, + queryLocks = _queryLocks, + getRoster = _getRoster, + getDateStr = todayDateStr, + log = console.log, + error = console.error, + logDir = 'task-logs', + ignoreLogs = false, + json = false, + jsonFile = null, + quiet = false, + } = deps; + + const relays = await getRelays(); + const namespace = await getNamespace(); + const dateStr = getDateStr(); + const config = await loadTorchConfig(); + const pausedAgents = config.scheduler.paused[cadence] || []; + + if (!quiet) { + error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`); + error(`Relays: ${relays.join(', ')}`); + if (pausedAgents.length > 0) { + error(`Paused agents: ${pausedAgents.join(', ')}`); + } + } + + let completedAgents = new Set(); + if (!ignoreLogs) { + completedAgents = await getCompletedAgents(cadence, logDir, deps); + if (!quiet && completedAgents.size > 0) { + error(`Completed agents (logs): ${[...completedAgents].join(', ')}`); + } + } + + const locks = await queryLocks(relays, cadence, dateStr, namespace); + const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))]; + const roster = await getRoster(cadence); + const rosterSet = new Set(roster); + + const excludedAgentsSet = new Set([...lockedAgents, ...pausedAgents, ...completedAgents]); + const excludedAgents = [...excludedAgentsSet]; + const unknownLockedAgents = lockedAgents.filter((agent) => !rosterSet.has(agent)); + const available = roster.filter((a) => !excludedAgentsSet.has(a)); + + const result = { + namespace, + cadence, + date: dateStr, + locked: lockedAgents.sort(), + paused: pausedAgents.sort(), + completed: [...completedAgents].sort(), + excluded: excludedAgents.sort(), + available: available.sort(), + lockCount: locks.length, + unknownLockedAgents: unknownLockedAgents.sort(), + locks: locks.map((l) => ({ + agent: l.agent, + eventId: l.eventId, + createdAt: l.createdAtIso, + expiresAt: l.expiresAtIso, + platform: l.platform, + })), + }; + + const output = json ? JSON.stringify(result) : JSON.stringify(result, null, 2); + + if (jsonFile) { + const resolvedPath = path.resolve(process.cwd(), jsonFile); + await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); + await fs.writeFile(resolvedPath, `${output}\n`, 'utf8'); + } + + if (json || !quiet) { + log(output); + } + + return result; +} + +/** + * Attempts to acquire an exclusive lock for an agent on the specified cadence. + * + * Algorithm: + * 1. Validate agent against the roster. + * 2. Query relays for existing valid locks (checking for conflicts). + * 3. Generate a new ephemeral keypair and build a lock event (kind 30078). + * 4. Publish the lock event to relays. + * 5. Wait for propagation (raceCheckDelayMs) and re-query to confirm no earlier lock won the race. + * + * @param {string} agent - Agent name + * @param {string} cadence - 'daily' or 'weekly' + * @param {boolean} [dryRun=false] - If true, skips publishing + * @param {Object} [deps] - Dependency injection + * @returns {Promise<{status: string, eventId: string}>} + * @throws {ExitError} If lock is denied (already locked, completed, or race lost) + */ +export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {}) { + const options = typeof optionsOrDryRun === 'object' ? optionsOrDryRun : { dryRun: !!optionsOrDryRun }; + const { dryRun = false, platform = null, model = null } = options; + + const { + getRelays = _getRelays, + getNamespace = _getNamespace, + getHashtag = _getHashtag, + getTtl = _getTtl, + queryLocks = _queryLocks, + getRoster = _getRoster, + publishLock = _publishLock, + generateSecretKey = _generateSecretKey, + getPublicKey = _getPublicKey, + finalizeEvent = _finalizeEvent, + raceCheckDelayMs = RACE_CHECK_DELAY_MS, + getDateStr = todayDateStr, + log = console.log, + error = console.error + } = deps; + + const relays = await getRelays(); + const namespace = await getNamespace(); + const hashtag = await getHashtag(); + const dateStr = getDateStr(); + const ttl = await getTtl(); + const now = nowUnix(); + const expiresAt = now + ttl; + + error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`); + error(`Hashtag: #${hashtag}`); + error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`); + error(`Relays: ${relays.join(', ')}`); + + const roster = await getRoster(cadence); + if (!roster.includes(agent)) { + error(`ERROR: agent "${agent}" is not in the ${cadence} roster`); + error(`Allowed ${cadence} agents: ${roster.join(', ')}`); + throw new ExitError(1, 'Agent not in roster'); + } + + error('Step 1: Checking for existing locks...'); + const existingLocks = await queryLocks(relays, cadence, dateStr, namespace); + const conflicting = existingLocks.filter((l) => l.agent === agent); + + if (conflicting.length > 0) { + const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0]; + + // Check if it is a completed task + if (earliest.status === 'completed') { + error(`LOCK DENIED: Task already completed by event ${earliest.eventId}`); + log('LOCK_STATUS=denied'); + log('LOCK_REASON=already_completed'); + throw new ExitError(3, 'Task already completed'); + } + + error( + `LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` + + `(created ${earliest.createdAtIso}, platform: ${earliest.platform})`, + ); + log('LOCK_STATUS=denied'); + log('LOCK_REASON=already_locked'); + log(`LOCK_EXISTING_EVENT=${earliest.eventId}`); + throw new ExitError(3, 'Lock denied'); + } + + error('Step 2: Generating ephemeral keypair...'); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`); + + error('Step 3: Building lock event...'); + const event = finalizeEvent( + { + kind: KIND_APP_DATA, + created_at: now, + tags: [ + ['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`], + ['t', hashtag], + ['t', `${namespace}-lock-${cadence}`], + ['t', `${namespace}-lock-${cadence}-${dateStr}`], + ['expiration', String(expiresAt)], + ], + content: JSON.stringify({ + agent, + cadence, + status: 'started', + namespace, + date: dateStr, + platform: platform || process.env.AGENT_PLATFORM || detectPlatform() || 'unknown', + model: model || process.env.AGENT_MODEL || 'unknown', + lockedAt: new Date(now * 1000).toISOString(), + expiresAt: new Date(expiresAt * 1000).toISOString(), + }), + }, + sk, + ); + + error(` Event ID: ${event.id}`); + + if (dryRun) { + error('Step 4: [DRY RUN] Skipping publish — event built but not sent'); + error('RACE CHECK: won (dry run — no real contention possible)'); + } else { + error('Step 4: Publishing to relays...'); + await publishLock(relays, event); + + error('Step 5: Race check...'); + await new Promise((resolve) => setTimeout(resolve, raceCheckDelayMs)); + + const postLocks = await queryLocks(relays, cadence, dateStr, namespace); + const racingLocks = postLocks + .filter((l) => l.agent === agent) + .sort((a, b) => (a.createdAt - b.createdAt) || String(a.eventId).localeCompare(String(b.eventId))); + + if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) { + const winner = racingLocks[0]; + error( + `RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`, + ); + log('LOCK_STATUS=race_lost'); + log('LOCK_REASON=earlier_claim_exists'); + log(`LOCK_WINNER_EVENT=${winner.eventId}`); + throw new ExitError(3, 'Race check lost'); + } + + error('RACE CHECK: won'); + } + + log('LOCK_STATUS=ok'); + log(`LOCK_EVENT_ID=${event.id}`); + log(`LOCK_PUBKEY=${pk}`); + log(`LOCK_AGENT=${agent}`); + log(`LOCK_HASHTAG=${hashtag}`); + log(`LOCK_CADENCE=${cadence}`); + log(`LOCK_DATE=${dateStr}`); + log(`LOCK_EXPIRES=${expiresAt}`); + log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`); + return { status: 'ok', eventId: event.id }; +} + +/** + * Lists all active locks for the specified cadence (or all cadences if null). + * It prints a formatted table to stdout with lock age, TTL, and event ID. + * + * @param {string|null} cadence - Filter by cadence ('daily', 'weekly') or null for all + * @param {Object} [deps] - Dependency injection + * @returns {Promise} + */ +export async function cmdList(cadence, deps = {}) { + const { + getRelays = _getRelays, + getNamespace = _getNamespace, + queryLocks = _queryLocks, + getRoster = _getRoster, + getDateStr = todayDateStr, + log = console.log, + error = console.error + } = deps; + + const relays = await getRelays(); + const namespace = await getNamespace(); + const dateStr = getDateStr(); + const cadences = cadence ? [cadence] : [...VALID_CADENCES]; + + error(`Listing active locks: namespace=${namespace}, cadences=${cadences.join(', ')}`); + + const results = await Promise.all( + cadences.map(async (c) => { + const locks = await queryLocks(relays, c, dateStr, namespace); + return { c, locks }; + }), + ); + + for (const { c, locks } of results) { + log(`\n${'='.repeat(72)}`); + log(`Active ${namespace} ${c} locks (${dateStr})`); + log('='.repeat(72)); + + if (locks.length === 0) { + log(' (no active locks)'); + continue; + } + + const sorted = locks.sort((a, b) => a.createdAt - b.createdAt); + for (const lock of sorted) { + const age = nowUnix() - lock.createdAt; + const ageMin = Math.round(age / 60); + + let remainMin = '?'; + if (lock.status === 'completed') { + remainMin = 'done'; + } else if (lock.expiresAt) { + const remaining = lock.expiresAt - nowUnix(); + remainMin = Math.round(remaining / 60); + } + + log( + ` ${(lock.agent ?? 'unknown').padEnd(30)} ` + + `age: ${String(ageMin).padStart(4)}m ` + + `ttl: ${String(remainMin).padStart(4)} ` + + `platform: ${lock.platform ?? '?'} ` + + `event: ${lock.eventId?.slice(0, 12)}...`, + ); + } + + const roster = await getRoster(c); + const rosterSet = new Set(roster); + const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean)); + const unknownLockedAgents = [...lockedAgents].filter((agent) => !rosterSet.has(agent)); + const available = roster.filter((a) => !lockedAgents.has(a)); + + if (unknownLockedAgents.length > 0) { + log(` Warning: lock events found with non-roster agent names: ${unknownLockedAgents.join(', ')}`); + } + + log(`\n Locked: ${lockedAgents.size}/${roster.length}`); + log(` Available: ${available.join(', ') || '(none)'}`); + } +} + +/** + * Marks a task as permanently completed by publishing a new lock event with + * `status: 'completed'` and no expiration. + * + * This function: + * 1. Verifies that the agent currently holds a valid lock. + * 2. Publishes a replacement event that preserves the original `startedAt` time. + * + * @param {string} agent - Agent name + * @param {string} cadence - 'daily' or 'weekly' + * @param {boolean} [dryRun=false] - If true, skips publishing + * @param {Object} [deps] - Dependency injection + * @returns {Promise<{status: string, eventId: string}>} + * @throws {ExitError} If no active lock exists for the agent + */ +export async function cmdComplete(agent, cadence, optionsOrDryRun = false, deps = {}) { + const options = typeof optionsOrDryRun === 'object' ? optionsOrDryRun : { dryRun: !!optionsOrDryRun }; + const { dryRun = false, platform = null, model = null } = options; + + const { + getRelays = _getRelays, + getNamespace = _getNamespace, + getHashtag = _getHashtag, + queryLocks = _queryLocks, + publishLock = _publishLock, + generateSecretKey = _generateSecretKey, + getPublicKey = _getPublicKey, + finalizeEvent = _finalizeEvent, + getDateStr = todayDateStr, + log = console.log, + error = console.error + } = deps; + + const relays = await getRelays(); + const namespace = await getNamespace(); + const hashtag = await getHashtag(); + const dateStr = getDateStr(); + const now = nowUnix(); + + error(`Completing task: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`); + error(`Relays: ${relays.join(', ')}`); + + // 1. Find existing lock + const locks = await queryLocks(relays, cadence, dateStr, namespace); + const myLock = locks.find((l) => l.agent === agent); + + if (!myLock) { + error(`ERROR: No active lock found for agent "${agent}" on ${dateStr}.`); + error(`Cannot complete a task that is not locked or has already expired.`); + throw new ExitError(1, 'No active lock found'); + } + + if (myLock.status === 'completed') { + error(`Task is already marked as completed (event ${myLock.eventId}).`); + log('LOCK_STATUS=completed'); + return { status: 'completed', eventId: myLock.eventId }; + } + + // 2. Build completion event + const startedAtIso = myLock.createdAtIso; + + error('Step 1: Generating completion event...'); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + + const event = finalizeEvent( + { + kind: KIND_APP_DATA, + created_at: now, + tags: [ + ['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`], + ['t', hashtag], + ['t', `${namespace}-lock-${cadence}`], + ['t', `${namespace}-lock-${cadence}-${dateStr}`], + // No expiration tag -> permanent + ], + content: JSON.stringify({ + agent, + cadence, + status: 'completed', + namespace, + date: dateStr, + platform: platform || process.env.AGENT_PLATFORM || detectPlatform() || 'unknown', + model: model || process.env.AGENT_MODEL || 'unknown', + startedAt: startedAtIso, + completedAt: new Date(now * 1000).toISOString(), + }), + }, + sk, + ); + + error(` Event ID: ${event.id}`); + + if (dryRun) { + error('Step 2: [DRY RUN] Skipping publish — event built but not sent'); + } else { + error('Step 2: Publishing completion event...'); + await publishLock(relays, event); + error(' Published successfully.'); + } + + log('LOCK_STATUS=completed'); + log(`LOCK_EVENT_ID=${event.id}`); + log(`LOCK_PUBKEY=${pk}`); + log(`LOCK_AGENT=${agent}`); + log(`LOCK_CADENCE=${cadence}`); + log(`LOCK_DATE=${dateStr}`); + + return { status: 'completed', eventId: event.id }; +} + +function usage() { + console.error(USAGE_TEXT); +} + +/** + * Main entry point for the torch-lock CLI. + * Dispatches to specific commands (check, lock, complete, etc.) based on argv. + * + * @param {string[]} argv - Arguments from process.argv.slice(2) + */ +export async function main(argv) { + try { + const args = parseArgs(argv); + + if (!args.command) { + usage(); + throw new ExitError(1, 'No command specified'); + } + + switch (args.command) { + case 'check': { + if (!args.cadence || !VALID_CADENCES.has(args.cadence)) { + console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for check`); + throw new ExitError(1, 'Missing cadence'); + } + await cmdCheck(args.cadence, { + logDir: args.logDir, + ignoreLogs: args.ignoreLogs, + json: args.json, + jsonFile: args.jsonFile, + quiet: args.quiet, + }); + break; + } + + case 'lock': { + if (!args.agent) { + console.error('ERROR: --agent is required for lock'); + throw new ExitError(1, 'Missing agent'); + } + if (!args.cadence || !VALID_CADENCES.has(args.cadence)) { + console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for lock`); + throw new ExitError(1, 'Missing cadence'); + } + await cmdLock(args.agent, args.cadence, { + dryRun: args.dryRun, + platform: args.platform, + model: args.model + }); + break; + } + + case 'complete': { + if (!args.agent) { + console.error('ERROR: --agent is required for complete'); + throw new ExitError(1, 'Missing agent'); + } + if (!args.cadence || !VALID_CADENCES.has(args.cadence)) { + console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for complete`); + throw new ExitError(1, 'Missing cadence'); + } + await cmdComplete(args.agent, args.cadence, { + dryRun: args.dryRun, + platform: args.platform, + model: args.model + }); + break; + } + + case 'list': { + if (args.cadence && !VALID_CADENCES.has(args.cadence)) { + console.error(`ERROR: --cadence must be one of: ${[...VALID_CADENCES].join(', ')}`); + throw new ExitError(1, 'Invalid cadence'); + } + await cmdList(args.cadence || null); + break; + } + + case 'health': { + if (!args.cadence || !VALID_CADENCES.has(args.cadence)) { + console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for health`); + throw new ExitError(1, 'Missing cadence'); + } + const result = await runRelayHealthCheck({ + cadence: args.cadence, + ...(Number.isFinite(args.timeoutMs) ? { timeoutMs: args.timeoutMs } : {}), + ...(Number.isFinite(args.allRelaysDownMinutes) ? { allRelaysDownMinutes: args.allRelaysDownMinutes } : {}), + ...(Number.isFinite(args.minSuccessRate) ? { minSuccessRate: args.minSuccessRate } : {}), + ...(Number.isFinite(args.windowMinutes) ? { windowMinutes: args.windowMinutes } : {}), + }); + if (!result.ok) { + result.failureCategory = 'all relays unhealthy'; + } + console.log(JSON.stringify(result, null, 2)); + if (!result.ok) throw new ExitError(2, 'Relay health check failed'); + break; + } + + case 'dashboard': { + await cmdDashboard(args.port, args.host); + break; + } + + case 'init': { + await cmdInit(args.force); + break; + } + + case 'update': { + await cmdUpdate(args.force); + break; + } + + case 'list-memories': { + const result = await listMemories({ + agent_id: args.agent, + type: args.type, + tags: args.tags, + pinned: args.pinned, + limit: args.limit, + offset: args.offset, + }); + + if (!args.full && Array.isArray(result)) { + for (const memory of result) { + if (typeof memory.content === 'string' && memory.content.length > 200) { + memory.content = memory.content.slice(0, 200) + '... (truncated, use --full to see all)'; + } + } + } + + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'inspect-memory': { + if (!args.id) { + console.error('ERROR: --id is required for inspect-memory'); + throw new ExitError(1, 'Missing memory id'); + } + const result = await inspectMemory(args.id); + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'pin-memory': { + if (!args.id) { + console.error('ERROR: --id is required for pin-memory'); + throw new ExitError(1, 'Missing memory id'); + } + const result = await pinMemory(args.id); + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'unpin-memory': { + if (!args.id) { + console.error('ERROR: --id is required for unpin-memory'); + throw new ExitError(1, 'Missing memory id'); + } + const result = await unpinMemory(args.id); + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'trigger-prune-dry-run': { + const result = await triggerPruneDryRun({ retentionMs: args.retentionMs ?? undefined }); + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'memory-stats': { + const result = await memoryStats({ windowMs: args.windowMs ?? undefined }); + console.log(JSON.stringify(result, null, 2)); + break; + } + + default: + console.error(`ERROR: Unknown command: ${args.command}`); + usage(); + throw new ExitError(1, 'Unknown command'); + } + } catch (err) { + if (err instanceof ExitError) { + process.exit(err.code); + } else { + console.error(`torch-lock failed: ${err.message}`); + process.exit(2); + } + } +} diff --git a/torch/src/lock-ops.mjs b/torch/src/lock-ops.mjs new file mode 100644 index 00000000..5fac6227 --- /dev/null +++ b/torch/src/lock-ops.mjs @@ -0,0 +1,697 @@ +import { randomInt, randomUUID } from 'node:crypto'; +import { SimplePool } from 'nostr-tools/pool'; +import { + getQueryTimeoutMs, + getPublishTimeoutMs, + getMinSuccessfulRelayPublishes, + getRelayFallbacks, + getMinActiveRelayPool, +} from './torch-config.mjs'; +import { + KIND_APP_DATA, + DEFAULT_RETRY_ATTEMPTS, + DEFAULT_RETRY_BASE_DELAY_MS, + DEFAULT_RETRY_CAP_DELAY_MS, + DEFAULT_ROLLING_WINDOW_SIZE, + DEFAULT_FAILURE_THRESHOLD, + DEFAULT_QUARANTINE_COOLDOWN_MS, + DEFAULT_MAX_QUARANTINE_COOLDOWN_MS, + DEFAULT_SNAPSHOT_INTERVAL_MS, +} from './constants.mjs'; + +function nowUnix() { + return Math.floor(Date.now() / 1000); +} + +export function parseLockEvent(event) { + const dTag = event.tags.find((t) => t[0] === 'd')?.[1] ?? ''; + const expTag = event.tags.find((t) => t[0] === 'expiration')?.[1]; + const expiresAt = expTag ? parseInt(expTag, 10) : null; + + let content = {}; + try { + const parsed = JSON.parse(event.content); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + content = parsed; + } + } catch { + // Ignore malformed JSON content + } + + return { + eventId: event.id, + pubkey: event.pubkey, + createdAt: event.created_at, + createdAtIso: new Date(event.created_at * 1000).toISOString(), + expiresAt, + expiresAtIso: expiresAt ? new Date(expiresAt * 1000).toISOString() : null, + dTag, + agent: content.agent ?? null, + cadence: content.cadence ?? null, + status: content.status ?? null, + date: content.date ?? null, + platform: content.platform ?? null, + }; +} + +function filterActiveLocks(locks) { + const now = nowUnix(); + return locks.filter((lock) => !lock.expiresAt || lock.expiresAt > now); +} + +function withTimeout(promise, timeoutMs, timeoutMessage) { + let timeoutHandle; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeoutHandle) clearTimeout(timeoutHandle); + }); +} + +function relayListLabel(relays) { + return relays.join(', '); +} + +function classifyPublishError(message) { + const normalized = String(message || '').toLowerCase(); + if (normalized.includes('publish timed out after') || normalized.includes('publish timeout')) { + return 'publish_timeout'; + } + if ( + normalized.includes('enotfound') + || normalized.includes('eai_again') + || normalized.includes('getaddrinfo') + || (normalized.includes('dns') && normalized.includes('websocket')) + ) { + return 'dns_resolution'; + } + if ( + normalized.includes('connect etimedout') + || normalized.includes('tcp connect timed out') + || normalized.includes('connect timeout') + ) { + return 'tcp_connect_timeout'; + } + if ( + normalized.includes('tls') + || normalized.includes('ssl') + || normalized.includes('certificate') + || normalized.includes('handshake') + ) { + return 'tls_handshake'; + } + if ( + normalized.includes('websocket') + || normalized.includes('bad response') + || normalized.includes('unexpected server response') + ) { + return 'websocket_open_failure'; + } + if (normalized.includes('timed out') || normalized.includes('timeout') || normalized.includes('etimedout')) { + return 'network_timeout'; + } + if (normalized.includes('econnreset') || normalized.includes('connection reset') || normalized.includes('socket hang up')) { + return 'connection_reset'; + } + if ( + normalized.includes('unavailable') + || normalized.includes('offline') + || normalized.includes('econnrefused') + || normalized.includes('connection refused') + || normalized.includes('enotfound') + || normalized.includes('503') + ) { + return 'relay_unavailable'; + } + return 'permanent_validation_error'; +} + +function isTransientPublishCategory(category) { + return [ + 'publish_timeout', + 'dns_resolution', + 'tcp_connect_timeout', + 'tls_handshake', + 'websocket_open_failure', + 'network_timeout', + 'connection_reset', + 'relay_unavailable', + ].includes(category); +} + +const MAX_RANDOM = 281474976710655; // 2**48 - 1 + +function secureRandom() { + return randomInt(0, MAX_RANDOM) / MAX_RANDOM; +} + +function calculateBackoffDelayMs(attemptNumber, baseMs, capMs, randomFn = secureRandom) { + const maxDelay = Math.min(capMs, baseMs * (2 ** Math.max(0, attemptNumber - 1))); + return Math.floor(randomFn() * maxDelay); +} + +function summarizeRelayMetrics(metrics, nowMs) { + const total = metrics.recentOutcomes.length; + const successCount = metrics.recentOutcomes.filter((entry) => entry.success).length; + const timeoutCount = metrics.recentOutcomes.filter((entry) => entry.timedOut).length; + const latencyEntries = metrics.recentOutcomes.map((entry) => entry.latencyMs).filter((v) => Number.isFinite(v)); + const averageLatencyMs = latencyEntries.length + ? Math.round(latencyEntries.reduce((sum, value) => sum + value, 0) / latencyEntries.length) + : null; + const successRate = total > 0 ? successCount / total : 0.5; + const timeoutRate = total > 0 ? timeoutCount / total : 0; + const quarantineRemainingMs = metrics.quarantineUntil > nowMs ? metrics.quarantineUntil - nowMs : 0; + return { + relay: metrics.relay, + sampleSize: total, + successRate, + timeoutRate, + averageLatencyMs, + failureStreak: metrics.failureStreak, + quarantined: quarantineRemainingMs > 0, + quarantineRemainingMs, + cooldownMs: metrics.cooldownMs, + lastResultAt: metrics.lastResultAt, + }; +} + +function computeRelayScore(summary) { + const latencyPenalty = summary.averageLatencyMs === null + ? 0.1 + : Math.min(0.35, summary.averageLatencyMs / 10_000); + const quarantinePenalty = summary.quarantined ? 1 : 0; + return (summary.successRate * 1.4) - (summary.timeoutRate * 0.9) - latencyPenalty - quarantinePenalty; +} + +export class RelayHealthManager { + constructor() { + this.metricsByRelay = new Map(); + this.lastSnapshotAt = 0; + } + + ensureMetrics(relay, config) { + let metrics = this.metricsByRelay.get(relay); + if (!metrics) { + metrics = { + relay, + recentOutcomes: [], + failureStreak: 0, + quarantineUntil: 0, + cooldownMs: config.quarantineCooldownMs, + lastLatencyMs: null, + lastResultAt: null, + }; + this.metricsByRelay.set(relay, metrics); + } + return metrics; + } + + rankRelays(relays, config, nowMs = Date.now()) { + const summaries = relays.map((relay) => { + const metrics = this.ensureMetrics(relay, config); + const summary = summarizeRelayMetrics(metrics, nowMs); + return { + relay, + summary, + score: computeRelayScore(summary), + }; + }); + + return summaries.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (a.summary.quarantined !== b.summary.quarantined) return a.summary.quarantined ? 1 : -1; + if (a.summary.averageLatencyMs !== b.summary.averageLatencyMs) { + if (a.summary.averageLatencyMs === null) return 1; + if (b.summary.averageLatencyMs === null) return -1; + return a.summary.averageLatencyMs - b.summary.averageLatencyMs; + } + return a.relay.localeCompare(b.relay); + }); + } + + prioritizeRelays(relays, config, nowMs = Date.now()) { + const minActive = Math.max(1, Math.min(config.minActiveRelayPool, relays.length || 1)); + const ranked = this.rankRelays([...new Set(relays)], config, nowMs); + const active = ranked.filter((entry) => !entry.summary.quarantined); + const quarantined = ranked + .filter((entry) => entry.summary.quarantined) + .sort((a, b) => a.summary.quarantineRemainingMs - b.summary.quarantineRemainingMs); + + const selected = [...active]; + if (selected.length < minActive) { + const additionalNeeded = Math.min(minActive - selected.length, quarantined.length); + selected.push(...quarantined.slice(0, additionalNeeded)); + } + + return { + prioritized: selected.map((entry) => entry.relay), + ranked, + }; + } + + recordOutcome(relay, success, errorMessage, latencyMs, config, nowMs = Date.now()) { + const metrics = this.ensureMetrics(relay, config); + const timedOut = String(errorMessage || '').toLowerCase().includes('timeout') + || String(errorMessage || '').toLowerCase().includes('timed out'); + metrics.recentOutcomes.push({ success, timedOut, latencyMs, atMs: nowMs }); + if (metrics.recentOutcomes.length > config.rollingWindowSize) { + metrics.recentOutcomes.splice(0, metrics.recentOutcomes.length - config.rollingWindowSize); + } + metrics.lastResultAt = nowMs; + metrics.lastLatencyMs = Number.isFinite(latencyMs) ? latencyMs : null; + + if (success) { + metrics.failureStreak = 0; + if (metrics.quarantineUntil > nowMs) { + metrics.quarantineUntil = 0; + metrics.cooldownMs = config.quarantineCooldownMs; + } + return; + } + + metrics.failureStreak += 1; + if (metrics.failureStreak >= config.failureThreshold) { + metrics.quarantineUntil = nowMs + metrics.cooldownMs; + metrics.cooldownMs = Math.min(config.maxQuarantineCooldownMs, Math.floor(metrics.cooldownMs * 1.5)); + } + } + + collectSnapshot(relays, config, nowMs = Date.now()) { + const uniqueRelays = [...new Set(relays)]; + return this.rankRelays(uniqueRelays, config, nowMs).map((entry) => ({ + relay: entry.relay, + score: Number(entry.score.toFixed(4)), + ...entry.summary, + })); + } + + maybeLogSnapshot(relays, config, logger, reason, force = false, nowMs = Date.now()) { + const intervalReached = nowMs - this.lastSnapshotAt >= config.snapshotIntervalMs; + if (!force && !intervalReached) return; + this.lastSnapshotAt = nowMs; + logger(JSON.stringify({ + event: 'relay_health_snapshot', + reason, + relays: this.collectSnapshot(relays, config, nowMs), + })); + } + + reset() { + this.metricsByRelay.clear(); + this.lastSnapshotAt = 0; + } +} + +export const defaultHealthManager = new RelayHealthManager(); + +function buildRelayHealthConfig(deps) { + return { + rollingWindowSize: deps.rollingWindowSize ?? DEFAULT_ROLLING_WINDOW_SIZE, + failureThreshold: deps.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD, + quarantineCooldownMs: deps.quarantineCooldownMs ?? DEFAULT_QUARANTINE_COOLDOWN_MS, + maxQuarantineCooldownMs: deps.maxQuarantineCooldownMs ?? DEFAULT_MAX_QUARANTINE_COOLDOWN_MS, + snapshotIntervalMs: deps.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS, + minActiveRelayPool: Math.max(1, deps.minActiveRelayPool), + }; +} + +function mergeRelayList(primaryRelays, fallbackRelays) { + return [...new Set([...primaryRelays, ...fallbackRelays])]; +} + +export async function queryLocks(relays, cadence, dateStr, namespace, deps = {}) { + const { + poolFactory = () => new SimplePool(), + getQueryTimeoutMsFn = getQueryTimeoutMs, + getRelayFallbacksFn = getRelayFallbacks, + getMinActiveRelayPoolFn = getMinActiveRelayPool, + errorLogger = console.error, + healthLogger = console.error, + healthManager = defaultHealthManager, + } = deps; + + const pool = poolFactory(); + const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`; + const queryTimeoutMs = await getQueryTimeoutMsFn(); + const fallbackRelays = (await getRelayFallbacksFn()).filter((relay) => !relays.includes(relay)); + const healthConfig = buildRelayHealthConfig({ + ...deps, + minActiveRelayPool: await getMinActiveRelayPoolFn(), + }); + + const runQuery = async (relaySet, phase) => { + const { prioritized } = healthManager.prioritizeRelays(relaySet, healthConfig); + if (prioritized.length > 0) { + errorLogger(`[${phase}] Querying ${prioritized.length} relays (${relayListLabel(prioritized)})...`); + } + + const startedAtMs = Date.now(); + try { + const events = await withTimeout( + pool.querySync(prioritized, { + kinds: [KIND_APP_DATA], + '#t': [tagFilter], + }), + queryTimeoutMs, + `[${phase}] Relay query timed out after ${queryTimeoutMs}ms (relays: ${relayListLabel(prioritized)})`, + ); + const elapsedMs = Date.now() - startedAtMs; + for (const relay of prioritized) { + healthManager.recordOutcome(relay, true, null, elapsedMs, healthConfig); + } + return filterActiveLocks(events.map(parseLockEvent)); + } catch (err) { + const elapsedMs = Date.now() - startedAtMs; + const message = err instanceof Error ? err.message : String(err); + for (const relay of prioritized) { + healthManager.recordOutcome(relay, false, message, elapsedMs, healthConfig); + } + throw new Error( + `[${phase}] Relay query failed (timeout=${queryTimeoutMs}ms, relays=${relayListLabel(prioritized)}): ${message}`, + { cause: err }, + ); + } + }; + + const allRelays = mergeRelayList(relays, fallbackRelays); + + try { + healthManager.maybeLogSnapshot(allRelays, healthConfig, healthLogger, 'query:periodic'); + try { + return await runQuery(relays, 'query:primary'); + } catch (primaryErr) { + if (!fallbackRelays.length) { + healthManager.maybeLogSnapshot(allRelays, healthConfig, healthLogger, 'query:failure', true); + throw primaryErr; + } + errorLogger(`WARN: ${primaryErr.message}`); + errorLogger(`WARN: retrying query with fallback relays (${relayListLabel(fallbackRelays)})`); + return await runQuery(fallbackRelays, 'query:fallback'); + } + } finally { + pool.close(allRelays); + } +} + +async function publishToRelays(pool, relays, event, publishTimeoutMs, phase) { + const publishPromises = pool.publish(relays, event); + const settled = await Promise.allSettled( + publishPromises.map((publishPromise, index) => withTimeout( + publishPromise, + publishTimeoutMs, + `[${phase}] Publish timed out after ${publishTimeoutMs}ms (relay=${relays[index]})`, + )), + ); + + return settled.map((result, index) => { + if (result.status === 'fulfilled') { + return { + relay: relays[index], + success: true, + phase, + latencyMs: null, + }; + } + const reason = result.reason; + const message = reason instanceof Error ? reason.message : String(reason ?? 'unknown'); + return { + relay: relays[index], + success: false, + phase, + message, + latencyMs: null, + }; + }); +} + +export class LockPublisher { + constructor(relays, event, deps = {}) { + this.relays = relays; + this.event = event; + this.deps = deps; + this.pool = null; + this.healthConfig = null; + this.allRelays = []; + this.fallbackRelays = []; + this.publishTimeoutMs = 0; + this.minSuccesses = 0; + this.minActiveRelayPool = 0; + this.maxAttempts = 0; + this.retryBaseDelayMs = 0; + this.retryCapDelayMs = 0; + this.sleepFn = null; + this.randomFn = null; + this.telemetryLogger = null; + this.healthLogger = null; + this.healthManager = null; + this.correlationId = null; + this.attemptId = null; + } + + async publish() { + const { + poolFactory = () => new SimplePool(), + retryAttempts = DEFAULT_RETRY_ATTEMPTS, + retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS, + retryCapDelayMs = DEFAULT_RETRY_CAP_DELAY_MS, + sleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + randomFn = Math.random, + telemetryLogger = console.error, + healthLogger = console.error, + diagnostics = {}, + healthManager = defaultHealthManager, + } = this.deps; + + this.healthManager = healthManager; + this.pool = poolFactory(); + this.publishTimeoutMs = this.deps.resolvedConfig?.publishTimeoutMs; + this.minSuccesses = this.deps.resolvedConfig?.minSuccesses; + this.fallbackRelays = (this.deps.resolvedConfig?.fallbackRelays || []).filter((relay) => !this.relays.includes(relay)); + this.maxAttempts = Math.max(1, Math.floor(retryAttempts)); + this.healthConfig = buildRelayHealthConfig({ + ...this.deps, + minActiveRelayPool: this.deps.resolvedConfig?.minActiveRelayPool, + }); + + this.retryBaseDelayMs = retryBaseDelayMs; + this.retryCapDelayMs = retryCapDelayMs; + this.sleepFn = sleepFn; + this.randomFn = randomFn; + this.telemetryLogger = telemetryLogger; + this.healthLogger = healthLogger; + this.healthManager = healthManager; + this.correlationId = diagnostics.correlationId || randomUUID(); + this.attemptId = diagnostics.attemptId || randomUUID(); + + this.allRelays = mergeRelayList(this.relays, this.fallbackRelays); + + try { + this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:periodic'); + let lastAttemptState = null; + const retryTimeline = []; + const overallStartedAt = Date.now(); + let terminalFailureCategory = 'relay_publish_non_retryable'; + + for (let attemptNumber = 1; attemptNumber <= this.maxAttempts; attemptNumber += 1) { + const startedAtMs = Date.now(); + lastAttemptState = await this.executePublishCycle(); + const elapsedMs = Date.now() - startedAtMs; + retryTimeline.push({ + publishAttempt: attemptNumber, + successCount: lastAttemptState.successCount, + relayAttemptedCount: lastAttemptState.attempted.size, + elapsedMs, + }); + + if (lastAttemptState.successCount >= this.minSuccesses) { + this.logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt); + return this.event; + } + + const { hasTransientFailure, hasPermanentFailure } = this.analyzeFailures(lastAttemptState.failures); + const canRetry = attemptNumber < this.maxAttempts && hasTransientFailure && !hasPermanentFailure; + + terminalFailureCategory = this.determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber); + + if (!canRetry) { + break; + } + + const nextDelayMs = calculateBackoffDelayMs(attemptNumber, this.retryBaseDelayMs, this.retryCapDelayMs, this.randomFn); + this.logRetry(attemptNumber, lastAttemptState.failures, elapsedMs, nextDelayMs); + await this.sleepFn(nextDelayMs); + } + + this.handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt); + } finally { + this.pool.close(this.allRelays); + } + } + + async executePublishCycle() { + const attempted = new Set(); + const publishResults = []; + + await this.attemptPhase(this.relays, 'publish:primary', attempted, publishResults); + + let successCount = publishResults.filter((result) => result.success).length; + if (successCount < this.minSuccesses && this.fallbackRelays.length > 0) { + await this.attemptPhase(this.fallbackRelays, 'publish:fallback', attempted, publishResults); + successCount = publishResults.filter((result) => result.success).length; + } + + const failures = publishResults + .filter((result) => !result.success) + .map((result) => ({ + ...result, + category: classifyPublishError(result.message), + })); + + return { attempted, publishResults, successCount, failures }; + } + + async attemptPhase(phaseRelays, phaseName, attempted, publishResults) { + if (!phaseRelays.length) return; + const { prioritized } = this.healthManager.prioritizeRelays(phaseRelays, this.healthConfig); + if (!prioritized.length) return; + + console.error(`[${phaseName}] Publishing to ${prioritized.length} relays (${prioritized.join(', ')})...`); + + const startedAtMs = Date.now(); + for (const relay of prioritized) { + attempted.add(relay); + } + const phaseResults = await publishToRelays(this.pool, prioritized, this.event, this.publishTimeoutMs, phaseName); + const elapsedMs = Date.now() - startedAtMs; + for (const result of phaseResults) { + result.latencyMs = elapsedMs; + this.healthManager.recordOutcome(result.relay, result.success, result.message, elapsedMs, this.healthConfig); + } + publishResults.push(...phaseResults); + } + + analyzeFailures(failures) { + const hasTransientFailure = failures.some((failure) => isTransientPublishCategory(failure.category)); + const hasPermanentFailure = failures.some((failure) => !isTransientPublishCategory(failure.category)); + return { hasTransientFailure, hasPermanentFailure }; + } + + determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber) { + if (hasTransientFailure && !hasPermanentFailure && attemptNumber >= this.maxAttempts) { + return 'relay_publish_quorum_failure'; + } else if (hasPermanentFailure) { + return 'relay_publish_non_retryable'; + } else { + return 'relay_publish_quorum_failure'; + } + } + + logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt) { + this.telemetryLogger(JSON.stringify({ + event: 'lock_publish_quorum_met', + correlationId: this.correlationId, + attemptId: this.attemptId, + publishAttempt: attemptNumber, + successCount: lastAttemptState.successCount, + relayAttemptedCount: lastAttemptState.attempted.size, + requiredSuccesses: this.minSuccesses, + timeoutMs: this.publishTimeoutMs, + retryTimeline, + totalElapsedMs: Date.now() - overallStartedAt, + })); + console.error( + ` Published to ${lastAttemptState.successCount}/${lastAttemptState.attempted.size} relays ` + + `(required=${this.minSuccesses}, timeout=${this.publishTimeoutMs}ms)`, + ); + } + + logRetry(attemptNumber, failures, elapsedMs, nextDelayMs) { + for (const failure of failures) { + if (!isTransientPublishCategory(failure.category)) continue; + this.telemetryLogger(JSON.stringify({ + event: 'lock_publish_retry', + correlationId: this.correlationId, + attemptId: this.attemptId, + publishAttempt: attemptNumber, + relayUrl: failure.relay, + errorCategory: failure.category, + elapsedMs, + nextDelayMs, + })); + } + } + + handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt) { + const reasonDistribution = {}; + const failureLines = lastAttemptState.failures + .map((result) => { + reasonDistribution[result.category] = (reasonDistribution[result.category] || 0) + 1; + this.telemetryLogger(JSON.stringify({ + event: 'lock_publish_failure', + correlationId: this.correlationId, + attemptId: this.attemptId, + relayUrl: result.relay, + phase: result.phase, + reason: result.category, + message: result.message, + })); + return `${result.relay} (${result.phase}, reason=${result.category}): ${result.message}`; + }); + + this.telemetryLogger(JSON.stringify({ + event: 'lock_publish_quorum_failed', + correlationId: this.correlationId, + attemptId: this.attemptId, + errorCategory: terminalFailureCategory, + successCount: lastAttemptState.successCount, + relayAttemptedCount: lastAttemptState.attempted.size, + requiredSuccesses: this.minSuccesses, + timeoutMs: this.publishTimeoutMs, + attempts: this.maxAttempts, + reasonDistribution, + retryTimeline, + totalElapsedMs: Date.now() - overallStartedAt, + })); + + this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:failure', true); + throw new Error( + `Failed relay publish quorum in publish phase: ${lastAttemptState.successCount}/${lastAttemptState.attempted.size} successful ` + + `(required=${this.minSuccesses}, timeout=${this.publishTimeoutMs}ms, attempts=${this.maxAttempts}, attempt_id=${this.attemptId}, correlation_id=${this.correlationId}, error_category=${terminalFailureCategory}, total_retry_timeline_ms=${Date.now() - overallStartedAt})\n` + + ` retry timeline: ${retryTimeline.map((item) => `#${item.publishAttempt}:${item.elapsedMs}ms`).join(', ')}\n` + + ` ${failureLines.join('\n ')}`, + ); + } +} + +export async function publishLock(relays, event, deps = {}) { + const { + getPublishTimeoutMsFn = getPublishTimeoutMs, + getMinSuccessfulRelayPublishesFn = getMinSuccessfulRelayPublishes, + getRelayFallbacksFn = getRelayFallbacks, + getMinActiveRelayPoolFn = getMinActiveRelayPool, + } = deps; + + const [publishTimeoutMs, minSuccesses, fallbackRelays, minActiveRelayPool] = await Promise.all([ + getPublishTimeoutMsFn(), + getMinSuccessfulRelayPublishesFn(), + getRelayFallbacksFn(), + getMinActiveRelayPoolFn(), + ]); + + return new LockPublisher(relays, event, { + ...deps, + resolvedConfig: { + publishTimeoutMs, + minSuccesses, + fallbackRelays, + minActiveRelayPool, + }, + }).publish(); +} + +export function _resetRelayHealthState() { + defaultHealthManager.reset(); +} + +export const _secureRandom = secureRandom; diff --git a/torch/src/lock-utils.mjs b/torch/src/lock-utils.mjs new file mode 100644 index 00000000..29863e48 --- /dev/null +++ b/torch/src/lock-utils.mjs @@ -0,0 +1,49 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { todayDateStr, getIsoWeekStr } from './utils.mjs'; + +/** + * Scans the local log directory to identify agents that have already completed their task + * for the current period (daily or weekly). + * + * @param {string} cadence - 'daily' or 'weekly' + * @param {string} logDir - Path to the log directory (e.g., 'task-logs') + * @param {Object} deps - Dependency injection for testing + * @returns {Promise>} - Set of agent names that have completed their task + */ +export async function getCompletedAgents(cadence, logDir, deps) { + const { readdir = fs.readdir, getDateStr = todayDateStr, getIsoWeek = getIsoWeekStr } = deps; + const completed = new Set(); + const targetDir = path.join(logDir, cadence); + + try { + const files = await readdir(targetDir); + const today = getDateStr(); + const currentWeek = getIsoWeek(); + + for (const file of files) { + // Format: YYYY-MM-DDTHH-mm-ssZ____.md + const match = file.match(/^(\d{4}-\d{2}-\d{2})T.*__([a-zA-Z0-9-_]+)__(.*)\.md$/); + if (!match) continue; + + const [, datePart, agent, status] = match; + + if (status === 'completed') { + if (cadence === 'daily' && datePart === today) { + completed.add(agent); + } else if (cadence === 'weekly') { + const fileWeek = getIsoWeek(datePart); // datePart is YYYY-MM-DD + if (fileWeek === currentWeek) { + completed.add(agent); + } + } + } + } + } catch (err) { + if (err.code !== 'ENOENT') { + console.error(`Warning: Failed to read log dir ${targetDir}: ${err.message}`); + } + } + + return completed; +} diff --git a/torch/src/ops.mjs b/torch/src/ops.mjs new file mode 100644 index 00000000..34063417 --- /dev/null +++ b/torch/src/ops.mjs @@ -0,0 +1,449 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import crypto from 'node:crypto'; +import readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import { DEFAULT_RELAYS } from './constants.mjs'; + +const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// Source paths (in the package) +const SRC_PROMPTS_DIR = path.join(PKG_ROOT, 'src', 'prompts'); + +// Files to treat as "Static" (always overwrite on update, with transformations) +const STATIC_FILES = [ + 'META_PROMPTS.md', + 'scheduler-flow.md', + 'daily-scheduler.md', + 'weekly-scheduler.md', +]; + +// Directories containing "Evolving" files (copy if missing, preserve if present) +const EVOLVING_DIRS = ['daily', 'weekly']; + +// New constants for full application install +const APP_DIRS = ['src', 'bin', 'dashboard', 'landing', 'assets', 'scripts']; +const APP_FILES = ['package.json', 'build.mjs', 'README.md', 'torch-config.example.json', 'TORCH.md']; + +function getPaths(root, installDirName) { + const torchDir = path.resolve(root, installDirName); + return { + root, + torchDir, + promptsDir: path.join(torchDir, 'prompts'), + roster: path.join(torchDir, 'roster.json'), + }; +} + +function ensureDir(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function copyDir(src, dest) { + if (fs.existsSync(src)) { + fs.cpSync(src, dest, { recursive: true }); + } +} + +function transformContent(content, installDirName) { + // Replace source paths with user paths + // We assume the user is running from root, so 'src/prompts/daily/' becomes 'torch/prompts/daily/' + // If installDirName is different, we should use that. + // Note: These replacements are somewhat fragile regexes. + // We'll replace 'src/' with 'installDirName/' essentially? + // The original code replaced 'src/prompts/' with 'torch/prompts/'. + + // If installDirName is '.', we want 'prompts/daily/'. + // If installDirName is 'torch', we want 'torch/prompts/daily/'. + + const prefix = installDirName === '.' ? '' : `${installDirName}/`; + + return content + .replace(/src\/prompts\/daily\//g, `${prefix}prompts/daily/`) + .replace(/src\/prompts\/weekly\//g, `${prefix}prompts/weekly/`) + .replace(/src\/prompts\/roster\.json/g, `${prefix}roster.json`) + .replace(/src\/prompts\/scheduler-flow\.md/g, `${prefix}scheduler-flow.md`) + .replace(/TORCH\.md/g, `${prefix}TORCH.md`); +} + +function copyFile(src, dest, transform = false, overwrite = true, installDirName = 'torch') { + if (fs.existsSync(dest) && !overwrite) { + return false; // Skipped + } + if (!fs.existsSync(src)) return false; + + const content = fs.readFileSync(src, 'utf8'); + const finalContent = transform ? transformContent(content, installDirName) : content; + fs.writeFileSync(dest, finalContent, 'utf8'); + return true; // Copied/Overwritten +} + +function syncAppDirectories(torchDir, verb = 'Copied') { + console.log(`${verb === 'Copied' ? 'Copying' : 'Updating'} application directories...`); + for (const dir of APP_DIRS) { + const src = path.join(PKG_ROOT, dir); + const dest = path.join(torchDir, dir); + if (fs.existsSync(src)) { + copyDir(src, dest); + console.log(` ${verb} ${dir}/`); + } + } +} + +function syncAppFiles(torchDir, installDir, verb = 'Copied') { + console.log(`${verb === 'Copied' ? 'Copying' : 'Updating'} application files...`); + for (const file of APP_FILES) { + const src = path.join(PKG_ROOT, file); + const dest = path.join(torchDir, file); + if (fs.existsSync(src)) { + if (installDir === '.' && file === 'package.json') { + if (verb === 'Copied' && fs.existsSync(dest)) { + console.warn(' Skipping package.json to avoid overwriting host package.json (installing to root).'); + continue; + } + if (verb === 'Updated') { + console.log(' Skipping package.json update (installed in root).'); + continue; + } + } + + fs.copyFileSync(src, dest); + console.log(` ${verb} ${file}`); + } + } +} + +async function interactiveInit(cwd) { + const rl = readline.createInterface({ input, output }); + const currentDirName = path.basename(cwd); + + console.log('\n🔥 TORCH Initialization 🔥\n'); + + try { + // 1. Install Directory + let defaultDir = 'torch'; + if (currentDirName === 'torch') { + defaultDir = '.'; + } + + const dirAnswer = await rl.question(`Install directory (default: ${defaultDir}): `); + const installDir = dirAnswer.trim() || defaultDir; + + // 2. Namespace + const randomSuffix = crypto.randomBytes(4).toString('hex'); + const defaultNamespace = `torch-${randomSuffix}`; + const namespaceAnswer = await rl.question(`Nostr Namespace (default: ${defaultNamespace}): `); + const namespace = namespaceAnswer.trim() || defaultNamespace; + + // 3. Relays + console.log(`\nDefault Relays:\n ${DEFAULT_RELAYS.join('\n ')}`); + const relaysAnswer = await rl.question('Enter relays (comma-separated) or press Enter to use defaults: '); + let relays = DEFAULT_RELAYS; + if (relaysAnswer.trim()) { + relays = relaysAnswer.split(',').map(r => r.trim()).filter(Boolean); + } + + return { installDir, namespace, relays }; + } finally { + rl.close(); + } +} + +async function resolveConfiguration(cwd, mockAnswers) { + if (mockAnswers) { + return mockAnswers; + } + return await interactiveInit(cwd); +} + +function ensureInstallDirectory(paths, force, installDir) { + if (fs.existsSync(paths.torchDir) && !force) { + const entries = fs.readdirSync(paths.torchDir); + if (entries.length > 0 && installDir !== '.') { + throw new Error(`Directory ${paths.torchDir} already exists and is not empty. Use --force to overwrite.`); + } + } + ensureDir(paths.torchDir); + ensureDir(paths.promptsDir); +} + +function installAppAssets(torchDir, installDir) { + // 1. Copy App Directories + syncAppDirectories(torchDir, 'Copied'); + + // 2. Copy App Files + syncAppFiles(torchDir, installDir, 'Copied'); +} + +function installTorchAssets(paths, installDir) { + // 3. Copy Roster + const srcRoster = path.join(SRC_PROMPTS_DIR, 'roster.json'); + if (fs.existsSync(srcRoster)) { + copyFile(srcRoster, paths.roster, false, true, installDir); + console.log(`Created ${path.relative(paths.root, paths.roster)}`); + } + + // 4. Copy Static Files + for (const file of STATIC_FILES) { + const src = path.join(SRC_PROMPTS_DIR, file); + const dest = path.join(paths.torchDir, file); + if (fs.existsSync(src)) { + copyFile(src, dest, true, true, installDir); + console.log(`Created ${path.relative(paths.root, dest)}`); + } + } + + // 5. Copy Prompts + for (const dir of EVOLVING_DIRS) { + const srcDir = path.join(SRC_PROMPTS_DIR, dir); + const destDir = path.join(paths.promptsDir, dir); + ensureDir(destDir); + + if (fs.existsSync(srcDir)) { + const files = fs.readdirSync(srcDir); + for (const file of files) { + const srcFile = path.join(srcDir, file); + const destFile = path.join(destDir, file); + copyFile(srcFile, destFile, false, true, installDir); + } + console.log(`Created ${files.length} files in ${path.relative(paths.root, destDir)}/`); + } + } +} + +function configureTorch(cwd, paths, installDir, namespace, relays) { + // 6. Create/Update torch-config.json + const configPath = path.join(paths.root, 'torch-config.json'); + + let configData = {}; + + // Try to load existing or example + if (fs.existsSync(configPath)) { + try { + configData = JSON.parse(fs.readFileSync(configPath, 'utf8')); + console.log(`Updating existing ${path.relative(cwd, configPath)}...`); + } catch (e) { + console.warn(`Could not parse existing config: ${e.message}`); + } + } else { + const exampleConfigPath = path.join(PKG_ROOT, 'torch-config.example.json'); + if (fs.existsSync(exampleConfigPath)) { + configData = JSON.parse(fs.readFileSync(exampleConfigPath, 'utf8')); + } + } + + // Apply user choices + if (!configData.nostrLock) configData.nostrLock = {}; + configData.nostrLock.namespace = namespace; + configData.nostrLock.relays = relays; + + // Configure memory policy with correct paths + if (!configData.scheduler) configData.scheduler = {}; + // Always ensure memory policy exists and points to the correct scripts + // We use the installDir to construct the path + const scriptPrefix = installDir === '.' ? '' : `${installDir}/`; + + if (!configData.scheduler.memoryPolicyByCadence) { + configData.scheduler.memoryPolicyByCadence = {}; + } + + // Populate daily/weekly if missing + ['daily', 'weekly'].forEach(cadence => { + if (!configData.scheduler.memoryPolicyByCadence[cadence]) { + configData.scheduler.memoryPolicyByCadence[cadence] = { + mode: "required", + retrieveCommand: `node ${scriptPrefix}scripts/memory/retrieve.mjs`, + storeCommand: `node ${scriptPrefix}scripts/memory/store.mjs`, + retrieveSuccessMarkers: ["MEMORY_RETRIEVED"], + storeSuccessMarkers: ["MEMORY_STORED"], + retrieveArtifacts: [`.scheduler-memory/latest/${cadence}/retrieve.ok`], + storeArtifacts: [`.scheduler-memory/latest/${cadence}/store.ok`] + }; + } + }); + + fs.writeFileSync(configPath, JSON.stringify(configData, null, 2), 'utf8'); + console.log(`Saved configuration to ${path.relative(cwd, configPath)}`); +} + +function injectHostScriptsIfNeeded(paths, installDir) { + // 7. Inject Scripts into Host Package.json + // If we are NOT installing to '.', the host package.json is in paths.root + if (installDir !== '.') { + injectScriptsIntoHost(paths.root, installDir); + } +} + +export async function cmdInit(force = false, cwd = process.cwd(), mockAnswers = null) { + const config = await resolveConfiguration(cwd, mockAnswers); + const { installDir, namespace, relays } = config; + + const paths = getPaths(cwd, installDir); + console.log(`\nInitializing torch in ${paths.torchDir}...`); + + ensureInstallDirectory(paths, force, installDir); + + installAppAssets(paths.torchDir, installDir); + installTorchAssets(paths, installDir); + configureTorch(cwd, paths, installDir, namespace, relays); + injectHostScriptsIfNeeded(paths, installDir); + + console.log('\nInitialization complete.'); + console.log('You can now customize the files in ' + path.relative(cwd, paths.torchDir) + '/'); +} + +function injectScriptsIntoHost(hostRoot, installDirName) { + const hostPkgPath = path.join(hostRoot, 'package.json'); + if (!fs.existsSync(hostPkgPath)) { + console.warn('No package.json found in host root. Skipping script injection.'); + return; + } + + try { + const pkg = JSON.parse(fs.readFileSync(hostPkgPath, 'utf8')); + if (!pkg.scripts) pkg.scripts = {}; + + const scriptsToAdd = { + 'torch:dashboard': `npm run --prefix ${installDirName} dashboard:serve`, + 'torch:check': `npm run --prefix ${installDirName} lock:check:daily`, // Default to daily check + 'torch:lock': `npm run --prefix ${installDirName} lock:lock`, + 'torch:health': `npm run --prefix ${installDirName} lock:health`, + 'torch:memory:list': `node ${installDirName === '.' ? '' : installDirName + '/'}bin/torch-lock.mjs list-memories`, + 'torch:memory:inspect': `node ${installDirName === '.' ? '' : installDirName + '/'}bin/torch-lock.mjs inspect-memory`, + }; + + let modified = false; + for (const [key, cmd] of Object.entries(scriptsToAdd)) { + if (!pkg.scripts[key]) { + pkg.scripts[key] = cmd; + console.log(` Added script: "${key}"`); + modified = true; + } else { + console.log(` Script "${key}" already exists, skipping.`); + } + } + + if (modified) { + fs.writeFileSync(hostPkgPath, JSON.stringify(pkg, null, 2), 'utf8'); + console.log('Updated package.json with convenience scripts.'); + } + + } catch (e) { + console.error(`Failed to inject scripts: ${e.message}`); + } +} + +export function cmdUpdate(force = false, cwd = process.cwd()) { + // Update logic needs to know WHERE torch is installed. + // We can look for torch directory? Or assume 'torch'? + // For now, let's look for 'torch' directory first, then fallback to '.' if we detect torch files? + // Or just default to 'torch' and let user move files if they changed it? + // Realistically, 'update' should probably take an argument for the dir, or we just default to 'torch'. + + // If the user installed to 'custom-dir', cmdUpdate will fail unless we auto-detect. + // Auto-detection strategy: check if 'torch' exists. If not, check if 'package.json' has 'torch-lock' name? + + let installDirName = 'torch'; + if (!fs.existsSync(path.join(cwd, 'torch')) && fs.existsSync(path.join(cwd, 'package.json'))) { + // Check if current dir is the torch dir + try { + const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')); + if (pkg.name === 'torch-lock') { + installDirName = '.'; + } + } catch { + // Ignore error if package.json is missing or invalid + } + } + + const paths = getPaths(cwd, installDirName); + console.log(`Updating torch configuration in ${paths.torchDir}...`); + + if (!fs.existsSync(paths.torchDir)) { + throw new Error(`${paths.torchDir} not found. Run 'torch-lock init' first.`); + } + + // 1. Backup + const backupName = `backup_${new Date().toISOString().replace(/[:.]/g, '-')}`; + const backupRoot = path.join(paths.torchDir, '_backups'); + const thisBackupDir = path.join(backupRoot, backupName); + + ensureDir(thisBackupDir); + console.log(`Creating backup at ${path.relative(paths.root, thisBackupDir)}...`); + + // We backup EVERYTHING in torchDir except _backups and node_modules + const entries = fs.readdirSync(paths.torchDir); + for (const entry of entries) { + if (entry === '_backups' || entry === 'node_modules' || entry === '.git') continue; + + const srcPath = path.join(paths.torchDir, entry); + const destPath = path.join(thisBackupDir, entry); + fs.cpSync(srcPath, destPath, { recursive: true }); + } + + // 2. Update App Directories (Overwrite) + syncAppDirectories(paths.torchDir, 'Updated'); + + // 3. Update App Files (Overwrite) + syncAppFiles(paths.torchDir, installDirName, 'Updated'); + + // 4. Update Static Files (Always Overwrite) + console.log('Updating static files...'); + for (const file of STATIC_FILES) { + const src = path.join(SRC_PROMPTS_DIR, file); + const dest = path.join(paths.torchDir, file); + if (fs.existsSync(src)) { + copyFile(src, dest, true, true, installDirName); + console.log(` Updated ${file}`); + } + } + + // 5. Update Roster (Preserve unless force) + const srcRoster = path.join(SRC_PROMPTS_DIR, 'roster.json'); + if (fs.existsSync(srcRoster)) { + if (force) { + copyFile(srcRoster, paths.roster, false, true, installDirName); + console.log(' Overwrote roster.json (forced)'); + } else { + console.log(' Skipped roster.json (preserved)'); + } + } + + // 6. Update Prompts (Copy missing, preserve existing unless force) + console.log('Updating prompts...'); + for (const dir of EVOLVING_DIRS) { + const srcDir = path.join(SRC_PROMPTS_DIR, dir); + const destDir = path.join(paths.promptsDir, dir); + ensureDir(destDir); + + if (fs.existsSync(srcDir)) { + const files = fs.readdirSync(srcDir); + let added = 0; + let updated = 0; + let skipped = 0; + + for (const file of files) { + const srcFile = path.join(srcDir, file); + const destFile = path.join(destDir, file); + + if (force) { + copyFile(srcFile, destFile, false, true, installDirName); + updated++; + } else { + if (!fs.existsSync(destFile)) { + copyFile(srcFile, destFile, false, true, installDirName); + added++; + } else { + skipped++; + } + } + } + console.log(` ${dir}/: ${added} added, ${updated} updated, ${skipped} preserved`); + } + } + + console.log('\nUpdate complete.'); +} diff --git a/torch/src/relay-health.mjs b/torch/src/relay-health.mjs new file mode 100644 index 00000000..0c0a824d --- /dev/null +++ b/torch/src/relay-health.mjs @@ -0,0 +1,290 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import WebSocket from 'ws'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { getNamespace, getRelays } from './torch-config.mjs'; + +const PROBE_KIND = 27235; + +function withTimeout(promise, timeoutMs, message) { + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId)); +} + +function nowIso() { + return new Date().toISOString(); +} + +export function summarizeHistory(historyEntries, { nowMs = Date.now(), windowMinutes = 60 } = {}) { + const windowStartMs = nowMs - (windowMinutes * 60_000); + const recent = historyEntries.filter((entry) => Date.parse(entry.timestamp || '') >= windowStartMs); + const relayProbeCount = recent.reduce((sum, entry) => sum + (entry.summary?.totalRelays || 0), 0); + const successCount = recent.reduce((sum, entry) => sum + (entry.summary?.healthyRelays || 0), 0); + const successRate = relayProbeCount > 0 ? successCount / relayProbeCount : 1; + + let lastHealthyAtMs = null; + for (let i = historyEntries.length - 1; i >= 0; i -= 1) { + if ((historyEntries[i].summary?.healthyRelays || 0) > 0) { + lastHealthyAtMs = Date.parse(historyEntries[i].timestamp || ''); + break; + } + } + + const allDownDurationMinutes = lastHealthyAtMs === null + ? null + : Math.max(0, Math.floor((nowMs - lastHealthyAtMs) / 60_000)); + + return { + windowMinutes, + sampleCount: recent.length, + successRate, + allDownDurationMinutes, + }; +} + +export function evaluateAlertThresholds(currentResult, historyEntries, thresholds, { nowMs = Date.now() } = {}) { + const stats = summarizeHistory(historyEntries, { nowMs, windowMinutes: thresholds.windowMinutes }); + const alerts = []; + + if (currentResult.summary.allRelaysUnhealthy && stats.allDownDurationMinutes !== null && stats.allDownDurationMinutes >= thresholds.allRelaysDownMinutes) { + alerts.push({ + type: 'all_relays_down_duration', + thresholdMinutes: thresholds.allRelaysDownMinutes, + actualMinutes: stats.allDownDurationMinutes, + severity: 'critical', + }); + } + + if (stats.sampleCount > 0 && stats.successRate < thresholds.minSuccessRate) { + alerts.push({ + type: 'relay_success_rate_below_threshold', + threshold: thresholds.minSuccessRate, + actual: Number(stats.successRate.toFixed(4)), + windowMinutes: thresholds.windowMinutes, + severity: 'warning', + }); + } + + return { alerts, stats }; +} + +async function probeWebSocketReachability(relayUrl, timeoutMs) { + const startedAt = Date.now(); + return withTimeout(new Promise((resolve, reject) => { + const ws = new WebSocket(relayUrl); + let settled = false; + + const finish = (fn) => (value) => { + if (settled) return; + settled = true; + try { + ws.close(); + } catch { + // ignore + } + fn(value); + }; + + ws.once('open', finish(() => resolve({ ok: true, latencyMs: Date.now() - startedAt }))); + ws.once('error', finish((err) => reject(err))); + }), timeoutMs, `WebSocket reachability timeout after ${timeoutMs}ms`); +} + +async function probePublishRead(relayUrl, namespace, timeoutMs) { + const ws = new WebSocket(relayUrl); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const createdAt = Math.floor(Date.now() / 1000); + const nonce = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; + const probeTag = `${namespace}-health-probe-${nonce}`; + const event = finalizeEvent({ + kind: PROBE_KIND, + created_at: createdAt, + tags: [ + ['t', `${namespace}-relay-health`], + ['d', probeTag], + ], + content: JSON.stringify({ type: 'relay_health_probe', nonce, namespace, pubkey: pk }), + }, sk); + + const subId = `probe-${nonce}`; + + return withTimeout(new Promise((resolve, reject) => { + let okAck = false; + let readSeen = false; + let settled = false; + + const settle = (fn) => (value) => { + if (settled) return; + settled = true; + try { + ws.close(); + } catch { + // ignore + } + fn(value); + }; + + ws.once('open', () => { + ws.send(JSON.stringify(['EVENT', event])); + ws.send(JSON.stringify(['REQ', subId, { ids: [event.id], limit: 1 }])); + }); + + ws.on('message', (raw) => { + let message; + try { + message = JSON.parse(raw.toString()); + } catch { + // Ignore malformed messages. The relay might be sending non-JSON data + // or we might have a protocol mismatch. We only care about valid JSON. + return; + } + if (!Array.isArray(message) || message.length < 2) return; + const type = message[0]; + + if (type === 'OK' && message[1] === event.id) { + okAck = Boolean(message[2]); + if (!okAck) { + settle(reject)(new Error(`Relay rejected health probe event: ${String(message[3] || 'unknown reason')}`)); + return; + } + } + + if (type === 'EVENT' && message[1] === subId && message[2]?.id === event.id) { + readSeen = true; + } + + if (okAck && readSeen) { + ws.send(JSON.stringify(['CLOSE', subId])); + settle(resolve)({ ok: true, eventId: event.id }); + } + }); + + ws.once('error', settle(reject)); + }), timeoutMs, `Publish/read probe timeout after ${timeoutMs}ms`); +} + +async function readHistory(historyPath) { + try { + const raw = await fs.readFile(historyPath, 'utf8'); + return raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter((entry) => entry && typeof entry === 'object'); + } catch (error) { + if (error && error.code === 'ENOENT') return []; + throw error; + } +} + +async function appendHistory(historyPath, entry) { + await fs.mkdir(path.dirname(historyPath), { recursive: true }); + await fs.appendFile(historyPath, `${JSON.stringify(entry)}\n`, 'utf8'); +} + +export async function runRelayHealthCheck(options = {}) { + const { + cadence = 'daily', + timeoutMs = 6000, + allRelaysDownMinutes = 10, + minSuccessRate = 0.7, + windowMinutes = 60, + } = options; + + let { + relays, + namespace, + historyPath, + } = options; + + if (!relays) relays = await getRelays(); + if (!namespace) namespace = await getNamespace(); + if (!historyPath) historyPath = path.resolve(process.cwd(), 'task-logs', 'relay-health', `${cadence}.jsonl`); + + const relayResults = []; + for (const relay of relays) { + const relayResult = { + relay, + websocketReachable: false, + publishReadOk: false, + healthy: false, + checks: {}, + }; + + try { + const wsProbe = await probeWebSocketReachability(relay, timeoutMs); + relayResult.websocketReachable = true; + relayResult.checks.websocket = wsProbe; + } catch (error) { + relayResult.checks.websocket = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + + if (relayResult.websocketReachable) { + try { + const prProbe = await probePublishRead(relay, namespace, timeoutMs); + relayResult.publishReadOk = true; + relayResult.checks.publishRead = prProbe; + } catch (error) { + relayResult.checks.publishRead = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + relayResult.healthy = relayResult.websocketReachable && relayResult.publishReadOk; + relayResults.push(relayResult); + } + + const summary = { + totalRelays: relayResults.length, + healthyRelays: relayResults.filter((relay) => relay.healthy).length, + }; + summary.unhealthyRelays = summary.totalRelays - summary.healthyRelays; + summary.allRelaysUnhealthy = summary.healthyRelays === 0; + + const result = { + ok: !summary.allRelaysUnhealthy, + cadence, + namespace, + timestamp: nowIso(), + relays, + relayResults, + summary, + incidentSignal: null, + alerts: [], + alertStats: null, + historyPath, + }; + + const historyEntries = await readHistory(historyPath); + const prospectiveHistory = [...historyEntries, result]; + const thresholdEval = evaluateAlertThresholds(result, prospectiveHistory, { + allRelaysDownMinutes, + minSuccessRate, + windowMinutes, + }); + result.alerts = thresholdEval.alerts; + result.alertStats = thresholdEval.stats; + + if (summary.allRelaysUnhealthy) { + const incidentId = `relay-health-${cadence}-${Date.now()}`; + result.incidentSignal = { + id: incidentId, + reason: 'All relays unhealthy before lock acquisition', + severity: 'critical', + suggestedAction: 'Defer scheduler run and investigate relay availability before retrying lock acquisition.', + }; + } + + await appendHistory(historyPath, result); + return result; +} diff --git a/torch/src/roster.mjs b/torch/src/roster.mjs new file mode 100644 index 00000000..99ab4149 --- /dev/null +++ b/torch/src/roster.mjs @@ -0,0 +1,114 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadTorchConfig } from './torch-config.mjs'; + +const ROSTER_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'prompts/roster.json'); +const USER_ROSTER_FILE = path.resolve(process.cwd(), 'torch/roster.json'); +const CWD_ROSTER_FILE = path.resolve(process.cwd(), 'roster.json'); + +const FALLBACK_ROSTER = { + daily: [ + 'audit-agent', + 'ci-health-agent', + 'const-refactor-agent', + 'content-audit-agent', + 'decompose-agent', + 'deps-security-agent', + 'design-system-audit-agent', + 'docs-agent', + 'docs-alignment-agent', + 'docs-code-investigator', + 'innerhtml-migration-agent', + 'known-issues-agent', + 'load-test-agent', + 'onboarding-audit-agent', + 'perf-agent', + 'prompt-curator-agent', + 'protocol-research-agent', + 'scheduler-update-agent', + 'style-agent', + 'test-audit-agent', + 'todo-triage-agent', + ], + weekly: [ + 'bug-reproducer-agent', + 'changelog-agent', + 'dead-code-agent', + 'event-schema-agent', + 'frontend-console-debug-agent', + 'fuzz-agent', + 'interop-agent', + 'perf-deepdive-agent', + 'perf-optimization-agent', + 'pr-review-agent', + 'race-condition-agent', + 'refactor-agent', + 'smoke-agent', + 'telemetry-agent', + 'test-coverage-agent', + 'weekly-synthesis-agent', + ], +}; + +let cachedCanonicalRoster = null; + +function loadCanonicalRoster() { + if (cachedCanonicalRoster) return cachedCanonicalRoster; + + let rosterPath = ROSTER_FILE; + + // Prefer user-managed roster if present + if (fs.existsSync(USER_ROSTER_FILE)) { + rosterPath = USER_ROSTER_FILE; + } else if (fs.existsSync(CWD_ROSTER_FILE)) { + rosterPath = CWD_ROSTER_FILE; + } + + try { + const parsed = JSON.parse(fs.readFileSync(rosterPath, 'utf8')); + const daily = Array.isArray(parsed.daily) ? parsed.daily.map((item) => String(item).trim()).filter(Boolean) : []; + const weekly = Array.isArray(parsed.weekly) + ? parsed.weekly.map((item) => String(item).trim()).filter(Boolean) + : []; + + if (daily.length > 0 && weekly.length > 0) { + cachedCanonicalRoster = { daily, weekly }; + return cachedCanonicalRoster; + } + + console.error(`WARNING: Roster file is missing daily/weekly entries, falling back: ${rosterPath}`); + } catch { + // It's okay if roster file is missing when used as a library/CLI without the file present + } + + cachedCanonicalRoster = FALLBACK_ROSTER; + return cachedCanonicalRoster; +} + +function parseEnvRoster(value) { + if (!value) return null; + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +export async function getRoster(cadence) { + const config = await loadTorchConfig(); + const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER); + const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER); + const canonical = loadCanonicalRoster(); + const dailyFromConfig = config.nostrLock.dailyRoster; + const weeklyFromConfig = config.nostrLock.weeklyRoster; + + if (cadence === 'daily') { + if (dailyFromEnv && dailyFromEnv.length) return dailyFromEnv; + if (dailyFromConfig && dailyFromConfig.length) return dailyFromConfig; + return canonical.daily; + } + + if (weeklyFromEnv && weeklyFromEnv.length) return weeklyFromEnv; + if (weeklyFromConfig && weeklyFromConfig.length) return weeklyFromConfig; + return canonical.weekly; +} diff --git a/torch/src/services/memory/cache.js b/torch/src/services/memory/cache.js new file mode 100644 index 00000000..63170421 --- /dev/null +++ b/torch/src/services/memory/cache.js @@ -0,0 +1,218 @@ +const DEFAULT_MAX_NAMESPACES = 128; +const DEFAULT_MAX_EVENTS_PER_NAMESPACE = 200; +const DEFAULT_MAX_TOTAL_EVENTS = 5000; + +function normalizeScope(scope = {}) { + const signerId = typeof scope.signer_id === 'string' && scope.signer_id.trim() + ? scope.signer_id.trim() + : 'anonymous-signer'; + const sessionId = typeof scope.session_id === 'string' && scope.session_id.trim() + ? scope.session_id.trim() + : 'anonymous-session'; + + return { signer_id: signerId, session_id: sessionId }; +} + +function getNamespaceKey(scope) { + const normalized = normalizeScope(scope); + return `${normalized.signer_id}::${normalized.session_id}`; +} + +function isEventSensitive(event) { + if (!event || typeof event !== 'object') return false; + if (event.is_decrypted === true || event.session_sensitive === true) return true; + + const classification = typeof event.classification === 'string' ? event.classification.toLowerCase() : ''; + return classification === 'decrypted' || classification === 'session-sensitive'; +} + +function canPromoteToDurableStorage(event) { + if (!isEventSensitive(event)) return true; + return event.sanitized_for_durable === true; +} + +/** + * @param {{ maxNamespaces?: number, maxEventsPerNamespace?: number, maxTotalEvents?: number }} [options] + * @returns {{ + * get: (key: string) => unknown, + * set: (key: string, value: unknown) => void, + * delete: (key: string) => boolean, + * clear: () => void, + * setRuntimeEvent: (scope: { signer_id?: string, session_id?: string }, event: Record, ttlMs?: number) => boolean, + * getRecentRuntimeEvents: (scope: { signer_id?: string, session_id?: string }, params?: { limit?: number, since?: number }) => Record[], + * clearScope: (scope: { signer_id?: string, session_id?: string }) => void, + * getMetrics: () => Record + * }} + */ +export function createMemoryCache(options = {}) { + const store = new Map(); + const runtimeNamespaces = new Map(); + const maxNamespaces = Number.isFinite(options.maxNamespaces) && options.maxNamespaces > 0 + ? Math.floor(options.maxNamespaces) + : DEFAULT_MAX_NAMESPACES; + const maxEventsPerNamespace = Number.isFinite(options.maxEventsPerNamespace) && options.maxEventsPerNamespace > 0 + ? Math.floor(options.maxEventsPerNamespace) + : DEFAULT_MAX_EVENTS_PER_NAMESPACE; + const maxTotalEvents = Number.isFinite(options.maxTotalEvents) && options.maxTotalEvents > 0 + ? Math.floor(options.maxTotalEvents) + : DEFAULT_MAX_TOTAL_EVENTS; + + let totalRuntimeEvents = 0; + const metrics = { + runtime_events_added: 0, + runtime_events_expired: 0, + runtime_events_lru_evicted: 0, + runtime_scopes_cleared: 0, + runtime_sensitive_events_blocked: 0, + }; + + function touchNamespace(key) { + const bucket = runtimeNamespaces.get(key); + if (!bucket) return; + runtimeNamespaces.delete(key); + runtimeNamespaces.set(key, bucket); + } + + function evictNamespace(key) { + const bucket = runtimeNamespaces.get(key); + if (!bucket) return; + totalRuntimeEvents -= bucket.events.length; + metrics.runtime_events_lru_evicted += bucket.events.length; + runtimeNamespaces.delete(key); + } + + function evictOldestNamespaceIfNeeded() { + while (runtimeNamespaces.size > maxNamespaces) { + const oldestKey = runtimeNamespaces.keys().next().value; + if (!oldestKey) return; + evictNamespace(oldestKey); + } + } + + function removeExpired(now = Date.now()) { + for (const [namespaceKey, bucket] of runtimeNamespaces.entries()) { + if (!bucket.events.length) continue; + + const originalLength = bucket.events.length; + bucket.events = bucket.events.filter((entry) => entry.expiresAt > now); + const removed = originalLength - bucket.events.length; + if (removed > 0) { + totalRuntimeEvents -= removed; + metrics.runtime_events_expired += removed; + } + + if (bucket.events.length === 0) { + runtimeNamespaces.delete(namespaceKey); + } + } + } + + function enforceEventBounds(namespaceKey) { + const bucket = runtimeNamespaces.get(namespaceKey); + if (!bucket) return; + + while (bucket.events.length > maxEventsPerNamespace) { + bucket.events.shift(); + totalRuntimeEvents -= 1; + metrics.runtime_events_lru_evicted += 1; + } + + while (totalRuntimeEvents > maxTotalEvents) { + const oldestNamespaceKey = runtimeNamespaces.keys().next().value; + if (!oldestNamespaceKey) return; + const oldestBucket = runtimeNamespaces.get(oldestNamespaceKey); + if (!oldestBucket || oldestBucket.events.length === 0) { + runtimeNamespaces.delete(oldestNamespaceKey); + continue; + } + + oldestBucket.events.shift(); + totalRuntimeEvents -= 1; + metrics.runtime_events_lru_evicted += 1; + if (oldestBucket.events.length === 0) { + runtimeNamespaces.delete(oldestNamespaceKey); + } + } + } + + return { + get(key) { + return store.get(key); + }, + set(key, value) { + store.set(key, value); + }, + delete(key) { + return store.delete(key); + }, + clear() { + store.clear(); + runtimeNamespaces.clear(); + totalRuntimeEvents = 0; + }, + setRuntimeEvent(scope, event, ttlMs = 60_000) { + const now = Date.now(); + removeExpired(now); + + const safeTtlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : 60_000; + const expiresAt = now + safeTtlMs; + const namespaceKey = getNamespaceKey(scope); + const bucket = runtimeNamespaces.get(namespaceKey) ?? { events: [] }; + + bucket.events.push({ + event, + createdAt: now, + expiresAt, + durableEligible: canPromoteToDurableStorage(event), + }); + + totalRuntimeEvents += 1; + metrics.runtime_events_added += 1; + if (!canPromoteToDurableStorage(event)) { + metrics.runtime_sensitive_events_blocked += 1; + } + + runtimeNamespaces.set(namespaceKey, bucket); + touchNamespace(namespaceKey); + evictOldestNamespaceIfNeeded(); + enforceEventBounds(namespaceKey); + return true; + }, + getRecentRuntimeEvents(scope, params = {}) { + const now = Date.now(); + removeExpired(now); + + const namespaceKey = getNamespaceKey(scope); + const bucket = runtimeNamespaces.get(namespaceKey); + if (!bucket) return []; + + touchNamespace(namespaceKey); + + const limit = Number.isFinite(params.limit) && params.limit > 0 ? Math.floor(params.limit) : 50; + const since = Number.isFinite(params.since) ? params.since : 0; + + return bucket.events + .filter((entry) => entry.createdAt >= since) + .slice(-limit) + .map((entry) => ({ + ...entry.event, + durable_eligible: entry.durableEligible, + })); + }, + clearScope(scope) { + const namespaceKey = getNamespaceKey(scope); + const bucket = runtimeNamespaces.get(namespaceKey); + if (!bucket) return; + totalRuntimeEvents -= bucket.events.length; + metrics.runtime_scopes_cleared += 1; + runtimeNamespaces.delete(namespaceKey); + }, + getMetrics() { + return { + ...metrics, + runtime_active_namespaces: runtimeNamespaces.size, + runtime_active_events: totalRuntimeEvents, + }; + }, + }; +} diff --git a/torch/src/services/memory/embedder.js b/torch/src/services/memory/embedder.js new file mode 100644 index 00000000..ba26c63d --- /dev/null +++ b/torch/src/services/memory/embedder.js @@ -0,0 +1,134 @@ +const DEFAULT_VECTOR_BACKEND = 'inmemory'; +const VECTOR_BACKEND_ENV = 'MEMORY_VECTOR_BACKEND'; + +/** + * @typedef {{ + * embedText: (text: string) => Promise | number[], + * upsertVector: (input: { id: string, vector: number[], metadata?: Record }) => Promise | void, + * queryVector: (input: { vector: number[], k?: number, filter?: Record }) => Promise }>> | Array<{ id: string, score: number, vector: number[], metadata: Record }>, + * deleteVector: (id: string) => Promise | boolean, + * }} EmbedderAdapter + */ + +/** + * @param {string} text + * @returns {number[]} + */ +function fallbackEmbedding(_text) { + // Disabled as per configuration: Jules uses text-based memory, not vector embeddings. + return []; +} + +function cosineSimilarity(left, right) { + const size = Math.max(left.length, right.length); + let dot = 0; + let leftNorm = 0; + let rightNorm = 0; + + for (let index = 0; index < size; index += 1) { + const leftValue = Number(left[index] ?? 0); + const rightValue = Number(right[index] ?? 0); + dot += leftValue * rightValue; + leftNorm += leftValue ** 2; + rightNorm += rightValue ** 2; + } + + if (leftNorm === 0 || rightNorm === 0) return 0; + return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm)); +} + +/** @implements {EmbedderAdapter} */ +class InMemoryEmbedderAdapter { + constructor() { + this.vectors = new Map(); + } + + /** @param {string} text */ + async embedText(text) { + return fallbackEmbedding(text); + } + + /** @param {{ id: string, vector: number[], metadata?: Record }} input */ + async upsertVector({ id, vector, metadata = {} }) { + if (typeof id !== 'string' || id.length === 0) { + throw new TypeError('upsertVector requires a non-empty id'); + } + if (!Array.isArray(vector) || vector.length === 0) { + // In strict mode this might throw, but since we return [] from embedText, + // ingestor won't call this. If someone calls it manually with [], we can just ignore or warn. + // But adhering to the interface, we should probably throw or just return. + // Given we want to support "no embedding", simply returning is safer than throwing. + return; + } + this.vectors.set(id, { vector: [...vector], metadata: { ...metadata } }); + } + + /** @param {{ vector: number[], k?: number, filter?: Record }} input */ + async queryVector({ vector, k = 5, filter = {} }) { + if (!Array.isArray(vector) || vector.length === 0) return []; + + const matchesFilter = (metadata) => Object.entries(filter).every(([key, value]) => metadata?.[key] === value); + + return [...this.vectors.entries()] + .filter(([, value]) => matchesFilter(value.metadata)) + .map(([id, value]) => ({ + id, + score: cosineSimilarity(vector, value.vector), + vector: [...value.vector], + metadata: { ...value.metadata }, + })) + .sort((left, right) => right.score - left.score) + .slice(0, Number.isFinite(k) && k > 0 ? Math.floor(k) : 5); + } + + /** @param {string} id */ + async deleteVector(id) { + if (typeof id !== 'string' || id.length === 0) return false; + return this.vectors.delete(id); + } +} + +const ADAPTER_FACTORIES = { + inmemory: () => new InMemoryEmbedderAdapter(), + local: () => new InMemoryEmbedderAdapter(), + mock: () => new InMemoryEmbedderAdapter(), +}; + +let defaultAdapter; + +/** + * @param {{ backend?: string }} [options] + * @returns {EmbedderAdapter} + */ +export function createEmbedderAdapter(options = {}) { + const configuredBackend = String(options.backend ?? process.env[VECTOR_BACKEND_ENV] ?? DEFAULT_VECTOR_BACKEND).trim().toLowerCase(); + const factory = ADAPTER_FACTORIES[configuredBackend]; + if (!factory) { + throw new Error(`Unsupported vector backend "${configuredBackend}". Configure ${VECTOR_BACKEND_ENV}=inmemory (default) until provider adapters are added.`); + } + return factory(); +} + +/** + * @returns {EmbedderAdapter} + */ +export function getDefaultEmbedderAdapter() { + if (!defaultAdapter) { + defaultAdapter = createEmbedderAdapter(); + } + return defaultAdapter; +} + +/** + * @param {string} text + * @param {{ embedText?: (value: string) => Promise | number[], adapter?: EmbedderAdapter }} [options] + * @returns {Promise} + */ +export async function embedText(text, options = {}) { + if (typeof options.embedText === 'function') { + return options.embedText(text); + } + + const adapter = options.adapter ?? getDefaultEmbedderAdapter(); + return adapter.embedText(text); +} diff --git a/torch/src/services/memory/feature-flags.js b/torch/src/services/memory/feature-flags.js new file mode 100644 index 00000000..784f3760 --- /dev/null +++ b/torch/src/services/memory/feature-flags.js @@ -0,0 +1,61 @@ +const FALSEY = new Set(['0', 'false', 'off', 'no', 'disabled']); +const TRUTHY = new Set(['1', 'true', 'on', 'yes', 'enabled', 'all']); + +function normalize(value) { + return String(value ?? '').trim(); +} + +function parseAgentToggle(value) { + const raw = normalize(value); + if (!raw) return { kind: 'unset' }; + + const lower = raw.toLowerCase(); + if (FALSEY.has(lower)) return { kind: 'off' }; + if (TRUTHY.has(lower)) return { kind: 'all' }; + + const allowList = raw + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + return allowList.length > 0 ? { kind: 'allow-list', allowList } : { kind: 'off' }; +} + +function isToggleEnabledForAgent(value, agentId, fallbackWhenUnset) { + const parsed = parseAgentToggle(value); + if (parsed.kind === 'unset') return fallbackWhenUnset; + if (parsed.kind === 'off') return false; + if (parsed.kind === 'all') return true; + if (!agentId) return false; + return parsed.allowList.includes(String(agentId)); +} + +export function isMemoryEnabled(env = process.env) { + return isToggleEnabledForAgent(env.TORCH_MEMORY_ENABLED, undefined, true); +} + +export function isMemoryIngestEnabled(agentId, env = process.env) { + if (!isMemoryEnabled(env)) return false; + return isToggleEnabledForAgent(env.TORCH_MEMORY_INGEST_ENABLED, agentId, true); +} + +export function isMemoryRetrievalEnabled(agentId, env = process.env) { + if (!isMemoryEnabled(env)) return false; + return isToggleEnabledForAgent(env.TORCH_MEMORY_RETRIEVAL_ENABLED, agentId, false); +} + +export function getMemoryPruneMode(env = process.env) { + if (!isMemoryEnabled(env)) return 'off'; + + const raw = normalize(env.TORCH_MEMORY_PRUNE_ENABLED); + if (!raw) return 'active'; + + const lower = raw.toLowerCase(); + if (FALSEY.has(lower)) return 'off'; + if (['dry-run', 'dryrun', 'report', 'audit'].includes(lower)) return 'dry-run'; + return 'active'; +} + +export function isMemoryPruneEnabled(env = process.env) { + return getMemoryPruneMode(env) === 'active'; +} diff --git a/torch/src/services/memory/formatter.js b/torch/src/services/memory/formatter.js new file mode 100644 index 00000000..3f3509b8 --- /dev/null +++ b/torch/src/services/memory/formatter.js @@ -0,0 +1,194 @@ +import { isMemoryRetrievalEnabled } from './feature-flags.js'; +export { isMemoryRetrievalEnabled } from './feature-flags.js'; +const DEFAULT_TOKEN_BUDGET = 1000; +const DEFAULT_MAX_SUMMARY_CHARS = 240; +const DEFAULT_MAX_CONTEXT_CHARS = 320; +const MEMORY_CONTEXT_HEADER = '[CONTEXT — Relevant memories]'; + +function clampString(value, maxChars) { + if (typeof value !== 'string') return ''; + if (!Number.isFinite(maxChars) || maxChars <= 0) return ''; + if (value.length <= maxChars) return value; + return `${value.slice(0, Math.max(0, maxChars - 1))}…`; +} + +/** + * Fast heuristic (approx 4 chars/token) suitable for guardrail budgeting. + * @param {string} text + */ +export function estimateTokenCount(text) { + const normalized = String(text ?? ''); + if (normalized.length === 0) return 0; + return Math.ceil(normalized.length / 4); +} + +/** + * @param {import('./schema.js').MemoryRecord} memory + * @param {{ maxSummaryChars?: number, maxContextChars?: number }} [options] + */ +export function formatMemoryBlock(memory, options = {}) { + const summary = clampString(memory.summary ?? '', options.maxSummaryChars ?? DEFAULT_MAX_SUMMARY_CHARS); + const shortContext = clampString(memory.content ?? '', options.maxContextChars ?? DEFAULT_MAX_CONTEXT_CHARS); + const timestamp = new Date(memory.created_at).toISOString(); + const tags = Array.isArray(memory.tags) ? memory.tags.join(',') : ''; + + return { + id: memory.id, + timestamp, + importance: Number(memory.importance ?? 0).toFixed(2), + summary, + short_context: shortContext, + tags, + }; +} + + +function fitBlockToBudget(memory, options, maxTokens) { + let maxSummaryChars = options.maxSummaryChars ?? DEFAULT_MAX_SUMMARY_CHARS; + let maxContextChars = options.maxContextChars ?? DEFAULT_MAX_CONTEXT_CHARS; + + while (maxSummaryChars >= 8 && maxContextChars >= 8) { + const block = formatMemoryBlock(memory, { maxSummaryChars, maxContextChars }); + const line = blockToLine(block); + if (estimateTokenCount(line) <= maxTokens) { + return { block, line }; + } + + if (maxContextChars >= maxSummaryChars) { + maxContextChars = Math.max(8, Math.floor(maxContextChars * 0.7)); + } else { + maxSummaryChars = Math.max(8, Math.floor(maxSummaryChars * 0.7)); + } + + if (maxSummaryChars === 8 && maxContextChars === 8) break; + } + + const block = formatMemoryBlock(memory, { maxSummaryChars: 8, maxContextChars: 8 }); + return { block, line: blockToLine(block) }; +} +function blockToLine(block) { + return `[${block.id}, ${block.timestamp}, ${block.importance}, ${block.summary}, ${block.short_context}, ${block.tags}]`; +} + +/** + * @param {import('./schema.js').MemoryRecord[]} memories + * @param {{ k?: number, tokenBudget?: number, maxSummaryChars?: number, maxContextChars?: number }} [options] + */ +export function formatMemoriesForPrompt(memories, options = {}) { + const k = Number.isFinite(options.k) && options.k > 0 ? Math.floor(options.k) : memories.length; + const tokenBudget = Number.isFinite(options.tokenBudget) && options.tokenBudget > 0 + ? Math.floor(options.tokenBudget) + : DEFAULT_TOKEN_BUDGET; + + const blocks = []; + let usedTokens = 0; + + for (const memory of memories.slice(0, k)) { + const remainingTokens = tokenBudget - usedTokens; + if (remainingTokens <= 0) break; + + const fitted = fitBlockToBudget(memory, options, remainingTokens); + const block = fitted.block; + const line = fitted.line; + const lineTokens = estimateTokenCount(line); + + if (usedTokens + lineTokens > tokenBudget) { + break; + } + + blocks.push(block); + usedTokens += lineTokens; + } + + return { + blocks, + text: blocks.map(blockToLine).join('\n'), + usedTokens, + truncated: blocks.length < Math.min(k, memories.length), + }; +} + +function toContextString(agentContext) { + if (typeof agentContext === 'string') { + return agentContext.trim(); + } + + if (agentContext == null) { + return ''; + } + + if (Array.isArray(agentContext)) { + return agentContext.map((value) => String(value)).join('\n').trim(); + } + + if (typeof agentContext === 'object') { + try { + return JSON.stringify(agentContext); + } catch { + return String(agentContext).trim(); + } + } + + return String(agentContext).trim(); +} + +/** + * Builds a final prompt with retrieved memory context inserted ahead of the base prompt. + * + * @param {{ + * basePrompt: string, + * userRequest: string, + * agentContext?: unknown, + * agent_id: string, + * memoryService: { getRelevantMemories: (params: { agent_id: string, query: string, k?: number }) => Promise | import('./schema.js').MemoryRecord[] }, + * retrievalK?: number, + * tokenBudget?: number, + * env?: Record + * }} params + */ +export async function renderPromptWithMemoryContext(params) { + const { + basePrompt, + userRequest, + agentContext, + agent_id, + memoryService, + retrievalK = 5, + tokenBudget = 320, + env = process.env, + } = params; + + if (!isMemoryRetrievalEnabled(agent_id, env)) { + return String(basePrompt ?? ''); + } + + const requestText = String(userRequest ?? '').trim(); + const contextText = toContextString(agentContext); + const query = [requestText, contextText].filter(Boolean).join('\n\n'); + + if (!query || !memoryService?.getRelevantMemories) { + return String(basePrompt ?? ''); + } + + const memories = await memoryService.getRelevantMemories({ + agent_id, + query, + k: retrievalK, + }); + + if (memoryService?.updateMemoryUsage) { + const now = Date.now(); + await Promise.all((memories ?? []).map((memory) => memoryService.updateMemoryUsage(memory.id, now))); + } + + const rendered = formatMemoriesForPrompt(memories ?? [], { + k: retrievalK, + tokenBudget, + }); + + if (!rendered.text) { + return String(basePrompt ?? ''); + } + + return `${MEMORY_CONTEXT_HEADER}\n${rendered.text}\n\n${String(basePrompt ?? '')}`; +} diff --git a/torch/src/services/memory/index.js b/torch/src/services/memory/index.js new file mode 100644 index 00000000..cf7375fb --- /dev/null +++ b/torch/src/services/memory/index.js @@ -0,0 +1,439 @@ +import { createMemoryCache } from './cache.js'; +import { ingestMemoryWindow } from './ingestor.js'; +import { listPruneCandidates, selectPrunableMemories } from './pruner.js'; +import { filterAndRankMemories, updateMemoryUsage } from './retriever.js'; +import { startScheduler } from './scheduler.js'; +import { getMemoryPruneMode, isMemoryIngestEnabled } from './feature-flags.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import util from 'node:util'; + +const MEMORY_FILE_PATH = path.join(process.cwd(), '.scheduler-memory', 'memory-store.json'); +const debug = util.debuglog('torch-memory'); + +function loadMemoryStore() { + try { + if (fs.existsSync(MEMORY_FILE_PATH)) { + const data = fs.readFileSync(MEMORY_FILE_PATH, 'utf8'); + const entries = JSON.parse(data); + if (Array.isArray(entries)) { + return new Map(entries); + } + } + } catch (err) { + console.error('Failed to load memory store:', err); + } + return new Map(); +} + +function saveMemoryStore(store) { + try { + const dir = path.dirname(MEMORY_FILE_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const entries = [...store.entries()]; + fs.writeFileSync(MEMORY_FILE_PATH, JSON.stringify(entries, null, 2), 'utf8'); + } catch (err) { + console.error('Failed to save memory store:', err); + } +} + +const memoryStore = loadMemoryStore(); +const cache = createMemoryCache(); + +const memoryRepository = { + async insertMemory(memory) { + memoryStore.set(memory.id, memory); + saveMemoryStore(memoryStore); + return memory; + }, + async updateMemoryUsage(id, lastSeen = Date.now()) { + const existing = memoryStore.get(id); + if (!existing) return null; + const updated = { ...existing, last_seen: lastSeen }; + memoryStore.set(id, updated); + saveMemoryStore(memoryStore); + return updated; + }, + async listPruneCandidates({ cutoff }) { + return [...memoryStore.values()].filter((memory) => !memory.pinned && memory.last_seen < cutoff); + }, + async markMerged(id, mergedInto) { + const existing = memoryStore.get(id); + if (!existing) return false; + memoryStore.set(id, { ...existing, merged_into: mergedInto, last_seen: Date.now() }); + saveMemoryStore(memoryStore); + return true; + }, + async setPinned(id, pinned) { + const existing = memoryStore.get(id); + if (!existing) return null; + const updated = { ...existing, pinned, last_seen: Date.now() }; + memoryStore.set(id, updated); + saveMemoryStore(memoryStore); + return updated; + }, + async getMemoryById(id) { + return memoryStore.get(id) ?? null; + }, + async listMemories() { + return [...memoryStore.values()]; + }, +}; + +const memoryStatsState = { + ingested: [], + retrieved: [], + pruned: [], + archived: 0, + deleted: 0, +}; + +const ESTIMATED_INDEX_BYTES_PER_RECORD = 512; + +function toSafeTelemetryPayload(payload = {}) { + const blockedFields = new Set(['content', 'summary', 'query', 'raw']); + return Object.entries(payload).reduce((safe, [key, value]) => { + if (blockedFields.has(key)) return safe; + if (typeof value === 'string' && value.length > 300) { + safe[key] = `${value.slice(0, 300)}…`; + return safe; + } + safe[key] = value; + return safe; + }, {}); +} + +function buildTelemetryEmitter(options = {}) { + if (typeof options.emitTelemetry === 'function') { + return (event, payload) => options.emitTelemetry(event, toSafeTelemetryPayload(payload)); + } + + if (typeof options.telemetry?.emit === 'function') { + return (event, payload) => options.telemetry.emit(event, toSafeTelemetryPayload(payload)); + } + + return (event, payload) => { + debug('memory_telemetry', { + event, + payload: toSafeTelemetryPayload(payload), + ts: Date.now(), + }); + }; +} + +function emitMetric(options = {}, metric, payload) { + if (typeof options.emitMetric === 'function') { + options.emitMetric(metric, payload); + return; + } + + if (typeof options.metrics?.emit === 'function') { + options.metrics.emit(metric, payload); + return; + } + + debug('memory_metric', { metric, payload, ts: Date.now() }); +} + +function applyMemoryFilters(memories, filters = {}) { + const tags = Array.isArray(filters.tags) + ? filters.tags.map((tag) => String(tag).trim()).filter(Boolean) + : []; + + return memories.filter((memory) => { + if (filters.agent_id && memory.agent_id !== filters.agent_id) return false; + if (filters.type && memory.type !== filters.type) return false; + if (typeof filters.pinned === 'boolean' && memory.pinned !== filters.pinned) return false; + if (typeof filters.includeMerged === 'boolean' && !filters.includeMerged && memory.merged_into) return false; + if (tags.length > 0) { + const memoryTags = new Set(memory.tags); + if (!tags.every((tag) => memoryTags.has(tag))) return false; + } + return true; + }); +} + +function recordThroughput(samples, now) { + samples.push(now); + if (samples.length > 10_000) { + samples.splice(0, samples.length - 10_000); + } +} + +/** + * Ingests agent events and stores them as memory records. + * + * @param {import('./schema.js').MemoryEvent[]} events - Ordered list of raw events to persist. + * @param {{ maxSummaryLength?: number, embedText?: (value: string) => Promise | number[], repository?: typeof memoryRepository }} [options] - Optional ingest tuning. + * @returns {Promise} Stored memory records. + */ +export async function ingestEvents(events, options = {}) { + const repository = options.repository ?? memoryRepository; + const telemetry = buildTelemetryEmitter(options); + if (!isMemoryIngestEnabled(options.agent_id, options.env)) { + telemetry('memory:ingest_skipped', { reason: 'flag_disabled', agent_id: options.agent_id ?? null }); + return []; + } + const records = await ingestMemoryWindow({ events }, { + ...options, + repository, + emitTelemetry: (event, payload) => { + telemetry(event, payload); + if (event === 'memory:ingested') { + recordThroughput(memoryStatsState.ingested, Date.now()); + emitMetric(options, 'memory_ingested_total', { count: 1 }); + } + }, + }); + cache.clear(); + return records; +} + +/** + * Retrieves top-k memories for an agent query. + * + * @param {{ agent_id: string, query?: string, tags?: string[], timeframe?: { from?: number, to?: number }, k?: number, repository?: typeof memoryRepository }} params - Retrieval filter and ranking inputs. + * @returns {Promise} Sorted list of relevant memories. + */ +export async function getRelevantMemories(params) { + const { + repository = memoryRepository, + ranker = filterAndRankMemories, + cache: cacheProvider = cache, + ...queryParams + } = params; + const telemetry = buildTelemetryEmitter(params); + const cacheKey = JSON.stringify(queryParams); + const cached = cacheProvider.get(cacheKey); + if (cached) { + telemetry('memory:retrieved', { + agent_id: queryParams.agent_id, + result_count: cached.length, + cache_hit: true, + query_present: Boolean(queryParams.query), + query_length: typeof queryParams.query === 'string' ? queryParams.query.length : 0, + tags_count: Array.isArray(queryParams.tags) ? queryParams.tags.length : 0, + }); + emitMetric(params, 'memory_retrieved_total', { count: cached.length, cache_hit: true }); + recordThroughput(memoryStatsState.retrieved, Date.now()); + return cached; + } + + const source = typeof repository.listMemories === 'function' + ? await repository.listMemories(queryParams) + : [...memoryStore.values()]; + + const ranked = await ranker(source, queryParams); + await updateMemoryUsage(repository, ranked.map((memory) => memory.id)); + cacheProvider.set(cacheKey, ranked); + + telemetry('memory:retrieved', { + agent_id: queryParams.agent_id, + result_count: ranked.length, + cache_hit: false, + query_present: Boolean(queryParams.query), + query_length: typeof queryParams.query === 'string' ? queryParams.query.length : 0, + tags_count: Array.isArray(queryParams.tags) ? queryParams.tags.length : 0, + }); + emitMetric(params, 'memory_retrieved_total', { count: ranked.length, cache_hit: false }); + recordThroughput(memoryStatsState.retrieved, Date.now()); + + return ranked; +} + +/** + * Runs a memory pruning pass and removes unpinned stale records. + * + * @param {{ retentionMs?: number, now?: number, scheduleEveryMs?: number, repository?: typeof memoryRepository }} [options] - Prune configuration; if scheduleEveryMs is set, returns a scheduler handle. + * @returns {Promise<{ pruned: import('./schema.js').MemoryRecord[], scheduler?: { stop: () => void } }>} Pruned records and optional scheduler handle. + */ +export async function runPruneCycle(options = {}) { + const repository = options.repository ?? memoryRepository; + const telemetry = buildTelemetryEmitter(options); + const retentionMs = options.retentionMs ?? (1000 * 60 * 60 * 24 * 30); + const pruneMode = options.pruneMode ?? getMemoryPruneMode(options.env); + const dbCandidates = await listPruneCandidates(repository, { + retentionMs, + now: options.now, + }); + + const prunable = dbCandidates.length > 0 + ? dbCandidates + : selectPrunableMemories([...memoryStore.values()], { + retentionMs, + now: options.now, + }); + + if (pruneMode === 'off') { + telemetry('memory:prune_skipped', { reason: 'flag_disabled', retention_ms: retentionMs }); + return { pruned: [], mode: 'off' }; + } + + if (pruneMode === 'dry-run') { + telemetry('memory:pruned', { + pruned_count: prunable.length, + retention_ms: retentionMs, + dry_run: true, + }); + emitMetric(options, 'memory_pruned_total', { count: 0, retention_ms: retentionMs, dry_run: true }); + return { pruned: [], candidates: prunable, mode: 'dry-run' }; + } + + for (const memory of prunable) { + memoryStore.delete(memory.id); + } + + if (prunable.length > 0) { + saveMemoryStore(memoryStore); + } + + memoryStatsState.deleted += prunable.length; + recordThroughput(memoryStatsState.pruned, Date.now()); + telemetry('memory:pruned', { + pruned_count: prunable.length, + retention_ms: retentionMs, + }); + emitMetric(options, 'memory_pruned_total', { count: prunable.length, retention_ms: retentionMs }); + + cache.clear(); + + const result = { pruned: prunable }; + if (Number.isFinite(options.scheduleEveryMs) && options.scheduleEveryMs > 0) { + result.scheduler = startScheduler(() => runPruneCycle({ retentionMs, repository, env: options.env, pruneMode: options.pruneMode }), { + intervalMs: options.scheduleEveryMs, + }); + } + + return result; +} + +/** + * Pins a memory to prevent pruning and boost retrieval ranking. + * + * @param {string} id - Memory record identifier. + * @returns {Promise} Updated memory record or null when not found. + */ +export async function pinMemory(id, options = {}) { + const repository = options.repository ?? memoryRepository; + const updated = await repository.setPinned(id, true); + cache.clear(); + return updated; +} + +/** + * Removes a pin from a memory so it can be pruned normally. + * + * @param {string} id - Memory record identifier. + * @returns {Promise} Updated memory record or null when not found. + */ +export async function unpinMemory(id, options = {}) { + const repository = options.repository ?? memoryRepository; + const updated = await repository.setPinned(id, false); + cache.clear(); + return updated; +} + +/** + * Marks one memory as merged into another. + * + * @param {string} id + * @param {string} mergedInto + */ +export async function markMemoryMerged(id, mergedInto, options = {}) { + const repository = options.repository ?? memoryRepository; + const merged = await repository.markMerged(id, mergedInto); + cache.clear(); + return merged; +} + +export async function listMemories(filters = {}, options = {}) { + const repository = options.repository ?? memoryRepository; + const source = typeof repository.listMemories === 'function' + ? await repository.listMemories(filters) + : [...memoryStore.values()]; + + const filtered = applyMemoryFilters(source, filters) + .sort((left, right) => right.last_seen - left.last_seen); + + const offset = Number.isFinite(filters.offset) ? Math.max(0, Number(filters.offset)) : 0; + const limit = Number.isFinite(filters.limit) + ? Math.max(1, Math.floor(Number(filters.limit))) + : filtered.length; + + return filtered.slice(offset, offset + limit); +} + +export async function inspectMemory(id, options = {}) { + const repository = options.repository ?? memoryRepository; + if (typeof repository.getMemoryById === 'function') { + return repository.getMemoryById(id); + } + return memoryStore.get(id) ?? null; +} + +export async function triggerPruneDryRun(options = {}) { + const repository = options.repository ?? memoryRepository; + const retentionMs = options.retentionMs ?? (1000 * 60 * 60 * 24 * 30); + const dbCandidates = await listPruneCandidates(repository, { + retentionMs, + now: options.now, + }); + + const candidates = dbCandidates.length > 0 + ? dbCandidates + : selectPrunableMemories(await listMemories({}, { repository }), { + retentionMs, + now: options.now, + }); + + return { + dryRun: true, + retentionMs, + candidateCount: candidates.length, + candidates: candidates.map((memory) => ({ + id: memory.id, + agent_id: memory.agent_id, + type: memory.type, + pinned: memory.pinned, + last_seen: memory.last_seen, + merged_into: memory.merged_into, + })), + }; +} + +export async function memoryStats(options = {}) { + const repository = options.repository ?? memoryRepository; + const now = options.now ?? Date.now(); + const windowMs = Number.isFinite(options.windowMs) ? Math.max(1, Number(options.windowMs)) : (60 * 60 * 1000); + const memories = await listMemories({}, { repository }); + + const countsByType = memories.reduce((acc, memory) => { + acc[memory.type] = (acc[memory.type] ?? 0) + 1; + return acc; + }, {}); + + const archivedCount = memories.filter((memory) => Boolean(memory.merged_into)).length; + const deletedRate = memoryStatsState.deleted / Math.max(1, memoryStatsState.ingested.length); + const archivedRate = archivedCount / Math.max(1, memories.length); + + const ingestedInWindow = memoryStatsState.ingested.filter((timestamp) => timestamp >= (now - windowMs)).length; + const ingestThroughputPerMinute = ingestedInWindow / Math.max(1, (windowMs / 60_000)); + + return { + countsByType, + totals: { + total: memories.length, + pinned: memories.filter((memory) => memory.pinned).length, + archived: archivedCount, + deletedObserved: memoryStatsState.deleted, + }, + rates: { + archivedRate, + deletedRate, + }, + indexSizeEstimateBytes: memories.length * ESTIMATED_INDEX_BYTES_PER_RECORD, + ingestThroughputPerMinute, + }; +} diff --git a/torch/src/services/memory/ingestor.js b/torch/src/services/memory/ingestor.js new file mode 100644 index 00000000..5f9ac422 --- /dev/null +++ b/torch/src/services/memory/ingestor.js @@ -0,0 +1,291 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { createMemoryRecord, normalizeEvent, validateMemoryItem } from './schema.js'; +import { embedText, getDefaultEmbedderAdapter } from './embedder.js'; +import { summarizeEvents } from './summarizer.js'; + +const WINDOW_BUCKET_MS = 60_000; +const MAX_DEDUPE_KEYS = 2000; +const dedupeWindow = new Map(); + +function pruneDedupeWindow() { + while (dedupeWindow.size > MAX_DEDUPE_KEYS) { + const oldestKey = dedupeWindow.keys().next().value; + if (!oldestKey) break; + dedupeWindow.delete(oldestKey); + } +} + +function redactObviousPii(text) { + if (typeof text !== 'string' || text.length === 0) return ''; + return text + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted:email]') + .replace(/\b(?:\+?\d{1,2}[\s.-]?)?(?:\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})\b/g, '[redacted:phone]') + .replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[redacted:ssn]'); +} + +function estimateTokens(text) { + if (!text) return 0; + return Math.ceil(text.length / 4); +} + +function chunkEvents(events, options = {}) { + const maxChunkChars = Number.isFinite(options.maxChunkChars) && options.maxChunkChars > 0 + ? Math.floor(options.maxChunkChars) + : 1_200; + const maxChunkTokens = Number.isFinite(options.maxChunkTokens) && options.maxChunkTokens > 0 + ? Math.floor(options.maxChunkTokens) + : 300; + + const chunks = []; + let currentChunk = []; + let currentChars = 0; + let currentTokens = 0; + + const flush = () => { + if (currentChunk.length > 0) { + chunks.push(currentChunk); + currentChunk = []; + currentChars = 0; + currentTokens = 0; + } + }; + + for (const event of events) { + const eventChars = event.content.length; + const eventTokens = estimateTokens(event.content); + const overBudget = (currentChars + eventChars > maxChunkChars) || (currentTokens + eventTokens > maxChunkTokens); + if (overBudget) { + flush(); + } + + currentChunk.push(event); + currentChars += eventChars; + currentTokens += eventTokens; + } + + flush(); + return chunks; +} + +function hashIngestionWindow({ agentId, bucket, content }) { + return createHash('sha256').update(`${agentId}|${bucket}|${content}`).digest('hex'); +} + +async function isDuplicateWindow(repository, dedupeKey) { + if (dedupeWindow.has(dedupeKey)) return true; + if (typeof repository?.hasIngestionFingerprint === 'function') { + const exists = await repository.hasIngestionFingerprint(dedupeKey); + if (exists) return true; + } + return false; +} + +async function rememberWindowFingerprint(repository, dedupeKey, payload) { + dedupeWindow.set(dedupeKey, payload); + pruneDedupeWindow(); + if (typeof repository?.storeIngestionFingerprint === 'function') { + await repository.storeIngestionFingerprint(dedupeKey, payload); + } +} + + +function compactContextExcerpt(text, maxChars = 280) { + if (typeof text !== 'string' || text.length === 0) return ''; + const compact = text.replace(/\s+/g, ' ').trim(); + if (compact.length <= maxChars) return compact; + return `${compact.slice(0, maxChars - 1)}…`; +} + +function buildTelemetryEmitter(options = {}) { + if (typeof options.emitTelemetry === 'function') return options.emitTelemetry; + if (typeof options.telemetry?.emit === 'function') { + return (event, payload) => options.telemetry.emit(event, payload); + } + return () => {}; +} + +/** + * @param {import('./schema.js').MemoryEvent[]} events + * @returns {import('./schema.js').MemoryEvent[]} + */ +export function normalizeEvents(events) { + return events.map(normalizeEvent); +} + +/** + * @param {{ + * events?: import('./schema.js').MemoryEvent[], + * runtimeCache?: { getRecentRuntimeEvents?: (scope: Record, params?: { since?: number, limit?: number }) => Record[] }, + * runtimeScope?: Record, + * logSource?: { getEvents?: (params: { from: number, to: number, agent_id?: string }) => Promise[]> | Record[], fetchEvents?: (params: { from: number, to: number, agent_id?: string }) => Promise[]> | Record[] }, + * agent_id?: string, + * windowStart?: number, + * windowEnd?: number, + * }} input + * @param {{ + * repository?: { insertMemory: (memory: import('./schema.js').MemoryRecord) => Promise, hasIngestionFingerprint?: (dedupeKey: string) => Promise, storeIngestionFingerprint?: (dedupeKey: string, payload: Record) => Promise, linkEmbedding?: (memoryId: string, embedding: { id: string, vector: number[] }) => Promise }, + * maxSummaryLength?: number, + * maxChunkChars?: number, + * maxChunkTokens?: number, + * embedText?: (value: string) => Promise | number[], + * adapter?: import('./embedder.js').EmbedderAdapter, + * embedderAdapter?: import('./embedder.js').EmbedderAdapter, + * contextExcerptChars?: number, + * emitTelemetry?: (event: string, payload: Record) => void, + * telemetry?: { emit: (event: string, payload: Record) => void }, + * now?: number, + * windowBucketMs?: number, + * }} options + * @returns {Promise} + */ +export async function ingestMemoryWindow(input = {}, options = {}) { + const repository = options.repository; + if (!repository || typeof repository.insertMemory !== 'function') { + throw new TypeError('Memory ingest requires a repository with insertMemory'); + } + + const emitTelemetry = buildTelemetryEmitter(options); + const windowStart = Number.isFinite(input.windowStart) ? Number(input.windowStart) : 0; + const windowEnd = Number.isFinite(input.windowEnd) ? Number(input.windowEnd) : (Number.isFinite(options.now) ? Number(options.now) : Date.now()); + + const runtimeEvents = typeof input.runtimeCache?.getRecentRuntimeEvents === 'function' + ? input.runtimeCache.getRecentRuntimeEvents(input.runtimeScope ?? {}, { since: windowStart, limit: 500 }) + : []; + + const logReader = input.logSource?.fetchEvents ?? input.logSource?.getEvents; + const logEvents = typeof logReader === 'function' + ? await logReader({ from: windowStart, to: windowEnd, agent_id: input.agent_id }) + : []; + + const normalizedEvents = normalizeEvents([ + ...(Array.isArray(input.events) ? input.events : []), + ...(Array.isArray(runtimeEvents) ? runtimeEvents : []), + ...(Array.isArray(logEvents) ? logEvents : []), + ]); + + const redactedEvents = normalizedEvents.map((event) => ({ + ...event, + content: redactObviousPii(event.content), + })); + + const eventsByAgent = new Map(); + for (const event of redactedEvents) { + const events = eventsByAgent.get(event.agent_id) ?? []; + events.push(event); + eventsByAgent.set(event.agent_id, events); + } + + const insertedRecords = []; + const bucketMs = Number.isFinite(options.windowBucketMs) && options.windowBucketMs > 0 + ? Math.floor(options.windowBucketMs) + : WINDOW_BUCKET_MS; + const bucket = Math.floor(windowEnd / bucketMs); + + for (const [agentId, agentEvents] of eventsByAgent.entries()) { + if (!agentEvents.length) continue; + const sorted = [...agentEvents].sort((a, b) => a.timestamp - b.timestamp); + const dedupeContent = sorted.map((event) => `${event.timestamp}:${event.content}`).join('\n'); + const dedupeKey = hashIngestionWindow({ agentId, bucket, content: dedupeContent }); + + if (await isDuplicateWindow(repository, dedupeKey)) { + continue; + } + + const chunks = chunkEvents(sorted, options); + for (const chunk of chunks) { + const summarization = await summarizeEvents(chunk, options); + const contextText = chunk.map((event) => event.content).join('\n'); + const summaryEmbedding = await embedText(summarization.summary, options); + const contextExcerpt = compactContextExcerpt(contextText, options.contextExcerptChars); + const chunkStart = Math.min(...chunk.map((event) => event.timestamp)); + const chunkEnd = Math.max(...chunk.map((event) => event.timestamp)); + + const record = createMemoryRecord({ + agent_id: agentId, + session_id: typeof chunk[0]?.metadata?.session_id === 'string' ? chunk[0].metadata.session_id : 'unknown', + type: 'batch_window', + content: contextText, + summary: summarization.summary, + tags: [...new Set(chunk.flatMap((event) => event.tags))], + importance: Number.isFinite(chunk[0]?.metadata?.importance) + ? Number(chunk[0].metadata.importance) + : summarization.importance, + embedding_id: Array.isArray(summaryEmbedding) && summaryEmbedding.length > 0 ? randomUUID() : null, + created_at: chunkStart, + last_seen: chunkEnd, + source: typeof chunk[0]?.metadata?.source === 'string' ? chunk[0].metadata.source : 'ingest', + ttl_days: Number.isFinite(chunk[0]?.metadata?.ttl_days) ? Number(chunk[0].metadata.ttl_days) : null, + merged_into: null, + pinned: false, + }); + + const validation = validateMemoryItem(record); + if (!validation.valid) { + console.error('memory_validation_error', { + stage: 'ingest', + reason: 'record_failed_schema_validation', + fields: validation.errors, + item: record, + }); + throw new TypeError('Memory ingest rejected: invalid durable record format'); + } + + await insertMemory(repository, record); + insertedRecords.push(record); + + if (record.embedding_id) { + const vectorMetadata = { + memory_id: record.id, + agent_id: record.agent_id, + summary: record.summary, + context_excerpt: contextExcerpt, + source: record.source, + created_at: record.created_at, + last_seen: record.last_seen, + }; + + if (typeof repository.linkEmbedding === 'function') { + await repository.linkEmbedding(record.id, { + id: record.embedding_id, + vector: summaryEmbedding, + metadata: vectorMetadata, + }); + } + + const adapter = options.embedderAdapter ?? options.adapter ?? getDefaultEmbedderAdapter(); + if (typeof adapter?.upsertVector === 'function') { + await adapter.upsertVector({ + id: record.embedding_id, + vector: summaryEmbedding, + metadata: vectorMetadata, + }); + } + } + + emitTelemetry('memory:ingested', { + memory_id: record.id, + agent_id: record.agent_id, + dedupe_key: dedupeKey, + chunk_events: chunk.length, + window_start: windowStart, + window_end: windowEnd, + }); + } + + await rememberWindowFingerprint(repository, dedupeKey, { + agent_id: agentId, + window_start: windowStart, + window_end: windowEnd, + }); + } + + return insertedRecords; +} + +/** + * @param {{ insertMemory: (memory: import('./schema.js').MemoryRecord) => Promise }} repository + * @param {import('./schema.js').MemoryRecord} memory + */ +export async function insertMemory(repository, memory) { + return repository.insertMemory(memory); +} diff --git a/torch/src/services/memory/pruner.js b/torch/src/services/memory/pruner.js new file mode 100644 index 00000000..bf996d6a --- /dev/null +++ b/torch/src/services/memory/pruner.js @@ -0,0 +1,313 @@ +import { loadMemoryPromptTemplates } from './summarizer.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * @typedef {'keep' | 'archive' | 'delete'} LifecycleAction + */ + +/** + * @param {number[] | null | undefined} a + * @param {number[] | null | undefined} b + */ +function cosineSimilarity(a, b) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) { + return 0; + } + + let dot = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < a.length; i += 1) { + const av = Number(a[i]) || 0; + const bv = Number(b[i]) || 0; + dot += av * bv; + normA += av * av; + normB += bv * bv; + } + + const denom = Math.sqrt(normA) * Math.sqrt(normB); + return denom > 0 ? dot / denom : 0; +} + +/** + * @param {string[]} left + * @param {string[]} right + */ +function hasTagOverlap(left = [], right = []) { + if (!left.length || !right.length) return false; + const rightSet = new Set(right); + return left.some((tag) => rightSet.has(tag)); +} + +/** + * @param {import('./schema.js').MemoryRecord} memory + * @param {{ now: number, retentionMs: number, lowImportanceThreshold: number, recentUsageMs: number }} policy + */ +function evaluateCandidate(memory, policy) { + const ttlExpired = Number.isInteger(memory.ttl_days) + ? memory.created_at + (memory.ttl_days * DAY_MS) <= policy.now + : false; + const staleByRetention = memory.last_seen < (policy.now - policy.retentionMs); + const lowImportance = memory.importance <= policy.lowImportanceThreshold; + const lowRecentUsage = memory.last_seen <= (policy.now - policy.recentUsageMs); + + return { + ttlExpired, + staleByRetention, + lowImportance, + lowRecentUsage, + candidate: !memory.pinned && (ttlExpired || staleByRetention) && lowImportance && lowRecentUsage, + }; +} + +/** + * @param {import('./schema.js').MemoryRecord[]} memories + * @param {{ getEmbedding?: (memory: import('./schema.js').MemoryRecord) => number[] | null | undefined, similarityThreshold: number, duplicateWindowMs: number }} options + */ +function groupNearDuplicates(memories, options) { + const groups = []; + const consumed = new Set(); + + for (let i = 0; i < memories.length; i += 1) { + const base = memories[i]; + if (consumed.has(base.id) || base.merged_into) continue; + + const baseEmbedding = options.getEmbedding?.(base); + const group = [base]; + + for (let j = i + 1; j < memories.length; j += 1) { + const probe = memories[j]; + if (consumed.has(probe.id) || probe.merged_into || probe.id === base.id) continue; + + const withinWindow = Math.abs(base.created_at - probe.created_at) <= options.duplicateWindowMs; + if (!withinWindow || !hasTagOverlap(base.tags, probe.tags)) continue; + + const similarity = cosineSimilarity(baseEmbedding, options.getEmbedding?.(probe)); + if (similarity >= options.similarityThreshold) { + group.push(probe); + } + } + + if (group.length > 1) { + group.forEach((item) => consumed.add(item.id)); + groups.push(group.sort((a, b) => b.last_seen - a.last_seen)); + } + } + + return groups; +} + +/** + * @param {import('./schema.js').MemoryRecord[]} group + * @param {(prompt: string) => Promise | string} [generateSummary] + * @param {string} condenseTemplate + */ +async function condenseGroup(group, generateSummary, condenseTemplate) { + const [primary, ...rest] = group; + const fallbackSummary = group.map((item) => item.summary || item.content).filter(Boolean).join(' ').slice(0, 280); + const fallback = { + summary: fallbackSummary || primary.summary, + importance: Math.max(...group.map((item) => item.importance)), + }; + + if (typeof generateSummary !== 'function') { + return { merged: { ...primary, summary: fallback.summary, importance: fallback.importance }, mergedIds: rest.map((item) => item.id), usedFallback: true }; + } + + const sourceBlock = group + .map((item) => `- id=${item.id}; tags=${item.tags.join(',')}; ts=${item.created_at}; summary=${item.summary || item.content}`) + .join('\n'); + const prompt = [ + 'Condense the following near-duplicate memories into one factual summary.', + 'Return STRICT JSON: {"summary":"...","importance":0.0}', + 'Do not invent facts.', + '', + `Memories:\n${sourceBlock}`, + '', + 'If your first response is malformed, repair it using this policy:', + condenseTemplate, + ].join('\n'); + + try { + const raw = await generateSummary(prompt); + const parsed = JSON.parse(raw); + const summary = typeof parsed?.summary === 'string' && parsed.summary.trim() ? parsed.summary.trim() : fallback.summary; + const importance = Number.isFinite(Number(parsed?.importance)) + ? Math.max(0, Math.min(1, Number(parsed.importance))) + : fallback.importance; + + return { + merged: { + ...primary, + summary, + importance, + tags: [...new Set(group.flatMap((item) => item.tags))], + last_seen: Math.max(...group.map((item) => item.last_seen)), + }, + mergedIds: rest.map((item) => item.id), + usedFallback: false, + }; + } catch { + return { merged: { ...primary, summary: fallback.summary, importance: fallback.importance }, mergedIds: rest.map((item) => item.id), usedFallback: true }; + } +} + +/** + * @param {import('./schema.js').MemoryRecord[]} memories + * @param {{ + * retentionMs: number, + * now?: number, + * lowImportanceThreshold?: number, + * deleteImportanceThreshold?: number, + * recentUsageMs?: number, + * duplicateWindowMs?: number, + * similarityThreshold?: number, + * getEmbedding?: (memory: import('./schema.js').MemoryRecord) => number[] | null | undefined, + * generateSummary?: (prompt: string) => Promise | string, + * templateDir?: string, + * }} options + */ +export async function createLifecyclePlan(memories, options) { + const now = options.now ?? Date.now(); + const policy = { + now, + retentionMs: options.retentionMs, + lowImportanceThreshold: options.lowImportanceThreshold ?? 0.35, + deleteImportanceThreshold: options.deleteImportanceThreshold ?? 0.12, + recentUsageMs: options.recentUsageMs ?? Math.max(Math.floor(options.retentionMs / 2), DAY_MS), + duplicateWindowMs: options.duplicateWindowMs ?? (7 * DAY_MS), + similarityThreshold: options.similarityThreshold ?? 0.93, + }; + + const candidateById = new Map(memories.map((memory) => [memory.id, evaluateCandidate(memory, policy)])); + const groups = groupNearDuplicates(memories.filter((memory) => !memory.pinned), { + getEmbedding: options.getEmbedding, + similarityThreshold: policy.similarityThreshold, + duplicateWindowMs: policy.duplicateWindowMs, + }); + + const templates = loadMemoryPromptTemplates({ templateDir: options.templateDir }); + const condensedGroups = []; + const mergedIntoMap = new Map(); + + for (const group of groups) { + const condensed = await condenseGroup(group, options.generateSummary, templates.condense); + condensedGroups.push(condensed); + for (const mergedId of condensed.mergedIds) { + mergedIntoMap.set(mergedId, condensed.merged.id); + } + } + + const protectedFromDelete = new Set(mergedIntoMap.keys()); + const actions = []; + const policyLogs = []; + + for (const memory of memories) { + const candidate = candidateById.get(memory.id) ?? evaluateCandidate(memory, policy); + + /** @type {LifecycleAction} */ + let action = 'keep'; + let reason = 'retained: does not satisfy low-value prune gates'; + + if (memory.pinned) { + action = 'keep'; + reason = 'retained: pinned memories are never deleted'; + } else if (mergedIntoMap.has(memory.id)) { + action = 'archive'; + reason = `archived: merged into ${mergedIntoMap.get(memory.id)} and retained as source provenance`; + } else if (candidate.candidate) { + const veryLowImportance = memory.importance <= policy.deleteImportanceThreshold; + const veryOld = memory.last_seen < (now - (policy.retentionMs * 2)); + const decisiveDelete = candidate.ttlExpired && candidate.staleByRetention && veryLowImportance && veryOld; + + if (protectedFromDelete.has(memory.id)) { + action = 'archive'; + reason = 'archived: referenced by retained merged summary'; + } else if (decisiveDelete) { + action = 'delete'; + reason = 'deleted: expired, very low importance, and long-unseen beyond retention grace'; + } else { + action = 'archive'; + reason = 'archived: borderline low-value candidate; archive preferred over delete'; + } + } + + actions.push({ id: memory.id, action, merged_into: mergedIntoMap.get(memory.id) ?? null, reason }); + policyLogs.push({ + id: memory.id, + action, + reason, + signals: { + ttlExpired: candidate.ttlExpired, + staleByRetention: candidate.staleByRetention, + lowImportance: candidate.lowImportance, + lowRecentUsage: candidate.lowRecentUsage, + }, + }); + } + + return { + actions, + policyLogs, + condensedGroups, + }; +} + +/** + * @param {{ + * archiveMemory?: (id: string, reason: string) => Promise | unknown, + * deleteMemory?: (id: string, reason: string) => Promise | unknown, + * keepMemory?: (id: string, reason: string) => Promise | unknown, + * markMerged?: (id: string, mergedInto: string) => Promise | unknown, + * }} repository + * @param {Awaited>} plan + */ +export async function applyLifecycleActions(repository, plan) { + const results = []; + + for (const decision of plan.actions) { + if (decision.merged_into && typeof repository.markMerged === 'function') { + await repository.markMerged(decision.id, decision.merged_into); + } + + if (decision.action === 'archive') { + await repository.archiveMemory?.(decision.id, decision.reason); + } else if (decision.action === 'delete') { + await repository.deleteMemory?.(decision.id, decision.reason); + } else { + await repository.keepMemory?.(decision.id, decision.reason); + } + + results.push(decision); + } + + return { + applied: results, + policyLogs: plan.policyLogs, + }; +} + +/** + * @param {import('./schema.js').MemoryRecord[]} memories + * @param {{ retentionMs: number, now?: number }} options + * @returns {import('./schema.js').MemoryRecord[]} + */ +export function selectPrunableMemories(memories, options) { + const now = options.now ?? Date.now(); + const cutoff = now - options.retentionMs; + + return memories.filter((memory) => !memory.pinned && memory.last_seen < cutoff); +} + + +/** + * @param {{ listPruneCandidates: (options: { cutoff: number, limit?: number }) => Promise }} repository + * @param {{ retentionMs: number, now?: number, limit?: number }} options + */ +export async function listPruneCandidates(repository, options) { + const now = options.now ?? Date.now(); + const cutoff = now - options.retentionMs; + return repository.listPruneCandidates({ cutoff, limit: options.limit }); +} diff --git a/torch/src/services/memory/retriever.js b/torch/src/services/memory/retriever.js new file mode 100644 index 00000000..8b3d76f4 --- /dev/null +++ b/torch/src/services/memory/retriever.js @@ -0,0 +1,181 @@ +const DEFAULT_WEIGHTS = { + w_sim: 0.6, + w_imp: 0.25, + w_rec: 0.15, + pinBoost: 0.15, +}; + +const DEFAULT_RECENCY_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 7; + +function cosineSimilarity(left = [], right = []) { + const size = Math.max(left.length, right.length); + let dot = 0; + let leftNorm = 0; + let rightNorm = 0; + + for (let index = 0; index < size; index += 1) { + const leftValue = Number(left[index] ?? 0); + const rightValue = Number(right[index] ?? 0); + dot += leftValue * rightValue; + leftNorm += leftValue ** 2; + rightNorm += rightValue ** 2; + } + + if (leftNorm === 0 || rightNorm === 0) return 0; + return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm)); +} + +function computeRecencyDecay(timestamp, now, halfLifeMs) { + if (!Number.isFinite(timestamp) || !Number.isFinite(now) || halfLifeMs <= 0) return 0; + const age = Math.max(0, now - timestamp); + return Math.exp(-(age / halfLifeMs)); +} + +function textMatchSimilarity(query, memory) { + const normalizedQuery = String(query ?? '').trim().toLowerCase(); + if (!normalizedQuery) return 0; + + const haystack = `${memory.summary} ${memory.content}`.toLowerCase(); + if (haystack.includes(normalizedQuery)) return 1; + + const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean); + if (queryTerms.length === 0) return 0; + + const matchedTerms = queryTerms.filter((term) => haystack.includes(term)).length; + return matchedTerms / queryTerms.length; +} + +/** + * @param {import('./schema.js').MemoryRecord} memory + * @param {{ tags: string[], timeframe?: { from?: number, to?: number }, pinnedPreference?: 'prefer' | 'only' | 'exclude' }} params + */ +function matchesMetadataFilters(memory, params) { + const inTimeframe = !params.timeframe + || ((params.timeframe.from == null || memory.created_at >= params.timeframe.from) + && (params.timeframe.to == null || memory.created_at <= params.timeframe.to)); + + if (!inTimeframe) return false; + + if (params.tags.length > 0) { + const memoryTags = new Set(memory.tags); + const matchesAllTags = params.tags.every((tag) => memoryTags.has(tag)); + if (!matchesAllTags) return false; + } + + if (params.pinnedPreference === 'only' && !memory.pinned) return false; + if (params.pinnedPreference === 'exclude' && memory.pinned) return false; + + return true; +} + +/** + * @param {import('./schema.js').MemoryRecord[]} memories + * @param {{ query: string, agent_id: string, k: number, vectorAdapter?: { embedText: (text: string) => Promise | number[], queryVector: (input: { vector: number[], k?: number, filter?: Record }) => Promise> | Array<{ id: string, score: number }> }, embedText?: (value: string) => Promise | number[] }} params + */ +async function buildSimilarityIndex(memories, params) { + const similarityById = new Map(); + const normalizedQuery = params.query.trim(); + + if (!normalizedQuery) { + return similarityById; + } + + if (params.vectorAdapter && typeof params.vectorAdapter.queryVector === 'function') { + const queryVector = typeof params.embedText === 'function' + ? await params.embedText(normalizedQuery) + : await params.vectorAdapter.embedText(normalizedQuery); + + const vectorHits = await params.vectorAdapter.queryVector({ + vector: queryVector, + k: Math.max(params.k * 4, memories.length), + filter: { agent_id: params.agent_id }, + }); + + for (const hit of vectorHits) { + if (typeof hit.id === 'string' && Number.isFinite(hit.score)) { + similarityById.set(hit.id, Math.max(0, Number(hit.score))); + } + } + + if (similarityById.size > 0) { + return similarityById; + } + } + + for (const memory of memories) { + const lexicalScore = textMatchSimilarity(normalizedQuery, memory); + if (lexicalScore > 0) { + similarityById.set(memory.id, lexicalScore); + } + } + + return similarityById; +} + +/** + * @param {import('./schema.js').MemoryRecord[]} memories + * @param {{ agent_id: string, query?: string, tags?: string[], timeframe?: { from?: number, to?: number }, k?: number, pinnedPreference?: 'prefer' | 'only' | 'exclude', weights?: { w_sim?: number, w_imp?: number, w_rec?: number, pinBoost?: number }, now?: number, recencyHalfLifeMs?: number, vectorAdapter?: { embedText: (text: string) => Promise | number[], queryVector: (input: { vector: number[], k?: number, filter?: Record }) => Promise> | Array<{ id: string, score: number }> }, embedText?: (value: string) => Promise | number[] }} params + * @returns {Promise} + */ +export async function filterAndRankMemories(memories, params) { + const { + agent_id, + query = '', + tags = [], + timeframe, + k = 10, + pinnedPreference = 'prefer', + weights = {}, + now = Date.now(), + recencyHalfLifeMs = DEFAULT_RECENCY_HALF_LIFE_MS, + vectorAdapter, + embedText, + } = params; + + const mergedWeights = { + ...DEFAULT_WEIGHTS, + ...weights, + }; + + const filtered = memories.filter((memory) => { + if (memory.agent_id !== agent_id) return false; + return matchesMetadataFilters(memory, { tags, timeframe, pinnedPreference }); + }); + + const similarityById = await buildSimilarityIndex(filtered, { + query, + agent_id, + k, + vectorAdapter, + embedText, + }); + + return filtered + .map((memory) => { + const memoryTimestamp = Number.isFinite(memory.last_seen) ? memory.last_seen : memory.created_at; + const recencyDecay = computeRecencyDecay(memoryTimestamp, now, recencyHalfLifeMs); + const similarity = similarityById.get(memory.id) ?? 0; + const pinBoost = memory.pinned && pinnedPreference !== 'exclude' ? mergedWeights.pinBoost : 0; + + const score = (similarity * mergedWeights.w_sim) + + (memory.importance * mergedWeights.w_imp) + + (recencyDecay * mergedWeights.w_rec) + + pinBoost; + + return { memory, score }; + }) + .sort((left, right) => right.score - left.score) + .slice(0, k) + .map((entry) => entry.memory); +} + +/** + * @param {{ updateMemoryUsage: (id: string, lastSeen?: number) => Promise }} repository + * @param {string[]} ids + * @param {number} [lastSeen] + */ +export async function updateMemoryUsage(repository, ids, lastSeen = Date.now()) { + await Promise.all(ids.map((id) => repository.updateMemoryUsage(id, lastSeen))); +} + +export { cosineSimilarity }; diff --git a/torch/src/services/memory/scheduler.js b/torch/src/services/memory/scheduler.js new file mode 100644 index 00000000..533734d9 --- /dev/null +++ b/torch/src/services/memory/scheduler.js @@ -0,0 +1,317 @@ +import { getMemoryPruneMode, isMemoryEnabled, isMemoryIngestEnabled } from './feature-flags.js'; +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const WEEK_MS = 7 * DAY_MS; + +const JOBS = { + ingestRecentRuntimeEvents: { + lockKey: 'memory:job:ingest-recent-runtime-events', + minIntervalMs: MINUTE_MS, + maxIntervalMs: 5 * MINUTE_MS, + }, + consolidateObservations: { + lockKey: 'memory:job:consolidate-observations', + intervalMs: HOUR_MS, + }, + pruningCycle: { + lockKey: 'memory:job:pruning-cycle', + intervalMs: DAY_MS, + }, + deepMergeArchivalMaintenance: { + lockKey: 'memory:job:deep-merge-archival-maintenance', + intervalMs: WEEK_MS, + }, +}; + +/** + * @param {() => Promise | unknown} task + * @param {{ intervalMs: number }} options + * @returns {{ stop: () => void }} + */ +export function startScheduler(task, options) { + const handle = setInterval(() => { + void task(); + }, options.intervalMs); + + return { + stop() { + clearInterval(handle); + }, + }; +} + +function normalizeItemCount(result) { + if (typeof result === 'number' && Number.isFinite(result)) { + return Math.max(0, Math.floor(result)); + } + + if (result && typeof result === 'object') { + const count = result.itemCount ?? result.count ?? result.processed ?? result.rows; + if (typeof count === 'number' && Number.isFinite(count)) { + return Math.max(0, Math.floor(count)); + } + } + + return 0; +} + +/** + * @param {string} key + */ +function hashLockKey(key) { + let hash = 0; + for (let i = 0; i < key.length; i += 1) { + hash = ((hash * 31) + key.charCodeAt(i)) | 0; + } + return hash; +} + +/** + * @param {{ query: (sql: string, params?: unknown[]) => Promise<{ rows?: any[] }> }} db + * @param {{ namespace?: string }} [options] + */ +export function createDbAdvisoryLockProvider(db, options = {}) { + const namespace = options.namespace ?? 'torch-memory-jobs'; + + return { + /** + * @param {string} key + * @param {() => Promise} task + */ + async withLock(key, task) { + const lockId = `${namespace}:${key}`; + const keyA = hashLockKey(namespace); + const keyB = hashLockKey(lockId); + const acquiredResult = await db.query('SELECT pg_try_advisory_lock($1, $2) AS locked', [keyA, keyB]); + const acquired = Boolean(acquiredResult?.rows?.[0]?.locked); + if (!acquired) { + return { acquired: false, result: null }; + } + + try { + const result = await task(); + return { acquired: true, result }; + } finally { + await db.query('SELECT pg_advisory_unlock($1, $2)', [keyA, keyB]); + } + }, + }; +} + +function createInMemoryLockProvider() { + const locks = new Set(); + return { + async withLock(key, task) { + if (locks.has(key)) { + return { acquired: false, result: null }; + } + + locks.add(key); + try { + const result = await task(); + return { acquired: true, result }; + } finally { + locks.delete(key); + } + }, + }; +} + +/** + * @param {{ + * jobName: string, + * lockKey: string, + * handler: () => Promise | unknown, + * lockProvider: { withLock: (key: string, task: () => Promise) => Promise<{ acquired: boolean, result: unknown }> }, + * maxRetries: number, + * retryDelayMs: number, + * emitMetric?: (metricName: string, payload: Record) => void, + * }} options + */ +async function runScheduledJob(options) { + const startedAt = Date.now(); + let failures = 0; + let retries = 0; + + for (let attempt = 0; attempt <= options.maxRetries; attempt += 1) { + try { + const lockRun = await options.lockProvider.withLock(options.lockKey, async () => options.handler()); + + if (!lockRun.acquired) { + options.emitMetric?.('memory_scheduler_job', { + job: options.jobName, + status: 'skipped_lock_unavailable', + durationMs: Date.now() - startedAt, + itemCount: 0, + failures, + retries, + }); + return; + } + + options.emitMetric?.('memory_scheduler_job', { + job: options.jobName, + status: 'success', + durationMs: Date.now() - startedAt, + itemCount: normalizeItemCount(lockRun.result), + failures, + retries, + }); + return; + } catch (error) { + failures += 1; + if (attempt < options.maxRetries) { + retries += 1; + await new Promise((resolve) => { + setTimeout(resolve, options.retryDelayMs); + }); + continue; + } + + options.emitMetric?.('memory_scheduler_job', { + job: options.jobName, + status: 'failed', + durationMs: Date.now() - startedAt, + itemCount: 0, + failures, + retries, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + } +} + +function shouldRunJob(jobName, options) { + const env = options.env ?? process.env; + if (!isMemoryEnabled(env)) return false; + + if (jobName === 'ingestRecentRuntimeEvents') { + return isMemoryIngestEnabled(options.ingestAgentId, env); + } + + if (jobName === 'pruningCycle') { + return getMemoryPruneMode(env) !== 'off'; + } + + return true; +} + +/** + * @param {{ + * handlers: { + * ingestRecentRuntimeEvents: () => Promise | unknown, + * consolidateObservations: () => Promise | unknown, + * pruningCycle: () => Promise | unknown, + * deepMergeArchivalMaintenance: () => Promise | unknown, + * }, + * lockProvider?: { withLock: (key: string, task: () => Promise) => Promise<{ acquired: boolean, result: unknown }> }, + * emitMetric?: (metricName: string, payload: Record) => void, + * maxRetries?: number, + * retryDelayMs?: number, + * runImmediately?: boolean, + * random?: () => number, + * }} options + */ +export function startMemoryMaintenanceScheduler(options) { + const lockProvider = options.lockProvider ?? createInMemoryLockProvider(); + const maxRetries = Number.isFinite(options.maxRetries) ? Math.max(0, Math.floor(options.maxRetries)) : 2; + const retryDelayMs = Number.isFinite(options.retryDelayMs) ? Math.max(0, Math.floor(options.retryDelayMs)) : 1_000; + const random = typeof options.random === 'function' ? options.random : Math.random; + const handles = []; + const timeouts = new Set(); + + const runJob = async (jobName, definition, handler) => runScheduledJob({ + jobName, + lockKey: definition.lockKey, + handler, + lockProvider, + maxRetries, + retryDelayMs, + emitMetric: options.emitMetric, + }); + + const startFixedIntervalJob = (jobName, definition, handler) => { + const run = () => { + if (!shouldRunJob(jobName, options)) { + options.emitMetric?.('memory_scheduler_job', { + job: jobName, + status: 'skipped_flag_disabled', + durationMs: 0, + itemCount: 0, + failures: 0, + retries: 0, + }); + return; + } + void runJob(jobName, definition, handler); + }; + + if (options.runImmediately !== false) run(); + const intervalHandle = setInterval(run, definition.intervalMs); + handles.push(() => clearInterval(intervalHandle)); + }; + + const startRandomIntervalJob = (jobName, definition, handler) => { + const scheduleNext = () => { + const jitterRatio = Math.min(1, Math.max(0, random())); + const delayMs = definition.minIntervalMs + Math.floor((definition.maxIntervalMs - definition.minIntervalMs) * jitterRatio); + const timeout = setTimeout(async () => { + timeouts.delete(timeout); + if (!shouldRunJob(jobName, options)) { + options.emitMetric?.('memory_scheduler_job', { + job: jobName, + status: 'skipped_flag_disabled', + durationMs: 0, + itemCount: 0, + failures: 0, + retries: 0, + }); + scheduleNext(); + return; + } + await runJob(jobName, definition, handler); + scheduleNext(); + }, delayMs); + timeouts.add(timeout); + }; + + if (options.runImmediately !== false) { + if (!shouldRunJob(jobName, options)) { + options.emitMetric?.('memory_scheduler_job', { + job: jobName, + status: 'skipped_flag_disabled', + durationMs: 0, + itemCount: 0, + failures: 0, + retries: 0, + }); + scheduleNext(); + return; + } + void runJob(jobName, definition, handler).then(() => { + scheduleNext(); + }); + } else { + scheduleNext(); + } + }; + + startRandomIntervalJob('ingestRecentRuntimeEvents', JOBS.ingestRecentRuntimeEvents, options.handlers.ingestRecentRuntimeEvents); + startFixedIntervalJob('consolidateObservations', JOBS.consolidateObservations, options.handlers.consolidateObservations); + startFixedIntervalJob('pruningCycle', JOBS.pruningCycle, options.handlers.pruningCycle); + startFixedIntervalJob('deepMergeArchivalMaintenance', JOBS.deepMergeArchivalMaintenance, options.handlers.deepMergeArchivalMaintenance); + + return { + stop() { + for (const stopHandle of handles) { + stopHandle(); + } + for (const timeout of timeouts) { + clearTimeout(timeout); + } + timeouts.clear(); + }, + }; +} diff --git a/torch/src/services/memory/schema.js b/torch/src/services/memory/schema.js new file mode 100644 index 00000000..9aa53f85 --- /dev/null +++ b/torch/src/services/memory/schema.js @@ -0,0 +1,134 @@ +/** + * @typedef {Object} MemoryEvent + * @property {string} agent_id + * @property {string} content + * @property {number} [timestamp] + * @property {string[]} [tags] + * @property {Record} [metadata] + */ + +export const SCHEMA_VERSION = 1; + +/** + * @typedef {Object} MemoryRecord + * @property {number} schema_version + * @property {string} id + * @property {string} agent_id + * @property {string} session_id + * @property {string} type + * @property {string} content + * @property {string} summary + * @property {string[]} tags + * @property {number} importance + * @property {string | null} embedding_id + * @property {number} created_at + * @property {number} last_seen + * @property {string} source + * @property {number | null} ttl_days + * @property {string | null} merged_into + * @property {boolean} pinned + */ + +/** + * @param {MemoryEvent} event + * @returns {MemoryEvent} + */ +export function normalizeEvent(event) { + return { + ...event, + timestamp: Number.isFinite(event?.timestamp) ? Number(event.timestamp) : Date.now(), + tags: Array.isArray(event?.tags) + ? event.tags.map((tag) => String(tag)).filter((tag) => tag.length > 0) + : [], + metadata: event?.metadata && typeof event.metadata === 'object' ? event.metadata : {}, + }; +} + +/** + * @param {Partial} input + * @returns {MemoryRecord} + */ +export function normalizeMemoryItem(input = {}) { + const now = Date.now(); + + return { + schema_version: SCHEMA_VERSION, + id: typeof input.id === 'string' && input.id.trim() ? input.id.trim() : crypto.randomUUID(), + agent_id: typeof input.agent_id === 'string' ? input.agent_id.trim() : '', + session_id: typeof input.session_id === 'string' ? input.session_id.trim() : 'unknown', + type: typeof input.type === 'string' && input.type.trim() ? input.type.trim() : 'event', + content: typeof input.content === 'string' ? input.content : '', + summary: typeof input.summary === 'string' ? input.summary : '', + tags: Array.isArray(input.tags) + ? input.tags.map((tag) => String(tag).trim()).filter((tag) => tag.length > 0) + : [], + importance: Number.isFinite(input.importance) + ? Math.max(0, Math.min(1, Number(input.importance))) + : 0.5, + embedding_id: typeof input.embedding_id === 'string' && input.embedding_id.trim() + ? input.embedding_id.trim() + : null, + created_at: Number.isFinite(input.created_at) ? Number(input.created_at) : now, + last_seen: Number.isFinite(input.last_seen) ? Number(input.last_seen) : now, + source: typeof input.source === 'string' && input.source.trim() ? input.source.trim() : 'ingest', + ttl_days: Number.isFinite(input.ttl_days) ? Math.max(0, Math.floor(Number(input.ttl_days))) : null, + merged_into: typeof input.merged_into === 'string' && input.merged_into.trim() + ? input.merged_into.trim() + : null, + pinned: Boolean(input.pinned), + }; +} + +/** + * @param {unknown} item + * @returns {{ valid: boolean, errors: Array<{ field: string, expected: string, received: string, value: unknown }> }} + */ +export function validateMemoryItem(item) { + const errors = []; + + const check = (condition, field, expected, value) => { + if (!condition) { + errors.push({ + field, + expected, + received: Array.isArray(value) ? 'array' : typeof value, + value, + }); + } + }; + + check(item && typeof item === 'object', 'item', 'object', item); + if (!item || typeof item !== 'object') { + return { valid: false, errors }; + } + + check(item.schema_version === SCHEMA_VERSION, 'schema_version', `number (${SCHEMA_VERSION})`, item.schema_version); + check(typeof item.id === 'string' && item.id.length > 0, 'id', 'non-empty string', item.id); + check(typeof item.agent_id === 'string' && item.agent_id.length > 0, 'agent_id', 'non-empty string', item.agent_id); + check(typeof item.session_id === 'string' && item.session_id.length > 0, 'session_id', 'non-empty string', item.session_id); + check(typeof item.type === 'string' && item.type.length > 0, 'type', 'non-empty string', item.type); + check(typeof item.content === 'string', 'content', 'string', item.content); + check(typeof item.summary === 'string', 'summary', 'string', item.summary); + check(Array.isArray(item.tags) && item.tags.every((tag) => typeof tag === 'string'), 'tags', 'string[]', item.tags); + check(Number.isFinite(item.importance) && item.importance >= 0 && item.importance <= 1, 'importance', 'number between 0 and 1', item.importance); + check(item.embedding_id === null || (typeof item.embedding_id === 'string' && item.embedding_id.length > 0), 'embedding_id', 'null or non-empty string', item.embedding_id); + check(Number.isFinite(item.created_at), 'created_at', 'finite number', item.created_at); + check(Number.isFinite(item.last_seen), 'last_seen', 'finite number', item.last_seen); + check(typeof item.source === 'string' && item.source.length > 0, 'source', 'non-empty string', item.source); + check(item.ttl_days === null || (Number.isInteger(item.ttl_days) && item.ttl_days >= 0), 'ttl_days', 'null or non-negative integer', item.ttl_days); + check(item.merged_into === null || (typeof item.merged_into === 'string' && item.merged_into.length > 0), 'merged_into', 'null or non-empty string', item.merged_into); + check(typeof item.pinned === 'boolean', 'pinned', 'boolean', item.pinned); + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * @param {Partial} record + * @returns {MemoryRecord} + */ +export function createMemoryRecord(record) { + return normalizeMemoryItem(record); +} diff --git a/torch/src/services/memory/summarizer.js b/torch/src/services/memory/summarizer.js new file mode 100644 index 00000000..0f128509 --- /dev/null +++ b/torch/src/services/memory/summarizer.js @@ -0,0 +1,151 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const DEFAULT_TEMPLATE_DIR = path.resolve(__dirname, '../../prompts/memory'); +const TEMPLATE_FILES = { + summarize: 'summarize_prompt.txt', + condense: 'condense_prompt.txt', + score: 'score_prompt.txt', + prune: 'prune_prompt.txt', +}; + +const templateCache = new Map(); + +function redactObviousPii(text) { + if (typeof text !== 'string' || text.length === 0) return ''; + return text + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted:email]') + .replace(/\b(?:\+?\d{1,2}[\s.-]?)?(?:\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})\b/g, '[redacted:phone]') + .replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[redacted:ssn]'); +} + +function renderTemplate(template, replacements) { + return Object.entries(replacements).reduce( + (output, [key, value]) => output.replaceAll(`{{${key}}}`, String(value ?? '')), + template + ); +} + +export function loadMemoryPromptTemplates(options = {}) { + const templateDir = options.templateDir ?? DEFAULT_TEMPLATE_DIR; + if (templateCache.has(templateDir)) { + return templateCache.get(templateDir); + } + + const templates = Object.fromEntries( + Object.entries(TEMPLATE_FILES).map(([key, fileName]) => { + const filePath = path.join(templateDir, fileName); + const content = readFileSync(filePath, 'utf8').trim(); + return [key, content]; + }) + ); + + templateCache.set(templateDir, templates); + return templates; +} + +function parseSummaryPayload(rawResponse) { + const parsed = JSON.parse(rawResponse); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new TypeError('Response must be a JSON object'); + } + + const summary = typeof parsed.summary === 'string' ? parsed.summary.trim() : ''; + if (!summary) { + throw new TypeError('Response.summary must be a non-empty string'); + } + + const rawImportance = Number(parsed.importance); + const importance = Number.isFinite(rawImportance) + ? Math.min(1, Math.max(0, rawImportance)) + : 0.25; + + return { summary, importance }; +} + +function deterministicFallback(events, maxSummaryLength) { + const joined = events.map((event) => redactObviousPii(event.content)).join(' ').trim(); + const bounded = joined.length > maxSummaryLength ? `${joined.slice(0, maxSummaryLength - 1)}…` : joined; + return { summary: bounded, importance: 0.25 }; +} + +function isLikelyFactual(summary, sourceText) { + const sourceTokens = new Set( + sourceText + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((token) => token.length > 3) + ); + const summaryTokens = summary + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((token) => token.length > 3); + + if (!summaryTokens.length) return true; + const overlap = summaryTokens.filter((token) => sourceTokens.has(token)).length; + return overlap / summaryTokens.length >= 0.35; +} + +function sanitizeResult(parsed, sourceText, maxSummaryLength, fallback) { + const sanitizedSummary = redactObviousPii(parsed.summary); + if (!isLikelyFactual(sanitizedSummary, sourceText)) { + return fallback; + } + + return { + summary: sanitizedSummary.length > maxSummaryLength + ? `${sanitizedSummary.slice(0, maxSummaryLength - 1)}…` + : sanitizedSummary, + importance: parsed.importance, + }; +} + +/** + * @param {import('./schema.js').MemoryEvent[]} events + * @param {{ + * maxSummaryLength?: number, + * generateSummary?: (prompt: string) => Promise | string, + * templateDir?: string, + * }} [options] + * @returns {Promise<{ summary: string, importance: number }>} + */ +export async function summarizeEvents(events, options = {}) { + const maxSummaryLength = options.maxSummaryLength ?? 280; + const fallback = deterministicFallback(events, maxSummaryLength); + if (!fallback.summary) return fallback; + + const templates = loadMemoryPromptTemplates(options); + const sourceText = events.map((event) => redactObviousPii(event.content)).join('\n').trim(); + const generateSummary = options.generateSummary; + + if (typeof generateSummary !== 'function') { + return fallback; + } + + const summaryPrompt = renderTemplate(templates.summarize, { + EVENTS: sourceText, + MAX_SUMMARY_LENGTH: maxSummaryLength, + }); + + let firstResponse = ''; + try { + firstResponse = await generateSummary(summaryPrompt); + const parsed = parseSummaryPayload(firstResponse); + return sanitizeResult(parsed, sourceText, maxSummaryLength, fallback); + } catch (firstError) { + try { + const repairPrompt = renderTemplate(templates.condense, { + RAW_RESPONSE: firstResponse, + PARSE_ERROR: firstError instanceof Error ? firstError.message : 'unknown_parse_error', + }); + const repairedResponse = await generateSummary(repairPrompt); + const parsed = parseSummaryPayload(repairedResponse); + return sanitizeResult(parsed, sourceText, maxSummaryLength, fallback); + } catch { + return fallback; + } + } +} diff --git a/torch/src/todo/2026-02-13-dom-migration-todo.md b/torch/src/todo/2026-02-13-dom-migration-todo.md new file mode 100644 index 00000000..8d38192e --- /dev/null +++ b/torch/src/todo/2026-02-13-dom-migration-todo.md @@ -0,0 +1,12 @@ +# TODO — Rendering Migration (Example) + +## Pending +- [ ] Replace remaining string-based rendering paths. +- [ ] Add safe helper utilities where needed. +- [ ] Update related tests. + +## Completed +- [x] Audited current rendering entry points. + +## Blocked +- [ ] Confirm final acceptance criteria with maintainers. diff --git a/torch/src/todo/TODO_2026-02-14.md b/torch/src/todo/TODO_2026-02-14.md new file mode 100644 index 00000000..5be3b018 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-14.md @@ -0,0 +1,11 @@ +# TODO Checklist + +- [ ] Update `src/constants.mjs` with defaults. +- [ ] Update `src/torch-config.mjs` with config getters. +- [ ] Create `src/roster.mjs` with roster logic. +- [ ] Create `src/lock-ops.mjs` with lock operations. +- [ ] Create `src/dashboard.mjs` with dashboard logic. +- [ ] Update `src/lib.mjs` to import new modules. +- [ ] Verify `npm run lint` passes. +- [ ] Verify `npm test` passes. +- [ ] Update baseline using `scripts/check-file-size.mjs --update`. diff --git a/torch/src/todo/TODO_2026-02-14T04-53-53Z.md b/torch/src/todo/TODO_2026-02-14T04-53-53Z.md new file mode 100644 index 00000000..1f600ada --- /dev/null +++ b/torch/src/todo/TODO_2026-02-14T04-53-53Z.md @@ -0,0 +1 @@ +# Todo diff --git a/torch/src/todo/TODO_2026-02-14T06-04-19Z.md b/torch/src/todo/TODO_2026-02-14T06-04-19Z.md new file mode 100644 index 00000000..b882ef9c --- /dev/null +++ b/torch/src/todo/TODO_2026-02-14T06-04-19Z.md @@ -0,0 +1,6 @@ +# Audit Run TODO + +- [ ] Run check-file-size.mjs +- [ ] Run check-innerhtml.mjs +- [ ] Run npm run lint +- [ ] Create summary diff --git a/torch/src/todo/TODO_2026-02-14T13-01-37Z.md b/torch/src/todo/TODO_2026-02-14T13-01-37Z.md new file mode 100644 index 00000000..8bb31aac --- /dev/null +++ b/torch/src/todo/TODO_2026-02-14T13-01-37Z.md @@ -0,0 +1,5 @@ +# Todo: Content Audit 2026-02-14T13-01-37Z + +- [ ] Check if /content directory exists +- [ ] Inventory claims if content exists +- [ ] Verify claims if content exists diff --git a/torch/src/todo/TODO_2026-02-14T13-30-00Z.md b/torch/src/todo/TODO_2026-02-14T13-30-00Z.md new file mode 100644 index 00000000..01527ef8 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-14T13-30-00Z.md @@ -0,0 +1,11 @@ +# Decomposition TODO + +- [x] Create `src/errors.mjs` with `ExitError`. +- [x] Create `src/utils.mjs` with `todayDateStr`, `nowUnix`. +- [x] Create `src/cmd-check.mjs`. +- [x] Create `src/cmd-lock.mjs`. +- [x] Create `src/cmd-list.mjs`. +- [x] Update `src/lib.mjs` to import new modules. +- [x] Verify with lint. +- [ ] Verify with tests. +- [ ] Update file size baseline. diff --git a/torch/src/todo/TODO_2026-02-14T23-00-00Z.md b/torch/src/todo/TODO_2026-02-14T23-00-00Z.md new file mode 100644 index 00000000..542d8427 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-14T23-00-00Z.md @@ -0,0 +1,10 @@ +# TODO: dashboard/index.html innerHTML Migration + +- [ ] `summaryBar.innerHTML = cards.map(...).join('')` (Line ~436) + - Strategy: Replace with `document.createElement` loop and `replaceChildren`. +- [ ] `lockGrid.innerHTML = ''` (Line ~448) + - Strategy: `lockGrid.replaceChildren()` + `appendChild(emptyState)`. +- [ ] `lockGrid.innerHTML = locks.map(...).join('')` (Line ~455) + - Strategy: Create `createLockCard` function using `document.createElement`, then `lockGrid.replaceChildren(...cards)`. +- [ ] `rawLog.innerHTML = ''` (Line ~534) + - Strategy: `rawLog.replaceChildren()`. diff --git a/torch/src/todo/TODO_2026-02-15.md b/torch/src/todo/TODO_2026-02-15.md new file mode 100644 index 00000000..bc05a2e4 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15.md @@ -0,0 +1,19 @@ +# Todo List (2026-02-15) - Protocol Research Agent + +- **agent**: protocol-research-agent +- **cadence**: daily +- **run-start**: 2026-02-15T00:00:00Z +- **prompt**: src/prompts/daily/protocol-research-agent.md + +## Pending Tasks +- [x] Scan for protocol references. +- [x] Create `PROTOCOL_INVENTORY.md`. +- [x] Create `protocol-report-2026-02-15.md`. + +## Completed Tasks +- Scanned repository for `nostr-tools` and `ws` (executed by `protocol-research-agent`). +- Created inventory of protocols. +- Generated protocol report. + +## Blocked Tasks +- None. diff --git a/torch/src/todo/TODO_2026-02-15T01-22-33Z.md b/torch/src/todo/TODO_2026-02-15T01-22-33Z.md index 2c86eded..cb0d27cb 100644 --- a/torch/src/todo/TODO_2026-02-15T01-22-33Z.md +++ b/torch/src/todo/TODO_2026-02-15T01-22-33Z.md @@ -1,4 +1,5 @@ # TODO for Perf Agent Run 2026-02-15T01-22-33Z - [x] Scan codebase -- [x] Fix linting and formatting issues +- [x] Generate report +- [ ] Fix critical issues (none identified yet) diff --git a/torch/src/todo/TODO_2026-02-15T01:03:31Z.md b/torch/src/todo/TODO_2026-02-15T01:03:31Z.md new file mode 100644 index 00000000..999c23ca --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T01:03:31Z.md @@ -0,0 +1,14 @@ +--- +agent: onboarding-audit-agent +cadence: daily +run-start: 2026-02-15T01:03:31Z +prompt: src/prompts/daily/onboarding-audit-agent.md +--- + +# TODO Update - 2026-02-15T01:03:31Z + +Agent: onboarding-audit-agent +Prompt: src/prompts/daily/onboarding-audit-agent.md + +## Pending Tasks +- [ ] Fix prompt contract violations in `src/prompts/` files. diff --git a/torch/src/todo/TODO_2026-02-15T05-06-49Z.md b/torch/src/todo/TODO_2026-02-15T05-06-49Z.md new file mode 100644 index 00000000..59828dbd --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T05-06-49Z.md @@ -0,0 +1,18 @@ +--- +agent: prompt-fixer-agent +cadence: weekly +run-start: 2026-02-15T05:03:00Z +prompt: src/prompts/weekly/prompt-fixer-agent.md +--- +# Todo - Prompt Safety Fixes + +## Completed Tasks +- [x] Read `artifacts/prompt-safety-audit.md` +- [x] Identify agents needing fixes (24 total) +- [x] Create `fix_prompts.py` script to automate fixes +- [x] Apply fixes to 23 agents via script +- [x] Manually fix `protocol-research-agent.md` (no-op logic insertion) +- [x] Verify changes (checked `const-refactor-agent.md` and script output) +- [x] Generate run artifacts +- [ ] Run lint checks +- [ ] Complete lock diff --git a/torch/src/todo/TODO_2026-02-15T08-00-00Z.md b/torch/src/todo/TODO_2026-02-15T08-00-00Z.md new file mode 100644 index 00000000..1f600ada --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T08-00-00Z.md @@ -0,0 +1 @@ +# Todo diff --git a/torch/src/todo/TODO_2026-02-15T15-22-42Z.md b/torch/src/todo/TODO_2026-02-15T15-22-42Z.md new file mode 100644 index 00000000..bc7f30c4 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T15-22-42Z.md @@ -0,0 +1,17 @@ +--- +agent: race-condition-agent +cadence: weekly +run-start: 2026-02-15T15:22:42Z +prompt: src/prompts/weekly/race-condition-agent.md +--- +## Completed Tasks +- [x] Audit lock logic. +- [x] Implement fix in `src/lib.mjs`. +- [x] Add regression test in `test/lib.test.mjs`. +- [x] Verify tests pass. +- [x] Create weekly report. +- [x] Complete memory storage. + +## Pending Tasks +- [ ] Verify artifacts. +- [ ] Complete lock. diff --git a/torch/src/todo/TODO_2026-02-15T16-15-05Z.md b/torch/src/todo/TODO_2026-02-15T16-15-05Z.md new file mode 100644 index 00000000..f8957791 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T16-15-05Z.md @@ -0,0 +1,15 @@ +--- +agent: test-audit-agent +cadence: daily +run-start: 2026-02-15T16-15-05Z +prompt: src/prompts/daily/test-audit-agent.md +--- + +# Pending tasks +- [ ] Run tests with coverage +- [ ] Detect flakiness +- [ ] Run static analysis +- [ ] Generate report + +# Completed tasks +- [x] Initialize artifacts diff --git a/torch/src/todo/TODO_2026-02-15T18-00-00Z.md b/torch/src/todo/TODO_2026-02-15T18-00-00Z.md new file mode 100644 index 00000000..ade295c5 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T18-00-00Z.md @@ -0,0 +1,16 @@ +--- +agent: todo-triage-agent +cadence: daily +run-start: 2026-02-15T18:00:00Z +prompt: src/prompts/daily/todo-triage-agent.md +--- + +# Todo + +## Pending tasks +- None (Scan complete, no actionable items found). + +## Completed tasks +- Scan repository for TODOs. +- Generate inventory `artifacts/todos.txt`. +- Analyze findings (0 actionable items). diff --git a/torch/src/todo/TODO_2026-02-15T18-51-10Z.md b/torch/src/todo/TODO_2026-02-15T18-51-10Z.md new file mode 100644 index 00000000..c14e1d68 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T18-51-10Z.md @@ -0,0 +1,12 @@ +--- +agent: audit-agent +cadence: daily +run-start: 2026-02-15T18-51-10Z +prompt: src/prompts/daily/audit-agent.md +--- +# Pending Tasks + +- [ ] Run file size check +- [ ] Run innerHTML check +- [ ] Run lint check +- [ ] Synthesize summary diff --git a/torch/src/todo/TODO_2026-02-15T19-12-02Z.md b/torch/src/todo/TODO_2026-02-15T19-12-02Z.md new file mode 100644 index 00000000..9817903a --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T19-12-02Z.md @@ -0,0 +1,14 @@ +--- +agent: ci-health-agent +cadence: daily +run-start: 2026-02-15T19-12-02Z +prompt: src/prompts/daily/ci-health-agent.md +--- + +# Todo for ci-health-agent +## Pending +- None +## Completed +- Run tests (5 loops) +- Run lint +Prompt: src/prompts/daily/ci-health-agent.md diff --git a/torch/src/todo/TODO_2026-02-15T20-00-00Z.md b/torch/src/todo/TODO_2026-02-15T20-00-00Z.md new file mode 100644 index 00000000..b1182bc1 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T20-00-00Z.md @@ -0,0 +1,2 @@ +# Todo +- [x] Replace 30078 with KIND_APP_DATA in src/cmd-lock.mjs diff --git a/torch/src/todo/TODO_2026-02-15T23-20-19Z.md b/torch/src/todo/TODO_2026-02-15T23-20-19Z.md new file mode 100644 index 00000000..401c0e3a --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T23-20-19Z.md @@ -0,0 +1,16 @@ +--- +agent: decompose-agent +cadence: daily +run-start: 2026-02-15T23:20:19Z +prompt: src/prompts/daily/decompose-agent.md +--- + +# Pending tasks +- [ ] Run `scripts/check-file-size.mjs --report` +- [ ] Select largest file +- [ ] Verify recent changes via git log +- [ ] Plan decomposition blocks +- [ ] Create extraction plan + +# Completed tasks +- [x] Initialized artifacts diff --git a/torch/src/todo/TODO_2026-02-16.md b/torch/src/todo/TODO_2026-02-16.md new file mode 100644 index 00000000..f23f62f6 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-16.md @@ -0,0 +1,10 @@ +# TODO: perf-agent (2026-02-16) + +- [ ] Create context file. +- [ ] Run grep searches. +- [ ] Create hits file. +- [ ] Implement visibility gating in `dashboard/index.html`. +- [ ] Document decisions. +- [ ] Create test logs. +- [ ] Create daily report. +- [ ] Commit and push. diff --git a/torch/src/todo/TODO_2026-02-16T01-18-43Z.md b/torch/src/todo/TODO_2026-02-16T01-18-43Z.md new file mode 100644 index 00000000..06999e7a --- /dev/null +++ b/torch/src/todo/TODO_2026-02-16T01-18-43Z.md @@ -0,0 +1,13 @@ +# Todo + +**Agent**: docs-alignment-agent +**Cadence**: daily +**Session**: session-1771204722 +**Run-Start**: 2026-02-16T01-18-43Z +**Prompt**: src/prompts/daily/docs-alignment-agent.md + +## Pending Tasks +- [ ] Update other docs + +## Completed Tasks +- [x] Check README.md vs package.json diff --git a/torch/src/todo/TODO_2026-02-16T02-11-24Z.md b/torch/src/todo/TODO_2026-02-16T02-11-24Z.md new file mode 100644 index 00000000..55b2a199 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-16T02-11-24Z.md @@ -0,0 +1,12 @@ +# Todo +- Agent: docs-code-investigator +- Cadence: daily +- Run-Start: 2026-02-16T02:11:24Z +- Prompt Path: src/prompts/daily/docs-code-investigator.md + +## Pending Tasks +- [ ] [pending] Review `src/lock-ops.mjs` + +## Completed Tasks +- [x] [completed] Select candidate file (`src/lock-ops.mjs`) +- [x] [completed] Create overview document diff --git a/torch/src/todo/TODO_2026-02-17T00-00-00Z.md b/torch/src/todo/TODO_2026-02-17T00-00-00Z.md new file mode 100644 index 00000000..d349d292 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T00-00-00Z.md @@ -0,0 +1,15 @@ +--- +agent: perf-agent +cadence: daily +run-start: 2026-02-17T00-00-00Z +prompt: src/prompts/daily/perf-agent.md +--- +# Tasks + +## Pending Tasks +- [x] Analyze search results + +## Completed Tasks +- [x] Run search patterns + +References: src/prompts/daily/perf-agent.md diff --git a/torch/src/todo/TODO_2026-02-17T02-04-05Z.md b/torch/src/todo/TODO_2026-02-17T02-04-05Z.md new file mode 100644 index 00000000..8764d20d --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T02-04-05Z.md @@ -0,0 +1,5 @@ +# TODO for Scheduler Update Agent + +- [x] Scan prompt directories +- [x] Update roster.json +- [x] Update scheduler markdown tables diff --git a/torch/src/todo/TODO_2026-02-17T02-04-31Z.md b/torch/src/todo/TODO_2026-02-17T02-04-31Z.md new file mode 100644 index 00000000..491cee4f --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T02-04-31Z.md @@ -0,0 +1,10 @@ +# TODO for Scheduler Update Agent + +**Agent**: scheduler-update-agent +**Cadence**: daily +**Session**: manual-jules-1771293773 +Prompt: src/prompts/daily/scheduler-update-agent.md + +- [x] Scan prompt directories +- [x] Update roster.json +- [x] Update scheduler markdown tables diff --git a/torch/src/todo/TODO_2026-02-17T05-04-24Z.md b/torch/src/todo/TODO_2026-02-17T05-04-24Z.md new file mode 100644 index 00000000..a7cb26f3 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T05-04-24Z.md @@ -0,0 +1,15 @@ +# Todo +- Agent: changelog-agent +- Cadence: weekly +- Session: 2026-02-17T05-04-24Z +- Prompt Path: src/prompts/weekly/changelog-agent.md + +## Pending Tasks +- [x] Read git log +- [x] Classify changes +- [x] Create release draft +- [x] Create memory update + +## Completed Tasks +- Generated changelog draft +- Updated artifacts diff --git a/torch/src/todo/TODO_2026-02-17T15-00-00Z.md b/torch/src/todo/TODO_2026-02-17T15-00-00Z.md new file mode 100644 index 00000000..a26e55a8 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T15-00-00Z.md @@ -0,0 +1,17 @@ +# Todo - Feature Proposer Agent Run + +**Agent**: feature-proposer-agent +**Cadence**: weekly +**Run-Start**: 2026-02-17T15:00:00Z +**Prompt-Path**: src/prompts/weekly/feature-proposer-agent.md + +## Completed +- [x] Analyze repository for potential features. +- [x] Select `log-summary.mjs` as the target feature. +- [x] Create `features/` directory. +- [x] Implement `features/log-summary.mjs`. +- [x] Verify the script functionality. +- [x] Follow instructions in `src/prompts/weekly/feature-proposer-agent.md`. + +## Pending +- [ ] Incorporate into main CLI (future work). diff --git a/torch/src/todo/TODO_2026-02-17T16-37-11Z.md b/torch/src/todo/TODO_2026-02-17T16-37-11Z.md new file mode 100644 index 00000000..d0e1bbfb --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T16-37-11Z.md @@ -0,0 +1,13 @@ +--- +agent: torch-garbage-collection-agent +cadence: daily +session: 2026-02-17T16-37-11Z +run-start: 2026-02-17T16-37-11Z +--- +# TODO for torch-garbage-collection-agent + +Active prompt: src/prompts/daily/torch-garbage-collection-agent.md + +## Completed +- [x] Scan for stale files +- [x] Verify no stale files exist diff --git a/torch/src/todo/TODO_2026-02-17T18-04-00Z.md b/torch/src/todo/TODO_2026-02-17T18-04-00Z.md new file mode 100644 index 00000000..dfa09b8e --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T18-04-00Z.md @@ -0,0 +1,18 @@ +# Todo + + + +## Pending tasks +- [ ] Run memory retrieval +- [ ] Run audit scripts +- [ ] Parse results +- [ ] Generate summary +- [ ] Run memory storage + +## Completed tasks +- [x] Initialize artifacts +- [x] Run `src/prompts/daily/audit-agent.md` workflow diff --git a/torch/src/todo/TODO_2026-02-17T19-36-32Z.md b/torch/src/todo/TODO_2026-02-17T19-36-32Z.md new file mode 100644 index 00000000..c24aa23d --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T19-36-32Z.md @@ -0,0 +1,10 @@ +# Pending Tasks +- [ ] Run `npm test` to identify flakes. +- [ ] Fix identified flakes. +- [ ] Document findings. + +- agent: ci-health-agent +- cadence: daily +- session: 2026-02-17T19-36-32Z +- run-start: 2026-02-17T19-36-32Z +- prompt-path: src/prompts/daily/ci-health-agent.md diff --git a/torch/src/todo/TODO_2026-02-17T19-43-02Z.md b/torch/src/todo/TODO_2026-02-17T19-43-02Z.md new file mode 100644 index 00000000..96de603a --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T19-43-02Z.md @@ -0,0 +1 @@ +- [x] Extract lock-ops constants to src/constants.mjs diff --git a/torch/src/todo/TODO_2026-02-17T21-29-25Z.md b/torch/src/todo/TODO_2026-02-17T21-29-25Z.md new file mode 100644 index 00000000..00b76424 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T21-29-25Z.md @@ -0,0 +1,13 @@ +--- +agent: content-audit-agent +cadence: daily +run-start: 2026-02-17T21:29:25Z +prompt-path: src/prompts/daily/content-audit-agent.md +--- + +# Audit Tasks + +## Completed Tasks +- [x] Check for existence of `/content` directory +- [x] If exists, inventory claims (Skipped - missing content) +- [x] If missing, report finding and exit (Done) diff --git a/torch/src/todo/TODO_2026-02-17T22-29-00Z.md b/torch/src/todo/TODO_2026-02-17T22-29-00Z.md new file mode 100644 index 00000000..4b931687 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T22-29-00Z.md @@ -0,0 +1,20 @@ +# Todo: Decompose scripts/agent/run-scheduler-cycle.mjs + +- [ ] Extract `scripts/agent/scheduler-utils.mjs` + - [ ] `runCommand` + - [ ] `getSafeEnv` + - [ ] `sleep` + - [ ] `parseJsonFromOutput` + - [ ] `parseJsonEventsFromOutput` + - [ ] `redactSensitive` + - [ ] `excerptText` + - [ ] `parseNonNegativeInt` + - [ ] `parseBooleanFlag` + - [ ] `readJson` + - [ ] `normalizeStringList` +- [ ] Extract `scripts/agent/scheduler-lock.mjs` + - [ ] `acquireLockWithRetry` + - [ ] `summarizeLockFailureReasons` + - [ ] `classifyLockBackendError` + - [ ] `buildLockBackendRemediation` + - [ ] `runLockHealthPreflight` diff --git a/torch/src/todo/TODO_2026-02-17T23-13-04Z.md b/torch/src/todo/TODO_2026-02-17T23-13-04Z.md new file mode 100644 index 00000000..862a9b1b --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T23-13-04Z.md @@ -0,0 +1,11 @@ +# Tasks + +- Agent: deps-security-agent +- Cadence: daily +- Run Start: 2026-02-17T23-13-04Z +- Prompt: src/prompts/daily/deps-security-agent.md + +## Completed Tasks +- [x] Document `nostr-tools` update availability in report. +- [x] Document `eslint` vulnerabilities in report. +- [x] Create `artifacts/deps-report.md`. diff --git a/torch/src/todo/TODO_2026-02-18.md b/torch/src/todo/TODO_2026-02-18.md new file mode 100644 index 00000000..58b7ef1d --- /dev/null +++ b/torch/src/todo/TODO_2026-02-18.md @@ -0,0 +1,7 @@ +# Todo: Docs Agent Run 2026-02-18 + +- [ ] Create `AGENTS.md` defining repo-wide policies. +- [ ] Create `docs/event-schemas.md`. +- [ ] Create `docs/runtime-fallback.md`. +- [ ] Add `npm run format` script (e.g., using Prettier) to `package.json`. +- [ ] Investigate why `test:unit` is not standardized in `package.json`. diff --git a/torch/src/todo/TODO_daily_style-agent.md b/torch/src/todo/TODO_daily_style-agent.md new file mode 100644 index 00000000..3cd61042 --- /dev/null +++ b/torch/src/todo/TODO_daily_style-agent.md @@ -0,0 +1,16 @@ +--- +agent: style-agent +cadence: daily +run-start: 2026-02-15T08:00:00Z +prompt: src/prompts/daily/style-agent.md +--- + +# Todo: Style Agent Daily Run + +## Pending tasks + +## Completed tasks +- [x] Run `npm run lint` +- [x] Run `npm run lint -- --fix` (not needed) +- [x] Run `node scripts/check-innerhtml.mjs` +- [x] Document results in test log diff --git a/torch/src/todo/TODO_manual-jules-1771293773.md b/torch/src/todo/TODO_manual-jules-1771293773.md new file mode 100644 index 00000000..8e60593c --- /dev/null +++ b/torch/src/todo/TODO_manual-jules-1771293773.md @@ -0,0 +1,14 @@ +# TODO for Scheduler Update Agent + +**Agent**: scheduler-update-agent +**Cadence**: daily +**Session**: manual-jules-1771293773 +Prompt: src/prompts/daily/scheduler-update-agent.md + +## Pending Tasks +- None + +## Completed Tasks +- [x] Scan prompt directories +- [x] Update roster.json +- [x] Update scheduler markdown tables diff --git a/torch/src/todo/TODO_weekly-synthesis-agent.md b/torch/src/todo/TODO_weekly-synthesis-agent.md new file mode 100644 index 00000000..d17ad5ca --- /dev/null +++ b/torch/src/todo/TODO_weekly-synthesis-agent.md @@ -0,0 +1,10 @@ +# Todo: Weekly Synthesis Agent + +- [x] Acquire lock. +- [x] Retrieve memory. +- [x] Gather git logs. +- [x] Gather task logs. +- [x] Analyze data. +- [x] Generate weekly report. +- [ ] Store memory. +- [ ] Publish completion. diff --git a/torch/src/torch-config.mjs b/torch/src/torch-config.mjs new file mode 100644 index 00000000..a759051e --- /dev/null +++ b/torch/src/torch-config.mjs @@ -0,0 +1,311 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + DEFAULT_RELAYS, + DEFAULT_TTL, + DEFAULT_NAMESPACE, + DEFAULT_QUERY_TIMEOUT_MS, + DEFAULT_PUBLISH_TIMEOUT_MS, + DEFAULT_MIN_SUCCESSFUL_PUBLISHES, + DEFAULT_MIN_ACTIVE_RELAY_POOL, +} from './constants.mjs'; + +const DEFAULT_CONFIG_PATH = 'torch-config.json'; + +let cachedConfig = null; +const MIN_TIMEOUT_MS = 100; +const MAX_TIMEOUT_MS = 120_000; + +function parsePositiveInteger(value) { + if (!Number.isFinite(value) || value <= 0) return null; + return Math.floor(value); +} + +function parseStringList(value) { + if (!Array.isArray(value)) return null; + const parsed = value.map((item) => String(item).trim()).filter(Boolean); + return parsed.length > 0 ? parsed : null; +} + +function assertValidRelayUrl(relay, sourceLabel) { + let parsed; + try { + parsed = new URL(relay); + } catch { + throw new Error(`Invalid relay URL in ${sourceLabel}: "${relay}" (must be an absolute ws:// or wss:// URL)`); + } + if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') { + throw new Error(`Invalid relay URL in ${sourceLabel}: "${relay}" (protocol must be ws:// or wss://)`); + } +} + +function assertTimeoutInRange(value, sourceLabel) { + if (!Number.isInteger(value) || value < MIN_TIMEOUT_MS || value > MAX_TIMEOUT_MS) { + throw new Error( + `Invalid ${sourceLabel}: ${value} (must be an integer between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS} ms)`, + ); + } +} + +function assertPositiveCount(value, sourceLabel) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`Invalid ${sourceLabel}: ${value} (must be a positive integer)`); + } +} + +function normalizeCadence(value, fallback) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'daily' || normalized === 'weekly' || normalized === 'all' ? normalized : fallback; +} + +function normalizeStatus(value, fallback) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'active' || normalized === 'all' ? normalized : fallback; +} + + +export function getTorchConfigPath() { + const explicitPath = (process.env.TORCH_CONFIG_PATH || '').trim(); + if (explicitPath) return path.resolve(process.cwd(), explicitPath); + + const localPath = path.resolve(process.cwd(), DEFAULT_CONFIG_PATH); + if (fs.existsSync(localPath)) return localPath; + + const parentPath = path.resolve(process.cwd(), '..', DEFAULT_CONFIG_PATH); + if (fs.existsSync(parentPath)) return parentPath; + + return localPath; +} + +export function parseTorchConfig(raw, configPath = null) { + const nostrLock = raw.nostrLock || {}; + const dashboard = raw.dashboard || {}; + const scheduler = raw.scheduler || {}; + const firstPromptByCadence = scheduler.firstPromptByCadence || {}; + const paused = scheduler.paused || {}; + + return { + configPath, + raw, + nostrLock: { + namespace: typeof nostrLock.namespace === 'string' ? nostrLock.namespace.trim() : null, + relays: parseStringList(nostrLock.relays), + relayFallbacks: parseStringList(nostrLock.relayFallbacks), + ttlSeconds: Number.isFinite(nostrLock.ttlSeconds) && nostrLock.ttlSeconds > 0 + ? Math.floor(nostrLock.ttlSeconds) + : null, + queryTimeoutMs: parsePositiveInteger(nostrLock.queryTimeoutMs), + publishTimeoutMs: parsePositiveInteger(nostrLock.publishTimeoutMs), + minSuccessfulRelayPublishes: parsePositiveInteger(nostrLock.minSuccessfulRelayPublishes), + minActiveRelayPool: parsePositiveInteger(nostrLock.minActiveRelayPool), + dailyRoster: parseStringList(nostrLock.dailyRoster), + weeklyRoster: parseStringList(nostrLock.weeklyRoster), + }, + dashboard: { + defaultCadenceView: normalizeCadence(dashboard.defaultCadenceView, 'daily'), + defaultStatusView: normalizeStatus(dashboard.defaultStatusView, 'active'), + relays: parseStringList(dashboard.relays), + namespace: typeof dashboard.namespace === 'string' ? dashboard.namespace.trim() : null, + hashtag: typeof dashboard.hashtag === 'string' ? dashboard.hashtag.trim() : null, + auth: typeof dashboard.auth === 'string' ? dashboard.auth.trim() : null, + }, + scheduler: { + firstPromptByCadence: { + daily: typeof firstPromptByCadence.daily === 'string' ? firstPromptByCadence.daily.trim() : null, + weekly: typeof firstPromptByCadence.weekly === 'string' ? firstPromptByCadence.weekly.trim() : null, + }, + paused: { + daily: parseStringList(paused.daily) || [], + weekly: parseStringList(paused.weekly) || [], + }, + }, + }; +} + +/** @internal */ +export function _resetTorchConfigCache() { + cachedConfig = null; +} + +export async function loadTorchConfig(fileSystem = fs) { + if (cachedConfig) return cachedConfig; + + const configPath = getTorchConfigPath(); + let raw = {}; + + try { + const content = await fileSystem.promises.readFile(configPath, 'utf8'); + raw = JSON.parse(content); + } catch (err) { + if (err.code !== 'ENOENT') { + throw new Error(`Failed to parse ${configPath}: ${err.message}`, { cause: err }); + } + // If file missing (ENOENT), we use empty object defaults + } + + cachedConfig = parseTorchConfig(raw, configPath); + validateLockBackendConfig(cachedConfig); + return cachedConfig; +} + +function validateLockBackendConfig(config) { + const relays = config.nostrLock.relays || []; + const relayFallbacks = config.nostrLock.relayFallbacks || []; + + for (const relay of relays) { + assertValidRelayUrl(relay, 'nostrLock.relays'); + } + for (const relay of relayFallbacks) { + assertValidRelayUrl(relay, 'nostrLock.relayFallbacks'); + } + + if (config.nostrLock.queryTimeoutMs !== null) { + assertTimeoutInRange(config.nostrLock.queryTimeoutMs, 'nostrLock.queryTimeoutMs'); + } + if (config.nostrLock.publishTimeoutMs !== null) { + assertTimeoutInRange(config.nostrLock.publishTimeoutMs, 'nostrLock.publishTimeoutMs'); + } + if (config.nostrLock.minSuccessfulRelayPublishes !== null) { + assertPositiveCount(config.nostrLock.minSuccessfulRelayPublishes, 'nostrLock.minSuccessfulRelayPublishes'); + } + if (config.nostrLock.minActiveRelayPool !== null) { + assertPositiveCount(config.nostrLock.minActiveRelayPool, 'nostrLock.minActiveRelayPool'); + } +} + +function parseEnvRelayList(envValue, envName) { + const relays = envValue.split(',').map((r) => r.trim()).filter(Boolean); + for (const relay of relays) { + assertValidRelayUrl(relay, envName); + } + return relays; +} + +function parseEnvInteger(envValue, envName) { + const parsed = parseInt(envValue, 10); + if (Number.isNaN(parsed)) { + throw new Error(`Invalid ${envName}: "${envValue}" (must be an integer)`); + } + return parsed; +} + +function resolveIntegerConfig(envKey, configValue, defaultValue, validator, configLabel) { + const envValue = process.env[envKey]; + if (envValue) { + const parsed = parseEnvInteger(envValue, envKey); + validator(parsed, envKey); + return parsed; + } + const value = configValue || defaultValue; + validator(value, `effective ${configLabel}`); + return value; +} + +export async function getRelays() { + const config = await loadTorchConfig(); + const envRelays = process.env.NOSTR_LOCK_RELAYS; + if (envRelays) { + return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAYS'); + } + if (config.nostrLock.relays?.length) { + return config.nostrLock.relays; + } + for (const relay of DEFAULT_RELAYS) { + assertValidRelayUrl(relay, 'DEFAULT_RELAYS'); + } + return DEFAULT_RELAYS; +} + +export async function getRelayFallbacks() { + const config = await loadTorchConfig(); + const envRelays = process.env.NOSTR_LOCK_RELAY_FALLBACKS; + if (envRelays) { + return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAY_FALLBACKS'); + } + return config.nostrLock.relayFallbacks || []; +} + +export async function getNamespace() { + const config = await loadTorchConfig(); + const namespace = (process.env.NOSTR_LOCK_NAMESPACE || config.nostrLock.namespace || DEFAULT_NAMESPACE).trim(); + return namespace || DEFAULT_NAMESPACE; +} + +export async function getTtl() { + const config = await loadTorchConfig(); + const envTtl = process.env.NOSTR_LOCK_TTL; + if (envTtl) { + const parsed = parseInt(envTtl, 10); + if (!Number.isNaN(parsed) && parsed > 0) return parsed; + } + if (config.nostrLock.ttlSeconds) { + return config.nostrLock.ttlSeconds; + } + return DEFAULT_TTL; +} + +export async function getQueryTimeoutMs() { + const config = await loadTorchConfig(); + return resolveIntegerConfig( + 'NOSTR_LOCK_QUERY_TIMEOUT_MS', + config.nostrLock.queryTimeoutMs, + DEFAULT_QUERY_TIMEOUT_MS, + assertTimeoutInRange, + 'query timeout', + ); +} + +export async function getPublishTimeoutMs() { + const config = await loadTorchConfig(); + return resolveIntegerConfig( + 'NOSTR_LOCK_PUBLISH_TIMEOUT_MS', + config.nostrLock.publishTimeoutMs, + DEFAULT_PUBLISH_TIMEOUT_MS, + assertTimeoutInRange, + 'publish timeout', + ); +} + +export async function getMinSuccessfulRelayPublishes() { + const config = await loadTorchConfig(); + return resolveIntegerConfig( + 'NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES', + config.nostrLock.minSuccessfulRelayPublishes, + DEFAULT_MIN_SUCCESSFUL_PUBLISHES, + assertPositiveCount, + 'min successful relay publishes', + ); +} + +export async function getMinActiveRelayPool() { + const config = await loadTorchConfig(); + return resolveIntegerConfig( + 'NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL', + config.nostrLock.minActiveRelayPool, + DEFAULT_MIN_ACTIVE_RELAY_POOL, + assertPositiveCount, + 'min active relay pool', + ); +} + +export async function getHashtag() { + const config = await loadTorchConfig(); + const envValue = process.env.NOSTR_LOCK_HASHTAG; + if (envValue) { + return envValue.trim(); + } + if (config.dashboard.hashtag) { + return config.dashboard.hashtag; + } + const namespace = await getNamespace(); + return `${namespace}-agent-lock`; +} + +export async function getDashboardAuth() { + const config = await loadTorchConfig(); + const envValue = process.env.TORCH_DASHBOARD_AUTH; + if (envValue) { + return envValue.trim(); + } + return config.dashboard.auth; +} diff --git a/torch/src/utils.mjs b/torch/src/utils.mjs new file mode 100644 index 00000000..4b7c76c7 --- /dev/null +++ b/torch/src/utils.mjs @@ -0,0 +1,36 @@ +export function todayDateStr() { + return new Date().toISOString().slice(0, 10); +} + +export function nowUnix() { + return Math.floor(Date.now() / 1000); +} + +export function getIsoWeekStr(dateInput) { + // Parse input to ensure we work with a Date object + // If dateInput is YYYY-MM-DD string, new Date() treats it as UTC. + const d = dateInput ? new Date(dateInput) : new Date(); + + // If valid date, use UTC components to avoid local timezone issues + if (isNaN(d.getTime())) return ''; + + // Copy date so we don't mutate the original if passed by reference (though new Date() handles that) + const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + + // ISO week date system: Thursday determines the year + // day: 0 (Sun) -> 6 (Sat). We want 1 (Mon) -> 7 (Sun) + const day = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - day); + + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil((((date - yearStart) / 86400000) + 1) / 7); + + return `${date.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; +} + +export function detectPlatform() { + if (process.env.JULES_API_KEY || process.env.JULES_SESSION_ID) return 'jules'; + if (process.env.CODEX_API_KEY || process.env.CODEX_SESSION_ID) return 'codex'; + if (process.env.GOOSE_API_KEY || process.env.GOOSE_SESSION_ID) return 'goose'; + return null; +}