Tages
README.md
# Tages
**Team memory for AI coding agents.**
[](LICENSE)
[]()
Your AI agents forget everything between sessions. Every decision re-litigated. Every convention re-explained. Every past mistake repeated by the next agent that touches the same code.
Personal memory tools patch the symptom: store some facts, retrieve them later. That works for solo developers who remember what they stored. It breaks down when teams ship with AI — when three developers are using agents on the same codebase, when onboarding requires transferring institutional knowledge, when the wrong memory survives a refactor because no one audited it.
Memory isn't storage. It's a team practice.
Tages treats codebase memory as a managed artifact: structured types, quality scoring, audit trails, sharpen passes that rewrite vague notes into imperative agent instructions. One developer's architecture decision becomes every agent's context. A bad memory gets flagged and corrected before it misleads the next session.
Start in under 60 seconds with one command. Add team features when your workflow demands them.
```bash
claude mcp add tages -- npx -y @tages/server
```
## Why Tages?
| | Tages | Zep | Mem0 |
|---|---|---|---|
| Install | One line (`claude mcp add`) | Docker + API key | API key + SDK |
| Local-only mode | Yes (SQLite, zero config) | Self-hosted only | No (cloud-only) |
| Team sharing | Yes (RBAC, federation) | Yes (cloud) | No |
| Dashboard | Yes (Next.js, analytics) | Yes | Basic |
| Quality control | Audit, sharpen, enforce | No | No |
| Memory types | 11 structured types | Knowledge graph (Graphiti) | Structured |
| MCP tools | 56 | N/A | N/A |
| Search | Trigram + semantic + decay | Temporal knowledge graph | Vector |
| Workflow integration | Git hooks, CI/CD, briefs | SDK calls | SDK calls |
| Pricing | Free local / $14 Pro | Open-source / Cloud | $19-$249/mo |
## What It Remembers
Source code tells agents **what** exists. Tages tells them **why** it was built, **how** to work with it, and **what not to do**.
| Type | Example |
|------|---------|
| **Convention** | "Always use snake_case for API routes" |
| **Decision** | "Chose Postgres over MongoDB for pg_trgm fuzzy search" |
| **Architecture** | "Auth middleware in lib/auth.ts, JWT in httpOnly cookies" |
| **Lesson** | "Don't cache the Supabase mock — tests need fresh state" |
| **Anti-pattern** | "Never pass id in upsert with onConflict — causes FK violation" |
| **Pattern** | "All API errors return { error, code, status }" |
Plus: entity, execution, operational, environment, preference (11 types total).
## How It Works
1. **Install** — `tages init` connects to your project, or install the Claude Code plugin for zero-config setup
2. **Remember** — Store decisions and conventions manually, via git hooks, or by importing CLAUDE.md
3. **Recall** — Every session gets full project context in <10ms from local SQLite cache
### Zero-Config Auto-Detection
When running as a Claude Code plugin or MCP server, Tages automatically detects which project you're in:
1. **`.tages/config.json`** — explicit marker file (created by `tages link`)
2. **Git remote** — matches the repo name against registered projects
3. **Directory name** — matches the folder name against registered project slugs
4. **Auto-create** — if authenticated, creates a new cloud project automatically; otherwise uses local-only mode
No `tages init` required per directory. Use `tages link [slug]` to explicitly bind a directory to a project.
### Claude Code Plugin
Install Tages as a Claude Code plugin for automatic session memory:
```
/plugin https://github.com/ryantlee25-droid/tages
```
## Works With
Claude Code, Cursor, Codex, Gemini — anything that speaks [MCP](https://modelcontextprotocol.io).
## Features
- **56 MCP tools** — remember, recall, audit, sharpen, import, federation, analytics, and more
- **53 CLI commands** — full control from the terminal
- **Web dashboard** — browse, search, and edit memories with dark-mode UI
- **Auto-indexing** — git hooks extract decisions from commits via Ollama or Claude Haiku
- **Import** — seed from existing CLAUDE.md, ARCHITECTURE.md, or JSON files
- **`tages brief`** — generate a cached context document for system prompt injection
- **`tages audit`** — score your memory coverage and get suggestions for improvement
- **`tages sharpen`** — rewrite memories into imperative form for better agent consumption
- **Local-first** — SQLite cache for sub-10ms queries, works offline
- **Hybrid search** — pg_trgm trigram matching + pgvector semantic search
- **Team sharing** — multiple developers share one codebase memory (Pro)
## Benchmarks
Reproducible LongMemEval and coding-memory benchmark results are published under [`eval/`](eval/) with full methodology, judge configuration, and run notebooks. Results are reproducible against the published harness; raw numbers are in each eval's `results/` directory.
## Setup Guides
- [Quickstart](docs/quickstart.md)
- [Team Onboarding](docs/team-onboarding.md)
- [Claude Code](docs/claude-code-setup.md)
- [Cursor](docs/cursor-setup.md)
- [Codex](docs/codex-setup.md)
- [Gemini](docs/gemini-setup.md)
- [GitHub Actions](docs/github-actions.md)
- [Self-Hosting](docs/self-hosting.md)
## Architecture
```
packages/
server/ MCP server (56 tools, stdio transport, 818 tests: 805 passing, 13 skipped)
cli/ CLI (45 top-level commands, npm global install, 331 tests)
shared/ TypeScript types + Supabase client
apps/
dashboard/ Next.js 16, Supabase Auth, Tailwind, shadcn/ui
supabase/
migrations/ 64 migrations (tables, RLS, pgvector, RBAC, encryption)
```
## Security
- **Encryption at rest** — AES-256-GCM for memory values (opt-in)
- **RBAC** — Owner/admin write, member read-only
- **Row Level Security** — All tables enforce project membership at the database layer
- **Auth** — Supabase Auth + GitHub OAuth; API tokens SHA-256 hashed with expiration
- **Secret detection** — Memories scanned for API keys, credentials, PII before storage
- **Audit logging** — Auth events, exports, and token validation tracked
See [SECURITY.md](SECURITY.md) for our full security policy and responsible disclosure process.
See [PRIVACY.md](PRIVACY.md) for our privacy policy.
## Pricing
| Plan | Price | Includes |
|------|-------|----------|
| **Free** | $0 | 1 project (cloud sync), 10,000 memories, 20 core MCP tools, local SQLite |
| **Pro** | $14/mo | Up to 10 projects, 50K memories, all 56 tools, cloud sync |
| **Team** | $19/seat/mo | Up to 20 projects, 100K memories, federation, RBAC, audit logging |
| **Self-hosted** | Free forever | Bring your own Supabase, no limits, MIT license |
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
## Release Notes
### 2026-08-25 — `v0.5.5`: six onboarding defects, found while provisioning a real team project
Every item here was found by actually provisioning a new project and inviting two teammates, not by reading the code. All six are on the first-run path, which is why none of the 1,500+ unit tests caught them.
- **`tages init` ignored a valid saved session.** It called `runGithubOAuth()` unconditionally (`packages/cli/src/commands/init.ts`), so it demanded a fresh browser round-trip even immediately after a successful `tages login`, and failed outright after a 5-minute OAuth timeout on any headless run. `link` had always reused the session; `init` now matches it, falling back to the browser only when the stored session is expired, absent, or a `TAGES_SERVICE_KEY` client with no user to own the project.
- **The session-expiry message pointed at the one destructive command.** `packages/cli/src/auth/session.ts` and both sites in `commands/link.ts` said *"Run `tages init` to re-authenticate."* `init` **creates a project** — running it to fix auth trips the global slug-unique constraint or drops you into a `local-<slug>` store that then blocks `link`. Now `tages login`, which has existed since 0.5.0.
- **`tages recall` exited `0` on a dead session.** An expired session yields an anonymous client, every RLS-protected read returns zero rows, and the command printed "No memories found" — indistinguishable from an empty project, to a human and to a script. `createAuthenticatedClient` now reports a `SessionStatus`, and `recall` fails loudly on `expired` while leaving `anonymous` (legitimate local-only use) working.
- **"Free tier is limited to 2 projects" was never the enforced number.** The RLS policy is `is_pro(uid) OR (projects you own) < 1` (`supabase/migrations/0002_rls_policies.sql`) — a cap of **1**. The message now says 1.
- **A slug collision was reported as a billing limit.** Slugs are globally unique across all owners, and a unique violation also contains the word `violates`, so it fell into the plan-limit branch and told you to upgrade over a name clash. It is now detected first, named as a collision, and points at `tages link --project-id <uuid>` for the case where you meant to join rather than create.
- **The installed post-commit hook invoked a package that does not exist.** It ran `npx tages index --last-commit`; there is no `tages` package on npm (404) — the binary ships inside `@tages/cli`. It resolved only on machines with a global CLI install already on PATH, and failed silently everywhere else because it runs backgrounded. Now prefers `tages` on PATH and falls back to `npx -y @tages/cli`.
**Docs:** `docs/quickstart.md` and `docs/team-onboarding.md` both told new users *"do not install from npm, `@tages/cli` is 0.2.1, build from source"* and to `git checkout release/2026-08-13-onboarding` — a branch that merged long ago. Both now lead with `npm install -g @tages/cli`, with the source build as an optional aside for Tages developers. Also corrected there: the team seat limit is `LEAST(subscription_quantity, 20)` (`supabase/migrations/0049`), **not** a flat 25 — a `team` profile with a null `subscription_quantity` resolves to **1 seat**; the "no periodic pull" trap now distinguishes the CLI (reconciles automatically on every memory command) from MCP (hydrates at boot only, so an agent still needs a session restart); and the stale `tages doctor` caveat is gone, since `doctor` has probed the project-scoped `.mcp.json` first since 0.4.0.
**Versions:** `@tages/cli` 0.5.5, `@tages/shared` 0.2.3. `@tages/server` is unchanged at 0.3.4. The CLI bundles `@tages/shared` from source at build time, so the `project-factory` fixes ship inside `@tages/cli` regardless of what the registry holds.
**Tests:** +15 CLI, +3 shared. The `init` session-reuse tests were verified by reintroducing the bug — 2 of 6 failed, and the 4 fallback cases correctly stayed green.
### 2026-08-18 — Recall relevance floor: `recall` can now answer "nothing"
- **`tages recall <anything>` returned the whole project.** The recall RPCs filter on an absolute cosine threshold (`p_threshold`, 0.7 or 0.3), which against the hosted embedding model filters nothing: measured, pure nonsense (`wibbleflux ganthorpe zzzqx`) scores **0.775** against unrelated memories and *"what is the best recipe for sourdough bread"* scores **0.725** — both clear 0.7. Agents were receiving irrelevant memories presented as answers, and every positive recall result was therefore uninformative.
- **No absolute cutoff can fix this.** The model's cosines occupy a narrow high band (~0.69–0.95), and any constant tuned to it breaks when the embedding model changes — which is exactly how the existing 0.7 became inert.
- **The signal is distributional.** Calibrated over 12 memories × 13 queries (8 answerable, 5 not): a query the corpus can answer makes one result stand out; one it cannot produces a flat pack. The top result's **z-score** separated the two cleanly (answerable 2.27–3.22, unanswerable 1.12–1.99, margin 0.277) and is scale-free, unlike the absolute top (margin 0.060), top−2nd (0.008), or top−mean (0.003).
- **The rule is deliberately conservative.** Suppression is destructive — hiding a memory someone needed is worse than showing one they did not — so results are dropped only when both signals agree: a top score below 0.80 **and** either a flat distribution or too few candidates to judge. A tightly focused corpus, where everything is legitimately about one subject, keeps its high absolute scores and survives. Scoped to the semantic and chunk channels only; the trigram channel already has a real floor (measured 0 rows for the same nonsense queries) and is left alone.
- **The suite is now 137/137**, green three runs consecutively, with the paraphrase check still passing — the floor rejects nonsense without suppressing genuine semantic matches. Constants come from n=12 on one model: a defensible starting point, not a tuned optimum. `TAGES_DEBUG_RECALL=1` prints the verdict and its reason.
### 2026-08-18 — `v0.5.3`: republish with `@tages/shared` bumped
- **`v0.5.2` shipped a broken MCP server.** `evidenceWeight` was added to `@tages/shared` without bumping that package, so the publish workflow correctly skipped it as already-published at `0.2.0` — and `@tages/server@0.3.2` went out pinned to `@tages/shared@0.2.0`, a version with no such export. Every MCP `recall` failed with `(0, shared_1.evidenceWeight) is not a function`. Local builds were unaffected because the workspace link resolves to the source tree, so nothing caught it until the end-to-end suite ran against the public registry: 134/137 published vs 136/137 local, with two MCP checks failing only from npm.
- **Fix is versions, not code:** `@tages/shared` 0.2.1, `@tages/server` 0.3.3, `@tages/cli` 0.5.3.
- **Lesson worth keeping:** a monorepo package whose *exports* changed must be bumped even when its consumers were. Testing only the workspace build cannot catch this class of defect — the suite's npm mode is what did.
### 2026-08-18 — Release readiness: CLI secret gate, working authorship, `created_by` pinned in the database (migration `0074`)
- **The CLI never scanned for secrets.** `tages remember` persisted an AWS key that the MCP path already blocked (`tools/remember.ts:65`) — and it was the wrong way round, since the human-typed path is where a pasted config snippet arrives, and a stored secret is then readable by every project member and pulled into every agent's context by recall. The CLI now runs the same scan, fails closed before any write, and exposes the same `--force` override.
- **Authorship never worked.** `memories.created_by`/`updated_by` and the sync mapper have existed since `0048`, but the **local SQLite cache had neither column** — and the CLI syncs *from* SQLite, so every write dropped its author on the way out and `get_memory_authors` returned `"Unknown"` for everything. Columns added to the cache schema with an upgrade path for existing caches.
- **`created_by` is now pinned by the database (migration `0074`), because no client can get it right.** `remoteInsert` upserts with `onConflict: 'project_id,key'`, and a Supabase upsert overwrites every column it is given — so an editor either sends `null` (erasing the author) or its own id (stealing authorship). Choosing correctly needs to know whether the row exists *remotely*, which a client cannot know: the CLI's pull is rate-limited, so a teammate's row may legitimately not be cached yet. Measured — the owner editing a teammate's memory rewrote `created_by` to the owner. A `BEFORE INSERT OR UPDATE` trigger now fills it from `auth.uid()` on insert and pins it to its existing value on update. `updated_by` is left to the client, since that field is supposed to move.
- End-to-end **136/137** against prod, up from 132: the four authorship and secret-gate checks now pass. The one remaining failure is recall's missing relevance floor, a ranking-quality issue tracked separately. 916 server + 505 CLI + 40 shared tests pass.
### 2026-08-17 — Evidence levels: how well established a memory is (migration `0070`)
- **A memory carried three fields circling "how much should I trust this" and none that answered it.** `type` says what it is about, `source` says how it was captured, and `confidence` is a float that conflates the two — `0.8` could mean a test proved it or a model guessed it, and those warrant opposite behaviour from a reader. New `evidence` column: `verified` (checked against something executable), `declared` (asserted by a human as policy), `observed` (seen once), `inferred` (reasoned, not checked), `disputed` (contradicted). Named `evidence` because `status` is already `live`/`pending`/`archived`. Adapted from [YAIML](https://github.com/wirsingj/YAIML)'s evidence discipline.
- **The defaults are the design.** A human `tages remember` records `declared`; an agent's own MCP write records `inferred`. Nothing becomes `verified` unless explicitly claimed, and the MCP tool description tells the model so outright. Defaulting the flattering way would let every unexamined guess arrive dressed as a fact, which is the failure the field exists to prevent. Existing rows are **not** backfilled — `NULL` means unknown, and inventing an assessment nobody made is the same failure in another form.
- **The level changes retrieval order, in both ranking implementations.** The end-to-end suite caught that applying it only in the MCP server left `tages recall` ordering purely on lexical fit, so an inferred guess that matched the query text slightly better was served ahead of the verified fact answering the same question. Both paths now weight by evidence. `disputed` is demoted hardest but never suppressed — a contradicted claim is exactly what someone re-litigating a decision needs to find, and it renders as `DISPUTED — contradicted, re-check before acting` rather than as a quiet tag.
- **Known cost, made explicit rather than hidden:** the four recall RPCs return a fixed table shape predating this column, so the CLI issues one extra primary-key lookup to fetch levels for the fused candidates. Widening those `SECURITY DEFINER` return types would mean `DROP` + `CREATE` on four functions that `0066`, `0068` and `0072` have each already had to patch. A test asserts that exactly one such call is made, so a future change adding a third still trips.
- **Also found while testing this:** when two memories tie on score, recall order is **nondeterministic** — six identical runs produced two different orders. Unrelated to this change and not fixed here.
### 2026-08-17 — Post-merge hardening: three more PR #75 review findings closed
- **Migration `0072` closes three more SECURITY DEFINER RPCs that `0066`/`0068` missed.** `0066`'s header claimed to close "the same class of hole" but only closed four of seven. `revert_memory_to_version` and `get_memory_versions` (both `0015`) and `contextual_recall` (`0016`) share the same shape: SECURITY DEFINER, a caller-supplied `p_project_id`, no `auth.uid()` check, and no explicit GRANT so EXECUTE defaults to PUBLIC. `revert_memory_to_version` is the serious one — it issues an UPDATE on `memories`, so any authenticated caller who could guess a project UUID could overwrite that project's memory, and since `0069` that UPDATE also writes a forged entry into the audit trail via the snapshot trigger. The guard is copied verbatim from `0068` so all seven functions now share one implementation; reads return empty on denial to match the existing recall RPCs, the write raises `42501`.
- **Migration `0073` makes `hybrid_recall` filter `status = 'live'`.** The other three recall RPCs already did. `memories.status` is one of `('live','pending','archived')` per `0054` — `pending` is the `observe` staging queue awaiting approval, `archived` is a soft delete. `hybrid_recall` is the default path for `tages recall` and what the MCP `recall` tool uses, so it had been returning memories the user never approved and memories they explicitly forgot. The filter is added inside each fusion leg rather than around the fused result, since RANK() is computed per leg.
- **`auto-reconcile` is now time-bounded (default 3s, `TAGES_SYNC_TIMEOUT_MS` overrides).** It runs from a commander `preAction` hook in front of nearly every command, and no call underneath it carries an AbortSignal — a captive portal or a firewall that completes the TCP handshake and then never answers fell through to undici's 300s `headersTimeout`, hanging commands like `tages recall` for five minutes on background work the user never asked for. The abandoned promise gets a no-op catch so a late rejection can't surface after the command has moved on, and the sync handle is held in an object so it stays closable in `finally` when the timeout wins.
- Migrations are numbered `0072`/`0073` rather than `0070`/`0071` because another concurrent session independently claimed `0070_memory_evidence_level.sql`; both files were renumbered and their verifications re-run against the renamed files before this commit.
- Verified: `0072` applied to a throwaway Postgres with a fixture matching prod's ownership shape — `get_memory_versions`/`contextual_recall` return the row for the owner and for `service_role`, 0 rows for an outsider, and an outsider's `revert_memory_to_version` is blocked with `42501` while the owner's revert still succeeds. `0073` applied to a throwaway pgvector instance — `hybrid_recall` went from returning `archived-one, live-one, pending-one` to only `live-one`, with the outsider still getting 0 rows. 37 tests passing in this worktree across `reconcile-timeout`, `auto-reconcile`, `auth-store`, and `login` (5 new).
- A fourth review finding from the same round — an anchored-regex tightening of the destructive sweep in `e2e/run.mjs` — is not in this commit; it was picked up inadvertently by a concurrent session's commit while both sessions shared a working tree, and is filed under an unrelated message on `feat/evidence-levels`.
### 2026-08-17 — Version bump to actually ship the fixes, and the auth.json write path the last fix missed
- **The version bump was the actual blocker, not a formality.** Two independent re-reviewers blocked this PR on it. `@tages/cli@0.5.1` and `@tages/server@0.3.1` are already live on npm, and `.github/workflows/publish.yml` checks each package against the registry and prints SKIP + exits 0 when the version already exists — so merging and tagging without a bump would have produced a green pipeline that published nothing. Verified against the live registry rather than by reading the workflow: `npm pack @tages/server@0.3.1` then grepping the bundled `dist/index.js` still shows `await deps.tracker?.endSession()` as a bare sequential await with no `allSettled` anywhere, and `npm pack @tages/cli@0.5.1` shows zero `chmod` calls on `auth.json`. Both published packages still carry the shutdown data-loss bug and the world-readable `auth.json`, while the never-reviewed auto-reconcile feature is already live in `0.5.1`. Bumped to `@tages/cli@0.5.2` and `@tages/server@0.3.2`.
- **`login` wasn't the only writer of `auth.json` — the silent refresh in `auth/session.ts` was the other, and it mattered more.** Both used `writeFileSync(path, data, { mode: 0o600 })`, and `mode` there is the `open(2)` creation mode: ignored on a file that already exists. The prior fix covered only `login`, which a user hits occasionally. It missed the refresh path in `auth/session.ts`, which now runs on nearly every command since auto-reconcile is wired into a commander `preAction` hook — the common case, not the rare one. New `packages/cli/src/auth/store.ts` exports a single `writeAuthFile()` used by both call sites: it sets the directory to `0700` and the file to `0600` unconditionally, and opens+truncates before correcting the mode and writing, so a fresh token never sits on disk at looser permissions even momentarily.
- Also corrects the "published as `@tages/cli@0.5.1` / `@tages/server@0.3.1`" claim in the 2026-08-13 sync-fixes entry below — those versions predate this release and never carried those fixes; it now names `0.5.2`/`0.3.2`.
- Verified: 3 new tests in `auth-store.test.ts`, mutation-checked — reverting `writeAuthFile` to the plain creation-mode write fails "tightens a pre-existing 0644 auth.json instead of inheriting its mode". Full suite: 1,483 passing / 16 skipped across 8 packages (`cli` 495, `server` 907). `pnpm typecheck` clean.
### 2026-08-17 — Shutdown drain fix and CLI login permission fix (two PR #75 review blockers)
- **Shutdown dropped the entire dirty memory queue when analytics failed.** `packages/server/src/index.ts`'s drain step ran `await deps.tracker?.endSession()` then `await deps.sync?.flush()` as two sequential awaits in one async function. `endSession()` writes an analytics row and rejects on any network blip, which aborted the function before `flush()` ever ran — so every memory in the dirty queue was lost on the `exit(0)` that follows, the exact loss this block exists to prevent, over the least important thing in it. Now `Promise.allSettled` runs both regardless, logs either failure, and rethrows only a failed flush, since only that means data was left behind.
- **`tages login` could not tighten permissions on an existing `auth.json`.** `writeFileSync`'s `mode` option is the open(2) *creation* mode — applied only when the file is actually created. `login` is a re-run command by design (switch accounts, refresh an expired session), so the common case is an existing file keeping whatever bits it already had, and this file holds a live Supabase refresh token. It has been observed at `0644` on real machines. Now followed by unconditional `chmodSync(getConfigDir(), 0o700)` and `chmodSync(getAuthPath(), 0o600)`.
- Both were flagged by independent reviewers on PR #75; the shutdown fix was a merge blocker. Each has a new regression test, and both were mutation-checked: reverting the shutdown fix to sequential awaits fails "still flushes queued memories when the analytics endSession rejects"; removing the chmods fails "tightens permissions on an auth.json that already exists at 0644". Full suite after restoring both: 1,480 passing / 16 skipped across 8 packages, `pnpm typecheck` clean.
- Ships in the already-published `@tages/cli` 0.5.0 — no version bump in this commit; a follow-up release is needed to distribute the login fix.
### 2026-08-17 — Security fix: CLI login redirect could send live session tokens to any host
- **Session tokens could be redirected to any attacker-chosen host.** `apps/dashboard/src/app/auth/callback/route.ts` read the `cli_redirect` query param raw and redirected to it with `access_token`, `refresh_token`, and `user_id` attached — no host validation. An attacker could craft a provider authorize URL with `redirect_to=<site>/auth/callback?cli_redirect=https://evil.com`; the victim signs in normally and their live refresh token is delivered to the attacker. That is full account takeover, and the refresh token is long-lived. The sibling route `/auth/cli` already had the correct loopback-only guard, but `/auth/callback` never called it, and an attacker doesn't need to go through `/auth/cli` to reach the callback at all. The guard is now extracted to a shared module, `apps/dashboard/src/lib/safe-redirect.ts` (`isLocalRedirect`), and used by both routes instead of duplicated — a copy per route is how the two drifted apart in the first place. `/auth/cli`'s local copy was deleted in favor of the import; its behavior is unchanged.
- **Open redirect via the `next` param, same file.** `${origin}${next}` looks origin-locked but is not: `next=//evil.com` and `next=/\evil.com` are protocol-relative and leave the site. Now validated by the new `isSafeRelativePath`, falling back to `/app/projects`.
- **`accept_pending_invites` failures were invisible.** The call sat in a `try/catch`, but `supabase.rpc()` resolves with `{ data, error }` rather than throwing, so the catch caught nothing and a failed invite sweep logged nowhere. Now the result is destructured and `error.code`/`error.message` are logged. Still non-fatal by design — a failed invite sweep must not block the sign-in that triggered it.
- **Removed three internal audit/handoff documents from this public repository**: `FINDINGS-RELEASE-AUDIT.md`, `HANDOFF-ONBOARDING-DAY.md`, `PLAN-HOSTED-EMBEDDING.md`. Verified they contain no key material, but they document that the production `service_role` key is unrotated and world-readable, give a working privilege-escalation call against the named production deployment, name the prod project ref, and pair real user emails with `auth.users` UUIDs. `.gitignore` now blocks the whole class (`FINDINGS-*.md`, `HANDOFF-*.md`, `PLAN-*.md`, `SPLIT-*.md`). These files were public on this branch and remain in git history — removal stops further exposure but does not un-publish what was already visible.
- Verified: `isLocalRedirect`/`isSafeRelativePath` executed directly against 22 cases including `http://localhost.evil.com`, `http://127.0.0.1.evil.com`, `http://user@evil.com`, `javascript:`, `//evil.com`, and `/\evil.com` — all correctly rejected, while `http://127.0.0.1:<port>` and `http://localhost:<port>` are still accepted so the CLI login flow keeps working. `tsc --noEmit` clean, `next build` compiled successfully.
### 2026-08-17 — End-to-end suite, the `memory_versions` sync blocker (migration `0069`), and bidirectional CLI sync
- **Every edit permanently killed that user's cloud sync, silently (migration `0069`).** `memory_versions` was created in `0006` with RLS enabled and exactly one policy, `for select`; no `INSERT` policy has ever existed in any of the 69 migrations. The `snapshot_memory_version()` BEFORE UPDATE trigger on `memories` was plain `plpgsql` with no `SECURITY DEFINER`, so it ran as the invoking user and its insert was denied — and a BEFORE trigger that raises takes the whole `UPDATE` with it. Worse, the rejected row stayed dirty in the local sync queue, so every subsequent flush retried it, hit the same error, and aborted, taking brand-new unrelated memories down with it. From a user's first correction onward, nothing they wrote reached their team. Exit code stayed `0` and stdout stayed empty; only stderr carried the warning. Fixed by making the trigger `SECURITY DEFINER` with a pinned `search_path` (the pattern `0051` already uses for `check_seat_limit_on_update`) and populating `changed_by_user_id`. Safe because the function takes no caller-supplied arguments — every value comes from `OLD`/`NEW` of a row the caller was already authorized to update. Applied to prod; the end-to-end suite went from 85/109 to 115/122 on that change alone.
- **The CLI now reconciles with the cloud on its own** (`packages/cli/src/sync/auto-reconcile.ts`). Sync was previously push-only, with the local store refreshed just once at MCP server startup — so a teammate's memory was invisible until you restarted your agent. A commander `preAction` hook now pushes local changes and pulls remote state before any command that reads or writes memories. Best-effort throughout: it never changes a command's exit status or output. Rate-limited to one round trip per 60s (`TAGES_SYNC_TTL_MS`; `TAGES_NO_AUTO_SYNC=1` disables). Skips `init`, `link`, `login`, `logout`, `whoami`, `doctor`, `token`, `harness`, `dashboard`, `onboard` — `doctor` especially, since a doctor that repairs what it is diagnosing hides the bug.
- **The database is authoritative, without that meaning data loss.** Order is push-then-pull, always, and `hydrateFromRemote` now skips keys still marked dirty. Both are load-bearing: `upsertMemory(mem, false)` overwrites the value *and* clears the dirty flag, so a pull-first reconciliation would revert an unsynced local edit to the cloud's older copy and drop it from the retry queue, with no error anywhere. `hydrateFromRemote` also reports what it withheld, and `SupabaseSync.hydrate()` holds its `last_synced_at` watermark when anything was skipped — advancing past a withheld revision would push it outside every future `updated_at > lastSynced` window, so that key could never be refreshed again while the "cache is current" fast path reported it up to date forever.
- **New end-to-end suite at `e2e/`**, 126 assertions across 11 phases, driving the real CLI and the real MCP server as five separately authenticated identities with isolated `$HOME`s and their own git work repos, against real Supabase. Covers create, retrieve, update, invite/join, bidirectional sharing, roles and seat limits, cold-cache persistence, tenant isolation, retraction, and reconciliation. Phase 99 asserts the suite itself can fail. Teardown runs in a `finally` and on `SIGINT`/`SIGTERM`, then verifies zero fixture rows remain; a start-of-run sweep clears residue from any earlier run killed where teardown could not run. `node e2e/run.mjs --set-credentials` stores keys in the macOS Keychain — the `security -w` prompt cannot be used, as its `readpassphrase(3)` buffer is 128 bytes and truncates a Supabase JWT silently at exit 0.
- **Known and unfixed**, surfaced by the suite: the CLI never calls `scanForSensitiveData`, so `tages remember` persists a secret the MCP server would block (`tools/remember.ts:65`); `memories.created_by`/`updated_by` are never populated by any write path, so `get_memory_authors` returns `"Unknown"`; and recall has no relevance floor, returning top-k for nonsense queries.
- Published as `@tages/cli@0.5.2` and `@tages/server@0.3.2`. **Both are required** — the CLI carries the reconcile hook, and the server carries the `preserveDirty` guard and the watermark hold that the MCP startup hydration depends on. The other five packages are unchanged since their last release. Migration `0069` is already applied to prod, so the database half of the fix protects everyone regardless of client version.
### 2026-08-17 — Marketing site: mobile overflow, button shadow, pill rendering, nav a11y
- **Fixed horizontal overflow on every phone-width viewport.** At 390px the document laid out 523px wide, pushing the nav hamburger off-screen and running section headings off the right edge. Root cause was `min-width: auto`, the default on grid/flex children, refusing to shrink below content width — one `nowrap` command line (`tages link --project-id <project-id>`) set a width floor for the whole page. Fixed with `min-w-0` in four places: the `Command` code element and `CodePanel` root in `ui.tsx`, both grid children in `search.tsx`, and the step body in `quickstart.tsx`. Verified zero horizontal overflow across 8 routes at 5 viewport widths (360/390/430/768/1024) against a production build.
- **Removed the accent-tinted halo from the primary button's shadow**, keeping only the neutral hairline contact shadow. The fill (`signal-600` #2563eb) is identical to every other blue on the site, but the blue-tinted shadow darkened its own edge so the button read heavier than the same blue reads as text. A lighter fill (`signal-500` #3b82f6) was rejected — it gives white label text only 3.68:1 contrast, under the 4.5:1 AA floor; `signal-600` is 5.17:1.
- **Fixed the `convention` memory type rendering as bare text on `/examples`** while the other seven types rendered as pills. The tint was built by string concatenation (`` `${color}15` ``), which works for a bare hex but produced `var(--color-signal-600)15` for the one type whose color was a token, silently invalidating the background and border declarations. Replaced with `color-mix(in srgb, <color> N%, transparent)`, which works for hex and `var()` alike.
- **Mobile nav menu now locks body scroll while open, closes on Escape, and closes when the viewport crosses the 768px breakpoint** — the panel is `md:hidden`, so without that last case rotating to landscape would hide the menu while leaving the scroll lock stranded on.
Verified with `npx tsc --noEmit` (clean), `pnpm build` (clean), and a 9-check browser suite against `next start` covering scroll lock on/off, Escape, resize release, button fill, absence of the halo, 5.17:1 label contrast, and all 22 type pills painting a capsule.
### 2026-08-17 — Recall RPCs: restore service-role access, a zero-row regression from `0066` (migration `0068`)
- **`tages recall` silently returned zero rows for every query, on every project, since `0066_recall_rls_guards.sql`.** 0066 added an `auth.uid()` access guard to all four recall RPCs (`recall_memories`, `semantic_recall`, `hybrid_recall`, `chunk_semantic_recall`). All four are `SECURITY DEFINER`, so the guard is an ordinary predicate rather than RLS, and the service role does not bypass it: under `TAGES_SERVICE_KEY`, `auth.uid()` is `NULL`, both branches of the guard evaluate false, and every recall RPC returned zero rows. `tages recall --all` kept working the entire time because it is a plain PostgREST table select, which the service role does bypass RLS on — that asymmetry, identical credentials producing a full table read but zero RPC rows, was the whole bug, and it looked like a broken search index rather than an access-control regression. 0066's own header predicted this exact failure, labelled it a known behavior change, and deliberately deferred the service-role decision to a later migration; `0068` is that migration.
- **Fix adds a `service_role` exemption to the guard**, reading the role from the request-scoped JWT GUC rather than `current_user` (which under `SECURITY DEFINER` becomes the function owner and would silently exempt every caller, not just service_role). It fails closed: an absent GUC yields `NULL`, and `NULL is distinct from 'service_role'` is true, so the guard still applies to every other caller. This is not a new hole — a service-role caller can already read `memories` directly with RLS bypassed, so the exemption only restores parity between the RPC path and the table path. The case 0066 actually closed, an authenticated non-member reading another project's memories by guessing its UUID, is untouched. Everything else in all four functions carries forward from 0066 verbatim, generated by textual substitution of the guard block alone.
- Applied to dev and prod (`wezagdgpvwfywjoxztfs`) via `supabase db push --linked`; `supabase migration list --linked` confirms `0068` applied on both. Verified against prod post-deploy: `tages recall "shadow"` returns `no-accent-tinted-shadows` at 0.85 semantic similarity, and previously-dead queries ("migration", "supabase", "vercel", "how do I deploy the site", "why did mobile break") all return sensible hits.
### 2026-08-17 — CLI: `login`, `logout`, `whoami`
- **The GitHub OAuth loopback (`runGithubOAuth`) was already built and working** — it spins up a local server on `127.0.0.1`, opens the browser to the dashboard's `/auth/cli` route, and receives session tokens back — but it was only reachable from `init`, `link`, and `migrate`, each of which also creates or looks up a project and rewrites config. There was no way to sign in, sign out, or switch accounts on their own; switching identity meant hand-pasting tokens from the `/auth/cli-token` page into `~/.config/tages/auth.json`.
- **`tages login`** runs the OAuth flow and writes `~/.config/tages/auth.json` at mode `0600`, reporting the account it left and the one it landed on. **`tages logout`** removes the credentials file. **`tages whoami`** reports the signed-in identity and flags an expired access token.
- Identity is read by decoding the Supabase access token's JWT claims rather than making a network call, avoiding threading the anon key through this module to answer a question the token already answers.
- **All three warn when `TAGES_SERVICE_KEY` is set.** That env var takes precedence over `auth.json` in `createAuthenticatedClient` (`packages/cli/src/auth/session.ts:15-18`), so with it exported a successful login silently has no effect and every command keeps running as the service role. That exact trap cost a debugging session; it now announces itself.
- Verified with 10 new tests in `login.test.ts`, the full CLI suite at 473 passing across 36 files, and `tsc --noEmit` clean. Mutation-checked: neutering the service-key warning and removing the `auth.json` write each made 4 tests fail, confirming the tests assert behavior rather than just passing.
- Not yet published to npm — the shipped `@tages/cli` is 0.4.0 and does not include these commands.
### 2026-08-13 — Onboarding release: three security fixes, a working join path, a bundled CLI, and zero-install semantic search (`@tages/cli` 0.4.0 · `@tages/server` 0.3.0 · `@tages/shared` 0.2.0 · `@tages/harness-claude-code` 0.1.0 · the three editor plugins 0.1.0)
A full-repo audit against the goal "a teammate signs up, joins, and recalls a colleague's memory" found the v0.3.0 tag was never distributed and the documented onboarding path did not work end to end. This release closes that. Verified by an automated end-to-end suite driving the real CLI as three distinct authenticated identities against production: **28/28**.
- **Three security fixes, all found in this audit and applied to dev and prod (migrations `0065`-`0067`).**
- `accept_pending_invites` was `SECURITY DEFINER`, granted to `authenticated`, and took the email and user id as **caller-supplied parameters bound to nothing**. Any signed-in user could claim anyone's pending invite and read that project's memories. Now zero-argument, deriving identity from `auth.uid()` and the JWT email claim.
- **All four recall RPCs bypassed RLS** (`recall_memories`, `semantic_recall`, `hybrid_recall`, `chunk_semantic_recall`), filtering only on a caller-supplied `p_project_id`. The project UUID was effectively a bearer token for every memory in the project, while the documented join flow was sharing that UUID. Guarded with the same `owner OR is_project_member` pattern `0051` already applied to two other RPCs. The leak was reproduced on both databases before the fix and confirmed closed after; owner and active-member results are byte-identical.
- **`tages team invite --role owner` had no guard**, and the RLS policy checked only the caller's role, never the role being granted, so any admin could mint an owner. Closed at the CLI and with a `BEFORE INSERT OR UPDATE` trigger, which also covers the dashboard's service-role write path.
- **The join path now works.** `tages link --project-id` previously bound a project and configured nothing. It now writes a Claude Code project-scoped `.mcp.json` (init and link both targeted **Claude Desktop's** config before this), installs the git hook, points the MCP config at the locally built server instead of the four-month-stale published package, and adds `.mcp.json` to the repo's local git excludes since it carries the project id and anon key.
- **The CLI is bundled with tsup.** `rootDir: "../../"` emitted CJS copies of sibling packages into an ESM package, so the published tarball threw `exports is not defined`. This was **not** tarball-only, as first assumed: `link.ts` loaded the emitted copy rather than the real sibling `dist/`, so the join path crashed on source installs too. Tarball is now 3 files instead of 229, and `tages templates` works from an npm install for the first time.
- **Silent failures made visible.** Both `tages remember` and the MCP `remember` tool reported success when the cloud write had failed, leaving memories local-only and invisible to teammates forever. `observe`/`verify` promoted remote rows by a local id that never matches, so promotions silently stayed `pending`. `tages doctor` reported a correct setup as broken and then advised the one command that wedges a joiner. Project resolution fell back to the alphabetically-first config, so a command run from a subdirectory could target the wrong project.
- **Housekeeping:** SIGTERM handling (MCP clients send it; only SIGINT was handled, losing embeddings on shutdown), a runnable lint for the first time (the root `lint` script had no `eslint` dependency at all), vitest no longer globs into stale worktrees, and the invite integration tests re-plumbed onto real per-user JWTs — they could never have run before, since the fixture project omitted a `NOT NULL` slug.
- **Semantic search now needs nothing installed.** Embedding used to run client-side, so every developer had to install Ollama or hold an OpenAI key — and `embedOne` probed Ollama *unconditionally*, so a teammate who happened to have it running for an unrelated project silently wrote vectors from a different model into the shared index. Similarity across models is meaningless, so those results looked confident and were noise. Embedding now runs through a hosted Supabase edge function (`gte-small`, JWT-gated, project-membership checked), and provider selection is a deterministic switch resolved once per process with no fallthrough. An unreachable endpoint degrades to trigram; it never silently switches model. All 135 existing prod memories were re-embedded onto the new provider.
### 2026-08-17 — Release: `@tages/cli` 0.5.0
Releasing `@tages/cli` 0.5.0, which packages the `login`, `logout`, and `whoami` commands added in commit `2529355` (see the entry above). No other workspace package is changing in this release.
**Known limitation (superseded):** embeddings were previously opt-in. Without Ollama or `TAGES_OPENAI_EMBED`, memories store with a null embedding and recall silently degrades to trigram matching. See `docs/team-onboarding.md`.
### 2026-07-19 — Team-readiness release (`@tages/cli` 0.3.0 · `@tages/server` 0.2.0 · `@tages/shared` 0.1.2)
First npm release since April; ships the three months of merged retrieval/harness work to users and adds the team-join path. See `CHANGELOG.md` for the rolled-up detail.
- **`tages link --project-id <uuid>`** — an invited teammate can bind their machine to an existing shared project without having created it (the gap that previously made "team memory" impossible for anyone but the project owner). Membership enforced by the `is_project_member` SECURITY DEFINER RPC (fail-closed); refuses to clobber a local link on a slug collision; expired sessions route to re-auth. New `docs/team-onboarding.md` walks the full install → auth → join → harness-opt-in path.
- **Cross-encoder rerank is now opt-in.** The local `@huggingface/transformers` ONNX model (~90MB) is dropped as a runtime dependency; rerank runs only with `OPENAI_API_KEY` + `TAGES_OPENAI_EMBED` (OpenAI-judge, fail-open), on both CLI and server, so default recall fires no per-query API call. It measured net-neutral on the eval. Lighter `npx`/global-install footprint.
- **Quality gate:** White + Gray + two high-effort `/code-review` passes on the combined diff caught 12 defects that 1,100+ passing tests and standard review cleared — silent config-clobber (data loss), an untimed CLI fetch (multi-minute hang), a server-side rerank firing on every recall, a service-role membership bypass, and an expired-session misroute among them. All fixed and re-verified READY. Final: 1,228 tests passing, typecheck clean.
- **Harness (Milestone 1)** and PRIVACY disclosure of its marker-gated redaction limitation ship for the Mersive dogfood; drift wiring (M2) is deferred.
### 2026-07-10 — Two-stage retrieval: RRF fusion, cross-encoder rerank, multi-vector chunk storage (Tier 1 + Tier 2)
- **Phase 1 (Tier 1)**: candidate-pool widening + Reciprocal Rank Fusion (k=60) replacing raw-score merge across trigram, semantic, and temporal channels (CLI merge path + SQL `hybrid_recall`, migration `0062`); local cross-encoder rerank (`Xenova/ms-marco-MiniLM-L-6-v2`, ONNX/CPU) with an OpenAI-judge fallback; new temporal date-range retrieval channel; opt-in `--assembled-context` / `assembledContext` budget-fitted output.
- **Phase 2 (Tier 2)**: new `memory_chunks` child table + HNSW index (migration `0063`); per-chunk embedding write path; `chunk_semantic_recall` RPC returning winning-chunk citations (migration `0064`); chunk channel wired in as a 4th RRF list; single-project backfill script for existing memories.
- **Measured results (LongMemEval 50q, seed 42, dev project)** vs. the pre-Phase-1/2 baseline (migration 0061): overall accuracy 72%→80% (+8), recall@k 90%→94% (+4), temporal-reasoning 38.5%→61.5% (+23), single-session-preference 33%→67% (+34), zero-hit questions 5/50→3/50. All targets from the plan's recalibrated expectations were met. The gains come from the chunk + temporal channels; the cross-encoder rerank is net-neutral on this 50q sample since retrieval already surfaces the gold memory into top-k (consistent with the reader-is-the-bottleneck finding). A 500q run is pending as the headline number.
- **Quality gate**: White + Gray + a high-effort `/code-review` pass on the combined diff found 10 confirmed defects that 1,191 passing tests and a medium review both cleared — all fixed and White-re-verified (0 blockers). Notable catches: the cross-encoder rerank was a silent no-op (text-classification pipeline saturated every score to 1.0; fixed to raw logits); an embedding-space mismatch between OpenAI-only chunks and Ollama-first queries; unencrypted `chunk_text` defeating at-rest encryption; the reranker scoring ciphertext; a flush/write concurrency clobber of dirty flags; and chunk sync keyed on local ids that never match remote (would have shipped Phase 2 as a silent no-op).
- **Reviewer decision (resolved)**: the local `@huggingface/transformers` cross-encoder (ONNX, ~90MB model cached on first use) that this phase originally added to CLI + server has since been dropped as a runtime dependency; cross-encoder rerank is now opt-in and requires `OPENAI_API_KEY` + `TAGES_OPENAI_EMBED` rather than shipping a bundled local model. Migrations `0062`–`0064` are now applied to prod (prod is current through `0064`).
- **Known scope boundaries**: MCP rerank only fires on the remote-hybrid fallback path (warm local cache returns early); temporal channel only helps regex-resolvable dates and its nearest-date guarantee is heuristic above ~1000 date-carrying memories/project; a pre-existing transient double-insert window on concurrent chunk writes self-heals (follow-up: route through the flush mutex or add a `(memory_id, chunk_index)` unique constraint). Phase 3 (ingestion-time observation distillation + knowledge-update supersedence relations) is deferred, not in this change.
### 2026-07-10 (precision + recall)
- **Reader temporal fix + recall widening.** The eval reader now receives the question's reference date: temporal-reasoning +15.4pt, knowledge-update +25pt, overall 54%→62% on the 50q LongMemEval calibration (mechanism row-verified: "28 weeks ago"→"2 weeks ago"). Added `word_similarity()` to `recall_memories`/`hybrid_recall` (migration 0061) to recover long-single-session recall dilution. CLI recall: surfaces referenced/relative dates, tunable `TAGES_RECALL_THRESHOLD` (clamped [0,1]), conservative content dedup. Migration 0061 validated on dev; recall-lift measurement + prod apply pending.
### 2026-07-10
- **fix(cli): one-shot `tages remember` now generates + persists a durable embedding.** The CLI write path never embedded (only the long-lived MCP server did via fire-and-forget), so CLI-stored memories were invisible to semantic search (trigram-only). Also fixed: embedding was dropped on re-remember of an existing key (id vs project_id+key mismatch), and dropped in sync because rowToMemory never reconstructed it.
### 2026-07-09 — Long-input embedding fix + 3-date temporal anchoring (Tier-1 retrieval quality)
- **Embedding silent-drop fix (Tasks A+B)**: memories over ~8192 tokens got NO embedding at all — OpenAI's 400 was swallowed, so the memory was invisible to semantic search with no error surfaced. `generateEmbedding()` (new `chunking.ts` in both `packages/cli` and `packages/server`) now token-aware chunks long input and mean-pools the resulting vectors; HTTP error bodies are logged instead of discarded; 429s are retried with a fresh per-attempt timeout and total backoff capped at 2s so the recall read path can't hang.
- **Temporal 3-date anchoring (Task C)**: new `referenced_date`/`relative_date` columns (migration `0060_temporal_date_anchoring`, drops and recreates `hybrid_recall`/`semantic_recall` to return them with every original clause preserved verbatim), a rule-based date extractor, a narrowed temporal-query classifier, and relevance-preserving date-proximity reordering in recall. Targets temporal reasoning, the universal weak spot (23–54%) across every LongMemEval run to date.
- **Quality gate**: 1,089 tests passing, typecheck clean. White (Opus) review + Gray + a high-effort `/code-review` workflow pass; the workflow caught real blockers White/Gray missed (temporal reorder was discarding relevance, the classifier over-fired on "may"/"after", and the retry hardening had re-introduced the silent-drop and could hang recall) — all fixed and adversarially re-verified READY.
- **Must-do before prod**: migration `0060` is SQL and not exercised by the test suite — apply it against a Supabase dev branch and confirm `hybrid_recall`/`semantic_recall` return the new columns and existing recall still works before it reaches prod.
- **Follow-ups (non-blocking)**: classifier still misses bare relative phrases like "last week"/"last month" (pre-existing, not a regression); previously-silently-dropped long memories need a manual per-project embedding backfill via `packages/server/scripts/backfill-embeddings.ts`; the CLI's `remember` command still never generates embeddings at all (separate gap, out of scope here, needs its own ticket).
### 2026-07-09 — Instrumented Claude Code hook capture for behavioral drift (Milestone 1)
- **New `packages/harness-claude-code` capture package**: an opt-in, local-first Claude Code hook (bin) that parses stdin tool-call events, redacts secrets, and appends them to a local SQLite log. Fail-closed by design — any parse/write error is swallowed and the process still exits 0, so a broken hook can never block or crash an agent's tool call. Additive to the existing MCP path; ships ahead of a Mersive engineering team dogfooding it for a baseline data window.
- **Secret redaction extracted to `@tages/shared`**: new `redactSensitiveData` helper, shared between the MCP server's existing `safety.ts` and the new hook capture package, redacting before persistence rather than after.
- **Migration `0059_harness_tool_events`**: new table for captured tool-call events; RLS policies copied verbatim from `tool_call_log` (both the owner and `team_members` branches) to avoid the RLS-drift class of bug.
- **CLI `tages harness enable|disable|status|sync`**: per-developer opt-in. `enable` writes an absolute-path hook entry into the developer's gitignored `.claude/settings.local.json` (never into shared repo config); `sync` batch-uploads redacted rows to Supabase.
- **`PRIVACY.md`** amended to disclose the opt-in harness and its 90-day retention window.
- **Quality gate**: 971 tests passing, typecheck clean across all 9 packages. A high-effort review plus 3 adversarial review rounds found and fixed 8 defects, including 3 distinct secret/PII redaction leaks (nested-object, numeric, and argv-split forms), a silent-no-op hook path, and a slug-misroute regression. A subprocess smoke test confirmed end-to-end capture + redaction against the real compiled bin.
- **Coverage warnings (non-blocking)**: `harness.ts` ~76% line coverage, `packages/harness-claude-code/src/index.ts` ~90% — flagged for follow-up, not a merge blocker.
- **Deferred to Milestone 2 / follow-up**: `harness sync` is currently at-least-once (needs a dedup/unique constraint or upsert on `harness_tool_events`); redaction is still marker-gated regex (brittle — a bare secret with no adjacent marker is indistinguishable from a SHA/base64, worth an entropy-based or structural deny-by-default pass); `harness_tool_events` still needs to be merged into the drift computation in `drift.ts`; and the server's `embeddings.ts` OpenAI fallback (pre-existing, from PR #65) should be gated behind an env flag the same way the CLI already is.
### 2026-07-09 — LongMemEval-driven memory-quality fixes (product + eval harness)
- **Product fix: document embeddings were never written (the #1 bug)** — `remember` never populated the pgvector `embedding` column, so semantic recall had been silently trigram-only since it shipped. `packages/server/src/tools/remember.ts` and `packages/server/src/embeddings.ts` now generate and persist the embedding on write; `packages/server/src/sync/supabase-sync.ts` syncs it to Supabase narrowly, serialized against concurrent writes/deletes so a late embedding upsert can no longer revert a newer value, resurrect a `forget`-ed memory, or strand a dirty flag.
- **CLI/server embedding parity**: `packages/cli/src/lib/embedding.ts` and `packages/cli/src/commands/recall.ts` gain the same Ollama-primary embedding path the server uses, with the OpenAI fallback made opt-in (`TAGES_OPENAI_EMBED`) rather than a blocking, billable per-recall call — closes both the hot-path cost/latency issue and the Ollama(768-dim)/OpenAI(1536-dim) vector-space mismatch.
- **Structured, citable recall output**: `packages/server/src/tools/recall.ts` reshapes passages for the client-agent reader (source, updated-at citation, formatted body), and now guards against undefined `updatedAt`/`source` on legacy/backfilled rows instead of throwing.
- **Migration 0058 DDL fix**: `supabase/migrations/0058_drop_provenance_user_id.sql` switched from `CREATE OR REPLACE` to `DROP` + `CREATE` so the function signature change actually applies.
- **Embedding backfill script**: `packages/server/scripts/backfill-embeddings.ts` — sandbox-scoped, one-time backfill for memories written before the embedding-on-write fix.
- **Truncate-renormalize guard**: `normalizeTo1536` now renormalizes after truncating embeddings over 1536 dims, so an over-1536-dim future embedding can't silently corrupt cosine-similarity rankings.
- **Eval harness (Phase 1, EVAL-ONLY)**: per-type judge, type-aware answer prompt, retrieved-memory logging, a real `recall@k` retrieval metric, turn-level ingest, a Chain-of-Note reader, and a correction to `eval/longmemeval/README.md`.
- **Validation**: 918 tests passing (669 server + 204 CLI + 45 eval), monorepo typecheck clean. Quality gate ran White + Gray + a high-effort `/code-review`; the high-effort pass caught 6 concurrency/correctness blockers (embedding-sync races, a recall crash on null dates, CLI blocking-paid-embed cost, vector-space mismatch) — all fixed and re-verified READY before this PR.
### 2026-07-08 — LongMemEval eval-backend expansion + memory-quality fix plan
- **Pluggable LongMemEval embedder backends**: `eval/longmemeval/src/memory.ts` gains three new `Backend` variants — `tages-semantic` (nomic/pgvector via Tages' own semantic store, `semantic-store.ts`), `openai-cosine` (OpenAI embeddings + cosine similarity, `openai-store.ts`), and `voyage-cosine` (Voyage AI embeddings, `voyage-store.ts`) — alongside the existing `tages-cli` and `in-memory` backends. Also fixes the `tages remember` memory type passed by `TagesCliStore` from `fact` to `entity`.
- **Benchmark results captured**: nine result JSONs from tonight's runs across all backends (baseline in-memory, Tages local/cloud-dev/semantic v1+v2, OpenAI small/large, Voyage 4-large/code-3) landed in `eval/longmemeval/results/`.
- **Memory-quality fix plan**: `PLAN-MEMORY-FIXES.md` lays out a two-phase plan — Phase 1 (EVAL-ONLY) fixes the harness's judge/prompting/ingestion issues; Phase 2 (PRODUCT) fixes the real bug found during this run: the pgvector `embedding` column is never populated on the `remember` write path, so semantic search has been silently trigram-only since it shipped. This commit is the base Phase 1 builds on.
### 2026-04-29 — Week 1 housekeeping (governance unghost, action-setup v6, drop provenance user_id)
- **A1 — Governance page indexed**: removed `robots: 'noindex, nofollow'` from `/governance` metadata. The page is now crawlable and eligible for Google Search Console indexing. Added `/governance` link to both the desktop nav (after Security, before GitHub) and the mobile menu using the same styling as adjacent links.
- **A4 follow-up — publish.yml pnpm/action-setup aligned to v6**: bumped `pnpm/action-setup@v5` to `@v6` in `.github/workflows/publish.yml`. PR #31 (Dependabot) is bringing `ci.yml` to v6; without this bump `publish.yml` would be left on v5 — functional but inconsistent and easy to forget before the v0.3.1 tag push.
- **B1 — `get_memory_provenance` no longer returns raw auth.users UUID (migration 0058)**: `create or replace function` in `supabase/migrations/0058_drop_provenance_user_id.sql` removes the `user_id uuid` column from the function's `returns table(...)` and the corresponding `m.updated_by as user_id` from the SELECT. All other columns retained. The function still returns `user_display` (full_name → email-prefix → "Unknown") which is sufficient for every caller. Zero callers in the codebase (`grep -r get_memory_provenance apps/ packages/` returns no hits). Closes White W1 review finding from PR #55. Migration must be applied to prod via `supabase db push --linked` after merge.
### 2026-04-29 — v0.3.1: behavioral drift (Jensen-Shannon divergence on tool-call distributions)
- **Behavioral drift algorithm**: replaced the v1 `insufficient_data` stub in `behavioral-drift.ts` with a real Jensen-Shannon divergence implementation. Per-agent temporal drift: for each agent active in both windows with ≥5 calls per window, compute JSD(baseline_distribution, current_distribution) over the union tool vocabulary with Laplace smoothing, normalize to [0,1] by dividing by ln(2), and average across eligible agents (max 20). Returns `BehavioralDriftReport` with `score`, `status`, `note`, `jsd` (raw), `agentCount`, `agentDistributions` (top-3 tools per agent), and `windowA`/`windowB` boundary metadata.
- **Type layer**: new `BehavioralDriftReport`, `AgentToolDistribution`, `BehavioralWindow` interfaces in `types.ts`. `DriftReport.behavioral` retyped from `MetricStub` to `BehavioralDriftReport` (superset — no breaking change for callers reading `score`/`status`/`note`). `DriftInput` extended with `baselineSince?` and `currentSince?`.
- **Weight rebalance**: `WEIGHTS` in `compute.ts` shifted from `{semantic: 1.0, coordination: 0, behavioral: 0}` to `{semantic: 0.7, coordination: 0, behavioral: 0.3}`. For projects without tool_call_log volume, behavioral returns `insufficient_data` and contributes 0 — overall drift score drops ~30% relative to the v1 number for projects with sufficient behavioral data. Next target `{0.5, 0.25, 0.25}` when coordination ships.
- **CLI**: new `--baseline-since <window>` and `--current-since <window>` flags on `tages drift`. Both must appear together; baseline-since must be earlier than current-since; both use the existing `Nd`/`Nh`/ISO grammar. `renderHuman` extended with behavioral score, raw JSD, agent count, window boundaries, threshold guidance, and per-agent top-3 tool breakdown.
- **Tests**: 18 new tests — 12 in `behavioral-drift.test.ts` (windowing, JSD math, multi-agent averaging, top-tools reporting), 5 in `compute.test.ts` (weight exposure, weight contribution paths, edge cases), 1 in CLI `drift.test.ts` (flag parsing). 843 passing total (was 826 on main).
### 2026-04-29 — CI test-mock fix
- **CI test-mock fix — `supabase.auth` interface added to `commands-smoke` mock**: `mockSupabase` now includes a `mockAuth` object with stubbed `setSession`, `getSession`, and `refreshSession` (all returning a valid session shape). Without this, any test that calls `writeAuthConfig` and runs without `TAGES_SERVICE_KEY` set would crash with `cannot read 'setSession' of undefined` — because `createAuthenticatedClient` reads `auth.json` and calls `supabase.auth.setSession(...)` on the auth-path branch. The bug was latent until CI was widened from `pnpm --filter @tages/server test` to `pnpm -r test` in c9a27f1; `resetMockSupabase()` updated to clear the three new mock functions alongside `from`/`rpc`.
### 2026-04-29 — PR #55 White second-review fix bundle (W2 limit-semantics, W2-AOT codex regex, Q1 Windows guard)
- **W2 limit-semantics — `drift.ts` `--limit` regression reverted**: `--limit` now controls display top-K only (original semantics, default 10). A new `MAX_DB_ROWS = 10000` constant applied to both Supabase queries provides defensive OOM protection without conflating "show top N keys" with "fetch N rows from the chronological window".
- **W2-AOT codex regex — `stripTagesBlock` TOML array-of-tables support**: `targetHeader` and `anyHeader` regexes now match `[[mcp_servers.tages]]` in addition to `[mcp_servers.tages]`. Hand-edited configs using double-bracket syntax are now cleaned correctly by `--force`. JSDoc updated to document the comment-swallowing limitation.
- **Q1 Windows guard — `pathToFileURL` entrypoint fix (codex, cursor, gemini plugins)**: Replaced `import.meta.url === \`file://${process.argv[1]}\`` with `import.meta.url === pathToFileURL(process.argv[1]).href` in all three plugins. The original was broken on Windows (`import.meta.url` uses `file:///C:/...`; `process.argv[1]` uses `C:\...` — never equal), silently no-opping `main()` under `npx`.
- **Regression test**: `packages/codex-plugin/src/__tests__/index.test.ts` adds one test covering `[[mcp_servers.tages]]` strip (10 tests total, was 9). 826 passing overall.
- Deferred: W1 (comment swallowing before next non-tages header) — documented in JSDoc; S2 (friendly ENOENT for `computeOracleSha`) — punt, raw error acceptable for eval harness.
### 2026-04-29 — PR #55 White-review fix bundle
- **B1 — `drift.ts` crash on bad `--since`**: `resolveSince()` is now wrapped in try/catch; invalid input prints a clean error message and exits with code 1 instead of throwing uncaught.
- **B2 — `codex-plugin` duplicate TOML block**: `--force` no longer appends a second `[mcp_servers.tages]` header. A new exported `stripTagesBlock()` helper removes any existing tages tables in-place before the new block is written. USAGE text updated to reflect the replace-in-place behaviour.
- **W2 — `--limit` applied to queries**: `tages drift --limit` now passes `.limit()` to both Supabase queries (`field_changes` and `tool_call_log`) in addition to display truncation; the value is validated as a positive integer.
- **W3 — `agents-md` federation header**: when `agents-md-owners.json` is configured but `memories.team_id` doesn't exist, the generated AGENTS.md opens with a machine-readable `<!-- TAGES_FEDERATION_NOTE: ... -->` HTML comment so agents and reviewers see the limitation immediately.
- **Q1 — dynamic oracle SHA**: `eval/longmemeval/src/dataset.ts` now exports `computeOracleSha()` that hashes the on-disk oracle file at run time; `run.ts` reports the actual SHA instead of a hardcoded constant.
- **CI widening**: `.github/workflows/ci.yml` and `publish.yml` expanded from `--filter @tages/server` to `-r` (all packages). `publish.yml` also adds `@tages/codex-plugin` and `@tages/gemini-plugin` to the npm publish matrix.
- **New tests (15 total)**: regression suite for `codex-plugin` (9 tests, including round-trip strip+append guard), plus entry-point tests for `cursor-plugin` (3) and `gemini-plugin` (3). Plugin `main()` calls guarded with `import.meta.url` check so packages are importable in tests.
### 2026-04-20
- **README hygiene**: Stripped unreproducible benchmark claim from `## Benchmarks` section; corrected MCP tool, CLI command, test, and migration counts to match current codebase.
- **Bet A — Memory Governance foundation**: New `/governance` marketing page (draft, `noindex`). Migration `0057_provenance_fields.sql` adds `session_id`, `source_context`, and `tool_name` columns to `memories` with a GIN index and a `get_memory_provenance` RPC. `Memory` TypeScript type extended with `sessionId`, `toolName`, and `sourceContext`. Formal spec at `docs/provenance-model.md`.
- **Bet B — AGENTS.md native tooling**: New `tages agents-md write` and `tages agents-md audit` CLI subcommands. `write` generates a canonical 6-section AGENTS.md from project memory. `audit` flags vagueness, missing sections, missing runnable commands, and absence of the three-tier Always/Ask/Never boundary pattern.
- **Bet D — Cross-tool distribution**: New `@tages/cursor-plugin` package. Running `npx @tages/cursor-plugin` installs Tages in Cursor by writing `.cursor/mcp.json`. Setup guide at `docs/cursor-setup.md`.
- **CI**: New `.github/workflows/publish.yml` triggers on `v*` tag push to publish packages to npm (requires `NPM_TOKEN` repo secret).
- **Strategy documents**: `analysis/` directory lands with competitive analysis, trend scan, positioning brief, deep research execution doc, Monte Carlo pricing model, and research notes. `PLAN.md` and `REMAINING.md` added at repo root.
#### Review fixes (post-White review)
- `supabase/migrations/0057_provenance_fields.sql`: pinned `search_path = public, extensions` and added an `is_project_member(auth.uid(), m.project_id)` guard inside the `get_memory_provenance` SECURITY DEFINER function so it cannot leak provenance across projects. (B1, Q1)
- `.github/workflows/publish.yml`: publish `@tages/cursor-plugin` alongside `@tages/shared`, `@tages/server`, `@tages/cli` on `v*` tag push. (W1)
- `packages/cli/src/commands/agents-md.ts`: replaced the unsupported `\Z` anchor in `extractSection` with a JS-correct end-of-string lookahead so last-section audit rules (missing-commands, missing-tech-versions) fire correctly. Regression test added. (W2)
- `apps/dashboard/src/components/marketing/governance-page.tsx`: corrected `session_id` field type in the Provenance model table from `text` to `uuid`. (S1)
- W3 (test count targets) was declined — counts reflect a verified test-run output, not a goal. S2 (control-flow warning) was deferred — no runtime impact.
### 2026-04-20 — Bet A governance foundation (Sprint A + B + C)
- **Pre-launch hygiene (Phase 0)**: `HOOK.md` and `.semgrep-results/` added to `.gitignore`. Closed migration 0042 git gap — file had been applied to prod on Apr 10 but never committed; `supabase migration list --linked` confirmed prod/local match.
- **Sprint A — Differentiation foundation (Phase 3.1 + 3.2)**: New `@tages/codex-plugin` package (TOML writer targeting `~/.codex/config.toml`, `--dry-run`, block detection). New `@tages/gemini-plugin` package (JSON merge into `~/.gemini/settings.json` with preserved top-level keys). New `tages agents-md diff` and `tages agents-md federate` CLI subcommands extending the Bet B foundation. White review fixes: gemini env-var placeholders, codex regex false-positive on `[mcp_servers.tages.env]` alone, diff negation-word boundary.
- **Sprint B — LongMemEval harness scaffold (Phase 1.1)**: New `eval/longmemeval/` directory with standalone TypeScript harness (not in pnpm workspace). RetainDB-pattern methodology documented with comparability caveat (Supermemory/RetainDB baselines on deprecated dataset). Pluggable memory backend: `in-memory` lexical floor + `tages-cli` real integration. Dry-run verified; real runs pending `OPENAI_API_KEY` + sandbox `TAGES_EVAL_PROJECT`.
- **Sprint C — `tages drift` v1 (Phase 3.3)**: New `tages drift` CLI command (experimental). Semantic drift uses a real instability metric (1 − 1/distinct_values over field_changes); 10 unit tests cover zero/two/three-value instability, session+agent reporting, whitespace normalization, topK. Coordination drift stubbed (`not_implemented`) — blocked on `memories.team_id` column. Behavioral drift stubbed (`insufficient_data` / `not_implemented`) — tool_call_log has raw data, v2 calibration pending design partners. `--json`, `--since`, `--agent`, `--limit` flags.
- **Chore**: Plugin packages (cursor, codex, gemini) now use `--passWithNoTests` so `pnpm -r test` does not fail at the root.
### 2026-04-19
- Fixed GitHub OAuth login redirecting to the marketing homepage instead of `/app/projects` after callback. Root cause: `SameSite=Strict` cookies are withheld by the browser on cross-site top-level navigations (i.e. GitHub's redirect back to `/auth/callback?code=...`), so the PKCE verifier and refreshed session cookies never reached the server. Reverted to Supabase's default `SameSite=Lax`, which is the correct setting for SSR auth cookies. `HttpOnly + Secure + Lax` still blocks CSRF on state-changing requests.
- **Invite-flow overhaul (Team plan):** Team invites now send a one-time Supabase magic-link email rather than inserting directly into `team_members`. Pending invites expire after 30 days; expired rows are skipped by `accept_pending_invites`. Owners and admins can revoke a pending invite before it is accepted (new DELETE RLS policy). The invite role dropdown enforces RBAC at both the UI and server layers — owners can invite admin or member, admins can only invite member. Dashboard sign-ins now call `accept_pending_invites` via the OAuth callback, so web-only sign-ups resolve dangling invites automatically (previously only the MCP server did this on startup). Requires migrations `0055_invite_expiry.sql` and `0056_invite_delete_policy.sql` applied to your Supabase project.
### 2026-04-17
- Split literal Stripe-style test fixtures in `safety.test.ts` and `observe.test.ts` via string concatenation so GitHub secret scanning no longer flags them. The fixtures are intentional test inputs, not real credentials.
## License
[MIT](LICENSE)
## Named After
[Tages](https://en.wikipedia.org/wiki/Tages) — the Etruscan divine child who appeared from a furrow in the earth and dictated sacred knowledge to scribes before vanishing. The knowledge persisted long after the source was gone.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues