ashlr-plugin 1.36.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +39 -0
- package/.claude-plugin/plugin.json +36 -0
- package/.codex/agents/README.md +9 -0
- package/.codex/agents/ashlr-explorer.md +36 -0
- package/.codex/agents/ashlr-worker.md +38 -0
- package/.codex-plugin/plugin.json +45 -0
- package/.mcp.json +14 -0
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/agents/ashlr-code.md +69 -0
- package/agents/ashlr-explore.md +44 -0
- package/agents/ashlr-plan.md +54 -0
- package/bench/fixtures/notebook-large.ipynb +660 -0
- package/bench/fixtures/notebook-small.ipynb +39 -0
- package/bench/fixtures/tasklist-closed.json +62 -0
- package/bench/fixtures/tasklist-large-mixed.json +27 -0
- package/bench/fixtures/tasklist-sprint-open.json +79 -0
- package/bench/fixtures/websearch-bun-runtime.json +32 -0
- package/bench/fixtures/websearch-react-hooks-patterns.json +32 -0
- package/bench/fixtures/websearch-typescript-generics.json +38 -0
- package/bench/refs/node-sdk/.refrev +1 -0
- package/bench/refs/node-sdk/README.md +17 -0
- package/bench/refs/node-sdk/src/agent/agent.ts +163 -0
- package/bench/refs/node-sdk/src/agent/create-agent-ui-stream.ts +83 -0
- package/bench/refs/node-sdk/src/agent/tool-loop-agent.ts +238 -0
- package/bench/refs/node-sdk/src/embed/embed-many.ts +409 -0
- package/bench/refs/node-sdk/src/embed/embed-result.ts +50 -0
- package/bench/refs/node-sdk/src/embed/embed.ts +257 -0
- package/bench/refs/node-sdk/src/error/invalid-argument-error.ts +34 -0
- package/bench/refs/node-sdk/src/error/invalid-tool-input-error.ts +33 -0
- package/bench/refs/node-sdk/src/error/no-object-generated-error.ts +70 -0
- package/bench/refs/node-sdk/src/generate-image/generate-image-result.ts +42 -0
- package/bench/refs/node-sdk/src/generate-image/generate-image.ts +362 -0
- package/bench/refs/node-sdk/src/generate-object/generate-object-result.ts +67 -0
- package/bench/refs/node-sdk/src/generate-object/generate-object.ts +501 -0
- package/bench/refs/node-sdk/src/generate-object/inject-json-instruction.ts +30 -0
- package/bench/refs/node-sdk/src/generate-object/output-strategy.ts +415 -0
- package/bench/refs/node-sdk/src/generate-object/repair-text.ts +12 -0
- package/bench/refs/node-sdk/src/generate-object/stream-object.ts +960 -0
- package/bench/refs/node-sdk/src/generate-speech/generate-speech.ts +191 -0
- package/bench/refs/node-sdk/src/generate-text/generate-text.ts +1484 -0
- package/bench/refs/node-sdk/src/generate-text/stream-text.ts +2708 -0
- package/bench/refs/node-sdk/src/generate-text/tool-call.ts +59 -0
- package/bench/refs/node-sdk/src/generate-text/tool-result.ts +36 -0
- package/bench/refs/node-sdk/src/telemetry/create-telemetry-dispatcher.ts +146 -0
- package/bench/refs/node-sdk/src/telemetry/telemetry-registry.ts +15 -0
- package/bench/refs/node-sdk/src/telemetry/telemetry.ts +223 -0
- package/bench/refs/node-sdk/src/util/async-iterable-stream.ts +94 -0
- package/bench/refs/node-sdk/src/util/consume-stream.ts +31 -0
- package/bench/refs/node-sdk/src/util/create-stitchable-stream.ts +112 -0
- package/bench/refs/node-sdk/src/util/download/download.ts +74 -0
- package/bench/refs/node-sdk/src/util/error-handler.ts +1 -0
- package/bench/refs/node-sdk/src/util/fix-json.ts +401 -0
- package/bench/refs/node-sdk/src/util/merge-objects.ts +79 -0
- package/bench/refs/python-lib/.refrev +1 -0
- package/bench/refs/python-lib/README.md +18 -0
- package/bench/refs/python-lib/pandas/core/_numba/executor.py +249 -0
- package/bench/refs/python-lib/pandas/core/_numba/extensions.py +586 -0
- package/bench/refs/python-lib/pandas/core/_numba/kernels/mean_.py +198 -0
- package/bench/refs/python-lib/pandas/core/_numba/kernels/min_max_.py +178 -0
- package/bench/refs/python-lib/pandas/core/_numba/kernels/sum_.py +255 -0
- package/bench/refs/python-lib/pandas/core/_numba/kernels/var_.py +251 -0
- package/bench/refs/python-lib/pandas/core/accessor.py +520 -0
- package/bench/refs/python-lib/pandas/core/api.py +138 -0
- package/bench/refs/python-lib/pandas/core/array_algos/masked_accumulations.py +97 -0
- package/bench/refs/python-lib/pandas/core/array_algos/masked_reductions.py +252 -0
- package/bench/refs/python-lib/pandas/core/array_algos/putmask.py +150 -0
- package/bench/refs/python-lib/pandas/core/array_algos/quantile.py +232 -0
- package/bench/refs/python-lib/pandas/core/array_algos/replace.py +159 -0
- package/bench/refs/python-lib/pandas/core/array_algos/take.py +609 -0
- package/bench/refs/python-lib/pandas/core/arraylike.py +534 -0
- package/bench/refs/python-lib/pandas/core/arrays/_arrow_string_mixins.py +434 -0
- package/bench/refs/python-lib/pandas/core/arrays/_mixins.py +630 -0
- package/bench/refs/python-lib/pandas/core/arrays/_ranges.py +276 -0
- package/bench/refs/python-lib/pandas/core/arrays/_utils.py +77 -0
- package/bench/refs/python-lib/pandas/core/arrays/arrow/accessors.py +521 -0
- package/bench/refs/python-lib/pandas/core/arrays/arrow/extension_types.py +174 -0
- package/bench/refs/python-lib/pandas/core/arrays/boolean.py +454 -0
- package/bench/refs/python-lib/pandas/core/arrays/floating.py +192 -0
- package/bench/refs/python-lib/pandas/core/arrays/integer.py +296 -0
- package/bench/refs/python-lib/pandas/core/arrays/masked.py +2140 -0
- package/bench/refs/python-lib/pandas/core/arrays/numeric.py +305 -0
- package/bench/refs/python-lib/pandas/core/arrays/numpy_.py +701 -0
- package/bench/refs/python-lib/pandas/core/arrays/period.py +1586 -0
- package/bench/refs/python-lib/pandas/core/arrays/sparse/accessor.py +505 -0
- package/bench/refs/python-lib/pandas/core/arrays/sparse/scipy_sparse.py +208 -0
- package/bench/refs/rust-project/.refrev +1 -0
- package/bench/refs/rust-project/README.md +17 -0
- package/bench/refs/rust-project/tokio/src/doc/os.rs +68 -0
- package/bench/refs/rust-project/tokio/src/fs/dir_builder.rs +129 -0
- package/bench/refs/rust-project/tokio/src/fs/file.rs +1113 -0
- package/bench/refs/rust-project/tokio/src/fs/mocks.rs +176 -0
- package/bench/refs/rust-project/tokio/src/fs/mod.rs +324 -0
- package/bench/refs/rust-project/tokio/src/fs/open_options.rs +856 -0
- package/bench/refs/rust-project/tokio/src/fs/read.rs +80 -0
- package/bench/refs/rust-project/tokio/src/fs/read_dir.rs +357 -0
- package/bench/refs/rust-project/tokio/src/fs/write.rs +99 -0
- package/bench/refs/rust-project/tokio/src/future/maybe_done.rs +123 -0
- package/bench/refs/rust-project/tokio/src/future/try_join.rs +82 -0
- package/bench/refs/rust-project/tokio/src/io/async_buf_read.rs +117 -0
- package/bench/refs/rust-project/tokio/src/io/async_read.rs +133 -0
- package/bench/refs/rust-project/tokio/src/io/async_seek.rs +96 -0
- package/bench/refs/rust-project/tokio/src/io/async_write.rs +402 -0
- package/bench/refs/rust-project/tokio/src/io/blocking.rs +339 -0
- package/bench/refs/rust-project/tokio/src/io/interest.rs +345 -0
- package/bench/refs/rust-project/tokio/src/io/join.rs +130 -0
- package/bench/refs/rust-project/tokio/src/io/mod.rs +308 -0
- package/bench/refs/rust-project/tokio/src/io/poll_evented.rs +319 -0
- package/bench/refs/rust-project/tokio/src/io/read_buf.rs +344 -0
- package/bench/refs/rust-project/tokio/src/io/ready.rs +322 -0
- package/bench/refs/rust-project/tokio/src/io/split.rs +166 -0
- package/bench/refs/rust-project/tokio/src/io/stderr.rs +148 -0
- package/bench/refs/rust-project/tokio/src/io/stdin.rs +98 -0
- package/bench/refs/rust-project/tokio/src/io/stdio_common.rs +222 -0
- package/bench/refs/rust-project/tokio/src/io/stdout.rs +197 -0
- package/bench/refs/rust-project/tokio/src/io/util/buf_reader.rs +311 -0
- package/bench/refs/rust-project/tokio/src/io/util/buf_stream.rs +207 -0
- package/bench/refs/rust-project/tokio/src/io/util/buf_writer.rs +310 -0
- package/bench/refs/rust-project/tokio/src/io/util/chain.rs +144 -0
- package/commands/_spawn-patterns.json +32 -0
- package/commands/ashlr-allow.md +21 -0
- package/commands/ashlr-benchmark.md +53 -0
- package/commands/ashlr-brief.md +97 -0
- package/commands/ashlr-budget.md +76 -0
- package/commands/ashlr-compact.md +54 -0
- package/commands/ashlr-dashboard.md +37 -0
- package/commands/ashlr-doctor.md +56 -0
- package/commands/ashlr-eco-mode.md +92 -0
- package/commands/ashlr-genome-init.md +24 -0
- package/commands/ashlr-genome-keygen.md +36 -0
- package/commands/ashlr-genome-loop.md +118 -0
- package/commands/ashlr-genome-push.md +36 -0
- package/commands/ashlr-genome-rewrap.md +48 -0
- package/commands/ashlr-genome-team-init.md +67 -0
- package/commands/ashlr-handoff.md +23 -0
- package/commands/ashlr-help.md +86 -0
- package/commands/ashlr-hook-timings.md +30 -0
- package/commands/ashlr-leaderboard.md +50 -0
- package/commands/ashlr-ollama-setup.md +39 -0
- package/commands/ashlr-orchestrate-status.md +72 -0
- package/commands/ashlr-orchestrate.md +83 -0
- package/commands/ashlr-parallelize.md +63 -0
- package/commands/ashlr-report-crash.md +33 -0
- package/commands/ashlr-resume.md +72 -0
- package/commands/ashlr-savings.md +24 -0
- package/commands/ashlr-settings.md +50 -0
- package/commands/ashlr-spawn.md +61 -0
- package/commands/ashlr-start.md +60 -0
- package/commands/ashlr-status.md +64 -0
- package/commands/ashlr-team-invite.md +47 -0
- package/commands/ashlr-tier.md +122 -0
- package/commands/ashlr-tour.md +60 -0
- package/commands/ashlr-update.md +155 -0
- package/commands/ashlr-upgrade.md +96 -0
- package/commands/goal.md +58 -0
- package/commands/loop.md +40 -0
- package/docs/CNAME +1 -0
- package/docs/PRO_TIER.md +104 -0
- package/docs/README.md +21 -0
- package/docs/architecture.md +415 -0
- package/docs/ashlr-pipe.md +161 -0
- package/docs/assets/logo.svg +19 -0
- package/docs/assets/og.png +0 -0
- package/docs/assets/og.svg +41 -0
- package/docs/benchmarks-v2.json +355 -0
- package/docs/benchmarks.json +102 -0
- package/docs/benchmarks.md +211 -0
- package/docs/billing.md +185 -0
- package/docs/cloud-genome.md +155 -0
- package/docs/competitive-wozcode.md +92 -0
- package/docs/deploy.md +325 -0
- package/docs/email-templates.md +113 -0
- package/docs/embed-tuning.md +103 -0
- package/docs/genome.md +120 -0
- package/docs/github-oauth-onboarding.md +127 -0
- package/docs/hero-video-script.md +129 -0
- package/docs/index.html +2262 -0
- package/docs/install-prompt.md +78 -0
- package/docs/install-windows.md +132 -0
- package/docs/install.ps1 +171 -0
- package/docs/install.sh +146 -0
- package/docs/launch/README.md +114 -0
- package/docs/launch/hn.md +49 -0
- package/docs/launch/reddit.md +45 -0
- package/docs/launch/x.md +65 -0
- package/docs/launch-post.md +112 -0
- package/docs/legal.md +78 -0
- package/docs/llm-providers.md +99 -0
- package/docs/multi-host-mcp.md +217 -0
- package/docs/multi-turn-architecture.md +108 -0
- package/docs/operations/deployment.md +108 -0
- package/docs/operations/error-monitoring.md +70 -0
- package/docs/operations/weekly-digest.md +70 -0
- package/docs/operations.md +494 -0
- package/docs/platform-support.md +133 -0
- package/docs/policy-packs.md +188 -0
- package/docs/press-kit/fact-sheet.md +78 -0
- package/docs/press-kit/founder-bio.md +3 -0
- package/docs/press-kit/logo.svg +33 -0
- package/docs/press-kit/screenshots.md +146 -0
- package/docs/pricing.md +251 -0
- package/docs/pro-backend-architecture.md +523 -0
- package/docs/producthunt.md +52 -0
- package/docs/router-migration.md +110 -0
- package/docs/session-log-schema.md +68 -0
- package/docs/social.md +95 -0
- package/docs/team-genome.md +528 -0
- package/docs/telemetry.md +194 -0
- package/docs/test-isolation.md +100 -0
- package/docs/upgrade.md +159 -0
- package/hooks/_hook-errors.ts +87 -0
- package/hooks/_hook-perf-emit.ts +113 -0
- package/hooks/_hook-timing-ledger.ts +1133 -0
- package/hooks/_nudge-throttle.ts +127 -0
- package/hooks/_recent-blocks.ts +127 -0
- package/hooks/audit-upload.ts +86 -0
- package/hooks/codex-hooks.json +171 -0
- package/hooks/commit-attribution.ts +179 -0
- package/hooks/edit-batching-nudge.ts +154 -0
- package/hooks/genome-scribe-hook.ts +219 -0
- package/hooks/hooks.json +294 -0
- package/hooks/orient-nudge-hook.ts +242 -0
- package/hooks/policy-enforce.ts +214 -0
- package/hooks/post-tool-use-embedding.ts +93 -0
- package/hooks/post-tool-use-genome.sh +37 -0
- package/hooks/post-tool-use-genome.ts +69 -0
- package/hooks/posttooluse-correlate.ts +122 -0
- package/hooks/posttooluse-dedupe-output.ts +241 -0
- package/hooks/posttooluse-genome-refresh.ts +223 -0
- package/hooks/posttooluse-native-nudge.ts +205 -0
- package/hooks/posttooluse-stale-result.ts +277 -0
- package/hooks/pretooluse-bash.ts +63 -0
- package/hooks/pretooluse-budget-guard.ts +318 -0
- package/hooks/pretooluse-common.ts +985 -0
- package/hooks/pretooluse-eco-router.ts +196 -0
- package/hooks/pretooluse-edit.sh +93 -0
- package/hooks/pretooluse-edit.ts +197 -0
- package/hooks/pretooluse-glob.ts +70 -0
- package/hooks/pretooluse-grep.sh +85 -0
- package/hooks/pretooluse-grep.ts +96 -0
- package/hooks/pretooluse-notebookedit.ts +121 -0
- package/hooks/pretooluse-read.sh +118 -0
- package/hooks/pretooluse-read.ts +118 -0
- package/hooks/pretooluse-task.ts +130 -0
- package/hooks/pretooluse-webfetch.ts +66 -0
- package/hooks/pretooluse-websearch.ts +85 -0
- package/hooks/pretooluse-write.ts +128 -0
- package/hooks/pulse-emit.ts +197 -0
- package/hooks/session-end-consolidate.sh +35 -0
- package/hooks/session-end-consolidate.ts +188 -0
- package/hooks/session-end-stats-gc.ts +80 -0
- package/hooks/session-log-append.sh +94 -0
- package/hooks/session-log-append.ts +116 -0
- package/hooks/session-start.ts +993 -0
- package/hooks/sessionend-hook-health-nudge.ts +451 -0
- package/hooks/sessionend-savings-nudge.ts +318 -0
- package/hooks/sessionend-status-line-nudge.ts +296 -0
- package/hooks/sessionstart-brief.ts +140 -0
- package/hooks/sessionstart-cost-refactor.ts +95 -0
- package/hooks/sessionstart-efficient.ts +110 -0
- package/hooks/sessionstart-genome-author.ts +93 -0
- package/hooks/sessionstart-lean-tools.ts +98 -0
- package/hooks/sessionstart-search.ts +97 -0
- package/hooks/stop-accounting.ts +152 -0
- package/hooks/subagent-stop-rollup.ts +162 -0
- package/hooks/userpromptsubmit-brief-trigger.ts +156 -0
- package/package.json +56 -0
- package/ports/README.md +108 -0
- package/ports/cursor/mcp.json +11 -0
- package/ports/goose/recipe.yaml +26 -0
- package/scripts/ashlr-mcp.ts +40 -0
- package/scripts/auto-update.ts +177 -0
- package/scripts/baseline-scan.ts +831 -0
- package/scripts/bench-orchestrate.ts +377 -0
- package/scripts/benchmark-refs.ts +292 -0
- package/scripts/bootstrap.mjs +100 -0
- package/scripts/brief-eval.ts +234 -0
- package/scripts/bump-version.ts +82 -0
- package/scripts/bun-resolve.d.mts +11 -0
- package/scripts/bun-resolve.mjs +59 -0
- package/scripts/calibrate-grep.ts +467 -0
- package/scripts/capture-grep-workload.ts +260 -0
- package/scripts/check-tier.ts +61 -0
- package/scripts/check-version-sync.ts +97 -0
- package/scripts/cli-orchestrate-status.ts +386 -0
- package/scripts/cli-orchestrate.ts +303 -0
- package/scripts/cli.ts +478 -0
- package/scripts/cloud-smoke-test.ts +313 -0
- package/scripts/coach-report.ts +373 -0
- package/scripts/context-db-migrate.ts +137 -0
- package/scripts/context-status.ts +43 -0
- package/scripts/demo-run.ts +255 -0
- package/scripts/deploy-smoke.sh +140 -0
- package/scripts/doctor.ts +992 -0
- package/scripts/embed-file-worker.ts +104 -0
- package/scripts/embed-tune.ts +308 -0
- package/scripts/errors-report.ts +428 -0
- package/scripts/find-test-leak.ts +69 -0
- package/scripts/generate-badge.ts +357 -0
- package/scripts/genome-auto-consolidate.ts +533 -0
- package/scripts/genome-auto-propose.ts +578 -0
- package/scripts/genome-cloud-pull.ts +363 -0
- package/scripts/genome-cloud-push.ts +382 -0
- package/scripts/genome-cloud-sync.ts +587 -0
- package/scripts/genome-commit-watcher.ts +295 -0
- package/scripts/genome-init.ts +986 -0
- package/scripts/genome-key.ts +233 -0
- package/scripts/genome-keygen.ts +168 -0
- package/scripts/genome-link.ts +59 -0
- package/scripts/genome-refresh-worker.ts +364 -0
- package/scripts/genome-rewrap.ts +90 -0
- package/scripts/genome-synthesizer.ts +679 -0
- package/scripts/genome-team-init.ts +312 -0
- package/scripts/handoff-pack.ts +438 -0
- package/scripts/hook-bootstrap.mjs +75 -0
- package/scripts/hook-timing-reader.ts +371 -0
- package/scripts/hook-timings-report.ts +554 -0
- package/scripts/install-genome-hooks.ts +196 -0
- package/scripts/install-onnx-model.ts +154 -0
- package/scripts/install-permissions.ts +276 -0
- package/scripts/install-status-line.ts +150 -0
- package/scripts/mcp-entrypoint.sh +78 -0
- package/scripts/mcp-entrypoint.ts +130 -0
- package/scripts/mdx-angle-bracket-lint.ts +152 -0
- package/scripts/measure-cold-start.ts +126 -0
- package/scripts/migrate-stats-to-sqlite.ts +86 -0
- package/scripts/ollama-setup.ts +498 -0
- package/scripts/onboarding-wizard.ts +1691 -0
- package/scripts/orchestrate-emit-telemetry.ts +177 -0
- package/scripts/orchestrate-expand.ts +517 -0
- package/scripts/orchestrate-run.ts +744 -0
- package/scripts/print-legend.ts +50 -0
- package/scripts/product-metadata.ts +38 -0
- package/scripts/prune-old-memories.ts +208 -0
- package/scripts/publish.sh +88 -0
- package/scripts/read-calibration.ts +122 -0
- package/scripts/report-crash.ts +226 -0
- package/scripts/run-benchmark.ts +750 -0
- package/scripts/run-root-tests.ts +28 -0
- package/scripts/savings-dashboard.ts +1258 -0
- package/scripts/savings-report-extras.ts +447 -0
- package/scripts/savings-status-line.ts +873 -0
- package/scripts/session-event-emit.ts +266 -0
- package/scripts/session-greet.ts +686 -0
- package/scripts/session-log-report.ts +551 -0
- package/scripts/session-resume.ts +614 -0
- package/scripts/set-hook-mode.ts +83 -0
- package/scripts/smoke-cross-platform.ts +117 -0
- package/scripts/smoke-realtime.ts +257 -0
- package/scripts/smoke-tools.ts +21 -0
- package/scripts/stats-cloud-pull.ts +213 -0
- package/scripts/stats-cloud-sync.ts +261 -0
- package/scripts/telemetry-flush.ts +160 -0
- package/scripts/telemetry-status.ts +266 -0
- package/scripts/timing-budget.ts +35 -0
- package/scripts/ui-animation.ts +120 -0
- package/scripts/ui.ts +431 -0
- package/scripts/upgrade-flow.ts +775 -0
- package/scripts/weekly-digest.ts +394 -0
- package/servers/_accounting.ts +78 -0
- package/servers/_ask-router.ts +84 -0
- package/servers/_ast-chunker.ts +434 -0
- package/servers/_ast-helpers.ts +183 -0
- package/servers/_ast-languages.ts +153 -0
- package/servers/_ast-refactor/_shared.ts +221 -0
- package/servers/_ast-refactor/cross-file-rename.ts +560 -0
- package/servers/_ast-refactor/extract-function.ts +357 -0
- package/servers/_ast-refactor/file-local-rename.ts +172 -0
- package/servers/_ast-refactor.ts +41 -0
- package/servers/_ast-skeleton.ts +107 -0
- package/servers/_bash-summarizers-registry.ts +1096 -0
- package/servers/_crash-dump.ts +210 -0
- package/servers/_cwd-clamp.ts +342 -0
- package/servers/_edit-log.ts +28 -0
- package/servers/_edit-match.ts +236 -0
- package/servers/_edit-validate.ts +196 -0
- package/servers/_embed-calibration.ts +123 -0
- package/servers/_embedding-cache.ts +401 -0
- package/servers/_embedding-model.ts +329 -0
- package/servers/_events.ts +113 -0
- package/servers/_genome-cache.ts +154 -0
- package/servers/_genome-commits.ts +172 -0
- package/servers/_genome-crypto-v2.ts +242 -0
- package/servers/_genome-crypto.ts +259 -0
- package/servers/_genome-discoveries.ts +140 -0
- package/servers/_genome-embed-populator.ts +216 -0
- package/servers/_genome-freshness.ts +134 -0
- package/servers/_genome-issues.ts +171 -0
- package/servers/_genome-live.ts +263 -0
- package/servers/_genome-prs.ts +188 -0
- package/servers/_genome-search.ts +479 -0
- package/servers/_genome-sync.ts +297 -0
- package/servers/_history-tracker.ts +279 -0
- package/servers/_http-helpers.ts +151 -0
- package/servers/_identity-hash.ts +151 -0
- package/servers/_llm-providers/anthropic.ts +132 -0
- package/servers/_llm-providers/cloud.ts +195 -0
- package/servers/_llm-providers/index.ts +350 -0
- package/servers/_llm-providers/local.ts +127 -0
- package/servers/_llm-providers/onnx.ts +393 -0
- package/servers/_llm-providers/types.ts +27 -0
- package/servers/_manifest-v2.ts +447 -0
- package/servers/_mcp-host.ts +93 -0
- package/servers/_nudge-events.ts +451 -0
- package/servers/_orchestrate-executor.ts +316 -0
- package/servers/_prefetch.ts +435 -0
- package/servers/_pricing.ts +256 -0
- package/servers/_pro.ts +381 -0
- package/servers/_read-cache.ts +44 -0
- package/servers/_router-handlers.ts +46 -0
- package/servers/_router.ts +55 -0
- package/servers/_run-with-timeout.ts +173 -0
- package/servers/_savings-render.ts +214 -0
- package/servers/_stats-sqlite.ts +640 -0
- package/servers/_stats.ts +1183 -0
- package/servers/_status-line-cells.ts +469 -0
- package/servers/_streaks.ts +162 -0
- package/servers/_summarize.ts +208 -0
- package/servers/_task-graph-render.ts +217 -0
- package/servers/_task-graph.ts +545 -0
- package/servers/_telemetry.ts +719 -0
- package/servers/_test-parsers.ts +530 -0
- package/servers/_test-watch.ts +626 -0
- package/servers/_text-helpers.ts +37 -0
- package/servers/_token-measure.ts +160 -0
- package/servers/_tool-base.ts +248 -0
- package/servers/_weekly-digest.ts +391 -0
- package/servers/ask-server-handlers.ts +49 -0
- package/servers/ask-server.ts +174 -0
- package/servers/bash-server-handlers.ts +138 -0
- package/servers/bash-server.ts +1136 -0
- package/servers/bench.ts +186 -0
- package/servers/diff-semantic-server-handlers.ts +39 -0
- package/servers/diff-semantic-server.ts +566 -0
- package/servers/diff-server-handlers.ts +317 -0
- package/servers/diff-server.ts +20 -0
- package/servers/edit-server-handlers.ts +31 -0
- package/servers/edit-server.ts +209 -0
- package/servers/edit-structural-server-handlers.ts +322 -0
- package/servers/efficiency-server-handlers.ts +161 -0
- package/servers/efficiency-server.ts +1135 -0
- package/servers/flush-server-handlers.ts +21 -0
- package/servers/flush-server.ts +19 -0
- package/servers/genome-server-handlers.ts +118 -0
- package/servers/genome-server.ts +397 -0
- package/servers/github-server-handlers.ts +187 -0
- package/servers/github-server.ts +856 -0
- package/servers/glob-server-handlers.ts +233 -0
- package/servers/glob-server.ts +20 -0
- package/servers/grep-server-handlers.ts +61 -0
- package/servers/grep-server.ts +564 -0
- package/servers/http-server-handlers.ts +36 -0
- package/servers/http-server.ts +149 -0
- package/servers/logs-server-handlers.ts +42 -0
- package/servers/logs-server.ts +359 -0
- package/servers/ls-server-handlers.ts +166 -0
- package/servers/ls-server.ts +20 -0
- package/servers/multi-edit-server-handlers.ts +51 -0
- package/servers/multi-edit-server.ts +422 -0
- package/servers/notebook-edit-server-handlers.ts +34 -0
- package/servers/notebook-edit-server.ts +181 -0
- package/servers/orient-server-handlers.ts +52 -0
- package/servers/orient-server.ts +436 -0
- package/servers/pipe-server-handlers.ts +68 -0
- package/servers/pipe-server.ts +300 -0
- package/servers/read-server-handlers.ts +42 -0
- package/servers/read-server.ts +206 -0
- package/servers/rename-file-server-handlers.ts +67 -0
- package/servers/rename-file-server.ts +699 -0
- package/servers/savings-server-handlers.ts +27 -0
- package/servers/savings-server.ts +54 -0
- package/servers/search-replace-regex-server-handlers.ts +96 -0
- package/servers/search-replace-regex-server.ts +751 -0
- package/servers/sql-server-handlers.ts +58 -0
- package/servers/sql-server.ts +505 -0
- package/servers/task-server-handlers.ts +358 -0
- package/servers/task-server.ts +24 -0
- package/servers/test-server-handlers.ts +351 -0
- package/servers/test-server.ts +20 -0
- package/servers/tree-server-handlers.ts +503 -0
- package/servers/tree-server.ts +20 -0
- package/servers/webfetch-server-handlers.ts +166 -0
- package/servers/webfetch-server.ts +20 -0
- package/servers/websearch-server-handlers.ts +215 -0
- package/servers/websearch-server.ts +19 -0
- package/servers/write-server-handlers.ts +27 -0
- package/servers/write-server.ts +97 -0
- package/skills/ashlr-brief/SKILL.md +80 -0
- package/skills/ashlr-codex-doctor/SKILL.md +21 -0
- package/skills/ashlr-codex-efficient-tools/SKILL.md +17 -0
- package/skills/ashlr-codex-genome-refresh/SKILL.md +20 -0
- package/skills/ashlr-codex-parallel-explore/SKILL.md +20 -0
- package/skills/ashlr-codex-savings/SKILL.md +17 -0
- package/skills/ashlr-cost-refactor/SKILL.md +50 -0
- package/skills/ashlr-efficient/SKILL.md +64 -0
- package/skills/ashlr-genome-author/SKILL.md +43 -0
- package/skills/ashlr-lean-tools/SKILL.md +43 -0
- package/skills/ashlr-search/SKILL.md +46 -0
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared stats accounting for all ashlr MCP servers.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the 12 duplicated `recordSaving`/`STATS_PATH`/`loadLifetime`/
|
|
5
|
+
* `persistStats` implementations scattered across servers/* with a single
|
|
6
|
+
* source of truth that is correct under concurrency.
|
|
7
|
+
*
|
|
8
|
+
* Key invariants:
|
|
9
|
+
* 1. Session stats are keyed by CLAUDE_SESSION_ID (or a PPID-derived
|
|
10
|
+
* fallback) so N concurrent Claude Code terminals never clobber each
|
|
11
|
+
* other's session counters. The old shape had a single global `session`
|
|
12
|
+
* field that every server wrote to — that's why the status line number
|
|
13
|
+
* was unreliable across terminals.
|
|
14
|
+
* 2. Writes are atomic (tempfile + fsync + rename) and serialized by both
|
|
15
|
+
* an in-process mutex and a filesystem lockfile, so 12 MCP servers +
|
|
16
|
+
* hooks writing at once will not corrupt the file or lose updates.
|
|
17
|
+
* 3. JSON is minified on disk — every byte of pretty-printing was paid
|
|
18
|
+
* disk cost with no model-facing benefit.
|
|
19
|
+
* 4. Schema is versioned (schemaVersion: 2) with a migration from v1's
|
|
20
|
+
* legacy global-`session` layout. Migration is best-effort and
|
|
21
|
+
* conservative (never loses lifetime totals).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, renameSync } from "fs";
|
|
25
|
+
import { mkdir, readFile, rename, stat, unlink, writeFile } from "fs/promises";
|
|
26
|
+
import { homedir } from "os";
|
|
27
|
+
import { dirname, join } from "path";
|
|
28
|
+
import { fileURLToPath } from "url";
|
|
29
|
+
import { randomBytes } from "crypto";
|
|
30
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
31
|
+
import { bumpStreak } from "./_streaks.ts";
|
|
32
|
+
import { getIdentityHash } from "./_identity-hash.ts";
|
|
33
|
+
import { emitDailyHeartbeat } from "./_telemetry.ts";
|
|
34
|
+
|
|
35
|
+
// Optional SQLite backend. Statically imported so there's no dynamic-import
|
|
36
|
+
// penalty on the hot path, but the SQLite connection is only opened when a
|
|
37
|
+
// function below actually routes through it (see useSqlite).
|
|
38
|
+
import * as sqliteBackend from "./_stats-sqlite";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Backend switch. When `ASHLR_STATS_BACKEND=sqlite`, all exported async
|
|
42
|
+
* entry points delegate to `./_stats-sqlite.ts` (WAL-mode SQLite at
|
|
43
|
+
* `~/.ashlr/stats.db`) instead of the legacy tempfile+lockfile+JSON
|
|
44
|
+
* path. Default `json` — flip to `sqlite` in v1.16 once VS Code and the
|
|
45
|
+
* other direct-stats.json readers catch up.
|
|
46
|
+
*/
|
|
47
|
+
function useSqlite(): boolean {
|
|
48
|
+
return process.env.ASHLR_STATS_BACKEND === "sqlite";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Types (exported so server code can reference shapes without duplicating)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export interface PerTool { calls: number; tokensSaved: number }
|
|
56
|
+
export interface ByTool { [k: string]: PerTool }
|
|
57
|
+
export interface ByDay { [date: string]: { calls: number; tokensSaved: number } }
|
|
58
|
+
|
|
59
|
+
/** Per-session bucket. One per live CLAUDE_SESSION_ID. */
|
|
60
|
+
export interface SessionBucket {
|
|
61
|
+
startedAt: string;
|
|
62
|
+
/** ISO timestamp of the most recent recordSaving — drives the status-line animation pulse. */
|
|
63
|
+
lastSavingAt: string | null;
|
|
64
|
+
calls: number;
|
|
65
|
+
tokensSaved: number;
|
|
66
|
+
byTool: ByTool;
|
|
67
|
+
/** v1.34: measured savings (optional, absent on pre-v1.34 buckets). */
|
|
68
|
+
tokensSavedMeasured?: number;
|
|
69
|
+
measuredCalls?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface LifetimeBucket {
|
|
73
|
+
calls: number;
|
|
74
|
+
tokensSaved: number;
|
|
75
|
+
/**
|
|
76
|
+
* Raw token equivalent of every source payload that fed a recordSaving
|
|
77
|
+
* call — the denominator for a `ratio = tokensSaved / rawTotal` display.
|
|
78
|
+
*
|
|
79
|
+
* Added in v1.18 for defensible "% savings" reporting. Optional so that
|
|
80
|
+
* callers constructing a LifetimeBucket literal (tests, fixtures) don't
|
|
81
|
+
* have to be updated — missing field is treated as 0 by every reader.
|
|
82
|
+
* migrateToV2() populates it from on-disk data when present.
|
|
83
|
+
*/
|
|
84
|
+
rawTotal?: number;
|
|
85
|
+
byTool: ByTool;
|
|
86
|
+
byDay: ByDay;
|
|
87
|
+
/** v1.34: measured savings (optional, absent on pre-v1.34 files). */
|
|
88
|
+
tokensSavedMeasured?: number;
|
|
89
|
+
measuredCalls?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface SummarizationStats {
|
|
93
|
+
calls: number;
|
|
94
|
+
cacheHits: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Sparse map keyed by UTC `YYYY-MM-DD`. Only days with at least one
|
|
99
|
+
* successful ashlr tool invocation appear. Used by the WAD-D north-star
|
|
100
|
+
* metric (weekly-active-developers-using-it-daily).
|
|
101
|
+
*/
|
|
102
|
+
export interface DailyActiveMap {
|
|
103
|
+
[date: string]: true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** On-disk shape. schemaVersion lets us migrate without breaking older clients. */
|
|
107
|
+
export interface StatsFile {
|
|
108
|
+
schemaVersion: 2;
|
|
109
|
+
sessions: { [sessionId: string]: SessionBucket };
|
|
110
|
+
lifetime: LifetimeBucket;
|
|
111
|
+
summarization?: SummarizationStats;
|
|
112
|
+
/**
|
|
113
|
+
* WAD-D: ISO timestamp set on the first ashlr tool success ever observed
|
|
114
|
+
* on this machine. Never overwritten once set.
|
|
115
|
+
* Older stats.json files (pre-WAD-D) lack this field, treated as unset
|
|
116
|
+
* and populated on next save.
|
|
117
|
+
*/
|
|
118
|
+
user_first_active_at?: string;
|
|
119
|
+
/**
|
|
120
|
+
* WAD-D: ISO timestamp of the most recent ashlr tool success on this
|
|
121
|
+
* machine. Updated on every recordSaving call.
|
|
122
|
+
*/
|
|
123
|
+
user_last_active_at?: string;
|
|
124
|
+
/**
|
|
125
|
+
* WAD-D: sparse map of UTC dates the machine has had >=1 success on.
|
|
126
|
+
* Drives the once-per-UTC-day heartbeat to /stats/daily-active.
|
|
127
|
+
*/
|
|
128
|
+
daily_active?: DailyActiveMap;
|
|
129
|
+
/**
|
|
130
|
+
* WAD-D lead indicator: ISO timestamp set when the /ashlr-start
|
|
131
|
+
* onboarding wizard completes the final step. Never overwritten once set.
|
|
132
|
+
*/
|
|
133
|
+
onboarding_completed_at?: string;
|
|
134
|
+
/**
|
|
135
|
+
* WAD-D lead indicator: ISO timestamp of the first recordSaving call
|
|
136
|
+
* whose tokensSaved cleared a meaningful threshold (currently 100 raw
|
|
137
|
+
* tokens). Never overwritten once set. Used to compute the
|
|
138
|
+
* first-savings-within-30-min cohort rate server-side.
|
|
139
|
+
*/
|
|
140
|
+
first_savings_at?: string;
|
|
141
|
+
/**
|
|
142
|
+
* WAD-D lead indicator: total /ashlr-savings slash-command invocations
|
|
143
|
+
* grouped by ISO-week key (e.g. "2026-W21"). Sparse map; weeks with no
|
|
144
|
+
* invocations are absent. Drives the "savings invocations per active
|
|
145
|
+
* user per week" lever. Aggregator reads the current ISO-week count.
|
|
146
|
+
*/
|
|
147
|
+
savings_invocations_by_week?: { [isoWeek: string]: number };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Paths & session id
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
function home(): string { return process.env.HOME ?? homedir(); }
|
|
155
|
+
export function statsPath(): string { return join(home(), ".ashlr", "stats.json"); }
|
|
156
|
+
function lockPath(): string { return statsPath() + ".lock"; }
|
|
157
|
+
function tempPath(): string { return statsPath() + ".tmp." + process.pid + "." + randomBytes(3).toString("hex"); }
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Derive the PPID-based fallback session id. Used when CLAUDE_SESSION_ID is
|
|
161
|
+
* not forwarded to this process (which, as of v0.9.x, is what happens for
|
|
162
|
+
* MCP server subprocesses Claude Code spawns — it passes the env var to the
|
|
163
|
+
* status-line and hooks, but NOT to MCP servers).
|
|
164
|
+
*/
|
|
165
|
+
function ppidSessionId(): string {
|
|
166
|
+
const seed = `ppid:${typeof process.ppid === "number" ? process.ppid : "?"}:${process.env.HOME ?? ""}`;
|
|
167
|
+
let h = 0;
|
|
168
|
+
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0;
|
|
169
|
+
return `p${(h >>> 0).toString(16)}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* v1.19.2: read a sessionId hint from ~/.ashlr/last-project.json (written by
|
|
174
|
+
* hooks/session-start.ts in hook-context where CLAUDE_SESSION_ID IS set).
|
|
175
|
+
* Used when the running process doesn't see the env var (MCP subprocesses,
|
|
176
|
+
* status-line). Hint is gated on a 24h updatedAt TTL so stale entries from
|
|
177
|
+
* old sessions don't leak into new ones.
|
|
178
|
+
*
|
|
179
|
+
* v1.20.2: exported + accepts an optional homeDir so callers in other
|
|
180
|
+
* processes (status-line, sqlite stats backend) can share the same logic
|
|
181
|
+
* instead of duplicating it. The single-source-of-truth prevents the
|
|
182
|
+
* recurring "session counter stuck at 0" regressions (v0.9.3, v1.0.1,
|
|
183
|
+
* v1.20.1) caused by drifted local copies.
|
|
184
|
+
*/
|
|
185
|
+
export const SESSION_HINT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
186
|
+
|
|
187
|
+
export function readSessionHint(homeDir: string = process.env.HOME ?? ""): string | null {
|
|
188
|
+
if (!homeDir) return null;
|
|
189
|
+
try {
|
|
190
|
+
const hintPath = join(homeDir, ".ashlr", "last-project.json");
|
|
191
|
+
const raw = readFileSync(hintPath, "utf-8");
|
|
192
|
+
const parsed = JSON.parse(raw) as { sessionId?: unknown; updatedAt?: unknown };
|
|
193
|
+
if (typeof parsed.sessionId !== "string" || parsed.sessionId.trim().length === 0) return null;
|
|
194
|
+
if (typeof parsed.updatedAt !== "string") return null;
|
|
195
|
+
const age = Date.now() - new Date(parsed.updatedAt).getTime();
|
|
196
|
+
if (!Number.isFinite(age) || age < 0 || age > SESSION_HINT_TTL_MS) return null;
|
|
197
|
+
return parsed.sessionId.trim();
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolve the current session id for WRITERS. Prefers CLAUDE_SESSION_ID
|
|
205
|
+
* (set by Claude Code for status-line/hook contexts); falls back to the
|
|
206
|
+
* session-start hint file (v1.19.2 — captured where CLAUDE_SESSION_ID is
|
|
207
|
+
* visible); falls back to PPID-derived hash as a last resort.
|
|
208
|
+
*
|
|
209
|
+
* NEVER returns an empty string — always a stable identifier.
|
|
210
|
+
*/
|
|
211
|
+
export function currentSessionId(): string {
|
|
212
|
+
const explicit = process.env.CLAUDE_SESSION_ID;
|
|
213
|
+
if (explicit && explicit.trim().length > 0) return explicit.trim();
|
|
214
|
+
const hint = readSessionHint();
|
|
215
|
+
if (hint) return hint;
|
|
216
|
+
return ppidSessionId();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve the set of session ids this process could belong to, for READERS.
|
|
221
|
+
* Because Claude Code inconsistently forwards CLAUDE_SESSION_ID (status line
|
|
222
|
+
* sees it, MCP servers don't), a reader that used only `currentSessionId()`
|
|
223
|
+
* would miss savings written under the OTHER id.
|
|
224
|
+
*
|
|
225
|
+
* v1.19.2: also include the session-start hint id so writers and readers
|
|
226
|
+
* converge on a shared bucket even when neither side sees the env var.
|
|
227
|
+
*
|
|
228
|
+
* The status line calls this and sums across every candidate bucket so the
|
|
229
|
+
* session counter stays correct regardless of which side CLAUDE_SESSION_ID
|
|
230
|
+
* reached.
|
|
231
|
+
*/
|
|
232
|
+
export function candidateSessionIds(): string[] {
|
|
233
|
+
const ids: string[] = [];
|
|
234
|
+
const explicit = process.env.CLAUDE_SESSION_ID;
|
|
235
|
+
if (explicit && explicit.trim().length > 0) ids.push(explicit.trim());
|
|
236
|
+
const hint = readSessionHint();
|
|
237
|
+
if (hint && !ids.includes(hint)) ids.push(hint);
|
|
238
|
+
const ppid = ppidSessionId();
|
|
239
|
+
if (!ids.includes(ppid)) ids.push(ppid);
|
|
240
|
+
return ids;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Empty-shape helpers
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
function emptyTools(): ByTool { return {}; }
|
|
248
|
+
|
|
249
|
+
function emptySession(startedAt = new Date().toISOString()): SessionBucket {
|
|
250
|
+
return { startedAt, lastSavingAt: null, calls: 0, tokensSaved: 0, byTool: emptyTools() };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function emptyLifetime(): LifetimeBucket {
|
|
254
|
+
return { calls: 0, tokensSaved: 0, rawTotal: 0, byTool: emptyTools(), byDay: {} };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function emptyStats(): StatsFile {
|
|
258
|
+
return { schemaVersion: 2, sessions: {}, lifetime: emptyLifetime() };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function todayKey(): string { return new Date().toISOString().slice(0, 10); }
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* UTC ISO-week key (e.g. "2026-W21"). Week 01 is the week containing the
|
|
265
|
+
* first Thursday of the year (ISO 8601). Used to bucket
|
|
266
|
+
* /ashlr-savings invocations per week.
|
|
267
|
+
*/
|
|
268
|
+
export function currentIsoWeek(d: Date = new Date()): string {
|
|
269
|
+
// Clone to a copy at UTC midnight so we don't mutate the caller's Date.
|
|
270
|
+
const dt = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
271
|
+
// ISO weekday: Mon=1 ... Sun=7. JS getUTCDay: Sun=0 ... Sat=6.
|
|
272
|
+
const dayNum = (dt.getUTCDay() + 6) % 7 + 1;
|
|
273
|
+
// Thursday of the current ISO week.
|
|
274
|
+
dt.setUTCDate(dt.getUTCDate() + 4 - dayNum);
|
|
275
|
+
const yearStart = new Date(Date.UTC(dt.getUTCFullYear(), 0, 1));
|
|
276
|
+
const weekNum = Math.ceil(((dt.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
|
|
277
|
+
return `${dt.getUTCFullYear()}-W${String(weekNum).padStart(2, "0")}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* WAD-D lead indicator threshold: a recordSaving call with at least this
|
|
282
|
+
* many raw saved tokens (the *output* of the savings math, post-/4 divide)
|
|
283
|
+
* counts as "meaningful" — sets first_savings_at the first time it fires.
|
|
284
|
+
*
|
|
285
|
+
* 100 tokens ~= one decent compressed grep result; not a noise-level event.
|
|
286
|
+
*/
|
|
287
|
+
export const FIRST_SAVINGS_THRESHOLD_TOKENS = 100;
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Migration (v1 → v2)
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Accept any prior shape and coerce to v2. Never throws; returns an empty
|
|
295
|
+
* file on unreadable input rather than losing data silently.
|
|
296
|
+
*
|
|
297
|
+
* v1 shape (in the wild): { session: {...}, lifetime: {...} }
|
|
298
|
+
* v2 shape: { schemaVersion: 2, sessions: {...}, lifetime: {...} }
|
|
299
|
+
*/
|
|
300
|
+
export function migrateToV2(raw: unknown): StatsFile {
|
|
301
|
+
if (!raw || typeof raw !== "object") return emptyStats();
|
|
302
|
+
const r = raw as Partial<StatsFile> & { session?: Partial<SessionBucket>; summarization?: SummarizationStats };
|
|
303
|
+
if (r.schemaVersion === 2 && r.sessions && r.lifetime) {
|
|
304
|
+
const out: StatsFile = {
|
|
305
|
+
schemaVersion: 2,
|
|
306
|
+
sessions: coerceSessions(r.sessions),
|
|
307
|
+
lifetime: coerceLifetime(r.lifetime),
|
|
308
|
+
summarization: r.summarization,
|
|
309
|
+
};
|
|
310
|
+
applyWaddFields(out, r);
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
// v1 → v2: lifetime carries over; legacy global `session` is discarded
|
|
314
|
+
// because it was never accurate across concurrent terminals anyway.
|
|
315
|
+
// Pre-WAD-D files just lack the new fields — applyWaddFields() leaves
|
|
316
|
+
// them undefined so bump() can populate them on the next save.
|
|
317
|
+
const out: StatsFile = {
|
|
318
|
+
schemaVersion: 2,
|
|
319
|
+
sessions: {},
|
|
320
|
+
lifetime: coerceLifetime(r.lifetime),
|
|
321
|
+
summarization: r.summarization,
|
|
322
|
+
};
|
|
323
|
+
applyWaddFields(out, r);
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Copy WAD-D top-level fields (user_first_active_at, user_last_active_at,
|
|
329
|
+
* daily_active) from a partial on-disk shape onto a freshly-coerced StatsFile.
|
|
330
|
+
* Tolerant of missing/malformed fields — older stats.json files predate
|
|
331
|
+
* WAD-D and lack them; bump() will populate on next recordSaving.
|
|
332
|
+
*/
|
|
333
|
+
function applyWaddFields(
|
|
334
|
+
out: StatsFile,
|
|
335
|
+
r: Partial<StatsFile> & Record<string, unknown>,
|
|
336
|
+
): void {
|
|
337
|
+
if (typeof r.user_first_active_at === "string" && r.user_first_active_at.length > 0) {
|
|
338
|
+
out.user_first_active_at = r.user_first_active_at;
|
|
339
|
+
}
|
|
340
|
+
if (typeof r.user_last_active_at === "string" && r.user_last_active_at.length > 0) {
|
|
341
|
+
out.user_last_active_at = r.user_last_active_at;
|
|
342
|
+
}
|
|
343
|
+
if (r.daily_active && typeof r.daily_active === "object" && !Array.isArray(r.daily_active)) {
|
|
344
|
+
const da: DailyActiveMap = {};
|
|
345
|
+
for (const [k, v] of Object.entries(r.daily_active as Record<string, unknown>)) {
|
|
346
|
+
// Only accept "YYYY-MM-DD" keys with value === true.
|
|
347
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(k) && v === true) {
|
|
348
|
+
da[k] = true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (Object.keys(da).length > 0) out.daily_active = da;
|
|
352
|
+
}
|
|
353
|
+
if (typeof r.onboarding_completed_at === "string" && r.onboarding_completed_at.length > 0) {
|
|
354
|
+
out.onboarding_completed_at = r.onboarding_completed_at;
|
|
355
|
+
}
|
|
356
|
+
if (typeof r.first_savings_at === "string" && r.first_savings_at.length > 0) {
|
|
357
|
+
out.first_savings_at = r.first_savings_at;
|
|
358
|
+
}
|
|
359
|
+
if (
|
|
360
|
+
r.savings_invocations_by_week &&
|
|
361
|
+
typeof r.savings_invocations_by_week === "object" &&
|
|
362
|
+
!Array.isArray(r.savings_invocations_by_week)
|
|
363
|
+
) {
|
|
364
|
+
const wk: Record<string, number> = {};
|
|
365
|
+
for (const [k, v] of Object.entries(r.savings_invocations_by_week as Record<string, unknown>)) {
|
|
366
|
+
// Accept ISO-week keys like "2026-W21".
|
|
367
|
+
if (/^\d{4}-W\d{2}$/.test(k) && typeof v === "number" && Number.isFinite(v) && v >= 0) {
|
|
368
|
+
wk[k] = Math.floor(v);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (Object.keys(wk).length > 0) out.savings_invocations_by_week = wk;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function coerceSessions(v: unknown): { [id: string]: SessionBucket } {
|
|
376
|
+
if (!v || typeof v !== "object") return {};
|
|
377
|
+
const out: { [id: string]: SessionBucket } = {};
|
|
378
|
+
for (const [id, bucket] of Object.entries(v as Record<string, unknown>)) {
|
|
379
|
+
const b = (bucket ?? {}) as Partial<SessionBucket>;
|
|
380
|
+
out[id] = {
|
|
381
|
+
startedAt: typeof b.startedAt === "string" ? b.startedAt : new Date().toISOString(),
|
|
382
|
+
lastSavingAt: typeof b.lastSavingAt === "string" ? b.lastSavingAt : null,
|
|
383
|
+
calls: numOr0(b.calls),
|
|
384
|
+
tokensSaved: numOr0(b.tokensSaved),
|
|
385
|
+
byTool: coerceByTool(b.byTool),
|
|
386
|
+
};
|
|
387
|
+
const sm = numOr0(b.tokensSavedMeasured);
|
|
388
|
+
if (sm > 0) out[id]!.tokensSavedMeasured = sm;
|
|
389
|
+
const mc = numOr0(b.measuredCalls);
|
|
390
|
+
if (mc > 0) out[id]!.measuredCalls = mc;
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function coerceLifetime(v: unknown): LifetimeBucket {
|
|
396
|
+
const l = (v ?? {}) as Partial<LifetimeBucket>;
|
|
397
|
+
const out: LifetimeBucket = {
|
|
398
|
+
calls: numOr0(l.calls),
|
|
399
|
+
tokensSaved: numOr0(l.tokensSaved),
|
|
400
|
+
byTool: coerceByTool(l.byTool),
|
|
401
|
+
byDay: coerceByDay(l.byDay),
|
|
402
|
+
};
|
|
403
|
+
// v1.18: rawTotal is optional. Only set it when present + non-zero so
|
|
404
|
+
// reads of legacy files stay byte-equal (no spurious rawTotal:0 tag in
|
|
405
|
+
// the in-memory shape that wasn't on disk).
|
|
406
|
+
const rt = numOr0(l.rawTotal);
|
|
407
|
+
if (rt > 0) out.rawTotal = rt;
|
|
408
|
+
const ltsm = numOr0(l.tokensSavedMeasured);
|
|
409
|
+
if (ltsm > 0) out.tokensSavedMeasured = ltsm;
|
|
410
|
+
const ltmc = numOr0(l.measuredCalls);
|
|
411
|
+
if (ltmc > 0) out.measuredCalls = ltmc;
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function coerceByTool(v: unknown): ByTool {
|
|
416
|
+
if (!v || typeof v !== "object") return {};
|
|
417
|
+
const out: ByTool = {};
|
|
418
|
+
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
|
419
|
+
const p = (val ?? {}) as Partial<PerTool>;
|
|
420
|
+
out[k] = { calls: numOr0(p.calls), tokensSaved: numOr0(p.tokensSaved) };
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function coerceByDay(v: unknown): ByDay {
|
|
426
|
+
if (!v || typeof v !== "object") return {};
|
|
427
|
+
const out: ByDay = {};
|
|
428
|
+
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
|
429
|
+
const p = (val ?? {}) as { calls?: number; tokensSaved?: number };
|
|
430
|
+
out[k] = { calls: numOr0(p.calls), tokensSaved: numOr0(p.tokensSaved) };
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function numOr0(v: unknown): number { return typeof v === "number" && Number.isFinite(v) ? v : 0; }
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// In-memory cache + mtime invalidation
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
/** True when ASHLR_STATS_SYNC=1 — bypasses debouncing entirely. Checked per-call so tests can toggle it. */
|
|
442
|
+
function isSyncMode(): boolean { return process.env.ASHLR_STATS_SYNC === "1"; }
|
|
443
|
+
|
|
444
|
+
interface MemCache {
|
|
445
|
+
data: StatsFile;
|
|
446
|
+
/** mtime of the file when we last read it; used to detect external writes. */
|
|
447
|
+
mtime: number;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
let _memCache: MemCache | null = null;
|
|
451
|
+
|
|
452
|
+
function fileMtime(): number {
|
|
453
|
+
try { return statSync(statsPath()).mtimeMs; } catch { return 0; }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Read from in-memory cache if still valid, otherwise hit disk. */
|
|
457
|
+
async function readStatsCached(): Promise<StatsFile> {
|
|
458
|
+
const mtime = fileMtime();
|
|
459
|
+
if (_memCache && _memCache.mtime === mtime) return _memCache.data;
|
|
460
|
+
const fresh = await readStats();
|
|
461
|
+
_memCache = { data: fresh, mtime };
|
|
462
|
+
return fresh;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function updateMemCache(s: StatsFile): void {
|
|
466
|
+
_memCache = { data: s, mtime: fileMtime() };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// Debounce flush
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
/** Pending deltas waiting to be merged into the next disk write. */
|
|
474
|
+
interface PendingDelta {
|
|
475
|
+
toolName: string;
|
|
476
|
+
saved: number;
|
|
477
|
+
sessionId: string;
|
|
478
|
+
day: string;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
let _pendingDeltas: PendingDelta[] = [];
|
|
482
|
+
let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
483
|
+
const DEBOUNCE_MS = 250;
|
|
484
|
+
|
|
485
|
+
/** Accumulated in-memory state that hasn't been flushed yet. */
|
|
486
|
+
let _pendingStats: StatsFile | null = null;
|
|
487
|
+
|
|
488
|
+
/** How many disk writes have occurred (for test coalescing assertions). */
|
|
489
|
+
let _writeCount = 0;
|
|
490
|
+
export function _getWriteCount(): number { return _writeCount; }
|
|
491
|
+
export function _resetWriteCount(): void { _writeCount = 0; }
|
|
492
|
+
|
|
493
|
+
async function flushToDisk(): Promise<void> {
|
|
494
|
+
if (!_pendingStats) return;
|
|
495
|
+
const toWrite = _pendingStats;
|
|
496
|
+
// Clear the debounce timer but keep `_pendingStats` populated until the
|
|
497
|
+
// write actually lands — that way if the process exits mid-write, the
|
|
498
|
+
// sync exit handler can re-run the flush and nothing is lost.
|
|
499
|
+
if (_debounceTimer) { clearTimeout(_debounceTimer); _debounceTimer = null; }
|
|
500
|
+
const release = await acquireFileLock();
|
|
501
|
+
try {
|
|
502
|
+
await writeStatsAtomic(toWrite);
|
|
503
|
+
_writeCount++;
|
|
504
|
+
updateMemCache(toWrite);
|
|
505
|
+
// Only now is it safe to declare the pending delta flushed. A new
|
|
506
|
+
// recordSaving that arrived mid-write mutated `_pendingStats` in place
|
|
507
|
+
// (same object reference), so `toWrite === _pendingStats` is still true.
|
|
508
|
+
if (_pendingStats === toWrite) _pendingStats = null;
|
|
509
|
+
} finally {
|
|
510
|
+
await release();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Synchronous flush on exit — never loses tail of a session. Because
|
|
516
|
+
* `flushToDisk` keeps `_pendingStats` populated until the rename succeeds,
|
|
517
|
+
* this sync path will re-fire any in-flight async flush that hadn't
|
|
518
|
+
* completed before process exit.
|
|
519
|
+
*/
|
|
520
|
+
function flushToDiskSync(): void {
|
|
521
|
+
if (!_pendingStats) return;
|
|
522
|
+
const toWrite = _pendingStats;
|
|
523
|
+
_pendingStats = null;
|
|
524
|
+
if (_debounceTimer) { clearTimeout(_debounceTimer); _debounceTimer = null; }
|
|
525
|
+
const p = statsPath();
|
|
526
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
527
|
+
const tp = p + ".tmp." + process.pid + "." + randomBytes(3).toString("hex");
|
|
528
|
+
writeFileSync(tp, JSON.stringify(toWrite));
|
|
529
|
+
renameSync(tp, p);
|
|
530
|
+
_writeCount++;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Register exit handlers once.
|
|
534
|
+
process.on("exit", flushToDiskSync);
|
|
535
|
+
process.on("beforeExit", () => { if (_pendingStats) { flushToDiskSync(); } });
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// Cross-process + in-process locking
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* In-process mutex. Serializes the read-modify-write cycle across every
|
|
543
|
+
* recordSaving invocation within a single MCP server process. Without this,
|
|
544
|
+
* two concurrent tool calls in the same server could race on the in-memory
|
|
545
|
+
* copy between read and write.
|
|
546
|
+
*/
|
|
547
|
+
let writeQueue: Promise<void> = Promise.resolve();
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Cross-process advisory lock via a sibling `.lock` file.
|
|
551
|
+
* - atomic create-exclusive (O_EXCL) — fails if another process holds it
|
|
552
|
+
* - mtime-based stale-lock detection (5s) so a crashed holder doesn't
|
|
553
|
+
* wedge us forever
|
|
554
|
+
* - bounded retry with jitter; on timeout we proceed *without* the lock
|
|
555
|
+
* rather than block the tool call. Under contention we prefer a
|
|
556
|
+
* possibly-lossy write to a hung MCP server.
|
|
557
|
+
*/
|
|
558
|
+
async function acquireFileLock(timeoutMs = 500): Promise<() => Promise<void>> {
|
|
559
|
+
const lp = lockPath();
|
|
560
|
+
await mkdir(dirname(lp), { recursive: true });
|
|
561
|
+
const deadline = Date.now() + timeoutMs;
|
|
562
|
+
while (Date.now() < deadline) {
|
|
563
|
+
try {
|
|
564
|
+
await writeFile(lp, String(process.pid), { flag: "wx" });
|
|
565
|
+
return async () => { try { await unlink(lp); } catch { /* ignore */ } };
|
|
566
|
+
} catch (e) {
|
|
567
|
+
const err = e as NodeJS.ErrnoException;
|
|
568
|
+
if (err.code !== "EEXIST") {
|
|
569
|
+
// Non-contention error (perm denied, etc.) — give up and proceed
|
|
570
|
+
// without lock rather than throw.
|
|
571
|
+
return async () => { /* noop */ };
|
|
572
|
+
}
|
|
573
|
+
// Check for stale lock (mtime older than 5s)
|
|
574
|
+
try {
|
|
575
|
+
const st = await stat(lp);
|
|
576
|
+
if (Date.now() - st.mtimeMs > 5_000) {
|
|
577
|
+
// best-effort: clearing a stale lock is advisory; a concurrent writer may have just deleted it, in which case the next wx-create will race to acquire it.
|
|
578
|
+
await unlink(lp).catch(() => {});
|
|
579
|
+
continue; // retry immediately
|
|
580
|
+
}
|
|
581
|
+
} catch { /* stat failed; lock may have been released, retry */ }
|
|
582
|
+
await new Promise((r) => setTimeout(r, 10 + Math.random() * 20));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// Lock timed out — proceed without it. Not ideal, but better than
|
|
586
|
+
// blocking a tool call. Callers still see consistent in-process state
|
|
587
|
+
// via the write queue.
|
|
588
|
+
return async () => { /* noop */ };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
// Read / write
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
/** Read stats.json, migrating if needed. Always resolves; never throws.
|
|
596
|
+
* Returns the pending in-memory state when a debounced flush is outstanding,
|
|
597
|
+
* so callers see up-to-date numbers without waiting for disk. */
|
|
598
|
+
export async function readStats(): Promise<StatsFile> {
|
|
599
|
+
if (useSqlite()) return sqliteBackend.readStats();
|
|
600
|
+
if (_pendingStats) return _pendingStats;
|
|
601
|
+
const p = statsPath();
|
|
602
|
+
if (!existsSync(p)) return emptyStats();
|
|
603
|
+
try {
|
|
604
|
+
return migrateToV2(JSON.parse(await readFile(p, "utf-8")));
|
|
605
|
+
} catch {
|
|
606
|
+
return emptyStats();
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Atomic write: temp + rename. Minified JSON. */
|
|
611
|
+
async function writeStatsAtomic(s: StatsFile): Promise<void> {
|
|
612
|
+
const p = statsPath();
|
|
613
|
+
await mkdir(dirname(p), { recursive: true });
|
|
614
|
+
const tp = tempPath();
|
|
615
|
+
// Minified: no pretty-print whitespace on disk.
|
|
616
|
+
await writeFile(tp, JSON.stringify(s));
|
|
617
|
+
await rename(tp, p);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Serialize every read-modify-write through one queue so in-process
|
|
622
|
+
* recordSaving calls can't race with each other, then acquire the
|
|
623
|
+
* cross-process lock for the disk portion.
|
|
624
|
+
*
|
|
625
|
+
* In SYNC_MODE (ASHLR_STATS_SYNC=1) this behaves as before: every call
|
|
626
|
+
* acquires the file lock and writes atomically. In debounced mode the fn
|
|
627
|
+
* still runs inside the in-process queue but the actual disk write is
|
|
628
|
+
* coalesced and deferred by up to DEBOUNCE_MS.
|
|
629
|
+
*/
|
|
630
|
+
async function withSerializedWrite<T>(fn: (s: StatsFile) => Promise<{ result: T; updated: StatsFile }>, opts: { immediate?: boolean } = {}): Promise<T> {
|
|
631
|
+
let resolveOuter!: (v: T) => void;
|
|
632
|
+
let rejectOuter!: (err: unknown) => void;
|
|
633
|
+
const outer = new Promise<T>((res, rej) => { resolveOuter = res; rejectOuter = rej; });
|
|
634
|
+
|
|
635
|
+
const runFn = async () => {
|
|
636
|
+
if (isSyncMode() || opts.immediate) {
|
|
637
|
+
const release = await acquireFileLock();
|
|
638
|
+
try {
|
|
639
|
+
const s = await readStatsCached();
|
|
640
|
+
const { result, updated } = await fn(s);
|
|
641
|
+
await writeStatsAtomic(updated);
|
|
642
|
+
_writeCount++;
|
|
643
|
+
updateMemCache(updated);
|
|
644
|
+
resolveOuter(result);
|
|
645
|
+
} catch (e) {
|
|
646
|
+
rejectOuter(e);
|
|
647
|
+
} finally {
|
|
648
|
+
await release();
|
|
649
|
+
}
|
|
650
|
+
} else {
|
|
651
|
+
// Debounced path: mutate in memory, schedule flush.
|
|
652
|
+
try {
|
|
653
|
+
const base = _pendingStats ?? await readStatsCached();
|
|
654
|
+
const { result, updated } = await fn(base);
|
|
655
|
+
_pendingStats = updated;
|
|
656
|
+
resolveOuter(result);
|
|
657
|
+
// Arm/re-arm the debounce timer.
|
|
658
|
+
if (_debounceTimer) clearTimeout(_debounceTimer);
|
|
659
|
+
_debounceTimer = setTimeout(() => {
|
|
660
|
+
writeQueue = writeQueue.then(() => flushToDisk());
|
|
661
|
+
}, DEBOUNCE_MS);
|
|
662
|
+
} catch (e) {
|
|
663
|
+
rejectOuter(e);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
writeQueue = writeQueue.then(runFn, runFn);
|
|
669
|
+
return outer;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
// Public API — the single recordSaving used by every server
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Suppress flag for ashlr__pipe intermediate calls.
|
|
678
|
+
*
|
|
679
|
+
* When ashlr__pipe runs ctx.grep/read/bash/ls/glob internally, those handlers
|
|
680
|
+
* call recordSaving as usual. To prevent double-counting (pipe records ONE
|
|
681
|
+
* aggregate saving at the end), pipe sets this flag to true before each
|
|
682
|
+
* intermediate call and back to false after. recordSaving returns 0 immediately
|
|
683
|
+
* while suppressed.
|
|
684
|
+
*
|
|
685
|
+
* This is a simple module-level boolean — safe because the MCP server is
|
|
686
|
+
* single-threaded (Node/Bun event loop). Concurrent pipe calls on separate
|
|
687
|
+
* server processes each have their own module instance.
|
|
688
|
+
*/
|
|
689
|
+
const _suppressAccounting = new AsyncLocalStorage<boolean>();
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Run `fn` with savings-accounting suppressed for ITS async call tree only.
|
|
693
|
+
* ashlr__pipe wraps each intermediate tool call in this so the inner
|
|
694
|
+
* recordSaving() no-ops (the pipe records ONE aggregate instead). Scoped via
|
|
695
|
+
* AsyncLocalStorage — a process-global boolean flakily suppressed *concurrent*
|
|
696
|
+
* unrelated recordSaving calls whenever execution overlapped (it zeroed other
|
|
697
|
+
* tools' savings, e.g. the orient stats test, under parallel test scheduling).
|
|
698
|
+
*/
|
|
699
|
+
export function _withSuppressedAccounting<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
700
|
+
return Promise.resolve(_suppressAccounting.run(true, fn));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Test/introspection helper: is accounting currently suppressed in this async scope? */
|
|
704
|
+
export function _isAccountingSuppressed(): boolean {
|
|
705
|
+
return _suppressAccounting.getStore() ?? false;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Record a tokens-saved event. Safe under concurrency. Returns the delta
|
|
710
|
+
* applied (useful for callers that want to surface a "[compact saved N]"
|
|
711
|
+
* note inline). Never throws.
|
|
712
|
+
*/
|
|
713
|
+
export async function recordSaving(
|
|
714
|
+
rawBytes: number,
|
|
715
|
+
compactBytes: number,
|
|
716
|
+
toolName: string,
|
|
717
|
+
opts: { sessionId?: string; measurementMode?: 'estimate' | 'measured' } = {},
|
|
718
|
+
): Promise<number> {
|
|
719
|
+
// ashlr__pipe sets this flag around intermediate ctx calls so they don't
|
|
720
|
+
// double-count. The pipe records ONE aggregate saving after execution.
|
|
721
|
+
if (_suppressAccounting.getStore()) return 0;
|
|
722
|
+
if (useSqlite()) return sqliteBackend.recordSaving(rawBytes, compactBytes, toolName, opts);
|
|
723
|
+
// Defensive: raw and compact must be finite non-negative numbers. If
|
|
724
|
+
// caller passes garbage, zero out rather than record NaN into lifetime.
|
|
725
|
+
const rawSafe = Number.isFinite(rawBytes) && rawBytes >= 0 ? rawBytes : 0;
|
|
726
|
+
const compactSafe = Number.isFinite(compactBytes) && compactBytes >= 0 ? compactBytes : 0;
|
|
727
|
+
const saved = Math.max(0, Math.ceil((rawSafe - compactSafe) / 4));
|
|
728
|
+
// `rawTok` is the counterfactual token count — tokens that *would have*
|
|
729
|
+
// been sent if the plugin hadn't intercepted. Used as the denominator
|
|
730
|
+
// for the % savings display.
|
|
731
|
+
const rawTok = Math.max(0, Math.ceil(rawSafe / 4));
|
|
732
|
+
const sid = opts.sessionId ?? currentSessionId();
|
|
733
|
+
let heartbeatToday: string | null = null;
|
|
734
|
+
const result = await withSerializedWrite(async (s) => {
|
|
735
|
+
const { firstActiveToday, today } = bump(s, toolName, saved, rawTok, sid, opts.measurementMode ?? 'estimate');
|
|
736
|
+
if (firstActiveToday) heartbeatToday = today;
|
|
737
|
+
return { result: saved, updated: s };
|
|
738
|
+
});
|
|
739
|
+
// Bump the daily saving streak (gated internally to once per day).
|
|
740
|
+
// Fire-and-forget + best-effort — streak tracking must never break accounting.
|
|
741
|
+
try { bumpStreak(); } catch { /* ignore */ }
|
|
742
|
+
// WAD-D: once per UTC day per machine, emit the daily-active heartbeat.
|
|
743
|
+
// Fire-and-forget; the emitter itself gates on telemetry consent.
|
|
744
|
+
if (heartbeatToday) {
|
|
745
|
+
try {
|
|
746
|
+
const id = getIdentityHash();
|
|
747
|
+
// Best-effort lead-indicator block — never break recordSaving if any
|
|
748
|
+
// of the helpers throws. We swallow errors per-field via the helpers
|
|
749
|
+
// themselves; the only thing we guard against here is a programming
|
|
750
|
+
// error introducing a sync throw.
|
|
751
|
+
let leadIndicators;
|
|
752
|
+
try {
|
|
753
|
+
leadIndicators = await buildHeartbeatLeadIndicators();
|
|
754
|
+
} catch {
|
|
755
|
+
leadIndicators = undefined;
|
|
756
|
+
}
|
|
757
|
+
emitDailyHeartbeat({
|
|
758
|
+
machineHash: id.machineHash,
|
|
759
|
+
githubHash: id.githubHash,
|
|
760
|
+
date: heartbeatToday,
|
|
761
|
+
pluginVersion: pluginVersionForHeartbeat(),
|
|
762
|
+
...(leadIndicators ? { leadIndicators } : {}),
|
|
763
|
+
});
|
|
764
|
+
} catch {
|
|
765
|
+
/* never break recordSaving */
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return result;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ---------------------------------------------------------------------------
|
|
772
|
+
// WAD-D heartbeat plumbing
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Best-effort plugin-version read for the daily heartbeat. Cached after the
|
|
777
|
+
* first call so we don't touch package.json on every tool invocation. Falls
|
|
778
|
+
* back to "0.0.0" when the file isn't readable (e.g. tests with a bare $HOME).
|
|
779
|
+
*/
|
|
780
|
+
let _cachedPluginVersion: string | null = null;
|
|
781
|
+
function pluginVersionForHeartbeat(): string {
|
|
782
|
+
if (_cachedPluginVersion) return _cachedPluginVersion;
|
|
783
|
+
try {
|
|
784
|
+
// package.json lives at the plugin root, one level up from servers/.
|
|
785
|
+
// Resolve from this file's URL so it works under ESM (Bun's import.meta).
|
|
786
|
+
const here = fileURLToPath(import.meta.url);
|
|
787
|
+
const pkgPath = join(dirname(dirname(here)), "package.json");
|
|
788
|
+
const raw = readFileSync(pkgPath, "utf-8");
|
|
789
|
+
const parsed = JSON.parse(raw) as { version?: unknown };
|
|
790
|
+
if (typeof parsed.version === "string" && parsed.version.length > 0) {
|
|
791
|
+
_cachedPluginVersion = parsed.version;
|
|
792
|
+
return _cachedPluginVersion;
|
|
793
|
+
}
|
|
794
|
+
} catch {
|
|
795
|
+
/* fall through */
|
|
796
|
+
}
|
|
797
|
+
_cachedPluginVersion = "0.0.0";
|
|
798
|
+
return _cachedPluginVersion;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Test hook: clear the cached plugin-version so tests can assert reads. */
|
|
802
|
+
export function _resetPluginVersionCache(): void { _cachedPluginVersion = null; }
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Assemble the per-client lead-indicator block for the daily heartbeat.
|
|
806
|
+
* Pulls from:
|
|
807
|
+
* - stats.json (onboarding_completed_at, first_savings_at, savings_invocations_by_week)
|
|
808
|
+
* - ~/.claude/settings.json (status-line opt-in)
|
|
809
|
+
* - ~/.ashlr/streaks.json (current streak)
|
|
810
|
+
* - ~/.ashlr/nudge-events.jsonl (nudge accept rate)
|
|
811
|
+
*
|
|
812
|
+
* Every field is independently best-effort. If a helper throws, that field
|
|
813
|
+
* is omitted from the block; the rest still ships.
|
|
814
|
+
*/
|
|
815
|
+
async function buildHeartbeatLeadIndicators(): Promise<import("./_telemetry.ts").DailyHeartbeatLeadIndicators> {
|
|
816
|
+
const out: import("./_telemetry.ts").DailyHeartbeatLeadIndicators = {};
|
|
817
|
+
try {
|
|
818
|
+
const snap = await readLeadIndicatorSnapshot();
|
|
819
|
+
if (snap.onboarding_completed !== null) out.onboarding_completed = snap.onboarding_completed;
|
|
820
|
+
else out.onboarding_completed = false;
|
|
821
|
+
out.status_line_enabled = snap.status_line_enabled;
|
|
822
|
+
out.first_savings_at = snap.first_savings_at;
|
|
823
|
+
out.savings_invocations_this_week = snap.savings_invocations_this_week;
|
|
824
|
+
} catch {
|
|
825
|
+
/* skip these fields */
|
|
826
|
+
}
|
|
827
|
+
try {
|
|
828
|
+
const { readStreaks } = await import("./_streaks.ts");
|
|
829
|
+
const s = readStreaks();
|
|
830
|
+
if (typeof s.currentStreak === "number" && s.currentStreak >= 0) {
|
|
831
|
+
out.streak_days = s.currentStreak;
|
|
832
|
+
}
|
|
833
|
+
} catch {
|
|
834
|
+
/* skip streak */
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
const { readNudgeSummarySync } = await import("./_nudge-events.ts");
|
|
838
|
+
const ns = readNudgeSummarySync();
|
|
839
|
+
if (ns.shown > 0) {
|
|
840
|
+
out.nudge_accept_rate = Math.min(1, Math.max(0, ns.clicked / ns.shown));
|
|
841
|
+
} else {
|
|
842
|
+
out.nudge_accept_rate = null;
|
|
843
|
+
}
|
|
844
|
+
} catch {
|
|
845
|
+
/* skip nudge rate */
|
|
846
|
+
}
|
|
847
|
+
return out;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Apply a single recordSaving bump to the in-memory stats shape. Also
|
|
852
|
+
* maintains the WAD-D top-level fields (user_first_active_at,
|
|
853
|
+
* user_last_active_at, daily_active) and returns whether today's
|
|
854
|
+
* daily_active entry was just created — the caller uses that signal to
|
|
855
|
+
* fire the once-per-UTC-day heartbeat.
|
|
856
|
+
*/
|
|
857
|
+
function bump(
|
|
858
|
+
s: StatsFile,
|
|
859
|
+
toolName: string,
|
|
860
|
+
saved: number,
|
|
861
|
+
rawTok: number,
|
|
862
|
+
sessionId: string,
|
|
863
|
+
measurementMode: 'estimate' | 'measured' = 'estimate',
|
|
864
|
+
): { firstActiveToday: boolean; today: string } {
|
|
865
|
+
// Session bucket
|
|
866
|
+
const sess = s.sessions[sessionId] ?? (s.sessions[sessionId] = emptySession());
|
|
867
|
+
sess.calls += 1;
|
|
868
|
+
sess.tokensSaved += saved;
|
|
869
|
+
const nowIso = new Date().toISOString();
|
|
870
|
+
sess.lastSavingAt = nowIso;
|
|
871
|
+
const st = sess.byTool[toolName] ?? (sess.byTool[toolName] = { calls: 0, tokensSaved: 0 });
|
|
872
|
+
st.calls += 1;
|
|
873
|
+
st.tokensSaved += saved;
|
|
874
|
+
|
|
875
|
+
// Lifetime
|
|
876
|
+
s.lifetime.calls += 1;
|
|
877
|
+
s.lifetime.tokensSaved += saved;
|
|
878
|
+
// v1.18: accumulate the raw (counterfactual) token count so the dashboard
|
|
879
|
+
// can render a defensible `saved / rawTotal` ratio. Guard against legacy
|
|
880
|
+
// in-memory state that predates the field.
|
|
881
|
+
s.lifetime.rawTotal = (s.lifetime.rawTotal ?? 0) + rawTok;
|
|
882
|
+
if (measurementMode === 'measured') {
|
|
883
|
+
sess.tokensSavedMeasured = (sess.tokensSavedMeasured ?? 0) + saved;
|
|
884
|
+
sess.measuredCalls = (sess.measuredCalls ?? 0) + 1;
|
|
885
|
+
s.lifetime.tokensSavedMeasured = (s.lifetime.tokensSavedMeasured ?? 0) + saved;
|
|
886
|
+
s.lifetime.measuredCalls = (s.lifetime.measuredCalls ?? 0) + 1;
|
|
887
|
+
}
|
|
888
|
+
const lt = s.lifetime.byTool[toolName] ?? (s.lifetime.byTool[toolName] = { calls: 0, tokensSaved: 0 });
|
|
889
|
+
lt.calls += 1;
|
|
890
|
+
lt.tokensSaved += saved;
|
|
891
|
+
const day = todayKey();
|
|
892
|
+
const d = s.lifetime.byDay[day] ?? (s.lifetime.byDay[day] = { calls: 0, tokensSaved: 0 });
|
|
893
|
+
d.calls += 1;
|
|
894
|
+
d.tokensSaved += saved;
|
|
895
|
+
|
|
896
|
+
// WAD-D: always touch user_last_active_at; never overwrite first.
|
|
897
|
+
if (!s.user_first_active_at) s.user_first_active_at = nowIso;
|
|
898
|
+
s.user_last_active_at = nowIso;
|
|
899
|
+
|
|
900
|
+
// WAD-D lead indicator: first_savings_at — set on the first recordSaving
|
|
901
|
+
// whose saved-token count clears the threshold. Sub-threshold events
|
|
902
|
+
// intentionally don't set it so a stray no-op tool call can't anchor the
|
|
903
|
+
// 30-minute first-savings cohort at install time.
|
|
904
|
+
if (!s.first_savings_at && saved >= FIRST_SAVINGS_THRESHOLD_TOKENS) {
|
|
905
|
+
s.first_savings_at = nowIso;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// WAD-D: idempotent daily_active gate. Re-using `day` (UTC YYYY-MM-DD)
|
|
909
|
+
// for consistency with lifetime.byDay so a single calendar boundary
|
|
910
|
+
// moves both signals together.
|
|
911
|
+
if (!s.daily_active) s.daily_active = {};
|
|
912
|
+
let firstActiveToday = false;
|
|
913
|
+
if (!s.daily_active[day]) {
|
|
914
|
+
s.daily_active[day] = true;
|
|
915
|
+
firstActiveToday = true;
|
|
916
|
+
}
|
|
917
|
+
return { firstActiveToday, today: day };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Initialize (or refresh startedAt on) the current session's bucket.
|
|
922
|
+
* Called by SessionStart hook. Idempotent. Never clobbers lifetime.
|
|
923
|
+
*/
|
|
924
|
+
export async function initSessionBucket(sessionId: string = currentSessionId()): Promise<void> {
|
|
925
|
+
if (useSqlite()) return sqliteBackend.initSessionBucket(sessionId);
|
|
926
|
+
await withSerializedWrite(async (s) => {
|
|
927
|
+
if (!s.sessions[sessionId]) s.sessions[sessionId] = emptySession();
|
|
928
|
+
return { result: undefined as void, updated: s };
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Drop this session's bucket(s) (called by SessionEnd GC hook). Removes every
|
|
934
|
+
* candidate id so that orphaned buckets (e.g. the MCP-written PPID-hash bucket
|
|
935
|
+
* when CLAUDE_SESSION_ID was set for the hook but not forwarded to MCP) don't
|
|
936
|
+
* accumulate. Returns the combined dropped bucket for summary logging.
|
|
937
|
+
*/
|
|
938
|
+
export async function dropSessionBucket(sessionId?: string): Promise<SessionBucket | null> {
|
|
939
|
+
if (useSqlite()) return sqliteBackend.dropSessionBucket(sessionId);
|
|
940
|
+
const ids = sessionId ? [sessionId] : candidateSessionIds();
|
|
941
|
+
return withSerializedWrite(async (s) => {
|
|
942
|
+
let calls = 0;
|
|
943
|
+
let tokensSaved = 0;
|
|
944
|
+
let startedAt = "";
|
|
945
|
+
let lastSavingAt: string | null = null;
|
|
946
|
+
let hit = false;
|
|
947
|
+
const byTool: ByTool = {};
|
|
948
|
+
for (const id of ids) {
|
|
949
|
+
const b = s.sessions[id];
|
|
950
|
+
if (!b) continue;
|
|
951
|
+
hit = true;
|
|
952
|
+
calls += b.calls;
|
|
953
|
+
tokensSaved += b.tokensSaved;
|
|
954
|
+
if (!startedAt || b.startedAt < startedAt) startedAt = b.startedAt;
|
|
955
|
+
if (b.lastSavingAt && (!lastSavingAt || b.lastSavingAt > lastSavingAt)) lastSavingAt = b.lastSavingAt;
|
|
956
|
+
for (const [tool, pt] of Object.entries(b.byTool)) {
|
|
957
|
+
const t = byTool[tool] ?? (byTool[tool] = { calls: 0, tokensSaved: 0 });
|
|
958
|
+
t.calls += pt.calls;
|
|
959
|
+
t.tokensSaved += pt.tokensSaved;
|
|
960
|
+
}
|
|
961
|
+
delete s.sessions[id];
|
|
962
|
+
}
|
|
963
|
+
const dropped: SessionBucket | null = hit
|
|
964
|
+
? { startedAt: startedAt || new Date().toISOString(), lastSavingAt, calls, tokensSaved, byTool }
|
|
965
|
+
: null;
|
|
966
|
+
return { result: dropped, updated: s };
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Bump a summarization counter (calls | cacheHits). Used by _summarize.ts. */
|
|
971
|
+
export async function bumpSummarization(field: "calls" | "cacheHits"): Promise<void> {
|
|
972
|
+
if (useSqlite()) return sqliteBackend.bumpSummarization(field);
|
|
973
|
+
await withSerializedWrite(async (s) => {
|
|
974
|
+
const sm = s.summarization ?? (s.summarization = { calls: 0, cacheHits: 0 });
|
|
975
|
+
sm[field] = (sm[field] ?? 0) + 1;
|
|
976
|
+
return { result: undefined as void, updated: s };
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* WAD-D lead indicator: stamp the onboarding-completion timestamp. Called
|
|
982
|
+
* by scripts/onboarding-wizard.ts when /ashlr-start finishes the final step.
|
|
983
|
+
* Never overwrites an existing stamp — first-completion wins so the cohort
|
|
984
|
+
* timestamp is stable across re-runs.
|
|
985
|
+
*/
|
|
986
|
+
export async function markOnboardingComplete(nowIso: string = new Date().toISOString()): Promise<void> {
|
|
987
|
+
// No sqlite delegation needed — the sqlite backend reads stats.json's
|
|
988
|
+
// top-level WAD-D fields via importStatsFile + readStats, so we always
|
|
989
|
+
// write through the JSON path for these aggregate-only signals.
|
|
990
|
+
await withSerializedWrite(async (s) => {
|
|
991
|
+
if (!s.onboarding_completed_at) s.onboarding_completed_at = nowIso;
|
|
992
|
+
return { result: undefined as void, updated: s };
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* WAD-D lead indicator: increment the /ashlr-savings invocation counter
|
|
998
|
+
* for the current ISO week. Called by the savings-server tool handler so
|
|
999
|
+
* we can compute "savings invocations per active user per week" without
|
|
1000
|
+
* shipping per-call telemetry.
|
|
1001
|
+
*/
|
|
1002
|
+
export async function bumpSavingsInvocation(now: Date = new Date()): Promise<void> {
|
|
1003
|
+
const week = currentIsoWeek(now);
|
|
1004
|
+
await withSerializedWrite(async (s) => {
|
|
1005
|
+
if (!s.savings_invocations_by_week) s.savings_invocations_by_week = {};
|
|
1006
|
+
s.savings_invocations_by_week[week] = (s.savings_invocations_by_week[week] ?? 0) + 1;
|
|
1007
|
+
return { result: undefined as void, updated: s };
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* WAD-D lead indicator: read the status-line opt-in flag from
|
|
1013
|
+
* ~/.claude/settings.json. Mirrors the lookup used by
|
|
1014
|
+
* scripts/savings-status-line.ts so heartbeat reports the same value the
|
|
1015
|
+
* user actually sees rendered.
|
|
1016
|
+
*
|
|
1017
|
+
* Defaults to true — the master switch is enabled by default, so a missing
|
|
1018
|
+
* field means the user has the status line on.
|
|
1019
|
+
*/
|
|
1020
|
+
export function getStatusLineEnabled(homeDir: string = home()): boolean {
|
|
1021
|
+
try {
|
|
1022
|
+
const settingsPath = join(homeDir, ".claude", "settings.json");
|
|
1023
|
+
if (!existsSync(settingsPath)) return true;
|
|
1024
|
+
const raw = readFileSync(settingsPath, "utf-8");
|
|
1025
|
+
const parsed = JSON.parse(raw) as { ashlr?: { statusLine?: unknown } };
|
|
1026
|
+
const v = parsed?.ashlr?.statusLine;
|
|
1027
|
+
if (typeof v === "boolean") return v;
|
|
1028
|
+
return true;
|
|
1029
|
+
} catch {
|
|
1030
|
+
return true;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Snapshot the current lead-indicator values from stats.json — pure read,
|
|
1036
|
+
* no mutation. Used by the daily heartbeat builder to assemble the
|
|
1037
|
+
* heartbeat payload without leaking the stats internals to its caller.
|
|
1038
|
+
*
|
|
1039
|
+
* Returns `null` fields for indicators that aren't populated yet so the
|
|
1040
|
+
* server-side aggregator can distinguish "not set" from "set to 0".
|
|
1041
|
+
*/
|
|
1042
|
+
export interface LeadIndicatorSnapshot {
|
|
1043
|
+
onboarding_completed: boolean | null;
|
|
1044
|
+
first_savings_at: string | null;
|
|
1045
|
+
savings_invocations_this_week: number;
|
|
1046
|
+
status_line_enabled: boolean;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
export async function readLeadIndicatorSnapshot(
|
|
1050
|
+
homeDir: string = home(),
|
|
1051
|
+
now: Date = new Date(),
|
|
1052
|
+
): Promise<LeadIndicatorSnapshot> {
|
|
1053
|
+
const stats = await readStats();
|
|
1054
|
+
const week = currentIsoWeek(now);
|
|
1055
|
+
const invocations = stats.savings_invocations_by_week?.[week] ?? 0;
|
|
1056
|
+
return {
|
|
1057
|
+
onboarding_completed: stats.onboarding_completed_at ? true : null,
|
|
1058
|
+
first_savings_at: stats.first_savings_at ?? null,
|
|
1059
|
+
savings_invocations_this_week: invocations,
|
|
1060
|
+
status_line_enabled: getStatusLineEnabled(homeDir),
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Convenience read for the status line and /ashlr-savings: returns the
|
|
1066
|
+
* current session's bucket (or an empty one if absent).
|
|
1067
|
+
*/
|
|
1068
|
+
export async function readCurrentSession(sessionId?: string): Promise<SessionBucket> {
|
|
1069
|
+
if (useSqlite()) return sqliteBackend.readCurrentSession(sessionId);
|
|
1070
|
+
const s = await readStats();
|
|
1071
|
+
const ids = sessionId ? [sessionId] : candidateSessionIds();
|
|
1072
|
+
let calls = 0;
|
|
1073
|
+
let tokensSaved = 0;
|
|
1074
|
+
let startedAt = "";
|
|
1075
|
+
let lastSavingAt: string | null = null;
|
|
1076
|
+
const byTool: ByTool = {};
|
|
1077
|
+
for (const id of ids) {
|
|
1078
|
+
const b = s.sessions[id];
|
|
1079
|
+
if (!b) continue;
|
|
1080
|
+
calls += b.calls;
|
|
1081
|
+
tokensSaved += b.tokensSaved;
|
|
1082
|
+
if (!startedAt || b.startedAt < startedAt) startedAt = b.startedAt;
|
|
1083
|
+
if (b.lastSavingAt && (!lastSavingAt || b.lastSavingAt > lastSavingAt)) lastSavingAt = b.lastSavingAt;
|
|
1084
|
+
for (const [tool, pt] of Object.entries(b.byTool)) {
|
|
1085
|
+
const t = byTool[tool] ?? (byTool[tool] = { calls: 0, tokensSaved: 0 });
|
|
1086
|
+
t.calls += pt.calls;
|
|
1087
|
+
t.tokensSaved += pt.tokensSaved;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return { startedAt: startedAt || new Date().toISOString(), lastSavingAt, calls, tokensSaved, byTool };
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Test hook: flush any pending debounced state to disk, then drain the write
|
|
1095
|
+
* queue. Lets tests assert committed on-disk state without sleeping.
|
|
1096
|
+
*/
|
|
1097
|
+
export async function _drainWrites(): Promise<void> {
|
|
1098
|
+
// If there's a pending debounce timer, fire it now.
|
|
1099
|
+
if (_debounceTimer) {
|
|
1100
|
+
clearTimeout(_debounceTimer);
|
|
1101
|
+
_debounceTimer = null;
|
|
1102
|
+
writeQueue = writeQueue.then(() => flushToDisk());
|
|
1103
|
+
}
|
|
1104
|
+
// best-effort: this is a test-only drain; underlying flushes already reported any errors via their own logging paths, so we just need the queue to settle.
|
|
1105
|
+
await writeQueue.catch(() => {});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** Test hook: reset in-memory cache so next read goes to disk. */
|
|
1109
|
+
export function _resetMemCache(): void {
|
|
1110
|
+
_memCache = null;
|
|
1111
|
+
_pendingStats = null;
|
|
1112
|
+
_pendingDeltas = [];
|
|
1113
|
+
if (_debounceTimer) { clearTimeout(_debounceTimer); _debounceTimer = null; }
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// ---------------------------------------------------------------------------
|
|
1117
|
+
// Pro-tier cloud sync (opt-in via ASHLR_PRO_TOKEN)
|
|
1118
|
+
// ---------------------------------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
/** Epoch ms of the last successful or attempted cloud upload. */
|
|
1121
|
+
let _lastSyncAt = 0;
|
|
1122
|
+
|
|
1123
|
+
/** Test hook: reset the sync throttle so the next maybeSyncToCloud() call fires. */
|
|
1124
|
+
export function _resetCloudSync(): void { _lastSyncAt = 0; }
|
|
1125
|
+
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Fire-and-forget upload of lifetime stats to the ashlr pro backend.
|
|
1129
|
+
*
|
|
1130
|
+
* Gated on:
|
|
1131
|
+
* - ASHLR_PRO_TOKEN being set (opt-in)
|
|
1132
|
+
* - ASHLR_STATS_UPLOAD !== "0" (kill switch)
|
|
1133
|
+
* - At most once per 5 minutes
|
|
1134
|
+
*
|
|
1135
|
+
* Never throws, never blocks a tool call.
|
|
1136
|
+
*/
|
|
1137
|
+
export function maybeSyncToCloud(): void {
|
|
1138
|
+
const token = process.env["ASHLR_PRO_TOKEN"];
|
|
1139
|
+
if (!token) return;
|
|
1140
|
+
if (process.env["ASHLR_STATS_UPLOAD"] === "0") return;
|
|
1141
|
+
const now = Date.now();
|
|
1142
|
+
if (now - _lastSyncAt < SYNC_INTERVAL_MS) return;
|
|
1143
|
+
_lastSyncAt = now;
|
|
1144
|
+
|
|
1145
|
+
// Detach — never await this
|
|
1146
|
+
void _doCloudSync(token);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
async function _doCloudSync(token: string): Promise<void> {
|
|
1150
|
+
try {
|
|
1151
|
+
const stats = await readStats();
|
|
1152
|
+
const apiUrl = process.env["ASHLR_API_URL"] ?? "https://api.ashlr.ai";
|
|
1153
|
+
|
|
1154
|
+
// Build a pure-counts payload — no paths, no content, no PII
|
|
1155
|
+
const byTool: Record<string, number> = {};
|
|
1156
|
+
for (const [k, v] of Object.entries(stats.lifetime.byTool)) {
|
|
1157
|
+
byTool[k] = v.tokensSaved;
|
|
1158
|
+
}
|
|
1159
|
+
const byDay: Record<string, number> = {};
|
|
1160
|
+
for (const [k, v] of Object.entries(stats.lifetime.byDay)) {
|
|
1161
|
+
byDay[k] = v.tokensSaved;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
await fetch(`${apiUrl}/stats/sync`, {
|
|
1165
|
+
method: "POST",
|
|
1166
|
+
headers: { "Content-Type": "application/json" },
|
|
1167
|
+
body: JSON.stringify({
|
|
1168
|
+
apiToken: token,
|
|
1169
|
+
stats: {
|
|
1170
|
+
lifetime: {
|
|
1171
|
+
calls: stats.lifetime.calls,
|
|
1172
|
+
tokensSaved: stats.lifetime.tokensSaved,
|
|
1173
|
+
byTool,
|
|
1174
|
+
byDay,
|
|
1175
|
+
},
|
|
1176
|
+
},
|
|
1177
|
+
}),
|
|
1178
|
+
signal: AbortSignal.timeout(10_000),
|
|
1179
|
+
});
|
|
1180
|
+
} catch {
|
|
1181
|
+
// Network failure or timeout — drop silently, never surface to the user
|
|
1182
|
+
}
|
|
1183
|
+
}
|