F3 — opencoti-server (Go companion daemon)
Parent: ../MASTER_PLAN.md Status: planning Owner: TBD Reference:
/shared/dev/claude-hooks(the inspiration; mirror its goals, then go further)
Problem
claude-hooks is a Python daemon that wraps Claude Code with a hook
API and adds persistent memory, multi-session coordination, tool
glue, and a small zoo of integrations. It works very well for Claude
Code. opencoti has the same shape of needs — and a few extra
because opencoti has its own tier engine, its own local llamafile,
and its own session model.
We want a Go daemon that is to opencoti what claude-hooks is to Claude Code, but deeper because we control opencoti's source. Instead of bolting onto a hook API from outside, opencoti-server can be a first-class peer of opencoti's runtime.
Goals
- G1. Single Go binary,
opencoti-server, that exposes a small, well-versioned API to opencoti. - G2. Persistent memory (vector + KG) shared across opencoti sessions on a host.
- G3. Multi-session coordination: opencoti CLI instances on the same host see each other, share context where the user wants it.
- G4. Tool glue: a place to register external tools (search, filesystem extensions, MCP-like) once and have them appear in all opencoti sessions.
- G5. Event sink for tier-engine routing decisions (so we can log, audit, and later learn from them).
- G6. Hook fan-out compatible with the existing claude-hooks ecosystem where it makes sense — but not bound by it.
Non-goals (for now)
- Replacing claude-hooks. opencoti-server is opencoti-specific.
- Becoming a generic "MCP host". MCP integration is a feature, not the architecture.
- Multi-host federation. Single-host first.
Design sketch
Where it lives
opencoti/server/— top-level non-bun source root. Go module. Putting it outsidepackages/keeps the bun workspace clean.packages/opencoti-server-client/— TypeScript client used by opencoti to talk to the server.- Surgical hooks in opencode session lifecycle (session start,
session end, turn complete, tool invocation, model call result):
each hook is a one-line "if a client is available, notify it".
Listed in
docs/protocols/UPSTREAM_SYNC.md.
Transport
Unix domain socket on Linux/macOS, named pipe on Windows. Loopback TCP as a fallback. HTTP/JSON wire format for simplicity (no gRPC build dependency for the client).
Daemon-launch policy (applies to opencoti-server and every opencoti daemon):
- Listen port MUST come from opencoti's reserved
47000-48000 range — never 38000-39000 or 18790-18811
(claude-hooks territory). Tentative reservation table to
keep adjacent ports clear of accidental collision:
- 47092 —
@opencoti/embedder(F4) - 47190 — opencoti-server daemon (proposed default; revisit when F3 M1 ships)
- 47191 — opencoti-server dashboard (proposed default)
- 47092 —
- First-run setup MUST ask the user explicitly where the
daemon should bind:
127.0.0.1(default), all interfaces, or a specific IP. The default port from the reservation table is proposed; the user may override either field within the 47000-48000 range. Setup writes the validated choice to the user's config.
Persistence
Two backends, picked per-deployment:
- pgvector — same as solidPC's
claude-hookssetup. Best when a Postgres is already on the host. - sqlite-vec — single-file, no external dependency. Default for fresh installs.
The backend choice is config; the memory API is identical.
Surface (illustrative)
POST /v1/sessions # register a session
POST /v1/sessions/:id/turns # record a turn
POST /v1/memory # store a memory (M2)
POST /v1/memory/search # vector recall (M2 — POST because the embedding is multi-KB)
POST /v1/memory/collections # collections CRUD (M2)
PUT /v1/memory/collections/:name/acl # per-session ACL (M2)
GET /v1/memory/count # count (M2)
POST /v1/memory/kg/entities # KG ops
POST /v1/memory/kg/relations
POST /v1/tier-events # log a tier-engine decision
GET /v1/tools # list registered tools
POST /v1/tools/:name/invoke # invoke (server-side glue)
GET /v1/healthz # M1; M2 extended with store_ok/store_path/embedding_dim/store_error
The server is embedder-agnostic at M2: clients pass vectors,
not text. The text→vector convenience (e.g.,
GET /v1/memory?q=<text> proxying to the embedder daemon) is a
deferred follow-up — adding it requires a runtime dep on
@opencoti/embedder (F4 M5) and is most naturally landed once
the TS client (M3) demonstrates the convenience is wanted at
the HTTP layer vs done client-side.
API is versioned (/v1) and the wire schema is owned by
packages/opencoti-server-client/ (so opencoti can rev independently
of the server binary, within compatible versions).
Mirroring claude-hooks goals
Same goals, mapped:
| claude-hooks | opencoti-server |
|---|---|
claude-hooks recall hooks |
implicit recall on /v1/memory query, surfaced by opencoti's prompt builder |
mcp__pgvector__* MCP tools |
/v1/memory (and a thin MCP shim if external MCP clients want it) |
| Stop hook auto-ingest | server records turn-complete events, applies same heuristics |
claude-hooks companion tools (Episodic etc.) |
first-class tools registered via /v1/tools |
Deeper integration than claude-hooks
Because we own opencoti, we can:
- Receive tier-engine decisions directly (provider chosen, escalation reasons, cost) without scraping logs.
- Receive diff and tool-output events as structured payloads, not parsed from a transcript.
- Push back: server can suggest a memory recall payload that opencoti injects directly into the prompt builder, instead of appending to a transcript.
Milestones
M1 — Server skeleton + healthz (2026-05-23 — shipped)
- Go module under
opencoti/server/(modulegithub.com/mann1x/opencoti/server, Go 1.22). Single dep:golang.org/x/sysfor the Windowssvcpackages. opencoti-server servebinds a UDS by default on Unix ($XDG_RUNTIME_DIR/opencoti/server.sockor~/.opencoti/server.sockfallback) ortcp://127.0.0.1:47190on Windows.--addr unix:///pathand--addr tcp://host:portoverride.- One HTTP endpoint:
GET /v1/healthzreturns 200 + JSON{status, version, started_at, uptime_seconds}. All other paths return a 404 envelope with code + message so the wire format is consistent for M2+.POST /v1/healthzreturns 405 with the same envelope. - Graceful shutdown on SIGTERM/SIGINT (Unix) or SCM Stop/Shutdown (Windows). UDS file unlinked on Unix shutdown.
- Windows service support shipped in M1 (per user directive
2026-05-23):
installsubcommand wraps SCM (golang.org/x/sys/windows/svc/mgr):--name,--display-name,--description,--start-type=auto|manual|disabled,--addr. Registers the Event Log source under the same name.uninstallremoves the SCM entry AND the Event Log source.start/stopwrapmgr.Service.Start/Service.Control(svc.Stop).stopwaits up to 10s for the state to become Stopped.- When
svc.IsWindowsService()returns true,serveenterssvc.Runwith a handler that translates SCM Stop / Shutdown into a gracefulhttp.Server.Shutdown. Log records flow to the Windows Application Event Log viaeventlog.Open(name). - Single source tree, build-tagged platform splits
(
*_windows.go/*_nonwindows.go). Same binary surface on every platform —install/uninstall/start/stopon Unix print "Windows-only command" and exit 2.
- 7 cross-platform tests (parser + listener + healthz/404/405
- UDS roundtrip + UDS unlink-on-cleanup + double-shutdown
safety) + 3 Windows-tagged tests for the
BuildMgrConfiginstall-config builder. All green on Linux; Windows cross-compile clean.
- UDS roundtrip + UDS unlink-on-cleanup + double-shutdown
safety) + 3 Windows-tagged tests for the
- Hook footprint: ZERO. M1 is purely additive — no
opencoti-hook:markers, no UPSTREAM_SYNC.md registry rows. The autostart hook lands in M3. - Deferred from M1: Windows named-pipe TRANSPORT (use TCP on Windows in M1; named-pipe support is a follow-up); systemd/launchd unit files on Unix; CI workflows (the repo has no CI yet — separate concern); the first-run setup wizard step ("install as Windows service?" — comes when the setup-flow picks up F3 concerns).
M2 — Memory backend (sqlite-vec) + recall API (2026-05-23 — shipped)
- Go SQLite stack:
github.com/mattn/go-sqlite3(cgo) +github.com/asg017/sqlite-vec-go-bindings/cgo, sqlite-vec statically linked viasqlite_vec.Auto()so the daemon ships as a single binary (novec0.soto bundle). CGO becomes a hard build requirement at M2; cross-compile to Windows from Linux needs MinGW-w64 (apt install gcc-mingw-w64-x86-64). internal/store/store.godefines aStoreinterface mirroring the TSMemoryStoreinpackages/opencoti-memory/src/types.ts1:1. M7's pgvector backend will implement the same interface, so the HTTP layer doesn't change.internal/store/sqlitevec/is the M2 backend. DDL mirrorspackages/opencoti-memory/src/schema.tsbyte-functionally: four tables (meta,collections,memories,session_acl) plus thememory_vecsvec0 virtual table;SCHEMA_VERSION=1;embedding_dimparameter-substituted into the vec0 DDL at first creation and stored inmetafor validation on later opens. Content idempotency: SHA256-hex withUNIQUE(collection_name, content_hash). PureResolveAccessModemirrors the TS resolver (global=r, session-owner=rw, others=none; explicitsession_aclrows override). Recall is vec0 MATCH + overscan (k * min(8, max(2, len(candidates)))) + join-back + ACL filter, truncated to k.- DB schema is wire-compatible with the in-process
@opencoti/memorypackage — a DB created by either side opens cleanly under the other. This is the load-bearing invariant for M3's path-B refactor. - HTTP surface (eight new routes, all under
/v1/memory/*):POST /v1/memory/collections— create (201 / 409 collection_exists / 400 invalid_collection_name).GET /v1/memory/collections?session_id=...— list, optionally filtered through the ACL resolver.DELETE /v1/memory/collections/{name}— cascade-deletes memories + vec0 rows + session_acl entries.PUT /v1/memory/collections/{name}/acl— set per-session ACL (r/w/rw/none).GET /v1/memory/collections/{name}/acl?session_id=...— read the resolved access mode.POST /v1/memory— store. 422 dim_mismatch on length mismatch; 403 write_denied on ACL deny.POST /v1/memory/search— vector recall (POST because a 1024-element Float32 array doesn't fit a query string).GET /v1/memory/count?collection=...— count, optionally scoped to one collection.
- All error responses use M1's
{error: {code, message}}envelope. Embeddings on the wire are JSON[float32, ...]arrays (verbose but trivial for the M3 TS client; base64 raw bytes is a follow-up if wire size measurably matters). /v1/healthzextended withstore_ok,store_path,embedding_dim, andstore_error(omitempty). When the store fails to open, the daemon still serves/v1/healthzwithstore_ok=falseand/v1/memory/*returns 503store_unavailable— partial degradation beats refuse-to-start so the daemon stays observable when something's wrong with the DB.servegets--db-path(default$HOME/.opencoti/memory/state.db— matches the TSdefaultDbPath()exactly so both implementations point at the same file by default) and--embedding-dim(default 1024, matchesDEFAULT_EMBEDDING_DIM).installbakes both into the SCM service-start arguments.- 30 tests cover the surface: 18 unit tests on the store
conformance (collection lifecycle, ACL resolver, idempotent
store, recall ordering, count, close+reopen, dim-mismatch
paths) + 12 HTTP integration tests via
httptest.NewServer(every endpoint, happy + 4xx + 5xx envelopes). testdata/crosslang/ships a Go↔Bun schema-compat probe: Go writes a DB → optionally invokes a Bun script that opens the same DB via@opencoti/memoryand confirms readback. Skips cleanly when bun or the workspace package isn't on PATH.make test-cross-langis always safe to run.- Hook footprint: ZERO (unchanged from M1). M2 is purely additive — the API exists but no surgical hooks into opencode yet.
- Deferred from M2: text→vector convenience
(
GET /v1/memory?q=<text>); streaming recall (SSE); pagination on list-collections; authentication for TCP transport (UDS owner-only on Unix is the M2 security boundary); pgvector backend (M7); migration tools (schemas are identical — the DB just opens); server-side embedder cache; bulk-store / batch endpoints; base64 raw-byte embedding wire format.
M3 — TS client + opencoti hook to start the server (2026-05-23 — shipped)
@opencoti/opencoti-server-clientpublished in-workspace underpackages/opencoti-server-client/. Single classOpencotiServerClientimplementsMemoryStoreover HTTP — all eight/v1/memory/*routes from M2 plus ahealthz()probe. Wire format is M2's verbatim: JSON[float32, ...]for embeddings; M1/M2's{error: {code, message}}envelope decoded into a sentinelOpencotiServerError({status, code, message})so callers can match oncode === "store_unavailable",dim_mismatch, etc. Per-requestAbortController+ 5 s default timeout.close()is a documented no-op (no per-instance handle to close).- Transport uses Bun's native
fetch—tcp://host:portis rewritten tohttp://...intoFetchBase;unix:///pathpasses through verbatim (Bun supports UDS fetch natively, no custom adapter needed).defaultAddress(platform)mirrors the GoDefaultAddress()exactly:$XDG_RUNTIME_DIR/opencoti/server.sockon Linux ($HOME/.opencoti/server.sockfallback), andtcp://127.0.0.1:47190on Windows. - Autostart lives in
src/autostart.ts:startAutostart({address, binaryPath?, dbPath?, embeddingDim?, ...})probes/v1/healthzfirst (200 →already_running), locates the binary viaBun.whichif none was passed (→no_binarywhen missing), spawns withstdio: "ignore"(mirroring the@opencoti/embeddermanager pattern), and polls/v1/healthzevery 200 ms until the deadline (default 5 s; →ready_timeoutif never recovers). Returns a discriminatedAutostartOutcomeso consumers can log five distinct states (already_running | spawned | no_binary | spawn_failed | ready_timeout) without exception handling. Test seams:spawnImpl,fetchImpl,whichImpl,sleepImpl. - Surgical hook footprint: 3 markers + 1 package.json dep. All
three TS markers land in
packages/opencode/src/config/config.ts(the same file that already carriesopencoti-default-plugins): the import next to it, theopencoti.server.*schema struct (autostart: boolean,address: string,binary_path: string,db_path: string,embedding_dim: PositiveInt), and the fire-and-forgetvoid maybeStartOpencotiServer(...)call site right after the existingapplyDefaultPlugins(...)call. The hook's full substance lives in@opencoti/opencoti-server-client/autostart-hookso the opencode-side surface stays minimal — three lines plus the package.json dep (no anchor comment in JSON, registered in the table). - Plugin swap. Both
@opencoti/memory-pluginand@opencoti/tui-memoryaccept a new optionalserver_address?: string. The defaultStoreFactory: ifserverAddressis set, instantiateOpencotiServerClient, callhealthz(), and return the client whenstoreOk === true; otherwise silently fall through to the existing in-processSqliteVecStore.open(...). The__setStoreFactorytest seam is unchanged — existing tests continue to inject stubs that ignoreserverAddress. - Health envelope mismatch detection. Client validates that
embedding_dimfrom the server matches the configured dim; on mismatch,healthz()returnsstoreOk: falsewith adim mismatcherror so the plugin falls back to the in-process store rather than corrupting the wire format. This is client-side; the server's own dim check still fires onPOST /v1/memory. - Tests. 56 new tests in the client package: 33 mocked-fetch
client conformance (every endpoint, happy + 4xx + 5xx envelopes,
dedup, dim mismatch, store_unavailable propagation), 11 pure
tests for
defaultAddress/toFetchBase/joinURL/probeReady, 6 mocked-spawnstartAutostartcases (already running, no binary, spawned-then-ready, ready timeout, spawn failed, --addr passthrough). Plus 2 new cases in the memory-plugin tests and 3 new in the tui-memory tests for theserver_addressswap path. All 23 workspace packages typecheck clean; 82 tests pass across the three affected packages. - Live verification. Manual end-to-end smoke against the
F3 M2 binary (TCP loopback):
healthzreturns the expected shape (store_ok: true,embedding_dim: 64),createCollectionsetSessionAcl(rw)+store(...)(withdeduplicated: falsethentrueon idempotency) +recall(...)(distance 0 on exact match) +count(...)(returns 1) +deleteCollectionall return the expected shapes. UDS path tested at the discover / autostart layer; full UDS smoke deferred to M4 when session-event hooks bring it under day-to-day use.
- Path-B loop closed. With M3 in, the F4 M5
in-process-then-daemon migration path described in
memory_embedder.md is mechanically
complete: setting
opencoti.server.address(orautostart: true) inopencode.jsoncis the only change a user makes to switch from the in-process store to the Go daemon. No code paths change in@opencoti/memoryitself. - Deferred from M3: detached / setsid daemon lifecycle
(M3's spawn dies with opencode — cross-session sharing
requires running the daemon externally, e.g. the M1 Windows
service installer or a future systemd unit); the first-run
"install as a service?" wizard step (a follow-up once the
setup-flow picks up F3 concerns); a text-
q=convenience endpoint (GET /v1/memory?q=<text>) — every M3 caller has the embedder in-process and can pre-embed; retry / backoff on transient HTTP errors (single attempt, the plugin's silent fallback handles the failure case); bulk-store / batch endpoints; TLS / shared-secret authentication for TCP transport (UDS owner-only remains the M3 security boundary).
M4 — Session + turn events flowing (2026-05-23 — shipped)
Design pivot vs the original spec. M4 was originally specced
as surgical hooks at session start/end/turn complete. Phase-1
exploration confirmed opencode's @opencode-ai/plugin API
already exposes those events fully typed via Hooks.event
(session.created/updated/deleted/error/idle plus
session.status). Following the precedent set by M5-D2
(@opencoti/memory-plugin), M4 ships as a plugin with zero
opencoti-hook footprint — strictly richer than a surgical hook
for this use case (typed payloads, zero upstream-source touch,
no UPSTREAM_SYNC.md row to maintain on every sync). Hook count
stays at 9 source markers + 4 JSON deps (unchanged from F3 M3).
- Per-feature schema versioning.
meta.schema_versionstays at 1 (memory tables — kept stable so M2/M3 TypeScript@opencoti/memoryclients still open M4 DBs cleanly). Newmeta.sessions_schema_version = 1is written by M4+ daemons and reported on/v1/healthz. M3 clients without the key treat the daemon as pre-M4; M4 plugins disable forwarding silently whensessionsSchemaVersion < 1. - Three new tables in the existing
state.db(no new--flag, no migration tool):sessions— soft-delete viadeleted_at. Indices onparent_id(fork-tree queries) anddeleted_at(cheapWHERE deleted_at IS NULL). Upsert is idempotent onid;INSERT ... ON CONFLICT(id) DO UPDATE SET ...overwrites all columns from the latest snapshot.session_events— append-only audit log.FOREIGN KEY ... ON DELETE CASCADEwould normally wipe history onDELETE FROM sessions; we use soft-delete instead so the audit trail outlives the session row.session_messages— UPSERT on opencodeMessageID. The plugin re-sends the full latest snapshot on every turn boundary; SQLite UPSERT handles dedup.
- SessionStore interface (
internal/store/store.go): 8 methods + sentinel errors (ErrSessionNotFound,ErrInvalidSessionID,ErrSessionPayloadTooLarge). The same*sqlitevec.Storeimplements bothStore(memory) andSessionStore; server.go type-asserts at each entry point. M7 pgvector will implement the same interface. - HTTP surface under
/v1/sessions/*:POST /v1/sessions— upsert (201 on insert, 200 on update, 400 invalid_session_id, 413 payload_too_large).GET /v1/sessions[?parent_id=&include_deleted=&limit=&offset=]— list, default newest-first byupdated_at, omitting soft-deleted rows.GET /v1/sessions/{id}/DELETE /v1/sessions/{id}— fetch / soft-delete.POST /v1/sessions/{id}/events/GET /v1/sessions/{id}/events[?type=&limit=&offset=]— append + list audit entries.POST /v1/sessions/{id}/messages/GET /v1/sessions/{id}/messages[?limit=&offset=&since=]— bulk-upsert (idempotent on message id) + list.GET /v1/sessions/{id}/turns— coarse server-side projection grouping messages by user-message boundaries. Authoritative turn-boundary logic stays in opencode'scompaction.ts; this view is for UI / debugging.
- healthz extension:
sessions_schema_versionfield added; M3 clients without the field treat the daemon as pre-M4. - Payload size guards: 8 MiB hard cap on
session_events.payloadand the bulk-upsert messages body (413payload_too_large); the plugin chunks at a softer 1 MiB default before posting. - TS client extension (
@opencoti/opencoti-server-client): 8 newOpencotiServerClientmethods (upsertSession,listSessions,getSession,deleteSession,appendSessionEvent,listSessionEvents,upsertSessionMessages,listSessionMessages,listSessionTurns). Wire format snake_case → camelCase via private mappers (same pattern as M2'smapHit/mapCollectionInfo).healthz()return type +ProbeResultgainsessionsSchemaVersion?: number. - New plugin
@opencoti/session-events-plugin: subscribes tosession.created/updated/deleted/error/idleandsession.status(idle transition) via opencode's typedHooks.event; does not subscribe tomessage.updated(fires thousands of times per turn — text deltas, tool calls, reasoning chunks — and forwarding every delta would melt the daemon). On idle, the plugin fetches the session's latest messages viainput.client.session.messages.listand bulk-upserts in byte-bounded chunks. Maintains a per-session merge cache so opencode's partialsession.updatedpayloads are reconciled into a full snapshot before reaching the daemon. Best-effort, never-throws: a forwarding plugin must not propagate daemon failures into the opencode session. - session.error before sessionID exists lands on a
synthetic
__pre_session_errors__session (lazily upserted on first such error) so the audit log captures pre-creation failures without losing context. - Auto-wire: one-line addition to
@opencoti/tiers/default-pluginsOPENCOTI_DEFAULT_PLUGINS. Any user with opencoti config inopencode.jsoncgets the plugin automatically; without anopencoti.server.addressthe plugin's healthz probe fails, the internal client staysundefined, and every event handler early-returns — same no-op-when-unconfigured ergonomic the memory-plugin uses. - Tests: 26 new Go tests (16 store unit + 10 HTTP
integration) — total Go test count climbs from 30 → 63.
14 new TS client tests (33 → 47). 16 new plugin tests in
@opencoti/session-events-plugin. All 24 workspace packages typecheck clean. - Live verification: end-to-end smoke against a real M4
daemon (TCP loopback) confirmed healthz reports
sessions_schema_version: 1, full upsert → update → get → list → events → messages → turns → soft-delete cycle works, soft-deleted session's events + messages remain queryable throughinclude_deleted=true. - Deferred from M4:
message.updatedreal-time forwarding — coalescing on idle is the right granularity; per-delta forwarding would mean O(text-deltas) HTTP roundtrips per turn.- Per-tool-invocation events as first-class rows —
captured today inside the message's
partsJSON; can be promoted to asession_tool_callstable later if needed. - WebSocket / SSE push from daemon → client — request-response only in M4.
- TLS / authentication for
/v1/sessions/*TCP transport — UDS owner-only on Unix remains the M4 security boundary.
M5 — Tier-engine event sink (shipped 2026-05-23)
Daemon side:
/v1/tier-events.POST /v1/tier-eventsappends a row (201; 400 invalid_tier_event; 413 payload_too_large at the same 8 MiB cap as/v1/sessions/*). Body:{event_type, ts?, session_id?, payload}wherepayloadis opaque JSON — the daemon does not introspect it.GET /v1/tier-events?session_id=&event_type=&since=&limit=&offset=returns the audit log newest-first.- One new
tier_eventstable:(id PK AUTO, session_id TEXT, event_type TEXT, payload TEXT, ts INTEGER)+ two indices(session_id, ts)and(event_type, ts).session_idis a soft reference — no FK, no cascade. Tier events fire intra-turn and can land beforesession.createdreaches the daemon; the soft reference guarantees no event is dropped. - Per-feature
meta.tier_events_schema_version = 1. The cross-language sqlite_vec contract continues to live onmeta.schema_version = 1(still M2-compatible with the TS@opencoti/memoryreader); the new key is independent.
TS plumbing.
@opencoti/opencoti-server-clientgainsappendTierEvent+listTierEvents(camelCase→snake_case wire mapping mirroringmapSessionMessage).healthz()surfacestierEventsSchemaVersion?: number; plugins use it as their feature gate.Design pivot continues: plugin path > surgical hook. The
Telemetryinterface in@opencoti/tiers(designed for this milestone — its docstring attelemetry.ts:1-4said so) is already threaded end-to-end throughruntime.ts:99 → executor.ts → escalator.ts → fanout.ts. M5 plugs a real sink into the existing seam via a new module-levelregistered-telemetry.tsslot (mirrorsactive-config.ts). Precedence chain inruntime.ts:99becomes:const telemetry = hook.telemetry ?? execOpts.telemetry ?? getRegisteredTelemetry() ?? noopTelemetryThe new
@opencoti/tier-events-plugincallssetRegisteredTelemetry(impl)during plugin boot when the daemon's healthz reportstierEventsSchemaVersion >= 1. Zero new opencoti-hook source markers — count unchanged from F3 M4 (9 source + 4 JSON deps + 3 prose). See the F1 page for where the Telemetry interface itself was added.Fire-and-forget posture. The plugin's Telemetry impl maps each method to a
client.appendTierEvent(...).catch(() => {})— synchronous to the caller, HTTP swallowed on failure. The tier engine never blocks on the daemon. Cf. M4's@opencoti/session-events-plugin, same design.Auto-wire via defaults.
@opencoti/tier-events-pluginis inOPENCOTI_DEFAULT_PLUGINSalongside the M4 session plugin. Any user withopencoti: {...}in their config gets the audit log for free; if the daemon is unreachable, the healthz gate fails and the runtime stays on noop.Cross-version compat. An M5 plugin against an M4 daemon sees
sessions_schema_version: 1but notier_events_schema_versionin healthz, sosetRegisteredTelemetryis never called and the runtime stays on noop. An M5 daemon against M4 clients keeps shipping the same/v1/sessions/*surface untouched.Explicitly deferred to later: aggregation endpoints (
/v1/tier-events/stats?...), real-time push (WS/SSE), server-side retention policy, client-side batching/coalescing, FK onsession_id. M5 ships raw events + filters; everything on top of that is composable later.
M6 — Tool registry + invocation (shipped 2026-05-23)
Daemon side:
/v1/tools.GET /v1/toolslists every registered tool sorted by name:{ tools: [{ name, description, params_schema, handler, created_at, updated_at }] }.params_schemais a string-serialised JSON Schema;handleris a stable opaque dispatch ID (debug-only — invocation always goes by name).GET /v1/tools/{name}fetches one (404 tool_not_found).POST /v1/tools/{name}/invokeruns it. Body{ arguments: {...}, session_id? }→{ result: {...} }(200) or{ error: { code, message } }(400 invalid_arguments / 404 tool_not_found / 500 handler_failed / 413 payload_too_large at the same 8 MiB cap as/v1/sessions/*and/v1/tier-events).- One
toolstable:(name PK, description, params_schema, handler, created_at, updated_at). Process-scoped, no session FK. Dispatch is a daemon-internalmap[string]ToolHandlerintoolhandlers.go; no external registration in M6 (no POST/DELETE on/v1/toolsitself) — the catalog is seeded into the binary at boot. - Per-feature
meta.tools_schema_version = 1, independent of the cross-languagemeta.schema_version = 1(still M2-compatible with the TS@opencoti/memoryreader).
Two seed tools are
UpsertTool'd at startup (idempotent —created_atfixed on first insert,updated_atbumps on re-seed):opencoti_episodic_search— lexicalLIKEsearch across the M4session_messagestable joined withsessions, newest-first. Params{ query, limit?, session_id_excludes? }. The exclude list lets the plugin pass the live session ID so the model recalls other sessions, not echoes of the current one. The integrated analog of claude-hooks'sepisodic_server— same SQLite file the sessions API writes, no second daemon, no shell-out. FTS5/vector ranking is an M7+ optimisation.opencoti_recall— vector recall over the M2 memory store, delegating to the sameStore.Recallpath/v1/memory/searchuses. Params{ embedding, collection?, k?, session_id? }. Takes a pre-computed embedding, not a query string — the daemon bundles no embedder, so the caller (the plugin, or an operator via curl) embeds the query and POSTs the float vector; length must equal the daemon'sembedding_dim.session_iddrives the M2 per-session ACL check. Coexists intentionally with@opencoti/memory's in-process__memory_recall: that one survives a daemon being down;opencoti_recallgives the same surface to plugins without direct DB access.
TS plumbing.
@opencoti/opencoti-server-clientgainslistTools+getTool+invokeTool;healthz()surfacestoolsSchemaVersion?: numberas the plugin's feature gate.ServerToolis camelCase (paramsSchema←params_schema),paramsSchemaleft asunknown(interpreted in the plugin).Design pivot continues: plugin path > surgical hook. A new module-level
registered-server-tools.tsslot in@opencoti/tiers(mirrors M5'sregistered-telemetry.ts) holds the daemon tools as aRecord<string, Tool>;runtime.tsmerges it last into the tier tool list, next to synthetic-tier and memory-bridge tools:const tools = mergeTools(hook.prepared.tools, { ...syntheticTools(hook.input.sessionID, telemetry), ...memoryTools, ...(getRegisteredServerTools() ?? {}), })Server tools merge last so user-explicit + memory tools win on a name collision (none expected — server tools are
opencoti_*, memory__memory_*, synthetic-tier__tier_*). Zero new opencoti-hook source markers — count unchanged from F3 M4/M5 (9 source + 4 JSON deps + 3 prose).The
@opencoti/server-tools-pluginprobes/v1/healthz(gated ontoolsSchemaVersion >= 1), fetches/v1/toolsonce on boot, and builds an AI SDKdynamicToolper entry:params_schemaround-trips throughai'sjsonSchema()(no JSON-Schema-to-Zod reimplementation); eachexecuteforwards to/v1/tools/{name}/invoke. Tool names are prefixed (opencoti_, idempotently). The session ID arrives viaexperimental_contexton the singleopenStreamstreamTextcall and is forwarded assession_id. Invocations are awaited (not fire-and-forget — tool calls are model-blocking), but errors crash the tool call, not the session: 404→tool_not_found, 400→invalid_arguments, 5xx/network→tool_handler_failed, deadline→timeout(plugin-ownedPromise.race, separateinvoke_timeout_ms). Args over 1 MiB are pre-rejected client-side.Auto-wire via defaults.
@opencoti/server-tools-pluginis inOPENCOTI_DEFAULT_PLUGINS. No reachable daemon → healthz gate fails →setRegisteredServerToolsnever called → runtime tool set unchanged.Cross-version compat. An M6 plugin against an M5 daemon sees no
tools_schema_versionin healthz, so the gate fails and the tool set is unchanged — a model query expecting the tool gets "I don't have that tool", no crash. An M6 daemon serves M5 clients the/v1/sessions/*and/v1/tier-eventssurfaces untouched.Explicitly deferred: external tool registration (POST/DELETE on
/v1/tools— needs a per-tool ACL/owner model); streaming tool results; tool invocations astier_eventsaudit rows; an MCP wrapper for the registry; FTS5/vector ranking inepisodic_search; an in-daemon embedder soopencoti_recallcan take aquerystring directly.
M7 — pgvector backend parity (shipped 2026-05-23)
opencoti-server gains its second storage backend: PostgreSQL +
the pgvector extension, via github.com/jackc/pgx/v5 (pure Go, no new
cgo). *pgvector.Store implements the full interface family —
Store + SessionStore + TierEventStore + ToolStore +
SchemaInspector — so the HTTP layer, /v1/healthz, and the TS client
are untouched. sqlite-vec stays the zero-dependency default;
pgvector is for hosts that already run Postgres.
Schema-phrasing correction. The original M7 stub said "/v1/memory
works against pgvector with the same schema as claude-hooks." That
predated the M4–M6 buildout. opencoti's binding contract is now its
own Store interface (collections + per-session ACL + sessions +
tier_events + tools), which is a different data model from
claude-hooks's flat memories + kg_* schema. So M7 implements
opencoti's own model on a dedicated opencoti Postgres database,
isolated from claude-hooks. "Same as claude-hooks" is reread as same
storage technology (Postgres + pgvector), not the same tables.
internal/store/pgvector/mirrorsinternal/store/sqlitevec/table-for-table in PG dialect, with three dialect differences:- the embedding lives inline on
memoriesas avector(dim)column (no separate vec0 virtual table); an HNSWvector_l2_opsindex is created whendim ≤ 2000(pgvector's HNSW ceiling) — correctness-neutral, perf-positive. Recallpushes the ACL filter into the query (WHERE collection_name = ANY($readable) ORDER BY embedding <-> $q LIMIT k) — Postgres can filter + rank in one statement where sqlite-vec must overscan + join-back + post-filter. The<->operator is L2, matching sqlite-vec's vec0 default, soMemoryHit.Distancestays comparable across backends.Storededups viaINSERT … ON CONFLICT (collection_name, content_hash) DO NOTHING RETURNING idin one round trip.
- the embedding lives inline on
- Per-feature schema versions (
schema_version,sessions_/tier_events_/tools_schema_version, all1) live in ametakey/value table, validated on open exactly as the sqlite-vec backend does — but they are pgvector's own contract (no cross-language reader, since pgvector is daemon-only). - Shared conformance harness
internal/store/storetest/is the parity guarantee: a singleRunConformancebody (Memory, Sessions, TierEvents, Tools sub-suites) that both backends opt into via a thinconformance_test.go. Either backend drifting from the contract fails the same assertions. sqlite-vec runs it witht.TempDir; pgvector with a testcontainers fixture. - Pure-logic lifted to
internal/store/common.go(packagestore): the ACL resolver (ResolveAccessMode/CanRead/CanWrite), the collection/session validators,Sha256Hex, and the dim bounds — one cgo-free source of truth both backends share. sqlite-vec keeps its exported names as thin delegating wrappers, so the HTTP layer'ssqlitevec.ValidateCollectionName/ValidateSessionIDcall sites are byte-identical. - Backend selection is a serve-time flag, not a build flag:
--backend sqlite|pgvector(defaultsqlite) +--pg-dsn(falling back to$OPENCOTI_PG_DSN).--db-pathstays sqlite-only;--embedding-dimapplies to both (locks thevector(dim)column). The interface var is assigned only on a successful open (avoids the typed-nil trap), so a pgvector open failure degrades tostore_ok=false+ a clearstore_errorrather than crashing. The Windowsinstallcommand bakes--backend/--pg-dsninto the service args alongside the existing flags. - healthz reports a redacted
store_pathfor pgvector (pg://user@host:port/db, never the password). - Tests:
pgtestspins an ephemeralpgvector/pgvector:pg17container via testcontainers-go and skips cleanly when Docker is absent (testcontainers.SkipIfProviderIsNotHealthy), somake teststays green on a Docker-less host. Both backends pass the full shared conformance suite; an end-to-end smoke (daemon → pgvector container) confirms collection create, store, and<->- ranked recall over HTTP. - Toolchain: the modern pgx / pgvector-go / testcontainers-go
releases require Go 1.25, so the module's
godirective and the host toolchain moved to go1.25 (latest stable). No build-posture regression — pgx is pure Go; sqlite-vec's existing cgo requirement is unchanged. - Zero new surgical hooks. pgvector is additive Go inside
opencoti/server/; no TS changes. The surgical-hook grep count stays at 18 (seedocs/protocols/UPSTREAM_SYNC.md).
Deferred from M7: data migration between backends (a migrate
subcommand — "Migration tools later"); a TS pgvector backend for the
in-process @opencoti/memory (pgvector is daemon-only); external tool
registration over HTTP (still security-gated, from M6); pgxpool tuning
/ read replicas.
M8 — Multi-session coordination (shipped 2026-05-23)
Goal G3: opencoti instances/sessions on one host see each other and
cooperate. M8 ships the live-coordination half as an in-memory,
ephemeral hub — deliberately store-independent (neither the Store
interface nor either backend is touched), because presence and locks are
runtime state that should not survive a daemon restart.
internal/coord.Hub— backend-agnostic, onesync.RWMutex:- Presence:
RegisterPeer/Heartbeat/DeregisterPeer/ListPeers, with a background TTL sweep that reaps peers whose heartbeat lapsed (default 45s) and emitspeer.left.ListPeersalso filters expired peers lazily. - Broadcasts: a pub/sub bus —
Publishassigns a monotonicseq, appends to a bounded replay ring, and fans out non-blockingly to subscribers (a full subscriber channel is dropped + closed so the client reconnects with its last seq).Subscribe(since)atomically snapshots the replay backlog and registers for live events. - Advisory locks: try-only
AcquireLock(reentrant-by-holder refresh; expired locks reclaimable) /ReleaseLock(holder-checked) /ListLocks. Lock transitions emitlock.acquired/lock.released.
- Presence:
HTTP (
internal/server/coord.go), hub injected viaOptions.Hub, 503coord_unavailablewhen absent:Method + path Purpose POST /v1/coord/peersregister/upsert presence → PeerInfo POST /v1/coord/peers/{id}/heartbeatrefresh TTL DELETE /v1/coord/peers/{id}deregister GET /v1/coord/peerslist live peers POST /v1/coord/broadcastpublish {peer_id,topic,payload}→{seq}GET /v1/coord/events?since=&peer_id=SSE event stream (the daemon's first) POST /v1/coord/locks/{name}acquire {holder,ttl_ms?}; 200 or 409lock_heldDELETE /v1/coord/locks/{name}release {holder}; 200 / 404lock_not_held/ 409lock_not_holderGET /v1/coord/lockslist held locks SSE is viable because the
http.Serversets noWriteTimeout; the handler exits on request-context cancellation so graceful shutdown releases it within the grace window./v1/healthzgainscoord_ok+coord_peers.TS client:
registerPeer/heartbeatPeer/deregisterPeer/listPeers/broadcast/acquireLock/releaseLock/listLocks, plussubscribeCoordEvents— the client's first streaming method (readsresponse.body, parsesdata:frames).@opencoti/coordination-plugin: registers each session as a peer, heartbeats while active, deregisters on delete, subscribes to the event stream (SSE) to keep a live peer view, and advertises "N other active opencoti session(s)" in the system prompt. Auto-wired via the@opencoti/tiersdefault-plugins list — no surgical hook (grep count stays 18).
Deferred to F3 M9: opt-in shared context (a thin convention atop
existing M2 global collections + per-session ACL — a session opts to
expose a collection to peers); the sqlite↔pgvector migrate subcommand
(open from M7); blocking/queued lock acquire (M8 is try-only). Cross-host
federation remains an explicit F3 non-goal.
M9 — Opt-in shared context (shipped 2026-05-23)
Goal G3, the persisted-context half: a session exposes one of its
session-scoped memory collections to peer sessions on the same host. The
storage model from M2 already supports the grant, so M9 adds no new
Store method, no new schema, no new surgical hook (grep count stays
18). It is a thin convention bridging two things that already exist — the
persisted per-session ACL (SetSessionACL) and M8's ephemeral coord bus.
internal/share.Manager— in-memory registry + one goroutine:Share(collection, owner, mode)records the share, grants read-ACL to every live peer!= ownerviaSetSessionACL, and publishescollection.sharedon the hub.- It subscribes to the hub and, on
peer.joined, grants every active share to the newcomer — so peers that join after a share still get access (auto-grant via the bus, dogfooding M8's SSE). Unshare(collection, owner)revokes live peers (writes modenone, the non-owner default) and publishescollection.unshared.- Persisted vs ephemeral: the ACL grants persist (they survive a restart); the "keep auto-granting new joiners" intent is in-memory and lost on restart by design — existing grants remain, but the owner must re-share to resume auto-granting. This keeps M9 storage-free.
Recall "just works": once a peer holds an
rgrant, its no-filterRecallincludes the shared collection automatically (M2's readable-collections resolution), so no recall-path change is needed.HTTP (
internal/server/share.go), Manager constructed inserver.New()whenStore+Hubare present, released onShutdown; 503share_unavailablewhen the hub is absent:Method + path Purpose POST /v1/memory/collections/{name}/shareowner-only share {owner_session, mode?}(mode defaultr) →{granted}DELETE /v1/memory/collections/{name}/sharewithdraw {owner_session}→ 200GET /v1/memory/shareslist active shares Owner-only: the handler looks the collection up via
ListCollections(no filter) and requiresscope == sessionandsession_id == owner_session; global collections (alreadyr-for-all) are rejectednot_shareable, a different ownernot_owner(403).TS client:
shareCollection/unshareCollection/listSharesSharedCollectionInfo.
@opencoti/coordination-plugin(extended in place — no new plugin): consumescollection.shared/collection.unsharedto advertise peer-shared collections in the system prompt, and gains an opt-inshare_session_collectionflag (default false) that shares the session's own collection (sessionCollectionName(id)) onsession.createdand unshares it onsession.deleted(best-effort; a not-yet-created collection's 404 is swallowed).
Deferred to F3 M10: the sqlite↔pgvector migrate subcommand — not
a cutover: both backends are first-class and may run in parallel; migrate
is an idempotent, re-runnable, either-direction copy (--from/--to,
dedupe on content_hash, upsert PKs) that tops up a parallel target,
never abandoning the source. Also deferred: revoke-on-peer-leave cleanup
(M9 keeps grants on peer.left, since peers may return) and wildcard /
group ACLs.
M10 — sqlite↔pgvector migrate (shipped 2026-05-24)
A daemon-internal migrate subcommand that copies data between the two
first-class backends. It is not a cutover: both backends stay
first-class and may run in parallel (two daemons, or alternating
--backend). migrate is an idempotent, re-runnable, either-direction,
selectable copy that tops up a target (dedupe on natural keys), never
"move then abandon source". Additive Go only — no plugin, no HTTP
surface, no TS client, no surgical hook (grep count stays 18).
Use cases (maximum flexibility):
- Scale-up (primary). Start on sqlite; migrate everything
(collections, memories+embeddings, ACL, sessions, session_events,
session_messages, tier_events, tools) into pgvector; then switch the
primary backend by changing the serve flag to
--backend pgvector. "Switch primary backend" is operational, no extra code: run the full migrate, then change--backend. - Way back. Same command with
--from/--toswapped. - Single memory container / additional partial backend. Copy only
specific collection(s) with
--collection, so e.g. pgvector holds just certain memories while sqlite keeps the rest. The two backends coexist, each holding different data. - Additive top-up. Re-running, or copying into a populated target, merges idempotently (natural-key dedupe).
Selection model — two orthogonal selectors:
--include <csv>of sections:memory,sessions,tier-events,tools. Default (unset) = all four (full dataset). Thememorysection carries collections + their memories + their ACL rows.--collection <name>(repeatable) restricts thememorysection to those collections only. When--collectionis given and--includeis unset, the default narrows to memory-only (the single-container case).
So: full switch = no selectors; single container = --collection notes;
logs-only = --include sessions,tier-events.
Copy phases (FK-respecting; each gated by the selection):
| Section | Phase | Export | Import |
|---|---|---|---|
| memory | collections | ListCollections (filtered) |
PutCollection |
| memory | memories | ListMemories (per coll, keyset-paged) |
PutMemory |
| memory | session_acl | ListSessionACLs |
SetSessionACL |
| sessions | sessions | ListSessions(+deleted) |
UpsertSession |
| sessions | session_events | ListSessionEvents/sess |
PutSessionEvent |
| sessions | session_messages | ListSessionMessages/sess |
UpsertSessionMessages |
| tier-events | tier_events | ListTierEvents (batched) |
PutTierEvent |
| tools | tools | ListTools |
UpsertTool |
Both backends are the same concrete *Store implementing every feature
interface, so migrate opens each side as storepkg.Store, type-asserts
the optional MigrationStore, and copies a section only if both sides
implement it. Per-phase counts (scanned / inserted / skipped) print at the
end; --dry-run reads sources and reports would-copy counts, no writes.
Idempotency / dedup. Memories dedupe on (collection_name, content_hash). The append-only audit logs (session_events,
tier_events) have no natural key (autoinc id only), so their
idempotent import dedups on an insert-if-no-identical-row check (all
business columns match) — re-running produces no duplicates. A second full
run reports 0 inserts everywhere.
Two backend-specific wrinkles (both resolved):
- sqlite-vec embeddings are an opaque BLOB. vec0
memory_vecsstores the embedding as a blob and the Go binding shipsSerializeFloat32but no deserialize. The on-disk format is plain little-endianfloat32(binary.Write(buf, LittleEndian, vector)), sodeserializeFloat32reverses it withbinary.Readat the locked dim — byte-faithful, no re-embedding. pgvector exports viapgvector.Vector.Scan+.Slice(). - The public
Store()write path enforces ACL (a global collection isr-for-all →ErrWriteDenied), so migrate cannot reuse it for imports. The newMigrationStore.PutMemoryis ACL-free and timestamp- preserving.
Fidelity contract (documented caveat). Preserved exactly: collection
created_at, memory ts+content+embedding (byte-identical), session
created_at/deleted_at, message created_at/finished_at, event
occurred_at/ts, all ACL modes. Rewritten to migrate-time (reused
upserts stamp now()): sessions.updated_at, tools.created_at/
updated_at — acceptable "last-written" fields (tools are also re-seeded
at startup). A byte-identical PutSession/PutTool is deferred.
The new MigrationStore optional interface (in store.go, mirroring the
SchemaInspector/SessionStore convention) is exercised by the
storetest conformance harness on both backends — the round-trip
asserts the exported embedding is byte-identical, validating the sqlite LE
deserialize and the pgvector scan together. Verified end-to-end across a
sqlite→pgvector→sqlite hop: recall on the round-tripped file returns
distance 0 for the exact source vector.
Deferred to F3 M11+: continuous-sync / daemon mode (migrate is
one-shot CLI only), cross-host federation (explicit F3 non-goal),
per-session selective log copy (selective granularity is per-collection
for memory + section toggles for the rest), and the byte-identical
updated_at/tool-timestamp imports noted above.
Open questions
- Do we ship the server inside the opencoti binary or alongside it? Alongside (separate binary) is cleaner; explore a single-fat-binary option as an opt-in.
- Authentication. Local UDS owner is the user; over loopback TCP we need a shared secret. Default to UDS.
- claude-hooks coexistence. If a user runs both Claude Code (with claude-hooks) and opencoti (with opencoti-server) on the same host, the two memories should not collide. They use different stores and different schemas by default; a separate migration tool can bridge them if the user wants.
Risks
- Two persistence backends doubles the test matrix. Mitigation: a storage trait + a shared conformance test.
- Drift from claude-hooks features. Mitigation: don't try to replicate feature-for-feature; replicate goals. claude-hooks remains the reference for Claude Code; opencoti-server can diverge where it makes opencoti better.