Documentation Index
Fetch the complete documentation index at: https://docs.crewship.ai/llms.txt
Use this file to discover all available pages before exploring further.
Memory Provider interface
Provider (internal/memory/provider.go) is the formal contract for pluggable memory backends. Today Crewship ships one implementation — LocalDispatcher, which wraps the on-disk dispatcher behind the interface. The interface exists so a future HTTP-backed external store can slot into the same wire surface the orchestrator and sidecar already speak, without touching agent call sites.
This page is for developers building a new provider. Operators looking for how the on-disk default behaves should read Agent memory instead.
Why a formal interface
Before the interface, every external memory backend would have to forkinternal/memory/dispatcher.go and re-wire the four tool handlers (memory.read / memory.write / memory.search / memory.append_daily) against its own SDK. The dispatcher then becomes the integration boundary instead of a clean contract.
Provider flips that:
- The dispatcher remains the on-disk path (call sites unchanged)
- New backends implement four methods (
Retain/Recall/Forget/Health) against the interface - The orchestrator picks a Provider at boot from config — no per-call branching needed
- Tests can stub the interface without spinning up a real backend
LocalDispatcher proves the shape fits the existing dispatcher logic, but production call sites still route through *Dispatcher directly. The swap to provider-routed call sites lands as PR-F17 once the second implementation (PR-F18) is ready to switch between.
The four methods
Retain — persist content
- Flat struct, no nested option bags. New fields require deciding which bag they live in; flat ages better.
Modeis required, no default. A “default mode” hides intent at the wire — when the operator inspects logs they should see exactly what was requested. Local impl returns an error for an empty mode; remote impls should too.Tieris the closed enum from the dispatcher. Adding a tier requires extendingvalidTiersintools.goAND every provider that switches on tier — a tier nobody implements is worse than a tier nobody declares.Keyis tier-specific. Required fordaily(date) andpeers(user slug); ignored elsewhere. Providers validate at the boundary; the local impl errors on missing key for these tiers.Contentis plain UTF-8. Binary payloads use the existing blob-store path, not the memory dispatcher.
ID is the canonical handle for Forget(byID). For LocalDispatcher the ID is the tier-relative source label (e.g. "AGENT.md", "daily/2026-05-21.md") — stable across reads on the same disk, and the same shape the model sees in memory.read metadata. Remote providers should pick whatever stable id their backend uses (a row id, a content-hash, a path).
Recall — search snippets
Tierempty means “all accessible tiers” — providers iterate the tiers the caller’sAgentIDcan read.Limitis advisory. Providers SHOULD return fewer thanLimitwhen the corpus is small; they SHOULD cap at 20 to match the dispatcher’s existing JSON Schema (the model has been trained on the cap).Scoreis 0..1 and provider-specific. Local impl returns 1.0 for substring hits (no ranking signal). Vector providers return cosine similarity. BM25 providers return rescaled BM25. Don’t compare scores across providers.Snippetis the matched excerpt the model sees in the tool result. Keep it short (≤500 chars) — the model has theSourceif it wants to fetch the full content viamemory.read.Sourceis the tier-relative label, never an absolute filesystem path (leaking bind-mount layout to the model is a small but real info-disclosure surface — seetools.go::pathToSourceLabelfor the canonicalisation contract).Quarantinedis for files the inbound prompt-injection scanner rejected (Layer 5 — memory prompt-injection scanner). Surface these in the operator UI but NEVER feed them back to the model — the model would re-read poisoned content into its context.
Forget — delete
- Two selectors, mutually exclusive.
IDis for the operator-driven “delete this specific row” path.DataSubjectIDis the GDPR Article 17 cascade path (PR-F1). Mixing them is rejected — the caller knows which they want, and round-9 hardening rejects the both-set case explicitly with a “must set exactly one” error. - The on-disk LocalDispatcher rejects
DataSubjectIDwith an explicit “cascade not implemented in local provider; use the PR-F1 API endpoint” error because the on-disk tier doesn’t carrydata_subject_idforeign keys. GDPR cascade goes through the SQL handler (internal/api/admin_gdpr.go) which queries the cascade-aware tables directly. This is documented behaviour — local-disk providers should return the same error. Removedreports items actually removed. Already-absent items count as 0 (per-id) or contribute to the total based on what the provider actually touched. Re-issued DELETE for an already-deleted item returnsRemoved: 0, error: nil(no-op semantics, not a failure).
Health — liveness check
- Health MUST return promptly — under 200ms for local, under 1000ms for remote. A stuck Health() blocks the operator’s aux-status panel render. Implementations SHOULD use a hard deadline (
context.WithTimeout(ctx, 1*time.Second)internally) and returnOK=false, Message="health check timeout"rather than block. Messageis for failure context only. OnOK=true, leave it empty. OnOK=false, give enough text for the operator to triage (“connection refused”, “auth expired”, “503 from upstream”).CheckedAtlets the panel show “last successful check N seconds ago” without piggy-backing on the framework’s clock — each provider stamps its own observation time.- A provider identifier (e.g.
"local-dispatcher","http://memory.internal:9090") lives in the workspace’s provider config, NOT on the HealthStatus — the panel knows which provider it’s asking about.
Reference implementation — LocalDispatcher
internal/memory/provider.go ships LocalDispatcher, the on-disk wrapper:
LocalDispatcher just translates Provider requests into ToolCall invocations and back into typed results. Look at TestLocalDispatcher_Retain_PersistsToDisk etc. for shape examples.
The wrapper exists for two reasons:
- It proves the Provider interface fits the existing dispatcher logic without forcing a rewrite — if the on-disk impl didn’t fit, we’d need to widen the interface
- It gives tests a stub seam — code that depends on
Providercan useLocalDispatcherin unit tests without spinning up a remote backend, and remote-provider tests can use the same dispatcher fixtures the local one uses
Building a new provider
Step-by-step for a new HTTP-backed external store:1. Define the type
2. Implement the four methods
Each method translates between the Provider wire shape and the upstream API. Be defensive about upstream:- Wrap upstream errors so callers can
errors.Is(err, ErrUpstreamTimeout)— don’t leak raw HTTP error strings - Implement timeouts via the passed
ctx; don’t trust upstream to time out cooperatively - Return
OK=falsefromHealthrather than panicking on a network blip — the operator can see the panel and react
3. Wire into the bootstrap
The orchestrator picks a Provider at boot based on config. For a new provider, add:crewship.yamlconfig block (e.g.memory.provider: foo+memory.foo.url:etc.)- A factory in the bootstrap that constructs the provider from config
- A test that exercises Retain → Recall → Forget round-trip against a mock upstream
4. Run the parity test suite
Crewship will ship a provider-parity test suite (PR-F17 deliverable) — every provider runs the same fixture suite and must produce equivalent results for the same input. Until then, mirror the tests ininternal/memory/provider_test.go and verify your provider passes them all (substituting your upstream URL for the on-disk path).
5. Document the operator-facing behaviour
A new provider needs:- A line in Agent memory under “Backends” naming the new option
- A config block reference in Configuration → Providers
- A threat-model note if the provider changes any tenant-isolation or data-locality property
Versioning
The interface is append-only. New methods, new fields on existing request/result structs, new error types — all OK provided existing implementations keep compiling and behaving the same for inputs that don’t carry the new field. Breaking changes (renaming methods, removing fields, changing the meaning of an existing parameter) require a major version bump on the interface AND a parallel old-shape interface for backwards compatibility during the transition. The bar for breaking is high — every external provider has to be updated in lockstep. The opposite trap is “let’s just add a*Options bag and stuff new flags in there.” Don’t. The interface owns the contract, not the option struct — a new flag in *Options is the same breaking surface as a new method, except harder to spot in code review.
When NOT to add a provider
A new provider is the right answer when:- The data needs to live in a backend that has its own SLA, security perimeter, or query model (vector recall, conversation history, GDPR-tagged enterprise store)
- The operator already runs that backend for other reasons and wants Crewship’s agents to share it
- You want to add a new memory tier alongside AGENT / CREW / PERSONA / etc. — that’s a
validTiersextension in the dispatcher, not a provider - You want to add a new memory tool alongside
memory.read/memory.write/ etc. — that’s aToolSchemasaddition in the dispatcher - You want the local backend to behave slightly differently — that’s a fork of
LocalDispatchernot a new provider
Cross-references
internal/memory/provider.go— the interface + reference implinternal/memory/provider_test.go— the test suite shape- Agent memory — operator-facing memory model
- Configuration → Providers — where providers get wired
- GDPR cascade — how
Forgetinteracts with Article 17