Troubleshoot ZettelForge¶
A decision tree for the most common failures, grouped by the phase they occur in. Each entry traces back to the behaviour in source or config.
Install / first run¶
ModuleNotFoundError: No module named 'zettelforge.mcp'¶
You are running a version older than v2.2.0. The MCP server only became
importable as zettelforge.mcp starting in v2.2.0. Upgrade:
pip install -U 'zettelforge>=2.2.0'
python -m zettelforge.mcp # starts the stdio server
fastembed download stalls on first use¶
fastembed pulls its ONNX model on first call. If you are behind a proxy:
export HTTPS_PROXY=http://proxy:3128
export HF_HUB_ENABLE_HF_TRANSFER=0
Pre-download the model outside ZettelForge:
from fastembed import TextEmbedding
# Use the full HuggingFace model id. The short alias is rejected by fastembed.
TextEmbedding(model_name="nomic-ai/nomic-embed-text-v1.5-Q")
Ollama backend returns empty strings¶
Two distinct causes share this symptom.
1. Model not pulled. Confirm the requested model is present:
ollama list
ollama pull qwen2.5:3b # or the model ZETTELFORGE_LLM_MODEL points to
2. Reasoning-model token starvation. If your configured model emits
<think>...</think> reasoning tokens and the OCSF log shows
event=llm_call_empty_response done_reason=length eval_count=<num_predict>,
the model consumed its entire token budget on hidden <think>...</think> tokens
before emitting a final answer.
Pre-v2.5.2 budgets were too low (300–1024 tokens depending on the call site) and
silently failed every causal-extraction, synthesis, fact-extraction, and LLM NER
call. Upgrade to v2.5.2+; the per-call-site caps are now 2500–8000 tokens and are
config-overridable. See the Configuration Reference — llm
for the exact per-call-site knobs.
If you cannot upgrade and you are on a reasoning model, switch to a model that
does not emit <think> tokens, such as qwen2.5:3b when served normally.
remember() problems¶
GovernanceViolationError: content too short¶
GovernanceValidator enforces governance.min_content_length (default: 1 character).
Pure whitespace is rejected as well. For benchmark or replay scenarios, set
governance.enabled: false in config.yaml.
remember() is slow (> 1 s per call)¶
The normal path returns in approximately 45 ms on v2.1.1+ (55 ms warm with fastembed preload on v2.4.3+). If you see multi-second latencies:
- Version too old. You are on a version older than v2.1.1 and
_check_supersessionruns linearly over the full store. Upgrade. - LanceDB version overhead. On v2.4.x or older,
notes_<domain>.lancecan accumulate multi-gigabyte version-history overhead. Run:
python -m zettelforge.scripts.compact_lance --data-dir ~/.amem --all --force
Then upgrade to v2.4.3+ so the background lance.cleanup_* daemon keeps the
shard trimmed automatically. See the
Configuration Reference — lance
for the daemon's two knobs.
-
sync=Truepassed. That is expected behaviour. The call blocks until the background enrichment queue (causal triples, LLM NER, A-Mem evolution) finishes. On a 9B-Q4_K_M reasoning model in v2.5.2, this can be 1–3 minutes per note because causal extraction uses an 8000-token budget. Use the default async path unless you specifically need the result in-line. -
Enrichment queue full.
llm_ner.enabledistrueand the LLM backend is slow. LLM NER runs asynchronously and does not blockremember()until the enrichment queue reaches its limit (maxsize=500). At that point, writes back-pressure. Either scale the LLM or setZETTELFORGE_LLM_NER_ENABLED=false.
remember() aborts with KeyError: 'from_node_id'¶
Pre-v2.5.1 versions hard-failed KnowledgeGraph._cache_edge on legacy edges that
used {source_id, target_id, relation_type} keys instead of the canonical
{from_node_id, to_node_id, relationship}. This affects any deployment with
mixed-schema history in kg_edges.jsonl and takes down every recall() and
synthesize() at construction time. The v2.5.1 hotfix added a normalize-on-load
pass. Upgrade to v2.5.1+.
Entities I expect are not extracted¶
Regex-only extraction covers 13 types: cve, intrusion_set, actor, tool,
campaign, attack_pattern, ipv4, domain, url, md5, sha1, sha256,
email. Conversational types (person, location, organization, event,
activity, temporal) require LLM NER. Check:
llm_ner.enabledistruein your config (default).- Your LLM backend is reachable.
- Wait for enrichment to complete, or pass
sync=True.
recall() problems¶
Zero results on obvious queries¶
- Confirm the backend matches the data directory.
ZETTELFORGE_BACKEND=sqliteis the v2.2.0 default. A mismatched backend points at an empty database. - The cross-encoder reranker drops low-similarity hits. Lower
retrieval.similarity_thresholdor raiseretrieval.default_k. - Notes may be superseded. Retry with
exclude_superseded=False.
Results include stale notes¶
Raise retrieval.entity_boost or set a tighter retrieval.similarity_threshold.
To exclude tier "C" notes from synthesis results, pass tier_filter=["A", "B"]
as a keyword argument to synthesize(). The synthesis.tier_filter config key is
not read at runtime; tier filtering is only effective when passed directly to the
call.
"Too many supersessions" on conversational data¶
Known behaviour. _check_supersession() is entity-overlap driven and
LOCOMO-style dialogue shares speakers. Pass exclude_superseded=False on
recall() or disable evolution with mm.remember(..., evolve=False) for the
ingest pass.
synthesize() problems¶
Every query returns "No specific answer found for: …"¶
This is the synthesis fallback string. The LLM call returned empty, malformed JSON, or raised. The most likely cause on a reasoning model is token starvation. See Ollama backend returns empty strings.
Upgrade to v2.5.2+, which raised the synthesis budget from 800 to 2500 tokens. Alternatively, switch to a non-reasoning model.
Confirm the degradation in the OCSF log:
grep '"schema":"synthesis","raw":""' ~/.amem/logs/zettelforge.log | tail -5
grep '"event":"llm_call_empty_response"' ~/.amem/logs/zettelforge.log | tail -5
Both events appear when synthesis is silently degrading.
synthesize() returned an answer but cited 0 sources¶
recall() returned no notes for the query. Check:
retrieval.similarity_threshold— too high; lower to 0.15.retrieval.default_k— too low; raise.- Tier filtering — when you call
synthesize()without atier_filterargument, it defaults to all tiers["A", "B", "C"], so every tier is eligible. If you are explicitly passingtier_filter=["A", "B"]in your call, tier"C"notes are excluded. Passtier_filter=["A", "B", "C"]or remove the argument to include all tiers. Note: thesynthesis.tier_filterconfig key has no effect at runtime and can be ignored for troubleshooting purposes.
Causal triple extraction problems¶
kg_edges table has no edge_type=causal rows¶
Either the LLM call returned empty (token starvation; see Ollama backend returns empty strings) or the parser failed. Check:
sqlite3 ~/.amem/zettelforge.db \
"SELECT edge_type, count(*) FROM kg_edges GROUP BY edge_type;"
If you only see heuristic rows, no causal triples are being persisted. v2.5.2
is the minimum version where this works end-to-end on reasoning models — earlier
versions silently failed because the 300-token budget was exhausted by <think>
tokens.
If you are on v2.5.2+ and still see zero causal edges:
- Confirm the LLM is reachable and returns non-empty responses for the synthesis prompt.
- Pass
sync=Trueand watch the OCSF log forevent=parse_failed schema=causal_triples raw=.... Therawpreview shows what the model returned: either an empty string (token starvation) or relations outside the allowlist (causes,enables,targets,uses,exploits,attributed_to,related_to). Out-of-allowlist relations appear asevent=invalid_causal_relationwith the offending string.
MCP¶
Claude Code cannot find the server¶
Confirm the invocation in your MCP client config:
{
"mcpServers": {
"zettelforge": {
"command": "python3",
"args": ["-m", "zettelforge.mcp"]
}
}
}
Test the server directly:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | python -m zettelforge.mcp
The command returns a JSON-RPC response listing seven tools. If the call hangs for more than approximately 10 seconds on first use, the MemoryManager is initialising embeddings and models — this only happens once per process.
zettelforge_sync is unavailable¶
zettelforge_sync is an OpenCTI sync tool available on ThreatRecall.ai (SaaS).
It is not included in the ZettelForge OSS build. See
Configure OpenCTI for the current availability status.
Logs and diagnostics¶
ZettelForge writes structured JSON logs to rotating files under the data directory. Stdout is never used for logs (GOV-012). Typical paths:
tail -f ~/.amem/logs/zettelforge.log # OCSF structured events
tail -f ~/.amem/logs/audit.log # Security-relevant events (GOV-012)
tail -f ~/.amem/telemetry/telemetry_$(date +%F).jsonl # Operational telemetry (RFC-007)
Useful events to grep:
| Event | Meaning |
|---|---|
remember_completed |
Fast-path finished; includes note_id, duration_ms |
enrichment_queue_full |
Write back-pressure — scale the LLM or disable LLM NER |
supersession_applied |
A note was marked superseded; includes old_note_id, new_note_id |
lance_index_failed |
LanceDB write failed; check rebuild script and disk space |
governance_violation |
Input validation rejected a write |
Set logging.level: DEBUG in config.yaml for verbose output.
Operational telemetry (RFC-007)¶
Every MemoryManager.recall() and .synthesize() call emits a per-query event
to ~/.amem/telemetry/telemetry_YYYY-MM-DD.jsonl. At INFO level this is
aggregated counts plus latency; at DEBUG it adds per-note metadata, tier
distribution, vector/graph latency breakdown, and citation-based utility feedback.
Telemetry scripts:
| Script | Purpose |
|---|---|
python -m zettelforge.scripts.telemetry_aggregator --date YYYY-MM-DD |
Daily summary: latency averages, tier distribution, unused notes, top utility notes |
python -m zettelforge.scripts.human_eval_sampler |
Sample 20 random synthesis briefings for the monthly human-evaluation rubric |
streamlit run src/zettelforge/scripts/telemetry_dashboard.py |
Optional visualisation: query volume, latency p50/p95, tier/utility trends |
Raw note content is never persisted in telemetry — only IDs, tiers, source types, and domains. Query text is truncated to 200 chars at INFO and 500 at DEBUG. All data stays local.