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,1258 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* ashlr savings dashboard — rich CLI view.
|
|
4
|
+
*
|
|
5
|
+
* Reads ~/.ashlr/stats.json and renders a multi-panel dashboard:
|
|
6
|
+
* - ASCII-art banner (3 lines, block chars, ≤70 cols)
|
|
7
|
+
* - "At a glance" tile strip (session / lifetime / best day)
|
|
8
|
+
* - Per-tool horizontal bar chart (top 8 tools, Unicode block bars)
|
|
9
|
+
* - 7-day + 30-day sparklines (labeled, capped at 20 cells)
|
|
10
|
+
* - Projected annual savings (extrapolated from last 30d)
|
|
11
|
+
* - Top 3 projects (from ~/.ashlr/session-log.jsonl)
|
|
12
|
+
*
|
|
13
|
+
* Uses ANSI truecolor only when COLORTERM=truecolor/24bit and NO_COLOR is
|
|
14
|
+
* unset. Falls back to plain text with identical visible column widths.
|
|
15
|
+
*
|
|
16
|
+
* --watch flag: clear + redraw every 1.5s. Exits on Ctrl-C.
|
|
17
|
+
* Skipped (single render) when process.stdin.isTTY === false.
|
|
18
|
+
*
|
|
19
|
+
* Contract: always exit 0. No external dependencies.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execSync } from "child_process";
|
|
23
|
+
import { existsSync, readFileSync } from "fs";
|
|
24
|
+
import { homedir } from "os";
|
|
25
|
+
import { join } from "path";
|
|
26
|
+
import { buildTopProjects, renderNudgeSection } from "./savings-report-extras.ts";
|
|
27
|
+
import { renderCompact } from "./hook-timings-report.ts";
|
|
28
|
+
import { readHookTimingsDetailed } from "./hook-timing-reader.ts";
|
|
29
|
+
import { readNudgeSummarySync } from "../servers/_nudge-events.ts";
|
|
30
|
+
import { costFor as _costFor, pricing as _pricing, pricingModel as _pricingModel } from "../servers/_pricing.ts";
|
|
31
|
+
import { readStreaks } from "../servers/_streaks.ts";
|
|
32
|
+
import { readAggregateCache } from "./stats-cloud-pull.ts";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Types
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
interface PerTool {
|
|
39
|
+
calls?: number;
|
|
40
|
+
tokensSaved?: number;
|
|
41
|
+
}
|
|
42
|
+
interface ByTool {
|
|
43
|
+
[k: string]: PerTool | undefined;
|
|
44
|
+
}
|
|
45
|
+
interface PerDay {
|
|
46
|
+
calls?: number;
|
|
47
|
+
tokensSaved?: number;
|
|
48
|
+
}
|
|
49
|
+
interface ByDay {
|
|
50
|
+
[date: string]: PerDay | undefined;
|
|
51
|
+
}
|
|
52
|
+
interface ByProject {
|
|
53
|
+
[path: string]: { calls?: number; tokensSaved?: number } | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SessionStats {
|
|
57
|
+
startedAt?: string;
|
|
58
|
+
calls?: number;
|
|
59
|
+
tokensSaved?: number;
|
|
60
|
+
byTool?: ByTool;
|
|
61
|
+
byProject?: ByProject;
|
|
62
|
+
}
|
|
63
|
+
interface LifetimeStats {
|
|
64
|
+
calls?: number;
|
|
65
|
+
tokensSaved?: number;
|
|
66
|
+
/** v1.18: denominator for % savings. Missing from older stats.json → treat as 0. */
|
|
67
|
+
rawTotal?: number;
|
|
68
|
+
byTool?: ByTool;
|
|
69
|
+
byDay?: ByDay;
|
|
70
|
+
byProject?: ByProject;
|
|
71
|
+
}
|
|
72
|
+
interface Stats {
|
|
73
|
+
session?: SessionStats;
|
|
74
|
+
lifetime?: LifetimeStats;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Color / ANSI — truecolor when COLORTERM advertises it; plain fallback
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
// Exported so sibling renderers (e.g. servers/efficiency-server.ts'
|
|
82
|
+
// renderColoredBanner) can share one capability detection rule instead of
|
|
83
|
+
// reimplementing it. Treats FORCE_COLOR=0/false as an explicit opt-out — CI
|
|
84
|
+
// systems (notably some GitHub Actions runners) set this even when COLORTERM
|
|
85
|
+
// inherits truecolor from the parent shell, and we shouldn't override.
|
|
86
|
+
export const TRUECOLOR = (() => {
|
|
87
|
+
if (process.env.NO_COLOR) return false;
|
|
88
|
+
if (process.env.FORCE_COLOR === "0" || process.env.FORCE_COLOR === "false") return false;
|
|
89
|
+
if (process.env.FORCE_COLOR === "3" || process.env.FORCE_COLOR === "true") return true;
|
|
90
|
+
const ct = (process.env.COLORTERM ?? "").toLowerCase();
|
|
91
|
+
return ct === "truecolor" || ct === "24bit";
|
|
92
|
+
})();
|
|
93
|
+
|
|
94
|
+
// Brand palette: green family for primary, slate for structural chrome
|
|
95
|
+
const RGB = {
|
|
96
|
+
brand: [0, 208, 156] as const, // #00d09c primary brand green
|
|
97
|
+
brandDim: [0, 140, 100] as const, // dimmer brand green
|
|
98
|
+
brandBold: [124, 255, 214] as const, // #7cffd6 bright highlight
|
|
99
|
+
gold: [220, 180, 50] as const, // #dcb432 numbers / values
|
|
100
|
+
slate: [120, 130, 145] as const, // structural chrome
|
|
101
|
+
white: [220, 225, 235] as const, // labels
|
|
102
|
+
red: [225, 91, 91] as const, // errors
|
|
103
|
+
blue: [ 90, 160, 230] as const, // low-intensity bars
|
|
104
|
+
cyan: [ 60, 200, 220] as const, // mid-intensity bars
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type RGBTriple = readonly [number, number, number];
|
|
108
|
+
|
|
109
|
+
export function tc(rgb: RGBTriple, s: string): string {
|
|
110
|
+
if (!TRUECOLOR) return s;
|
|
111
|
+
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${s}\x1b[0m`;
|
|
112
|
+
}
|
|
113
|
+
export function bold(s: string): string {
|
|
114
|
+
if (!TRUECOLOR) return s;
|
|
115
|
+
return `\x1b[1m${s}\x1b[22m`;
|
|
116
|
+
}
|
|
117
|
+
function dim(s: string): string {
|
|
118
|
+
if (!TRUECOLOR) return s;
|
|
119
|
+
return `\x1b[2m${s}\x1b[22m`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Visible-width helpers — strip ANSI before measuring
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
127
|
+
|
|
128
|
+
export function visibleWidth(s: string): number {
|
|
129
|
+
return Array.from(s.replace(ANSI_RE, "")).length;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function padEnd(s: string, w: number, ch = " "): string {
|
|
133
|
+
const pad = w - visibleWidth(s);
|
|
134
|
+
return pad > 0 ? s + ch.repeat(pad) : s;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function padStart(s: string, w: number, ch = " "): string {
|
|
138
|
+
const pad = w - visibleWidth(s);
|
|
139
|
+
return pad > 0 ? ch.repeat(pad) + s : s;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Number formatters
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* v1.18: unified pricing via `../servers/_pricing.ts` — same token count
|
|
148
|
+
* produces the same dollar value here as in the efficiency-server's
|
|
149
|
+
* `renderSavings()`. Prior $5 "blended" value is replaced by the model-
|
|
150
|
+
* specific input rate (sonnet-4.5 default → $3/MTok).
|
|
151
|
+
*/
|
|
152
|
+
export function fmtTokens(n: number): string {
|
|
153
|
+
if (!Number.isFinite(n) || n < 0) return "0";
|
|
154
|
+
if (n < 1_000) return String(Math.floor(n));
|
|
155
|
+
if (n < 1_000_000) return (n / 1_000).toFixed(1) + "K";
|
|
156
|
+
return (n / 1_000_000).toFixed(2) + "M";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function fmtUsd(tokens: number): string {
|
|
160
|
+
const cost = _costFor(tokens);
|
|
161
|
+
if (cost < 0.01) return `~$${cost.toFixed(4)}`;
|
|
162
|
+
if (cost < 1) return `~$${cost.toFixed(3)}`;
|
|
163
|
+
if (cost < 100) return `~$${cost.toFixed(2)}`;
|
|
164
|
+
return `~$${Math.round(cost).toLocaleString()}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function fmtAge(iso: string | undefined): string {
|
|
168
|
+
if (!iso) return "unknown";
|
|
169
|
+
const t = new Date(iso).getTime();
|
|
170
|
+
if (!Number.isFinite(t)) return "unknown";
|
|
171
|
+
const ms = Date.now() - t;
|
|
172
|
+
if (ms < 0) return "just now";
|
|
173
|
+
const s = Math.floor(ms / 1000);
|
|
174
|
+
if (s < 60) return `${s}s`;
|
|
175
|
+
const m = Math.floor(s / 60);
|
|
176
|
+
if (m < 60) return `${m}m`;
|
|
177
|
+
const h = Math.floor(m / 60);
|
|
178
|
+
if (h < 24) return `${h}h ${m % 60}m`;
|
|
179
|
+
return `${Math.floor(h / 24)}d`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Date helpers
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
function lastNDayKeys(n: number, now: Date = new Date()): string[] {
|
|
187
|
+
const out: string[] = [];
|
|
188
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
189
|
+
const d = new Date(now);
|
|
190
|
+
d.setUTCDate(d.getUTCDate() - i);
|
|
191
|
+
out.push(d.toISOString().slice(0, 10));
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function bestDay(byDay: ByDay): { date: string; tokens: number } | null {
|
|
197
|
+
let best: { date: string; tokens: number } | null = null;
|
|
198
|
+
for (const [date, v] of Object.entries(byDay)) {
|
|
199
|
+
const tok = v?.tokensSaved ?? 0;
|
|
200
|
+
if (!best || tok > best.tokens) best = { date, tokens: tok };
|
|
201
|
+
}
|
|
202
|
+
return best;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* UTC YYYY-MM-DD for `today` and `today - 1 day`.
|
|
207
|
+
* Isolated into a helper so tests can inject a fixed `now`.
|
|
208
|
+
*/
|
|
209
|
+
export function todayYesterdayKeys(now: Date = new Date()): { today: string; yesterday: string } {
|
|
210
|
+
const todayDate = new Date(now);
|
|
211
|
+
const yesterdayDate = new Date(now);
|
|
212
|
+
yesterdayDate.setUTCDate(yesterdayDate.getUTCDate() - 1);
|
|
213
|
+
return {
|
|
214
|
+
today: todayDate.toISOString().slice(0, 10),
|
|
215
|
+
yesterday: yesterdayDate.toISOString().slice(0, 10),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Today-vs-yesterday one-liner.
|
|
221
|
+
*
|
|
222
|
+
* Render rules (from the v1.18.1 brief):
|
|
223
|
+
* - todaySaved === 0 → "" (silent; nothing to celebrate)
|
|
224
|
+
* - yesterdaySaved === 0 && todaySaved > 0 → "Saved X tokens today — great start!"
|
|
225
|
+
* - ratio >= 1.1 → "Today's pace is N.Nx yesterday's (X vs Y tokens)"
|
|
226
|
+
* - ratio <= 0.5 → "Today's saved fewer than yesterday (X vs Y — less active session?)"
|
|
227
|
+
* - otherwise → "" (quiet; numbers are similar)
|
|
228
|
+
*
|
|
229
|
+
* Returns an already-colored line (single line, no trailing newline). Returns
|
|
230
|
+
* "" when the callout should not render.
|
|
231
|
+
*/
|
|
232
|
+
export function renderTodayVsYesterday(
|
|
233
|
+
byDay: ByDay,
|
|
234
|
+
now: Date = new Date(),
|
|
235
|
+
): string {
|
|
236
|
+
const { today, yesterday } = todayYesterdayKeys(now);
|
|
237
|
+
const todaySaved = byDay[today]?.tokensSaved ?? 0;
|
|
238
|
+
const yesterdaySaved = byDay[yesterday]?.tokensSaved ?? 0;
|
|
239
|
+
|
|
240
|
+
if (todaySaved === 0) return "";
|
|
241
|
+
|
|
242
|
+
if (yesterdaySaved === 0) {
|
|
243
|
+
// Great-start case — today is nonzero, yesterday was silent.
|
|
244
|
+
return (
|
|
245
|
+
" " +
|
|
246
|
+
tc(RGB.brandBold, bold(`Saved ${fmtTokens(todaySaved)} tokens today`)) +
|
|
247
|
+
tc(RGB.brand, " — great start!")
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const ratio = todaySaved / yesterdaySaved;
|
|
252
|
+
|
|
253
|
+
if (ratio >= 1.1) {
|
|
254
|
+
return (
|
|
255
|
+
" " +
|
|
256
|
+
tc(RGB.brand, bold(`Today's pace is ${ratio.toFixed(1)}x yesterday's`)) +
|
|
257
|
+
tc(RGB.slate, dim(` (${fmtTokens(todaySaved)} vs ${fmtTokens(yesterdaySaved)} tokens)`))
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (ratio <= 0.5) {
|
|
262
|
+
return (
|
|
263
|
+
" " +
|
|
264
|
+
tc(RGB.gold, "Today's saved fewer than yesterday") +
|
|
265
|
+
tc(RGB.slate, dim(` (${fmtTokens(todaySaved)} vs ${fmtTokens(yesterdaySaved)} — less active session?)`))
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Quiet zone — numbers are similar. No callout.
|
|
270
|
+
return "";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Stats loading
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
export const STATS_PATH = join(homedir(), ".ashlr", "stats.json");
|
|
278
|
+
|
|
279
|
+
export function loadStats(path = STATS_PATH): Stats | null {
|
|
280
|
+
try {
|
|
281
|
+
if (!existsSync(path)) return null;
|
|
282
|
+
const raw = readFileSync(path, "utf-8");
|
|
283
|
+
return JSON.parse(raw) as Stats;
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Dashboard width budget
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
// All content must fit in 80 visible columns.
|
|
294
|
+
export const DASH_WIDTH = 78; // outer box spans 80 cols (border chars included)
|
|
295
|
+
const INNER = DASH_WIDTH - 2; // inner content width
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// ASCII-art banner — block/shade chars, 3 lines, ≤70 cols
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
// Compact "ashlr" banner — built manually to stay within 70 cols
|
|
302
|
+
// a s h l r (5 chars × ~13 cols + spacing ≈ 68 cols total)
|
|
303
|
+
// 5-row block-letter "ashlr" hero — matches SAVINGS_BANNER (efficiency-server.ts).
|
|
304
|
+
// Each row gets a different color from BANNER_GRADIENT in renderBanner() so the
|
|
305
|
+
// wordmark fades top-to-bottom from neon highlight (#7cffd6) through brand
|
|
306
|
+
// (#00d09c) to brand-shadow (#008c64). The previous 3-line block-letter art
|
|
307
|
+
// rendered as garbled glyphs because variable-shape lowercase doesn't fit
|
|
308
|
+
// 3-line block letters; this version uses 5 rows + variable letter widths
|
|
309
|
+
// (a/s/h/r = 4 cols, l = 1 col) for proper lowercase form. The `r` is an
|
|
310
|
+
// open hook (not a closed bowl) so it doesn't read as `P`.
|
|
311
|
+
const BANNER: string[] = [
|
|
312
|
+
" ▄▓▓▄ ▄▓▓▄ █ █ ▄▓▓▒",
|
|
313
|
+
" ▓░░▓ ▓░░░ █ █ █░░▒",
|
|
314
|
+
" ▓▓▓▓ ░▓▓▒ █▓▓▒ █ █",
|
|
315
|
+
" ▓░░▓ ░░░▓ █░░▓ █ █",
|
|
316
|
+
" ▀░░▀ ▀▓▓▀ ▀░░▀ ▀ ▀",
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
// Per-row vertical gradient: highlight at the top, shadow at the bottom.
|
|
320
|
+
// Exported so the savings server can apply the same gradient to its hero
|
|
321
|
+
// without duplicating the color stops.
|
|
322
|
+
export const BANNER_GRADIENT: ReadonlyArray<RGBTriple> = [
|
|
323
|
+
[0x7c, 0xff, 0xd6], // brandBold
|
|
324
|
+
[0x4f, 0xe5, 0xbe],
|
|
325
|
+
[0x00, 0xd0, 0x9c], // brand
|
|
326
|
+
[0x00, 0xa8, 0x7d],
|
|
327
|
+
[0x00, 0x8c, 0x64], // brandDim
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
// Tagline under banner
|
|
331
|
+
const TAGLINE = " ▓░ token-efficiency layer for claude code ░▓";
|
|
332
|
+
|
|
333
|
+
function renderBanner(): string[] {
|
|
334
|
+
const lines: string[] = [];
|
|
335
|
+
// Top rule
|
|
336
|
+
lines.push(tc(RGB.slate, "─".repeat(DASH_WIDTH)));
|
|
337
|
+
// Hero rows: each row gets its own gradient color, all bold.
|
|
338
|
+
for (let i = 0; i < BANNER.length; i++) {
|
|
339
|
+
const color = BANNER_GRADIENT[i] ?? RGB.brand;
|
|
340
|
+
lines.push(tc(color, bold(BANNER[i]!)));
|
|
341
|
+
}
|
|
342
|
+
lines.push(tc(RGB.brandDim, TAGLINE));
|
|
343
|
+
lines.push(tc(RGB.slate, "─".repeat(DASH_WIDTH)));
|
|
344
|
+
return lines;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Box drawing helpers
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
function boxTop(title: string, width: number): string {
|
|
352
|
+
const inner = width - 2;
|
|
353
|
+
const titleStr = ` ${title} `;
|
|
354
|
+
const titleLen = visibleWidth(titleStr);
|
|
355
|
+
const dashes = Math.max(0, inner - titleLen);
|
|
356
|
+
const l = Math.floor(dashes / 2);
|
|
357
|
+
const r = dashes - l;
|
|
358
|
+
return tc(RGB.slate, "╭" + "─".repeat(l)) +
|
|
359
|
+
tc(RGB.brandBold, bold(titleStr)) +
|
|
360
|
+
tc(RGB.slate, "─".repeat(r) + "╮");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function boxBottom(width: number): string {
|
|
364
|
+
return tc(RGB.slate, "╰" + "─".repeat(width - 2) + "╯");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function boxLine(content: string, width: number): string {
|
|
368
|
+
const inner = width - 2;
|
|
369
|
+
const padded = padEnd(" " + content, inner);
|
|
370
|
+
return tc(RGB.slate, "│") + padded + tc(RGB.slate, "│");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function boxEmpty(width: number): string {
|
|
374
|
+
return tc(RGB.slate, "│") + " ".repeat(width - 2) + tc(RGB.slate, "│");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// "At a glance" tile strip
|
|
379
|
+
// 3 tiles side by side, total width ≤ 78
|
|
380
|
+
// Each tile: 24 cols wide (22 inner + 2 border), gap = 1 space
|
|
381
|
+
// 3 × 24 + 2 gaps = 74. Center-padding to 78: 2 each side → fine.
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
const TILE_W = 24; // total tile width including border chars
|
|
385
|
+
|
|
386
|
+
function renderTileStrip(stats: Stats): string[] {
|
|
387
|
+
const sess = stats.session;
|
|
388
|
+
const life = stats.lifetime;
|
|
389
|
+
const bd = bestDay(life?.byDay ?? {});
|
|
390
|
+
|
|
391
|
+
const tiles: Array<{ title: string; line1: string; line2: string }> = [
|
|
392
|
+
{
|
|
393
|
+
title: "session",
|
|
394
|
+
line1: tc(RGB.brandBold, bold(fmtTokens(sess?.tokensSaved ?? 0))),
|
|
395
|
+
line2: tc(RGB.gold, fmtUsd(sess?.tokensSaved ?? 0)) +
|
|
396
|
+
tc(RGB.slate, dim(` ${fmtAge(sess?.startedAt)}`)),
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
title: "lifetime",
|
|
400
|
+
line1: tc(RGB.brandBold, bold(fmtTokens(life?.tokensSaved ?? 0))),
|
|
401
|
+
line2: tc(RGB.gold, fmtUsd(life?.tokensSaved ?? 0)) +
|
|
402
|
+
tc(RGB.slate, dim(` ${life?.calls ?? 0} calls`)),
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
title: "best day",
|
|
406
|
+
line1: tc(RGB.white, bd?.date ?? "none yet"),
|
|
407
|
+
line2: bd
|
|
408
|
+
? tc(RGB.brandBold, bold(fmtTokens(bd.tokens))) + tc(RGB.slate, dim(" tok"))
|
|
409
|
+
: tc(RGB.slate, dim("no data")),
|
|
410
|
+
},
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
// Each tile has 3 rows: top, line1, line2, bottom
|
|
414
|
+
const rows: string[][] = tiles.map(({ title, line1, line2 }) => [
|
|
415
|
+
boxTop(title, TILE_W),
|
|
416
|
+
boxLine(line1, TILE_W),
|
|
417
|
+
boxLine(line2, TILE_W),
|
|
418
|
+
boxBottom(TILE_W),
|
|
419
|
+
]);
|
|
420
|
+
|
|
421
|
+
const out: string[] = [];
|
|
422
|
+
// Render row by row (interleave the 3 tiles)
|
|
423
|
+
for (let r = 0; r < rows[0]!.length; r++) {
|
|
424
|
+
out.push(rows.map((tile) => tile[r]).join(" "));
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Per-tool horizontal bar chart
|
|
431
|
+
// Top 8 tools sorted by lifetime tokensSaved descending.
|
|
432
|
+
// Bar width: 24 cols. Line format:
|
|
433
|
+
// <name padded 16> <bar 24> <tok 6> <pct 4>
|
|
434
|
+
// = 16 + 2 + 24 + 2 + 6 + 2 + 4 = 56 chars (fits in 78)
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
const BAR_WIDTH = 24;
|
|
438
|
+
const BLOCK_CHARS = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
|
|
439
|
+
|
|
440
|
+
function hBar(value: number, max: number, width: number): string {
|
|
441
|
+
if (max <= 0 || width <= 0) return " ".repeat(width);
|
|
442
|
+
const fraction = Math.max(0, Math.min(1, value / max));
|
|
443
|
+
const totalEighths = Math.round(fraction * width * 8);
|
|
444
|
+
const fullCells = Math.floor(totalEighths / 8);
|
|
445
|
+
const remainder = totalEighths % 8;
|
|
446
|
+
|
|
447
|
+
let bar = "";
|
|
448
|
+
// Color by fill level — low blue, mid cyan, high brand green
|
|
449
|
+
const fillLevel = fraction;
|
|
450
|
+
const barColor = fillLevel >= 0.7
|
|
451
|
+
? RGB.brand
|
|
452
|
+
: fillLevel >= 0.35
|
|
453
|
+
? RGB.cyan
|
|
454
|
+
: RGB.blue;
|
|
455
|
+
|
|
456
|
+
for (let i = 0; i < fullCells; i++) {
|
|
457
|
+
bar += tc(barColor, "█");
|
|
458
|
+
}
|
|
459
|
+
if (remainder > 0 && fullCells < width) {
|
|
460
|
+
bar += tc(barColor, BLOCK_CHARS[remainder - 1]!);
|
|
461
|
+
// Fill remainder with dim empty
|
|
462
|
+
bar += tc(RGB.slate, dim("░".repeat(width - fullCells - 1)));
|
|
463
|
+
} else if (fullCells < width) {
|
|
464
|
+
bar += tc(RGB.slate, dim("░".repeat(width - fullCells)));
|
|
465
|
+
}
|
|
466
|
+
return bar;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function renderBarChart(stats: Stats): string[] {
|
|
470
|
+
const out: string[] = [];
|
|
471
|
+
out.push("");
|
|
472
|
+
out.push(tc(RGB.brand, bold(" per-tool savings (lifetime)")));
|
|
473
|
+
out.push("");
|
|
474
|
+
|
|
475
|
+
const byTool = stats.lifetime?.byTool ?? {};
|
|
476
|
+
const rows = Object.entries(byTool)
|
|
477
|
+
.map(([name, t]) => ({
|
|
478
|
+
name,
|
|
479
|
+
calls: t?.calls ?? 0,
|
|
480
|
+
tokensSaved: t?.tokensSaved ?? 0,
|
|
481
|
+
avgPerCall: (t?.calls ?? 0) > 0 ? Math.round((t?.tokensSaved ?? 0) / (t?.calls ?? 1)) : 0,
|
|
482
|
+
}))
|
|
483
|
+
.filter((r) => r.tokensSaved > 0)
|
|
484
|
+
.sort((a, b) => b.tokensSaved - a.tokensSaved)
|
|
485
|
+
.slice(0, 8);
|
|
486
|
+
|
|
487
|
+
if (rows.length === 0) {
|
|
488
|
+
out.push(tc(RGB.gold, " no tool usage recorded yet."));
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const maxTok = Math.max(...rows.map((r) => r.tokensSaved));
|
|
493
|
+
const total = rows.reduce((s, r) => s + r.tokensSaved, 0);
|
|
494
|
+
const maxAvg = Math.max(...rows.map((r) => r.avgPerCall));
|
|
495
|
+
|
|
496
|
+
// v1.30: per-tool ROI scorecard. The avg/call column tells the user which
|
|
497
|
+
// tools earn their keep PER INVOCATION, not just per session. A high
|
|
498
|
+
// tokensSaved with low avg/call means "called a lot but saved little
|
|
499
|
+
// each time" — useful for spotting tools that look heroic in totals but
|
|
500
|
+
// are actually weak on a per-call basis.
|
|
501
|
+
for (const r of rows) {
|
|
502
|
+
const name = padEnd(tc(RGB.white, r.name), 16);
|
|
503
|
+
const bar = hBar(r.tokensSaved, maxTok, BAR_WIDTH);
|
|
504
|
+
const tok = padStart(tc(RGB.brandBold, fmtTokens(r.tokensSaved)), 7);
|
|
505
|
+
// Highlight per-call winners: best-in-show gets brand color; others slate.
|
|
506
|
+
const avgColor = r.avgPerCall === maxAvg ? RGB.brand : RGB.cyan;
|
|
507
|
+
const avg = padStart(tc(avgColor, fmtTokens(r.avgPerCall) + "/call"), 11);
|
|
508
|
+
const pct = padStart(
|
|
509
|
+
tc(RGB.slate, dim(Math.round((r.tokensSaved / total) * 100) + "%")),
|
|
510
|
+
4,
|
|
511
|
+
);
|
|
512
|
+
out.push(` ${name} ${bar} ${tok} ${avg} ${pct}`);
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// Sparklines — 7d and 30d
|
|
519
|
+
// One Unicode block char per day, capped at 20 cells, labeled.
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
const SPARK_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
523
|
+
|
|
524
|
+
function sparkGlyph(value: number, max: number): string {
|
|
525
|
+
if (value <= 0 || max <= 0) return tc(RGB.slate, dim("▁"));
|
|
526
|
+
const idx = Math.max(0, Math.min(7, Math.ceil((value / max) * 7) - 1));
|
|
527
|
+
const fraction = value / max;
|
|
528
|
+
const color = fraction >= 0.7 ? RGB.brand : fraction >= 0.35 ? RGB.cyan : RGB.blue;
|
|
529
|
+
return tc(color, SPARK_CHARS[idx]!);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function renderSparklines(stats: Stats): string[] {
|
|
533
|
+
const byDay = stats.lifetime?.byDay ?? {};
|
|
534
|
+
const out: string[] = [];
|
|
535
|
+
out.push("");
|
|
536
|
+
out.push(tc(RGB.brand, bold(" activity sparklines")));
|
|
537
|
+
out.push("");
|
|
538
|
+
|
|
539
|
+
const MAX_CELLS = 20;
|
|
540
|
+
|
|
541
|
+
for (const [days, label] of [[7, "last 7d "], [30, "last 30d"]] as Array<[number, string]>) {
|
|
542
|
+
const keys = lastNDayKeys(days);
|
|
543
|
+
const values = keys.map((k) => byDay[k]?.tokensSaved ?? 0);
|
|
544
|
+
// Truncate to MAX_CELLS (30d → 20 cells, every 1.5th day; 7d fits fine)
|
|
545
|
+
const step = days > MAX_CELLS ? days / MAX_CELLS : 1;
|
|
546
|
+
const sampled: number[] = [];
|
|
547
|
+
if (step > 1) {
|
|
548
|
+
// Average buckets for 30d → 20 cells
|
|
549
|
+
const cellCount = MAX_CELLS;
|
|
550
|
+
for (let i = 0; i < cellCount; i++) {
|
|
551
|
+
const start = Math.floor(i * step);
|
|
552
|
+
const end = Math.min(values.length, Math.floor((i + 1) * step));
|
|
553
|
+
const bucket = values.slice(start, end);
|
|
554
|
+
sampled.push(bucket.reduce((s, v) => s + v, 0));
|
|
555
|
+
}
|
|
556
|
+
} else {
|
|
557
|
+
sampled.push(...values);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const max = Math.max(...sampled);
|
|
561
|
+
const spark = sampled.map((v) => sparkGlyph(v, max)).join("");
|
|
562
|
+
const totalTok = values.reduce((s, v) => s + v, 0);
|
|
563
|
+
const activeDays = values.filter((v) => v > 0).length;
|
|
564
|
+
const suffix =
|
|
565
|
+
max > 0
|
|
566
|
+
? tc(RGB.slate, dim(` total ${fmtTokens(totalTok)} active ${activeDays}/${days}d`))
|
|
567
|
+
: tc(RGB.slate, dim(" no data"));
|
|
568
|
+
|
|
569
|
+
out.push(
|
|
570
|
+
` ${tc(RGB.white, label)} ${spark}${suffix}`,
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
// Peak annotation on busiest day (7d only — 30d too wide)
|
|
574
|
+
if (days === 7 && max > 0) {
|
|
575
|
+
const peakIdx = values.indexOf(max);
|
|
576
|
+
const peakDate = keys[peakIdx] ?? "";
|
|
577
|
+
out.push(
|
|
578
|
+
` ${" ".repeat(peakIdx)}${tc(RGB.gold, "^")} ${tc(RGB.slate, dim(`peak ${peakDate.slice(5)} (${fmtTokens(max)})`))}`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return out;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// Projected annual
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
function renderProjection(stats: Stats, now: Date = new Date()): string[] {
|
|
590
|
+
const byDay = stats.lifetime?.byDay ?? {};
|
|
591
|
+
const keys = lastNDayKeys(30, now);
|
|
592
|
+
const values = keys.map((k) => byDay[k]?.tokensSaved ?? 0);
|
|
593
|
+
const activeDays = values.filter((v) => v > 0).length;
|
|
594
|
+
const total = values.reduce((s, v) => s + v, 0);
|
|
595
|
+
|
|
596
|
+
const out: string[] = [];
|
|
597
|
+
out.push("");
|
|
598
|
+
out.push(tc(RGB.brand, bold(" projected annual savings")));
|
|
599
|
+
out.push("");
|
|
600
|
+
|
|
601
|
+
if (activeDays < 3 || total === 0) {
|
|
602
|
+
out.push(tc(RGB.gold, ` not enough history — projection unlocks after ≥3 active days.`));
|
|
603
|
+
out.push(tc(RGB.slate, dim(` currently tracking ${activeDays} active day(s) in the last 30.`)));
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const annualTokens = Math.round((total * 365) / 30);
|
|
608
|
+
const annualCost = fmtUsd(annualTokens);
|
|
609
|
+
const annualCalls = Math.round(((stats.lifetime?.calls ?? 0) / Math.max(1, activeDays)) * 220);
|
|
610
|
+
|
|
611
|
+
// Active-day workday extrapolation
|
|
612
|
+
const perActive = total / activeDays;
|
|
613
|
+
const workdayTokens = Math.round(perActive * 220);
|
|
614
|
+
const workdayCost = fmtUsd(workdayTokens);
|
|
615
|
+
|
|
616
|
+
const colL = 22;
|
|
617
|
+
const col1 = 10;
|
|
618
|
+
const col2 = 12;
|
|
619
|
+
|
|
620
|
+
out.push(
|
|
621
|
+
padEnd(tc(RGB.slate, dim(" 30d rolling × 12")), colL) +
|
|
622
|
+
padStart(tc(RGB.brandBold, bold(fmtTokens(annualTokens))), col1) +
|
|
623
|
+
" tok/yr " +
|
|
624
|
+
tc(RGB.gold, annualCost),
|
|
625
|
+
);
|
|
626
|
+
out.push(
|
|
627
|
+
padEnd(tc(RGB.slate, dim(" active-day × 220")), colL) +
|
|
628
|
+
padStart(tc(RGB.brandBold, bold(fmtTokens(workdayTokens))), col1) +
|
|
629
|
+
" tok/yr " +
|
|
630
|
+
tc(RGB.gold, workdayCost),
|
|
631
|
+
);
|
|
632
|
+
out.push(
|
|
633
|
+
padEnd(tc(RGB.slate, dim(" calls extrapolation")), colL) +
|
|
634
|
+
padStart(tc(RGB.white, String(annualCalls)), col1) +
|
|
635
|
+
" calls/yr",
|
|
636
|
+
);
|
|
637
|
+
out.push("");
|
|
638
|
+
out.push(tc(RGB.slate, dim(" projection based on last 30d average — may vary.")));
|
|
639
|
+
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
// Top 3 projects (from session-log.jsonl)
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
function renderTopProjects(statsHome?: string): string[] {
|
|
648
|
+
const projects = buildTopProjects(statsHome).slice(0, 3);
|
|
649
|
+
const out: string[] = [];
|
|
650
|
+
if (projects.length === 0) return out;
|
|
651
|
+
|
|
652
|
+
out.push("");
|
|
653
|
+
out.push(tc(RGB.brand, bold(" top projects")));
|
|
654
|
+
out.push("");
|
|
655
|
+
|
|
656
|
+
const maxCalls = Math.max(...projects.map((p) => p.calls));
|
|
657
|
+
for (const [i, p] of projects.entries()) {
|
|
658
|
+
// Layout: indent(2) + rank(2) + sp(1) + name(24) + sp(1) + calls(5) + "x "(3) + bar(12) + tools(8) = 58
|
|
659
|
+
const name = p.name.length > 24 ? "..." + p.name.slice(-21) : p.name;
|
|
660
|
+
const rankStr = padEnd(tc(RGB.slate, dim(`${i + 1}.`)), 2);
|
|
661
|
+
const nameStr = padEnd(tc(RGB.white, name), 24);
|
|
662
|
+
const callsStr = padStart(tc(RGB.brandBold, String(p.calls)), 5);
|
|
663
|
+
const miniBar = hBar(p.calls, maxCalls, 12);
|
|
664
|
+
const toolStr = tc(RGB.slate, dim(` ${p.toolVariety} tool${p.toolVariety === 1 ? "" : "s"}`));
|
|
665
|
+
out.push(` ${rankStr} ${nameStr} ${callsStr}x ${miniBar}${toolStr}`);
|
|
666
|
+
}
|
|
667
|
+
return out;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ---------------------------------------------------------------------------
|
|
671
|
+
// Empty stats fallback
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
function renderNoData(): string {
|
|
675
|
+
const lines: string[] = [];
|
|
676
|
+
lines.push(...renderBanner());
|
|
677
|
+
lines.push("");
|
|
678
|
+
lines.push(
|
|
679
|
+
tc(RGB.gold,
|
|
680
|
+
" no savings recorded yet — run /ashlr-demo to see the plugin in action."
|
|
681
|
+
)
|
|
682
|
+
);
|
|
683
|
+
lines.push("");
|
|
684
|
+
lines.push(tc(RGB.slate, dim(` stats path: ${STATS_PATH}`)));
|
|
685
|
+
lines.push(tc(RGB.slate, dim(" use ashlr__read, ashlr__grep, or ashlr__edit to start.")));
|
|
686
|
+
lines.push("");
|
|
687
|
+
lines.push(tc(RGB.slate, "─".repeat(DASH_WIDTH)));
|
|
688
|
+
return lines.join("\n");
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
// Session-log reader (for telemetry sections)
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
|
|
695
|
+
interface SessionLogRecord {
|
|
696
|
+
event: string;
|
|
697
|
+
tool?: string;
|
|
698
|
+
ts?: string;
|
|
699
|
+
provider?: string;
|
|
700
|
+
latency_ms?: number;
|
|
701
|
+
in_tokens?: number;
|
|
702
|
+
out_tokens?: number;
|
|
703
|
+
sectionsRetrieved?: number;
|
|
704
|
+
tokensSaved?: number;
|
|
705
|
+
nativeToolBlocked?: string;
|
|
706
|
+
latencyMs?: number;
|
|
707
|
+
[key: string]: unknown;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export function readSessionLog(statsHome?: string): SessionLogRecord[] {
|
|
711
|
+
try {
|
|
712
|
+
const h = statsHome ?? process.env.HOME ?? homedir();
|
|
713
|
+
const p = join(h, ".ashlr", "session-log.jsonl");
|
|
714
|
+
if (!existsSync(p)) return [];
|
|
715
|
+
const raw = readFileSync(p, "utf-8");
|
|
716
|
+
return raw
|
|
717
|
+
.split("\n")
|
|
718
|
+
.filter((l) => l.trim())
|
|
719
|
+
.map((l) => {
|
|
720
|
+
try { return JSON.parse(l) as SessionLogRecord; }
|
|
721
|
+
catch { return null; }
|
|
722
|
+
})
|
|
723
|
+
.filter((r): r is SessionLogRecord => r !== null);
|
|
724
|
+
} catch {
|
|
725
|
+
return [];
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ---------------------------------------------------------------------------
|
|
730
|
+
// "Where savings come from" section
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
interface MechanismBucket {
|
|
734
|
+
calls: number;
|
|
735
|
+
bytesSaved: number;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Render the "where savings come from" breakdown.
|
|
740
|
+
* Reads session-log.jsonl to classify events by mechanism:
|
|
741
|
+
* snipCompact / LLM-anthropic / LLM-onnx / LLM-local / genome / embed-cache / structured-render
|
|
742
|
+
*/
|
|
743
|
+
export function renderSavingsMechanisms(statsHome?: string): string[] {
|
|
744
|
+
const records = readSessionLog(statsHome);
|
|
745
|
+
if (records.length === 0) {
|
|
746
|
+
return [
|
|
747
|
+
"",
|
|
748
|
+
tc(RGB.brand, bold(" where savings come from")),
|
|
749
|
+
"",
|
|
750
|
+
tc(RGB.slate, dim(" (no data — try /ashlr-grep to see telemetry populate)")),
|
|
751
|
+
];
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const buckets: Record<string, MechanismBucket> = {
|
|
755
|
+
snipCompact: { calls: 0, bytesSaved: 0 },
|
|
756
|
+
"LLM-anthropic": { calls: 0, bytesSaved: 0 },
|
|
757
|
+
"LLM-onnx": { calls: 0, bytesSaved: 0 },
|
|
758
|
+
"LLM-local": { calls: 0, bytesSaved: 0 },
|
|
759
|
+
genome: { calls: 0, bytesSaved: 0 },
|
|
760
|
+
"embed-cache": { calls: 0, bytesSaved: 0 },
|
|
761
|
+
"structured-render": { calls: 0, bytesSaved: 0 },
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
for (const r of records) {
|
|
765
|
+
if (r.event === "llm_summarize_provider_used") {
|
|
766
|
+
const provider = typeof r.provider === "string" ? r.provider : "local";
|
|
767
|
+
const key = provider === "anthropic" ? "LLM-anthropic"
|
|
768
|
+
: provider === "onnx" ? "LLM-onnx"
|
|
769
|
+
: "LLM-local";
|
|
770
|
+
const b = buckets[key]!;
|
|
771
|
+
b.calls++;
|
|
772
|
+
// Estimate bytes saved from in_tokens reported (rough: 4 bytes per input token)
|
|
773
|
+
b.bytesSaved += (typeof r.in_tokens === "number" ? r.in_tokens : 0) * 4;
|
|
774
|
+
} else if (r.event === "genome_route_taken") {
|
|
775
|
+
const b = buckets["genome"]!;
|
|
776
|
+
b.calls++;
|
|
777
|
+
b.bytesSaved += typeof r.sectionsRetrieved === "number" ? r.sectionsRetrieved * 500 : 500;
|
|
778
|
+
} else if (r.event === "embed_cache_hit") {
|
|
779
|
+
const b = buckets["embed-cache"]!;
|
|
780
|
+
b.calls++;
|
|
781
|
+
b.bytesSaved += typeof r.tokensSaved === "number" ? r.tokensSaved * 4 : 0;
|
|
782
|
+
} else if (r.event === "tool_call") {
|
|
783
|
+
// tool_call records from bash/PR/issue/diff tools = structured-render
|
|
784
|
+
const t = typeof r.tool === "string" ? r.tool : "";
|
|
785
|
+
if (t === "ashlr__bash" || t === "ashlr__pr" || t === "ashlr__issue" || t === "ashlr__diff") {
|
|
786
|
+
const b = buckets["structured-render"]!;
|
|
787
|
+
b.calls++;
|
|
788
|
+
}
|
|
789
|
+
} else if (r.event === "tool_noop" || r.event === "tool_fallback") {
|
|
790
|
+
// tool_noop = snipCompact kicked in (no summarization, pure truncation)
|
|
791
|
+
const b = buckets["snipCompact"]!;
|
|
792
|
+
b.calls++;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Total bytes saved across all mechanisms
|
|
797
|
+
const totalBytes = Object.values(buckets).reduce((s, b) => s + b.bytesSaved, 0);
|
|
798
|
+
const activeMechanisms = Object.entries(buckets).filter(([, b]) => b.calls > 0);
|
|
799
|
+
|
|
800
|
+
if (activeMechanisms.length === 0) {
|
|
801
|
+
return [
|
|
802
|
+
"",
|
|
803
|
+
tc(RGB.brand, bold(" where savings come from")),
|
|
804
|
+
"",
|
|
805
|
+
tc(RGB.slate, dim(" (no data — try /ashlr-grep to see telemetry populate)")),
|
|
806
|
+
];
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const out: string[] = [];
|
|
810
|
+
out.push("");
|
|
811
|
+
out.push(tc(RGB.brand, bold(" where savings come from")));
|
|
812
|
+
out.push("");
|
|
813
|
+
|
|
814
|
+
// Sort by bytesSaved descending
|
|
815
|
+
const sorted = Object.entries(buckets)
|
|
816
|
+
.filter(([, b]) => b.calls > 0)
|
|
817
|
+
.sort(([, a], [, b]) => b.bytesSaved - a.bytesSaved);
|
|
818
|
+
|
|
819
|
+
const maxBytes = Math.max(...sorted.map(([, b]) => b.bytesSaved), 1);
|
|
820
|
+
|
|
821
|
+
for (const [name, b] of sorted) {
|
|
822
|
+
const sharePct = totalBytes > 0 ? Math.round((b.bytesSaved / totalBytes) * 100) : 0;
|
|
823
|
+
const miniBar = hBar(b.bytesSaved, maxBytes, 16);
|
|
824
|
+
const nameStr = padEnd(tc(RGB.white, name), 18);
|
|
825
|
+
const callsStr = padStart(tc(RGB.slate, dim(`${b.calls}x`)), 5);
|
|
826
|
+
const pctStr = padStart(tc(RGB.brandBold, `${sharePct}%`), 4);
|
|
827
|
+
out.push(` ${nameStr} ${callsStr} ${miniBar} ${pctStr}`);
|
|
828
|
+
}
|
|
829
|
+
return out;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ---------------------------------------------------------------------------
|
|
833
|
+
// "Adoption funnel" section — redirect-to-call conversion rate
|
|
834
|
+
// ---------------------------------------------------------------------------
|
|
835
|
+
|
|
836
|
+
export function renderAdoptionFunnel(statsHome?: string): string[] {
|
|
837
|
+
const records = readSessionLog(statsHome);
|
|
838
|
+
|
|
839
|
+
// Count blocks emitted (PreToolUse redirects) from hook-timings.jsonl
|
|
840
|
+
// We also look in session-log for block events emitted by correlate hook.
|
|
841
|
+
let blocksEmitted = 0;
|
|
842
|
+
let ashlrCallsAfterBlock = 0;
|
|
843
|
+
|
|
844
|
+
// 7-day rolling window
|
|
845
|
+
const windowStart = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
846
|
+
|
|
847
|
+
for (const r of records) {
|
|
848
|
+
const ts = typeof r.ts === "string" ? Date.parse(r.ts) : 0;
|
|
849
|
+
if (!Number.isFinite(ts) || ts < windowStart) continue;
|
|
850
|
+
|
|
851
|
+
if (r.event === "tool_called_after_block") {
|
|
852
|
+
ashlrCallsAfterBlock++;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Also read hook-timings for block outcome counts
|
|
857
|
+
const h = statsHome ?? process.env.HOME ?? homedir();
|
|
858
|
+
const p = join(h, ".ashlr", "hook-timings.jsonl");
|
|
859
|
+
const timingRead = readHookTimingsDetailed({ path: p, sinceMs: windowStart });
|
|
860
|
+
for (const r of timingRead.rows) {
|
|
861
|
+
const ts = typeof r.ts === "string" ? Date.parse(r.ts) : 0;
|
|
862
|
+
if (Number.isFinite(ts) && ts >= windowStart && r.outcome === "block") blocksEmitted++;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const out: string[] = [];
|
|
866
|
+
out.push("");
|
|
867
|
+
out.push(tc(RGB.brand, bold(" adoption funnel (7-day rolling)")));
|
|
868
|
+
out.push("");
|
|
869
|
+
|
|
870
|
+
if (timingRead.coverage === "partial") {
|
|
871
|
+
out.push(tc(RGB.gold, " conversion unavailable (partial hook-timing coverage)"));
|
|
872
|
+
return out;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (blocksEmitted === 0 && ashlrCallsAfterBlock === 0) {
|
|
876
|
+
out.push(tc(RGB.slate, dim(" (no data — try /ashlr-grep to see telemetry populate)")));
|
|
877
|
+
return out;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const conversionRate = blocksEmitted > 0
|
|
881
|
+
? Math.round((ashlrCallsAfterBlock / blocksEmitted) * 100)
|
|
882
|
+
: 0;
|
|
883
|
+
|
|
884
|
+
const funnelBar = hBar(ashlrCallsAfterBlock, Math.max(blocksEmitted, 1), 20);
|
|
885
|
+
|
|
886
|
+
out.push(
|
|
887
|
+
` ${padEnd(tc(RGB.white, "blocks emitted"), 22)}` +
|
|
888
|
+
padStart(tc(RGB.brandBold, bold(String(blocksEmitted))), 6),
|
|
889
|
+
);
|
|
890
|
+
out.push(
|
|
891
|
+
` ${padEnd(tc(RGB.white, "converted to ashlr call"), 22)}` +
|
|
892
|
+
padStart(tc(RGB.brand, String(ashlrCallsAfterBlock)), 6) +
|
|
893
|
+
` ${funnelBar}`,
|
|
894
|
+
);
|
|
895
|
+
out.push(
|
|
896
|
+
` ${padEnd(tc(RGB.white, "conversion rate"), 22)}` +
|
|
897
|
+
padStart(tc(conversionRate >= 50 ? RGB.brand : RGB.gold, `${conversionRate}%`), 6),
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
return out;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// ---------------------------------------------------------------------------
|
|
904
|
+
// Divider helper
|
|
905
|
+
// ---------------------------------------------------------------------------
|
|
906
|
+
|
|
907
|
+
function divider(label?: string): string {
|
|
908
|
+
if (!label) return tc(RGB.slate, dim(" " + "·".repeat(DASH_WIDTH - 2)));
|
|
909
|
+
const inner = label;
|
|
910
|
+
const dashes = Math.max(4, DASH_WIDTH - visibleWidth(inner) - 4);
|
|
911
|
+
return tc(RGB.slate, dim(" ")) + tc(RGB.slate, inner) + tc(RGB.slate, dim(" " + "·".repeat(dashes)));
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ---------------------------------------------------------------------------
|
|
915
|
+
// Top-level renderer
|
|
916
|
+
// ---------------------------------------------------------------------------
|
|
917
|
+
|
|
918
|
+
function renderHookPerformance(statsHome?: string): string[] {
|
|
919
|
+
try {
|
|
920
|
+
const timingsPath = join(statsHome ?? join(homedir(), ".ashlr"), "hook-timings.jsonl");
|
|
921
|
+
const sinceMs = Date.now() - 24 * 3_600_000;
|
|
922
|
+
const detailed = readHookTimingsDetailed({ path: timingsPath, sinceMs });
|
|
923
|
+
const records = detailed.records;
|
|
924
|
+
if (records.length === 0 && detailed.coverage === "complete") return [];
|
|
925
|
+
const out: string[] = [];
|
|
926
|
+
out.push("");
|
|
927
|
+
out.push(tc(RGB.brand, bold(" hook performance (last 24h)")));
|
|
928
|
+
out.push("");
|
|
929
|
+
if (detailed.coverage === "partial") {
|
|
930
|
+
out.push(tc(RGB.gold, " Warning: partial timing coverage"));
|
|
931
|
+
}
|
|
932
|
+
if (records.length === 0) return out;
|
|
933
|
+
const compact = renderCompact({ records, topN: 5, windowHours: 24 });
|
|
934
|
+
if (!compact) return out;
|
|
935
|
+
for (const line of compact.split("\n").slice(1)) { // skip redundant header line
|
|
936
|
+
out.push(` ${line}`);
|
|
937
|
+
}
|
|
938
|
+
return out;
|
|
939
|
+
} catch {
|
|
940
|
+
return [];
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function renderNudge(home?: string): string[] {
|
|
945
|
+
try {
|
|
946
|
+
const h = home ?? process.env.HOME ?? homedir();
|
|
947
|
+
const proUser = existsSync(join(h, ".ashlr", "pro-token"));
|
|
948
|
+
const summary = readNudgeSummarySync(h);
|
|
949
|
+
const block = renderNudgeSection(summary, proUser);
|
|
950
|
+
if (!block) return [];
|
|
951
|
+
const out: string[] = [""];
|
|
952
|
+
for (const line of block.split("\n")) out.push(` ${line}`);
|
|
953
|
+
return out;
|
|
954
|
+
} catch {
|
|
955
|
+
return [];
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ---------------------------------------------------------------------------
|
|
960
|
+
// Streak section — shown when currentStreak >= 3
|
|
961
|
+
// ---------------------------------------------------------------------------
|
|
962
|
+
|
|
963
|
+
function renderStreakSection(home?: string): string[] {
|
|
964
|
+
try {
|
|
965
|
+
const h = home ?? process.env.HOME ?? homedir();
|
|
966
|
+
const data = readStreaks(h);
|
|
967
|
+
if (data.currentStreak < 3) return [];
|
|
968
|
+
const lines: string[] = [];
|
|
969
|
+
lines.push(boxTop("streak", DASH_WIDTH));
|
|
970
|
+
const currentLine =
|
|
971
|
+
tc(RGB.brandBold, bold(String(data.currentStreak))) +
|
|
972
|
+
tc(RGB.brand, "-day streak") +
|
|
973
|
+
tc(RGB.slate, dim(` (best: ${data.longestStreak} days)`));
|
|
974
|
+
lines.push(boxLine(currentLine, DASH_WIDTH));
|
|
975
|
+
lines.push(boxLine(
|
|
976
|
+
tc(RGB.slate, dim(`last active: ${data.lastActiveDay || "—"}`)),
|
|
977
|
+
DASH_WIDTH,
|
|
978
|
+
));
|
|
979
|
+
lines.push(boxBottom(DASH_WIDTH));
|
|
980
|
+
return lines;
|
|
981
|
+
} catch {
|
|
982
|
+
return [];
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ---------------------------------------------------------------------------
|
|
987
|
+
// Cross-machine aggregate (Pro only)
|
|
988
|
+
// Reads from the 1h cache populated by stats-cloud-pull.ts. Silent when:
|
|
989
|
+
// - No pro-token (free tier)
|
|
990
|
+
// - Cache absent or empty (pull hasn't run yet)
|
|
991
|
+
// ---------------------------------------------------------------------------
|
|
992
|
+
|
|
993
|
+
export function renderCrossMachine(_statsHome?: string): string[] {
|
|
994
|
+
// readAggregateCache() checks for pro-token internally.
|
|
995
|
+
// Returns null for free-tier users → silent.
|
|
996
|
+
const data = readAggregateCache();
|
|
997
|
+
if (!data) return [];
|
|
998
|
+
|
|
999
|
+
const machineCount = data.machine_count;
|
|
1000
|
+
const lifetimeTok = data.lifetime_tokens_saved;
|
|
1001
|
+
const lifetimeCalls = data.lifetime_calls;
|
|
1002
|
+
|
|
1003
|
+
const out: string[] = [];
|
|
1004
|
+
out.push(boxTop("cloud (pro · all machines)", DASH_WIDTH));
|
|
1005
|
+
|
|
1006
|
+
const machineStr =
|
|
1007
|
+
machineCount != null
|
|
1008
|
+
? tc(RGB.brandBold, bold(String(machineCount))) + tc(RGB.slate, dim(" machines"))
|
|
1009
|
+
: tc(RGB.slate, dim("machines: —"));
|
|
1010
|
+
|
|
1011
|
+
const tokStr = tc(RGB.brandBold, bold(fmtTokens(lifetimeTok)));
|
|
1012
|
+
const usdStr = tc(RGB.gold, fmtUsd(lifetimeTok));
|
|
1013
|
+
const callStr = tc(RGB.slate, dim(`${lifetimeCalls.toLocaleString()} calls`));
|
|
1014
|
+
|
|
1015
|
+
out.push(
|
|
1016
|
+
boxLine(
|
|
1017
|
+
`lifetime across all machines ${tokStr} ${usdStr} ${callStr}`,
|
|
1018
|
+
DASH_WIDTH,
|
|
1019
|
+
),
|
|
1020
|
+
);
|
|
1021
|
+
if (machineCount != null) {
|
|
1022
|
+
out.push(boxLine(`synced from ${machineStr}`, DASH_WIDTH));
|
|
1023
|
+
}
|
|
1024
|
+
out.push(boxBottom(DASH_WIDTH));
|
|
1025
|
+
return out;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
export function render(stats: Stats | null, statsHome?: string, now: Date = new Date()): string {
|
|
1029
|
+
if (!stats) return renderNoData();
|
|
1030
|
+
|
|
1031
|
+
const parts: string[] = [];
|
|
1032
|
+
parts.push(...renderBanner());
|
|
1033
|
+
parts.push("");
|
|
1034
|
+
// Today-vs-yesterday one-liner — lives directly below the banner so it's the
|
|
1035
|
+
// first data point the eye catches, above the tile strip and per-tool bars.
|
|
1036
|
+
// Renders "" (and is therefore filtered out) when the numbers are quiet.
|
|
1037
|
+
const tvy = renderTodayVsYesterday(stats.lifetime?.byDay ?? {}, now);
|
|
1038
|
+
if (tvy) {
|
|
1039
|
+
parts.push(tvy);
|
|
1040
|
+
parts.push("");
|
|
1041
|
+
}
|
|
1042
|
+
parts.push(...renderTileStrip(stats));
|
|
1043
|
+
const crossMachine = renderCrossMachine(statsHome);
|
|
1044
|
+
if (crossMachine.length > 0) {
|
|
1045
|
+
parts.push(...crossMachine);
|
|
1046
|
+
}
|
|
1047
|
+
parts.push(...renderBarChart(stats));
|
|
1048
|
+
parts.push(divider());
|
|
1049
|
+
// Track G: "Where savings come from" + "Adoption funnel" — rendered after
|
|
1050
|
+
// "by tool" and before "last 7 days" so mechanism breakdown sits near the
|
|
1051
|
+
// bar chart that inspired the question.
|
|
1052
|
+
parts.push(...renderSavingsMechanisms(statsHome));
|
|
1053
|
+
parts.push(divider());
|
|
1054
|
+
parts.push(...renderAdoptionFunnel(statsHome));
|
|
1055
|
+
parts.push(divider());
|
|
1056
|
+
parts.push(...renderSparklines(stats));
|
|
1057
|
+
parts.push(divider());
|
|
1058
|
+
parts.push(...renderProjection(stats, now));
|
|
1059
|
+
parts.push(divider());
|
|
1060
|
+
parts.push(...renderTopProjects(statsHome));
|
|
1061
|
+
const hookPerf = renderHookPerformance(statsHome);
|
|
1062
|
+
if (hookPerf.length > 0) {
|
|
1063
|
+
parts.push(divider());
|
|
1064
|
+
parts.push(...hookPerf);
|
|
1065
|
+
}
|
|
1066
|
+
const nudge = renderNudge(statsHome);
|
|
1067
|
+
if (nudge.length > 0) {
|
|
1068
|
+
parts.push(divider());
|
|
1069
|
+
parts.push(...nudge);
|
|
1070
|
+
}
|
|
1071
|
+
const streakSection = renderStreakSection(statsHome);
|
|
1072
|
+
if (streakSection.length > 0) {
|
|
1073
|
+
parts.push(divider());
|
|
1074
|
+
parts.push(...streakSection);
|
|
1075
|
+
}
|
|
1076
|
+
parts.push("");
|
|
1077
|
+
const priceNow = _pricing();
|
|
1078
|
+
const modelNow = _pricingModel();
|
|
1079
|
+
// v1.18 ratio line: `saved / rawTotal` — only shown when rawTotal > 0.
|
|
1080
|
+
// Legacy stats.json files lack `rawTotal`; we surface "—" rather than a
|
|
1081
|
+
// fake 100% so the user sees the fix honestly.
|
|
1082
|
+
const life = stats.lifetime;
|
|
1083
|
+
const rawTot = life?.rawTotal ?? 0;
|
|
1084
|
+
const savedTot = life?.tokensSaved ?? 0;
|
|
1085
|
+
const ratioStr = rawTot > 0
|
|
1086
|
+
? `${Math.round((savedTot / rawTot) * 100)}%`
|
|
1087
|
+
: "—";
|
|
1088
|
+
// Keep this footer ≤ 80 visible cols. Abbreviate STATS_PATH to ~/.ashlr/...
|
|
1089
|
+
// when it lives under $HOME so long home-dir paths don't blow the budget.
|
|
1090
|
+
const h = process.env.HOME ?? homedir();
|
|
1091
|
+
const shortPath = STATS_PATH.startsWith(h) ? "~" + STATS_PATH.slice(h.length) : STATS_PATH;
|
|
1092
|
+
parts.push(tc(RGB.slate, dim(
|
|
1093
|
+
` ${shortPath} · ${modelNow} $${priceNow.inUsd}/M · saved/raw ${ratioStr}`,
|
|
1094
|
+
)));
|
|
1095
|
+
parts.push(tc(RGB.slate, "─".repeat(DASH_WIDTH)));
|
|
1096
|
+
|
|
1097
|
+
return parts.join("\n");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// ---------------------------------------------------------------------------
|
|
1101
|
+
// Handoff mode — context-pack a fresh session can ingest cold
|
|
1102
|
+
// ---------------------------------------------------------------------------
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Plain-text context-pack designed to be pasted into a fresh Claude Code
|
|
1106
|
+
* session so the next agent can resume with full context. No ANSI; ASCII
|
|
1107
|
+
* tables only. Intentionally short (~30 lines) so a human can scan it
|
|
1108
|
+
* quickly before pasting.
|
|
1109
|
+
*/
|
|
1110
|
+
export function renderHandoff(stats: Stats | null, opts: { cwd?: string; statsHome?: string } = {}): string {
|
|
1111
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1112
|
+
const lines: string[] = [];
|
|
1113
|
+
|
|
1114
|
+
lines.push("# ashlr handoff — paste into next session for cold-start context");
|
|
1115
|
+
lines.push("");
|
|
1116
|
+
lines.push(`Generated: ${new Date().toISOString()}`);
|
|
1117
|
+
lines.push(`Working dir: ${cwd}`);
|
|
1118
|
+
|
|
1119
|
+
// --- Git state (best-effort; per-command failures are swallowed) ---------
|
|
1120
|
+
// Cap stdout buffer at 4 MB so a `git status --porcelain` against a repo
|
|
1121
|
+
// with thousands of untracked paths (e.g. an unignored node_modules) can't
|
|
1122
|
+
// throw ERR_CHILD_PROCESS_STDIO_MAXBUFFER and wipe the partial state we'd
|
|
1123
|
+
// already printed.
|
|
1124
|
+
const run = (cmd: string): string => {
|
|
1125
|
+
try {
|
|
1126
|
+
return execSync(cmd, {
|
|
1127
|
+
cwd,
|
|
1128
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1129
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
1130
|
+
}).toString().trim();
|
|
1131
|
+
} catch {
|
|
1132
|
+
return "";
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
const branch = run("git rev-parse --abbrev-ref HEAD");
|
|
1136
|
+
const dirty = run("git status --porcelain").split("\n").filter(Boolean).length;
|
|
1137
|
+
const last5 = run("git log --oneline -5");
|
|
1138
|
+
if (branch) {
|
|
1139
|
+
lines.push(`Branch: ${branch}${dirty > 0 ? ` (${dirty} uncommitted file${dirty === 1 ? "" : "s"})` : ""}`);
|
|
1140
|
+
}
|
|
1141
|
+
if (last5) {
|
|
1142
|
+
lines.push("");
|
|
1143
|
+
lines.push("Recent commits:");
|
|
1144
|
+
for (const l of last5.split("\n")) lines.push(` ${l}`);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// --- Genome state --------------------------------------------------------
|
|
1148
|
+
const genomePath = join(cwd, ".ashlrcode", "genome");
|
|
1149
|
+
lines.push("");
|
|
1150
|
+
lines.push(`Genome: ${existsSync(genomePath) ? "present at .ashlrcode/genome/" : "not initialized (/ashlr-genome-init to add)"}`);
|
|
1151
|
+
|
|
1152
|
+
// --- Session activity (top tools + top projects) -------------------------
|
|
1153
|
+
if (stats?.session) {
|
|
1154
|
+
const calls = stats.session.calls ?? 0;
|
|
1155
|
+
const saved = stats.session.tokensSaved ?? 0;
|
|
1156
|
+
lines.push("");
|
|
1157
|
+
lines.push(`Session: ${calls.toLocaleString()} call${calls === 1 ? "" : "s"} · ${saved.toLocaleString()} tokens saved`);
|
|
1158
|
+
|
|
1159
|
+
const byTool = stats.session.byTool ?? {};
|
|
1160
|
+
const tools = Object.entries(byTool)
|
|
1161
|
+
.map(([name, v]) => ({ name, calls: v?.calls ?? 0, saved: v?.tokensSaved ?? 0 }))
|
|
1162
|
+
.filter((t) => t.calls > 0)
|
|
1163
|
+
.sort((a, b) => b.calls - a.calls)
|
|
1164
|
+
.slice(0, 5);
|
|
1165
|
+
if (tools.length > 0) {
|
|
1166
|
+
lines.push("");
|
|
1167
|
+
lines.push("Top tools this session:");
|
|
1168
|
+
for (const t of tools) {
|
|
1169
|
+
lines.push(` ${t.name.padEnd(24)} ${String(t.calls).padStart(5)} call${t.calls === 1 ? "" : "s"} · ${t.saved.toLocaleString()} saved`);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
try {
|
|
1175
|
+
const projects = buildTopProjects(opts.statsHome).slice(0, 3);
|
|
1176
|
+
if (projects.length > 0) {
|
|
1177
|
+
lines.push("");
|
|
1178
|
+
lines.push("Top projects (recent):");
|
|
1179
|
+
for (const p of projects) {
|
|
1180
|
+
lines.push(
|
|
1181
|
+
` ${p.name.padEnd(40)} ${p.calls.toLocaleString()} call${p.calls === 1 ? "" : "s"} · ${p.toolVariety} tool${p.toolVariety === 1 ? "" : "s"}`,
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
} catch {
|
|
1186
|
+
/* session-log absent or unreadable — skip silently */
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
lines.push("");
|
|
1190
|
+
lines.push("Tip: run /ashlr-savings or /ashlr-dashboard for the rich view.");
|
|
1191
|
+
return lines.join("\n");
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ---------------------------------------------------------------------------
|
|
1195
|
+
// Watch mode
|
|
1196
|
+
// ---------------------------------------------------------------------------
|
|
1197
|
+
|
|
1198
|
+
const WATCH_INTERVAL_MS = 1500;
|
|
1199
|
+
|
|
1200
|
+
function clearScreen(): void {
|
|
1201
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function watchMode(statsPath: string): Promise<void> {
|
|
1205
|
+
// Skip watch when not a TTY
|
|
1206
|
+
if (!process.stdin.isTTY) {
|
|
1207
|
+
const stats = loadStats(statsPath);
|
|
1208
|
+
process.stdout.write(render(stats) + "\n");
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Initial render
|
|
1213
|
+
clearScreen();
|
|
1214
|
+
process.stdout.write(render(loadStats(statsPath)) + "\n");
|
|
1215
|
+
|
|
1216
|
+
const interval = setInterval(() => {
|
|
1217
|
+
clearScreen();
|
|
1218
|
+
process.stdout.write(render(loadStats(statsPath)) + "\n");
|
|
1219
|
+
}, WATCH_INTERVAL_MS);
|
|
1220
|
+
|
|
1221
|
+
// Clean exit on Ctrl-C
|
|
1222
|
+
process.on("SIGINT", () => {
|
|
1223
|
+
clearInterval(interval);
|
|
1224
|
+
process.stdout.write("\n");
|
|
1225
|
+
process.exit(0);
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
// Also exit cleanly if stdin closes (non-interactive pipe)
|
|
1229
|
+
process.stdin.resume();
|
|
1230
|
+
process.stdin.on("close", () => {
|
|
1231
|
+
clearInterval(interval);
|
|
1232
|
+
process.exit(0);
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// ---------------------------------------------------------------------------
|
|
1237
|
+
// Entry point
|
|
1238
|
+
// ---------------------------------------------------------------------------
|
|
1239
|
+
|
|
1240
|
+
if (import.meta.main) {
|
|
1241
|
+
const watch = process.argv.includes("--watch");
|
|
1242
|
+
const handoff = process.argv.includes("--handoff");
|
|
1243
|
+
try {
|
|
1244
|
+
if (handoff) {
|
|
1245
|
+
const stats = loadStats();
|
|
1246
|
+
process.stdout.write(renderHandoff(stats) + "\n");
|
|
1247
|
+
} else if (watch) {
|
|
1248
|
+
await watchMode(STATS_PATH);
|
|
1249
|
+
} else {
|
|
1250
|
+
const stats = loadStats();
|
|
1251
|
+
process.stdout.write(render(stats) + "\n");
|
|
1252
|
+
}
|
|
1253
|
+
} catch (err) {
|
|
1254
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1255
|
+
process.stdout.write(tc(RGB.red, "ashlr dashboard failed: ") + msg + "\n");
|
|
1256
|
+
}
|
|
1257
|
+
if (!watch) process.exit(0);
|
|
1258
|
+
}
|