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,873 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* ashlr status line script.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code invokes this command periodically and renders the first line of
|
|
6
|
+
* stdout in its status bar. We surface lifetime + session token-savings from
|
|
7
|
+
* ~/.ashlr/stats.json, gated by toggles in ~/.claude/settings.json under the
|
|
8
|
+
* "ashlr" key:
|
|
9
|
+
*
|
|
10
|
+
* {
|
|
11
|
+
* "ashlr": {
|
|
12
|
+
* "statusLine": true, // master switch (default: true)
|
|
13
|
+
* "statusLineSession": true, // show "session +N" segment
|
|
14
|
+
* "statusLineLifetime": true, // show "lifetime +N" segment
|
|
15
|
+
* "statusLineTips": true // rotate a helpful tip at the end
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Contract:
|
|
20
|
+
* - Always exits 0.
|
|
21
|
+
* - On any error, emits an empty line.
|
|
22
|
+
* - Output is a single line, target ≤ 80 chars (Claude Code truncates
|
|
23
|
+
* overflow rather than scrolling, so we self-trim).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
27
|
+
import { homedir } from "os";
|
|
28
|
+
import { join } from "path";
|
|
29
|
+
import { c } from "./ui.ts";
|
|
30
|
+
import { maybeSyncToCloud, recordNudgeShown } from "../servers/_nudge-events.ts";
|
|
31
|
+
import { readSessionHint } from "../servers/_stats.ts";
|
|
32
|
+
import { costFor, costForSummarizer } from "../servers/_pricing.ts";
|
|
33
|
+
import { readStreaks, renderStreakBadge } from "../servers/_streaks.ts";
|
|
34
|
+
import { readAggregateCache } from "./stats-cloud-pull.ts";
|
|
35
|
+
import {
|
|
36
|
+
activityIndicator,
|
|
37
|
+
detectCapability,
|
|
38
|
+
frameAt,
|
|
39
|
+
renderContextPressure,
|
|
40
|
+
renderHeartbeat,
|
|
41
|
+
renderSparkline as renderAnimatedSparkline,
|
|
42
|
+
visibleWidth,
|
|
43
|
+
type Capability,
|
|
44
|
+
} from "./ui-animation.ts";
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Stats shape (v2 schema — keyed by session id)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
//
|
|
50
|
+
// We read the file defensively and support both the v1 shape (`session`
|
|
51
|
+
// singular) and the v2 shape (`sessions` map + `schemaVersion: 2`). The
|
|
52
|
+
// status line should never crash a terminal because stats.json is on an
|
|
53
|
+
// old version — we just fall back to zero.
|
|
54
|
+
|
|
55
|
+
interface ByDay { [date: string]: { calls?: number; tokensSaved?: number } }
|
|
56
|
+
interface SessionBucket {
|
|
57
|
+
calls?: number;
|
|
58
|
+
tokensSaved?: number;
|
|
59
|
+
lastSavingAt?: string | null;
|
|
60
|
+
}
|
|
61
|
+
interface Stats {
|
|
62
|
+
schemaVersion?: number;
|
|
63
|
+
/** v2: per-session buckets keyed by CLAUDE_SESSION_ID. */
|
|
64
|
+
sessions?: Record<string, SessionBucket>;
|
|
65
|
+
/** v1 legacy shape — kept for readback compatibility during migration. */
|
|
66
|
+
session?: SessionBucket;
|
|
67
|
+
lifetime?: {
|
|
68
|
+
calls?: number;
|
|
69
|
+
tokensSaved?: number;
|
|
70
|
+
tokensSavedMeasured?: number;
|
|
71
|
+
measuredCalls?: number;
|
|
72
|
+
byDay?: ByDay;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Candidate session ids for this process. Claude Code forwards
|
|
78
|
+
* CLAUDE_SESSION_ID to status-line and hook invocations but NOT to MCP
|
|
79
|
+
* server subprocesses — so the status line sees one id and the MCP servers
|
|
80
|
+
* write under a different (PPID-derived) id. Returning every candidate
|
|
81
|
+
* lets the status line aggregate across whichever bucket actually got
|
|
82
|
+
* written.
|
|
83
|
+
*
|
|
84
|
+
* v1.20.2: also include the session-start hint id (~/.ashlr/last-project.json)
|
|
85
|
+
* so the status line converges on the same bucket the MCP writers use when
|
|
86
|
+
* neither side has CLAUDE_SESSION_ID. Without this, every Claude Code
|
|
87
|
+
* session shows session=0 because writers land in the hint-id bucket while
|
|
88
|
+
* the status line was looking at the ppid-id bucket.
|
|
89
|
+
*/
|
|
90
|
+
function candidateSessionIds(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
91
|
+
const ids: string[] = [];
|
|
92
|
+
const explicit = env.CLAUDE_SESSION_ID ?? env.ASHLR_SESSION_ID;
|
|
93
|
+
if (explicit && explicit.trim().length > 0) ids.push(explicit.trim());
|
|
94
|
+
const hint = readSessionHint(env.HOME ?? "");
|
|
95
|
+
if (hint && !ids.includes(hint)) ids.push(hint);
|
|
96
|
+
// PPID-hash fallback — matches the same shape used by servers/_stats.ts.
|
|
97
|
+
const seed = `ppid:${typeof process.ppid === "number" ? process.ppid : "?"}:${env.HOME ?? ""}`;
|
|
98
|
+
let h = 0;
|
|
99
|
+
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0;
|
|
100
|
+
const ppidId = `p${(h >>> 0).toString(16)}`;
|
|
101
|
+
if (!ids.includes(ppidId)) ids.push(ppidId);
|
|
102
|
+
return ids;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Pick the first candidate id that has a bucket — for display/startedAt fields. */
|
|
106
|
+
function currentSessionId(env: NodeJS.ProcessEnv = process.env): string {
|
|
107
|
+
return candidateSessionIds(env)[0]!;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Return aggregated session counters across every candidate id the current
|
|
112
|
+
* process could own, or null if no candidate bucket exists. Summing across
|
|
113
|
+
* candidates ensures the status line reflects the full session even when
|
|
114
|
+
* CLAUDE_SESSION_ID reached the status line but not the MCP servers (or
|
|
115
|
+
* vice versa).
|
|
116
|
+
*/
|
|
117
|
+
function pickSession(stats: Stats | null, ids: string[]): SessionBucket | null {
|
|
118
|
+
if (!stats) return null;
|
|
119
|
+
// Primary path: sum across the v2 `sessions` map candidates.
|
|
120
|
+
if (stats.sessions) {
|
|
121
|
+
let calls = 0;
|
|
122
|
+
let tokensSaved = 0;
|
|
123
|
+
let latest: string | null = null;
|
|
124
|
+
let hit = false;
|
|
125
|
+
for (const id of ids) {
|
|
126
|
+
const b = stats.sessions[id];
|
|
127
|
+
if (!b) continue;
|
|
128
|
+
hit = true;
|
|
129
|
+
calls += b.calls ?? 0;
|
|
130
|
+
tokensSaved += b.tokensSaved ?? 0;
|
|
131
|
+
if (b.lastSavingAt && (!latest || b.lastSavingAt > latest)) latest = b.lastSavingAt;
|
|
132
|
+
}
|
|
133
|
+
if (hit) return { calls, tokensSaved, lastSavingAt: latest };
|
|
134
|
+
}
|
|
135
|
+
// Fallback: if the file is in v1 shape (zombie pre-v0.8.0 process still
|
|
136
|
+
// writing), surface the v1 `session` field rather than showing 0. The v1
|
|
137
|
+
// counter technically lies across concurrent terminals, but "lying
|
|
138
|
+
// slightly" beats "stuck at 0" for UX. The zombie-writer scenario
|
|
139
|
+
// resolves once the user fully restarts Claude Code.
|
|
140
|
+
if (stats.session && typeof stats.session.tokensSaved === "number") {
|
|
141
|
+
return {
|
|
142
|
+
calls: stats.session.calls ?? 0,
|
|
143
|
+
tokensSaved: stats.session.tokensSaved,
|
|
144
|
+
lastSavingAt: null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface AshlrSettings {
|
|
151
|
+
statusLine?: boolean;
|
|
152
|
+
statusLineSession?: boolean;
|
|
153
|
+
statusLineLifetime?: boolean;
|
|
154
|
+
statusLineTips?: boolean;
|
|
155
|
+
statusLineSparkline?: boolean;
|
|
156
|
+
/**
|
|
157
|
+
* When the current session crosses ~50k tokens saved and the user is on
|
|
158
|
+
* the free tier, swap the random tip for a single upgrade nudge. Default
|
|
159
|
+
* true. Set false to silence the nudge entirely.
|
|
160
|
+
*/
|
|
161
|
+
statusLineUpgradeNudge?: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Session-token threshold above which free users see the Pro upgrade nudge. */
|
|
165
|
+
const UPGRADE_NUDGE_THRESHOLD = 50_000;
|
|
166
|
+
|
|
167
|
+
/** Upgrade nudge copy — kept short so it fits under typical 120-col budgets. */
|
|
168
|
+
const UPGRADE_NUDGE_TEXT = "50k+ saved — try Pro (7d trial /ashlr-upgrade)";
|
|
169
|
+
|
|
170
|
+
/** Copy-version string. Bump when we reword UPGRADE_NUDGE_TEXT so A/B math stays honest. */
|
|
171
|
+
const UPGRADE_NUDGE_VARIANT = "v1";
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Lifetime-savings threshold that unlocks a one-shot celebration. We keep
|
|
175
|
+
* this low on purpose — first real "ok this plugin is paying for itself"
|
|
176
|
+
* moment fires around 10k tokens on most dev workflows. The celebration
|
|
177
|
+
* only runs once; subsequent renders see `milestones.ten_k_reached: true`
|
|
178
|
+
* in ~/.ashlr/milestones.json and stay silent.
|
|
179
|
+
*/
|
|
180
|
+
const MILESTONE_10K_THRESHOLD = 10_000;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Format a token count into "≈$0.04" / "≈$0.00" using the active summarizer
|
|
184
|
+
* model's input price. This corrects the prior behaviour of always using
|
|
185
|
+
* Sonnet pricing (~$3/MTok) even when the actual summarizer was Haiku
|
|
186
|
+
* (~$0.80/MTok) — which inflated displayed savings by up to 3-4x.
|
|
187
|
+
*
|
|
188
|
+
* When ASHLR_PRICING_MODEL is explicitly set by the user, costForSummarizer
|
|
189
|
+
* honours that override for backward compat.
|
|
190
|
+
*/
|
|
191
|
+
function formatCost(tokens: number): string {
|
|
192
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return "≈$0.00";
|
|
193
|
+
const dollars = costForSummarizer(tokens);
|
|
194
|
+
if (dollars < 0.01) return "≈$0.00";
|
|
195
|
+
if (dollars < 10) return `≈$${dollars.toFixed(2)}`;
|
|
196
|
+
if (dollars < 1000) return `≈$${dollars.toFixed(1)}`;
|
|
197
|
+
return `≈$${Math.round(dollars)}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* True when the user appears to be on Pro / Team (a local `~/.ashlr/pro-token`
|
|
202
|
+
* file is written by the upgrade flow once activation succeeds). Best-effort —
|
|
203
|
+
* a missing or empty file means "assume free". Never throws.
|
|
204
|
+
*/
|
|
205
|
+
function hasProToken(home: string): boolean {
|
|
206
|
+
try {
|
|
207
|
+
const path = join(home, ".ashlr", "pro-token");
|
|
208
|
+
const st = statSync(path);
|
|
209
|
+
return st.isFile() && st.size > 0;
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Milestone tracking. Persisted in a separate JSON file from stats.json so
|
|
217
|
+
* this agent can stay out of Agent 1's stats-schema lane. Flags are flipped
|
|
218
|
+
* exactly once — subsequent renders read the flag and skip the celebration.
|
|
219
|
+
*
|
|
220
|
+
* Shape: `{ "ten_k_reached": true }` once fired.
|
|
221
|
+
*/
|
|
222
|
+
interface Milestones {
|
|
223
|
+
ten_k_reached?: boolean;
|
|
224
|
+
/**
|
|
225
|
+
* One-shot flag set after the first-call savings projection has been
|
|
226
|
+
* shown to the user. Subsequent renders read this flag and stay silent
|
|
227
|
+
* even when lifetime.calls happens to be 1 again (e.g. stats reset).
|
|
228
|
+
*/
|
|
229
|
+
first_call_celebrated?: boolean;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readMilestones(home: string): Milestones {
|
|
233
|
+
try {
|
|
234
|
+
const path = join(home, ".ashlr", "milestones.json");
|
|
235
|
+
if (!existsSync(path)) return {};
|
|
236
|
+
const raw = readFileSync(path, "utf-8");
|
|
237
|
+
const parsed = JSON.parse(raw) as Milestones;
|
|
238
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
239
|
+
} catch {
|
|
240
|
+
return {};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Persist a milestone flag as a plain JSON blob. Best-effort, synchronous,
|
|
246
|
+
* and wrapped in try/catch so it never blocks the status-line render path.
|
|
247
|
+
* A failed write means the celebration might fire twice — acceptable
|
|
248
|
+
* tradeoff vs. crashing the terminal.
|
|
249
|
+
*/
|
|
250
|
+
function writeMilestones(home: string, m: Milestones): void {
|
|
251
|
+
try {
|
|
252
|
+
const { mkdirSync, writeFileSync } = require("fs") as typeof import("fs");
|
|
253
|
+
const dir = join(home, ".ashlr");
|
|
254
|
+
mkdirSync(dir, { recursive: true });
|
|
255
|
+
writeFileSync(join(dir, "milestones.json"), JSON.stringify(m));
|
|
256
|
+
} catch {
|
|
257
|
+
/* best-effort */
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Render a one-shot 10k celebration to stderr (stdout is the status line). */
|
|
262
|
+
function celebrate10k(home: string, stderr: NodeJS.WritableStream = process.stderr): void {
|
|
263
|
+
try {
|
|
264
|
+
stderr.write("🎉 10,000 tokens saved! ashlr just paid for your next coffee.\n");
|
|
265
|
+
} catch {
|
|
266
|
+
/* ignore — diag output is best-effort */
|
|
267
|
+
}
|
|
268
|
+
const current = readMilestones(home);
|
|
269
|
+
writeMilestones(home, { ...current, ten_k_reached: true });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Render the one-shot "first ashlr tool call" celebration to stderr.
|
|
274
|
+
*
|
|
275
|
+
* This closes the audit-identified "$0.001 saved → user closes terminal"
|
|
276
|
+
* drop-off cliff: on the very first successful ashlr call we surface a
|
|
277
|
+
* lifetime-value projection so the user immediately sees the annualized
|
|
278
|
+
* upside, not just a tiny per-call delta.
|
|
279
|
+
*
|
|
280
|
+
* Math (intentionally simple, intentionally explained inline so reviewers
|
|
281
|
+
* don't have to chase it):
|
|
282
|
+
* - dailyEstimate = perCallTokens (one call's worth — the call that
|
|
283
|
+
* just landed)
|
|
284
|
+
* - annualEstimate = dailyEstimate × 50 calls/day × 250 work days/year
|
|
285
|
+
*
|
|
286
|
+
* Cost uses `costFor()` (NOT `costForSummarizer`) on purpose: the user
|
|
287
|
+
* cares about the counterfactual — "what would this have cost on my main
|
|
288
|
+
* model if ashlr weren't here" — which is main-model pricing. This is the
|
|
289
|
+
* same reasoning behind the v1.31 $0-fix where the summarizer fallback
|
|
290
|
+
* lands on costFor when no summarizer is configured.
|
|
291
|
+
*/
|
|
292
|
+
function celebrateFirstCall(
|
|
293
|
+
home: string,
|
|
294
|
+
perCallTokens: number,
|
|
295
|
+
stderr: NodeJS.WritableStream = process.stderr,
|
|
296
|
+
): void {
|
|
297
|
+
const dailyEstimate = perCallTokens;
|
|
298
|
+
const annualEstimate = dailyEstimate * 50 * 250;
|
|
299
|
+
const perCallCost = costFor(perCallTokens);
|
|
300
|
+
const annualCost = costFor(annualEstimate);
|
|
301
|
+
const perCallDollars = perCallCost < 0.01 ? "≈$0.00" : `≈$${perCallCost.toFixed(2)}`;
|
|
302
|
+
const annualDollars =
|
|
303
|
+
annualCost < 0.01
|
|
304
|
+
? "≈$0.00"
|
|
305
|
+
: annualCost < 10
|
|
306
|
+
? `≈$${annualCost.toFixed(2)}`
|
|
307
|
+
: annualCost < 1000
|
|
308
|
+
? `≈$${annualCost.toFixed(0)}`
|
|
309
|
+
: `≈$${Math.round(annualCost).toLocaleString("en-US")}`;
|
|
310
|
+
try {
|
|
311
|
+
stderr.write(
|
|
312
|
+
`🎉 ashlr saved you ${formatTokens(perCallTokens)} tokens on your first call (${perCallDollars}).\n` +
|
|
313
|
+
` At this rate (~50 calls/day × 250 work days/year) → ${annualDollars}/year saved.\n`,
|
|
314
|
+
);
|
|
315
|
+
} catch {
|
|
316
|
+
/* ignore — diag output is best-effort */
|
|
317
|
+
}
|
|
318
|
+
const current = readMilestones(home);
|
|
319
|
+
writeMilestones(home, { ...current, first_call_celebrated: true });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 9-rung Braille ladder: empty → full. Each char represents one day's
|
|
323
|
+
// tokens-saved, bucketed against the busiest day in the window.
|
|
324
|
+
// 0% → ⠀ (U+2800, blank-but-present braille), 100% → ⣿
|
|
325
|
+
const SPARK_LADDER = ["\u2800", "\u2840", "\u2844", "\u2846", "\u2847", "\u28E7", "\u28F7", "\u28FF", "\u28FF"];
|
|
326
|
+
// Note: we use a 9-slot ladder so that ratio 0 maps to blank, anything >0 maps
|
|
327
|
+
// to at least the first rung (so an active-but-quiet day is still visible).
|
|
328
|
+
|
|
329
|
+
function lastNDayKeys(n: number): string[] {
|
|
330
|
+
const out: string[] = [];
|
|
331
|
+
const now = new Date();
|
|
332
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
333
|
+
const d = new Date(now);
|
|
334
|
+
d.setUTCDate(d.getUTCDate() - i);
|
|
335
|
+
out.push(d.toISOString().slice(0, 10));
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function renderSparkline(
|
|
341
|
+
byDay: Record<string, { tokensSaved?: number }> | undefined,
|
|
342
|
+
days = 7,
|
|
343
|
+
): string {
|
|
344
|
+
const keys = lastNDayKeys(days);
|
|
345
|
+
const vals = keys.map((k) => byDay?.[k]?.tokensSaved ?? 0);
|
|
346
|
+
const max = Math.max(...vals, 0);
|
|
347
|
+
if (max <= 0) return SPARK_LADDER[0]!.repeat(days);
|
|
348
|
+
return vals
|
|
349
|
+
.map((v) => {
|
|
350
|
+
if (v <= 0) return SPARK_LADDER[0]!;
|
|
351
|
+
// Scale 1..max → rungs 1..8 (skip rung 0 so any activity is visible).
|
|
352
|
+
const idx = Math.max(1, Math.min(8, Math.ceil((v / max) * 8)));
|
|
353
|
+
return SPARK_LADDER[idx]!;
|
|
354
|
+
})
|
|
355
|
+
.join("");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const MAX_LEN = 80;
|
|
359
|
+
|
|
360
|
+
// Editable list — keep small, keep useful.
|
|
361
|
+
const TIPS: readonly string[] = [
|
|
362
|
+
"use /ashlr-savings to see totals",
|
|
363
|
+
"ashlr__read auto-snips files >2KB",
|
|
364
|
+
"ashlr__edit ships diffs, not full files",
|
|
365
|
+
"ashlr__grep is genome-aware in mapped repos",
|
|
366
|
+
"toggle status line via /ashlr-settings",
|
|
367
|
+
"run `ashlr map` to build a code genome",
|
|
368
|
+
"savings persist in ~/.ashlr/stats.json",
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
/** Resolve the terminal-width budget for the status line.
|
|
372
|
+
* - Reads $COLUMNS from the environment (terminals typically set this).
|
|
373
|
+
* - Clamped to [1, 120] to avoid absurdly wide rendering.
|
|
374
|
+
* - Falls back to 80 when unset/invalid.
|
|
375
|
+
*/
|
|
376
|
+
export function resolveBudget(env: NodeJS.ProcessEnv = process.env): number {
|
|
377
|
+
const raw = env.COLUMNS;
|
|
378
|
+
const parsed = raw ? parseInt(raw, 10) : NaN;
|
|
379
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return 80;
|
|
380
|
+
return Math.min(parsed, 120);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function formatTokens(n: number): string {
|
|
384
|
+
if (!Number.isFinite(n) || n < 0) return "0";
|
|
385
|
+
if (n < 1000) return String(Math.floor(n));
|
|
386
|
+
if (n < 1_000_000) return (n / 1000).toFixed(1) + "K";
|
|
387
|
+
return (n / 1_000_000).toFixed(1) + "M";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function readJson<T>(path: string): T | null {
|
|
391
|
+
try {
|
|
392
|
+
if (!existsSync(path)) return null;
|
|
393
|
+
return JSON.parse(readFileSync(path, "utf-8")) as T;
|
|
394
|
+
} catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Read cache (mtime-aware, 300ms TTL)
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// Claude Code invokes the status line frequently (on every prompt tick).
|
|
403
|
+
// Re-reading + re-parsing stats.json on every call is wasteful — the status
|
|
404
|
+
// bar does not need sub-millisecond freshness. We cache JSON reads for 300ms
|
|
405
|
+
// keyed by absolute path, but a cache entry is invalidated immediately whenever
|
|
406
|
+
// the file's mtime changes (or its existence flips). That makes the cache
|
|
407
|
+
// effective under steady load (caps at ~3 reads/second) while staying correct
|
|
408
|
+
// across terminals: as soon as terminal A's debounce flush lands on disk (≤250ms),
|
|
409
|
+
// terminal B's next status-line poll sees the new mtime and re-reads.
|
|
410
|
+
//
|
|
411
|
+
// Net worst-case latency after recordSaving: 250ms debounce + 300ms TTL = 550ms.
|
|
412
|
+
// Typical case when mtime changes mid-window: 250ms debounce + 0ms (mtime
|
|
413
|
+
// invalidation fires immediately) = ~250ms.
|
|
414
|
+
//
|
|
415
|
+
// Cache is process-local — each fresh invocation of this script as a Claude
|
|
416
|
+
// Code subprocess starts empty; the cache only helps long-running hosts and
|
|
417
|
+
// within-test batch calls.
|
|
418
|
+
const READ_CACHE_TTL_MS = 300;
|
|
419
|
+
interface CacheEntry {
|
|
420
|
+
at: number;
|
|
421
|
+
mtimeMs: number;
|
|
422
|
+
value: unknown;
|
|
423
|
+
}
|
|
424
|
+
const _readCache = new Map<string, CacheEntry>();
|
|
425
|
+
|
|
426
|
+
function fileMtime(path: string): number {
|
|
427
|
+
try {
|
|
428
|
+
return statSync(path).mtimeMs;
|
|
429
|
+
} catch {
|
|
430
|
+
// Missing → sentinel -1 so "absent → present" also invalidates.
|
|
431
|
+
return -1;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function readJsonCached<T>(path: string): T | null {
|
|
436
|
+
const now = Date.now();
|
|
437
|
+
const mtime = fileMtime(path);
|
|
438
|
+
const hit = _readCache.get(path);
|
|
439
|
+
if (hit && now - hit.at < READ_CACHE_TTL_MS && hit.mtimeMs === mtime) {
|
|
440
|
+
return hit.value as T | null;
|
|
441
|
+
}
|
|
442
|
+
const fresh = readJson<T>(path);
|
|
443
|
+
_readCache.set(path, { at: now, mtimeMs: mtime, value: fresh });
|
|
444
|
+
return fresh;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Test hook — flush the 2s read cache. */
|
|
448
|
+
export function _resetReadCache(): void {
|
|
449
|
+
_readCache.clear();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function pickTip(tips: readonly string[], seed?: number): string {
|
|
453
|
+
if (tips.length === 0) return "";
|
|
454
|
+
const idx = (seed ?? Math.floor(Date.now() / 86_400_000)) % tips.length;
|
|
455
|
+
return tips[idx]!;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Subset of the JSON payload Claude Code may send on stdin to the status-line
|
|
460
|
+
* command. We try multiple field names defensively; if none resolve to a
|
|
461
|
+
* positive number the context-pressure widget is hidden entirely.
|
|
462
|
+
*
|
|
463
|
+
* Known / candidate fields (any may be present depending on CC version):
|
|
464
|
+
* input_tokens — tokens consumed in the current context window
|
|
465
|
+
* context_tokens — alias used in some builds
|
|
466
|
+
* total_tokens — alias used in some builds
|
|
467
|
+
* total_tokens_used — alias
|
|
468
|
+
* sessionTokens — alias
|
|
469
|
+
* context_used_tokens — tokens used (paired with context_limit_tokens)
|
|
470
|
+
* context_limit_tokens — total window size
|
|
471
|
+
*/
|
|
472
|
+
export interface StatusLineInput {
|
|
473
|
+
input_tokens?: number;
|
|
474
|
+
context_tokens?: number;
|
|
475
|
+
total_tokens?: number;
|
|
476
|
+
total_tokens_used?: number;
|
|
477
|
+
sessionTokens?: number;
|
|
478
|
+
context_used_tokens?: number;
|
|
479
|
+
context_limit_tokens?: number;
|
|
480
|
+
[key: string]: unknown;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Extract a 0–100 context-pressure percentage from a CC stdin payload.
|
|
484
|
+
* Returns null when no usable field is found. */
|
|
485
|
+
export function extractContextPct(input: StatusLineInput | null | undefined): number | null {
|
|
486
|
+
if (!input) return null;
|
|
487
|
+
// Prefer the explicit used+limit pair — most precise.
|
|
488
|
+
if (
|
|
489
|
+
typeof input.context_used_tokens === "number" &&
|
|
490
|
+
typeof input.context_limit_tokens === "number" &&
|
|
491
|
+
input.context_limit_tokens > 0
|
|
492
|
+
) {
|
|
493
|
+
return Math.min(100, (input.context_used_tokens / input.context_limit_tokens) * 100);
|
|
494
|
+
}
|
|
495
|
+
// Fall through to token-count fields — these need a known window size.
|
|
496
|
+
// Claude claude-sonnet-4-6 / opus context windows are large; without a limit
|
|
497
|
+
// field we cannot compute a meaningful %. Hide the widget.
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export interface BuildOptions {
|
|
502
|
+
home?: string;
|
|
503
|
+
/** Deterministic tip index, used by tests. */
|
|
504
|
+
tipSeed?: number;
|
|
505
|
+
/** Explicit budget override (bypasses $COLUMNS detection). */
|
|
506
|
+
budget?: number;
|
|
507
|
+
/** Environment to read $COLUMNS from — used by tests. */
|
|
508
|
+
env?: NodeJS.ProcessEnv;
|
|
509
|
+
/** Clock injection — tests pin this; production uses Date.now. */
|
|
510
|
+
now?: number;
|
|
511
|
+
/**
|
|
512
|
+
* Parsed JSON payload from Claude Code's stdin. When present, we extract a
|
|
513
|
+
* context-pressure percentage and render the ctx widget. When absent/null
|
|
514
|
+
* the widget is hidden entirely (never lies).
|
|
515
|
+
*/
|
|
516
|
+
statusLineInput?: StatusLineInput | null;
|
|
517
|
+
/**
|
|
518
|
+
* Test hook. When true, the status line still renders the upgrade nudge
|
|
519
|
+
* but does not emit a nudge_shown telemetry event. Production callers
|
|
520
|
+
* never set this; existing tests that don't care about telemetry leave
|
|
521
|
+
* it undefined and rely on the HOME sandbox to isolate the log file.
|
|
522
|
+
*/
|
|
523
|
+
suppressNudgeTelemetry?: boolean;
|
|
524
|
+
/**
|
|
525
|
+
* Test hook. When true, the 10k celebration is evaluated for rendering
|
|
526
|
+
* but does not write the milestones.json flag and does not print to
|
|
527
|
+
* stderr. Lets tests assert on render output without polluting the tmp
|
|
528
|
+
* HOME or stderr.
|
|
529
|
+
*/
|
|
530
|
+
suppressMilestoneSideEffects?: boolean;
|
|
531
|
+
/**
|
|
532
|
+
* "Last save" window in milliseconds. The "last save" segment is shown only
|
|
533
|
+
* when msSinceActive is within this window. Defaults to 60 seconds.
|
|
534
|
+
*/
|
|
535
|
+
lastSaveWindowMs?: number;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function buildStatusLine(opts: BuildOptions = {}): string {
|
|
539
|
+
try {
|
|
540
|
+
const home = opts.home ?? homedir();
|
|
541
|
+
const env = opts.env ?? process.env;
|
|
542
|
+
const budget = opts.budget ?? resolveBudget(env);
|
|
543
|
+
const now = opts.now ?? Date.now();
|
|
544
|
+
const settings = readJsonCached<{ ashlr?: AshlrSettings }>(
|
|
545
|
+
join(home, ".claude", "settings.json"),
|
|
546
|
+
);
|
|
547
|
+
const cfg: AshlrSettings = settings?.ashlr ?? {};
|
|
548
|
+
|
|
549
|
+
// Defaults: master on, session on, lifetime on, tips on.
|
|
550
|
+
const master = cfg.statusLine ?? true;
|
|
551
|
+
if (!master) return "";
|
|
552
|
+
|
|
553
|
+
const showSession = cfg.statusLineSession ?? true;
|
|
554
|
+
const showLifetime = cfg.statusLineLifetime ?? true;
|
|
555
|
+
const showTips = cfg.statusLineTips ?? true;
|
|
556
|
+
const showSpark = cfg.statusLineSparkline ?? true;
|
|
557
|
+
|
|
558
|
+
const statsPath = join(home, ".ashlr", "stats.json");
|
|
559
|
+
const statsExists = existsSync(statsPath);
|
|
560
|
+
const stats = readJsonCached<Stats>(statsPath);
|
|
561
|
+
const sessionIds = candidateSessionIds(env);
|
|
562
|
+
const sess = pickSession(stats, sessionIds);
|
|
563
|
+
// v1.34: use API-measured figure when >= 10 measured calls exist
|
|
564
|
+
const lifetimeMeasuredCalls = stats?.lifetime?.measuredCalls ?? 0;
|
|
565
|
+
const showMeasured = lifetimeMeasuredCalls >= 10;
|
|
566
|
+
const session = sess?.tokensSaved ?? 0;
|
|
567
|
+
const lifetime = showMeasured
|
|
568
|
+
? (stats?.lifetime?.tokensSavedMeasured ?? 0)
|
|
569
|
+
: (stats?.lifetime?.tokensSaved ?? 0);
|
|
570
|
+
const methodSuffix = showMeasured ? ' (API-measured)' : ' (est.)';
|
|
571
|
+
const lifetimeCalls = stats?.lifetime?.calls ?? 0;
|
|
572
|
+
const lastSavingAt = sess?.lastSavingAt ?? null;
|
|
573
|
+
const msSinceActive = lastSavingAt ? Math.max(0, now - Date.parse(lastSavingAt)) : Number.POSITIVE_INFINITY;
|
|
574
|
+
|
|
575
|
+
const cap = detectCapability(env);
|
|
576
|
+
const frame = frameAt(now);
|
|
577
|
+
|
|
578
|
+
// -----------------------------------------------------------------------
|
|
579
|
+
// Left edge: "ashlr" brand + heartbeat + animated sparkline
|
|
580
|
+
// -----------------------------------------------------------------------
|
|
581
|
+
const brandParts: string[] = ["ashlr"];
|
|
582
|
+
if (showSpark) {
|
|
583
|
+
// Heartbeat glyph: dim middle-dot when idle, braille-wave when active.
|
|
584
|
+
brandParts.push(renderHeartbeat(frame, msSinceActive, cap));
|
|
585
|
+
// 7-day sparkline. Values come from the existing lifetime.byDay map
|
|
586
|
+
// so the 7-day shape stays stable across the new per-session stats.
|
|
587
|
+
const keys = lastNDayKeys(7);
|
|
588
|
+
const values = keys.map((k) => stats?.lifetime?.byDay?.[k]?.tokensSaved ?? 0);
|
|
589
|
+
const spark = renderAnimatedSparkline({ values, frame, msSinceActive, cap });
|
|
590
|
+
// Wide terminals (>100 cols) get a "7d " label before the sparkline so
|
|
591
|
+
// users know at a glance what the cells represent. Under 100 cols the
|
|
592
|
+
// prefix is dropped to keep things compact. Width-stable in both modes.
|
|
593
|
+
const sparkPrefix = budget > 100 ? "7d " : "";
|
|
594
|
+
brandParts.push(sparkPrefix + spark);
|
|
595
|
+
}
|
|
596
|
+
const brand = brandParts.join(" ");
|
|
597
|
+
|
|
598
|
+
const parts: string[] = [brand];
|
|
599
|
+
|
|
600
|
+
// Context-pressure widget — inserted between sparkline and "session +N".
|
|
601
|
+
// Hidden entirely when no usable payload field is present.
|
|
602
|
+
const ctxPct = extractContextPct(opts.statusLineInput);
|
|
603
|
+
const ctxWidget = ctxPct !== null ? renderContextPressure(ctxPct, cap) : null;
|
|
604
|
+
if (ctxWidget !== null) parts.push(ctxWidget);
|
|
605
|
+
|
|
606
|
+
const actIndicator = activityIndicator(msSinceActive, cap);
|
|
607
|
+
// Session segment gains an inline `$` cost estimate (≈$0.04) right after
|
|
608
|
+
// the token counter. The cost is built from a shared per-MTok price so a
|
|
609
|
+
// future module swap only changes SESSION_PRICE_PER_MTOK / the helper.
|
|
610
|
+
const sessionCost = formatCost(session);
|
|
611
|
+
if (showSession) {
|
|
612
|
+
if (!statsExists) {
|
|
613
|
+
// Stats file doesn't exist yet — waiting for the first tool call.
|
|
614
|
+
// Render a distinct waiting message instead of the regular counters.
|
|
615
|
+
parts.push("(waiting for first tool call)");
|
|
616
|
+
} else if (stats !== null && lifetimeCalls < 5 && session === 0) {
|
|
617
|
+
// Stats file present but very early (< 5 lifetime calls) AND the
|
|
618
|
+
// current session has no tokens yet — not enough data to show
|
|
619
|
+
// meaningful counters. Render a dim hint so users know the plugin
|
|
620
|
+
// is warming up (not broken). If the session HAS tokens, fall
|
|
621
|
+
// through to the normal path even if lifetime is low — the user
|
|
622
|
+
// is actively saving and deserves to see the counter tick.
|
|
623
|
+
parts.push(`session +0 (collecting…)`);
|
|
624
|
+
} else {
|
|
625
|
+
// Normal path: stats file present and established (≥ 5 calls) or
|
|
626
|
+
// stats is null/corrupt (falls back gracefully to +0 counters).
|
|
627
|
+
parts.push(`session ${actIndicator}+${formatTokens(session)} ${sessionCost}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (showLifetime && statsExists && lifetime > 0)
|
|
631
|
+
parts.push(`lifetime +${formatTokens(lifetime)}${methodSuffix}`);
|
|
632
|
+
|
|
633
|
+
// -----------------------------------------------------------------------
|
|
634
|
+
// Streak badge — shown when currentStreak >= 3.
|
|
635
|
+
// Read from ~/.ashlr/streaks.json; no-op on any error.
|
|
636
|
+
// -----------------------------------------------------------------------
|
|
637
|
+
const streakData = readStreaks(home);
|
|
638
|
+
const streakBadge = renderStreakBadge(streakData);
|
|
639
|
+
if (streakBadge) parts.push(streakBadge);
|
|
640
|
+
|
|
641
|
+
// -----------------------------------------------------------------------
|
|
642
|
+
// Cross-machine badge (Pro only) — "☁ N machines" suffix.
|
|
643
|
+
// Reads from the 1h aggregate cache; silent for free-tier users.
|
|
644
|
+
// Only shown when machine_count > 1 (trivial for single-machine users).
|
|
645
|
+
// -----------------------------------------------------------------------
|
|
646
|
+
try {
|
|
647
|
+
const aggData = readAggregateCache();
|
|
648
|
+
if (aggData?.machine_count != null && aggData.machine_count > 1) {
|
|
649
|
+
parts.push(`☁ ${aggData.machine_count} machines`);
|
|
650
|
+
}
|
|
651
|
+
} catch {
|
|
652
|
+
/* aggregate cache is decoration — never break status line */
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// -----------------------------------------------------------------------
|
|
656
|
+
// Brief badge — `[brief: <level>]` when /ashlr-brief is active.
|
|
657
|
+
// Reads ~/.ashlr/brief.json (project file wins via the SessionStart hook,
|
|
658
|
+
// but for status-line purposes we just surface the user-level state +
|
|
659
|
+
// an indicator if a project file exists). Hidden when level is "off".
|
|
660
|
+
// -----------------------------------------------------------------------
|
|
661
|
+
try {
|
|
662
|
+
const userBriefPath = join(home, ".ashlr", "brief.json");
|
|
663
|
+
if (existsSync(userBriefPath)) {
|
|
664
|
+
const cfg = JSON.parse(readFileSync(userBriefPath, "utf-8")) as { level?: string };
|
|
665
|
+
if (cfg.level && cfg.level !== "off") {
|
|
666
|
+
parts.push(`brief: ${cfg.level}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
} catch {
|
|
670
|
+
/* brief badge is decoration — never break status line */
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// -----------------------------------------------------------------------
|
|
674
|
+
// "Last save" segment — shows the most recent ashlr tool call within the
|
|
675
|
+
// last N seconds (default: 60s). Format: "last: ashlr__read +5K [2s ago]"
|
|
676
|
+
// Uses lastSavingAt + byTool from the session bucket.
|
|
677
|
+
// -----------------------------------------------------------------------
|
|
678
|
+
const lastSaveWindowMs = opts.lastSaveWindowMs ?? 60_000;
|
|
679
|
+
if (msSinceActive <= lastSaveWindowMs && lastSavingAt !== null) {
|
|
680
|
+
const secsAgo = Math.floor(msSinceActive / 1000);
|
|
681
|
+
const agoStr = secsAgo <= 1 ? "1s ago" : `${secsAgo}s ago`;
|
|
682
|
+
// Best-effort: find the tool with the most recent save across candidates.
|
|
683
|
+
// We show the tool that has the highest tokensSaved as a proxy for the
|
|
684
|
+
// "just fired" tool (no per-call timestamp is stored, only per-session).
|
|
685
|
+
const sessionBuckets = stats?.sessions;
|
|
686
|
+
let lastToolName = "";
|
|
687
|
+
let lastToolTok = 0;
|
|
688
|
+
if (sessionBuckets) {
|
|
689
|
+
for (const id of sessionIds) {
|
|
690
|
+
const b = sessionBuckets[id];
|
|
691
|
+
if (!b) continue;
|
|
692
|
+
const bt = (b as unknown as { byTool?: Record<string, { tokensSaved?: number }> }).byTool;
|
|
693
|
+
if (!bt) continue;
|
|
694
|
+
for (const [name, info] of Object.entries(bt)) {
|
|
695
|
+
const tok = info?.tokensSaved ?? 0;
|
|
696
|
+
if (tok > lastToolTok) { lastToolTok = tok; lastToolName = name; }
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (lastToolName && lastToolTok > 0) {
|
|
701
|
+
parts.push(`last: ${lastToolName} +${formatTokens(lastToolTok)} [${agoStr}]`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// One-shot 10k lifetime celebration. Fires the first time lifetime
|
|
706
|
+
// crosses MILESTONE_10K_THRESHOLD; persisted in milestones.json so
|
|
707
|
+
// subsequent renders stay silent. Stderr output so the status line
|
|
708
|
+
// (stdout) remains a single unbroken line for Claude Code.
|
|
709
|
+
//
|
|
710
|
+
// Disabled by opts.suppressMilestoneSideEffects (test hook) or by the
|
|
711
|
+
// ASHLR_DISABLE_MILESTONES env var (CI / debugging).
|
|
712
|
+
const milestonesDisabled =
|
|
713
|
+
opts.suppressMilestoneSideEffects === true ||
|
|
714
|
+
(env.ASHLR_DISABLE_MILESTONES && env.ASHLR_DISABLE_MILESTONES !== "0");
|
|
715
|
+
if (!milestonesDisabled) {
|
|
716
|
+
const ms = readMilestones(home);
|
|
717
|
+
// First-call projection — fires exactly once on the lifetime.calls === 1
|
|
718
|
+
// transition. Closes the audit's drop-off cliff by surfacing the
|
|
719
|
+
// annualized upside the moment a new user sees their first save.
|
|
720
|
+
if (!ms.first_call_celebrated && lifetimeCalls === 1 && lifetime > 0) {
|
|
721
|
+
celebrateFirstCall(home, lifetime);
|
|
722
|
+
}
|
|
723
|
+
if (!ms.ten_k_reached && lifetime >= MILESTONE_10K_THRESHOLD) {
|
|
724
|
+
celebrate10k(home);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
let line = parts.join(" · ");
|
|
729
|
+
|
|
730
|
+
// Drop-order under tight budget:
|
|
731
|
+
// 1. Try to add the tip — drop tip first if it doesn't fit.
|
|
732
|
+
// 2. If still over budget, drop the ctx widget.
|
|
733
|
+
// 3. Hard-truncate as last resort.
|
|
734
|
+
if (showTips) {
|
|
735
|
+
// When a free-tier user crosses the session savings threshold, swap the
|
|
736
|
+
// rotating tip for a single targeted upgrade nudge. `Pro token` absence
|
|
737
|
+
// is the cheap tier check — good enough for a status-line hint and
|
|
738
|
+
// avoids any backend round-trip on every render.
|
|
739
|
+
const nudgeEnabled = cfg.statusLineUpgradeNudge ?? true;
|
|
740
|
+
const showNudge =
|
|
741
|
+
nudgeEnabled && session >= UPGRADE_NUDGE_THRESHOLD && !hasProToken(home);
|
|
742
|
+
const tipText = showNudge ? UPGRADE_NUDGE_TEXT : pickTip(TIPS, opts.tipSeed);
|
|
743
|
+
const tipLabel = showNudge ? "↑" : "tip";
|
|
744
|
+
const candidate = `${line} · ${tipLabel}: ${tipText}`;
|
|
745
|
+
if (visibleWidth(candidate) <= budget) {
|
|
746
|
+
line = candidate;
|
|
747
|
+
// Only emit telemetry when the nudge actually fits on the rendered
|
|
748
|
+
// line — a truncated nudge the user never sees shouldn't count.
|
|
749
|
+
if (showNudge && !opts.suppressNudgeTelemetry) {
|
|
750
|
+
// Fire-and-forget: status line must never block on IO.
|
|
751
|
+
// Pass `home` through so tests using a tmp $HOME stay sandboxed.
|
|
752
|
+
void recordNudgeShown({
|
|
753
|
+
tokenCount: session,
|
|
754
|
+
variant: UPGRADE_NUDGE_VARIANT,
|
|
755
|
+
home,
|
|
756
|
+
});
|
|
757
|
+
maybeSyncToCloud();
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// Otherwise drop the tip/nudge entirely (no partial rendering).
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Drop order when still over budget after tip was dropped:
|
|
764
|
+
// (a) drop last-save + streak badge first (lowest signal under pressure)
|
|
765
|
+
// (b) drop cost suffix (saves ~7 chars)
|
|
766
|
+
// (c) then drop ctx widget if still overflowing
|
|
767
|
+
// Cost is cheap bookkeeping, ctx widget is higher-signal for heavy users,
|
|
768
|
+
// so cost goes before ctx.
|
|
769
|
+
if (visibleWidth(line) > budget) {
|
|
770
|
+
// Drop streak badge + last-save first — they're bonus delight, not core data.
|
|
771
|
+
const partsNoExtras: string[] = [brand];
|
|
772
|
+
if (ctxWidget !== null) partsNoExtras.push(ctxWidget);
|
|
773
|
+
if (showSession) partsNoExtras.push(`session ${actIndicator}+${formatTokens(session)} ${sessionCost}`);
|
|
774
|
+
if (showLifetime) partsNoExtras.push(`lifetime +${formatTokens(lifetime)}`);
|
|
775
|
+
line = partsNoExtras.join(" · ");
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (visibleWidth(line) > budget) {
|
|
779
|
+
const partsNoCost: string[] = [brand];
|
|
780
|
+
if (ctxWidget !== null) partsNoCost.push(ctxWidget);
|
|
781
|
+
if (showSession) partsNoCost.push(`session ${actIndicator}+${formatTokens(session)}`);
|
|
782
|
+
if (showLifetime) partsNoCost.push(`lifetime +${formatTokens(lifetime)}`);
|
|
783
|
+
line = partsNoCost.join(" · ");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// If ctx widget caused overflow (narrow terminal), rebuild without it.
|
|
787
|
+
if (ctxWidget !== null && visibleWidth(line) > budget) {
|
|
788
|
+
const partsNoCtx: string[] = [brand];
|
|
789
|
+
if (showSession) partsNoCtx.push(`session ${actIndicator}+${formatTokens(session)}`);
|
|
790
|
+
if (showLifetime) partsNoCtx.push(`lifetime +${formatTokens(lifetime)}`);
|
|
791
|
+
line = partsNoCtx.join(" · ");
|
|
792
|
+
// Tip + cost + streak were already dropped above (we only reach here when over budget).
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Budget enforcement operates on VISIBLE width — ANSI escapes don't count.
|
|
796
|
+
if (visibleWidth(line) > budget) {
|
|
797
|
+
// Last-resort safety. A naive `line.slice()` could cut mid-ANSI and
|
|
798
|
+
// leak a dangling escape that corrupts the terminal. Strip ANSI first
|
|
799
|
+
// so the slice operates on visible characters only.
|
|
800
|
+
const stripped = line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
801
|
+
line = stripped.slice(0, Math.max(0, budget - 1)) + "…";
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return colorize(line);
|
|
805
|
+
} catch {
|
|
806
|
+
return "";
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Overlay color on a finished, length-bounded status line. */
|
|
811
|
+
function colorize(line: string): string {
|
|
812
|
+
// Brand (green, bold) — the very first "ashlr" token.
|
|
813
|
+
let out = line.replace(/^ashlr\b/, c.bold(c.brightGreen("ashlr")));
|
|
814
|
+
// Savings numbers — green for positive, dim grey for the zero case.
|
|
815
|
+
// The session segment may have an activity indicator glyph (possibly ANSI-
|
|
816
|
+
// wrapped) between the label and the "+N" — capture it with a lazy wildcard
|
|
817
|
+
// that matches ANSI escape sequences and/or a single Unicode/ASCII glyph.
|
|
818
|
+
out = out.replace(/(session |lifetime )((?:\x1b\[[0-9;]*m)*[↑+]?(?:\x1b\[[0-9;]*m)*)\+([\d.]+[KM]?)/g, (_m, lbl, indicator, num) => {
|
|
819
|
+
const isZero = num === "0";
|
|
820
|
+
const coloredLabel = c.dim(lbl);
|
|
821
|
+
const coloredNum = isZero ? c.dim(`+${num}`) : c.green(`+${num}`);
|
|
822
|
+
// indicator may be empty (idle) or already ANSI-wrapped by activityIndicator.
|
|
823
|
+
return `${coloredLabel}${indicator}${coloredNum}`;
|
|
824
|
+
});
|
|
825
|
+
// Cost suffix after the session counter — dim grey so it doesn't compete
|
|
826
|
+
// with the primary token count. Matches "≈$0.04" / "≈$12" / "≈$1.2K".
|
|
827
|
+
out = out.replace(/(\s)(≈\$[0-9]+(?:\.[0-9]+)?[KM]?)/g, (_m, sp, money) => `${sp}${c.dim(money)}`);
|
|
828
|
+
// Tip prefix — dim cyan label, dim body.
|
|
829
|
+
out = out.replace(/tip: (.+)$/, (_m, body) => `${c.cyan("tip:")} ${c.dim(body)}`);
|
|
830
|
+
// Early-state hints — dim italic for collecting… and waiting messages.
|
|
831
|
+
out = out.replace(/(\(collecting…\))/, (_m, s) => c.italic(c.dim(s)));
|
|
832
|
+
out = out.replace(/(\(waiting for first tool call\))/, (_m, s) => c.dim(s));
|
|
833
|
+
// Mid-dot separators — dim.
|
|
834
|
+
out = out.replaceAll(" · ", ` ${c.dim("\u00B7")} `);
|
|
835
|
+
return out;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ---------------------------------------------------------------------------
|
|
839
|
+
// Stdin reader — non-blocking, 50ms deadline
|
|
840
|
+
// ---------------------------------------------------------------------------
|
|
841
|
+
// Claude Code may pipe a JSON payload describing the current session state.
|
|
842
|
+
// We read it with a hard 50ms deadline so we never stall the terminal on a
|
|
843
|
+
// slow or empty stdin. Returns null on timeout, parse error, or empty input.
|
|
844
|
+
|
|
845
|
+
async function readStdinPayload(): Promise<StatusLineInput | null> {
|
|
846
|
+
return new Promise<StatusLineInput | null>((resolve) => {
|
|
847
|
+
// Resolve null if stdin is a TTY (nothing to read) or non-readable.
|
|
848
|
+
if (process.stdin.isTTY) { resolve(null); return; }
|
|
849
|
+
|
|
850
|
+
let raw = "";
|
|
851
|
+
const timer = setTimeout(() => {
|
|
852
|
+
process.stdin.destroy();
|
|
853
|
+
resolve(null);
|
|
854
|
+
}, 50);
|
|
855
|
+
|
|
856
|
+
process.stdin.setEncoding("utf-8");
|
|
857
|
+
process.stdin.on("data", (chunk: string) => { raw += chunk; });
|
|
858
|
+
process.stdin.on("end", () => {
|
|
859
|
+
clearTimeout(timer);
|
|
860
|
+
if (!raw.trim()) { resolve(null); return; }
|
|
861
|
+
try { resolve(JSON.parse(raw) as StatusLineInput); }
|
|
862
|
+
catch { resolve(null); }
|
|
863
|
+
});
|
|
864
|
+
process.stdin.on("error", () => { clearTimeout(timer); resolve(null); });
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Run as script (skip when imported by tests).
|
|
869
|
+
if (import.meta.main) {
|
|
870
|
+
const statusLineInput = await readStdinPayload();
|
|
871
|
+
process.stdout.write(buildStatusLine({ statusLineInput }) + "\n");
|
|
872
|
+
process.exit(0);
|
|
873
|
+
}
|