mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-07-03 12:32:32 +08:00
* feat(ux): claude-mem UX improvements with installer enhancements Squashed PR #2156 commits for clean rebase onto main: - feat(installer): add provider selection, model prompt, worker auto-start - refactor: rename *Agent provider classes to *Provider - feat: add /learn-codebase skill and viewer welcome card - feat(worker): inject welcome hint when project has zero observations - fix(pr-2156): address greptile review comments - fix(pr-2156): address coderabbit review comments - fix(pr-2156): persist CLAUDE_MEM_PROVIDER for non-claude in non-TTY mode - fix(pr-2156): file-backed settings reads in installer + env-first SKILL doc Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * build: rebuild plugin artifacts after rebase onto v12.4.7 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(skills): strip claude-mem internals from learn-codebase The learn-codebase skill, install next-step copy, WelcomeCard, and welcome-hint previously walked the primary agent through worker endpoints and synthetic observation payloads. The PostToolUse hook already captures every Read/Edit the agent makes — the agent should have no awareness that the memory layer exists. Collapse the skill to one instruction ("read every source file in full") and rephrase touchpoints to describe only what the user observes (Claude reading files), not what happens behind the scenes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): preflight version mismatch + settings-aware port resolution Two related fixes for build-and-sync's worker restart step: 1. Read CLAUDE_MEM_WORKER_PORT from ~/.claude-mem/settings.json the same way the worker does, instead of computing the default port from the uid alone. Previously, users with a custom port saw a misleading "Worker not running" message because the restart POST hit the wrong port and got ECONNREFUSED. 2. Add a preflight check that aborts the sync when the running worker's reported version does not match the version we are about to build. Claude Code's plugin loader pins the worker to a specific cache version per session, so syncing into a newer cache directory has no effect until the user runs `claude plugin update thedotmack/claude-mem` to bump the pin. The preflight surfaces this explicitly with the exact command to run; --force bypasses it for intentional cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(learn-codebase): note sed for partial reads of large files Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: strip comments codebase-wide Removed prose comments from all tracked source. Preserved directives (@ts-ignore, eslint-disable, biome-ignore, prettier-ignore, triple-slash references, webpack magic, shebangs). Deleted two tests that asserted on comment text rather than runtime behavior. Net: 401 files, -14,587 / +389 lines, -10.4% bytes. Verified: typecheck passes, build passes, test count unchanged from baseline (22 pre-existing fails, all unrelated). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(installer): move runtime setup into npx, eliminate hook dead air Smart-install ran 3 times during a fresh install — the worst run was silent, fired by Claude Code's Setup hook after `claude plugin install`, producing ~30s of dead air that looked like the plugin was hung. This change makes `npx claude-mem install` the single place heavy work happens, with a visible spinner. Hooks become runtime-only. - New `src/npx-cli/install/setup-runtime.ts` module: ensureBun, ensureUv, installPluginDependencies, read/writeInstallMarker, isInstallCurrent. Marker schema preserved exactly ({version, bun, uv, installedAt}) so ContextBuilder and BranchManager readers keep working. - `npx claude-mem install`: ungated copy/register/enable for every IDE, inserts a "Setting up runtime" task with honest "first install can take ~30s" spinner. The claude-code shell-out to `claude plugin install` is removed — npx already populated everything Claude reads. - New `npx claude-mem repair` command for post-`claude plugin update` recovery, force-reinstalls runtime. - Setup hook now runs `plugin/scripts/version-check.js` (29ms wall) instead of smart-install. Mismatch prints "run: npx claude-mem repair" on stderr. Always exits 0 (non-blocking, per CLAUDE.md exit-code strategy). - SessionStart loses the smart-install entry; 2 hooks remain (worker start, context fetch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(installer): delete smart-install sources, retarget tests - Delete scripts/smart-install.js + plugin/scripts/smart-install.js (both are source files kept in sync manually; both must go). - Delete tests/smart-install.test.ts (covered surface is gone). - tests/plugin-scripts-line-endings: drop smart-install.js entry. - tests/infrastructure/plugin-distribution: retarget two assertions at version-check.js (the new Setup hook script). - New tests/setup-runtime.test.ts: 9 tests covering marker read/write, isInstallCurrent semantics. Marker schema invariant verified. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(installer): describe npx-driven setup + version-check Setup hook Sweep public docs and architecture notes to reflect the new flow: npx installer does Bun/uv setup with a visible spinner; Setup hook runs sub-100ms version-check.js; users hit `npx claude-mem repair` after a `claude plugin update`. - docs/architecture-overview.md: hook lifecycle table + npx flow paragraph - docs/public/configuration.mdx: tree + hook config example - docs/public/development.mdx: build output line - docs/public/hooks-architecture.mdx: full rewrite of pre-hook section, timing table, performance table - docs/public/architecture/{overview,hooks,worker-service}.mdx: tree comments, JSON config example, Bun requirement section docs/reports/* untouched (historical incident reports). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(install): mergeSettings writes via USER_SETTINGS_PATH Greptile P1 (#2156): `settingsFilePath()` only resolved `process.env.CLAUDE_MEM_DATA_DIR`, while `getSetting()` reads via `USER_SETTINGS_PATH` which `resolveDataDir()` populates from BOTH the env var AND a `CLAUDE_MEM_DATA_DIR` entry persisted in `~/.claude-mem/settings.json`. Result: a user with the data dir saved in settings.json but not exported in their shell would have provider/model settings silently written to `~/.claude-mem/settings.json` while `getSetting()` read from `/custom/path/settings.json` — read/write split. Drop `settingsFilePath()` and the now-unused `homedir` import; reuse the already-imported `USER_SETTINGS_PATH` constant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): parse --provider, --model, --no-auto-start install flags Greptile P1 (#2156): InstallOptions has fields `provider`, `model`, `noAutoStart`, but the install case in the npx-cli switch only parsed `--ide`. The other three flags were silently dropped — `npx claude-mem install --provider gemini` was a no-op. Extract a `parseInstallOptions(argv)` helper, share it between the bare `npx claude-mem` and `npx claude-mem install` paths, and validate `--provider` against the allowed set. Update help text accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(install): pipe runtime-setup output, always show IDE multiselect Two issues caught in a docker test of the installer: 1. The bun.sh installer, uv installer, and `bun install` were using stdio: 'inherit', dumping their stdout/stderr through clack's spinner region — visible as raw "downloading uv 0.11.8…" / "Checked 58 installs across 38 packages…" text streaming under the spinner. Switch to stdio: 'pipe' and surface captured stderr only on failure (via a shared describeExecError() helper that includes stdout when stderr is empty). Spinner stays clean on the happy path. 2. promptForIDESelection() silently picked claude-code when no IDEs were detected, never showing the user the multiselect. On a fresh machine with no IDEs present yet (e.g. our docker test container), the user never got to choose. Now: always show the full IDE list when interactive; mark detected ones with [detected] hints and pre-select them; show a warn line if zero are detected explaining they should pick what they plan to use. Non-TTY callers still get the silent claude-code default at the call site (unchanged). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(install): skip marketplace work for claude-code-only, offer to install Claude Code Two related UX fixes from a docker test: **Delay between "Saved Claude model=…" and "Plugin files copied OK"** After dropping the needsManualInstall gate, every install was unconditionally running `copyPluginToMarketplace` (which copied the entire root node_modules tree — thousands of files, dozens of seconds) and `runNpmInstallInMarketplace` (npm install --production) even when only claude-code was selected. Neither is needed for claude-code: that path uses the plugin cache dir + the installed_plugins.json + enabledPlugins flag, all of which we already write. - Drop `node_modules` from `copyPluginToMarketplace`'s allowed-entries list; the dependency-install task populates it on the destination side anyway. - Re-introduce `needsMarketplace = selectedIDEs.some(id => id !== 'claude-code')` scoped *only* to `copyPluginToMarketplace`, `runNpmInstallInMarketplace`, and the pre-install `shutdownWorkerAndWait` (also pointless for claude-code- only flows since we're not overwriting the worker's running cache dir source). All other tasks (cache copy, register, enable, runtime setup) stay unconditional. **Claude Code missing → silent install of an IDE that isn't there** When the user picked claude-code on a machine without it (e.g. a fresh container), the install completed but `claude` was unavailable and the only hint was a generic warn line. Replace with an explicit pre-flight prompt: Claude Code is not installed. Claude-mem works best in Claude Code, but also works with the IDEs below. ? Install Claude Code now? ◆ Yes — install Claude Code (recommended) ◯ No — pick another IDE below ◯ Cancel installation If the user picks "Yes", run `curl -fsSL https://claude.ai/install.sh | bash` (or the PowerShell equivalent on Windows), then re-detect IDEs and proceed with claude-code pre-selected. If the install fails or the user picks "No", the multiselect still appears with claude-code visible (just unmarked [detected]), so they can opt in or pick another IDE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(install): detect Claude Code via `claude` CLI, not ~/.claude dir The directory `~/.claude` can exist (e.g. mounted in Docker, or created by tooling) without Claude Code actually being installed. Detect the `claude` command in PATH instead so the installer correctly offers to install Claude Code when missing. * docs(learn-codebase): add reviewer note explaining the cost tradeoff The skill intentionally reads every file in full to build a cognitive cache that pays off across the rest of the project. Add a brief note so reviewers (human or bot) understand the tradeoff before flagging the unbounded read as a cost issue. * fix: address Greptile P1 feedback on welcome hint and learn-codebase - SearchRoutes: skip welcome hint when caller passes ?full=true so explicit full-context requests aren't intercepted by the hint. - learn-codebase: replace `sed` instruction with the Read tool's offset/limit parameters, since Bash is gated in Claude Code by default. * feat(install): ASCII-animated logo splash on interactive install Plays a ~1s bloom animation of the claude-mem sunburst logomark when the installer starts in an interactive terminal — geometrically rendered via 12 ray curves around a center disc, in the brand orange. The wordmark and tagline type on alongside the final frame. Auto-skipped on non-TTY, in CI, when NO_COLOR or CLAUDE_MEM_NO_BANNER is set, or when the terminal is too narrow. Inspired by ghostty +boo. * feat(banner): replace rotation frames with angular-sector bloom generator Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(banner): replace rotation frames with angular-sector bloom generator Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(banner): three-act choreography renderer with radial gradient and diff redraw Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(banner): update preview script to support small/medium/hero tier selection Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(docker): add COLORTERM=truecolor to test-installer sandbox Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(install): auto-apply PATH for Claude Code with spinner UX The Claude Code install.sh prints a Setup notes block telling users to manually edit "your shell config file" to add ~/.local/bin to PATH — which left fresh installs unable to launch claude from the command line. After a successful install, detect ~/.local/bin/claude on disk and, if the dir is missing from PATH, append the right export line to .zshrc / .bash_profile / .bashrc / fish config (idempotent, marked with a comment). Also updates process.env.PATH for the current install run. Wraps the curl|bash install in a clack spinner (interactive only) so the ~4 minute native-build download doesn't look frozen — output is captured silently and dumped on failure for debuggability. Non-interactive mode keeps inherited stdio for CI logs. Verified end-to-end in the test-installer docker sandbox: spinner animates, .bashrc gets the export, fresh login shell resolves claude. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(banner): video-frame ASCII renderer with three-act choreography Generator switched from a single Jimp-rendered logo to pre-extracted video frames concatenated with \x01 separators and gzip-deflated, ported from ghostty's boo wire format. Renderer rewritten around three acts (ignite → stagger bloom → text reveal + breathe) with adaptive sizing, radial gradient, and diff-based redraw. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(onboarding): unify install / SessionStart / viewer around one first-success moment Three surfaces now point at the same north-star moment — open the viewer, do anything in Claude Code, watch an observation appear within seconds — with the same verbatim timing and privacy lines, and a single canonical "how it works" explainer instead of three diverging copies. - Canonical explainer at src/services/worker/onboarding-explainer.md served via GET /api/onboarding/explainer; mirrored into plugin/skills/how-it-works/SKILL.md - SessionStart welcome hint rewritten as third-person status (no imperatives Claude tries to execute), pinned with a default-value regression test - Post-install Next Steps reframed as "two paths": passive default + optional /learn-codebase front-load; drops /mem-search and /knowledge-agent from this surface; adds verbatim timing + privacy lines and /how-it-works link - /api/stats response gains firstObservationAt for the viewer stat row - Viewer WelcomeCard branches on observationCount === 0: empty state shows live worker-connection dot + "waiting for activity"; has-data state shows observations · projects · since [date] and two example prompts. v2 dismiss key - jimp added to package.json to fix pre-existing banner-frame build break Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(banner): play unconditionally; only honor CLAUDE_MEM_NO_BANNER The 128-col / TTY / CI / NO_COLOR gates silently swallowed the banner in narrower terminals, CI logs, and any non-TTY pipe — including Docker runs where -it should preserve the experience but column width was the wrong gate. Remove the implicit gates; keep the explicit opt-out only. If a frame wraps in a narrow terminal, that's better than the banner not playing at all. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert(banner): restore 15:33 gating logic per user request Revertseb6fc157. Restores isBannerEnabled to the state at commit8e448015(2026-04-30 15:33): TTY check, !CI, !NO_COLOR, !CLAUDE_MEM_NO_BANNER, and cols >= BANNER.width. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(install): wrap remaining slow steps with spinners Each IDE installer (Cursor, Gemini CLI, OpenCode, Windsurf, OpenClaw, Codex CLI, MCP integrations) now runs inside a clack task spinner with per-step progress messages instead of silent dynamic-import + cpSync. Pre-overwrite worker shutdown (up to 10s) and the post-install health probe (up to 3s) also get spinners. Internal console.log/error/warn from each IDE installer is buffered during the spinner; if the install fails, captured output is replayed afterward via log.warn so users can see what broke. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(review): observation count + IDE pre-selection regressions WelcomeCard's "no observations yet" empty state was triggered when a project filter narrowed the feed to zero rows, even with thousands of observations elsewhere. Source the count from global stats.database to match firstObservationAt's scope. Restore initialValues: [] in the IDE multiselect — pre-selecting every detected IDE was the exact regression #2106 was filed for. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(install): trichotomy worker state + cache fallback for script path ensureWorkerStarted now returns 'ready' | 'warming' | 'dead' instead of boolean. The spawned-but-still-warming case (common in Docker cold starts and slow first-time inits) was being misreported as 'did not start', which contradicted the next-steps panel saying 'still starting up'. Install task message and Next Steps headline now agree on the actual state. Also fixes the actual root cause of 'Worker did not start' on claude-code-only installs: the worker script path was hardcoded to the marketplace dir, which is left empty when no non-claude-code IDE is selected. Now falls back to pluginCacheDirectory(version) when the marketplace copy isn't present. Verified end-to-end in docker/claude-mem with --ide claude-code, --ide cursor, and a fresh container — install task and headline agree on 'Worker ready at http://localhost:<port>' in all cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: align CLAUDE.md and public docs with current code Sweep across CLAUDE.md and 10 high-traffic docs/public/ MDX files to remove point-in-time references and align with the actual current shape of the codebase. Highlights: - Hardcoded port 37777 → per-user formula (37700 + uid % 100) on the front-door pages (introduction, installation, configuration, architecture/overview, architecture/worker-service, troubleshooting, hooks-architecture, platform-integration). - Default model 'sonnet' → 'claude-haiku-4-5-20251001' (matches SettingsDefaultsManager). - Node 18 → 20 (matches package.json engines). - Lifecycle hook count corrected (5 events). - Removed the nonexistent 'Smart Install' component and pre-built directory tree referencing files that no longer exist (context-hook.ts, save-hook.ts, cleanup-hook.ts, etc.); replaced with the real worker dispatcher shape. - Removed CLAUDE.md '#2101' issue tag (kept the design rationale). - Replaced obsolete hooks.json example with a description of the real bun-runner.js / worker-service.cjs hook event shape. Lower-traffic doc pages still hardcode 37777 — left for a separate global pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(scripts): land strip-comments around real parsers (postcss, remark, parse5) Each language gets a real parser to locate comments, then we splice ranges out of the original source. The library never serializes — that's how remark-stringify produced 243 reformat-noise diffs in the first attempt versus the 21 real strip targets here. JS/TS/JSX -> ts.createSourceFile + getLeadingCommentRanges CSS/SCSS -> postcss.parse + walkComments + node.source offsets MD/MDX -> remark-parse (+ remark-mdx) + AST html / mdx-expression nodes HTML -> parse5 with sourceCodeLocationInfo shell/py -> kept hand-rolled hash stripper (no library worth the dep) Preserves: shebangs, @ts-* directives, eslint-disable, biome-ignore, prettier-ignore, triple-slash refs, webpack magic, /*! license keep, @strip-comments-keep file marker. JS/TS handler runs a parse-roundtrip check and refuses to write if syntax errors increased (catches the worker-utils.ts breakage class from the 2026-04-29 attempt). npm scripts: strip-comments (apply) strip-comments:check (CI-style, exits non-zero if changes needed) strip-comments:dry-run (list, no writes) Verified --check on this repo: 21 changes, -4.0% bytes, no parse-error regressions, no reformat-suspect false positives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: strip comments codebase-wide via parser-backed tool 21 files changed, -17,550 bytes (-4.0%) of narrative comments removed across .ts / .tsx / .js / .mjs and the .gitignore. JS/TS comments stripped via ts.createSourceFile + getLeadingCommentRanges — same canonical lexer, same behavior as the 2026-04-29 strip, no reformat noise. Preexisting baseline (unchanged): typecheck: 16 errors at HEAD, 16 errors after strip (line numbers shift, no new error classes — verified via diff of sorted error lists) build: fails at HEAD with CrushHooksInstaller.js unresolved import (preexisting, unrelated to this strip) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(install): drop Crush integration references after extract The Crush integration was extracted to its own branch on May 1, but the import at install.ts:280 (and the case block + ide-detection entry + McpIntegrations config + npx-cli help text) still referenced the now- removed CrushHooksInstaller.js, breaking the build. Removes: - case 'crush' block in install.ts - crush entry in ide-detection.ts - CRUSH_CONFIG and registration in McpIntegrations.ts - 'crush' from the IDE Identifiers help line in index.ts Rebuilds worker-service.cjs to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(banner): mark generated banner-frames.ts with @strip-comments-keep Without this, every build/strip cycle ping-pongs five lines of doc comments in and out of the auto-generated output. The keep-marker tells strip-comments.ts to skip the file entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(build): drop banner-frame regen from build script generate-banner-frames.mjs requires PNG frames in /tmp/cmem-banner-frames that only exist after the maintainer runs ffmpeg locally on the source video. CI has neither the video nor the frames, so the build broke on Windows. The output (src/npx-cli/banner-frames.ts) is committed, so the regen is a one-shot dev step — not a build step. Run the script directly when the video changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(worker): unstick the spinner — kill claim-self-lock, wake on fail, auto-broadcast Three surgical changes that cure the stuck-spinner bug at the source. Phase 1.1 (L9): claimNextMessage no longer self-excludes its own worker_pid. A single UPDATE-RETURNING grabs the oldest pending row by id. Removes the LiveWorkerPidsProvider plumbing that was never injected — Supervisor enforces single-worker via PID file, so the multi-worker SQL was defending against a configuration the project does not support. Phase 1.2 (L19): SessionManager.markMessageFailed wraps PendingMessageStore.markFailed and emits 'message' on the per-session EventEmitter. The iterator's waitForMessage now wakes immediately on re-pend instead of parking for 3 minutes. ResponseProcessor and SessionRoutes routed through the new wrapper. Phase 1.3 (L24): PendingMessageStore takes an optional onMutate callback fired from every mutator (enqueue, claimNextMessage, confirmProcessed, markFailed, transitionMessagesTo, clearFailedOlderThan). SessionManager wires it; WorkerService passes broadcastProcessingStatus. Ten manual broadcast calls deleted across SessionCleanupHelper, SessionEventBroadcaster, SessionRoutes, DataRoutes, and worker-service. Caller discipline becomes structurally impossible to forget. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(worker): delete dead code — legacy routes, processPendingQueues, decorative guards Pure deletions. Phase 2 of kill-the-asshole-gates. - Legacy /sessions/:sessionDbId/* routes (handleSessionInit, handleObservations, handleSummarize, handleSessionStatus, handleSessionDelete, handleSessionComplete) bypassed all five ingest gates and were a parallel write path. Folded the initializeSession + broadcastNewPrompt + syncUserPrompt + ensureGeneratorRunning + broadcastSessionStarted work into the canonical /api/sessions/init handler so the hook makes one round trip instead of two. - processPendingQueues (~104 lines, zero callers) — replaced in Phase 6 by a one-statement startup sweep. - spawnInProgress Map and crashRecoveryScheduled Set — decorative dedupe over generatorPromise and stillExists checks that already provide the real safety. - STALE_GENERATOR_THRESHOLD_MS — pre-empted live generators and raced with the finally block; the 3min idle timeout already kills zombies. - MAX_SESSION_WALL_CLOCK_MS — ran a SELECT on every observation to enforce 24h. Runaway-spend protection lives in the API key, not in claude-mem. - Missing-id 400 in shared.ts ingestObservation — Zod already enforces min(1) on contentSessionId and toolName at the route schema. - SessionCompletionHandler import + completionHandler field on SessionRoutes (orphaned after handler deletions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(worker): SQL-backed getTotalQueueDepth — single source of truth Was: iterate this.sessions.values() and sum getPendingCount per session. Now: SELECT COUNT(*) FROM pending_messages WHERE status IN ('pending','processing'). The in-memory sessions Map drifted from the DB rows whenever a generator exited without confirm/fail, leading to false-positive isProcessing in the UI. Phase 1.3's auto-broadcast fires on every mutation, but it broadcast a stale Map count. Reading from the DB makes the UI's spinner state match what the queue actually holds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(worker): typed abortReason replaces wasAborted boolean Was: a boolean wasAborted that lumped every abort together. The finally block branched on !wasAborted, so any abort skipped restart — including idle aborts with pending work, which is exactly the case where we DO want to restart. Now: ActiveSession.abortReason is a typed enum 'idle' | 'shutdown' | 'overflow' | 'restart-guard'. The finally block consumes the reason and only skips restart for 'shutdown' and 'restart-guard'. Idle and overflow aborts fall through, so if pending work exists they trigger restart correctly. Dropped 'stale' and 'wall-clock' from the union — Phase 2 deleted those paths. Natural-completion abort (post-success) intentionally has no reason; it's not gating restart logic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(worker): unify the two generator-exit finally blocks Was: worker-service.ts:startSessionProcessor and SessionRoutes:ensureGeneratorRunning each had their own ~70-line finally block with divergent restart-guard handling. The worker-service path called terminateSession on RestartGuard trip and orphaned pending rows (the L16 bug); the SessionRoutes path drained them. Two places to update when rules changed. Now: handleGeneratorExit in src/services/worker/session/GeneratorExitHandler.ts owns the contract: 1. Always kill the SDK subprocess if alive. 2. Always drain processingMessageIds via sessionManager.markMessageFailed (which wakes the iterator — Phase 1.2). 3. shutdown / restart-guard reasons: drain pending rows via transitionMessagesTo('failed'), finalize, remove from Map. Fixes L16. 4. pendingCount=0: finalize normally and remove from Map. 5. pendingCount>0: backoff respawn via per-session respawnTimer (no global Set; Phase 2.4 deleted that). RestartGuard trip drains to 'abandoned'. Both finally blocks are now ~10-line wrappers that translate local state into the canonical abortReason and delegate. Restored completionHandler injection into SessionRoutes (was dropped in Phase 2 cleanup; needed by the unified helper for finalizeSession). Behavior change: SessionRoutes' previous "keep idle session in memory" was deliberately replaced by the plan's "remove from Map on natural completion" — next observation reinitializes via getMessageIterator → initializeSession. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(worker): startup orphan sweep — reset 'processing' rows at boot When the worker dies (crash, kill, restart), any pending_messages rows it left in 'processing' state are by definition orphans (the only worker is dead). Single SQL UPDATE at boot resets them to 'pending' so the iterator can claim them again. Replaces the deleted processPendingQueues function (Phase 2.2). Runs in initializeBackground after dbManager.initialize() and before the initializationComplete middleware releases blocked HTTP requests, so no in-flight request can race the sweep. NOT on a periodic timer — after boot, every 'processing' row has a live consumer and a periodic sweep would race. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(worker): simplify enqueue catch, replace memorySessionId throw with re-pend 7.1: queueObservation's catch was logging two ERROR-level messages and rethrowing. The rethrow is correct (FK violations / disk full / schema drift should crash loudly), but the verbose ERROR logging pretended the error was recoverable. Reduced to one INFO line + rethrow. 7.2: ResponseProcessor's memorySessionId guard was throwing if the SDK hadn't included session_id on the first user-yield, terminal-failing the entire batch. Now warns and re-pends in-flight messages via sessionManager.markMessageFailed (which wakes the iterator — Phase 1.2). The next iteration tries again with memorySessionId hopefully captured. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): mirror builds to installed-version cache for hot reload When package.json bumps past Claude Code's installed pin, sync-marketplace wrote new code to cache/<buildVersion>/ but the worker loaded from cache/<installedVersion>/, so worker:restart reloaded the same old code. Replace the exit-on-mismatch preflight with a mirror step: when versions differ, also rsync plugin/ into cache/<installedVersion>/ so worker:restart hot-reloads new code without a Claude Code session restart. The build-version cache still gets written for the eventual `claude plugin update`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: delete dead barrel files and orphan utilities - src/sdk/index.ts (re-exports parser+prompts; nothing imported the barrel) - src/services/Context.ts (re-exports ./context/index.js; no importers) - src/services/integrations/index.ts (no importers) - src/services/worker/Search.ts (3-line barrel of ./search/index.js) - src/services/infrastructure/index.ts: drop CleanupV12_4_3 re-export - src/utils/error-messages.ts (getWorkerRestartInstructions never imported) - src/types/transcript.ts (170 LoC of types, zero importers) - src/npx-cli/_preview.ts (banner dev preview, no script wires it) Build + tests still pass; observations still flowing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(parser): drop unused detectLanguage Only the user-grammar-aware variant detectLanguageWithUserGrammars() is actually called. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(types): drop unused SdkSessionRecord + ObservationWithContext Both interfaces in src/types/database.ts had zero importers anywhere in src or tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(npx-cli): drop unused getDetectedIDEs + claudeMemDataDirectory getDetectedIDEs has no callers — install.ts uses detectInstalledIDEs directly. claudeMemDataDirectory has no callers either. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ProcessManager): drop dead orphan-reaper + signal-handler helpers Each had zero callers in src/ or tests/: - cleanupOrphanedProcesses + enumerateOrphanedProcesses - ORPHAN_PROCESS_PATTERNS + ORPHAN_MAX_AGE_MINUTES - forceKillProcess - waitForProcessesExit - createSignalHandler - resetWorkerRuntimePathCache The orphan reaper was retired in PATHFINDER Plan 02 ("OS process groups replace hand-rolled reapers", commit94d592f2) — these were the leftover pieces. shutdown.ts uses the supervisor's own kill-pgid path instead. parseElapsedTime kept (covered by tests/infrastructure/process-manager.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(scripts): delete 11 unreferenced DX/forensic scripts None of these are referenced by package.json npm scripts or docs/. All last touched on Apr 29 only as part of the comment-stripping pass — the feature code itself is older and orphaned: analyze-transformations-smart.js debug-transcript-structure.ts dump-transcript-readable.ts endless-mode-token-calculator.js extract-prompts-to-yaml.cjs extract-rich-context-examples.ts find-silent-failures.sh fix-all-timestamps.ts format-transcript-context.ts test-transcript-parser.ts transcript-to-markdown.ts These are standalone tools — runtime behavior unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(scripts): delete unused extraction/ and types/ subdirs - scripts/extraction/{extract-all-xml.py, filter-actual-xml.py, README.md} point at ~/Scripts/claude-mem/ — the user's pre-relocation path that no longer exists. Zero references in package.json, src/, or tests/. - scripts/types/export.ts duplicates ObservationRecord etc. and has no importers (CodexCliInstaller imports transcripts/types, not this). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(BranchManager): drop dead getInstalledPluginPath OpenCodeInstaller has its own (used) getInstalledPluginPath; the BranchManager copy never had any external callers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ChromaSyncState): unexport DocKind (used internally only) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(gemini): drop stale earliestPendingTimestamp / processingMessageIds Both fields were removed from ActiveSession in earlier queue-engine cleanup. Tests had been silently keeping them because the mock sessions use 'as any' to bypass strict typing, so the dead fields rode along without complaint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: drop 3 unused module-level constants - src/npx-cli/banner.ts: CURSOR_HOME, CLEAR_DOWN (banner uses CLEAR_SCREEN which combines clear-down + cursor-home into a single CSI sequence; the standalone constants were leftovers). - src/services/worker/BranchManager.ts: DEFAULT_SHELL_TIMEOUT_MS (BranchManager only uses GIT_COMMAND_TIMEOUT_MS / NPM_INSTALL_TIMEOUT_MS). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(opencode-plugin): drop dead workerPost helper Only the fire-and-forget variant (workerPostFireAndForget) is actually called. workerPost was the await-result version with no remaining caller. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: drop 8 truly-unused interface fields Verified each by grepping for `.field`, `"field"`, `'field'`, and `field:` patterns across src/ + tests/ + plugin/scripts. Where the only remaining usage was the assignment site, removed the assignments too. - GitHubStarsData: watchers_count, forks_count (only stargazers_count read) - TableColumnInfo: dflt_value (PRAGMA returns it but no caller reads it) - IndexInfo: seq (PRAGMA returns it but no caller reads it) - ObservationRecord: source_files (legacy field, no readers) - HookResult.hookSpecificOutput: permissionDecisionReason - WatchTarget: rescanIntervalMs (set in config, never read) - ShutdownResult: confirmedStopped (write-only — assigned but no reader; updated all 3 return sites to drop it) - ModePrompts: language_instruction (multilingual support never wired) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(npx-cli): reuse InstallOptions type instead of inline duplicate parseInstallOptions had its return type written out inline as an anonymous duplicate of InstallOptions. Use the canonical type (import type — zero bundle cost). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(integrations): drop unused Platform type alias The detectPlatform() function that returned this type was deleted earlier in the branch (along with getScriptExtension that consumed it). The type itself outlived its consumer; only string literals "Platform:" survive in console.log diagnostics, which don't reference the alias. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(worker): broadcast processing_status when summarize is queued broadcastSummarizeQueued was an empty no-op even though handleSummarizeByClaudeId calls it after enqueueing. The PendingMessageStore onMutate callback already fires broadcastProcessingStatus on enqueue, but calling it explicitly from broadcastSummarizeQueued ensures the spinner ticks on the moment a summary is requested even if the onMutate chain has any timing race. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(worker): keep spinner on while summary generates ClaudeProvider's SDK can pull multiple synthetic prompts (e.g. observation + summarize) before producing responses. Each pull pushed an ID to session.processingMessageIds. When the SDK's first observation response came back, ResponseProcessor.confirmProcessed deleted ALL pending message rows — including the still-in-flight summary — so getTotalQueueDepth dropped to 0 and the spinner turned off, even though the summary took another ~22s to actually generate. Tag each in-flight message with its type ({id, type}) so the response processor can pop only the FIFO message of the matching type (observation vs summarize). The summary row stays in 'processing' until its own response arrives, keeping the spinner lit through the entire summary window. Also updates Gemini/OpenRouter providers and GeneratorExitHandler for the new shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(worker): clear summary from queue on any SDK response Switch ResponseProcessor from type-aware FIFO matching to strict FIFO popping (each SDK response → 1 in-flight message consumed). This way the summary always clears when the SDK responds, even when the response is unparseable or the summary doesn't actually generate content — preventing stuck spinner / queue-depth-stuck-at-1. Spinner behavior is preserved: messages enqueued after the summary keep the queue depth elevated, and only when the SDK has responded to every prompt does the queue drain to zero. Also: when the consumed message is a 'summarize' and parsing fails, treat it as best-effort and confirmProcessed (no retry) — summaries that can't be parsed shouldn't keep retrying. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(viewer): redesign welcome card and remove source filters The first-start welcome card now explains the three feed card types (observation/summary/prompt) with color-coded badges, points users at the gear icon for settings and the project dropdown for filtering, and plugs /mem-search for recall — replacing the old two-line "ask:" prompts. Source filter tabs (Claude/Codex/etc.) are removed from the header. Filtering by AI provider was nonsense from a user POV; the project dropdown is the only header filter now. Source tracking is also stripped from useSSE, usePagination, App state, and CSS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(viewer): keep welcome card in feed column, swap rows for 3 squares Two visible problems in the previous design: the card stretched edge-to-edge while feed cards sit in a centered 650px column, and the body was a stack of long horizontal rows that scanned line-by-line. Both fixed: Feed now accepts a pinnedTop slot so the welcome card renders inside the same .feed-content column as observation cards. Body is now a 3-column grid of square feature blocks — Live feed, Tune it, Recall it — each with a custom inline SVG illustration (stacked cards with color-coded stripes, gear+sliders, magnifier over cards). Old text-row sections (welcome-card-types, welcome-card-tips, welcome-card-section, welcome-card-tip-icon) are removed. Squares stack to one column under 600px. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(viewer): convert welcome card to glassy modal with stylized logo Card now opens as a centered modal with a frosted/glass backdrop (blur + saturate) so it doubles as a proper help dialog when reopened from the header's question-mark button. Removed the observation count, project count, and "since" date — those don't make sense for a first-launch surface and felt out of place in a help context. Header art swapped from the small webp logomark to the new high-resolution sun/sunburst PNG (claude-mem-logo-stylized.png), shipped as a checked-in asset in src/ui and plugin/ui. Bigger throughout: 28px h2, 16px tagline, 88px illustrations, 26px feature padding, 1:1 aspect-ratio squares. Backdrop click and Esc both close. Mobile collapses the grid to one column and drops the aspect-ratio constraint. Reverted the unused pinnedTop slot on Feed.tsx since the welcome card is now a true overlay rather than an in-feed pinned card. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(viewer): make welcome modal actually glassy Previous version had a 55%-opacity black backdrop that almost fully blocked the underlying UI — the "glass" was just a dark plate. Now the backdrop is fully transparent (no darkening at all), the panel itself drops to 55% bg-card opacity with its existing backdrop-filter blur(28px) saturate(170%), and the feature squares drop to 35% bg-tertiary so they layer as glass-on-glass over the already-blurred panel. The header and feed below now read clearly through the modal's frosted blur. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(viewer): bulletproof square features via padding-bottom + clamp() fluid type Squares were rendering taller than wide because aspect-ratio is treated as a minimum — content can push the box past 1:1. Switched to the classic padding-bottom: 100% trick: percentage padding resolves against the parent's width, so the box is ALWAYS W × W regardless of content. Inner content sits in an absolutely-positioned flex column that can't push the shell taller. Whole modal is now desktop-first and fluid via clamp() — no media-query stair-steps for type, padding, gaps, border-radius, illustration size, or modal width. Single mobile breakpoint at <600px collapses the grid to one column and reverts the padding-bottom trick so each feature can grow to natural content height. Tightened the three feature descriptions so they fit comfortably inside the square at the desktop size. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(viewer): 15% black overlay + heavier modal shadow for elevation Backdrop goes from transparent to rgba(0,0,0,0.15) — just enough darkening to push the modal visually forward without burying the underlying UI. Modal shadow stacked: 40px/120px ambient + 16px/48px contact, both deeper, plus the existing inset 1px highlight. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(build): clear pending_messages queue on build-and-sync Rewrites scripts/clear-failed-queue.ts to talk directly to SQLite via bun:sqlite — the previous HTTP endpoints (/api/pending-queue/*) were removed during the queue engine rewrite, so the script was orphaned. Wires `npm run queue:clear` into `build-and-sync` so each rebuild starts with a clean queue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(worker): collapse parser to binary valid/invalid + clearPendingForSession model - Parser: { valid: true, observations, summary } | { valid: false } — drops kind/skipped enum dispatch - ResponseProcessor: two branches only (parseable → store + clearPendingForSession; else → no-op) - Drop processingMessageIds + per-message claim/confirm/markFailed lifecycle across 3 providers - PendingMessageStore: 226 → 140 lines; remove markFailed/transitionMessagesTo/confirmProcessed/clearFailedOlderThan/getAllPending/peekPendingTypes... wait keep peekPendingTypes - Schema migration v31+v32: drop retry_count, failed_at_epoch, completed_at_epoch, worker_pid columns - SessionQueueProcessor: delete two 1s recovery sleeps (let iterator end on error) - Server.ts/SettingsRoutes.ts: replace four magic-number setTimeout exit-flush patterns with flushResponseThen helper - GeneratorExitHandler: 183 → 117 lines (drain in-flight loop gone) Net: -181 lines. No more silent data loss via maxRetries=3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-2255): address review comments batch 1 - install.ts: needsMarketplace true when claude-code selected (P1, was no-op) - install.ts: throw on invalid --model so CLI exits non-zero - install.ts: skip worker health checks + adapt next-step copy when --no-auto-start - install.ts: repair regenerates plugin cache when missing - index.ts: readFlag rejects missing/flag-shaped values - index.ts: route flag-first invocations (e.g. `--provider claude`) to install - banner.ts: fail-open if frame payload decode throws - SearchRoutes.ts: 5s TTL cache for settings reads on hot hook path (P2) - detect-error-handling-antipatterns.ts: trailing-brace strip whitespace-tolerant - investigate-timestamps.ts: compute Dec 2025 epochs at runtime (was Dec 2024) - regenerate-claude-md.ts: include workingDir in fallback walker so root is covered - sync-marketplace.cjs: parseWorkerPort validates 1..65535 before http.request - sync-to-marketplace.sh: resolve SOURCE_DIR from script location, not cwd - Dockerfile.test-installer: bash --login sources .bashrc via .bash_profile - docs/configuration.mdx: drop nonexistent .worker.port file refs, use settings.json - docs/architecture-overview.md: dynamic port + queue model after parser collapse - docs/architecture/worker-service.mdx: dynamic port example + drop port-file claim - docs/platform-integration.mdx: WORKER_BASE_URL pattern, drop hardcoded 37777 - install/public/install.sh: Node 20 floor (was 18) to match docs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-2255): reset claimed messages to pending on early-return paths ResponseProcessor returns early in two cases: - parser invalid (unparseable response) - memorySessionId not yet captured Both paths previously left the just-claimed message in `status='processing'`, which counts toward `getPendingCount`. The generator-exit handler then sees `pendingCount > 0` and respawns the generator, looping until the restart guard trips and `clearPendingForSession` deletes the message — silent data loss. Calling `resetProcessingToPending` on these paths lets the next generator pass re-claim the message and try again, instead of burning the restart budget on no-op respawns. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-2255): swebench fallback row + troubleshooting port path - evals/swebench/run-batch.py: append fallback prediction row when orchestrator future raises, preserving "never drop an instance" guarantee - docs/troubleshooting.mdx: drop nonexistent .worker.port / worker.port file references; use settings.json + /api/health for port discovery Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-2255): memoize per-project observation count for welcome-hint hot path handleContextInject runs on every PostToolUse hook (after every Read/Edit). The welcome-hint block ran a COUNT(*) on observations for every call once CLAUDE_MEM_WELCOME_HINT_ENABLED was true. Observation counts are monotonically increasing — once a project has any observations it always will — so cache the positive result in a Set and skip the COUNT(*) on subsequent requests. Combined with the 5s settings TTL added earlier, the steady-state cost on the hook hot path drops to a Set lookup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-2255): use clearProcessingForSession on AI-success path clearPendingForSession deletes ALL rows for the session. On the success path of processAgentResponse, that's wrong: messages that arrived as 'pending' during the (1-5s) AI response latency get deleted along with the 'processing' row we just consumed. In a hook burst (three quick PostToolUse hooks), B and C land while A is in flight; A's success then nukes B and C — silent data loss. Add a status-scoped clearProcessingForSession to PendingMessageStore + SessionManager, and use it in ResponseProcessor's success path. The unconditional clearPendingForSession remains correct in GeneratorExitHandler for hard-stop / restart-guard-trip paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Revert "fix(pr-2255): use clearProcessingForSession on AI-success path" This reverts commita08995299c. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
#!/usr/bin/env bun
|
|
import ts from 'typescript';
|
|
import postcss from 'postcss';
|
|
import { unified } from 'unified';
|
|
import remarkParse from 'remark-parse';
|
|
import remarkMdx from 'remark-mdx';
|
|
import { visit } from 'unist-util-visit';
|
|
import { parse as parse5Parse, parseFragment as parse5ParseFragment } from 'parse5';
|
|
import { readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
import { execSync } from 'node:child_process';
|
|
import { extname, basename, join } from 'node:path';
|
|
|
|
interface CliOptions {
|
|
root: string;
|
|
check: boolean;
|
|
dryRun: boolean;
|
|
verbose: boolean;
|
|
}
|
|
|
|
function parseArgs(argv: string[]): CliOptions {
|
|
let root = process.cwd();
|
|
let check = false;
|
|
let dryRun = false;
|
|
let verbose = false;
|
|
for (const arg of argv.slice(2)) {
|
|
if (arg === '--check') check = true;
|
|
else if (arg === '--dry-run') dryRun = true;
|
|
else if (arg === '--verbose' || arg === '-v') verbose = true;
|
|
else if (arg === '--help' || arg === '-h') {
|
|
printHelp();
|
|
process.exit(0);
|
|
} else if (!arg.startsWith('-')) {
|
|
root = arg;
|
|
} else {
|
|
console.error(`Unknown flag: ${arg}`);
|
|
printHelp();
|
|
process.exit(2);
|
|
}
|
|
}
|
|
return { root, check, dryRun, verbose };
|
|
}
|
|
|
|
function printHelp(): void {
|
|
console.log(`Usage: bun scripts/strip-comments.ts [path] [flags]
|
|
|
|
Strips narrative comments from all git-tracked files using real parsers:
|
|
JS/TS/JSX -> TypeScript compiler (ts.getLeadingCommentRanges)
|
|
CSS/SCSS -> postcss + postcss-discard-comments
|
|
MD/MDX -> unified + remark-parse + remark-mdx + remark-stringify
|
|
HTML -> parse5 (parse, drop #comment nodes, serialize)
|
|
shell/py -> line-based hash stripper (kept; no library worth its weight)
|
|
|
|
Build directives are preserved (shebangs, @ts-*, eslint-disable, biome-ignore,
|
|
prettier-ignore, triple-slash references, webpack magic, /*! license keep).
|
|
Files containing @strip-comments-keep in the first 4096 bytes are skipped.
|
|
|
|
Flags:
|
|
--check Exit non-zero if any file would change. Doesn't write.
|
|
--dry-run Print what would change without writing.
|
|
--verbose Log each changed file.
|
|
-h, --help Show this help.
|
|
|
|
After running, review the diff with \`git diff\`, then run
|
|
\`npm run build-and-sync\` to confirm typecheck and build still pass.
|
|
`);
|
|
}
|
|
|
|
const SKIP_PATHS = new Set<string>([
|
|
'package-lock.json',
|
|
'bun.lock',
|
|
'bun.lockb',
|
|
'plugin/scripts/claude-mem',
|
|
]);
|
|
|
|
const SKIP_BASENAMES = new Set<string>([
|
|
'LICENSE',
|
|
'COPYING',
|
|
'NOTICE',
|
|
]);
|
|
|
|
const BINARY_EXT = new Set<string>([
|
|
'.svg', '.webp', '.woff', '.woff2', '.gif', '.png', '.jpg', '.jpeg', '.ico', '.pdf', '.zip',
|
|
]);
|
|
|
|
const JS_LIKE_EXT = new Set<string>(['.ts', '.tsx', '.js', '.jsx', '.cjs', '.mjs']);
|
|
const MD_EXT = new Set<string>(['.md', '.mdx']);
|
|
const HTML_EXT = new Set<string>(['.html', '.htm']);
|
|
const CSS_LIKE_EXT = new Set<string>(['.css', '.scss', '.less']);
|
|
const HASH_LIKE_EXT = new Set<string>(['.sh', '.bash', '.zsh', '.py']);
|
|
const HASH_LIKE_BASE = new Set<string>([
|
|
'.gitignore', '.npmignore', '.dockerignore', '.gitattributes', '.npmrc', '.editorconfig',
|
|
]);
|
|
|
|
const KEEP_MARKER = /@strip-comments-keep/;
|
|
const NUL_BYTE = 0;
|
|
|
|
function isDirectiveJs(text: string): boolean {
|
|
if (text.startsWith('///')) return true;
|
|
if (text.startsWith('/*!')) return true;
|
|
if (KEEP_MARKER.test(text)) return true;
|
|
const inner = text
|
|
.replace(/^\/\/\s*/, '')
|
|
.replace(/^\/\*+\s*/, '')
|
|
.replace(/\s*\*+\/$/, '')
|
|
.trim();
|
|
return /^(@ts-(?:ignore|expect-error|nocheck|check)\b|eslint-(?:disable|enable)|biome-ignore|prettier-ignore|@vitest-|c8\s+ignore|istanbul\s+ignore|@__PURE__|#__PURE__|webpack(?:ChunkName|Prefetch|Preload|Include|Exclude|Mode|Ignore))/.test(inner);
|
|
}
|
|
|
|
function scriptKindFor(ext: string): ts.ScriptKind {
|
|
switch (ext) {
|
|
case '.tsx': return ts.ScriptKind.TSX;
|
|
case '.jsx': return ts.ScriptKind.JSX;
|
|
case '.js':
|
|
case '.cjs':
|
|
case '.mjs': return ts.ScriptKind.JS;
|
|
default: return ts.ScriptKind.TS;
|
|
}
|
|
}
|
|
|
|
function parseDiagnosticsCount(sf: ts.SourceFile): number {
|
|
return ((sf as unknown as { parseDiagnostics?: ts.Diagnostic[] }).parseDiagnostics ?? []).length;
|
|
}
|
|
|
|
function stripJsLike(source: string, ext: string): string {
|
|
const kind = scriptKindFor(ext);
|
|
const sf = ts.createSourceFile('input', source, ts.ScriptTarget.Latest, true, kind);
|
|
const beforeErrs = parseDiagnosticsCount(sf);
|
|
|
|
const seen = new Set<string>();
|
|
const ranges: Array<[number, number]> = [];
|
|
|
|
function visitNode(node: ts.Node): void {
|
|
const leading = ts.getLeadingCommentRanges(source, node.getFullStart()) || [];
|
|
const trailing = ts.getTrailingCommentRanges(source, node.getEnd()) || [];
|
|
for (const r of leading) addRange(r);
|
|
for (const r of trailing) addRange(r);
|
|
ts.forEachChild(node, visitNode);
|
|
}
|
|
function addRange(r: ts.CommentRange): void {
|
|
const key = `${r.pos}-${r.end}`;
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
const text = source.slice(r.pos, r.end);
|
|
if (isDirectiveJs(text)) return;
|
|
ranges.push([r.pos, r.end]);
|
|
}
|
|
|
|
visitNode(sf);
|
|
const out = spliceRanges(source, ranges);
|
|
|
|
const after = ts.createSourceFile('check', out, ts.ScriptTarget.Latest, true, kind);
|
|
const afterErrs = parseDiagnosticsCount(after);
|
|
if (afterErrs > beforeErrs) {
|
|
throw new Error(`strip introduced ${afterErrs - beforeErrs} new parse error(s); refusing to write`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function collapseBlankLines(s: string): string {
|
|
return s.replace(/(?:[ \t]*\n){3,}/g, '\n\n');
|
|
}
|
|
|
|
function spliceRanges(source: string, ranges: Array<[number, number]>): string {
|
|
ranges.sort((a, b) => a[0] - b[0]);
|
|
let out = source;
|
|
for (let i = ranges.length - 1; i >= 0; i--) {
|
|
const [s, e] = ranges[i];
|
|
let removeStart = s;
|
|
let removeEnd = e;
|
|
let lineStart = s;
|
|
while (lineStart > 0 && (out[lineStart - 1] === ' ' || out[lineStart - 1] === '\t')) {
|
|
lineStart--;
|
|
}
|
|
if (lineStart === 0 || out[lineStart - 1] === '\n') {
|
|
removeStart = lineStart;
|
|
if (out[removeEnd] === '\n') removeEnd++;
|
|
}
|
|
out = out.slice(0, removeStart) + out.slice(removeEnd);
|
|
}
|
|
return collapseBlankLines(out);
|
|
}
|
|
|
|
function stripCss(source: string): string {
|
|
const root = postcss.parse(source);
|
|
const ranges: Array<[number, number]> = [];
|
|
root.walkComments((node) => {
|
|
const start = node.source?.start?.offset;
|
|
const end = node.source?.end?.offset;
|
|
if (typeof start !== 'number' || typeof end !== 'number') return;
|
|
const raw = source.slice(start, end);
|
|
if (raw.startsWith('/*!')) return;
|
|
if (KEEP_MARKER.test(raw)) return;
|
|
if (/\/\*\s*prettier-ignore/.test(raw)) return;
|
|
ranges.push([start, end]);
|
|
});
|
|
return spliceRanges(source, ranges);
|
|
}
|
|
|
|
const HTML_COMMENT_RE = /^<!--[\s\S]*-->$/;
|
|
|
|
function isMdxNarrativeComment(value: string): boolean {
|
|
const trimmed = value.trim();
|
|
if (/^\/\*[\s\S]*\*\/$/.test(trimmed)) return true;
|
|
if (/^\/\/.*$/.test(trimmed)) return true;
|
|
return false;
|
|
}
|
|
|
|
interface MdNode {
|
|
type: string;
|
|
value?: string;
|
|
position?: { start?: { offset?: number }; end?: { offset?: number } };
|
|
}
|
|
|
|
function stripMarkdown(source: string, isMdx: boolean): string {
|
|
const processor = unified().use(remarkParse);
|
|
if (isMdx) processor.use(remarkMdx);
|
|
const tree = processor.parse(source);
|
|
const ranges: Array<[number, number]> = [];
|
|
visit(tree, (node) => {
|
|
const n = node as MdNode;
|
|
const start = n.position?.start?.offset;
|
|
const end = n.position?.end?.offset;
|
|
if (typeof start !== 'number' || typeof end !== 'number') return;
|
|
if (n.type === 'html' && typeof n.value === 'string' && HTML_COMMENT_RE.test(n.value.trim())) {
|
|
if (KEEP_MARKER.test(n.value)) return;
|
|
ranges.push([start, end]);
|
|
return;
|
|
}
|
|
if (isMdx && (n.type === 'mdxFlowExpression' || n.type === 'mdxTextExpression')) {
|
|
const v = n.value ?? '';
|
|
if (isMdxNarrativeComment(v) && !KEEP_MARKER.test(v)) {
|
|
ranges.push([start, end]);
|
|
}
|
|
}
|
|
});
|
|
return spliceRanges(source, ranges);
|
|
}
|
|
|
|
interface Parse5Node {
|
|
nodeName: string;
|
|
childNodes?: Parse5Node[];
|
|
data?: string;
|
|
sourceCodeLocation?: { startOffset?: number; endOffset?: number };
|
|
}
|
|
|
|
function stripHtml(source: string, isFragment: boolean): string {
|
|
const tree = (isFragment
|
|
? parse5ParseFragment(source, { sourceCodeLocationInfo: true })
|
|
: parse5Parse(source, { sourceCodeLocationInfo: true })) as unknown as Parse5Node;
|
|
const ranges: Array<[number, number]> = [];
|
|
collectHtmlCommentRanges(tree, source, ranges);
|
|
return spliceRanges(source, ranges);
|
|
}
|
|
|
|
function collectHtmlCommentRanges(node: Parse5Node, source: string, ranges: Array<[number, number]>): void {
|
|
if (node.nodeName === '#comment') {
|
|
const start = node.sourceCodeLocation?.startOffset;
|
|
const end = node.sourceCodeLocation?.endOffset;
|
|
if (typeof start === 'number' && typeof end === 'number') {
|
|
const raw = source.slice(start, end);
|
|
if (!KEEP_MARKER.test(raw)) {
|
|
ranges.push([start, end]);
|
|
}
|
|
}
|
|
}
|
|
if (node.childNodes) {
|
|
for (const child of node.childNodes) {
|
|
collectHtmlCommentRanges(child, source, ranges);
|
|
}
|
|
}
|
|
}
|
|
|
|
function stripHashComments(source: string, preserveShebang: boolean): string {
|
|
const lines = source.split('\n');
|
|
const out: string[] = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (i === 0 && preserveShebang && line.startsWith('#!')) {
|
|
out.push(line);
|
|
continue;
|
|
}
|
|
const stripped = stripHashFromLine(line);
|
|
if (stripped === '' && line.trim().startsWith('#')) {
|
|
continue;
|
|
}
|
|
out.push(stripped);
|
|
}
|
|
return collapseBlankLines(out.join('\n'));
|
|
}
|
|
|
|
function stripHashFromLine(line: string): string {
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
let inBacktick = false;
|
|
for (let i = 0; i < line.length; i++) {
|
|
const c = line[i];
|
|
if (c === '\\' && i + 1 < line.length) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (!inDouble && !inBacktick && c === "'") inSingle = !inSingle;
|
|
else if (!inSingle && !inBacktick && c === '"') inDouble = !inDouble;
|
|
else if (!inSingle && !inDouble && c === '`') inBacktick = !inBacktick;
|
|
else if (!inSingle && !inDouble && !inBacktick && c === '#') {
|
|
if (i === 0 || /\s/.test(line[i - 1])) {
|
|
return line.slice(0, i).replace(/[ \t]+$/, '');
|
|
}
|
|
}
|
|
}
|
|
return line;
|
|
}
|
|
|
|
interface Stats {
|
|
changed: number;
|
|
unchanged: number;
|
|
skipped: number;
|
|
bytesBefore: number;
|
|
bytesAfter: number;
|
|
errors: string[];
|
|
changedFiles: string[];
|
|
reformatRatioFlags: string[];
|
|
}
|
|
|
|
function processFile(absPath: string, relPath: string, stats: Stats, opts: CliOptions): void {
|
|
if (SKIP_PATHS.has(relPath)) {
|
|
stats.skipped++;
|
|
return;
|
|
}
|
|
const base = basename(relPath);
|
|
if (SKIP_BASENAMES.has(base)) {
|
|
stats.skipped++;
|
|
return;
|
|
}
|
|
const ext = extname(relPath).toLowerCase();
|
|
if (BINARY_EXT.has(ext)) {
|
|
stats.skipped++;
|
|
return;
|
|
}
|
|
|
|
let st;
|
|
try {
|
|
st = statSync(absPath);
|
|
} catch {
|
|
stats.skipped++;
|
|
return;
|
|
}
|
|
if (!st.isFile()) {
|
|
stats.skipped++;
|
|
return;
|
|
}
|
|
|
|
let raw: Buffer;
|
|
try {
|
|
raw = readFileSync(absPath);
|
|
} catch {
|
|
stats.skipped++;
|
|
return;
|
|
}
|
|
|
|
if (raw.includes(NUL_BYTE)) {
|
|
stats.skipped++;
|
|
return;
|
|
}
|
|
|
|
const original = raw.toString('utf-8');
|
|
if (KEEP_MARKER.test(original.slice(0, 4096))) {
|
|
stats.skipped++;
|
|
return;
|
|
}
|
|
|
|
let stripped: string;
|
|
try {
|
|
if (JS_LIKE_EXT.has(ext)) {
|
|
stripped = stripJsLike(original, ext);
|
|
} else if (CSS_LIKE_EXT.has(ext)) {
|
|
stripped = stripCss(original);
|
|
} else if (MD_EXT.has(ext)) {
|
|
stripped = stripMarkdown(original, ext === '.mdx');
|
|
} else if (HTML_EXT.has(ext)) {
|
|
stripped = stripHtml(original, false);
|
|
} else if (
|
|
HASH_LIKE_EXT.has(ext) ||
|
|
HASH_LIKE_BASE.has(base) ||
|
|
base === 'Dockerfile' ||
|
|
base.startsWith('Dockerfile.')
|
|
) {
|
|
stripped = stripHashComments(original, true);
|
|
} else {
|
|
stats.skipped++;
|
|
return;
|
|
}
|
|
} catch (e: unknown) {
|
|
stats.errors.push(`${relPath}: ${(e as Error).message}`);
|
|
return;
|
|
}
|
|
|
|
if (stripped === original) {
|
|
stats.unchanged++;
|
|
return;
|
|
}
|
|
|
|
const removedBytes = original.length - stripped.length;
|
|
if (removedBytes > 0) {
|
|
const lineDiff = Math.abs(original.split('\n').length - stripped.split('\n').length);
|
|
const removedFraction = removedBytes / Math.max(original.length, 1);
|
|
if (lineDiff > 20 && removedFraction < 0.005) {
|
|
stats.reformatRatioFlags.push(relPath);
|
|
}
|
|
}
|
|
|
|
stats.changed++;
|
|
stats.bytesBefore += original.length;
|
|
stats.bytesAfter += stripped.length;
|
|
stats.changedFiles.push(relPath);
|
|
|
|
if (!opts.check && !opts.dryRun) {
|
|
writeFileSync(absPath, stripped, 'utf-8');
|
|
}
|
|
if (opts.verbose || opts.dryRun) {
|
|
console.log(`would change: ${relPath}`);
|
|
}
|
|
}
|
|
|
|
function main(): void {
|
|
const opts = parseArgs(process.argv);
|
|
|
|
const stats: Stats = {
|
|
changed: 0,
|
|
unchanged: 0,
|
|
skipped: 0,
|
|
bytesBefore: 0,
|
|
bytesAfter: 0,
|
|
errors: [],
|
|
changedFiles: [],
|
|
reformatRatioFlags: [],
|
|
};
|
|
|
|
let files: string[];
|
|
try {
|
|
files = execSync('git ls-files', { cwd: opts.root, encoding: 'utf-8' })
|
|
.split('\n')
|
|
.map((l) => l.trim())
|
|
.filter((l) => l.length > 0);
|
|
} catch (e) {
|
|
console.error(`git ls-files failed in ${opts.root}: ${(e as Error).message}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
for (const rel of files) {
|
|
processFile(join(opts.root, rel), rel, stats, opts);
|
|
}
|
|
|
|
const suffix = opts.check ? ' (check mode, no writes)' : opts.dryRun ? ' (dry-run, no writes)' : '';
|
|
console.log(`Changed: ${stats.changed}${suffix}`);
|
|
console.log(`Unchanged: ${stats.unchanged}`);
|
|
console.log(`Skipped: ${stats.skipped}`);
|
|
if (stats.changed > 0) {
|
|
const saved = stats.bytesBefore - stats.bytesAfter;
|
|
const pct = ((saved / stats.bytesBefore) * 100).toFixed(1);
|
|
console.log(`Bytes: ${stats.bytesBefore} -> ${stats.bytesAfter} (-${saved}, -${pct}%)`);
|
|
}
|
|
if (stats.reformatRatioFlags.length > 0) {
|
|
console.log(`Reformat-suspect (${stats.reformatRatioFlags.length}): library may be reformatting more than stripping`);
|
|
for (const f of stats.reformatRatioFlags.slice(0, 10)) console.log(` ${f}`);
|
|
}
|
|
if (stats.errors.length > 0) {
|
|
console.log(`Errors (${stats.errors.length}):`);
|
|
for (const e of stats.errors.slice(0, 20)) {
|
|
console.log(` ${e}`);
|
|
}
|
|
}
|
|
|
|
if (stats.errors.length > 0) process.exit(1);
|
|
if (opts.check && stats.changed > 0) process.exit(1);
|
|
}
|
|
|
|
main();
|