When Claude calls project.identify with `source` ∈ {explicit, header,
inferred, undefined}, the response now includes a `setupHint` field
with a short message + a copy-pasteable command to create the
`.shared-memory-project` file. When `source: 'file'` is passed,
no hint is emitted (the user already has the file).
Hint only fires on owned-project responses — you can't ask a viewer of
a shared project to commit to a repo they don't own.
Tool description tells Claude: "Pass `source` based on how you
resolved the key. If the response carries a setupHint, briefly relay
its message and command to the user." This gives us a one-prompt
nudge per session without auto-creating files or being pushy — the
user decides.
ProjectIdentifyInput in @shared-memory/schemas gains an optional
`source: 'file' | 'explicit' | 'header' | 'inferred'` field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces "per-machine X-Project-Key header" as the default way to tell
Claude Code which shared-memory project a repo belongs to. Commit a
single-line `.shared-memory-project` text file at the repo root; every
collaborator's Claude Code reads it at session start and attaches all
memories + snippets to the same shared project. No per-machine config
required.
Changes:
- New file convention documented in README (resolution order, format,
authoring snippet, rationale for plain-text over JSON).
- project.identify tool description now leads with "check
.shared-memory-project at the repo root", with inference as fallback.
- memory.write description mentions the file as the canonical source for
project keys.
- Project detail page in the Web UI shows a copy-paste `echo > file`
command so users see exactly what to add to their repo.
- Added .shared-memory-project to this repo (content: `shared-memory`).
Resolution precedence is: explicit tool arg → .shared-memory-project →
X-Project-Key header → inference. The file beats the header because
repo context is more specific than machine context.
This is a soft convention — Claude has to read the file. Directive tool
descriptions make this very likely; a Claude Code skill would make it
bulletproof, deferred until we see whether the description alone is
enough.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Phase 4f from the deferred backlog. Renders every audit_log row
that pertains to a project — memory writes/updates/deletes, snippet
puts/deletes, share grants/changes/revocations, project.identify
collisions — newest first, capped at 150 rows.
Query is a three-leg UNION ALL joining audit_log against memories,
snippets, and the project itself. Avoids relying on payload->>projectKey
which isn't populated consistently across all action shapes.
Each row renders as "<actor> <verb-phrase>" with entity links where
applicable, plus a compact relative timestamp. memory.update entries
also show scope/project transitions inline when those changed. Share
events surface the group name and access level with the existing
tone-coded Badge.
Auth: viewable by anyone with read access to the project (owner +
ro/rw group members). Same guard as the project detail page.
Linked from the project page header so it's discoverable without typing
the URL. No nav-bar link — it's a per-project artifact, not a global
view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the long-standing backlog item from abfa5463. memory.search now
accepts an optional `minScore` parameter (Zod range 0..1) that drops hits
below the given Reciprocal Rank Fusion score. Default behavior is
unchanged — when minScore is unset, every fused result is returned, same
as today.
The original design memo suggested defaulting to 0.020, but that would
exclude valid pure-semantic matches (one ranker at rank 1 = 1/61 ≈
0.0164). Real-world Phase 2 testing surfaced exactly that case (the
"expose TLS" → HAProxy memory hit). Shipping unfiltered-by-default and
exposing the knob lets specific callers opt into stricter filtering
(e.g. ~0.025 to require two rankers to fire at rank 1) without
penalising legitimate semantic-only hits for the rest.
Tool description updated; per-source rank breakdown remains the primary
confidence signal for the model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. lib/snippets.ts + lib/memory-actions.ts (×2): shared-project key
lookups were querying `projects` by key with no visibility scope. Since
`projects.key` is unique per user (not global), the unscoped match
could resolve another user's project entirely. Restricted the lookups
to `readableProjectIds(userId, groupNames)` — own + shared only.
2. lib/access.ts getAccessibleProjects: a stray `if (existing) continue`
inside the share-collapse loop short-circuited on the first match,
killing the rw-beats-ro upgrade path. Two-group cases where one share
was ro and another rw on the same project were incorrectly resolved
as ro. Replaced with explicit owner/rw skip.
3. lib/mcp/tools.ts snippetPut: removed a dead `void exists` block that
looked like an authorization pre-flight but was actually a no-op —
real write authorization lives inside putSnippet, called next. Added
a comment at the call site documenting where the check is.
4. memory.delete + snippet.delete: previously had no optimistic-lock
CAS, so a concurrent peer edit could be silently overwritten by a
delete on a stale view. Added optional `version` to MemoryDeleteInput
(new) and SnippetDeleteInput (extended); UPDATE WHERE now CASes on
version; 0-row response surfaces CONCURRENT_EDIT_ERROR. Web detail
pages pass `version` through hidden form inputs. When the caller
doesn't supply a version, falls back to the version we just read in
the same handler for in-handler consistency.
5. lib/mcp/tools.ts memorySearch re-fetch: missing `isNull(deletedAt)`
on the post-search row hydration left a TOCTOU window where a row
soft-deleted between the search and the re-fetch would be returned.
Visibility is still enforced by searchMemories itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds project-level sharing via the new project_shares table plus the
infrastructure that makes multi-user editing safe and visible.
Authorization (lib/access.ts):
- getAccessibleProjects / getProjectAccess centralise the predicate
used by every read and write path.
- readableProjectIds / writableProjectIds drive listing-style queries.
- Web UI Server Actions and pages source group memberships from the
user_groups table so authorization works without depending on
Agent A's session callback shape.
Optimistic locking:
- memories + snippets gain version + last_edited_by columns. Every
UPDATE bumps version and stamps the editor; UPDATE WHERE clauses
require the caller's pre-fetched version, surfacing a clear
"refresh and try again" error on lost-write races rather than
silently clobbering.
- MemoryUpdateInput / SnippetPutInput accept an optional version
token.
MCP tools:
- memory.write / .update / .delete / .get / .list / .search,
snippet.put / .get / .list / .delete now respect shared-project
access (read = owner | any share, write = owner | rw share).
- project, defaults to ctx.defaultProjectKey from the X-Project-Key
header (populated by the MCP route — Agent A's wiring).
- project.identify returns shared projects you have access to and
prefers an owned project on key collision, audit-logging the
collision so an operator can debug it.
- Tool descriptions for memory.update, memory.write, snippet.put,
and project.identify updated with the co-edit / shared-project
notes.
Web UI:
- Project detail page: ownership badge, shared-with-N-groups badge,
owner-only "Manage sharing" section (add/flip/remove shares via
lib/share-actions.ts). Add-share is constrained to groups the
granter is already in.
- "Shared" chips on memory cards in /memories and /dashboard.
- "Last edited by ..." on memory + snippet detail pages, shown only
when the last editor isn't the row's original author so the chip
stays informative.
- Read-only viewers (ro shares) lose Edit/Delete affordances on
memories and snippets.
Migration 0004_project_shares.sql adds project_shares + the two new
columns on memories and snippets; it depends on Agent A's
0003_groups.sql for the groups, user_groups, and memory_access enum.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundational work for the upcoming group-scoped sharing feature.
Schema (migration 0003_groups.sql + drizzle schema):
- memory_access enum ('ro' | 'rw') reserved for Agent B's project_shares
- groups (id, oidc_iss, name, display_name, …) keyed by (oidc_iss, name)
so different IdPs can both have e.g. "platform" without colliding
- user_groups (user_id, group_id, synced_at) PK (user_id, group_id)
Auth (auth.ts + lib/auth/sync-groups.ts):
- jwt callback now syncs `profile.groups` after upserting the user
- syncUserGroupsFromClaim runs in a single tx: upserts each group,
inserts new memberships, deletes ones no longer in the claim
- missing/empty claim → user has zero groups (wipe memberships)
- EntraID GUID-vs-name edge case: we treat whatever strings the claim
emits as names verbatim; groups overage (>200 groups → no claim)
is documented as unsupported in v1
UserContext + JWT (lib/mcp/context.ts, lib/auth/jwt.ts):
- AuthenticatedClaims.groups surfaced from verified JWT payload
- UserContext.groups: string[] — live from OIDC token claim, falls
back to DB snapshot for CLI (HMAC) tokens which carry no claim
- UserContext.defaultProjectKey: optional, set from header
MCP route (app/api/mcp/route.ts):
- reads X-Project-Key header, validates against ProjectKey Zod schema,
400 on invalid; empty/missing leaves defaultProjectKey undefined
- auto-upserts the header-supplied project so first-use works without
a separate project.identify call
Tools (lib/mcp/tools.ts):
- withDefaultProject helper injects ctx.defaultProjectKey when the
caller omits `project`. Per-tool defaultScope hint avoids breaking
snippet.put (user-scope default) while making memory.write
(project-scope default) honor the header
- applied to memory.write/list/search/update and all snippet.* tools
Web UI:
- /settings/groups debug page lists current memberships with synced_at
and a clear empty state pointing at README troubleshooting
- /settings/tokens grows a "Pin to project" dropdown; selected key is
baked into the generated `claude mcp add` snippet as
`--header "X-Project-Key: <key>"`. The JWT itself stays
identity-only — pinning is purely a UX shortcut
- settings landing page links to /settings/groups
- README troubleshooting bullet covers the empty-groups path for
Authentik / EntraID / Keycloak
Refactor:
- extracted resolveProjectId + upsertProject from memory-actions.ts
into lib/projects.ts so the MCP route can reuse upsertProject
Verification:
- pnpm typecheck clean
- SKIP_ENV_VALIDATION=true pnpm build clean; /settings/groups in route table
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five high-confidence findings from the post-merge reviewer pass:
1. memory.update MCP tool description claimed "Project is upserted if it
doesn't exist", but the handler used resolveProjectId and would error.
Matched the description to the actual behavior (call project.identify
first) — keeps parity with memory.write.
2. updateMemoryAction's UPDATE statement was missing the userId guard.
The preceding scoped SELECT made it not exploitable in practice, but
it diverged from deleteMemoryAction's pattern. Added the guard for
defense in depth.
3. putSnippet's UPDATE statement had the same missing userId guard —
fixed the same way.
4. MemoryUpdateInput's refine for scope='user' accepted both
project=undefined AND project=""; the snippets refine only accepted
undefined. Tightened MemoryUpdateInput to require undefined, matching
the snippets rule. Web actions already coerce "" → undefined before
parsing, so no caller is affected.
5. 0002_snippets_scope.sql created two indexes unconditionally —
replaced with CREATE INDEX IF NOT EXISTS so re-runs after a
drizzle-kit push won't trip.
Also adds .claude/ to .gitignore so worktree directories from
multi-agent builds aren't accidentally committed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Snippets are exact-name-keyed templates (PR formats, checklists, style
rules) — prescriptive artifacts the user wants applied consistently.
Distinct from memories, which are searched descriptive prose.
- Migration 0002 adds scope/project_id/deleted_at to snippets, partial
unique indexes per scope, and a scope/project CHECK constraint
mirroring memories_scope_project_chk.
- New @shared-memory/schemas: SnippetName/Put/Get/List/Delete inputs
with shared scope-project refinement.
- lib/snippets.ts: get/put/list/softDelete helpers used by both the
Web Server Actions and the MCP tool handlers.
- Four new MCP tools (snippet.put/get/list/delete) with directive
descriptions contrasting against memory.* (exact-name lookup vs
search; templates vs facts).
- /snippets pages: list, new, [name] with edit + delete and a
scope-picker when a name lives in multiple scopes.
- Nav: Snippets link between Memories and Projects.
Design note: when a name exists in both user and project scope,
snippet.get without an explicit scope prefers project (if project key
given) then falls back to user. The detail page shows a picker when
the name is ambiguous and no scope query param was supplied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends both the Web UI edit form and the memory.update MCP tool so
existing memories can be reclassified between user-global and
project-attached scopes without delete+rewrite. Schema refines enforce
the user/project consistency invariants; audit log captures from/to
scope and projectKey on transitions.
Server components can't pass function props (onSubmit handlers etc.) to
client-rendered HTML elements. The handler here was a no-op leftover —
removing it fixes the 500 'Event handlers cannot be passed to Client
Component props' on /memories/[id].
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-user OIDC-gated storage is strictly safer than writing API keys /
credentials to local container files, so the tool description should
not discourage that use case. Adds an explicit allowlist for sensitive
data the user actively shares (vs. asking for them).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 1/2 descriptions explained WHAT each tool does. The model has
no other signal for WHEN to call them, so they only fire on explicit
user prompts ("remember that…", "search for…"). This rewrites each
description to lead with the trigger condition.
Highlights:
- memory.write now explicitly contrasts with the built-in file-based
memory at ~/.claude/.../memory/, so transient or container-specific
state stays there while user/project facts go here
- memory.search description tells the model to call it BEFORE answering
any question that might touch a previously-shared fact ("cheap; lean
toward calling it")
- project.identify becomes a session-start ritual when there's a repo
context, anchoring all subsequent project-scoped writes
- update vs delete: explicit "prefer update" guidance to keep ids stable
No code or schema changes — descriptions are pure metadata that surface
in the MCP tools/list response on the next session of every connected
client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the debug /me + /connect pages with a real authed app shell.
Pages
- / — anonymous landing; redirects to /dashboard once signed in
- /dashboard — recent memories + top projects, quick "new memory" action
- /memories — searchable list with hybrid (vector+FTS+tags) scoring;
per-result rank breakdown shown inline
- /memories/[id] — view + inline edit toggle + delete
- /memories/new — create form with project autocomplete
- /projects — list with memory counts and last-activity
- /projects/[key] — that project's memories
- /settings — read-only Authentik profile + link to tokens
- /settings/tokens — list / create / revoke CLI tokens
Old URLs preserved as redirects:
- /me → /dashboard
- /connect → /settings/tokens
Stack additions
- Tailwind v4 with CSS-first @theme tokens (dark only for now)
- App shell in app/(authed)/ — auth guard + top nav with global search box
- Lightweight UI primitives in app/_components/ui/ (Button, Input, Card,
Badge, EmptyState, Container, PageHeader)
- Search logic extracted from MCP tool into lib/memories.ts so Web UI and
MCP both call the same RRF code path
- Memory CRUD via Server Actions in lib/memory-actions.ts; audit_log
rows are tagged actor='web' to distinguish from MCP writes
Per-token revoke
- New cli_tokens table (id, user_id, jti unique, name, created_at,
last_used_at, expires_at, revoked_at) — migration 0001_cli_tokens.sql
- mintCliToken now records jti + name; verifyCliToken enforces revocation
for tracked tokens. Legacy tokens minted before this change (no jti)
are accepted on signature alone until they expire naturally.
- /settings/tokens lists active + revoked tokens with one-click revoke
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the manual-paste fallback page MCP clients hit when their OAuth
loopback callback isn't reachable (sealed containers, port-restricted
hosts). The page displays the authorization code, the full callback URL,
and the state parameter, all with copy buttons, plus instructions to
paste back into the waiting terminal. Single-use codes plus client-side
PKCE mean displaying the code here is safe — it isn't a credential by
itself.
README "Connecting Claude Code" rewritten to make OAuth the primary
path, with three options ordered by preference: (A) standard OAuth +
loopback, (B) manual paste via /auth/cli-callback, (C) static HMAC
bearer via /connect for fully headless setups. Also notes that the
zero-config plugin path is blocked on Authentik DCR (issue #8751,
expected later this year) so we're shipping the one-liner now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Authentik-preset provider with an inline generic OIDC config
(id: "oidc"). The verifier path was already protocol-generic; the only
Authentik-named piece was the next-auth provider preset, which mapped to
the same OIDC fields anyway. Any compliant IdP — Authentik, EntraID,
Keycloak, Okta, Auth0, Zitadel, etc. — now works with just the existing
OIDC_* env vars.
Breaking change for existing deployments: the Auth.js callback path
changes from /api/auth/callback/authentik → /api/auth/callback/oidc.
Update the Web-UI client's redirect URI in your IdP before redeploying.
README rewritten to frame Authentik as the worked example, with a
concept-mapping table for EntraID and Keycloak, and audience-claim notes
for non-Authentik IdPs in the troubleshooting section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a small embedder sidecar (Xenova/bge-small-en-v1.5, ONNX, CPU-only)
that the web app calls inline on memory.write and memory.update, and on
demand from the new memory.search tool.
memory.search performs three candidate fetches in parallel — pgvector
cosine similarity, Postgres full-text via plainto_tsquery + ts_rank_cd,
and tag-set overlap — then fuses them with Reciprocal Rank Fusion
(k=60). Each result carries its per-source rank so the model can see
*why* a memory surfaced.
The migrator boot step gained an idempotent embedding backfill: any row
with embedding IS NULL is batched (32 at a time) through the embedder
after SQL migrations apply. Safe to run on every boot.
New tool memory.update fixes the missing edit path; centralises the
re-embed-on-content-change rule alongside write.
Stack additions:
- apps/embedder/ — Fastify server, persistent /data/models volume so the
~30 MB model only downloads once
- apps/web/lib/embedder.ts — typed HTTP client with batched embed +
health probe
- packages/schemas — MemoryUpdateInput, MemorySearchInput
- docker-compose — embedder service, healthcheck, app + migrator both
depend_on it healthy; EMBEDDER_URL promoted to a required env var
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a second token kind alongside Authentik OIDC access tokens for MCP
authentication. When the user visits /connect after signing into the Web
UI, the server mints an HMAC-signed JWT (kid="cli-v1") carrying their
Authentik identity in oidc_iss / oidc_sub claims. The token is shown
once in React state — never put in the URL or persisted on the client.
The MCP endpoint's bearer-token verifier dispatches by JWT `kid` header:
CLI tokens are verified locally via HS256(CLI_TOKEN_SECRET); everything
else goes through Authentik JWKS. Both paths resolve to the same
AuthenticatedClaims shape so userContextFromClaims handles them
identically.
This unblocks MCP clients running in containers where the OAuth loopback
callback isn't reachable — paste the token into Claude Code as a static
Authorization header and skip the OAuth flow entirely.
Revocation in v1 is "rotate CLI_TOKEN_SECRET to invalidate every issued
CLI token at once." Per-token revocation can come later if needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pages.signIn is meant to point at a CUSTOM sign-in page. Setting it to
'/api/auth/signin' — Auth.js's own built-in endpoint — makes Auth.js
redirect there whenever it wants the sign-in page, which is the same
endpoint, producing ERR_TOO_MANY_REDIRECTS in browsers.
Omitting the setting falls back to Auth.js's default sign-in handler,
which renders the provider-picker HTML at /api/auth/signin instead of
redirecting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Zod's .url().optional() still rejects "" because the empty string is a
present value. EMBEDDER_URL is unused in Phase 1 and intentionally left
blank in .env, so preprocess "" to undefined before validation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gin_trgm_ops only applies to text, not text[]. The plain GIN index on
tags is sufficient for the `@>` / `<@` / `&&` set-containment operators
used by memory.list. pg_trgm stays loaded for future fuzzy search over
content.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When esbuild bundles migrate.ts to apps/web/migrate.mjs (one directory
higher than the source location), the relative `..` path in the previous
implementation pointed at apps/, not apps/web/. Try both layouts and
allow an explicit MIGRATIONS_DIR override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pnpm --filter runs in the package directory, so the input and output
paths must be relative to apps/web, not the repo root.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Dockerfile uses `pnpm exec esbuild` to bundle the migrator into a
single ESM file before copying it into the runtime image. pnpm exec only
resolves binaries from declared dependencies, so the transitive esbuild
that tsx pulls in wasn't visible to the build stage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Local pnpm builds worked because all workspace packages had their
node_modules populated by `pnpm install`. The Docker builder stage was
only restoring `apps/web/node_modules` from the deps stage, leaving
`packages/schemas/node_modules` empty — so `next build` couldn't resolve
`zod` when transpiling the shared schemas package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end Phase 1 of shared-memory: a logged-in Authentik user can sign
into the Web UI (/me debug page), and an MCP client with an Authentik-
issued bearer token can call memory.write / memory.list / memory.get /
memory.delete plus project.identify against /api/mcp.
Stack:
- Next.js 15 (App Router) + React 19 + TypeScript, pnpm workspaces
- Drizzle ORM + Postgres 16 + pgvector + pg_trgm
- Auth.js v5 with Authentik provider (Web UI)
- jose + Authentik JWKS for MCP bearer-token validation
- JSON-RPC 2.0 dispatcher implementing the MCP wire protocol over plain
HTTP POST (hand-rolled to fit Next.js App Router; switches to SSE in a
later phase if server-initiated events are needed)
- bge-small embeddings sidecar deferred to Phase 2; the schema already
reserves the vector(384) column + IVFFlat index, FTS via a STORED
tsvector column, and the visibility enum (private/shared/team) so
cross-user memory sharing can be added without a future migration
Deployment supports two modes (set in .env, never committed):
- Behind an external reverse proxy (HAProxy / nginx / Cloudflare Tunnel /
Traefik) — DEFAULT; the app exposes APP_PORT on the host with
X-Forwarded-* trusted, no in-container TLS
- Built-in TLS via Caddy — opt-in with `docker compose --profile tls up`
Discovery endpoint at /.well-known/oauth-protected-resource (RFC 9728)
points MCP clients at the Authentik authorization server after a 401.
README walks through both Authentik providers (Web UI + MCP resource
server), the audience scope mapping, redirect URIs, and includes a worked
HAProxy config snippet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>