Skip to content

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_supersession runs linearly over the full store. Upgrade.
  • LanceDB version overhead. On v2.4.x or older, notes_<domain>.lance can 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=True passed. 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.enabled is true and the LLM backend is slow. LLM NER runs asynchronously and does not block remember() until the enrichment queue reaches its limit (maxsize=500). At that point, writes back-pressure. Either scale the LLM or set ZETTELFORGE_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.enabled is true in 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=sqlite is the v2.2.0 default. A mismatched backend points at an empty database.
  • The cross-encoder reranker drops low-similarity hits. Lower retrieval.similarity_threshold or raise retrieval.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 a tier_filter argument, it defaults to all tiers ["A", "B", "C"], so every tier is eligible. If you are explicitly passing tier_filter=["A", "B"] in your call, tier "C" notes are excluded. Pass tier_filter=["A", "B", "C"] or remove the argument to include all tiers. Note: the synthesis.tier_filter config 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:

  1. Confirm the LLM is reachable and returns non-empty responses for the synthesis prompt.
  2. Pass sync=True and watch the OCSF log for event=parse_failed schema=causal_triples raw=.... The raw preview 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 as event=invalid_causal_relation with 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.