From 66e69be629b3fee95b4063dc859bf65708434084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20V=C4=83n=20Rum=20=28VSF-QTVHNTDVX-PMGTCC?= =?UTF-8?q?=29?= Date: Sun, 7 Jun 2026 10:23:28 +0700 Subject: [PATCH] feat(mcp,store): T7 agent-driven export + cross-clone memory (v0.10.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two workflow gaps close in v0.10.0. A — Agent-driven sharing (no terminal hop): - export_memory MCP tool: reuses exportSync + shareToGit(push:false) to write .tre-mem/ and make a LOCAL commit; never pushes (returns the exact git push command). Fail-closed on secrets — returns matched categories, never the values, writes nothing. - get_share_status MCP tool: pending/shared/graduated counts. - graduate_fact result gains a `hint` nudging export_memory. B — Cross-clone memory by git remote (default-on; TRE_MEM_CROSS_CLONE=0 off): - Clones sharing remote.origin.url union their memory (branch tags, pins, graduated, observations). Read-time alias union, NOT a stored-key rewrite, so the committed .tre-mem/ format + SYNC_SCHEMA_VERSION are unchanged and teammates are unaffected. - New src/git/remote.ts (canonicalizeRemoteUrl, remoteSlug) and src/store/aliases.ts (resolveProjectIdentity, crossCloneEnabled). - Schema v3 (additive nullable branch_state.remote + index, self-healing). - repo: projectAliases, *Across(projects[]) readers, setRemoteForCwd, remoteForProject; adapter/retrieval refactored to project IN (...). - Any tre invocation eagerly registers the current clone's remote; web resolves aliases per requested project so the dashboard picker unions too. C — Visibility: - tre status prints remote: + linked clones (N): … - /api/health + /api/branches carry remote/linked_clones; topbar 🔗 N clones chip. Tests: git-remote, store-aliases, mcp-export-crossclone, v3 migration, repo alias/*Across; projects[] migration across adapter/retrieval/web/mcp fixtures. Full gate green: format · lint · typecheck · 366 tests · build. --- CHANGELOG.md | 35 ++++ PLAN-PHASE7.md | 86 +++++++++ PLAN.md | 4 +- README.md | 23 ++- README.vi.md | 4 +- docs/CROSS-TOOL.md | 3 +- docs/DEMO.md | 2 +- docs/WEB-UI.md | 28 +-- package.json | 2 +- src/adapter/claude-mem.ts | 38 ++-- src/adapter/types.ts | 3 +- src/cli.ts | 39 +++- src/git/backfill.ts | 2 +- src/git/remote.ts | 66 +++++++ src/git/watcher.ts | 3 + src/hooks/session-start.ts | 11 +- src/hooks/user-prompt-submit.ts | 7 +- src/mcp/tools.ts | 246 +++++++++++++++++++++++-- src/retrieval/search.ts | 13 +- src/retrieval/semantic.ts | 4 +- src/store/aliases.ts | 77 ++++++++ src/store/migrate.ts | 54 +++++- src/store/repo.ts | 122 ++++++++++--- src/store/schema.sql | 5 +- src/version.ts | 2 +- src/web/api.ts | 35 +++- src/web/server.ts | 6 +- src/web/types.ts | 4 + test/adapter.test.ts | 16 +- test/git-remote.test.ts | 82 +++++++++ test/git-watcher.test.ts | 1 + test/mcp-export-crossclone.test.ts | 253 ++++++++++++++++++++++++++ test/mcp-no-claudemem.test.ts | 4 +- test/mcp-tools.test.ts | 8 +- test/migrate.test.ts | 92 +++++++++- test/repo.test.ts | 156 ++++++++++++++++ test/retrieval-search.test.ts | 18 +- test/retrieval-semantic.test.ts | 16 +- test/session-start-hook.test.ts | 1 + test/store-aliases.test.ts | 93 ++++++++++ test/web-api.test.ts | 2 + test/web-degrade-no-claudemem.test.ts | 2 + test/web-grove.test.ts | 2 + test/web-sse.test.ts | 2 + test/web-static.test.ts | 2 + web/app.tsx | 11 ++ web/lib.tsx | 3 + 47 files changed, 1552 insertions(+), 136 deletions(-) create mode 100644 PLAN-PHASE7.md create mode 100644 src/git/remote.ts create mode 100644 src/store/aliases.ts create mode 100644 test/git-remote.test.ts create mode 100644 test/mcp-export-crossclone.test.ts create mode 100644 test/store-aliases.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d23ae30..efcfab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ All notable changes to **tre-mem** are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] — 2026-06-07 + +**The agent shares for you, and memory crosses your clones.** Two workflow gaps +close: the assistant can now publish curated memory itself (no terminal hop), and +multiple local clones of the same repo finally share one memory. + +### Added + +- **`export_memory` MCP tool** — the assistant writes `.tre-mem/` and makes a + **local git commit** (never pushes; you push when ready). The result carries the + exact `git push…` command. Reuses the same `exportSync` + `shareToGit` pipeline + as `tre share`, so behavior never drifts. Fail-closed on secrets: a blocked + export returns the matched secret _categories_ and a fix instruction, never the + secret values. +- **`get_share_status` MCP tool** — lets the assistant see unshared pins / shared + pins / graduated counts so it can proactively offer to export. +- **Cross-clone memory by git remote** — clones that share an identical + `remote.origin.url` (e.g. `app`, `app-2`, `app-3`) now **union** their memory + (branch tags, pins, graduated facts, and claude-mem observations). Identity is a + read-time alias set, so the on-disk `.tre-mem/` format is unchanged and teammates + are unaffected. Default-on; disable with `TRE_MEM_CROSS_CLONE=0`. +- **`tre status`** now prints the canonical `remote:` and, when more than one clone + shares it, a `linked clones (N): …` line. The dashboard topbar shows a + `🔗 N clones` chip. +- **`graduate_fact`** result now includes a `hint` nudging the assistant to call + `export_memory` to publish. + +### Changed + +- Sidecar schema **v3** (additive): a nullable `remote` column on `branch_state`, + recorded on every session-start / git-watch. No backfill; existing rows behave + exactly as before until their clone is next opened. +- Retrieval and the claude-mem adapter now query an `IN (…)` set of project labels + internally; a single-element set is identical to prior behavior. + ## [0.9.0] — 2026-06-07 **The Grove — a second-brain contributor graph, plus a Vietnamese dashboard.** A new diff --git a/PLAN-PHASE7.md b/PLAN-PHASE7.md new file mode 100644 index 0000000..f4124c4 --- /dev/null +++ b/PLAN-PHASE7.md @@ -0,0 +1,86 @@ +# PLAN — Phase 7 (v0.10.x): Agent-driven export + cross-clone memory + +> Phase SSOT, chained from [`PLAN.md`](./PLAN.md). Status: **done (v0.10.0)**. + +## Why + +Two gaps surfaced from daily use: + +1. **The assistant couldn't close the share loop.** `pin_fact` / `graduate_fact` + only write the sidecar; publishing required dropping to a terminal for + `tre share`. That one manual hop broke the "let the agent do it" flow. +2. **Memory didn't cross clones of one repo.** Identity was `basename(cwd)`, so + `app`, `app-2`, `app-3` (parallel clones) were three isolated projects. + +User decisions: MCP tool = **export + local commit, no push**; cross-clone = +**default-on + `TRE_MEM_CROSS_CLONE=0` kill-switch**; plus three small UX extras +(pending-share status tool, `tre status` clone visibility, `graduate_fact` hint). + +## A — Agent-driven export (MCP) + +- **`export_memory`** — reuses `exportSync` (`src/sync/export.ts`) + + `shareToGit({commit:true, push:false})` (`src/sync/share.ts`). Writes `.tre-mem/` + and makes a **local commit**; never pushes. Returns `{ files, total_added, +graduated_added, committed, pushed:false, commit_hint, note }`. Fail-closed on + secrets: a `RedactionError` returns `redaction_blocked { categories, count, +instruction }` — never the secret values, no files written. +- **`get_share_status`** — `{ pending_export, shared_pins, total_pins, graduated, +has_sync_dir }` (mirrors `/api/share-status`). Lets the agent nudge the user. +- **`graduate_fact`** — result gains a `hint` field pointing at `export_memory`. + +Both new tools use the **write key** (`basename(cwd)` / `--project`), since +publishing and pending-share are per-clone. + +## B — Cross-clone memory by git remote + +**Approach: read-time alias union, never a stored-key rewrite.** Writes keep using +`basename(cwd)`; reads expand to `project IN (alias set)`. Forced by constraints: +claude-mem observations are permanently basename-keyed (read-only upstream), +historical sidecar rows can't be reliably re-keyed, and committed `.tre-mem/` JSONL +carries `project`. Payoff: **on-disk format + `SYNC_SCHEMA_VERSION` unchanged, zero +teammate impact**, and only one additive schema column. + +- `src/git/remote.ts` — `canonicalizeRemoteUrl(url)` (pure: ssh scp / ssh-url / + https-with-creds / `git://`, strip `.git` + trailing slash, lowercase → + `host/org/repo`); `remoteSlug(cwd)` reads `remote.origin.url`, never throws. +- `src/store/aliases.ts` — `resolveProjectIdentity(repo, cwd, opts) → { project, +remote, aliases }`; `crossCloneEnabled(env)` (`TRE_MEM_CROSS_CLONE` off-switch). +- Schema **v3** (`src/store/migrate.ts` + `schema.sql`): nullable + `branch_state.remote` + `idx_branch_state_remote`, idempotent self-heal mirroring + v2. No backfill; rows self-heal on next session-start / git-watch write. +- `src/store/repo.ts` — `projectAliases(project, remote)` (= `SELECT DISTINCT +project FROM branch_state WHERE remote = ?` ∪ `{project}`, else `[project]`) and + `*Across(projects[])` readers (`countBranchTagsAcross`, + `listBranchTagsForBranchAcross`, `listPinsForBranchAcross`, + `listPinsForProjectAcross`, `listGraduatedAcross`, `listBranchesForProjectAcross`, + `listPinBranchesAcross`). Single-element array == prior behavior. +- Adapter/retrieval refactored `project: string → projects: string[]`, + `= @project → IN (…)`: `src/adapter/claude-mem.ts`, `src/adapter/types.ts` + (`ListQuery`), `src/retrieval/semantic.ts`, `src/retrieval/search.ts`. +- Entry points resolve identity (writes use `project`, reads use `aliases`): + `src/mcp/tools.ts`, `src/hooks/session-start.ts`, + `src/hooks/user-prompt-submit.ts`, `src/web/server.ts` + `api.ts`, `src/cli.ts`. + `branch_state.remote` is persisted by session-start and the git watcher. + +## C — Visibility + +- `tre status` prints `remote:` and, when >1 clone shares it, + `linked clones (N): …`. +- `/api/health` carries `remote` + `linked_clones`; dashboard topbar shows a + `🔗 N clones` chip (`web/app.tsx`, `Health` type in `web/lib.tsx`). + +## Tests + +`test/git-remote.test.ts`, `test/store-aliases.test.ts`, +`test/mcp-export-crossclone.test.ts` (export commit-no-push, redaction fail-closed, +share-status, graduate hint, cross-clone surfacing + isolation), plus v3 migration +cases (`test/migrate.test.ts`), repo alias/`*Across` cases (`test/repo.test.ts`), +and the `projects[]` migration across adapter/retrieval/web/mcp test fixtures. +Full gate green: format · lint · typecheck · 364 tests · build. + +## Out of scope (deferred) + +- Monorepo sub-path identity (one remote, many logical projects) — kill-switch is + the escape hatch for now. +- Cross-clone for the per-clone "pending share" view (kept per-clone by design). +- Auto-export-on-graduate (the user prefers an explicit `export_memory` call). diff --git a/PLAN.md b/PLAN.md index 9d11b1b..78a8d70 100644 --- a/PLAN.md +++ b/PLAN.md @@ -10,7 +10,8 @@ > [`PLAN-PHASE3.md`](./PLAN-PHASE3.md) — Phase 3 (v0.5.x, team web dashboard) · > [`PLAN-PHASE4.md`](./PLAN-PHASE4.md) — Phase 4 (v0.6.x, cross-tool port) · > [`PLAN-PHASE5.md`](./PLAN-PHASE5.md) — Phase 5 (v0.7.x, "Share, made obvious") · -> [`PLAN-PHASE6.md`](./PLAN-PHASE6.md) — Phase 6 (v0.9.x, "The Grove" — contributor graph + VN i18n). Current: **v0.9.0**. +> [`PLAN-PHASE6.md`](./PLAN-PHASE6.md) — Phase 6 (v0.9.x, "The Grove" — contributor graph + VN i18n) · +> [`PLAN-PHASE7.md`](./PLAN-PHASE7.md) — Phase 7 (v0.10.x, agent-driven export + cross-clone memory). Current: **v0.10.0**. --- @@ -388,4 +389,5 @@ Parser: `cac` (nhẹ, đủ). - **2026-05-31** — T2D10 polish (docs + version) done. Added `README.md` (one-liner + why + 3-step install + MCP registration via `claude mcp add -s user tre-mem -- tre mcp` + SessionStart hook + CLI surface + MCP tool table + architecture diagram + status), `CHANGELOG.md` in Keep-a-Changelog format with a single `[0.1.0] — 2026-05-31` entry covering sidecar store, read-only adapter, branch resolver/watcher, reflog backfill, 3-signal rerank, MCP server, SessionStart hook, CLI, benchmark harness, docs. Added MIT `LICENSE`. Bumped `package.json` version `0.0.0 → 0.1.0` and synced `getPackageVersion()` in `src/cli.ts` so `tre --version` prints `tre/0.1.0`. All quality gates remain green (typecheck + lint + 96/96 tests + build). `npm publish v0.1.0`, demo screen-record, and external-dev validation deliberately deferred to user action — those require credentials / hands / a second human and cannot be automated from this session. - **2026-06-07** — v0.8.0 "Bamboo — the green identity" done. Established a design-system SSOT (`docs/BRAND.md`) and made tre actually look like bamboo on both surfaces a user sees. **Web** (`web/styles.css`): full re-theme — primary `--bark` → `--bamboo` (amber → jade green), green-tinted rice-paper/ink ramp, semantic spines re-hued into a coordinated grove palette (sky / young-shoot / lacquer-gold / clay-red), both light + dark intentional; token-only change, no component restructure, JS bundle unchanged; added a bamboo-culm `BambooMark` SVG before the wordmark + a 🎋 favicon. **Terminal** (`src/format/`): `colors.ts` gained a `brand()` helper + `BAMBOO` motif; the SessionStart digest header (`🎋 tre-mem · recent context`), live-dashboard link, and pinned-section header now read green; `tre web` / `tre doctor` carry the 🎋 mark; the plain model-facing `context` string stays ANSI-free. Docs synced (README + README.vi "Brand & design", WEB-UI theme note, PLAN-PHASE3 design-direction update); version `0.7.1 → 0.8.0` across `package.json` + `src/version.ts` + CHANGELOG. Decision was user-confirmed: full web re-theme, subtle terminal (no banner), minor bump. - **2026-05-31** — T2D8 done. `src/mcp/tools.ts` exposes 5 pure tool functions (`getBranchContext`, `getBranchTimeline`, `listBranches`, `pinFact`, `graduateFact`) plus a `callTool(name, args)` dispatcher and a frozen `TOOL_DEFINITIONS` array carrying name/description/JSON-Schema for each. Tools resolve `project`/`branch` defaults via injectable `ToolDeps.defaultCwd` + `ToolDeps.resolveBranch` (defaults to `process.cwd()` + `currentBranch()`), and `now` is injectable for deterministic tests. `src/mcp/server.ts` uses the lower-level `@modelcontextprotocol/sdk` `Server` (not `McpServer`) because we don't want a zod dependency: `ListToolsRequestSchema` returns `TOOL_DEFINITIONS` 1:1; `CallToolRequestSchema` dispatches to `callTool`, JSON-stringifies into `content[0].text` AND mirrors structured into `structuredContent` so both old + new MCP clients work, and converts thrown errors to `{isError: true}`. `runMcpServer()` runs `migrate()`, opens adapter + repo, wires `StdioServerTransport`, and closes handles on SIGINT/SIGTERM. CLI gains `tre mcp` command. **Live MCP handshake smoke (real `~/.tre-mem/tre-mem.db`)**: `initialize` → `notifications/initialized` → `tools/list` returns all 5 tool descriptors; `tools/call list_branches {project:"tre-mem"}` returns `{branches: [{branch:"main", count:22}]}` — i.e. the same 22-tag count `tre status` shows, proving the full stack works through stdio. 10 new tests (`mcp-tools.test.ts` × 9, `mcp-server.test.ts` × 1); suite 96/96 green; lint + typecheck + build clean. +- **2026-06-07** — **Phase 7 (v0.10.0) done — agent-driven export + cross-clone memory.** Two workflow gaps closed (SSOT `PLAN-PHASE7.md`). **(A) Agent-driven sharing:** new MCP tools `export_memory` (reuses `exportSync` + `shareToGit` with `push:false` → writes `.tre-mem/` + a **local commit**, never pushes; returns the exact `git push` command; fail-closed on secrets, returning categories not values) and `get_share_status` (pending/shared/graduated counts); `graduate_fact` now returns a `hint` nudging export. So the assistant closes the share loop without a terminal hop — the user reviews + pushes. **(B) Cross-clone memory:** clones sharing a `remote.origin.url` now union memory. Approach = **read-time alias union, not a stored-key rewrite** (forced by: claude-mem observations are permanently basename-keyed; committed `.tre-mem/` JSONL carries `project`; can't reliably re-key history). New `src/git/remote.ts` (`canonicalizeRemoteUrl` → `host/org/repo`, `remoteSlug`), new `src/store/aliases.ts` (`resolveProjectIdentity` + `crossCloneEnabled`), schema **v3** (additive nullable `branch_state.remote` + index, self-healing like v2), `projectAliases` + `*Across(projects[])` repo readers, and adapter/retrieval refactored to `project IN (…)`. Writes still key on `basename(cwd)` → **on-disk format + `SYNC_SCHEMA_VERSION` unchanged, zero teammate impact**. Default-on; `TRE_MEM_CROSS_CLONE=0` disables. `tre status` shows `remote:` + `linked clones`; dashboard topbar shows a `🔗 N clones` chip (`/api/health` carries `remote` + `linked_clones`). Tests: `git-remote`, `store-aliases`, `mcp-export-crossclone`, plus v3 migration + repo alias cases; 364 tests green; full gate (format/lint/typecheck/test/build) clean. Version `0.9.0 → 0.10.0`. - **2026-06-07** — **Phase 6 "The Grove" (v0.8.0) done.** New `tre web` Grove tab: an Obsidian-style `d3-force` + canvas graph of the repo's shared memory (root/trunk + branches + contributors + facts) beside a contributor leaderboard. Built on the realization that contributor identity already lives in the committed `.tre-mem/` JSONL `author` field (set at `tre share`/graduate time) — so a new read-only reader (`src/sync/read.ts`) feeds pure aggregators in `src/web/grove.ts` (value score = pins×1 + graduated×3, plus Gardener-of-the-week / Most-rooted / Longest-streak / First-sprout badges) with **no schema migration**. Two read-only endpoints `/api/contributors` + `/api/graph` (project-scoped, `?fallback=git` defaulting on so solo/unshared repos backfill contributors from `git log` authors). Frontend: `Grove.tsx` + `Leaderboard.tsx` + `GraphCanvas.tsx` (theme-token colors, bamboo node sizing, hover/click drill into Branch detail) + `ShareCard.tsx` (canvas→PNG) + growth time-lapse scrubber. `d3-force` (sim only) + hand-rolled canvas chosen over cytoscape/react-force-graph to stay dependency-light (~13 KB add). Also full **Vietnamese i18n** (EN/VI toggle, casual voice) + a VN-friendly type system (Baloo 2 + Be Vietnam Pro). Tests: `test/sync-read.test.ts`, `test/web-grove.test.ts`. Rebased onto v0.8.0 bamboo identity (`--bark` → `--bamboo`); version → 0.9.0; CHANGELOG `[0.9.0]`; SSOT `PLAN-PHASE6.md`. diff --git a/README.md b/README.md index a56b19b..1f6c602 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ sees Stripe webhook chatter alongside JWT context. tre-mem fixes that: - **History backfill** via `git reflog` so existing observations get a branch. - **3-signal rerank**: semantic (FTS5/BM25), branch locality, recency-in-branch, plus a `pin` boost for facts you want pinned to a branch. -- **MCP server** exposing 5 tools so Claude Code can call branch-aware - retrieval directly. +- **MCP server** exposing 7 tools so Claude Code can call branch-aware + retrieval — and publish memory — directly. On the tre-mem repo itself the rerank lifts precision@10 from **0.19** (raw FTS5 baseline) to **0.97**. See [BENCHMARK.md](./BENCHMARK.md) for the harness. @@ -95,7 +95,7 @@ CLI directly: `claude mcp add -s user tre-mem -- node /abs/path/to/tre-mem/dist/ Verify inside Claude Code with `/mcp`. You should see: ``` -tre-mem · connected · 5 tools +tre-mem · connected · 7 tools ``` ## Register the SessionStart hook (optional but recommended) @@ -216,6 +216,23 @@ tre-mem search "stripe webhook" | `list_branches` | `project?` | Branches with tag counts | | `pin_fact` | `observation_id`, `branch?`, `note?` | Pin a fact to a branch (boost = 1.0) | | `graduate_fact` | `observation_id` | Promote a branch fact to project scope | +| `export_memory` | `branch?`, `all?`, `force?` | Write `.tre-mem/` + local commit (no push) | +| `get_share_status` | `project?` | Pending / shared / graduated counts | + +After the assistant pins or graduates a fact, it can call **`export_memory`** to +publish it: this writes the `.tre-mem/` files and makes a **local git commit** — it +never pushes, so you review and `git push` when ready (the tool returns the exact +command). It's fail-closed on secrets: a blocked export reports the matched secret +_categories_, never the values. + +## Cross-clone memory + +If you clone the same repo into several directories (e.g. `app`, `app-2`, `app-3`) +to work branches in parallel, tre-mem **unions their memory** — they're recognized +as one project by a shared `remote.origin.url`. `tre status` shows the canonical +`remote:` and the `linked clones`. This is on by default; set +`TRE_MEM_CROSS_CLONE=0` to keep each directory isolated. The committed `.tre-mem/` +format is unchanged, so teammates are unaffected. ## Diagnostics log diff --git a/README.vi.md b/README.vi.md index b809fdf..eb5bedd 100644 --- a/README.vi.md +++ b/README.vi.md @@ -50,7 +50,7 @@ Stripe webhook với JWT context. tre-mem xử lý điều đó: - **Backfill lịch sử** qua `git reflog` để observation cũ cũng có branch. - **Rerank 3-signal**: semantic (FTS5/BM25), branch locality, recency-trong-branch, cộng thêm pin boost cho fact muốn ghim vào branch. -- **MCP server** expose 5 tool để Claude Code gọi retrieval branch-aware trực tiếp. +- **MCP server** expose 7 tool để Claude Code gọi retrieval branch-aware trực tiếp. Trên chính repo tre-mem, rerank nâng precision@10 từ **0.19** (FTS5 baseline thuần) lên **0.97**. Xem [BENCHMARK.md](./BENCHMARK.md) để biết chi tiết harness. @@ -89,7 +89,7 @@ claude mcp add -s user tre-mem -- tre mcp Kiểm tra trong Claude Code bằng `/mcp`. Sẽ thấy: ``` -tre-mem · connected · 5 tools +tre-mem · connected · 7 tools ``` ## Đăng ký SessionStart hook (tùy chọn nhưng nên có) diff --git a/docs/CROSS-TOOL.md b/docs/CROSS-TOOL.md index b4a2d56..28ae964 100644 --- a/docs/CROSS-TOOL.md +++ b/docs/CROSS-TOOL.md @@ -79,7 +79,8 @@ tre mcp # start by hand; without claude-mem it prints "shared-memory ``` In the tool, list MCP tools — you should see `get_branch_context`, -`get_branch_timeline`, `list_branches`, `pin_fact`, `graduate_fact`. Ask: +`get_branch_timeline`, `list_branches`, `pin_fact`, `graduate_fact`, +`export_memory`, `get_share_status`. Ask: _"what has the team pinned on this branch?"_ ## Notes diff --git a/docs/DEMO.md b/docs/DEMO.md index 2968478..39d7bc4 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -148,7 +148,7 @@ Claude Code session in the same repo. **Expected**: Claude calls the `tre-mem.get_branch_context` MCP tool and answers in terms of **BMOtpTextView**, not AccountManager. (Check `/mcp` shows -`tre-mem · connected · 5 tools` before recording.) +`tre-mem · connected · 7 tools` before recording.) Then `git checkout feature/test_tre_mem` in the terminal, ask the same question again, and watch Claude switch to **AccountManager**. diff --git a/docs/WEB-UI.md b/docs/WEB-UI.md index 66ff9a1..4ec2e93 100644 --- a/docs/WEB-UI.md +++ b/docs/WEB-UI.md @@ -97,20 +97,20 @@ observation timeline.) The SPA is served over a dependency-light `node:http` server. Endpoints, all `GET`, all returning JSON: -| Route | Purpose | -| ------------------------ | ----------------------------------------------- | -| `/api/health` | version, mode (`full` / `shared-only`), project | -| `/api/projects` | known projects + the launched one | -| `/api/branches` | branch graph (counts, pins, last-active) | -| `/api/branch/:branch` | timeline + pins + graduated for a branch | -| `/api/pins` | all pins for the project | -| `/api/graduated` | all graduated facts for the project | -| `/api/share-status` | pending export / shared / graduated counts | -| `/api/contributors` | contributor leaderboard (value score + badges) | -| `/api/graph` | force-graph nodes + edges for the Grove view | -| `/api/search?q=&branch=` | branch-aware search (full or degraded) | -| `/api/observation/:id` | observation detail (`full` mode only) | -| `/api/events` | SSE live-update stream | +| Route | Purpose | +| ------------------------ | ---------------------------------------------- | +| `/api/health` | version, mode, project, remote + linked clones | +| `/api/projects` | known projects + the launched one | +| `/api/branches` | branch graph (counts, pins, last-active) | +| `/api/branch/:branch` | timeline + pins + graduated for a branch | +| `/api/pins` | all pins for the project | +| `/api/graduated` | all graduated facts for the project | +| `/api/share-status` | pending export / shared / graduated counts | +| `/api/contributors` | contributor leaderboard (value score + badges) | +| `/api/graph` | force-graph nodes + edges for the Grove view | +| `/api/search?q=&branch=` | branch-aware search (full or degraded) | +| `/api/observation/:id` | observation detail (`full` mode only) | +| `/api/events` | SSE live-update stream | All routes accept `?project=` to scope to a project other than the one the server was launched in. diff --git a/package.json b/package.json index b182669..26f36ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tre-mem", - "version": "0.9.0", + "version": "0.10.0", "description": "Branch-aware shared memory layer for AI coding tools. Sidecar on top of claude-mem.", "keywords": [ "claude-code", diff --git a/src/adapter/claude-mem.ts b/src/adapter/claude-mem.ts index 49dc618..fc03653 100644 --- a/src/adapter/claude-mem.ts +++ b/src/adapter/claude-mem.ts @@ -109,6 +109,7 @@ export class ClaudeMemAdapter { } getObservations(query: ListQuery): Observation[] { + if (query.projects.length === 0) return []; const { sql, params } = this.buildRangeQuery( `SELECT id, memory_session_id, project, text, type, title, subtitle, facts, narrative, concepts, files_read, files_modified, @@ -121,6 +122,7 @@ export class ClaudeMemAdapter { } getSessionSummaries(query: ListQuery): SessionSummary[] { + if (query.projects.length === 0) return []; const { sql, params } = this.buildRangeQuery( `SELECT id, memory_session_id, project, request, investigated, learned, completed, next_steps, files_read, files_edited, notes, @@ -149,28 +151,28 @@ export class ClaudeMemAdapter { fts5SearchObservations(opts: { match: string; - project: string; + projects: string[]; k: number; }): Array<{ id: number; rank: number }> { + if (opts.projects.length === 0) return []; + const params: Record = { match: opts.match, k: opts.k }; + const inClause = bindList(params, 'p', opts.projects); const sql = ` SELECT o.id AS id, bm25(observations_fts) AS rank FROM observations_fts JOIN observations o ON o.id = observations_fts.rowid WHERE observations_fts MATCH @match - AND o.project = @project + AND o.project IN (${inClause}) ORDER BY rank ASC LIMIT @k `; - return this.db.prepare(sql).all({ - match: opts.match, - project: opts.project, - k: opts.k, - }) as Array<{ id: number; rank: number }>; + return this.db.prepare(sql).all(params) as Array<{ id: number; rank: number }>; } getPendingMessages(query: ListQuery): PendingMessage[] { - const where: string[] = ['s.project = @project']; - const params: Record = { project: query.project }; + if (query.projects.length === 0) return []; + const params: Record = {}; + const where: string[] = [`s.project IN (${bindList(params, 'proj', query.projects)})`]; if (query.sinceEpoch !== undefined) { where.push('p.created_at_epoch >= @sinceEpoch'); params.sinceEpoch = this.toUpstreamEpoch(query.sinceEpoch); @@ -200,8 +202,8 @@ export class ClaudeMemAdapter { selectClause: string, query: ListQuery, ): { sql: string; params: Record } { - const where: string[] = ['project = @project']; - const params: Record = { project: query.project }; + const params: Record = {}; + const where: string[] = [`project IN (${bindList(params, 'proj', query.projects)})`]; if (query.sinceEpoch !== undefined) { where.push('created_at_epoch >= @sinceEpoch'); params.sinceEpoch = this.toUpstreamEpoch(query.sinceEpoch); @@ -223,6 +225,20 @@ export class ClaudeMemAdapter { } } +/** + * Bind a list of values as named params (`@prefix0`, `@prefix1`, ...) and return + * the comma-joined placeholder string for an `IN (...)` clause. Mutates `params`. + */ +function bindList(params: Record, prefix: string, values: string[]): string { + return values + .map((v, i) => { + const key = `${prefix}${i}`; + params[key] = v; + return `@${key}`; + }) + .join(', '); +} + function detectEpochUnit(db: Database.Database): EpochUnit { const row = db .prepare('SELECT created_at_epoch AS e FROM observations ORDER BY id DESC LIMIT 1') diff --git a/src/adapter/types.ts b/src/adapter/types.ts index 98b88d2..442c5fc 100644 --- a/src/adapter/types.ts +++ b/src/adapter/types.ts @@ -49,7 +49,8 @@ export interface PendingMessage { } export interface ListQuery { - project: string; + /** One or more project labels to union over (cross-clone alias set). */ + projects: string[]; sinceEpoch?: number; untilEpoch?: number; limit?: number; diff --git a/src/cli.ts b/src/cli.ts index 5b8c831..cb620ce 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,6 +38,7 @@ import { shareToGit, simpleGitShare } from './sync/share.js'; import { loadShareignore } from './sync/shareignore.js'; import { AdapterSnapshotProvider } from './sync/snapshot.js'; import { TRE_MEM_DB_PATH, TRE_MEM_HOME } from './store/paths.js'; +import { resolveProjectIdentity } from './store/aliases.js'; import { TreMemRepo } from './store/repo.js'; import { VERSION } from './version.js'; import { clearDaemon, reconcileDaemon, stopDaemon, writeDaemon } from './web/daemon.js'; @@ -175,16 +176,25 @@ cli const project = basename(cwd); const branch = await currentBranch(cwd); - console.log('tre-mem status:'); - console.log(` cwd: ${cwd}`); - console.log(` project: ${project}`); - console.log(` branch: ${branch}`); - migrate(); const repo = new TreMemRepo(); try { - console.log(` branch_tag rows (project): ${repo.countBranchTags(project)}`); - const branches = repo.listBranchesForProject(project); + const identity = await resolveProjectIdentity(repo, cwd); + const linked = identity.aliases.filter((p) => p !== identity.project); + + console.log('tre-mem status:'); + console.log(` cwd: ${cwd}`); + console.log(` project: ${project}`); + if (identity.remote) console.log(` remote: ${identity.remote}`); + console.log(` branch: ${branch}`); + if (linked.length > 0) { + console.log( + ` linked clones (${identity.aliases.length}): ${identity.aliases.join(', ')} ${theme.dim('(memory unioned via shared remote)')}`, + ); + } + + console.log(` branch_tag rows (project): ${repo.countBranchTagsAcross(identity.aliases)}`); + const branches = repo.listBranchesForProjectAcross(identity.aliases); if (branches.length > 0) { console.log(' branches with tags:'); for (const b of branches) { @@ -240,7 +250,7 @@ cli } const adapter = new ClaudeMemAdapter(); try { - const obs = adapter.getObservations({ project, limit: 1 }); + const obs = adapter.getObservations({ projects: [project], limit: 1 }); const head = obs[0]; console.log( head @@ -337,7 +347,11 @@ cli const adapter = new ClaudeMemAdapter(); const repo = new TreMemRepo(); try { - const hits = searchBranchContext({ adapter, repo }, { query, project, branch, k }); + const identity = await resolveProjectIdentity(repo, cwd, { project: flags.project }); + const hits = searchBranchContext( + { adapter, repo }, + { query, projects: identity.aliases, branch, k }, + ); printSearchHeader({ project, branch, query, k, hitCount: hits.length }); if (hits.length === 0) { console.log(' (no matches)'); @@ -1187,7 +1201,12 @@ async function runUserPromptSubmitHookCli(format: HookFormat): Promise { const repo = new TreMemRepo(); try { const result = await runUserPromptSubmitHook(input, { - getHits: (args) => searchBranchContext({ adapter, repo }, { ...args, k: 5 }), + getHits: (args) => + searchBranchContext( + { adapter, repo }, + { query: args.query, projects: args.projects, branch: args.branch, k: 5 }, + ), + resolveProjects: async (c) => (await resolveProjectIdentity(repo, c)).aliases, }); process.stdout.write(`${JSON.stringify(promptEnvelope(format, result.context))}\n`); } finally { diff --git a/src/git/backfill.ts b/src/git/backfill.ts index 57a80cc..b3f11a1 100644 --- a/src/git/backfill.ts +++ b/src/git/backfill.ts @@ -33,7 +33,7 @@ export interface BackfillResult { export async function backfill(opts: BackfillOptions): Promise { const transitions = await readHeadReflog(opts.cwd); const observations = opts.adapter.getObservations({ - project: opts.project, + projects: [opts.project], sinceEpoch: opts.sinceEpoch, limit: opts.limit, }); diff --git a/src/git/remote.ts b/src/git/remote.ts new file mode 100644 index 0000000..0b6c6e6 --- /dev/null +++ b/src/git/remote.ts @@ -0,0 +1,66 @@ +import { existsSync } from 'node:fs'; +import { simpleGit } from 'simple-git'; + +/** + * Normalize a git remote URL to a stable, scheme-less canonical slug of the form + * `host/org/repo` (e.g. `github.com/rumitvn/tre-mem`). This is the identity used to + * union memory across multiple local clones of the same repository. + * + * Pure function (no I/O) so it can be unit-tested exhaustively. Handles ssh scp + * short form, ssh/git/https/http URLs, embedded credentials, a trailing `.git`, + * and a trailing slash. Lowercases the whole slug (host is case-insensitive; path + * case collisions across orgs are negligible and maximizing the union is the point). + * Returns `null` for empty or unparseable input. + */ +export function canonicalizeRemoteUrl(url: string): string | null { + const raw = url.trim(); + if (raw === '') return null; + + let rest: string; + + // ssh scp short form: git@host:org/repo(.git) — note the colon, no `//`. + const scp = /^[^/@]+@([^:/]+):(.+)$/.exec(raw); + if (scp && !raw.includes('://')) { + rest = `${scp[1]}/${scp[2]}`; + } else { + // Strip a leading scheme (https://, http://, ssh://, git://, git+ssh://). + const withoutScheme = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); + // Drop embedded credentials: everything up to and including the last `@` + // in the authority portion (before the first `/`). + const slashIdx = withoutScheme.indexOf('/'); + const authority = slashIdx === -1 ? withoutScheme : withoutScheme.slice(0, slashIdx); + const path = slashIdx === -1 ? '' : withoutScheme.slice(slashIdx); + const at = authority.lastIndexOf('@'); + const hostPort = at === -1 ? authority : authority.slice(at + 1); + // Drop a port suffix on the host (host:22/...). + const host = hostPort.replace(/:\d+$/, ''); + rest = `${host}${path}`; + } + + // Normalize: collapse, strip trailing `.git`, strip surrounding slashes, lowercase. + let slug = rest.replace(/\/{2,}/g, '/'); + slug = slug.replace(/\.git$/i, ''); + slug = slug.replace(/^\/+|\/+$/g, ''); + slug = slug.toLowerCase(); + + // A valid slug needs at least host + one path segment. + if (slug === '' || !slug.includes('/')) return null; + return slug; +} + +/** + * Best-effort canonical slug for the `origin` remote of the repo at `cwd`. + * Reads `git config --get remote.origin.url` (origin only — reading every remote + * would over-union). Returns `null` when there's no repo, no origin, or any error. + * Never throws (mirrors the other git helpers). + */ +export async function remoteSlug(cwd: string): Promise { + if (!existsSync(cwd)) return null; + try { + const url = (await simpleGit(cwd).raw(['config', '--get', 'remote.origin.url'])).trim(); + if (url === '') return null; + return canonicalizeRemoteUrl(url); + } catch { + return null; + } +} diff --git a/src/git/watcher.ts b/src/git/watcher.ts index 5f4c3b0..b7e2099 100644 --- a/src/git/watcher.ts +++ b/src/git/watcher.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import type { TreMemRepo } from '../store/repo.js'; +import { remoteSlug } from './remote.js'; import { currentBranch } from './resolver.js'; export interface WatcherOptions { @@ -39,11 +40,13 @@ export class GitWatcher { async sync(): Promise { const branch = await currentBranch(this.opts.cwd); + const remote = await remoteSlug(this.opts.cwd); this.opts.repo.upsertBranchState({ cwd: this.opts.cwd, project: this.opts.project, current_branch: branch, updated_at_epoch: this.now(), + remote, }); this.opts.onSync?.(branch); return branch; diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index a094f01..4a7b234 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -9,6 +9,7 @@ import { } from '../format/digest.js'; import { currentBranch } from '../git/resolver.js'; import { log, logError } from '../log/logger.js'; +import { resolveProjectIdentity } from '../store/aliases.js'; import { TreMemRepo } from '../store/repo.js'; import { importDir } from '../sync/import.js'; import { SYNC_DIR_NAME } from '../sync/layout.js'; @@ -30,6 +31,8 @@ export interface SessionStartOptions { now?: () => number; /** Skip the auto-import of the committed `.tre-mem/` directory. */ skipImport?: boolean; + /** Injectable git-remote resolver for cross-clone identity (tests override). */ + resolveRemote?: (cwd: string) => Promise; /** * Resolve the most-recent branch-tagged observations for the digest. May * throw (e.g. claude-mem missing) — the hook then renders an empty list plus @@ -81,14 +84,18 @@ export async function runSessionStartHook( const ownsRepo = !opts.repo; const repo = opts.repo ?? new TreMemRepo(); try { + const identity = await resolveProjectIdentity(repo, cwd, { + resolveRemote: opts.resolveRemote, + }); repo.upsertBranchState({ cwd, project, current_branch: branch, updated_at_epoch: tagged_at_epoch, + remote: identity.remote, }); - const tagged_count_for_branch = repo.countBranchTags(project, branch); - const tagged_count_for_project = repo.countBranchTags(project); + const tagged_count_for_branch = repo.countBranchTagsAcross(identity.aliases, branch); + const tagged_count_for_project = repo.countBranchTagsAcross(identity.aliases); // Auto-import teammates' shared memory. Cheap + idempotent: unchanged // files are skipped via import_state SHA tracking. Never blocks a session. diff --git a/src/hooks/user-prompt-submit.ts b/src/hooks/user-prompt-submit.ts index 2fdd596..2ab9fb2 100644 --- a/src/hooks/user-prompt-submit.ts +++ b/src/hooks/user-prompt-submit.ts @@ -12,7 +12,9 @@ export interface UserPromptSubmitInput { export interface UserPromptSubmitDeps { /** Branch-aware retrieval, injected so the handler stays testable. */ - getHits: (args: { query: string; project: string; branch: string }) => SearchHit[]; + getHits: (args: { query: string; projects: string[]; branch: string }) => SearchHit[]; + /** Resolve the cross-clone alias set for a cwd; defaults to `[basename(cwd)]`. */ + resolveProjects?: (cwd: string) => Promise; k?: number; } @@ -50,7 +52,8 @@ export async function runUserPromptSubmitHook( if (prompt === '') return { project, branch, injected: 0, context: '' }; - const hits = deps.getHits({ query: prompt, project, branch }).slice(0, deps.k ?? DEFAULT_K); + const projects = deps.resolveProjects ? await deps.resolveProjects(cwd) : [project]; + const hits = deps.getHits({ query: prompt, projects, branch }).slice(0, deps.k ?? DEFAULT_K); if (hits.length === 0) return { project, branch, injected: 0, context: '' }; const context = diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index f66c951..3109f2a 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1,11 +1,20 @@ -import { basename } from 'node:path'; +import { existsSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; import type { ClaudeMemAdapter } from '../adapter/claude-mem.js'; import type { Observation } from '../adapter/types.js'; -import { currentBranch } from '../git/resolver.js'; +import { gitAuthor } from '../git/identity.js'; +import { currentBranch, isDetached, NO_GIT } from '../git/resolver.js'; import type { RerankBreakdown } from '../retrieval/rerank.js'; import { searchBranchContext } from '../retrieval/search.js'; +import { resolveProjectIdentity } from '../store/aliases.js'; import type { TreMemRepo } from '../store/repo.js'; +import { exportSync, type SnapshotProvider } from '../sync/export.js'; +import { SYNC_DIR_NAME, ensureSyncScaffold } from '../sync/layout.js'; +import { RedactionError } from '../sync/redact.js'; +import { shareToGit, simpleGitShare } from '../sync/share.js'; +import { loadShareignore } from '../sync/shareignore.js'; +import { AdapterSnapshotProvider } from '../sync/snapshot.js'; export interface ToolDeps { /** Null in shared-memory-only mode (claude-mem absent): full-text/observation @@ -14,6 +23,8 @@ export interface ToolDeps { repo: TreMemRepo; defaultCwd?: string; resolveBranch?: (cwd: string) => Promise; + /** Injectable git-remote resolver for cross-clone identity (tests override). */ + resolveRemote?: (cwd: string) => Promise; now?: () => number; } @@ -108,6 +119,46 @@ export interface GraduateFactResult { observation_id: number; graduated_from_branch: string; graduated_at_epoch: number; + /** Workflow nudge: graduating only writes the sidecar; export to publish. */ + hint: string; +} + +export interface ExportMemoryInput { + cwd?: string; + project?: string; + branch?: string; + all?: boolean; + message?: string; + force?: boolean; +} + +export interface ExportMemoryResult { + project: string; + files: string[]; + total_added: number; + graduated_added: number; + committed: boolean; + /** Always false — export_memory never pushes; the user pushes when ready. */ + pushed: boolean; + /** Exact command to publish the local commit (e.g. `git push -u origin `). */ + commit_hint: string | null; + note: string | null; + /** Present when the fail-closed secret scan blocked the export (no files written). */ + redaction_blocked?: { categories: string[]; count: number; instruction: string }; +} + +export interface ShareStatusInput { + cwd?: string; + project?: string; +} + +export interface ShareStatusResult { + project: string; + pending_export: number; + shared_pins: number; + total_pins: number; + graduated: number; + has_sync_dir: boolean; } export interface ToolDefinition { @@ -215,6 +266,44 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [ required: ['observation_id'], }, }, + { + name: 'export_memory', + description: + 'Publish curated memory (pins + graduated facts) to the repo-local .tre-mem/ files and make a local git commit. Does NOT push — the user pushes when ready (the result carries the exact push command). Call this after pin_fact/graduate_fact to share with the team. Fail-closed on detected secrets.', + inputSchema: { + type: 'object', + properties: { + cwd: { type: 'string', description: 'Repo root (defaults to server cwd)' }, + project: { type: 'string', description: 'Project slug (defaults to basename of cwd)' }, + branch: { + type: 'string', + description: 'Export a single branch (defaults to current branch)', + }, + all: { type: 'boolean', description: 'Export every branch that has pins' }, + message: { + type: 'string', + description: 'Commit message (defaults to a generated summary)', + }, + force: { + type: 'boolean', + description: + 'Redact detected secrets with placeholders instead of aborting (use with care)', + }, + }, + }, + }, + { + name: 'get_share_status', + description: + 'Report how much curated memory is waiting to be shared: unshared pins, shared pins, total pins, graduated facts, and whether a .tre-mem/ directory exists. Use to nudge the user to export.', + inputSchema: { + type: 'object', + properties: { + cwd: { type: 'string', description: 'Repo root (defaults to server cwd)' }, + project: { type: 'string', description: 'Project slug (defaults to basename of cwd)' }, + }, + }, + }, ] as const; export async function getBranchContext( @@ -222,18 +311,18 @@ export async function getBranchContext( input: GetBranchContextInput, ): Promise { const cwd = resolveCwd(deps, input.cwd); - const project = input.project ?? basename(cwd); + const identity = await resolveIdentity(deps, cwd, input.project); const branch = input.branch ?? (await resolveBranch(deps, cwd)); const k = input.k ?? 10; const nowEpoch = (deps.now ?? defaultNow)(); const hits = searchBranchContext( { adapter: deps.adapter, repo: deps.repo }, - { query: input.query, project, branch, k, nowEpoch }, + { query: input.query, projects: identity.aliases, branch, k, nowEpoch }, ); return { - project, + project: identity.project, branch, query: input.query, k, @@ -257,10 +346,10 @@ export async function getBranchTimeline( input: GetBranchTimelineInput, ): Promise { const cwd = resolveCwd(deps, input.cwd); - const project = input.project ?? basename(cwd); + const identity = await resolveIdentity(deps, cwd, input.project); const limit = input.limit ?? 50; - const tags = deps.repo.listBranchTagsForBranch(project, input.branch, limit); + const tags = deps.repo.listBranchTagsForBranchAcross(identity.aliases, input.branch, limit); const ids = tags.map((t) => t.observation_id); const observations = deps.adapter && ids.length > 0 ? deps.adapter.getObservationsByIds(ids) : []; const byId = new Map(observations.map((o) => [o.id, o])); @@ -281,13 +370,19 @@ export async function getBranchTimeline( }); } - return { project, branch: input.branch, limit, entries }; + return { project: identity.project, branch: input.branch, limit, entries }; } -export function listBranches(deps: ToolDeps, input: ListBranchesInput): ListBranchesResult { +export async function listBranches( + deps: ToolDeps, + input: ListBranchesInput, +): Promise { const cwd = resolveCwd(deps, input.cwd); - const project = input.project ?? basename(cwd); - return { project, branches: deps.repo.listBranchesForProject(project) }; + const identity = await resolveIdentity(deps, cwd, input.project); + return { + project: identity.project, + branches: deps.repo.listBranchesForProjectAcross(identity.aliases), + }; } export async function pinFact(deps: ToolDeps, input: PinFactInput): Promise { @@ -342,6 +437,127 @@ export async function graduateFact( observation_id: g.observation_id, graduated_from_branch: g.graduated_from_branch, graduated_at_epoch: g.graduated_at_epoch, + hint: 'Graduated to the sidecar. Call export_memory to publish it to your team (writes .tre-mem/ and commits locally — you push when ready).', + }; +} + +export async function exportMemory( + deps: ToolDeps, + input: ExportMemoryInput, +): Promise { + const cwd = resolveCwd(deps, input.cwd); + const project = input.project ?? basename(cwd); // WRITE key — export the current clone's facts + const dir = resolve(cwd, SYNC_DIR_NAME); + const author = await gitAuthor(cwd); + const now = (deps.now ?? defaultNow)(); + + let branches: string[]; + if (input.all) { + branches = deps.repo.listPinBranches(project); + } else if (input.branch) { + branches = [input.branch]; + } else { + const cur = await resolveBranch(deps, cwd); + branches = cur === NO_GIT || isDetached(cur) ? deps.repo.listPinBranches(project) : [cur]; + } + + const snapshots: SnapshotProvider = deps.adapter + ? new AdapterSnapshotProvider(deps.adapter) + : { getSnapshots: () => new Map() }; + + let result; + try { + result = exportSync({ + repo: deps.repo, + snapshots, + project, + dir, + branches, + now, + author, + shareignore: loadShareignore(dir), + redact: input.force ?? false, + dryRun: false, + }); + } catch (err) { + if (err instanceof RedactionError) { + const categories = [...new Set(err.matches.map((m) => m.rule))]; + return { + project, + files: [], + total_added: 0, + graduated_added: 0, + committed: false, + pushed: false, + commit_hint: null, + note: `export blocked: ${err.matches.length} potential secret(s) detected`, + redaction_blocked: { + categories, + count: err.matches.length, + instruction: + 'Remove the secret(s) from the pinned facts, add a .tre-mem/.shareignore pattern, or run `tre share --force` in a terminal to redact with placeholders. No secret values are returned here.', + }, + }; + } + throw err; + } + + const totalAdded = result.branches.reduce((s, b) => s + b.added, 0) + result.graduated.added; + const files = [ + ...result.branches.filter((b) => b.added > 0).map((b) => b.file), + ...(result.graduated.added > 0 ? [result.graduated.file] : []), + ]; + + let committed = false; + let commitHint: string | null = null; + let note: string | null = null; + if (totalAdded > 0) { + ensureSyncScaffold(result.dir); + try { + const share = await shareToGit({ + git: simpleGitShare(cwd), + dir: result.dir, + pathspec: SYNC_DIR_NAME, + message: + input.message ?? `chore(tre-mem): export ${totalAdded} memory fact(s) for ${project}`, + commit: true, + push: false, + }); + committed = share.committed; + note = share.note; + if (share.committed) { + commitHint = share.upstream ? 'git push' : `git push -u origin ${share.branch}`; + } + } catch (err) { + note = `export written, but git commit was skipped (${err instanceof Error ? err.message.split('\n')[0] : 'git unavailable'})`; + } + } else { + note = 'nothing new to export — curated facts are already in .tre-mem/'; + } + + return { + project, + files, + total_added: totalAdded, + graduated_added: result.graduated.added, + committed, + pushed: false, + commit_hint: commitHint, + note, + }; +} + +export function getShareStatus(deps: ToolDeps, input: ShareStatusInput): ShareStatusResult { + const cwd = resolveCwd(deps, input.cwd); + const project = input.project ?? basename(cwd); // WRITE key — pending share is per-clone + const pins = deps.repo.listPinsForProject(project); + return { + project, + pending_export: deps.repo.countUnsharedPins(project), + shared_pins: pins.filter((p) => p.shared_at_epoch !== null).length, + total_pins: pins.length, + graduated: deps.repo.listGraduated(project).length, + has_sync_dir: existsSync(join(cwd, SYNC_DIR_NAME)), }; } @@ -357,6 +573,10 @@ export async function callTool( return getBranchTimeline(deps, args as unknown as GetBranchTimelineInput); case 'list_branches': return listBranches(deps, args as unknown as ListBranchesInput); + case 'export_memory': + return exportMemory(deps, args as unknown as ExportMemoryInput); + case 'get_share_status': + return getShareStatus(deps, args as unknown as ShareStatusInput); case 'pin_fact': return pinFact(deps, args as unknown as PinFactInput); case 'graduate_fact': @@ -375,6 +595,10 @@ async function resolveBranch(deps: ToolDeps, cwd: string): Promise { return deps.resolveBranch ? deps.resolveBranch(cwd) : currentBranch(cwd); } +function resolveIdentity(deps: ToolDeps, cwd: string, project?: string) { + return resolveProjectIdentity(deps.repo, cwd, { project, resolveRemote: deps.resolveRemote }); +} + function defaultNow(): number { return Math.floor(Date.now() / 1000); } diff --git a/src/retrieval/search.ts b/src/retrieval/search.ts index 1435f39..dcb1a73 100644 --- a/src/retrieval/search.ts +++ b/src/retrieval/search.ts @@ -21,7 +21,8 @@ export interface SearchHit { export interface SearchOptions { query: string; - project: string; + /** Project alias set to union over (cross-clone). Use `[project]` for one repo. */ + projects: string[]; branch: string; k?: number; weights?: Partial; @@ -98,23 +99,23 @@ export function searchBranchContext(deps: SearchDeps, opts: SearchOptions): Sear const searcher = opts.semantic ?? (deps.adapter ? new Fts5SemanticSearcher(deps.adapter) : null); const semHits = searcher - ? searcher.search({ query: opts.query, project: opts.project, k: fetchK }) + ? searcher.search({ query: opts.query, projects: opts.projects, k: fetchK }) : []; - const branchTags = deps.repo.listBranchTagsForBranch(opts.project, opts.branch, fetchK); + const branchTags = deps.repo.listBranchTagsForBranchAcross(opts.projects, opts.branch, fetchK); const recentObs = deps.adapter - ? deps.adapter.getObservations({ project: opts.project, sinceEpoch, limit: fetchK }) + ? deps.adapter.getObservations({ projects: opts.projects, sinceEpoch, limit: fetchK }) : []; // Pins: a free-text pin (no observation) still surfaces, via a stable // negative synthetic id so it flows through the id-keyed reranker. - const pins = deps.repo.listPinsForBranch(opts.project, opts.branch); + const pins = deps.repo.listPinsForBranchAcross(opts.projects, opts.branch); const pinEntries = pins.map((pin) => ({ id: pin.observation_id ?? -pin.id, pin })); const pinById = new Map(pinEntries.map((e) => [e.id, e.pin])); // Cap the always-on graduated boost to the most-recent fetchK (listGraduated // is ordered newest-first) so a repo with many merged PRs can't crowd out // query-relevant observations from the k result slots. - const graduated = deps.repo.listGraduated(opts.project).slice(0, fetchK); + const graduated = deps.repo.listGraduatedAcross(opts.projects).slice(0, fetchK); const gradByObsId = new Map(graduated.map((g) => [g.observation_id, g])); const ranked = rerank( diff --git a/src/retrieval/semantic.ts b/src/retrieval/semantic.ts index 6dc0ff3..78b4069 100644 --- a/src/retrieval/semantic.ts +++ b/src/retrieval/semantic.ts @@ -7,7 +7,7 @@ export interface SemanticHit { export interface SemanticSearchQuery { query: string; - project: string; + projects: string[]; k: number; } @@ -34,7 +34,7 @@ export class Fts5SemanticSearcher implements SemanticSearcher { if (match === null) return []; const rows = this.adapter.fts5SearchObservations({ match, - project: opts.project, + projects: opts.projects, k: opts.k, }); if (rows.length === 0) return []; diff --git a/src/store/aliases.ts b/src/store/aliases.ts new file mode 100644 index 0000000..f1c4158 --- /dev/null +++ b/src/store/aliases.ts @@ -0,0 +1,77 @@ +import { basename } from 'node:path'; + +import { remoteSlug } from '../git/remote.js'; +import type { TreMemRepo } from './repo.js'; + +/** + * Resolved project identity for a working directory. + * + * - `project` is the WRITE key — `basename(cwd)` — unchanged from v0.x. New pins, + * graduations and branch tags are always stored under this label. + * - `aliases` is the READ key set — every project label that shares this repo's + * git remote (always includes `project`). Reads union over these so memory + * crosses multiple local clones of the same repo. + */ +export interface ProjectIdentity { + project: string; + remote: string | null; + aliases: string[]; +} + +export interface ResolveIdentityOptions { + /** Explicit project override (`--project`). Bypasses cross-clone union. */ + project?: string; + /** Injectable remote resolver for tests (defaults to reading git origin). */ + resolveRemote?: (cwd: string) => Promise; + /** Force cross-clone on/off, overriding the env default. */ + crossClone?: boolean; +} + +/** + * Whether cross-clone memory unioning is enabled. Default ON; disabled by setting + * `TRE_MEM_CROSS_CLONE` to `0`/`false`/`off`/`no` (mirrors the other boolean envs). + */ +export function crossCloneEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const flag = (env.TRE_MEM_CROSS_CLONE ?? '').trim().toLowerCase(); + return !(flag === '0' || flag === 'false' || flag === 'off' || flag === 'no'); +} + +/** + * Resolve the project identity for `cwd`. Best-effort and never throws: when there + * is no git remote, cross-clone is disabled, or an explicit `project` override is + * given, `aliases` collapses to `[project]` (exactly the pre-v0.10 behavior). + */ +export async function resolveProjectIdentity( + repo: Pick, + cwd: string, + opts: ResolveIdentityOptions = {}, +): Promise { + // An explicit project override is a deliberate single-project scope. + if (opts.project !== undefined) { + return { project: opts.project, remote: null, aliases: [opts.project] }; + } + + const project = basename(cwd); + const enabled = opts.crossClone ?? crossCloneEnabled(); + if (!enabled) return { project, remote: null, aliases: [project] }; + + const resolve = opts.resolveRemote ?? remoteSlug; + let remote: string | null = null; + try { + remote = await resolve(cwd); + } catch { + remote = null; + } + // Eagerly register this clone's remote so any tre invocation (status, MCP, web) + // — not just session-start — lets siblings discover it. No-op until the row + // exists; cheap idempotent UPDATE otherwise. + if (remote !== null) { + try { + repo.setRemoteForCwd(cwd, remote); + } catch { + /* registration is best-effort, never block resolution */ + } + } + const aliases = repo.projectAliases(project, remote); + return { project, remote, aliases }; +} diff --git a/src/store/migrate.ts b/src/store/migrate.ts index dbf253a..8b525e6 100644 --- a/src/store/migrate.ts +++ b/src/store/migrate.ts @@ -7,7 +7,7 @@ import { TRE_MEM_DB_PATH } from './paths.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); -export const CURRENT_SCHEMA_VERSION = 2; +export const CURRENT_SCHEMA_VERSION = 3; const V1_SCHEMA_FILE = join(__dirname, 'schema.sql'); @@ -53,6 +53,43 @@ const V2_IMPORT_STATE = ` ); `; +/** + * Schema v3 (Phase 7 — cross-clone memory). Additive only: a single nullable + * `remote` column on `branch_state` recording the canonical git remote slug of + * each working directory. Lets reads union memory across clones that share a + * remote. No backfill — existing rows stay `NULL` (single-project behavior) and + * self-heal on the next session-start write. + */ +const V3_COLUMNS: ReadonlyArray<{ table: string; column: string; ddl: string }> = [ + { + table: 'branch_state', + column: 'remote', + ddl: 'ALTER TABLE branch_state ADD COLUMN remote TEXT', + }, +]; + +/** + * Reconcile the additive v3 column + index. Idempotent (guarded by `columnExists` + * / `IF NOT EXISTS`), mirroring `ensureSyncColumns` so a database that recorded + * `schema_versions = 3` under an earlier, incomplete build self-heals. + */ +function ensureRemoteColumn(db: Database.Database): void { + // branch_state is a v1 table, but guard anyway so a partially-seeded database + // self-heals rather than throwing on a missing table. + if (!tableExists(db, 'branch_state')) return; + for (const { table, column, ddl } of V3_COLUMNS) { + if (!columnExists(db, table, column)) db.exec(ddl); + } + db.exec('CREATE INDEX IF NOT EXISTS idx_branch_state_remote ON branch_state(remote)'); +} + +function tableExists(db: Database.Database, table: string): boolean { + const row = db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`) + .get(table); + return row !== undefined; +} + function columnExists(db: Database.Database, table: string, column: string): boolean { const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; return cols.some((c) => c.name === column); @@ -130,6 +167,21 @@ export function migrate(dbPath: string = TRE_MEM_DB_PATH): MigrateResult { db.transaction(() => ensureSyncColumns(db))(); } + if (currentVersion(db) < 3) { + const recordVersion = db.prepare( + 'INSERT OR IGNORE INTO schema_versions (version, applied_at_epoch) VALUES (?, ?)', + ); + const tx = db.transaction(() => { + ensureRemoteColumn(db); + recordVersion.run(3, Math.floor(Date.now() / 1000)); + }); + tx(); + applied.push(3); + } else { + // Self-heal: reconcile the v3 column/index even if v3 was recorded early. + db.transaction(() => ensureRemoteColumn(db))(); + } + const toVersion = currentVersion(db); return { dbPath, fromVersion, toVersion, applied }; } finally { diff --git a/src/store/repo.ts b/src/store/repo.ts index 1143954..8bd070a 100644 --- a/src/store/repo.ts +++ b/src/store/repo.ts @@ -7,6 +7,8 @@ export interface BranchState { project: string; current_branch: string; updated_at_epoch: number; + /** Canonical git remote slug (host/org/repo), null when there's no origin. */ + remote?: string | null; } export type BranchTagSource = 'live' | 'reflog-backfill' | 'manual'; @@ -84,20 +86,21 @@ export class TreMemRepo { upsertBranchState(state: BranchState): void { this.db .prepare( - `INSERT INTO branch_state (cwd, project, current_branch, updated_at_epoch) - VALUES (@cwd, @project, @current_branch, @updated_at_epoch) + `INSERT INTO branch_state (cwd, project, current_branch, updated_at_epoch, remote) + VALUES (@cwd, @project, @current_branch, @updated_at_epoch, @remote) ON CONFLICT(cwd) DO UPDATE SET project = excluded.project, current_branch = excluded.current_branch, - updated_at_epoch = excluded.updated_at_epoch`, + updated_at_epoch = excluded.updated_at_epoch, + remote = excluded.remote`, ) - .run(state); + .run({ ...state, remote: state.remote ?? null }); } getBranchState(cwd: string): BranchState | null { const row = this.db .prepare( - `SELECT cwd, project, current_branch, updated_at_epoch + `SELECT cwd, project, current_branch, updated_at_epoch, remote FROM branch_state WHERE cwd = ?`, ) @@ -108,13 +111,45 @@ export class TreMemRepo { listBranchStates(): BranchState[] { return this.db .prepare( - `SELECT cwd, project, current_branch, updated_at_epoch + `SELECT cwd, project, current_branch, updated_at_epoch, remote FROM branch_state ORDER BY project, cwd`, ) .all() as BranchState[]; } + /** Update the remote slug for a known cwd. No-op if the row doesn't exist yet + * (session-start / the git watcher create it). Lets any tre invocation register + * the current clone's remote so cross-clone siblings can find each other. */ + setRemoteForCwd(cwd: string, remote: string | null): void { + this.db.prepare(`UPDATE branch_state SET remote = ? WHERE cwd = ?`).run(remote, cwd); + } + + /** The remote slug recorded for any clone of `project`, or null if none known. */ + remoteForProject(project: string): string | null { + const row = this.db + .prepare(`SELECT remote FROM branch_state WHERE project = ? AND remote IS NOT NULL LIMIT 1`) + .get(project) as { remote: string } | undefined; + return row?.remote ?? null; + } + + /** + * The set of project labels (directory basenames) that share a git remote with + * `project`, always including `project` itself. This is the cross-clone read + * key: when several local clones of one repo report the same `remote`, their + * memory is unioned. Returns `[project]` when `remote` is null/empty (no origin + * or cross-clone disabled) — i.e. single-project behavior. + */ + projectAliases(project: string, remote: string | null | undefined): string[] { + if (remote === null || remote === undefined || remote === '') return [project]; + const rows = this.db + .prepare(`SELECT DISTINCT project FROM branch_state WHERE remote = ?`) + .all(remote) as Array<{ project: string }>; + const set = new Set(rows.map((r) => r.project)); + set.add(project); + return [...set].sort(); + } + upsertBranchTag(tag: BranchTag): void { this.db .prepare( @@ -149,24 +184,35 @@ export class TreMemRepo { } countBranchTags(project: string, branch?: string): number { + return this.countBranchTagsAcross([project], branch); + } + + countBranchTagsAcross(projects: string[], branch?: string): number { + if (projects.length === 0) return 0; + const ph = placeholders(projects.length); if (branch !== undefined) { const row = this.db - .prepare('SELECT COUNT(*) AS n FROM branch_tag WHERE project = ? AND branch = ?') - .get(project, branch) as { n: number }; + .prepare(`SELECT COUNT(*) AS n FROM branch_tag WHERE project IN (${ph}) AND branch = ?`) + .get(...projects, branch) as { n: number }; return row.n; } const row = this.db - .prepare('SELECT COUNT(*) AS n FROM branch_tag WHERE project = ?') - .get(project) as { n: number }; + .prepare(`SELECT COUNT(*) AS n FROM branch_tag WHERE project IN (${ph})`) + .get(...projects) as { n: number }; return row.n; } listBranchTagsForBranch(project: string, branch: string, limit?: number): BranchTag[] { + return this.listBranchTagsForBranchAcross([project], branch, limit); + } + + listBranchTagsForBranchAcross(projects: string[], branch: string, limit?: number): BranchTag[] { + if (projects.length === 0) return []; let sql = `SELECT observation_id, project, branch, tagged_at_epoch, source FROM branch_tag - WHERE project = ? AND branch = ? + WHERE project IN (${placeholders(projects.length)}) AND branch = ? ORDER BY tagged_at_epoch DESC`; - const params: unknown[] = [project, branch]; + const params: unknown[] = [...projects, branch]; if (limit !== undefined) { sql += ' LIMIT ?'; params.push(limit); @@ -202,25 +248,35 @@ export class TreMemRepo { } listPinsForBranch(project: string, branch: string): BranchPin[] { + return this.listPinsForBranchAcross([project], branch); + } + + listPinsForBranchAcross(projects: string[], branch: string): BranchPin[] { + if (projects.length === 0) return []; return this.db .prepare( `SELECT id, project, branch, observation_id, note, created_at_epoch, content_hash, shared_at_epoch, title, body FROM branch_pin - WHERE project = ? AND branch = ? + WHERE project IN (${placeholders(projects.length)}) AND branch = ? ORDER BY created_at_epoch DESC, id DESC`, ) - .all(project, branch) as BranchPin[]; + .all(...projects, branch) as BranchPin[]; } listPinsForProject(project: string): BranchPin[] { + return this.listPinsForProjectAcross([project]); + } + + listPinsForProjectAcross(projects: string[]): BranchPin[] { + if (projects.length === 0) return []; return this.db .prepare( `SELECT id, project, branch, observation_id, note, created_at_epoch, content_hash, shared_at_epoch, title, body FROM branch_pin - WHERE project = ? + WHERE project IN (${placeholders(projects.length)}) ORDER BY created_at_epoch DESC, id DESC`, ) - .all(project) as BranchPin[]; + .all(...projects) as BranchPin[]; } graduateFact(input: GraduatedInsert): Graduated { @@ -248,35 +304,52 @@ export class TreMemRepo { } listGraduated(project: string): Graduated[] { + return this.listGraduatedAcross([project]); + } + + listGraduatedAcross(projects: string[]): Graduated[] { + if (projects.length === 0) return []; return this.db .prepare( `SELECT id, project, observation_id, graduated_from_branch, graduated_at_epoch, content_hash, shared_at_epoch, title, body FROM graduated - WHERE project = ? + WHERE project IN (${placeholders(projects.length)}) ORDER BY graduated_at_epoch DESC, id DESC`, ) - .all(project) as Graduated[]; + .all(...projects) as Graduated[]; } listBranchesForProject(project: string): Array<{ branch: string; count: number }> { + return this.listBranchesForProjectAcross([project]); + } + + listBranchesForProjectAcross(projects: string[]): Array<{ branch: string; count: number }> { + if (projects.length === 0) return []; return this.db .prepare( `SELECT branch, COUNT(*) AS count FROM branch_tag - WHERE project = ? + WHERE project IN (${placeholders(projects.length)}) GROUP BY branch ORDER BY count DESC, branch ASC`, ) - .all(project) as Array<{ branch: string; count: number }>; + .all(...projects) as Array<{ branch: string; count: number }>; } // --- Phase 2 sync (schema v2) --- listPinBranches(project: string): string[] { + return this.listPinBranchesAcross([project]); + } + + listPinBranchesAcross(projects: string[]): string[] { + if (projects.length === 0) return []; return ( this.db - .prepare(`SELECT DISTINCT branch FROM branch_pin WHERE project = ? ORDER BY branch ASC`) - .all(project) as Array<{ branch: string }> + .prepare( + `SELECT DISTINCT branch FROM branch_pin WHERE project IN (${placeholders(projects.length)}) ORDER BY branch ASC`, + ) + .all(...projects) as Array<{ branch: string }> ).map((r) => r.branch); } @@ -398,3 +471,8 @@ export class TreMemRepo { return true; } } + +/** Comma-separated `?` placeholders for an `IN (...)` clause of length `n`. */ +function placeholders(n: number): string { + return new Array(n).fill('?').join(', '); +} diff --git a/src/store/schema.sql b/src/store/schema.sql index a00e63c..093822c 100644 --- a/src/store/schema.sql +++ b/src/store/schema.sql @@ -43,10 +43,13 @@ CREATE TABLE IF NOT EXISTS branch_state ( cwd TEXT PRIMARY KEY, project TEXT NOT NULL, current_branch TEXT NOT NULL, - updated_at_epoch INTEGER NOT NULL + updated_at_epoch INTEGER NOT NULL, + remote TEXT ); CREATE INDEX IF NOT EXISTS idx_branch_state_project ON branch_state(project); +CREATE INDEX IF NOT EXISTS idx_branch_state_remote + ON branch_state(remote); CREATE TABLE IF NOT EXISTS schema_versions ( version INTEGER PRIMARY KEY, diff --git a/src/version.ts b/src/version.ts index b32f48e..a1c8edc 100644 --- a/src/version.ts +++ b/src/version.ts @@ -4,4 +4,4 @@ * Imported by the CLI (`tre --version`), the MCP server handshake, and the * session digest's compatibility warnings. */ -export const VERSION = '0.9.0'; +export const VERSION = '0.10.0'; diff --git a/src/web/api.ts b/src/web/api.ts index a5b7c51..b57ae00 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -24,6 +24,18 @@ function resolveProject(ctx: RouteCtx): string { return q && q.trim() !== '' ? q.trim() : ctx.deps.project; } +/** + * Cross-clone alias set for a requested project. For the daemon's home project we + * reuse the precomputed `deps.aliases`; for any other project picked in the UI we + * resolve its remote from `branch_state` so its clones union too. Returns + * `[project]` when no remote is recorded. + */ +function aliasesFor(ctx: RouteCtx, project: string): string[] { + if (project === ctx.deps.project) return ctx.deps.aliases; + const remote = ctx.deps.repo.remoteForProject(project); + return ctx.deps.repo.projectAliases(project, remote); +} + /** Branch from `?branch=`, else the live branch_state for cwd, else first branch. */ function resolveBranch(ctx: RouteCtx, project: string): string { const q = ctx.query.get('branch'); @@ -109,6 +121,8 @@ const ROUTES: Route[] = [ mode: webMode(ctx.deps), project: ctx.deps.project, cwd: ctx.deps.cwd, + remote: ctx.deps.remote, + linked_clones: ctx.deps.aliases, }); }, }, @@ -128,13 +142,14 @@ const ROUTES: Route[] = [ handler: (_req, res, ctx) => { const project = resolveProject(ctx); const { repo } = ctx.deps; + const projects = aliasesFor(ctx, project); const pinsByBranch = new Map(); - for (const p of repo.listPinsForProject(project)) { + for (const p of repo.listPinsForProjectAcross(projects)) { pinsByBranch.set(p.branch, (pinsByBranch.get(p.branch) ?? 0) + 1); } const state = repo.getBranchState(ctx.deps.cwd); - const branches = repo.listBranchesForProject(project).map((b) => { - const newest = repo.listBranchTagsForBranch(project, b.branch, 1)[0]; + const branches = repo.listBranchesForProjectAcross(projects).map((b) => { + const newest = repo.listBranchTagsForBranchAcross(projects, b.branch, 1)[0]; return { branch: b.branch, count: b.count, @@ -146,6 +161,7 @@ const ROUTES: Route[] = [ project, current_branch: state && state.project === project ? state.current_branch : null, branches, + linked_clones: projects, }); }, }, @@ -156,7 +172,8 @@ const ROUTES: Route[] = [ const project = resolveProject(ctx); const branch = decodeURIComponent(ctx.params.branch ?? ''); const { repo, adapter } = ctx.deps; - const tags = repo.listBranchTagsForBranch(project, branch); + const projects = aliasesFor(ctx, project); + const tags = repo.listBranchTagsForBranchAcross(projects, branch); const byId = new Map(); if (adapter && tags.length > 0) { for (const o of adapter.getObservationsByIds(tags.map((t) => t.observation_id))) { @@ -177,9 +194,9 @@ const ROUTES: Route[] = [ project, branch, timeline, - pins: repo.listPinsForBranch(project, branch).map(pinView), + pins: repo.listPinsForBranchAcross(projects, branch).map(pinView), graduated: repo - .listGraduated(project) + .listGraduatedAcross(projects) .filter((g) => g.graduated_from_branch === branch) .map(graduatedView), }); @@ -192,7 +209,7 @@ const ROUTES: Route[] = [ const project = resolveProject(ctx); sendJson(res, 200, { project, - pins: ctx.deps.repo.listPinsForProject(project).map(pinView), + pins: ctx.deps.repo.listPinsForProjectAcross(aliasesFor(ctx, project)).map(pinView), }); }, }, @@ -203,7 +220,7 @@ const ROUTES: Route[] = [ const project = resolveProject(ctx); sendJson(res, 200, { project, - graduated: ctx.deps.repo.listGraduated(project).map(graduatedView), + graduated: ctx.deps.repo.listGraduatedAcross(aliasesFor(ctx, project)).map(graduatedView), }); }, }, @@ -252,7 +269,7 @@ const ROUTES: Route[] = [ if (ctx.deps.adapter) { const hits = searchBranchContext( { adapter: ctx.deps.adapter, repo: ctx.deps.repo }, - { query, project, branch, k, nowEpoch: ctx.deps.now() }, + { query, projects: aliasesFor(ctx, project), branch, k, nowEpoch: ctx.deps.now() }, ).map((h) => ({ observation_id: h.observation.id, title: h.observation.title, diff --git a/src/web/server.ts b/src/web/server.ts index 7a7a79a..4c02b28 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { ClaudeMemAdapter } from '../adapter/claude-mem.js'; import { diagnoseClaudeMem } from '../adapter/preflight.js'; import { log, logError } from '../log/logger.js'; +import { resolveProjectIdentity } from '../store/aliases.js'; import { migrate } from '../store/migrate.js'; import { TreMemRepo } from '../store/repo.js'; import { VERSION } from '../version.js'; @@ -160,11 +161,14 @@ export async function runWebServer(opts: RunWebOptions = {}): Promise Math.floor(Date.now() / 1000), diff --git a/src/web/types.ts b/src/web/types.ts index aac1aae..efeed23 100644 --- a/src/web/types.ts +++ b/src/web/types.ts @@ -11,6 +11,10 @@ export interface WebDeps { adapter: ClaudeMemAdapter | null; cwd: string; project: string; + /** Canonical git remote slug (null when no origin / cross-clone disabled). */ + remote: string | null; + /** Cross-clone alias set to union reads over (always includes `project`). */ + aliases: string[]; staticDir: string; version: string; now: () => number; diff --git a/test/adapter.test.ts b/test/adapter.test.ts index 132ed7c..77836a9 100644 --- a/test/adapter.test.ts +++ b/test/adapter.test.ts @@ -96,7 +96,7 @@ describe('ClaudeMemAdapter', () => { it('returns observations for a project ordered by created_at_epoch DESC', () => { const adapter = new ClaudeMemAdapter({ dbPath }); try { - const rows = adapter.getObservations({ project: 'proj-a' }); + const rows = adapter.getObservations({ projects: ['proj-a'] }); expect(rows.map((r) => r.id)).toEqual([3, 2, 1]); expect(rows[0]).toMatchObject({ project: 'proj-a', @@ -111,17 +111,17 @@ describe('ClaudeMemAdapter', () => { it('filters observations by sinceEpoch / untilEpoch and limit', () => { const adapter = new ClaudeMemAdapter({ dbPath }); try { - const since = adapter.getObservations({ project: 'proj-a', sinceEpoch: 2000 }); + const since = adapter.getObservations({ projects: ['proj-a'], sinceEpoch: 2000 }); expect(since.map((r) => r.id)).toEqual([3, 2]); const range = adapter.getObservations({ - project: 'proj-a', + projects: ['proj-a'], sinceEpoch: 1000, untilEpoch: 2000, }); expect(range.map((r) => r.id)).toEqual([2, 1]); - const limited = adapter.getObservations({ project: 'proj-a', limit: 1 }); + const limited = adapter.getObservations({ projects: ['proj-a'], limit: 1 }); expect(limited.map((r) => r.id)).toEqual([3]); } finally { adapter.close(); @@ -131,7 +131,7 @@ describe('ClaudeMemAdapter', () => { it('scopes observations by project', () => { const adapter = new ClaudeMemAdapter({ dbPath }); try { - const rows = adapter.getObservations({ project: 'proj-b' }); + const rows = adapter.getObservations({ projects: ['proj-b'] }); expect(rows.map((r) => r.id)).toEqual([4]); } finally { adapter.close(); @@ -141,7 +141,7 @@ describe('ClaudeMemAdapter', () => { it('returns session_summaries scoped by project, newest first', () => { const adapter = new ClaudeMemAdapter({ dbPath }); try { - const rows = adapter.getSessionSummaries({ project: 'proj-a' }); + const rows = adapter.getSessionSummaries({ projects: ['proj-a'] }); expect(rows.map((r) => r.id)).toEqual([2, 1]); expect(rows[0]).toMatchObject({ request: 'second request', project: 'proj-a' }); } finally { @@ -152,7 +152,7 @@ describe('ClaudeMemAdapter', () => { it('returns pending_messages joined with sdk_sessions for project + cwd', () => { const adapter = new ClaudeMemAdapter({ dbPath }); try { - const rows = adapter.getPendingMessages({ project: 'proj-a' }); + const rows = adapter.getPendingMessages({ projects: ['proj-a'] }); expect(rows.map((r) => r.id)).toEqual([2, 1]); expect(rows[0]).toMatchObject({ project: 'proj-a', @@ -198,7 +198,7 @@ describe('ClaudeMemAdapter', () => { const fresh = new ClaudeMemAdapter({ dbPath }); try { expect( - fresh.getObservations({ project: 'proj-a', sinceEpoch: 9999 }).map((r) => r.id), + fresh.getObservations({ projects: ['proj-a'], sinceEpoch: 9999 }).map((r) => r.id), ).toEqual([999]); } finally { fresh.close(); diff --git a/test/git-remote.test.ts b/test/git-remote.test.ts new file mode 100644 index 0000000..4a32dff --- /dev/null +++ b/test/git-remote.test.ts @@ -0,0 +1,82 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { simpleGit } from 'simple-git'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { canonicalizeRemoteUrl, remoteSlug } from '../src/git/remote.js'; + +describe('canonicalizeRemoteUrl', () => { + const cases: Array<[string, string | null]> = [ + // ssh scp short form + ['git@github.com:rumitvn/tre-mem.git', 'github.com/rumitvn/tre-mem'], + ['git@github.com:rumitvn/tre-mem', 'github.com/rumitvn/tre-mem'], + // ssh url form + ['ssh://git@github.com/rumitvn/tre-mem.git', 'github.com/rumitvn/tre-mem'], + ['ssh://git@github.com:22/rumitvn/tre-mem.git', 'github.com/rumitvn/tre-mem'], + // https + ['https://github.com/rumitvn/tre-mem.git', 'github.com/rumitvn/tre-mem'], + ['https://github.com/rumitvn/tre-mem', 'github.com/rumitvn/tre-mem'], + ['http://github.com/rumitvn/tre-mem.git', 'github.com/rumitvn/tre-mem'], + // https with embedded credentials (must be stripped) + ['https://user:token@github.com/rumitvn/tre-mem.git', 'github.com/rumitvn/tre-mem'], + [ + 'https://x-access-token:ghp_abc123@github.com/rumitvn/tre-mem.git', + 'github.com/rumitvn/tre-mem', + ], + // git protocol + ['git://github.com/rumitvn/tre-mem.git', 'github.com/rumitvn/tre-mem'], + // case: host lowercased, whole slug lowercased + ['https://GitHub.com/RumitVN/Tre-Mem.git', 'github.com/rumitvn/tre-mem'], + // trailing slash + ['https://github.com/rumitvn/tre-mem/', 'github.com/rumitvn/tre-mem'], + // self-hosted gitlab with nested groups + ['git@gitlab.example.com:team/sub/app.git', 'gitlab.example.com/team/sub/app'], + // garbage / empty + ['', null], + [' ', null], + ]; + + for (const [input, expected] of cases) { + it(`normalizes ${JSON.stringify(input)} -> ${JSON.stringify(expected)}`, () => { + expect(canonicalizeRemoteUrl(input)).toBe(expected); + }); + } +}); + +describe('remoteSlug', () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'tre-mem-remote-')); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns null for a non-existent directory', async () => { + expect(await remoteSlug(join(tmp, 'nope'))).toBeNull(); + }); + + it('returns null for a repo without an origin remote', async () => { + await initRepo(tmp); + expect(await remoteSlug(tmp)).toBeNull(); + }); + + it('returns the canonical slug of origin', async () => { + await initRepo(tmp); + await simpleGit(tmp).addRemote('origin', 'git@github.com:rumitvn/tre-mem.git'); + expect(await remoteSlug(tmp)).toBe('github.com/rumitvn/tre-mem'); + }); +}); + +async function initRepo(cwd: string): Promise { + const git = simpleGit(cwd); + await git.init(['--initial-branch', 'main']); + await git.addConfig('user.email', 'test@example.com', false, 'local'); + await git.addConfig('user.name', 'Test User', false, 'local'); + writeFileSync(join(cwd, 'README'), 'seed\n'); + await git.add('README'); + await git.commit('seed'); +} diff --git a/test/git-watcher.test.ts b/test/git-watcher.test.ts index e246a06..a788fd4 100644 --- a/test/git-watcher.test.ts +++ b/test/git-watcher.test.ts @@ -44,6 +44,7 @@ describe('GitWatcher', () => { project: 'workrepo', current_branch: 'main', updated_at_epoch: 5000, + remote: null, }); }); diff --git a/test/mcp-export-crossclone.test.ts b/test/mcp-export-crossclone.test.ts new file mode 100644 index 0000000..e7cedbf --- /dev/null +++ b/test/mcp-export-crossclone.test.ts @@ -0,0 +1,253 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { simpleGit } from 'simple-git'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + exportMemory, + getBranchContext, + getShareStatus, + graduateFact, + type ToolDeps, +} from '../src/mcp/tools.js'; +import { migrate } from '../src/store/migrate.js'; +import { TreMemRepo } from '../src/store/repo.js'; + +const NOW = 10_000_000; + +async function initRepo(cwd: string): Promise { + const git = simpleGit(cwd); + await git.init(['--initial-branch', 'main']); + await git.addConfig('user.email', 'test@example.com', false, 'local'); + await git.addConfig('user.name', 'Test User', false, 'local'); + writeFileSync(join(cwd, 'README'), 'seed\n'); + await git.add('README'); + await git.commit('seed'); +} + +describe('export_memory MCP tool', () => { + let tmp: string; + let repo: TreMemRepo; + let deps: ToolDeps; + + beforeEach(async () => { + tmp = mkdtempSync(join(tmpdir(), 'tre-mem-export-')); + await initRepo(tmp); + migrate(join(tmp, 'tre-mem.db')); + repo = new TreMemRepo({ dbPath: join(tmp, 'tre-mem.db') }); + deps = { + adapter: null, + repo, + defaultCwd: tmp, + resolveBranch: async () => 'main', + now: () => NOW, + }; + }); + + afterEach(() => { + repo.close(); + rmSync(tmp, { recursive: true, force: true }); + }); + + it('writes .tre-mem/ files and commits locally without pushing', async () => { + repo.addPin({ + project: 'proj', + branch: 'main', + observation_id: 1, + note: 'a decision', + created_at_epoch: NOW, + }); + + const res = await exportMemory(deps, { project: 'proj', branch: 'main' }); + + expect(res.total_added).toBe(1); + expect(res.files.length).toBeGreaterThan(0); + expect(res.files.every((f) => existsSync(f))).toBe(true); + expect(res.committed).toBe(true); + expect(res.pushed).toBe(false); + // No remote configured → hint is the upstream-creating push command. + expect(res.commit_hint).toBe('git push -u origin main'); + + // A commit actually landed and nothing was pushed (no remote exists). + const log = await simpleGit(tmp).log(); + expect(log.latest?.message).toContain('export'); + }); + + it('is fail-closed on secrets: returns categories, writes nothing', async () => { + const secret = `sk-ant-${'a'.repeat(28)}`; + repo.addPin({ + project: 'proj', + branch: 'main', + observation_id: 2, + note: secret, + created_at_epoch: NOW, + }); + + const res = await exportMemory(deps, { project: 'proj', branch: 'main' }); + + expect(res.total_added).toBe(0); + expect(res.files).toEqual([]); + expect(res.committed).toBe(false); + expect(res.redaction_blocked?.categories).toContain('anthropic-key'); + // The raw secret must never leak through the tool response. + expect(JSON.stringify(res)).not.toContain(secret); + }); +}); + +describe('get_share_status MCP tool', () => { + let tmp: string; + let repo: TreMemRepo; + let deps: ToolDeps; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'tre-mem-share-')); + migrate(join(tmp, 'tre-mem.db')); + repo = new TreMemRepo({ dbPath: join(tmp, 'tre-mem.db') }); + deps = { + adapter: null, + repo, + defaultCwd: tmp, + resolveBranch: async () => 'main', + now: () => NOW, + }; + }); + + afterEach(() => { + repo.close(); + rmSync(tmp, { recursive: true, force: true }); + }); + + it('reports pending vs shared pins and graduated counts', () => { + repo.addPin({ + project: 'proj', + branch: 'main', + observation_id: 1, + note: 'x', + created_at_epoch: NOW, + }); + repo.graduateFact({ + project: 'proj', + observation_id: 9, + graduated_from_branch: 'main', + graduated_at_epoch: NOW, + }); + + const res = getShareStatus(deps, { project: 'proj' }); + expect(res).toMatchObject({ + project: 'proj', + pending_export: 1, + shared_pins: 0, + total_pins: 1, + graduated: 1, + }); + }); +}); + +describe('graduate_fact share hint', () => { + it('includes a hint nudging export_memory', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'tre-mem-grad-')); + migrate(join(tmp, 'tre-mem.db')); + const repo = new TreMemRepo({ dbPath: join(tmp, 'tre-mem.db') }); + try { + const deps: ToolDeps = { + adapter: null, + repo, + defaultCwd: tmp, + resolveBranch: async () => 'main', + now: () => NOW, + }; + const res = await graduateFact(deps, { observation_id: 5, project: 'proj', branch: 'main' }); + expect(res.hint).toContain('export_memory'); + } finally { + repo.close(); + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + +describe('cross-clone memory via injected remote', () => { + let tmp: string; + let repo: TreMemRepo; + const remote = 'github.com/org/app'; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'tre-mem-xc-')); + migrate(join(tmp, 'tre-mem.db')); + repo = new TreMemRepo({ dbPath: join(tmp, 'tre-mem.db') }); + }); + + afterEach(() => { + repo.close(); + rmSync(tmp, { recursive: true, force: true }); + }); + + function deps(cwd: string, crossRemote: string | null): ToolDeps { + return { + adapter: null, + repo, + defaultCwd: cwd, + resolveBranch: async () => 'main', + resolveRemote: async () => crossRemote, + now: () => NOW, + }; + } + + it('surfaces a pin made in clone A from clone B (same remote)', async () => { + // Register both clones under the same remote. + repo.upsertBranchState({ + cwd: '/clones/app', + project: 'app', + current_branch: 'main', + updated_at_epoch: NOW, + remote, + }); + repo.upsertBranchState({ + cwd: '/clones/app-2', + project: 'app-2', + current_branch: 'main', + updated_at_epoch: NOW, + remote, + }); + // Pin lives under clone A's project label. + repo.addPin({ + project: 'app', + branch: 'main', + observation_id: null, + note: 'shared decision X', + created_at_epoch: NOW, + }); + + // Query from clone B. + const res = await getBranchContext(deps('/clones/app-2', remote), { + query: 'decision', + branch: 'main', + }); + expect(res.project).toBe('app-2'); + const titles = res.hits.map((h) => h.title ?? ''); + expect(titles.some((t) => t.includes('shared decision X'))).toBe(true); + }); + + it('isolates clones when cross-clone is disabled (no remote)', async () => { + repo.upsertBranchState({ + cwd: '/clones/app', + project: 'app', + current_branch: 'main', + updated_at_epoch: NOW, + remote, + }); + repo.addPin({ + project: 'app', + branch: 'main', + observation_id: null, + note: 'shared decision X', + created_at_epoch: NOW, + }); + + const res = await getBranchContext(deps('/clones/app-2', null), { + query: 'decision', + branch: 'main', + }); + expect(res.hits.some((h) => (h.title ?? '').includes('shared decision X'))).toBe(false); + }); +}); diff --git a/test/mcp-no-claudemem.test.ts b/test/mcp-no-claudemem.test.ts index cdda3cc..bb1d493 100644 --- a/test/mcp-no-claudemem.test.ts +++ b/test/mcp-no-claudemem.test.ts @@ -67,8 +67,8 @@ describe('MCP tools in shared-memory-only mode (adapter: null)', () => { expect(top?.title).toContain('stripe'); }); - it('list_branches works without an adapter', () => { - const res = listBranches(deps(), { project: 'demo' }); + it('list_branches works without an adapter', async () => { + const res = await listBranches(deps(), { project: 'demo' }); expect(res.branches.map((b) => b.branch)).toContain('feature/pay'); }); diff --git a/test/mcp-tools.test.ts b/test/mcp-tools.test.ts index 250ab84..53e9251 100644 --- a/test/mcp-tools.test.ts +++ b/test/mcp-tools.test.ts @@ -53,13 +53,15 @@ describe('MCP tools', () => { rmSync(tmp, { recursive: true, force: true }); }); - it('exposes exactly 5 tool definitions with required fields', () => { + it('exposes exactly 7 tool definitions with required fields', () => { expect(TOOL_DEFINITIONS.map((t) => t.name)).toEqual([ 'get_branch_context', 'get_branch_timeline', 'list_branches', 'pin_fact', 'graduate_fact', + 'export_memory', + 'get_share_status', ]); for (const t of TOOL_DEFINITIONS) { expect(t.description.length).toBeGreaterThan(0); @@ -100,8 +102,8 @@ describe('MCP tools', () => { expect(result.entries[0]!.tagged_at_epoch).toBeGreaterThan(result.entries[1]!.tagged_at_epoch); }); - it('list_branches: groups tag counts per branch', () => { - const result = listBranches(deps, { project: PROJECT }); + it('list_branches: groups tag counts per branch', async () => { + const result = await listBranches(deps, { project: PROJECT }); const map = new Map(result.branches.map((b) => [b.branch, b.count])); expect(map.get(FEATURE)).toBe(2); expect(map.get(FIX)).toBe(1); diff --git a/test/migrate.test.ts b/test/migrate.test.ts index 5c7090a..ec2faa9 100644 --- a/test/migrate.test.ts +++ b/test/migrate.test.ts @@ -24,7 +24,7 @@ describe('migrate', () => { expect(result.fromVersion).toBe(0); expect(result.toVersion).toBe(CURRENT_SCHEMA_VERSION); - expect(result.applied).toEqual([1, 2]); + expect(result.applied).toEqual([1, 2, 3]); const db = new Database(dbPath, { readonly: true }); try { @@ -48,6 +48,7 @@ describe('migrate', () => { 'idx_branch_pin_branch', 'idx_graduated_project', 'idx_branch_state_project', + 'idx_branch_state_remote', ]), ); @@ -130,8 +131,8 @@ describe('migrate', () => { const result = migrate(dbPath); expect(result.fromVersion).toBe(1); - expect(result.toVersion).toBe(2); - expect(result.applied).toEqual([2]); + expect(result.toVersion).toBe(3); + expect(result.applied).toEqual([2, 3]); const verify = new Database(dbPath, { readonly: true }); try { @@ -151,6 +152,87 @@ describe('migrate', () => { } }); + it('upgrades a v2 database to v3 adding branch_state.remote without data loss', () => { + // Simulate a v2 install: apply v1+v2 schema, seed a branch_state row. + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.exec(` + CREATE TABLE schema_versions (version INTEGER PRIMARY KEY, applied_at_epoch INTEGER NOT NULL); + CREATE TABLE branch_state ( + cwd TEXT PRIMARY KEY, project TEXT NOT NULL, current_branch TEXT NOT NULL, + updated_at_epoch INTEGER NOT NULL + ); + CREATE TABLE branch_pin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL, branch TEXT NOT NULL, + observation_id INTEGER, note TEXT, created_at_epoch INTEGER NOT NULL, + content_hash TEXT, shared_at_epoch INTEGER, title TEXT, body TEXT + ); + CREATE TABLE graduated ( + id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL, observation_id INTEGER NOT NULL, + graduated_from_branch TEXT NOT NULL, graduated_at_epoch INTEGER NOT NULL, + content_hash TEXT, shared_at_epoch INTEGER, title TEXT, body TEXT, + UNIQUE(project, observation_id) + ); + INSERT INTO schema_versions (version, applied_at_epoch) VALUES (1, 0), (2, 0); + INSERT INTO branch_state (cwd, project, current_branch, updated_at_epoch) + VALUES ('/repo', 'app', 'main', 100); + `); + db.close(); + + const result = migrate(dbPath); + expect(result.fromVersion).toBe(2); + expect(result.toVersion).toBe(3); + expect(result.applied).toEqual([3]); + + const verify = new Database(dbPath, { readonly: true }); + try { + expect(columnNames(verify, 'branch_state')).toEqual(expect.arrayContaining(['remote'])); + const row = verify + .prepare('SELECT project, remote FROM branch_state WHERE cwd = ?') + .get('/repo') as { project: string; remote: string | null }; + expect(row.project).toBe('app'); + expect(row.remote).toBeNull(); + } finally { + verify.close(); + } + }); + + it('self-heals a db recorded at v3 before the remote column existed', () => { + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.exec(` + CREATE TABLE schema_versions (version INTEGER PRIMARY KEY, applied_at_epoch INTEGER NOT NULL); + CREATE TABLE branch_state ( + cwd TEXT PRIMARY KEY, project TEXT NOT NULL, current_branch TEXT NOT NULL, + updated_at_epoch INTEGER NOT NULL + ); + CREATE TABLE branch_pin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL, branch TEXT NOT NULL, + observation_id INTEGER, note TEXT, created_at_epoch INTEGER NOT NULL, + content_hash TEXT, shared_at_epoch INTEGER, title TEXT, body TEXT + ); + CREATE TABLE graduated ( + id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL, observation_id INTEGER NOT NULL, + graduated_from_branch TEXT NOT NULL, graduated_at_epoch INTEGER NOT NULL, + content_hash TEXT, shared_at_epoch INTEGER, title TEXT, body TEXT, + UNIQUE(project, observation_id) + ); + INSERT INTO schema_versions (version, applied_at_epoch) VALUES (1, 0), (2, 0), (3, 0); + `); + db.close(); + + const result = migrate(dbPath); + expect(result.toVersion).toBe(3); + expect(result.applied).toEqual([]); // version unchanged — column reconciled in place + + const verify = new Database(dbPath, { readonly: true }); + try { + expect(columnNames(verify, 'branch_state')).toEqual(expect.arrayContaining(['remote'])); + } finally { + verify.close(); + } + }); + it('self-heals a db recorded at v2 before title/body columns existed', () => { // Reproduce a device migrated to v2 by an early build: schema_versions=2 but // branch_pin/graduated only have the first-wave sync columns, no title/body. @@ -176,8 +258,8 @@ describe('migrate', () => { const result = migrate(dbPath); expect(result.fromVersion).toBe(2); - expect(result.toVersion).toBe(2); - expect(result.applied).toEqual([]); // version unchanged — columns reconciled in place + expect(result.toVersion).toBe(3); + expect(result.applied).toEqual([3]); // title/body reconciled in place; v3 column added const verify = new Database(dbPath, { readonly: true }); try { diff --git a/test/repo.test.ts b/test/repo.test.ts index 2fb526b..cba6f2c 100644 --- a/test/repo.test.ts +++ b/test/repo.test.ts @@ -326,3 +326,159 @@ describe('TreMemRepo graduated', () => { expect(out.map((g) => g.observation_id)).toEqual([2, 1]); }); }); + +describe('TreMemRepo cross-clone aliases', () => { + let tmp: string; + let repo: TreMemRepo; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'tre-mem-alias-')); + const dbPath = join(tmp, 'tre-mem.db'); + migrate(dbPath); + repo = new TreMemRepo({ dbPath }); + }); + + afterEach(() => { + repo.close(); + rmSync(tmp, { recursive: true, force: true }); + }); + + it('upsertBranchState round-trips the remote column', () => { + repo.upsertBranchState({ + cwd: '/a/app', + project: 'app', + current_branch: 'main', + updated_at_epoch: 1, + remote: 'github.com/org/app', + }); + expect(repo.getBranchState('/a/app')?.remote).toBe('github.com/org/app'); + }); + + it('defaults remote to null when omitted', () => { + repo.upsertBranchState({ + cwd: '/a/app', + project: 'app', + current_branch: 'main', + updated_at_epoch: 1, + }); + expect(repo.getBranchState('/a/app')?.remote).toBeNull(); + }); + + it('projectAliases returns [project] when remote is null/empty', () => { + expect(repo.projectAliases('app', null)).toEqual(['app']); + expect(repo.projectAliases('app', '')).toEqual(['app']); + expect(repo.projectAliases('app', undefined)).toEqual(['app']); + }); + + it('projectAliases unions clones sharing a remote and isolates others', () => { + const r = 'github.com/org/app'; + repo.upsertBranchState({ + cwd: '/a/app', + project: 'app', + current_branch: 'main', + updated_at_epoch: 1, + remote: r, + }); + repo.upsertBranchState({ + cwd: '/a/app-2', + project: 'app-2', + current_branch: 'dev', + updated_at_epoch: 1, + remote: r, + }); + repo.upsertBranchState({ + cwd: '/a/other', + project: 'other', + current_branch: 'main', + updated_at_epoch: 1, + remote: 'github.com/org/other', + }); + + expect(repo.projectAliases('app', r)).toEqual(['app', 'app-2']); + expect(repo.projectAliases('other', 'github.com/org/other')).toEqual(['other']); + }); + + it('setRemoteForCwd updates an existing row and remoteForProject reads it back', () => { + repo.upsertBranchState({ + cwd: '/a/app', + project: 'app', + current_branch: 'main', + updated_at_epoch: 1, + }); + expect(repo.remoteForProject('app')).toBeNull(); + repo.setRemoteForCwd('/a/app', 'github.com/org/app'); + expect(repo.getBranchState('/a/app')?.remote).toBe('github.com/org/app'); + expect(repo.remoteForProject('app')).toBe('github.com/org/app'); + }); + + it('setRemoteForCwd is a no-op when the row does not exist', () => { + expect(() => repo.setRemoteForCwd('/nope', 'github.com/org/app')).not.toThrow(); + expect(repo.getBranchState('/nope')).toBeNull(); + }); + + it('projectAliases always includes the current project even if unregistered', () => { + const r = 'github.com/org/app'; + repo.upsertBranchState({ + cwd: '/a/app-2', + project: 'app-2', + current_branch: 'dev', + updated_at_epoch: 1, + remote: r, + }); + expect(repo.projectAliases('app', r)).toEqual(['app', 'app-2']); + }); + + it('*Across methods union pins, graduated and tags across projects', () => { + repo.addPin({ + project: 'app', + branch: 'feat', + observation_id: 1, + note: 'a', + created_at_epoch: 10, + }); + repo.addPin({ + project: 'app-2', + branch: 'feat', + observation_id: 2, + note: 'b', + created_at_epoch: 20, + }); + repo.graduateFact({ + project: 'app', + observation_id: 1, + graduated_from_branch: 'feat', + graduated_at_epoch: 10, + }); + repo.graduateFact({ + project: 'app-2', + observation_id: 2, + graduated_from_branch: 'feat', + graduated_at_epoch: 20, + }); + repo.upsertBranchTag({ + observation_id: 1, + project: 'app', + branch: 'feat', + tagged_at_epoch: 10, + source: 'manual', + }); + repo.upsertBranchTag({ + observation_id: 2, + project: 'app-2', + branch: 'feat', + tagged_at_epoch: 20, + source: 'manual', + }); + + const union = ['app', 'app-2']; + expect(repo.listPinsForBranchAcross(union, 'feat').map((p) => p.observation_id)).toEqual([ + 2, 1, + ]); + expect(repo.listGraduatedAcross(union).map((g) => g.observation_id)).toEqual([2, 1]); + expect(repo.countBranchTagsAcross(union, 'feat')).toBe(2); + expect(repo.listBranchesForProjectAcross(union)).toEqual([{ branch: 'feat', count: 2 }]); + + // single-element array == old single-project behavior (regression guard) + expect(repo.listPinsForBranchAcross(['app'], 'feat').map((p) => p.observation_id)).toEqual([1]); + }); +}); diff --git a/test/retrieval-search.test.ts b/test/retrieval-search.test.ts index 4becddf..7248f5f 100644 --- a/test/retrieval-search.test.ts +++ b/test/retrieval-search.test.ts @@ -39,7 +39,7 @@ describe('searchBranchContext', () => { { adapter, repo }, { query: 'stripe webhook', - project: 'proj-a', + projects: ['proj-a'], branch: 'feature/payment', k: 5, nowEpoch: NOW, @@ -62,7 +62,7 @@ describe('searchBranchContext', () => { { adapter, repo }, { query: 'stripe', - project: 'proj-a', + projects: ['proj-a'], branch: 'feature/payment', k: 5, nowEpoch: NOW, @@ -77,7 +77,7 @@ describe('searchBranchContext', () => { { adapter, repo }, { query: 'anything', - project: 'proj-empty', + projects: ['proj-empty'], branch: 'main', k: 5, nowEpoch: NOW, @@ -102,7 +102,7 @@ describe('searchBranchContext', () => { const hits = searchBranchContext( { adapter, repo }, - { query: 'anything', project: 'proj-a', branch: 'feature/payment', k: 10, nowEpoch: NOW }, + { query: 'anything', projects: ['proj-a'], branch: 'feature/payment', k: 10, nowEpoch: NOW }, ); const shared = hits.find((h) => h.observation.id === 9001); expect(shared).toBeDefined(); @@ -121,7 +121,7 @@ describe('searchBranchContext', () => { }); const hits = searchBranchContext( { adapter, repo }, - { query: 'anything', project: 'proj-a', branch: 'feature/payment', k: 10, nowEpoch: NOW }, + { query: 'anything', projects: ['proj-a'], branch: 'feature/payment', k: 10, nowEpoch: NOW }, ); const freeText = hits.find((h) => h.source === 'shared-pin' && h.observation.id < 0); expect(freeText).toBeDefined(); @@ -141,7 +141,13 @@ describe('searchBranchContext', () => { }); const hits = searchBranchContext( { adapter, repo }, - { query: 'anything', project: 'proj-a', branch: 'some/other-branch', k: 10, nowEpoch: NOW }, + { + query: 'anything', + projects: ['proj-a'], + branch: 'some/other-branch', + k: 10, + nowEpoch: NOW, + }, ); const grad = hits.find((h) => h.observation.id === 9002); expect(grad).toBeDefined(); diff --git a/test/retrieval-semantic.test.ts b/test/retrieval-semantic.test.ts index 9ea55ed..e877c16 100644 --- a/test/retrieval-semantic.test.ts +++ b/test/retrieval-semantic.test.ts @@ -26,7 +26,7 @@ describe('Fts5SemanticSearcher', () => { it('returns observations matching a single token, scoped to project', () => { const searcher = new Fts5SemanticSearcher(adapter); - const hits = searcher.search({ query: 'stripe', project: 'proj-a', k: 10 }); + const hits = searcher.search({ query: 'stripe', projects: ['proj-a'], k: 10 }); const ids = hits.map((h) => h.observationId).sort((a, b) => a - b); expect(ids).toEqual([1, 3]); for (const h of hits) { @@ -37,7 +37,7 @@ describe('Fts5SemanticSearcher', () => { it('orders results by similarity DESC (best match first)', () => { const searcher = new Fts5SemanticSearcher(adapter); - const hits = searcher.search({ query: 'stripe webhook', project: 'proj-a', k: 10 }); + const hits = searcher.search({ query: 'stripe webhook', projects: ['proj-a'], k: 10 }); expect(hits.length).toBeGreaterThanOrEqual(2); for (let i = 1; i < hits.length; i++) { expect(hits[i - 1]!.similarity).toBeGreaterThanOrEqual(hits[i]!.similarity); @@ -47,24 +47,24 @@ describe('Fts5SemanticSearcher', () => { it('respects the k limit', () => { const searcher = new Fts5SemanticSearcher(adapter); - const hits = searcher.search({ query: 'auth', project: 'proj-a', k: 1 }); + const hits = searcher.search({ query: 'auth', projects: ['proj-a'], k: 1 }); expect(hits).toHaveLength(1); }); it('returns an empty array for queries with no matches', () => { const searcher = new Fts5SemanticSearcher(adapter); - expect(searcher.search({ query: 'kubernetes', project: 'proj-a', k: 10 })).toEqual([]); + expect(searcher.search({ query: 'kubernetes', projects: ['proj-a'], k: 10 })).toEqual([]); }); it('returns an empty array for a blank query (no FTS injection)', () => { const searcher = new Fts5SemanticSearcher(adapter); - expect(searcher.search({ query: ' ', project: 'proj-a', k: 10 })).toEqual([]); + expect(searcher.search({ query: ' ', projects: ['proj-a'], k: 10 })).toEqual([]); }); it('isolates results to the requested project', () => { const searcher = new Fts5SemanticSearcher(adapter); - const hitsA = searcher.search({ query: 'stripe', project: 'proj-a', k: 10 }); - const hitsB = searcher.search({ query: 'stripe', project: 'proj-b', k: 10 }); + const hitsA = searcher.search({ query: 'stripe', projects: ['proj-a'], k: 10 }); + const hitsB = searcher.search({ query: 'stripe', projects: ['proj-b'], k: 10 }); expect(hitsA.every((h) => h.observationId !== 4)).toBe(true); expect(hitsB.map((h) => h.observationId)).toEqual([4]); }); @@ -74,7 +74,7 @@ describe('Fts5SemanticSearcher', () => { expect(() => searcher.search({ query: 'stripe AND OR "*foo" NEAR(', - project: 'proj-a', + projects: ['proj-a'], k: 5, }), ).not.toThrow(); diff --git a/test/session-start-hook.test.ts b/test/session-start-hook.test.ts index 4c34635..71958f9 100644 --- a/test/session-start-hook.test.ts +++ b/test/session-start-hook.test.ts @@ -55,6 +55,7 @@ describe('runSessionStartHook', () => { project: 'workrepo', current_branch: 'main', updated_at_epoch: 5000, + remote: null, }); }); diff --git a/test/store-aliases.test.ts b/test/store-aliases.test.ts new file mode 100644 index 0000000..5948ec3 --- /dev/null +++ b/test/store-aliases.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { crossCloneEnabled, resolveProjectIdentity } from '../src/store/aliases.js'; +import { migrate } from '../src/store/migrate.js'; +import { TreMemRepo } from '../src/store/repo.js'; + +describe('crossCloneEnabled', () => { + it('defaults on', () => { + expect(crossCloneEnabled({})).toBe(true); + expect(crossCloneEnabled({ TRE_MEM_CROSS_CLONE: '1' })).toBe(true); + }); + + it('disables on falsey flags', () => { + for (const v of ['0', 'false', 'off', 'no', 'OFF', 'False']) { + expect(crossCloneEnabled({ TRE_MEM_CROSS_CLONE: v })).toBe(false); + } + }); +}); + +describe('resolveProjectIdentity', () => { + let tmp: string; + let repo: TreMemRepo; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'tre-mem-ident-')); + const dbPath = join(tmp, 'tre-mem.db'); + migrate(dbPath); + repo = new TreMemRepo({ dbPath }); + }); + + afterEach(() => { + repo.close(); + rmSync(tmp, { recursive: true, force: true }); + }); + + const remote = 'github.com/org/app'; + + it('unions clones sharing a remote', async () => { + repo.upsertBranchState({ + cwd: '/x/app-2', + project: 'app-2', + current_branch: 'd', + updated_at_epoch: 1, + remote, + }); + const id = await resolveProjectIdentity(repo, '/x/app', { + resolveRemote: async () => remote, + }); + expect(id.project).toBe('app'); + expect(id.remote).toBe(remote); + expect(id.aliases).toEqual(['app', 'app-2']); + }); + + it('collapses to [project] when there is no remote', async () => { + const id = await resolveProjectIdentity(repo, '/x/app', { resolveRemote: async () => null }); + expect(id.aliases).toEqual(['app']); + expect(id.remote).toBeNull(); + }); + + it('collapses to [project] when cross-clone disabled', async () => { + repo.upsertBranchState({ + cwd: '/x/app-2', + project: 'app-2', + current_branch: 'd', + updated_at_epoch: 1, + remote, + }); + const id = await resolveProjectIdentity(repo, '/x/app', { + resolveRemote: async () => remote, + crossClone: false, + }); + expect(id.aliases).toEqual(['app']); + }); + + it('explicit project override bypasses the union', async () => { + repo.upsertBranchState({ + cwd: '/x/app-2', + project: 'app-2', + current_branch: 'd', + updated_at_epoch: 1, + remote, + }); + const id = await resolveProjectIdentity(repo, '/x/app', { + project: 'custom', + resolveRemote: async () => remote, + }); + expect(id.project).toBe('custom'); + expect(id.aliases).toEqual(['custom']); + }); +}); diff --git a/test/web-api.test.ts b/test/web-api.test.ts index c1281d0..0aaf68a 100644 --- a/test/web-api.test.ts +++ b/test/web-api.test.ts @@ -64,6 +64,8 @@ describe('web API (shared-memory-only, adapter absent)', () => { adapter: null, cwd: tmp, project: PROJECT, + remote: null, + aliases: [PROJECT], staticDir: join(tmp, 'no-static'), version: '9.9.9', now: () => 1000, diff --git a/test/web-degrade-no-claudemem.test.ts b/test/web-degrade-no-claudemem.test.ts index a37dd6b..d1da0f3 100644 --- a/test/web-degrade-no-claudemem.test.ts +++ b/test/web-degrade-no-claudemem.test.ts @@ -63,6 +63,8 @@ describe('web degrade — no claude-mem adapter', () => { adapter: null, cwd: tmp, project: PROJECT, + remote: null, + aliases: [PROJECT], staticDir: join(tmp, 'no-static'), version: '0.0.0', now: () => 1000, diff --git a/test/web-grove.test.ts b/test/web-grove.test.ts index 071889b..5e2010b 100644 --- a/test/web-grove.test.ts +++ b/test/web-grove.test.ts @@ -159,6 +159,8 @@ describe('web API — grove endpoints', () => { adapter: null, cwd: tmp, project: PROJECT, + remote: null, + aliases: [PROJECT], staticDir: join(tmp, 'no-static'), version: '9.9.9', now: () => 1000, diff --git a/test/web-sse.test.ts b/test/web-sse.test.ts index bc4062d..0307be6 100644 --- a/test/web-sse.test.ts +++ b/test/web-sse.test.ts @@ -90,6 +90,8 @@ describe('GET /api/events live stream', () => { adapter: null, cwd: tmp, project: 'demo', + remote: null, + aliases: ['demo'], staticDir: join(tmp, 'no-static'), version: '0.0.0', now: () => 1, diff --git a/test/web-static.test.ts b/test/web-static.test.ts index d3fab68..a203c0a 100644 --- a/test/web-static.test.ts +++ b/test/web-static.test.ts @@ -34,6 +34,8 @@ describe('static serving + SPA fallback', () => { adapter: null, cwd: tmp, project: 'demo', + remote: null, + aliases: ['demo'], staticDir, version: '0.0.0', now: () => 1, diff --git a/web/app.tsx b/web/app.tsx index 9296539..af269fb 100644 --- a/web/app.tsx +++ b/web/app.tsx @@ -51,6 +51,9 @@ export function App() { }, []); const mode = health.data?.mode; + // Clones of the *selected* project (from /api/branches), falling back to the + // daemon's home project (from /api/health) before branches load. + const linkedClones = branches.data?.linked_clones ?? health.data?.linked_clones ?? []; return (
@@ -61,6 +64,14 @@ export function App() { {t('wordmark.tagline')}
+ {linkedClones.length > 1 ? ( + + {`🔗 ${linkedClones.length} clones`} + + ) : null} {mode ? {mode} : null} {projects.data && projects.data.projects.length > 1 ? (