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,1135 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* ashlr-efficiency MCP server.
|
|
4
|
+
*
|
|
5
|
+
* Exposes token-efficient replacements for Claude Code's built-in file tools:
|
|
6
|
+
* - ashlr__read — snipCompact on file contents > 2KB
|
|
7
|
+
* - ashlr__grep — genome-aware retrieval when .ashlrcode/genome/ exists,
|
|
8
|
+
* ripgrep fallback otherwise
|
|
9
|
+
* - ashlr__edit — diff-format edits that avoid sending full file contents
|
|
10
|
+
*
|
|
11
|
+
* Also tracks estimated tokens saved, persisted at ~/.ashlr/stats.json.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
15
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import {
|
|
17
|
+
CallToolRequestSchema,
|
|
18
|
+
ListToolsRequestSchema,
|
|
19
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
20
|
+
import { readFile, writeFile } from "fs/promises";
|
|
21
|
+
import { resolve } from "path";
|
|
22
|
+
import { runWithTimeout } from "./_run-with-timeout";
|
|
23
|
+
|
|
24
|
+
import { statSync } from "fs";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
estimateTokensFromString,
|
|
28
|
+
formatGenomeForPrompt,
|
|
29
|
+
genomeExists,
|
|
30
|
+
type Message,
|
|
31
|
+
snipCompact,
|
|
32
|
+
} from "@ashlr/core-efficiency";
|
|
33
|
+
import { retrieveCached } from "./_genome-cache";
|
|
34
|
+
import { refreshGenomeAfterEdit } from "./_genome-live";
|
|
35
|
+
import { getGenomeSearchIndex } from "./_genome-search";
|
|
36
|
+
|
|
37
|
+
import { summarizeIfLarge, PROMPTS, confidenceBadge, confidenceTier } from "./_summarize";
|
|
38
|
+
import { logEvent } from "./_events";
|
|
39
|
+
import { findParentGenome } from "../scripts/genome-link";
|
|
40
|
+
import { getCalibrationMultiplier } from "../scripts/read-calibration";
|
|
41
|
+
import {
|
|
42
|
+
readStats,
|
|
43
|
+
readCurrentSession,
|
|
44
|
+
recordSaving,
|
|
45
|
+
type LifetimeBucket,
|
|
46
|
+
type SessionBucket,
|
|
47
|
+
} from "./_stats";
|
|
48
|
+
import { clampToCwd } from "./_cwd-clamp";
|
|
49
|
+
import { getEmbeddingCache } from "./_tool-base";
|
|
50
|
+
import { embed, upsertCorpus } from "./_embedding-model";
|
|
51
|
+
import { populateGenomeEmbeddings } from "./_genome-embed-populator";
|
|
52
|
+
import { createHash } from "crypto";
|
|
53
|
+
import { currentSessionId } from "./_stats";
|
|
54
|
+
import {
|
|
55
|
+
buildTopProjects,
|
|
56
|
+
readCalibrationState,
|
|
57
|
+
renderPerProjectSection,
|
|
58
|
+
renderBestDaySection,
|
|
59
|
+
renderCalibrationLine,
|
|
60
|
+
renderNudgeSection,
|
|
61
|
+
renderProUpsellHint,
|
|
62
|
+
type ExtraContext,
|
|
63
|
+
} from "../scripts/savings-report-extras";
|
|
64
|
+
// Shared with /ashlr-dashboard so the two surfaces agree on the today-vs-yesterday
|
|
65
|
+
// callout (parity gap flagged by Agent E in v1.18.1). The dashboard module is
|
|
66
|
+
// pure — importing it does not trigger any side effects on the MCP server path.
|
|
67
|
+
import {
|
|
68
|
+
BANNER_GRADIENT,
|
|
69
|
+
bold,
|
|
70
|
+
renderTodayVsYesterday,
|
|
71
|
+
tc,
|
|
72
|
+
TRUECOLOR,
|
|
73
|
+
} from "../scripts/savings-dashboard";
|
|
74
|
+
import { readNudgeSummary } from "./_nudge-events";
|
|
75
|
+
import { isProSync } from "./_pro";
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Embedding cache — shared process-wide via _tool-base.getEmbeddingCache()
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/** Stable 8-char project hash from absolute cwd path. */
|
|
82
|
+
function projectHash(cwd: string): string {
|
|
83
|
+
return createHash("sha256").update(cwd).digest("hex").slice(0, 8);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Cosine similarity threshold for a cache hit to prepend results.
|
|
88
|
+
*
|
|
89
|
+
* Baseline 0.68 (down from 0.75 in v1.12) — calibrated against the 24-doc
|
|
90
|
+
* BM25 corpus where IDF weights are nearly flat and real similarity scores
|
|
91
|
+
* cluster lower than classic dense-embedding expectations. Override via
|
|
92
|
+
* `ASHLR_EMBED_THRESHOLD` (float in [0, 1]). A/B data flows to
|
|
93
|
+
* `~/.ashlr/embed-calibration.jsonl` (see recordEmbedCalibration below).
|
|
94
|
+
*/
|
|
95
|
+
const EMBED_HIT_THRESHOLD = (() => {
|
|
96
|
+
const raw = process.env.ASHLR_EMBED_THRESHOLD;
|
|
97
|
+
if (!raw) return 0.68;
|
|
98
|
+
const n = Number.parseFloat(raw);
|
|
99
|
+
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.68;
|
|
100
|
+
})();
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Minimum BM25 corpus size before the embedding cache is consulted. Below
|
|
104
|
+
* this threshold, IDF weights are dominated by noise and nearly any query
|
|
105
|
+
* produces a spurious hit — see v1.17 incident where a 6-doc corpus emitted
|
|
106
|
+
* 0.7+ cosine hits on unrelated patterns.
|
|
107
|
+
*
|
|
108
|
+
* v1.18 Trust Pass: if docCount < THIS, the cache is skipped outright.
|
|
109
|
+
* Reads `~/.ashlr/embed-corpus.json` directly (the writer lives in
|
|
110
|
+
* `_embedding-model.ts`) so we don't have to export a new function.
|
|
111
|
+
*/
|
|
112
|
+
const BM25_CORPUS_MIN = 50;
|
|
113
|
+
|
|
114
|
+
function readCorpusDocCount(): number {
|
|
115
|
+
try {
|
|
116
|
+
const { readFileSync, existsSync } = require("fs") as typeof import("fs");
|
|
117
|
+
const { join } = require("path") as typeof import("path");
|
|
118
|
+
const { homedir } = require("os") as typeof import("os");
|
|
119
|
+
const p = join(process.env.HOME ?? homedir(), ".ashlr", "embed-corpus.json");
|
|
120
|
+
if (!existsSync(p)) return 0;
|
|
121
|
+
const raw = readFileSync(p, "utf-8");
|
|
122
|
+
const parsed = JSON.parse(raw) as { docCount?: number };
|
|
123
|
+
return typeof parsed.docCount === "number" && Number.isFinite(parsed.docCount) ? parsed.docCount : 0;
|
|
124
|
+
} catch {
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Lightweight calibration log so we can tune EMBED_HIT_THRESHOLD from real data
|
|
130
|
+
// instead of guesses. Appends one JSONL record per grep call; fire-and-forget.
|
|
131
|
+
async function recordEmbedCalibration(record: {
|
|
132
|
+
queryHashHex: string;
|
|
133
|
+
topSimilarity: number;
|
|
134
|
+
hit: boolean;
|
|
135
|
+
contentLength: number;
|
|
136
|
+
threshold: number;
|
|
137
|
+
}): Promise<void> {
|
|
138
|
+
try {
|
|
139
|
+
const { homedir } = await import("os");
|
|
140
|
+
const { appendFile, mkdir } = await import("fs/promises");
|
|
141
|
+
const { dirname, join } = await import("path");
|
|
142
|
+
const path = join(process.env.HOME ?? homedir(), ".ashlr", "embed-calibration.jsonl");
|
|
143
|
+
await mkdir(dirname(path), { recursive: true }).catch(() => undefined);
|
|
144
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...record }) + "\n";
|
|
145
|
+
await appendFile(path, line, "utf-8");
|
|
146
|
+
} catch {
|
|
147
|
+
// Calibration log is observability, not critical path.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Pricing (used by the savings display — not by accounting)
|
|
153
|
+
//
|
|
154
|
+
// v1.18: imports from ./_pricing.ts so the efficiency-server renderer and
|
|
155
|
+
// scripts/savings-dashboard.ts resolve the same token count to the same
|
|
156
|
+
// dollar value. Prior to v1.18 these diverged ($3 vs $5 blended).
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
type ToolName = "ashlr__read" | "ashlr__grep" | "ashlr__edit" | "ashlr__sql" | "ashlr__bash";
|
|
160
|
+
|
|
161
|
+
import { pricing as _pricing, costFor as _costFor, pricingModel as _pricingModel, PRICING_TABLE } from "./_pricing";
|
|
162
|
+
|
|
163
|
+
// Re-export under the prior public name for any downstream importers. Shape
|
|
164
|
+
// intentionally matches the old `{ input, output }` record so generate-badge
|
|
165
|
+
// and the test assertion in generate-badge.test.ts keep working.
|
|
166
|
+
export const PRICING: Record<string, { input: number; output: number }> = Object.fromEntries(
|
|
167
|
+
Object.entries(PRICING_TABLE).map(([k, v]) => [k, { input: v.inUsd, output: v.outUsd }]),
|
|
168
|
+
);
|
|
169
|
+
function pricingModel(): string { return _pricingModel(); }
|
|
170
|
+
function costFor(tokens: number, model?: string): number { return _costFor(tokens, model); }
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Savings report rendering
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
function formatAge(iso: string): string {
|
|
177
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
178
|
+
if (ms < 0 || !Number.isFinite(ms)) return "just now";
|
|
179
|
+
const s = Math.floor(ms / 1000);
|
|
180
|
+
if (s < 60) return `${s}s ago`;
|
|
181
|
+
const m = Math.floor(s / 60);
|
|
182
|
+
if (m < 60) return `${m}m ago`;
|
|
183
|
+
const h = Math.floor(m / 60);
|
|
184
|
+
const rm = m % 60;
|
|
185
|
+
if (h < 24) return `${h}h ${rm}m ago`;
|
|
186
|
+
const d = Math.floor(h / 24);
|
|
187
|
+
return `${d}d ${h % 24}h ago`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function fmtCost(tokens: number): string {
|
|
191
|
+
const c = costFor(tokens);
|
|
192
|
+
if (c < 0.01) return `≈ $${c.toFixed(4)}`;
|
|
193
|
+
return `≈ $${c.toFixed(2)}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function bar(value: number, max: number, width = 12): string {
|
|
197
|
+
if (max <= 0 || value <= 0) return "";
|
|
198
|
+
const n = Math.max(0, Math.min(width, Math.round((value / max) * width)));
|
|
199
|
+
return "█".repeat(n);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function pct(value: number, total: number): string {
|
|
203
|
+
if (total <= 0) return "0%";
|
|
204
|
+
return `${Math.round((value / total) * 100)}%`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function lastNDays(n: number): string[] {
|
|
208
|
+
const out: string[] = [];
|
|
209
|
+
const now = new Date();
|
|
210
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
211
|
+
const d = new Date(now);
|
|
212
|
+
d.setUTCDate(d.getUTCDate() - i);
|
|
213
|
+
out.push(d.toISOString().slice(0, 10));
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Banner displayed at the top of every /ashlr-savings report.
|
|
219
|
+
// Each line stays under 80 visible chars (test enforces).
|
|
220
|
+
//
|
|
221
|
+
// 5-row block-letter "ashlr" hero with a tagline below. Letterforms are
|
|
222
|
+
// hand-drawn for readability of LOWERCASE shapes \u2014 the previous attempts
|
|
223
|
+
// (block-letter or bracket-frame) either rendered as garbled glyphs or felt
|
|
224
|
+
// too small. This version uses variable letter widths (a/s/h/r = 4 cols,
|
|
225
|
+
// l = 1 col) like real type, and density blocks (\u2593\u2592\u2591) for shading.
|
|
226
|
+
//
|
|
227
|
+
// `r` is intentionally an OPEN HOOK \u2014 vertical stem on the left, small
|
|
228
|
+
// terminal on the right, no closing bar. The closing bar in the previous
|
|
229
|
+
// design (\u2584\u2584\u2584\u2584 / \u2588\u2591\u2591\u2591\u2588) read as `P`, not `r`.
|
|
230
|
+
//
|
|
231
|
+
// renderColoredBanner() applies a top-to-bottom row gradient when truecolor
|
|
232
|
+
// is supported (#7cffd6 \u2192 #00d09c \u2192 #008c64); plain text fallback for
|
|
233
|
+
// NO_COLOR / non-TTY / piped output.
|
|
234
|
+
export const SAVINGS_BANNER = [
|
|
235
|
+
" \u2584\u2593\u2593\u2584 \u2584\u2593\u2593\u2584 \u2588 \u2588 \u2584\u2593\u2593\u2592",
|
|
236
|
+
" \u2593\u2591\u2591\u2593 \u2593\u2591\u2591\u2591 \u2588 \u2588 \u2588\u2591\u2591\u2592",
|
|
237
|
+
" \u2593\u2593\u2593\u2593 \u2591\u2593\u2593\u2592 \u2588\u2593\u2593\u2592 \u2588 \u2588",
|
|
238
|
+
" \u2593\u2591\u2591\u2593 \u2591\u2591\u2591\u2593 \u2588\u2591\u2591\u2593 \u2588 \u2588",
|
|
239
|
+
" \u2580\u2591\u2591\u2580 \u2580\u2593\u2593\u2580 \u2580\u2591\u2591\u2580 \u2580 \u2580",
|
|
240
|
+
"",
|
|
241
|
+
" \u2593\u2591 token-efficient file tools \u2591\u2593",
|
|
242
|
+
].join("\n");
|
|
243
|
+
|
|
244
|
+
const SLATE_DIM: readonly [number, number, number] = [120, 130, 145];
|
|
245
|
+
|
|
246
|
+
/** Render SAVINGS_BANNER with a per-row vertical color gradient. Falls back
|
|
247
|
+
* to the plain banner when truecolor is unavailable. Reuses the shared
|
|
248
|
+
* gradient stops + ANSI helpers from scripts/savings-dashboard.ts so the
|
|
249
|
+
* savings hero can never visually drift from the dashboard hero. */
|
|
250
|
+
export function renderColoredBanner(): string {
|
|
251
|
+
if (!TRUECOLOR) return SAVINGS_BANNER;
|
|
252
|
+
return SAVINGS_BANNER.split("\n")
|
|
253
|
+
.map((line, i) => {
|
|
254
|
+
if (i < BANNER_GRADIENT.length) return bold(tc(BANNER_GRADIENT[i]!, line));
|
|
255
|
+
if (line.includes("token-efficient")) return tc(SLATE_DIM, line);
|
|
256
|
+
return line;
|
|
257
|
+
})
|
|
258
|
+
.join("\n");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function renderSavings(session: SessionBucket, lifetime: LifetimeBucket, extra?: ExtraContext): string {
|
|
262
|
+
const model = pricingModel();
|
|
263
|
+
const lines: string[] = [];
|
|
264
|
+
lines.push(renderColoredBanner());
|
|
265
|
+
lines.push("");
|
|
266
|
+
lines.push(`ashlr savings · session started ${formatAge(session.startedAt)} · model ${model}`);
|
|
267
|
+
lines.push("");
|
|
268
|
+
// Summary columns
|
|
269
|
+
const sLabel = ` calls ${session.calls}`;
|
|
270
|
+
const lLabel = `calls ${lifetime.calls}`;
|
|
271
|
+
const sSaved = ` saved ${session.tokensSaved.toLocaleString()} tok`;
|
|
272
|
+
const lSaved = `saved ${lifetime.tokensSaved.toLocaleString()} tok`;
|
|
273
|
+
const sCost = ` cost ${fmtCost(session.tokensSaved)}`;
|
|
274
|
+
const lCost = `cost ${fmtCost(lifetime.tokensSaved)}`;
|
|
275
|
+
lines.push(`this session all-time`);
|
|
276
|
+
const pad = (s: string, w: number) => s + " ".repeat(Math.max(1, w - s.length));
|
|
277
|
+
lines.push(pad(sLabel, 25) + lLabel);
|
|
278
|
+
lines.push(pad(sSaved, 25) + lSaved);
|
|
279
|
+
lines.push(pad(sCost, 25) + lCost);
|
|
280
|
+
lines.push("");
|
|
281
|
+
|
|
282
|
+
// Today-vs-yesterday one-liner — shared with /ashlr-dashboard so the two
|
|
283
|
+
// surfaces agree on when to celebrate a pace bump (or flag a slower day).
|
|
284
|
+
// Returns "" (and is then skipped with no trailing blank) when quiet.
|
|
285
|
+
const tvy = renderTodayVsYesterday(lifetime.byDay ?? {});
|
|
286
|
+
if (tvy) {
|
|
287
|
+
lines.push(tvy);
|
|
288
|
+
lines.push("");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// By tool (session) — iterate whatever tools actually fired this session.
|
|
292
|
+
lines.push("by tool (session):");
|
|
293
|
+
const entries = Object.entries(session.byTool)
|
|
294
|
+
.map(([name, pt]) => ({ name, calls: pt.calls, tokensSaved: pt.tokensSaved }))
|
|
295
|
+
.filter((e) => e.calls > 0 || e.tokensSaved > 0)
|
|
296
|
+
.sort((a, b) => b.tokensSaved - a.tokensSaved);
|
|
297
|
+
if (entries.length === 0) {
|
|
298
|
+
lines.push(" (no calls yet this session)");
|
|
299
|
+
} else {
|
|
300
|
+
const maxTok = Math.max(...entries.map((e) => e.tokensSaved), 1);
|
|
301
|
+
const totalTok = entries.reduce((s, e) => s + e.tokensSaved, 0);
|
|
302
|
+
for (const e of entries) {
|
|
303
|
+
const name = e.name.padEnd(14);
|
|
304
|
+
const calls = `${e.calls} call${e.calls === 1 ? " " : "s"}`.padEnd(10);
|
|
305
|
+
const tok = `${e.tokensSaved.toLocaleString()} tok`.padEnd(13);
|
|
306
|
+
lines.push(` ${name}${calls}${tok}${bar(e.tokensSaved, maxTok).padEnd(13)}${pct(e.tokensSaved, totalTok)}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
lines.push("");
|
|
310
|
+
|
|
311
|
+
// Last 7 days
|
|
312
|
+
lines.push("last 7 days:");
|
|
313
|
+
const days = lastNDays(7);
|
|
314
|
+
const dayVals = days.map((d) => ({ d, v: lifetime.byDay[d]?.tokensSaved ?? 0 }));
|
|
315
|
+
const maxDay = Math.max(...dayVals.map((x) => x.v), 1);
|
|
316
|
+
for (const { d, v } of dayVals) {
|
|
317
|
+
const label = d.slice(5); // MM-DD
|
|
318
|
+
const b = v === 0 ? "(quiet) " : bar(v, maxDay, 20).padEnd(20);
|
|
319
|
+
const val = v === 0 ? " 0" : v.toLocaleString();
|
|
320
|
+
lines.push(` ${label} ${b} ${val}`);
|
|
321
|
+
}
|
|
322
|
+
lines.push("");
|
|
323
|
+
|
|
324
|
+
// Last 30 days rollup. The 7-day view above shows *shape*; this block shows
|
|
325
|
+
// the *totals* — calls, tokens, dollars — plus the single best day. They're
|
|
326
|
+
// complementary: sparkline answers "when did I work?", rollup answers "how
|
|
327
|
+
// much did I save?".
|
|
328
|
+
lines.push("last 30 days:");
|
|
329
|
+
const monthDays = lastNDays(30);
|
|
330
|
+
const activeEntries = monthDays
|
|
331
|
+
.map((d) => ({ d, entry: lifetime.byDay[d] }))
|
|
332
|
+
.filter((x) => x.entry && (x.entry.calls > 0 || x.entry.tokensSaved > 0)) as Array<{
|
|
333
|
+
d: string;
|
|
334
|
+
entry: { calls: number; tokensSaved: number };
|
|
335
|
+
}>;
|
|
336
|
+
|
|
337
|
+
// Require at least 2 distinct active days before claiming a "monthly" rollup;
|
|
338
|
+
// otherwise the number is just "today" dressed up as a month and misleading.
|
|
339
|
+
if (activeEntries.length < 2) {
|
|
340
|
+
lines.push(" (not enough history yet — come back in a few weeks)");
|
|
341
|
+
} else {
|
|
342
|
+
const totalCalls = activeEntries.reduce((s, x) => s + x.entry.calls, 0);
|
|
343
|
+
const totalTok = activeEntries.reduce((s, x) => s + x.entry.tokensSaved, 0);
|
|
344
|
+
const best = activeEntries.reduce((a, b) => (b.entry.tokensSaved > a.entry.tokensSaved ? b : a));
|
|
345
|
+
lines.push(` calls ${totalCalls.toLocaleString()}`);
|
|
346
|
+
lines.push(` saved ${totalTok.toLocaleString()} tok ${fmtCost(totalTok)}`);
|
|
347
|
+
lines.push(
|
|
348
|
+
` best day ${best.d} · ${best.entry.tokensSaved.toLocaleString()} tok · ${best.entry.calls} call${best.entry.calls === 1 ? "" : "s"}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Extra sections (appended; never remove existing ones)
|
|
353
|
+
if (extra?.topProjects && extra.topProjects.length > 0) {
|
|
354
|
+
lines.push("");
|
|
355
|
+
lines.push(renderPerProjectSection(extra.topProjects));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const bestDay = renderBestDaySection(lifetime);
|
|
359
|
+
if (bestDay) {
|
|
360
|
+
lines.push("");
|
|
361
|
+
lines.push(bestDay);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const nudgeSection = renderNudgeSection(extra?.nudgeSummary, extra?.proUser ?? false);
|
|
365
|
+
if (nudgeSection) {
|
|
366
|
+
lines.push("");
|
|
367
|
+
lines.push(nudgeSection);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Pro upsell hint — shown when lifetime savings >= $20 and user is on Free.
|
|
371
|
+
// Sits below nudge telemetry so it appears at the natural "what's next?" spot.
|
|
372
|
+
const proUpsell = renderProUpsellHint(
|
|
373
|
+
extra?.lifetimeDollarsSaved ?? 0,
|
|
374
|
+
extra?.proUser ?? false,
|
|
375
|
+
);
|
|
376
|
+
if (proUpsell) {
|
|
377
|
+
lines.push("");
|
|
378
|
+
lines.push(proUpsell);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
lines.push("");
|
|
382
|
+
const calibLine = renderCalibrationLine(
|
|
383
|
+
extra?.calibrationRatio ?? 4,
|
|
384
|
+
extra?.calibrationPresent ?? false,
|
|
385
|
+
);
|
|
386
|
+
lines.push(calibLine);
|
|
387
|
+
|
|
388
|
+
return lines.join("\n");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Tool impls
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
// Per-process content cache for ashlr__read. Keyed by absolute path; the
|
|
396
|
+
// cached result is only reused when the file's mtimeMs matches — any write
|
|
397
|
+
// (ours via ashlr__edit, or external) invalidates. Lives for the MCP server
|
|
398
|
+
// lifetime, which aligns with a single Claude Code session.
|
|
399
|
+
interface ReadCacheEntry {
|
|
400
|
+
mtimeMs: number;
|
|
401
|
+
/** The exact string we would have returned on a miss. */
|
|
402
|
+
result: string;
|
|
403
|
+
/** Bytes of the original file when cached — for correct savings math on reuse. */
|
|
404
|
+
sourceBytes: number;
|
|
405
|
+
}
|
|
406
|
+
const readCache: Map<string, ReadCacheEntry> = new Map();
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* File extensions treated as code for the line-number-preservation path.
|
|
410
|
+
* When ashlr__read returns a snipCompact-truncated view of one of these files,
|
|
411
|
+
* every preserved line is prefixed with its original line number so Claude can
|
|
412
|
+
* cite `file:line` accurately even across the elided middle.
|
|
413
|
+
*/
|
|
414
|
+
const CODE_EXTENSIONS = new Set([
|
|
415
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
|
416
|
+
".py", ".go", ".rs", ".java", ".kt", ".kts",
|
|
417
|
+
".rb", ".php", ".swift", ".cs", ".scala", ".cpp", ".c", ".h", ".hpp",
|
|
418
|
+
".sh", ".bash", ".zsh", ".fish",
|
|
419
|
+
".sql", ".graphql", ".proto", ".css", ".scss", ".html", ".vue", ".svelte",
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
function isCodeFile(path: string): boolean {
|
|
423
|
+
const m = path.match(/\.[a-zA-Z0-9]+$/);
|
|
424
|
+
if (!m) return false;
|
|
425
|
+
return CODE_EXTENSIONS.has(m[0].toLowerCase());
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Prepend every line with its 1-based line number + ": " so that head/tail
|
|
430
|
+
* fragments surviving snipCompact still carry positional information. Line
|
|
431
|
+
* numbers use right-justified padding so alignment stays consistent across
|
|
432
|
+
* the file (cheap visual affordance when Claude reads the output).
|
|
433
|
+
*/
|
|
434
|
+
function numberCodeLines(source: string): string {
|
|
435
|
+
const lines = source.split("\n");
|
|
436
|
+
const pad = String(lines.length).length;
|
|
437
|
+
return lines.map((line, i) => `${String(i + 1).padStart(pad, " ")}: ${line}`).join("\n");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function ashlrRead(input: { path: string; bypassSummary?: boolean; preserveLineNumbers?: boolean }): Promise<string> {
|
|
441
|
+
const clamp = clampToCwd(input.path, "ashlr__read");
|
|
442
|
+
if (!clamp.ok) return clamp.message;
|
|
443
|
+
const abs = clamp.abs;
|
|
444
|
+
|
|
445
|
+
// Cache hit path: same absolute path + unchanged mtime → return cached
|
|
446
|
+
// result tagged "(cached)" and record full savings (0 bytes emitted to the
|
|
447
|
+
// model beyond the tiny tag, so treat output as ~cache_entry.result.length
|
|
448
|
+
// for the saving calculation just like a miss would).
|
|
449
|
+
let mtimeMs = 0;
|
|
450
|
+
try {
|
|
451
|
+
mtimeMs = statSync(abs).mtimeMs;
|
|
452
|
+
const hit = readCache.get(abs);
|
|
453
|
+
if (hit && hit.mtimeMs === mtimeMs && input.bypassSummary !== true) {
|
|
454
|
+
// On a repeat read we would otherwise have re-paid the full source
|
|
455
|
+
// bytes → recompute path. Credit the original-size saving again since
|
|
456
|
+
// the agent received zero new tokens of file content.
|
|
457
|
+
await recordSaving(hit.sourceBytes, 0, "ashlr__read");
|
|
458
|
+
return `(cached)\n${hit.result}`;
|
|
459
|
+
}
|
|
460
|
+
} catch {
|
|
461
|
+
// If stat fails (broken symlink, perms), fall through to the normal read
|
|
462
|
+
// path which will surface a descriptive error.
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const content = await readFile(abs, "utf-8");
|
|
466
|
+
|
|
467
|
+
// For code files, prepend 1-based line numbers to every line before
|
|
468
|
+
// snipCompact runs so `file:line` citations survive truncation. Override
|
|
469
|
+
// with { preserveLineNumbers: false } to get raw bytes back (e.g., when
|
|
470
|
+
// feeding the output into another tool that doesn't tolerate the prefix).
|
|
471
|
+
const preserveLineNumbers =
|
|
472
|
+
input.preserveLineNumbers ?? isCodeFile(abs);
|
|
473
|
+
const renderedContent = preserveLineNumbers
|
|
474
|
+
? numberCodeLines(content)
|
|
475
|
+
: content;
|
|
476
|
+
|
|
477
|
+
// Wrap as a fake tool_result message so snipCompact has something to snip.
|
|
478
|
+
const msgs: Message[] = [
|
|
479
|
+
{
|
|
480
|
+
role: "user",
|
|
481
|
+
content: [
|
|
482
|
+
{ type: "tool_result", tool_use_id: "ashlr-read", content: renderedContent },
|
|
483
|
+
],
|
|
484
|
+
},
|
|
485
|
+
];
|
|
486
|
+
|
|
487
|
+
const compact = snipCompact(msgs);
|
|
488
|
+
const block = (compact[0]!.content as { type: string; content: string }[])[0]!;
|
|
489
|
+
const out = (block as { content: string }).content;
|
|
490
|
+
|
|
491
|
+
// snipCompact aggressively truncates at 2KB, which throws away the middle of
|
|
492
|
+
// large source files. For files > 16KB, summarize the raw content (the LLM
|
|
493
|
+
// can preserve symbol-level structure snipCompact can't). Small files skip
|
|
494
|
+
// summarization entirely (threshold check inside summarizeIfLarge).
|
|
495
|
+
if (!(renderedContent.length > out.length)) {
|
|
496
|
+
await logEvent("tool_noop", { tool: "ashlr__read", reason: "small-file" });
|
|
497
|
+
}
|
|
498
|
+
const summarizeInput = renderedContent.length > out.length ? renderedContent : out;
|
|
499
|
+
const summarized = await summarizeIfLarge(summarizeInput, {
|
|
500
|
+
toolName: "ashlr__read",
|
|
501
|
+
systemPrompt: PROMPTS.read,
|
|
502
|
+
bypass: input.bypassSummary === true,
|
|
503
|
+
});
|
|
504
|
+
// Fall back to snipCompact output if summarize short-circuited (below threshold).
|
|
505
|
+
const finalText = summarized.summarized || summarized.fellBack || input.bypassSummary ? summarized.text : out;
|
|
506
|
+
const finalBytes = summarized.summarized || summarized.fellBack ? summarized.outputBytes : out.length;
|
|
507
|
+
await recordSaving(content.length, finalBytes, "ashlr__read");
|
|
508
|
+
|
|
509
|
+
const badgeOpts = {
|
|
510
|
+
toolName: "ashlr__read",
|
|
511
|
+
rawBytes: content.length,
|
|
512
|
+
outputBytes: finalBytes,
|
|
513
|
+
fellBack: summarized.fellBack,
|
|
514
|
+
extra: mtimeMs > 0 ? `mtime=${mtimeMs}` : undefined,
|
|
515
|
+
};
|
|
516
|
+
if (confidenceTier(badgeOpts) === "low") {
|
|
517
|
+
await logEvent("tool_low_confidence_shipped", { tool: "ashlr__read", reason: "low-confidence" });
|
|
518
|
+
}
|
|
519
|
+
const badge = confidenceBadge(badgeOpts);
|
|
520
|
+
const finalTextWithBadge = finalText + badge;
|
|
521
|
+
|
|
522
|
+
// Cache the fully computed result for this (path, mtimeMs). Skip caching
|
|
523
|
+
// when bypassSummary was used — that's an opt-out path and shouldn't
|
|
524
|
+
// poison future non-bypass calls.
|
|
525
|
+
if (input.bypassSummary !== true && mtimeMs > 0) {
|
|
526
|
+
readCache.set(abs, { mtimeMs, result: finalTextWithBadge, sourceBytes: content.length });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return finalTextWithBadge;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Resolve rg via Bun.which (walks PATH and common Homebrew locations). Shell
|
|
534
|
+
* aliases like Claude Code's own rg wrapper don't resolve under spawn, so we
|
|
535
|
+
* need the actual binary. Returns "rg" as last resort so spawn can at least
|
|
536
|
+
* surface a useful error.
|
|
537
|
+
*/
|
|
538
|
+
function resolveRg(): string {
|
|
539
|
+
return (
|
|
540
|
+
(typeof (globalThis as { Bun?: { which(bin: string): string | null } }).Bun !== "undefined"
|
|
541
|
+
? (globalThis as { Bun: { which(bin: string): string | null } }).Bun.which("rg")
|
|
542
|
+
: null) ??
|
|
543
|
+
["/opt/homebrew/bin/rg", "/usr/local/bin/rg", "/usr/bin/rg"].find((p) => {
|
|
544
|
+
try {
|
|
545
|
+
require("fs").accessSync(p);
|
|
546
|
+
return true;
|
|
547
|
+
} catch {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
}) ??
|
|
551
|
+
"rg"
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Estimate total matches by shelling out to `rg -c` (count-only, small
|
|
557
|
+
* output). Returns null when rg is unavailable or the call fails — callers
|
|
558
|
+
* should treat null as "unknown" rather than zero.
|
|
559
|
+
*
|
|
560
|
+
* This is the *confidence signal* for genome-routed greps: it lets the
|
|
561
|
+
* caller tell the model "genome returned N sections, rg sees ~M total
|
|
562
|
+
* matches" so an incomplete summary doesn't pass silently. Cost is tiny
|
|
563
|
+
* (single-integer-per-file output) and timeout is short.
|
|
564
|
+
*/
|
|
565
|
+
async function estimateMatchCount(pattern: string, cwd: string): Promise<number | null> {
|
|
566
|
+
try {
|
|
567
|
+
const res = await runWithTimeout({
|
|
568
|
+
command: resolveRg(),
|
|
569
|
+
args: ["-c", pattern, cwd],
|
|
570
|
+
timeoutMs: 3_000,
|
|
571
|
+
});
|
|
572
|
+
if (res.exitCode !== 0 && res.exitCode !== 1) return null; // 1 == no matches
|
|
573
|
+
const out = res.stdout ?? "";
|
|
574
|
+
if (!out.trim()) return 0;
|
|
575
|
+
// `rg -c` output is `path:count` per line.
|
|
576
|
+
let total = 0;
|
|
577
|
+
for (const line of out.split("\n")) {
|
|
578
|
+
const idx = line.lastIndexOf(":");
|
|
579
|
+
if (idx < 0) continue;
|
|
580
|
+
const n = parseInt(line.slice(idx + 1), 10);
|
|
581
|
+
if (Number.isFinite(n)) total += n;
|
|
582
|
+
}
|
|
583
|
+
return total;
|
|
584
|
+
} catch {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export async function ashlrGrep(input: { pattern: string; cwd?: string; bypassSummary?: boolean }): Promise<string> {
|
|
590
|
+
// Clamp input.cwd to process.cwd() — ripgrep spawns below use this path as
|
|
591
|
+
// their search root, so an unclamped caller could exfiltrate /etc, /root,
|
|
592
|
+
// etc. The parent-genome walk-up via findParentGenome() stays legitimate
|
|
593
|
+
// because it only reads .ashlrcode/genome/ metadata in ancestor dirs, not
|
|
594
|
+
// arbitrary files. Refuse outside paths at the top so downstream spawns
|
|
595
|
+
// never see them.
|
|
596
|
+
const clamp = clampToCwd(input.cwd, "ashlr__grep");
|
|
597
|
+
if (!clamp.ok) return clamp.message;
|
|
598
|
+
const cwd = clamp.abs;
|
|
599
|
+
|
|
600
|
+
// Prefer the local genome. If none, walk up to 4 parents (capped at $HOME)
|
|
601
|
+
// looking for a workspace-level genome — e.g. a project under ~/Desktop/
|
|
602
|
+
// can borrow ~/Desktop/.ashlrcode/genome/ when it has nothing of its own.
|
|
603
|
+
let genomeRoot: string | null = null;
|
|
604
|
+
let genomeIsParent = false;
|
|
605
|
+
if (genomeExists(cwd)) {
|
|
606
|
+
genomeRoot = cwd;
|
|
607
|
+
} else {
|
|
608
|
+
const parent = findParentGenome(cwd);
|
|
609
|
+
if (parent) {
|
|
610
|
+
genomeRoot = parent;
|
|
611
|
+
genomeIsParent = true;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (!genomeRoot) {
|
|
616
|
+
await logEvent("tool_fallback", { tool: "ashlr__grep", reason: "no-genome" });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ------------------------------------------------------------------
|
|
620
|
+
// Embedding cache — query before genome retrieval (fire-and-forget
|
|
621
|
+
// upsert on completion; never blocks if cache is disabled or slow).
|
|
622
|
+
// ------------------------------------------------------------------
|
|
623
|
+
const pHash = projectHash(cwd);
|
|
624
|
+
const sessionId = currentSessionId();
|
|
625
|
+
let embedCachePrefix = "";
|
|
626
|
+
// v1.18 Trust Pass: the BM25 IDF corpus produces noisy similarity scores
|
|
627
|
+
// below ~50 docs — we've observed spurious 0.7+ hits on unrelated
|
|
628
|
+
// patterns. Gate the cache read on corpus size so we never serve a
|
|
629
|
+
// false-positive prefix from a thin corpus.
|
|
630
|
+
const corpusSize = readCorpusDocCount();
|
|
631
|
+
const embedCacheEnabled = corpusSize >= BM25_CORPUS_MIN;
|
|
632
|
+
try {
|
|
633
|
+
const ctxDb = getEmbeddingCache();
|
|
634
|
+
|
|
635
|
+
// Populate the cache on first-seen / mtime-bumped manifest so the
|
|
636
|
+
// similarity query below has something to match against. Watermarked,
|
|
637
|
+
// so steady-state grep calls pay only a tiny stat() + JSON read.
|
|
638
|
+
if (genomeRoot) {
|
|
639
|
+
try {
|
|
640
|
+
await populateGenomeEmbeddings(genomeRoot, { ctxDb, projectHash: pHash });
|
|
641
|
+
} catch {
|
|
642
|
+
// Populator must never break grep.
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (embedCacheEnabled) {
|
|
647
|
+
const queryVec = await embed(input.pattern);
|
|
648
|
+
const hits = ctxDb.searchSimilar({ projectHash: pHash, embedding: queryVec, limit: 3 });
|
|
649
|
+
const topHit = hits[0];
|
|
650
|
+
const topSim = topHit?.similarity ?? 0;
|
|
651
|
+
const isHit = Boolean(topHit) && topSim >= EMBED_HIT_THRESHOLD;
|
|
652
|
+
let hitContentLength = 0;
|
|
653
|
+
if (isHit) {
|
|
654
|
+
const hitSections = hits
|
|
655
|
+
.filter((h) => h.similarity >= EMBED_HIT_THRESHOLD)
|
|
656
|
+
.map((h) => `[embedding-cache hit | sim=${h.similarity.toFixed(3)} | ${h.sectionPath}]\n${h.sectionText}`)
|
|
657
|
+
.join("\n\n");
|
|
658
|
+
hitContentLength = hitSections.length;
|
|
659
|
+
const tokensSaved = Math.round(hitContentLength / 4);
|
|
660
|
+
embedCachePrefix = hitSections + "\n\n";
|
|
661
|
+
ctxDb.recordRetrieval({ sessionId, projectHash: pHash, pattern: input.pattern, hit: true, tokensSaved });
|
|
662
|
+
void logEvent("embed_cache_hit", {
|
|
663
|
+
tool: "ashlr__grep",
|
|
664
|
+
extra: { topSimilarity: topSim, sectionsReturned: hits.filter((h) => h.similarity >= EMBED_HIT_THRESHOLD).length, tokensSaved },
|
|
665
|
+
});
|
|
666
|
+
} else {
|
|
667
|
+
ctxDb.recordRetrieval({ sessionId, projectHash: pHash, pattern: input.pattern, hit: false, tokensSaved: 0 });
|
|
668
|
+
void logEvent("embed_cache_miss", {
|
|
669
|
+
tool: "ashlr__grep",
|
|
670
|
+
extra: { topSimilarity: topSim, corpusSize },
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
const queryHashHex = createHash("sha256").update(input.pattern).digest("hex").slice(0, 12);
|
|
674
|
+
void recordEmbedCalibration({
|
|
675
|
+
queryHashHex,
|
|
676
|
+
topSimilarity: topSim,
|
|
677
|
+
hit: isHit,
|
|
678
|
+
contentLength: hitContentLength,
|
|
679
|
+
threshold: EMBED_HIT_THRESHOLD,
|
|
680
|
+
});
|
|
681
|
+
} else {
|
|
682
|
+
// Skip-gated: record a miss with tokensSaved=0 so dashboards still
|
|
683
|
+
// reflect lookup activity. No embedding call, no prefix — so the raw
|
|
684
|
+
// baseline below is unaffected and no false-positive content lands
|
|
685
|
+
// in the response.
|
|
686
|
+
ctxDb.recordRetrieval({ sessionId, projectHash: pHash, pattern: input.pattern, hit: false, tokensSaved: 0 });
|
|
687
|
+
}
|
|
688
|
+
} catch {
|
|
689
|
+
// Embedding cache errors must never break grep. Silently skip.
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (genomeRoot) {
|
|
693
|
+
const sections = await retrieveCached(genomeRoot, input.pattern, 4000);
|
|
694
|
+
if (sections.length === 0) {
|
|
695
|
+
await logEvent("tool_fallback", { tool: "ashlr__grep", reason: "genome-empty" });
|
|
696
|
+
}
|
|
697
|
+
if (sections.length > 0) {
|
|
698
|
+
// Emit genome_route_taken telemetry (fire-and-forget)
|
|
699
|
+
void logEvent("genome_route_taken", {
|
|
700
|
+
tool: "ashlr__grep",
|
|
701
|
+
extra: {
|
|
702
|
+
sectionsRetrieved: sections.length,
|
|
703
|
+
parentNote: genomeIsParent ? genomeRoot : null,
|
|
704
|
+
hadConfidenceLow: false, // updated below if badge fires
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
const formatted = formatGenomeForPrompt(sections);
|
|
708
|
+
// Use empirical multiplier from ~/.ashlr/calibration.json when available;
|
|
709
|
+
// falls back to 4× (hardcoded guess) when no calibration has been run.
|
|
710
|
+
const grepsMultiplier = getCalibrationMultiplier();
|
|
711
|
+
let rawBytesEstimate = formatted.length * grepsMultiplier;
|
|
712
|
+
|
|
713
|
+
// ASHLR_CALIBRATE=1: run real rg --json in parallel to record the TRUE
|
|
714
|
+
// raw bytes. Adds to normal work but never blocks the tool response.
|
|
715
|
+
if (process.env.ASHLR_CALIBRATE === "1") {
|
|
716
|
+
try {
|
|
717
|
+
const calibRes = await runWithTimeout({
|
|
718
|
+
command: resolveRg(),
|
|
719
|
+
args: ["--json", "-n", input.pattern, cwd],
|
|
720
|
+
timeoutMs: 5_000,
|
|
721
|
+
});
|
|
722
|
+
const trueRawBytes = Buffer.byteLength(calibRes.stdout, "utf-8");
|
|
723
|
+
// Never underreport — take the max of empirical and estimated.
|
|
724
|
+
rawBytesEstimate = Math.max(trueRawBytes, formatted.length * grepsMultiplier);
|
|
725
|
+
} catch {
|
|
726
|
+
// Calibration run failed — silently fall through to the estimate.
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// v1.18 Trust Pass invariant: neither side of recordSaving includes
|
|
731
|
+
// `embedCachePrefix`. The cache prefix is response-only decoration;
|
|
732
|
+
// counting its length as "raw bytes saved" would inflate savings on
|
|
733
|
+
// every cache hit because we'd be crediting content we generated, not
|
|
734
|
+
// content the agent would have otherwise fetched.
|
|
735
|
+
await recordSaving(rawBytesEstimate, formatted.length, "ashlr__grep");
|
|
736
|
+
// Run `rg -c` to get an independent estimate of total matches. If genome
|
|
737
|
+
// returned only N sections but ripgrep would find 10× that, the model
|
|
738
|
+
// needs to know it should escalate rather than trust a stale/partial
|
|
739
|
+
// retrieval. This is the fix for the silent-incomplete-genome risk.
|
|
740
|
+
const estimated = await estimateMatchCount(input.pattern, cwd);
|
|
741
|
+
if (estimated !== null && estimated > sections.length * 4) {
|
|
742
|
+
await logEvent("tool_escalate", {
|
|
743
|
+
tool: "ashlr__grep",
|
|
744
|
+
reason: "incomplete-genome",
|
|
745
|
+
extra: { sections: sections.length, estimated },
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
const parentNote = genomeIsParent ? ` (from parent genome at ${genomeRoot})` : "";
|
|
749
|
+
const countNote =
|
|
750
|
+
estimated === null
|
|
751
|
+
? ""
|
|
752
|
+
: ` · rg estimates ${estimated.toLocaleString()} total match${estimated === 1 ? "" : "es"}${
|
|
753
|
+
estimated > sections.length * 4
|
|
754
|
+
? " · call with bypassSummary:true for the full ripgrep list"
|
|
755
|
+
: ""
|
|
756
|
+
}`;
|
|
757
|
+
const header = `[ashlr__grep] genome-retrieved ${sections.length} section(s)${parentNote}${countNote}`;
|
|
758
|
+
const genomeBadgeOpts = {
|
|
759
|
+
toolName: "ashlr__grep",
|
|
760
|
+
rawBytes: Math.round(rawBytesEstimate),
|
|
761
|
+
outputBytes: formatted.length,
|
|
762
|
+
};
|
|
763
|
+
if (confidenceTier(genomeBadgeOpts) === "low") {
|
|
764
|
+
// v1.22 trust fix: previously emitted `tool_noop` even though the
|
|
765
|
+
// compressed content WAS shipped to the caller. That mislabel
|
|
766
|
+
// distorted savings accounting (10-25%) by treating shipped low-
|
|
767
|
+
// confidence output as zero work. Use a dedicated event so the
|
|
768
|
+
// negative signal (consider bypassSummary) is preserved without
|
|
769
|
+
// claiming nothing was returned.
|
|
770
|
+
await logEvent("tool_low_confidence_shipped", { tool: "ashlr__grep", reason: "low-confidence" });
|
|
771
|
+
}
|
|
772
|
+
return embedCachePrefix + `${header}\n\n${formatted}` + confidenceBadge(genomeBadgeOpts);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// v1.27 Track 5: consult the genome-search inverted index before ripgrep.
|
|
777
|
+
// The embedding-cache path above handles semantic-similarity hits; the
|
|
778
|
+
// index handles token-exact matches that BM25/embeddings might miss when
|
|
779
|
+
// the corpus is small or the query is acronym-heavy.
|
|
780
|
+
if (genomeRoot) {
|
|
781
|
+
try {
|
|
782
|
+
const idx = getGenomeSearchIndex(genomeRoot);
|
|
783
|
+
const indexHits = idx.lookup(input.pattern);
|
|
784
|
+
if (indexHits.length >= 3) {
|
|
785
|
+
const formatted = indexHits
|
|
786
|
+
.slice(0, 12)
|
|
787
|
+
.map((h) => `${h.file}:${h.line}: ${h.snippet} // from genome: ${h.section}`)
|
|
788
|
+
.join("\n");
|
|
789
|
+
const moreNote = indexHits.length > 12 ? `\n[... and ${indexHits.length - 12} more genome hits — call with bypassSummary:true to see all]` : "";
|
|
790
|
+
await logEvent("genome_search_hit", {
|
|
791
|
+
tool: "ashlr__grep",
|
|
792
|
+
extra: { results: indexHits.length, source: "index", grep_source: "genome" },
|
|
793
|
+
});
|
|
794
|
+
return embedCachePrefix + `[ashlr-grep | source=genome-search-index | hits=${indexHits.length} | ripgrep skipped]\n\n${formatted}${moreNote}`;
|
|
795
|
+
} else if (indexHits.length === 0) {
|
|
796
|
+
await logEvent("genome_search_miss", {
|
|
797
|
+
tool: "ashlr__grep",
|
|
798
|
+
extra: { grep_source: "ripgrep" },
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
// 1-2 hits: continue to ripgrep — too few to confidently skip the full
|
|
802
|
+
// search. Hybrid merge is a v1.28 follow-up.
|
|
803
|
+
} catch {
|
|
804
|
+
// Index failure must never break grep.
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const rgBin = resolveRg();
|
|
809
|
+
|
|
810
|
+
const res = await runWithTimeout({
|
|
811
|
+
command: rgBin,
|
|
812
|
+
args: ["--json", "-n", input.pattern, cwd],
|
|
813
|
+
timeoutMs: 15_000,
|
|
814
|
+
});
|
|
815
|
+
const raw = res.stdout ?? "";
|
|
816
|
+
const truncated = raw.length > 4000 ? raw.slice(0, 2000) + "\n\n[... truncated ...]\n\n" + raw.slice(-1000) : raw;
|
|
817
|
+
const summarized = await summarizeIfLarge(truncated, {
|
|
818
|
+
toolName: "ashlr__grep",
|
|
819
|
+
systemPrompt: PROMPTS.grep,
|
|
820
|
+
bypass: input.bypassSummary === true,
|
|
821
|
+
});
|
|
822
|
+
await recordSaving(raw.length, summarized.outputBytes, "ashlr__grep");
|
|
823
|
+
const rgBadgeOpts = {
|
|
824
|
+
toolName: "ashlr__grep",
|
|
825
|
+
rawBytes: raw.length,
|
|
826
|
+
outputBytes: summarized.outputBytes,
|
|
827
|
+
fellBack: summarized.fellBack,
|
|
828
|
+
};
|
|
829
|
+
if (confidenceTier(rgBadgeOpts) === "low") {
|
|
830
|
+
// v1.22 trust fix: see paired comment above on the genome path.
|
|
831
|
+
await logEvent("tool_low_confidence_shipped", { tool: "ashlr__grep", reason: "low-confidence" });
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Post-grep: upsert matched snippets into the embedding cache (fire-and-forget).
|
|
835
|
+
setImmediate(() => {
|
|
836
|
+
try {
|
|
837
|
+
const snippet = (summarized.text || raw).slice(0, 2000);
|
|
838
|
+
if (snippet.length > 50) {
|
|
839
|
+
upsertCorpus(snippet);
|
|
840
|
+
const ctxDb = getEmbeddingCache();
|
|
841
|
+
// embed() is async — spawn a detached micro-task so we never await here.
|
|
842
|
+
// best-effort: embedding upsert is a speculative cache fill after the tool already returned; a failure here cannot affect the user's result.
|
|
843
|
+
embed(snippet).then((vec) => {
|
|
844
|
+
ctxDb.upsertEmbedding({
|
|
845
|
+
projectHash: pHash,
|
|
846
|
+
sectionPath: `grep:${input.pattern}`,
|
|
847
|
+
sectionText: snippet,
|
|
848
|
+
embedding: vec,
|
|
849
|
+
embeddingDim: vec.length,
|
|
850
|
+
source: "code",
|
|
851
|
+
});
|
|
852
|
+
}).catch(() => {});
|
|
853
|
+
}
|
|
854
|
+
} catch {
|
|
855
|
+
/* best-effort */
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
return embedCachePrefix + (summarized.text || "[no matches]") + confidenceBadge(rgBadgeOpts);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export interface EditArgs {
|
|
863
|
+
path: string;
|
|
864
|
+
search: string;
|
|
865
|
+
replace: string;
|
|
866
|
+
/** When true (default), require exactly one match of `search` for safety. */
|
|
867
|
+
strict?: boolean;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export interface EditResult {
|
|
871
|
+
text: string;
|
|
872
|
+
hunksApplied: number;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ---------------------------------------------------------------------------
|
|
876
|
+
// Edit session log (for ashlr__flush summary)
|
|
877
|
+
//
|
|
878
|
+
// All edits write immediately to disk. This is the safe design: the MCP SDK
|
|
879
|
+
// dispatches requests concurrently, so a deferred-write queue would create
|
|
880
|
+
// races where a read arrives before the preceding edit's timer fires.
|
|
881
|
+
//
|
|
882
|
+
// ashlr__flush is a "what did I just write?" reporting tool — it returns a
|
|
883
|
+
// compact summary of edits applied since the last flush, which lets the
|
|
884
|
+
// agent confirm what landed without re-reading the full files.
|
|
885
|
+
// ---------------------------------------------------------------------------
|
|
886
|
+
|
|
887
|
+
interface EditLogEntry {
|
|
888
|
+
relPath: string;
|
|
889
|
+
hunksApplied: number;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const editLog: EditLogEntry[] = [];
|
|
893
|
+
|
|
894
|
+
/** Summarize edits applied since the last flush (or session start). */
|
|
895
|
+
export async function flushPending(): Promise<string> {
|
|
896
|
+
if (editLog.length === 0) return "";
|
|
897
|
+
const batch = editLog.splice(0, editLog.length);
|
|
898
|
+
const lines = [`[ashlr__flush] ${batch.length} edit(s) applied this batch:`];
|
|
899
|
+
for (const e of batch) {
|
|
900
|
+
lines.push(` ok ${e.relPath} (${e.hunksApplied} hunk${e.hunksApplied === 1 ? "" : "s"})`);
|
|
901
|
+
}
|
|
902
|
+
return lines.join("\n");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Levenshtein distance (capped at maxDist for speed). */
|
|
906
|
+
function levenshtein(a: string, b: string, maxDist = 256): number {
|
|
907
|
+
if (a === b) return 0;
|
|
908
|
+
if (a.length === 0) return Math.min(b.length, maxDist);
|
|
909
|
+
if (b.length === 0) return Math.min(a.length, maxDist);
|
|
910
|
+
// Truncate both to keep work bounded
|
|
911
|
+
const A = a.slice(0, 200);
|
|
912
|
+
const B = b.slice(0, 200);
|
|
913
|
+
const m = A.length, n = B.length;
|
|
914
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
915
|
+
let curr = new Array<number>(n + 1);
|
|
916
|
+
for (let i = 1; i <= m; i++) {
|
|
917
|
+
curr[0] = i;
|
|
918
|
+
for (let j = 1; j <= n; j++) {
|
|
919
|
+
const cost = A[i - 1] === B[j - 1] ? 0 : 1;
|
|
920
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
921
|
+
}
|
|
922
|
+
[prev, curr] = [curr, prev];
|
|
923
|
+
}
|
|
924
|
+
return prev[n];
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Return top-3 closest lines to `search` from `content`, with similarity scores. */
|
|
928
|
+
function fuzzyTopLines(
|
|
929
|
+
search: string,
|
|
930
|
+
content: string,
|
|
931
|
+
): Array<{ lineNo: number; text: string; sim: number }> {
|
|
932
|
+
const needle = search.split("\n")[0].trim().slice(0, 200);
|
|
933
|
+
const lines = content.split("\n");
|
|
934
|
+
const results: Array<{ lineNo: number; text: string; sim: number }> = [];
|
|
935
|
+
for (let i = 0; i < lines.length; i++) {
|
|
936
|
+
const line = lines[i].trim();
|
|
937
|
+
if (!line) continue;
|
|
938
|
+
const dist = levenshtein(needle, line.slice(0, 200));
|
|
939
|
+
const maxLen = Math.max(needle.length, line.length, 1);
|
|
940
|
+
const sim = Math.round((1 - dist / maxLen) * 100) / 100;
|
|
941
|
+
results.push({ lineNo: i + 1, text: lines[i], sim });
|
|
942
|
+
}
|
|
943
|
+
results.sort((a, b) => b.sim - a.sim);
|
|
944
|
+
return results.slice(0, 3);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const FUZZY_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB
|
|
948
|
+
|
|
949
|
+
export async function ashlrEdit(input: EditArgs): Promise<EditResult> {
|
|
950
|
+
const { path: relPath, search, replace, strict = true } = input;
|
|
951
|
+
if (!search) throw new Error("ashlr__edit: 'search' must not be empty");
|
|
952
|
+
|
|
953
|
+
const clamp = clampToCwd(relPath, "ashlr__edit");
|
|
954
|
+
if (!clamp.ok) throw new Error(clamp.message);
|
|
955
|
+
const abs = clamp.abs;
|
|
956
|
+
const original = await readFile(abs, "utf-8");
|
|
957
|
+
|
|
958
|
+
let count = 0;
|
|
959
|
+
let idx = 0;
|
|
960
|
+
while ((idx = original.indexOf(search, idx)) !== -1) { count++; idx += search.length; }
|
|
961
|
+
|
|
962
|
+
if (count === 0) {
|
|
963
|
+
if (strict && original.length <= FUZZY_SIZE_LIMIT) {
|
|
964
|
+
const candidates = fuzzyTopLines(search, original);
|
|
965
|
+
const hint = candidates.length
|
|
966
|
+
? "\nClosest lines in file:\n" +
|
|
967
|
+
candidates.map(c => ` ${c.lineNo}: ${c.text.slice(0, 120)} (sim=${c.sim.toFixed(2)})`).join("\n")
|
|
968
|
+
: "";
|
|
969
|
+
throw new Error(`ashlr__edit: no match for search string in ${relPath} (strict mode).${hint}`);
|
|
970
|
+
}
|
|
971
|
+
throw new Error(`ashlr__edit: search string not found in ${relPath}`);
|
|
972
|
+
}
|
|
973
|
+
if (strict && count > 1) {
|
|
974
|
+
throw new Error(
|
|
975
|
+
`ashlr__edit: search string matched ${count} times in ${relPath}; pass strict:false to replace all, or widen the context to a unique span.`,
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Treat replacement as literal text. String.prototype.replace(string, string)
|
|
980
|
+
// interprets `$&`, `$1`, `$'`, and `$`` sequences in the replacement.
|
|
981
|
+
const matchIdx = original.indexOf(search);
|
|
982
|
+
const updated = strict
|
|
983
|
+
? original.slice(0, matchIdx) + replace + original.slice(matchIdx + search.length)
|
|
984
|
+
: original.split(search).join(replace);
|
|
985
|
+
|
|
986
|
+
await writeFile(abs, updated, "utf-8");
|
|
987
|
+
|
|
988
|
+
// Invalidate read-cache so subsequent ashlr__read calls see new content.
|
|
989
|
+
const hit = readCache.get(abs);
|
|
990
|
+
if (hit) readCache.set(abs, { ...hit, mtimeMs: -1 });
|
|
991
|
+
|
|
992
|
+
// best-effort: refreshGenomeAfterEdit already swallows internally; this outer catch guards against a pre-try sync throw so edits never fail because of observability.
|
|
993
|
+
refreshGenomeAfterEdit(abs, original, updated).catch(() => {});
|
|
994
|
+
|
|
995
|
+
// v1.18 Trust Pass: baseline is what Claude Code would have SENT for a
|
|
996
|
+
// native Edit — search + replace, NOT the full file twice. The prior
|
|
997
|
+
// `original.length + updated.length` inflated savings 2–5× because it
|
|
998
|
+
// counted the entire file twice even for a one-line change.
|
|
999
|
+
const naiveBytes = search.length + replace.length;
|
|
1000
|
+
const compactSummary = summarizeEdit(relPath, search, replace, count, strict);
|
|
1001
|
+
await recordSaving(naiveBytes, compactSummary.length, "ashlr__edit");
|
|
1002
|
+
|
|
1003
|
+
const hunksApplied = strict ? 1 : count;
|
|
1004
|
+
editLog.push({ relPath, hunksApplied });
|
|
1005
|
+
|
|
1006
|
+
return { text: compactSummary, hunksApplied };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function summarizeEdit(
|
|
1010
|
+
relPath: string,
|
|
1011
|
+
search: string,
|
|
1012
|
+
replace: string,
|
|
1013
|
+
matchCount: number,
|
|
1014
|
+
strict: boolean,
|
|
1015
|
+
): string {
|
|
1016
|
+
const first = (s: string) => s.split("\n")[0]?.slice(0, 72) ?? "";
|
|
1017
|
+
return [
|
|
1018
|
+
`[ashlr__edit] ${relPath} · ${strict ? "1 of " + matchCount : matchCount + " of " + matchCount} hunks applied`,
|
|
1019
|
+
` - removed (${estimateTokensFromString(search)} tok): ${first(search)}${search.length > 72 ? "…" : ""}`,
|
|
1020
|
+
` + added (${estimateTokensFromString(replace)} tok): ${first(replace)}${replace.length > 72 ? "…" : ""}`,
|
|
1021
|
+
].join("\n");
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ---------------------------------------------------------------------------
|
|
1025
|
+
// MCP server wiring
|
|
1026
|
+
// ---------------------------------------------------------------------------
|
|
1027
|
+
|
|
1028
|
+
const server = new Server(
|
|
1029
|
+
{ name: "ashlr-efficiency", version: "0.1.0" },
|
|
1030
|
+
{ capabilities: { tools: {} } },
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1034
|
+
tools: [
|
|
1035
|
+
{
|
|
1036
|
+
name: "ashlr__read",
|
|
1037
|
+
description: "Read a file with automatic snipCompact truncation for results > 2KB. Preserves head + tail, elides middle. Lower-token alternative to the built-in Read tool.",
|
|
1038
|
+
inputSchema: {
|
|
1039
|
+
type: "object",
|
|
1040
|
+
properties: {
|
|
1041
|
+
path: { type: "string", description: "Absolute or cwd-relative file path" },
|
|
1042
|
+
bypassSummary: { type: "boolean", description: "Skip LLM summarization, return snipCompact-truncated content (default: false)" },
|
|
1043
|
+
},
|
|
1044
|
+
required: ["path"],
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
name: "ashlr__grep",
|
|
1049
|
+
description: "Search for a pattern. When a .ashlrcode/genome/ directory exists, uses genome-aware retrieval to return only the most relevant sections. Falls back to ripgrep otherwise.",
|
|
1050
|
+
inputSchema: {
|
|
1051
|
+
type: "object",
|
|
1052
|
+
properties: {
|
|
1053
|
+
pattern: { type: "string", description: "Query or regex" },
|
|
1054
|
+
cwd: { type: "string", description: "Working directory (default: process.cwd())" },
|
|
1055
|
+
bypassSummary: { type: "boolean", description: "Skip LLM summarization, return rg output as-is (default: false)" },
|
|
1056
|
+
},
|
|
1057
|
+
required: ["pattern"],
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
name: "ashlr__edit",
|
|
1062
|
+
description: "Apply a search/replace edit in-place and return only a diff summary. In strict mode (default), requires exactly one match for safety. Set strict:false to replace all occurrences.",
|
|
1063
|
+
inputSchema: {
|
|
1064
|
+
type: "object",
|
|
1065
|
+
properties: {
|
|
1066
|
+
path: { type: "string", description: "Absolute or cwd-relative file path" },
|
|
1067
|
+
search: { type: "string", description: "Exact text to find" },
|
|
1068
|
+
replace: { type: "string", description: "Replacement text" },
|
|
1069
|
+
strict: { type: "boolean", description: "Require exactly one match (default: true)" },
|
|
1070
|
+
},
|
|
1071
|
+
required: ["path", "search", "replace"],
|
|
1072
|
+
},
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
name: "ashlr__flush",
|
|
1076
|
+
description: "Flush all queued ashlr__edit writes to disk immediately and return a summary of what was committed. Use when you need to read a file you just edited, or at the end of a multi-edit sequence.",
|
|
1077
|
+
inputSchema: { type: "object", properties: {} },
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
name: "ashlr__savings",
|
|
1081
|
+
description: "Return estimated tokens saved in the current session and lifetime totals.",
|
|
1082
|
+
inputSchema: { type: "object", properties: {} },
|
|
1083
|
+
},
|
|
1084
|
+
],
|
|
1085
|
+
}));
|
|
1086
|
+
|
|
1087
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
1088
|
+
const { name, arguments: args } = req.params;
|
|
1089
|
+
|
|
1090
|
+
try {
|
|
1091
|
+
switch (name) {
|
|
1092
|
+
case "ashlr__read": {
|
|
1093
|
+
const text = await ashlrRead(args as { path: string; bypassSummary?: boolean });
|
|
1094
|
+
return { content: [{ type: "text", text }] };
|
|
1095
|
+
}
|
|
1096
|
+
case "ashlr__grep": {
|
|
1097
|
+
const text = await ashlrGrep(args as { pattern: string; cwd?: string; bypassSummary?: boolean });
|
|
1098
|
+
return { content: [{ type: "text", text }] };
|
|
1099
|
+
}
|
|
1100
|
+
case "ashlr__edit": {
|
|
1101
|
+
const res = await ashlrEdit(args as unknown as EditArgs);
|
|
1102
|
+
return { content: [{ type: "text", text: res.text }] };
|
|
1103
|
+
}
|
|
1104
|
+
case "ashlr__flush": {
|
|
1105
|
+
const summary = await flushPending();
|
|
1106
|
+
return { content: [{ type: "text", text: summary || "[ashlr__flush] nothing to flush" }] };
|
|
1107
|
+
}
|
|
1108
|
+
case "ashlr__savings": {
|
|
1109
|
+
const stats = await readStats();
|
|
1110
|
+
const session = await readCurrentSession();
|
|
1111
|
+
const topProjects = buildTopProjects(
|
|
1112
|
+
process.env.HOME ?? process.env.USERPROFILE,
|
|
1113
|
+
);
|
|
1114
|
+
const { ratio: calibrationRatio, present: calibrationPresent } = readCalibrationState();
|
|
1115
|
+
const nudgeSummary = await readNudgeSummary();
|
|
1116
|
+
const proUser = isProSync();
|
|
1117
|
+
const lifetimeDollarsSaved = costFor(stats.lifetime.tokensSaved);
|
|
1118
|
+
const extra: ExtraContext = { topProjects, calibrationRatio, calibrationPresent, nudgeSummary, proUser, lifetimeDollarsSaved };
|
|
1119
|
+
return {
|
|
1120
|
+
content: [{ type: "text", text: renderSavings(session, stats.lifetime, extra) }],
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
default:
|
|
1124
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
1125
|
+
}
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1128
|
+
return { content: [{ type: "text", text: `ashlr error: ${message}` }], isError: true };
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
if (import.meta.main) {
|
|
1133
|
+
const transport = new StdioServerTransport();
|
|
1134
|
+
await server.connect(transport);
|
|
1135
|
+
}
|