server-beta: Phases 4–13 — event pipeline, generation, MCP, compat, Docker, team audit, observability (#2383)

* feat(server-beta): Phase 4 — Postgres event-to-generation-job pipeline

Adds POST /v1/events, /v1/events/batch, GET /v1/jobs/:id, GET /v1/events/:id,
and POST /v1/memories on the server-beta runtime, backed by Postgres.

- Event row + outbox generation-job row insert in one withPostgresTransaction.
- BullMQ enqueue happens after commit; enqueue failure leaves the row queued
  for Phase 3 startup reconciliation.
- ?generate=false skips the outbox; ?wait=true returns queue status only,
  never observation IDs (provider generation is Phase 5).
- Batch pre-validates all event projectIds against api-key scope before any
  write; mixed-project batches reject 403 with zero side effects.
- /v1/memories is a direct insert alias — no generator, no outbox.
- Cross-tenant /v1/jobs/:id returns 404 to avoid leaking row existence.
- New PostgresAuthMiddleware reads api_keys by SHA-256 hash; populates
  req.authContext.teamId/projectId; legacy ServerV1Routes (SQLite, used by
  worker runtime) is left untouched.
- Tests: unit suite hardened with stubbed pool.query so route registration
  is safe; integration tests skip cleanly without CLAUDE_MEM_TEST_POSTGRES_URL.

Verification: 87 pass / 1 skip / 0 fail. No new typecheck errors. Required
greps for WorkerService and MemoryItemsRepository in src/server/routes/v1
and src/server/runtime return no hits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 5 — provider observation generator

Adds independent provider generation under src/server/generation/ with no
worker coupling. Server beta can now generate observations end-to-end:
event -> outbox -> BullMQ -> provider -> parser -> persisted observation.

- ProviderObservationGenerator orchestrates: lock outbox (queued -> processing),
  reload agent_event from Postgres (BullMQ payload is advisory only), call
  provider, hand raw text to processGeneratedResponse, route errors via
  markGenerationFailed with retryable flag from ServerClassifiedProviderError.
- processGeneratedResponse parses with parseAgentXml, persists via
  PostgresObservationRepository with deterministic
  generation_key = generation:v1:{job_id}:{index}:{fingerprint},
  links via PostgresObservationSourcesRepository, advances outbox status,
  appends observation_generation_job_events, audits — all in one
  withPostgresTransaction. Idempotent on retry via UNIQUE constraints.
- Three provider adapters under src/server/generation/providers/:
  Claude, Gemini, OpenRouter. Self-contained — no imports from
  src/services/worker/*. Worker providers unchanged.
- Shared error classification + prompt builder under providers/shared/.
  Prompt builder strips <private> at the edge; fully-private batches
  emit <skip_summary /> without billing the provider.
- ActiveServerBetaGenerationWorkerManager wires BullMQ Worker via
  ServerJobQueue.start(...) with concurrency 1 + autorun:false +
  worker.on('error') per BullMQ docs.
- New GET /v1/events/:id/observations on ServerV1PostgresRoutes returns
  observations linked via observation_sources, team/project scoped.

Verification: 104 pass / 4 skip / 0 fail. No typecheck regressions.
Anti-pattern greps clean for services/worker imports under src/server,
WorkerRef/ActiveSession/SessionStore in src/server/generation.

Deferred: ModeManager loading uses a stable fallback observation type
list; summary and reindex queue lanes are not yet wired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 6 — independent server session semantics

server_sessions is now the canonical Server beta session model. Sessions
are independent of legacy worker ActiveSession state.

- PostgresServerSessionRepository extended: findByExternalIdForScope,
  endSession (idempotent via COALESCE(ended_at, now())),
  markGenerationStarted/Completed/Failed, listUnprocessedEvents (filters
  agent_events with completed agent_event jobs).
- ServerSessionRuntimeRepository wraps the repo; every method requires
  explicit team_id + project_id and validates scope via assertProjectOwnership.
- SessionGenerationPolicy supports per-event (default), debounce
  (BullMQ delayed-job replace via getJob+remove+add), and end-of-session.
  Configured via CLAUDE_MEM_SERVER_SESSION_POLICY and
  CLAUDE_MEM_SERVER_SESSION_DEBOUNCE_MS env vars; per-team override hooks
  are exposed on ServerV1PostgresRoutesOptions for future settings layer.
- POST /v1/sessions/start (find-or-create on (project_id, external_session_id),
  GET /v1/sessions/:id (scoped 404), POST /v1/sessions/:id/end
  (transactional: end + create summary outbox via UNIQUE collapse +
  enqueue post-commit). Re-ending is fully idempotent.
- processSessionSummaryResponse persists summary as kind='summary'
  observation with the same idempotency model
  (generation_key + observation_sources UNIQUE).
- ProviderObservationGenerator dispatches on source_type:
  agent_event -> processGeneratedResponse, session_summary ->
  processSessionSummaryResponse; loadEvents handles session-summary
  by loading unprocessed events.
- ActiveServerBetaGenerationWorkerManager wires summary BullMQ lane
  alongside event lane (concurrency=1, autorun=false, error listener
  attached per BullMQ docs).

Verification: 110 pass / 6 skip / 0 fail. Net typecheck error count
unchanged at 24 (pre-existing, none in Phase 6 files). Anti-pattern
greps clean for ActiveSession/SessionStore in src/server/runtime,
no worker imports anywhere in src/server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 7 — hook routing without worker dependency

Hooks can now talk directly to server-beta when CLAUDE_MEM_RUNTIME=server-beta
is selected, with a clean worker fallback when server-beta is unhealthy.

- src/services/hooks/server-beta-client.ts — typed HTTP client for
  /v1/sessions/start, /v1/events, /v1/sessions/:id/end. Throws
  ServerBetaClientError with kind classification (missing_api_key,
  transport, timeout, http_error, invalid_response) and isFallbackEligible
  helper. Zero imports from services/worker/.
- src/services/hooks/runtime-selector.ts — reads CLAUDE_MEM_RUNTIME from
  settings, returns worker or server-beta context, logs
  [server-beta-fallback] reason=<code> on every config-time fallback.
- src/services/hooks/server-beta-bootstrap.ts — Postgres-backed API key
  bootstrap. Find-or-creates local-hook-team + local-hook-project,
  generates cmem_<random> key (SHA-256 hashed), inserts into api_keys
  with scopes events:write/sessions:write/observations:read/jobs:read.
  Settings file written with chmod 0600. rotateServerBetaApiKey() wired
  to a new `claude-mem server keys rotate` command.
- src/cli/handlers/{observation,session-init,summarize}.ts — every hook
  handler tries server-beta first when configured, falls through to the
  existing worker path on transport/5xx/429/missing-key. One WARN line
  per fallback. Hook JSON output shape unchanged.
- src/shared/SettingsDefaultsManager.ts — three new keys with defaults:
  CLAUDE_MEM_SERVER_BETA_URL, CLAUDE_MEM_SERVER_BETA_API_KEY,
  CLAUDE_MEM_SERVER_BETA_PROJECT_ID.
- src/npx-cli/commands/install.ts — when installer selects server-beta
  runtime and CLAUDE_MEM_SERVER_DATABASE_URL is set, bootstraps a local
  API key automatically. Warns and continues if the DB URL is missing.

plugin/scripts/*.cjs bundles rebuilt via npm run build to pick up the
new hook handler code path. No plaintext keys in the bundle (verified).

Verification: 16 hook unit tests pass; 275 server/storage/services tests
pass with 7 pre-existing failures (verified independent of this change
via git stash --include-untracked). Build clean. No new typecheck
errors in Phase 7 files.

Anti-pattern guards verified:
- /api/sessions/observations only reached via explicit fallback path
- server-beta runtime never starts the worker process
- API keys live only in ~/.claude-mem/settings.json (chmod 0600), never
  in the bundle (grep confirmed)
- Worker fallback preserved, observable via single WARN line per call

Deferred: semantic context injection (UserPromptSubmit hook) stays
worker-only; server-beta does not yet expose /v1/context/semantic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 8 — MCP backed by server-beta core

MCP tools now route through server-beta in server-beta mode while keeping
worker-mode search/timeline/get_observations tools fully working.

- src/servers/mcp-server.ts — five new observation_* tools registered:
  observation_add, observation_record_event, observation_search,
  observation_context, observation_generation_status. Three memory_*
  compatibility aliases delegate to the canonical handlers. Worker
  auto-start is gated when selectRuntime() === 'server-beta' so MCP
  in server-beta mode never spawns the worker.
- src/services/hooks/server-beta-client.ts — addObservation,
  searchObservations, contextObservations, getJobStatus added so MCP
  shares one transport with hooks (Phase 7).
- src/server/routes/v1/ServerV1PostgresRoutes.ts — POST /v1/search and
  POST /v1/context REST cores backed by PostgresObservationRepository
  full-text search (GIN tsvector from Phase 1).
- Existing memory_search/timeline/get_observations tools call
  callWorkerAPI unchanged in worker mode; worker tests unaffected.

Verification: 39 pass / 4 skip / 0 fail on targeted suite. Pre-existing
7 baseline failures verified independent (git stash). No new typecheck
errors. WorkerService grep clean across src/servers/mcp-server.ts and
src/server/.

Anti-pattern guards verified:
- No duplicate generation logic in MCP — observation_record_event hits
  /v1/events which owns event+outbox+enqueue inside one tx
- WorkerService not imported anywhere under MCP server-beta path
- No hardcoded worker URLs — all transport via Phase 7 ServerBetaClient
- memory_* aliases retained, single handler per pair

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 9 — compatibility adapters without coupling

Legacy /api/sessions/observations and /api/sessions/summarize endpoints
keep working on server-beta runtime by translating to AgentEvent and
session-end calls — no worker code, no route duplication.

- src/server/services/IngestEventsService.ts — shared event-ingest path
  used by both /v1/events and the compat adapter. Owns transactional
  event row + outbox row + lifecycle log + post-commit BullMQ enqueue,
  honors Phase 6 SessionGenerationPolicy.
- src/server/services/EndSessionService.ts — shared session-end path
  used by both /v1/sessions/:id/end and the compat adapter. Idempotent
  ended_at + summary outbox + deterministic summary job id.
- src/server/compat/SessionsObservationsAdapter.ts — translates legacy
  POST /api/sessions/observations payload (Claude Code transcript shape)
  -> AgentEvent (source_adapter='claude-code-compat',
  event_type='tool_use') -> IngestEventsService.ingestOne. Resolves
  contentSessionId to server_sessions via find-or-create.
- src/server/compat/SessionsSummarizeAdapter.ts — translates legacy
  POST /api/sessions/summarize -> EndSessionService.end. Preserves the
  legacy agentId -> {status:'skipped', reason:'subagent_context'}
  behavior so existing clients see the same response shape.
- src/server/routes/v1/ServerV1PostgresRoutes.ts — refactored to
  delegate to the new shared services (-203 LoC net) so /v1 and
  /api compat both call the SAME canonical code path.
- src/server/runtime/ServerBetaService.ts — registers both compat
  adapters alongside ServerV1PostgresRoutes, sharing service instances.
- docs/server-beta-parity-map.md — full enumeration of legacy /api/*
  routes labeled native, adapter, or unsupported (with reasons).
  Viewer read-path adapters explicitly listed as unsupported pending
  a future viewer-rewrite phase.

Verification: 7 compat tests pass, 6 v1-routes tests still pass
(refactor preserved behavior), 4 session-routes tests pass. Pre-
existing 16 baseline failures verified independent via git stash.
Zero new typecheck errors.

Anti-pattern guards verified:
- No services/worker/http/routes or WorkerService imports under
  src/server/compat or src/server/runtime
- Compat adapters are thin translators with names ending in *Adapter
  and a top-of-file comment noting they are legacy compatibility
- /v1/* remains the canonical Server beta API; compat adapters
  call shared services rather than acting as a parallel API

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 10 — Docker stack and deployable runtime

Server beta now ships as a Docker stack with no worker process anywhere
and a separate horizontal generation worker for scaling.

- src/server/runtime/create-server-beta-service.ts — validateServerBetaEnv()
  fails fast on missing CLAUDE_MEM_SERVER_DATABASE_URL, requires
  CLAUDE_MEM_QUEUE_ENGINE=bullmq in Docker, rejects
  CLAUDE_MEM_AUTH_MODE=local-dev and CLAUDE_MEM_ALLOW_LOCAL_DEV_BYPASS
  inside containers (detected via /.dockerenv or CLAUDE_MEM_DOCKER=1).
  Adds CLAUDE_MEM_GENERATION_DISABLED so the HTTP service can run
  generator-free.
- src/server/runtime/ServerBetaService.ts — runServerBetaGenerationWorker
  for the dedicated consumer process; runServerBetaApiKeyCli is a new
  Postgres-backed `server api-key` command (the legacy worker CLI wrote
  to SQLite and was invisible to the Postgres runtime); getQueueHealth
  shim feeds /api/health a consistent ObservationQueueHealth shape.
- src/npx-cli/commands/{runtime,server}.ts — `claude-mem server worker
  start` subcommand that boots only the BullMQ consumer.
- docker/claude-mem/{Dockerfile,entrypoint.sh} — entrypoint forces
  CLAUDE_MEM_DOCKER=1 + CLAUDE_MEM_RUNTIME=server-beta and exposes
  three modes: server (HTTP only, generation disabled), worker (BullMQ
  consumer), shell. Worker bundle is no longer the default CMD.
- docker-compose.yml — full stack: postgres + valkey + claude-mem-server
  (HTTP-only) + claude-mem-worker (generation consumer). Wires
  service-to-service env vars.
- scripts/e2e-server-beta-docker.sh + docker/e2e/server-beta-e2e.mjs —
  E2E now hits /v1/sessions/start, /v1/events?wait=true, /v1/jobs/:id;
  asserts no worker-service.cjs process anywhere in the stack;
  one-shot docker compose run --rm verifies local-dev auth is
  rejected with the expected stderr; restart-and-verify confirms
  Postgres durability and BullMQ retry idempotency.
- docs/server.md — full Phase 10 doc: stack diagram, env table,
  worker mode, auth-in-Docker policy.
- docs/api.md — event generation semantics (wait=true, generationJob).

Verification: full Docker E2E PASSED on live daemon
(phase1 + phase2 + restart-and-verify + revoked-key + no-worker-
process + local-dev-rejected). Unit tests 292 pass / 9 skip / 7 fail
(7 fails pre-existing baseline). Zero new typecheck errors.

Anti-pattern guards verified:
- entrypoint never execs worker-service.cjs; E2E greps prove no
  worker process anywhere in the stack
- validateServerBetaEnv refuses local-dev auth in Docker with explicit
  remediation message; ALLOW_LOCAL_DEV_BYPASS rejected the same way
- Docker requires CLAUDE_MEM_QUEUE_ENGINE=bullmq; in-process queue
  rejected at startup
- claude-mem worker / worker-service / WorkerService greps clean
  in docker/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 11 — team-aware generation with audit chain

Generation jobs now carry team_id/project_id/api_key_id/actor_id/
source_adapter from enqueue through execution; the outbox is reloaded
from Postgres before any side effect so BullMQ payload can never act
as auth authority.

- src/server/jobs/types.ts — ServerGenerationJobPayloadSchema (Zod
  discriminated union) requires team_id, project_id, generation_job_id,
  source_adapter, api_key_id, actor_id (nullable), source_type, source_id,
  plus event_id / server_session_id per kind. assertServerGenerationJobPayload
  is called at enqueue (outbox.ts) and again at execution boundary.
- src/server/services/{IngestEventsService,EndSessionService}.ts +
  SessionGenerationPolicy.ts — thread identity context (apiKeyId, actorId,
  sourceAdapter) into both event and summary BullMQ payloads.
- src/server/generation/ProviderObservationGenerator.ts —
  loadCanonicalOutbox loads the outbox row WITHOUT scope filter, then
  compares candidate.team_id/project_id to payload.team_id/project_id;
  mismatch -> ServerGenerationScopeViolationError (non-retryable),
  failed status, generation_job.scope_violation audit. isApiKeyRevoked
  checks api_keys (revoked_at, expires_at, row missing) before any
  provider call; revoked -> generation_job.revoked_key audit + non-
  retryable failure. generation_job.processing audit emitted on lock.
- src/server/generation/processGeneratedResponse.ts — generated
  observations carry team_id/project_id/server_session_id from the
  reloaded source row (not job payload). observation_sources.metadata
  records source_adapter, actor_id, api_key_id for traceability.
  observation.created audit per observation; generation_job.completed
  audit per terminal transition. All audit rows reference the same
  generation_job_id in details.
- src/server/routes/v1/ServerV1PostgresRoutes.ts — GET /v1/teams/:id/jobs
  and GET /v1/projects/:id/jobs with SQL-layer scoping (WHERE team_id=$1
  [AND project_id=$2] [AND status=$3]); cross-tenant returns 404 to
  avoid leaking row existence. Pagination via status/limit/offset.
  audit_log rows for event.received, event.batch_received, observation.read.
- src/server/compat/{SessionsObservationsAdapter,SessionsSummarizeAdapter}.ts —
  propagate apiKeyId and sourceAdapter='claude-code-compat'.

Verification: 162 pass / 10 skip / 0 fail. Pre-existing failures in
tests/services/queue and tests/services/worker confirmed independent
via git stash. Zero new typecheck errors in server-beta files.
Required greps:
  rg "team_id.*req\.body|project_id.*req\.body" src/server -> 0 matches
Audit chain integration test passes — generation_job.processing,
observation.created, and generation_job.completed audit rows all
share the same generation_job_id reference.

Anti-pattern guards verified:
- BullMQ payload never acts as auth authority — Postgres outbox
  reload with mismatch check happens before every side effect
- team_id / project_id never derived from request body for scope
  decisions; always req.authContext.teamId / projectId
- Application-layer team/project filtering forbidden — listJobsForScope
  pushes scope into the SQL WHERE clause
- Project-scoped key on cross-project /v1/teams/:id/jobs returns 404
- Revoked api keys cause non-retryable failure with audit before
  any provider call

Deferred: a redundant generation_job.queued audit_log row (already
covered by observation_generation_job_events lifecycle log per Phase 1
schema split). Compat adapters set actor_id=null but propagate
api_key_id which is the canonical reference downstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 12 — observability and operations

Operators can now inspect, retry, and cancel generation jobs from the
CLI; queue lane metrics flow into /api/health and /v1/info; every
request gets a stable request_id that flows through HTTP -> audit ->
outbox -> generator -> completion log.

- src/server/middleware/request-id.ts — honors safe inbound X-Request-Id,
  mints uuid v4 otherwise. Set on req.requestId and echoed via response
  header so external traces can correlate.
- src/server/jobs/ServerJobQueue.ts — QueueEvents wired with completed,
  failed, progress, stalled, error listeners; lifecycle counters
  exposed via observe() API. Logs emitted as
  [generation] job=<id> source_type=<...> duration=<ms> attempts=<N>
  reason=<message>. Stalled and error counters survive worker restart.
- src/server/jobs/types.ts — ServerGenerationJob payload schema
  extended with optional request_id; flows through from HTTP into
  every BullMQ job.
- src/server/queue/ObservationQueueEngine.ts — health snapshot now
  carries per-lane (event, summary) counts via
  ObservationQueueHealthLaneSnapshot.
- src/server/runtime/{ActiveServerBetaQueueManager,
  ActiveServerBetaGenerationWorkerManager,ServerBetaService}.ts —
  per-lane getJobCounts feed /api/health and /v1/info; stalled events
  audit through audit_log with action generation_job.stalled.
- src/server/routes/v1/ServerV1PostgresRoutes.ts —
  GET /v1/jobs (status/source_type/since/limit/offset, scope from
  api-key, payload stripped unless ?include=payload AND admin scope),
  POST /v1/jobs/:id/retry (idempotent; queued -> no-op; audit
  generation_job.retried_by_operator), POST /v1/jobs/:id/cancel
  (terminal -> no-op; audit generation_job.cancelled_by_operator;
  generator reload-before-side-effects already prevents double work).
- src/server/services/IngestEventsService.ts +
  SessionGenerationPolicy.ts + ProviderObservationGenerator.ts —
  request_id propagated end to end. Generator extracts request_id
  from BullMQ payload and includes it in lock/processing/completion
  logs and audit details.
- src/npx-cli/commands/server-jobs.ts +
  src/npx-cli/commands/server.ts — `claude-mem server jobs
  status|failed|retry|cancel`. status compares Postgres outbox counts
  to BullMQ queue counts and surfaces divergence. failed prints
  attempts + last_error message. --team and --project filters.

Verification: 350 pass / 12 skip / 7 fail (pre-existing baseline,
verified independent via git stash). 18 new tests added (request-id
middleware, server-jobs CLI seams, jobs list/retry/cancel routes
Postgres-gated). Zero new typecheck errors.

Anti-pattern guards verified:
- agent_events.payload only emitted in /v1/jobs response inside the
  admin-gated branch (?include=payload + admin scope) — returns 403
  otherwise
- jobs retry on a queued row is a no-op (no double BullMQ enqueue,
  no double UPDATE)
- Every operator action writes to audit_log with the
  *_by_operator action and request_id correlation in details
- Stalled events audit through generation_job.stalled

Sample correlated trace (one request_id end to end):
  HTTP middleware: req.requestId = 'req-abc'
  audit event.received: details.requestId = 'req-abc'
  BullMQ payload: { request_id: 'req-abc', generation_job_id: 'gj_x' }
  generator lock log: [generation] job locked { jobId, requestId }
  audit generation_job.processing: details.requestId = 'req-abc'
  completion log: [generation] job=evt_... duration=1230ms

Deferred: live /api/health round-trip integration test (needs
Redis); stalled event live integration test (needs Redis); storing
request_id on the observations row itself (spec did not require).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(server-beta): add Phase 13 release readiness report

Captures the final verification gate: tests (1749 pass, 45 fail all
pre-existing baseline, zero regressions), required greps clean,
Docker E2E green end-to-end, all 7 exit criteria met, build clean,
typecheck unchanged from main. Documents deferred items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* build(server-beta): rebuild server-beta-service bundle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-beta): address Greptile review on PR #2383

- ProviderObservationGenerator.lockOutbox: skip duplicate worker run when
  another lock is active instead of returning the row, which previously let
  two BullMQ workers issue the (paid, rate-limited) external provider call
  before the persistence-layer terminal-status guard collapsed the duplicate.
  Reconciliation still recovers from a stale lock on startup or next retry.
- docker-compose.yml: require POSTGRES_USER/PASSWORD/DB env vars (no
  defaults). Stack refuses to start without explicit secrets. Added a header
  warning that the file must not be deployed unmodified.
- e2e-server-beta-docker.sh: export ephemeral test creds for the new
  required env vars so the Docker E2E driver still runs unattended.
- ServerBetaService api-key list: bound query with LIMIT/OFFSET (default 100,
  max 500) and add optional --team filter to prevent unintentional
  cross-tenant key metadata disclosure on shared admin hosts.
- SessionGenerationPolicy: fix dead `??` fallback for NaN parseInt result;
  use `||` so DEFAULT_DEBOUNCE_MS actually applies.
- ServerV1PostgresRoutes: `?wait=true` now actually waits — polls the outbox
  row until terminal status (timeout 30s, 100ms interval) on both
  /v1/events and /v1/events/batch. Returns `waitTimedOut: true` if the cap
  is hit so callers can re-poll the status endpoints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-beta): address CodeRabbit + Greptile second review on PR #2383

P1 fixes
- Operator retry endpoint was re-publishing the Postgres outbox metadata
  column as the BullMQ payload; the worker's
  assertServerGenerationJobPayload always rejected it, leaving the row
  stuck in queued until startup reconciliation. Persist the BullMQ payload
  on the outbox row at create-time inside IngestEventsService and
  EndSessionService, then re-enqueue that canonical payload on retry.

Major fixes
- prompt-builder: escape server_session_id when interpolating into the
  XML prompt; previously a session id containing `<`, `&`, or quotes
  could inject XML into the provider input.
- ServerJobQueue: route both worker.on('stalled') and the QueueEvents
  'stalled' subscriber through a single notifyStalled helper that
  dedupes by jobId for 30s, so counters.stalled increments once per
  stall. QueueEvents 'error' now routes through notifyQueueError so
  it increments counters.errored and runs onError listeners — keeping
  observability symmetric across both sources.
- ServerV1PostgresRoutes: convert PostgresObservationRepository from
  three dynamic imports to a single static import for consistency.
- mcp-server / ServerBetaClient: actually forward the
  observation_record_event tool's `generate` flag through to the
  /v1/events endpoint as `?generate=false` instead of voiding it.
- server-sessions.markGenerationFailed: guard jsonb_set against a null
  error payload so the failure path can't null out metadata before the
  generation_status='failed' write commits.

Minor fixes
- server-sessions.endSession: keep updated_at stable on repeated calls
  so the documented idempotency contract holds.
- SettingsDefaultsManager + ServerBetaService.getServerBetaPort: derive
  the server-beta default port from UID (37877 + uid%100), matching the
  worker port pattern, so two users on the same host don't collide.
  Docker stacks always pass CLAUDE_MEM_SERVER_PORT explicitly so the
  containerized deployment is unaffected.
- server-session-runtime test: close the pg.Pool in afterAll.
- server-beta-release-readiness.md: escape pipes inside table inline
  code, add `text` language tag to the fenced log block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-beta): address Greptile + CodeRabbit third review on PR #2383

P1 fixes
- SessionsObservationsAdapter.resolveServerSession: catch unique-violation
  (23505) on concurrent compat inserts and re-fetch instead of returning
  500. Two compat callers carrying the same contentSessionId can both
  observe `existing===null` and race on the (project_id,
  external_session_id) unique constraint; the second now resolves to the
  raced row instead of dropping the event.
- /v1/events/batch: pass `sourceAdapter: null` to ingestBatch so each
  event's BullMQ payload (and persisted outbox payload column) reflects
  its own event.sourceAdapter via buildEventBullmqPayload's fallback,
  rather than stamping the whole batch with the first event's adapter.

Minor
- server-session-runtime test afterEach: wrap DROP SCHEMA in try/finally
  so client.release() always runs even if the drop throws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): drop `pool as never` cast — pg.Pool already matches PostgresPool

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-beta): retry of completed job now 409s instead of duplicating

retryGenerationJob previously fell through to the reset+re-enqueue path
when called on a job in `completed` status. The observations index
dedupes on (generation_job_id, parsed_observation_index, content) but
LLM output is non-deterministic, so a second provider run almost always
produced a different content string and bypassed the index, persisting a
parallel set of observation rows attributed to the same generation job.

Match cancelGenerationJob's 409 guard for completed jobs. failed and
cancelled remain valid retry targets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* build(server-beta): rebuild bundles after rebase onto main

Regenerates the three plugin bundles so they reflect the rebased source
state. Mechanical rebuild output only — no source changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-beta): wrap resolveServerSession in try/catch for structured error response

Greptile P1 on PR #2383: resolveServerSession was called before the try/catch
in both compat adapters, so Postgres errors during session lookup (timeout,
pool exhaustion, etc.) escaped to Express's default error handler and returned
HTML/text 500s. Legacy clients calling response.json() would get a parse
failure instead of the documented { stored: false, reason: 'internal_error' }
(or { status: 'error', reason: 'internal_error' } for the summarize adapter)
shape.

Move the resolveServerSession call inside the existing try block in both
adapters so any failure flows through the structured catch handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-beta): catch 23505 unique violation in POST /v1/sessions/start

Greptile P1 on PR #2383: concurrent requests with the same externalSessionId
can both pass the findByExternalIdForScope check, both call repo.create,
and the loser hits the (project_id, external_session_id) unique constraint.
The handler treated that as an unknown error and returned a 500.

Apply the same pattern resolveServerSession already uses: catch error.code
'23505' when externalSessionId is set, refetch the row inserted by the
winning request, and return 200 with that session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-05-11 00:26:11 -07:00
committed by GitHub
parent a10d1b342f
commit e7bbb2a9aa
72 changed files with 13901 additions and 982 deletions
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: Apache-2.0
//
// Phase 7 — Runtime selector for hook subcommands.
//
// Reads `CLAUDE_MEM_RUNTIME` from `~/.claude-mem/settings.json` (via
// `loadFromFileOnce`) and decides whether the hook should call the
// server-beta /v1 endpoints or fall through to the worker compat path.
//
// This module deliberately does not import worker code so that hooks
// running in server-beta mode can reach the runtime even when no worker
// is installed.
import { loadFromFileOnce } from '../../shared/hook-settings.js';
import { logger } from '../../utils/logger.js';
import { ServerBetaClient, type ServerBetaClientConfig } from './server-beta-client.js';
export type SelectedRuntime = 'worker' | 'server-beta';
export interface ServerBetaRuntimeContext {
runtime: 'server-beta';
client: ServerBetaClient;
projectId: string;
serverBaseUrl: string;
}
export interface WorkerRuntimeContext {
runtime: 'worker';
}
export type RuntimeContext = ServerBetaRuntimeContext | WorkerRuntimeContext;
export function selectRuntime(): SelectedRuntime {
const settings = loadFromFileOnce();
const raw = (settings.CLAUDE_MEM_RUNTIME ?? 'worker').trim().toLowerCase();
if (raw === 'server-beta') return 'server-beta';
return 'worker';
}
export function buildServerBetaContext(): ServerBetaRuntimeContext | null {
const settings = loadFromFileOnce();
const serverBaseUrl = (settings.CLAUDE_MEM_SERVER_BETA_URL ?? '').trim();
const apiKey = (settings.CLAUDE_MEM_SERVER_BETA_API_KEY ?? '').trim();
const projectId = (settings.CLAUDE_MEM_SERVER_BETA_PROJECT_ID ?? '').trim();
if (!serverBaseUrl) {
logger.warn('HOOK', '[server-beta-fallback] reason=missing_base_url');
return null;
}
if (!apiKey) {
logger.warn('HOOK', '[server-beta-fallback] reason=missing_api_key');
return null;
}
if (!projectId) {
logger.warn('HOOK', '[server-beta-fallback] reason=missing_project_id');
return null;
}
const config: ServerBetaClientConfig = {
serverBaseUrl,
apiKey,
};
return {
runtime: 'server-beta',
client: new ServerBetaClient(config),
projectId,
serverBaseUrl,
};
}
export function resolveRuntimeContext(): RuntimeContext {
if (selectRuntime() !== 'server-beta') {
return { runtime: 'worker' };
}
const ctx = buildServerBetaContext();
if (!ctx) {
return { runtime: 'worker' };
}
return ctx;
}
export function logServerBetaFallback(reason: string, details?: Record<string, unknown>): void {
logger.warn('HOOK', `[server-beta-fallback] reason=${reason}`, details ?? {});
}
+209
View File
@@ -0,0 +1,209 @@
// SPDX-License-Identifier: Apache-2.0
//
// Phase 7 — Local API key bootstrap for the server-beta runtime.
//
// When the operator selects `runtime: "server-beta"` during install (or via
// the `claude-mem server keys rotate` command), we provision a local hook
// API key against the local Postgres so hooks can authenticate to /v1/*.
//
// Bootstrapping flow:
// 1. Connect to Postgres (CLAUDE_MEM_SERVER_DATABASE_URL).
// 2. Find or create a "local-hook" team and project so the api_key has
// proper tenant scope.
// 3. Generate a `cmem_<random>` key, hash with SHA-256, insert into
// `api_keys` with the scopes hooks need: events:write, sessions:write,
// observations:read, jobs:read.
// 4. Persist the plaintext key to ~/.claude-mem/settings.json under
// `CLAUDE_MEM_SERVER_BETA_API_KEY`, then chmod that file to 0600 so
// only the owner can read it.
//
// The plaintext key is NEVER written into the generated bundle and never
// logged.
import { createHash, randomBytes } from 'crypto';
import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { dirname } from 'path';
import { createPostgresPool, type PostgresPool } from '../../storage/postgres/pool.js';
import { parsePostgresConfig } from '../../storage/postgres/config.js';
import { PostgresAuthRepository } from '../../storage/postgres/auth.js';
import { PostgresProjectsRepository } from '../../storage/postgres/projects.js';
import { PostgresTeamsRepository } from '../../storage/postgres/teams.js';
const LOCAL_HOOK_TEAM_NAME = 'local-hook-team';
const LOCAL_HOOK_PROJECT_NAME = 'local-hook-project';
const LOCAL_HOOK_ACTOR_ID = 'system:local-hook-bootstrap';
export const HOOK_API_KEY_SCOPES: readonly string[] = Object.freeze([
'events:write',
'sessions:write',
'observations:read',
'jobs:read',
]);
export interface BootstrapResult {
rawKey: string;
apiKeyId: string;
teamId: string;
projectId: string;
}
export interface BootstrapDependencies {
pool?: PostgresPool;
// For tests: skip pool.end() because the caller owns lifecycle.
closePool?: boolean;
}
export async function bootstrapServerBetaApiKey(
deps: BootstrapDependencies = {},
): Promise<BootstrapResult> {
const closePool = deps.closePool ?? deps.pool === undefined;
const pool = deps.pool ?? buildPoolFromEnv();
try {
const teamId = await findOrCreateTeam(pool);
const projectId = await findOrCreateProject(pool, teamId);
const rawKey = createRawApiKey();
const keyHash = hashApiKey(rawKey);
const repo = new PostgresAuthRepository(pool);
const created = await repo.createApiKey({
keyHash,
teamId,
projectId,
actorId: LOCAL_HOOK_ACTOR_ID,
scopes: [...HOOK_API_KEY_SCOPES],
});
await repo.createAuditLog({
teamId,
projectId,
actorId: LOCAL_HOOK_ACTOR_ID,
apiKeyId: created.id,
action: 'api_key.create',
resourceType: 'api_key',
resourceId: created.id,
details: { source: 'server-beta-bootstrap' },
});
return {
rawKey,
apiKeyId: created.id,
teamId,
projectId,
};
} finally {
if (closePool) {
await pool.end().catch(() => undefined);
}
}
}
export interface RotateOptions {
previousApiKeyId?: string | null;
pool?: PostgresPool;
}
export async function rotateServerBetaApiKey(options: RotateOptions = {}): Promise<BootstrapResult> {
const closePool = options.pool === undefined;
const pool = options.pool ?? buildPoolFromEnv();
try {
if (options.previousApiKeyId) {
await pool.query(
`UPDATE api_keys SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`,
[options.previousApiKeyId],
);
}
return await bootstrapServerBetaApiKey({ pool, closePool: false });
} finally {
if (closePool) {
await pool.end().catch(() => undefined);
}
}
}
export function persistServerBetaSettings(
settingsPath: string,
values: { apiKey: string; projectId: string; serverBaseUrl?: string },
): void {
const dir = dirname(settingsPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
let existing: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>;
} catch {
existing = {};
}
}
// Settings file format: prefer the flat shape (modern). The migration in
// SettingsDefaultsManager.loadFromFile already collapses nested → flat.
const flat = (existing.env && typeof existing.env === 'object'
? existing.env
: existing) as Record<string, unknown>;
flat.CLAUDE_MEM_SERVER_BETA_API_KEY = values.apiKey;
flat.CLAUDE_MEM_SERVER_BETA_PROJECT_ID = values.projectId;
if (values.serverBaseUrl) {
flat.CLAUDE_MEM_SERVER_BETA_URL = values.serverBaseUrl;
}
writeFileSync(settingsPath, JSON.stringify(flat, null, 2), 'utf-8');
// Hooks read this file on every invocation; restrict permissions so other
// local users cannot read the API key.
try {
chmodSync(settingsPath, 0o600);
} catch {
// Non-POSIX filesystems may reject chmod; settings file remains readable.
}
}
export function createRawApiKey(): string {
return `cmem_${randomBytes(32).toString('base64url')}`;
}
export function hashApiKey(rawKey: string): string {
return createHash('sha256').update(rawKey).digest('hex');
}
async function findOrCreateTeam(pool: PostgresPool): Promise<string> {
const existing = await pool.query<{ id: string }>(
`SELECT id FROM teams WHERE name = $1 LIMIT 1`,
[LOCAL_HOOK_TEAM_NAME],
);
if (existing.rows[0]) {
return existing.rows[0].id;
}
const repo = new PostgresTeamsRepository(pool);
const team = await repo.create({ name: LOCAL_HOOK_TEAM_NAME, metadata: { source: 'local-hook-bootstrap' } });
return team.id;
}
async function findOrCreateProject(pool: PostgresPool, teamId: string): Promise<string> {
const existing = await pool.query<{ id: string }>(
`SELECT id FROM projects WHERE team_id = $1 AND name = $2 LIMIT 1`,
[teamId, LOCAL_HOOK_PROJECT_NAME],
);
if (existing.rows[0]) {
return existing.rows[0].id;
}
const repo = new PostgresProjectsRepository(pool);
const project = await repo.create({
teamId,
name: LOCAL_HOOK_PROJECT_NAME,
metadata: { source: 'local-hook-bootstrap' },
});
return project.id;
}
function buildPoolFromEnv(): PostgresPool {
const config = parsePostgresConfig({ requireDatabaseUrl: true });
if (!config) {
throw new Error(
'Cannot bootstrap server-beta API key: CLAUDE_MEM_SERVER_DATABASE_URL is not set.',
);
}
return createPostgresPool(config);
}
+400
View File
@@ -0,0 +1,400 @@
// SPDX-License-Identifier: Apache-2.0
//
// Phase 7 — Server beta HTTP client used by hook subcommands when the
// installer/setting selects the server-beta runtime. This client speaks
// directly to the server-beta runtime's `/v1/*` endpoints. It MUST NOT
// import or transitively depend on the worker runtime: the whole point
// of phase 7 is that hooks can reach server-beta even when no worker is
// running.
//
// On any transport-class failure (timeout, ECONNREFUSED, 5xx, missing
// API key, etc.) callers receive a typed `ServerBetaClientError` so the
// hook handler can decide whether to fall back to the worker path.
import { fetchWithTimeout } from '../../shared/worker-utils.js';
import { HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
const DEFAULT_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.API_REQUEST);
export type ServerBetaClientErrorKind =
| 'missing_api_key'
| 'transport'
| 'timeout'
| 'http_error'
| 'invalid_response';
export class ServerBetaClientError extends Error {
readonly kind: ServerBetaClientErrorKind;
readonly status: number | null;
readonly cause?: unknown;
constructor(kind: ServerBetaClientErrorKind, message: string, options: {
status?: number | null;
cause?: unknown;
} = {}) {
super(message);
this.name = 'ServerBetaClientError';
this.kind = kind;
this.status = options.status ?? null;
this.cause = options.cause;
}
isFallbackEligible(): boolean {
if (this.kind === 'transport' || this.kind === 'timeout' || this.kind === 'missing_api_key') {
return true;
}
if (this.kind === 'http_error') {
// 5xx and 429 are transient; fall back. 4xx other than 429 is a real
// client bug — surface it via the worker path so it can be observed.
if (this.status !== null && this.status >= 500) return true;
if (this.status === 429) return true;
}
return false;
}
}
export interface ServerBetaClientConfig {
serverBaseUrl: string;
apiKey: string;
timeoutMs?: number;
}
export interface ServerBetaStartSessionRequest {
projectId: string;
externalSessionId?: string | null;
contentSessionId?: string | null;
agentId?: string | null;
agentType?: string | null;
platformSource?: string | null;
metadata?: Record<string, unknown>;
}
export interface ServerBetaStartSessionResponse {
session: {
id: string;
projectId: string;
teamId: string;
externalSessionId: string | null;
contentSessionId: string | null;
[key: string]: unknown;
};
}
export interface ServerBetaRecordEventRequest {
projectId: string;
serverSessionId?: string | null;
contentSessionId?: string | null;
memorySessionId?: string | null;
sourceType: 'hook' | 'worker' | 'provider' | 'server' | 'api';
eventType: string;
payload?: unknown;
occurredAtEpoch: number;
// When false, the event is recorded but no generation job is enqueued.
// Maps to the REST endpoint's `?generate=false` query flag.
generate?: boolean;
}
export interface ServerBetaRecordEventResponse {
event: {
id: string;
projectId: string;
serverSessionId: string | null;
[key: string]: unknown;
};
generationJob?: {
id: string;
status: string;
[key: string]: unknown;
};
}
export interface ServerBetaEndSessionRequest {
sessionId: string;
}
export interface ServerBetaEndSessionResponse {
session: {
id: string;
[key: string]: unknown;
};
generationJob?: {
id: string;
status: string;
[key: string]: unknown;
};
}
// Phase 8 — direct/manual observation insertion through `/v1/memories`.
// This calls the same Postgres repository path as the REST core, so MCP
// and REST never diverge on what counts as a valid observation insert.
export interface ServerBetaAddObservationRequest {
projectId: string;
serverSessionId?: string | null;
kind?: string;
content: string;
metadata?: Record<string, unknown>;
}
export interface ServerBetaAddObservationResponse {
memory: {
id: string;
projectId: string;
teamId: string;
serverSessionId: string | null;
kind: string;
content: string;
metadata: Record<string, unknown>;
[key: string]: unknown;
};
}
// Phase 8 — full-text search over generated observations.
export interface ServerBetaSearchObservationsRequest {
projectId: string;
query: string;
limit?: number;
}
export interface ServerBetaSearchObservationsResponse {
observations: Array<{
id: string;
projectId: string;
content: string;
[key: string]: unknown;
}>;
}
// Phase 8 — context pack for prompt injection. Server returns both the
// matched observations AND a pre-joined `context` string.
export interface ServerBetaContextObservationsRequest {
projectId: string;
query: string;
limit?: number;
}
export interface ServerBetaContextObservationsResponse {
observations: Array<{
id: string;
projectId: string;
content: string;
[key: string]: unknown;
}>;
context: string;
}
// Phase 8 — generation job status, scoped by api-key team/project.
export interface ServerBetaJobStatusResponse {
generationJob: {
id: string;
status: string;
[key: string]: unknown;
};
}
export class ServerBetaClient {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly timeoutMs: number;
constructor(config: ServerBetaClientConfig) {
this.baseUrl = stripTrailingSlash(config.serverBaseUrl);
this.apiKey = config.apiKey;
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
}
async startSession(input: ServerBetaStartSessionRequest): Promise<ServerBetaStartSessionResponse> {
const body = this.buildStartSessionPayload(input);
return this.request<ServerBetaStartSessionResponse>('POST', '/v1/sessions/start', body);
}
async recordEvent(input: ServerBetaRecordEventRequest): Promise<ServerBetaRecordEventResponse> {
const body = this.buildEventPayload(input);
const path = input.generate === false ? '/v1/events?generate=false' : '/v1/events';
return this.request<ServerBetaRecordEventResponse>('POST', path, body);
}
async endSession(input: ServerBetaEndSessionRequest): Promise<ServerBetaEndSessionResponse> {
if (!input.sessionId) {
throw new ServerBetaClientError('invalid_response', 'sessionId is required for endSession');
}
return this.request<ServerBetaEndSessionResponse>(
'POST',
`/v1/sessions/${encodeURIComponent(input.sessionId)}/end`,
{},
);
}
// Phase 8 — direct observation insert (MCP `observation_add`). Calls
// `/v1/memories`, which is the canonical write path that MUST NOT enqueue
// a generation job. Anti-pattern guard for plan line 770: never duplicate
// generation logic in MCP tools.
async addObservation(
input: ServerBetaAddObservationRequest,
): Promise<ServerBetaAddObservationResponse> {
return this.request<ServerBetaAddObservationResponse>(
'POST',
'/v1/memories',
this.buildAddObservationPayload(input),
);
}
// Phase 8 — MCP `observation_search`. Routes to the FTS-backed REST
// endpoint so search ranking and tenant scoping are owned by one place.
async searchObservations(
input: ServerBetaSearchObservationsRequest,
): Promise<ServerBetaSearchObservationsResponse> {
return this.request<ServerBetaSearchObservationsResponse>(
'POST',
'/v1/search',
this.buildSearchPayload(input),
);
}
// Phase 8 — MCP `observation_context`. Same FTS surface as search, but
// returns a pre-joined context string suitable for direct prompt injection.
async contextObservations(
input: ServerBetaContextObservationsRequest,
): Promise<ServerBetaContextObservationsResponse> {
return this.request<ServerBetaContextObservationsResponse>(
'POST',
'/v1/context',
this.buildSearchPayload(input),
);
}
// Phase 8 — MCP `observation_generation_status`. Server returns the same
// payload as `/v1/jobs/:id` so MCP clients and REST clients see identical
// job status (including transport state).
async getJobStatus(jobId: string): Promise<ServerBetaJobStatusResponse> {
if (!jobId) {
throw new ServerBetaClientError('invalid_response', 'jobId is required for getJobStatus');
}
return this.request<ServerBetaJobStatusResponse>(
'GET',
`/v1/jobs/${encodeURIComponent(jobId)}`,
);
}
buildAddObservationPayload(
input: ServerBetaAddObservationRequest,
): Record<string, unknown> {
return {
projectId: input.projectId,
content: input.content,
...(input.serverSessionId !== undefined ? { serverSessionId: input.serverSessionId } : {}),
...(input.kind !== undefined ? { kind: input.kind } : {}),
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
};
}
buildSearchPayload(
input: { projectId: string; query: string; limit?: number },
): Record<string, unknown> {
return {
projectId: input.projectId,
query: input.query,
...(input.limit !== undefined ? { limit: input.limit } : {}),
};
}
buildStartSessionPayload(input: ServerBetaStartSessionRequest): Record<string, unknown> {
return {
projectId: input.projectId,
...(input.externalSessionId !== undefined ? { externalSessionId: input.externalSessionId } : {}),
...(input.contentSessionId !== undefined ? { contentSessionId: input.contentSessionId } : {}),
...(input.agentId !== undefined ? { agentId: input.agentId } : {}),
...(input.agentType !== undefined ? { agentType: input.agentType } : {}),
...(input.platformSource !== undefined ? { platformSource: input.platformSource } : {}),
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
};
}
buildEventPayload(input: ServerBetaRecordEventRequest): Record<string, unknown> {
return {
projectId: input.projectId,
sourceType: input.sourceType,
eventType: input.eventType,
occurredAtEpoch: input.occurredAtEpoch,
...(input.serverSessionId !== undefined ? { serverSessionId: input.serverSessionId } : {}),
...(input.contentSessionId !== undefined ? { contentSessionId: input.contentSessionId } : {}),
...(input.memorySessionId !== undefined ? { memorySessionId: input.memorySessionId } : {}),
...(input.payload !== undefined ? { payload: input.payload } : {}),
};
}
private async request<T>(
method: 'GET' | 'POST',
path: string,
body?: unknown,
): Promise<T> {
if (!this.apiKey || !this.apiKey.trim()) {
throw new ServerBetaClientError(
'missing_api_key',
'Server beta API key is not configured (CLAUDE_MEM_SERVER_BETA_API_KEY).',
);
}
const url = `${this.baseUrl}${path}`;
const init: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
};
if (body !== undefined) {
init.body = JSON.stringify(body);
}
let response: Response;
try {
response = await fetchWithTimeout(url, init, this.timeoutMs);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const isTimeout = /timed out|timeout/i.test(message);
throw new ServerBetaClientError(
isTimeout ? 'timeout' : 'transport',
`Server beta ${method} ${path} failed: ${message}`,
{ cause: error },
);
}
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new ServerBetaClientError(
'http_error',
`Server beta ${method} ${path} returned ${response.status}: ${truncate(text, 200)}`,
{ status: response.status },
);
}
const text = await response.text();
if (!text || text.length === 0) {
// Endpoints we call always return JSON; a body-less success is unusual
// but not fatal — return undefined-shaped object.
return {} as T;
}
try {
return JSON.parse(text) as T;
} catch (error: unknown) {
throw new ServerBetaClientError(
'invalid_response',
`Server beta ${method} ${path} returned non-JSON response`,
{ cause: error },
);
}
}
}
export function isServerBetaClientError(error: unknown): error is ServerBetaClientError {
return error instanceof ServerBetaClientError;
}
function stripTrailingSlash(url: string): string {
return url.replace(/\/+$/, '');
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return `${text.slice(0, max)}`;
}