# design — ext import + tool-dispatch dedup (paired)

**Status:** Exploration. No code yet. The artifact this doc unblocks is a one-slice TDD build of #202.3 (semantic dedup at tool-dispatch), with `ext import` design captured in the same place because **both threads pivot around the same architectural seam**.

**Trigger:** 2026-05-22 conversation. Ben on `axi ext migrate` (#207): the verb feels narrow and context-dependent; the more interesting framing is `axi ext import` — bring arbitrary code into an Axiom slot and make it agent-aware. Same conversation surfaced #202.3 as the runner-up build. Realization: both touch the tool-dispatch surface. Captured together so the design discoveries from one inform the other.

## The shared seam

`src/axiom/infra/tool_gateway.py:70` — `dispatch_tool(...)` is the single call-site for any agent-initiated tool invocation. Before the tool body runs, the gateway fires a `tool.pre_invoke` hook on the `HookBus`. Hooks can:

- `deny()` → `HookDenied` raised, tool never runs
- `request_approval()` → `ApprovalRequired` raised
- `allow_modified(args)` → mutate args, then dispatch
- (do nothing) → pass through

After the tool body, `tool.post_invoke` fires (cost meter, audit log, etc.). The architecture is already exactly the shape both threads want.

**Why this matters:**

- **#202.3 semantic dedup** wires as a `tool.pre_invoke` hook. For memory-write tools (`axiom_memory__compose` and friends), it runs a similarity probe against existing fragments. If similar enough, `deny()` with a reason like "duplicate of fragment X (sim=0.94)". Cheap, declarative, doesn't touch the dispatcher or the tool body.

- **`ext import`** is the question of how external code's surface becomes addressable as Axiom tools. Whatever shape we pick (MCP wrapper / AEOS extension / both), the resulting tools all dispatch through this same gateway. The dedup hook applies to them automatically. Any other pre-invoke hook (policy, audit, rate limit) does too. **Imported tools inherit the platform contract by virtue of going through `dispatch_tool`.**

This is the load-bearing observation. Whatever else `ext import` ends up doing, *passing imported callables through `dispatch_tool` is non-negotiable*.

## #202.3 — semantic dedup at tool-dispatch

### Shape

New module `src/axiom/policy/memory_dedup_hook.py` (sibling of `agent_action_guard.py` — both are policy middleware that hooks into platform seams):

- Registers a `tool.pre_invoke` listener
- Filters to memory-write tool names (config: list of tool-name regexes; default catches `*memory*compose*`, `axiom_memory__compose`, etc.)
- For matching invocations, extracts the candidate write content from `args`
- Runs the similarity probe (`rag.embeddings.embed_texts` + cosine against existing fragments — bounded to recent N, cheap)
- If `max_similarity > threshold` (default ~0.92, env-configurable), `deny()` with structured reason
- Otherwise passes through

### Open design questions (to resolve during the slice)

1. **Where does the corpus to compare against live?** Probably the active session's recent fragments plus a small window of cross-session fragments. Full corpus = too expensive. Need to define "recent" — last 100 fragments? Last hour?
2. **What's the threshold?** Start at 0.92 (high — only obvious near-duplicates), let operators tune via `AXIOM_MEMORY_DEDUP_THRESHOLD` env var. Too low → false positives that confuse agents.
3. **Per-agent or global?** Probably global (a fragment is a fragment regardless of who wrote it), but the dedup-reason returned to the agent should still attribute the existing fragment's writer for auditability.
4. **Bypass for legitimate near-duplicates?** Operator may want to write a fragment that's syntactically similar but semantically distinct (e.g., two test cases with similar prose). Honor an `args.force=true` opt-out so agents can override when needed. Logged for audit.
5. **What gets returned to the agent on deny?** The dedup decision needs to be *legible* to the LLM so it learns. Format: `{"deduped": true, "existing_fragment_id": "...", "similarity": 0.94, "hint": "this fact is already known"}`. Surfacing the existing fragment ID lets the agent reference it instead of re-writing it.

### Build sequencing

1. RED: tests for `memory_dedup_hook` — exact-duplicate denies, near-duplicate denies, distinct allows, force-flag bypasses, threshold-from-env honored
2. GREEN: implement the hook, register on `tool.pre_invoke`
3. Integration test: register the hook + call `dispatch_tool` with a synthetic memory-write tool name + verify the deny propagates as `HookDenied`
4. Wire into the live hook registry (so it's on by default in production builds)
5. Documentation in `docs/specs/spec-memory.md` § "Dedup at write"

## `ext import` — design exploration

### What `ext import` is *not*

- Not `ext migrate` (the existing #207 framing — narrow, one-time refactor of legacy axiom code into AEOS layout). That verb's value-per-effort is low; punt or repurpose.
- Not "axiomize everything" — needs explicit operator opt-in per import; no automatic scanning of `site-packages`.

### What it *is* — three candidate shapes

**Shape A — MCP-wrapper import.** Operator points `ext import` at a Python module or installed package. The verb generates an MCP server that exposes the module's public callables as MCP tools, then registers it with the local MCP gateway. From the agent's side, these are normal MCP tools; they dispatch through the standard pipeline. Light-touch: no new extension machinery, just MCP plumbing.

**Shape B — AEOS-extension import.** Generate a full AEOS-compliant extension (manifest, capability-kind subdirs, signed release) from a target module. Heavier, but the imported surface is now a first-class Axiom extension with federation distribution, signed releases, etc. Closer to the `ext migrate` shape but starting from any code, not just legacy axiom code.

**Shape C — Both, gated by the operator.** `ext import <target> --as mcp` vs `ext import <target> --as extension`. Operator picks shape based on need.

### Decision questions (to resolve before building)

1. **Source granularity.** Single file? Module dir? PyPI package? Git URL? Each implies different bounds + different identity stories.
2. **Capability-kind mapping (Shape B).** A library has functions/classes; how do they map to `agent / tool / cmd / service / adapter / skill / hook`? LLM-mediated proposal with deterministic verification is the natural fit but adds dependencies.
3. **"Bring it to life via chat" semantics.** Ben's framing: chatting with the imported library brings it to life. What does this mean concretely?
   - Possibility 1: chat's LLM gets the library's README + signatures as context, can call exposed tools
   - Possibility 2: a wizard runs at import time that asks the LLM to propose capability-kind mappings, which the operator confirms
   - Possibility 3: a learning loop where the LLM annotates the imported tools based on observed usage
4. **Idempotence + refresh.** Library evolves. Import is a *refreshable link*, not a fork. `ext import --refresh <name>` re-imports current state. Decline state preserved across refreshes.
5. **Identity + dovetailing across installs.** Multiple Axiom slots on one machine, each with their own imports. When a new slot installs, it should detect prior slots' imports and inherit / dovetail (per Ben's vision). Needs an inter-slot identity model the install-time probe (#227) doesn't currently provide.
6. **MCP server discovery.** If Shape A, the generated MCP server needs to be registered somewhere the gateway sees. Probably `~/.axi/mcp.d/<import-name>.json`.

### Patterns to avoid (carried forward from the earlier conversation)

- **Capability sprawl.** Don't generate all 7 AEOS kinds for every import. Imported tools by default; agents / services / hooks only if the source declares them.
- **Implicit auto-import without explicit consent.** Supply-chain risk; never scan and import without `--allow`.
- **Magical "axiomize" with no deterministic shape.** If the import process is LLM-mediated, capture the proposal as a structured artifact (TOML / JSON) the operator can review + commit. The LLM proposes; the operator decides.
- **Name collisions.** Imported tool names must be namespaced (e.g., `pandas:read_csv`, not bare `read_csv`).
- **One-way imports that diverge from the source.** Refresh is part of the contract; treat the source library as the source of truth.

### Future unlocks (when the shape is settled)

- Knowledge packs declare library deps → `axi install <pack>` auto-imports them with explicit operator confirmation
- Federation peers share imported extensions (governance question)
- Agent introspection: "what libraries does this slot have available?" becomes a queryable surface
- The chat extension's federation-nudge (slice 25) generalizes: nudges become a category, applicable to "richer LLM reachable" + "library available for import" + "peer extension publishes a new version"

### Phasing proposal

- **Phase 0 (this slice):** ship #202.3 dedup. Surfaces the concrete shape of the tool.pre_invoke contract that import will plug into.
- **Phase 1:** Shape A (MCP-wrapper import) for a single package. Smallest possible end-to-end demo. Operator runs `ext import some-package`; the package's callables become callable from `axi chat`. No AEOS, no signing, no federation.
- **Phase 2:** capability-kind proposal layer for Shape B (LLM-mediated mapping with structured artifact review).
- **Phase 3:** federation distribution + signed-release path for Shape B.
- **Phase 4:** inter-slot dovetailing + install-time scan.

Each phase is its own design pass + slice family.

## Open before next session

- Confirm the threshold default (0.92 vs other) by running the dedup against a representative session's fragment corpus
- Decide Shape A vs B vs C for the first `ext import` slice (or defer until #202.3 ships and surfaces concrete contract requirements)
- Identify where the MCP gateway registry lives so Shape A's generated servers can register
