Import ClickGap memory
loom import-clickgap loads one SQLite export shape, a ClickGap lessons store, into a Loom
namespace; skip this page unless you have such an export. A lesson is a short, deduplicated
review memory with typed entities, categories, a kind, a per-row TTL and a recall_count.
Two commands do the work:
loom import-clickgap: the importer (server/cli/import_clickgap.py), run where theloomCLI reaches ClickHouse (the server container or a clone with the server's.env).python -m bench.clickgap_migration_validate: the validator (bench/clickgap_migration_validate.py).
What the importer preserves
| ClickGap | Loom | Note |
|---|---|---|
lessons.text |
loom.memory.value + embedded content_excerpt |
the assertion is embedded; the reasoning is not |
lessons.reasoning |
value_json.reasoning (with .text = assertion) |
get returns value_json, so the assertion travels with the reasoning, never concatenated into it |
lessons.kind |
memory_type (mostly 1:1) + tag kind:<kind> |
summary → episodic (non-searchable): summaries stay reachable through memory.get / memory.list and the kind:summary tag but don't rank in memory.search. The kind:<kind> tag preserves the original taxonomy. See _clickgap_memory_type / _CLICKGAP_TYPE_REMAP. |
lessons.source |
source_trace='clickgap://<class>/<source>' + tag source:<class> |
classes: pipeline / session / human / reviewer / eval |
lessons.created_at |
created_at, valid_at, temporal_anchor |
the original recording time, not the import time |
lessons.expires_at |
expires_at verbatim, per row |
never recomputed from a global TTL |
lessons.recall_count |
recall_count and memory_embeddings.reinforcement_count |
see recall signal |
lessons.last_recalled_at |
last_recalled_at and last_reinforced_at |
the recall-recency axis |
lessons.evidence_count |
evidence_count |
|
lesson_entities |
entities (bare) + entity:<kind>:<text> tags + loom.entity_vectors |
the bare entities array drops the kind; the tag carries it |
lesson_categories + items |
loom.categories + loom.memory_categories |
names case-folded before the deterministic category_id |
false_positive lessons |
tag polarity:negative |
suppression ("do NOT re-raise") memories |
| human-attributed sources | tag pii:human-attributed |
erasure-by-tag candidates |
The import is idempotent. memory_key='lesson_<id>' and a UUIDv5 memory_id (from
clickgap://<org>/<namespace>/lesson/<id>) are deterministic, and each run stamps one fresh
version. A re-import supersedes under ReplacingMergeTree(version) and the row count stays
the same.
1. Dry-run
No writes. The dry run checks that every timestamp parses, prints the kind → type and TTL crosstab and the category count, and probes the embedder so rows do not land unembedded.
loom import-clickgap \
--sqlite ~/Downloads/clickgap-memory-YYYYMMDD/memory.db \
--org <org> --namespace clickgap --dry-run
Confirm the crosstab matches the source and that the embedder is reachable.
2. Import
Use a dedicated namespace (clickgap) so rollback is a single namespace.delete. The
export carries no vectors, so every row is embedded on import with the live
LOOM_EMBEDDING_MODEL.
loom import-clickgap \
--sqlite .../memory.db --org <org> --namespace clickgap --agent clickgap
--agent defaults to clickgap; --batch-size sets rows per ClickHouse insert (default
1000). The verification block at the end prints, and fails the run (exit 1) on:
- live memory rows against the source's lesson count;
- wrong-model embedding rows (must be 0);
- the permanent (no-TTL) row count;
- the highest
recall_count, read back.
lesson_recalls (the per-recall event log) is not imported by default: the
denormalized recall_count columns already carry the ranking signal. Pass
--seed-recall-events only if you need the per-event log in loom.memory_usage_events for
analytics.
Warning:
--seed-recall-eventsis additive and not idempotent: run it once, into a fresh namespace. It dropspr_number, because Loom usage events have no PR column.
Recall signal
The importer mirrors recall_count onto memory_embeddings.reinforcement_count and
last_recalled_at onto last_reinforced_at. In memory.search that count feeds the
decayed-reinforcement rank term: LOOM_MEMORY_SEARCH_REINFORCEMENT_WEIGHT sizes it
(0 disables), LOOM_MEMORY_SEARCH_REINFORCEMENT_HALF_LIFE_DAYS sets its decay, and a
request can override both with reinforcement_weight / reinforcement_half_life_days
(configuration).
The term is capped below relevance, so Loom does not reproduce ClickGap's
recall_count DESC ordering: it ranks primarily by similarity with entity, recency and
reinforcement terms (retrieval and ranking). If your
workload depends on recall-frequency ordering, validate that the resulting order is
acceptable (the validator reports recall-order τ) before cutover.
3. Validate before cutover
The validator mints its own admin token with server.auth.jwt.mint_token, so it needs
LOOM_JWT_SECRET and an explicit LOOM_BASE_URL: there is no default target. Its probes
are evaluation reads (auto_touch: false, the bench/_gate.py measurement fields), so a
run records no recall usage on the imported lessons and repeated runs do not feed the
reinforcement signal the τ gate measures.
Warning: point it at a trusted endpoint. The probe queries are the lessons' assertions; the import data must not leave your infrastructure through a third-party embedder.
--show-queriesprints truncated probe text and is off by default for the same reason.
LOOM_BASE_URL=http://localhost:7777 LOOM_JWT_SECRET=... \
python -m bench.clickgap_migration_validate \
--sqlite .../memory.db --org <org> --namespace clickgap --agent clickgap \
--probes 200 --top-k 10 \
--min-recall 0.90 --min-recallw 0.90 --min-tau 0.2
Each gate fails the run with a non-zero exit when set above 0 (0 = report only):
| Gate | Measures |
|---|---|
--min-recall |
Known-item recall@k: is the probe lesson itself in Loom's top k? Per-kind and per-entity-type breakdowns catch a single slice collapsing (a wrong type remap, say). |
--min-recallw |
Recall-weighted recall@k: each probe weighs by its recall_count, so missing a hot lesson costs more. |
--min-tau |
Recall-order τ: Kendall agreement between Loom's order and recall_count. A diagnostic: Loom does not sort by recall_count, so don't expect a high τ. |
A failed search is reported and excluded from the ratios, never scored as a miss, and any
failed search makes the run exit non-zero, gates or not. A run with no computable τ prints n/a and
fails --min-tau.
Summaries (summary → episodic) are excluded from probes, since they are non-searchable by
design. --seed (default 42) makes the probe sample reproducible; --concurrency
(default 8) bounds concurrent searches.
Use suppression memories
The false_positive lessons ("looks like a bug, do NOT re-raise") are the suppression
layer. They are searchable and carry the polarity:negative tag. A detector deciding
whether to raise a candidate finding runs a tag-filtered search over the finding's text:
hits = await agent.memory.search(finding_text, tags=["polarity:negative"], top_k=5)
if hits and hits[0].similarity >= SUPPRESS_THRESHOLD: # calibrate per embedder
skip_or_downweight(finding) # a known false positive
The tags filter applies to every retrieval leg (vector, lexical and the recency/confidence
backfill), so the result holds only suppressions
(test_lexical_fallback_applies_caller_filters in tests/unit/test_memory_search_service.py).
A leaked positive would hide a real finding.
Filter any kind the same way through its kind:<kind> tag: tags=["kind:principle"], or
tags=["kind:false_positive", "kind:maintainer_preference"] for a multi-kind kill-list
(tags is an OR filter). memory.search's memory_type parameter accepts only the coarse
types (semantic, procedural, preference, reflection, decision), so the full kind
taxonomy lives on tags.
Roll back
- Full revert:
namespace.deleteonclickgap(admin; over MCP call it throughloom_op). The import targets a dedicated namespace, so nothing else is removed. - Erasure or cohort scrub:
erasure.requestwith a namespace scope (checkpointed and resumed by the recovery worker), or erase only thepii:human-attributedrows for a maintainer's removal request (retention and erasure). - Mapping fix: re-run the importer. Deterministic keys and a higher
versionsupersede the bad rows in place.
Consolidate (optional, after validation)
If you compact entity-set near-duplicates, run memory.consolidate after validation.
The guards in server/memory/app/consolidate.py exclude pinned rows and never merge
across memory_type or across polarity, so a false_positive suppression can't be
absorbed into a positive observation, and a principle can't absorb an observation.
Keep consolidation_enabled=false on the namespace (namespace.register) so the
background consolidation worker doesn't consolidate ClickGap's already rolled-up summary
lessons a second time.