diff --git a/README.md b/README.md index 1ce119e..dd0da87 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,14 @@ too. Account-channel provisioning still needs an interactive `agentage setup` se - [`docs/reference.md`](docs/reference.md) - every command and flag, the full sync options, MCP details, and environment variables. +## Telemetry (off by default) + +Nothing leaves your machine unless you ask for it. The CLI and its local helper ship with +`@agentage/observability`, which stays inert until `OTEL_EXPORTER_OTLP_ENDPOINT` points at a +collector you choose - then the CLI reports as `agentage-cli` and the helper as `agentage-daemon` +(override with `OTEL_SERVICE_NAME`). Every line it writes goes to stderr, so `--json` output and the +helper's MCP wire stay clean. + ## Development ```bash diff --git a/package-lock.json b/package-lock.json index 4a97add..b15054c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "MIT", "dependencies": { "@agentage/memory-core": "^0.5.0", + "@agentage/observability": "^1.0.0", "@agentage/server-memory": "^0.7.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@opentelemetry/api": "^1.9.0", "chalk": "^6.0.0", "commander": "^15.0.0", "open": "^11.0.0" @@ -52,6 +54,60 @@ "npm": ">=10.0.0" } }, + "node_modules/@agentage/observability": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@agentage/observability/-/observability-1.0.0.tgz", + "integrity": "sha512-TbHSd4QGx5dky0x5bsabFWC955a2vTxafEcv3IYe1ppiYY4/JKcqSQCeoguvfwLlySsO8pN0ODvcz3yet6eibA==", + "license": "MIT", + "dependencies": { + "@opentelemetry/exporter-trace-otlp-http": "^0.222.0", + "@opentelemetry/instrumentation": "^0.222.0", + "@opentelemetry/instrumentation-express": "^0.70.0", + "@opentelemetry/instrumentation-http": "^0.222.0", + "@opentelemetry/instrumentation-undici": "^0.32.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", + "@opentelemetry/sdk-trace-node": "^2.11.0", + "@opentelemetry/semantic-conventions": "^1.43.0", + "pino": "^10.3.1" + }, + "engines": { + "node": ">=22.13" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": ">=1.11 <2", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/instrumentation-amqplib": "^0.69.0", + "@opentelemetry/instrumentation-mongodb": "^0.75.0", + "@opentelemetry/instrumentation-pg": "^0.74.0", + "@opentelemetry/instrumentation-redis": "^0.70.0", + "@vercel/otel": ">=2", + "react": ">=18" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + }, + "@opentelemetry/instrumentation-amqplib": { + "optional": true + }, + "@opentelemetry/instrumentation-mongodb": { + "optional": true + }, + "@opentelemetry/instrumentation-pg": { + "optional": true + }, + "@opentelemetry/instrumentation-redis": { + "optional": true + }, + "@vercel/otel": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@agentage/server-memory": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@agentage/server-memory/-/server-memory-0.7.0.tgz", @@ -436,6 +492,287 @@ } } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.222.0.tgz", + "integrity": "sha512-9mb1If+IF6u0ZVXkHQ6ogEae5HwA6ajIVUgpSDQyRASxft6BSXHvBvPooRle3yFN/fKnCdSOnuu0OC3PLcF6+g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.11.0.tgz", + "integrity": "sha512-Tr79DyWI8itsBdg+jH+opjfrwLzX+erk1/ExkIwhWoAVjVrJIn2y5+cGjTC0Vy8fyNIA/y8wuJPZwr1T3xCZeQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.222.0.tgz", + "integrity": "sha512-RCnPWcHppwiquQ+cV3nWvNwdf0MG1w26e5jewW2T83nTZOlXgcg88sY9ulCVgagGHcq1mj0L0GP6YHgzk2v8oA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/otlp-exporter-base": "0.222.0", + "@opentelemetry/otlp-transformer": "0.222.0", + "@opentelemetry/sdk-trace": "2.11.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.222.0.tgz", + "integrity": "sha512-fhbRDuAgzPKpq1k5+hX3uS+3QAYNca0on0Ngot/R8dDsQtuFaADeZnG2uSgxABuvcI7S2phAaRhbct9jieeKZw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.222.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.70.0.tgz", + "integrity": "sha512-OHdj/2apRZNhMKkd6w4OV4SnX/aS2LWMLyHwGGNYhWratgh88o+cVJS9FvqC+HCYWSDWTEWEfl/8d7BdEI7c+g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.9.0", + "@opentelemetry/instrumentation": "^0.222.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.222.0.tgz", + "integrity": "sha512-HhMHW32LyGzIJEmUElxaML4oUe6JiCq0Hvjt/Awxb60SlMsSFetFLFjed45g/0wrPr1n7z+xW+x1B/584WBAkg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/instrumentation": "0.222.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.32.0.tgz", + "integrity": "sha512-Ms8oPkrCHa6hJOYodcFC8QOt1AFlns3KD1CqXBEihXyTFeLXDO84tIXYyKlXNHr8jMrl1Gm2velDvsSDXAuh9w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.9.0", + "@opentelemetry/instrumentation": "^0.222.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.222.0.tgz", + "integrity": "sha512-YbywG3veEm2Fb6TbdxRkuquWob6eVWXuA8/Ba1tXz9jHfUqpdE3keilOHEtPboC4CvS1bjeeVfNkWGOOrLj+lw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/otlp-transformer": "0.222.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.222.0.tgz", + "integrity": "sha512-/F3BZ89+CJQnZkMh2tCrtcdB+XT2Dxhj4FFE+WPQ//413hmFL0/RfEX6vgOIWGhiSzrkHWTK3+6SiT7K5/g/jQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.222.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-logs": "0.222.0", + "@opentelemetry/sdk-metrics": "2.11.0", + "@opentelemetry/sdk-trace": "2.11.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.11.0.tgz", + "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.222.0.tgz", + "integrity": "sha512-+19YHODIjaUCArxleaJtuufFZVpz/xvvK+VllQqE+W8hHolxdoRwHfK/s667zezwh1hkx6FFF+oYzetYgqK+Bg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.222.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.11.0.tgz", + "integrity": "sha512-7GXXcObyHyDUUSG+L+kJoquty01bzm7ivE7+SSgXXJcHuPzGviptxwARmI2c+bnnxjexGQbJnyNlN8HxBP/Y7A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.11.0.tgz", + "integrity": "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.11.0.tgz", + "integrity": "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-trace": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.11.0.tgz", + "integrity": "sha512-CuvCMJmZxswhNLlM2LfuLOW3h3fZujA4hsG4B+Sz4dX2zvaXO8Ng74cnDHWD64gLszTlhiG3c0iNUjj4g+0/sA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.11.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/sdk-trace-base": "2.11.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.148.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", @@ -447,6 +784,12 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgr/core": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", @@ -1224,6 +1567,15 @@ "js-tokens": "^10.0.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1373,6 +1725,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cjs-module-lexer": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", + "license": "MIT" + }, "node_modules/commander": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", @@ -2139,6 +2497,12 @@ "node": ">= 0.6" } }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -2334,6 +2698,26 @@ "node": ">= 4" } }, + "node_modules/import-in-the-middle": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.5.1.tgz", + "integrity": "sha512-mPKuL8bPQzecui2KK6Gb+M8JvJoHnhS1FeYGa22QopBmlevF5F0FE6ued/B5EgHDeIoMTONIpDWWFKUPOG0DBQ==", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^3.0.2", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/import-in-the-middle/node_modules/es-module-lexer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.2.tgz", + "integrity": "sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg==", + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2918,6 +3302,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2995,6 +3385,15 @@ "node": ">=12.20.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3145,6 +3544,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -3264,6 +3700,22 @@ "node": ">=6.0.0" } }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3323,6 +3775,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", @@ -3351,6 +3809,15 @@ "node": ">= 0.10" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3360,6 +3827,19 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/rolldown": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", @@ -3423,6 +3903,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3593,6 +4082,15 @@ "dev": true, "license": "ISC" }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3603,6 +4101,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3653,6 +4160,24 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", diff --git a/package.json b/package.json index 7bef172..74ff7c0 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,10 @@ }, "dependencies": { "@agentage/memory-core": "^0.5.0", + "@agentage/observability": "^1.0.0", "@agentage/server-memory": "^0.7.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@opentelemetry/api": "^1.9.0", "chalk": "^6.0.0", "commander": "^15.0.0", "open": "^11.0.0" diff --git a/src/cli.ts b/src/cli.ts index d91f248..35dae34 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,16 +1,8 @@ #!/usr/bin/env node -import chalk from 'chalk'; -import { Command } from 'commander'; -import { registerDaemon } from './commands/daemon/daemon-cmd.js'; -import { registerMemory } from './commands/memory/memory.js'; -import { registerSetup } from './commands/auth/setup.js'; -import { registerStatus } from './commands/status/status.js'; -import { registerUpdate } from './commands/update/update.js'; -import { registerVault } from './commands/vault/vault.js'; -import { disableDaemon } from './lib/daemon/daemon-pref.js'; -import { refreshUpdateCache, updateHint } from './lib/update/update-cache.js'; -import { VERSION } from './utils/version.js'; +// The bin is a loader, nothing else. @agentage/observability instruments node:http, fetch and MCP +// tool registration through module hooks, and a hook only sees a module imported after it is +// registered - so the bootstrap runs first and the program is imported dynamically behind it. // Dependency-free guard so the message survives even if deps fail to parse on old Node. const nodeMajor = Number(process.versions.node.split('.')[0]); @@ -19,45 +11,8 @@ if (nodeMajor < 22) { process.exit(1); } -const program = new Command(); +const { CLI_SERVICE, startObservability } = await import('./observability.js'); +await startObservability(CLI_SERVICE); -program - .name('agentage') - .description('The offline-first terminal client for agentage Memory') - .version(VERSION) - .option('--no-daemon', 'run memory verbs in-process instead of via the daemon'); - -program.addHelpText( - 'after', - '\nNew here? Run: agentage vault add --local, or agentage setup to connect an account.' -); - -program.hook('preAction', () => { - if (program.opts().daemon === false) disableDaemon(); -}); - -// After any command (except the update-aware ones, which report richer state themselves), print a -// one-line hint from the cache, then kick a fire-and-forget refresh. Never awaited, never throws - -// the command output is already done and the cache-only read keeps this instant. The hint is -// suppressed under --json so it never corrupts machine-readable output. -program.hook('postAction', (_thisCommand, actionCommand) => { - const name = actionCommand.name(); - if (name === 'status' || name === 'update') return; - if (!actionCommand.opts()['json']) { - const hint = updateHint(); - if (hint) console.error(chalk.dim(hint)); // stderr: never corrupt piped stdout - } - void refreshUpdateCache(); -}); - -registerSetup(program); -registerStatus(program); -registerVault(program); -registerMemory(program); -registerDaemon(program); -registerUpdate(program); - -program.parseAsync().catch((err: unknown) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); -}); +const { run } = await import('./program.js'); +await run(); diff --git a/src/daemon-entry.ts b/src/daemon-entry.ts index 01ab042..ef2fbba 100644 --- a/src/daemon-entry.ts +++ b/src/daemon-entry.ts @@ -1,145 +1,10 @@ -import { unwatchFile, watchFile } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { createClientProvider } from './daemon/client-provider.js'; -import { - EADDRINUSE_EXIT_CODE, - generateDaemonToken, - removePidFile, - removePortFile, - removeTokenFile, - resolvePort, - writePidFile, - writePortFile, - writeTokenFile, -} from './daemon/lifecycle.js'; -import { createDaemonServer } from './daemon/server.js'; -import { loadLocalMemoryServer } from './mcp/local-server.js'; -import { vaultsJsonPath } from './lib/vault/vaults.js'; -import { createDiscoverWatcher } from './sync/discover/watcher.js'; -import { createSyncManager } from './sync/git/manager.js'; -import { VERSION } from './utils/version.js'; +// The daemon's bin, a loader and nothing else: @agentage/observability instruments node:http and +// MCP tool registration through module hooks that only see modules imported after they register, so +// the bootstrap runs before the daemon itself is loaded. The path stays src/daemon-entry.ts because +// spawnDaemon resolves dist/daemon-entry.js. -export const isEaddrinuse = (err: unknown): boolean => - typeof err === 'object' && err !== null && (err as NodeJS.ErrnoException).code === 'EADDRINUSE'; +const { DAEMON_SERVICE, startObservability } = await import('./observability.js'); +await startObservability(DAEMON_SERVICE); -// Gate state-file cleanup on ownership: a loser of an autostart race must never wipe the winner's -// pid/port/token files. Only the process that actually wrote them may remove them. -export const createStateCleanup = ( - remove: () => void -): { markOwned: () => void; cleanup: () => void } => { - let owned = false; - return { - markOwned: () => { - owned = true; - }, - cleanup: () => { - if (owned) remove(); - }, - }; -}; - -// Run each reschedule independently: a transiently-invalid config edit must not crash the daemon or -// stop the others rescheduling; the throwing one keeps its last-good schedule. -export const safeReschedule = (steps: Array<() => void>, onError: (msg: string) => void): void => { - for (const step of steps) { - try { - step(); - } catch (err) { - onError(err instanceof Error ? err.message : String(err)); - } - } -}; - -// MCP is served at /mcp by default; AGENTAGE_DAEMON_NO_MCP=1 leaves buildMcpServer unset so the -// endpoint 404s (the `daemon start --no-mcp` flag sets this on the spawned process). -export const mcpEnabled = (env: NodeJS.ProcessEnv = process.env): boolean => - env['AGENTAGE_DAEMON_NO_MCP'] !== '1'; - -// Unset/empty/invalid -> undefined (the watcher's defaults apply); the watcher floors low values. -const envInt = (name: string): number | undefined => { - const raw = process.env[name]; - if (!raw) return undefined; - const v = Number(raw); - return Number.isFinite(v) && v >= 0 ? v : undefined; -}; - -const state = createStateCleanup(() => { - removePidFile(); - removePortFile(); - removeTokenFile(); -}); - -// The detached, long-lived engine host: one loopback HTTP server that owns a single in-process -// engine and serialises every vault mutation, avoiding concurrent git index.lock collisions. It -// runs the git sync loop and reschedules on config change. -const main = async (): Promise => { - const port = resolvePort(); - const authToken = generateDaemonToken(); - const git = createSyncManager(); - const discover = createDiscoverWatcher({ - log: (msg) => console.log(`[discover] ${msg}`), - debounceMs: envInt('AGENTAGE_DISCOVER_DEBOUNCE_MS'), - pollMs: envInt('AGENTAGE_DISCOVER_POLL_MS'), - }); - - const server = createDaemonServer({ - getClient: createClientProvider(), - buildMcpServer: mcpEnabled() ? loadLocalMemoryServer : undefined, - sync: { - status: () => ({ ...git.status(), discover: discover.status() }), - runNow: (vault) => git.runNow(vault), - }, - authToken, - version: VERSION, - }); - await server.start(port); - writePidFile(process.pid); - writePortFile(port); - writeTokenFile(authToken); - state.markOwned(); - - const reschedule = (): void => - safeReschedule([() => git.reschedule(), () => discover.reschedule()], (msg) => - console.error(`[daemon] reschedule failed: ${msg}`) - ); - reschedule(); - - const configPath = vaultsJsonPath(); - watchFile(configPath, { interval: 2000 }, reschedule); - - const shutdown = (): void => { - unwatchFile(configPath); - git.stop(); - discover.stop(); - server.stop().finally(() => { - state.cleanup(); - process.exit(0); - }); - }; - process.on('SIGTERM', shutdown); - process.on('SIGINT', shutdown); -}; - -// Only self-invoke when run directly (spawnDaemon's `node daemon-entry.js`); importing for tests -// must not boot a daemon. -const invokedDirectly = (): boolean => { - const entry = process.argv[1]; - return !!entry && fileURLToPath(import.meta.url) === entry; -}; - -if (invokedDirectly()) { - process.on('uncaughtException', (err: unknown) => { - console.error(`[daemon] uncaught: ${err instanceof Error ? err.message : String(err)}`); - state.cleanup(); - process.exit(1); - }); - main().catch((err: unknown) => { - if (isEaddrinuse(err)) { - // Another daemon owns the port + our state files: exit distinctly, touch nothing. - process.exit(EADDRINUSE_EXIT_CODE); - } - console.error(err instanceof Error ? err.message : String(err)); - state.cleanup(); - process.exit(1); - }); -} +const { boot } = await import('./daemon/entry.js'); +boot(); diff --git a/src/daemon-entry.test.ts b/src/daemon/entry.test.ts similarity index 98% rename from src/daemon-entry.test.ts rename to src/daemon/entry.test.ts index aca46bd..55c5a95 100644 --- a/src/daemon-entry.test.ts +++ b/src/daemon/entry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createStateCleanup, isEaddrinuse, mcpEnabled, safeReschedule } from './daemon-entry.js'; +import { createStateCleanup, isEaddrinuse, mcpEnabled, safeReschedule } from './entry.js'; describe('mcpEnabled', () => { it('serves MCP by default and only AGENTAGE_DAEMON_NO_MCP=1 turns it off', () => { diff --git a/src/daemon/entry.ts b/src/daemon/entry.ts new file mode 100644 index 0000000..a8c427a --- /dev/null +++ b/src/daemon/entry.ts @@ -0,0 +1,139 @@ +import { unwatchFile, watchFile } from 'node:fs'; +import { createClientProvider } from './client-provider.js'; +import { + EADDRINUSE_EXIT_CODE, + generateDaemonToken, + removePidFile, + removePortFile, + removeTokenFile, + resolvePort, + writePidFile, + writePortFile, + writeTokenFile, +} from './lifecycle.js'; +import { createDaemonServer } from './server.js'; +import { loadLocalMemoryServer } from '../mcp/local-server.js'; +import { vaultsJsonPath } from '../lib/vault/vaults.js'; +import { createDiscoverWatcher } from '../sync/discover/watcher.js'; +import { createSyncManager } from '../sync/git/manager.js'; +import { VERSION } from '../utils/version.js'; + +export const isEaddrinuse = (err: unknown): boolean => + typeof err === 'object' && err !== null && (err as NodeJS.ErrnoException).code === 'EADDRINUSE'; + +// Gate state-file cleanup on ownership: a loser of an autostart race must never wipe the winner's +// pid/port/token files. Only the process that actually wrote them may remove them. +export const createStateCleanup = ( + remove: () => void +): { markOwned: () => void; cleanup: () => void } => { + let owned = false; + return { + markOwned: () => { + owned = true; + }, + cleanup: () => { + if (owned) remove(); + }, + }; +}; + +// Run each reschedule independently: a transiently-invalid config edit must not crash the daemon or +// stop the others rescheduling; the throwing one keeps its last-good schedule. +export const safeReschedule = (steps: Array<() => void>, onError: (msg: string) => void): void => { + for (const step of steps) { + try { + step(); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } + } +}; + +// MCP is served at /mcp by default; AGENTAGE_DAEMON_NO_MCP=1 leaves buildMcpServer unset so the +// endpoint 404s (the `daemon start --no-mcp` flag sets this on the spawned process). +export const mcpEnabled = (env: NodeJS.ProcessEnv = process.env): boolean => + env['AGENTAGE_DAEMON_NO_MCP'] !== '1'; + +// Unset/empty/invalid -> undefined (the watcher's defaults apply); the watcher floors low values. +const envInt = (name: string): number | undefined => { + const raw = process.env[name]; + if (!raw) return undefined; + const v = Number(raw); + return Number.isFinite(v) && v >= 0 ? v : undefined; +}; + +const state = createStateCleanup(() => { + removePidFile(); + removePortFile(); + removeTokenFile(); +}); + +// The detached, long-lived engine host: one loopback HTTP server that owns a single in-process +// engine and serialises every vault mutation, avoiding concurrent git index.lock collisions. It +// runs the git sync loop and reschedules on config change. +const main = async (): Promise => { + const port = resolvePort(); + const authToken = generateDaemonToken(); + const git = createSyncManager(); + const discover = createDiscoverWatcher({ + log: (msg) => console.log(`[discover] ${msg}`), + debounceMs: envInt('AGENTAGE_DISCOVER_DEBOUNCE_MS'), + pollMs: envInt('AGENTAGE_DISCOVER_POLL_MS'), + }); + + const server = createDaemonServer({ + getClient: createClientProvider(), + buildMcpServer: mcpEnabled() ? loadLocalMemoryServer : undefined, + sync: { + status: () => ({ ...git.status(), discover: discover.status() }), + runNow: (vault) => git.runNow(vault), + }, + authToken, + version: VERSION, + }); + await server.start(port); + writePidFile(process.pid); + writePortFile(port); + writeTokenFile(authToken); + state.markOwned(); + + const reschedule = (): void => + safeReschedule([() => git.reschedule(), () => discover.reschedule()], (msg) => + console.error(`[daemon] reschedule failed: ${msg}`) + ); + reschedule(); + + const configPath = vaultsJsonPath(); + watchFile(configPath, { interval: 2000 }, reschedule); + + const shutdown = (): void => { + unwatchFile(configPath); + git.stop(); + discover.stop(); + server.stop().finally(() => { + state.cleanup(); + process.exit(0); + }); + }; + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); +}; + +// Boot the real daemon. Invoked by the bin (src/daemon-entry.ts) once the observability bootstrap +// has run; importing this module for tests must never start a server. +export const boot = (): void => { + process.on('uncaughtException', (err: unknown) => { + console.error(`[daemon] uncaught: ${err instanceof Error ? err.message : String(err)}`); + state.cleanup(); + process.exit(1); + }); + main().catch((err: unknown) => { + if (isEaddrinuse(err)) { + // Another daemon owns the port + our state files: exit distinctly, touch nothing. + process.exit(EADDRINUSE_EXIT_CODE); + } + console.error(err instanceof Error ? err.message : String(err)); + state.cleanup(); + process.exit(1); + }); +}; diff --git a/src/lib/daemon/daemon-client.ts b/src/lib/daemon/daemon-client.ts index c146d83..fef400f 100644 --- a/src/lib/daemon/daemon-client.ts +++ b/src/lib/daemon/daemon-client.ts @@ -9,6 +9,7 @@ import { type WriteResult, } from '@agentage/memory-core'; import { EADDRINUSE_EXIT_CODE, readDaemonToken, resolvePort } from '../../daemon/lifecycle.js'; +import { daemonServiceName } from '../../observability.js'; import { type SyncResult } from '../../sync/git/cycle.js'; import { type SyncStatus } from '../../sync/git/manager.js'; import { VERSION } from '../../utils/version.js'; @@ -150,6 +151,8 @@ export const spawnDaemon = async ( env: { ...process.env, AGENTAGE_DAEMON_PORT: String(port), + // Its own service in telemetry: never inherit the name we defaulted into the CLI's env. + OTEL_SERVICE_NAME: daemonServiceName(), ...(opts.noMcp ? { AGENTAGE_DAEMON_NO_MCP: '1' } : {}), }, }); diff --git a/src/observability.test.ts b/src/observability.test.ts new file mode 100644 index 0000000..52d7e2e --- /dev/null +++ b/src/observability.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type * as Observability from './observability.js'; + +// Fresh module each time: the user's OTEL_SERVICE_NAME is captured at import. +const load = async (): Promise => { + vi.resetModules(); + return import('./observability.js'); +}; + +const clearEnv = (): void => { + delete process.env['OTEL_SERVICE_NAME']; + delete process.env['OTEL_EXPORTER_OTLP_ENDPOINT']; +}; + +describe('observability', () => { + afterEach(() => { + clearEnv(); + vi.restoreAllMocks(); + }); + + it('names the daemon separately from the CLI', async () => { + clearEnv(); + const obs = await load(); + expect(obs.daemonServiceName()).toBe('agentage-daemon'); + expect(obs.CLI_SERVICE).toBe('agentage-cli'); + }); + + it("lets the daemon inherit the user's own service name", async () => { + process.env['OTEL_SERVICE_NAME'] = 'my-service'; + const obs = await load(); + expect(obs.daemonServiceName()).toBe('my-service'); + }); + + it('defaults the service name only when the user set none', async () => { + clearEnv(); + const obs = await load(); + await obs.startObservability('agentage-cli'); + expect(process.env['OTEL_SERVICE_NAME']).toBe('agentage-cli'); + + process.env['OTEL_SERVICE_NAME'] = 'mine'; + await obs.startObservability('agentage-cli'); + expect(process.env['OTEL_SERVICE_NAME']).toBe('mine'); + }); + + it('writes nothing to stdout while booting, and restores it', async () => { + clearEnv(); + const obs = await load(); + const write = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + await obs.startObservability('agentage-cli'); + expect(write).not.toHaveBeenCalled(); + expect(process.stdout.write).toBe(write); + }); +}); diff --git a/src/observability.ts b/src/observability.ts new file mode 100644 index 0000000..616b9d3 --- /dev/null +++ b/src/observability.ts @@ -0,0 +1,36 @@ +// Telemetry for the CLI and its daemon, through @agentage/observability. A real dependency here +// (unlike @agentage/server-memory, which must stay npx-light): `agentage` is installed once, +// globally. It is inert until OTEL_EXPORTER_OTLP_ENDPOINT names a collector, and every line it +// writes goes to stderr - stdout stays the machine-readable channel (`--json`, the daemon's +// JSON-RPC wire). + +// Captured before startObservability() can overwrite it, so the daemon we spawn can tell a name the +// user chose from the default we injected. +const userServiceName = process.env['OTEL_SERVICE_NAME']; + +export const CLI_SERVICE = 'agentage-cli'; +export const DAEMON_SERVICE = 'agentage-daemon'; + +// What OTEL_SERVICE_NAME the spawned daemon should run under: the user's, else our own default. +export const daemonServiceName = (): string => userServiceName || DAEMON_SERVICE; + +// Guarded even though the kit is a dependency: a half-installed global must degrade to a working +// CLI, never to a stack trace on every command. +export const startObservability = async (service: string): Promise => { + // The kit (1.0.0) announces an enabled tracer with one console.log, which would land in a + // `--json` payload or on the daemon's JSON-RPC wire. Divert stdout to stderr while it boots; drop + // this once the kit writes that line to stderr itself. + const stdoutWrite = process.stdout.write; + process.stdout.write = process.stderr.write.bind(process.stderr) as typeof process.stdout.write; + try { + if (!process.env['OTEL_SERVICE_NAME']) process.env['OTEL_SERVICE_NAME'] = service; + await import('@agentage/observability/bootstrap'); + } catch (err) { + if (process.env['AGENTAGE_DEBUG']) { + const reason = err instanceof Error ? err.message : String(err); + console.error(`[agentage] observability off: ${reason}`); // stderr: never corrupt stdout + } + } finally { + process.stdout.write = stdoutWrite; + } +}; diff --git a/src/package-guard.test.ts b/src/package-guard.test.ts index 765aa2e..d50f070 100644 --- a/src/package-guard.test.ts +++ b/src/package-guard.test.ts @@ -40,11 +40,16 @@ describe('package guard (R6)', () => { }; // @agentage/memory-core is the one local engine at M2-C (decision V7/V11-C). M3 adds the MCP // contract layer: @agentage/server-memory (the frozen 6-tool builder, wrapped verbatim) and - // @modelcontextprotocol/sdk (stdio + Streamable HTTP transports). Still minimal: no daemon. + // @modelcontextprotocol/sdk (stdio + Streamable HTTP transports). @agentage/observability is + // the estate kit (+ its @opentelemetry/api peer, listed so one copy is hoisted): affordable + // here because `agentage` is installed globally once, unlike npx-per-call server-memory, which + // takes the kit optionally. Still minimal: no daemon. expect(Object.keys(pkg.dependencies).sort()).toEqual([ '@agentage/memory-core', + '@agentage/observability', '@agentage/server-memory', '@modelcontextprotocol/sdk', + '@opentelemetry/api', 'chalk', 'commander', 'open', diff --git a/src/program.ts b/src/program.ts new file mode 100644 index 0000000..f7dbaeb --- /dev/null +++ b/src/program.ts @@ -0,0 +1,57 @@ +import chalk from 'chalk'; +import { Command } from 'commander'; +import { registerDaemon } from './commands/daemon/daemon-cmd.js'; +import { registerMemory } from './commands/memory/memory.js'; +import { registerSetup } from './commands/auth/setup.js'; +import { registerStatus } from './commands/status/status.js'; +import { registerUpdate } from './commands/update/update.js'; +import { registerVault } from './commands/vault/vault.js'; +import { disableDaemon } from './lib/daemon/daemon-pref.js'; +import { refreshUpdateCache, updateHint } from './lib/update/update-cache.js'; +import { VERSION } from './utils/version.js'; + +const program = new Command(); + +program + .name('agentage') + .description('The offline-first terminal client for agentage Memory') + .version(VERSION) + .option('--no-daemon', 'run memory verbs in-process instead of via the daemon'); + +program.addHelpText( + 'after', + '\nNew here? Run: agentage vault add --local, or agentage setup to connect an account.' +); + +program.hook('preAction', () => { + if (program.opts().daemon === false) disableDaemon(); +}); + +// After any command (except the update-aware ones, which report richer state themselves), print a +// one-line hint from the cache, then kick a fire-and-forget refresh. Never awaited, never throws - +// the command output is already done and the cache-only read keeps this instant. The hint is +// suppressed under --json so it never corrupts machine-readable output. +program.hook('postAction', (_thisCommand, actionCommand) => { + const name = actionCommand.name(); + if (name === 'status' || name === 'update') return; + if (!actionCommand.opts()['json']) { + const hint = updateHint(); + if (hint) console.error(chalk.dim(hint)); // stderr: never corrupt piped stdout + } + void refreshUpdateCache(); +}); + +registerSetup(program); +registerStatus(program); +registerVault(program); +registerMemory(program); +registerDaemon(program); +registerUpdate(program); + +// Called by the bin (src/cli.ts) once the observability bootstrap has run. +export const run = async (): Promise => { + await program.parseAsync().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + }); +};