UX redesign: installer + provider rename + /learn-codebase + welcome card + SessionStart hint (#2255)
* 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
Reverts eb6fc157. Restores isBannerEnabled to the state at commit
8e448015 (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", commit 94d592f2) — 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 commit a08995299c30cbad36bddc3e5bddda7af8604b35.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,63 +1,38 @@
|
||||
/**
|
||||
* EnvManager - Centralized environment variable management for claude-mem
|
||||
*
|
||||
* Provides isolated credential storage in ~/.claude-mem/.env
|
||||
* This ensures claude-mem uses its own configured credentials,
|
||||
* not random ANTHROPIC_API_KEY values from project .env files.
|
||||
*
|
||||
* Issue #733: SDK was auto-discovering API keys from user's shell environment,
|
||||
* causing memory operations to bill personal API accounts instead of CLI subscription.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// Path to claude-mem's centralized .env file
|
||||
const DATA_DIR = join(homedir(), '.claude-mem');
|
||||
export const ENV_FILE_PATH = join(DATA_DIR, '.env');
|
||||
|
||||
// Environment variables to STRIP from subprocess environment (blocklist approach)
|
||||
// Only ANTHROPIC_API_KEY is stripped because it's the specific variable that causes
|
||||
// Issue #733: project .env files set ANTHROPIC_API_KEY which the SDK auto-discovers,
|
||||
// causing memory operations to bill personal API accounts instead of CLI subscription.
|
||||
//
|
||||
// All other env vars (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, system vars, etc.)
|
||||
// are passed through to avoid breaking CLI authentication, proxies, and platform features.
|
||||
const BLOCKED_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY', // Issue #733: Prevent auto-discovery from project .env files
|
||||
'CLAUDECODE', // Prevent "cannot be launched inside another Claude Code session" error
|
||||
];
|
||||
|
||||
export interface ClaudeMemEnv {
|
||||
// Credentials (optional - empty means use CLI billing for Claude)
|
||||
ANTHROPIC_API_KEY?: string;
|
||||
ANTHROPIC_BASE_URL?: string;
|
||||
GEMINI_API_KEY?: string;
|
||||
OPENROUTER_API_KEY?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a .env file content into key-value pairs
|
||||
*/
|
||||
function parseEnvFile(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Parse KEY=value format
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
|
||||
// Remove surrounding quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
@@ -71,9 +46,6 @@ function parseEnvFile(content: string): Record<string, string> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize key-value pairs to .env file format
|
||||
*/
|
||||
function serializeEnvFile(env: Record<string, string>): string {
|
||||
const lines: string[] = [
|
||||
'# claude-mem credentials',
|
||||
@@ -84,7 +56,6 @@ function serializeEnvFile(env: Record<string, string>): string {
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value) {
|
||||
// Quote values that contain spaces or special characters
|
||||
const needsQuotes = /[\s#=]/.test(value);
|
||||
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
|
||||
}
|
||||
@@ -93,10 +64,6 @@ function serializeEnvFile(env: Record<string, string>): string {
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load credentials from ~/.claude-mem/.env
|
||||
* Returns empty object if file doesn't exist (means use CLI billing)
|
||||
*/
|
||||
export function loadClaudeMemEnv(): ClaudeMemEnv {
|
||||
if (!existsSync(ENV_FILE_PATH)) {
|
||||
return {};
|
||||
@@ -106,7 +73,6 @@ export function loadClaudeMemEnv(): ClaudeMemEnv {
|
||||
const content = readFileSync(ENV_FILE_PATH, 'utf-8');
|
||||
const parsed = parseEnvFile(content);
|
||||
|
||||
// Only return managed credential keys
|
||||
const result: ClaudeMemEnv = {};
|
||||
if (parsed.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = parsed.ANTHROPIC_API_KEY;
|
||||
if (parsed.ANTHROPIC_BASE_URL) result.ANTHROPIC_BASE_URL = parsed.ANTHROPIC_BASE_URL;
|
||||
@@ -120,21 +86,14 @@ export function loadClaudeMemEnv(): ClaudeMemEnv {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credentials to ~/.claude-mem/.env
|
||||
*/
|
||||
export function saveClaudeMemEnv(env: ClaudeMemEnv): void {
|
||||
let existing: Record<string, string> = {};
|
||||
try {
|
||||
// Ensure directory exists with restricted permissions (owner only)
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
// Fix permissions on pre-existing directories (mode: is only applied on creation)
|
||||
// Note: On Windows, chmod has no effect — permissions are controlled via ACLs.
|
||||
chmodSync(DATA_DIR, 0o700);
|
||||
|
||||
// Load existing to preserve any extra keys
|
||||
existing = existsSync(ENV_FILE_PATH)
|
||||
? parseEnvFile(readFileSync(ENV_FILE_PATH, 'utf-8'))
|
||||
: {};
|
||||
@@ -144,10 +103,8 @@ export function saveClaudeMemEnv(env: ClaudeMemEnv): void {
|
||||
throw normalizedError;
|
||||
}
|
||||
|
||||
// Update with new values
|
||||
const updated: Record<string, string> = { ...existing };
|
||||
|
||||
// Only update managed keys
|
||||
if (env.ANTHROPIC_API_KEY !== undefined) {
|
||||
if (env.ANTHROPIC_API_KEY) {
|
||||
updated.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
|
||||
@@ -179,9 +136,6 @@ export function saveClaudeMemEnv(env: ClaudeMemEnv): void {
|
||||
|
||||
try {
|
||||
writeFileSync(ENV_FILE_PATH, serializeEnvFile(updated), { encoding: 'utf-8', mode: 0o600 });
|
||||
// Explicitly set permissions in case the file already existed before this fix.
|
||||
// writeFileSync's mode option only applies on file creation (O_CREAT), not on overwrites.
|
||||
// Note: On Windows, chmod has no effect — permissions are controlled via ACLs.
|
||||
chmodSync(ENV_FILE_PATH, 0o600);
|
||||
} catch (error: unknown) {
|
||||
logger.error('ENV', 'Failed to save .env file', { path: ENV_FILE_PATH }, error instanceof Error ? error : new Error(String(error)));
|
||||
@@ -189,24 +143,7 @@ export function saveClaudeMemEnv(env: ClaudeMemEnv): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clean environment for spawning SDK subprocesses
|
||||
*
|
||||
* Uses a BLOCKLIST approach: inherits the full process environment but strips
|
||||
* only ANTHROPIC_API_KEY to prevent Issue #733 (accidental billing from project .env files).
|
||||
*
|
||||
* All other variables pass through, including:
|
||||
* - ANTHROPIC_AUTH_TOKEN (CLI subscription auth)
|
||||
* - ANTHROPIC_BASE_URL (custom proxy endpoints)
|
||||
* - Platform-specific vars (USERPROFILE, XDG_*, etc.)
|
||||
*
|
||||
* If claude-mem has an explicit ANTHROPIC_API_KEY in ~/.claude-mem/.env, it's re-injected
|
||||
* after stripping, so the managed credential takes precedence over any ambient value.
|
||||
*
|
||||
* @param includeCredentials - Whether to include API keys from ~/.claude-mem/.env (default: true)
|
||||
*/
|
||||
export function buildIsolatedEnv(includeCredentials: boolean = true): Record<string, string> {
|
||||
// 1. Start with full process environment
|
||||
const isolatedEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !BLOCKED_ENV_VARS.includes(key)) {
|
||||
@@ -214,33 +151,19 @@ export function buildIsolatedEnv(includeCredentials: boolean = true): Record<str
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Override SDK entrypoint marker
|
||||
isolatedEnv.CLAUDE_CODE_ENTRYPOINT = 'sdk-ts';
|
||||
|
||||
// 2a. Mark this as an internal claude-mem subprocess so spawned hooks can
|
||||
// skip tracking unconditionally. This is the single trust boundary for
|
||||
// observer-session detection — every consumer can check
|
||||
// process.env.CLAUDE_MEM_INTERNAL instead of repeating cwd-based exclusion
|
||||
// checks (which inevitably drift; see #2118 / #2126).
|
||||
isolatedEnv.CLAUDE_MEM_INTERNAL = '1';
|
||||
|
||||
// 3. Re-inject managed credentials from claude-mem's .env file
|
||||
if (includeCredentials) {
|
||||
const credentials = loadClaudeMemEnv();
|
||||
|
||||
// Only add ANTHROPIC_API_KEY if explicitly configured in claude-mem
|
||||
// If not configured, CLI billing will be used (via ANTHROPIC_AUTH_TOKEN passthrough)
|
||||
if (credentials.ANTHROPIC_API_KEY) {
|
||||
isolatedEnv.ANTHROPIC_API_KEY = credentials.ANTHROPIC_API_KEY;
|
||||
}
|
||||
// Override ANTHROPIC_BASE_URL from .env if configured
|
||||
// This ensures the SDK subprocess uses a stable API endpoint instead of
|
||||
// inheriting a dynamic local proxy port that may become stale
|
||||
if (credentials.ANTHROPIC_BASE_URL) {
|
||||
isolatedEnv.ANTHROPIC_BASE_URL = credentials.ANTHROPIC_BASE_URL;
|
||||
}
|
||||
// Note: GEMINI_API_KEY and OPENROUTER_API_KEY pass through from process.env,
|
||||
// but claude-mem's .env takes precedence if configured
|
||||
if (credentials.GEMINI_API_KEY) {
|
||||
isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY;
|
||||
}
|
||||
@@ -248,10 +171,6 @@ export function buildIsolatedEnv(includeCredentials: boolean = true): Record<str
|
||||
isolatedEnv.OPENROUTER_API_KEY = credentials.OPENROUTER_API_KEY;
|
||||
}
|
||||
|
||||
// 4. Pass through Claude CLI's OAuth token if available (fallback for CLI subscription billing)
|
||||
// When no ANTHROPIC_API_KEY is configured, the spawned CLI uses subscription billing
|
||||
// which requires either ~/.claude/.credentials.json or CLAUDE_CODE_OAUTH_TOKEN.
|
||||
// The worker inherits this token from the Claude Code session that started it.
|
||||
if (!isolatedEnv.ANTHROPIC_API_KEY && process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
isolatedEnv.CLAUDE_CODE_OAUTH_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
}
|
||||
@@ -260,27 +179,16 @@ export function buildIsolatedEnv(includeCredentials: boolean = true): Record<str
|
||||
return isolatedEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific credential from claude-mem's .env
|
||||
* Returns undefined if not set (which means use default/CLI billing)
|
||||
*/
|
||||
export function getCredential(key: keyof ClaudeMemEnv): string | undefined {
|
||||
const env = loadClaudeMemEnv();
|
||||
return env[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if claude-mem has an Anthropic API key configured
|
||||
* If false, it means CLI billing should be used
|
||||
*/
|
||||
export function hasAnthropicApiKey(): boolean {
|
||||
const env = loadClaudeMemEnv();
|
||||
return !!env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth method description for logging
|
||||
*/
|
||||
export function getAuthMethodDescription(): string {
|
||||
if (hasAnthropicApiKey()) {
|
||||
return 'API key (from ~/.claude-mem/.env)';
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
/**
|
||||
* SettingsDefaultsManager
|
||||
*
|
||||
* Single source of truth for all default configuration values.
|
||||
* Provides methods to get defaults with optional environment variable overrides.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
// NOTE: Do NOT import logger here - it creates a circular dependency
|
||||
// logger.ts depends on SettingsDefaultsManager for its initialization
|
||||
|
||||
export interface SettingsDefaults {
|
||||
CLAUDE_MEM_MODEL: string;
|
||||
@@ -17,67 +9,56 @@ export interface SettingsDefaults {
|
||||
CLAUDE_MEM_WORKER_PORT: string;
|
||||
CLAUDE_MEM_WORKER_HOST: string;
|
||||
CLAUDE_MEM_SKIP_TOOLS: string;
|
||||
// AI Provider Configuration
|
||||
CLAUDE_MEM_PROVIDER: string; // 'claude' | 'gemini' | 'openrouter'
|
||||
CLAUDE_MEM_CLAUDE_AUTH_METHOD: string; // 'cli' | 'api' - how Claude provider authenticates
|
||||
CLAUDE_MEM_PROVIDER: string;
|
||||
CLAUDE_MEM_CLAUDE_AUTH_METHOD: string;
|
||||
CLAUDE_MEM_GEMINI_API_KEY: string;
|
||||
CLAUDE_MEM_GEMINI_MODEL: string; // 'gemini-2.5-flash-lite' | 'gemini-2.5-flash' | 'gemini-3-flash-preview'
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: string; // 'true' | 'false' - enable rate limiting for free tier
|
||||
CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES: string; // Max messages in Gemini context window (prevents O(N²) cost growth)
|
||||
CLAUDE_MEM_GEMINI_MAX_TOKENS: string; // Max estimated tokens for Gemini context (~100k safety limit)
|
||||
CLAUDE_MEM_GEMINI_MODEL: string;
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: string;
|
||||
CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES: string;
|
||||
CLAUDE_MEM_GEMINI_MAX_TOKENS: string;
|
||||
CLAUDE_MEM_OPENROUTER_API_KEY: string;
|
||||
CLAUDE_MEM_OPENROUTER_MODEL: string;
|
||||
CLAUDE_MEM_OPENROUTER_SITE_URL: string;
|
||||
CLAUDE_MEM_OPENROUTER_APP_NAME: string;
|
||||
CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES: string;
|
||||
CLAUDE_MEM_OPENROUTER_MAX_TOKENS: string;
|
||||
// System Configuration
|
||||
CLAUDE_MEM_DATA_DIR: string;
|
||||
CLAUDE_MEM_LOG_LEVEL: string;
|
||||
CLAUDE_MEM_PYTHON_VERSION: string;
|
||||
CLAUDE_CODE_PATH: string;
|
||||
CLAUDE_MEM_MODE: string;
|
||||
// Token Economics
|
||||
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: string;
|
||||
// Display Configuration
|
||||
CLAUDE_MEM_CONTEXT_FULL_COUNT: string;
|
||||
CLAUDE_MEM_CONTEXT_FULL_FIELD: string;
|
||||
CLAUDE_MEM_CONTEXT_SESSION_COUNT: string;
|
||||
// Feature Toggles
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT: string;
|
||||
CLAUDE_MEM_WELCOME_HINT_ENABLED: string;
|
||||
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: string;
|
||||
CLAUDE_MEM_FOLDER_USE_LOCAL_MD: string; // 'true' | 'false' - write to CLAUDE.local.md instead of CLAUDE.md
|
||||
CLAUDE_MEM_TRANSCRIPTS_ENABLED: string; // 'true' | 'false' - enable transcript watcher ingestion for Codex and other transcript-based clients
|
||||
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: string; // Path to transcript watcher config JSON
|
||||
// Process Management
|
||||
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: string; // Max concurrent Claude SDK agent subprocesses (default: 2)
|
||||
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: string; // Plan 05 Phase 8 — consecutive hook→worker unreachable failures before exit code 2 (default: 3)
|
||||
// Exclusion Settings
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||
// Semantic Context Injection (per-prompt via Chroma)
|
||||
CLAUDE_MEM_SEMANTIC_INJECT: string; // 'true' | 'false' - inject relevant observations on each prompt
|
||||
CLAUDE_MEM_SEMANTIC_INJECT_LIMIT: string; // Max observations to inject per prompt
|
||||
// Tier Routing (model selection by queue complexity)
|
||||
CLAUDE_MEM_TIER_ROUTING_ENABLED: string; // 'true' | 'false' - enable model tier routing
|
||||
CLAUDE_MEM_TIER_SIMPLE_MODEL: string; // Tier alias or model ID for simple tool observations (Read, Glob, Grep)
|
||||
CLAUDE_MEM_TIER_SUMMARY_MODEL: string; // Tier alias or model ID for session summaries
|
||||
// Chroma Vector Database Configuration
|
||||
CLAUDE_MEM_CHROMA_ENABLED: string; // 'true' | 'false' - set to 'false' for SQLite-only mode
|
||||
CLAUDE_MEM_CHROMA_MODE: string; // 'local' | 'remote'
|
||||
CLAUDE_MEM_FOLDER_USE_LOCAL_MD: string;
|
||||
CLAUDE_MEM_TRANSCRIPTS_ENABLED: string;
|
||||
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: string;
|
||||
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: string;
|
||||
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: string;
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: string;
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string;
|
||||
CLAUDE_MEM_SEMANTIC_INJECT: string;
|
||||
CLAUDE_MEM_SEMANTIC_INJECT_LIMIT: string;
|
||||
CLAUDE_MEM_TIER_ROUTING_ENABLED: string;
|
||||
CLAUDE_MEM_TIER_SIMPLE_MODEL: string;
|
||||
CLAUDE_MEM_TIER_SUMMARY_MODEL: string;
|
||||
CLAUDE_MEM_CHROMA_ENABLED: string;
|
||||
CLAUDE_MEM_CHROMA_MODE: string;
|
||||
CLAUDE_MEM_CHROMA_HOST: string;
|
||||
CLAUDE_MEM_CHROMA_PORT: string;
|
||||
CLAUDE_MEM_CHROMA_SSL: string;
|
||||
// Future cloud support
|
||||
CLAUDE_MEM_CHROMA_API_KEY: string;
|
||||
CLAUDE_MEM_CHROMA_TENANT: string;
|
||||
CLAUDE_MEM_CHROMA_DATABASE: string;
|
||||
// Telegram Notifier
|
||||
CLAUDE_MEM_TELEGRAM_ENABLED: string;
|
||||
CLAUDE_MEM_TELEGRAM_BOT_TOKEN: string;
|
||||
CLAUDE_MEM_TELEGRAM_CHAT_ID: string;
|
||||
@@ -86,16 +67,12 @@ export interface SettingsDefaults {
|
||||
}
|
||||
|
||||
export class SettingsDefaultsManager {
|
||||
/**
|
||||
* Default values for all settings
|
||||
*/
|
||||
private static readonly DEFAULTS: SettingsDefaults = {
|
||||
CLAUDE_MEM_MODEL: 'claude-sonnet-4-6',
|
||||
CLAUDE_MEM_MODEL: 'claude-haiku-4-5-20251001',
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
|
||||
CLAUDE_MEM_WORKER_PORT: String(37700 + ((process.getuid?.() ?? 77) % 100)),
|
||||
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
|
||||
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
|
||||
// AI Provider Configuration
|
||||
CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude
|
||||
CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'cli', // Default to CLI subscription billing (not API key)
|
||||
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
|
||||
@@ -109,53 +86,43 @@ export class SettingsDefaultsManager {
|
||||
CLAUDE_MEM_OPENROUTER_APP_NAME: 'claude-mem', // App name for OpenRouter analytics
|
||||
CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES: '20', // Max messages in context window
|
||||
CLAUDE_MEM_OPENROUTER_MAX_TOKENS: '100000', // Max estimated tokens (~100k safety limit)
|
||||
// System Configuration
|
||||
CLAUDE_MEM_DATA_DIR: join(homedir(), '.claude-mem'),
|
||||
CLAUDE_MEM_LOG_LEVEL: 'INFO',
|
||||
CLAUDE_MEM_PYTHON_VERSION: '3.13',
|
||||
CLAUDE_CODE_PATH: '', // Empty means auto-detect via 'which claude'
|
||||
CLAUDE_MEM_MODE: 'code', // Default mode profile
|
||||
// Token Economics
|
||||
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: 'false',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: 'false',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: 'false',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: 'true',
|
||||
// Display Configuration
|
||||
CLAUDE_MEM_CONTEXT_FULL_COUNT: '0',
|
||||
CLAUDE_MEM_CONTEXT_FULL_FIELD: 'narrative',
|
||||
CLAUDE_MEM_CONTEXT_SESSION_COUNT: '10',
|
||||
// Feature Toggles
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT: 'true',
|
||||
CLAUDE_MEM_WELCOME_HINT_ENABLED: 'true',
|
||||
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false',
|
||||
CLAUDE_MEM_FOLDER_USE_LOCAL_MD: 'false', // When true, writes to CLAUDE.local.md instead of CLAUDE.md
|
||||
CLAUDE_MEM_TRANSCRIPTS_ENABLED: 'true',
|
||||
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: join(homedir(), '.claude-mem', 'transcript-watch.json'),
|
||||
// Process Management
|
||||
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: '2', // Max concurrent Claude SDK agent subprocesses
|
||||
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: '3', // Plan 05 Phase 8 — escalate to exit code 2 after N consecutive worker-unreachable hook invocations
|
||||
// Exclusion Settings
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||
// Semantic Context Injection (per-prompt via Chroma vector search)
|
||||
CLAUDE_MEM_SEMANTIC_INJECT: 'false', // Inject relevant past observations on every UserPromptSubmit (experimental, disabled by default)
|
||||
CLAUDE_MEM_SEMANTIC_INJECT_LIMIT: '5', // Top-N most relevant observations to inject per prompt
|
||||
// Tier Routing (model selection by queue complexity)
|
||||
CLAUDE_MEM_TIER_ROUTING_ENABLED: 'true', // Route observations to models by complexity
|
||||
CLAUDE_MEM_TIER_SIMPLE_MODEL: 'haiku', // Portable tier alias — works across Direct API, Bedrock, Vertex, Azure (see #1463)
|
||||
CLAUDE_MEM_TIER_SUMMARY_MODEL: '', // Empty = use default model for summaries
|
||||
// Chroma Vector Database Configuration
|
||||
CLAUDE_MEM_CHROMA_ENABLED: 'true', // Set to 'false' to disable Chroma and use SQLite-only search
|
||||
CLAUDE_MEM_CHROMA_MODE: 'local', // 'local' uses persistent chroma-mcp via uvx, 'remote' connects to existing server
|
||||
CLAUDE_MEM_CHROMA_HOST: '127.0.0.1',
|
||||
CLAUDE_MEM_CHROMA_PORT: '8000',
|
||||
CLAUDE_MEM_CHROMA_SSL: 'false',
|
||||
// Future cloud support (claude-mem pro)
|
||||
CLAUDE_MEM_CHROMA_API_KEY: '',
|
||||
CLAUDE_MEM_CHROMA_TENANT: 'default_tenant',
|
||||
CLAUDE_MEM_CHROMA_DATABASE: 'default_database',
|
||||
// Telegram Notifier
|
||||
CLAUDE_MEM_TELEGRAM_ENABLED: 'true',
|
||||
CLAUDE_MEM_TELEGRAM_BOT_TOKEN: '',
|
||||
CLAUDE_MEM_TELEGRAM_CHAT_ID: '',
|
||||
@@ -163,46 +130,24 @@ export class SettingsDefaultsManager {
|
||||
CLAUDE_MEM_TELEGRAM_TRIGGER_CONCEPTS: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all defaults as an object
|
||||
*/
|
||||
static getAllDefaults(): SettingsDefaults {
|
||||
return { ...this.DEFAULTS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a setting value with environment variable override.
|
||||
* Priority: process.env > hardcoded default
|
||||
*
|
||||
* For full priority (env > settings file > default), use loadFromFile().
|
||||
* This method is safe to call at module-load time (no file I/O) and still
|
||||
* respects environment variable overrides that were previously ignored.
|
||||
*/
|
||||
static get(key: keyof SettingsDefaults): string {
|
||||
return process.env[key] ?? this.DEFAULTS[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an integer default value
|
||||
*/
|
||||
static getInt(key: keyof SettingsDefaults): number {
|
||||
const value = this.get(key);
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a boolean default value
|
||||
* Handles both string 'true' and boolean true from JSON
|
||||
*/
|
||||
static getBool(key: keyof SettingsDefaults): boolean {
|
||||
const value: unknown = this.get(key);
|
||||
return value === 'true' || value === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply environment variable overrides to settings
|
||||
* Environment variables take highest priority over file and defaults
|
||||
*/
|
||||
private static applyEnvOverrides(settings: SettingsDefaults): SettingsDefaults {
|
||||
const result = { ...settings };
|
||||
for (const key of Object.keys(this.DEFAULTS) as Array<keyof SettingsDefaults>) {
|
||||
@@ -213,16 +158,6 @@ export class SettingsDefaultsManager {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from file with fallback to defaults
|
||||
* Returns merged settings with proper priority: process.env > settings file > defaults
|
||||
* Handles all errors (missing file, corrupted JSON, permissions) gracefully
|
||||
*
|
||||
* Configuration Priority:
|
||||
* 1. Environment variables (highest priority)
|
||||
* 2. Settings file (~/.claude-mem/settings.json)
|
||||
* 3. Default values (lowest priority)
|
||||
*/
|
||||
static loadFromFile(settingsPath: string): SettingsDefaults {
|
||||
try {
|
||||
if (!existsSync(settingsPath)) {
|
||||
@@ -233,25 +168,20 @@ export class SettingsDefaultsManager {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(settingsPath, JSON.stringify(defaults, null, 2), 'utf-8');
|
||||
// Use console instead of logger to avoid circular dependency
|
||||
console.log('[SETTINGS] Created settings file with defaults:', settingsPath);
|
||||
} catch (error: unknown) {
|
||||
console.warn('[SETTINGS] Failed to create settings file, using in-memory defaults:', settingsPath, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
// Still apply env var overrides even when file doesn't exist
|
||||
return this.applyEnvOverrides(defaults);
|
||||
}
|
||||
|
||||
const settingsData = readFileSync(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(settingsData);
|
||||
|
||||
// MIGRATION: Handle old nested schema { env: {...} }
|
||||
let flatSettings = settings;
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// Migrate from nested to flat schema
|
||||
flatSettings = settings.env;
|
||||
|
||||
// Auto-migrate the file to flat schema
|
||||
try {
|
||||
writeFileSync(settingsPath, JSON.stringify(flatSettings, null, 2), 'utf-8');
|
||||
console.log('[SETTINGS] Migrated settings file from nested to flat schema:', settingsPath);
|
||||
@@ -261,7 +191,6 @@ export class SettingsDefaultsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Merge file settings with defaults (flat schema)
|
||||
const result: SettingsDefaults = { ...this.DEFAULTS };
|
||||
for (const key of Object.keys(this.DEFAULTS) as Array<keyof SettingsDefaults>) {
|
||||
if (flatSettings[key] !== undefined) {
|
||||
@@ -269,11 +198,9 @@ export class SettingsDefaultsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply environment variable overrides (highest priority)
|
||||
return this.applyEnvOverrides(result);
|
||||
} catch (error: unknown) {
|
||||
console.warn('[SETTINGS] Failed to load settings, using defaults:', settingsPath, error instanceof Error ? error.message : String(error));
|
||||
// Still apply env var overrides even on error
|
||||
return this.applyEnvOverrides(this.getAllDefaults());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,23 +7,13 @@ export const HOOK_TIMEOUTS = {
|
||||
WORKER_STARTUP_WAIT: 1000,
|
||||
PRE_RESTART_SETTLE_DELAY: 2000, // Give files time to sync before restart
|
||||
POWERSHELL_COMMAND: 10000, // PowerShell process enumeration (10s - typically completes in <1s)
|
||||
WINDOWS_MULTIPLIER: 1.5 // Platform-specific adjustment for hook-side operations
|
||||
WINDOWS_MULTIPLIER: 1.5
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Hook exit codes for Claude Code
|
||||
*
|
||||
* Exit code behavior per Claude Code docs:
|
||||
* - 0: Success. For SessionStart/UserPromptSubmit, stdout added to context.
|
||||
* - 2: Blocking error. For SessionStart, stderr shown to user only.
|
||||
* - Other non-zero: stderr shown in verbose mode only.
|
||||
*/
|
||||
export const HOOK_EXIT_CODES = {
|
||||
SUCCESS: 0,
|
||||
FAILURE: 1,
|
||||
/** Blocking error - for SessionStart, shows stderr to user only */
|
||||
BLOCKING_ERROR: 2,
|
||||
/** Show stderr to user only, don't inject into context. Used by user-message handler (Cursor). */
|
||||
USER_MESSAGE_ONLY: 3,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
/**
|
||||
* Per-process settings cache for hook handlers.
|
||||
*
|
||||
* Plan 05 Phase 4 (PATHFINDER-2026-04-22): each hook process is short-lived,
|
||||
* but multiple handlers within a single hook invocation independently call
|
||||
* `SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH)` and re-read the
|
||||
* settings file from disk. Settings cannot mutate during a single hook
|
||||
* invocation, so we memoize the first read for the lifetime of the process.
|
||||
*
|
||||
* One helper, N callers (Principle 6). Every hook handler that needs settings
|
||||
* imports `loadFromFileOnce()` from here instead of calling
|
||||
* `SettingsDefaultsManager.loadFromFile` directly.
|
||||
*/
|
||||
|
||||
import {
|
||||
SettingsDefaultsManager,
|
||||
@@ -20,14 +7,6 @@ import { USER_SETTINGS_PATH } from './paths.js';
|
||||
|
||||
let cachedSettings: SettingsDefaults | null = null;
|
||||
|
||||
/**
|
||||
* Load settings from disk on first call, return the memoized value thereafter.
|
||||
*
|
||||
* Cache lifetime is the process — hooks are short-lived (typically <1s), so a
|
||||
* settings change made by the user is picked up the next time Claude Code
|
||||
* spawns a hook process. There is no in-process invalidation API because there
|
||||
* is no in-process mutation path.
|
||||
*/
|
||||
export function loadFromFileOnce(): SettingsDefaults {
|
||||
if (cachedSettings !== null) return cachedSettings;
|
||||
cachedSettings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
|
||||
@@ -1,81 +1,31 @@
|
||||
/**
|
||||
* Shared path utilities for CLAUDE.md file generation
|
||||
*
|
||||
* These utilities handle path normalization and matching, particularly
|
||||
* for comparing absolute and relative paths in folder CLAUDE.md generation.
|
||||
*
|
||||
* @see Issue #794 - Path format mismatch causes folder CLAUDE.md files to show "No recent activity"
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize path separators to forward slashes, collapse consecutive slashes,
|
||||
* and remove trailing slashes.
|
||||
*
|
||||
* @example
|
||||
* normalizePath('app\\api\\router.py') // 'app/api/router.py'
|
||||
* normalizePath('app//api///router.py') // 'app/api/router.py'
|
||||
* normalizePath('app/api/') // 'app/api'
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
return p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a direct child of a folder (not in a subfolder).
|
||||
*
|
||||
* Handles path format mismatches where folderPath may be absolute but
|
||||
* filePath is stored as relative in the database.
|
||||
*
|
||||
* NOTE: This uses suffix matching which assumes both paths are relative to
|
||||
* the same project root. It may produce false positives if used across
|
||||
* different project roots, but this is mitigated by project-scoped queries.
|
||||
*
|
||||
* @param filePath - Path to the file (e.g., "app/api/router.py" or "/Users/x/project/app/api/router.py")
|
||||
* @param folderPath - Path to the folder (e.g., "app/api" or "/Users/x/project/app/api")
|
||||
* @returns true if file is directly in folder, false if in a subfolder or different folder
|
||||
*
|
||||
* @example
|
||||
* // Same format (both relative)
|
||||
* isDirectChild('app/api/router.py', 'app/api') // true
|
||||
* isDirectChild('app/api/v1/router.py', 'app/api') // false (in subfolder)
|
||||
*
|
||||
* @example
|
||||
* // Mixed format (absolute folder, relative file) - fixes #794
|
||||
* isDirectChild('app/api/router.py', '/Users/dev/project/app/api') // true
|
||||
*/
|
||||
export function isDirectChild(filePath: string, folderPath: string): boolean {
|
||||
const normFile = normalizePath(filePath);
|
||||
const normFolder = normalizePath(folderPath);
|
||||
|
||||
// Strategy 1: Direct prefix match (both paths in same format)
|
||||
if (normFile.startsWith(normFolder + '/')) {
|
||||
const remainder = normFile.slice(normFolder.length + 1);
|
||||
return !remainder.includes('/');
|
||||
}
|
||||
|
||||
// Strategy 2: Handle absolute folderPath with relative filePath
|
||||
// e.g., folderPath="/Users/x/project/app/api" and filePath="app/api/router.py"
|
||||
const folderSegments = normFolder.split('/');
|
||||
const fileSegments = normFile.split('/');
|
||||
|
||||
// Handle bare filenames (no directory component, e.g. stored as "dashboard.html").
|
||||
// These are root-level files and are a direct child only of the root folder.
|
||||
// Fixes #1514: bare filenames stored in DB were never matched by any folder query.
|
||||
if (fileSegments.length < 2) {
|
||||
return normFolder === '' || normFolder === '.';
|
||||
}
|
||||
|
||||
const fileDir = fileSegments.slice(0, -1).join('/'); // Directory part of file
|
||||
const fileName = fileSegments[fileSegments.length - 1]; // Actual filename
|
||||
const fileDir = fileSegments.slice(0, -1).join('/');
|
||||
const fileName = fileSegments[fileSegments.length - 1];
|
||||
|
||||
// Check if folder path ends with the file's directory path
|
||||
if (normFolder.endsWith('/' + fileDir) || normFolder === fileDir) {
|
||||
// File is a direct child (no additional subdirectories)
|
||||
return !fileName.includes('/');
|
||||
}
|
||||
|
||||
// Check if file's directory is contained at the end of folder path
|
||||
// by progressively checking suffixes
|
||||
for (let i = 0; i < folderSegments.length; i++) {
|
||||
const folderSuffix = folderSegments.slice(i).join('/');
|
||||
if (folderSuffix === fileDir) {
|
||||
|
||||
+1
-60
@@ -6,42 +6,27 @@ import { fileURLToPath } from 'url';
|
||||
import { SettingsDefaultsManager } from './SettingsDefaultsManager.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// Get __dirname that works in both ESM (hooks) and CJS (worker) contexts
|
||||
function getDirname(): string {
|
||||
// CJS context - __dirname exists
|
||||
if (typeof __dirname !== 'undefined') {
|
||||
return __dirname;
|
||||
}
|
||||
// ESM context - use import.meta.url
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
const _dirname = getDirname();
|
||||
|
||||
/**
|
||||
* Simple path configuration for claude-mem
|
||||
* Standard paths based on Claude Code conventions
|
||||
*/
|
||||
|
||||
// Base directories
|
||||
// Resolve DATA_DIR with full priority: env var > settings.json > default.
|
||||
// SettingsDefaultsManager.get() handles env > default. For settings file
|
||||
// support, we do a one-time synchronous read of the default settings path
|
||||
// to check if the user configured a custom DATA_DIR there.
|
||||
function resolveDataDir(): string {
|
||||
// 1. Environment variable (highest priority) — already handled by get()
|
||||
if (process.env.CLAUDE_MEM_DATA_DIR) {
|
||||
return process.env.CLAUDE_MEM_DATA_DIR;
|
||||
}
|
||||
|
||||
// 2. Settings file at the default location
|
||||
const defaultDataDir = join(homedir(), '.claude-mem');
|
||||
const settingsPath = join(defaultDataDir, 'settings.json');
|
||||
try {
|
||||
if (existsSync(settingsPath)) {
|
||||
const { readFileSync } = require('fs');
|
||||
const raw = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
const settings = raw.env ?? raw; // handle legacy nested schema
|
||||
const settings = raw.env ?? raw;
|
||||
if (settings.CLAUDE_MEM_DATA_DIR) {
|
||||
return settings.CLAUDE_MEM_DATA_DIR;
|
||||
}
|
||||
@@ -50,18 +35,14 @@ function resolveDataDir(): string {
|
||||
// settings file missing or corrupt — fall through to default
|
||||
}
|
||||
|
||||
// 3. Hardcoded default
|
||||
return defaultDataDir;
|
||||
}
|
||||
|
||||
export const DATA_DIR = resolveDataDir();
|
||||
// Note: CLAUDE_CONFIG_DIR is a Claude Code setting, not claude-mem, so leave as env var
|
||||
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
||||
|
||||
// Plugin installation directory - respects CLAUDE_CONFIG_DIR for users with custom Claude locations
|
||||
export const MARKETPLACE_ROOT = join(CLAUDE_CONFIG_DIR, 'plugins', 'marketplaces', 'thedotmack');
|
||||
|
||||
// Data subdirectories
|
||||
export const ARCHIVES_DIR = join(DATA_DIR, 'archives');
|
||||
export const LOGS_DIR = join(DATA_DIR, 'logs');
|
||||
export const TRASH_DIR = join(DATA_DIR, 'trash');
|
||||
@@ -71,43 +52,26 @@ export const USER_SETTINGS_PATH = join(DATA_DIR, 'settings.json');
|
||||
export const DB_PATH = join(DATA_DIR, 'claude-mem.db');
|
||||
export const VECTOR_DB_DIR = join(DATA_DIR, 'vector-db');
|
||||
|
||||
// Observer sessions directory - used as cwd for SDK queries
|
||||
// Sessions here won't appear in user's `claude --resume` for their actual projects
|
||||
export const OBSERVER_SESSIONS_DIR = join(DATA_DIR, 'observer-sessions');
|
||||
|
||||
// Project name assigned to observer sessions (basename of OBSERVER_SESSIONS_DIR).
|
||||
// UI queries filter this out so internal worker sessions don't pollute project lists.
|
||||
export const OBSERVER_SESSIONS_PROJECT = basename(OBSERVER_SESSIONS_DIR);
|
||||
|
||||
// Claude integration paths
|
||||
export const CLAUDE_SETTINGS_PATH = join(CLAUDE_CONFIG_DIR, 'settings.json');
|
||||
export const CLAUDE_COMMANDS_DIR = join(CLAUDE_CONFIG_DIR, 'commands');
|
||||
export const CLAUDE_MD_PATH = join(CLAUDE_CONFIG_DIR, 'CLAUDE.md');
|
||||
|
||||
/**
|
||||
* Get project-specific archive directory
|
||||
*/
|
||||
export function getProjectArchiveDir(projectName: string): string {
|
||||
return join(ARCHIVES_DIR, projectName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worker socket path for a session
|
||||
*/
|
||||
export function getWorkerSocketPath(sessionId: number): string {
|
||||
return join(DATA_DIR, `worker-${sessionId}.sock`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a directory exists
|
||||
*/
|
||||
export function ensureDir(dirPath: string): void {
|
||||
mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all data directories exist
|
||||
*/
|
||||
export function ensureAllDataDirs(): void {
|
||||
ensureDir(DATA_DIR);
|
||||
ensureDir(ARCHIVES_DIR);
|
||||
@@ -117,26 +81,15 @@ export function ensureAllDataDirs(): void {
|
||||
ensureDir(MODES_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure modes directory exists
|
||||
*/
|
||||
export function ensureModesDir(): void {
|
||||
ensureDir(MODES_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all Claude integration directories exist
|
||||
*/
|
||||
export function ensureAllClaudeDirs(): void {
|
||||
ensureDir(CLAUDE_CONFIG_DIR);
|
||||
ensureDir(CLAUDE_COMMANDS_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current project name from git root or cwd.
|
||||
* Includes parent directory to avoid collisions when repos share a folder name
|
||||
* (e.g., ~/work/monorepo → "work/monorepo" vs ~/personal/monorepo → "personal/monorepo").
|
||||
*/
|
||||
export function getCurrentProjectName(): string {
|
||||
try {
|
||||
const gitRoot = execSync('git rev-parse --show-toplevel', {
|
||||
@@ -155,27 +108,15 @@ export function getCurrentProjectName(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find package root directory
|
||||
*
|
||||
* Works because bundled hooks are in plugin/scripts/,
|
||||
* so package root is always one level up (the plugin directory)
|
||||
*/
|
||||
export function getPackageRoot(): string {
|
||||
return join(_dirname, '..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find commands directory in the installed package
|
||||
*/
|
||||
export function getPackageCommandsDir(): string {
|
||||
const packageRoot = getPackageRoot();
|
||||
return join(packageRoot, 'commands');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timestamped backup filename
|
||||
*/
|
||||
export function createBackupFilename(originalPath: string): string {
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Plugin state utilities for checking Claude Code's plugin settings.
|
||||
* Kept minimal — no heavy dependencies — so hooks can check quickly.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
@@ -9,11 +5,6 @@ import { homedir } from 'os';
|
||||
|
||||
const PLUGIN_SETTINGS_KEY = 'claude-mem@thedotmack';
|
||||
|
||||
/**
|
||||
* Check if claude-mem is disabled in Claude Code's settings (#781).
|
||||
* Sync read + JSON parse for speed — called before any async work.
|
||||
* Returns true only if the plugin is explicitly disabled (enabledPlugins[key] === false).
|
||||
*/
|
||||
export function isPluginDisabledInClaudeSettings(): boolean {
|
||||
try {
|
||||
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
||||
@@ -23,7 +14,6 @@ export function isPluginDisabledInClaudeSettings(): boolean {
|
||||
const settings = JSON.parse(raw);
|
||||
return settings?.enabledPlugins?.[PLUGIN_SETTINGS_KEY] === false;
|
||||
} catch (error: unknown) {
|
||||
// If settings can't be read/parsed, assume not disabled
|
||||
console.error('[plugin-state] Failed to read Claude settings:', error instanceof Error ? error.message : String(error));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
/**
|
||||
* Single answer to "should this hook run for this cwd?"
|
||||
*
|
||||
* Plan 05 Phase 5 (PATHFINDER-2026-04-22): three handlers (observation,
|
||||
* session-init, file-context) each duplicated the
|
||||
* `loadFromFileOnce() → isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)`
|
||||
* pair. This module is the only entry point for that question; handlers call
|
||||
* `shouldTrackProject(cwd)` and route through here.
|
||||
*
|
||||
* One helper, N callers (Principle 6). After this module lands, no handler
|
||||
* references `isProjectExcluded` directly — the import lives only here.
|
||||
*/
|
||||
|
||||
import { relative, isAbsolute } from 'path';
|
||||
import { isProjectExcluded } from '../utils/project-filter.js';
|
||||
@@ -22,26 +10,9 @@ function isWithin(child: string, parent: string): boolean {
|
||||
return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true when the project at `cwd` is NOT excluded from claude-mem
|
||||
* tracking, i.e., the hook should proceed; false when the project
|
||||
* matches one of the exclusion globs.
|
||||
*
|
||||
* Single trust boundary: when the spawning worker set CLAUDE_MEM_INTERNAL=1
|
||||
* (see EnvManager.buildIsolatedEnv), the spawned subprocess is an internal
|
||||
* claude-mem agent and must never feed the worker — otherwise the observer's
|
||||
* own init/continuation/summary prompts end up stored as `user_prompts` and
|
||||
* leak into the viewer (meta-observation; see #2118, #2126).
|
||||
*
|
||||
* The cwd-based OBSERVER_SESSIONS_DIR check stays as belt-and-braces for any
|
||||
* pre-env-var spawn path (e.g., user manually launching `claude` inside the
|
||||
* observer dir) and for tests that don't exercise the env var.
|
||||
*/
|
||||
export function shouldTrackProject(cwd: string): boolean {
|
||||
if (process.env.CLAUDE_MEM_INTERNAL === '1') return false;
|
||||
if (!cwd) return true;
|
||||
// path.relative handles separator differences (Windows '\\' vs POSIX '/')
|
||||
// and trailing-slash variance, which a literal startsWith would miss.
|
||||
if (isWithin(cwd, OBSERVER_SESSIONS_DIR)) {
|
||||
return false;
|
||||
}
|
||||
@@ -49,15 +20,6 @@ export function shouldTrackProject(cwd: string): boolean {
|
||||
return !isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared predicate: should a row tagged with `project` be emitted to user-facing
|
||||
* surfaces (SSE stream, viewer UI list)? Used by both PaginationHelper SQL
|
||||
* filters and SSEBroadcaster payload filters so they can never drift.
|
||||
*
|
||||
* Internal claude-mem rows (project === OBSERVER_SESSIONS_PROJECT) are hidden
|
||||
* from the unfiltered list view and the live SSE stream. They remain queryable
|
||||
* by id and by explicit `project=observer-sessions` filter for diagnostics.
|
||||
*/
|
||||
export function shouldEmitProjectRow(project: string | null | undefined): boolean {
|
||||
if (!project) return true;
|
||||
return project !== OBSERVER_SESSIONS_PROJECT;
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
/**
|
||||
* Shared timeline formatting utilities
|
||||
*
|
||||
* Pure formatting and grouping functions extracted from context-generator.ts
|
||||
* to be reused by SearchManager and other services.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Parse JSON array string, returning empty array on failure
|
||||
*/
|
||||
export function parseJsonArray(json: string | null): string[] {
|
||||
if (!json) return [];
|
||||
try {
|
||||
@@ -24,10 +15,6 @@ export function parseJsonArray(json: string | null): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date with time (e.g., "Dec 14, 7:30 PM")
|
||||
* Accepts either ISO date string or epoch milliseconds
|
||||
*/
|
||||
export function formatDateTime(dateInput: string | number): string {
|
||||
const date = new Date(dateInput);
|
||||
return date.toLocaleString('en-US', {
|
||||
@@ -39,10 +26,6 @@ export function formatDateTime(dateInput: string | number): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format just time, no date (e.g., "7:30 PM")
|
||||
* Accepts either ISO date string or epoch milliseconds
|
||||
*/
|
||||
export function formatTime(dateInput: string | number): string {
|
||||
const date = new Date(dateInput);
|
||||
return date.toLocaleString('en-US', {
|
||||
@@ -52,10 +35,6 @@ export function formatTime(dateInput: string | number): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format just date (e.g., "Dec 14, 2025")
|
||||
* Accepts either ISO date string or epoch milliseconds
|
||||
*/
|
||||
export function formatDate(dateInput: string | number): string {
|
||||
const date = new Date(dateInput);
|
||||
return date.toLocaleString('en-US', {
|
||||
@@ -65,9 +44,6 @@ export function formatDate(dateInput: string | number): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert absolute paths to relative paths
|
||||
*/
|
||||
export function toRelativePath(filePath: string, cwd: string): string {
|
||||
if (path.isAbsolute(filePath)) {
|
||||
return path.relative(cwd, filePath);
|
||||
@@ -75,23 +51,16 @@ export function toRelativePath(filePath: string, cwd: string): string {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first relevant file from files_modified OR files_read JSON arrays.
|
||||
* Prefers files_modified, falls back to files_read.
|
||||
* Returns 'General' only if both are empty.
|
||||
*/
|
||||
export function extractFirstFile(
|
||||
filesModified: string | null,
|
||||
cwd: string,
|
||||
filesRead?: string | null
|
||||
): string {
|
||||
// Try files_modified first
|
||||
const modified = parseJsonArray(filesModified);
|
||||
if (modified.length > 0) {
|
||||
return toRelativePath(modified[0], cwd);
|
||||
}
|
||||
|
||||
// Fall back to files_read
|
||||
if (filesRead) {
|
||||
const read = parseJsonArray(filesRead);
|
||||
if (read.length > 0) {
|
||||
@@ -102,29 +71,15 @@ export function extractFirstFile(
|
||||
return 'General';
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token count for text (rough approximation: ~4 chars per token)
|
||||
*/
|
||||
export function estimateTokens(text: string | null): number {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group items by date
|
||||
*
|
||||
* Generic function that works with any item type that has a date field.
|
||||
* Returns a Map of date string -> items array, sorted chronologically.
|
||||
*
|
||||
* @param items - Array of items to group
|
||||
* @param getDate - Function to extract date string from each item
|
||||
* @returns Map of formatted date strings to item arrays, sorted chronologically
|
||||
*/
|
||||
export function groupByDate<T>(
|
||||
items: T[],
|
||||
getDate: (item: T) => string
|
||||
): Map<string, T[]> {
|
||||
// Group by day
|
||||
const itemsByDay = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const itemDate = getDate(item);
|
||||
@@ -135,7 +90,6 @@ export function groupByDate<T>(
|
||||
itemsByDay.get(day)!.push(item);
|
||||
}
|
||||
|
||||
// Sort days chronologically
|
||||
const sortedEntries = Array.from(itemsByDay.entries()).sort((a, b) => {
|
||||
const aDate = new Date(a[0]).getTime();
|
||||
const bDate = new Date(b[0]).getTime();
|
||||
|
||||
@@ -2,19 +2,6 @@ import { readFileSync, existsSync } from 'fs';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { SYSTEM_REMINDER_REGEX } from '../utils/tag-stripping.js';
|
||||
|
||||
/**
|
||||
* Detect whether a transcript file is in Gemini CLI JSON document format.
|
||||
*
|
||||
* Gemini CLI 0.37.0 writes a single JSON document with a top-level `messages`
|
||||
* array instead of JSONL. Assistant entries use `type: "gemini"` rather than
|
||||
* `type: "assistant"`.
|
||||
*
|
||||
* Example Gemini format:
|
||||
* { "messages": [{ "type": "user", "content": "..." }, { "type": "gemini", "content": "..." }] }
|
||||
*
|
||||
* Claude Code format (JSONL):
|
||||
* {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
|
||||
*/
|
||||
function isGeminiTranscriptFormat(content: string): { isGemini: true; messages: any[] } | { isGemini: false } {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
@@ -27,17 +14,6 @@ function isGeminiTranscriptFormat(content: string): { isGemini: true; messages:
|
||||
return { isGemini: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract last message of specified role from transcript file.
|
||||
*
|
||||
* Supports two transcript formats:
|
||||
* - JSONL (Claude Code): one JSON object per line, `type: "assistant"` or `type: "user"`
|
||||
* - JSON document (Gemini CLI 0.37.0+): `{ messages: [{ type: "gemini"|"user", content: string }] }`
|
||||
*
|
||||
* @param transcriptPath Path to transcript file
|
||||
* @param role 'user' or 'assistant'
|
||||
* @param stripSystemReminders Whether to remove <system-reminder> tags (for assistant)
|
||||
*/
|
||||
export function extractLastMessage(
|
||||
transcriptPath: string,
|
||||
role: 'user' | 'assistant',
|
||||
@@ -54,8 +30,6 @@ export function extractLastMessage(
|
||||
return '';
|
||||
}
|
||||
|
||||
// Gemini CLI 0.37.0 writes a JSON document rather than JSONL.
|
||||
// Detect and handle it before falling through to the JSONL parser.
|
||||
const geminiCheck = isGeminiTranscriptFormat(content);
|
||||
if (geminiCheck.isGemini) {
|
||||
return extractLastMessageFromGeminiTranscript(geminiCheck.messages, role, stripSystemReminders);
|
||||
@@ -64,16 +38,11 @@ export function extractLastMessage(
|
||||
return extractLastMessageFromJsonl(content, role, stripSystemReminders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract last message from Gemini CLI JSON document transcript.
|
||||
* Maps `type: "gemini"` → assistant role; `type: "user"` → user role.
|
||||
*/
|
||||
function extractLastMessageFromGeminiTranscript(
|
||||
messages: any[],
|
||||
role: 'user' | 'assistant',
|
||||
stripSystemReminders: boolean
|
||||
): string {
|
||||
// "gemini" entries are assistant turns; "user" entries are user turns
|
||||
const geminiRole = role === 'assistant' ? 'gemini' : 'user';
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
@@ -91,10 +60,6 @@ function extractLastMessageFromGeminiTranscript(
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract last message from Claude Code JSONL transcript.
|
||||
* Each line is an independent JSON object with `type: "assistant"` or `type: "user"`.
|
||||
*/
|
||||
function extractLastMessageFromJsonl(
|
||||
content: string,
|
||||
role: 'user' | 'assistant',
|
||||
@@ -120,7 +85,6 @@ function extractLastMessageFromJsonl(
|
||||
.map((c: any) => c.text)
|
||||
.join('\n');
|
||||
} else {
|
||||
// Unknown content format - throw error
|
||||
throw new Error(`Unknown message content format in transcript. Type: ${typeof msgContent}`);
|
||||
}
|
||||
|
||||
@@ -129,13 +93,11 @@ function extractLastMessageFromJsonl(
|
||||
text = text.replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
// Return text even if empty - caller decides if that's an error
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we searched the whole transcript and didn't find any message of this role
|
||||
if (!foundMatchingRole) {
|
||||
return '';
|
||||
}
|
||||
|
||||
+4
-201
@@ -6,15 +6,8 @@ import { HOOK_TIMEOUTS, HOOK_EXIT_CODES, getTimeout } from "./hook-constants.js"
|
||||
import { SettingsDefaultsManager } from "./SettingsDefaultsManager.js";
|
||||
import { MARKETPLACE_ROOT, DATA_DIR } from "./paths.js";
|
||||
import { loadFromFileOnce } from "./hook-settings.js";
|
||||
// `validateWorkerPidFile` consults `captureProcessStartToken` at
|
||||
// `src/supervisor/process-registry.ts` for PID-reuse detection (commit
|
||||
// 99060bac). The lazy-spawn fast path below uses it to confirm a live port
|
||||
// is owned by OUR worker incarnation rather than a stale PID squatting on
|
||||
// the port after container restart.
|
||||
import { validateWorkerPidFile } from "../supervisor/index.js";
|
||||
|
||||
// Named constants for health checks
|
||||
// Allow env var override for users on slow systems (e.g., CLAUDE_MEM_HEALTH_TIMEOUT_MS=10000)
|
||||
const HEALTH_CHECK_TIMEOUT_MS = (() => {
|
||||
const envVal = process.env.CLAUDE_MEM_HEALTH_TIMEOUT_MS;
|
||||
if (envVal) {
|
||||
@@ -22,7 +15,6 @@ const HEALTH_CHECK_TIMEOUT_MS = (() => {
|
||||
if (Number.isFinite(parsed) && parsed >= 500 && parsed <= 300000) {
|
||||
return parsed;
|
||||
}
|
||||
// Invalid env var — log once and use default
|
||||
logger.warn('SYSTEM', 'Invalid CLAUDE_MEM_HEALTH_TIMEOUT_MS, using default', {
|
||||
value: envVal, min: 500, max: 300000
|
||||
});
|
||||
@@ -30,12 +22,6 @@ const HEALTH_CHECK_TIMEOUT_MS = (() => {
|
||||
return getTimeout(HOOK_TIMEOUTS.HEALTH_CHECK);
|
||||
})();
|
||||
|
||||
/**
|
||||
* Fetch with a timeout using Promise.race instead of AbortSignal.
|
||||
* AbortSignal.timeout() causes a libuv assertion crash in Bun on Windows,
|
||||
* so we use a racing setTimeout pattern that avoids signal cleanup entirely.
|
||||
* The orphaned fetch is harmless since the process exits shortly after.
|
||||
*/
|
||||
export function fetchWithTimeout(url: string, init: RequestInit = {}, timeoutMs: number): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(
|
||||
@@ -49,16 +35,9 @@ export function fetchWithTimeout(url: string, init: RequestInit = {}, timeoutMs:
|
||||
});
|
||||
}
|
||||
|
||||
// Cache to avoid repeated settings file reads
|
||||
let cachedPort: number | null = null;
|
||||
let cachedHost: string | null = null;
|
||||
|
||||
/**
|
||||
* Get the worker port number from settings
|
||||
* Uses CLAUDE_MEM_WORKER_PORT from settings file, or the per-UID default
|
||||
* (37700 + uid % 100) defined in SettingsDefaultsManager.
|
||||
* Caches the port value to avoid repeated file reads
|
||||
*/
|
||||
export function getWorkerPort(): number {
|
||||
if (cachedPort !== null) {
|
||||
return cachedPort;
|
||||
@@ -70,11 +49,6 @@ export function getWorkerPort(): number {
|
||||
return cachedPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the worker host address
|
||||
* Uses CLAUDE_MEM_WORKER_HOST from settings file or default (127.0.0.1)
|
||||
* Caches the host value to avoid repeated file reads
|
||||
*/
|
||||
export function getWorkerHost(): string {
|
||||
if (cachedHost !== null) {
|
||||
return cachedHost;
|
||||
@@ -86,27 +60,15 @@ export function getWorkerHost(): string {
|
||||
return cachedHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached port and host values.
|
||||
* Call this when settings are updated to force re-reading from file.
|
||||
*/
|
||||
export function clearPortCache(): void {
|
||||
cachedPort = null;
|
||||
cachedHost = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full URL for a given API path.
|
||||
*/
|
||||
export function buildWorkerUrl(apiPath: string): string {
|
||||
return `http://${getWorkerHost()}:${getWorkerPort()}${apiPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP request to the worker over TCP.
|
||||
*
|
||||
* This is the preferred way for hooks to communicate with the worker.
|
||||
*/
|
||||
export function workerHttpRequest(
|
||||
apiPath: string,
|
||||
options: {
|
||||
@@ -134,23 +96,11 @@ export function workerHttpRequest(
|
||||
return fetch(url, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if worker HTTP server is responsive.
|
||||
* Uses /api/health (liveness) instead of /api/readiness because:
|
||||
* - Hooks have 15-second timeout, but full initialization can take 5+ minutes (MCP connection)
|
||||
* - /api/health returns 200 as soon as HTTP server is up (sufficient for hook communication)
|
||||
* - /api/readiness returns 503 until full initialization completes (too slow for hooks)
|
||||
* See: https://github.com/thedotmack/claude-mem/issues/811
|
||||
*/
|
||||
async function isWorkerHealthy(): Promise<boolean> {
|
||||
const response = await workerHttpRequest('/api/health', { timeoutMs: HEALTH_CHECK_TIMEOUT_MS });
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current plugin version from package.json.
|
||||
* Returns 'unknown' on ENOENT/EBUSY (shutdown race condition, fix #1042).
|
||||
*/
|
||||
function getPluginVersion(): string {
|
||||
try {
|
||||
const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json');
|
||||
@@ -166,9 +116,6 @@ function getPluginVersion(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the running worker's version from the API
|
||||
*/
|
||||
async function getWorkerVersion(): Promise<string> {
|
||||
const response = await workerHttpRequest('/api/version', { timeoutMs: HEALTH_CHECK_TIMEOUT_MS });
|
||||
if (!response.ok) {
|
||||
@@ -178,12 +125,6 @@ async function getWorkerVersion(): Promise<string> {
|
||||
return data.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if worker version matches plugin version
|
||||
* Note: Auto-restart on version mismatch is now handled in worker-service.ts start command (issue #484)
|
||||
* This function logs for informational purposes only.
|
||||
* Skips comparison when either version is 'unknown' (fix #1042 — avoids restart loops).
|
||||
*/
|
||||
async function checkWorkerVersion(): Promise<void> {
|
||||
let pluginVersion: string;
|
||||
try {
|
||||
@@ -195,7 +136,6 @@ async function checkWorkerVersion(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip version check if plugin version couldn't be read (shutdown race)
|
||||
if (pluginVersion === 'unknown') return;
|
||||
|
||||
let workerVersion: string;
|
||||
@@ -208,11 +148,9 @@ async function checkWorkerVersion(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip version check if worker version is 'unknown' (avoids restart loops)
|
||||
if (workerVersion === 'unknown') return;
|
||||
|
||||
if (pluginVersion !== workerVersion) {
|
||||
// Just log debug info - auto-restart handles the mismatch in worker-service.ts
|
||||
logger.debug('SYSTEM', 'Version check', {
|
||||
pluginVersion,
|
||||
workerVersion,
|
||||
@@ -221,14 +159,6 @@ async function checkWorkerVersion(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolve the absolute path to the worker-service script the hook should
|
||||
* relaunch as a detached daemon. Hooks live in the plugin's `scripts/`
|
||||
* directory next to `worker-service.cjs`; production and dev checkouts both
|
||||
* ship the bundled CJS there. Returns null when no candidate exists on disk
|
||||
* (partial install, build artifact missing).
|
||||
*/
|
||||
function resolveWorkerScriptPath(): string | null {
|
||||
const candidates = [
|
||||
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'worker-service.cjs'),
|
||||
@@ -240,16 +170,6 @@ function resolveWorkerScriptPath(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute path to the Bun runtime.
|
||||
*
|
||||
* Local to worker-utils.ts so the lazy-spawn path does not transitively
|
||||
* import `services/infrastructure/ProcessManager.ts` — that module pulls
|
||||
* in `bun:sqlite` via `cwd-remap`, and pulling it in would break the NPX
|
||||
* CLI bundle which must run under plain Node (no Bun). The worker daemon
|
||||
* itself requires Bun (it uses bun:sqlite directly); this lookup finds
|
||||
* the Bun binary that the daemon will execute under.
|
||||
*/
|
||||
function resolveBunRuntime(): string | null {
|
||||
if (process.env.BUN && existsSync(process.env.BUN)) return process.env.BUN;
|
||||
|
||||
@@ -270,15 +190,6 @@ function resolveBunRuntime(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the worker port to open, using exponential backoff.
|
||||
*
|
||||
* Deliberately hand-rolled — `respawn` or similar npm helpers add a
|
||||
* supervisor semantic layer we do not want here (Principle 6). The retry
|
||||
* policy is three attempts with 250ms → 500ms → 1000ms backoff, which is
|
||||
* enough to cover the worker's start-up (~1-2s on a warm cache, slower on
|
||||
* Windows) without blocking a hook for long when the spawn outright failed.
|
||||
*/
|
||||
async function waitForWorkerPort(options: { attempts: number; backoffMs: number }): Promise<boolean> {
|
||||
let delayMs = options.backoffMs;
|
||||
for (let attempt = 1; attempt <= options.attempts; attempt++) {
|
||||
@@ -291,25 +202,6 @@ async function waitForWorkerPort(options: { attempts: number; backoffMs: number
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the worker port owned by a live worker we recognize?
|
||||
*
|
||||
* Two gates:
|
||||
* 1. HTTP /api/health returns 200, AND
|
||||
* 2. PID-file start-token check (via `validateWorkerPidFile` →
|
||||
* `captureProcessStartToken`) confirms the recorded PID has not been
|
||||
* reused by a different process since the file was written.
|
||||
*
|
||||
* When the PID file is missing we accept a healthy HTTP response on its own
|
||||
* — the file is written by the worker itself after `listen()` succeeds, so
|
||||
* a brief window exists during which a freshly-spawned worker is reachable
|
||||
* via HTTP but has not yet persisted its PID record. Treating this as
|
||||
* "not ours" would cause the hook to double-spawn in a race with the
|
||||
* worker's own PID-file write.
|
||||
*
|
||||
* An 'alive' status that fails identity verification is treated as dead so
|
||||
* the caller falls through to the spawn path (Phase 8 contract).
|
||||
*/
|
||||
async function isWorkerPortAlive(): Promise<boolean> {
|
||||
let healthy: boolean;
|
||||
try {
|
||||
@@ -323,27 +215,11 @@ async function isWorkerPortAlive(): Promise<boolean> {
|
||||
if (!healthy) return false;
|
||||
|
||||
const pidStatus = validateWorkerPidFile({ logAlive: false });
|
||||
if (pidStatus === 'missing') return true; // race: listening before PID file written
|
||||
if (pidStatus === 'alive') return true; // identity verified via start-token
|
||||
return false; // 'stale' | 'invalid' — PID reused
|
||||
if (pidStatus === 'missing') return true;
|
||||
if (pidStatus === 'alive') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-spawn the worker if it is not already running, then wait for its port.
|
||||
*
|
||||
* Flow:
|
||||
* 1. If the port is alive AND verified as ours, return true (fast path).
|
||||
* 2. Otherwise, resolve the bun runtime + worker script path.
|
||||
* 3. Spawn detached, `unref()` so the hook's exit does not take the worker
|
||||
* down with it (the worker lives as its own independent daemon).
|
||||
* 4. Wait for the port to come up, up to 3 attempts with exponential
|
||||
* backoff (250ms → 500ms → 1000ms — ~1.75s total).
|
||||
*
|
||||
* PID-reuse safety is inherited from `validateWorkerPidFile` (commit
|
||||
* 99060bac) — see the `isWorkerPortAlive` comment above. There is no
|
||||
* auto-restart loop; failure is reported via the return value so the hook
|
||||
* can surface it through exit code 2 (Principle 2 — fail-fast).
|
||||
*/
|
||||
export async function ensureWorkerRunning(): Promise<boolean> {
|
||||
if (await isWorkerPortAlive()) {
|
||||
await checkWorkerVersion();
|
||||
@@ -389,16 +265,6 @@ export async function ensureWorkerRunning(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plan 05 Phase 9 — single per-process alive cache.
|
||||
//
|
||||
// One hook invocation may issue multiple worker requests (session-init issues
|
||||
// several). The alive-state cannot change mid-invocation without the hook
|
||||
// process exiting, so memoize the first result. By Principle 6 (one helper,
|
||||
// N callers), this is the ONLY alive-state cache; all hook→worker call sites
|
||||
// route through `executeWithWorkerFallback` (Phase 2) which calls this.
|
||||
// ============================================================================
|
||||
|
||||
let aliveCache: boolean | null = null;
|
||||
|
||||
export async function ensureWorkerAliveOnce(): Promise<boolean> {
|
||||
@@ -407,22 +273,6 @@ export async function ensureWorkerAliveOnce(): Promise<boolean> {
|
||||
return aliveCache;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plan 05 Phase 8 — fail-loud counter.
|
||||
//
|
||||
// The counter records how many consecutive hook invocations have seen the
|
||||
// worker unreachable. After N (default 3) consecutive failures, the next
|
||||
// hook exits code 2 so Claude Code's hook contract surfaces the outage to
|
||||
// Claude. Below N, hooks exit 0 to avoid breaking the user's session.
|
||||
//
|
||||
// This is NOT a retry. We do not reinvoke `ensureWorkerAliveOnce` or
|
||||
// reattempt the HTTP request. We record the result of the one primary-path
|
||||
// attempt and either return (graceful) or escalate (fail-loud).
|
||||
//
|
||||
// File: ~/.claude-mem/state/hook-failures.json
|
||||
// Atomic write: tmp + rename (POSIX atomic within a filesystem).
|
||||
// ============================================================================
|
||||
|
||||
interface HookFailureState {
|
||||
consecutiveFailures: number;
|
||||
lastFailureAt: number;
|
||||
@@ -451,7 +301,6 @@ function readHookFailureState(): HookFailureState {
|
||||
: 0,
|
||||
};
|
||||
} catch {
|
||||
// Missing file or corrupt JSON → fresh state.
|
||||
return { consecutiveFailures: 0, lastFailureAt: 0 };
|
||||
}
|
||||
}
|
||||
@@ -485,14 +334,6 @@ function getFailLoudThreshold(): number {
|
||||
return FAIL_LOUD_DEFAULT_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a worker-unreachable hook invocation. Returns the new counter value.
|
||||
* If the counter reaches the threshold, this function writes to stderr and
|
||||
* exits the process with code 2 (blocking error per Claude Code hook contract).
|
||||
*
|
||||
* Not a retry — does not reattempt the operation. The caller already ran the
|
||||
* single primary-path attempt and got `false` from `ensureWorkerAliveOnce`.
|
||||
*/
|
||||
function recordWorkerUnreachable(): number {
|
||||
const state = readHookFailureState();
|
||||
const next: HookFailureState = {
|
||||
@@ -511,36 +352,12 @@ function recordWorkerUnreachable(): number {
|
||||
return next.consecutiveFailures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the consecutive-failure counter. Called when the worker is alive,
|
||||
* acknowledging that any prior outage has ended. Not a retry — it is a
|
||||
* success-path acknowledgement.
|
||||
*/
|
||||
function resetWorkerFailureCounter(): void {
|
||||
const state = readHookFailureState();
|
||||
if (state.consecutiveFailures === 0) return; // skip a no-op write
|
||||
if (state.consecutiveFailures === 0) return;
|
||||
writeHookFailureStateAtomic({ consecutiveFailures: 0, lastFailureAt: 0 });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plan 05 Phase 2 — `executeWithWorkerFallback(url, method, body)`.
|
||||
//
|
||||
// Eight handlers used to duplicate the
|
||||
// `ensureWorkerRunning() → workerHttpRequest() → if (!ok) return { continue: true }`
|
||||
// sequence. This helper is the ONE implementation; eight handlers import it.
|
||||
//
|
||||
// Behavior:
|
||||
// 1. ensureWorkerAliveOnce() (Phase 9). If false → fail-loud counter
|
||||
// (Phase 8). May process.exit(2). Otherwise return graceful fallback.
|
||||
// 2. workerHttpRequest(url, method, body). Parse JSON.
|
||||
// 3. On success, reset the fail-loud counter.
|
||||
//
|
||||
// No retry inside this helper. No timeout-and-exit-0 swallow. The fail-loud
|
||||
// counter records consecutive invocation outcomes; it does not reinvoke work.
|
||||
// ============================================================================
|
||||
|
||||
// Branded sentinel so isWorkerFallback cannot false-positive on legitimate
|
||||
// API responses that happen to carry `continue: true` in their own schema.
|
||||
const WORKER_FALLBACK_BRAND: unique symbol = Symbol.for('claude-mem/worker-fallback');
|
||||
|
||||
export type WorkerFallback =
|
||||
@@ -556,12 +373,6 @@ export function isWorkerFallback<T>(result: WorkerCallResult<T>): result is Work
|
||||
}
|
||||
|
||||
export interface WorkerFallbackOptions {
|
||||
/**
|
||||
* Per-call HTTP timeout in ms. Forwarded to workerHttpRequest. Omit to use
|
||||
* HEALTH_CHECK_TIMEOUT_MS (the default ~3 s suitable for short pings).
|
||||
* All hook endpoints are fire-and-forget queueing endpoints that return
|
||||
* `{status: 'queued'}` immediately, so the default suffices.
|
||||
*/
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
@@ -573,8 +384,6 @@ export async function executeWithWorkerFallback<T = unknown>(
|
||||
): Promise<WorkerCallResult<T>> {
|
||||
const alive = await ensureWorkerAliveOnce();
|
||||
if (!alive) {
|
||||
// Records and possibly process.exit(2). If we return below, the counter
|
||||
// is below threshold, the user's session continues uninterrupted.
|
||||
recordWorkerUnreachable();
|
||||
return { continue: true, reason: 'worker_unreachable', [WORKER_FALLBACK_BRAND]: true };
|
||||
}
|
||||
@@ -590,12 +399,6 @@ export async function executeWithWorkerFallback<T = unknown>(
|
||||
|
||||
const response = await workerHttpRequest(url, init);
|
||||
if (!response.ok) {
|
||||
// Non-2xx is a real worker response (so the worker IS reachable). Reset
|
||||
// the consecutive-failures counter; surface the response body to the
|
||||
// caller as a typed value via T's caller-controlled shape. Callers that
|
||||
// care about non-2xx must inspect the value (or wrap with their own
|
||||
// status check); the helper does not silently coerce non-2xx into a
|
||||
// graceful fallback.
|
||||
resetWorkerFailureCounter();
|
||||
const text = await response.text().catch(() => '');
|
||||
let parsed: unknown = text;
|
||||
|
||||
Reference in New Issue
Block a user