The arthack codebase has a centralized vector daemon (vectorctl) that manages all ChromaDB operations via ZMQ IPC. Two major consumers — knowctl and claudectl — delegate all embedding, ingestion, and search through this daemon. The QMD server on artbird is a completely different system: a Bun/TypeScript search engine (@tobilu/qmd) with BM25 + vector + HyDE search, file-based collections, and its own GGUF embedding model. knowctl still uses vectorctl's ChromaDB backend for embeddings and search. The migration means replacing the vectorctl daemon + local ChromaDB stack with QMD as the universal search backend.
vectorctl is a ZMQ IPC daemon that keeps the SentenceTransformer model warm in memory and handles all ChromaDB operations for every consumer:
| Action | Protocol | Timeout |
|---|---|---|
semantic_search |
ZMQ request/reply | 30s (3 retries) |
write_documents |
ZMQ (slow queue) | 120s |
delete_documents |
ZMQ (slow queue) | 30s |
embed_documents |
ZMQ request/reply | 120s |
count_documents |
ZMQ request/reply | 30s |
ping / status |
ZMQ request/reply | 5s |
Embedding model: all-MiniLM-L6-v2 (SentenceTransformer, 384-dim)
Socket: ~/.local/share/vectorctl/daemon.sock
LaunchAgent: arthack.vectorctl.sync-vectors.plist
| Aspect | Detail |
|---|---|
| ChromaDB path | ~/.local/share/knowctl/chromadb/ |
| Collection | documents (single unified collection for all topics) |
| Chunking | chonkie RecursiveChunker, 768 chars, 128 overlap |
| Metadata | topic, path, chunk_index, source_url, ordinal_id, source_type |
| Client code | knowctl/daemon_client.py → cli_common/vectorctl_client.py → vectorctl daemon |
| Direct chromadb imports | helpers.py (cleanup), run_list_documents.py, run_show_content.py (ChromaDocumentStore queries) |
| Aspect | Detail |
|---|---|
| ChromaDB path | ~/.local/share/claudectl/data/chromadb/ |
| Collection | sessions (all source types in one collection) |
| Chunking | Custom: 1400 chars, 180 overlap, message-boundary-aware |
| Source types | session, subagent, sessions-index, agent-cache, todo, plan |
| Metadata | source_type, session_id, agent_id, project_dir_name, project_path, timestamp, session_scope, slug, role, cwd, git_branch |
| Client code | claudectl/daemon_client.py → cli_common/vectorctl_client.py → vectorctl daemon |
| Direct chromadb imports | search_helpers.py (SharedSystemClient for memory management between batches) |
| Sync daemon | arthack.claudectl.sync-sessions.plist — triggers sync every 120s |
| Aspect | Detail |
|---|---|
| What it is | @tobilu/qmd — a Bun/TypeScript search engine |
| Runs on | artbird |
| Ports | 8181 (MCP), 8182 (web UI) |
| Search modes | lex (BM25), vec (vector), hyde (HyDE), expand (query expansion) |
| Embedding model | GGUF via node-llama-cpp (on-device, possibly GPU) |
| Collections | File-based: directories with qmd.yaml manifests in /home/artbird/collections/ |
| CLI commands | qmd query, qmd search, qmd get, qmd collection list/add/remove, qmd update, qmd embed |
| API | Bun HTTP server: /api/query, /api/search, /api/get, /api/multi-get, /api/status, /api/health |
| Deployment | qmdctl push-deploy (rsync + restart), qmdctl provision-service (setup) |
| Monitoring | qmdctl check-health (5min LaunchAgent), Telegram notifications |
| Auto-reindex | qmdctl watch-collections daemon watches filesystem, throttled 30s |
knowctl ──→ daemon_client ──→ vectorctl_client ──→ [ZMQ] ──→ vectorctl daemon ──→ ChromaDB (local)
↑
SentenceTransformer
all-MiniLM-L6-v2
claudectl ──→ daemon_client ──→ vectorctl_client ──→ [ZMQ] ──→ vectorctl daemon ──→ ChromaDB (local)
QMD server (artbird) ────→ qmd CLI ────→ GGUF embeddings + file-based collections
↑
qmdctl (deployment/ops only, not a search client)
| File | Import | Purpose |
|---|---|---|
vectorctl/daemon.py |
ChromaDocumentStore, ChromaEmbeddingRetriever |
Core daemon — owns all ChromaDB instances |
knowctl/helpers.py |
chromadb.PersistentClient, ChromaDocumentStore |
Cleanup + chromadb path |
knowctl/run_list_documents.py |
ChromaDocumentStore |
Query ordinal mappings |
knowctl/run_show_content.py |
ChromaDocumentStore |
Retrieve document content |
knowctl/run_cleanup_example_collections.py |
chromadb |
One-time migration script |
knowctl/daemon_client.py |
(indirect via vectorctl_client) | Search wrapper |
claudectl/search_helpers.py |
chromadb.api.shared_system_client.SharedSystemClient |
Memory management |
claudectl/daemon_client.py |
(indirect via vectorctl_client) | Search wrapper |
| Package | Dependency |
|---|---|
vectorctl/pyproject.toml |
chroma-haystack>=0.6.0 |
knowctl/pyproject.toml |
chroma-haystack>=0.6.0 |
claudectl/pyproject.toml |
chroma-haystack>=0.6.0, haystack-ai>=2.8.0, sentence-transformers>=3.0.0 |
Root pyproject.toml |
chromadb, chroma_haystack (type-checking stubs) |
| Plist | What It Runs |
|---|---|
arthack.vectorctl.sync-vectors.plist |
vectorctl daemon (ZMQ server + embedding model) |
arthack.claudectl.sync-sessions.plist |
claudectl session sync (calls vectorctl for writes) |
arthack.qmdctl.check-health.plist |
QMD health check every 5min |
| File | Purpose | Used By |
|---|---|---|
cli_common/vectorctl_client.py |
ZMQ client: daemon_search, daemon_write_documents, daemon_delete_documents, etc. |
knowctl, claudectl |
cli_common/embedding.py |
Output suppression for SentenceTransformer init | vectorctl |
cli_common/zmq_ipc.py |
ZMQ IPC request/reply infrastructure | vectorctl |
knowctl ──→ daemon_client ──→ vectorctl_client ──→ [ZMQ] ──→ vectorctl daemon ──→ ChromaDB (local)
↑
SentenceTransformer
all-MiniLM-L6-v2
claudectl ──→ daemon_client ──→ vectorctl_client ──→ [ZMQ] ──→ vectorctl daemon ──→ ChromaDB (local)
QMD server (artbird) ────→ qmd CLI ────→ GGUF embeddings + file-based collections
↑
qmdctl (deployment/ops only, no search client)
knowctl ──→ [HTTP] ──→ QMD server (artbird) ──→ qmd search engine
claudectl ──→ [HTTP] ──→ QMD server (artbird) ──→ qmd search engine
Eliminated: vectorctl daemon, local ChromaDB, SentenceTransformer on local machine, ZMQ IPC layer, chroma-haystack dependency, sentence-transformers dependency.
Before writing any migration code, answer these questions by testing against the live QMD server:
| Question | Why It Matters |
|---|---|
Does QMD's /api/search support metadata filtering? |
knowctl filters by topic, claudectl filters by project_dir_name, session_scope, source_type |
| How does QMD handle document updates/deletes? | claudectl re-indexes changed sessions, deletes old chunks |
| Can QMD handle the metadata cardinality? | claudectl stores 12+ metadata fields per chunk |
| What's QMD's chunking behavior? | Does it chunk on ingest, or expect pre-chunked input? |
| How do QMD collection names work? | Can we use knowctl-documents and claudectl-sessions? Or are they directory-based only? |
Is the /api/query endpoint the right one? |
It supports multiple search modes — do we want BM25+vec hybrid, or vec only for backward compatibility? |
| What's the ingest API? | Is it the /api/... endpoint, or do files need to land on disk in /home/artbird/collections/? |
This phase is critical. The QMD server is a file-based search engine — documents live as files in /home/artbird/collections/. The current vectorctl approach sends document content over the wire to ChromaDB. These are fundamentally different models. We need to understand whether QMD supports an "API ingest" path or whether we need to rsync markdown files to artbird.
Deliverable: A tested understanding of QMD's ingest + search API, with sample requests/responses confirming each capability we need.
Scope: Create the cross-CLI client library that replaces vectorctl_client.py.
Work:
- Create (or expand)
qmdctl/api.pywith search/ingest/delete functions matching vectorctl_client's interface - Functions needed:
search(),ingest_document(),delete_document(),list_collections(),health_check() - Handle auth (bearer token from
~/.config/qmdctl/config.yaml) - Handle network errors gracefully (QMD is remote, not local)
- Match the filter interface that knowctl and claudectl expect
Decision needed: Should this be a thin HTTP client, or should it also handle chunking? If QMD expects files-on-disk, this layer needs to rsync or SCP content to artbird.
Scope: Switch knowctl from vectorctl daemon to QMD.
Why first: knowctl has simpler metadata (5 fields vs 12) and its sync-topics pipeline is well-understood. Also, the qmdctl watch-collections daemon already handles re-indexing of file-based collections — knowctl's topic directories could map directly to QMD collections.
Work:
- Replace
knowctl/daemon_client.pyto call QMD instead of vectorctl - Replace direct
ChromaDocumentStoreusage inrun_list_documents.pyandrun_show_content.py - Update
helpers.pyto removeget_chromadb_path()and cleanup functions - Update
run_sync_topics.pyto ingest via QMD instead ofdaemon_write_documents() - Remove
chroma-haystackfromknowctl/pyproject.toml - Test:
knowctl semantic-search <topic> "query"returns correct results
Scope: Switch claudectl from vectorctl daemon to QMD.
Why second: claudectl has richer metadata, complex filtering (AND/OR conditions on scope, project, source_type), and a daemon sync loop with crash recovery. More moving parts.
Work:
- Replace
claudectl/daemon_client.pyto call QMD instead of vectorctl - Update
search_helpers.py:- Remove
get_chromadb_path() - Remove
SharedSystemClientimport - Replace
daemon_write_documents()/daemon_delete_documents()with QMD equivalents - Keep the chunking logic (message-boundary chunking is claudectl-specific)
- Remove
- Update
run_sync_sessions.pydaemon to write to QMD - Update
run_semantic_search.pyandrun_search_plans.pyto search via QMD - Backfill: full re-sync to populate QMD with all session data
- Remove
chroma-haystackandsentence-transformersfromclaudectl/pyproject.toml
Scope: Remove all dead infrastructure.
Work:
- Delete
apps/vectorctl/entirely - Delete
cli_common/vectorctl_client.py - Delete
cli_common/embedding.py - Delete
cli_common/zmq_ipc.py(if no other consumers) - Delete
arthack.vectorctl.sync-vectors.plist - Update
arthack.claudectl.sync-sessions.plistif daemon interface changed - Remove
chromadb,chroma-haystack,sentence-transformersfrom rootpyproject.tomlstubs - Run
uv sync --all-packagesto clean lock file - Run
scripts/install.shto clean completions and symlinks - Update CLAUDE.md references to vectorctl
- Check
sandcastles.mdfor vectorctl entries - Delete local ChromaDB data directories (
~/.local/share/knowctl/chromadb/,~/.local/share/claudectl/data/chromadb/)
-
File-based vs API-based ingest. QMD collections are directories of files on artbird. vectorctl sends content over ZMQ. If QMD has no HTTP ingest endpoint (only file watching), the migration requires either: (a) rsyncing markdown to artbird, or (b) adding an ingest API to the QMD Bun server. This is the single biggest architectural question.
-
Metadata filtering. vectorctl supports complex Haystack/ChromaDB filters (AND/OR, equality,
inoperator). QMD may or may not support equivalent filtering. claudectl's search is useless withoutproject_dir_nameandsession_scopefilters. If QMD doesn't support metadata filters, we need to add them or rethink the data model. -
Different embedding models. vectorctl uses
all-MiniLM-L6-v2(SentenceTransformer, 384-dim). QMD uses a GGUF model via node-llama-cpp. These produce different embeddings. There's no cross-compatibility — all data must be re-embedded by QMD.
-
Network dependency. vectorctl is local (ZMQ IPC, microsecond latency). QMD is remote on artbird (HTTP, millisecond+ latency). Every search and ingest becomes a network call. Need to verify latency is acceptable for interactive use (claudectl semantic-search during a conversation).
-
Session data volume. claudectl indexes all Claude Code sessions, subagents, plans, todos, and agent-cache files. Could be thousands of documents with tens of thousands of chunks. Network ingest to artbird at 120s intervals needs to handle this throughput.
-
Hybrid search vs. vector-only. QMD offers BM25 + vector + HyDE. vectorctl is vector-only. Migrating gives us potentially better search quality, but results will differ. Users may notice changes in what
semantic-searchreturns. -
knowctl's direct ChromaDocumentStore usage. Several knowctl commands (
run_list_documents.py,run_show_content.py) directly instantiate ChromaDocumentStore to query by document ID or retrieve content. These bypass the daemon entirely. Need a QMD equivalent for document retrieval by ID (qmd get?).
-
ZMQ infrastructure removal.
cli_common/zmq_ipc.pyandcli_common/zmq_events.pymay have other consumers. Verify before deleting. -
Test infrastructure.
vectorctl/tests/andclaudectl/tests/test_search_helpers.pyuse ChromaDB fixtures. Tests need updating but this is mechanical. -
Model warm-up. vectorctl keeps the embedding model warm in the daemon. QMD also keeps its model loaded. No cold-start concern.
| # | Decision | Options | Recommendation |
|---|---|---|---|
| D1 | Ingest path | (a) HTTP API, (b) rsync files to artbird, (c) add ingest endpoint to Bun server | Investigate in Phase 0. (c) is most compatible with current architecture |
| D2 | Chunking | (a) Client-side (keep current logic), (b) Server-side (let QMD chunk) | (a) — claudectl's message-boundary chunking is semantically important |
| D3 | Metadata filtering | (a) QMD native, (b) Client-side post-filter, (c) Extend QMD | Investigate in Phase 0. (a) if available, (c) if not |
| D4 | Embedding model | (a) Keep all-MiniLM-L6-v2 on QMD, (b) Use QMD's GGUF model | (b) — whole point is GPU-accelerated embeddings. Accept re-embedding cost |
| D5 | Collection strategy | (a) One collection per consumer, (b) Merged collections | (a) — knowctl-documents, claudectl-sessions keeps things isolated |
| D6 | Search mode | (a) Vector-only (backward compat), (b) Hybrid BM25+vec | (b) — take advantage of QMD's capabilities, but make it configurable |
- notifyctl — pure desktop notification wrapper (terminal-notifier). No vectors, no search, no ChromaDB. Not part of this migration.
- telegramctl — message routing. No vector/embedding usage.