From d68e118e168470b17233a9684b5d62cd3d5cca74 Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Mon, 12 Oct 2026 19:30:35 -0500 Subject: [PATCH 1/8] feat(mcp): sort tools into categories and explore the public directory Every agent tool now belongs to one category (Session, Memory, Explore, or Manage) and its description opens with that label. Orientation prints what each category is for, and the tool inventory and skills are reorganized around it. Only Explore tools are marked open-world. Searching is reserved for note content and the hub's public directory. keg_search, flight_search, and namespace_search are replaced by explore_kegs, explore_flights, and explore_people (and `tap explore kegs|flights|people`), backed by POST /api/v1/explore. The caller's own KEGs, flights, and namespaces are listed, never searched: keg_list now carries titles and descriptions, and namespace_list and invitation_list are always available. The REST contract revision moves to 2026-10-12, since the namespace search route is gone. --- README.md | 3 + docs/README.md | 5 +- docs/ai-coding-agents/mcp-setup.md | 24 ++- docs/ai-coding-agents/orient.md | 14 +- docs/api-compatibility.md | 10 +- docs/configuration/flights.md | 2 +- docs/development/gotchas.md | 3 +- integrations/content/agent-orient.md | 7 +- integrations/content/tool-inventory.md | 138 +++++++++++------ .../claude/tapper/skills/tapper/SKILL.md | 145 +++++++++++------- .../codex/tapper/skills/tapper/SKILL.md | 145 +++++++++++------- .../opencode/tapper/skills/tapper/SKILL.md | 145 +++++++++++------- pkg/apicontract/contract.go | 2 +- pkg/apicontract/contract_test.go | 4 +- pkg/cli/cmd_explore.go | 59 +++++++ pkg/cli/cmd_explore_test.go | 45 ++++++ pkg/cli/cmd_namespace.go | 31 +--- pkg/cli/cmd_root.go | 1 + pkg/keg/orientation_test.go | 2 +- pkg/mcp/flight_search_test.go | 24 --- pkg/mcp/orientation_cache_test.go | 2 +- pkg/mcp/providers.go | 81 +--------- pkg/mcp/public_response_test.go | 24 +-- pkg/mcp/server.go | 13 +- pkg/mcp/server_test.go | 8 +- pkg/mcp/session_bootstrap_test.go | 4 +- pkg/mcp/session_flight.go | 11 +- pkg/mcp/session_flight_test.go | 18 +-- pkg/mcp/session_transition_test.go | 66 ++++---- pkg/mcp/tool_allowlist.go | 5 +- pkg/mcp/tool_categories_test.go | 37 +++++ pkg/mcp/tools_agent.go | 20 +-- pkg/mcp/tools_agent_test.go | 25 +-- pkg/mcp/tools_auth.go | 4 +- pkg/mcp/tools_doctor.go | 2 +- pkg/mcp/tools_explore.go | 100 ++++++++++++ pkg/mcp/tools_files.go | 22 +-- pkg/mcp/tools_flight.go | 17 +- pkg/mcp/tools_flight_search.go | 90 ----------- pkg/mcp/tools_index.go | 6 +- pkg/mcp/tools_keg.go | 75 +++------ pkg/mcp/tools_lock.go | 8 +- pkg/mcp/tools_namespace.go | 38 +---- pkg/mcp/tools_orient.go | 10 +- pkg/mcp/tools_read.go | 18 +-- pkg/mcp/tools_schema.go | 12 +- pkg/mcp/tools_search.go | 3 +- pkg/mcp/tools_search_test.go | 2 +- pkg/mcp/tools_snapshot.go | 8 +- pkg/mcp/tools_write.go | 10 +- pkg/parity/parity_coverage_test.go | 15 +- pkg/tapper/compact_orientation_test.go | 6 +- pkg/tapper/hub_agents_test.go | 15 +- pkg/tapper/hub_namespaces.go | 12 -- pkg/tapper/invocation_telemetry_test.go | 2 +- pkg/tapper/launch_split_test.go | 4 +- pkg/tapper/tap_explore.go | 104 +++++++++++++ pkg/tapper/tap_namespace.go | 32 ---- pkg/tapper/tap_orient.go | 17 +- pkg/tapper/tool_categories.go | 69 +++++++++ 60 files changed, 1043 insertions(+), 781 deletions(-) create mode 100644 pkg/cli/cmd_explore.go create mode 100644 pkg/cli/cmd_explore_test.go delete mode 100644 pkg/mcp/flight_search_test.go create mode 100644 pkg/mcp/tool_categories_test.go create mode 100644 pkg/mcp/tools_explore.go delete mode 100644 pkg/mcp/tools_flight_search.go create mode 100644 pkg/tapper/tap_explore.go create mode 100644 pkg/tapper/tool_categories.go diff --git a/README.md b/README.md index 8c9691bf..cb737364 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,9 @@ tap use @acme/engineering tap use @acme/engineering --flight @acme/+release-42 ``` +`tap explore kegs|flights|people QUERY` searches the hub's public directory; +your own kegs come from `tap keg list`. + Export a keg for backup or migration: ```bash diff --git a/docs/README.md b/docs/README.md index 4130350e..9546987d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -133,7 +133,10 @@ writing KEG files directly. succeeding. A keg's version never changes after creation. Keg listings, `tap keg info`, and orientation label KEGv2 kegs, and an operation a keg's version does not offer fails with `KEG_VERSION_UNSUPPORTED`. -- `tap keg list` - list kegs visible on a hub. +- `tap keg list` - list your kegs on a hub. The list is complete; search is + only for the public directory. +- `tap explore kegs|flights|people QUERY` - search the hub's public directory. + A match grants no access. - `tap keg grant|grants|revoke` - manage per-keg access. - `tap keg visibility public|private` - set keg visibility. - `tap keg rename @namespace/old new` - rename a keg alias in its namespace. diff --git a/docs/ai-coding-agents/mcp-setup.md b/docs/ai-coding-agents/mcp-setup.md index 9c56f362..6f6e6701 100644 --- a/docs/ai-coding-agents/mcp-setup.md +++ b/docs/ai-coding-agents/mcp-setup.md @@ -192,14 +192,22 @@ vocabulary to ask. ### Discovery And Identity -| Tool | Description | -| --- | --- | -| `keg_list` | List canonical KEGs, effective roles, and winning granting flights for the live pinned-root graph by default or exactly one supplied `flight` | -| `keg_search` | Search all identity-accessible KEG refs, titles, and summaries, including KEGs outside the flight graph; results grant no operational access | -| `session_info` | Return structured credential-safe `identities[]` and exact pinned-root-context `kegs[]` | -| `namespace_list` | List the namespaces you belong to, with your role | -| `namespace_search` | Search the people and org namespaces visible on the Hub; discovery only | -| `invitation_list` | List pending invitations to namespaces and kegs addressed to you; read-only, since accepting is the user's decision | +Every tool belongs to one category, and its description opens with the +category name: **Session** (identity and what the session may do; always +available), **Memory** (reading and writing notes in the covered KEGs), +**Explore** (the Hub's public directory), or **Manage** (KEGs, settings, +schemas, repair, flights, and agents). Your own resources are listed, never +searched; search is for note content (`node_search`) and the public directory. + +| Tool | Category | Description | +| --- | --- | --- | +| `keg_list` | Memory | List canonical KEGs with effective roles, titles, descriptions, and winning granting flights for the live pinned-root graph by default or exactly one supplied `flight` | +| `session_info` | Session | Return structured credential-safe `identities[]` and exact pinned-root-context `kegs[]` | +| `namespace_list` | Session | List the namespaces you belong to, with your role | +| `invitation_list` | Session | List pending invitations to namespaces and kegs addressed to you; read-only, since accepting is the user's decision | +| `explore_kegs` | Explore | Search the Hub's public KEGs; results grant no operational access | +| `explore_flights` | Explore | Search the Hub's public flights; metadata only | +| `explore_people` | Explore | Search the people and org namespaces on the Hub; discovery only | Each identity includes only its hub locator, user ID, username, display name, default namespace, and namespace names. Tokens, email, scopes, cookies, expiry, diff --git a/docs/ai-coding-agents/orient.md b/docs/ai-coding-agents/orient.md index 12cb0bd9..e42243ae 100644 --- a/docs/ai-coding-agents/orient.md +++ b/docs/ai-coding-agents/orient.md @@ -56,8 +56,8 @@ instructions: | Snapshot any node before changing public API guidance. ``` -`summary` is the concise discovery description shown by graph orientation and -identity `keg_search`. +`summary` is the concise discovery description shown by graph orientation, +`keg_list`, and, for a public KEG, `explore_kegs`. It should help an agent decide whether the KEG is relevant and is not automatically truncated. `instructions` is targeted operational guidance. Aggregate orientation never includes it. @@ -117,11 +117,11 @@ access requires reconnecting. A failed refresh returns `SESSION_REFRESH_FAILED`, keeps the prior mode and tool surface, and reports `toolsChanged:false`. -`keg_search` is deliberately outside this authority flow. It performs a -case-insensitive literal match over canonical ref, title, and summary for all -identity-accessible KEGs and returns at most 50 canonical rows. Finding a KEG -does not change authority: no-flight calls still use the identity role, while a -real-flight call must also cover it. +`explore_kegs`, `explore_flights`, and `explore_people` are deliberately +outside this authority flow. They search only the Hub's public directory with a +literal, non-empty query and return a capped page. Finding a KEG does not change +authority: a real-flight call must still cover it. Your own KEGs come from +`keg_list`, which is complete and needs no query. ## MCP Resource diff --git a/docs/api-compatibility.md b/docs/api-compatibility.md index e6b23e4a..43503ad7 100644 --- a/docs/api-compatibility.md +++ b/docs/api-compatibility.md @@ -1,7 +1,7 @@ # REST API contract compatibility Tapper and Tapper Hub select a REST contract independently of their release -numbers. The sole supported revision is `2026-09-28`, which replaced `2026-09-11` when grep, listing, and relationship responses gained server-side projection. +numbers. The sole supported revision is `2026-10-12`, which replaced `2026-09-28` when `POST /api/v1/namespaces/search` was removed in favor of `POST /api/v1/explore`, the public directory of KEGs, flights, and people. `2026-09-28` had replaced `2026-09-11` when grep, listing, and relationship responses gained server-side projection. Change the revision for incompatible API behavior. Compatible additions and fixes retain the revision; release numbers remain diagnostic metadata. @@ -9,11 +9,11 @@ fixes retain the revision; release numbers remain diagnostic metadata. returns `Cache-Control: no-store` with the actual server build version: ```json -{"server_version":"0.24.0","api_versions":["2026-09-28"]} +{"server_version":"0.24.0","api_versions":["2026-10-12"]} ``` Every request under `/api/v1`, including event-stream handshakes and telemetry, -must send exactly one `Tapper-API-Version: 2026-09-28` header. Accepted requests +must send exactly one `Tapper-API-Version: 2026-10-12` header. Accepted requests echo it in the response. Clients send their release in `Tapper-Client-Version`. The Hub validates the contract before authentication, flight authority, or application handlers. Discovery, health, OAuth, browser routes, and hosted @@ -26,7 +26,7 @@ duplicate, comma-joined, or unsupported values return HTTP 400 with `operationPerformed: false`, for example: ```json -{"error":"unsupported REST contract; upgrade Tapper and Hub together","code":"API_VERSION_UNSUPPORTED","requested_api_versions":["2099-01-01"],"supported_api_versions":["2026-09-28"],"server_version":"0.24.0","operationPerformed":false} +{"error":"unsupported REST contract; upgrade Tapper and Hub together","code":"API_VERSION_UNSUPPORTED","requested_api_versions":["2099-01-01"],"supported_api_versions":["2026-10-12"],"server_version":"0.24.0","operationPerformed":false} ``` CLI invocations and MCP connections discover each selected Hub once, before @@ -57,7 +57,7 @@ working against the new Hub, and new clients reject legacy Hubs. Update custom scripts explicitly, for example: ```sh -curl -H 'Tapper-API-Version: 2026-09-28' \ +curl -H 'Tapper-API-Version: 2026-10-12' \ -H "Authorization: Bearer ${TAPPER_TOKEN}" \ https://hub.example.com/api/v1/whoami ``` diff --git a/docs/configuration/flights.md b/docs/configuration/flights.md index 384ad0e1..efaf6059 100644 --- a/docs/configuration/flights.md +++ b/docs/configuration/flights.md @@ -155,7 +155,7 @@ on the agent's page on Hub. - Recovery-only mode applies only when an explicitly configured root is missing, inaccessible, invalid, or unavailable. Seeing only `orient`, `session_refresh`, `flight_list`, `flight_read`, `session_info`, and - `keg_search` means configured authority failed to initialize. Repair the + `guide` means configured authority failed to initialize. Repair the configured root outside MCP, then call `session_refresh` and `orient`. - Every MCP connection pins either no-flight authority or one real root at initialization. Configuration and account-preference changes cannot change diff --git a/docs/development/gotchas.md b/docs/development/gotchas.md index 9a6c6923..260d01c4 100644 --- a/docs/development/gotchas.md +++ b/docs/development/gotchas.md @@ -32,8 +32,7 @@ Behavior that has surprised people before. KEG metadata uses `description`; legacy settings/archive `summary` is accepted only when description is absent. Flight descriptions flow through manifests, REST/MCP, editing, and hashes independently of instructions and authority. -`orient` renders full active instructions, the complete active effective KEG cover; child flights require explicit inspection. No-flight orientation is search-first. `flight_search` returns -at most 50 ordered metadata rows with a truncation notice; `guide` serves detailed +`orient` renders full active instructions, the complete active effective KEG cover; child flights require explicit inspection. No-flight orientation points to the complete `keg_list` and `flight_list`, and to the `explore_*` tools for the public directory, which return a capped page with a truncation notice; `guide` serves detailed canonical guidance by topic. Display descriptions never affect authority revisions. The coordinated REST/MCP replacement is in progress, not complete. Cover entries diff --git a/integrations/content/agent-orient.md b/integrations/content/agent-orient.md index c0125680..f4cbd83d 100644 --- a/integrations/content/agent-orient.md +++ b/integrations/content/agent-orient.md @@ -14,8 +14,9 @@ flight on subsequent operations. keg selects a target and never grants access. orient returns current active instructions, effective readable KEGs, and immediate readable children. Orient with a child reference to inspect the next -level. With no root, orientation is search-first; keg_search and flight_search -find identity-readable metadata. Discovery is independent of selection. +level. With no root, keg_list and flight_list are the complete lists of what +your identity reads, and explore_kegs and explore_flights search the public +directory. Discovery is independent of selection. flight_read inspects a permission-filtered declared manifest and its hash; it never activates a session or selects operational authority. @@ -25,7 +26,7 @@ MCP and starts a fresh connection. session_refresh only retries failed initial activation; it cannot change an active connection. A failed configured root exposes recovery tools: orient, session_refresh, -flight_list, flight_read, session_info, keg_search, flight_search, guide. Repair +flight_list, flight_read, session_info, guide. Repair the configured root outside MCP, then retry session_refresh and orient. An active flight can have no effective KEGs and still expose the full inventory; KEG denials in that scope do not imply missing tools. diff --git a/integrations/content/tool-inventory.md b/integrations/content/tool-inventory.md index 855d6d8e..079cafe3 100644 --- a/integrations/content/tool-inventory.md +++ b/integrations/content/tool-inventory.md @@ -1,12 +1,28 @@ # Tool inventory +Every tool belongs to one category, and its description opens with the +category name: + +- **Session**: who you are and what this session may do. Always available. +- **Memory**: read and write notes in the KEGs this session covers. +- **Explore**: search the Hub's public directory of KEGs, flights, and people. +- **Manage**: change the memory system itself: KEGs, settings, schemas, + indexes, locks, flights, and agents. Writing a note never needs these. + +One rule decides between listing and searching. Your own KEGs, flights, +namespaces, schemas, and tags are short, complete lists: read them with +`keg_list`, `flight_list`, `namespace_list`, `schema_list`, and `tag_list`. +Searching is only for note content (`node_search`) and for the public +directory (`explore_kegs`, `explore_flights`, `explore_people`). Explore never +finds notes and never grants access. + Every authority-bearing tool below accepts an optional top-level `flight`. When the connection starts without a flight, omission uses normal identity-authorized full access and an explicit value selects any listed real flight exactly. With a real pinned root, omission selects that root and an explicit value selects the root or an accessible flattened descendant. `session_info`, `session_refresh`, `flight_list`, `flight_read`, -`flight_search`, `keg_search`, `namespace_list`, `namespace_search`, +`explore_kegs`, `explore_flights`, `explore_people`, `namespace_list`, `invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root authority while rendering graph-wide discovery. @@ -31,27 +47,35 @@ Naming an uncovered KEG is `ORIENTATION_DENIED`, and that is the access control working, not a bug. To widen what a call can reach, pass a `flight` that covers it. -## Orientation and management +## Session | Tool | Purpose | | --- | --- | -| `mcp__tapper__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, and current instructions. | +| `mcp__tapper__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, the tool categories, and current instructions. | | `mcp__tapper__session_refresh` | Retry activation only after a broken configured root is repaired. It never replaces active no-flight or real-flight authority; narrowing no-flight access requires a new connection. | -| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Creation takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it); listings label KEGv2 KEGs. | -| `mcp__tapper__flight_create`, `mcp__tapper__flight_edit`, `mcp__tapper__flight_delete` | Manage Hub flights when the selected flight grants `manage_flights`; edits and deletes require the manifest hash returned by `flight_read`, and normal Hub ACLs still apply. | -| `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Identity-readable discovery; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | +| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | +| `mcp__tapper__namespace_list` | The namespaces you belong to, with your role. | +| `mcp__tapper__invitation_list` | Pending invitations addressed to your user: org memberships and keg grants that take effect only if accepted. Read-only: accepting or declining is your user's decision (Hub account page or `tap invitation`), so tell them rather than acting. | +| `mcp__tapper__guide` | Canonical guidance by topic: `operating`, `authoring` or `linking`, `snapshots`, `tools`, and `troubleshooting`. | + +## Memory: your KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_list` | Every KEG this session can work in, at its real role with no flight, or the effective projection of a selected real flight, with title and description. The list is complete: read it rather than searching. Listings label KEGv2 KEGs. | +| `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Your readable flights; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | -`keg_list` returns `@namespace/kegrole@namespace/+flight` text -(the final field is empty for no-flight authority) and -structured -`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","flights":["@namespace/+flight"]}]}` +`keg_list` returns `@namespace/kegrole@namespace/+flighttitledescription` +text (the flight field is empty for no-flight authority, and empty trailing +fields are dropped) and structured +`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","title":"…","description":"…","flights":["@namespace/+flight"]}]}` rows. Omission is the aggregate selector; supplying `flight` requests an exact projection. The removed `all` property is rejected by schema validation. With no flight, aggregate results contain every identity-accessible KEG. With a real pinned root, they are restricted to that root and its currently accessible transitive descendants. -## Search and discovery +## Memory: finding notes | Tool | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -64,7 +88,6 @@ accessible transitive descendants. | `mcp__tapper__node_backlinks` | Inbound links to a node. | | `mcp__tapper__index_list`, `mcp__tapper__index_read` | Read generated index files (tag index, changelog, and others). | | `mcp__tapper__keg_settings_read` | Read targeted title, description, updated metadata, and instructions for one or more selected KEGs; batches accept up to 100 canonical references. | -| `mcp__tapper__keg_search` | Case-insensitive literal search across identity-accessible canonical refs, titles, and descriptions. Returns at most 50 rows and never grants operational access. | Start topic lookups with `node_search`; reach for `node_grep` when you need every line matching an exact pattern. Pass `id_only: true` to `node_grep` and @@ -97,19 +120,19 @@ Examples: Prefer a targeted query over reading many nodes and filtering in your own code; the index does the work in O(matches) rather than O(total). -## Maintenance +## Memory: writing notes | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__node_create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. | | `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. | -| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | | `mcp__tapper__snapshot_list`, `mcp__tapper__snapshot_read` | Inspect read-only prior revisions. | | `mcp__tapper__snapshot_restore` | Recover the current node from a prior revision. | -| `mcp__tapper__keg_settings_edit` | Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | +| `mcp__tapper__keg_settings_edit` | Manage. Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | Schema edits and deletes similarly require the hash from `schema_read`. Every conflict performs no operation: merge the change into returned current content @@ -163,7 +186,29 @@ against it, so it needs no `schema`. {"nodes": [{"node_id": "12", "expected_hash": "HASH_FROM_READ"}]} ``` -## Agents +## Explore: the public directory + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__explore_kegs` | Public KEGs on the Hub, matched literally by reference, title, or description. | +| `mcp__tapper__explore_flights` | Public flights on the Hub. Metadata only, never instructions. | +| `mcp__tapper__explore_people` | People and org namespaces on the Hub, matched by name or display name. Role is set where you already belong. | + +Each takes a required, non-empty `query` and returns `refnamedescriptionrole` +text and structured `{"kind":"kegs","results":[{"ref","name","description","role"}],"truncated":false}`. +Results are capped; refine a truncated query, there is no cursor. A result +grants nothing: a public KEG outside this session's flight still needs a +flight that covers it before any Memory tool can read it. Explore asks the +connection-pinned Hub, never other configured Hubs. + +## Manage: KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_create` | Create a KEG; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it). | +| `mcp__tapper__flight_create`, `mcp__tapper__flight_edit`, `mcp__tapper__flight_delete` | Manage Hub flights when the selected flight grants `manage_flights`; edits and deletes require the manifest hash returned by `flight_read`, and normal Hub ACLs still apply. | + +## Manage: agents Hub agents are a namespace-owned model, description, markdown instructions, and tool allowlist. A flight names the agents that run on it. Members of a @@ -175,31 +220,18 @@ namespace read its agents; owners and admins change them. Agent refs are | `mcp__tapper__agent_list`, `mcp__tapper__agent_read` | Identity-readable agents in one namespace or every namespace you belong to; `agent_read` adds instructions and `effective_tools`. Neither accepts `flight`. | | `mcp__tapper__agent_create`, `mcp__tapper__agent_edit`, `mcp__tapper__agent_delete` | Manage agents in a namespace you own or administer. A selected real flight must also grant `manage_flights`. `agent_edit` keeps omitted fields; an edit applies to open sessions on their next call. | -An agent's `tools` hold tool group ids (`keg:read`, `keg:write`, `keg:admin`, -`flight:read`, `flight:admin`, `agent:read`, `agent:admin`, `discover`) and -single tool names; empty means every tool. `orient`, `guide`, `session_info`, -and `session_refresh` are always available. - -## On-demand discovery and guidance - -- `mcp__tapper__namespace_list`: the namespaces you belong to, with your role. -- `mcp__tapper__namespace_search`: people and org namespaces visible on the - Hub, not only your own, matched by name or display name; empty query browses. - Discovery only: a match grants nothing. Follow up with `keg_search` and - `flight_search`. -- `mcp__tapper__invitation_list`: pending invitations addressed to your user, - org memberships and keg grants that take effect only if accepted. Read-only: - accepting or declining is your user's decision (Hub account page or - `tap invitation`), so tell them rather than acting. - -- `mcp__tapper__flight_search`: literal reference/title/description search over - readable flights; at most 50 deterministic metadata results, with a truncation - notice. No instructions or additional access are returned. -- `mcp__tapper__guide`: read canonical guidance by topic: `operating`, `authoring` - or `linking`, `snapshots`, `tools`, and `troubleshooting`. -- `mcp__tapper__orient`: full active instructions, effective readable KEGs, - and immediate readable child flights. Orient with a child reference for the - next level. No-flight orientation points to search without listing resources. +An agent's `tools` hold tool group ids and single tool names; empty means +every tool. Groups follow the categories: Memory is `keg:read`, `keg:write`, +and `flight:read`; Explore is `explore`; Manage is `keg:admin`, +`schema:admin`, `keg:maintain`, `flight:admin`, `agent:read`, and +`agent:admin`. The Session tools (`orient`, `guide`, `session_info`, +`session_refresh`, `namespace_list`, and `invitation_list`) are always +available. + +`orient` gives the full active instructions, effective readable KEGs, and +immediate readable child flights. Orient with a child reference for the next +level. No-flight orientation points to `keg_list` and `flight_list` without +listing resources. ## Response and pagination contracts @@ -223,31 +255,35 @@ mode reports `keyword` when the Hub ranked without semantic matching. A KEG outside the flight's cover is never searched, and a Hub without ranked search fails with a message pointing at node_grep. -keg_search returns warnings, partial, and truncated. flight_search returns -truncated. Refine a truncated metadata query; neither search has a cursor. -Both search the connection-pinned Hub, never other configured Hubs. +The explore tools return truncated and have no cursor; refine the query. Supported flight capabilities are manage_flights, manage_kegs, and delete_kegs; full_access is rejected. Declared cover is not effective authority: inspect orient for the permission-checked relationship expansion. Creating or inspecting a flight does not activate it or change the connection's pinned root. -## Other registered tools +## Memory: schemas, diagnostics, locks, and attachments | Tool | Purpose | | --- | --- | -| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | -| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. | -| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. | +| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. Call `schema_list` before writing in a KEG and pick a type it lists. | | `mcp__tapper__schema_validate` | Schema validation findings. | -| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | -| `mcp__tapper__keg_info`, `mcp__tapper__node_stats`, `mcp__tapper__keg_check` | KEG diagnostics, node statistics, health findings. | -| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release`, `mcp__tapper__lock_force_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | +| `mcp__tapper__keg_info`, `mcp__tapper__node_stats` | KEG diagnostics and node statistics. | +| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | | `mcp__tapper__file_list`, `mcp__tapper__image_list` | Stored attachment names. | | `mcp__tapper__file_upload`, `mcp__tapper__image_upload` | Inline base64/data URI/embedded resource uploads; local stdio also accepts source_path. Link the returned stored filename. | | `mcp__tapper__image_download` | Image content block and metadata; local stdio optionally accepts dest_path. | | `mcp__tapper__file_download` | Local stdio only: writes an explicit destination path. Absent on hosted MCP. | +## Manage: schemas and repair + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. Writing a note never needs these. | +| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | +| `mcp__tapper__keg_check` | KEG health findings. | +| `mcp__tapper__lock_force_release` | Break a lock another session holds. | + Configuration, namespace administration, license, archive, and video tools are not registered here. Features present elsewhere in Tapper/Hub are not thereby callable over MCP. Use tools/list for this connection's available inventory. diff --git a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md index 8795ee10..d8295d47 100644 --- a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md @@ -19,8 +19,9 @@ flight on subsequent operations. keg selects a target and never grants access. orient returns current active instructions, effective readable KEGs, and immediate readable children. Orient with a child reference to inspect the next -level. With no root, orientation is search-first; keg_search and flight_search -find identity-readable metadata. Discovery is independent of selection. +level. With no root, keg_list and flight_list are the complete lists of what +your identity reads, and explore_kegs and explore_flights search the public +directory. Discovery is independent of selection. flight_read inspects a permission-filtered declared manifest and its hash; it never activates a session or selects operational authority. @@ -30,7 +31,7 @@ MCP and starts a fresh connection. session_refresh only retries failed initial activation; it cannot change an active connection. A failed configured root exposes recovery tools: orient, session_refresh, -flight_list, flight_read, session_info, keg_search, flight_search, guide. Repair +flight_list, flight_read, session_info, guide. Repair the configured root outside MCP, then retry session_refresh and orient. An active flight can have no effective KEGs and still expose the full inventory; KEG denials in that scope do not imply missing tools. @@ -44,13 +45,29 @@ If orient is unavailable, report that the Tapper MCP connection is unavailable and ask the user to reconnect or restart the host session. Never kill or signal host-owned processes. +Every tool belongs to one category, and its description opens with the +category name: + +- **Session**: who you are and what this session may do. Always available. +- **Memory**: read and write notes in the KEGs this session covers. +- **Explore**: search the Hub's public directory of KEGs, flights, and people. +- **Manage**: change the memory system itself: KEGs, settings, schemas, + indexes, locks, flights, and agents. Writing a note never needs these. + +One rule decides between listing and searching. Your own KEGs, flights, +namespaces, schemas, and tags are short, complete lists: read them with +`keg_list`, `flight_list`, `namespace_list`, `schema_list`, and `tag_list`. +Searching is only for note content (`node_search`) and for the public +directory (`explore_kegs`, `explore_flights`, `explore_people`). Explore never +finds notes and never grants access. + Every authority-bearing tool below accepts an optional top-level `flight`. When the connection starts without a flight, omission uses normal identity-authorized full access and an explicit value selects any listed real flight exactly. With a real pinned root, omission selects that root and an explicit value selects the root or an accessible flattened descendant. `session_info`, `session_refresh`, `flight_list`, `flight_read`, -`flight_search`, `keg_search`, `namespace_list`, `namespace_search`, +`explore_kegs`, `explore_flights`, `explore_people`, `namespace_list`, `invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root authority while rendering graph-wide discovery. @@ -75,27 +92,35 @@ Naming an uncovered KEG is `ORIENTATION_DENIED`, and that is the access control working, not a bug. To widen what a call can reach, pass a `flight` that covers it. -## Orientation and management +## Session | Tool | Purpose | | --- | --- | -| `mcp__tapper__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, and current instructions. | +| `mcp__tapper__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, the tool categories, and current instructions. | | `mcp__tapper__session_refresh` | Retry activation only after a broken configured root is repaired. It never replaces active no-flight or real-flight authority; narrowing no-flight access requires a new connection. | -| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Creation takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it); listings label KEGv2 KEGs. | -| `mcp__tapper__flight_create`, `mcp__tapper__flight_edit`, `mcp__tapper__flight_delete` | Manage Hub flights when the selected flight grants `manage_flights`; edits and deletes require the manifest hash returned by `flight_read`, and normal Hub ACLs still apply. | -| `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Identity-readable discovery; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | +| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | +| `mcp__tapper__namespace_list` | The namespaces you belong to, with your role. | +| `mcp__tapper__invitation_list` | Pending invitations addressed to your user: org memberships and keg grants that take effect only if accepted. Read-only: accepting or declining is your user's decision (Hub account page or `tap invitation`), so tell them rather than acting. | +| `mcp__tapper__guide` | Canonical guidance by topic: `operating`, `authoring` or `linking`, `snapshots`, `tools`, and `troubleshooting`. | + +## Memory: your KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_list` | Every KEG this session can work in, at its real role with no flight, or the effective projection of a selected real flight, with title and description. The list is complete: read it rather than searching. Listings label KEGv2 KEGs. | +| `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Your readable flights; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | -`keg_list` returns `@namespace/kegrole@namespace/+flight` text -(the final field is empty for no-flight authority) and -structured -`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","flights":["@namespace/+flight"]}]}` +`keg_list` returns `@namespace/kegrole@namespace/+flighttitledescription` +text (the flight field is empty for no-flight authority, and empty trailing +fields are dropped) and structured +`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","title":"…","description":"…","flights":["@namespace/+flight"]}]}` rows. Omission is the aggregate selector; supplying `flight` requests an exact projection. The removed `all` property is rejected by schema validation. With no flight, aggregate results contain every identity-accessible KEG. With a real pinned root, they are restricted to that root and its currently accessible transitive descendants. -## Search and discovery +## Memory: finding notes | Tool | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -108,7 +133,6 @@ accessible transitive descendants. | `mcp__tapper__node_backlinks` | Inbound links to a node. | | `mcp__tapper__index_list`, `mcp__tapper__index_read` | Read generated index files (tag index, changelog, and others). | | `mcp__tapper__keg_settings_read` | Read targeted title, description, updated metadata, and instructions for one or more selected KEGs; batches accept up to 100 canonical references. | -| `mcp__tapper__keg_search` | Case-insensitive literal search across identity-accessible canonical refs, titles, and descriptions. Returns at most 50 rows and never grants operational access. | Start topic lookups with `node_search`; reach for `node_grep` when you need every line matching an exact pattern. Pass `id_only: true` to `node_grep` and @@ -141,19 +165,19 @@ Examples: Prefer a targeted query over reading many nodes and filtering in your own code; the index does the work in O(matches) rather than O(total). -## Maintenance +## Memory: writing notes | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__node_create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. | | `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. | -| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | | `mcp__tapper__snapshot_list`, `mcp__tapper__snapshot_read` | Inspect read-only prior revisions. | | `mcp__tapper__snapshot_restore` | Recover the current node from a prior revision. | -| `mcp__tapper__keg_settings_edit` | Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | +| `mcp__tapper__keg_settings_edit` | Manage. Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | Schema edits and deletes similarly require the hash from `schema_read`. Every conflict performs no operation: merge the change into returned current content @@ -207,7 +231,29 @@ against it, so it needs no `schema`. {"nodes": [{"node_id": "12", "expected_hash": "HASH_FROM_READ"}]} ``` -## Agents +## Explore: the public directory + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__explore_kegs` | Public KEGs on the Hub, matched literally by reference, title, or description. | +| `mcp__tapper__explore_flights` | Public flights on the Hub. Metadata only, never instructions. | +| `mcp__tapper__explore_people` | People and org namespaces on the Hub, matched by name or display name. Role is set where you already belong. | + +Each takes a required, non-empty `query` and returns `refnamedescriptionrole` +text and structured `{"kind":"kegs","results":[{"ref","name","description","role"}],"truncated":false}`. +Results are capped; refine a truncated query, there is no cursor. A result +grants nothing: a public KEG outside this session's flight still needs a +flight that covers it before any Memory tool can read it. Explore asks the +connection-pinned Hub, never other configured Hubs. + +## Manage: KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_create` | Create a KEG; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it). | +| `mcp__tapper__flight_create`, `mcp__tapper__flight_edit`, `mcp__tapper__flight_delete` | Manage Hub flights when the selected flight grants `manage_flights`; edits and deletes require the manifest hash returned by `flight_read`, and normal Hub ACLs still apply. | + +## Manage: agents Hub agents are a namespace-owned model, description, markdown instructions, and tool allowlist. A flight names the agents that run on it. Members of a @@ -219,31 +265,18 @@ namespace read its agents; owners and admins change them. Agent refs are | `mcp__tapper__agent_list`, `mcp__tapper__agent_read` | Identity-readable agents in one namespace or every namespace you belong to; `agent_read` adds instructions and `effective_tools`. Neither accepts `flight`. | | `mcp__tapper__agent_create`, `mcp__tapper__agent_edit`, `mcp__tapper__agent_delete` | Manage agents in a namespace you own or administer. A selected real flight must also grant `manage_flights`. `agent_edit` keeps omitted fields; an edit applies to open sessions on their next call. | -An agent's `tools` hold tool group ids (`keg:read`, `keg:write`, `keg:admin`, -`flight:read`, `flight:admin`, `agent:read`, `agent:admin`, `discover`) and -single tool names; empty means every tool. `orient`, `guide`, `session_info`, -and `session_refresh` are always available. - -## On-demand discovery and guidance - -- `mcp__tapper__namespace_list`: the namespaces you belong to, with your role. -- `mcp__tapper__namespace_search`: people and org namespaces visible on the - Hub, not only your own, matched by name or display name; empty query browses. - Discovery only: a match grants nothing. Follow up with `keg_search` and - `flight_search`. -- `mcp__tapper__invitation_list`: pending invitations addressed to your user, - org memberships and keg grants that take effect only if accepted. Read-only: - accepting or declining is your user's decision (Hub account page or - `tap invitation`), so tell them rather than acting. - -- `mcp__tapper__flight_search`: literal reference/title/description search over - readable flights; at most 50 deterministic metadata results, with a truncation - notice. No instructions or additional access are returned. -- `mcp__tapper__guide`: read canonical guidance by topic: `operating`, `authoring` - or `linking`, `snapshots`, `tools`, and `troubleshooting`. -- `mcp__tapper__orient`: full active instructions, effective readable KEGs, - and immediate readable child flights. Orient with a child reference for the - next level. No-flight orientation points to search without listing resources. +An agent's `tools` hold tool group ids and single tool names; empty means +every tool. Groups follow the categories: Memory is `keg:read`, `keg:write`, +and `flight:read`; Explore is `explore`; Manage is `keg:admin`, +`schema:admin`, `keg:maintain`, `flight:admin`, `agent:read`, and +`agent:admin`. The Session tools (`orient`, `guide`, `session_info`, +`session_refresh`, `namespace_list`, and `invitation_list`) are always +available. + +`orient` gives the full active instructions, effective readable KEGs, and +immediate readable child flights. Orient with a child reference for the next +level. No-flight orientation points to `keg_list` and `flight_list` without +listing resources. ## Response and pagination contracts @@ -267,31 +300,35 @@ mode reports `keyword` when the Hub ranked without semantic matching. A KEG outside the flight's cover is never searched, and a Hub without ranked search fails with a message pointing at node_grep. -keg_search returns warnings, partial, and truncated. flight_search returns -truncated. Refine a truncated metadata query; neither search has a cursor. -Both search the connection-pinned Hub, never other configured Hubs. +The explore tools return truncated and have no cursor; refine the query. Supported flight capabilities are manage_flights, manage_kegs, and delete_kegs; full_access is rejected. Declared cover is not effective authority: inspect orient for the permission-checked relationship expansion. Creating or inspecting a flight does not activate it or change the connection's pinned root. -## Other registered tools +## Memory: schemas, diagnostics, locks, and attachments | Tool | Purpose | | --- | --- | -| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | -| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. | -| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. | +| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. Call `schema_list` before writing in a KEG and pick a type it lists. | | `mcp__tapper__schema_validate` | Schema validation findings. | -| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | -| `mcp__tapper__keg_info`, `mcp__tapper__node_stats`, `mcp__tapper__keg_check` | KEG diagnostics, node statistics, health findings. | -| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release`, `mcp__tapper__lock_force_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | +| `mcp__tapper__keg_info`, `mcp__tapper__node_stats` | KEG diagnostics and node statistics. | +| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | | `mcp__tapper__file_list`, `mcp__tapper__image_list` | Stored attachment names. | | `mcp__tapper__file_upload`, `mcp__tapper__image_upload` | Inline base64/data URI/embedded resource uploads; local stdio also accepts source_path. Link the returned stored filename. | | `mcp__tapper__image_download` | Image content block and metadata; local stdio optionally accepts dest_path. | | `mcp__tapper__file_download` | Local stdio only: writes an explicit destination path. Absent on hosted MCP. | +## Manage: schemas and repair + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. Writing a note never needs these. | +| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | +| `mcp__tapper__keg_check` | KEG health findings. | +| `mcp__tapper__lock_force_release` | Break a lock another session holds. | + Configuration, namespace administration, license, archive, and video tools are not registered here. Features present elsewhere in Tapper/Hub are not thereby callable over MCP. Use tools/list for this connection's available inventory. diff --git a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md index 8795ee10..d8295d47 100644 --- a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md @@ -19,8 +19,9 @@ flight on subsequent operations. keg selects a target and never grants access. orient returns current active instructions, effective readable KEGs, and immediate readable children. Orient with a child reference to inspect the next -level. With no root, orientation is search-first; keg_search and flight_search -find identity-readable metadata. Discovery is independent of selection. +level. With no root, keg_list and flight_list are the complete lists of what +your identity reads, and explore_kegs and explore_flights search the public +directory. Discovery is independent of selection. flight_read inspects a permission-filtered declared manifest and its hash; it never activates a session or selects operational authority. @@ -30,7 +31,7 @@ MCP and starts a fresh connection. session_refresh only retries failed initial activation; it cannot change an active connection. A failed configured root exposes recovery tools: orient, session_refresh, -flight_list, flight_read, session_info, keg_search, flight_search, guide. Repair +flight_list, flight_read, session_info, guide. Repair the configured root outside MCP, then retry session_refresh and orient. An active flight can have no effective KEGs and still expose the full inventory; KEG denials in that scope do not imply missing tools. @@ -44,13 +45,29 @@ If orient is unavailable, report that the Tapper MCP connection is unavailable and ask the user to reconnect or restart the host session. Never kill or signal host-owned processes. +Every tool belongs to one category, and its description opens with the +category name: + +- **Session**: who you are and what this session may do. Always available. +- **Memory**: read and write notes in the KEGs this session covers. +- **Explore**: search the Hub's public directory of KEGs, flights, and people. +- **Manage**: change the memory system itself: KEGs, settings, schemas, + indexes, locks, flights, and agents. Writing a note never needs these. + +One rule decides between listing and searching. Your own KEGs, flights, +namespaces, schemas, and tags are short, complete lists: read them with +`keg_list`, `flight_list`, `namespace_list`, `schema_list`, and `tag_list`. +Searching is only for note content (`node_search`) and for the public +directory (`explore_kegs`, `explore_flights`, `explore_people`). Explore never +finds notes and never grants access. + Every authority-bearing tool below accepts an optional top-level `flight`. When the connection starts without a flight, omission uses normal identity-authorized full access and an explicit value selects any listed real flight exactly. With a real pinned root, omission selects that root and an explicit value selects the root or an accessible flattened descendant. `session_info`, `session_refresh`, `flight_list`, `flight_read`, -`flight_search`, `keg_search`, `namespace_list`, `namespace_search`, +`explore_kegs`, `explore_flights`, `explore_people`, `namespace_list`, `invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root authority while rendering graph-wide discovery. @@ -75,27 +92,35 @@ Naming an uncovered KEG is `ORIENTATION_DENIED`, and that is the access control working, not a bug. To widen what a call can reach, pass a `flight` that covers it. -## Orientation and management +## Session | Tool | Purpose | | --- | --- | -| `mcp__tapper__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, and current instructions. | +| `mcp__tapper__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, the tool categories, and current instructions. | | `mcp__tapper__session_refresh` | Retry activation only after a broken configured root is repaired. It never replaces active no-flight or real-flight authority; narrowing no-flight access requires a new connection. | -| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Creation takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it); listings label KEGv2 KEGs. | -| `mcp__tapper__flight_create`, `mcp__tapper__flight_edit`, `mcp__tapper__flight_delete` | Manage Hub flights when the selected flight grants `manage_flights`; edits and deletes require the manifest hash returned by `flight_read`, and normal Hub ACLs still apply. | -| `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Identity-readable discovery; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | +| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | +| `mcp__tapper__namespace_list` | The namespaces you belong to, with your role. | +| `mcp__tapper__invitation_list` | Pending invitations addressed to your user: org memberships and keg grants that take effect only if accepted. Read-only: accepting or declining is your user's decision (Hub account page or `tap invitation`), so tell them rather than acting. | +| `mcp__tapper__guide` | Canonical guidance by topic: `operating`, `authoring` or `linking`, `snapshots`, `tools`, and `troubleshooting`. | + +## Memory: your KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_list` | Every KEG this session can work in, at its real role with no flight, or the effective projection of a selected real flight, with title and description. The list is complete: read it rather than searching. Listings label KEGv2 KEGs. | +| `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Your readable flights; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | -`keg_list` returns `@namespace/kegrole@namespace/+flight` text -(the final field is empty for no-flight authority) and -structured -`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","flights":["@namespace/+flight"]}]}` +`keg_list` returns `@namespace/kegrole@namespace/+flighttitledescription` +text (the flight field is empty for no-flight authority, and empty trailing +fields are dropped) and structured +`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","title":"…","description":"…","flights":["@namespace/+flight"]}]}` rows. Omission is the aggregate selector; supplying `flight` requests an exact projection. The removed `all` property is rejected by schema validation. With no flight, aggregate results contain every identity-accessible KEG. With a real pinned root, they are restricted to that root and its currently accessible transitive descendants. -## Search and discovery +## Memory: finding notes | Tool | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -108,7 +133,6 @@ accessible transitive descendants. | `mcp__tapper__node_backlinks` | Inbound links to a node. | | `mcp__tapper__index_list`, `mcp__tapper__index_read` | Read generated index files (tag index, changelog, and others). | | `mcp__tapper__keg_settings_read` | Read targeted title, description, updated metadata, and instructions for one or more selected KEGs; batches accept up to 100 canonical references. | -| `mcp__tapper__keg_search` | Case-insensitive literal search across identity-accessible canonical refs, titles, and descriptions. Returns at most 50 rows and never grants operational access. | Start topic lookups with `node_search`; reach for `node_grep` when you need every line matching an exact pattern. Pass `id_only: true` to `node_grep` and @@ -141,19 +165,19 @@ Examples: Prefer a targeted query over reading many nodes and filtering in your own code; the index does the work in O(matches) rather than O(total). -## Maintenance +## Memory: writing notes | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__node_create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. | | `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. | -| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | | `mcp__tapper__snapshot_list`, `mcp__tapper__snapshot_read` | Inspect read-only prior revisions. | | `mcp__tapper__snapshot_restore` | Recover the current node from a prior revision. | -| `mcp__tapper__keg_settings_edit` | Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | +| `mcp__tapper__keg_settings_edit` | Manage. Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | Schema edits and deletes similarly require the hash from `schema_read`. Every conflict performs no operation: merge the change into returned current content @@ -207,7 +231,29 @@ against it, so it needs no `schema`. {"nodes": [{"node_id": "12", "expected_hash": "HASH_FROM_READ"}]} ``` -## Agents +## Explore: the public directory + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__explore_kegs` | Public KEGs on the Hub, matched literally by reference, title, or description. | +| `mcp__tapper__explore_flights` | Public flights on the Hub. Metadata only, never instructions. | +| `mcp__tapper__explore_people` | People and org namespaces on the Hub, matched by name or display name. Role is set where you already belong. | + +Each takes a required, non-empty `query` and returns `refnamedescriptionrole` +text and structured `{"kind":"kegs","results":[{"ref","name","description","role"}],"truncated":false}`. +Results are capped; refine a truncated query, there is no cursor. A result +grants nothing: a public KEG outside this session's flight still needs a +flight that covers it before any Memory tool can read it. Explore asks the +connection-pinned Hub, never other configured Hubs. + +## Manage: KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_create` | Create a KEG; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it). | +| `mcp__tapper__flight_create`, `mcp__tapper__flight_edit`, `mcp__tapper__flight_delete` | Manage Hub flights when the selected flight grants `manage_flights`; edits and deletes require the manifest hash returned by `flight_read`, and normal Hub ACLs still apply. | + +## Manage: agents Hub agents are a namespace-owned model, description, markdown instructions, and tool allowlist. A flight names the agents that run on it. Members of a @@ -219,31 +265,18 @@ namespace read its agents; owners and admins change them. Agent refs are | `mcp__tapper__agent_list`, `mcp__tapper__agent_read` | Identity-readable agents in one namespace or every namespace you belong to; `agent_read` adds instructions and `effective_tools`. Neither accepts `flight`. | | `mcp__tapper__agent_create`, `mcp__tapper__agent_edit`, `mcp__tapper__agent_delete` | Manage agents in a namespace you own or administer. A selected real flight must also grant `manage_flights`. `agent_edit` keeps omitted fields; an edit applies to open sessions on their next call. | -An agent's `tools` hold tool group ids (`keg:read`, `keg:write`, `keg:admin`, -`flight:read`, `flight:admin`, `agent:read`, `agent:admin`, `discover`) and -single tool names; empty means every tool. `orient`, `guide`, `session_info`, -and `session_refresh` are always available. - -## On-demand discovery and guidance - -- `mcp__tapper__namespace_list`: the namespaces you belong to, with your role. -- `mcp__tapper__namespace_search`: people and org namespaces visible on the - Hub, not only your own, matched by name or display name; empty query browses. - Discovery only: a match grants nothing. Follow up with `keg_search` and - `flight_search`. -- `mcp__tapper__invitation_list`: pending invitations addressed to your user, - org memberships and keg grants that take effect only if accepted. Read-only: - accepting or declining is your user's decision (Hub account page or - `tap invitation`), so tell them rather than acting. - -- `mcp__tapper__flight_search`: literal reference/title/description search over - readable flights; at most 50 deterministic metadata results, with a truncation - notice. No instructions or additional access are returned. -- `mcp__tapper__guide`: read canonical guidance by topic: `operating`, `authoring` - or `linking`, `snapshots`, `tools`, and `troubleshooting`. -- `mcp__tapper__orient`: full active instructions, effective readable KEGs, - and immediate readable child flights. Orient with a child reference for the - next level. No-flight orientation points to search without listing resources. +An agent's `tools` hold tool group ids and single tool names; empty means +every tool. Groups follow the categories: Memory is `keg:read`, `keg:write`, +and `flight:read`; Explore is `explore`; Manage is `keg:admin`, +`schema:admin`, `keg:maintain`, `flight:admin`, `agent:read`, and +`agent:admin`. The Session tools (`orient`, `guide`, `session_info`, +`session_refresh`, `namespace_list`, and `invitation_list`) are always +available. + +`orient` gives the full active instructions, effective readable KEGs, and +immediate readable child flights. Orient with a child reference for the next +level. No-flight orientation points to `keg_list` and `flight_list` without +listing resources. ## Response and pagination contracts @@ -267,31 +300,35 @@ mode reports `keyword` when the Hub ranked without semantic matching. A KEG outside the flight's cover is never searched, and a Hub without ranked search fails with a message pointing at node_grep. -keg_search returns warnings, partial, and truncated. flight_search returns -truncated. Refine a truncated metadata query; neither search has a cursor. -Both search the connection-pinned Hub, never other configured Hubs. +The explore tools return truncated and have no cursor; refine the query. Supported flight capabilities are manage_flights, manage_kegs, and delete_kegs; full_access is rejected. Declared cover is not effective authority: inspect orient for the permission-checked relationship expansion. Creating or inspecting a flight does not activate it or change the connection's pinned root. -## Other registered tools +## Memory: schemas, diagnostics, locks, and attachments | Tool | Purpose | | --- | --- | -| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | -| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. | -| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. | +| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. Call `schema_list` before writing in a KEG and pick a type it lists. | | `mcp__tapper__schema_validate` | Schema validation findings. | -| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | -| `mcp__tapper__keg_info`, `mcp__tapper__node_stats`, `mcp__tapper__keg_check` | KEG diagnostics, node statistics, health findings. | -| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release`, `mcp__tapper__lock_force_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | +| `mcp__tapper__keg_info`, `mcp__tapper__node_stats` | KEG diagnostics and node statistics. | +| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | | `mcp__tapper__file_list`, `mcp__tapper__image_list` | Stored attachment names. | | `mcp__tapper__file_upload`, `mcp__tapper__image_upload` | Inline base64/data URI/embedded resource uploads; local stdio also accepts source_path. Link the returned stored filename. | | `mcp__tapper__image_download` | Image content block and metadata; local stdio optionally accepts dest_path. | | `mcp__tapper__file_download` | Local stdio only: writes an explicit destination path. Absent on hosted MCP. | +## Manage: schemas and repair + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. Writing a note never needs these. | +| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | +| `mcp__tapper__keg_check` | KEG health findings. | +| `mcp__tapper__lock_force_release` | Break a lock another session holds. | + Configuration, namespace administration, license, archive, and video tools are not registered here. Features present elsewhere in Tapper/Hub are not thereby callable over MCP. Use tools/list for this connection's available inventory. diff --git a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md index 8795ee10..d8295d47 100644 --- a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md @@ -19,8 +19,9 @@ flight on subsequent operations. keg selects a target and never grants access. orient returns current active instructions, effective readable KEGs, and immediate readable children. Orient with a child reference to inspect the next -level. With no root, orientation is search-first; keg_search and flight_search -find identity-readable metadata. Discovery is independent of selection. +level. With no root, keg_list and flight_list are the complete lists of what +your identity reads, and explore_kegs and explore_flights search the public +directory. Discovery is independent of selection. flight_read inspects a permission-filtered declared manifest and its hash; it never activates a session or selects operational authority. @@ -30,7 +31,7 @@ MCP and starts a fresh connection. session_refresh only retries failed initial activation; it cannot change an active connection. A failed configured root exposes recovery tools: orient, session_refresh, -flight_list, flight_read, session_info, keg_search, flight_search, guide. Repair +flight_list, flight_read, session_info, guide. Repair the configured root outside MCP, then retry session_refresh and orient. An active flight can have no effective KEGs and still expose the full inventory; KEG denials in that scope do not imply missing tools. @@ -44,13 +45,29 @@ If orient is unavailable, report that the Tapper MCP connection is unavailable and ask the user to reconnect or restart the host session. Never kill or signal host-owned processes. +Every tool belongs to one category, and its description opens with the +category name: + +- **Session**: who you are and what this session may do. Always available. +- **Memory**: read and write notes in the KEGs this session covers. +- **Explore**: search the Hub's public directory of KEGs, flights, and people. +- **Manage**: change the memory system itself: KEGs, settings, schemas, + indexes, locks, flights, and agents. Writing a note never needs these. + +One rule decides between listing and searching. Your own KEGs, flights, +namespaces, schemas, and tags are short, complete lists: read them with +`keg_list`, `flight_list`, `namespace_list`, `schema_list`, and `tag_list`. +Searching is only for note content (`node_search`) and for the public +directory (`explore_kegs`, `explore_flights`, `explore_people`). Explore never +finds notes and never grants access. + Every authority-bearing tool below accepts an optional top-level `flight`. When the connection starts without a flight, omission uses normal identity-authorized full access and an explicit value selects any listed real flight exactly. With a real pinned root, omission selects that root and an explicit value selects the root or an accessible flattened descendant. `session_info`, `session_refresh`, `flight_list`, `flight_read`, -`flight_search`, `keg_search`, `namespace_list`, `namespace_search`, +`explore_kegs`, `explore_flights`, `explore_people`, `namespace_list`, `invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root authority while rendering graph-wide discovery. @@ -75,27 +92,35 @@ Naming an uncovered KEG is `ORIENTATION_DENIED`, and that is the access control working, not a bug. To widen what a call can reach, pass a `flight` that covers it. -## Orientation and management +## Session | Tool | Purpose | | --- | --- | -| `mcp__tapper__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, and current instructions. | +| `mcp__tapper__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, the tool categories, and current instructions. | | `mcp__tapper__session_refresh` | Retry activation only after a broken configured root is repaired. It never replaces active no-flight or real-flight authority; narrowing no-flight access requires a new connection. | -| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Creation takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it); listings label KEGv2 KEGs. | -| `mcp__tapper__flight_create`, `mcp__tapper__flight_edit`, `mcp__tapper__flight_delete` | Manage Hub flights when the selected flight grants `manage_flights`; edits and deletes require the manifest hash returned by `flight_read`, and normal Hub ACLs still apply. | -| `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Identity-readable discovery; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | +| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | +| `mcp__tapper__namespace_list` | The namespaces you belong to, with your role. | +| `mcp__tapper__invitation_list` | Pending invitations addressed to your user: org memberships and keg grants that take effect only if accepted. Read-only: accepting or declining is your user's decision (Hub account page or `tap invitation`), so tell them rather than acting. | +| `mcp__tapper__guide` | Canonical guidance by topic: `operating`, `authoring` or `linking`, `snapshots`, `tools`, and `troubleshooting`. | + +## Memory: your KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_list` | Every KEG this session can work in, at its real role with no flight, or the effective projection of a selected real flight, with title and description. The list is complete: read it rather than searching. Listings label KEGv2 KEGs. | +| `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Your readable flights; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | -`keg_list` returns `@namespace/kegrole@namespace/+flight` text -(the final field is empty for no-flight authority) and -structured -`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","flights":["@namespace/+flight"]}]}` +`keg_list` returns `@namespace/kegrole@namespace/+flighttitledescription` +text (the flight field is empty for no-flight authority, and empty trailing +fields are dropped) and structured +`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","title":"…","description":"…","flights":["@namespace/+flight"]}]}` rows. Omission is the aggregate selector; supplying `flight` requests an exact projection. The removed `all` property is rejected by schema validation. With no flight, aggregate results contain every identity-accessible KEG. With a real pinned root, they are restricted to that root and its currently accessible transitive descendants. -## Search and discovery +## Memory: finding notes | Tool | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -108,7 +133,6 @@ accessible transitive descendants. | `mcp__tapper__node_backlinks` | Inbound links to a node. | | `mcp__tapper__index_list`, `mcp__tapper__index_read` | Read generated index files (tag index, changelog, and others). | | `mcp__tapper__keg_settings_read` | Read targeted title, description, updated metadata, and instructions for one or more selected KEGs; batches accept up to 100 canonical references. | -| `mcp__tapper__keg_search` | Case-insensitive literal search across identity-accessible canonical refs, titles, and descriptions. Returns at most 50 rows and never grants operational access. | Start topic lookups with `node_search`; reach for `node_grep` when you need every line matching an exact pattern. Pass `id_only: true` to `node_grep` and @@ -141,19 +165,19 @@ Examples: Prefer a targeted query over reading many nodes and filtering in your own code; the index does the work in O(matches) rather than O(total). -## Maintenance +## Memory: writing notes | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__node_create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. | | `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. | -| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | | `mcp__tapper__snapshot_list`, `mcp__tapper__snapshot_read` | Inspect read-only prior revisions. | | `mcp__tapper__snapshot_restore` | Recover the current node from a prior revision. | -| `mcp__tapper__keg_settings_edit` | Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | +| `mcp__tapper__keg_settings_edit` | Manage. Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | Schema edits and deletes similarly require the hash from `schema_read`. Every conflict performs no operation: merge the change into returned current content @@ -207,7 +231,29 @@ against it, so it needs no `schema`. {"nodes": [{"node_id": "12", "expected_hash": "HASH_FROM_READ"}]} ``` -## Agents +## Explore: the public directory + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__explore_kegs` | Public KEGs on the Hub, matched literally by reference, title, or description. | +| `mcp__tapper__explore_flights` | Public flights on the Hub. Metadata only, never instructions. | +| `mcp__tapper__explore_people` | People and org namespaces on the Hub, matched by name or display name. Role is set where you already belong. | + +Each takes a required, non-empty `query` and returns `refnamedescriptionrole` +text and structured `{"kind":"kegs","results":[{"ref","name","description","role"}],"truncated":false}`. +Results are capped; refine a truncated query, there is no cursor. A result +grants nothing: a public KEG outside this session's flight still needs a +flight that covers it before any Memory tool can read it. Explore asks the +connection-pinned Hub, never other configured Hubs. + +## Manage: KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_create` | Create a KEG; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it). | +| `mcp__tapper__flight_create`, `mcp__tapper__flight_edit`, `mcp__tapper__flight_delete` | Manage Hub flights when the selected flight grants `manage_flights`; edits and deletes require the manifest hash returned by `flight_read`, and normal Hub ACLs still apply. | + +## Manage: agents Hub agents are a namespace-owned model, description, markdown instructions, and tool allowlist. A flight names the agents that run on it. Members of a @@ -219,31 +265,18 @@ namespace read its agents; owners and admins change them. Agent refs are | `mcp__tapper__agent_list`, `mcp__tapper__agent_read` | Identity-readable agents in one namespace or every namespace you belong to; `agent_read` adds instructions and `effective_tools`. Neither accepts `flight`. | | `mcp__tapper__agent_create`, `mcp__tapper__agent_edit`, `mcp__tapper__agent_delete` | Manage agents in a namespace you own or administer. A selected real flight must also grant `manage_flights`. `agent_edit` keeps omitted fields; an edit applies to open sessions on their next call. | -An agent's `tools` hold tool group ids (`keg:read`, `keg:write`, `keg:admin`, -`flight:read`, `flight:admin`, `agent:read`, `agent:admin`, `discover`) and -single tool names; empty means every tool. `orient`, `guide`, `session_info`, -and `session_refresh` are always available. - -## On-demand discovery and guidance - -- `mcp__tapper__namespace_list`: the namespaces you belong to, with your role. -- `mcp__tapper__namespace_search`: people and org namespaces visible on the - Hub, not only your own, matched by name or display name; empty query browses. - Discovery only: a match grants nothing. Follow up with `keg_search` and - `flight_search`. -- `mcp__tapper__invitation_list`: pending invitations addressed to your user, - org memberships and keg grants that take effect only if accepted. Read-only: - accepting or declining is your user's decision (Hub account page or - `tap invitation`), so tell them rather than acting. - -- `mcp__tapper__flight_search`: literal reference/title/description search over - readable flights; at most 50 deterministic metadata results, with a truncation - notice. No instructions or additional access are returned. -- `mcp__tapper__guide`: read canonical guidance by topic: `operating`, `authoring` - or `linking`, `snapshots`, `tools`, and `troubleshooting`. -- `mcp__tapper__orient`: full active instructions, effective readable KEGs, - and immediate readable child flights. Orient with a child reference for the - next level. No-flight orientation points to search without listing resources. +An agent's `tools` hold tool group ids and single tool names; empty means +every tool. Groups follow the categories: Memory is `keg:read`, `keg:write`, +and `flight:read`; Explore is `explore`; Manage is `keg:admin`, +`schema:admin`, `keg:maintain`, `flight:admin`, `agent:read`, and +`agent:admin`. The Session tools (`orient`, `guide`, `session_info`, +`session_refresh`, `namespace_list`, and `invitation_list`) are always +available. + +`orient` gives the full active instructions, effective readable KEGs, and +immediate readable child flights. Orient with a child reference for the next +level. No-flight orientation points to `keg_list` and `flight_list` without +listing resources. ## Response and pagination contracts @@ -267,31 +300,35 @@ mode reports `keyword` when the Hub ranked without semantic matching. A KEG outside the flight's cover is never searched, and a Hub without ranked search fails with a message pointing at node_grep. -keg_search returns warnings, partial, and truncated. flight_search returns -truncated. Refine a truncated metadata query; neither search has a cursor. -Both search the connection-pinned Hub, never other configured Hubs. +The explore tools return truncated and have no cursor; refine the query. Supported flight capabilities are manage_flights, manage_kegs, and delete_kegs; full_access is rejected. Declared cover is not effective authority: inspect orient for the permission-checked relationship expansion. Creating or inspecting a flight does not activate it or change the connection's pinned root. -## Other registered tools +## Memory: schemas, diagnostics, locks, and attachments | Tool | Purpose | | --- | --- | -| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | -| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. | -| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. | +| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. Call `schema_list` before writing in a KEG and pick a type it lists. | | `mcp__tapper__schema_validate` | Schema validation findings. | -| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | -| `mcp__tapper__keg_info`, `mcp__tapper__node_stats`, `mcp__tapper__keg_check` | KEG diagnostics, node statistics, health findings. | -| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release`, `mcp__tapper__lock_force_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | +| `mcp__tapper__keg_info`, `mcp__tapper__node_stats` | KEG diagnostics and node statistics. | +| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | | `mcp__tapper__file_list`, `mcp__tapper__image_list` | Stored attachment names. | | `mcp__tapper__file_upload`, `mcp__tapper__image_upload` | Inline base64/data URI/embedded resource uploads; local stdio also accepts source_path. Link the returned stored filename. | | `mcp__tapper__image_download` | Image content block and metadata; local stdio optionally accepts dest_path. | | `mcp__tapper__file_download` | Local stdio only: writes an explicit destination path. Absent on hosted MCP. | +## Manage: schemas and repair + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. Writing a note never needs these. | +| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | +| `mcp__tapper__keg_check` | KEG health findings. | +| `mcp__tapper__lock_force_release` | Break a lock another session holds. | + Configuration, namespace administration, license, archive, and video tools are not registered here. Features present elsewhere in Tapper/Hub are not thereby callable over MCP. Use tools/list for this connection's available inventory. diff --git a/pkg/apicontract/contract.go b/pkg/apicontract/contract.go index ad244647..b1057212 100644 --- a/pkg/apicontract/contract.go +++ b/pkg/apicontract/contract.go @@ -15,7 +15,7 @@ import ( "time" ) -const Revision = "2026-09-28" +const Revision = "2026-10-12" const VersionHeader = "Tapper-API-Version" const ClientHeader = "Tapper-Client-Version" const Required = "API_VERSION_REQUIRED" diff --git a/pkg/apicontract/contract_test.go b/pkg/apicontract/contract_test.go index d947b2a2..7e7412f2 100644 --- a/pkg/apicontract/contract_test.go +++ b/pkg/apicontract/contract_test.go @@ -85,8 +85,8 @@ func TestClientDiscoveryMatrix(t *testing.T) { name, body, code string status int }{ - {"matching", `{"server_version":"0.24.0","api_versions":["2026-09-28"]}`, "", 200}, - {"newer compatible", `{"server_version":"99.0.0","api_versions":["2026-09-28","2099-01-01"]}`, "", 200}, + {"matching", `{"server_version":"0.24.0","api_versions":["2026-10-12"]}`, "", 200}, + {"newer compatible", `{"server_version":"99.0.0","api_versions":["2026-10-12","2099-01-01"]}`, "", 200}, {"newer incompatible", `{"server_version":"99.0.0","api_versions":["2099-01-01"]}`, Unsupported, 200}, {"legacy", "", DiscoveryUnavailable, 404}, {"redirect", "", DiscoveryUnavailable, 302}, {"malformed", "oops", DiscoveryInvalid, 200}, {"missing versions", `{"server_version":"v1"}`, DiscoveryInvalid, 200}, diff --git a/pkg/cli/cmd_explore.go b/pkg/cli/cmd_explore.go new file mode 100644 index 00000000..92e2601b --- /dev/null +++ b/pkg/cli/cmd_explore.go @@ -0,0 +1,59 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/spf13/cobra" +) + +// NewExploreCmd returns the `explore` cobra command: the hub's public +// directory. Everything the caller already holds is listed by `keg list`, +// `flight list`, and `namespace list` instead. +// +// Usage examples: +// +// tap explore kegs golang +// tap explore flights onboarding +// tap explore people acme +func NewExploreCmd(deps *Deps) *cobra.Command { + cmd := &cobra.Command{ + Use: "explore", + Short: "search the hub's public directory", + Long: `Search the public directory of the active Hub: public KEGs, public +flights, and the people and org namespaces on it. Discovery only: a match +grants nothing. Your own KEGs, flights, and namespaces come from the list +commands.`, + } + cmd.AddCommand( + newExploreKindCmd(deps, "kegs", "search the hub's public KEGs", (*tapper.Tap).ExploreKegs), + newExploreKindCmd(deps, "flights", "search the hub's public flights", (*tapper.Tap).ExploreFlights), + newExploreKindCmd(deps, "people", "search the people and org namespaces on the hub", (*tapper.Tap).ExplorePeople), + ) + return cmd +} + +func newExploreKindCmd(deps *Deps, use, short string, run func(*tapper.Tap, context.Context, tapper.ExploreOptions) (tapper.ExploreResult, error)) *cobra.Command { + return &cobra.Command{ + Use: use + " QUERY", + Short: short, + Long: short + `, printing REF, NAME, DESCRIPTION, ROLE. +ROLE is set where you already belong. The query is matched literally +against references, names, and descriptions.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + res, err := run(deps.Tap, cmd.Context(), tapper.ExploreOptions{Query: args[0], Hub: globalKegTarget(deps).Hub}) + if err != nil { + return err + } + for _, row := range res.Results { + fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\t%s\n", row.Ref, row.Name, row.Description, row.Role) + } + if res.Truncated { + fmt.Fprintln(cmd.ErrOrStderr(), "warning: results truncated; refine the query") + } + return nil + }, + } +} diff --git a/pkg/cli/cmd_explore_test.go b/pkg/cli/cmd_explore_test.go new file mode 100644 index 00000000..0f7a2477 --- /dev/null +++ b/pkg/cli/cmd_explore_test.go @@ -0,0 +1,45 @@ +package cli_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/stretchr/testify/require" +) + +func TestExploreCLI_SearchesEachPublicDirectorySection(t *testing.T) { + hub := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer hub-token", r.Header.Get("Authorization")) + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/explore" { + http.NotFound(w, r) + return + } + var in tapper.ExploreRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&in)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tapper.ExploreResult{ + Kind: in.Kind, + Results: []tapper.ExploreRow{{Ref: "@world/" + string(in.Kind), Name: "Match for " + in.Query, Description: "public"}}, + Truncated: in.Kind == tapper.ExplorePeople, + }) + })) + defer hub.Close() + sb := NewSandbox(t) + require.NoError(t, sb.Runtime().AtomicWriteFile("/home/testuser/.config/tapper/config.yaml", []byte(fmt.Sprintf("hub: test\nhubs:\n test: {url: %s, token: hub-token}\n", hub.URL)), 0600)) + + for _, kind := range []string{"kegs", "flights", "people"} { + res := NewProcess(t, false, "explore", kind, "go").Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err, kind) + require.Equal(t, "@world/"+kind+"\tMatch for go\tpublic\t\n", string(res.Stdout), kind) + if kind == "people" { + require.Contains(t, string(res.Stderr), "results truncated") + } + } + + missing := NewProcess(t, false, "explore", "kegs").Run(sb.Context(), sb.Runtime()) + require.Error(t, missing.Err, "the public directory is searched, never browsed in full") +} diff --git a/pkg/cli/cmd_namespace.go b/pkg/cli/cmd_namespace.go index fed4ca43..908a1c46 100644 --- a/pkg/cli/cmd_namespace.go +++ b/pkg/cli/cmd_namespace.go @@ -32,7 +32,6 @@ manage org-namespace membership and roles, and create org namespaces.`, } cmd.AddCommand( newNamespaceListCmd(deps), - newNamespaceSearchCmd(deps), newNamespaceMembersCmd(deps), newNamespaceAddMemberCmd(deps), newNamespaceSetRoleCmd(deps), @@ -71,34 +70,6 @@ Only the active Hub is queried. Use --hub to select another saved connection.`, return cmd } -func newNamespaceSearchCmd(deps *Deps) *cobra.Command { - return &cobra.Command{ - Use: "search [QUERY]", - Short: "search the namespaces visible on the hub", - Long: `Search the people and org namespaces visible on the active Hub, not only -your own, as NAMESPACE, KIND, DISPLAY NAME, ROLE. ROLE is set where you are a -member. With no query, browse. Discovery only: a match grants nothing.`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - query := "" - if len(args) == 1 { - query = args[0] - } - res, err := deps.Tap.NamespaceSearch(cmd.Context(), tapper.NamespaceSearchOptions{Query: query, Hub: globalKegTarget(deps).Hub}) - if err != nil { - return err - } - for _, ns := range res.Namespaces { - fmt.Fprintf(cmd.OutOrStdout(), "@%s\t%s\t%s\t%s\n", ns.Name, ns.Kind, ns.DisplayName, ns.Role) - } - if res.Truncated { - fmt.Fprintln(cmd.ErrOrStderr(), "warning: results truncated; refine the query") - } - return nil - }, - } -} - func newNamespaceMembersCmd(deps *Deps) *cobra.Command { cmd := &cobra.Command{ Use: "members", @@ -127,7 +98,7 @@ func newNamespaceAddMemberCmd(deps *Deps) *cobra.Command { Long: "Invite a member to the namespace selected by --namespace/--hub (default: the resolved namespace).\n\n" + "They join only after accepting the invitation (tap invitation accept). For an existing member the\n" + "role changes immediately. Member management requires the owner role.", - Args: cobra.ExactArgs(2), + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { kt := globalKegTarget(deps) change, err := deps.Tap.NamespaceAddMember(cmd.Context(), tapper.NamespaceAddMemberOptions{ diff --git a/pkg/cli/cmd_root.go b/pkg/cli/cmd_root.go index f1730e5d..466b8ca7 100644 --- a/pkg/cli/cmd_root.go +++ b/pkg/cli/cmd_root.go @@ -334,6 +334,7 @@ func NewRootCmd(deps *Deps) *cobra.Command { configCmd = NewConfigCmd(deps) subcommands = append(subcommands, NewKegCmd(deps), + NewExploreCmd(deps), NewNamespaceCmd(deps), NewInvitationCmd(deps), NewUseCmd(deps), diff --git a/pkg/keg/orientation_test.go b/pkg/keg/orientation_test.go index 020b330a..9049f10f 100644 --- a/pkg/keg/orientation_test.go +++ b/pkg/keg/orientation_test.go @@ -13,7 +13,7 @@ type orientationRoundTripFunc func(*http.Request) (*http.Response, error) func (f orientationRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { if req.URL.Path == "/api/version" { - return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"server_version":"test","api_versions":["2026-09-28"]}`))}, nil + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"server_version":"test","api_versions":["2026-10-12"]}`))}, nil } return f(req) } diff --git a/pkg/mcp/flight_search_test.go b/pkg/mcp/flight_search_test.go deleted file mode 100644 index 9b457e2f..00000000 --- a/pkg/mcp/flight_search_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package mcp - -import ( - "fmt" - "testing" - - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/stretchr/testify/require" -) - -func TestFlightSearchLiteralBoundedMetadata(t *testing.T) { - rows := []*tapper.Flight{} - for i := 60; i >= 0; i-- { - rows = append(rows, &tapper.Flight{Name: fmt.Sprintf("@n/+f%02d", i), FlightManifest: tapper.FlightManifest{Title: "Title", Description: "literal %_ [x]", Instructions: "instruction-only"}}) - } - out := SearchIdentityFlights(rows, "%_") - require.Len(t, out.Flights, 50) - require.True(t, out.Truncated) - require.Equal(t, "@n/+f00", out.Flights[0].Ref) - require.Empty(t, SearchIdentityFlights(rows, "instruction-only").Flights) - require.Empty(t, SearchIdentityFlights(rows, " ").Flights) - require.Len(t, SearchIdentityFlights(rows, "@n/+f60").Flights, 1) - require.Len(t, SearchIdentityFlights(rows, "TITLE").Flights, 50) -} diff --git a/pkg/mcp/orientation_cache_test.go b/pkg/mcp/orientation_cache_test.go index 6af99666..77e8881c 100644 --- a/pkg/mcp/orientation_cache_test.go +++ b/pkg/mcp/orientation_cache_test.go @@ -20,7 +20,7 @@ func newCachedFlightSession(t *testing.T, backend *perCallFlightBackend, ttl tim require.NoError(t, err) server := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ OrientationProvider: backend, FlightProvider: backend, KegProvider: backend, - KegSearchProvider: backend, IdentityProvider: backend, + ExploreProvider: backend, IdentityProvider: backend, OrientationCacheTTL: ttl, }) return connectFlightSession(t, ctx, server, nil), ctx, func(d time.Duration) { sb.Advance(d) } diff --git a/pkg/mcp/providers.go b/pkg/mcp/providers.go index 47f2ce9a..66402840 100644 --- a/pkg/mcp/providers.go +++ b/pkg/mcp/providers.go @@ -111,14 +111,11 @@ type AgentProvider interface { DeleteAgent(ctx context.Context, ref string) error } -// NamespaceProvider discovers namespaces: the caller's own, and a search -// across the hub. Discovery never grants authority. +// NamespaceProvider lists the caller's own namespaces. The hub-wide people +// directory is ExploreProvider's. Listing never grants authority. type NamespaceProvider interface { // ListNamespaces returns the namespaces the caller belongs to, with role. ListNamespaces(context.Context) ([]tapper.HubNamespace, error) - // SearchNamespaces returns a bounded page of namespaces visible on the - // hub whose name or display name matches query; empty browses. - SearchNamespaces(ctx context.Context, query string) (tapper.NamespaceSearchResult, error) } // NodeSearchProvider ranks nodes by relevance across the KEGs a call may @@ -136,34 +133,6 @@ func (p localNodeSearchProvider) SearchNodes(ctx context.Context, opts tapper.Se return p.tap.Search(ctx, opts) } -// KegSearchRow is identity-authorized KEG metadata. Search results are not a -// flight projection and never grant operational authority. -type KegSearchRow struct { - Ref string `json:"ref"` - Role string `json:"role"` - Title string `json:"title"` - Description string `json:"description"` - Visibility string `json:"visibility"` - Source string `json:"source"` - KegVersion string `json:"kegv,omitempty"` -} - -// KegSearchResult includes partial-discovery warnings without failing useful -// results from reachable hubs. -type KegSearchResult struct { - Kegs []KegSearchRow `json:"kegs"` - Warnings []string `json:"warnings"` - Truncated bool `json:"truncated"` - Partial bool `json:"partial"` -} - -// KegSearchProvider searches identity-authorized KEG metadata independently -// of flight authority. -type KegSearchProvider interface { - // SearchKegs returns bounded identity-authorized metadata matches. - SearchKegs(context.Context, string) (KegSearchResult, error) -} - // AuthIdentity is deliberately credential-free. Do not add token, email, // scope, cookie, expiry, or session fields to this MCP wire shape. type AuthIdentity struct { @@ -671,49 +640,6 @@ func (p localKegDiscoveryProvider) CreateKeg(ctx context.Context, opts tapper.Cr return opts.Keg, nil } -func (p localKegDiscoveryProvider) SearchKegs(ctx context.Context, query string) (KegSearchResult, error) { - rows, warnings, err := p.tap.IdentityKegCatalog(ctx) - if err != nil { - return KegSearchResult{}, err - } - out := SearchIdentityKegsResult(rows, query) - out.Warnings = append([]string{}, warnings...) - out.Partial = len(warnings) > 0 - return out, nil -} - -// SearchIdentityKegs performs case-insensitive literal matching over canonical -// ref, title, and description, returning at most 50 canonically ordered rows. -func SearchIdentityKegs(rows []tapper.OrientationKeg, query string) []KegSearchRow { - return SearchIdentityKegsResult(rows, query).Kegs -} - -// SearchIdentityKegsResult retains truncation information for MCP callers. -func SearchIdentityKegsResult(rows []tapper.OrientationKeg, query string) KegSearchResult { - needle := strings.ToLower(strings.TrimSpace(query)) - if needle == "" { - return KegSearchResult{Kegs: []KegSearchRow{}, Warnings: []string{}} - } - matched := make([]KegSearchRow, 0, len(rows)) - for _, row := range rows { - haystack := strings.ToLower(row.Ref + "\n" + row.Title + "\n" + row.Description) - if !strings.Contains(haystack, needle) { - continue - } - matched = append(matched, KegSearchRow{ - Ref: row.Ref, Role: tapper.EffectiveOrientationRole(row), - Title: row.Title, Description: row.Description, Visibility: row.Visibility, Source: row.Source, - KegVersion: row.KegVersion, - }) - } - sort.Slice(matched, func(i, j int) bool { return matched[i].Ref < matched[j].Ref }) - out := KegSearchResult{Kegs: matched, Warnings: []string{}, Truncated: len(matched) > 50} - if out.Truncated { - out.Kegs = out.Kegs[:50] - } - return out -} - type localAgentProvider struct{ tap *tapper.Tap } func (p localAgentProvider) ListAgents(ctx context.Context, namespace string) ([]tapper.HubAgent, error) { @@ -738,9 +664,6 @@ func (p localNamespaceProvider) ListNamespaces(ctx context.Context) ([]tapper.Hu res, err := p.tap.NamespaceList(ctx, tapper.NamespaceListOptions{}) return res.Namespaces, err } -func (p localNamespaceProvider) SearchNamespaces(ctx context.Context, query string) (tapper.NamespaceSearchResult, error) { - return p.tap.NamespaceSearch(ctx, tapper.NamespaceSearchOptions{Query: query}) -} func (p localNamespaceProvider) ListInvitations(ctx context.Context) ([]tapper.HubInvitation, error) { return p.tap.InvitationList(ctx, tapper.InvitationOptions{}) } diff --git a/pkg/mcp/public_response_test.go b/pkg/mcp/public_response_test.go index f1c62040..ecb10fec 100644 --- a/pkg/mcp/public_response_test.go +++ b/pkg/mcp/public_response_test.go @@ -1,6 +1,7 @@ package mcp_test import ( + "context" "encoding/json" "fmt" "regexp" @@ -182,14 +183,17 @@ func TestPublicContractGuideInventory(t *testing.T) { require.Contains(t, wirePayload(t, invalid, "structured")["message"], "operating") } -func TestPublicContractSearchTruncation(t *testing.T) { - rows := []tapper.OrientationKeg{} - for i := 0; i < 51; i++ { - rows = append(rows, tapper.OrientationKeg{Ref: fmt.Sprintf("@team/notes%d", i), Title: "Match"}) - } - out := mcp.SearchIdentityKegsResult(rows, "Match") - require.Len(t, out.Kegs, 50) - require.True(t, out.Truncated) - require.False(t, out.Partial) - require.False(t, mcp.SearchIdentityKegsResult(rows[:50], "Match").Truncated) +type truncatedExplore struct{} + +func (truncatedExplore) Explore(_ context.Context, kind tapper.ExploreKind, _ string) (tapper.ExploreResult, error) { + return tapper.ExploreResult{Kind: kind, Results: []tapper.ExploreRow{{Ref: "@team/notes", Name: "Notes"}}, Truncated: true}, nil +} + +func TestPublicContractExploreTruncation(t *testing.T) { + session, ctx := newTestSessionWithOpts(t, mcp.ServerOptions{ExploreProvider: truncatedExplore{}}) + res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "explore_kegs", Arguments: map[string]any{"query": "notes"}}) + require.NoError(t, err) + require.False(t, res.IsError) + require.True(t, wirePayload(t, res, "structured")["truncated"].(bool)) + require.Contains(t, extractText(t, res), "Results truncated; refine the query.") } diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index eb65035f..8e838fe9 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -37,7 +37,7 @@ type ServerOptions struct { OrientationProvider OrientationProvider FlightProvider FlightProvider KegProvider KegDiscoveryProvider - KegSearchProvider KegSearchProvider + ExploreProvider ExploreProvider IdentityProvider IdentityProvider AgentProvider AgentProvider NamespaceProvider NamespaceProvider @@ -76,8 +76,8 @@ func NewServer(tap *tapper.Tap, version string, defaults KegDefaults, opts ...Se if opt.KegProvider == nil { opt.KegProvider = localKegDiscoveryProvider{tap: tap} } - if opt.KegSearchProvider == nil { - opt.KegSearchProvider = localKegDiscoveryProvider{tap: tap} + if opt.ExploreProvider == nil { + opt.ExploreProvider = localExploreProvider{tap: tap} } if opt.IdentityProvider == nil { opt.IdentityProvider = localIdentityProvider{tap: tap} @@ -135,7 +135,8 @@ func NewServer(tap *tapper.Tap, version string, defaults KegDefaults, opts ...Se registerDoctorTools(srv, tap, defaults) registerLockTools(srv, tap, defaults) registerFlightTools(srv, defaults, opt.FlightProvider) - registerKegTools(srv, defaults, opt.KegProvider, opt.KegSearchProvider) + registerKegTools(srv, defaults, opt.KegProvider) + registerExploreTools(srv, opt.ExploreProvider) registerResourceTools(srv, tap, defaults) registerAuthInfoTool(srv, defaults, opt.IdentityProvider, opt.KegProvider) registerAgentTools(srv, defaults, opt.AgentProvider) @@ -352,7 +353,7 @@ func errorResult(err error) *sdkmcp.CallToolResult { func errorGuidance(code string) (action string, operationPerformed any) { switch code { case keg.RemoteCodeNotFound: - return "The target was not found or is not readable. Confirm its identity with `node_list`/`node_search`/`node_grep` for nodes, `schema_list` for schemas, `flight_list`/`flight_search` for flights, or `keg_search` for KEGs before retrying.", false + return "The target was not found or is not readable. Confirm its identity with `node_list`/`node_search`/`node_grep` for nodes, `schema_list` for schemas, `flight_list` for flights, or `keg_list` for KEGs before retrying.", false case keg.RemoteCodeExist, keg.RemoteCodeDestExists: return "Something already occupies that id or name. Choose another, or edit the existing node instead.", false case keg.RemoteCodeSchemaInvalid: @@ -370,7 +371,7 @@ func errorGuidance(code string) (action string, operationPerformed any) { case keg.RemoteCodeNotSupported: return "This backend does not implement the operation. Do not retry unchanged; use a supported tool from tools/list or have the operator update the backend.", false case keg.RemoteCodeKegVersionUnsupported: - return "The KEG version does not offer the operation, or the Hub does not offer the requested KEG version. Do not retry unchanged: for keg_create, omit kegv to create a KEGv1 KEG; for node tools, keg_list and keg_search label KEGv2 preview KEGs, so pick a KEGv1 KEG.", false + return "The KEG version does not offer the operation, or the Hub does not offer the requested KEG version. Do not retry unchanged: for keg_create, omit kegv to create a KEGv1 KEG; for node tools, keg_list labels KEGv2 preview KEGs, so pick a KEGv1 KEG.", false default: return "The outcome is unknown: this failure may follow a partial write. Inspect the resource before retrying: `node_read` for nodes, `schema_read` for schemas, `keg_settings_read` with minimal=false for settings, or `flight_read` for flights. Discover possible creations before repeating them; do not blindly replay a mutation.", nil } diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index abdea00c..8dc044e6 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -240,7 +240,7 @@ func TestMCP_ToolsList(t *testing.T) { names[tool.Name] = true } for _, want := range []string{ - "session_info", "keg_list", "keg_search", "node_read", "node_list", "node_search", "node_grep", "tag_list", "node_backlinks", "node_links", "keg_info", + "session_info", "keg_list", "explore_kegs", "explore_flights", "explore_people", "node_read", "node_list", "node_search", "node_grep", "tag_list", "node_backlinks", "node_links", "keg_info", "keg_settings_read", "keg_settings_edit", "node_stats", "node_create", "node_edit", "node_delete", "node_move", "index_rebuild", "index_list", "index_read", "keg_check", "snapshot_list", "snapshot_create", "snapshot_read", "snapshot_restore", "file_list", "image_list", "file_delete", "image_delete", @@ -249,7 +249,7 @@ func TestMCP_ToolsList(t *testing.T) { "flight_create", "flight_edit", "flight_delete", "schema_list", "schema_read", "schema_create", "schema_edit", "schema_delete", "schema_validate", "agent_list", "agent_read", "agent_create", "agent_edit", "agent_delete", - "namespace_list", "namespace_search", + "namespace_list", } { require.Truef(t, names[want], "agent-safe surface missing %q", want) } @@ -292,11 +292,11 @@ func TestMCP_CommonAgentSafeSurface(t *testing.T) { "file_list", "image_list", "file_delete", "image_delete", "file_upload", "image_upload", "image_download", "schema_list", "schema_read", "schema_create", "schema_edit", - "schema_delete", "schema_validate", "keg_check", "keg_list", "keg_search", "session_info", + "schema_delete", "schema_validate", "keg_check", "keg_list", "explore_kegs", "explore_flights", "explore_people", "session_info", "lock_acquire", "lock_release", "lock_status", "lock_force_release", "flight_list", "flight_read", "flight_create", "flight_edit", "flight_delete", "agent_list", "agent_read", "agent_create", "agent_edit", "agent_delete", - "namespace_list", "namespace_search", + "namespace_list", } { require.Truef(t, names[want], "common surface should expose %q", want) } diff --git a/pkg/mcp/session_bootstrap_test.go b/pkg/mcp/session_bootstrap_test.go index 71b50358..84dedab3 100644 --- a/pkg/mcp/session_bootstrap_test.go +++ b/pkg/mcp/session_bootstrap_test.go @@ -58,9 +58,9 @@ func TestMCP_NoFlightsAnywhereUsesIdentityFullAccess(t *testing.T) { require.Contains(t, tools, "flight_create") require.Contains(t, tools, "keg_create") require.Contains(t, tools, "keg_delete") - search, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_search", Arguments: map[string]any{"query": "anything"}}) + listed, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{}}) require.NoError(t, err) - require.False(t, search.IsError, extractText(t, search)) + require.False(t, listed.IsError, extractText(t, listed)) created, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_create", Arguments: map[string]any{ "keg": "@local/first", "title": "First KEG", diff --git a/pkg/mcp/session_flight.go b/pkg/mcp/session_flight.go index a70e91b4..290030fa 100644 --- a/pkg/mcp/session_flight.go +++ b/pkg/mcp/session_flight.go @@ -46,7 +46,7 @@ func failedOrientationPayload(err error) string { } return "This session could not establish flight authority: " + err.Error() + "\n\nKEG tools are locked until it does, so only `orient`, `session_refresh`, `flight_list`," + - " `flight_read`, `session_info`, `keg_search`, `flight_search`, and `guide` are published. An empty cover on a" + + " `flight_read`, `session_info`, and `guide` are published. An empty cover on a" + " successfully loaded flight would still publish the complete registered" + " inventory. Call `flight_list` to see what" + " actually exists, then ask the user to correct the selected flight (in" + @@ -56,27 +56,26 @@ func failedOrientationPayload(err error) string { } var recoveryToolNames = map[string]bool{ - "guide": true, "flight_search": true, + "guide": true, "orient": true, "session_refresh": true, "flight_list": true, "flight_read": true, "session_info": true, - "keg_search": true, } // ungovernedToolNames never select authority and therefore do not advertise a // flight argument. Keep administration/configuration discovery here even when // a particular build does not register those tools. var ungovernedToolNames = map[string]bool{ - "guide": true, "flight_search": true, - "session_info": true, "auth_status": true, "keg_search": true, + "guide": true, "explore_kegs": true, "explore_flights": true, "explore_people": true, + "session_info": true, "auth_status": true, "session_refresh": true, "config": true, "config_template": true, "namespace_list": true, "namespace_create": true, "namespace_members": true, "namespace_add_member": true, "namespace_set_role": true, "namespace_remove_member": true, "license": true, "flight_list": true, "flight_read": true, - "namespace_search": true, "invitation_list": true, "agent_list": true, "agent_read": true, + "invitation_list": true, "agent_list": true, "agent_read": true, } // sessionMode is the authority state of one MCP session. diff --git a/pkg/mcp/session_flight_test.go b/pkg/mcp/session_flight_test.go index 45ef70da..97b44750 100644 --- a/pkg/mcp/session_flight_test.go +++ b/pkg/mcp/session_flight_test.go @@ -141,7 +141,8 @@ func TestMCP_OrientRepeatsTheRulesInitializationAlreadySent(t *testing.T) { oriented := callOrient(t, ctx, session) require.Contains(t, oriented, "# KEG System") require.Contains(t, oriented, "Use only `mcp__tapper__*` tools") - require.Contains(t, oriented, "`flight_search`") + require.Contains(t, oriented, "`flight_list`") + require.Contains(t, oriented, "`explore_flights`") require.NotContains(t, oriented, "## Available KEGs") } @@ -240,10 +241,10 @@ func TestMCP_RemoteAliasCoverlessRootActivatesFullSurfaceAndCrossFlightKegList(t rootList, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{}}) require.NoError(t, err) require.False(t, rootList.IsError, extractText(t, rootList)) - require.Equal(t, "@admin/private\teditor\t@admin/+test", extractText(t, rootList)) + require.Equal(t, "@admin/private\teditor\t@admin/+test\tPrivate\tCovered child keg", extractText(t, rootList)) rootStructured, err := json.Marshal(rootList.StructuredContent) require.NoError(t, err) - require.JSONEq(t, `{"kegs":[{"ref":"@admin/private","role":"editor","flights":["@admin/+test"]}],"message":"@admin/private\teditor\t@admin/+test"}`, string(rootStructured)) + require.JSONEq(t, `{"kegs":[{"ref":"@admin/private","role":"editor","title":"Private","description":"Covered child keg","flights":["@admin/+test"]}],"message":"@admin/private\teditor\t@admin/+test\tPrivate\tCovered child keg"}`, string(rootStructured)) explicitRoot, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"flight": "@admin/+admin"}}) require.NoError(t, err) @@ -253,10 +254,10 @@ func TestMCP_RemoteAliasCoverlessRootActivatesFullSurfaceAndCrossFlightKegList(t beforeSelected := catalogRequests.Load() selected, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"flight": "+test"}}) require.NoError(t, err) - require.Equal(t, "@admin/private\teditor\t@admin/+test", extractText(t, selected)) + require.Equal(t, "@admin/private\teditor\t@admin/+test\tPrivate\tCovered child keg", extractText(t, selected)) selectedStructured, err := json.Marshal(selected.StructuredContent) require.NoError(t, err) - require.JSONEq(t, `{"kegs":[{"ref":"@admin/private","role":"editor","flights":["@admin/+test"]}],"message":"@admin/private\teditor\t@admin/+test"}`, string(selectedStructured)) + require.JSONEq(t, `{"kegs":[{"ref":"@admin/private","role":"editor","title":"Private","description":"Covered child keg","flights":["@admin/+test"]}],"message":"@admin/private\teditor\t@admin/+test\tPrivate\tCovered child keg"}`, string(selectedStructured)) require.Equal(t, beforeSelected+1, catalogRequests.Load(), "one active Hub catalog projection") deniedOperation, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_settings_read", Arguments: map[string]any{"keg": "@admin/private"}}) @@ -276,13 +277,6 @@ func TestMCP_RemoteAliasCoverlessRootActivatesFullSurfaceAndCrossFlightKegList(t require.NoError(t, err) require.NotContains(t, extractText(t, explicitRootOrient), "`@admin/private`") - for _, query := range []string{"ecw", "EXAMPLE", "covered child"} { - found, searchErr := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_search", Arguments: map[string]any{"query": query}}) - require.NoError(t, searchErr) - require.False(t, found.IsError, extractText(t, found)) - require.NotEmpty(t, extractText(t, found)) - } - beforeInvalid := catalogRequests.Load() invalid, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"all": true}}) require.NoError(t, err) diff --git a/pkg/mcp/session_transition_test.go b/pkg/mcp/session_transition_test.go index a2e17a73..741e6a29 100644 --- a/pkg/mcp/session_transition_test.go +++ b/pkg/mcp/session_transition_test.go @@ -311,19 +311,14 @@ func (p *perCallFlightBackend) CreateKeg(_ context.Context, opts tapper.CreateKe return ref, nil } -func (p *perCallFlightBackend) SearchKegs(_ context.Context, query string) (mcp.KegSearchResult, error) { - p.mu.Lock() - defer p.mu.Unlock() - rows := make([]tapper.OrientationKeg, 0, len(p.kegs)) - for _, ref := range p.kegs { - namespaceAlias := strings.TrimPrefix(ref, "@") - namespace, alias, _ := strings.Cut(namespaceAlias, "/") - rows = append(rows, tapper.OrientationKeg{ - Ref: ref, Namespace: namespace, Alias: alias, Role: "admin", Source: "test", Visibility: "private", - Title: alias, Description: "summary for " + alias, - }) +// Explore answers from a fixed public directory that holds none of the +// backend's own KEGs, so a test can tell a public match from a private one. +func (p *perCallFlightBackend) Explore(_ context.Context, kind tapper.ExploreKind, query string) (tapper.ExploreResult, error) { + out := tapper.ExploreResult{Kind: kind, Results: []tapper.ExploreRow{}} + if kind == tapper.ExploreKegs && strings.Contains("public-notes", strings.ToLower(query)) { + out.Results = append(out.Results, tapper.ExploreRow{Ref: "@world/public-notes", Name: "Public notes", Description: "shared with everyone"}) } - return mcp.KegSearchResult{Kegs: mcp.SearchIdentityKegs(rows, query)}, nil + return out, nil } func (p *perCallFlightBackend) Identities(context.Context) ([]mcp.AuthIdentity, error) { @@ -338,7 +333,7 @@ func newPerCallFlightSession(t *testing.T, backend *perCallFlightBackend) (*sdkm require.NoError(t, err) server := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ OrientationProvider: backend, FlightProvider: backend, KegProvider: backend, - KegSearchProvider: backend, IdentityProvider: backend, + ExploreProvider: backend, IdentityProvider: backend, }) return connectFlightSession(t, ctx, server, nil), ctx } @@ -351,7 +346,7 @@ func newRefreshFlightSession(t *testing.T, backend *refreshFlightBackend, opts * require.NoError(t, err) server := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ OrientationProvider: backend, FlightProvider: backend, KegProvider: backend, - KegSearchProvider: backend, IdentityProvider: backend, + ExploreProvider: backend, IdentityProvider: backend, }) return connectFlightSession(t, ctx, server, opts), ctx } @@ -501,41 +496,32 @@ func TestAggregateOrientationKegsMergesEqualWinningFlightProvenance(t *testing.T require.Equal(t, []string{"@team/+child", "@team/+root"}, rows[0].Flights) } -func TestMCP_KegSearchIsIdentityScopedLiteralBoundedAndUngoverned(t *testing.T) { +func TestMCP_ExploreKegsSearchesOnlyThePublicDirectory(t *testing.T) { backend := newPerCallFlightBackend() session, ctx := newPerCallFlightSession(t, backend) - found, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_search", Arguments: map[string]any{"query": "NEW-KEG"}}) + found, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "explore_kegs", Arguments: map[string]any{"query": "PUBLIC"}}) require.NoError(t, err) require.False(t, found.IsError, extractText(t, found)) - require.Equal(t, "@team/new-keg\tadmin\tnew-keg\tsummary for new-keg\tprivate\ttest", extractText(t, found)) - require.NotContains(t, extractText(t, found), "flight") + require.Equal(t, "@world/public-notes\tPublic notes\tshared with everyone\t", extractText(t, found)) raw, err := json.Marshal(found.StructuredContent) require.NoError(t, err) - var structured mcp.KegSearchResult + var structured tapper.ExploreResult require.NoError(t, json.Unmarshal(raw, &structured)) - require.Equal(t, []mcp.KegSearchRow{{ - Ref: "@team/new-keg", Role: "admin", Title: "new-keg", - Description: "summary for new-keg", Visibility: "private", Source: "test", - }}, structured.Kegs) + require.Equal(t, tapper.ExploreKegs, structured.Kind) + require.Equal(t, []tapper.ExploreRow{{Ref: "@world/public-notes", Name: "Public notes", Description: "shared with everyone"}}, structured.Results) - empty, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_search", Arguments: map[string]any{"query": " "}}) + private, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "explore_kegs", Arguments: map[string]any{"query": "new-keg"}}) require.NoError(t, err) - require.True(t, empty.IsError) - require.Contains(t, extractText(t, empty), "query must not be empty") - - rows := make([]tapper.OrientationKeg, 0, 60) - for i := 59; i >= 0; i-- { - alias := fmt.Sprintf("match-%02d", i) - rows = append(rows, tapper.OrientationKeg{ - Ref: "@team/" + alias, Namespace: "team", Alias: alias, - Title: "A match", Description: "literal metadata", Role: "viewer", - }) + require.False(t, private.IsError, extractText(t, private)) + require.Empty(t, extractText(t, private), "the session's own KEGs are listed by keg_list, never explored") + + for _, tool := range []string{"explore_kegs", "explore_flights", "explore_people"} { + empty, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: tool, Arguments: map[string]any{"query": " "}}) + require.NoError(t, err) + require.True(t, empty.IsError, tool) + require.Contains(t, extractText(t, empty), "query must not be empty", tool) } - bounded := mcp.SearchIdentityKegs(rows, "MATCH") - require.Len(t, bounded, 50) - require.Equal(t, "@team/match-00", bounded[0].Ref) - require.Equal(t, "@team/match-49", bounded[49].Ref) } func TestMCP_PerCallSelectionAdoptsGraphAuthorityChanges(t *testing.T) { @@ -630,7 +616,7 @@ func TestMCP_AuthorityBearingSchemasExposeOptionalFlightAndRejectKegListAll(t *t session, ctx := newPerCallFlightSession(t, backend) result, err := session.ListTools(ctx, nil) require.NoError(t, err) - ungoverned := map[string]bool{"guide": true, "flight_search": true, "session_info": true, "keg_search": true, "flight_list": true, "flight_read": true, "session_refresh": true, "namespace_list": true, "namespace_search": true, "invitation_list": true, "agent_list": true, "agent_read": true} + ungoverned := map[string]bool{"guide": true, "explore_kegs": true, "explore_flights": true, "explore_people": true, "session_info": true, "flight_list": true, "flight_read": true, "session_refresh": true, "namespace_list": true, "invitation_list": true, "agent_list": true, "agent_read": true} seen := map[string]bool{} for _, tool := range result.Tools { seen[tool.Name] = true @@ -655,7 +641,7 @@ func TestMCP_AuthorityBearingSchemasExposeOptionalFlightAndRejectKegListAll(t *t if tool.Name == "session_refresh" { require.Empty(t, properties, "session_refresh must remain zero-argument") } - if tool.Name == "keg_search" { + if strings.HasPrefix(tool.Name, "explore_") { require.Contains(t, properties, "query") required, _ := schema["required"].([]any) require.Contains(t, required, "query") diff --git a/pkg/mcp/tool_allowlist.go b/pkg/mcp/tool_allowlist.go index e5b27c4f..be2c4e34 100644 --- a/pkg/mcp/tool_allowlist.go +++ b/pkg/mcp/tool_allowlist.go @@ -12,8 +12,9 @@ import ( // they carry no KEG authority. orient and guide are how a session learns what // it may do; session_info reports the identity and authority it already acts // with; session_refresh retries a failed flight activation and cannot change -// a working one. -var AlwaysAvailableTools = []string{"orient", "guide", "session_info", "session_refresh"} +// a working one; namespace_list and invitation_list report the caller's own +// memberships and pending invitations. +var AlwaysAvailableTools = []string{"orient", "guide", "session_info", "session_refresh", "namespace_list", "invitation_list"} // ToolAllowlist limits a server to an agent's tools. The zero value (and a // nil *ToolAllowlist) admits every tool. diff --git a/pkg/mcp/tool_categories_test.go b/pkg/mcp/tool_categories_test.go new file mode 100644 index 00000000..e849ae4f --- /dev/null +++ b/pkg/mcp/tool_categories_test.go @@ -0,0 +1,37 @@ +package mcp_test + +import ( + "strings" + "testing" + + "github.com/jlrickert/tapper/pkg/mcp" + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/stretchr/testify/require" +) + +// Every tool tells the agent which job it does before anything else, and +// only the public directory reaches outside the caller's own memory. +func TestToolsDeclareTheirCategory(t *testing.T) { + session, ctx := newTestSessionWithOpts(t, mcp.ServerOptions{ + AgentProvider: &fakeAgents{}, + NamespaceProvider: &fakeNamespaces{}, + ExploreProvider: &fakeExplore{}, + }) + listed, err := session.ListTools(ctx, nil) + require.NoError(t, err) + require.NotEmpty(t, listed.Tools) + + for _, tool := range listed.Tools { + category, ok := tapper.ToolCategoryOf(tool.Name) + require.Truef(t, ok, "%s has no tool category", tool.Name) + require.Truef(t, strings.HasPrefix(tool.Description, string(category)+": "), + "%s description must open with %q: %s", tool.Name, string(category)+": ", tool.Description) + + openWorld := tool.Annotations != nil && tool.Annotations.OpenWorldHint != nil && *tool.Annotations.OpenWorldHint + require.Equalf(t, category == tapper.ToolCategoryExplore, openWorld, + "%s: only Explore tools reach outside the caller's memory", tool.Name) + if strings.HasSuffix(tool.Name, "_search") { + require.Equal(t, "node_search", tool.Name, "searching is for note content and the public directory; private resources are listed") + } + } +} diff --git a/pkg/mcp/tools_agent.go b/pkg/mcp/tools_agent.go index a9c01cf7..9c32df77 100644 --- a/pkg/mcp/tools_agent.go +++ b/pkg/mcp/tools_agent.go @@ -49,8 +49,8 @@ type agentDeleteInput struct { func registerAgentTools(srv *sdkmcp.Server, defaults KegDefaults, agents AgentProvider) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "agent_list", - Description: "List Hub agents (model, description, tool allowlist, flight) in a namespace, or in every namespace you belong to", - Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(true)}, + Description: "Manage: List Hub agents (model, description, tool allowlist, flight) in a namespace, or in every namespace you belong to", + Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(false)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in agentListInput) (*sdkmcp.CallToolResult, any, error) { rows, err := agents.ListAgents(ctx, in.Namespace) if err != nil { @@ -67,8 +67,8 @@ func registerAgentTools(srv *sdkmcp.Server, defaults KegDefaults, agents AgentPr sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "agent_read", - Description: "Read one Hub agent: model, description, instructions, tool allowlist (groups and names, plus effective_tools expanded), and its memory flight", - Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(true)}, + Description: "Manage: Read one Hub agent: model, description, instructions, tool allowlist (groups and names, plus effective_tools expanded), and its memory flight", + Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(false)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in agentReadInput) (*sdkmcp.CallToolResult, any, error) { agent, err := agents.GetAgent(ctx, in.Agent) if err != nil { @@ -79,8 +79,8 @@ func registerAgentTools(srv *sdkmcp.Server, defaults KegDefaults, agents AgentPr sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "agent_create", - Description: "Create a Hub agent in a namespace you own or administer. Its tools say what it may do; its flight is the memory it works in.", - Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(true)}, + Description: "Manage: Create a Hub agent in a namespace you own or administer. Its tools say what it may do; its flight is the memory it works in.", + Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in agentCreateInput) (*sdkmcp.CallToolResult, any, error) { if err := defaults.gate.authorizeMutation(ctx); err != nil { return errorResult(err), nil, nil @@ -97,8 +97,8 @@ func registerAgentTools(srv *sdkmcp.Server, defaults KegDefaults, agents AgentPr sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "agent_edit", - Description: "Edit a Hub agent in a namespace you own or administer; omitted fields keep their current values. An edit applies to open sessions running the agent on their next call.", - Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(true)}, + Description: "Manage: Edit a Hub agent in a namespace you own or administer; omitted fields keep their current values. An edit applies to open sessions running the agent on their next call.", + Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in agentEditInput) (*sdkmcp.CallToolResult, any, error) { if err := defaults.gate.authorizeMutation(ctx); err != nil { return errorResult(err), nil, nil @@ -115,8 +115,8 @@ func registerAgentTools(srv *sdkmcp.Server, defaults KegDefaults, agents AgentPr sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "agent_delete", - Description: "Delete a Hub agent in a namespace you own or administer. Apps and tokens bound to it lose access.", - Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(true)}, + Description: "Manage: Delete a Hub agent in a namespace you own or administer. Apps and tokens bound to it lose access.", + Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in agentDeleteInput) (*sdkmcp.CallToolResult, any, error) { if err := defaults.gate.authorizeMutation(ctx); err != nil { return errorResult(err), nil, nil diff --git a/pkg/mcp/tools_agent_test.go b/pkg/mcp/tools_agent_test.go index 6f958d5b..de6ff09c 100644 --- a/pkg/mcp/tools_agent_test.go +++ b/pkg/mcp/tools_agent_test.go @@ -56,14 +56,17 @@ func (f *fakeAgents) DeleteAgent(_ context.Context, ref string) error { return nil } -type fakeNamespaces struct{ lastQuery string } +type fakeNamespaces struct{} func (f *fakeNamespaces) ListNamespaces(context.Context) ([]tapper.HubNamespace, error) { return []tapper.HubNamespace{{Name: "me", Kind: "user", Role: "owner"}}, nil } -func (f *fakeNamespaces) SearchNamespaces(_ context.Context, query string) (tapper.NamespaceSearchResult, error) { - f.lastQuery = query - return tapper.NamespaceSearchResult{Namespaces: []tapper.NamespaceMatch{{Name: "acme", Kind: "org", DisplayName: "Acme"}}}, nil + +type fakeExplore struct{ lastKind tapper.ExploreKind } + +func (f *fakeExplore) Explore(_ context.Context, kind tapper.ExploreKind, query string) (tapper.ExploreResult, error) { + f.lastKind = kind + return tapper.ExploreResult{Kind: kind, Results: []tapper.ExploreRow{{Ref: "@acme", Name: "Acme", Role: "member"}}}, nil } func callTool(t *testing.T, ctx context.Context, session *sdkmcp.ClientSession, name string, args map[string]any) *sdkmcp.CallToolResult { @@ -106,15 +109,15 @@ func TestAgentTools_CRUD(t *testing.T) { require.Empty(t, agents.byRef) } -func TestNamespaceTools_ListAndSearch(t *testing.T) { - namespaces := &fakeNamespaces{} - session, ctx := newTestSessionWithOpts(t, mcp.ServerOptions{AgentProvider: &fakeAgents{byRef: map[string]tapper.HubAgent{}}, NamespaceProvider: namespaces}) +func TestNamespaceListAndExplorePeople(t *testing.T) { + explore := &fakeExplore{} + session, ctx := newTestSessionWithOpts(t, mcp.ServerOptions{AgentProvider: &fakeAgents{byRef: map[string]tapper.HubAgent{}}, NamespaceProvider: &fakeNamespaces{}, ExploreProvider: explore}) - res := callTool(t, ctx, session, "namespace_search", map[string]any{"query": "ac"}) + res := callTool(t, ctx, session, "explore_people", map[string]any{"query": "ac"}) require.False(t, res.IsError) - require.Equal(t, "ac", namespaces.lastQuery) - found := wirePayload(t, res, "structured")["namespaces"].([]any) - require.Equal(t, "acme", found[0].(map[string]any)["name"]) + require.Equal(t, tapper.ExplorePeople, explore.lastKind) + found := wirePayload(t, res, "structured")["results"].([]any) + require.Equal(t, "@acme", found[0].(map[string]any)["ref"]) res = callTool(t, ctx, session, "namespace_list", nil) require.False(t, res.IsError) diff --git a/pkg/mcp/tools_auth.go b/pkg/mcp/tools_auth.go index 26dac506..b1060c0b 100644 --- a/pkg/mcp/tools_auth.go +++ b/pkg/mcp/tools_auth.go @@ -21,8 +21,8 @@ type authInfoOutput struct { func registerAuthInfoTool(srv *sdkmcp.Server, _ KegDefaults, identities IdentityProvider, kegs KegDiscoveryProvider) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "session_info", - Description: "Report authenticated hub identities and exact pinned-root KEG authority without exposing credentials or private account data. Use keg_list for live flight-graph discovery and keg_search for identity-accessible KEGs outside that graph", - Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(true)}, + Description: "Session: Report authenticated hub identities and exact pinned-root KEG authority without exposing credentials or private account data. Use keg_list for the KEGs this session can work in and orient for the flight and its instructions", + Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(false)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ authInfoInput) (*sdkmcp.CallToolResult, any, error) { found, err := identities.Identities(ctx) if err != nil { diff --git a/pkg/mcp/tools_doctor.go b/pkg/mcp/tools_doctor.go index b6653254..38be343a 100644 --- a/pkg/mcp/tools_doctor.go +++ b/pkg/mcp/tools_doctor.go @@ -23,7 +23,7 @@ type doctorInput struct { func registerDoctor(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_check", - Description: "Check KEG health and report issues", + Description: "Manage: Check KEG health and report issues", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_explore.go b/pkg/mcp/tools_explore.go new file mode 100644 index 00000000..2c95a235 --- /dev/null +++ b/pkg/mcp/tools_explore.go @@ -0,0 +1,100 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" +) + +// ExploreProvider searches the hub's public directory. It never sees the +// caller's private KEGs or flights: those are listed by keg_list and +// flight_list, never searched. +type ExploreProvider interface { + // Explore returns a bounded page of public matches of one kind. + Explore(ctx context.Context, kind tapper.ExploreKind, query string) (tapper.ExploreResult, error) +} + +type localExploreProvider struct{ tap *tapper.Tap } + +func (p localExploreProvider) Explore(ctx context.Context, kind tapper.ExploreKind, query string) (tapper.ExploreResult, error) { + opts := tapper.ExploreOptions{Query: query} + switch kind { + case tapper.ExploreKegs: + return p.tap.ExploreKegs(ctx, opts) + case tapper.ExploreFlights: + return p.tap.ExploreFlights(ctx, opts) + case tapper.ExplorePeople: + return p.tap.ExplorePeople(ctx, opts) + } + return tapper.ExploreResult{}, fmt.Errorf("%w: unknown explore kind %q", keg.ErrInvalid, kind) +} + +type exploreInput struct { + Query string `json:"query" jsonschema:"literal text to match against public names, references, and descriptions"` +} + +// registerExploreTools exposes the public directory, at parity with +// `tap explore kegs|flights|people`. These are the only search tools over +// resources rather than note content; everything the caller already holds is +// listed instead. +func registerExploreTools(srv *sdkmcp.Server, explore ExploreProvider) { + tools := []struct { + name string + kind tapper.ExploreKind + desc string + }{ + { + name: "explore_kegs", kind: tapper.ExploreKegs, + desc: "Explore: search the hub's public KEGs by reference, title, or description. " + + "Discovery only: a result grants no access, and a KEG outside the flight still needs a flight that covers it. " + + "Your own KEGs come from keg_list; notes come from node_search, never from here.", + }, + { + name: "explore_flights", kind: tapper.ExploreFlights, + desc: "Explore: search the hub's public flights by reference, title, or description. " + + "Discovery only: returns metadata, never instructions, and grants no access. Your own flights come from flight_list.", + }, + { + name: "explore_people", kind: tapper.ExplorePeople, + desc: "Explore: search the people and org namespaces on the hub by name or display name. " + + "Discovery only: role is set where you already belong. Your own namespaces come from namespace_list.", + }, + } + for _, tool := range tools { + kind := tool.kind + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: tool.name, + Description: tool.desc, + Annotations: &sdkmcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: boolPtr(true), + }, + }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in exploreInput) (*sdkmcp.CallToolResult, any, error) { + query := strings.TrimSpace(in.Query) + if query == "" { + return errorResult(fmt.Errorf("%w: query must not be empty; list your own resources instead", keg.ErrInvalid)), nil, nil + } + found, err := explore.Explore(ctx, kind, query) + if err != nil { + return errorResult(err), nil, nil + } + lines := make([]string, 0, len(found.Results)+1) + if found.Truncated { + lines = append(lines, "Results truncated; refine the query.") + } + for _, row := range found.Results { + lines = append(lines, strings.Join([]string{ + row.Ref, tsvField(row.Name), tsvField(row.Description), row.Role, + }, "\t")) + } + res := linesResult(lines) + res.StructuredContent = found + return res, nil, nil + }) + } +} diff --git a/pkg/mcp/tools_files.go b/pkg/mcp/tools_files.go index e9e05406..05bb9566 100644 --- a/pkg/mcp/tools_files.go +++ b/pkg/mcp/tools_files.go @@ -53,7 +53,7 @@ type listFilesInput struct { func registerListFiles(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "file_list", - Description: "List file attachments for a node", + Description: "Memory: List file attachments for a node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -84,7 +84,7 @@ type listImagesInput struct { func registerListImages(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "image_list", - Description: "List image attachments for a node", + Description: "Memory: List image attachments for a node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -141,7 +141,7 @@ type deleteFileInput struct { func registerDeleteFile(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "file_delete", - Description: "Delete a file attachment from a node", + Description: "Memory: Delete a file attachment from a node", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), @@ -175,7 +175,7 @@ type deleteImageInput struct { func registerDeleteImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "image_delete", - Description: "Delete an image attachment from a node", + Description: "Memory: Delete an image attachment from a node", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), @@ -225,7 +225,7 @@ type localUploadFileInput struct { func registerUploadFile(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, uploadFileTool( - "Upload a file attachment to a node from raw bytes, a data URI, or an embedded resource", + "Memory: Upload a file attachment to a node from raw bytes, a data URI, or an embedded resource", ), func(ctx context.Context, req *sdkmcp.CallToolRequest, in uploadFileInput) (*sdkmcp.CallToolResult, any, error) { return handleFileUpload(ctx, tap, defaults, in.Keg, in.NodeID, in.Filename, uploadSourceInput{ SourceURI: in.SourceURI, @@ -237,7 +237,7 @@ func registerUploadFile(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefault func registerLocalUploadFile(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, uploadFileTool( - "Upload a file attachment to a node from a local path, raw bytes, a data URI, or an embedded resource", + "Memory: Upload a file attachment to a node from a local path, raw bytes, a data URI, or an embedded resource", ), func(ctx context.Context, req *sdkmcp.CallToolRequest, in localUploadFileInput) (*sdkmcp.CallToolResult, any, error) { return handleFileUpload(ctx, tap, defaults, in.Keg, in.NodeID, in.Filename, uploadSourceInput{ SourcePath: in.SourcePath, @@ -318,7 +318,7 @@ type downloadFileInput struct { func registerDownloadFile(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "file_download", - Description: "Download a file attachment from a node to a local file path", + Description: "Memory: Download a file attachment from a node to a local file path", Annotations: &sdkmcp.ToolAnnotations{ OpenWorldHint: boolPtr(false), }, @@ -367,7 +367,7 @@ type localUploadImageInput struct { func registerUploadImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, uploadImageTool( - "Upload an image attachment to a node from raw bytes, a data URI, or an embedded resource", + "Memory: Upload an image attachment to a node from raw bytes, a data URI, or an embedded resource", ), func(ctx context.Context, req *sdkmcp.CallToolRequest, in uploadImageInput) (*sdkmcp.CallToolResult, any, error) { return handleImageUpload(ctx, tap, defaults, in.Keg, in.NodeID, in.Filename, uploadSourceInput{ SourceURI: in.SourceURI, @@ -379,7 +379,7 @@ func registerUploadImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaul func registerLocalUploadImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, uploadImageTool( - "Upload an image attachment to a node from a local path, raw bytes, a data URI, or an embedded resource", + "Memory: Upload an image attachment to a node from a local path, raw bytes, a data URI, or an embedded resource", ), func(ctx context.Context, req *sdkmcp.CallToolRequest, in localUploadImageInput) (*sdkmcp.CallToolResult, any, error) { return handleImageUpload(ctx, tap, defaults, in.Keg, in.NodeID, in.Filename, uploadSourceInput{ SourcePath: in.SourcePath, @@ -445,7 +445,7 @@ type localDownloadImageInput struct { func registerDownloadImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, downloadImageTool( - "Return an image attachment from a node as MCP image content", + "Memory: Return an image attachment from a node as MCP image content", ), func(ctx context.Context, req *sdkmcp.CallToolRequest, in downloadImageInput) (*sdkmcp.CallToolResult, any, error) { return readImageContent(ctx, tap, defaults, in.Keg, in.NodeID, in.Filename) }) @@ -453,7 +453,7 @@ func registerDownloadImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefa func registerLocalDownloadImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, downloadImageTool( - "Download an image attachment from a node, either to a local file path or as MCP image content", + "Memory: Download an image attachment from a node, either to a local file path or as MCP image content", ), func(ctx context.Context, req *sdkmcp.CallToolRequest, in localDownloadImageInput) (*sdkmcp.CallToolResult, any, error) { dest := strings.TrimSpace(in.DestPath) if dest == "" { diff --git a/pkg/mcp/tools_flight.go b/pkg/mcp/tools_flight.go index 13a45ccd..4ecf3851 100644 --- a/pkg/mcp/tools_flight.go +++ b/pkg/mcp/tools_flight.go @@ -48,10 +48,9 @@ type flightDeleteInput struct { // `flight edit`: agents cannot open editors, so the partial-edit tool // (omitted fields keep their current values) remains the MCP surface. func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights FlightProvider) { - registerFlightSearch(srv, flights) sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "flight_list", - Description: "List available flights (keg restrictions + agent instructions)", + Description: "Memory: List available flights (keg restrictions + agent instructions)", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -66,7 +65,7 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "flight_read", - Description: "Inspect a permission-filtered flight manifest, including declared cover roles, depth, subflights, instructions, and hash. Inspection never activates a session or selects call authority. Cover is declared cover, not effective authority; use orient with flight for current authority. Pass hash as expected_hash to flight_edit or flight_delete.", + Description: "Memory: Inspect a permission-filtered flight manifest, including declared cover roles, depth, subflights, instructions, and hash. Inspection never activates a session or selects call authority. Cover is declared cover, not effective authority; use orient with flight for current authority. Pass hash as expected_hash to flight_edit or flight_delete.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -83,11 +82,11 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "flight_create", - Description: "Create a Hub-backed flight (cover roles + agent instructions)", + Description: "Manage: Create a Hub-backed flight (cover roles + agent instructions)", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, DestructiveHint: boolPtr(false), - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in flightCreateInput) (*sdkmcp.CallToolResult, any, error) { cover, err := tapper.ParseFlightCoverSpecs(in.Cover) @@ -116,11 +115,11 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "flight_edit", - Description: "Call flight_read first, then edit a Hub-backed flight using its manifest hash as expected_hash; omitted fields keep their current values. On conflict, merge into the returned current flight (or refetch with flight_read) and retry with the returned current hash.", + Description: "Manage: Call flight_read first, then edit a Hub-backed flight using its manifest hash as expected_hash; omitted fields keep their current values. On conflict, merge into the returned current flight (or refetch with flight_read) and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, DestructiveHint: boolPtr(true), - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in flightEditInput) (*sdkmcp.CallToolResult, any, error) { if err := defaults.gate.authorizeMutation(ctx); err != nil { @@ -163,11 +162,11 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "flight_delete", - Description: "Call flight_read first, then delete a Hub-backed flight using its manifest hash as expected_hash. On conflict, refetch with flight_read and retry with the returned current hash.", + Description: "Manage: Call flight_read first, then delete a Hub-backed flight using its manifest hash as expected_hash. On conflict, refetch with flight_read and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, DestructiveHint: boolPtr(true), - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in flightDeleteInput) (*sdkmcp.CallToolResult, any, error) { if err := defaults.gate.authorizeMutation(ctx); err != nil { diff --git a/pkg/mcp/tools_flight_search.go b/pkg/mcp/tools_flight_search.go deleted file mode 100644 index de400387..00000000 --- a/pkg/mcp/tools_flight_search.go +++ /dev/null @@ -1,90 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - "sort" - "strings" - - "github.com/jlrickert/tapper/pkg/keg" - "github.com/jlrickert/tapper/pkg/tapper" - sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// FlightCatalogProvider supplies an identity-filtered catalog without per-resource reads. -type FlightCatalogProvider interface { - // FlightCatalog returns readable flights from one catalog projection. - FlightCatalog(context.Context) ([]*tapper.Flight, error) -} - -// FlightSearchRow is descriptive metadata and confers no authority. -type FlightSearchRow struct { - Ref string `json:"ref"` - Title string `json:"title"` - Description string `json:"description"` -} - -// FlightSearchResult is a bounded deterministic metadata result. -type FlightSearchResult struct { - Flights []FlightSearchRow `json:"flights"` - Truncated bool `json:"truncated"` -} - -// SearchIdentityFlights matches a literal query over readable metadata. -func SearchIdentityFlights(rows []*tapper.Flight, query string) FlightSearchResult { - out := FlightSearchResult{Flights: []FlightSearchRow{}} - query = strings.ToLower(strings.TrimSpace(query)) - if query == "" { - return out - } - seen := map[string]bool{} - for _, f := range rows { - if f == nil || seen[f.Name] { - continue - } - seen[f.Name] = true - if !strings.Contains(strings.ToLower(f.Name+"\n"+f.Title+"\n"+f.Description), query) { - continue - } - title := f.Title - if strings.TrimSpace(title) == "" { - title = f.Name - } - out.Flights = append(out.Flights, FlightSearchRow{Ref: f.Name, Title: title, Description: f.Description}) - } - sort.Slice(out.Flights, func(i, j int) bool { return out.Flights[i].Ref < out.Flights[j].Ref }) - if len(out.Flights) > 50 { - out.Truncated = true - out.Flights = out.Flights[:50] - } - return out -} -func registerFlightSearch(srv *sdkmcp.Server, flights FlightProvider) { - sdkmcp.AddTool(srv, &sdkmcp.Tool{Name: "flight_search", Description: "Search readable flight reference, title, and description by literal query. Returns at most 50 deterministic metadata results, never instructions. Refine the query if truncated=true; there is no cursor. Results confer no access.", Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(true)}}, func(ctx context.Context, req *sdkmcp.CallToolRequest, in kegSearchInput) (*sdkmcp.CallToolResult, any, error) { - if strings.TrimSpace(in.Query) == "" { - return errorResult(fmt.Errorf("%w: query must not be empty", keg.ErrInvalid)), nil, nil - } - provider, ok := flights.(FlightCatalogProvider) - if !ok { - return errorResult(fmt.Errorf("%w: flight catalog unavailable", keg.ErrNotSupported)), nil, nil - } - rows, err := provider.FlightCatalog(ctx) - if err != nil { - return errorResult(err), nil, nil - } - out := SearchIdentityFlights(rows, in.Query) - lines := []string{} - for _, row := range out.Flights { - lines = append(lines, tsvField(row.Title)+"\t"+row.Ref+"\t"+tsvField(row.Description)) - } - if out.Truncated { - lines = append(lines, "Results truncated to 50 flights; refine the query.") - } - res := linesResult(lines) - res.StructuredContent = out - return res, nil, nil - }) -} -func (p localFlightProvider) FlightCatalog(ctx context.Context) ([]*tapper.Flight, error) { - return p.tap.FlightService.FlightCatalog(ctx, "", nil) -} diff --git a/pkg/mcp/tools_index.go b/pkg/mcp/tools_index.go index 1cdb7ac0..98c969ad 100644 --- a/pkg/mcp/tools_index.go +++ b/pkg/mcp/tools_index.go @@ -24,7 +24,7 @@ type indexInput struct { func registerIndex(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "index_rebuild", - Description: "Rebuild all KEG indexes from scratch (nodes, tags, links, backlinks)", + Description: "Manage: Rebuild all KEG indexes from scratch (nodes, tags, links, backlinks)", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), IdempotentHint: true, @@ -51,7 +51,7 @@ type listIndexesInput struct { func registerListIndexes(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "index_list", - Description: "List available index files for a KEG", + Description: "Memory: List available index files for a KEG", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -78,7 +78,7 @@ type indexCatInput struct { func registerIndexCat(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "index_read", - Description: "Read the raw contents of a KEG index file", + Description: "Memory: Read the raw contents of a KEG index file", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_keg.go b/pkg/mcp/tools_keg.go index 331a8e82..497962ea 100644 --- a/pkg/mcp/tools_keg.go +++ b/pkg/mcp/tools_keg.go @@ -15,20 +15,18 @@ import ( type kegListInput struct{} type kegListRow struct { - Ref string `json:"ref"` - Role string `json:"role"` - Flights []string `json:"flights"` - KegVersion string `json:"kegv,omitempty"` + Ref string `json:"ref"` + Role string `json:"role"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Flights []string `json:"flights"` + KegVersion string `json:"kegv,omitempty"` } type kegListOutput struct { Kegs []kegListRow `json:"kegs"` } -type kegSearchInput struct { - Query string `json:"query" jsonschema:"required,non-empty case-insensitive literal query matched against canonical ref, title, and description"` -} - type kegCreateInput struct { Keg string `json:"keg" jsonschema:"required,qualified creation reference @namespace/keg; each component is 1-64 lowercase letters, digits, or hyphens and starts with a letter or digit"` Title string `json:"title,omitempty" jsonschema:"human-readable keg title"` @@ -41,14 +39,14 @@ type kegCreateInput struct { // with normal namespace membership; whether it holds keg_create at all is its // agent's tool list, gated by the host. Transport-specific hub selection // is intentionally absent from the agent surface. -func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscoveryProvider, search KegSearchProvider) { +func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscoveryProvider) { registerKegDelete(srv, defaults, kegs) sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_list", - Description: "Discover canonical KEGs and granting-flight provenance. Without a configured root, returns identity-accessible KEGs at their real roles. With a pinned root, omission aggregates its accessible transitive graph for discovery; a discovered child-only KEG still requires that child as flight on operational calls. Supplying flight returns exactly that flight projection", + Description: "Memory: List every KEG this session can work in, with role, title, description, and granting-flight provenance. The list is complete, so read it rather than searching; explore_kegs searches public KEGs you do not hold. Without a configured root, returns identity-accessible KEGs at their real roles. With a pinned root, omission aggregates its accessible transitive graph for discovery; a discovered child-only KEG still requires that child as flight on operational calls. Supplying flight returns exactly that flight projection", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in kegListInput) (*sdkmcp.CallToolResult, any, error) { if HasSessionOrientation(ctx) { @@ -58,12 +56,16 @@ func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscover for _, row := range rows { effective := EffectiveOrientationRole(row) flights := append([]string{}, row.Flights...) - out.Kegs = append(out.Kegs, kegListRow{Ref: row.Ref, Role: effective, Flights: flights, KegVersion: row.KegVersion}) - line := row.Ref + "\t" + effective + "\t" + strings.Join(flights, ",") - if label := tapper.KegVersionLabel(row.KegVersion); label != "" { - line += "\t" + label - } - lines = append(lines, line) + out.Kegs = append(out.Kegs, kegListRow{ + Ref: row.Ref, Role: effective, Title: row.Title, Description: row.Description, + Flights: flights, KegVersion: row.KegVersion, + }) + // Title and description trail so a row without them keeps the + // ref, role, and flights columns unpadded. + lines = append(lines, strings.TrimRight(strings.Join([]string{ + row.Ref, effective, strings.Join(flights, ","), + tsvField(tapper.KegTitleWithVersion(row.Title, row.KegVersion)), tsvField(row.Description), + }, "\t"), "\t")) } res := linesResult(lines) res.StructuredContent = out @@ -87,47 +89,14 @@ func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscover return res, nil, nil }) - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "keg_search", - Description: "Search identity-accessible KEG metadata on the connection-pinned Hub. Search results never grant access: no-flight calls may operate at the returned identity role, while a real-flight call must also cover the KEG", - Annotations: &sdkmcp.ToolAnnotations{ - ReadOnlyHint: true, - OpenWorldHint: boolPtr(true), - }, - }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in kegSearchInput) (*sdkmcp.CallToolResult, any, error) { - query := strings.TrimSpace(in.Query) - if query == "" { - return errorResult(fmt.Errorf("%w: query must not be empty", keg.ErrInvalid)), nil, nil - } - found, err := search.SearchKegs(ctx, query) - if err != nil { - return errorResult(err), nil, nil - } - lines := make([]string, 0, len(found.Warnings)+len(found.Kegs)) - if found.Truncated { - lines = append(lines, "Results truncated to 50 KEGs; refine the query.") - } - for _, warning := range found.Warnings { - lines = append(lines, "Warning: "+tsvField(warning)) - } - for _, row := range found.Kegs { - lines = append(lines, strings.Join([]string{ - row.Ref, row.Role, tsvField(tapper.KegTitleWithVersion(row.Title, row.KegVersion)), tsvField(row.Description), row.Visibility, row.Source, - }, "\t")) - } - res := linesResult(lines) - res.StructuredContent = found - return res, nil, nil - }) - sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_create", - Description: "Create a new KEG in a namespace you belong to. Creating a KEG " + + Description: "Manage: Create a new KEG in a namespace you belong to. Creating a KEG " + "never adds it to a flight's cover.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, DestructiveHint: boolPtr(false), - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in kegCreateInput) (*sdkmcp.CallToolResult, any, error) { // The gate refuses this tool before dispatch; the check is repeated here @@ -208,7 +177,7 @@ type kegDeleteInput struct { } func registerKegDelete(srv *sdkmcp.Server, defaults KegDefaults, provider KegDiscoveryProvider) { - sdkmcp.AddTool(srv, &sdkmcp.Tool{Name: "keg_delete", Description: "Permanently delete an empty or populated KEG and all its data, including snapshots. Requires identity admin permission; a selected flight also requires effective admin cover. No expected_hash: a settings hash does not cover a whole KEG.", Annotations: &sdkmcp.ToolAnnotations{DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(true)}}, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in kegDeleteInput) (*sdkmcp.CallToolResult, any, error) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{Name: "keg_delete", Description: "Manage: Permanently delete an empty or populated KEG and all its data, including snapshots. Requires identity admin permission; a selected flight also requires effective admin cover. No expected_hash: a settings hash does not cover a whole KEG.", Annotations: &sdkmcp.ToolAnnotations{DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false)}}, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in kegDeleteInput) (*sdkmcp.CallToolResult, any, error) { ns, alias, err := tapper.ParseCanonicalKegRef(in.Keg) if err != nil { return errorResult(fmt.Errorf("keg must be an explicit canonical @namespace/keg reference: %w", err)), nil, nil diff --git a/pkg/mcp/tools_lock.go b/pkg/mcp/tools_lock.go index b083096b..437400c7 100644 --- a/pkg/mcp/tools_lock.go +++ b/pkg/mcp/tools_lock.go @@ -27,7 +27,7 @@ type lockAcquireInput struct { func registerLockAcquire(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "lock_acquire", - Description: "Acquire a cross-process lock on a node and return the token", + Description: "Memory: Acquire a cross-process lock on a node and return the token", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false), @@ -56,7 +56,7 @@ type lockReleaseInput struct { func registerLockRelease(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "lock_release", - Description: "Release a cross-process lock on a node", + Description: "Memory: Release a cross-process lock on a node", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false), @@ -84,7 +84,7 @@ type lockStatusInput struct { func registerLockStatus(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "lock_status", - Description: "Check the lock state of a node", + Description: "Memory: Check the lock state of a node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -120,7 +120,7 @@ type lockForceReleaseInput struct { func registerLockForceRelease(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "lock_force_release", - Description: "Unconditionally remove a lock on a node", + Description: "Manage: Unconditionally remove a lock on a node", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_namespace.go b/pkg/mcp/tools_namespace.go index 21373fe6..12758e01 100644 --- a/pkg/mcp/tools_namespace.go +++ b/pkg/mcp/tools_namespace.go @@ -11,10 +11,6 @@ import ( type namespaceListInput struct{} -type namespaceSearchInput struct { - Query string `json:"query,omitempty" jsonschema:"text to match against namespace names and display names; empty browses"` -} - // InvitationLister is implemented by namespace providers that can list the // caller's pending invitations. It is optional so a provider without it still // satisfies NamespaceProvider; invitation_list registers only when present. @@ -24,7 +20,7 @@ type InvitationLister interface { } // registerNamespaceTools exposes namespace discovery over MCP, at parity with -// `tap namespace list` and `tap namespace search`. Discovery never grants +// `tap namespace list`. The hub-wide people directory is explore_people. Listing never grants // authority. Member rosters and role changes stay UI-only, so those tools are // intentionally not registered. Pending invitations are listed read-only: // accepting or declining access is the account holder's own decision, made in @@ -33,10 +29,10 @@ func registerNamespaceTools(srv *sdkmcp.Server, namespaces NamespaceProvider) { if lister, ok := namespaces.(InvitationLister); ok { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "invitation_list", - Description: "List pending invitations addressed to you: org memberships and keg grants that take effect only if you accept them. Read-only; accepting or declining is your user's decision, made in the Hub UI or with `tap invitation`, so ask them rather than acting.", + Description: "Session: List pending invitations addressed to you: org memberships and keg grants that take effect only if you accept them. Read-only; accepting or declining is your user's decision, made in the Hub UI or with `tap invitation`, so ask them rather than acting.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ namespaceListInput) (*sdkmcp.CallToolResult, any, error) { rows, err := lister.ListInvitations(ctx) @@ -54,10 +50,10 @@ func registerNamespaceTools(srv *sdkmcp.Server, namespaces NamespaceProvider) { } sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "namespace_list", - Description: "List the namespaces you belong to, with your role in each", + Description: "Session: List the namespaces you belong to, with your role in each", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ namespaceListInput) (*sdkmcp.CallToolResult, any, error) { rows, err := namespaces.ListNamespaces(ctx) @@ -72,28 +68,4 @@ func registerNamespaceTools(srv *sdkmcp.Server, namespaces NamespaceProvider) { res.StructuredContent = map[string]any{"namespaces": rows} return res, nil, nil }) - - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "namespace_search", - Description: "Search the people and org namespaces visible on the Hub, not only your own. Results are discovery only and never grant access; role is set where you are a member. Use keg_search and flight_search to find their kegs and flights.", - Annotations: &sdkmcp.ToolAnnotations{ - ReadOnlyHint: true, - OpenWorldHint: boolPtr(true), - }, - }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in namespaceSearchInput) (*sdkmcp.CallToolResult, any, error) { - found, err := namespaces.SearchNamespaces(ctx, in.Query) - if err != nil { - return errorResult(err), nil, nil - } - lines := make([]string, 0, len(found.Namespaces)+1) - if found.Truncated { - lines = append(lines, "Results truncated; refine the query.") - } - for _, ns := range found.Namespaces { - lines = append(lines, fmt.Sprintf("@%s\t%s\t%s\t%s", ns.Name, ns.Kind, tsvField(ns.DisplayName), ns.Role)) - } - res := linesResult(lines) - res.StructuredContent = found - return res, nil, nil - }) } diff --git a/pkg/mcp/tools_orient.go b/pkg/mcp/tools_orient.go index d2d27740..110b2621 100644 --- a/pkg/mcp/tools_orient.go +++ b/pkg/mcp/tools_orient.go @@ -28,7 +28,7 @@ func registerOrient(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { // instructions arrive in this tool's *result*, which lives in the // conversation and is exactly what a clear or compact discards — so the // instruction to come back must live somewhere the reset cannot reach. - Description: "Call this first in every session, before any other tool and before " + + Description: "Session: Call this first in every session, before any other tool and before " + "replying to the user — not only when KEG work is expected. The active flight " + "carries this session's operating instructions, so until you orient you do not " + "know what the session is for or which KEGs you may touch. Returns that flight, " + @@ -39,7 +39,7 @@ func registerOrient(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { "not — orient. This tool is read-only and never activates or changes the connection's " + "session state. Every authority-bearing call accepts an optional top-level flight. " + "With no flight, omission uses all identity-authorized KEGs while an explicit value selects any listed real flight exactly. " + - "Orient reveals effective readable KEGs and immediate readable child flights. With no configured root it is search-first; use keg_search and flight_search to discover resources. Call orient with a child reference to reveal the next level. Explicit descendant selection remains limited to the authorized graph. Calls reload live authority independently, so concurrent agents may use different flights without " + + "Orient reveals effective readable KEGs and immediate readable child flights. With no configured root it lists every KEG your identity reaches; keg_list and flight_list are the complete lists, and explore_kegs and explore_flights search the public directory. Call orient with a child reference to reveal the next level. Explicit descendant selection remains limited to the authorized graph. Calls reload live authority independently, so concurrent agents may use different flights without " + "changing shared session state. " + "If an explicitly configured root fails to load, the session fails closed with only recovery tools. " + "When no root is configured, no-flight full access stays pinned until the connection ends; pin a restrictive flight outside MCP and start a new connection.", @@ -63,13 +63,13 @@ func registerOrient(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { func registerSessionRefresh(srv *sdkmcp.Server, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "session_refresh", - Description: "Retry MCP session activation after a failed configured flight becomes available. " + + Description: "Session: Retry MCP session activation after a failed configured flight becomes available. " + "Takes no arguments and never changes an already-active connection. A no-flight full-access " + "connection therefore requires a new MCP connection after a restrictive flight is pinned.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, DestructiveHint: boolPtr(false), - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ sessionRefreshInput) (*sdkmcp.CallToolResult, any, error) { out, err := defaults.gate.refresh(ctx, sessionIDFromContext(ctx)) @@ -100,7 +100,7 @@ type guideInput struct { } func registerGuide(srv *sdkmcp.Server) { - sdkmcp.AddTool(srv, &sdkmcp.Tool{Name: "guide", Description: "Read canonical KEG guidance on demand by topic.", Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(false)}}, func(ctx context.Context, req *sdkmcp.CallToolRequest, in guideInput) (*sdkmcp.CallToolResult, any, error) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{Name: "guide", Description: "Session: Read canonical KEG guidance on demand by topic.", Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(false)}}, func(ctx context.Context, req *sdkmcp.CallToolRequest, in guideInput) (*sdkmcp.CallToolResult, any, error) { text, err := tapper.OrientationGuide(in.Topic) if err != nil { return errorResult(err), nil, nil diff --git a/pkg/mcp/tools_read.go b/pkg/mcp/tools_read.go index 1cf8f1f3..13bb248f 100644 --- a/pkg/mcp/tools_read.go +++ b/pkg/mcp/tools_read.go @@ -86,7 +86,7 @@ func nodeReadSummary(count int) string { func registerCat(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_read", - Description: "Read KEG nodes using node_ids or query; they are mutually exclusive, and omitting both returns no nodes. The default returns metadata and content together; " + + Description: "Memory: Read KEG nodes using node_ids or query; they are mutually exclusive, and omitting both returns no nodes. The default returns metadata and content together; " + "meta_only returns just the metadata document, which is how you read metadata before " + "editing it. Each result carries content_hash (when content was read) and meta_hash (when " + "metadata was read): pass content_hash as expected_content_hash and meta_hash as " + @@ -138,7 +138,7 @@ type listInput struct { func registerList(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_list", - Description: "List KEG nodes, optionally filtered by a query expression", + Description: "Memory: List KEG nodes, optionally filtered by a query expression", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -179,7 +179,7 @@ type grepInput struct { func registerGrep(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_grep", - Description: "Match KEG node content lines against a regex pattern in one KEG, in node id order. Use it for exact identifiers, symbols, and audits; use node_search to find the nodes about a topic. max_lines defaults to 3 matched lines per node; -1 returns all matched lines. Follow next_offset for more nodes; use node_read for complete bodies.", + Description: "Memory: Match KEG node content lines against a regex pattern in one KEG, in node id order. Use it for exact identifiers, symbols, and audits; use node_search to find the nodes about a topic. max_lines defaults to 3 matched lines per node; -1 returns all matched lines. Follow next_offset for more nodes; use node_read for complete bodies.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -219,7 +219,7 @@ type tagsInput struct { func registerTags(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "tag_list", - Description: "List tags or filter nodes by tag expression", + Description: "Memory: List tags or filter nodes by tag expression", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -257,7 +257,7 @@ type backlinksInput struct { func registerBacklinks(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_backlinks", - Description: "List nodes that link to a given node", + Description: "Memory: List nodes that link to a given node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -295,7 +295,7 @@ type linksInput struct { func registerLinks(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_links", - Description: "List outgoing links from a node", + Description: "Memory: List outgoing links from a node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -329,7 +329,7 @@ type kegSettingsInput struct { func registerKegSettings(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_settings_read", - Description: "Read targeted KEG settings and instructions. Defaults to a minimal summary; set minimal=false for one complete YAML data document and its hash, required by keg_settings_edit. Use keg or 1-100 kegs, never both.", + Description: "Memory: Read targeted KEG settings and instructions. Defaults to a minimal summary; set minimal=false for one complete YAML data document and its hash, required by keg_settings_edit. Use keg or 1-100 kegs, never both.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -392,7 +392,7 @@ type infoInput struct { func registerInfo(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_info", - Description: "Show concise path-free diagnostics for a resolved KEG (canonical ref, flight, description, node count, and capabilities)", + Description: "Memory: Show concise path-free diagnostics for a resolved KEG (canonical ref, flight, description, node count, and capabilities)", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -419,7 +419,7 @@ type statsInput struct { func registerStats(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_stats", - Description: "Show stats (hash, timestamps, access count) for a node", + Description: "Memory: Show stats (hash, timestamps, access count) for a node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_schema.go b/pkg/mcp/tools_schema.go index f4b298e7..dcf42f46 100644 --- a/pkg/mcp/tools_schema.go +++ b/pkg/mcp/tools_schema.go @@ -37,7 +37,7 @@ type schemaListInput struct { func registerSchemaList(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_list", - Description: "List the schema type names defined for a keg", + Description: "Memory: List the schema type names defined for a keg", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -66,7 +66,7 @@ type schemaReadInput struct { func registerSchemaRead(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_read", - Description: "Read one schema definition as YAML. The result carries the schema's " + + Description: "Memory: Read one schema definition as YAML. The result carries the schema's " + "hash; pass it back as expected_hash when editing this schema.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, @@ -100,7 +100,7 @@ type schemaCreateInput struct { func registerSchemaCreate(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_create", - Description: "Create a new keg schema from a YAML definition (fails if the type already exists). Requires admin access to the KEG, and admin cover when a flight is selected", + Description: "Manage: Create a new keg schema from a YAML definition (fails if the type already exists). Requires admin access to the KEG, and admin cover when a flight is selected", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false), @@ -128,7 +128,7 @@ type schemaEditInput struct { func registerSchemaEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_edit", - Description: "Call schema_read first, then replace an existing keg schema with a new YAML definition using its hash as expected_hash. Requires admin access to the KEG, and admin cover when a flight is selected. On conflict, merge into the returned current schema (or refetch with schema_read) and retry with the returned current hash.", + Description: "Manage: Change how a KEG validates notes; writing a note never needs this, so pick an existing type from schema_list instead. Call schema_read first, then replace an existing keg schema with a new YAML definition using its hash as expected_hash. Requires admin access to the KEG, and admin cover when a flight is selected. On conflict, merge into the returned current schema (or refetch with schema_read) and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false), @@ -160,7 +160,7 @@ type schemaDeleteInput struct { func registerSchemaDelete(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_delete", - Description: "Call schema_read first, then delete a keg schema using its hash as expected_hash. Requires admin access to the KEG, and admin cover when a flight is selected. On conflict, refetch with schema_read and retry with the returned current hash.", + Description: "Manage: Call schema_read first, then delete a keg schema using its hash as expected_hash. Requires admin access to the KEG, and admin cover when a flight is selected. On conflict, refetch with schema_read and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), @@ -187,7 +187,7 @@ type validateInput struct { func registerValidate(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_validate", - Description: "Validate nodes against their declared schema type", + Description: "Memory: Validate nodes against their declared schema type", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_search.go b/pkg/mcp/tools_search.go index a63255f5..58112ddf 100644 --- a/pkg/mcp/tools_search.go +++ b/pkg/mcp/tools_search.go @@ -28,7 +28,8 @@ type nodeSearchOutput struct { func registerNodeSearch(srv *sdkmcp.Server, defaults KegDefaults, provider NodeSearchProvider) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_search", - Description: "Find the KEG nodes most relevant to a query, ranked across every KEG this call may read, one named keg, or the listed kegs. " + + Description: "Memory: Find notes about a topic: the KEG nodes most relevant to a query, ranked across every KEG this call may read (omit keg), one named keg, or the listed kegs. " + + "This is the only search over note content: explore_kegs finds public KEGs, never notes, and keg_list lists your own KEGs. " + "Matches titles, metadata, tags, and bodies. Use node_grep for exact regex matching of identifiers or symbols. " + "Each hit gives the node reference, score, title, matched fields, and a snippet with matches in **bold**; " + "follow next_cursor for more and use node_read for complete bodies.", diff --git a/pkg/mcp/tools_search_test.go b/pkg/mcp/tools_search_test.go index 6f044dbe..95088c59 100644 --- a/pkg/mcp/tools_search_test.go +++ b/pkg/mcp/tools_search_test.go @@ -155,7 +155,7 @@ func newFlightSearchSession(t *testing.T, mode string, provider mcp.NodeSearchPr require.NoError(t, err) server := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ OrientationProvider: backend, FlightProvider: backend, KegProvider: backend, - KegSearchProvider: backend, IdentityProvider: backend, NodeSearchProvider: provider, + ExploreProvider: backend, IdentityProvider: backend, NodeSearchProvider: provider, }) return connectFlightSession(t, ctx, server, nil), ctx } diff --git a/pkg/mcp/tools_snapshot.go b/pkg/mcp/tools_snapshot.go index 28589790..f6dddd10 100644 --- a/pkg/mcp/tools_snapshot.go +++ b/pkg/mcp/tools_snapshot.go @@ -27,7 +27,7 @@ type nodeHistoryInput struct { func registerNodeHistory(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "snapshot_list", - Description: "List snapshot history for a node", + Description: "Memory: List snapshot history for a node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -76,7 +76,7 @@ type nodeSnapshotOutput struct { func registerNodeSnapshot(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "snapshot_create", - Description: "Atomically snapshot the current state of 1-100 nodes", + Description: "Memory: Atomically snapshot the current state of 1-100 nodes", InputSchema: boundedMutationInputSchema[nodeSnapshotInput]("nodes"), Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), @@ -116,7 +116,7 @@ type nodeSnapshotViewInput struct { func registerNodeSnapshotView(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "snapshot_read", - Description: "View read-only content for a previous snapshot revision", + Description: "Memory: View read-only content for a previous snapshot revision", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -146,7 +146,7 @@ type nodeRestoreInput struct { func registerNodeRestore(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "snapshot_restore", - Description: "Restore a node to a previous snapshot revision", + Description: "Memory: Restore a node to a previous snapshot revision", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_write.go b/pkg/mcp/tools_write.go index d95299ee..df1f29fa 100644 --- a/pkg/mcp/tools_write.go +++ b/pkg/mcp/tools_write.go @@ -49,7 +49,7 @@ type kegSettingsEditInput struct { func registerKegSettingsEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_settings_edit", - Description: "Call keg_settings_read with minimal=false first, then replace the complete KEG settings with a validated YAML document using its hash as expected_hash. Requires admin access to the KEG itself, plus admin cover when a flight is selected. On conflict, merge into the returned current settings (or refetch with keg_settings_read) and retry with the returned current hash.", + Description: "Manage: Call keg_settings_read with minimal=false first, then replace the complete KEG settings with a validated YAML document using its hash as expected_hash. Requires admin access to the KEG itself, plus admin cover when a flight is selected. On conflict, merge into the returned current settings (or refetch with keg_settings_read) and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), @@ -101,7 +101,7 @@ func createNodeOutputs(results []keg.CreateNodeResult) []createNodeOutput { func registerCreate(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_create", - Description: "Atomically create 1-100 KEG nodes. " + + Description: "Memory: Atomically create 1-100 KEG nodes. " + "Each node is a markdown content document plus an optional YAML metadata document. " + "content must not begin with a YAML frontmatter block — metadata goes in meta. " + "A node's title is its content H1 and its lead is the first paragraph, so there are no separate title, lead, tags, or attrs fields. " + @@ -169,7 +169,7 @@ func nodeUpdateOutputs(results []keg.NodeUpdateResult) []nodeUpdateOutput { func registerEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_edit", - Description: "Atomically replace the content and/or metadata of 1-100 nodes. " + + Description: "Memory: Atomically replace the content and/or metadata of 1-100 nodes. " + "Supply content, meta, or both for each node; at least one is required. " + "content is the markdown body and must not begin with a YAML frontmatter block — metadata goes in meta. " + "Each part has its own precondition token: pass node_read's content_hash as expected_content_hash when supplying content, and its meta_hash as expected_meta_hash when supplying meta. " + @@ -222,7 +222,7 @@ type removeNodeInput struct { func registerRemove(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_delete", - Description: "Call node_read first for every node, then atomically remove 1-100 nodes using the hash from node_read (the combined hash, not content_hash or meta_hash) as that node's expected_hash. On conflict, refetch with node_read and retry with the returned current hash.", + Description: "Memory: Call node_read first for every node, then atomically remove 1-100 nodes using the hash from node_read (the combined hash, not content_hash or meta_hash) as that node's expected_hash. On conflict, refetch with node_read and retry with the returned current hash.", InputSchema: boundedMutationInputSchema[removeInput]("nodes"), Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), @@ -260,7 +260,7 @@ type moveInput struct { func registerMove(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_move", - Description: "Call node_read first, then move (rename) a KEG node to a new ID using the hash from node_read (the combined hash, not content_hash or meta_hash) as expected_hash. On conflict, refetch with node_read and retry with the returned current hash.", + Description: "Memory: Call node_read first, then move (rename) a KEG node to a new ID using the hash from node_read (the combined hash, not content_hash or meta_hash) as expected_hash. The destination must be free: the id of a node in the trash stays reserved until it is restored or purged. On conflict, refetch with node_read and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), diff --git a/pkg/parity/parity_coverage_test.go b/pkg/parity/parity_coverage_test.go index e7e91f84..f1ddee96 100644 --- a/pkg/parity/parity_coverage_test.go +++ b/pkg/parity/parity_coverage_test.go @@ -102,10 +102,15 @@ var tapMethodToSurfaces = map[string]struct { // Keg and namespace discovery (hub-side). HubListKegs backs `tap keg list`; // on MCP the same listing is filtered through the session's active flight // cover. - "HubListKegs": {CLI: "keg list", MCP: []string{"keg_list"}}, - "NamespaceList": {CLI: "namespace list", MCP: []string{"namespace_list"}}, - "NamespaceSearch": {CLI: "namespace search", MCP: []string{"namespace_search"}}, - "InvitationList": {CLI: "invitation list", MCP: []string{"invitation_list"}}, + "HubListKegs": {CLI: "keg list", MCP: []string{"keg_list"}}, + "NamespaceList": {CLI: "namespace list", MCP: []string{"namespace_list"}}, + "InvitationList": {CLI: "invitation list", MCP: []string{"invitation_list"}}, + + // The hub's public directory. Searching is reserved for it; the caller's + // own KEGs, flights, and namespaces are listed above. + "ExploreKegs": {CLI: "explore kegs", MCP: []string{"explore_kegs"}}, + "ExploreFlights": {CLI: "explore flights", MCP: []string{"explore_flights"}}, + "ExplorePeople": {CLI: "explore people", MCP: []string{"explore_people"}}, // Agent orientation remains shared. Native plugin installation is an // intentionally CLI-only host operation (see tapMethodsExcluded). @@ -166,7 +171,7 @@ var tapMethodsExcluded = map[string]string{ "ActiveFlightName": "internal pure read of the explicit flight or the loaded cascade's selection; backs Orient and MCP session adoption rather than being an operation of its own", "ActiveLaunch": "internal pure read of the `tap launch` harness and model driving the process; reported in orientation rather than being an operation of its own", "OrientationKegsForFlight": "internal authority projection used by MCP providers to compute a revision before rendering once", - "IdentityKegCatalog": "internal identity metadata projection used by MCP providers for graph discovery and ungoverned keg_search", + "IdentityKegCatalog": "internal identity metadata projection used by MCP providers for graph discovery and keg_list", // Dropped from MCP when the surface was unified behind providers: these // operate on machine-local Tapper state or perform tenant administration, // neither of which an agent should reach through either transport. diff --git a/pkg/tapper/compact_orientation_test.go b/pkg/tapper/compact_orientation_test.go index f26f9385..a1540664 100644 --- a/pkg/tapper/compact_orientation_test.go +++ b/pkg/tapper/compact_orientation_test.go @@ -32,7 +32,11 @@ func TestCompactOrientationProgressiveMetadata(t *testing.T) { noflight, err := BuildOrientationPayload(nil, "", "", kegs, nil, &OrientationAuthority{FullAccess: true, AvailableFlights: []string{root.Name}, Children: children}) require.NoError(t, err) require.NotContains(t, noflight, "@n/") - require.Contains(t, noflight, "flight_search") + require.Contains(t, noflight, "flight_list", "the no-flight payload points at the complete private list") + require.Contains(t, noflight, "explore_flights", "and at the public directory") + for _, c := range ToolCategories { + require.Contains(t, noflight, string(c.Category)+": "+c.Intent, "orientation explains every tool category") + } // Representative legacy payload sections: rules, full catalog, and all canonical guides. var before strings.Builder before.WriteString(OrientationOperatingRules()) diff --git a/pkg/tapper/hub_agents_test.go b/pkg/tapper/hub_agents_test.go index 9b4da4c4..7b36b564 100644 --- a/pkg/tapper/hub_agents_test.go +++ b/pkg/tapper/hub_agents_test.go @@ -31,11 +31,12 @@ func TestHubAgents_ClientPaths(t *testing.T) { _ = json.NewEncoder(w).Encode(agent) case "DELETE /api/v1/@acme/agents/writer": w.WriteHeader(http.StatusNoContent) - case "POST /api/v1/namespaces/search": - var body map[string]string + case "POST /api/v1/explore": + var body tapper.ExploreRequest require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - require.Equal(t, "ac", body["query"]) - _ = json.NewEncoder(w).Encode(tapper.NamespaceSearchResult{Namespaces: []tapper.NamespaceMatch{{Name: "acme", Kind: "org"}}}) + require.Equal(t, tapper.ExplorePeople, body.Kind) + require.Equal(t, "ac", body.Query) + _ = json.NewEncoder(w).Encode(tapper.ExploreResult{Kind: tapper.ExplorePeople, Results: []tapper.ExploreRow{{Ref: "@acme", Name: "Acme"}}}) default: http.NotFound(w, r) } @@ -58,13 +59,13 @@ func TestHubAgents_ClientPaths(t *testing.T) { require.Equal(t, "Scribe", wrote.Title) require.NoError(t, tapper.DeleteHubAgent(ctx, srv.URL, "tok", "acme", "writer")) - found, err := tapper.SearchHubNamespaces(ctx, srv.URL, "tok", "ac") + found, err := tapper.ExploreHub(ctx, srv.URL, "tok", tapper.ExplorePeople, "ac") require.NoError(t, err) - require.Equal(t, "acme", found.Namespaces[0].Name) + require.Equal(t, "@acme", found.Results[0].Ref) require.Equal(t, []string{ "GET /api/v1/agents", "GET /api/v1/@acme/agents", "POST /api/v1/@acme/agents", - "PUT /api/v1/@acme/agents/writer", "DELETE /api/v1/@acme/agents/writer", "POST /api/v1/namespaces/search", + "PUT /api/v1/@acme/agents/writer", "DELETE /api/v1/@acme/agents/writer", "POST /api/v1/explore", }, seen) // An older hub sends no effective tools; the stored names are the list. diff --git a/pkg/tapper/hub_namespaces.go b/pkg/tapper/hub_namespaces.go index 1ad698f5..426dbd29 100644 --- a/pkg/tapper/hub_namespaces.go +++ b/pkg/tapper/hub_namespaces.go @@ -90,15 +90,3 @@ func ListNamespaces(ctx context.Context, hubURL, token string) ([]HubNamespace, func CreateNamespace(ctx context.Context, hubURL, token, name string) (*HubNamespace, error) { return nil, fmt.Errorf("hub namespace creation is disabled for remote clients; use the hub UI: %w", keg.ErrNotSupported) } - -// SearchHubNamespaces searches the hub's visible namespaces by name. -func SearchHubNamespaces(ctx context.Context, hubURL, token, query string) (NamespaceSearchResult, error) { - var out NamespaceSearchResult - if err := doHubJSON(ctx, http.MethodPost, hubURL, token, "/api/v1/namespaces/search", map[string]string{"query": query}, &out); err != nil { - return NamespaceSearchResult{}, err - } - if out.Namespaces == nil { - out.Namespaces = []NamespaceMatch{} - } - return out, nil -} diff --git a/pkg/tapper/invocation_telemetry_test.go b/pkg/tapper/invocation_telemetry_test.go index cd1ff638..f08bdf86 100644 --- a/pkg/tapper/invocation_telemetry_test.go +++ b/pkg/tapper/invocation_telemetry_test.go @@ -19,7 +19,7 @@ type telemetryRoundTripFunc func(*http.Request) (*http.Response, error) func (f telemetryRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { if req.URL.Path == "/api/version" { - return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"server_version":"test","api_versions":["2026-09-28"]}`))}, nil + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"server_version":"test","api_versions":["2026-10-12"]}`))}, nil } return f(req) diff --git a/pkg/tapper/launch_split_test.go b/pkg/tapper/launch_split_test.go index 06cac1b0..f616b44a 100644 --- a/pkg/tapper/launch_split_test.go +++ b/pkg/tapper/launch_split_test.go @@ -190,7 +190,7 @@ func TestLaunchForwarder_NoRelayMCP(t *testing.T) { func TestResolveLaunch_ClaudeAgentExtras(t *testing.T) { t.Parallel() hub := fakeAgentHub(t, "hub-token", twoModels, map[string]string{ - "/api/v1/tools": `{"tools":["keg_search","node_read"],"always_available":["orient"]}`, + "/api/v1/tools": `{"tools":["explore_kegs","node_read"],"always_available":["orient"]}`, "/api/v1/relay/tools": `{"data":[{"name":"mcp__me__everything__echo"}]}`, "/api/v1/@me/agents/lead": `{"ref":"@me/lead","namespace":"me","name":"lead","instructions":"Lead.","model":"@me/llama3", "tools":["relay:tools"],"runtime_tools":[],"relay_tools":true,"subagents":["@me/reader","@me/open","@me/gone"]}`, @@ -224,7 +224,7 @@ func TestResolveLaunch_ClaudeAgentExtras(t *testing.T) { require.Equal(t, "Open", open.Description) require.Empty(t, open.Model, "a model the launch cannot reach is left to inherit") require.Equal(t, []string{"Read", "Grep", "Glob", - "mcp__plugin_tapper_tapper__keg_search", "mcp__plugin_tapper_tapper__node_read", "mcp__plugin_tapper_tapper__orient"}, open.Tools, + "mcp__plugin_tapper_tapper__explore_kegs", "mcp__plugin_tapper_tapper__node_read", "mcp__plugin_tapper_tapper__orient"}, open.Tools, "every Hub tool, and no relayed tool without relay_tools") got, err = newLaunchTap(t, hub.URL, "").ResolveLaunch(LaunchOptions{Harness: "claude", Agent: "@me/lead", Subagents: "none", SubagentBuiltins: []string{}}) diff --git a/pkg/tapper/tap_explore.go b/pkg/tapper/tap_explore.go new file mode 100644 index 00000000..f9db0c82 --- /dev/null +++ b/pkg/tapper/tap_explore.go @@ -0,0 +1,104 @@ +package tapper + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/jlrickert/tapper/pkg/keg" +) + +// ExploreKind names one section of a hub's public directory. +type ExploreKind string + +const ( + // ExploreKegs lists public KEGs. + ExploreKegs ExploreKind = "kegs" + // ExploreFlights lists public flights. + ExploreFlights ExploreKind = "flights" + // ExplorePeople lists the people and org namespaces on the hub. + ExplorePeople ExploreKind = "people" +) + +// ExploreOptions searches one section of the public directory. Query must +// not be empty: the directory is hub-wide, and the caller's own resources +// come from the list commands instead. +type ExploreOptions struct { + Query string + Hub string +} + +// ExploreRow is one public directory entry. Role is the caller's role when +// they already belong to it, empty otherwise. A row grants nothing. +type ExploreRow struct { + Ref string `json:"ref"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Role string `json:"role,omitempty"` +} + +// ExploreResult is a bounded page of public directory matches. +type ExploreResult struct { + Kind ExploreKind `json:"kind"` + Results []ExploreRow `json:"results"` + Truncated bool `json:"truncated"` +} + +// ExploreRequest is the body of POST /api/v1/explore. +type ExploreRequest struct { + Kind ExploreKind `json:"kind"` + Query string `json:"query"` +} + +// ValidExploreKind reports whether kind names a directory section. +func ValidExploreKind(kind ExploreKind) bool { + switch kind { + case ExploreKegs, ExploreFlights, ExplorePeople: + return true + } + return false +} + +// ExploreKegs searches the hub's public KEGs. +func (t *Tap) ExploreKegs(ctx context.Context, opts ExploreOptions) (ExploreResult, error) { + return t.explore(ctx, ExploreKegs, opts) +} + +// ExploreFlights searches the hub's public flights. +func (t *Tap) ExploreFlights(ctx context.Context, opts ExploreOptions) (ExploreResult, error) { + return t.explore(ctx, ExploreFlights, opts) +} + +// ExplorePeople searches the people and org namespaces on the hub. +func (t *Tap) ExplorePeople(ctx context.Context, opts ExploreOptions) (ExploreResult, error) { + return t.explore(ctx, ExplorePeople, opts) +} + +func (t *Tap) explore(ctx context.Context, kind ExploreKind, opts ExploreOptions) (ExploreResult, error) { + query := strings.TrimSpace(opts.Query) + if query == "" { + return ExploreResult{}, fmt.Errorf("%w: query must not be empty", keg.ErrInvalid) + } + hubURL, token, err := t.resolveHubEndpoint(opts.Hub) + if err != nil { + return ExploreResult{}, err + } + return ExploreHub(ctx, hubURL, token, kind, query) +} + +// ExploreHub searches one section of a hub's public directory via +// POST /api/v1/explore. +func ExploreHub(ctx context.Context, hubURL, token string, kind ExploreKind, query string) (ExploreResult, error) { + var out ExploreResult + if err := doHubJSON(ctx, http.MethodPost, hubURL, token, "/api/v1/explore", ExploreRequest{Kind: kind, Query: query}, &out); err != nil { + return ExploreResult{}, err + } + if out.Kind == "" { + out.Kind = kind + } + if out.Results == nil { + out.Results = []ExploreRow{} + } + return out, nil +} diff --git a/pkg/tapper/tap_namespace.go b/pkg/tapper/tap_namespace.go index 9e5b4633..3d9f38aa 100644 --- a/pkg/tapper/tap_namespace.go +++ b/pkg/tapper/tap_namespace.go @@ -299,35 +299,3 @@ func notLoggedInError(hubName, hubURL string) error { sentinel: keg.ErrUnauthorized, } } - -// NamespaceSearchOptions searches the namespaces visible across a hub: people -// and org namespaces, not only the caller's own. An empty Query browses. -type NamespaceSearchOptions struct { - Query string - Hub string -} - -// NamespaceMatch is one namespace a search found. Role is the caller's role -// when they belong to it, empty otherwise. -type NamespaceMatch struct { - Name string `json:"name"` - Kind string `json:"kind"` - DisplayName string `json:"display_name,omitempty"` - Role string `json:"role,omitempty"` -} - -// NamespaceSearchResult is a bounded page of matches. -type NamespaceSearchResult struct { - Namespaces []NamespaceMatch `json:"namespaces"` - Truncated bool `json:"truncated"` -} - -// NamespaceSearch finds namespaces on the selected hub by name or display -// name. It is discovery only: a match grants nothing. -func (t *Tap) NamespaceSearch(ctx context.Context, opts NamespaceSearchOptions) (NamespaceSearchResult, error) { - hubURL, token, err := t.resolveHubEndpoint(opts.Hub) - if err != nil { - return NamespaceSearchResult{}, err - } - return SearchHubNamespaces(ctx, hubURL, token, strings.TrimSpace(opts.Query)) -} diff --git a/pkg/tapper/tap_orient.go b/pkg/tapper/tap_orient.go index ec4275f7..d9386f4d 100644 --- a/pkg/tapper/tap_orient.go +++ b/pkg/tapper/tap_orient.go @@ -402,6 +402,7 @@ func BuildOrientationPayload(flight *Flight, flightNote, launch string, kegs []O var b strings.Builder b.WriteString("# KEG System\n\n") b.WriteString("Call `orient` at session start and after every context reset. Authority is bounded by identity permissions and the selected flight; child authority and instructions are not inherited. Use only `mcp__tapper__*` tools for KEG operations. Call `keg_settings_read` before operating in a KEG. Snapshot before meaningful edits; snapshots do not protect deletion. Re-read for a fresh hash before each guarded write; `node_edit` takes per-part hashes (`content_hash`, `meta_hash`), `node_delete` and `node_move` the combined `hash`. Call `guide` for detailed operating, authoring/linking, snapshots, tools, or troubleshooting guidance.\n\n") + writeToolCategories(&b) for _, warning := range warnings { fmt.Fprintf(&b, "Warning: %s\n\n", orientationTableCell(warning)) } @@ -415,9 +416,9 @@ func BuildOrientationPayload(flight *Flight, flightNote, launch string, kegs []O } if flight == nil { if authority != nil && authority.FullAccess { - b.WriteString("No flight is active. This session runs under your full identity authority; nothing here is scoped. Use `keg_search` and `flight_search` to discover readable resources; results confer no access. An explicit flight selects only that flight for one call. Pin a least-privilege flight outside MCP and start a new connection to narrow the session.\n") + b.WriteString("No flight is active. This session runs under your full identity authority; nothing here is scoped. `keg_list` and `flight_list` list every readable resource; `explore_kegs` and `explore_flights` search the public directory and confer no access. An explicit flight selects only that flight for one call. Pin a least-privilege flight outside MCP and start a new connection to narrow the session.\n") } else { - b.WriteString("The configured flight is unavailable: fail-closed recovery; KEG tools are locked. Repair the selection, then call `session_refresh` and `orient`. Use `flight_search`, `flight_list`, `flight_read`, or `session_info` for recovery.\n") + b.WriteString("The configured flight is unavailable: fail-closed recovery; KEG tools are locked. Repair the selection, then call `session_refresh` and `orient`. Use `flight_list`, `flight_read`, or `session_info` for recovery.\n") } return b.String(), nil } @@ -450,7 +451,7 @@ func BuildOrientationPayload(flight *Flight, flightNote, launch string, kegs []O } else { writeOrientationKegTable(&b, usable, "") } - b.WriteString("\nInspect a flight explicitly to see its children. Use `keg_search` and `flight_search` for metadata discovery. Select a reachable descendant explicitly with the `flight` argument; its instructions apply only to that call.\n") + b.WriteString("\nInspect a flight explicitly to see its children. Use `keg_list` and `flight_list` for your own resources and `explore_kegs` and `explore_flights` for the public directory. Select a reachable descendant explicitly with the `flight` argument; its instructions apply only to that call.\n") return b.String(), nil } @@ -629,3 +630,13 @@ func appendCanonical(b *strings.Builder, name string) error { } return nil } + +// writeToolCategories tells the agent what each tool category is for. Every +// tool description opens with its category label, so this is the key. +func writeToolCategories(b *strings.Builder) { + b.WriteString("Tool categories:\n") + for _, c := range ToolCategories { + fmt.Fprintf(b, "- %s: %s\n", c.Category, c.Intent) + } + b.WriteString("\n") +} diff --git a/pkg/tapper/tool_categories.go b/pkg/tapper/tool_categories.go new file mode 100644 index 00000000..b8e59b38 --- /dev/null +++ b/pkg/tapper/tool_categories.go @@ -0,0 +1,69 @@ +package tapper + +// ToolCategory sorts every agent tool by the job it does in the KEG system, +// so an agent can tell everyday memory work from browsing the public +// directory and from administering the memory system itself. Each tool's +// description opens with its category label, orientation prints the intent +// of each category, and the Hub's tool groups use the same split. +type ToolCategory string + +const ( + // ToolCategorySession tools report who the session is and what it may do. + ToolCategorySession ToolCategory = "Session" + // ToolCategoryMemory tools read and write notes in the covered KEGs. + ToolCategoryMemory ToolCategory = "Memory" + // ToolCategoryExplore tools search the hub's public directory. + ToolCategoryExplore ToolCategory = "Explore" + // ToolCategoryManage tools change the structure of the memory system. + ToolCategoryManage ToolCategory = "Manage" +) + +// ToolCategoryInfo is one category and the one-line intent an agent reads. +type ToolCategoryInfo struct { + Category ToolCategory + Intent string +} + +// ToolCategories lists the categories in the order agents see them. +var ToolCategories = []ToolCategoryInfo{ + {ToolCategorySession, "who you are and what this session may do; always available."}, + {ToolCategoryMemory, "read and write notes in the KEGs this session covers. Your own KEGs, flights, schemas, and tags are listed, never searched; node_search finds notes."}, + {ToolCategoryExplore, "search the hub's public directory of KEGs, flights, and people. Read-only, and a result never grants access."}, + {ToolCategoryManage, "change the memory system itself: create and delete KEGs, edit settings and schemas, repair indexes and locks, and manage flights and agents. Writing a note never needs these."}, +} + +var toolCategoryOf = map[string]ToolCategory{ + "orient": ToolCategorySession, "guide": ToolCategorySession, + "session_info": ToolCategorySession, "session_refresh": ToolCategorySession, + "namespace_list": ToolCategorySession, "invitation_list": ToolCategorySession, + + "keg_list": ToolCategoryMemory, "keg_info": ToolCategoryMemory, "keg_settings_read": ToolCategoryMemory, + "node_read": ToolCategoryMemory, "node_list": ToolCategoryMemory, "node_search": ToolCategoryMemory, + "node_grep": ToolCategoryMemory, "node_links": ToolCategoryMemory, "node_backlinks": ToolCategoryMemory, + "node_stats": ToolCategoryMemory, "node_create": ToolCategoryMemory, "node_edit": ToolCategoryMemory, + "node_move": ToolCategoryMemory, "node_delete": ToolCategoryMemory, + "tag_list": ToolCategoryMemory, "schema_list": ToolCategoryMemory, "schema_read": ToolCategoryMemory, + "schema_validate": ToolCategoryMemory, "index_list": ToolCategoryMemory, "index_read": ToolCategoryMemory, + "file_list": ToolCategoryMemory, "file_download": ToolCategoryMemory, "file_upload": ToolCategoryMemory, + "file_delete": ToolCategoryMemory, "image_list": ToolCategoryMemory, "image_download": ToolCategoryMemory, + "image_upload": ToolCategoryMemory, "image_delete": ToolCategoryMemory, "snapshot_list": ToolCategoryMemory, + "snapshot_read": ToolCategoryMemory, "snapshot_create": ToolCategoryMemory, "snapshot_restore": ToolCategoryMemory, + "lock_acquire": ToolCategoryMemory, + "lock_release": ToolCategoryMemory, "lock_status": ToolCategoryMemory, + "flight_list": ToolCategoryMemory, "flight_read": ToolCategoryMemory, + + "explore_kegs": ToolCategoryExplore, "explore_flights": ToolCategoryExplore, "explore_people": ToolCategoryExplore, + + "keg_create": ToolCategoryManage, "keg_delete": ToolCategoryManage, "keg_settings_edit": ToolCategoryManage, + "schema_create": ToolCategoryManage, "schema_edit": ToolCategoryManage, + "schema_delete": ToolCategoryManage, "index_rebuild": ToolCategoryManage, "keg_check": ToolCategoryManage, + "lock_force_release": ToolCategoryManage, "flight_create": ToolCategoryManage, "flight_edit": ToolCategoryManage, + "flight_delete": ToolCategoryManage, "agent_list": ToolCategoryManage, "agent_read": ToolCategoryManage, + "agent_create": ToolCategoryManage, "agent_edit": ToolCategoryManage, "agent_delete": ToolCategoryManage, +} + +// ToolCategoryOf returns the category of an agent tool. +func ToolCategoryOf(tool string) (ToolCategory, bool) { + c, ok := toolCategoryOf[tool] + return c, ok +} From 6011975e97e3f0d9e9753966f847f897e68856e5 Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Mon, 12 Oct 2026 19:30:35 -0500 Subject: [PATCH 2/8] feat(flight): follow note links in covers and block KEGs with none On a Hub a flight's cover reaches the KEGs its covered notes link to, up to the cover entry's depth, instead of settings relationships. A cover entry with role none blocks a KEG however it is reached, and covers nothing even when another entry names it. KEG settings links are renamed aliases, since they now only name KEGs for keg:~alias/N links. Settings written with links are still read when aliases is absent, and LinkEntry remains as a deprecated alias of AliasEntry. keg_check reports keg: links that cannot be followed: one missing the @ of a qualified reference, or an alias the settings do not name. --- docs/ai-coding-agents/agent-conventions.md | 5 +- docs/development/gotchas.md | 11 +++-- integrations/content/linking.md | 14 ++++-- .../claude/tapper/skills/tapper/SKILL.md | 14 ++++-- .../codex/tapper/skills/tapper/SKILL.md | 14 ++++-- .../opencode/tapper/skills/tapper/SKILL.md | 14 ++++-- pkg/keg/archive_test.go | 4 +- pkg/keg/keg_aggregate.go | 3 ++ pkg/keg/keg_local_settings.go | 4 +- pkg/keg/keg_settings.go | 45 ++++++++++++----- pkg/keg/keg_settings_test.go | 34 +++++++++++-- pkg/keg/relationships.go | 13 ++--- pkg/keg/relationships_test.go | 35 ++++++++++++-- pkg/keg/render.go | 48 +++++++++++++++++-- pkg/mcp/tools_flight.go | 4 +- pkg/tapper/flight.go | 24 +++++++--- pkg/tapper/flight_test.go | 15 ++++++ pkg/tapper/node_ref_resolve.go | 8 ++-- schemas/flight-manifest.json | 3 +- schemas/keg-settings.json | 12 ++++- 20 files changed, 259 insertions(+), 65 deletions(-) diff --git a/docs/ai-coding-agents/agent-conventions.md b/docs/ai-coding-agents/agent-conventions.md index dc16a35b..59d5ee06 100644 --- a/docs/ai-coding-agents/agent-conventions.md +++ b/docs/ai-coding-agents/agent-conventions.md @@ -107,8 +107,9 @@ Tapper recognizes two link forms in node bodies: - **Intra-keg:** `[title](../NODEID)` — relative path from the current node's directory to the target node's directory. - **Cross-keg:** `keg:@namespace/keg/NODEID` — fully qualified reference that - the index parses into a cross-keg edge. Bare names can also be used when the - active config resolves them unambiguously. + the index parses into a cross-keg edge; on a Hub a flight's cover follows it. + `keg:@namespace/keg/0` links a KEG's root. The `@` is required. Bare names can + also be used when the active config resolves them unambiguously. Both forms appear in backlinks. Prefer intra-keg links when the target is in the same keg — they resolve in any markdown renderer. diff --git a/docs/development/gotchas.md b/docs/development/gotchas.md index 260d01c4..5e8da539 100644 --- a/docs/development/gotchas.md +++ b/docs/development/gotchas.md @@ -36,10 +36,13 @@ REST/MCP, editing, and hashes independently of instructions and authority. canonical guidance by topic. Display descriptions never affect authority revisions. The coordinated REST/MCP replacement is in progress, not complete. Cover entries -now carry depth (default 2, range 1–8). The Hub composes authority through declared -same-Hub settings relationships. Flights carry no capabilities; an agent's tools decide what a session may do. -`keg:~alias/node` explicitly resolves the source KEG's settings alias; canonical -references keep their meaning. Orientation request transport requires a root and +now carry depth (default 2, range 1–8). The Hub composes a flight's cover from +its listed KEGs and the same-Hub KEGs their notes link to; a cover entry with +role `none` blocks a KEG however it is reached. Flights carry no capabilities; +an agent's tools decide what a session may do. `keg:~alias/node` explicitly +resolves the source KEG's settings alias (settings `aliases`, formerly `links`, +which is still read when `aliases` is absent); canonical references keep their +meaning. Orientation request transport requires a root and active flight, with no revision acknowledgment requirement. The hub publishes its own verification status for the replacement's scope and gaps. diff --git a/integrations/content/linking.md b/integrations/content/linking.md index 682ad36b..fc9d5f64 100644 --- a/integrations/content/linking.md +++ b/integrations/content/linking.md @@ -8,11 +8,19 @@ Tapper supports these link forms in node bodies: - **Cross-keg (same namespace):** `[title](keg:ALIAS/NODEID)` — names a KEG in the source namespace. - **Settings alias:** `[title](keg:~ALIAS/NODEID)` — explicitly resolves an - alias declared in the source KEG settings. The tilde distinguishes a settings - alias from a KEG name. + alias declared in the source KEG settings `aliases`. The tilde distinguishes + a settings alias from a KEG name. An alias keeps links working when the KEG + it names moves: only the alias's target changes. - **Cross-keg (fully qualified):** `[title](keg:@NAMESPACE/ALIAS/NODEID)` — identifies the namespace and keg - explicitly and is parsed into a cross-keg edge. + explicitly and is parsed into a cross-keg edge. `NODEID` may be `0`, the + KEG's root node, to link the KEG as a whole. The `@` is required: + `keg:NAMESPACE/ALIAS/NODEID` is not a link, and `keg_check` reports it. + +On a Hub, a flight that covers a KEG also covers the KEGs its notes link to, +as far as the cover entry's depth reaches, so a cross-keg link can widen what +an agent under that flight may read. A flight blocks a KEG by listing it with +role `none`. Indexed links appear in backlinks when readable. Prefer intra-keg links when the target is in the same keg. A bare `keg:` reference in node prose is plain text: it does not diff --git a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md index d8295d47..0bfd85e6 100644 --- a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md @@ -403,11 +403,19 @@ Tapper supports these link forms in node bodies: - **Cross-keg (same namespace):** `[title](keg:ALIAS/NODEID)` — names a KEG in the source namespace. - **Settings alias:** `[title](keg:~ALIAS/NODEID)` — explicitly resolves an - alias declared in the source KEG settings. The tilde distinguishes a settings - alias from a KEG name. + alias declared in the source KEG settings `aliases`. The tilde distinguishes + a settings alias from a KEG name. An alias keeps links working when the KEG + it names moves: only the alias's target changes. - **Cross-keg (fully qualified):** `[title](keg:@NAMESPACE/ALIAS/NODEID)` — identifies the namespace and keg - explicitly and is parsed into a cross-keg edge. + explicitly and is parsed into a cross-keg edge. `NODEID` may be `0`, the + KEG's root node, to link the KEG as a whole. The `@` is required: + `keg:NAMESPACE/ALIAS/NODEID` is not a link, and `keg_check` reports it. + +On a Hub, a flight that covers a KEG also covers the KEGs its notes link to, +as far as the cover entry's depth reaches, so a cross-keg link can widen what +an agent under that flight may read. A flight blocks a KEG by listing it with +role `none`. Indexed links appear in backlinks when readable. Prefer intra-keg links when the target is in the same keg. A bare `keg:` reference in node prose is plain text: it does not diff --git a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md index d8295d47..0bfd85e6 100644 --- a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md @@ -403,11 +403,19 @@ Tapper supports these link forms in node bodies: - **Cross-keg (same namespace):** `[title](keg:ALIAS/NODEID)` — names a KEG in the source namespace. - **Settings alias:** `[title](keg:~ALIAS/NODEID)` — explicitly resolves an - alias declared in the source KEG settings. The tilde distinguishes a settings - alias from a KEG name. + alias declared in the source KEG settings `aliases`. The tilde distinguishes + a settings alias from a KEG name. An alias keeps links working when the KEG + it names moves: only the alias's target changes. - **Cross-keg (fully qualified):** `[title](keg:@NAMESPACE/ALIAS/NODEID)` — identifies the namespace and keg - explicitly and is parsed into a cross-keg edge. + explicitly and is parsed into a cross-keg edge. `NODEID` may be `0`, the + KEG's root node, to link the KEG as a whole. The `@` is required: + `keg:NAMESPACE/ALIAS/NODEID` is not a link, and `keg_check` reports it. + +On a Hub, a flight that covers a KEG also covers the KEGs its notes link to, +as far as the cover entry's depth reaches, so a cross-keg link can widen what +an agent under that flight may read. A flight blocks a KEG by listing it with +role `none`. Indexed links appear in backlinks when readable. Prefer intra-keg links when the target is in the same keg. A bare `keg:` reference in node prose is plain text: it does not diff --git a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md index d8295d47..0bfd85e6 100644 --- a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md @@ -403,11 +403,19 @@ Tapper supports these link forms in node bodies: - **Cross-keg (same namespace):** `[title](keg:ALIAS/NODEID)` — names a KEG in the source namespace. - **Settings alias:** `[title](keg:~ALIAS/NODEID)` — explicitly resolves an - alias declared in the source KEG settings. The tilde distinguishes a settings - alias from a KEG name. + alias declared in the source KEG settings `aliases`. The tilde distinguishes + a settings alias from a KEG name. An alias keeps links working when the KEG + it names moves: only the alias's target changes. - **Cross-keg (fully qualified):** `[title](keg:@NAMESPACE/ALIAS/NODEID)` — identifies the namespace and keg - explicitly and is parsed into a cross-keg edge. + explicitly and is parsed into a cross-keg edge. `NODEID` may be `0`, the + KEG's root node, to link the KEG as a whole. The `@` is required: + `keg:NAMESPACE/ALIAS/NODEID` is not a link, and `keg_check` reports it. + +On a Hub, a flight that covers a KEG also covers the KEGs its notes link to, +as far as the cover entry's depth reaches, so a cross-keg link can widen what +an agent under that flight may read. A flight blocks a KEG by listing it with +role `none`. Indexed links appear in backlinks when readable. Prefer intra-keg links when the target is in the same keg. A bare `keg:` reference in node prose is plain text: it does not diff --git a/pkg/keg/archive_test.go b/pkg/keg/archive_test.go index f5ab2780..d53cc9a7 100644 --- a/pkg/keg/archive_test.go +++ b/pkg/keg/archive_test.go @@ -250,7 +250,7 @@ func TestArchiveImportRestoresKegSettingsForFullBackup(t *testing.T) { cfg.Description = "Restored summary" cfg.Instructions = "Restore carefully." cfg.Timezone = "America/Chicago" - cfg.Links = []keg.LinkEntry{{Alias: "docs", URL: "https://example.com/docs"}} + cfg.Aliases = []keg.AliasEntry{{Alias: "docs", URL: "https://example.com/docs"}} cfg.Indexes = append(cfg.UserIndexEntries(), keg.IndexEntry{File: "restored.md", Summary: "Restored nodes", Query: "restored"}) cfg.Snapshots = &keg.SnapshotSettings{Mode: keg.SnapshotModeOff, IdleAfter: "2h"} cfg.SchemaPolicy = &keg.SchemaPolicy{ @@ -282,7 +282,7 @@ func TestArchiveImportRestoresKegSettingsForFullBackup(t *testing.T) { require.Equal(t, "Restored summary", got.Description) require.Equal(t, "Restore carefully.", got.Instructions) require.Equal(t, "America/Chicago", got.Timezone) - require.Equal(t, []keg.LinkEntry{{Alias: "docs", URL: "https://example.com/docs"}}, got.Links) + require.Equal(t, []keg.AliasEntry{{Alias: "docs", URL: "https://example.com/docs"}}, got.Aliases) require.Equal(t, &keg.SnapshotSettings{Mode: keg.SnapshotModeOff, IdleAfter: "2h"}, got.Snapshots) require.Equal(t, &keg.SchemaPolicy{Human: keg.ValidationModeWarn, Agent: keg.ValidationModeBlock, API: keg.ValidationModeBlock}, got.SchemaPolicy) diff --git a/pkg/keg/keg_aggregate.go b/pkg/keg/keg_aggregate.go index 3f0c31a0..bd7f2c15 100644 --- a/pkg/keg/keg_aggregate.go +++ b/pkg/keg/keg_aggregate.go @@ -734,6 +734,9 @@ func (k *LocalKeg) doctor(ctx context.Context) ([]DoctorIssue, error) { issues = append(issues, DoctorIssue{Level: "error", Kind: "broken-link", NodeID: path, Message: fmt.Sprintf("broken link to node %s", link.Path())}) } } + for _, problem := range KegLinkProblems(content, cfg.Aliases) { + issues = append(issues, DoctorIssue{Level: "error", Kind: "malformed-keg-link", NodeID: path, Message: problem}) + } } rawMeta, metaErr := k.GetMetaRaw(ctx, id) if metaErr == nil { diff --git a/pkg/keg/keg_local_settings.go b/pkg/keg/keg_local_settings.go index 2dc2efbb..9c9593da 100644 --- a/pkg/keg/keg_local_settings.go +++ b/pkg/keg/keg_local_settings.go @@ -68,7 +68,7 @@ func (k *LocalKeg) updateSettings(ctx context.Context, f func(*Settings)) error } } f(cfg) - if err := ValidateRelationships(cfg.Links); err != nil { + if err := ValidateAliases(cfg.Aliases); err != nil { return err } if err := k.Repo.WriteSettings(ctx, cfg); err != nil { @@ -105,7 +105,7 @@ func (k *LocalKeg) replaceSettings(ctx context.Context, data []byte) error { if err != nil { return fmt.Errorf("unable to parse settings: %w", err) } - if err := ValidateRelationships(cfg.Links); err != nil { + if err := ValidateAliases(cfg.Aliases); err != nil { return err } if store, ok := k.Repo.(RepositorySettingsDocuments); ok { diff --git a/pkg/keg/keg_settings.go b/pkg/keg/keg_settings.go index 4eb14c85..22d59471 100644 --- a/pkg/keg/keg_settings.go +++ b/pkg/keg/keg_settings.go @@ -76,9 +76,11 @@ type SettingsV2 struct { // this keg. Instructions string `yaml:"instructions,omitempty" json:"instructions,omitempty"` - // Links holds a list of LinkEntry objects representing related links or - // references in the configuration. - Links []LinkEntry `yaml:"links,omitempty" json:"links,omitempty"` + // Aliases name same-Hub KEGs (and external URLs) so a note can link + // keg:~alias/N and keep that link when a KEG moves: only the alias's + // target changes. Documents written before the rename call this links; + // it is read when aliases is absent. + Aliases []AliasEntry `yaml:"aliases,omitempty" json:"aliases,omitempty"` // Indexes is a list of index entries that link to related files or nodes. Indexes []IndexEntry `yaml:"indexes,omitempty" json:"indexes,omitempty"` @@ -182,12 +184,17 @@ func formatSnapshotDuration(d time.Duration) string { return d.String() } -// LinkEntry represents a named link in the KEG settings. -type LinkEntry struct { - Alias string `yaml:"alias" json:"alias"` // Alias for the link - URL string `yaml:"url" json:"url"` // URL of the link +// AliasEntry names a KEG, or an external URL, in the KEG settings. +type AliasEntry struct { + Alias string `yaml:"alias" json:"alias"` // the name a keg:~alias/N link uses + URL string `yaml:"url" json:"url"` // keg:@namespace/keg, or an external URL } +// LinkEntry is the old name of AliasEntry. +// +// Deprecated: use AliasEntry. +type LinkEntry = AliasEntry + // IndexEntry represents an entry in the indexes list in the KEG settings. // // File is the bare filename of the generated index artifact, e.g. "backlinks" @@ -216,7 +223,7 @@ func (c *SettingsV1) toV2() *SettingsV2 { Creator: c.Creator, State: c.State, Description: c.Description, - Links: nil, // No links in v1, so leave as nil + Aliases: nil, // No aliases in v1, so leave as nil Indexes: c.Indexes, Snapshots: DefaultSnapshotSettings(), path: "", @@ -486,7 +493,7 @@ func (kc *Settings) Location() *time.Location { } func (kc *Settings) ResolveAlias(alias string) (*Target, error) { - for _, entry := range kc.Links { + for _, entry := range kc.Aliases { if alias == entry.Alias { kt, err := Parse(entry.URL) if err != nil { @@ -553,7 +560,8 @@ func (kc *Settings) Touch(t time.Time) { kc.Updated = t.Format(time.RFC3339) } -// UnmarshalYAML accepts legacy summary only when description is absent. +// UnmarshalYAML accepts legacy summary only when description is absent, and +// legacy links only when aliases is. func (s *SettingsV2) UnmarshalYAML(node *yaml.Node) error { type plain SettingsV2 var value plain @@ -571,11 +579,19 @@ func (s *SettingsV2) UnmarshalYAML(node *yaml.Node) error { } } } + if _, exists := fields["aliases"]; !exists { + if old, ok := fields["links"]; ok { + if err := old.Decode(&value.Aliases); err != nil { + return err + } + } + } *s = SettingsV2(value) return nil } -// UnmarshalJSON accepts legacy summary only when description is absent. +// UnmarshalJSON accepts legacy summary only when description is absent, and +// legacy links only when aliases is. func (s *SettingsV2) UnmarshalJSON(data []byte) error { type plain SettingsV2 var value plain @@ -593,6 +609,13 @@ func (s *SettingsV2) UnmarshalJSON(data []byte) error { } } } + if _, exists := fields["aliases"]; !exists { + if old, ok := fields["links"]; ok { + if err := json.Unmarshal(old, &value.Aliases); err != nil { + return err + } + } + } *s = SettingsV2(value) return nil } diff --git a/pkg/keg/keg_settings_test.go b/pkg/keg/keg_settings_test.go index f83091e6..42e08044 100644 --- a/pkg/keg/keg_settings_test.go +++ b/pkg/keg/keg_settings_test.go @@ -36,7 +36,7 @@ indexes: require.Len(t, userIndexes, 2) require.Equal(t, "index1.md", userIndexes[0].File) require.Equal(t, "index2.md", userIndexes[1].File) - require.Empty(t, settings.Links) + require.Empty(t, settings.Aliases) } func TestParseConfigDataV2(t *testing.T) { @@ -73,9 +73,9 @@ schemaPolicy: require.Equal(t, "Test KEG V2", settings.Title) require.Equal(t, "Use this KEG for parser tests.", settings.Instructions) - require.Len(t, settings.Links, 2, "expected 2 links") + require.Len(t, settings.Aliases, 2, "expected 2 links") links := map[string]string{} - for _, l := range settings.Links { + for _, l := range settings.Aliases { links[l.Alias] = l.URL } require.Contains(t, links, "home") @@ -452,3 +452,31 @@ func indexFiles(entries []keg.IndexEntry) []string { } return out } + +// Settings written before the rename call aliases links. They are still +// read, in YAML and JSON, but only when aliases is absent, and the +// settings are written back under aliases alone. +func TestSettingsReadLegacyLinksAsAliases(t *testing.T) { + legacy := "kegv: \"2025-07\"\ntitle: T\nlinks:\n - alias: docs\n url: keg:@team/manual\n" + settings, err := keg.ParseKegSettings([]byte(legacy)) + require.NoError(t, err) + require.Equal(t, []keg.AliasEntry{{Alias: "docs", URL: "keg:@team/manual"}}, settings.Aliases) + + out, err := settings.ToYAML() + require.NoError(t, err) + require.Contains(t, string(out), "aliases:") + require.NotContains(t, string(out), "links:") + + both := legacy + "aliases:\n - alias: kb\n url: keg:@team/kb\n" + settings, err = keg.ParseKegSettings([]byte(both)) + require.NoError(t, err) + require.Equal(t, []keg.AliasEntry{{Alias: "kb", URL: "keg:@team/kb"}}, settings.Aliases, "aliases wins over legacy links") + + var fromJSON keg.Settings + require.NoError(t, json.Unmarshal([]byte(`{"kegv":"2025-07","links":[{"alias":"docs","url":"keg:@team/manual"}]}`), &fromJSON)) + require.Equal(t, []keg.AliasEntry{{Alias: "docs", URL: "keg:@team/manual"}}, fromJSON.Aliases) + encoded, err := fromJSON.ToJSON() + require.NoError(t, err) + require.Contains(t, string(encoded), `"aliases":`) + require.NotContains(t, string(encoded), `"links":`) +} diff --git a/pkg/keg/relationships.go b/pkg/keg/relationships.go index 5c76e4fb..167de7f3 100644 --- a/pkg/keg/relationships.go +++ b/pkg/keg/relationships.go @@ -9,7 +9,8 @@ import ( var relationshipRefRE = regexp.MustCompile(`^@[a-z0-9][a-z0-9-]{0,63}/[a-z0-9][a-z0-9-]{0,63}$`) // RelationshipTarget returns the canonical same-Hub KEG reference of a -// settings relationship. Ordinary external URLs are metadata only. +// settings alias or flight cover target. Ordinary external URLs are metadata +// only. func RelationshipTarget(raw string) (string, bool, error) { ref := strings.TrimPrefix(raw, "keg:") if !strings.HasPrefix(raw, "keg:") && !strings.HasPrefix(raw, "@") { @@ -21,8 +22,8 @@ func RelationshipTarget(raw string) (string, bool, error) { return ref, true, nil } -// ValidateRelationships requires unique aliases and canonical KEG targets. -func ValidateRelationships(links []LinkEntry) error { +// ValidateAliases requires unique aliases and canonical KEG targets. +func ValidateAliases(links []AliasEntry) error { seen := map[string]bool{} for _, link := range links { if link.Alias != "" { @@ -41,10 +42,10 @@ func ValidateRelationships(links []LinkEntry) error { return nil } -// ResolveRelationshipAlias resolves the explicit keg:~alias/node form using +// ResolveSettingsAlias resolves the explicit keg:~alias/node form using // only the source KEG's settings, preserving canonical keg:@namespace/keg/node. -func ResolveRelationshipAlias(links []LinkEntry, alias string) (namespace, name string, err error) { - if err = ValidateRelationships(links); err != nil { +func ResolveSettingsAlias(links []AliasEntry, alias string) (namespace, name string, err error) { + if err = ValidateAliases(links); err != nil { return } for _, link := range links { diff --git a/pkg/keg/relationships_test.go b/pkg/keg/relationships_test.go index 6d1ab3a5..2156d988 100644 --- a/pkg/keg/relationships_test.go +++ b/pkg/keg/relationships_test.go @@ -1,19 +1,22 @@ package keg -import "testing" +import ( + "strings" + "testing" +) func TestRelationshipAliasRenderingAndParsing(t *testing.T) { - links := []LinkEntry{{Alias: "docs", URL: "keg:@team/manual"}} + links := []AliasEntry{{Alias: "docs", URL: "keg:@team/manual"}} ref, err := ParseNodeRef("keg:~docs/12") if err != nil || ref.Form != RefSettingsAlias || ref.String() != "keg:~docs/12" { t.Fatalf("parse: %+v %v", ref, err) } - opts := RenderOptions{Links: links, KegResolver: func(ns, alias, id string) string { return "/@" + ns + "/" + alias + "/" + id }} + opts := RenderOptions{Aliases: links, KegResolver: func(ns, alias, id string) string { return "/@" + ns + "/" + alias + "/" + id }} got, ok := ResolveNodeLink("keg:~docs/12#heading", opts) if !ok || got != "/@team/manual/12#heading" { t.Fatalf("resolved: %q %t", got, ok) } - opts.Links[0].URL = "keg:@team/renamed" + opts.Aliases[0].URL = "keg:@team/renamed" got, ok = ResolveNodeLink("keg:~docs/12", opts) if !ok || got != "/@team/renamed/12" { t.Fatalf("remapped: %q %t", got, ok) @@ -21,7 +24,7 @@ func TestRelationshipAliasRenderingAndParsing(t *testing.T) { if _, ok = ResolveNodeLink("keg:~missing/12", opts); ok { t.Fatal("missing alias resolved") } - if err := ValidateRelationships(append(links, links[0])); err == nil { + if err := ValidateAliases(append(links, links[0])); err == nil { t.Fatal("duplicate aliases accepted") } if _, _, err := RelationshipTarget("keg:manual"); err == nil { @@ -43,3 +46,25 @@ func TestOrientationStateNeedsScopeButNotRevision(t *testing.T) { t.Fatalf("round trip: %+v %v", decoded, err) } } + +// A keg: link outside the syntax renders as a dead link and links nothing, +// so it is reported, with the qualified form when it is a cross-KEG link +// missing its @. A ~alias the settings do not name is reported too; a link +// to a KEG's root node, keg:@ns/keg/0, is valid. +func TestKegLinkProblems(t *testing.T) { + aliases := []AliasEntry{{Alias: "docs", URL: "keg:@team/manual"}} + src := []byte("# N\n\n" + + "[guide](keg:outsider/guide/1) [again](keg:outsider/guide/1) " + + "[odd](keg:nothing) [root](keg:@team/manual/0) [local](keg:other/4) " + + "[docs](keg:~docs/2) [missing](keg:~gone/3) [web](https://example.com)\n") + problems := KegLinkProblems(src, aliases) + if len(problems) != 3 { + t.Fatalf("problems = %q, want three", problems) + } + if !strings.Contains(problems[0], "keg:@outsider/guide/1") { + t.Fatalf("unqualified link = %q, want the qualified form suggested", problems[0]) + } + if !strings.Contains(problems[1], `"keg:nothing"`) || !strings.Contains(problems[2], `"keg:~gone/3"`) { + t.Fatalf("problems = %q", problems) + } +} diff --git a/pkg/keg/render.go b/pkg/keg/render.go index c4474848..ad20678f 100644 --- a/pkg/keg/render.go +++ b/pkg/keg/render.go @@ -19,8 +19,8 @@ import ( // RenderOptions configures markdown-to-HTML rendering. type RenderOptions struct { - // Links supplies the source KEG settings aliases. - Links []LinkEntry + // Aliases supplies the source KEG's settings aliases, for keg:~alias/N. + Aliases []AliasEntry // BaseURL is the keg-root URL prefix for rewritten node links; node N // lives at {BaseURL}N. Defaults to "/". BaseURL string @@ -108,7 +108,7 @@ func ResolveNodeLink(dest string, opts RenderOptions) (string, bool) { return "", false } var err error - namespace, alias, err = ResolveRelationshipAlias(opts.Links, strings.TrimPrefix(alias, "~")) + namespace, alias, err = ResolveSettingsAlias(opts.Aliases, strings.TrimPrefix(alias, "~")) if err != nil { return "", false } @@ -198,3 +198,45 @@ func (t *nodeLinkTransformer) Transform(doc *ast.Document, reader text.Reader, p return ast.WalkContinue, nil }) } + +// unqualifiedKegLinkRE matches keg:NS/KEG/N, a cross-KEG link missing the @ +// that marks its namespace. +var unqualifiedKegLinkRE = regexp.MustCompile(`^keg:([A-Za-z0-9][A-Za-z0-9_-]*)/([A-Za-z0-9][A-Za-z0-9_-]*)/([0-9]+)`) + +// KegLinkProblems reports each keg: link in markdown src that cannot be +// followed: one outside the keg: syntax, which renders as a dead link and +// links nothing, or a keg:~alias/N whose alias aliases does not name. Each +// problem is reported once, in order of first appearance. +func KegLinkProblems(src []byte, aliases []AliasEntry) []string { + doc := goldmark.New().Parser().Parse(text.NewReader(src)) + var problems []string + seen := map[string]bool{} + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + link, ok := n.(*ast.Link) + if !entering || !ok { + return ast.WalkContinue, nil + } + dest := strings.TrimSpace(string(link.Destination)) + if !strings.HasPrefix(dest, "keg:") || seen[dest] { + return ast.WalkContinue, nil + } + seen[dest] = true + m := kegSchemeRE.FindStringSubmatch(dest) + switch { + case m == nil: + if u := unqualifiedKegLinkRE.FindStringSubmatch(dest); u != nil { + problems = append(problems, fmt.Sprintf("link %q is not a keg link; to link another KEG write keg:@%s/%s/%s", dest, u[1], u[2], u[3])) + } else { + problems = append(problems, fmt.Sprintf("link %q is not a keg link; write keg:@namespace/keg/N, or keg:~alias/N through the settings aliases", dest)) + } + case strings.HasPrefix(m[2], "~"): + if m[1] != "" { + problems = append(problems, fmt.Sprintf("link %q names both a namespace and an alias; write keg:~alias/N or keg:@namespace/keg/N", dest)) + } else if _, _, err := ResolveSettingsAlias(aliases, strings.TrimPrefix(m[2], "~")); err != nil { + problems = append(problems, fmt.Sprintf("link %q cannot be followed: %v", dest, err)) + } + } + return ast.WalkContinue, nil + }) + return problems +} diff --git a/pkg/mcp/tools_flight.go b/pkg/mcp/tools_flight.go index 4ecf3851..a820e1df 100644 --- a/pkg/mcp/tools_flight.go +++ b/pkg/mcp/tools_flight.go @@ -22,7 +22,7 @@ type flightCreateInput struct { Description string `json:"description,omitempty" jsonschema:"short description, separate from instructions"` Visibility string `json:"visibility,omitempty" jsonschema:"flight visibility: private (default) or public"` Instructions string `json:"instructions,omitempty" jsonschema:"markdown instructions"` - Cover []string `json:"cover,omitempty" jsonschema:"covered kegs with role caps, e.g. @ns/keg=viewer, @ns/keg=editor, or @ns/keg=admin (bare entries default to viewer)"` + Cover []string `json:"cover,omitempty" jsonschema:"covered kegs with role caps, e.g. @ns/keg=viewer, @ns/keg=editor, or @ns/keg=admin (bare entries default to viewer). A covered keg also covers the kegs its notes link to, to depth 2; @ns/keg=none blocks a keg however it is reached"` Subflights []string `json:"subflights,omitempty" jsonschema:"ordered child flight references; bare +slug references use this flight's namespace"` } @@ -32,7 +32,7 @@ type flightEditInput struct { Description *string `json:"description,omitempty" jsonschema:"short description, separate from instructions"` Visibility *string `json:"visibility,omitempty" jsonschema:"new visibility: private or public; omit to keep current"` Instructions *string `json:"instructions,omitempty" jsonschema:"new markdown instructions; omit to keep the current instructions"` - Cover []string `json:"cover,omitempty" jsonschema:"replacement declared cover, e.g. @ns/keg=viewer; entries use default depth 2. Custom depth cannot be set here. Omit to preserve all current cover entries and depths; [] clears"` + Cover []string `json:"cover,omitempty" jsonschema:"replacement declared cover, e.g. @ns/keg=viewer, or @ns/keg=none to block a keg its notes link to; entries use default depth 2. Custom depth cannot be set here. Omit to preserve all current cover entries and depths; [] clears"` Subflights []string `json:"subflights,omitempty" jsonschema:"replacement ordered child flight references; omit to keep current"` ExpectedHash string `json:"expected_hash" jsonschema:"precondition token returned by flight_read"` } diff --git a/pkg/tapper/flight.go b/pkg/tapper/flight.go index 8698c3ba..a37c61c2 100644 --- a/pkg/tapper/flight.go +++ b/pkg/tapper/flight.go @@ -26,6 +26,9 @@ const ( type FlightRole string const ( + // FlightRoleNone blocks a KEG: listed with it, the KEG is out of the + // flight's cover however its links reach it, and grants nothing. + FlightRoleNone FlightRole = "none" FlightRoleViewer FlightRole = "viewer" FlightRoleEditor FlightRole = "editor" FlightRoleAdmin FlightRole = "admin" @@ -64,6 +67,8 @@ func (r FlightRole) AtLeast(want FlightRole) bool { func normalizeFlightRole(role FlightRole) FlightRole { switch FlightRole(strings.TrimSpace(string(role))) { + case FlightRoleNone: + return FlightRoleNone case FlightRoleAdmin: return FlightRoleAdmin case FlightRoleEditor: @@ -595,7 +600,7 @@ func validateFlightManifest(m *FlightManifest, namespace string) error { return fmt.Errorf("invalid flight cover depth %d: must be 1–8", cover.Depth) } switch normalizeFlightRole(cover.Role) { - case FlightRoleViewer, FlightRoleEditor, FlightRoleAdmin: + case FlightRoleNone, FlightRoleViewer, FlightRoleEditor, FlightRoleAdmin: default: return fmt.Errorf("invalid flight cover role %q", cover.Role) } @@ -700,15 +705,20 @@ func (f *Flight) RoleFor(alias, namespace, kegName string) (FlightRole, bool) { kegName = strings.TrimSpace(kegName) alias = strings.TrimSpace(alias) for _, c := range cover { + match := false if c.Namespace == "" { - if (alias != "" && c.Keg == alias) || (kegName != "" && c.Keg == kegName) { - return normalizeFlightRole(c.Role), true - } + match = (alias != "" && c.Keg == alias) || (kegName != "" && c.Keg == kegName) + } else { + match = c.Namespace == namespace && c.Keg == kegName + } + if !match { continue } - if c.Namespace == namespace && c.Keg == kegName { - return normalizeFlightRole(c.Role), true + // A blocked KEG is not covered, whatever else names it. + if role := normalizeFlightRole(c.Role); role != FlightRoleNone { + return role, true } + return "", false } return "", false } @@ -727,6 +737,8 @@ func ParseFlightCoverSpecs(specs []string) ([]FlightCover, error) { role := FlightRoleViewer if hasRole { switch FlightRole(strings.TrimSpace(roleRaw)) { + case FlightRoleNone: + role = FlightRoleNone case FlightRoleViewer: role = FlightRoleViewer case FlightRoleEditor: diff --git a/pkg/tapper/flight_test.go b/pkg/tapper/flight_test.go index 4ee6c3af..3a1ef64a 100644 --- a/pkg/tapper/flight_test.go +++ b/pkg/tapper/flight_test.go @@ -453,3 +453,18 @@ func TestFlattenFlightGraph_ToleratesCycles(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{"@team/+y", "@team/+z"}, ringGraph.AvailableRefs()) } + +// A cover entry with role none blocks its KEG: specs accept it, and it +// covers nothing, even when another entry names the same KEG. +func TestFlightCoverNoneBlocks(t *testing.T) { + cover, err := tapper.ParseFlightCoverSpecs([]string{"@team/notes=viewer", "@team/secret=none"}) + require.NoError(t, err) + require.Equal(t, tapper.FlightRoleNone, cover[1].Role) + flight := &tapper.Flight{Namespace: "team", FlightManifest: tapper.FlightManifest{Cover: cover}} + _, ok := flight.RoleFor("", "team", "secret") + require.False(t, ok, "a blocked KEG is not covered") + role, ok := flight.RoleFor("", "team", "notes") + require.True(t, ok) + require.Equal(t, tapper.FlightRoleViewer, role) + require.False(t, tapper.FlightRoleNone.AtLeast(tapper.FlightRoleViewer)) +} diff --git a/pkg/tapper/node_ref_resolve.go b/pkg/tapper/node_ref_resolve.go index 87c801a3..d0b943da 100644 --- a/pkg/tapper/node_ref_resolve.go +++ b/pkg/tapper/node_ref_resolve.go @@ -9,7 +9,7 @@ import ( ) // RefContext carries the "current keg" so a node reference can resolve a -// keg-local alias against that keg's Links table and imply the hub for a +// keg-local alias against that keg's aliases and imply the hub for a // qualified reference from the current keg's hub. type RefContext struct { CurrentKeg keg.Keg @@ -19,7 +19,7 @@ type RefContext struct { // the numeric node id within that keg: // // - RefLocal: the current keg, with the bare node id. -// - RefAlias: the alias is resolved against the current keg's Links table +// - RefAlias: the alias is resolved against the current keg's aliases // first (so authored links travel with the keg), then the // Tapper configuration. // - RefQualified: a (hub, namespace, keg) reference whose hub is implied from @@ -40,7 +40,7 @@ func (t *Tap) ResolveNodeRef(ctx context.Context, ref *keg.NodeRef, rc RefContex if err != nil { return nil, keg.NodeId{}, err } - namespace, name, err := keg.ResolveRelationshipAlias(settings.Links, ref.Alias) + namespace, name, err := keg.ResolveSettingsAlias(settings.Aliases, ref.Alias) if err != nil { return nil, keg.NodeId{}, err } @@ -94,7 +94,7 @@ func (t *Tap) ResolveNodeRef(ctx context.Context, ref *keg.NodeRef, rc RefContex // // - "" / "-" operate on currentKeg (unchanged behavior). // - "keg:/" redirect to the keg the alias names (the -// current keg's Links table first, then tap-config kegs). +// current keg's aliases first, then tap-config kegs). // - "keg:@//" redirect to the fully qualified keg; the hub // is implied from currentKeg's hub. // diff --git a/schemas/flight-manifest.json b/schemas/flight-manifest.json index c19c92c3..5c0b5f7b 100644 --- a/schemas/flight-manifest.json +++ b/schemas/flight-manifest.json @@ -34,8 +34,9 @@ }, "role": { "type": "string", - "description": "Maximum role granted by this cover entry. Omitted roles default to viewer.", + "description": "Maximum role granted by this cover entry. Omitted roles default to viewer. none blocks the KEG: it is out of the cover however notes link to it, and the cover does not follow its links.", "enum": [ + "none", "viewer", "editor", "admin" diff --git a/schemas/keg-settings.json b/schemas/keg-settings.json index 6a2463d1..7e492ac7 100644 --- a/schemas/keg-settings.json +++ b/schemas/keg-settings.json @@ -37,9 +37,9 @@ "type": "string", "description": "Markdown guidance for agents working in this keg." }, - "links": { + "aliases": { "type": "array", - "description": "Named external links or related keg references.", + "description": "Named KEGs and external URLs. A note links keg:~alias/N to reach the KEG an alias names, so the link survives the KEG moving: only the alias changes.", "items": { "type": "object", "description": "A single named link entry.", @@ -60,6 +60,14 @@ "additionalProperties": true } }, + "links": { + "type": "array", + "deprecated": true, + "description": "Legacy name for aliases, read only when aliases is absent.", + "items": { + "$ref": "#/properties/aliases/items" + } + }, "listFields": { "type": "array", "description": "Field selectors shown by default in node listings, used by both `tap node list` and the hosted node list. A bare word names a metadata key (\"type\", \"subkind\"); a leading dot names a statistics field (\".omega\"); \"id\", \"title\", and \"tags\" are reserved.", From a6d29cf19ea04d2f1318a62761082836b58c157c Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Mon, 12 Oct 2026 19:30:35 -0500 Subject: [PATCH 3/8] feat(auth): show the personal namespace the hub reports whoami now carries personal_namespace, the user namespace the hub found the account owning rather than one assumed from the username. Relayed MCP tools are granted from it, so `tap auth status` shows it, or says the hub found none. An older hub that does not report it prints no line. --- pkg/tapper/auth_whoami.go | 19 +++++--- pkg/tapper/auth_whoami_test.go | 21 +++++++++ pkg/tapper/tap_auth.go | 82 ++++++++++++++++++++++------------ pkg/tapper/tap_auth_test.go | 30 +++++++++++++ 4 files changed, 116 insertions(+), 36 deletions(-) diff --git a/pkg/tapper/auth_whoami.go b/pkg/tapper/auth_whoami.go index 1a312ab6..8b1fe4b1 100644 --- a/pkg/tapper/auth_whoami.go +++ b/pkg/tapper/auth_whoami.go @@ -43,14 +43,19 @@ var ErrTokenRejected = errors.New("hub rejected the token") // fallback namespace so the hub, not the client, decides which namespace a // fresh login lands in. Namespaces lists every namespace the user belongs to // (personal + orgs) and is empty when the hub omits it (older hub). +// +// PersonalNamespace is the namespace of kind user the hub found the account +// owning, read from its database rather than assumed from the username. Nil +// when the hub omits it (older hub) or could not tell; "" when it has none. type WhoAmI struct { - UserID int64 `json:"user_id"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Email string `json:"email"` - CreatedAt time.Time `json:"created_at"` - DefaultNamespace string `json:"default_namespace"` - Namespaces []string `json:"namespaces,omitempty"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` + DefaultNamespace string `json:"default_namespace"` + Namespaces []string `json:"namespaces,omitempty"` + PersonalNamespace *string `json:"personal_namespace,omitempty"` } // ValidateToken calls GET {hubURL}/api/v1/whoami with the bearer token and diff --git a/pkg/tapper/auth_whoami_test.go b/pkg/tapper/auth_whoami_test.go index 1791d617..8350f75a 100644 --- a/pkg/tapper/auth_whoami_test.go +++ b/pkg/tapper/auth_whoami_test.go @@ -36,6 +36,27 @@ func TestValidateToken_Success(t *testing.T) { require.Equal(t, int64(42), who.UserID) require.Equal(t, "alice", who.Username) require.Equal(t, "alice@example.com", who.Email) + require.Nil(t, who.PersonalNamespace, "an older hub's silence is not a missing namespace") +} + +// personal_namespace "" (the hub found none) decodes apart from its absence. +func TestValidateToken_PersonalNamespace(t *testing.T) { + t.Parallel() + for _, want := range []string{"alice", ""} { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/whoami", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"user_id": 42, "username": "alice", "personal_namespace": want}) + }) + srv := testapi.NewServer(mux) + t.Cleanup(srv.Close) + + rt, _ := toolkit.NewRuntime() + who, err := tapper.ValidateToken(context.Background(), rt, srv.URL, "thub_abc") + require.NoError(t, err) + require.NotNil(t, who.PersonalNamespace) + require.Equal(t, want, *who.PersonalNamespace) + } } func TestValidateToken_Unauthorized(t *testing.T) { diff --git a/pkg/tapper/tap_auth.go b/pkg/tapper/tap_auth.go index 92ef4790..3e330e62 100644 --- a/pkg/tapper/tap_auth.go +++ b/pkg/tapper/tap_auth.go @@ -112,6 +112,11 @@ type AuthStatusHub struct { // Empty when the hub omits it or validation didn't run. DisplayName string + // PersonalNamespace is the personal namespace the hub reported for the + // account. Nil when the hub didn't say (older hub, --offline, failed + // probe); a pointer to "" when the hub found none. + PersonalNamespace *string + // Valid is true when the live whoami probe confirmed the token. False // when the token was rejected, the hub was unreachable, or --offline // skipped the check. @@ -181,6 +186,11 @@ type AuthStatusResult struct { // Empty when the hub omits it or validation didn't run. DisplayName string + // PersonalNamespace is the personal namespace the hub reported for the + // account. Nil when the hub didn't say (older hub, --offline, failed + // probe); a pointer to "" when the hub found none. + PersonalNamespace *string + // Valid is true when the live whoami probe confirmed the token. False // when the token was rejected, the hub was unreachable, or --offline // skipped the check. @@ -297,21 +307,22 @@ func (t *Tap) AuthStatus(ctx context.Context, opts AuthStatusOptions) (*AuthStat func authStatusResultFromHub(status AuthStatusHub) *AuthStatusResult { return &AuthStatusResult{ - Present: status.Present, - HubURL: status.HubURL, - TokenPrefix: status.TokenPrefix, - TokenType: status.TokenType, - Account: status.Account, - DisplayName: status.DisplayName, - Valid: status.Valid, - ValidationError: status.ValidationError, - Scope: status.Scope, - ExpiresAt: status.ExpiresAt, - ExpiryStatus: status.ExpiryStatus, - LoginMethod: status.LoginMethod, - Renewable: status.Renewable, - Hubs: []AuthStatusHub{status}, - Formatted: status.Formatted, + Present: status.Present, + HubURL: status.HubURL, + TokenPrefix: status.TokenPrefix, + TokenType: status.TokenType, + Account: status.Account, + DisplayName: status.DisplayName, + PersonalNamespace: status.PersonalNamespace, + Valid: status.Valid, + ValidationError: status.ValidationError, + Scope: status.Scope, + ExpiresAt: status.ExpiresAt, + ExpiryStatus: status.ExpiryStatus, + LoginMethod: status.LoginMethod, + Renewable: status.Renewable, + Hubs: []AuthStatusHub{status}, + Formatted: status.Formatted, } } @@ -384,6 +395,7 @@ func (t *Tap) authStatusForHub(ctx context.Context, store *AuthStore, storePath, var ( account string display string + personal *string valid bool validErr string statusLine string @@ -405,6 +417,7 @@ func (t *Tap) authStatusForHub(ctx context.Context, store *AuthStore, storePath, valid = true account = who.Username display = who.DisplayName + personal = who.PersonalNamespace ident := account if display != "" { ident = fmt.Sprintf("%s (%s)", account, display) @@ -451,6 +464,16 @@ func (t *Tap) authStatusForHub(ctx context.Context, store *AuthStore, storePath, var b strings.Builder fmt.Fprintf(&b, "%s\n", host) fmt.Fprintf(&b, "%s\n", statusLine) + // The personal namespace is the hub's answer, not the username assumed: + // relayed MCP tools are granted from it, so a missing one is worth seeing. + // An older hub doesn't report it, and then there is no line. + if personal != nil { + if *personal == "" { + b.WriteString(" - Namespace: none (the hub has no personal namespace for this account)\n") + } else { + fmt.Fprintf(&b, " - Namespace: @%s\n", *personal) + } + } fmt.Fprintf(&b, "%s\n", methodLine) // The token prefix is only useful for a pasted API token: the hub lists // token[:12] in the account UI, so showing the same prefix lets the user @@ -476,20 +499,21 @@ func (t *Tap) authStatusForHub(ctx context.Context, store *AuthStore, storePath, } return AuthStatusHub{ - Present: true, - HubURL: hub, - TokenPrefix: tokenPrefix, - TokenType: entry.TokenType, - Account: account, - DisplayName: display, - Valid: valid, - ValidationError: validErr, - Scope: entry.Scope, - ExpiresAt: entry.ExpiresAt, - ExpiryStatus: expiryStatus, - LoginMethod: loginMethod, - Renewable: renewable, - Formatted: b.String(), + Present: true, + HubURL: hub, + TokenPrefix: tokenPrefix, + TokenType: entry.TokenType, + Account: account, + DisplayName: display, + PersonalNamespace: personal, + Valid: valid, + ValidationError: validErr, + Scope: entry.Scope, + ExpiresAt: entry.ExpiresAt, + ExpiryStatus: expiryStatus, + LoginMethod: loginMethod, + Renewable: renewable, + Formatted: b.String(), }, nil } diff --git a/pkg/tapper/tap_auth_test.go b/pkg/tapper/tap_auth_test.go index 28637636..3be819e4 100644 --- a/pkg/tapper/tap_auth_test.go +++ b/pkg/tapper/tap_auth_test.go @@ -223,6 +223,36 @@ func TestTap_AuthStatus(t *testing.T) { require.NotContains(t, res.Formatted, tok) }) + t.Run("personal namespace is shown as the hub reports it", func(t *testing.T) { + t.Parallel() + named, none := "alice", "" + for _, tc := range []struct { + name string + personal *string + want string + }{ + {"set", &named, " ✓ Logged in as alice\n - Namespace: @alice\n"}, + {"none", &none, " ✓ Logged in as alice\n - Namespace: none (the hub has no personal namespace for this account)\n"}, + {"older hub", nil, " ✓ Logged in as alice\n - Method:"}, + } { + t.Run(tc.name, func(t *testing.T) { + sb := NewSandbox(t) + tap := newTestTap(t, sb) + seedStore(t, sb, tap, map[string]tapper.AuthEntry{ + "https://hub.example.com": {AccessToken: "thub_personalns0000", TokenType: "Bearer"}, + }) + tap.AuthValidateFn = func(context.Context, *toolkit.Runtime, string, string) (*tapper.WhoAmI, error) { + return &tapper.WhoAmI{Username: "alice", PersonalNamespace: tc.personal}, nil + } + + res, err := tap.AuthStatus(sb.Context(), tapper.AuthStatusOptions{}) + require.NoError(t, err) + require.Contains(t, res.Formatted, tc.want) + require.Equal(t, tc.personal, res.PersonalNamespace) + }) + } + }) + t.Run("valid token without display name omits parens", func(t *testing.T) { t.Parallel() sb := NewSandbox(t) From 7639c7f502a84a4338fcef6d880613c1f128bf42 Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Mon, 12 Oct 2026 19:30:35 -0500 Subject: [PATCH 4/8] feat(keg): edit a node's content with a unified diff node_edit accepts patch, a unified diff against the node's current body, as an alternative to content, so a small change to a long note sends only the changed lines. Every hunk's context and removed lines must match the body exactly and in one place; a patch that does not fit is refused with the lines it expected and nothing is written. A patch is a content write, held to expected_content_hash, and keeps the body's line endings. Remote kegs send it to the hub as content_patch. An update may keep a body that already opens with frontmatter, written before the no-frontmatter rule, but no update can introduce one. --- integrations/content/tool-inventory.md | 2 +- .../claude/tapper/skills/tapper/SKILL.md | 2 +- .../codex/tapper/skills/tapper/SKILL.md | 2 +- .../opencode/tapper/skills/tapper/SKILL.md | 2 +- pkg/keg/keg_aggregate.go | 21 +- pkg/keg/keg_batch.go | 193 ++++++--- pkg/keg/keg_batch_test.go | 53 +++ pkg/keg/keg_remote_aggregate.go | 9 +- pkg/keg/patch.go | 398 ++++++++++++++++++ pkg/keg/patch_test.go | 272 ++++++++++++ pkg/keg/remote_precondition_test.go | 20 + pkg/mcp/server_test.go | 87 +++- pkg/mcp/tools_write.go | 17 +- pkg/tapper/tap_batch.go | 80 ++-- 14 files changed, 1040 insertions(+), 118 deletions(-) create mode 100644 pkg/keg/patch.go create mode 100644 pkg/keg/patch_test.go diff --git a/integrations/content/tool-inventory.md b/integrations/content/tool-inventory.md index 079cafe3..be43995a 100644 --- a/integrations/content/tool-inventory.md +++ b/integrations/content/tool-inventory.md @@ -125,7 +125,7 @@ code; the index does the work in O(matches) rather than O(total). | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__node_create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. | -| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. | +| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | | `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | diff --git a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md index 0bfd85e6..9b00cbac 100644 --- a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md @@ -170,7 +170,7 @@ code; the index does the work in O(matches) rather than O(total). | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__node_create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. | -| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. | +| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | | `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | diff --git a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md index 0bfd85e6..9b00cbac 100644 --- a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md @@ -170,7 +170,7 @@ code; the index does the work in O(matches) rather than O(total). | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__node_create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. | -| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. | +| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | | `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | diff --git a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md index 0bfd85e6..9b00cbac 100644 --- a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md @@ -170,7 +170,7 @@ code; the index does the work in O(matches) rather than O(total). | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__node_create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. | -| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. | +| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | | `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | diff --git a/pkg/keg/keg_aggregate.go b/pkg/keg/keg_aggregate.go index bd7f2c15..efc8a3aa 100644 --- a/pkg/keg/keg_aggregate.go +++ b/pkg/keg/keg_aggregate.go @@ -244,13 +244,18 @@ type NodeOpenOptions struct { } type NodeUpdateOptions struct { - ID NodeId `json:"id"` - Schema string `json:"schema,omitempty"` - Content []byte `json:"content"` - HasContent bool `json:"has_content,omitempty"` - Meta []byte `json:"meta,omitempty"` - HasMeta bool `json:"has_meta,omitempty"` - LockToken LockToken `json:"lock_token,omitempty"` + ID NodeId `json:"id"` + Schema string `json:"schema,omitempty"` + Content []byte `json:"content"` + HasContent bool `json:"has_content,omitempty"` + // ContentPatch is a unified diff applied to the node's current content + // (see ApplyUnifiedPatch), an alternative to Content. It is checked + // like a content write: it needs ExpectedContentHash or ExpectedHash. + ContentPatch string `json:"content_patch,omitempty"` + HasContentPatch bool `json:"has_content_patch,omitempty"` + Meta []byte `json:"meta,omitempty"` + HasMeta bool `json:"has_meta,omitempty"` + LockToken LockToken `json:"lock_token,omitempty"` // ExpectedHash is the combined content+metadata token (NodeView.Hash). // It is checked only when neither part token below is set. ExpectedHash string `json:"expected_hash,omitempty"` @@ -922,7 +927,7 @@ func (k *LocalKeg) openNode(ctx context.Context, opts NodeOpenOptions) (*NodeVie } func (k *LocalKeg) UpdateNode(ctx context.Context, opts NodeUpdateOptions) (*NodeUpdateResult, error) { - opts.HasContent = true + opts.HasContent = !opts.HasContentPatch results, err := k.UpdateNodes(ctx, []NodeUpdateOptions{opts}) if len(results) == 0 { return nil, err diff --git a/pkg/keg/keg_batch.go b/pkg/keg/keg_batch.go index 355f71cf..c1da9c29 100644 --- a/pkg/keg/keg_batch.go +++ b/pkg/keg/keg_batch.go @@ -147,11 +147,49 @@ func expandNodePlaceholders(body []byte, ids map[string]NodeId) ([]byte, error) } type preparedNodeUpdate struct { - opts NodeUpdateOptions - data *NodeData - content []byte - validation *SchemaValidationResult - backup *aggregateBackup + opts NodeUpdateOptions + // existing is the node as it was read before the update. + existing *NodeView + data *NodeData + content []byte + // writeContent says the update carries a body (full content or a + // patch), so content is written. + writeContent bool + validation *SchemaValidationResult + backup *aggregateBackup +} + +// PlanContentUpdate works out the body an update would leave a node with, +// given the node's current body. It reports whether the update writes a body +// at all; without content or a patch it returns existing unchanged. +// +// Content and a content patch are alternatives. A patch must apply to +// existing exactly (see ApplyUnifiedPatch). Either way the result may open +// with a frontmatter block only when existing already does: a stored body +// written before the no-frontmatter rule stays editable, but no update can +// introduce one. +func PlanContentUpdate(existing []byte, opts NodeUpdateOptions) ([]byte, bool, error) { + var result []byte + switch { + case opts.HasContent && opts.HasContentPatch: + return nil, false, fmt.Errorf("content and a content patch are alternatives; send one: %w", ErrInvalid) + case opts.HasContentPatch: + patched, err := ApplyUnifiedPatch(existing, opts.ContentPatch) + if err != nil { + return nil, false, err + } + result = patched + case opts.HasContent: + result = opts.Content + default: + return existing, false, nil + } + if RejectFrontmatter(existing) == nil { + if err := RejectFrontmatter(result); err != nil { + return nil, false, err + } + } + return result, true, nil } func (k *LocalKeg) UpdateNodes(ctx context.Context, updates []NodeUpdateOptions) ([]NodeUpdateResult, error) { @@ -168,81 +206,26 @@ func (k *LocalKeg) updateNodes(ctx context.Context, updates []NodeUpdateOptions) if !opts.ID.Valid() { return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("node id is required: %w", ErrInvalid)} } - if !opts.HasContent && !opts.HasMeta { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("content or metadata replacement is required: %w", ErrInvalid)} + if !opts.HasContent && !opts.HasContentPatch && !opts.HasMeta { + return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("content, a content patch, or metadata replacement is required: %w", ErrInvalid)} + } + if opts.HasContent && opts.HasContentPatch { + return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("content and a content patch are alternatives; send one: %w", ErrInvalid)} } if first, ok := seen[opts.ID]; ok { return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("duplicate node id (first at index %d): %w", first, ErrInvalid)} } seen[opts.ID] = i - existing, err := k.ReadNode(ctx, opts.ID) - if err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} - } - backup, err := k.captureAggregateBackup(ctx, existing) + item, err := k.prepareNodeUpdate(ctx, opts) if err != nil { return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} } - if err := k.validateAggregateLock(ctx, opts.ID, opts.LockToken); err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} - } - if err := checkNodeUpdatePrecondition(ctx, k.Runtime, opts, existing); err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} - } - contentBytes := existing.Content - if opts.HasContent { - // Only caller-supplied content is held to the no-frontmatter rule. - // A node written before the rule existed may have a stored body - // that opens with `---`; checking contentBytes would make such a - // node permanently unupdatable. - if err := RejectFrontmatter(opts.Content); err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} - } - contentBytes = opts.Content - } - metaBytes := existing.Meta - if opts.HasMeta { - metaBytes = opts.Meta - } - content, err := ParseContent(k.Runtime, contentBytes, MarkdownContentFilename) + backup, err := k.captureAggregateBackup(ctx, item.existing) if err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("invalid content: %w", err)} - } - meta, err := ParseMeta(ctx, metaBytes) - if err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("invalid metadata: %s: %w", err, ErrInvalid)} - } - stats, err := cloneNodeStats(ctx, existing.Stats) - if err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} - } - if stats == nil { - stats = &NodeStats{} - } - // Without submitted meta, meta is the stored metadata: a content-only - // edit keeps its type. Read it before updateMeta touches meta. - kept := schemaTypeCandidateInherited(meta) - now := k.Runtime.Clock().Now() - data := &NodeData{ID: opts.ID, Content: content, Meta: meta, Stats: stats} - if err := data.updateMeta(ctx, k.Runtime, &now); err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} - } - // Only meta can declare a type: RejectFrontmatter above guarantees - // submitted content carries none. - candidates := make([]schemaTypeCandidate, 0, 1) - if opts.HasMeta { - candidates = append(candidates, schemaTypeCandidateFromMeta("metadata", meta)) - } else { - candidates = append(candidates, kept) - } - validation, err := k.validateNodeWrite(ctx, schemaWriteUpdate, opts.ID, data, opts.Schema, candidates...) - if err != nil && !errors.Is(err, ErrNotSupported) { return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} } - if validation != nil && validation.Valid { - validation = nil - } - prepared[i] = preparedNodeUpdate{opts: opts, data: data, content: contentBytes, validation: validation, backup: backup} + item.backup = backup + prepared[i] = item } results := make([]NodeUpdateResult, len(prepared)) createdSnapshots := false @@ -259,7 +242,7 @@ func (k *LocalKeg) updateNodes(ctx context.Context, updates []NodeUpdateOptions) return err } } - if item.opts.HasContent { + if item.writeContent { if err := k.Repo.WriteContent(lockCtx, item.opts.ID, item.content); err != nil { return err } @@ -294,6 +277,76 @@ func (k *LocalKeg) updateNodes(ctx context.Context, updates []NodeUpdateOptions) return results, nil } +// prepareNodeUpdate checks one update against the node as it stands and +// builds what it would write, without writing anything: the lock, the +// precondition tokens, the planned body (PlanContentUpdate), metadata that +// parses, and the keg's schema rules. Any error means the update would be +// refused. +func (k *LocalKeg) prepareNodeUpdate(ctx context.Context, opts NodeUpdateOptions) (preparedNodeUpdate, error) { + existing, err := k.ReadNode(ctx, opts.ID) + if err != nil { + return preparedNodeUpdate{}, err + } + if err := k.validateAggregateLock(ctx, opts.ID, opts.LockToken); err != nil { + return preparedNodeUpdate{}, err + } + // A patch is a content write: it is held to the content token, and + // checked before it is applied so a stale token reports a conflict + // rather than a patch that no longer fits. + check := opts + if opts.HasContentPatch { + check.HasContent = true + } + if err := checkNodeUpdatePrecondition(ctx, k.Runtime, check, existing); err != nil { + return preparedNodeUpdate{}, err + } + contentBytes, writeContent, err := PlanContentUpdate(existing.Content, opts) + if err != nil { + return preparedNodeUpdate{}, err + } + metaBytes := existing.Meta + if opts.HasMeta { + metaBytes = opts.Meta + } + content, err := ParseContent(k.Runtime, contentBytes, MarkdownContentFilename) + if err != nil { + return preparedNodeUpdate{}, fmt.Errorf("invalid content: %w", err) + } + meta, err := ParseMeta(ctx, metaBytes) + if err != nil { + return preparedNodeUpdate{}, fmt.Errorf("invalid metadata: %s: %w", err, ErrInvalid) + } + stats, err := cloneNodeStats(ctx, existing.Stats) + if err != nil { + return preparedNodeUpdate{}, err + } + if stats == nil { + stats = &NodeStats{} + } + // Without submitted meta, meta is the stored metadata: a content-only + // edit keeps its type. Read it before updateMeta touches meta. + kept := schemaTypeCandidateInherited(meta) + now := k.Runtime.Clock().Now() + data := &NodeData{ID: opts.ID, Content: content, Meta: meta, Stats: stats} + if err := data.updateMeta(ctx, k.Runtime, &now); err != nil { + return preparedNodeUpdate{}, err + } + // Only meta can declare a type: content may not introduce a frontmatter + // block, and a legacy one is not read as a type. + candidate := kept + if opts.HasMeta { + candidate = schemaTypeCandidateFromMeta("metadata", meta) + } + validation, err := k.validateNodeWrite(ctx, schemaWriteUpdate, opts.ID, data, opts.Schema, candidate) + if err != nil && !errors.Is(err, ErrNotSupported) { + return preparedNodeUpdate{}, err + } + if validation != nil && validation.Valid { + validation = nil + } + return preparedNodeUpdate{opts: opts, existing: existing, data: data, content: contentBytes, writeContent: writeContent, validation: validation}, nil +} + func (k *LocalKeg) AppendSnapshots(ctx context.Context, nodes []NodeSnapshotRequest) ([]Snapshot, error) { return withKegAtomicWriteValue(ctx, k, func(ctx context.Context) ([]Snapshot, error) { if err := validateMutationBatchSize(len(nodes)); err != nil { diff --git a/pkg/keg/keg_batch_test.go b/pkg/keg/keg_batch_test.go index 534e61d0..3b4253be 100644 --- a/pkg/keg/keg_batch_test.go +++ b/pkg/keg/keg_batch_test.go @@ -365,3 +365,56 @@ func TestStrictArchiveImportRemainsExempt(t *testing.T) { require.NoError(t, err) require.Len(t, after, len(before)+1) } + +func TestUpdateKeepsALegacyFrontmatterBodyEditable(t *testing.T) { + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + legacy, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# T\n")}) + require.NoError(t, err) + // Store the legacy shape past the write guard, as a node predating the + // no-frontmatter rule already is. + require.NoError(t, k.Repo.WriteContent(ctx, legacy.ID, []byte("---\na: 1\n---\n# T\n"))) + view, err := k.ReadNode(ctx, legacy.ID) + require.NoError(t, err) + + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: legacy.ID, Content: []byte("---\na: 1\n---\n# T\n\nMore.\n"), HasContent: true, ExpectedContentHash: view.ContentHash(), + }}) + require.NoError(t, err, "a full-content edit keeping the stored block is allowed") + got, err := k.ReadNode(ctx, legacy.ID) + require.NoError(t, err) + require.Equal(t, "---\na: 1\n---\n# T\n\nMore.\n", string(got.Content)) + + normal, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# N\n")}) + require.NoError(t, err) + nview, err := k.ReadNode(ctx, normal.ID) + require.NoError(t, err) + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: normal.ID, Content: []byte("---\na: 1\n---\n# N\n"), HasContent: true, ExpectedContentHash: nview.ContentHash(), + }}) + require.ErrorIs(t, err, keg.ErrInvalid, "content may not introduce a frontmatter block") + unchanged, err := k.ReadNode(ctx, normal.ID) + require.NoError(t, err) + require.Equal(t, "# N\n", string(unchanged.Content)) +} + +func TestPlanContentUpdate(t *testing.T) { + t.Parallel() + got, has, err := keg.PlanContentUpdate([]byte("# A\n"), keg.NodeUpdateOptions{}) + require.NoError(t, err) + require.False(t, has) + require.Equal(t, "# A\n", string(got)) + + got, has, err = keg.PlanContentUpdate([]byte("# A\nx\n"), keg.NodeUpdateOptions{ContentPatch: "@@ -2 +2 @@\n-x\n+y\n", HasContentPatch: true}) + require.NoError(t, err) + require.True(t, has) + require.Equal(t, "# A\ny\n", string(got)) + + _, _, err = keg.PlanContentUpdate([]byte("# A\n"), keg.NodeUpdateOptions{Content: []byte("# B\n"), HasContent: true, ContentPatch: "@@ -1 +1 @@\n-# A\n+# B\n", HasContentPatch: true}) + require.ErrorIs(t, err, keg.ErrInvalid) + + _, _, err = keg.PlanContentUpdate([]byte("# A\n"), keg.NodeUpdateOptions{ContentPatch: "@@ -1 +1,3 @@\n-# A\n+---\n+a: 1\n+---\n", HasContentPatch: true}) + require.ErrorIs(t, err, keg.ErrInvalid) +} diff --git a/pkg/keg/keg_remote_aggregate.go b/pkg/keg/keg_remote_aggregate.go index fdffbb4a..f7bcd718 100644 --- a/pkg/keg/keg_remote_aggregate.go +++ b/pkg/keg/keg_remote_aggregate.go @@ -196,7 +196,9 @@ func (k *RemoteKeg) OpenNode(ctx context.Context, opts NodeOpenOptions) (*NodeVi } func (k *RemoteKeg) UpdateNode(ctx context.Context, opts NodeUpdateOptions) (*NodeUpdateResult, error) { - opts.HasContent = true + // A single-node update carries content unless it carries a patch, as + // LocalKeg.UpdateNode treats it. + opts.HasContent = !opts.HasContentPatch results, err := k.UpdateNodes(ctx, []NodeUpdateOptions{opts}) if len(results) == 0 { return nil, err @@ -245,6 +247,7 @@ func (k *RemoteKeg) UpdateNodes(ctx context.Context, updates []NodeUpdateOptions NodeID int `json:"node_id"` Schema string `json:"schema,omitempty"` Content *string `json:"content,omitempty"` + ContentPatch *string `json:"content_patch,omitempty"` Meta *string `json:"meta,omitempty"` LockToken string `json:"lock_token,omitempty"` ExpectedHash string `json:"expected_hash,omitempty"` @@ -270,6 +273,10 @@ func (k *RemoteKeg) UpdateNodes(ctx context.Context, updates []NodeUpdateOptions v := string(item.Content) wire[i].Content = &v } + if item.HasContentPatch { + v := item.ContentPatch + wire[i].ContentPatch = &v + } if item.HasMeta { v := string(item.Meta) wire[i].Meta = &v diff --git a/pkg/keg/patch.go b/pkg/keg/patch.go new file mode 100644 index 00000000..d47da2d8 --- /dev/null +++ b/pkg/keg/patch.go @@ -0,0 +1,398 @@ +package keg + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// maxPatchBytes caps a content patch. A patch that size is already far +// larger than any note it could sensibly edit; send content instead. +const maxPatchBytes = 1 << 20 + +// maxPatchComparisons caps the line comparisons spent placing a patch's +// hunks, so a patch whose lines repeat throughout a large note cannot keep a +// write busy for long. +const maxPatchComparisons = 20_000_000 + +// ApplyUnifiedPatch applies a unified diff to base and returns the patched +// text. +// +// The patch is the hunk part of a `diff -u`: optional `diff`/`---`/`+++` +// header lines, then `@@ -a,b +c,d @@` hunks of context (" "), removed ("-") +// and added ("+") lines. A bare empty line inside a hunk counts as an empty +// context line, since editors and models often strip the single space. +// +// Content is never guessed at: each hunk's context and removed lines must +// appear in base exactly. The hunk header's line number is only a hint, so a +// hunk whose numbers are off still applies when its lines match in exactly +// one place; its counts only decide where a hunk may end, so a removed +// "-- x" line followed by an added "++ y" line is not mistaken for another +// file's header. A hunk that matches nowhere, or in more than one place none +// of which is at its stated line, is refused with ErrInvalid. Hunks apply in +// order and may not overlap. +// +// Line endings are kept: a base with CRLF line endings stays CRLF, and the +// patch's lines match base lines whatever their ending. "\ No newline at end +// of file" may only follow a hunk that reaches the end of the note. +func ApplyUnifiedPatch(base []byte, patch string) ([]byte, error) { + if len(patch) > maxPatchBytes { + return nil, fmt.Errorf("patch is %d bytes; the limit is %d, so send the content instead: %w", len(patch), maxPatchBytes, ErrInvalid) + } + hunks, err := parseUnifiedPatch(patch) + if err != nil { + return nil, err + } + lines, eofNewline := splitPatchLines(string(base)) + baseEOF := eofNewline + // Untouched lines keep their own endings, so a mixed body stays as it + // was. A line a hunk writes takes the ending of the line it replaces, or + // the body's predominant ending. + crlf := predominantCRLF(lines, eofNewline) + ending := func(line string, fallback bool) string { + if fallback { + return line + "\r" + } + return line + } + // lastBase is where base's final line landed in out when base had no + // final newline: if a line follows it now, it needs an ending. + lastBase := -1 + keep := func(out []string, i int) []string { + if i == len(lines)-1 && !baseEOF { + lastBase = len(out) + } + return append(out, lines[i]) + } + m := &patchMatcher{lines: lines, budget: maxPatchComparisons} + out := make([]string, 0, len(lines)) + cursor := 0 + for i, h := range hunks { + old := h.oldLines() + at, err := m.locateHunk(old, cursor, h.oldStart) + if err != nil { + return nil, fmt.Errorf("patch hunk %d: %w", i+1, err) + } + if (h.oldNoNewline || h.newNoNewline) && at+len(old) != len(lines) { + return nil, fmt.Errorf("patch hunk %d marks 'No newline at end of file' but does not reach the end of the note: %w", i+1, ErrInvalid) + } + for j := cursor; j < at; j++ { + out = keep(out, j) + } + j := at + // replaced is the ending of the last line this run removed, if any. + replaced := -1 + for _, l := range h.lines { + switch l.op { + case ' ': + out = keep(out, j) + j++ + replaced = -1 + case '-': + switch { + case j == len(lines)-1 && !baseEOF: + // Base's last line has no ending to pass on. + replaced = -1 + case strings.HasSuffix(lines[j], "\r"): + replaced = 1 + default: + replaced = 0 + } + j++ + case '+': + withCR := crlf + if replaced >= 0 { + withCR = replaced == 1 + } + out = append(out, ending(l.text, withCR)) + } + } + cursor = at + len(old) + if h.oldNoNewline || h.newNoNewline { + eofNewline = !h.newNoNewline + } + } + for j := cursor; j < len(lines); j++ { + out = keep(out, j) + } + if len(out) == 0 { + return []byte{}, nil + } + if lastBase >= 0 && (lastBase < len(out)-1 || eofNewline) && crlf && !strings.HasSuffix(out[lastBase], "\r") { + out[lastBase] += "\r" + } + if !eofNewline { + // No newline follows the last line, so it carries no "\r" either. + out[len(out)-1] = strings.TrimSuffix(out[len(out)-1], "\r") + } + text := strings.Join(out, "\n") + if eofNewline { + text += "\n" + } + return []byte(text), nil +} + +// predominantCRLF reports whether most of the body's line breaks are CRLF, +// the first line deciding a tie. lines are split on "\n"; the last has no +// break after it unless eofNewline. +func predominantCRLF(lines []string, eofNewline bool) bool { + breaks := len(lines) + if !eofNewline { + breaks-- + } + crlf := 0 + for i := 0; i < breaks; i++ { + if strings.HasSuffix(lines[i], "\r") { + crlf++ + } + } + lf := breaks - crlf + if crlf != lf { + return crlf > lf + } + return breaks > 0 && strings.HasSuffix(lines[0], "\r") +} + +type patchLine struct { + op byte + text string +} + +type patchHunk struct { + // oldStart is the 1-based line the hunk says it starts at, or 0 when the + // header carries no usable number. + oldStart int + // oldCount and newCount are the header's line counts; counted says the + // header had them. + oldCount, newCount int + counted bool + lines []patchLine + oldNoNewline bool + newNoNewline bool +} + +// complete reports whether the hunk already holds as many lines as its +// header declares. +func (h patchHunk) complete() bool { + if !h.counted { + return false + } + oldSeen, newSeen := 0, 0 + for _, l := range h.lines { + if l.op != '+' { + oldSeen++ + } + if l.op != '-' { + newSeen++ + } + } + return oldSeen >= h.oldCount && newSeen >= h.newCount +} + +func (h patchHunk) oldLines() []string { + var out []string + for _, l := range h.lines { + if l.op != '+' { + out = append(out, l.text) + } + } + return out +} + +var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`) + +func parseUnifiedPatch(patch string) ([]patchHunk, error) { + patch = strings.ReplaceAll(patch, "\r\n", "\n") + if strings.TrimSpace(patch) == "" { + return nil, fmt.Errorf("patch is empty: %w", ErrInvalid) + } + // Trailing blank lines are padding, not empty context lines. + raw := strings.Split(strings.TrimRight(patch, "\n"), "\n") + var hunks []patchHunk + var cur *patchHunk + for n, line := range raw { + if strings.HasPrefix(line, "@@") { + h := patchHunk{} + if m := hunkHeaderPattern.FindStringSubmatch(line); m != nil { + h.oldStart, _ = strconv.Atoi(m[1]) + // "-a,0" names the line an insertion follows, so the + // insertion starts one line later. + if m[2] == "0" { + h.oldStart++ + } + h.oldCount, h.newCount, h.counted = 1, 1, true + if m[2] != "" { + h.oldCount, _ = strconv.Atoi(m[2]) + } + if m[4] != "" { + h.newCount, _ = strconv.Atoi(m[4]) + } + } + hunks = append(hunks, h) + cur = &hunks[len(hunks)-1] + continue + } + if cur == nil { + if isPatchHeader(line) || strings.TrimSpace(line) == "" { + continue + } + return nil, fmt.Errorf("patch line %d: expected a hunk header (@@ -a,b +c,d @@) before %q: %w", n+1, line, ErrInvalid) + } + if line == "" { + cur.lines = append(cur.lines, patchLine{op: ' '}) + continue + } + // A line that looks like the next file's header only is one once + // the hunk holds all the lines its header declared; before that it + // is a removed "-- x" or an added "++ y" line. A header without + // counts gives nothing to go on, so the look alone decides. + if (!cur.counted || cur.complete()) && + (strings.HasPrefix(line, "diff ") || (strings.HasPrefix(line, "--- ") && n+1 < len(raw) && strings.HasPrefix(raw[n+1], "+++ "))) { + return nil, fmt.Errorf("patch line %d: a patch edits one note; %q starts another file: %w", n+1, line, ErrInvalid) + } + switch line[0] { + case ' ', '-', '+': + cur.lines = append(cur.lines, patchLine{op: line[0], text: line[1:]}) + case '\\': + if len(cur.lines) == 0 { + return nil, fmt.Errorf("patch line %d: %q follows no line: %w", n+1, line, ErrInvalid) + } + switch cur.lines[len(cur.lines)-1].op { + case ' ': + cur.oldNoNewline, cur.newNoNewline = true, true + case '-': + cur.oldNoNewline = true + case '+': + cur.newNoNewline = true + } + default: + return nil, fmt.Errorf("patch line %d: %q must start with ' ', '-' or '+': %w", n+1, line, ErrInvalid) + } + } + if len(hunks) == 0 { + return nil, fmt.Errorf("patch has no hunks (@@ -a,b +c,d @@): %w", ErrInvalid) + } + for i, h := range hunks { + changed := false + for _, l := range h.lines { + if l.op != ' ' { + changed = true + break + } + } + if !changed { + return nil, fmt.Errorf("patch hunk %d changes nothing: %w", i+1, ErrInvalid) + } + } + return hunks, nil +} + +func isPatchHeader(line string) bool { + return strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") || + strings.HasPrefix(line, "diff ") || strings.HasPrefix(line, "index ") +} + +// splitPatchLines splits text into lines without their "\n" and reports +// whether the text ended with one. A CRLF line keeps its "\r". +func splitPatchLines(text string) ([]string, bool) { + if text == "" { + return nil, true + } + eofNewline := strings.HasSuffix(text, "\n") + return strings.Split(strings.TrimSuffix(text, "\n"), "\n"), eofNewline +} + +// patchMatcher places hunks in a note's lines within a comparison budget. +type patchMatcher struct { + lines []string + budget int + exceeded bool +} + +var errPatchTooExpensive = fmt.Errorf("this patch is too expensive to place; add context lines or send content: %w", ErrInvalid) + +// locateHunk finds where old starts in the lines at or after from. The +// stated line wins when old is there; otherwise old must occur exactly once. +func (m *patchMatcher) locateHunk(old []string, from, oldStart int) (int, error) { + lines := m.lines + if len(old) == 0 { + // A pure insertion has nothing to match, so its stated line is all + // there is to go on. + at := oldStart - 1 + if oldStart == 0 || at < from || at > len(lines) { + return 0, fmt.Errorf("an insertion with no context lines needs a valid line number in its header: %w", ErrInvalid) + } + return at, nil + } + if at := oldStart - 1; at >= from && m.matchesAt(old, at) { + return at, nil + } + found := -1 + count := 0 + for at := from; at+len(old) <= len(lines) && !m.exceeded; at++ { + if m.matchesAt(old, at) { + if found < 0 { + found = at + } + count++ + } + } + if m.exceeded { + return 0, errPatchTooExpensive + } + switch { + case count == 1: + return found, nil + case count == 0 && m.occursBefore(old, from): + return 0, fmt.Errorf("its lines only occur before the end of the previous hunk; hunks must be in order and must not overlap: %w", ErrInvalid) + case count == 0 && m.exceeded: + return 0, errPatchTooExpensive + case count == 0: + return 0, fmt.Errorf("its context and removed lines do not match the current content (did the note change, or is a context line wrong?); expected:\n%s: %w", quotePatchLines(old), ErrInvalid) + default: + return 0, fmt.Errorf("its context and removed lines match %d places and none is at its stated line; add context lines to make it unique: %w", count, ErrInvalid) + } +} + +func (m *patchMatcher) occursBefore(old []string, end int) bool { + for at := 0; at < end && !m.exceeded; at++ { + if m.matchesAt(old, at) { + return true + } + } + return false +} + +// matchesAt reports whether old matches the lines starting at at, ignoring +// each line's "\r". It spends one comparison per line it compares. +func (m *patchMatcher) matchesAt(old []string, at int) bool { + lines := m.lines + if at < 0 || at+len(old) > len(lines) { + return false + } + for i, l := range old { + if m.budget <= 0 { + m.exceeded = true + return false + } + m.budget-- + if strings.TrimSuffix(lines[at+i], "\r") != l { + return false + } + } + return true +} + +func quotePatchLines(lines []string) string { + const max = 8 + var b strings.Builder + for i, l := range lines { + if i == max { + fmt.Fprintf(&b, " … %d more\n", len(lines)-max) + break + } + fmt.Fprintf(&b, " %q\n", l) + } + return strings.TrimSuffix(b.String(), "\n") +} diff --git a/pkg/keg/patch_test.go b/pkg/keg/patch_test.go new file mode 100644 index 00000000..70ae16e6 --- /dev/null +++ b/pkg/keg/patch_test.go @@ -0,0 +1,272 @@ +package keg_test + +import ( + "strings" + "testing" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/stretchr/testify/require" +) + +func TestApplyUnifiedPatch(t *testing.T) { + t.Parallel() + base := "# Title\n\nalpha\nbeta\ngamma\n\n## Status\nDraft\n" + cases := []struct { + name string + base string + patch string + want string + }{ + { + name: "replaces a line", + base: base, + patch: "@@ -7,2 +7,2 @@\n ## Status\n-Draft\n+Published\n", + want: "# Title\n\nalpha\nbeta\ngamma\n\n## Status\nPublished\n", + }, + { + name: "accepts file headers", + base: base, + patch: "--- a/README.md\n+++ b/README.md\n@@ -3,1 +3,1 @@\n-alpha\n+ALPHA\n", + want: "# Title\n\nALPHA\nbeta\ngamma\n\n## Status\nDraft\n", + }, + { + name: "applies when the stated line is wrong but the lines are unique", + base: base, + patch: "@@ -40,3 +40,3 @@\n alpha\n-beta\n+BETA\n gamma\n", + want: "# Title\n\nalpha\nBETA\ngamma\n\n## Status\nDraft\n", + }, + { + name: "applies several hunks in order", + base: base, + patch: "@@ -1 +1 @@\n-# Title\n+# New title\n@@ -8 +8,2 @@\n-Draft\n+Published\n+On 2026-10-10\n", + want: "# New title\n\nalpha\nbeta\ngamma\n\n## Status\nPublished\nOn 2026-10-10\n", + }, + { + name: "reads a bare empty line as empty context", + base: base, + patch: "@@ -5,3 +5,3 @@\n gamma\n\n-## Status\n+## State\n", + want: "# Title\n\nalpha\nbeta\ngamma\n\n## State\nDraft\n", + }, + { + name: "inserts after a stated line with no context", + base: "one\ntwo\n", + patch: "@@ -1,0 +2 @@\n+one and a half\n", + want: "one\none and a half\ntwo\n", + }, + { + name: "drops the final newline when told to", + base: "one\ntwo\n", + patch: "@@ -2 +2 @@\n-two\n+two\n\\ No newline at end of file\n", + want: "one\ntwo", + }, + { + name: "adds a final newline when the old side lacked one", + base: "one\ntwo", + patch: "@@ -2 +2 @@\n-two\n\\ No newline at end of file\n+two\n", + want: "one\ntwo\n", + }, + { + name: "uses the stated line when the lines repeat", + base: "x\ny\nx\ny\n", + patch: "@@ -3,2 +3,2 @@\n x\n-y\n+z\n", + want: "x\ny\nx\nz\n", + }, + { + name: "empties a note", + base: "only\n", + patch: "@@ -1 +0,0 @@\n-only\n", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := keg.ApplyUnifiedPatch([]byte(tc.base), tc.patch) + require.NoError(t, err) + require.Equal(t, tc.want, string(got)) + }) + } +} + +func TestApplyUnifiedPatchRefusesWhatItCannotPlace(t *testing.T) { + t.Parallel() + base := "x\ny\nx\ny\n" + cases := []struct { + name string + patch string + contains string + }{ + {name: "empty", patch: " \n", contains: "empty"}, + {name: "no hunks", patch: "--- a\n+++ b\n", contains: "no hunks"}, + {name: "text before a hunk", patch: "change y\n@@ -2 +2 @@\n-y\n+z\n", contains: "hunk header"}, + {name: "context that is not there", patch: "@@ -1,2 +1,2 @@\n x\n-q\n+z\n", contains: "do not match"}, + {name: "ambiguous lines away from the stated line", patch: "@@ -9,2 +9,2 @@\n x\n-y\n+z\n", contains: "match 2 places"}, + {name: "overlapping hunks", patch: "@@ -1,2 +1,2 @@\n-x\n+w\n y\n@@ -2,1 +2,1 @@\n-y\n+v\n@@ -1 +1 @@\n-x\n+u\n", contains: "hunk 3: its lines only occur before"}, + {name: "a hunk that changes nothing", patch: "@@ -1 +1 @@\n x\n", contains: "changes nothing"}, + {name: "a second file", patch: "@@ -1 +1 @@\n-x\n+z\n--- a/other\n+++ b/other\n", contains: "another file"}, + {name: "an insertion with no line", patch: "@@ @@\n+new\n", contains: "line number"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := keg.ApplyUnifiedPatch([]byte(base), tc.patch) + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, tc.contains) + }) + } +} + +func TestLocalKegUpdateNodesAppliesContentPatch(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Note\n\nDraft\n")}) + require.NoError(t, err) + original, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + + results, err := k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, ContentPatch: "@@ -3 +3 @@\n-Draft\n+Published\n", HasContentPatch: true, ExpectedContentHash: original.ContentHash(), + }}) + require.NoError(t, err) + patched, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, "# Note\n\nPublished\n", string(patched.Content)) + require.Equal(t, patched.ContentHash(), results[0].ContentHash) + + // A stale token is a conflict, even though the patch would no longer + // fit either: the caller learns the note moved on. + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, ContentPatch: "@@ -3 +3 @@\n-Draft\n+Final\n", HasContentPatch: true, ExpectedContentHash: original.ContentHash(), + }}) + var conflict *keg.PreconditionConflictError + require.ErrorAs(t, err, &conflict) + + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, Content: []byte("# X\n"), HasContent: true, ContentPatch: "@@ -1 +1 @@\n-# Note\n+# X\n", HasContentPatch: true, ExpectedContentHash: patched.ContentHash(), + }}) + require.ErrorIs(t, err, keg.ErrInvalid) + + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, ContentPatch: "@@ -1 +1,3 @@\n-# Note\n+---\n+tags: [x]\n+---\n", HasContentPatch: true, ExpectedContentHash: patched.ContentHash(), + }}) + require.Error(t, err, "a patch may not add frontmatter") + unchanged, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, patched.Content, unchanged.Content) +} + +func TestApplyUnifiedPatchKeepsLineEndingsAndCounts(t *testing.T) { + t.Parallel() + cases := []struct { + name string + base string + patch string + want string + }{ + { + name: "keeps CRLF line endings", + base: "a\r\nb\r\nc\r\n", + patch: "@@ -2 +2 @@\n-b\n+B\n", + want: "a\r\nB\r\nc\r\n", + }, + { + name: "keeps CRLF when adding lines at the end", + base: "a\r\nb\r\n", + patch: "@@ -2 +2,2 @@\n b\n+c\n", + want: "a\r\nb\r\nc\r\n", + }, + { + name: "keeps each untouched line's own ending in a mixed body", + base: "a\r\nb\n", + patch: "@@ -1 +1 @@\n-a\n+A\n", + want: "A\r\nb\n", + }, + { + name: "gives a former last line the body's ending when a line follows it", + base: "a\r\nb", + patch: "@@ -1,2 +1,3 @@\n a\n-b\n\\ No newline at end of file\n+b\n+c\n\\ No newline at end of file\n", + want: "a\r\nb\r\nc", + }, + { + name: "reads header-looking lines inside a hunk by its counts", + base: "x\n-- old\ny\n", + patch: "@@ -1,3 +1,3 @@\n x\n--- old\n+++ new\n y\n", + want: "x\n++ new\ny\n", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := keg.ApplyUnifiedPatch([]byte(tc.base), tc.patch) + require.NoError(t, err) + require.Equal(t, tc.want, string(got)) + }) + } +} + +func TestApplyUnifiedPatchRefusesBadMarkersAndCostlyPatches(t *testing.T) { + t.Parallel() + t.Run("no-newline marker on a hunk that does not reach the end", func(t *testing.T) { + t.Parallel() + _, err := keg.ApplyUnifiedPatch([]byte("one\ntwo\nthree\n"), "@@ -1 +1 @@\n-one\n+ONE\n\\ No newline at end of file\n") + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, "No newline") + }) + t.Run("a second file after a complete hunk", func(t *testing.T) { + t.Parallel() + _, err := keg.ApplyUnifiedPatch([]byte("x\ny\n"), "@@ -1 +1 @@\n-x\n+z\n--- a/other\n+++ b/other\n@@ -1 +1 @@\n-q\n+r\n") + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, "another file") + }) + t.Run("a patch too costly to place", func(t *testing.T) { + t.Parallel() + base := strings.Repeat("a\n", 200_000) + var patch strings.Builder + patch.WriteString("@@ -1,50001 +1,50001 @@\n") + for range 50_000 { + patch.WriteString(" a\n") + } + patch.WriteString("-b\n+c\n") + _, err := keg.ApplyUnifiedPatch([]byte(base), patch.String()) + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, "too expensive") + }) + t.Run("a patch over the size limit", func(t *testing.T) { + t.Parallel() + patch := "@@ -1 +1 @@\n-a\n+" + strings.Repeat("b", 1<<20) + "\n" + _, err := keg.ApplyUnifiedPatch([]byte("a\n"), patch) + require.ErrorIs(t, err, keg.ErrInvalid) + }) +} + +func TestUpdateNodesPatchKeepsCRLF(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Note\n")}) + require.NoError(t, err) + require.NoError(t, k.Repo.WriteContent(ctx, created.ID, []byte("# Note\r\n\r\nDraft\r\n"))) + view, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, ContentPatch: "@@ -3 +3 @@\n-Draft\n+Published\n", HasContentPatch: true, ExpectedContentHash: view.ContentHash(), + }}) + require.NoError(t, err) + got, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, "# Note\r\n\r\nPublished\r\n", string(got.Content)) + + // A marker on a hunk away from the end changes nothing. + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, ContentPatch: "@@ -1 +1 @@\n-# Note\n+# Title\n\\ No newline at end of file\n", HasContentPatch: true, ExpectedContentHash: got.ContentHash(), + }}) + require.ErrorIs(t, err, keg.ErrInvalid) + unchanged, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, got.Content, unchanged.Content) +} diff --git a/pkg/keg/remote_precondition_test.go b/pkg/keg/remote_precondition_test.go index e1e07347..0459297b 100644 --- a/pkg/keg/remote_precondition_test.go +++ b/pkg/keg/remote_precondition_test.go @@ -94,3 +94,23 @@ func TestRemoteKegUpdateNodesSendsPartTokensAndDecodesPartConflicts(t *testing.T require.NotContains(t, update, "expected_meta_hash") require.NotContains(t, update, "expected_hash") } + +func TestRemoteKegUpdateNodeSendsAPatchWithoutContent(t *testing.T) { + t.Parallel() + var body map[string]any + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":[{"id":3,"hash":"h"}]}`)) + })) + t.Cleanup(srv.Close) + rk := keg.NewRemoteKeg(srv.URL, "", nil) + _, _ = rk.UpdateNode(context.Background(), keg.NodeUpdateOptions{ + ID: keg.NodeId{ID: 3}, ContentPatch: "@@ -1 +1 @@\n-a\n+b\n", HasContentPatch: true, ExpectedContentHash: "c1", + }) + updates := body["updates"].([]any) + require.Len(t, updates, 1) + update := updates[0].(map[string]any) + require.Equal(t, "@@ -1 +1 @@\n-a\n+b\n", update["content_patch"]) + require.NotContains(t, update, "content", "a patch-only update sends no content") +} diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index 8dc044e6..16849f7e 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -1054,7 +1054,92 @@ func TestMCP_EditRequiresContentOrMeta(t *testing.T) { }) require.NoError(t, err) require.True(t, res.IsError, "edit accepted an item with neither content nor meta") - require.Contains(t, extractText(t, res), "content or meta is required") + require.Contains(t, extractText(t, res), "content, patch, or meta is required") +} + +func TestMCP_EditAppliesPatch(t *testing.T) { + t.Parallel() + session, ctx := newTestSession(t) + + createRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "node_create", + Arguments: batchCreateArgs(map[string]any{"title": "Patch Subject"}), + }) + require.NoError(t, err) + nodeID := extractText(t, createRes) + editRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "node_edit", + Arguments: batchEditArgs(map[string]any{ + "node_id": nodeID, + "content": "# Patch Subject\n\n## Status\nDraft\n\nKeep this line.\n", + "expected_content_hash": readContentHash(t, session, ctx, nodeID), + }), + }) + require.NoError(t, err) + require.False(t, editRes.IsError, "edit returned error: %s", extractText(t, editRes)) + + res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "node_edit", + Arguments: batchEditArgs(map[string]any{ + "node_id": nodeID, + "patch": "@@ -3,2 +3,2 @@\n ## Status\n-Draft\n+Published\n", + "expected_content_hash": readContentHash(t, session, ctx, nodeID), + }), + }) + require.NoError(t, err) + require.False(t, res.IsError, "patch returned error: %s", extractText(t, res)) + + readRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "node_read", + Arguments: map[string]any{"node_ids": []string{nodeID}, "content_only": true}, + }) + require.NoError(t, err) + body := nodeReadText(t, readRes) + require.Contains(t, body, "## Status\nPublished\n") + require.NotContains(t, body, "Draft") + require.Contains(t, body, "Keep this line.") +} + +func TestMCP_EditRefusesPatchesItCannotApply(t *testing.T) { + t.Parallel() + session, ctx := newTestSession(t) + + createRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "node_create", + Arguments: batchCreateArgs(map[string]any{"title": "Unpatched"}), + }) + require.NoError(t, err) + nodeID := extractText(t, createRes) + hash := readContentHash(t, session, ctx, nodeID) + cases := []struct { + name string + item map[string]any + contains string + }{ + { + name: "context not in the note", + item: map[string]any{"patch": "@@ -1 +1 @@\n-# Not the title\n+# New\n", "expected_content_hash": hash}, + contains: "do not match", + }, + { + name: "no content token", + item: map[string]any{"patch": "@@ -1 +1 @@\n-# Unpatched\n+# New\n"}, + contains: "expected_content_hash", + }, + { + name: "content and patch together", + item: map[string]any{"content": "# New\n", "patch": "@@ -1 +1 @@\n-# Unpatched\n+# New\n", "expected_content_hash": hash}, + contains: "alternatives", + }, + } + for _, tc := range cases { + tc.item["node_id"] = nodeID + res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "node_edit", Arguments: batchEditArgs(tc.item)}) + require.NoError(t, err, tc.name) + require.True(t, res.IsError, "%s: edit was accepted", tc.name) + require.Contains(t, extractText(t, res), tc.contains, tc.name) + } + require.Equal(t, hash, readContentHash(t, session, ctx, nodeID), "a refused patch changed the note") } // TestMCP_EditRejectsSnapshotBefore pins the removal of the implicit snapshot diff --git a/pkg/mcp/tools_write.go b/pkg/mcp/tools_write.go index df1f29fa..68a844bb 100644 --- a/pkg/mcp/tools_write.go +++ b/pkg/mcp/tools_write.go @@ -141,8 +141,9 @@ type editInput struct { type editItemInput struct { NodeID string `json:"node_id" jsonschema:"id of the node to update"` Content *string `json:"content,omitempty" jsonschema:"replacement markdown body, replacing the node's content entirely. Must not start with a YAML frontmatter block: metadata goes in the meta field. Omit to leave content unchanged."` + Patch *string `json:"patch,omitempty" jsonschema:"unified diff (the @@ hunks of diff -u) against the node's current content, an alternative to content. Each hunk's context and removed lines must match the current content exactly; header line numbers are a hint. Prefer it for small changes to long notes. The body keeps its line endings; a '\\ No newline at end of file' marker may only follow a hunk that reaches the end."` Meta *string `json:"meta,omitempty" jsonschema:"replacement metadata document as YAML, replacing the node's metadata entirely. Omit to leave metadata unchanged."` - ExpectedContentHash string `json:"expected_content_hash,omitempty" jsonschema:"content_hash from node_read (or from a previous node_edit result); required when content is supplied"` + ExpectedContentHash string `json:"expected_content_hash,omitempty" jsonschema:"content_hash from node_read (or from a previous node_edit result); required when content or patch is supplied"` ExpectedMetaHash string `json:"expected_meta_hash,omitempty" jsonschema:"meta_hash from node_read (or from a previous node_edit result); required when meta is supplied"` Schema string `json:"schema,omitempty" jsonschema:"schema selected for this write, one of the types schema_list returns. Writes meta.type; a different type declared in meta is a hard error, not an override. On a strict keg (the default for new kegs) required whenever meta is supplied; a content-only edit keeps the node's current type and needs none."` } @@ -169,10 +170,13 @@ func nodeUpdateOutputs(results []keg.NodeUpdateResult) []nodeUpdateOutput { func registerEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_edit", - Description: "Memory: Atomically replace the content and/or metadata of 1-100 nodes. " + - "Supply content, meta, or both for each node; at least one is required. " + - "content is the markdown body and must not begin with a YAML frontmatter block — metadata goes in meta. " + - "Each part has its own precondition token: pass node_read's content_hash as expected_content_hash when supplying content, and its meta_hash as expected_meta_hash when supplying meta. " + + Description: "Memory: Atomically change the content and/or metadata of 1-100 nodes. " + + "For each node supply content or patch, meta, or both; at least one is required. " + + "content is the full replacement markdown body and must not begin with a YAML frontmatter block — metadata goes in meta. " + + "patch is a unified diff against the current body (@@ -a,b +c,d @@ hunks of context ' ', removed '-' and added '+' lines), an alternative to content: prefer it for small changes to a long note, since only the changed lines are sent. " + + "Each hunk's context and removed lines must match the current body exactly and in only one place; the header line numbers are a hint. A patch that does not fit is refused with the lines it expected and nothing is written: re-read the node and rebuild the patch, or send content. " + + "A patch keeps the body's line endings (CRLF stays CRLF), and '\\ No newline at end of file' may only follow a hunk that reaches the end of the body. " + + "Each part has its own precondition token: pass node_read's content_hash as expected_content_hash when supplying content or patch, and its meta_hash as expected_meta_hash when supplying meta. " + "Editing content never invalidates meta_hash and editing meta never invalidates content_hash, so a concurrent edit to the other part does not conflict. " + "Call node_read first for every node; node_read meta_only reads metadata. " + "Each result returns the node's new content_hash and meta_hash, so a follow-up edit needs no re-read; its combined hash (for node_delete or node_move) changes with every edit. " + @@ -192,6 +196,9 @@ func registerEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { if item.Content != nil { edit.Content, edit.HasContent = *item.Content, true } + if item.Patch != nil { + edit.ContentPatch, edit.HasContentPatch = *item.Patch, true + } if item.Meta != nil { edit.Meta, edit.HasMeta = *item.Meta, true } diff --git a/pkg/tapper/tap_batch.go b/pkg/tapper/tap_batch.go index 58fe1fdd..cd42e42e 100644 --- a/pkg/tapper/tap_batch.go +++ b/pkg/tapper/tap_batch.go @@ -41,8 +41,12 @@ type BatchEditItem struct { Schema string Content string HasContent bool - Meta string - HasMeta bool + // ContentPatch is a unified diff against the node's current content, + // an alternative to Content. + ContentPatch string + HasContentPatch bool + Meta string + HasMeta bool // ExpectedHash is the combined content+metadata token (node_read's // hash). It is used only when neither part token is set; the CLI and // other single-token callers rely on it. @@ -66,41 +70,56 @@ func (t *Tap) EditBatch(ctx context.Context, opts BatchEditOptions) ([]keg.NodeU ctx = keg.WithDefaultValidationActor(ctx, keg.ValidationActorHuman) updates := make([]keg.NodeUpdateOptions, len(opts.Edits)) for i, item := range opts.Edits { - id, err := keg.ParseNode(item.NodeID) + update, err := batchEditUpdate(ctx, i, item) if err != nil { - return nil, fmt.Errorf("edit %d node %q: %w", i, item.NodeID, err) - } - if !item.HasContent && !item.HasMeta { - return nil, fmt.Errorf("edit %d node %q: content or meta is required: %w", i, item.NodeID, keg.ErrInvalid) - } - if err := requireEditPartHashes(i, item); err != nil { return nil, err } - update := keg.NodeUpdateOptions{ - ID: *id, - Schema: item.Schema, - ExpectedHash: item.ExpectedHash, - ExpectedContentHash: item.ExpectedContentHash, - ExpectedMetaHash: item.ExpectedMetaHash, - } - if item.HasContent { - if err := keg.RejectFrontmatter([]byte(item.Content)); err != nil { - return nil, fmt.Errorf("edit %d node %q: %w", i, item.NodeID, err) - } - update.Content, update.HasContent = []byte(item.Content), true - } - if item.HasMeta { - meta, err := keg.ParseMeta(ctx, []byte(item.Meta)) - if err != nil { - return nil, fmt.Errorf("edit %d node %q metadata: %w", i, item.NodeID, err) - } - update.Meta, update.HasMeta = []byte(meta.ToYAML()), true - } updates[i] = update } return k.UpdateNodes(ctx, updates) } +// batchEditUpdate checks one edit item and turns it into the keg update. +func batchEditUpdate(ctx context.Context, i int, item BatchEditItem) (keg.NodeUpdateOptions, error) { + id, err := keg.ParseNode(item.NodeID) + if err != nil { + return keg.NodeUpdateOptions{}, fmt.Errorf("edit %d node %q: %w", i, item.NodeID, err) + } + if !item.HasContent && !item.HasContentPatch && !item.HasMeta { + return keg.NodeUpdateOptions{}, fmt.Errorf("edit %d node %q: content, patch, or meta is required: %w", i, item.NodeID, keg.ErrInvalid) + } + if item.HasContent && item.HasContentPatch { + return keg.NodeUpdateOptions{}, fmt.Errorf("edit %d node %q: content and patch are alternatives; send one: %w", i, item.NodeID, keg.ErrInvalid) + } + if err := requireEditPartHashes(i, item); err != nil { + return keg.NodeUpdateOptions{}, err + } + update := keg.NodeUpdateOptions{ + ID: *id, + Schema: item.Schema, + ExpectedHash: item.ExpectedHash, + ExpectedContentHash: item.ExpectedContentHash, + ExpectedMetaHash: item.ExpectedMetaHash, + } + if item.HasContent { + if err := keg.RejectFrontmatter([]byte(item.Content)); err != nil { + return keg.NodeUpdateOptions{}, fmt.Errorf("edit %d node %q: %w", i, item.NodeID, err) + } + update.Content, update.HasContent = []byte(item.Content), true + } + if item.HasContentPatch { + update.ContentPatch, update.HasContentPatch = item.ContentPatch, true + } + if item.HasMeta { + meta, err := keg.ParseMeta(ctx, []byte(item.Meta)) + if err != nil { + return keg.NodeUpdateOptions{}, fmt.Errorf("edit %d node %q metadata: %w", i, item.NodeID, err) + } + update.Meta, update.HasMeta = []byte(meta.ToYAML()), true + } + return update, nil +} + type BatchSnapshotItem struct { NodeID string Message string @@ -135,6 +154,9 @@ func requireEditPartHashes(i int, item BatchEditItem) error { if item.ExpectedHash != "" && item.ExpectedContentHash == "" && item.ExpectedMetaHash == "" { return nil } + if item.HasContentPatch && item.ExpectedContentHash == "" { + return fmt.Errorf("edit %d node %q: patch requires expected_content_hash (the content_hash from node_read): %w", i, item.NodeID, keg.ErrPreconditionRequired) + } if item.HasContent && item.ExpectedContentHash == "" { return fmt.Errorf("edit %d node %q: content requires expected_content_hash (the content_hash from node_read): %w", i, item.NodeID, keg.ErrPreconditionRequired) } From 855d5a6a233adcae592286624b26860fe8763cfb Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Mon, 12 Oct 2026 19:30:35 -0500 Subject: [PATCH 5/8] feat(keg): keep removed nodes in a restorable trash Where a repository keeps a trash, removing a node moves it there with its snapshots, files, and images, and leaves links to it alone, so restoring it is lossless. trash_list shows removed nodes and why they went, and trash_restore puts 1-100 of them back at their ids. A purge deletes them for good and points links that still name them at node 0. A trashed id stays reserved: no node can be moved onto it, and an archive import that lands on one moves the trashed node to a fresh id first, so it stays restorable. Remote kegs use the hub's /trash routes. --- integrations/content/snapshot-policy.md | 22 +- integrations/content/tool-inventory.md | 1 + .../claude/tapper/skills/tapper/SKILL.md | 23 +- .../codex/tapper/skills/tapper/SKILL.md | 23 +- .../opencode/tapper/skills/tapper/SKILL.md | 23 +- pkg/keg/archive.go | 67 +- pkg/keg/keg_aggregate.go | 2 +- pkg/keg/keg_iface.go | 25 + pkg/keg/keg_local_move.go | 173 +++- pkg/keg/keg_remote_aggregate.go | 88 ++ pkg/keg/remote_trash_test.go | 37 + pkg/keg/repository_capabilities.go | 9 + pkg/keg/trash.go | 583 +++++++++++++ pkg/keg/trash_test.go | 809 ++++++++++++++++++ pkg/mcp/server.go | 1 + pkg/mcp/tools_trash.go | 118 +++ pkg/mcp/tools_write.go | 5 +- pkg/parity/parity_coverage_test.go | 2 + pkg/tapper/tap_trash.go | 39 + pkg/tapper/tool_categories.go | 2 +- 20 files changed, 1974 insertions(+), 78 deletions(-) create mode 100644 pkg/keg/remote_trash_test.go create mode 100644 pkg/keg/trash.go create mode 100644 pkg/keg/trash_test.go create mode 100644 pkg/mcp/tools_trash.go create mode 100644 pkg/tapper/tap_trash.go diff --git a/integrations/content/snapshot-policy.md b/integrations/content/snapshot-policy.md index 7bdf37b7..3dc58870 100644 --- a/integrations/content/snapshot-policy.md +++ b/integrations/content/snapshot-policy.md @@ -6,12 +6,19 @@ are cheap, deduplicated by content hash, and stored inside the node they belong to. -**Important: snapshots are stored inside the node. Removing a node -removes its snapshot history with it. A snapshot does not protect -against `mcp__tapper__node_delete`.** Before a removal, copy the content -somewhere that survives the deletion — read it with `mcp__tapper__node_read` -and keep the result in your working context, or write it to another -node first. If you are not certain the removal is correct, defer it. +**Important: snapshots are stored inside the node. A snapshot does not +protect against `mcp__tapper__node_delete`.** On a hub keg, a removed node +goes to the keg's trash with its snapshots, files, and images: +`mcp__tapper__trash_list` shows it and `mcp__tapper__trash_restore` puts it +back, until the keg's retention purges it. Until then its id stays reserved: +no node can be moved onto it. An archive restore that needs the id moves the +trashed node to a new id first (`displaced_from` in `mcp__tapper__trash_list` +names the old one), and restoring it puts it back at the new id. Elsewhere, removing a node removes +its snapshot history with it. Either way, before a removal you are not sure +of, copy the content somewhere that survives the deletion — read it with +`mcp__tapper__node_read` and keep the result in your working context, or +write it to another node first. If you are not certain the removal is +correct, defer it. Snapshots do protect in-place edits. Take one before any of: @@ -41,4 +48,5 @@ Recover with: If you are unsure whether an in-place edit warrants a snapshot, take one. The cost is negligible. For `node_delete`, a snapshot is not a -recovery path — preserve the content some other way first. +recovery path — the trash is, where the keg has one; otherwise preserve the +content some other way first. diff --git a/integrations/content/tool-inventory.md b/integrations/content/tool-inventory.md index be43995a..826ceee2 100644 --- a/integrations/content/tool-inventory.md +++ b/integrations/content/tool-inventory.md @@ -128,6 +128,7 @@ code; the index does the work in O(matches) rather than O(total). | `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | | `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__trash_list`, `mcp__tapper__trash_restore` | Where the keg keeps a trash, list removed nodes and why they went, and restore 1–100 of them at their ids with their history and assets. An entry an archive restore moved to make way shows `displaced_from` (the id it was trashed at) and restores at its new id. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | | `mcp__tapper__snapshot_list`, `mcp__tapper__snapshot_read` | Inspect read-only prior revisions. | diff --git a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md index 9b00cbac..20dff3e9 100644 --- a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md @@ -173,6 +173,7 @@ code; the index does the work in O(matches) rather than O(total). | `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | | `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__trash_list`, `mcp__tapper__trash_restore` | Where the keg keeps a trash, list removed nodes and why they went, and restore 1–100 of them at their ids with their history and assets. An entry an archive restore moved to make way shows `displaced_from` (the id it was trashed at) and restores at its new id. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | | `mcp__tapper__snapshot_list`, `mcp__tapper__snapshot_read` | Inspect read-only prior revisions. | @@ -358,12 +359,19 @@ supplied they must match exactly, or the call is rejected before mutation. are cheap, deduplicated by content hash, and stored inside the node they belong to. -**Important: snapshots are stored inside the node. Removing a node -removes its snapshot history with it. A snapshot does not protect -against `mcp__tapper__node_delete`.** Before a removal, copy the content -somewhere that survives the deletion — read it with `mcp__tapper__node_read` -and keep the result in your working context, or write it to another -node first. If you are not certain the removal is correct, defer it. +**Important: snapshots are stored inside the node. A snapshot does not +protect against `mcp__tapper__node_delete`.** On a hub keg, a removed node +goes to the keg's trash with its snapshots, files, and images: +`mcp__tapper__trash_list` shows it and `mcp__tapper__trash_restore` puts it +back, until the keg's retention purges it. Until then its id stays reserved: +no node can be moved onto it. An archive restore that needs the id moves the +trashed node to a new id first (`displaced_from` in `mcp__tapper__trash_list` +names the old one), and restoring it puts it back at the new id. Elsewhere, removing a node removes +its snapshot history with it. Either way, before a removal you are not sure +of, copy the content somewhere that survives the deletion — read it with +`mcp__tapper__node_read` and keep the result in your working context, or +write it to another node first. If you are not certain the removal is +correct, defer it. Snapshots do protect in-place edits. Take one before any of: @@ -393,7 +401,8 @@ Recover with: If you are unsure whether an in-place edit warrants a snapshot, take one. The cost is negligible. For `node_delete`, a snapshot is not a -recovery path — preserve the content some other way first. +recovery path — the trash is, where the keg has one; otherwise preserve the +content some other way first. Tapper supports these link forms in node bodies: diff --git a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md index 9b00cbac..20dff3e9 100644 --- a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md @@ -173,6 +173,7 @@ code; the index does the work in O(matches) rather than O(total). | `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | | `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__trash_list`, `mcp__tapper__trash_restore` | Where the keg keeps a trash, list removed nodes and why they went, and restore 1–100 of them at their ids with their history and assets. An entry an archive restore moved to make way shows `displaced_from` (the id it was trashed at) and restores at its new id. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | | `mcp__tapper__snapshot_list`, `mcp__tapper__snapshot_read` | Inspect read-only prior revisions. | @@ -358,12 +359,19 @@ supplied they must match exactly, or the call is rejected before mutation. are cheap, deduplicated by content hash, and stored inside the node they belong to. -**Important: snapshots are stored inside the node. Removing a node -removes its snapshot history with it. A snapshot does not protect -against `mcp__tapper__node_delete`.** Before a removal, copy the content -somewhere that survives the deletion — read it with `mcp__tapper__node_read` -and keep the result in your working context, or write it to another -node first. If you are not certain the removal is correct, defer it. +**Important: snapshots are stored inside the node. A snapshot does not +protect against `mcp__tapper__node_delete`.** On a hub keg, a removed node +goes to the keg's trash with its snapshots, files, and images: +`mcp__tapper__trash_list` shows it and `mcp__tapper__trash_restore` puts it +back, until the keg's retention purges it. Until then its id stays reserved: +no node can be moved onto it. An archive restore that needs the id moves the +trashed node to a new id first (`displaced_from` in `mcp__tapper__trash_list` +names the old one), and restoring it puts it back at the new id. Elsewhere, removing a node removes +its snapshot history with it. Either way, before a removal you are not sure +of, copy the content somewhere that survives the deletion — read it with +`mcp__tapper__node_read` and keep the result in your working context, or +write it to another node first. If you are not certain the removal is +correct, defer it. Snapshots do protect in-place edits. Take one before any of: @@ -393,7 +401,8 @@ Recover with: If you are unsure whether an in-place edit warrants a snapshot, take one. The cost is negligible. For `node_delete`, a snapshot is not a -recovery path — preserve the content some other way first. +recovery path — the trash is, where the keg has one; otherwise preserve the +content some other way first. Tapper supports these link forms in node bodies: diff --git a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md index 9b00cbac..20dff3e9 100644 --- a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md @@ -173,6 +173,7 @@ code; the index does the work in O(matches) rather than O(total). | `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | | `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__trash_list`, `mcp__tapper__trash_restore` | Where the keg keeps a trash, list removed nodes and why they went, and restore 1–100 of them at their ids with their history and assets. An entry an archive restore moved to make way shows `displaced_from` (the id it was trashed at) and restores at its new id. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | | `mcp__tapper__snapshot_list`, `mcp__tapper__snapshot_read` | Inspect read-only prior revisions. | @@ -358,12 +359,19 @@ supplied they must match exactly, or the call is rejected before mutation. are cheap, deduplicated by content hash, and stored inside the node they belong to. -**Important: snapshots are stored inside the node. Removing a node -removes its snapshot history with it. A snapshot does not protect -against `mcp__tapper__node_delete`.** Before a removal, copy the content -somewhere that survives the deletion — read it with `mcp__tapper__node_read` -and keep the result in your working context, or write it to another -node first. If you are not certain the removal is correct, defer it. +**Important: snapshots are stored inside the node. A snapshot does not +protect against `mcp__tapper__node_delete`.** On a hub keg, a removed node +goes to the keg's trash with its snapshots, files, and images: +`mcp__tapper__trash_list` shows it and `mcp__tapper__trash_restore` puts it +back, until the keg's retention purges it. Until then its id stays reserved: +no node can be moved onto it. An archive restore that needs the id moves the +trashed node to a new id first (`displaced_from` in `mcp__tapper__trash_list` +names the old one), and restoring it puts it back at the new id. Elsewhere, removing a node removes +its snapshot history with it. Either way, before a removal you are not sure +of, copy the content somewhere that survives the deletion — read it with +`mcp__tapper__node_read` and keep the result in your working context, or +write it to another node first. If you are not certain the removal is +correct, defer it. Snapshots do protect in-place edits. Take one before any of: @@ -393,7 +401,8 @@ Recover with: If you are unsure whether an in-place edit warrants a snapshot, take one. The cost is negligible. For `node_delete`, a snapshot is not a -recovery path — preserve the content some other way first. +recovery path — the trash is, where the keg has one; otherwise preserve the +content some other way first. Tapper supports these link forms in node bodies: diff --git a/pkg/keg/archive.go b/pkg/keg/archive.go index ed77c433..cee83042 100644 --- a/pkg/keg/archive.go +++ b/pkg/keg/archive.go @@ -416,6 +416,17 @@ func (k *LocalKeg) importNodes(ctx context.Context, r io.Reader, opts ImportNode if err != nil { return nil, err } + if opts.restoreTo != nil { + if len(ordered) != 1 { + return nil, fmt.Errorf("a trash bundle holds one node, not %d: %w", len(ordered), ErrInvalid) + } + mapping[ordered[0]] = *opts.restoreTo + } + if opts.restoreFromTrash { + if err := k.mapPurgedLinks(ctx, entries, ordered, mapping); err != nil { + return nil, err + } + } if opts.AssignNewIDs && len(ordered) > 0 { reserved := make([]NodeId, 0, len(ordered)) defer func() { k.cleanupUnusedImportReservations(ctx, reserved) }() @@ -490,6 +501,46 @@ func (k *LocalKeg) importNodes(ctx context.Context, r io.Reader, opts ImportNode } } + // An archive always lands on its own ids. A trashed node holding one + // of them moves to a fresh id first, so the trashed copy stays + // restorable rather than being lost or blocking the import. + if !opts.AssignNewIDs && !opts.restoreFromTrash { + landing := make(map[NodeId]bool, len(ordered)) + highest := NodeId{} + for _, sourceID := range ordered { + id := mapping[sourceID] + landing[id] = true + if id.Code == "" && id.ID > highest.ID { + highest = id + } + } + // Raise the counter past every id the archive lands on before + // displacing anything, so no displaced entry is handed one of them. + if counter, ok := k.Repo.(RepositoryNodeCounter); ok && len(ordered) > 0 { + if err := counter.RaiseNodeCounter(ctx, highest); err != nil { + return nil, fmt.Errorf("unable to reserve the archive's node ids: %w", err) + } + } + for _, sourceID := range ordered { + if _, _, err := k.displaceTrashed(ctx, mapping[sourceID], landing); err != nil { + return nil, err + } + } + } + // A trash restore never replaces a live node: refuse before anything + // is written. + if opts.restoreFromTrash { + for _, sourceID := range ordered { + exists, err := k.nodeExistsWithContent(ctx, mapping[sourceID]) + if err != nil { + return nil, err + } + if exists { + return nil, fmt.Errorf("node %s exists, so a trashed copy cannot be restored over it: %w", mapping[sourceID].Path(), ErrDestinationExists) + } + } + } + // Preserve assets of nodes about to be replaced, then delete them so the // archive payload lands on clean state. preservedAssets := make(map[string]importedNodeAssets, len(ordered)) @@ -580,12 +631,16 @@ func (k *LocalKeg) importNodes(ctx context.Context, r io.Reader, opts ImportNode return nil, fmt.Errorf("unable to restore keg settings after import: %w", err) } } - if err := k.rebuildDexFromRepo(ctx); err != nil { - return nil, err - } - if !manifest.WithSettings { - if err := k.touchSettingsUpdated(ctx, k.Runtime.Clock().Now()); err != nil { - return nil, fmt.Errorf("unable to update keg settings after import: %w", err) + // A trash restore updates the dex itself, once for its whole batch, + // rather than rebuilding it from every node for each restored one. + if !opts.restoreFromTrash { + if err := k.rebuildDexFromRepo(ctx); err != nil { + return nil, err + } + if !manifest.WithSettings { + if err := k.touchSettingsUpdated(ctx, k.Runtime.Clock().Now()); err != nil { + return nil, fmt.Errorf("unable to update keg settings after import: %w", err) + } } } diff --git a/pkg/keg/keg_aggregate.go b/pkg/keg/keg_aggregate.go index efc8a3aa..50bdef6d 100644 --- a/pkg/keg/keg_aggregate.go +++ b/pkg/keg/keg_aggregate.go @@ -827,7 +827,7 @@ func (k *LocalKeg) removeNodes(ctx context.Context, opts RemoveNodesOptions) (Re } } for _, item := range items { - rewritten, err := k.removeUnchecked(ctx, item.ID) + rewritten, err := k.removeUnchecked(ctx, item.ID, manualRemoval) if err != nil { result.Failure = newBatchFailure(item.ID, err) return result, nil diff --git a/pkg/keg/keg_iface.go b/pkg/keg/keg_iface.go index d67b8390..3ab9db88 100644 --- a/pkg/keg/keg_iface.go +++ b/pkg/keg/keg_iface.go @@ -110,6 +110,24 @@ type Keg interface { // the ids of nodes whose content was rewritten. Remove(ctx context.Context, opts NodeRemoveOptions) ([]NodeId, error) + // ListTrash returns removed nodes kept for restoring, newest first. + // ErrNotSupported when the keg keeps no trash. + ListTrash(ctx context.Context) ([]TrashEntry, error) + + // TrashInfo returns the trash entry for one id, the cheap check for + // whether that id is trashed. ErrNotExist when the id has no entry, + // ErrNotSupported when the keg keeps no trash. + TrashInfo(ctx context.Context, id NodeId) (TrashEntry, error) + + // RestoreTrashed puts 1-100 trashed nodes back at their ids, all or + // nothing. + RestoreTrashed(ctx context.Context, ids []NodeId) error + + // PurgeTrashed deletes 1-100 trashed nodes for good, as one write, and + // points links that still name them at node 0. It reports the ids it + // purged and the nodes whose links it rewrote. + PurgeTrashed(ctx context.Context, opts PurgeTrashOptions) (PurgeTrashResult, error) + // Commit promotes a temporary, code-backed node to a permanent numeric id. // It is a no-op for an already-permanent node. Commit(ctx context.Context, id NodeId) error @@ -434,6 +452,13 @@ type ImportNodesOptions struct { // TargetAlias, when set, rewrites keg:TargetAlias/N links in imported // content to relative ../N links. TargetAlias string + // restoreFromTrash lets the import land on ids held by trashed nodes; only + // restoring from the trash does that. + restoreFromTrash bool + // restoreTo lands a single-node archive (a trash bundle) on this id + // rather than its own, its links to itself following. Restoring from + // the trash always sets it to the entry's current id. + restoreTo *NodeId } // ImportedNode maps an archive source id to the node id it landed on. diff --git a/pkg/keg/keg_local_move.go b/pkg/keg/keg_local_move.go index 36bc840a..59703bcd 100644 --- a/pkg/keg/keg_local_move.go +++ b/pkg/keg/keg_local_move.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "regexp" + "slices" + "strings" ) // Move renames a node from src to dst and rewrites in-content links that @@ -57,47 +59,19 @@ func (k *LocalKeg) move(ctx context.Context, opts NodeMoveOptions) ([]NodeId, er if dstExists { return nil, fmt.Errorf("destination node %s already exists: %w", dst.Path(), ErrDestinationExists) } + dstTrashed, err := k.idTrashed(ctx, dst) + if err != nil { + return nil, fmt.Errorf("failed to check the trash for the destination node: %w", err) + } + if dstTrashed { + return nil, fmt.Errorf("node %s is in the trash; restore or purge it before moving a node there: %w", dst.Path(), ErrDestinationExists) + } if err := k.Repo.MoveNode(ctx, src, dst); err != nil { return nil, fmt.Errorf("failed to move node %s to %s: %w", src.Path(), dst.Path(), err) } - ids, err := k.Repo.ListNodes(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list nodes for link rewrite: %w", err) - } - - // Rewrite links in all nodes, collecting changed NodeData without - // writing the dex after each individual change. A single batched dex - // write follows the loop. - var errs []error - var changedNodes []*NodeData - linkRE := compileNodeLinkPattern(src) - for _, id := range ids { - raw, readErr := k.Repo.ReadContent(ctx, id) - if readErr != nil { - if errors.Is(readErr, ErrNotExist) { - continue - } - errs = append(errs, fmt.Errorf("failed to read node content %s: %w", id.Path(), readErr)) - continue - } - - updated, changed := rewriteNodeLinks(raw, linkRE, dst) - if !changed { - continue - } - // Link rewrites are part of the move operation rather than a live node - // edit, so strict explicit-selection does not apply. - nd, err := k.setContentNoDex(WithValidationMode(ctx, ValidationModeOff), id, updated) - if err != nil { - errs = append(errs, fmt.Errorf("failed to rewrite links for node %s: %w", id.Path(), err)) - continue - } - if nd != nil { - changedNodes = append(changedNodes, nd) - } - } + changedNodes, errs := k.rewriteLinksNoDex(ctx, src, dst) // Single batched dex update: load fresh dex, apply all changes, write once. dex, err := k.ensureDexFresh(ctx) @@ -173,10 +147,13 @@ func (k *LocalKeg) remove(ctx context.Context, opts NodeRemoveOptions) ([]NodeId if err := checkExpectedHash("node "+id.Path(), opts.ExpectedHash, current.Hash(), nodeRecoveryContent(current)); err != nil { return nil, err } - return k.removeUnchecked(ctx, id) + return k.removeUnchecked(ctx, id, manualRemoval) } -func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId) ([]NodeId, error) { +// removeUnchecked removes id without checking its hash. A repository with a +// trash keeps the node there and leaves links to it alone, so restoring it is +// lossless; otherwise the node is deleted and links to it point at node 0. +func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId, info trashInfo) ([]NodeId, error) { if !id.Valid() { return nil, fmt.Errorf("invalid node id: %w", ErrInvalid) } @@ -197,6 +174,13 @@ func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId) ([]NodeId, er return nil, fmt.Errorf("node %s not found: %w", id.Path(), ErrNotExist) } + if trash, ok := repoTrash(k.Repo); ok { + if err := k.trashNode(ctx, trash, id, info); err != nil { + return nil, err + } + return nil, k.afterRemove(ctx, id, nil) + } + // Acquire node lock to prevent concurrent writes from resurrecting // the node directory after deletion. Re-check existence under lock. if err := k.withNodeLock(ctx, id, func(lockCtx context.Context) error { @@ -247,6 +231,12 @@ func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId) ([]NodeId, er } } + return rewritten, k.afterRemove(ctx, id, errs) +} + +// afterRemove drops a removed node from the dex and stamps the keg, adding +// any failure to errs. +func (k *LocalKeg) afterRemove(ctx context.Context, id NodeId, errs []error) error { // Single batched dex update: remove deleted node, write once. dex, err := k.ensureDexFresh(ctx) if err != nil { @@ -271,7 +261,95 @@ func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId) ([]NodeId, er if err := k.refreshSnapshotGeneratedIndexes(ctx); err != nil { errs = append(errs, fmt.Errorf("failed to refresh snapshot indexes after remove: %w", err)) } + return errors.Join(errs...) +} + +// rewriteLinksNoDex points every ../from link in the keg at to, returning the +// nodes it rewrote for the caller's single dex update. Link rewrites belong to +// the operation that caused them rather than being live edits, so strict +// explicit-selection does not apply. Nodes in skip are neither read nor +// rewritten. +func (k *LocalKeg) rewriteLinksNoDex(ctx context.Context, from, to NodeId, skip ...NodeId) ([]*NodeData, []error) { + return k.rewriteMatchingLinksNoDex(ctx, compileNodeLinkPattern(from), to, skip...) +} +// rewriteMatchingLinksNoDex points every link linkRE matches at to, in one +// scan of the keg. +func (k *LocalKeg) rewriteMatchingLinksNoDex(ctx context.Context, linkRE *regexp.Regexp, to NodeId, skip ...NodeId) ([]*NodeData, []error) { + ids, err := k.Repo.ListNodes(ctx) + if err != nil { + return nil, []error{fmt.Errorf("failed to list nodes for link rewrite: %w", err)} + } + var errs []error + var changedNodes []*NodeData + for _, id := range ids { + if slices.Contains(skip, id) { + continue + } + raw, readErr := k.Repo.ReadContent(ctx, id) + if readErr != nil { + if errors.Is(readErr, ErrNotExist) { + continue + } + errs = append(errs, fmt.Errorf("failed to read node content %s: %w", id.Path(), readErr)) + continue + } + updated, changed := rewriteNodeLinks(raw, linkRE, to) + if !changed { + continue + } + nd, err := k.setContentNoDex(WithValidationMode(ctx, ValidationModeOff), id, updated) + if err != nil { + errs = append(errs, fmt.Errorf("failed to rewrite links for node %s: %w", id.Path(), err)) + continue + } + if nd != nil { + changedNodes = append(changedNodes, nd) + } + } + return changedNodes, errs +} + +// retargetLinks points every ../from link in the keg at to and brings the dex +// up to date in one write. It returns the ids of the rewritten nodes. Nodes +// in skip are left as they are. +func (k *LocalKeg) retargetLinks(ctx context.Context, from, to NodeId, skip ...NodeId) ([]NodeId, error) { + return k.retargetLinksMany(ctx, []NodeId{from}, to, skip...) +} + +// retargetLinksMany is retargetLinks for several ids at once: one scan of +// the keg and one dex write. +func (k *LocalKeg) retargetLinksMany(ctx context.Context, from []NodeId, to NodeId, skip ...NodeId) ([]NodeId, error) { + changedNodes, errs := k.rewriteMatchingLinksNoDex(ctx, compileNodeLinksPattern(from), to, skip...) + rewritten := make([]NodeId, 0, len(changedNodes)) + for _, nd := range changedNodes { + rewritten = append(rewritten, nd.ID) + } + if len(changedNodes) == 0 { + return rewritten, errors.Join(errs...) + } + dex, err := k.ensureDexFresh(ctx) + if err != nil { + return rewritten, errors.Join(append(errs, fmt.Errorf("failed to retrieve dex after link rewrite: %w", err))...) + } + for _, nd := range changedNodes { + if err := dex.Remove(ctx, nd.ID); err != nil { + errs = append(errs, fmt.Errorf("failed to remove stale dex entry for %s: %w", nd.ID.Path(), err)) + } + if err := dex.Add(ctx, nd); err != nil { + errs = append(errs, fmt.Errorf("failed to add changed node %s to dex: %w", nd.ID.Path(), err)) + } + } + if err := dex.Write(ctx, k.Repo); err != nil { + errs = append(errs, fmt.Errorf("failed to write dex after link rewrite: %w", err)) + } else { + k.dexMu.Lock() + k.recordDexWrite() + k.dexMu.Unlock() + } + if err := k.touchSettingsUpdated(ctx, k.Runtime.Clock().Now()); err != nil { + errs = append(errs, fmt.Errorf("failed to update settings after link rewrite: %w", err)) + } return rewritten, errors.Join(errs...) } @@ -279,12 +357,27 @@ func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId) ([]NodeId, er // links "../N" for the given source node ID. Pre-compile once and pass to // rewriteNodeLinks to avoid recompilation per call. func compileNodeLinkPattern(src NodeId) *regexp.Regexp { - oldID := src.Path() + return compileNodeLinksPattern([]NodeId{src}) +} + +// compileNodeLinksPattern matches canonical "../N" links to any of ids. +func compileNodeLinksPattern(ids []NodeId) *regexp.Regexp { + alternatives := make([]string, len(ids)) + for i, id := range ids { + alternatives[i] = regexp.QuoteMeta(id.Path()) + } delimiters := `[[:space:]\)\]\}\>\.,;:!?'\"#]` - pattern := `\.\./\s*` + regexp.QuoteMeta(oldID) + `(` + delimiters + `|$)` + pattern := `\.\./\s*(?:` + strings.Join(alternatives, "|") + `)(` + delimiters + `|$)` return regexp.MustCompile(pattern) } +// RetargetLinks returns content with every canonical "../from" node link +// pointed at to, as moving or merging a node rewrites the keg's links. +func RetargetLinks(content []byte, from, to NodeId) []byte { + updated, _ := rewriteNodeLinks(content, compileNodeLinkPattern(from), to) + return updated +} + func rewriteNodeLinks(raw []byte, re *regexp.Regexp, dst NodeId) ([]byte, bool) { newID := dst.Path() if newID == "" || len(raw) == 0 { diff --git a/pkg/keg/keg_remote_aggregate.go b/pkg/keg/keg_remote_aggregate.go index f7bcd718..cf8445ca 100644 --- a/pkg/keg/keg_remote_aggregate.go +++ b/pkg/keg/keg_remote_aggregate.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "time" ) func (k *RemoteKeg) ListEntries(ctx context.Context, opts ListEntriesOptions) (*ListEntriesResult, error) { @@ -340,3 +341,90 @@ func (k *RemoteKeg) DexArtifacts(ctx context.Context) (*DexArtifacts, error) { } return out, nil } + +// trashWireEntry is one GET /trash item. +type trashWireEntry struct { + ID int `json:"id"` + Title string `json:"title"` + Reason string `json:"reason"` + MergedInto *int `json:"merged_into,omitempty"` + TrashedAt time.Time `json:"trashed_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Size int64 `json:"size"` + // DisplacedFrom is the id the entry was trashed at before an archive + // restore moved it. + DisplacedFrom *int `json:"displaced_from,omitempty"` +} + +func (k *RemoteKeg) ListTrash(ctx context.Context) ([]TrashEntry, error) { + var wire struct { + Trash []trashWireEntry `json:"trash"` + } + if err := k.getJSON(ctx, "/trash", "ListTrash", &wire); err != nil { + return nil, err + } + out := make([]TrashEntry, len(wire.Trash)) + for i, item := range wire.Trash { + out[i] = TrashEntry{ID: NodeId{ID: item.ID}, Title: item.Title, Reason: item.Reason, TrashedAt: item.TrashedAt, ExpiresAt: item.ExpiresAt, Size: item.Size} + if item.MergedInto != nil { + out[i].MergedInto = &NodeId{ID: *item.MergedInto} + } + if item.DisplacedFrom != nil { + out[i].DisplacedFrom = &NodeId{ID: *item.DisplacedFrom} + } + } + return out, nil +} + +// TrashInfo finds id in the hub's trash listing; the REST API has no route +// for a single entry. ErrNotExist when id is not trashed. +func (k *RemoteKeg) TrashInfo(ctx context.Context, id NodeId) (TrashEntry, error) { + entries, err := k.ListTrash(ctx) + if err != nil { + return TrashEntry{}, err + } + for _, entry := range entries { + if entry.ID.ID == id.ID { + return entry, nil + } + } + return TrashEntry{}, fmt.Errorf("trashed node %s: %w", id.Path(), ErrNotExist) +} + +func (k *RemoteKeg) RestoreTrashed(ctx context.Context, ids []NodeId) error { + return k.postJSON(ctx, "/trash/restore", "RestoreTrashed", struct { + Nodes []int `json:"nodes"` + }{remoteNodeNumbers(ids)}, nil, http.StatusOK) +} + +func (k *RemoteKeg) PurgeTrashed(ctx context.Context, opts PurgeTrashOptions) (PurgeTrashResult, error) { + if opts.ExpiredAt != nil { + // The hub runs its own retention sweep; an expiry purge is local. + return PurgeTrashResult{}, ErrNotSupported + } + var wire struct { + Purged []int `json:"purged"` + Rewritten []int `json:"rewritten"` + } + if err := k.postJSON(ctx, "/trash/purge", "PurgeTrashed", struct { + Nodes []int `json:"nodes"` + }{remoteNodeNumbers(opts.IDs)}, &wire, http.StatusOK); err != nil { + return PurgeTrashResult{}, err + } + out := PurgeTrashResult{Purged: make([]NodeId, len(wire.Purged)), Rewritten: make([]NodeId, len(wire.Rewritten))} + for i, id := range wire.Purged { + out.Purged[i] = NodeId{ID: id} + } + for i, id := range wire.Rewritten { + out.Rewritten[i] = NodeId{ID: id} + } + return out, nil +} + +func remoteNodeNumbers(ids []NodeId) []int { + out := make([]int, len(ids)) + for i, id := range ids { + out[i] = id.ID + } + return out +} diff --git a/pkg/keg/remote_trash_test.go b/pkg/keg/remote_trash_test.go new file mode 100644 index 00000000..6111bff3 --- /dev/null +++ b/pkg/keg/remote_trash_test.go @@ -0,0 +1,37 @@ +package keg_test + +import ( + "context" + "net/http" + "testing" + + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/keg" + "github.com/stretchr/testify/require" +) + +// A remote keg answers TrashInfo from the hub's trash listing: the entry for +// a trashed id, ErrNotExist for any other. +func TestRemoteKegTrashInfo(t *testing.T) { + t.Parallel() + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/trash" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"trash":[{"id":7,"title":"Gone","reason":"merge","merged_into":3,"trashed_at":"2026-01-02T03:04:05Z","size":12}]}`)) + })) + t.Cleanup(srv.Close) + rk := keg.NewRemoteKeg(srv.URL, "", nil) + ctx := context.Background() + + info, err := rk.TrashInfo(ctx, keg.NodeId{ID: 7}) + require.NoError(t, err) + require.Equal(t, "Gone", info.Title) + require.Equal(t, keg.TrashReasonMerge, info.Reason) + require.Equal(t, &keg.NodeId{ID: 3}, info.MergedInto) + + _, err = rk.TrashInfo(ctx, keg.NodeId{ID: 8}) + require.ErrorIs(t, err, keg.ErrNotExist) +} diff --git a/pkg/keg/repository_capabilities.go b/pkg/keg/repository_capabilities.go index 3a58d975..3594255c 100644 --- a/pkg/keg/repository_capabilities.go +++ b/pkg/keg/repository_capabilities.go @@ -18,6 +18,15 @@ func repoListImages(ctx context.Context, repo Repository, id NodeId) ([]string, return withImages.ListImages(ctx, id) } +// RepositoryNodeCounter is an optional repository capability: a counter +// that Next draws from, which an import raises past the ids it lands on +// before allocating any other id. +type RepositoryNodeCounter interface { + // RaiseNodeCounter makes Next hand out only ids greater than past. + // It never lowers the counter. + RaiseNodeCounter(ctx context.Context, past NodeId) error +} + func repoSnapshots(repo Repository) (RepositorySnapshots, bool) { withSnapshots, ok := repo.(RepositorySnapshots) if !ok { diff --git a/pkg/keg/trash.go b/pkg/keg/trash.go new file mode 100644 index 00000000..31f50a6a --- /dev/null +++ b/pkg/keg/trash.go @@ -0,0 +1,583 @@ +package keg + +import ( + "bytes" + "context" + "errors" + "fmt" + "slices" + "time" +) + +// Why a node went to the trash. +const ( + // TrashReasonManual is a node someone removed. + TrashReasonManual = "manual" + // TrashReasonMerge is a node merged into another (TrashEntry.MergedInto). + TrashReasonMerge = "merge" + // TrashReasonArchiveRestore is a node an archive restore removed because + // the archive did not carry it. + TrashReasonArchiveRestore = "archive-restore" +) + +// MaxTrashBundleSize caps a trash bundle, a node with its history and files, +// unless the trash sets its own limit (RepositoryTrashLimit). Trashing a node +// whose bundle would pass the cap is refused with ErrTrashBundleTooLarge +// rather than failing opaquely in the repository. +const MaxTrashBundleSize = 256 << 20 + +// RepositoryTrashLimit is an optional RepositoryTrash capability: a trash +// that holds bundles up to its own limit, in bytes, in place of +// MaxTrashBundleSize. A limit of zero or less keeps the default. +type RepositoryTrashLimit interface { + // TrashBundleLimit returns the largest bundle, in bytes, the trash holds. + TrashBundleLimit() int +} + +func trashBundleLimit(trash RepositoryTrash) int { + if limited, ok := trash.(RepositoryTrashLimit); ok && limited.TrashBundleLimit() > 0 { + return limited.TrashBundleLimit() + } + return MaxTrashBundleSize +} + +// formatTrashLimit names a bundle limit for a person. +func formatTrashLimit(limit int) string { + if limit >= 1<<20 && limit%(1<<20) == 0 { + return fmt.Sprintf("%d MiB", limit>>20) + } + return fmt.Sprintf("%d bytes", limit) +} + +// ErrTrashBundleTooLarge refuses to trash a node too large for the trash. It +// wraps ErrInvalid. +var ErrTrashBundleTooLarge = fmt.Errorf("too large for the trash: %w", ErrInvalid) + +// cappedBuffer is a bytes.Buffer that refuses to grow past max, so building +// an oversized bundle stops at the cap instead of holding all of it. +type cappedBuffer struct { + bytes.Buffer + max int +} + +func (b *cappedBuffer) Write(p []byte) (int, error) { + if b.Len()+len(p) > b.max { + return 0, ErrTrashBundleTooLarge + } + return b.Buffer.Write(p) +} + +// TrashEntry describes a removed node a repository keeps for restoring. +type TrashEntry struct { + ID NodeId `json:"id"` + Title string `json:"title"` + Reason string `json:"reason"` + // MergedInto is the node a merge folded this one into. + MergedInto *NodeId `json:"merged_into,omitempty"` + TrashedAt time.Time `json:"trashed_at"` + // ExpiresAt is when the repository will purge the entry, nil for never. + ExpiresAt *time.Time `json:"expires_at,omitempty"` + // Size is the stored bundle's size in bytes. + Size int64 `json:"size"` + // DisplacedFrom is the id the entry was trashed at, when an archive + // restore needed that id and the entry moved to ID to make way. Its + // bundle still names the old id; restoring puts it back at ID. + DisplacedFrom *NodeId `json:"displaced_from,omitempty"` +} + +// TrashWrite is what a repository stores for a node it trashes. Bundle is a +// keg archive of the node with its history and assets, which restoring +// imports back at the node's id. +type TrashWrite struct { + Title string + Reason string + MergedInto *NodeId + Bundle []byte +} + +// RepositoryTrash is an optional repository capability: removed nodes go to +// a trash they can be restored from rather than being deleted outright. +type RepositoryTrash interface { + // TrashNode stores in and deletes the node, as one write. It refuses + // with ErrDestinationExists when id already has a trash entry, and never + // overwrites one. + TrashNode(ctx context.Context, id NodeId, in TrashWrite) error + // ListTrash returns the trashed nodes, newest first. + ListTrash(ctx context.Context) ([]TrashEntry, error) + // ReadTrash returns one entry and its bundle; ErrNotExist when absent. + ReadTrash(ctx context.Context, id NodeId) (TrashEntry, []byte, error) + // TrashInfo returns one entry without its bundle; ErrNotExist when + // absent. It is the cheap check for whether an id is trashed. + TrashInfo(ctx context.Context, id NodeId) (TrashEntry, error) + // MoveTrash re-keys the entry at from to the free id to, recording from + // as its DisplacedFrom. It refuses with ErrDestinationExists when to + // already has an entry, and ErrNotExist when from has none. + MoveTrash(ctx context.Context, from, to NodeId) error + // DeleteTrash drops an entry; absent entries are not an error. + DeleteTrash(ctx context.Context, id NodeId) error +} + +func repoTrash(repo Repository) (RepositoryTrash, bool) { + trash, ok := repo.(RepositoryTrash) + return trash, ok +} + +// idTrashed reports whether id has an entry in the keg's trash. A trashed +// node's id is reserved: nothing but a restore may put a node there. +func (k *LocalKeg) idTrashed(ctx context.Context, id NodeId) (bool, error) { + trash, ok := repoTrash(k.Repo) + if !ok { + return false, nil + } + _, err := trash.TrashInfo(ctx, id) + switch { + case err == nil: + return true, nil + case errors.Is(err, ErrNotExist): + return false, nil + default: + return false, err + } +} + +// displaceTrashed makes way for an import landing on id: a trash entry +// there moves to a fresh id from the repository's counter, never one in +// avoid (the ids the import lands on), so the import can take id and the +// trashed node stays restorable at its new one. It reports the new id, or +// ok false when id held no entry. +func (k *LocalKeg) displaceTrashed(ctx context.Context, id NodeId, avoid map[NodeId]bool) (NodeId, bool, error) { + trash, ok := repoTrash(k.Repo) + if !ok { + return NodeId{}, false, nil + } + isTrashed, err := k.idTrashed(ctx, id) + if err != nil || !isTrashed { + return NodeId{}, false, err + } + var to NodeId + for { + next, err := k.Repo.Next(ctx) + if err != nil { + return NodeId{}, false, fmt.Errorf("unable to allocate an id for trashed node %s: %w", id.Path(), err) + } + // Next reserves the id as an empty node; the trash entry takes it, + // not a node. + k.cleanupUnusedImportReservations(ctx, []NodeId{next}) + // The import raised the counter past its own ids first; this is the + // second guard against handing the entry one of them. + if !avoid[next] { + to = next + break + } + } + if err := trash.MoveTrash(ctx, id, to); err != nil { + return NodeId{}, false, fmt.Errorf("unable to move trashed node %s out of the way: %w", id.Path(), err) + } + return to, true, nil +} + +// trashInfo says why removeUnchecked is removing a node. +type trashInfo struct { + reason string + mergedInto *NodeId +} + +var manualRemoval = trashInfo{reason: TrashReasonManual} + +// trashNode bundles id and hands it to the repository's trash, which deletes +// the live node. Inbound links are left pointing at the trashed id so a +// restore is lossless; purging rewrites them. A node whose bundle would pass +// MaxTrashBundleSize is refused with ErrTrashBundleTooLarge and left alone. +func (k *LocalKeg) trashNode(ctx context.Context, trash RepositoryTrash, id NodeId, info trashInfo) error { + view, err := k.ReadNode(ctx, id) + if err != nil { + return err + } + snapshots, withHistory := repoSnapshots(k.Repo) + opts := ExportNodesOptions{NodeIDs: []NodeId{id}, WithHistory: withHistory, HistoryIfSupported: true, WithAssets: true} + limit := trashBundleLimit(trash) + bundle := cappedBuffer{max: limit} + if err := k.writeArchive(ctx, &bundle, []NodeId{id}, snapshots, opts); err != nil { + if errors.Is(err, ErrTrashBundleTooLarge) { + return fmt.Errorf("node %s with its history and files passes the trash's %s limit, so it cannot go to the trash; remove large files or snapshots first: %w", + id.Path(), formatTrashLimit(limit), ErrTrashBundleTooLarge) + } + return fmt.Errorf("failed to bundle node %s for the trash: %w", id.Path(), err) + } + return k.withNodeLock(ctx, id, func(lockCtx context.Context) error { + return trash.TrashNode(lockCtx, id, TrashWrite{ + Title: ExplicitMarkdownTitle(view.Content), + Reason: info.reason, + MergedInto: info.mergedInto, + Bundle: bundle.Bytes(), + }) + }) +} + +// TrashNodesResult says what TrashNodes did. +type TrashNodesResult struct { + // Trashed is the nodes now in the trash. + Trashed []NodeId + // TooLarge is the nodes left in place because their bundle would pass + // MaxTrashBundleSize. + TooLarge []NodeId +} + +// TrashNodes moves nodes to the trash for reason, one at a time, leaving +// links to them alone, as a removal does, and updates the dex once at the +// end. Ids with no live node are skipped, node 0 is refused, and a node too +// large for the trash stays where it is and is reported in TooLarge. It is +// for bulk removals with a reason of their own, such as an archive restore +// dropping what the archive did not carry; a merge goes through MergeNodes. +// ErrNotSupported when the repository keeps no trash. +func (k *LocalKeg) TrashNodes(ctx context.Context, ids []NodeId, reason string) (TrashNodesResult, error) { + return withKegWriteValue(ctx, k, func(ctx context.Context) (TrashNodesResult, error) { + return k.trashNodes(ctx, ids, reason) + }) +} + +func (k *LocalKeg) trashNodes(ctx context.Context, ids []NodeId, reason string) (TrashNodesResult, error) { + trash, ok := repoTrash(k.Repo) + if !ok { + return TrashNodesResult{}, ErrNotSupported + } + if reason == "" || reason == TrashReasonMerge { + return TrashNodesResult{}, fmt.Errorf("trash reason %q: %w", reason, ErrInvalid) + } + result := TrashNodesResult{Trashed: []NodeId{}, TooLarge: []NodeId{}} + var errs []error + for _, id := range ids { + if id.ID == 0 { + return result, fmt.Errorf("node 0 cannot be removed: %w", ErrInvalid) + } + live, err := k.nodeExistsWithContent(ctx, id) + if err != nil { + errs = append(errs, err) + break + } + if !live { + continue + } + err = k.trashNode(ctx, trash, id, trashInfo{reason: reason}) + if errors.Is(err, ErrTrashBundleTooLarge) { + result.TooLarge = append(result.TooLarge, id) + continue + } + if err != nil { + errs = append(errs, err) + break + } + result.Trashed = append(result.Trashed, id) + } + if len(result.Trashed) > 0 { + errs = append(errs, k.afterRemoveMany(ctx, result.Trashed)) + } + return result, errors.Join(errs...) +} + +// afterRemoveMany drops removed nodes from the dex in one write and stamps +// the keg. +func (k *LocalKeg) afterRemoveMany(ctx context.Context, ids []NodeId) error { + var errs []error + dex, err := k.ensureDexFresh(ctx) + if err != nil { + errs = append(errs, fmt.Errorf("failed to retrieve dex after remove: %w", err)) + } else { + for _, id := range ids { + if err := dex.Remove(ctx, id); err != nil { + errs = append(errs, fmt.Errorf("failed to remove %s from dex: %w", id.Path(), err)) + } + } + if err := dex.Write(ctx, k.Repo); err != nil { + errs = append(errs, fmt.Errorf("failed to write dex after remove: %w", err)) + } else { + k.dexMu.Lock() + k.recordDexWrite() + k.dexMu.Unlock() + } + } + if err := k.touchSettingsUpdated(ctx, k.Runtime.Clock().Now()); err != nil { + errs = append(errs, fmt.Errorf("failed to update settings after remove: %w", err)) + } + return errors.Join(errs...) +} + +// ListTrash returns the keg's trashed nodes, newest first, or ErrNotSupported +// when its repository keeps no trash. +func (k *LocalKeg) ListTrash(ctx context.Context) ([]TrashEntry, error) { + return withKegReadValue(ctx, k, func(ctx context.Context) ([]TrashEntry, error) { + trash, ok := repoTrash(k.Repo) + if !ok { + return nil, ErrNotSupported + } + return trash.ListTrash(ctx) + }) +} + +// TrashInfo returns id's trash entry without its bundle: ErrNotExist when id +// is not trashed, ErrNotSupported when the repository keeps no trash. +func (k *LocalKeg) TrashInfo(ctx context.Context, id NodeId) (TrashEntry, error) { + return withKegReadValue(ctx, k, func(ctx context.Context) (TrashEntry, error) { + trash, ok := repoTrash(k.Repo) + if !ok { + return TrashEntry{}, ErrNotSupported + } + return trash.TrashInfo(ctx, id) + }) +} + +// RestoreTrashed puts trashed nodes back at their own ids, with the history +// and assets they had. Links to them were left in place when they were +// trashed, so they resolve again. All or nothing. +func (k *LocalKeg) RestoreTrashed(ctx context.Context, ids []NodeId) error { + _, err := withKegAtomicWriteValue(ctx, k, func(ctx context.Context) (struct{}, error) { + return struct{}{}, k.restoreTrashed(ctx, ids) + }) + return err +} + +func (k *LocalKeg) restoreTrashed(ctx context.Context, ids []NodeId) error { + trash, ok := repoTrash(k.Repo) + if !ok { + return ErrNotSupported + } + if err := validateMutationBatchSize(len(ids)); err != nil { + return err + } + // The node is put back as it was; the keg's schemas may have moved on + // since, and a restore must not be refused for it. + ctx = WithValidationMode(ctx, ValidationModeOff) + seen := make(map[NodeId]bool, len(ids)) + for _, id := range ids { + if seen[id] { + return fmt.Errorf("node %s is listed twice: %w", id.Path(), ErrInvalid) + } + seen[id] = true + } + for _, id := range ids { + _, bundle, err := trash.ReadTrash(ctx, id) + if err != nil { + return fmt.Errorf("trashed node %s: %w", id.Path(), err) + } + exists, err := k.nodeExistsWithContent(ctx, id) + if err != nil { + return err + } + if exists { + return fmt.Errorf("node %s exists, so its trashed copy cannot be restored over it: %w", id.Path(), ErrDestinationExists) + } + // The bundle names the id the node was trashed at, which an archive + // restore may since have given to another node; the node comes back + // at the entry's current id, its links to itself with it. Other + // nodes' links to the old id belong to whatever holds it. The import + // leaves the dex alone: it is updated once, below, for the batch. + to := id + opts := ImportNodesOptions{HistoryIfSupported: true, restoreFromTrash: true, restoreTo: &to} + if _, err := k.importNodes(ctx, bytes.NewReader(bundle), opts); err != nil { + return fmt.Errorf("failed to restore node %s: %w", id.Path(), err) + } + if err := trash.DeleteTrash(ctx, id); err != nil { + return err + } + } + return k.indexRestored(ctx, ids) +} + +// indexRestored adds restored nodes to the dex without rebuilding it from +// every node: each restored node is added, and the links other nodes hold to +// it, which the removal dropped from the link indexes, are found in one +// batched read of the keg's stats. A repository without batched reads gets +// one full rebuild for the whole restore. +func (k *LocalKeg) indexRestored(ctx context.Context, ids []NodeId) error { + now := k.Runtime.Clock().Now() + batch, ok := repositoryBatchRead(k.Repo) + if !ok { + if err := k.rebuildDexFromRepo(ctx); err != nil { + return err + } + return k.touchSettingsUpdated(ctx, now) + } + restored := make(map[string]bool, len(ids)) + nodes := make([]*NodeData, 0, len(ids)) + for _, id := range ids { + data, err := k.loadNodeDataForDex(ctx, id) + if err != nil { + return fmt.Errorf("unable to read restored node %s for the dex: %w", id.Path(), err) + } + restored[id.Path()] = true + nodes = append(nodes, data) + } + all, err := k.Repo.ListNodes(ctx) + if err != nil { + return fmt.Errorf("unable to list nodes after restore: %w", err) + } + stats, err := batch.ReadStatsBatch(ctx, all) + if err != nil { + return fmt.Errorf("unable to read node links after restore: %w", err) + } + type inbound struct{ src, dst NodeId } + var links []inbound + for _, src := range all { + if restored[src.Path()] { + continue + } + for _, dst := range stats[src.Path()].Links() { + if restored[dst.Path()] { + links = append(links, inbound{src: src, dst: dst}) + } + } + } + dex, err := k.ensureDexFresh(ctx) + if err != nil { + return fmt.Errorf("failed to retrieve dex after restore: %w", err) + } + for _, data := range nodes { + if err := dex.Add(ctx, data); err != nil { + return fmt.Errorf("failed to add restored node %s to dex: %w", data.ID.Path(), err) + } + } + dex.mu.Lock() + for _, link := range links { + addDexLink(dex, link.src, link.dst) + } + dex.mu.Unlock() + if err := dex.Write(ctx, k.Repo); err != nil { + return fmt.Errorf("failed to write dex after restore: %w", err) + } + k.dexMu.Lock() + k.recordDexWrite() + k.dexMu.Unlock() + return k.touchSettingsUpdated(ctx, now) +} + +// addDexLink records src linking to dst in the dex's link and backlink +// indexes. The caller holds dex.mu. +func addDexLink(dex *Dex, src, dst NodeId) { + if dex.links.data == nil { + dex.links.data = map[string][]NodeId{} + } + if !slices.ContainsFunc(dex.links.data[src.Path()], dst.Equals) { + dex.links.data[src.Path()] = append(dex.links.data[src.Path()], dst) + } + if dex.backlinks.data == nil { + dex.backlinks.data = map[string][]NodeId{} + } + if !slices.ContainsFunc(dex.backlinks.data[dst.Path()], src.Equals) { + dex.backlinks.data[dst.Path()] = append(dex.backlinks.data[dst.Path()], src) + } +} + +// mapPurgedLinks points a trash bundle's links to nodes that are gone for +// good -- purged since the node was trashed, neither live nor in the trash -- +// at node 0, as purging did to the links live nodes held. Links to a live +// node or to another trashed node are left alone. +func (k *LocalKeg) mapPurgedLinks(ctx context.Context, entries map[string][]byte, ordered []string, mapping map[string]NodeId) error { + for _, sourceID := range ordered { + base := "keg-archive/nodes/" + sourceID + var targets []NodeId + if raw, ok := entries[base+"/README.md"]; ok { + if content, err := ParseContent(k.Runtime, raw, MarkdownContentFilename); err == nil { + targets = append(targets, content.Links...) + } + } + if raw, ok := entries[base+"/stats.json"]; ok { + if stats, err := ParseStats(ctx, raw); err == nil { + targets = append(targets, stats.Links()...) + } + } + for _, target := range targets { + if _, mapped := mapping[target.Path()]; mapped || target.ID == 0 || target.Code != "" || target.Alias != "" { + continue + } + live, err := k.nodeExistsWithContent(ctx, target) + if err != nil { + return err + } + trashed, err := k.idTrashed(ctx, target) + if err != nil { + return err + } + if !live && !trashed { + mapping[target.Path()] = NodeId{ID: 0} + } + } + } + return nil +} + +// PurgeTrashOptions names the trashed nodes to delete for good. +type PurgeTrashOptions struct { + IDs []NodeId `json:"nodes"` + // ExpiredAt, when set, purges only entries whose ExpiresAt is set and + // not after it. The expiry is checked inside the write, so an entry + // restored and trashed again since it was listed is left alone. + ExpiredAt *time.Time `json:"-"` +} + +// PurgeTrashResult says what a purge did. +type PurgeTrashResult struct { + // Purged is the ids whose trash entries were deleted. Ids with no entry, + // or not yet expired under ExpiredAt, are absent. + Purged []NodeId `json:"purged"` + // Rewritten is the nodes whose links to a purged id now point at node 0. + Rewritten []NodeId `json:"rewritten"` +} + +// PurgeTrashed deletes 1-100 trashed nodes for good, as one write, and +// points links that still name them at the zero node, as removing a node +// without a trash does. Links are left alone for an id a live node holds. +func (k *LocalKeg) PurgeTrashed(ctx context.Context, opts PurgeTrashOptions) (PurgeTrashResult, error) { + return withKegAtomicWriteValue(ctx, k, func(ctx context.Context) (PurgeTrashResult, error) { return k.purgeTrashed(ctx, opts) }) +} + +func (k *LocalKeg) purgeTrashed(ctx context.Context, opts PurgeTrashOptions) (PurgeTrashResult, error) { + trash, ok := repoTrash(k.Repo) + if !ok { + return PurgeTrashResult{}, ErrNotSupported + } + if err := validateMutationBatchSize(len(opts.IDs)); err != nil { + return PurgeTrashResult{}, err + } + seen := make(map[NodeId]bool, len(opts.IDs)) + for _, id := range opts.IDs { + if seen[id] { + return PurgeTrashResult{}, fmt.Errorf("node %s is listed twice: %w", id.Path(), ErrInvalid) + } + seen[id] = true + } + result := PurgeTrashResult{Purged: []NodeId{}, Rewritten: []NodeId{}} + var orphaned []NodeId + for _, id := range opts.IDs { + entry, err := trash.TrashInfo(ctx, id) + if errors.Is(err, ErrNotExist) { + continue + } + if err != nil { + return PurgeTrashResult{}, err + } + if opts.ExpiredAt != nil && (entry.ExpiresAt == nil || entry.ExpiresAt.After(*opts.ExpiredAt)) { + continue + } + if err := trash.DeleteTrash(ctx, id); err != nil { + return PurgeTrashResult{}, err + } + result.Purged = append(result.Purged, id) + // Links to an id a live node holds are that node's links. + live, err := k.nodeExistsWithContent(ctx, id) + if err != nil { + return PurgeTrashResult{}, err + } + if !live { + orphaned = append(orphaned, id) + } + } + if len(orphaned) == 0 { + return result, nil + } + rewritten, err := k.retargetLinksMany(ctx, orphaned, NodeId{ID: 0}) + if err != nil { + return PurgeTrashResult{}, err + } + result.Rewritten = append(result.Rewritten, rewritten...) + return result, nil +} diff --git a/pkg/keg/trash_test.go b/pkg/keg/trash_test.go new file mode 100644 index 00000000..ae8bd720 --- /dev/null +++ b/pkg/keg/trash_test.go @@ -0,0 +1,809 @@ +package keg_test + +import ( + "bytes" + "context" + "crypto/rand" + "fmt" + "io" + "sync" + "testing" + "time" + + "github.com/jlrickert/tapper/internal/testkegrepo" + "github.com/jlrickert/tapper/pkg/keg" + "github.com/stretchr/testify/require" +) + +// trashingRepo is the memory repository with a trash, as the hub keeps one. +type trashingRepo struct { + *testkegrepo.MemoryRepository + mu sync.Mutex + trash map[keg.NodeId]trashedNode + // floor is the highest id Next may not hand out, as the hub's + // next_node_id counter only rises. + floor int +} + +func (r *trashingRepo) RaiseNodeCounter(_ context.Context, past keg.NodeId) error { + r.mu.Lock() + defer r.mu.Unlock() + r.floor = max(r.floor, past.ID) + return nil +} + +type trashedNode struct { + entry keg.TrashEntry + bundle []byte +} + +// Next never hands out a trashed id, as the hub's rising counter never does. +func (r *trashingRepo) Next(ctx context.Context) (keg.NodeId, error) { + for { + id, err := r.MemoryRepository.Next(ctx) + if err != nil { + return id, err + } + r.mu.Lock() + _, trashed := r.trash[id] + below := id.ID <= r.floor + r.mu.Unlock() + if !trashed && !below { + return id, nil + } + } +} + +func (r *trashingRepo) TrashNode(ctx context.Context, id keg.NodeId, in keg.TrashWrite) error { + r.mu.Lock() + _, taken := r.trash[id] + r.mu.Unlock() + if taken { + return keg.ErrDestinationExists + } + if err := r.DeleteNode(ctx, id); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + r.trash[id] = trashedNode{entry: keg.TrashEntry{ID: id, Title: in.Title, Reason: in.Reason, MergedInto: in.MergedInto, Size: int64(len(in.Bundle))}, bundle: in.Bundle} + return nil +} + +func (r *trashingRepo) ListTrash(context.Context) ([]keg.TrashEntry, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]keg.TrashEntry, 0, len(r.trash)) + for _, item := range r.trash { + out = append(out, item.entry) + } + return out, nil +} + +func (r *trashingRepo) ReadTrash(_ context.Context, id keg.NodeId) (keg.TrashEntry, []byte, error) { + r.mu.Lock() + defer r.mu.Unlock() + item, ok := r.trash[id] + if !ok { + return keg.TrashEntry{}, nil, keg.ErrNotExist + } + return item.entry, item.bundle, nil +} + +// expire sets when the trash would purge id's entry. +func (r *trashingRepo) expire(id keg.NodeId, at time.Time) { + r.mu.Lock() + defer r.mu.Unlock() + item := r.trash[id] + item.entry.ExpiresAt = &at + r.trash[id] = item +} + +// plant puts an entry in the trash directly, as a stale or legacy entry is. +func (r *trashingRepo) plant(id keg.NodeId) { + r.mu.Lock() + defer r.mu.Unlock() + r.trash[id] = trashedNode{entry: keg.TrashEntry{ID: id, Reason: keg.TrashReasonManual}} +} + +func (r *trashingRepo) TrashInfo(_ context.Context, id keg.NodeId) (keg.TrashEntry, error) { + r.mu.Lock() + defer r.mu.Unlock() + item, ok := r.trash[id] + if !ok { + return keg.TrashEntry{}, keg.ErrNotExist + } + return item.entry, nil +} + +func (r *trashingRepo) MoveTrash(_ context.Context, from, to keg.NodeId) error { + r.mu.Lock() + defer r.mu.Unlock() + item, ok := r.trash[from] + if !ok { + return keg.ErrNotExist + } + if _, taken := r.trash[to]; taken { + return keg.ErrDestinationExists + } + delete(r.trash, from) + // The original id is kept through later displacements. + if item.entry.DisplacedFrom == nil { + old := from + item.entry.DisplacedFrom = &old + } + item.entry.ID = to + r.trash[to] = item + return nil +} + +func (r *trashingRepo) DeleteTrash(_ context.Context, id keg.NodeId) error { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.trash, id) + return nil +} + +func TestTrashKeepsRemovedNodesRestorable(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + repo := &trashingRepo{MemoryRepository: newTestMemoryRepo(fx.Runtime()), trash: map[keg.NodeId]trashedNode{}} + k := keg.NewLocalKeg(repo, fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + + gone, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Gone\n\nKeep me.\n"), Meta: []byte("tags: [kept]\n")}) + require.NoError(t, err) + _, err = k.AppendSnapshot(ctx, gone.ID, "before removal") + require.NoError(t, err) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Linker\n\nSee [gone](../%d).\n", gone.ID.ID))}) + require.NoError(t, err) + before, err := k.ReadNode(ctx, gone.ID) + require.NoError(t, err) + + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: gone.ID, ExpectedHash: before.Hash()}) + require.NoError(t, err) + exists, err := k.NodeExists(ctx, gone.ID) + require.NoError(t, err) + require.False(t, exists) + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) + require.Equal(t, gone.ID, trash[0].ID) + require.Equal(t, "Gone", trash[0].Title) + require.Equal(t, keg.TrashReasonManual, trash[0].Reason) + info, err := k.TrashInfo(ctx, gone.ID) + require.NoError(t, err) + require.Equal(t, "Gone", info.Title) + _, err = k.TrashInfo(ctx, linker.ID) + require.ErrorIs(t, err, keg.ErrNotExist, "a live node is not trashed") + // The link is left alone so a restore is lossless. + linking, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Contains(t, string(linking.Content), fmt.Sprintf("../%d)", gone.ID.ID)) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{gone.ID})) + restored, err := k.ReadNode(ctx, gone.ID) + require.NoError(t, err) + require.Equal(t, before.Content, restored.Content) + require.Equal(t, before.Meta, restored.Meta) + snapshots, err := k.ListSnapshots(ctx, gone.ID) + require.NoError(t, err) + require.NotEmpty(t, snapshots) + trash, err = k.ListTrash(ctx) + require.NoError(t, err) + require.Empty(t, trash) + _, err = k.TrashInfo(ctx, gone.ID) + require.ErrorIs(t, err, keg.ErrNotExist, "a restored node is no longer trashed") + require.ErrorIs(t, k.RestoreTrashed(ctx, []keg.NodeId{gone.ID}), keg.ErrNotExist) + + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: gone.ID, ExpectedHash: restored.Hash()}) + require.NoError(t, err) + purged, err := k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: []keg.NodeId{gone.ID}}) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{gone.ID}, purged.Purged) + require.Equal(t, []keg.NodeId{linker.ID}, purged.Rewritten) + linking, err = k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Contains(t, string(linking.Content), "../0)") + require.ErrorIs(t, k.RestoreTrashed(ctx, []keg.NodeId{gone.ID}), keg.ErrNotExist) +} + +func TestRemoveWithoutTrashRewritesLinks(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + gone, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Gone\n")}) + require.NoError(t, err) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Linker\n\n[gone](../%d)\n", gone.ID.ID))}) + require.NoError(t, err) + view, err := k.ReadNode(ctx, gone.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: gone.ID, ExpectedHash: view.Hash()}) + require.NoError(t, err) + linking, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Contains(t, string(linking.Content), "../0)") + _, err = k.ListTrash(ctx) + require.ErrorIs(t, err, keg.ErrNotSupported) + _, err = k.TrashInfo(ctx, gone.ID) + require.ErrorIs(t, err, keg.ErrNotSupported) +} + +func newTrashingKeg(t *testing.T) (*keg.LocalKeg, *trashingRepo, context.Context) { + t.Helper() + fx := NewSandbox(t) + ctx := fx.Context() + repo := &trashingRepo{MemoryRepository: newTestMemoryRepo(fx.Runtime()), trash: map[keg.NodeId]trashedNode{}} + k := keg.NewLocalKeg(repo, fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + return k, repo, ctx +} + +func TestTrashedIDIsReserved(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + a, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# A\n\nOriginal.\n")}) + require.NoError(t, err) + b, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# B\n")}) + require.NoError(t, err) + aView, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: a.ID, ExpectedHash: aView.Hash()}) + require.NoError(t, err) + _, bundle, err := k.Repo.(keg.RepositoryTrash).ReadTrash(ctx, a.ID) + require.NoError(t, err) + + bView, err := k.ReadNode(ctx, b.ID) + require.NoError(t, err) + _, err = k.Move(ctx, keg.NodeMoveOptions{Source: b.ID, Destination: a.ID, ExpectedHash: bView.Hash()}) + require.ErrorIs(t, err, keg.ErrDestinationExists, "a trashed id is not free to move onto") + stillB, err := k.ReadNode(ctx, b.ID) + require.NoError(t, err) + require.Equal(t, bView.Content, stillB.Content) + + _, after, err := k.Repo.(keg.RepositoryTrash).ReadTrash(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, bundle, after, "the trashed copy is untouched") + exists, err := k.NodeExists(ctx, a.ID) + require.NoError(t, err) + require.False(t, exists) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{a.ID})) + restored, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, aView.Content, restored.Content) +} + +// An archive always restores: a trashed node holding an archived id moves to +// a fresh id, keeps its content, and restores there with its links to itself +// following it, while links elsewhere stay with the archived node. +func TestArchiveRestoreDisplacesATrashedNode(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + a, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# A\n")}) + require.NoError(t, err) + n := a.ID.ID + archivedBody := fmt.Sprintf("# A\n\nArchived, see [me](../%d).\n", n) + require.NoError(t, k.Repo.WriteContent(ctx, a.ID, []byte(archivedBody))) + linkerBody := fmt.Sprintf("# Linker\n\n[a](../%d)\n", n) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(linkerBody)}) + require.NoError(t, err) + archive, err := k.ExportNodes(ctx, keg.ExportNodesOptions{NodeIDs: []keg.NodeId{a.ID}}) + require.NoError(t, err) + archived, err := io.ReadAll(archive) + require.NoError(t, err) + require.NoError(t, archive.Close()) + + trashedBody := fmt.Sprintf("# A\n\nLater, see [me](../%d).\n", n) + require.NoError(t, k.Repo.WriteContent(ctx, a.ID, []byte(trashedBody))) + view, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: a.ID, ExpectedHash: view.Hash()}) + require.NoError(t, err) + + _, err = k.ImportNodes(ctx, bytes.NewReader(archived), keg.ImportNodesOptions{}) + require.NoError(t, err, "an archive restores even when its id is in the trash") + got, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, archivedBody, string(got.Content)) + + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) + moved := trash[0].ID + require.NotEqual(t, a.ID, moved) + require.NotNil(t, trash[0].DisplacedFrom) + require.Equal(t, a.ID, *trash[0].DisplacedFrom) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{moved})) + back, err := k.ReadNode(ctx, moved) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("# A\n\nLater, see [me](../%d).\n", moved.ID), string(back.Content), "the trashed content, its self-link following it") + still, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, archivedBody, string(still.Content), "the archived node keeps its id") + link, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Equal(t, linkerBody, string(link.Content), "links elsewhere stay with the archived node") + + // The archived node can itself be trashed as usual. + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: a.ID, ExpectedHash: still.Hash()}) + require.NoError(t, err) + trash, err = k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) + require.Equal(t, a.ID, trash[0].ID) + require.Nil(t, trash[0].DisplacedFrom) +} + +// Purging a displaced entry rewrites links to its new id, never its old one, +// which belongs to the archived node. +func TestPurgingADisplacedEntryLeavesTheOldIDsLinks(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + a, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# A\n")}) + require.NoError(t, err) + archive, err := k.ExportNodes(ctx, keg.ExportNodesOptions{NodeIDs: []keg.NodeId{a.ID}}) + require.NoError(t, err) + archived, err := io.ReadAll(archive) + require.NoError(t, err) + require.NoError(t, archive.Close()) + linkerBody := fmt.Sprintf("# Linker\n\n[a](../%d)\n", a.ID.ID) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(linkerBody)}) + require.NoError(t, err) + view, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: a.ID, ExpectedHash: view.Hash()}) + require.NoError(t, err) + _, err = k.ImportNodes(ctx, bytes.NewReader(archived), keg.ImportNodesOptions{}) + require.NoError(t, err) + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) + result, err := k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: []keg.NodeId{trash[0].ID}}) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{trash[0].ID}, result.Purged) + require.Empty(t, result.Rewritten) + got, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Equal(t, linkerBody, string(got.Content)) +} + +func TestRestoreTrashedRefusesDuplicateIDs(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + a, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# A\n")}) + require.NoError(t, err) + view, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: a.ID, ExpectedHash: view.Hash()}) + require.NoError(t, err) + err = k.RestoreTrashed(ctx, []keg.NodeId{a.ID, a.ID}) + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, "listed twice") + exists, err := k.NodeExists(ctx, a.ID) + require.NoError(t, err) + require.False(t, exists, "nothing was restored") + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) +} + +func TestRetargetLinks(t *testing.T) { + t.Parallel() + got := keg.RetargetLinks([]byte("[a](../3) [b](../30) [c](../3#x)\n"), keg.NodeId{ID: 3}, keg.NodeId{ID: 7}) + require.Equal(t, "[a](../7) [b](../30) [c](../7#x)\n", string(got)) +} + +func TestPurgeLeavesLinksToALiveNodeAlone(t *testing.T) { + t.Parallel() + k, repo, ctx := newTrashingKeg(t) + live, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Live\n")}) + require.NoError(t, err) + linkerBody := fmt.Sprintf("# Linker\n\n[live](../%d)\n", live.ID.ID) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(linkerBody)}) + require.NoError(t, err) + repo.plant(live.ID) + + result, err := k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: []keg.NodeId{live.ID}}) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{live.ID}, result.Purged) + require.Empty(t, result.Rewritten) + got, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Equal(t, linkerBody, string(got.Content)) + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Empty(t, trash) +} + +func TestPurgeTrashedReportsWhatItPurged(t *testing.T) { + t.Parallel() + k, repo, ctx := newTrashingKeg(t) + remove := func(body string) keg.NodeId { + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(body)}) + require.NoError(t, err) + view, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: created.ID, ExpectedHash: view.Hash()}) + require.NoError(t, err) + return created.ID + } + a := remove("# A\n") + b := remove("# B\n") + linkerA, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# LA\n\n[a](../%d)\n", a.ID))}) + require.NoError(t, err) + linkerB, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# LB\n\n[b](../%d)\n", b.ID))}) + require.NoError(t, err) + + now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC) + repo.expire(a, now.Add(-time.Hour)) + repo.expire(b, now.Add(time.Hour)) + result, err := k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: []keg.NodeId{a, b}, ExpiredAt: &now}) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{a}, result.Purged, "only the expired entry goes") + require.Equal(t, []keg.NodeId{linkerA.ID}, result.Rewritten) + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{b}), "the unexpired one is still restorable") + + bView, err := k.ReadNode(ctx, b) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: b, ExpectedHash: bView.Hash()}) + require.NoError(t, err) + c := remove("# C\n") + linkerC, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# LC\n\n[c](../%d)\n", c.ID))}) + require.NoError(t, err) + result, err = k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: []keg.NodeId{b, c, {ID: 999}}}) + require.NoError(t, err) + require.ElementsMatch(t, []keg.NodeId{b, c}, result.Purged, "an id not in the trash is not reported purged") + require.ElementsMatch(t, []keg.NodeId{linkerB.ID, linkerC.ID}, result.Rewritten) + for _, id := range []keg.NodeId{linkerB.ID, linkerC.ID} { + view, err := k.ReadNode(ctx, id) + require.NoError(t, err) + require.Contains(t, string(view.Content), "(../0)") + } + + ids := make([]keg.NodeId, keg.MaxMutationBatchSize+1) + for i := range ids { + ids[i] = keg.NodeId{ID: 1000 + i} + } + _, err = k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: ids}) + require.ErrorIs(t, err, keg.ErrInvalid) +} + +// archiveOf builds a keg archive whose nodes sit at ids 1..len(bodies), each +// with its body, in a keg of its own. +func archiveOf(t *testing.T, bodies ...string) []byte { + t.Helper() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + ids := make([]keg.NodeId, len(bodies)) + for i, body := range bodies { + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(body)}) + require.NoError(t, err) + require.Equal(t, i+1, created.ID.ID) + ids[i] = created.ID + } + archive, err := k.ExportNodes(ctx, keg.ExportNodesOptions{NodeIDs: ids}) + require.NoError(t, err) + data, err := io.ReadAll(archive) + require.NoError(t, err) + require.NoError(t, archive.Close()) + return data +} + +// archiveAt builds a keg archive holding one node at id with body. +func archiveAt(t *testing.T, id int, body string) []byte { + t.Helper() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + var last keg.NodeId + for last.ID < id { + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(body)}) + require.NoError(t, err) + last = created.ID + } + archive, err := k.ExportNodes(ctx, keg.ExportNodesOptions{NodeIDs: []keg.NodeId{last}}) + require.NoError(t, err) + data, err := io.ReadAll(archive) + require.NoError(t, err) + require.NoError(t, archive.Close()) + return data +} + +func trashNode(t *testing.T, ctx context.Context, k *keg.LocalKeg, id keg.NodeId) { + t.Helper() + view, err := k.ReadNode(ctx, id) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: id, ExpectedHash: view.Hash()}) + require.NoError(t, err) +} + +func onlyTrashEntry(t *testing.T, ctx context.Context, k *keg.LocalKeg) keg.TrashEntry { + t.Helper() + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) + return trash[0] +} + +// A trashed node displaced twice keeps its original id on record and +// restores at its current id: the live node at its original id is never +// touched. +func TestTwiceDisplacedEntryRestoresAtItsCurrentID(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + first, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Trashed\n\nOriginal.\n")}) + require.NoError(t, err) + require.Equal(t, 1, first.ID.ID) + trashNode(t, ctx, k, first.ID) + + _, err = k.ImportNodes(ctx, bytes.NewReader(archiveAt(t, 1, "# Archived one\n")), keg.ImportNodesOptions{}) + require.NoError(t, err) + entry := onlyTrashEntry(t, ctx, k) + require.Equal(t, first.ID, *entry.DisplacedFrom) + + _, err = k.ImportNodes(ctx, bytes.NewReader(archiveAt(t, entry.ID.ID, "# Archived other\n")), keg.ImportNodesOptions{}) + require.NoError(t, err) + moved := onlyTrashEntry(t, ctx, k) + require.NotEqual(t, entry.ID, moved.ID) + require.Equal(t, first.ID, *moved.DisplacedFrom, "the original id is kept") + + live, err := k.ReadNode(ctx, first.ID) + require.NoError(t, err) + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ID: first.ID, Content: []byte("# Archived one\n\nEdited.\n"), HasContent: true, ExpectedContentHash: live.ContentHash()}}) + require.NoError(t, err) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{moved.ID})) + back, err := k.ReadNode(ctx, moved.ID) + require.NoError(t, err) + require.Equal(t, "# Trashed\n\nOriginal.\n", string(back.Content)) + edited, err := k.ReadNode(ctx, first.ID) + require.NoError(t, err) + require.Equal(t, "# Archived one\n\nEdited.\n", string(edited.Content), "the live node is untouched") +} + +// An archive whose ids run past the counter: the trashed node is displaced +// to an id the archive does not use, and the imported nodes stay as +// imported. +func TestDisplacementSkipsTheArchivesOwnIDs(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + first, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Trashed\n")}) + require.NoError(t, err) + _, err = k.Create(ctx, &keg.CreateOptions{Body: []byte("# Two\n")}) + require.NoError(t, err) + trashNode(t, ctx, k, first.ID) + + _, err = k.ImportNodes(ctx, bytes.NewReader(archiveOf(t, "# A1\n", "# A2\n", "# A3\n", "# A4\n")), keg.ImportNodesOptions{}) + require.NoError(t, err) + entry := onlyTrashEntry(t, ctx, k) + require.Greater(t, entry.ID.ID, 4, "the entry is not given one of the archive's ids") + require.Equal(t, first.ID, *entry.DisplacedFrom) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{entry.ID})) + back, err := k.ReadNode(ctx, entry.ID) + require.NoError(t, err) + require.Equal(t, "# Trashed\n", string(back.Content)) + for i, want := range []string{"# A1\n", "# A2\n", "# A3\n", "# A4\n"} { + got, err := k.ReadNode(ctx, keg.NodeId{ID: i + 1}) + require.NoError(t, err) + require.Equal(t, want, string(got.Content)) + } +} + +// A trash restore never overwrites a live node: with a node at the entry's +// id, it is refused and nothing changes. +func TestRestoreOntoALiveNodeIsRefused(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + gone, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Gone\n")}) + require.NoError(t, err) + trashNode(t, ctx, k, gone.ID) + // A node at the trashed id, as legacy data or a direct write leaves one. + require.NoError(t, k.Repo.WriteContent(ctx, gone.ID, []byte("# Squatter\n"))) + require.NoError(t, k.Repo.WriteMeta(ctx, gone.ID, []byte("{}\n"))) + + err = k.RestoreTrashed(ctx, []keg.NodeId{gone.ID}) + require.ErrorIs(t, err, keg.ErrDestinationExists) + got, err := k.ReadNode(ctx, gone.ID) + require.NoError(t, err) + require.Equal(t, "# Squatter\n", string(got.Content)) + require.Equal(t, gone.ID, onlyTrashEntry(t, ctx, k).ID) +} + +// readCountingRepo is a trash that counts reads of each node's content. +type readCountingRepo struct { + *trashingRepo + countMu sync.Mutex + reads map[keg.NodeId]int +} + +func (r *readCountingRepo) ReadContent(ctx context.Context, id keg.NodeId) ([]byte, error) { + r.countMu.Lock() + r.reads[id]++ + r.countMu.Unlock() + return r.trashingRepo.ReadContent(ctx, id) +} + +func (r *readCountingRepo) resetReads() { + r.countMu.Lock() + defer r.countMu.Unlock() + r.reads = map[keg.NodeId]int{} +} + +func (r *readCountingRepo) readsOf(id keg.NodeId) int { + r.countMu.Lock() + defer r.countMu.Unlock() + return r.reads[id] +} + +// Restoring a batch from the trash costs the nodes restored, not the whole +// keg: no other node's content is read, yet the indexes come out as a full +// rebuild would leave them, with the links other nodes hold to the restored +// ones back in place. +func TestRestoreTrashedReadsOnlyWhatItRestores(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + repo := &readCountingRepo{trashingRepo: &trashingRepo{MemoryRepository: newTestMemoryRepo(fx.Runtime()), trash: map[keg.NodeId]trashedNode{}}, reads: map[keg.NodeId]int{}} + k := keg.NewLocalKeg(repo, fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + + var bystanders []keg.NodeId + for i := range 20 { + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Bystander %d\n", i))}) + require.NoError(t, err) + bystanders = append(bystanders, created.ID) + } + first, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# First\n")}) + require.NoError(t, err) + second, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Second\n\nSee [first](../%d).\n", first.ID.ID))}) + require.NoError(t, err) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Linker\n\n[first](../%d) and [second](../%d)\n", first.ID.ID, second.ID.ID))}) + require.NoError(t, err) + trashNode(t, ctx, k, first.ID) + trashNode(t, ctx, k, second.ID) + + repo.resetReads() + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{first.ID, second.ID})) + for _, id := range bystanders { + require.Zero(t, repo.readsOf(id), "restoring read bystander %s", id.Path()) + } + + dex, err := k.Dex(ctx) + require.NoError(t, err) + backlinks, _ := dex.Backlinks(ctx, first.ID) + require.ElementsMatch(t, []keg.NodeId{second.ID, linker.ID}, backlinks) + backlinks, _ = dex.Backlinks(ctx, second.ID) + require.ElementsMatch(t, []keg.NodeId{linker.ID}, backlinks) + + restored := map[string][]byte{} + for _, name := range []string{"links", "backlinks", "nodes.tsv", "tags"} { + data, err := k.ReadIndex(ctx, name) + require.NoError(t, err) + restored[name] = data + } + require.NoError(t, k.Index(ctx, keg.IndexOptions{})) + for name, data := range restored { + rebuilt, err := k.ReadIndex(ctx, name) + require.NoError(t, err) + require.Equal(t, string(rebuilt), string(data), "index %s after a restore differs from a rebuild", name) + } +} + +// A restored node's links to a node purged while it sat in the trash point +// at node 0, as purging pointed the live nodes' links; links to a live node +// or to another trashed node are kept. +func TestRestoreTrashedPointsLinksToPurgedNodesAtZero(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + live, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Live\n")}) + require.NoError(t, err) + purged, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Purged\n")}) + require.NoError(t, err) + sleeping, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Sleeping\n")}) + require.NoError(t, err) + body := fmt.Sprintf("# Back\n\n[live](../%d) [purged](../%d) [sleeping](../%d)\n", live.ID.ID, purged.ID.ID, sleeping.ID.ID) + back, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(body)}) + require.NoError(t, err) + + trashNode(t, ctx, k, back.ID) + trashNode(t, ctx, k, purged.ID) + trashNode(t, ctx, k, sleeping.ID) + _, err = k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: []keg.NodeId{purged.ID}}) + require.NoError(t, err) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{back.ID})) + view, err := k.ReadNode(ctx, back.ID) + require.NoError(t, err) + want := fmt.Sprintf("# Back\n\n[live](../%d) [purged](../0) [sleeping](../%d)\n", live.ID.ID, sleeping.ID.ID) + require.Equal(t, want, string(view.Content)) +} + +// limitedTrashRepo is a trash that holds bundles up to limit bytes. +type limitedTrashRepo struct { + *trashingRepo + limit int +} + +func (r *limitedTrashRepo) TrashBundleLimit() int { return r.limit } + +// A node too large for the trash is refused with a clear error and stays +// where it is; the rest of a removal batch is untouched. +func TestTrashRefusesANodeTooLargeForIt(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + repo := &limitedTrashRepo{trashingRepo: &trashingRepo{MemoryRepository: newTestMemoryRepo(fx.Runtime()), trash: map[keg.NodeId]trashedNode{}}, limit: 64 << 10} + k := keg.NewLocalKeg(repo, fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + + small, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Small\n")}) + require.NoError(t, err) + big, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Big\n")}) + require.NoError(t, err) + // Random bytes do not compress, so the bundle passes the limit. + payload := make([]byte, 256<<10) + _, err = rand.Read(payload) + require.NoError(t, err) + require.NoError(t, k.WriteFile(ctx, big.ID, "blob.bin", payload)) + + view, err := k.ReadNode(ctx, big.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: big.ID, ExpectedHash: view.Hash()}) + require.ErrorIs(t, err, keg.ErrTrashBundleTooLarge) + require.ErrorIs(t, err, keg.ErrInvalid) + require.Contains(t, err.Error(), "cannot go to the trash") + exists, err := k.NodeExists(ctx, big.ID) + require.NoError(t, err) + require.True(t, exists, "a node refused by the trash stays live") + + result, err := k.TrashNodes(ctx, []keg.NodeId{small.ID, big.ID}, keg.TrashReasonArchiveRestore) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{small.ID}, result.Trashed) + require.Equal(t, []keg.NodeId{big.ID}, result.TooLarge) + entry := onlyTrashEntry(t, ctx, k) + require.Equal(t, small.ID, entry.ID) + require.Equal(t, keg.TrashReasonArchiveRestore, entry.Reason) +} + +// TrashNodes trashes live nodes for a reason of its own, skips ids with no +// node, refuses node 0 and a merge reason, and leaves links alone. +func TestTrashNodesKeepsNodesRestorable(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + gone, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Gone\n")}) + require.NoError(t, err) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Linker\n\n[gone](../%d)\n", gone.ID.ID))}) + require.NoError(t, err) + + _, err = k.TrashNodes(ctx, []keg.NodeId{gone.ID}, keg.TrashReasonMerge) + require.ErrorIs(t, err, keg.ErrInvalid) + _, err = k.TrashNodes(ctx, []keg.NodeId{{ID: 0}}, keg.TrashReasonArchiveRestore) + require.ErrorIs(t, err, keg.ErrInvalid) + + result, err := k.TrashNodes(ctx, []keg.NodeId{gone.ID, {ID: 9999}}, keg.TrashReasonArchiveRestore) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{gone.ID}, result.Trashed) + exists, err := k.NodeExists(ctx, gone.ID) + require.NoError(t, err) + require.False(t, exists) + linking, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Contains(t, string(linking.Content), fmt.Sprintf("../%d)", gone.ID.ID)) + dex, err := k.Dex(ctx) + require.NoError(t, err) + require.Nil(t, dex.GetRef(ctx, gone.ID), "a trashed node leaves the dex") + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{gone.ID})) + exists, err = k.NodeExists(ctx, gone.ID) + require.NoError(t, err) + require.True(t, exists) +} diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index 8e838fe9..d4f261b5 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -129,6 +129,7 @@ func NewServer(tap *tapper.Tap, version string, defaults KegDefaults, opts ...Se registerWriteTools(srv, tap, defaults) registerIndexTools(srv, tap, defaults) registerSnapshotTools(srv, tap, defaults) + registerTrashTools(srv, tap, defaults) registerOrientTools(srv, tap, defaults) registerSchemaTools(srv, tap, defaults) registerFileTools(srv, tap, defaults, opt.SharedFilesystem) diff --git a/pkg/mcp/tools_trash.go b/pkg/mcp/tools_trash.go new file mode 100644 index 00000000..28e62fbf --- /dev/null +++ b/pkg/mcp/tools_trash.go @@ -0,0 +1,118 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" +) + +func registerTrashTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { + registerTrashList(srv, tap, defaults) + registerTrashRestore(srv, tap, defaults) +} + +// --- trash_list --- + +type trashListInput struct { + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` +} + +type trashEntryOutput struct { + NodeID string `json:"node_id"` + Title string `json:"title"` + Reason string `json:"reason"` + MergedInto string `json:"merged_into,omitempty"` + TrashedAt string `json:"trashed_at"` + ExpiresAt string `json:"expires_at,omitempty"` + // DisplacedFrom is the id the node was trashed at, when an archive + // restore needed it and the entry moved to node_id. + DisplacedFrom string `json:"displaced_from,omitempty"` +} + +func trashEntryOutputs(entries []keg.TrashEntry) []trashEntryOutput { + out := make([]trashEntryOutput, len(entries)) + for i, entry := range entries { + out[i] = trashEntryOutput{NodeID: entry.ID.Path(), Title: entry.Title, Reason: entry.Reason, TrashedAt: entry.TrashedAt.UTC().Format(time.RFC3339)} + if entry.MergedInto != nil { + out[i].MergedInto = entry.MergedInto.Path() + } + if entry.ExpiresAt != nil { + out[i].ExpiresAt = entry.ExpiresAt.UTC().Format(time.RFC3339) + } + if entry.DisplacedFrom != nil { + out[i].DisplacedFrom = entry.DisplacedFrom.Path() + } + } + return out +} + +func registerTrashList(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "trash_list", + Description: "Memory: List a keg's removed nodes, newest first: id, title, why it was removed (manual, or merge with the node it was merged into), when, and when it will be purged for good. " + + "Restore one with trash_restore. A trashed node's id stays reserved, and links to it are left in place until it is purged. " + + "An archive restore that needs a trashed node's id moves the entry to a new id first; displaced_from names the id it was trashed at, and restoring puts it back at its new id.", + Annotations: &sdkmcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: boolPtr(false), + }, + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in trashListInput) (*sdkmcp.CallToolResult, any, error) { + entries, err := tap.TrashList(ctx, resolveKegTarget(ctx, in.Keg, defaults)) + if err != nil { + return errorResult(err), nil, nil + } + if len(entries) == 0 { + res := textResult("trash is empty") + res.StructuredContent = map[string]any{"trash": []trashEntryOutput{}} + return res, nil, nil + } + structured := trashEntryOutputs(entries) + lines := make([]string, len(structured)) + for i, entry := range structured { + line := fmt.Sprintf("%s %s %s", entry.NodeID, entry.TrashedAt, entry.Reason) + if entry.MergedInto != "" { + line += " into " + entry.MergedInto + } + if entry.DisplacedFrom != "" { + line += " (moved from " + entry.DisplacedFrom + ")" + } + if entry.Title != "" { + line += " " + entry.Title + } + lines[i] = line + } + res := linesResult(lines) + res.StructuredContent = map[string]any{"trash": structured} + return res, nil, nil + }) +} + +// --- trash_restore --- + +type trashRestoreInput struct { + NodeIDs []string `json:"node_ids" jsonschema:"1-100 trashed node ids to restore atomically"` + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` +} + +func registerTrashRestore(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "trash_restore", + Description: "Memory: Atomically restore 1-100 trashed nodes (see trash_list) at their own ids, with their content, metadata, history, files, and images. " + + "Links that still name a restored node resolve again. A node merged into another comes back as it was before the merge; links the merge repointed stay on the node it was merged into.", + InputSchema: boundedMutationInputSchema[trashRestoreInput]("node_ids"), + Annotations: &sdkmcp.ToolAnnotations{ + DestructiveHint: boolPtr(false), + OpenWorldHint: boolPtr(false), + }, + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in trashRestoreInput) (*sdkmcp.CallToolResult, any, error) { + if err := tap.TrashRestore(ctx, tapper.TrashRestoreOptions{KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), NodeIDs: in.NodeIDs}); err != nil { + return errorResult(err), nil, nil + } + return textResult(fmt.Sprintf("restored %d node(s)", len(in.NodeIDs))), nil, nil + }) +} diff --git a/pkg/mcp/tools_write.go b/pkg/mcp/tools_write.go index 68a844bb..5c114a57 100644 --- a/pkg/mcp/tools_write.go +++ b/pkg/mcp/tools_write.go @@ -228,8 +228,9 @@ type removeNodeInput struct { func registerRemove(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "node_delete", - Description: "Memory: Call node_read first for every node, then atomically remove 1-100 nodes using the hash from node_read (the combined hash, not content_hash or meta_hash) as that node's expected_hash. On conflict, refetch with node_read and retry with the returned current hash.", + Name: "node_delete", + Description: "Memory: Call node_read first for every node, then atomically remove 1-100 nodes using the hash from node_read (the combined hash, not content_hash or meta_hash) as that node's expected_hash. On conflict, refetch with node_read and retry with the returned current hash. " + + "Where the keg keeps a trash, a removed node goes there with its history and assets and trash_restore brings it back; links to it are left alone until it is purged. Otherwise the node and its snapshots are gone and links to it point at node 0.", InputSchema: boundedMutationInputSchema[removeInput]("nodes"), Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), diff --git a/pkg/parity/parity_coverage_test.go b/pkg/parity/parity_coverage_test.go index f1ddee96..583e7a9d 100644 --- a/pkg/parity/parity_coverage_test.go +++ b/pkg/parity/parity_coverage_test.go @@ -127,6 +127,8 @@ var tapMethodsExcluded = map[string]string{ "NodeSnapshotBatch": "MCP batch backing operation; CLI snapshot create remains a one-node command", "CatViews": "structured accessor behind Cat; MCP node_read uses it to return per-node precondition hashes without re-reading", "NodeHash": "explicit CLI read-before-write helper; MCP reads return the same token in structured content", + "TrashList": "MCP trash_list only so far; no tap trash command yet", + "TrashRestore": "MCP trash_restore only so far; no tap trash command yet", "SchemaHash": "explicit CLI read-before-write helper; MCP schema_read returns the same token", "KegSettingsHash": "explicit CLI read-before-write helper; MCP keg_settings_read returns the same token", "ConfigEdit": "interactive editor; not exposed via MCP", diff --git a/pkg/tapper/tap_trash.go b/pkg/tapper/tap_trash.go new file mode 100644 index 00000000..94e0751c --- /dev/null +++ b/pkg/tapper/tap_trash.go @@ -0,0 +1,39 @@ +package tapper + +import ( + "context" + "fmt" + + "github.com/jlrickert/tapper/pkg/keg" +) + +// TrashList returns the keg's removed nodes, newest first. +func (t *Tap) TrashList(ctx context.Context, opts KegTargetOptions) ([]keg.TrashEntry, error) { + k, err := t.resolveKegForRole(ctx, opts, FlightRoleViewer) + if err != nil { + return nil, err + } + return k.ListTrash(ctx) +} + +type TrashRestoreOptions struct { + KegTargetOptions + NodeIDs []string +} + +// TrashRestore puts trashed nodes back at their ids, all or nothing. +func (t *Tap) TrashRestore(ctx context.Context, opts TrashRestoreOptions) error { + k, err := t.resolveKegForRole(ctx, opts.KegTargetOptions, FlightRoleEditor) + if err != nil { + return err + } + ids := make([]keg.NodeId, len(opts.NodeIDs)) + for i, raw := range opts.NodeIDs { + id, err := keg.ParseNode(raw) + if err != nil { + return fmt.Errorf("restore %d node %q: %w", i, raw, err) + } + ids[i] = *id + } + return k.RestoreTrashed(ctx, ids) +} diff --git a/pkg/tapper/tool_categories.go b/pkg/tapper/tool_categories.go index b8e59b38..4cc546ee 100644 --- a/pkg/tapper/tool_categories.go +++ b/pkg/tapper/tool_categories.go @@ -48,7 +48,7 @@ var toolCategoryOf = map[string]ToolCategory{ "file_delete": ToolCategoryMemory, "image_list": ToolCategoryMemory, "image_download": ToolCategoryMemory, "image_upload": ToolCategoryMemory, "image_delete": ToolCategoryMemory, "snapshot_list": ToolCategoryMemory, "snapshot_read": ToolCategoryMemory, "snapshot_create": ToolCategoryMemory, "snapshot_restore": ToolCategoryMemory, - "lock_acquire": ToolCategoryMemory, + "trash_list": ToolCategoryMemory, "trash_restore": ToolCategoryMemory, "lock_acquire": ToolCategoryMemory, "lock_release": ToolCategoryMemory, "lock_status": ToolCategoryMemory, "flight_list": ToolCategoryMemory, "flight_read": ToolCategoryMemory, From e6c960020614f7e68a6daaa5c314f2c743a5bb5a Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Mon, 12 Oct 2026 19:30:35 -0500 Subject: [PATCH 6/8] feat(mcp): merge nodes and replace text across a keg node_merge folds a duplicate into a target in one write: the target takes the merged body (content or patch), links to the source are pointed at the target, and the source goes to the trash as merged, so it restores as it was. keg_replace finds and replaces text across a keg in one atomic write, literal or regex, narrowed by a query and optionally including metadata. dry_run reports every node that would change. A node the write would refuse is reported with its error and left alone while the rest are written. Both are MCP-only for now. --- integrations/content/tool-inventory.md | 2 + .../claude/tapper/skills/tapper/SKILL.md | 2 + .../codex/tapper/skills/tapper/SKILL.md | 2 + .../opencode/tapper/skills/tapper/SKILL.md | 2 + pkg/keg/keg_iface.go | 9 + pkg/keg/keg_remote_aggregate.go | 72 ++++ pkg/keg/merge.go | 83 +++++ pkg/keg/replace.go | 254 +++++++++++++ pkg/keg/trash_test.go | 333 ++++++++++++++++++ pkg/mcp/tools_merge.go | 72 ++++ pkg/mcp/tools_replace.go | 82 +++++ pkg/mcp/tools_write.go | 2 + pkg/parity/parity_coverage_test.go | 2 + pkg/tapper/tap_batch.go | 30 ++ pkg/tapper/tap_replace.go | 27 ++ pkg/tapper/tool_categories.go | 4 +- 16 files changed, 976 insertions(+), 2 deletions(-) create mode 100644 pkg/keg/merge.go create mode 100644 pkg/keg/replace.go create mode 100644 pkg/mcp/tools_merge.go create mode 100644 pkg/mcp/tools_replace.go create mode 100644 pkg/tapper/tap_replace.go diff --git a/integrations/content/tool-inventory.md b/integrations/content/tool-inventory.md index 826ceee2..da817488 100644 --- a/integrations/content/tool-inventory.md +++ b/integrations/content/tool-inventory.md @@ -128,6 +128,8 @@ code; the index does the work in O(matches) rather than O(total). | `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | | `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_merge` | Deduplicate: `node_read` both, then write the merged body into the target (`content` or `patch`, optional `meta`). Links to the source are repointed at the target and the source goes to the trash as merged. Guard the source with its combined `hash`, the target with `content_hash` (and `meta_hash` with meta). | +| `mcp__tapper__keg_replace` | Manage. Find and replace across a keg in one atomic write (literal or `regex`, narrowed by `query`, optionally in `include_meta`). Run it with `dry_run` first to see every node that would change. A node the write would refuse (schemas, metadata that must parse, a body opening with frontmatter) is reported with its error, without its bodies, and left alone while the rest are written; `dry_run` reports the same errors. At most 500 matching nodes per call, refused ones included. | | `mcp__tapper__trash_list`, `mcp__tapper__trash_restore` | Where the keg keeps a trash, list removed nodes and why they went, and restore 1–100 of them at their ids with their history and assets. An entry an archive restore moved to make way shows `displaced_from` (the id it was trashed at) and restores at its new id. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | diff --git a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md index 20dff3e9..179715ab 100644 --- a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md @@ -173,6 +173,8 @@ code; the index does the work in O(matches) rather than O(total). | `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | | `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_merge` | Deduplicate: `node_read` both, then write the merged body into the target (`content` or `patch`, optional `meta`). Links to the source are repointed at the target and the source goes to the trash as merged. Guard the source with its combined `hash`, the target with `content_hash` (and `meta_hash` with meta). | +| `mcp__tapper__keg_replace` | Manage. Find and replace across a keg in one atomic write (literal or `regex`, narrowed by `query`, optionally in `include_meta`). Run it with `dry_run` first to see every node that would change. A node the write would refuse (schemas, metadata that must parse, a body opening with frontmatter) is reported with its error, without its bodies, and left alone while the rest are written; `dry_run` reports the same errors. At most 500 matching nodes per call, refused ones included. | | `mcp__tapper__trash_list`, `mcp__tapper__trash_restore` | Where the keg keeps a trash, list removed nodes and why they went, and restore 1–100 of them at their ids with their history and assets. An entry an archive restore moved to make way shows `displaced_from` (the id it was trashed at) and restores at its new id. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | diff --git a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md index 20dff3e9..179715ab 100644 --- a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md @@ -173,6 +173,8 @@ code; the index does the work in O(matches) rather than O(total). | `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | | `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_merge` | Deduplicate: `node_read` both, then write the merged body into the target (`content` or `patch`, optional `meta`). Links to the source are repointed at the target and the source goes to the trash as merged. Guard the source with its combined `hash`, the target with `content_hash` (and `meta_hash` with meta). | +| `mcp__tapper__keg_replace` | Manage. Find and replace across a keg in one atomic write (literal or `regex`, narrowed by `query`, optionally in `include_meta`). Run it with `dry_run` first to see every node that would change. A node the write would refuse (schemas, metadata that must parse, a body opening with frontmatter) is reported with its error, without its bodies, and left alone while the rest are written; `dry_run` reports the same errors. At most 500 matching nodes per call, refused ones included. | | `mcp__tapper__trash_list`, `mcp__tapper__trash_restore` | Where the keg keeps a trash, list removed nodes and why they went, and restore 1–100 of them at their ids with their history and assets. An entry an archive restore moved to make way shows `displaced_from` (the id it was trashed at) and restores at its new id. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | diff --git a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md index 20dff3e9..179715ab 100644 --- a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md @@ -173,6 +173,8 @@ code; the index does the work in O(matches) rather than O(total). | `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | | `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_merge` | Deduplicate: `node_read` both, then write the merged body into the target (`content` or `patch`, optional `meta`). Links to the source are repointed at the target and the source goes to the trash as merged. Guard the source with its combined `hash`, the target with `content_hash` (and `meta_hash` with meta). | +| `mcp__tapper__keg_replace` | Manage. Find and replace across a keg in one atomic write (literal or `regex`, narrowed by `query`, optionally in `include_meta`). Run it with `dry_run` first to see every node that would change. A node the write would refuse (schemas, metadata that must parse, a body opening with frontmatter) is reported with its error, without its bodies, and left alone while the rest are written; `dry_run` reports the same errors. At most 500 matching nodes per call, refused ones included. | | `mcp__tapper__trash_list`, `mcp__tapper__trash_restore` | Where the keg keeps a trash, list removed nodes and why they went, and restore 1–100 of them at their ids with their history and assets. An entry an archive restore moved to make way shows `displaced_from` (the id it was trashed at) and restores at its new id. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | diff --git a/pkg/keg/keg_iface.go b/pkg/keg/keg_iface.go index 3ab9db88..0dfb05ce 100644 --- a/pkg/keg/keg_iface.go +++ b/pkg/keg/keg_iface.go @@ -110,6 +110,15 @@ type Keg interface { // the ids of nodes whose content was rewritten. Remove(ctx context.Context, opts NodeRemoveOptions) ([]NodeId, error) + // MergeNodes folds a source node into a target as one write: the target + // takes the merged body, links to the source are pointed at the target, + // and the source is removed (to the trash where the keg keeps one). + MergeNodes(ctx context.Context, opts MergeNodesOptions) (MergeNodesResult, error) + + // ReplaceText finds and replaces text across the keg as one write, or + // with DryRun only reports what would change. + ReplaceText(ctx context.Context, opts ReplaceTextOptions) (ReplaceTextResult, error) + // ListTrash returns removed nodes kept for restoring, newest first. // ErrNotSupported when the keg keeps no trash. ListTrash(ctx context.Context) ([]TrashEntry, error) diff --git a/pkg/keg/keg_remote_aggregate.go b/pkg/keg/keg_remote_aggregate.go index cf8445ca..2be8a6eb 100644 --- a/pkg/keg/keg_remote_aggregate.go +++ b/pkg/keg/keg_remote_aggregate.go @@ -428,3 +428,75 @@ func remoteNodeNumbers(ids []NodeId) []int { } return out } + +func (k *RemoteKeg) MergeNodes(ctx context.Context, opts MergeNodesOptions) (MergeNodesResult, error) { + type wireTarget struct { + NodeID int `json:"node_id"` + Schema string `json:"schema,omitempty"` + Content *string `json:"content,omitempty"` + ContentPatch *string `json:"content_patch,omitempty"` + Meta *string `json:"meta,omitempty"` + ExpectedHash string `json:"expected_hash,omitempty"` + ExpectedContentHash string `json:"expected_content_hash,omitempty"` + ExpectedMetaHash string `json:"expected_meta_hash,omitempty"` + } + target := wireTarget{ + NodeID: opts.Target.ID.ID, + Schema: opts.Target.Schema, + ExpectedHash: opts.Target.ExpectedHash, + ExpectedContentHash: opts.Target.ExpectedContentHash, + ExpectedMetaHash: opts.Target.ExpectedMetaHash, + } + if opts.Target.HasContent { + v := string(opts.Target.Content) + target.Content = &v + } + if opts.Target.HasContentPatch { + v := opts.Target.ContentPatch + target.ContentPatch = &v + } + if opts.Target.HasMeta { + v := string(opts.Target.Meta) + target.Meta = &v + } + var wire struct { + Target struct { + ID int `json:"id"` + Hash string `json:"hash"` + ContentHash string `json:"content_hash,omitempty"` + MetaHash string `json:"meta_hash,omitempty"` + Validation *SchemaValidationResult `json:"validation,omitempty"` + } `json:"target"` + Rewritten []int `json:"rewritten"` + } + if err := k.postJSON(ctx, "/nodes/merge", "MergeNodes", struct { + Source int `json:"source"` + ExpectedSourceHash string `json:"expected_source_hash"` + Target wireTarget `json:"target"` + }{opts.Source.ID, opts.ExpectedSourceHash, target}, &wire, http.StatusOK); err != nil { + return MergeNodesResult{}, err + } + out := MergeNodesResult{Target: NodeUpdateResult{ID: NodeId{ID: wire.Target.ID}, Hash: wire.Target.Hash, ContentHash: wire.Target.ContentHash, MetaHash: wire.Target.MetaHash, Validation: wire.Target.Validation}} + for _, id := range wire.Rewritten { + out.Rewritten = append(out.Rewritten, NodeId{ID: id}) + } + return out, nil +} + +func (k *RemoteKeg) ReplaceText(ctx context.Context, opts ReplaceTextOptions) (ReplaceTextResult, error) { + var wire struct { + Nodes []struct { + ReplacedNode + ID int `json:"id"` + } `json:"nodes"` + } + if err := k.postJSON(ctx, "/nodes/replace", "ReplaceText", opts, &wire, http.StatusOK); err != nil { + return ReplaceTextResult{}, err + } + out := ReplaceTextResult{Nodes: make([]ReplacedNode, len(wire.Nodes))} + for i, item := range wire.Nodes { + out.Nodes[i] = item.ReplacedNode + out.Nodes[i].ID = NodeId{ID: item.ID} + } + return out, nil +} diff --git a/pkg/keg/merge.go b/pkg/keg/merge.go new file mode 100644 index 00000000..5eb656a6 --- /dev/null +++ b/pkg/keg/merge.go @@ -0,0 +1,83 @@ +package keg + +import ( + "context" + "fmt" +) + +// MergeNodesOptions folds Source into Target: Target is rewritten with the +// merged body, every link to Source is pointed at Target, and Source is +// removed (to the trash, where the keg keeps one). +type MergeNodesOptions struct { + // Source is the node merged away, guarded by its combined hash. + Source NodeId `json:"source"` + ExpectedSourceHash string `json:"expected_source_hash"` + // Target is the surviving node's update. A merge is a rewrite, so it + // must carry content or a content patch; meta is optional, for combining + // tags and fields. Its part tokens guard it as for any update. + Target NodeUpdateOptions `json:"target"` +} + +// MergeNodesResult is the surviving node's tokens after the merge (links in +// it may have been repointed too), and the other nodes whose links moved. +type MergeNodesResult struct { + Target NodeUpdateResult `json:"target"` + Rewritten []NodeId `json:"rewritten"` +} + +// MergeNodes folds opts.Source into opts.Target as one write: either all of +// it happens or none of it does. +func (k *LocalKeg) MergeNodes(ctx context.Context, opts MergeNodesOptions) (MergeNodesResult, error) { + return withKegAtomicWriteValue(ctx, k, func(ctx context.Context) (MergeNodesResult, error) { return k.mergeNodes(ctx, opts) }) +} + +func (k *LocalKeg) mergeNodes(ctx context.Context, opts MergeNodesOptions) (MergeNodesResult, error) { + source, target := opts.Source, opts.Target.ID + if !source.Valid() || !target.Valid() { + return MergeNodesResult{}, fmt.Errorf("merge needs a source and a target node: %w", ErrInvalid) + } + if source.ID == 0 || target.ID == 0 { + return MergeNodesResult{}, fmt.Errorf("node 0 cannot be merged: %w", ErrInvalid) + } + if source.Equals(target) { + return MergeNodesResult{}, fmt.Errorf("a node cannot be merged into itself: %w", ErrInvalid) + } + if !opts.Target.HasContent && !opts.Target.HasContentPatch { + return MergeNodesResult{}, fmt.Errorf("a merge rewrites the target: send its merged content or a content patch: %w", ErrInvalid) + } + current, err := k.ReadNode(ctx, source) + if err != nil { + return MergeNodesResult{}, fmt.Errorf("merge source %s: %w", source.Path(), err) + } + if err := checkExpectedHash("node "+source.Path(), opts.ExpectedSourceHash, current.Hash(), nodeRecoveryContent(current)); err != nil { + return MergeNodesResult{}, err + } + results, err := k.updateNodes(ctx, []NodeUpdateOptions{opts.Target}) + if err != nil { + return MergeNodesResult{}, err + } + // The source's own links to itself stay as they are, so a restored + // source comes back exactly as it was trashed. + rewritten, err := k.retargetLinks(ctx, source, target, source) + if err != nil { + return MergeNodesResult{}, err + } + if _, err := k.removeUnchecked(ctx, source, trashInfo{reason: TrashReasonMerge, mergedInto: &target}); err != nil { + return MergeNodesResult{}, err + } + // The target's own links to the source were repointed too, so read its + // tokens back rather than trusting the update's. + merged, err := k.ReadNode(ctx, target) + if err != nil { + return MergeNodesResult{}, err + } + result := MergeNodesResult{ + Target: NodeUpdateResult{ID: target, Validation: results[0].Validation, Hash: merged.Hash(), ContentHash: merged.ContentHash(), MetaHash: merged.MetaHash()}, + } + for _, id := range rewritten { + if !id.Equals(target) && !id.Equals(source) { + result.Rewritten = append(result.Rewritten, id) + } + } + return result, nil +} diff --git a/pkg/keg/replace.go b/pkg/keg/replace.go new file mode 100644 index 00000000..b3fea048 --- /dev/null +++ b/pkg/keg/replace.go @@ -0,0 +1,254 @@ +package keg + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" +) + +// MaxReplaceNodes caps how many nodes one ReplaceText may change, so a +// careless pattern cannot rewrite a whole large keg in one call. +const MaxReplaceNodes = 500 + +// A short pattern and a long replacement multiply: a pattern that matches at +// every position, or at every word boundary, turns a small node and a small +// request into gigabytes. Each limit is checked before the replacement text +// is built, so an oversized replace allocates nothing for it. +const ( + // MaxReplaceNodeBytes caps the text a replace builds for one node: its + // body and metadata, where each matched. + MaxReplaceNodeBytes = 8 << 20 + // MaxReplaceTotalBytes caps every changed node's text after a replace, + // together. + MaxReplaceTotalBytes = 32 << 20 + // MaxReplaceNodeMatches caps the matches in one node's body or metadata. + MaxReplaceNodeMatches = 100_000 +) + +var errReplaceTooLarge = fmt.Errorf("the replacement would grow the notes past %d MiB in all; narrow it with a query or a more specific find: %w", MaxReplaceTotalBytes>>20, ErrInvalid) + +// ReplaceTextOptions is a keg-wide find and replace. +type ReplaceTextOptions struct { + // Find is literal text, or with Regex a Go regular expression. + Find string `json:"find"` + // Replace is the replacement; with Regex, $1 and ${name} expand groups. + Replace string `json:"replace"` + Regex bool `json:"regex,omitempty"` + // Query narrows the nodes considered, in node_list's query syntax. Empty + // considers every node. + Query string `json:"query,omitempty"` + // IncludeMeta replaces in each node's metadata YAML as well as its body. + IncludeMeta bool `json:"include_meta,omitempty"` + // DryRun reports what would change and writes nothing. + DryRun bool `json:"dry_run,omitempty"` +} + +// ReplacedNode is one node a replace changed, or would change. +type ReplacedNode struct { + ID NodeId `json:"id"` + Matches int `json:"matches"` + // ContentBefore/After and MetaBefore/After are set for the parts that + // change, on nodes that would be written. + ContentBefore string `json:"content_before,omitempty"` + ContentAfter string `json:"content_after,omitempty"` + MetaBefore string `json:"meta_before,omitempty"` + MetaAfter string `json:"meta_after,omitempty"` + // Error says why a node that matched is left alone: the write would be + // refused by any rule a node edit is held to (unparseable metadata, a + // body opening with frontmatter, the keg's schemas). A dry run reports + // the same errors a real run would. + Error string `json:"error,omitempty"` + // The written node's tokens; empty on a dry run or a skipped node. + Hash string `json:"hash,omitempty"` + ContentHash string `json:"content_hash,omitempty"` + MetaHash string `json:"meta_hash,omitempty"` +} + +type ReplaceTextResult struct { + Nodes []ReplacedNode `json:"nodes"` +} + +// ReplaceText replaces text across the keg as one write: every node it +// changes is written, or none is. A node whose replacement would be refused +// is reported with an Error (and no bodies) and left as it was; the rest are +// written. MaxReplaceNodes counts every changed node, written or refused. +func (k *LocalKeg) ReplaceText(ctx context.Context, opts ReplaceTextOptions) (ReplaceTextResult, error) { + if opts.DryRun { + return withKegReadValue(ctx, k, func(ctx context.Context) (ReplaceTextResult, error) { return k.replaceText(ctx, opts) }) + } + return withKegAtomicWriteValue(ctx, k, func(ctx context.Context) (ReplaceTextResult, error) { return k.replaceText(ctx, opts) }) +} + +func (k *LocalKeg) replaceText(ctx context.Context, opts ReplaceTextOptions) (ReplaceTextResult, error) { + if opts.Find == "" { + return ReplaceTextResult{}, fmt.Errorf("find is required: %w", ErrInvalid) + } + // measure counts the matches in text and how long the text would be + // after the replace, without building it. ok is false past + // MaxReplaceNodeMatches. + measure := func(text string) (n, size int, ok bool) { + n = strings.Count(text, opts.Find) + return n, len(text) + n*(len(opts.Replace)-len(opts.Find)), n <= MaxReplaceNodeMatches + } + replace := func(text string) string { return strings.ReplaceAll(text, opts.Find, opts.Replace) } + if opts.Regex { + re, err := regexp.Compile(opts.Find) + if err != nil { + return ReplaceTextResult{}, fmt.Errorf("find is not a valid regular expression: %v: %w", err, ErrInvalid) + } + measure = func(text string) (n, size int, ok bool) { + matches := re.FindAllStringSubmatchIndex(text, MaxReplaceNodeMatches+1) + if len(matches) > MaxReplaceNodeMatches { + return len(matches), 0, false + } + size = len(text) + var expanded []byte + for _, m := range matches { + expanded = re.ExpandString(expanded[:0], opts.Replace, text, m) + // Past the node cap the exact size no longer matters. + if size += len(expanded) - (m[1] - m[0]); size > MaxReplaceNodeBytes { + break + } + } + return len(matches), size, true + } + replace = func(text string) string { return re.ReplaceAllString(text, opts.Replace) } + } + total := 0 + // replacePart replaces in one part of a node. The error is the node's, + // for a part too large to replace; the whole replace fails only when the + // total grows past MaxReplaceTotalBytes. + replacePart := func(text string, nodeSize *int) (string, int, string, error) { + n, size, ok := measure(text) + if n == 0 { + return text, 0, "", nil + } + if !ok { + return text, n, fmt.Sprintf("this note has more than %d matches; narrow the find", MaxReplaceNodeMatches), nil + } + if *nodeSize += size; *nodeSize > MaxReplaceNodeBytes { + return text, n, fmt.Sprintf("the replacement would grow this note past %d MiB", MaxReplaceNodeBytes>>20), nil + } + if total += size; total > MaxReplaceTotalBytes { + return text, n, "", errReplaceTooLarge + } + return replace(text), n, "", nil + } + + var ids []NodeId + if q := strings.TrimSpace(opts.Query); q != "" { + entries, err := k.query(ctx, QueryOptions{Expr: q}) + if err != nil { + return ReplaceTextResult{}, err + } + for _, entry := range entries { + if id, err := ParseNode(entry.ID); err == nil && id != nil { + ids = append(ids, *id) + } + } + } else { + listed, err := k.Repo.ListNodes(ctx) + if err != nil { + return ReplaceTextResult{}, err + } + ids = listed + } + + result := ReplaceTextResult{Nodes: []ReplacedNode{}} + var updates []NodeUpdateOptions + var written []int + for _, id := range ids { + view, err := k.ReadNode(ctx, id) + if err != nil { + continue + } + node := ReplacedNode{ID: id} + nodeSize := 0 + content, n, tooLarge, err := replacePart(string(view.Content), &nodeSize) + if err != nil { + return ReplaceTextResult{}, err + } + node.Matches += n + contentChanged := content != string(view.Content) + if contentChanged { + node.ContentBefore, node.ContentAfter = string(view.Content), content + } + metaChanged := false + if opts.IncludeMeta && tooLarge == "" { + meta, n, metaTooLarge, err := replacePart(string(view.Meta), &nodeSize) + if err != nil { + return ReplaceTextResult{}, err + } + node.Matches += n + tooLarge = metaTooLarge + if metaChanged = meta != string(view.Meta); metaChanged { + node.MetaBefore, node.MetaAfter = string(view.Meta), meta + } + } + if tooLarge != "" { + // Left alone like any refused node, and counted against the cap. + if len(result.Nodes) == MaxReplaceNodes { + return ReplaceTextResult{}, fmt.Errorf("this matches more than %d nodes; narrow it with a query: %w", MaxReplaceNodes, ErrInvalid) + } + result.Nodes = append(result.Nodes, ReplacedNode{ID: id, Matches: node.Matches, Error: tooLarge}) + continue + } + if !contentChanged && !metaChanged { + continue + } + update := NodeUpdateOptions{ID: id, ExpectedHash: view.Hash()} + if contentChanged { + update.Content, update.HasContent = []byte(node.ContentAfter), true + } + if metaChanged { + update.Meta, update.HasMeta = []byte(node.MetaAfter), true + } + // Hold each node to every rule a write would, dry run or not, so a + // node that would be refused is reported and left alone instead of + // failing the whole replace. + if len(result.Nodes) == MaxReplaceNodes { + return ReplaceTextResult{}, fmt.Errorf("this matches more than %d nodes; narrow it with a query: %w", MaxReplaceNodes, ErrInvalid) + } + if _, err := k.prepareNodeUpdate(ctx, update); err != nil { + // A refused node reports why, not its bodies: it is left alone. + result.Nodes = append(result.Nodes, ReplacedNode{ID: id, Matches: node.Matches, Error: replaceNodeError(err)}) + continue + } + result.Nodes = append(result.Nodes, node) + updates = append(updates, update) + written = append(written, len(result.Nodes)-1) + } + if opts.DryRun || len(updates) == 0 { + return result, nil + } + for start := 0; start < len(updates); start += MaxMutationBatchSize { + end := min(start+MaxMutationBatchSize, len(updates)) + results, err := k.updateNodes(ctx, updates[start:end]) + if err != nil { + // Report the failing node by its place in the result, not in + // the chunk. + var batchErr *BatchMutationError + if errors.As(err, &batchErr) && batchErr.Index >= 0 && start+batchErr.Index < len(written) { + batchErr.Index = written[start+batchErr.Index] + } + return ReplaceTextResult{}, err + } + for i, res := range results { + node := &result.Nodes[written[start+i]] + node.Hash, node.ContentHash, node.MetaHash = res.Hash, res.ContentHash, res.MetaHash + } + } + return result, nil +} + +// replaceNodeError is why a node a replace leaves alone would be refused, +// worded for the node's Error. +func replaceNodeError(err error) string { + var batchErr *BatchMutationError + if errors.As(err, &batchErr) && batchErr.Err != nil { + err = batchErr.Err + } + return strings.TrimSuffix(err.Error(), ": "+ErrInvalid.Error()) +} diff --git a/pkg/keg/trash_test.go b/pkg/keg/trash_test.go index ae8bd720..56d10f9d 100644 --- a/pkg/keg/trash_test.go +++ b/pkg/keg/trash_test.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "fmt" "io" + "strings" "sync" "testing" "time" @@ -232,6 +233,141 @@ func TestRemoveWithoutTrashRewritesLinks(t *testing.T) { require.ErrorIs(t, err, keg.ErrNotSupported) } +func TestMergeNodesFoldsADuplicateIntoTheTarget(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + repo := &trashingRepo{MemoryRepository: newTestMemoryRepo(fx.Runtime()), trash: map[keg.NodeId]trashedNode{}} + k := keg.NewLocalKeg(repo, fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + + target, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Topic\n\nFirst take.\n")}) + require.NoError(t, err) + dup, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Topic again\n\nSecond take.\n")}) + require.NoError(t, err) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Linker\n\n[one](../%d) and [two](../%d)\n", target.ID.ID, dup.ID.ID))}) + require.NoError(t, err) + targetView, err := k.ReadNode(ctx, target.ID) + require.NoError(t, err) + dupView, err := k.ReadNode(ctx, dup.ID) + require.NoError(t, err) + + merged := fmt.Sprintf("# Topic\n\nFirst take.\n\nSecond take, once [a duplicate](../%d).\n", dup.ID.ID) + _, err = k.MergeNodes(ctx, keg.MergeNodesOptions{ + Source: dup.ID, ExpectedSourceHash: "stale", + Target: keg.NodeUpdateOptions{ID: target.ID, Content: []byte(merged), HasContent: true, ExpectedContentHash: targetView.ContentHash()}, + }) + var conflict *keg.PreconditionConflictError + require.ErrorAs(t, err, &conflict, "a stale source hash changes nothing") + unchanged, err := k.ReadNode(ctx, target.ID) + require.NoError(t, err) + require.Equal(t, targetView.Content, unchanged.Content) + + result, err := k.MergeNodes(ctx, keg.MergeNodesOptions{ + Source: dup.ID, ExpectedSourceHash: dupView.Hash(), + Target: keg.NodeUpdateOptions{ID: target.ID, Content: []byte(merged), HasContent: true, ExpectedContentHash: targetView.ContentHash()}, + }) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{linker.ID}, result.Rewritten) + + after, err := k.ReadNode(ctx, target.ID) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("# Topic\n\nFirst take.\n\nSecond take, once [a duplicate](../%d).\n", target.ID.ID), string(after.Content), "the target's own link to the source points at itself") + require.Equal(t, after.ContentHash(), result.Target.ContentHash) + linking, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("# Linker\n\n[one](../%d) and [two](../%d)\n", target.ID.ID, target.ID.ID), string(linking.Content)) + + exists, err := k.NodeExists(ctx, dup.ID) + require.NoError(t, err) + require.False(t, exists) + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) + require.Equal(t, keg.TrashReasonMerge, trash[0].Reason) + require.NotNil(t, trash[0].MergedInto) + require.Equal(t, target.ID, *trash[0].MergedInto) + + // Restoring the source brings it back; the links stay with the target. + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{dup.ID})) + restored, err := k.ReadNode(ctx, dup.ID) + require.NoError(t, err) + require.Contains(t, string(restored.Content), "Second take.") + linking, err = k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.NotContains(t, string(linking.Content), fmt.Sprintf("../%d)", dup.ID.ID)) +} + +func TestMergeNodesRefusesWhatIsNotAMerge(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + a, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# A\n")}) + require.NoError(t, err) + view, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + for name, opts := range map[string]keg.MergeNodesOptions{ + "into itself": {Source: a.ID, ExpectedSourceHash: view.Hash(), Target: keg.NodeUpdateOptions{ID: a.ID, Content: []byte("# A\n"), HasContent: true, ExpectedContentHash: view.ContentHash()}}, + "node 0": {Source: keg.NodeId{ID: 0}, Target: keg.NodeUpdateOptions{ID: a.ID, Content: []byte("# A\n"), HasContent: true}}, + "no body": {Source: keg.NodeId{ID: 0}, Target: keg.NodeUpdateOptions{ID: a.ID, Meta: []byte("tags: [x]\n"), HasMeta: true}}, + } { + _, err := k.MergeNodes(ctx, opts) + require.ErrorIs(t, err, keg.ErrInvalid, name) + } +} + +func TestReplaceTextAcrossTheKeg(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + a, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Alpha\n\nUses Widget v1 and Widget v1.\n"), Meta: []byte("tags: [widget]\n")}) + require.NoError(t, err) + b, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Beta\n\nWidget v1 here too.\n"), Meta: []byte("tags: [other]\n")}) + require.NoError(t, err) + c, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Gamma\n\nNothing to see.\n")}) + require.NoError(t, err) + read := func(id keg.NodeId) *keg.NodeView { + view, err := k.ReadNode(ctx, id) + require.NoError(t, err) + return view + } + before := read(c.ID).Hash() + + dry, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "Widget v1", Replace: "Widget v2", DryRun: true}) + require.NoError(t, err) + require.Len(t, dry.Nodes, 2) + require.Equal(t, 3, dry.Nodes[0].Matches+dry.Nodes[1].Matches, "three matches over two nodes") + require.Contains(t, string(read(a.ID).Content), "Widget v1", "a dry run writes nothing") + + done, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: `Widget v(\d)`, Replace: "Gadget v$1", Regex: true, Query: "widget"}) + require.NoError(t, err) + require.Len(t, done.Nodes, 1, "the query keeps it to the tagged node") + require.Equal(t, "# Alpha\n\nUses Gadget v1 and Gadget v1.\n", string(read(a.ID).Content)) + require.Equal(t, read(a.ID).ContentHash(), done.Nodes[0].ContentHash) + require.Contains(t, string(read(b.ID).Content), "Widget v1") + require.Equal(t, before, read(c.ID).Hash()) + + broken, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "tags: [other]", Replace: "tags: [other", IncludeMeta: true}) + require.NoError(t, err) + require.Len(t, broken.Nodes, 1) + require.NotEmpty(t, broken.Nodes[0].Error, "metadata that would not parse is reported") + require.Contains(t, string(read(b.ID).Meta), "other]", "and left alone") + + meta, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "widget", Replace: "gadget", IncludeMeta: true}) + require.NoError(t, err) + require.Len(t, meta.Nodes, 1) + require.Contains(t, string(read(a.ID).Meta), "gadget") + + _, err = k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "(", Regex: true}) + require.ErrorIs(t, err, keg.ErrInvalid) + _, err = k.ReplaceText(ctx, keg.ReplaceTextOptions{}) + require.ErrorIs(t, err, keg.ErrInvalid) +} + func newTrashingKeg(t *testing.T) (*keg.LocalKeg, *trashingRepo, context.Context) { t.Helper() fx := NewSandbox(t) @@ -392,12 +528,115 @@ func TestRestoreTrashedRefusesDuplicateIDs(t *testing.T) { require.Len(t, trash, 1) } +func TestMergedSourceRestoresAsItWas(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + target, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Target\n")}) + require.NoError(t, err) + source, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Source\n")}) + require.NoError(t, err) + selfLinking := fmt.Sprintf("# Source\n\nSee [myself](../%d).\n", source.ID.ID) + require.NoError(t, k.Repo.WriteContent(ctx, source.ID, []byte(selfLinking))) + third, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Third\n\n[src](../%d)\n", source.ID.ID))}) + require.NoError(t, err) + sourceView, err := k.ReadNode(ctx, source.ID) + require.NoError(t, err) + targetView, err := k.ReadNode(ctx, target.ID) + require.NoError(t, err) + + _, err = k.MergeNodes(ctx, keg.MergeNodesOptions{ + Source: source.ID, ExpectedSourceHash: sourceView.Hash(), + Target: keg.NodeUpdateOptions{ID: target.ID, Content: []byte("# Target\n\nMerged.\n"), HasContent: true, ExpectedContentHash: targetView.ContentHash()}, + }) + require.NoError(t, err) + thirdView, err := k.ReadNode(ctx, third.ID) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("# Third\n\n[src](../%d)\n", target.ID.ID), string(thirdView.Content)) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{source.ID})) + restored, err := k.ReadNode(ctx, source.ID) + require.NoError(t, err) + require.Equal(t, selfLinking, string(restored.Content)) +} + func TestRetargetLinks(t *testing.T) { t.Parallel() got := keg.RetargetLinks([]byte("[a](../3) [b](../30) [c](../3#x)\n"), keg.NodeId{ID: 3}, keg.NodeId{ID: 7}) require.Equal(t, "[a](../7) [b](../30) [c](../7#x)\n", string(got)) } +func TestReplaceTextLeavesRefusedNodesAloneAndDryRunPredictsThem(t *testing.T) { + t.Parallel() + k, ctx := newStrictBatchKeg(t) + created, err := k.CreateNodes(ctx, []keg.NodeCreate{ + {Key: "typed", Schema: "task", Body: []byte("# Typed\n\nfoo here\n"), Meta: []byte("type: task\n")}, + {Key: "untyped", Schema: "task", Body: []byte("# Untyped\n\nfoo there\n"), Meta: []byte("type: task\n")}, + {Key: "breaks", Schema: "task", Body: []byte("# Breaks\n\nfoo again\n"), Meta: []byte("type: task\nnote: foo\n")}, + }) + require.NoError(t, err) + typed, untyped, breaks := created[0].ID, created[1].ID, created[2].ID + // An untyped node, as one written before the keg turned strict is. + require.NoError(t, k.Repo.WriteMeta(ctx, untyped, []byte("tags: [foo]\n"))) + // Metadata that breaks the schema once "foo" in it is replaced. + require.NoError(t, k.Repo.WriteMeta(ctx, breaks, []byte("type: foo\n"))) + before := map[keg.NodeId]*keg.NodeView{} + for _, id := range []keg.NodeId{typed, untyped, breaks} { + view, err := k.ReadNode(ctx, id) + require.NoError(t, err) + before[id] = view + } + opts := keg.ReplaceTextOptions{Find: "foo", Replace: "bar", IncludeMeta: true, DryRun: true} + errorsOf := func(r keg.ReplaceTextResult) map[keg.NodeId]string { + out := map[keg.NodeId]string{} + for _, n := range r.Nodes { + out[n.ID] = n.Error + } + return out + } + + dry, err := k.ReplaceText(ctx, opts) + require.NoError(t, err) + opts.DryRun = false + done, err := k.ReplaceText(ctx, opts) + require.NoError(t, err, "a node that would be refused does not fail the replace") + require.Equal(t, errorsOf(dry), errorsOf(done), "the dry run predicts the same refusals") + + for _, n := range done.Nodes { + after, err := k.ReadNode(ctx, n.ID) + require.NoError(t, err) + if n.Error == "" { + require.NotEmpty(t, n.Hash) + require.Contains(t, string(after.Content), "bar") + continue + } + require.Empty(t, n.Hash) + require.Equal(t, before[n.ID].Content, after.Content, "node %s is left alone", n.ID.Path()) + require.Equal(t, before[n.ID].Meta, after.Meta) + } + errs := errorsOf(done) + require.Empty(t, errs[typed]) + require.NotEmpty(t, errs[breaks], "metadata that breaks the schema is refused") + require.NotContains(t, errs[breaks], "invalid argument") +} + +func TestReplaceTextEditsALegacyFrontmatterBody(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + legacy, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# T\n")}) + require.NoError(t, err) + require.NoError(t, k.Repo.WriteContent(ctx, legacy.ID, []byte("---\na: 1\n---\n# T\n\nold word\n"))) + done, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "old word", Replace: "new word"}) + require.NoError(t, err) + require.Len(t, done.Nodes, 1) + require.Empty(t, done.Nodes[0].Error) + got, err := k.ReadNode(ctx, legacy.ID) + require.NoError(t, err) + require.Equal(t, "---\na: 1\n---\n# T\n\nnew word\n", string(got.Content)) +} + func TestPurgeLeavesLinksToALiveNodeAlone(t *testing.T) { t.Parallel() k, repo, ctx := newTrashingKeg(t) @@ -473,6 +712,47 @@ func TestPurgeTrashedReportsWhatItPurged(t *testing.T) { require.ErrorIs(t, err, keg.ErrInvalid) } +// A replace is capped on every node it changes, refused ones included, and a +// refused node carries its error rather than its bodies. +func TestReplaceTextCapsRefusedNodesAndOmitsTheirBodies(t *testing.T) { + t.Parallel() + k, ctx := newStrictBatchKeg(t) + var ids []keg.NodeId + for len(ids) <= keg.MaxReplaceNodes { + batch := make([]keg.NodeCreate, 0, keg.MaxMutationBatchSize) + for i := 0; i < keg.MaxMutationBatchSize && len(ids)+len(batch) <= keg.MaxReplaceNodes; i++ { + batch = append(batch, keg.NodeCreate{Key: fmt.Sprintf("n%d-%d", len(ids), i), Schema: "task", Body: []byte("# N\n\nfoo\n"), Meta: []byte("type: task\n")}) + } + created, err := k.CreateNodes(ctx, batch) + require.NoError(t, err) + for _, c := range created { + ids = append(ids, c.ID) + } + } + // Untyped, as nodes written before the keg turned strict are: a strict + // keg refuses to edit them without a schema. + for _, id := range ids { + require.NoError(t, k.Repo.WriteMeta(ctx, id, []byte("tags: [x]\n"))) + } + _, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "foo", Replace: "bar", DryRun: true}) + require.ErrorIs(t, err, keg.ErrInvalid, "%d refused nodes still count against the cap", len(ids)) + + small, smallCtx := newStrictBatchKeg(t) + created, err := small.CreateNodes(smallCtx, []keg.NodeCreate{{Key: "a", Schema: "task", Body: []byte("# N\n\nfoo\n"), Meta: []byte("type: task\n")}}) + require.NoError(t, err) + require.NoError(t, small.Repo.WriteMeta(smallCtx, created[0].ID, []byte("tags: [x]\n"))) + few, err := small.ReplaceText(smallCtx, keg.ReplaceTextOptions{Find: "foo", Replace: "bar", DryRun: true}) + require.NoError(t, err) + require.Len(t, few.Nodes, 1) + n := few.Nodes[0] + require.NotEmpty(t, n.Error) + require.Equal(t, 1, n.Matches) + require.Empty(t, n.ContentBefore) + require.Empty(t, n.ContentAfter) + require.Empty(t, n.MetaBefore) + require.Empty(t, n.MetaAfter) +} + // archiveOf builds a keg archive whose nodes sit at ids 1..len(bodies), each // with its body, in a keg of its own. func archiveOf(t *testing.T, bodies ...string) []byte { @@ -618,6 +898,59 @@ func TestRestoreOntoALiveNodeIsRefused(t *testing.T) { require.Equal(t, gone.ID, onlyTrashEntry(t, ctx, k).ID) } +// A replace measures what it would build before building it: a node that +// would grow too large, or has too many matches, is left alone with an +// error, and a replace that would grow every node together too large fails +// before writing any. +func TestReplaceTextBoundsWhatItBuilds(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + read := func(id keg.NodeId) string { + view, err := k.ReadNode(ctx, id) + require.NoError(t, err) + return string(view.Content) + } + + small, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Small\n\n" + strings.Repeat("word ", 40) + "\n")}) + require.NoError(t, err) + wide := strings.Repeat("W", 64<<10) + for _, dryRun := range []bool{true, false} { + // Matching the empty string matches at every position. + got, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "x*", Replace: wide, Regex: true, DryRun: dryRun}) + require.NoError(t, err) + require.NotEmpty(t, got.Nodes) + for _, node := range got.Nodes { + require.Contains(t, node.Error, "MiB", "node %s", node.ID.Path()) + require.Empty(t, node.ContentAfter) + } + require.NotContains(t, read(small.ID), "WWW", "the oversized note is left alone") + } + + many, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Many\n\n" + strings.Repeat("q", keg.MaxReplaceNodeMatches+1) + "\n")}) + require.NoError(t, err) + got, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "q", Replace: "r"}) + require.NoError(t, err) + require.Len(t, got.Nodes, 1) + require.Contains(t, got.Nodes[0].Error, "matches") + require.Contains(t, read(many.ID), "qqq") + + perNode := keg.MaxReplaceNodeBytes / 2 + var ids []keg.NodeId + for written := 0; written <= keg.MaxReplaceTotalBytes; written += perNode { + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Big\n\nZZ\n")}) + require.NoError(t, err) + ids = append(ids, created.ID) + } + _, err = k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "ZZ", Replace: strings.Repeat("z", perNode)}) + require.ErrorIs(t, err, keg.ErrInvalid) + for _, id := range ids { + require.Contains(t, read(id), "ZZ", "a replace too large in all writes nothing") + } +} + // readCountingRepo is a trash that counts reads of each node's content. type readCountingRepo struct { *trashingRepo diff --git a/pkg/mcp/tools_merge.go b/pkg/mcp/tools_merge.go new file mode 100644 index 00000000..4e24c6ff --- /dev/null +++ b/pkg/mcp/tools_merge.go @@ -0,0 +1,72 @@ +package mcp + +import ( + "context" + "fmt" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" +) + +// --- node_merge --- + +type mergeInput struct { + SourceID string `json:"source_id" jsonschema:"id of the node merged away; it goes to the trash"` + ExpectedSourceHash string `json:"expected_source_hash" jsonschema:"the source's combined hash from node_read (not content_hash or meta_hash)"` + TargetID string `json:"target_id" jsonschema:"id of the node that survives and takes the merged body"` + Content *string `json:"content,omitempty" jsonschema:"the target's merged markdown body, replacing its content entirely. Must not start with a YAML frontmatter block. Send content or patch."` + Patch *string `json:"patch,omitempty" jsonschema:"unified diff against the target's current content, an alternative to content when the target already holds most of the merged body"` + Meta *string `json:"meta,omitempty" jsonschema:"the target's replacement metadata as YAML, for combining tags and fields; omit to keep it"` + ExpectedContentHash string `json:"expected_content_hash" jsonschema:"the target's content_hash from node_read"` + ExpectedMetaHash string `json:"expected_meta_hash,omitempty" jsonschema:"the target's meta_hash from node_read; required when meta is supplied"` + Schema string `json:"schema,omitempty" jsonschema:"schema for the target, as for node_edit: required on a strict keg when meta is supplied"` + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` +} + +func registerMerge(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "node_merge", + Description: "Memory: Merge a duplicate node into another as one atomic write, to deduplicate a keg. " + + "Call node_read on both first. The target survives: it takes the merged body you write (content, or a patch against its current content) and optionally merged meta. " + + "Every ../SOURCE link in the keg, the target's own included, is repointed at the target, and the source goes to the trash, recorded as merged into the target, where trash_restore can bring it back. " + + "Guard the source with its combined hash as expected_source_hash, and the target with its content_hash (and meta_hash when sending meta). " + + "The result returns the target's new hashes and the other nodes whose links moved.", + Annotations: &sdkmcp.ToolAnnotations{ + DestructiveHint: boolPtr(true), + OpenWorldHint: boolPtr(false), + }, + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in mergeInput) (*sdkmcp.CallToolResult, any, error) { + ctx = keg.WithValidationActor(ctx, keg.ValidationActorAgent) + target := tapper.BatchEditItem{NodeID: in.TargetID, Schema: in.Schema, ExpectedContentHash: in.ExpectedContentHash, ExpectedMetaHash: in.ExpectedMetaHash} + if in.Content != nil { + target.Content, target.HasContent = *in.Content, true + } + if in.Patch != nil { + target.ContentPatch, target.HasContentPatch = *in.Patch, true + } + if in.Meta != nil { + target.Meta, target.HasMeta = *in.Meta, true + } + result, err := tap.Merge(ctx, tapper.MergeOptions{ + KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), + SourceID: in.SourceID, + ExpectedSourceHash: in.ExpectedSourceHash, + Target: target, + }) + if err != nil { + return errorResult(err), nil, nil + } + rewritten := make([]string, len(result.Rewritten)) + for i, id := range result.Rewritten { + rewritten[i] = id.Path() + } + res := textResult(fmt.Sprintf("merged %s into %s; %d other node(s) relinked", in.SourceID, result.Target.ID.Path(), len(rewritten))) + res.StructuredContent = map[string]any{ + "target": nodeUpdateOutputs([]keg.NodeUpdateResult{result.Target})[0], + "rewritten": rewritten, + } + return res, nil, nil + }) +} diff --git a/pkg/mcp/tools_replace.go b/pkg/mcp/tools_replace.go new file mode 100644 index 00000000..f9062612 --- /dev/null +++ b/pkg/mcp/tools_replace.go @@ -0,0 +1,82 @@ +package mcp + +import ( + "context" + "fmt" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" +) + +// --- keg_replace --- + +type replaceInput struct { + Find string `json:"find" jsonschema:"text to find; with regex, a Go (RE2) regular expression"` + Replace string `json:"replace" jsonschema:"replacement text; with regex, $1 and ${name} insert captured groups"` + Regex bool `json:"regex,omitempty" jsonschema:"treat find as a regular expression instead of literal text"` + Query string `json:"query,omitempty" jsonschema:"limit the replace to the nodes this node_list query selects, e.g. a tag; empty means every node"` + IncludeMeta bool `json:"include_meta,omitempty" jsonschema:"replace in each node's metadata YAML as well as its body"` + DryRun bool `json:"dry_run,omitempty" jsonschema:"report what would change without writing"` + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` +} + +type replaceNodeOutput struct { + NodeID string `json:"node_id"` + Matches int `json:"matches"` + Error string `json:"error,omitempty"` + ContentHash string `json:"content_hash,omitempty"` + MetaHash string `json:"meta_hash,omitempty"` + Hash string `json:"hash,omitempty"` +} + +func registerReplace(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "keg_replace", + Description: "Manage: Find and replace text across a keg's nodes in one atomic write, for renames and consistent wording. " + + "Call it with dry_run first: it lists every node that would change and how many matches each has, and writes nothing. " + + "find is literal unless regex is set; query narrows the nodes (node_list syntax); include_meta also rewrites metadata. " + + "A node whose replacement would break any write rule (the keg's schemas, metadata that must parse, a body that may not open with frontmatter) is reported with its error (and no bodies) and left alone while the rest are written; dry_run reports the same errors. " + + fmt.Sprintf("At most %d matching nodes in one call, counting those left alone. ", keg.MaxReplaceNodes) + + fmt.Sprintf("A node with more than %d matches, or that would grow past %d MiB, is left alone with its error; a replace that would grow the changed nodes past %d MiB in all fails. ", keg.MaxReplaceNodeMatches, keg.MaxReplaceNodeBytes>>20, keg.MaxReplaceTotalBytes>>20) + + "Results return each changed node's new hashes.", + Annotations: &sdkmcp.ToolAnnotations{ + DestructiveHint: boolPtr(true), + OpenWorldHint: boolPtr(false), + }, + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in replaceInput) (*sdkmcp.CallToolResult, any, error) { + ctx = keg.WithValidationActor(ctx, keg.ValidationActorAgent) + result, err := tap.Replace(ctx, tapper.ReplaceOptions{ + KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), + ReplaceTextOptions: keg.ReplaceTextOptions{ + Find: in.Find, Replace: in.Replace, Regex: in.Regex, Query: in.Query, IncludeMeta: in.IncludeMeta, DryRun: in.DryRun, + }, + }) + if err != nil { + return errorResult(err), nil, nil + } + nodes := make([]replaceNodeOutput, len(result.Nodes)) + lines := make([]string, 0, len(result.Nodes)+1) + changed, matches := 0, 0 + for i, node := range result.Nodes { + nodes[i] = replaceNodeOutput{NodeID: node.ID.Path(), Matches: node.Matches, Error: node.Error, ContentHash: node.ContentHash, MetaHash: node.MetaHash, Hash: node.Hash} + line := fmt.Sprintf("%s %d match(es)", node.ID.Path(), node.Matches) + if node.Error != "" { + line += " skipped: " + node.Error + } else { + changed++ + matches += node.Matches + } + lines = append(lines, line) + } + verb := "changed" + if in.DryRun { + verb = "would change" + } + lines = append([]string{fmt.Sprintf("%s %d node(s), %d match(es)", verb, changed, matches)}, lines...) + res := linesResult(lines) + res.StructuredContent = map[string]any{"dry_run": in.DryRun, "nodes": nodes} + return res, nil, nil + }) +} diff --git a/pkg/mcp/tools_write.go b/pkg/mcp/tools_write.go index 5c114a57..c45fb3d3 100644 --- a/pkg/mcp/tools_write.go +++ b/pkg/mcp/tools_write.go @@ -35,6 +35,8 @@ func registerWriteTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefault registerEdit(srv, tap, defaults) registerRemove(srv, tap, defaults) registerMove(srv, tap, defaults) + registerMerge(srv, tap, defaults) + registerReplace(srv, tap, defaults) registerKegSettingsEdit(srv, tap, defaults) } diff --git a/pkg/parity/parity_coverage_test.go b/pkg/parity/parity_coverage_test.go index 583e7a9d..b05dc5c8 100644 --- a/pkg/parity/parity_coverage_test.go +++ b/pkg/parity/parity_coverage_test.go @@ -127,6 +127,8 @@ var tapMethodsExcluded = map[string]string{ "NodeSnapshotBatch": "MCP batch backing operation; CLI snapshot create remains a one-node command", "CatViews": "structured accessor behind Cat; MCP node_read uses it to return per-node precondition hashes without re-reading", "NodeHash": "explicit CLI read-before-write helper; MCP reads return the same token in structured content", + "Merge": "MCP node_merge only so far; no tap node merge command yet", + "Replace": "MCP keg_replace only so far; no tap keg replace command yet", "TrashList": "MCP trash_list only so far; no tap trash command yet", "TrashRestore": "MCP trash_restore only so far; no tap trash command yet", "SchemaHash": "explicit CLI read-before-write helper; MCP schema_read returns the same token", diff --git a/pkg/tapper/tap_batch.go b/pkg/tapper/tap_batch.go index cd42e42e..b99002e3 100644 --- a/pkg/tapper/tap_batch.go +++ b/pkg/tapper/tap_batch.go @@ -165,3 +165,33 @@ func requireEditPartHashes(i int, item BatchEditItem) error { } return nil } + +type MergeOptions struct { + KegTargetOptions + SourceID string + ExpectedSourceHash string + // Target is the surviving node's rewrite, as for one EditBatch item. + Target BatchEditItem +} + +// Merge folds a source node into a target: the target takes the merged +// body, links to the source follow it, and the source goes to the trash. +func (t *Tap) Merge(ctx context.Context, opts MergeOptions) (keg.MergeNodesResult, error) { + k, err := t.resolveKegForRole(ctx, opts.KegTargetOptions, FlightRoleEditor) + if err != nil { + return keg.MergeNodesResult{}, err + } + ctx = keg.WithDefaultValidationActor(ctx, keg.ValidationActorHuman) + source, err := keg.ParseNode(opts.SourceID) + if err != nil { + return keg.MergeNodesResult{}, fmt.Errorf("merge source %q: %w", opts.SourceID, err) + } + if !opts.Target.HasContent && !opts.Target.HasContentPatch { + return keg.MergeNodesResult{}, fmt.Errorf("merge into %q: content or patch is required, since a merge rewrites the target: %w", opts.Target.NodeID, keg.ErrInvalid) + } + update, err := batchEditUpdate(ctx, 0, opts.Target) + if err != nil { + return keg.MergeNodesResult{}, err + } + return k.MergeNodes(ctx, keg.MergeNodesOptions{Source: *source, ExpectedSourceHash: opts.ExpectedSourceHash, Target: update}) +} diff --git a/pkg/tapper/tap_replace.go b/pkg/tapper/tap_replace.go new file mode 100644 index 00000000..0fc510ed --- /dev/null +++ b/pkg/tapper/tap_replace.go @@ -0,0 +1,27 @@ +package tapper + +import ( + "context" + + "github.com/jlrickert/tapper/pkg/keg" +) + +type ReplaceOptions struct { + KegTargetOptions + keg.ReplaceTextOptions +} + +// Replace finds and replaces text across a keg. A dry run only reads, so it +// needs a viewer; a real one needs an editor. +func (t *Tap) Replace(ctx context.Context, opts ReplaceOptions) (keg.ReplaceTextResult, error) { + role := FlightRoleEditor + if opts.DryRun { + role = FlightRoleViewer + } + k, err := t.resolveKegForRole(ctx, opts.KegTargetOptions, role) + if err != nil { + return keg.ReplaceTextResult{}, err + } + ctx = keg.WithDefaultValidationActor(ctx, keg.ValidationActorHuman) + return k.ReplaceText(ctx, opts.ReplaceTextOptions) +} diff --git a/pkg/tapper/tool_categories.go b/pkg/tapper/tool_categories.go index 4cc546ee..b2f42085 100644 --- a/pkg/tapper/tool_categories.go +++ b/pkg/tapper/tool_categories.go @@ -41,7 +41,7 @@ var toolCategoryOf = map[string]ToolCategory{ "node_read": ToolCategoryMemory, "node_list": ToolCategoryMemory, "node_search": ToolCategoryMemory, "node_grep": ToolCategoryMemory, "node_links": ToolCategoryMemory, "node_backlinks": ToolCategoryMemory, "node_stats": ToolCategoryMemory, "node_create": ToolCategoryMemory, "node_edit": ToolCategoryMemory, - "node_move": ToolCategoryMemory, "node_delete": ToolCategoryMemory, + "node_move": ToolCategoryMemory, "node_delete": ToolCategoryMemory, "node_merge": ToolCategoryMemory, "tag_list": ToolCategoryMemory, "schema_list": ToolCategoryMemory, "schema_read": ToolCategoryMemory, "schema_validate": ToolCategoryMemory, "index_list": ToolCategoryMemory, "index_read": ToolCategoryMemory, "file_list": ToolCategoryMemory, "file_download": ToolCategoryMemory, "file_upload": ToolCategoryMemory, @@ -55,7 +55,7 @@ var toolCategoryOf = map[string]ToolCategory{ "explore_kegs": ToolCategoryExplore, "explore_flights": ToolCategoryExplore, "explore_people": ToolCategoryExplore, "keg_create": ToolCategoryManage, "keg_delete": ToolCategoryManage, "keg_settings_edit": ToolCategoryManage, - "schema_create": ToolCategoryManage, "schema_edit": ToolCategoryManage, + "keg_replace": ToolCategoryManage, "schema_create": ToolCategoryManage, "schema_edit": ToolCategoryManage, "schema_delete": ToolCategoryManage, "index_rebuild": ToolCategoryManage, "keg_check": ToolCategoryManage, "lock_force_release": ToolCategoryManage, "flight_create": ToolCategoryManage, "flight_edit": ToolCategoryManage, "flight_delete": ToolCategoryManage, "agent_list": ToolCategoryManage, "agent_read": ToolCategoryManage, From ac24922ff30c46ff10066a55eff2af8ee7f08c19 Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Mon, 12 Oct 2026 19:30:35 -0500 Subject: [PATCH 7/8] feat: link to Hub pages with tap url and the url tool `tap url [REF]` prints the Hub web link for the current keg, a node, a keg, a flight, or a namespace; --open also opens it in the browser and --json adds the kind and canonical reference. The MCP url tool returns the same link so an agent can hand the reader something clickable. The command builds the link from configuration alone, so it skips the startup credential refresh. --- README.md | 8 +- docs/README.md | 5 + docs/ai-coding-agents/mcp-setup.md | 2 + docs/architecture/configuration-resolution.md | 26 ++ integrations/content/tool-inventory.md | 1 + .../claude/tapper/skills/tapper/SKILL.md | 1 + .../codex/tapper/skills/tapper/SKILL.md | 1 + .../opencode/tapper/skills/tapper/SKILL.md | 1 + pkg/cli/cmd_root.go | 19 +- pkg/cli/cmd_root_authrefresh_test.go | 25 ++ pkg/cli/cmd_url.go | 78 ++++ pkg/cli/cmd_url_test.go | 126 ++++++ pkg/cli/cmd_use.go | 15 +- pkg/mcp/tools_read.go | 1 + pkg/mcp/tools_url.go | 39 ++ pkg/mcp/tools_url_test.go | 84 ++++ pkg/parity/parity_coverage_test.go | 3 + pkg/tapper/tap.go | 5 + pkg/tapper/tap_url.go | 363 ++++++++++++++++++ pkg/tapper/tap_url_test.go | 189 +++++++++ pkg/tapper/tool_categories.go | 2 +- 21 files changed, 976 insertions(+), 18 deletions(-) create mode 100644 pkg/cli/cmd_url.go create mode 100644 pkg/cli/cmd_url_test.go create mode 100644 pkg/mcp/tools_url.go create mode 100644 pkg/mcp/tools_url_test.go create mode 100644 pkg/tapper/tap_url.go create mode 100644 pkg/tapper/tap_url_test.go diff --git a/README.md b/README.md index cb737364..826f366f 100644 --- a/README.md +++ b/README.md @@ -209,10 +209,14 @@ Move a repository or session onto the right shared memory: ```bash tap use @acme/engineering tap use @acme/engineering --flight @acme/+release-42 +tap url 42 --open ``` -`tap explore kegs|flights|people QUERY` searches the hub's public directory; -your own kegs come from `tap keg list`. +`tap url [REF]` prints the hub web +link for the current keg, a node (`42`, `@acme/engineering/42`), a flight +(`+release-42`), or a namespace (`@acme`); `--open` also opens it in the +browser. Agents get the same link from the MCP `url` tool. `tap explore kegs|flights|people QUERY` +searches the hub's public directory; your own kegs come from `tap keg list`. Export a keg for backup or migration: diff --git a/docs/README.md b/docs/README.md index 9546987d..01c07407 100644 --- a/docs/README.md +++ b/docs/README.md @@ -133,6 +133,11 @@ writing KEG files directly. succeeding. A keg's version never changes after creation. Keg listings, `tap keg info`, and orientation label KEGv2 kegs, and an operation a keg's version does not offer fails with `KEG_VERSION_UNSUPPORTED`. +- `tap url [REF]` - print the hub web link for the current keg, a node (`42`, + `@namespace/keg/42`, `keg:~alias/42`), a keg, a flight (`+slug`), or a + namespace (`@namespace`). `--open` also opens it in the browser and `--json` + adds the kind and canonical reference. MCP's `url` tool returns the same + link. - `tap keg list` - list your kegs on a hub. The list is complete; search is only for the public directory. - `tap explore kegs|flights|people QUERY` - search the hub's public directory. diff --git a/docs/ai-coding-agents/mcp-setup.md b/docs/ai-coding-agents/mcp-setup.md index 6f6e6701..d102de2e 100644 --- a/docs/ai-coding-agents/mcp-setup.md +++ b/docs/ai-coding-agents/mcp-setup.md @@ -77,6 +77,7 @@ With a default keg: Tool names are `_` (`node_read`, `flight_list`, `agent_edit`), and each matches the CLI command `tap `. +`orient` and `url` are single words, matching `tap orient` and `tap url`. The MCP server exposes a shared agent surface rather than every machine-local CLI capability. Exact tool availability follows the installed Tapper version @@ -105,6 +106,7 @@ explicitly configured root failed to initialize. | `keg_info` | Show concise diagnostics for the resolved keg | | `keg_settings_read` | Read targeted minimal config for one or more selected KEGs; `minimal=false` reads one complete config | | `node_stats` | Show node statistics | +| `url` | Hub web link for a KEG, note, flight, or namespace, to hand the reader a clickable link | ### Write diff --git a/docs/architecture/configuration-resolution.md b/docs/architecture/configuration-resolution.md index f5186094..ba97f775 100644 --- a/docs/architecture/configuration-resolution.md +++ b/docs/architecture/configuration-resolution.md @@ -92,6 +92,32 @@ KEG settings are separate from Tapper user/project config — different schema, different purpose (KEG metadata vs resolver settings) — and are read or written through the Hub. +## Hub Web Links + +`tap url` and the MCP `url` tool (`Tap.WebURL`, `pkg/tapper/tap_url.go`) turn a +reference into a link to the Hub's web pages. Resolution reuses the keg +resolution above: a keg or node reference resolves to its target, whose +configured hub URL is the link's origin. Flights and namespaces use the +selected hub. Only a keg-local `keg:~alias` reads the current keg's settings, +so the command skips the startup credential refresh. A Hub serving its own MCP +sets `Tap.WebBaseURL` to its public address and resolves kegs through its +injected `KegResolver`. + +The links depend on four Hub browser routes. They are outside the Hub's REST +contract (see [API Compatibility](../api-compatibility.md)), so a Hub that +moves them breaks these links: + +| Page | Path | +| --- | --- | +| Namespace | `/@namespace` | +| KEG | `/@namespace/keg` | +| Note | `/@namespace/keg/42` (plain decimal node id, no code) | +| Flight | `/@namespace/+slug` | + +Every namespace, keg, and slug segment matches `^[a-z0-9_-]+$`, so paths are +never escaped. A raw HTTP(S) keg endpoint that no configured hub serves has no +web page and is refused. + ## Read Next - [Resolution Order](../configuration/resolution-order.md) diff --git a/integrations/content/tool-inventory.md b/integrations/content/tool-inventory.md index da817488..ef24e1a7 100644 --- a/integrations/content/tool-inventory.md +++ b/integrations/content/tool-inventory.md @@ -272,6 +272,7 @@ does not activate it or change the connection's pinned root. | `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. Call `schema_list` before writing in a KEG and pick a type it lists. | | `mcp__tapper__schema_validate` | Schema validation findings. | | `mcp__tapper__keg_info`, `mcp__tapper__node_stats` | KEG diagnostics and node statistics. | +| `mcp__tapper__url` | The Hub web link for a KEG, note, flight, or namespace (`ref` as `42`, `@ns/keg/42`, `keg:~alias/42`, `+slug`, `@ns`), returned as `url`, `kind`, and canonical `ref`. Use it to hand the reader a clickable link; it never reads the note. | | `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | | `mcp__tapper__file_list`, `mcp__tapper__image_list` | Stored attachment names. | | `mcp__tapper__file_upload`, `mcp__tapper__image_upload` | Inline base64/data URI/embedded resource uploads; local stdio also accepts source_path. Link the returned stored filename. | diff --git a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md index 179715ab..fdc91912 100644 --- a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md @@ -317,6 +317,7 @@ does not activate it or change the connection's pinned root. | `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. Call `schema_list` before writing in a KEG and pick a type it lists. | | `mcp__tapper__schema_validate` | Schema validation findings. | | `mcp__tapper__keg_info`, `mcp__tapper__node_stats` | KEG diagnostics and node statistics. | +| `mcp__tapper__url` | The Hub web link for a KEG, note, flight, or namespace (`ref` as `42`, `@ns/keg/42`, `keg:~alias/42`, `+slug`, `@ns`), returned as `url`, `kind`, and canonical `ref`. Use it to hand the reader a clickable link; it never reads the note. | | `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | | `mcp__tapper__file_list`, `mcp__tapper__image_list` | Stored attachment names. | | `mcp__tapper__file_upload`, `mcp__tapper__image_upload` | Inline base64/data URI/embedded resource uploads; local stdio also accepts source_path. Link the returned stored filename. | diff --git a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md index 179715ab..fdc91912 100644 --- a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md @@ -317,6 +317,7 @@ does not activate it or change the connection's pinned root. | `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. Call `schema_list` before writing in a KEG and pick a type it lists. | | `mcp__tapper__schema_validate` | Schema validation findings. | | `mcp__tapper__keg_info`, `mcp__tapper__node_stats` | KEG diagnostics and node statistics. | +| `mcp__tapper__url` | The Hub web link for a KEG, note, flight, or namespace (`ref` as `42`, `@ns/keg/42`, `keg:~alias/42`, `+slug`, `@ns`), returned as `url`, `kind`, and canonical `ref`. Use it to hand the reader a clickable link; it never reads the note. | | `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | | `mcp__tapper__file_list`, `mcp__tapper__image_list` | Stored attachment names. | | `mcp__tapper__file_upload`, `mcp__tapper__image_upload` | Inline base64/data URI/embedded resource uploads; local stdio also accepts source_path. Link the returned stored filename. | diff --git a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md index 179715ab..fdc91912 100644 --- a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md @@ -317,6 +317,7 @@ does not activate it or change the connection's pinned root. | `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. Call `schema_list` before writing in a KEG and pick a type it lists. | | `mcp__tapper__schema_validate` | Schema validation findings. | | `mcp__tapper__keg_info`, `mcp__tapper__node_stats` | KEG diagnostics and node statistics. | +| `mcp__tapper__url` | The Hub web link for a KEG, note, flight, or namespace (`ref` as `42`, `@ns/keg/42`, `keg:~alias/42`, `+slug`, `@ns`), returned as `url`, `kind`, and canonical `ref`. Use it to hand the reader a clickable link; it never reads the note. | | `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | | `mcp__tapper__file_list`, `mcp__tapper__image_list` | Stored attachment names. | | `mcp__tapper__file_upload`, `mcp__tapper__image_upload` | Inline base64/data URI/embedded resource uploads; local stdio also accepts source_path. Link the returned stored filename. | diff --git a/pkg/cli/cmd_root.go b/pkg/cli/cmd_root.go index 466b8ca7..28d90267 100644 --- a/pkg/cli/cmd_root.go +++ b/pkg/cli/cmd_root.go @@ -23,6 +23,14 @@ import ( type shutdownKey struct{} +// localOnlyAnnotation marks a command that works from local configuration, so +// the root skips the startup credential refresh and invocation reporting. +const localOnlyAnnotation = "tapper.io/local-only" + +func runsLocalOnly(cmd *cobra.Command) bool { + return cmd != nil && cmd.Annotations[localOnlyAnnotation] == "true" +} + type Deps struct { Root string Shutdown func() @@ -58,6 +66,11 @@ type Deps struct { // tapper.ValidateToken; tests inject a stub to avoid the network call. AuthValidateTokenFn func(ctx context.Context, rt *toolkit.Runtime, hubURL, token string) (*tapper.WhoAmI, error) + // OpenBrowserFn opens a link in the user's browser for `tap url --open`. + // Nil is lazy-defaulted to tapper.OpenBrowser; tests inject a stub so no + // browser process starts. + OpenBrowserFn func(ctx context.Context, rt *toolkit.Runtime, rawURL string) error + // AuthPrompter renders the interactive hub / method / URL / token prompts // for `tap auth login`. Nil is lazy-defaulted to the huh-backed prompter; // tests inject a scripted fake so the command logic runs without a TTY. @@ -97,6 +110,9 @@ func NewRootCmd(deps *Deps) *cobra.Command { if deps.AuthValidateTokenFn == nil { deps.AuthValidateTokenFn = tapper.ValidateToken } + if deps.OpenBrowserFn == nil { + deps.OpenBrowserFn = tapper.OpenBrowser + } if deps.AuthPrompter == nil { deps.AuthPrompter = huhAuthPrompter{} } @@ -233,7 +249,7 @@ func NewRootCmd(deps *Deps) *cobra.Command { // with fresh tokens. Skipped for shell completion, which must // never block on a network round trip. After the logger setup // so refresh failures land in the configured log destination. - localOnly := cmd.Name() == "bootstrap" || cmd.Name() == "config" || (cmd.Parent() != nil && (cmd.Parent().Name() == "config" || (cmd.Parent().Name() == "hub" && cmd.Name() == "list"))) + localOnly := runsLocalOnly(cmd) || cmd.Name() == "bootstrap" || cmd.Name() == "config" || (cmd.Parent() != nil && (cmd.Parent().Name() == "config" || (cmd.Parent().Name() == "hub" && cmd.Name() == "list"))) if cmd.Flags().Lookup("offline") != nil { offline, _ := cmd.Flags().GetBool("offline") localOnly = localOnly || offline @@ -338,6 +354,7 @@ func NewRootCmd(deps *Deps) *cobra.Command { NewNamespaceCmd(deps), NewInvitationCmd(deps), NewUseCmd(deps), + NewURLCmd(deps), NewBootstrapCmd(deps), configCmd, ) diff --git a/pkg/cli/cmd_root_authrefresh_test.go b/pkg/cli/cmd_root_authrefresh_test.go index 94c35d14..3abdb826 100644 --- a/pkg/cli/cmd_root_authrefresh_test.go +++ b/pkg/cli/cmd_root_authrefresh_test.go @@ -124,3 +124,28 @@ func TestStartup_APITokenNeverRefreshed(t *testing.T) { require.True(t, ok) require.Equal(t, "thub_pastedtoken0", entry.AccessToken, "pasted API token must be untouched") } + +// TestStartup_URLSkipsRefresh: `tap url` builds a link from configuration, +// so it is local-only and never renews credentials, even expired ones. +func TestStartup_URLSkipsRefresh(t *testing.T) { + t.Parallel() + sb := newTestSandbox(t) + srv, calls := countingRefreshHub(t) + + now := sb.Runtime().Clock().Now() + seedAuthStore(t, sb, map[string]tapper.AuthEntry{ + srv.URL: { + AccessToken: "thub_expiredseed0", + TokenType: "Bearer", + ExpiresAt: now.Add(-time.Hour), + RefreshToken: "rt-old", + ClientID: "tapper-cli", + TokenEndpoint: srv.URL, + }, + }) + + res := newAuthProcess(t, nil, "url", "@team/notes/1").Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err) + require.Equal(t, tapper.CanonicalHubURL(srv.URL)+"/@team/notes/1\n", string(res.Stdout)) + require.Equal(t, int64(0), calls.Load(), "tap url must not refresh credentials") +} diff --git a/pkg/cli/cmd_url.go b/pkg/cli/cmd_url.go new file mode 100644 index 00000000..78a93b5f --- /dev/null +++ b/pkg/cli/cmd_url.go @@ -0,0 +1,78 @@ +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/spf13/cobra" +) + +// NewURLCmd returns the `url` command: the Hub web link for a KEG, note, +// flight, or namespace. +// +// Usage examples: +// +// tap url +// tap url 42 +// tap url @me/notes/42 --open +// tap url +work --json +func NewURLCmd(deps *Deps) *cobra.Command { + var ( + open bool + asJSON bool + ) + cmd := &cobra.Command{ + Use: "url [REF]", + Short: "print the Hub web link for a keg, note, flight, or namespace", + Long: `Print the Hub web link for REF, or for the current keg when REF is omitted. + +REF is a node id in the current keg (42), a keg (keg:alias, keg:~alias, +@ns/keg), a note in another keg (keg:alias/42, keg:~alias/42, @ns/keg/42), a +flight (+slug, @ns/+slug), a namespace (@ns), or a Hub page URL. The link is +built from configuration; only a keg-local ~alias reads the current keg's +settings. --open also opens the link in the browser.`, + Args: cobra.MaximumNArgs(1), + Annotations: map[string]string{localOnlyAnnotation: "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + var opts tapper.WebURLOptions + applyKegTargetProfile(deps, &opts.KegTargetOptions) + if len(args) == 1 { + opts.Ref = args[0] + } + ctx := cmd.Context() + link, err := deps.Tap.WebURL(ctx, opts) + if err != nil { + return err + } + if asJSON { + out, err := json.MarshalIndent(link, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", out) + if err != nil { + return err + } + } else if _, err := fmt.Fprintln(cmd.OutOrStdout(), link.URL); err != nil { + return err + } + if open { + return deps.OpenBrowserFn(ctx, deps.Runtime, link.URL) + } + return nil + }, + } + cmd.Flags().BoolVar(&open, "open", false, "also open the link in the browser") + cmd.Flags().BoolVar(&asJSON, "json", false, "print the url, kind, and canonical ref as JSON") + nodeIDs := nodeIDCompletionFunc(deps, 1) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + out := useArgCompletions(cmd.Context(), deps, toComplete) + ids, _ := nodeIDs(cmd, args, toComplete) + return append(out, ids...), cobra.ShellCompDirectiveNoFileComp + } + return cmd +} diff --git a/pkg/cli/cmd_url_test.go b/pkg/cli/cmd_url_test.go new file mode 100644 index 00000000..4e85d170 --- /dev/null +++ b/pkg/cli/cmd_url_test.go @@ -0,0 +1,126 @@ +package cli_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync/atomic" + "testing" + + testutils "github.com/jlrickert/cli-toolkit/sandbox" + "github.com/jlrickert/cli-toolkit/toolkit" + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/cli" + "github.com/stretchr/testify/require" +) + +// newURLSandbox configures a hub whose every request is counted: building a +// link from configuration must not reach it, not even for a credential +// refresh. +func newURLSandbox(t *testing.T) (*testutils.Sandbox, *atomic.Int32, string) { + t.Helper() + var calls atomic.Int32 + hub := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.NotFound(w, r) + })) + t.Cleanup(hub.Close) + sb := NewSandbox(t) + cfg := fmt.Sprintf("hub: cloud\nkeg: \"@team/notes\"\nhubs:\n cloud: {url: %s, token: tok}\n", hub.URL) + require.NoError(t, sb.Runtime().AtomicWriteFile("/home/testuser/.config/tapper/config.yaml", []byte(cfg), 0o600)) + return sb, &calls, hub.URL +} + +// withBrowser records what `--open` would launch instead of starting a browser. +func withBrowser(ctx context.Context, opened *[]string) context.Context { + return cli.WithTestDepsHook(ctx, func(deps *cli.Deps) { + deps.OpenBrowserFn = func(_ context.Context, _ *toolkit.Runtime, rawURL string) error { + *opened = append(*opened, rawURL) + return nil + } + }) +} + +func TestURLCLI_PrintsTheLinkAlone(t *testing.T) { + t.Parallel() + sb, calls, base := newURLSandbox(t) + var opened []string + ctx := withBrowser(sb.Context(), &opened) + + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"url"}, "/@team/notes"}, + {[]string{"url", "42"}, "/@team/notes/42"}, + {[]string{"url", "@ops/runbooks/7"}, "/@ops/runbooks/7"}, + {[]string{"url", "keg:@ops/library/3"}, "/@ops/library/3"}, + {[]string{"url", "keg:@ops/runbooks"}, "/@ops/runbooks"}, + {[]string{"url", "journal", "--namespace", "ops"}, "/@ops/journal"}, + {[]string{"url", "5", "--keg", "@ops/runbooks"}, "/@ops/runbooks/5"}, + {[]string{"url", "+dev"}, "/@team/+dev"}, + {[]string{"url", "@ops"}, "/@ops"}, + } { + res := NewHubProcess(t, false, tc.args...).Run(ctx, sb.Runtime()) + require.NoError(t, res.Err, tc.args) + require.Equal(t, base+tc.want+"\n", string(res.Stdout), tc.args) + } + require.Empty(t, opened, "without --open nothing is launched") + require.Zero(t, calls.Load(), "a link built from configuration never reaches the hub") +} + +func TestURLCLI_JSON(t *testing.T) { + t.Parallel() + sb, _, base := newURLSandbox(t) + + res := NewHubProcess(t, false, "url", "42", "--json").Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err) + var got map[string]string + require.NoError(t, json.Unmarshal(res.Stdout, &got), string(res.Stdout)) + require.Equal(t, map[string]string{"url": base + "/@team/notes/42", "kind": "node", "ref": "keg:@team/notes/42"}, got) +} + +func TestURLCLI_OpenLaunchesTheBrowser(t *testing.T) { + t.Parallel() + sb, calls, base := newURLSandbox(t) + var opened []string + + res := NewHubProcess(t, false, "url", "@team/notes/9", "--open").Run(withBrowser(sb.Context(), &opened), sb.Runtime()) + require.NoError(t, res.Err) + require.Equal(t, base+"/@team/notes/9\n", string(res.Stdout)) + require.Equal(t, []string{base + "/@team/notes/9"}, opened) + require.Zero(t, calls.Load()) +} + +func TestURLCLI_RejectsAnUnrecognizedRef(t *testing.T) { + t.Parallel() + sb, _, _ := newURLSandbox(t) + var opened []string + + res := NewHubProcess(t, false, "url", "Not A Keg!", "--open").Run(withBrowser(sb.Context(), &opened), sb.Runtime()) + require.ErrorContains(t, res.Err, "unrecognized reference") + require.Empty(t, res.Stdout) + require.Empty(t, opened) +} + +func TestURLCompletion_SuggestsKegsFlightsAndNodeIDs(t *testing.T) { + t.Parallel() + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) + + comp := NewCompletionProcess(t, false, 0, "url", "").Run(sb.Context(), sb.Runtime()) + require.NoError(t, comp.Err) + suggestions := parseCompletionSuggestions(string(comp.Stdout)) + require.Contains(t, suggestions, "@team/personal") + require.Contains(t, suggestions, "@team/+backend") + + local := NewSandbox(t, testutils.WithFixture("joe", "~")) + comp = NewCompletionProcess(t, false, 0, "url", "--keg", "personal", "").Run(local.Context(), local.Runtime()) + require.NoError(t, comp.Err) + suggestions = parseCompletionSuggestions(string(comp.Stdout)) + require.Subset(t, suggestions, []string{"0", "1", "2", "3"}) + + comp = NewCompletionProcess(t, false, 0, "url", "--keg", "personal", "1", "").Run(local.Context(), local.Runtime()) + require.NoError(t, comp.Err) + require.Empty(t, parseCompletionSuggestions(string(comp.Stdout))) +} diff --git a/pkg/cli/cmd_use.go b/pkg/cli/cmd_use.go index 544a7e9c..5f62889c 100644 --- a/pkg/cli/cmd_use.go +++ b/pkg/cli/cmd_use.go @@ -3,7 +3,6 @@ package cli import ( "context" "fmt" - "strings" "github.com/jlrickert/tapper/pkg/tapper" "github.com/spf13/cobra" @@ -52,7 +51,7 @@ and flight and the source that set each.`, return err } - if len(args) == 1 && opts.Flight == "" && isFlightUseArg(args[0]) { + if len(args) == 1 && opts.Flight == "" && tapper.IsFlightRef(args[0]) { opts.Flight = args[0] } else if len(args) == 1 { opts.Keg = args[0] @@ -92,15 +91,3 @@ func useArgCompletions(ctx context.Context, deps *Deps, toComplete string) []str add(flightFlagCompletions(ctx, deps, toComplete)) return out } - -func isFlightUseArg(raw string) bool { - raw = strings.TrimSpace(raw) - if strings.HasPrefix(raw, "+") { - return true - } - if rest, ok := strings.CutPrefix(raw, "@"); ok { - _, slug, found := strings.Cut(rest, "/") - return found && strings.HasPrefix(slug, "+") - } - return false -} diff --git a/pkg/mcp/tools_read.go b/pkg/mcp/tools_read.go index 13bb248f..478bc894 100644 --- a/pkg/mcp/tools_read.go +++ b/pkg/mcp/tools_read.go @@ -20,6 +20,7 @@ func registerReadTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults registerInfo(srv, tap, defaults) registerKegSettings(srv, tap, defaults) registerStats(srv, tap, defaults) + registerURL(srv, tap, defaults) } // --- node_read --- diff --git a/pkg/mcp/tools_url.go b/pkg/mcp/tools_url.go new file mode 100644 index 00000000..1fcc9de2 --- /dev/null +++ b/pkg/mcp/tools_url.go @@ -0,0 +1,39 @@ +package mcp + +import ( + "context" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/jlrickert/tapper/pkg/tapper" +) + +// --- url --- + +type urlInput struct { + Ref string `json:"ref,omitempty" jsonschema:"what to link: a node id in keg (42), a KEG (keg:alias, keg:~alias, @ns/keg), a note (keg:alias/42, keg:~alias/42, @ns/keg/42), a flight (+slug, @ns/+slug), or a namespace (@ns). Empty links keg itself."` + Keg string `json:"keg,omitempty" jsonschema:"keg a bare node id or keg-local ~alias resolves against (uses default if empty)"` +} + +func registerURL(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "url", + Description: "Memory: Get the Hub web link for a KEG, note, flight, or namespace, to give the reader a clickable link. " + + "Returns url, kind (namespace, keg, node, or flight), and the canonical ref. Building a link never reads the note.", + Annotations: &sdkmcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: boolPtr(false), + }, + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in urlInput) (*sdkmcp.CallToolResult, any, error) { + link, err := tap.WebURL(ctx, tapper.WebURLOptions{ + KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), + Ref: in.Ref, + }) + if err != nil { + return errorResult(err), nil, nil + } + res := textResult(link.URL) + res.StructuredContent = link + return res, nil, nil + }) +} diff --git a/pkg/mcp/tools_url_test.go b/pkg/mcp/tools_url_test.go new file mode 100644 index 00000000..4ae41058 --- /dev/null +++ b/pkg/mcp/tools_url_test.go @@ -0,0 +1,84 @@ +package mcp_test + +import ( + "context" + "encoding/json" + "testing" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + + "github.com/jlrickert/tapper/pkg/mcp" + "github.com/jlrickert/tapper/pkg/tapper" +) + +func newURLSession(t *testing.T, webBaseURL string) (*sdkmcp.ClientSession, context.Context) { + t.Helper() + ctx := context.Background() + rt := newTestSandbox(t).Runtime() + tap := newMemoryTap(t, ctx, rt) + tap.WebBaseURL = webBaseURL + + srv := mcp.NewServer(tap, "test", mcp.KegDefaults{}) + serverTransport, clientTransport := sdkmcp.NewInMemoryTransports() + done := make(chan error, 1) + go func() { done <- srv.Run(ctx, serverTransport) }() + t.Cleanup(func() { <-done }) + session, err := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "test-client", Version: "0.1"}, nil). + Connect(ctx, clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + return session, ctx +} + +func callURL(t *testing.T, session *sdkmcp.ClientSession, ctx context.Context, args map[string]any) (*sdkmcp.CallToolResult, tapper.WebURL) { + t.Helper() + result, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "url", Arguments: args}) + require.NoError(t, err) + var link tapper.WebURL + if result.StructuredContent != nil { + raw, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &link)) + } + return result, link +} + +func TestURLTool_ReturnsTheNoteLink(t *testing.T) { + t.Parallel() + session, ctx := newURLSession(t, "https://hub.example") + + result, link := callURL(t, session, ctx, map[string]any{"ref": "1"}) + require.False(t, result.IsError, extractText(t, result)) + require.Equal(t, tapper.WebURL{URL: "https://hub.example/@local/personal/1", Kind: "node", Ref: "keg:@local/personal/1"}, link) + require.Equal(t, "https://hub.example/@local/personal/1", extractText(t, result)) + + _, link = callURL(t, session, ctx, map[string]any{"ref": "@team/+dev"}) + require.Equal(t, tapper.WebURL{URL: "https://hub.example/@team/+dev", Kind: "flight", Ref: "@team/+dev"}, link) + + _, link = callURL(t, session, ctx, map[string]any{}) + require.Equal(t, "keg", link.Kind) + require.Equal(t, "https://hub.example/@local/personal", link.URL) +} + +func TestURLTool_IsReadOnly(t *testing.T) { + t.Parallel() + session, ctx := newURLSession(t, "https://hub.example") + listed, err := session.ListTools(ctx, nil) + require.NoError(t, err) + for _, tool := range listed.Tools { + if tool.Name == "url" { + require.True(t, tool.Annotations.ReadOnlyHint) + return + } + } + t.Fatal("url tool not registered") +} + +func TestURLTool_ReportsAnUnrecognizedRef(t *testing.T) { + t.Parallel() + session, ctx := newURLSession(t, "https://hub.example") + result, _ := callURL(t, session, ctx, map[string]any{"ref": "Not A Keg!"}) + require.True(t, result.IsError) + require.Contains(t, extractText(t, result), "unrecognized reference") +} diff --git a/pkg/parity/parity_coverage_test.go b/pkg/parity/parity_coverage_test.go index b05dc5c8..166b7761 100644 --- a/pkg/parity/parity_coverage_test.go +++ b/pkg/parity/parity_coverage_test.go @@ -37,6 +37,9 @@ var tapMethodToSurfaces = map[string]struct { "ListIndexes": {CLI: "index list", MCP: []string{"index_list"}}, "IndexCat": {CLI: "index read", MCP: []string{"index_read"}}, "Doctor": {CLI: "keg check", MCP: []string{"keg_check"}}, + // Hub web links. `tap url --open` also launches the browser, a CLI-only + // convenience; the link itself is the same on both surfaces. + "WebURL": {CLI: "url", MCP: []string{"url"}}, // Write operations "KegDelete": {CLI: "keg delete", MCP: []string{"keg_delete"}}, diff --git a/pkg/tapper/tap.go b/pkg/tapper/tap.go index 483eb797..78924a8e 100644 --- a/pkg/tapper/tap.go +++ b/pkg/tapper/tap.go @@ -46,6 +46,11 @@ type Tap struct { // endpoint injects its in-process TAP service here. Operations Operations + // WebBaseURL is the public web origin of the Hub serving this Tap. A Hub + // that injects KegResolver sets it so WebURL links point at the Hub + // itself; the CLI leaves it empty and links to the configured hub. + WebBaseURL string + // Version is the version of the binary running this Tap, already // normalized by NewTap. `tap integrate` stamps it into every plugin // manifest it extracts, so an installed plugin always reports the tap diff --git a/pkg/tapper/tap_url.go b/pkg/tapper/tap_url.go new file mode 100644 index 00000000..458e1eb5 --- /dev/null +++ b/pkg/tapper/tap_url.go @@ -0,0 +1,363 @@ +package tapper + +import ( + "context" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/jlrickert/tapper/pkg/keg" +) + +// WebURL kinds: what a Hub web page shows. +const ( + WebURLKindNamespace = "namespace" + WebURLKindKeg = "keg" + WebURLKindNode = "node" + WebURLKindFlight = "flight" +) + +// WebURLOptions configures Tap.WebURL. +type WebURLOptions struct { + KegTargetOptions + + // Ref names the page: empty for the current KEG, a node id, a keg or node + // reference, a flight, a lone @namespace, or a Hub page URL. + Ref string +} + +// WebURL is the Hub web page for a reference. +type WebURL struct { + // URL is the absolute browser link. + URL string `json:"url"` + // Kind is namespace, keg, node, or flight. + Kind string `json:"kind"` + // Ref is the canonical reference the page shows: @ns, keg:@ns/keg, + // keg:@ns/keg/42, or @ns/+slug. + Ref string `json:"ref"` +} + +// webSegment is the Hub's route segment shape. Every namespace, keg, and +// flight segment that reaches a path matches it, so paths need no escaping. +var webSegment = regexp.MustCompile(`^[a-z0-9_-]+$`) + +// webNodeID is a note page's plain decimal node id. +var webNodeID = regexp.MustCompile(`^[0-9]+$`) + +const webURLForms = "use a node id (42), a KEG (keg:alias, keg:~alias, @ns/keg), " + + "a note (keg:alias/42, keg:~alias/42, @ns/keg/42), a flight (+slug, @ns/+slug), " + + "a namespace (@ns), or a Hub page URL" + +// IsFlightRef reports whether raw is spelled as a flight reference: a bare +// +slug or @namespace/+slug. +func IsFlightRef(raw string) bool { + raw = strings.TrimSpace(raw) + if strings.HasPrefix(raw, "+") { + return true + } + if rest, ok := strings.CutPrefix(raw, "@"); ok { + _, slug, found := strings.Cut(rest, "/") + return found && strings.HasPrefix(slug, "+") + } + return false +} + +// WebURL returns the Hub web page for a KEG, note, flight, or namespace. It +// builds the link from configuration alone where it can: only a keg-local +// ~alias reads the current KEG's settings. The page shapes are the Hub's +// browser routes (see "Hub Web Links" in docs/architecture/configuration-resolution.md): +// +// /@ns namespace +// /@ns/keg KEG +// /@ns/keg/42 note +// /@ns/+slug flight +func (t *Tap) WebURL(ctx context.Context, opts WebURLOptions) (WebURL, error) { + raw := strings.TrimSpace(opts.Ref) + switch { + case raw == "": + return t.webKegURL(ctx, opts.KegTargetOptions, "") + case strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://"): + return t.webPageURL(opts.KegTargetOptions, raw) + case IsFlightRef(raw): + return t.webFlightURL(opts.KegTargetOptions, raw) + case strings.HasPrefix(raw, "@") && !strings.Contains(raw, "/"): + ns := strings.TrimPrefix(raw, "@") + if !webSegment.MatchString(ns) { + return WebURL{}, fmt.Errorf("invalid namespace %q: %s: %w", raw, webURLForms, keg.ErrInvalid) + } + base, err := t.webBaseURL(opts.KegTargetOptions) + if err != nil { + return WebURL{}, err + } + return WebURL{URL: base + "/@" + ns, Kind: WebURLKindNamespace, Ref: "@" + ns}, nil + } + + nodeRaw := raw + if strings.HasPrefix(raw, "@") && strings.Count(raw, "/") == 2 { + nodeRaw = "keg:" + raw + } + if ref, err := keg.ParseNodeRef(nodeRaw); err == nil { + return t.webNodeURL(ctx, opts.KegTargetOptions, ref) + } + + if alias, ok := strings.CutPrefix(raw, "keg:~"); ok { + return t.webSettingsAliasURL(ctx, opts.KegTargetOptions, alias) + } + ref := parseKegRef(raw) + if ValidateKegAlias(ref.Name) != nil || (ref.Namespace != "" && !webSegment.MatchString(ref.Namespace)) { + return WebURL{}, fmt.Errorf("unrecognized reference %q: %s: %w", raw, webURLForms, keg.ErrInvalid) + } + return t.webKegURL(ctx, opts.KegTargetOptions, kegRefSelector(ref)) +} + +// kegRefSelector renders a parsed keg reference as the selector every resolver +// understands: @ns/name, or a bare name. +func kegRefSelector(ref KegRef) string { + if ref.Namespace != "" { + return "@" + strings.TrimPrefix(ref.Namespace, "@") + "/" + ref.Name + } + return ref.Name +} + +func (t *Tap) webKegURL(ctx context.Context, opts KegTargetOptions, selector string) (WebURL, error) { + base, ns, name, err := t.webKeg(ctx, opts, selector) + if err != nil { + return WebURL{}, err + } + return webKegPage(base, ns, name), nil +} + +func webKegPage(base, ns, name string) WebURL { + return WebURL{URL: base + "/@" + ns + "/" + name, Kind: WebURLKindKeg, Ref: "keg:@" + ns + "/" + name} +} + +func (t *Tap) webNodeURL(ctx context.Context, opts KegTargetOptions, ref *keg.NodeRef) (WebURL, error) { + var base, ns, name string + var err error + switch ref.Form { + case keg.RefLocal: + base, ns, name, err = t.webKeg(ctx, opts, "") + case keg.RefAlias: + base, ns, name, err = t.webKeg(ctx, opts, ref.Alias) + case keg.RefQualified: + opts.Namespace = "" + base, ns, name, err = t.webKeg(ctx, opts, "@"+ref.Namespace+"/"+ref.KegName) + case keg.RefSettingsAlias: + base, ns, name, err = t.webSettingsAliasKeg(ctx, opts, ref.Alias) + default: + err = fmt.Errorf("unknown node ref form") + } + if err != nil { + return WebURL{}, err + } + id := strconv.Itoa(ref.Node.ID) + return WebURL{ + URL: base + "/@" + ns + "/" + name + "/" + id, + Kind: WebURLKindNode, + Ref: "keg:@" + ns + "/" + name + "/" + id, + }, nil +} + +func (t *Tap) webSettingsAliasURL(ctx context.Context, opts KegTargetOptions, alias string) (WebURL, error) { + base, ns, name, err := t.webSettingsAliasKeg(ctx, opts, alias) + if err != nil { + return WebURL{}, err + } + return webKegPage(base, ns, name), nil +} + +// webSettingsAliasKeg resolves a keg-local ~alias against the current KEG's +// settings, the one form that reads the network. +func (t *Tap) webSettingsAliasKeg(ctx context.Context, opts KegTargetOptions, alias string) (base, ns, name string, err error) { + alias = strings.TrimSpace(alias) + if !webSegment.MatchString(alias) { + return "", "", "", fmt.Errorf("invalid settings alias %q: %s: %w", "~"+alias, webURLForms, keg.ErrInvalid) + } + current, err := t.resolveKeg(ctx, opts) + if err != nil { + return "", "", "", err + } + settings, err := current.Settings(ctx) + if err != nil { + return "", "", "", err + } + ns, name, err = keg.ResolveSettingsAlias(settings.Aliases, alias) + if err != nil { + return "", "", "", err + } + opts.Namespace = "" + if target := current.Target(); target != nil && strings.TrimSpace(target.Hub) != "" && t.KegResolver == nil { + opts.Hub = target.Hub + } + return t.webKeg(ctx, opts, "@"+ns+"/"+name) +} + +// webKeg resolves a KEG selector (empty for the current KEG) to its Hub base +// URL, namespace, and name. A Hub serving its own MCP resolves through its +// injected KegResolver; the CLI resolves from configuration without opening +// the KEG. +func (t *Tap) webKeg(ctx context.Context, opts KegTargetOptions, selector string) (base, ns, name string, err error) { + if selector != "" { + opts.Keg = selector + } + var target *keg.Target + if t.KegResolver != nil { + if base, err = t.webBaseURL(opts); err != nil { + return "", "", "", err + } + k, rerr := t.resolveKeg(ctx, opts) + if rerr != nil { + return "", "", "", rerr + } + target = k.Target() + } else { + target, err = t.ConfigService.ResolveTarget(opts.Keg, opts.Namespace, opts.Hub) + if err != nil { + if sel := t.webKegSelector(opts); strings.HasPrefix(sel, "http://") || strings.HasPrefix(sel, "https://") { + return "", "", "", fmt.Errorf("keg %q has no Hub web page: %w", sel, err) + } + return "", "", "", err + } + if target != nil && strings.TrimSpace(target.HubURL) != "" { + base = strings.TrimRight(CanonicalConfiguredHubURL(target.HubURL), "/") + } + } + if target == nil || base == "" || target.Namespace == "" || target.KegName == "" { + label := t.webKegSelector(opts) + if target != nil && target.Url != "" { + label = target.Url + } + return "", "", "", fmt.Errorf("keg %q has no Hub web page", label) + } + if !webSegment.MatchString(target.Namespace) || !webSegment.MatchString(target.KegName) { + return "", "", "", fmt.Errorf("keg @%s/%s has no Hub web page address", target.Namespace, target.KegName) + } + return base, target.Namespace, target.KegName, nil +} + +// webKegSelector is the keg selector ResolveTarget reads: the explicit one, +// else the configured keg. +func (t *Tap) webKegSelector(opts KegTargetOptions) string { + if sel := strings.TrimSpace(opts.Keg); sel != "" { + return sel + } + if cfg, err := t.ConfigService.Config(); err == nil && cfg != nil { + return strings.TrimSpace(cfg.Keg()) + } + return "" +} + +func (t *Tap) webFlightURL(opts KegTargetOptions, raw string) (WebURL, error) { + defaultNS := "" + if t.KegResolver == nil { + if cfg, err := t.ConfigService.Config(); err == nil { + defaultNS = t.defaultFlightNamespace(cfg) + } + } + if defaultNS == "" { + defaultNS = strings.TrimPrefix(strings.TrimSpace(opts.Namespace), "@") + } + ref, err := ParseFlightRef(raw, defaultNS) + if err != nil { + return WebURL{}, err + } + if ref.Namespace == "" { + return WebURL{}, fmt.Errorf("flight %q needs a namespace; use @namespace/+slug: %w", raw, keg.ErrInvalid) + } + if !webSegment.MatchString(ref.Namespace) || !webSegment.MatchString(ref.Slug) { + return WebURL{}, fmt.Errorf("invalid flight reference %q: %w", raw, keg.ErrInvalid) + } + base, err := t.webBaseURL(opts) + if err != nil { + return WebURL{}, err + } + canonical := ref.Canonical() + return WebURL{URL: base + "/" + canonical, Kind: WebURLKindFlight, Ref: canonical}, nil +} + +// webPageURL validates a Hub page URL: it must be on the Hub this context +// resolves to and name one of the four page shapes. It is returned unchanged. +func (t *Tap) webPageURL(opts KegTargetOptions, raw string) (WebURL, error) { + base, err := t.webBaseURL(opts) + if err != nil { + return WebURL{}, err + } + if err := ValidateBrowserURL(raw, base); err != nil { + return WebURL{}, fmt.Errorf("%q is not a page on the Hub at %s: %w", raw, base, keg.ErrInvalid) + } + u, err := url.Parse(raw) + if err != nil { + return WebURL{}, err + } + basePath := "" + if b, err := url.Parse(base); err == nil { + basePath = strings.TrimRight(b.Path, "/") + } + path, ok := strings.CutPrefix(u.Path, basePath+"/") + if !ok { + return WebURL{}, fmt.Errorf("%q is not a page on the Hub at %s: %w", raw, base, keg.ErrInvalid) + } + kind, ref, ok := classifyWebPath(strings.TrimSuffix(path, "/")) + if !ok { + return WebURL{}, fmt.Errorf("%q is not a namespace, KEG, note, or flight page: %w", raw, keg.ErrInvalid) + } + return WebURL{URL: raw, Kind: kind, Ref: ref}, nil +} + +// classifyWebPath reads a Hub page path (without its leading slash) back into +// its kind and canonical reference. +func classifyWebPath(path string) (kind, ref string, ok bool) { + rest, found := strings.CutPrefix(path, "@") + if !found { + return "", "", false + } + parts := strings.Split(rest, "/") + if !webSegment.MatchString(parts[0]) { + return "", "", false + } + ns := parts[0] + switch len(parts) { + case 1: + return WebURLKindNamespace, "@" + ns, true + case 2: + if slug, isFlight := strings.CutPrefix(parts[1], "+"); isFlight { + if !webSegment.MatchString(slug) { + return "", "", false + } + return WebURLKindFlight, "@" + ns + "/+" + slug, true + } + if !webSegment.MatchString(parts[1]) { + return "", "", false + } + return WebURLKindKeg, "keg:@" + ns + "/" + parts[1], true + case 3: + if !webNodeID.MatchString(parts[2]) || !webSegment.MatchString(parts[1]) { + return "", "", false + } + id, err := strconv.Atoi(parts[2]) + if err != nil { + return "", "", false + } + return WebURLKindNode, "keg:@" + ns + "/" + parts[1] + "/" + strconv.Itoa(id), true + } + return "", "", false +} + +// webBaseURL returns the Hub's web origin: the hub serving this Tap when one +// is injected, otherwise the selected configured hub. +func (t *Tap) webBaseURL(opts KegTargetOptions) (string, error) { + if t.KegResolver != nil { + base := strings.TrimRight(strings.TrimSpace(t.WebBaseURL), "/") + if base == "" { + return "", fmt.Errorf("this Hub has no public web address configured") + } + return base, nil + } + _, entry, err := t.ConfigService.SelectedHub(strings.TrimSpace(opts.Hub)) + if err != nil { + return "", err + } + return strings.TrimRight(CanonicalConfiguredHubURL(entry.URL), "/"), nil +} diff --git a/pkg/tapper/tap_url_test.go b/pkg/tapper/tap_url_test.go new file mode 100644 index 00000000..27bfab32 --- /dev/null +++ b/pkg/tapper/tap_url_test.go @@ -0,0 +1,189 @@ +package tapper + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/jlrickert/cli-toolkit/sandbox" + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/keg" + "github.com/stretchr/testify/require" +) + +func newWebURLTap(t *testing.T, config string) *Tap { + t.Helper() + sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) + rt := sb.Runtime() + tap, err := NewTap(TapOptions{Runtime: rt, Root: "/workspace"}) + require.NoError(t, err) + require.NoError(t, rt.AtomicWriteFile(tap.PathService.UserConfig(), []byte(config), 0o644)) + tap.ConfigService.Reload() + return tap +} + +func TestWebURL_EveryRefForm(t *testing.T) { + t.Parallel() + tap := newWebURLTap(t, "hub: cloud\nkeg: '@team/notes'\nhubs:\n cloud: {url: 'https://hub.example/', token: tok}\n") + ctx := context.Background() + + cases := []struct { + ref, url, kind, canonical string + }{ + {"", "https://hub.example/@team/notes", WebURLKindKeg, "keg:@team/notes"}, + {"42", "https://hub.example/@team/notes/42", WebURLKindNode, "keg:@team/notes/42"}, + {"42-1234", "https://hub.example/@team/notes/42", WebURLKindNode, "keg:@team/notes/42"}, + {"keg:@ops/runbooks", "https://hub.example/@ops/runbooks", WebURLKindKeg, "keg:@ops/runbooks"}, + {"@ops/runbooks", "https://hub.example/@ops/runbooks", WebURLKindKeg, "keg:@ops/runbooks"}, + {"keg:@ops/runbooks/7", "https://hub.example/@ops/runbooks/7", WebURLKindNode, "keg:@ops/runbooks/7"}, + {"@ops/runbooks/7", "https://hub.example/@ops/runbooks/7", WebURLKindNode, "keg:@ops/runbooks/7"}, + {"+dev", "https://hub.example/@team/+dev", WebURLKindFlight, "@team/+dev"}, + {"@ops/+oncall", "https://hub.example/@ops/+oncall", WebURLKindFlight, "@ops/+oncall"}, + {"@ops", "https://hub.example/@ops", WebURLKindNamespace, "@ops"}, + {"https://hub.example/@ops/runbooks/7", "https://hub.example/@ops/runbooks/7", WebURLKindNode, "keg:@ops/runbooks/7"}, + {"https://hub.example/@ops/+oncall", "https://hub.example/@ops/+oncall", WebURLKindFlight, "@ops/+oncall"}, + {"https://hub.example/@ops", "https://hub.example/@ops", WebURLKindNamespace, "@ops"}, + {"https://hub.example/@ops/runbooks/", "https://hub.example/@ops/runbooks/", WebURLKindKeg, "keg:@ops/runbooks"}, + {"https://hub.example/@ops/runbooks/007", "https://hub.example/@ops/runbooks/007", WebURLKindNode, "keg:@ops/runbooks/7"}, + } + for _, tc := range cases { + t.Run(tc.ref, func(t *testing.T) { + got, err := tap.WebURL(ctx, WebURLOptions{Ref: tc.ref}) + require.NoError(t, err) + require.Equal(t, WebURL{URL: tc.url, Kind: tc.kind, Ref: tc.canonical}, got) + }) + } +} + +func TestWebURL_KegTargetOptionsSelectTheCurrentKeg(t *testing.T) { + t.Parallel() + tap := newWebURLTap(t, "hub: cloud\nkeg: '@team/notes'\nhubs:\n cloud: {url: 'https://hub.example', token: tok}\n") + ctx := context.Background() + + got, err := tap.WebURL(ctx, WebURLOptions{Ref: "5", KegTargetOptions: KegTargetOptions{Keg: "@ops/runbooks"}}) + require.NoError(t, err) + require.Equal(t, "https://hub.example/@ops/runbooks/5", got.URL) + + got, err = tap.WebURL(ctx, WebURLOptions{KegTargetOptions: KegTargetOptions{Keg: "journal", Namespace: "ops"}}) + require.NoError(t, err) + require.Equal(t, "https://hub.example/@ops/journal", got.URL) + + // A bare alias resolves in --namespace, as it does for every command. + for ref, want := range map[string]string{ + "journal": "https://hub.example/@ops/journal", + "keg:journal": "https://hub.example/@ops/journal", + "keg:journal/3": "https://hub.example/@ops/journal/3", + } { + got, err = tap.WebURL(ctx, WebURLOptions{Ref: ref, KegTargetOptions: KegTargetOptions{Namespace: "ops"}}) + require.NoError(t, err, ref) + require.Equal(t, want, got.URL, ref) + } + _, err = tap.WebURL(ctx, WebURLOptions{Ref: "journal"}) + require.ErrorContains(t, err, "namespace is required") + + // A qualified reference names its own namespace; --namespace only fills a + // bare one. + got, err = tap.WebURL(ctx, WebURLOptions{Ref: "@team/notes/1", KegTargetOptions: KegTargetOptions{Namespace: "ops"}}) + require.NoError(t, err) + require.Equal(t, "https://hub.example/@team/notes/1", got.URL) +} + +func TestWebURL_Errors(t *testing.T) { + t.Parallel() + tap := newWebURLTap(t, "hub: cloud\nkeg: '@team/notes'\nhubs:\n cloud: {url: 'https://hub.example', token: tok}\n") + ctx := context.Background() + + cases := []struct { + name string + opts WebURLOptions + want string + }{ + {"raw endpoint keg", WebURLOptions{KegTargetOptions: KegTargetOptions{Keg: "https://raw.example/keg"}}, "has no Hub web page"}, + {"malformed ref", WebURLOptions{Ref: "Not A Keg!"}, "unrecognized reference"}, + {"malformed namespace", WebURLOptions{Ref: "@Bad"}, "invalid namespace"}, + {"url on another host", WebURLOptions{Ref: "https://elsewhere.example/@team/notes"}, "is not a page on the Hub"}, + {"url that is not a page", WebURLOptions{Ref: "https://hub.example/settings"}, "is not a namespace, KEG, note, or flight page"}, + {"unknown hub", WebURLOptions{Ref: "@ops", KegTargetOptions: KegTargetOptions{Hub: "missing"}}, "missing"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := tap.WebURL(ctx, tc.opts) + require.Error(t, err) + require.Contains(t, err.Error(), tc.want) + }) + } +} + +func TestWebURL_FlightNeedsANamespace(t *testing.T) { + t.Parallel() + tap := newWebURLTap(t, "hub: cloud\nkeg: notes\nhubs:\n cloud: {url: 'https://hub.example', token: tok}\n") + _, err := tap.WebURL(context.Background(), WebURLOptions{Ref: "+dev"}) + require.ErrorContains(t, err, "needs a namespace") +} + +// A keg-local ~alias is the one form that reads the network: the current +// keg's settings name the keg it points at. +func TestWebURL_SettingsAliasReadsCurrentKegSettings(t *testing.T) { + t.Parallel() + hub := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/@team/kegs/notes/settings" { + _, _ = w.Write([]byte("kegv: 2025-07\naliases:\n - alias: lib\n url: keg:@ops/library\n")) + return + } + http.NotFound(w, r) + })) + defer hub.Close() + tap := newWebURLTap(t, "hub: cloud\nkeg: '@team/notes'\nhubs:\n cloud: {url: '"+hub.URL+"', token: tok}\n") + ctx := context.Background() + + got, err := tap.WebURL(ctx, WebURLOptions{Ref: "keg:~lib/12"}) + require.NoError(t, err) + require.Equal(t, WebURL{URL: hub.URL + "/@ops/library/12", Kind: WebURLKindNode, Ref: "keg:@ops/library/12"}, got) + + got, err = tap.WebURL(ctx, WebURLOptions{Ref: "keg:~lib"}) + require.NoError(t, err) + require.Equal(t, WebURL{URL: hub.URL + "/@ops/library", Kind: WebURLKindKeg, Ref: "keg:@ops/library"}, got) + + _, err = tap.WebURL(ctx, WebURLOptions{Ref: "keg:~missing/1"}) + require.ErrorIs(t, err, keg.ErrNotExist) +} + +// A Hub serving its own MCP resolves kegs through its injected resolver and +// links to its own web address. +func TestWebURL_HostedUsesTheServingHub(t *testing.T) { + t.Parallel() + tap := newWebURLTap(t, "") + tap.KegResolver = func(_ context.Context, opts KegTargetOptions, _ FlightRole) (keg.Keg, error) { + ns, name := opts.Namespace, opts.Keg + if head, tail, ok := strings.Cut(strings.TrimPrefix(opts.Keg, "@"), "/"); ok && strings.HasPrefix(opts.Keg, "@") { + ns, name = head, tail + } + if name == "" { + name = "notes" + } + k := keg.NewLocalKeg(nil, tap.Runtime) + k.SetTarget(&keg.Target{Namespace: ns, KegName: name}) + return k, nil + } + ctx := context.Background() + opts := KegTargetOptions{Namespace: "jared"} + + _, err := tap.WebURL(ctx, WebURLOptions{Ref: "@ops", KegTargetOptions: opts}) + require.ErrorContains(t, err, "no public web address") + + tap.WebBaseURL = "https://atlas.example/" + for ref, want := range map[string]string{ + "": "https://atlas.example/@jared/notes", + "4": "https://atlas.example/@jared/notes/4", + "journal": "https://atlas.example/@jared/journal", + "@ops/runbooks/7": "https://atlas.example/@ops/runbooks/7", + "+dev": "https://atlas.example/@jared/+dev", + "@ops": "https://atlas.example/@ops", + "https://atlas.example/@ops/runbooks": "https://atlas.example/@ops/runbooks", + } { + got, err := tap.WebURL(ctx, WebURLOptions{Ref: ref, KegTargetOptions: opts}) + require.NoError(t, err, ref) + require.Equal(t, want, got.URL, ref) + } +} diff --git a/pkg/tapper/tool_categories.go b/pkg/tapper/tool_categories.go index b2f42085..e4b84051 100644 --- a/pkg/tapper/tool_categories.go +++ b/pkg/tapper/tool_categories.go @@ -50,7 +50,7 @@ var toolCategoryOf = map[string]ToolCategory{ "snapshot_read": ToolCategoryMemory, "snapshot_create": ToolCategoryMemory, "snapshot_restore": ToolCategoryMemory, "trash_list": ToolCategoryMemory, "trash_restore": ToolCategoryMemory, "lock_acquire": ToolCategoryMemory, "lock_release": ToolCategoryMemory, "lock_status": ToolCategoryMemory, - "flight_list": ToolCategoryMemory, "flight_read": ToolCategoryMemory, + "flight_list": ToolCategoryMemory, "flight_read": ToolCategoryMemory, "url": ToolCategoryMemory, "explore_kegs": ToolCategoryExplore, "explore_flights": ToolCategoryExplore, "explore_people": ToolCategoryExplore, From a86c5e68a4e567a8401b484cc4f51d8eda4416db Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Mon, 12 Oct 2026 19:30:35 -0500 Subject: [PATCH 8/8] feat(cli): show the CLI's context with tap info `tap info` shows the hub and the user signed in to it, the default keg, the active flight, and the kegs that flight covers with your role in each, noting the config tier that set each default. A hub that cannot be reached is a warning; the local defaults still print. `tap use` and `tap info` share one resolution of those defaults. --- README.md | 4 +- docs/README.md | 2 + pkg/cli/cmd_context_info_test.go | 74 +++++++++++++++++++++++++ pkg/cli/cmd_info.go | 45 +++++++++++++++ pkg/cli/cmd_root.go | 1 + pkg/parity/parity_coverage_test.go | 1 + pkg/tapper/tap_info.go | 89 ++++++++++++++++++++++++++++++ pkg/tapper/tap_use.go | 55 +++++++++++------- 8 files changed, 250 insertions(+), 21 deletions(-) create mode 100644 pkg/cli/cmd_context_info_test.go diff --git a/README.md b/README.md index 826f366f..4570fcab 100644 --- a/README.md +++ b/README.md @@ -209,10 +209,12 @@ Move a repository or session onto the right shared memory: ```bash tap use @acme/engineering tap use @acme/engineering --flight @acme/+release-42 +tap info tap url 42 --open ``` -`tap url [REF]` prints the hub web +`tap info` shows the hub you are signed in to, the default keg, the active +flight, and the kegs that flight covers. `tap url [REF]` prints the hub web link for the current keg, a node (`42`, `@acme/engineering/42`), a flight (`+release-42`), or a namespace (`@acme`); `--open` also opens it in the browser. Agents get the same link from the MCP `url` tool. `tap explore kegs|flights|people QUERY` diff --git a/docs/README.md b/docs/README.md index 01c07407..60b59f4a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -133,6 +133,8 @@ writing KEG files directly. succeeding. A keg's version never changes after creation. Keg listings, `tap keg info`, and orientation label KEGv2 kegs, and an operation a keg's version does not offer fails with `KEG_VERSION_UNSUPPORTED`. +- `tap info` - show the hub and signed-in user, the default keg, the active + flight, and the kegs that flight covers. - `tap url [REF]` - print the hub web link for the current keg, a node (`42`, `@namespace/keg/42`, `keg:~alias/42`), a keg, a flight (`+slug`), or a namespace (`@namespace`). `--open` also opens it in the browser and `--json` diff --git a/pkg/cli/cmd_context_info_test.go b/pkg/cli/cmd_context_info_test.go new file mode 100644 index 00000000..93531f94 --- /dev/null +++ b/pkg/cli/cmd_context_info_test.go @@ -0,0 +1,74 @@ +package cli_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/stretchr/testify/require" +) + +func TestInfoCLI_ShowsHubKegFlightAndFlightKegs(t *testing.T) { + t.Parallel() + hub := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/whoami": + _ = json.NewEncoder(w).Encode(tapper.WhoAmI{UserID: 1, Username: "jared", DefaultNamespace: "foldwise"}) + case "GET /api/v1/@foldwise/+work": + _ = json.NewEncoder(w).Encode(tapper.HubFlight{ + Namespace: "foldwise", Slug: "work", Title: "Work", + Cover: []tapper.HubFlightCover{{Namespace: "foldwise", Keg: "dev", Role: "editor"}}, + }) + case "GET /api/v1/kegs": + _ = json.NewEncoder(w).Encode([]tapper.HubKeg{ + {Namespace: "foldwise", Alias: "dev", Title: "Development", Role: "admin"}, + {Namespace: "foldwise", Alias: "private", Title: "Outside the flight", Role: "admin"}, + }) + default: + http.NotFound(w, r) + } + })) + defer hub.Close() + sb := NewSandbox(t) + cfg := fmt.Sprintf("hub: cloud\nkeg: \"@foldwise/dev\"\nflight: \"@foldwise/+work\"\nhubs:\n cloud: {url: %s, token: tok}\n", hub.URL) + require.NoError(t, sb.Runtime().AtomicWriteFile("/home/testuser/.config/tapper/config.yaml", []byte(cfg), 0o600)) + + res := NewProcess(t, false, "info", "--json").Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err) + var info tapper.ContextInfoResult + require.NoError(t, json.Unmarshal(res.Stdout, &info), string(res.Stdout)) + + require.Equal(t, "cloud", info.Hub.Name) + require.Equal(t, hub.URL, info.Hub.URL) + require.Equal(t, "jared", info.Hub.User) + require.Equal(t, "@foldwise/dev", info.Keg.Ref) + require.Equal(t, "user", info.Keg.Scope) + require.Equal(t, "@foldwise/+work", info.Flight.Ref) + require.Equal(t, "Work", info.Flight.Title) + require.Equal(t, []tapper.ContextInfoFlightKeg{{Ref: "@foldwise/dev", Role: "editor", Title: "Development"}}, info.Kegs, + "only the kegs the flight covers, at the role the flight caps them to") + require.Empty(t, info.Warnings) +} + +func TestInfoCLI_UnreachableHubStillPrintsLocalDefaults(t *testing.T) { + t.Parallel() + hub := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "down", http.StatusServiceUnavailable) + })) + defer hub.Close() + sb := NewSandbox(t) + cfg := fmt.Sprintf("hub: cloud\nkeg: \"@foldwise/dev\"\nflight: \"@foldwise/+work\"\nhubs:\n cloud: {url: %s, token: tok}\n", hub.URL) + require.NoError(t, sb.Runtime().AtomicWriteFile("/home/testuser/.config/tapper/config.yaml", []byte(cfg), 0o600)) + + res := NewProcess(t, false, "info").Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err, "an unreachable hub is a warning, not a failure") + out := string(res.Stdout) + require.Contains(t, out, "name: cloud") + require.Contains(t, out, "ref: '@foldwise/dev'") + require.Contains(t, out, "ref: '@foldwise/+work'") + require.Contains(t, out, "warnings:") +} diff --git a/pkg/cli/cmd_info.go b/pkg/cli/cmd_info.go index 4817201b..b02ae465 100644 --- a/pkg/cli/cmd_info.go +++ b/pkg/cli/cmd_info.go @@ -1,10 +1,12 @@ package cli import ( + "encoding/json" "fmt" "github.com/jlrickert/tapper/pkg/tapper" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) // NewInfoCmd returns the `info` cobra command. @@ -41,3 +43,46 @@ file/image capabilities. Pass --debug for path and backend diagnostics.`, return cmd } + +// NewContextInfoCmd returns the top-level `info` command: what the CLI is +// pointed at. `tap keg info` describes one KEG instead. +// +// Usage examples: +// +// tap info +// tap info --json +// tap info --flight @me/+work +func NewContextInfoCmd(deps *Deps) *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "info", + Short: "show the hub, default keg, flight, and the flight's kegs", + Long: `Show the CLI's current context: the hub and the user signed in to it, the +default keg, the active flight, and the kegs that flight covers with your role +in each. Each default notes the config tier that set it. A hub that cannot be +reached is reported as a warning; the local defaults still print.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + var kt tapper.KegTargetOptions + applyKegTargetProfile(deps, &kt) + info, err := deps.Tap.ContextInfo(cmd.Context(), kt) + if err != nil { + return err + } + var out []byte + if asJSON { + out, err = json.MarshalIndent(info, "", " ") + out = append(out, '\n') + } else { + out, err = yaml.Marshal(info) + } + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(out) + return err + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "render as JSON instead of YAML") + return cmd +} diff --git a/pkg/cli/cmd_root.go b/pkg/cli/cmd_root.go index 28d90267..b79e30c8 100644 --- a/pkg/cli/cmd_root.go +++ b/pkg/cli/cmd_root.go @@ -330,6 +330,7 @@ func NewRootCmd(deps *Deps) *cobra.Command { NewHubCmd(deps), NewImageCmd(deps), NewIndexCmd(deps), + NewContextInfoCmd(deps), NewLockCmd(deps), NewMcpCmd(deps), NewNodeCmd(deps), diff --git a/pkg/parity/parity_coverage_test.go b/pkg/parity/parity_coverage_test.go index 166b7761..2cc8b3b2 100644 --- a/pkg/parity/parity_coverage_test.go +++ b/pkg/parity/parity_coverage_test.go @@ -175,6 +175,7 @@ var tapMethodsExcluded = map[string]string{ "SetBootstrapFlight": "CLI-only bootstrap step; validates and persists the user-level flight baseline, not an MCP operation", "Use": "writes the project/user keg + flight to config; CLI-only config management by design", "UseStatus": "CLI-only summary of the resolved keg/flight context; config inspection via `tap use`", + "ContextInfo": "CLI-only `tap info` summary of the local hub, keg, and flight defaults; an MCP session learns its identity from session_info and its flight and KEGs from orient", "ActiveFlightName": "internal pure read of the explicit flight or the loaded cascade's selection; backs Orient and MCP session adoption rather than being an operation of its own", "ActiveLaunch": "internal pure read of the `tap launch` harness and model driving the process; reported in orientation rather than being an operation of its own", "OrientationKegsForFlight": "internal authority projection used by MCP providers to compute a revision before rendering once", diff --git a/pkg/tapper/tap_info.go b/pkg/tapper/tap_info.go index 95eb1e78..f7c57c2d 100644 --- a/pkg/tapper/tap_info.go +++ b/pkg/tapper/tap_info.go @@ -507,3 +507,92 @@ func (t *Tap) KegSettingsEdit(ctx context.Context, opts KegSettingsEditOptions) } return nil } + +// ContextInfoHub is the hub the CLI talks to and who it is signed in as. +type ContextInfoHub struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` + URL string `yaml:"url,omitempty" json:"url,omitempty"` + User string `yaml:"user,omitempty" json:"user,omitempty"` + Scope string `yaml:"scope,omitempty" json:"scope,omitempty"` +} + +// ContextInfoKeg is the CLI's default KEG. +type ContextInfoKeg struct { + Ref string `yaml:"ref,omitempty" json:"ref,omitempty"` + Scope string `yaml:"scope,omitempty" json:"scope,omitempty"` +} + +// ContextInfoFlight is the active flight. +type ContextInfoFlight struct { + Ref string `yaml:"ref,omitempty" json:"ref,omitempty"` + Title string `yaml:"title,omitempty" json:"title,omitempty"` + Scope string `yaml:"scope,omitempty" json:"scope,omitempty"` +} + +// ContextInfoFlightKeg is one KEG the active flight covers, at the role the +// caller holds through it. +type ContextInfoFlightKeg struct { + Ref string `yaml:"ref" json:"ref"` + Role string `yaml:"role,omitempty" json:"role,omitempty"` + Title string `yaml:"title,omitempty" json:"title,omitempty"` +} + +// ContextInfoResult summarizes the CLI's context for `tap info`. +type ContextInfoResult struct { + Hub ContextInfoHub `yaml:"hub" json:"hub"` + Keg ContextInfoKeg `yaml:"keg" json:"keg"` + Flight ContextInfoFlight `yaml:"flight" json:"flight"` + Kegs []ContextInfoFlightKeg `yaml:"kegs" json:"kegs"` + Warnings []string `yaml:"warnings,omitempty" json:"warnings,omitempty"` +} + +// ContextInfo reports the hub, the default KEG, the active flight, and the +// KEGs that flight covers. It is best-effort: a hub that cannot be reached or +// a flight that cannot be loaded becomes a warning, and the locally +// configured defaults are still reported. +func (t *Tap) ContextInfo(ctx context.Context, opts KegTargetOptions) (ContextInfoResult, error) { + out := ContextInfoResult{Kegs: []ContextInfoFlightKeg{}} + slots := t.configSlots(opts) + + out.Hub = ContextInfoHub{Name: slots.Hub.Value, Scope: slots.Hub.Scope} + if name, entry, err := t.ConfigService.SelectedHub(opts.Hub); err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("hub: %v", err)) + } else { + out.Hub.Name, out.Hub.URL = name, entry.URL + if who, err := t.SelectedHubIdentity(ctx); err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("hub identity: %v", err)) + } else if who != nil { + out.Hub.User = who.Username + } + } + + out.Keg = ContextInfoKeg{Ref: slots.Keg.Value, Scope: slots.Keg.Scope} + if ref := t.resolveIdentity(opts).Ref; ref != "" { + out.Keg.Ref = ref + } + + out.Flight = ContextInfoFlight{Ref: t.ActiveFlightName(opts.Flight), Scope: slots.Flight.Scope} + if out.Flight.Ref == "" { + return out, nil + } + flight, note, err := t.resolveOrientFlight(ctx, out.Flight.Ref) + if err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("flight: %v", err)) + return out, nil + } + if note != "" { + out.Warnings = append(out.Warnings, note) + return out, nil + } + out.Flight.Ref, out.Flight.Title = flight.Name, flight.Title + rows, warnings, err := t.OrientationKegsForFlight(ctx, flight) + out.Warnings = append(out.Warnings, warnings...) + if err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("flight kegs: %v", err)) + return out, nil + } + for _, row := range rows { + out.Kegs = append(out.Kegs, ContextInfoFlightKeg{Ref: row.Ref, Role: EffectiveOrientationRole(row), Title: row.Title}) + } + return out, nil +} diff --git a/pkg/tapper/tap_use.go b/pkg/tapper/tap_use.go index b7749514..fee9366d 100644 --- a/pkg/tapper/tap_use.go +++ b/pkg/tapper/tap_use.go @@ -84,36 +84,51 @@ func (t *Tap) Use(ctx context.Context, opts UseOptions) error { // UseStatus returns a YAML summary of the resolved KEG, Hub, and flight // defaults and the source that set each, for `tap use` with no args. func (t *Tap) UseStatus(_ context.Context, opts KegTargetOptions) (string, error) { - type slot struct { - Value string `yaml:"value,omitempty"` - Scope string `yaml:"scope,omitempty"` - } type status struct { Resolved resolvedIdentity `yaml:"resolved"` - Hub slot `yaml:"hub"` - Keg slot `yaml:"keg"` - Flight slot `yaml:"flight"` + Hub ConfigSlot `yaml:"hub"` + Keg ConfigSlot `yaml:"keg"` + Flight ConfigSlot `yaml:"flight"` } - out := status{Resolved: t.resolveIdentity(opts)} - if cfg, err := t.ConfigService.Config(); err == nil && cfg != nil { - out.Hub = slot{Value: cfg.resolveHubName(), Scope: t.configFieldScope("hub")} - out.Keg = slot{Value: cfg.Keg(), Scope: t.configFieldScope("keg")} - out.Flight = slot{Value: cfg.Flight(), Scope: t.configFieldScope("flight")} + slots := t.configSlots(opts) + out := status{Resolved: t.resolveIdentity(opts), Hub: slots.Hub, Keg: slots.Keg, Flight: slots.Flight} + b, err := yaml.Marshal(out) + if err != nil { + return "", fmt.Errorf("unable to marshal use status: %w", err) } + return string(b), nil +} + +// ConfigSlot is one configured default and the config tier that set it +// (flag, env, project, kegMap, or user). +type ConfigSlot struct { + Value string `yaml:"value,omitempty" json:"value,omitempty"` + Scope string `yaml:"scope,omitempty" json:"scope,omitempty"` +} +type configSlots struct { + Hub, Keg, Flight ConfigSlot +} + +// configSlots resolves the hub, keg, and flight defaults and where each came +// from, with explicit flags winning. `tap use` and `tap info` share it so they +// never disagree about the CLI's context. +func (t *Tap) configSlots(opts KegTargetOptions) configSlots { + var out configSlots + if cfg, err := t.ConfigService.Config(); err == nil && cfg != nil { + out.Hub = ConfigSlot{Value: cfg.resolveHubName(), Scope: t.configFieldScope("hub")} + out.Keg = ConfigSlot{Value: cfg.Keg(), Scope: t.configFieldScope("keg")} + out.Flight = ConfigSlot{Value: cfg.Flight(), Scope: t.configFieldScope("flight")} + } if opts.Keg != "" { - out.Keg = slot{Value: opts.Keg, Scope: "flag"} + out.Keg = ConfigSlot{Value: opts.Keg, Scope: "flag"} } if opts.Hub != "" { - out.Hub = slot{Value: opts.Hub, Scope: "flag"} + out.Hub = ConfigSlot{Value: opts.Hub, Scope: "flag"} } if opts.Flight != "" { - out.Flight = slot{Value: opts.Flight, Scope: "flag"} + out.Flight = ConfigSlot{Value: opts.Flight, Scope: "flag"} } - b, err := yaml.Marshal(out) - if err != nil { - return "", fmt.Errorf("unable to marshal use status: %w", err) - } - return string(b), nil + return out }