{"id":"cb627c19-27b0-48e0-9c6b-2bfaf66f01e1","entityType":"agent","slug":"clawhub-mistermijarvis-memory-toolkit","name":"Openclaw Memory Toolkit","canonicalUrl":"https://www.xpersona.co/agent/clawhub-mistermijarvis-memory-toolkit","canonicalPath":"/agent/clawhub-mistermijarvis-memory-toolkit","generatedAt":"2026-10-11T20:56:36.546Z","source":"CLAWHUB","claimStatus":"UNCLAIMED","verificationTier":"NONE","summary":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T16:14:28.850Z","emptyReason":null},"description":"OpenClaw Memory Toolkit is a memory layer for AI agents. It remembers what matters across sessions: it extracts durable facts from your conversations, resolves contradictions instead of hoarding them, and lets you ask what it knew on any past date.","descriptionLabel":"Source description","evidenceSummary":"Capability contract not published. No trust telemetry is available yet. 1K downloads reported by the source. Last updated 10/11/2026.","installCommand":"clawhub skill install s17bzyvy2hqcrtqhsbxdpbrrf18cpw5t:memory-toolkit","sourceUrl":"https://clawhub.ai/mistermijarvis/memory-toolkit","homepage":"https://clawhub.ai/mistermijarvis/skills/memory-toolkit","primaryLinks":[{"label":"View on ClawHub","url":"https://clawhub.ai/mistermijarvis/memory-toolkit","kind":"source"},{"label":"Homepage","url":"https://clawhub.ai/mistermijarvis/skills/memory-toolkit","kind":"homepage"}],"safetyScore":84,"overallRank":62,"popularityScore":60,"trustScore":null,"claimedByName":null,"isOwner":false,"seoDescription":"Openclaw Memory Toolkit technical dossier on Xpersona with agent coverage, OPENCLEW support, and live trust metadata."},"coverage":{"evidence":{"source":"public-profile","verified":false,"confidence":"medium","updatedAt":"2026-10-11T16:14:28.850Z","emptyReason":null},"protocols":[{"protocol":"OPENCLEW","label":"OpenClaw","status":"self-declared","notes":"Declared in the public agent profile."}],"capabilities":[],"verifiedCount":0,"selfDeclaredCount":1,"capabilityMatrix":{"rows":[{"key":"OPENCLEW","type":"protocol","support":"unknown","confidenceSource":"profile","notes":"Listed on profile"}],"flattenedTokens":"protocol:OPENCLEW|unknown|profile"}},"adoption":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T16:14:28.850Z","emptyReason":null},"stars":null,"forks":null,"downloads":1030,"packageName":null,"latestVersion":"4.0.5","tractionLabel":"1K downloads"},"release":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T16:14:28.834Z","emptyReason":null},"lastUpdatedAt":"2026-10-11T16:14:28.850Z","lastCrawledAt":"2026-10-11T16:14:28.834Z","lastIndexedAt":null,"nextCrawlAt":"2026-10-12T16:14:28.834Z","lastVerifiedAt":null,"highlights":[{"version":"4.0.5","createdAt":"2026-10-11T10:59:04.723Z","changelog":"- Added three new ontology maintenance scripts: `ontology_backfill_llm.py`, `ontology_backfill_orphans.py`, and `ontology_fix_day_nodes.py`. - Removed the obsolete `skill-card.md` file. - No user-facing pipeline or interface changes; core skill logic remains unchanged. - Documentation and version updated to reflect v4.0.5.","fileCount":37,"zipByteSize":213360},{"version":"4.0.4","createdAt":"2026-10-11T08:44:29.879Z","changelog":"- Added initial test coverage for hybrid search orphan link logic with new file: hybrid-search/test_orphan_link.py. - Version bump to 4.0.4; no changes to existing code or functionality.","fileCount":34,"zipByteSize":198351},{"version":"4.0.3","createdAt":"2026-10-11T08:30:38.078Z","changelog":"v4.0.3 — MEMORY.md can no longer grow unbounded (2026-10-10) Real fix Size guard in consolidate_advisor.apply_promotions(). MEMORY.md is injected into every main-session context and has a hard 5 KB budget, but the promotion path appended entries without ever checking the running total. That is how the file silently reached 19.6 KB (3.8x the limit) before a manual audit caught it. The writer now projects the post-write size and refuses to write when it would exceed MEMORY_MAX_SIZE (env-overridable, default 5000): ❌ Size guard: refusing to write — projected MEMORY.md 18689 bytes > limit 5000 bytes (+13710 would be added). Trim/archive MEMORY.md first, or promote fewer entries. The file is left byte-for-byte untouched on refusal. Promotions are suggestions, not obligations: losing an overflow is strictly better than paying for it in every future session's context. Verified by direct test: a 2-entry lot applies, a 20-entry lot is refused and the file stays intact.","fileCount":33,"zipByteSize":193703},{"version":"4.0.2","createdAt":"2026-10-10T17:56:24.009Z","changelog":"v4.0.2 — Ontology health check no longer cries wolf (2026-10-10) Real fix memory-health.py parser was blind to op=state. The ontology graph is an append-only log whose dominant record is now op=state (879 of 964 lines in the current graph); create/upsert are the older forms. check_ontology() counted an entity only for op in (\"create\", \"upsert\"), so it saw 50 entities while ontology_compact.py, which replays the full log, saw 927. The 34 relate records pointed at the real entities, so the check reported 68 orphan relations on every run — all of them false. The parser now accepts state as an entity-bearing op. Both tools agree: before: 50 entities, 68 orphan relations (🟡) after : 928 entities, 0 orphan relations (🟢) No false-positive suppression: the orphan count is still computed and still reported when real orphan relations exist. Only the parse was wrong. Notes Discovered while triaging a health report (10/10): the \"68 orphan relations\" warning was the health check disagreeing with the compactor, not real drift. ontology_compact.py needed no change — it was already correct.","fileCount":33,"zipByteSize":192694},{"version":"4.0.1","createdAt":"2026-10-10T16:40:05.698Z","changelog":"Security hardening patch. Real fixes plus a cleared scanner false positive. Real fixes A. Loopback guard on llm_resolution.py (SSRF gap). The v4.0 resolver read OLLAMA_URL straight from the environment while every other module routes it through get_safe_ollama_url() — and test_loopback_guard.py even exempted it from the static scan. A crafted environment could point the safety-net endpoint at a remote host. It now uses the same loopback guard, and the exemption is gone. B. Cloud transport is now opt-in. trace_extractor.py used to send memory and session excerpts to ollama.com whenever an API key was present. Cloud is now used only when the operator explicitly sets TRACE_LLM_ALLOW_CLOUD=1 in addition to the key; otherwise extraction stays on 127.0.0.1:11434. TRACE_LLM_LOCAL_ONLY=1 remains a hard local-only lock. Cleared scanner false positive hybrid-search/test_ontology_key_parity.py:61 was flagged suspicious.dynamic_code_execution. It is the standard Python import machinery loading a fixed, literal path in the same repository — no network, no user input, no env var, no argv. The loader was not removed to silence the scanner (that would make the test track a stale copy); an inline explanation and a noqa marker were added instead. Triaged false positives (documented in CHANGELOG) Tainted flow / credential exfiltration (×5): flagged values are OLLAMA_URL, a timeout, and a Content-Type header — not secrets. Anti-Refusal Statement: prose describing a feature, not an instruction. Credential Access (×4): the quoted text is the refusal list (.secrets/*, /etc/passwd are rejected). Referenced artifact not completely inspected: the skill documents modules outside the scanned bundle.","fileCount":33,"zipByteSize":191797},{"version":"4.0.0","createdAt":"2026-10-10T16:00:30.163Z","changelog":"Breaking contract change. Model selection is no longer copied into each script: it is resolved once, at runtime, from the gateway configuration. Every caller now goes through a single module, hybrid-search/llm_resolution.py, so a catalogue rotation or a config change propagates everywhere at once instead of silently breaking one script at a time. Why this release exists v3.6.0 and v3.6.1 each fixed a symptom: a hard-coded model name that no longer matched what the Ollama daemon served. The fix worked, but the pattern survived. v4.0 removes the pattern. Added hybrid-search/llm_resolution.py — single source of truth for the effective LLM model. Chain: explicit env var → agents.defaults.model.primary → fallbacks[0] → local safety net (qwen2.5:7b, presence-checked). Exposes resolve_llm_model() and explain_resolution(). No-silent-failure invariant: a missing local safety model raises instead of letting Ollama start a synchronous multi-GB pull. test_model_resolution.py (extended), test_extract_atomic.py (new), test_ontology_key_parity.py (new). Changed 4 callers migrated to the shared module: consolidate_advisor.py, hybrid-search/auto_capture.py, hybrid-search/conflict_resolver.py, trace_extractor.py. PREFERRED_MODELS lists removed. sync-skill.sh now ships llm_resolution.py (it was missing — callers would have silently fallen back to the old behaviour). Fixed Destructive detection bug in migrate_ontology_subjects.py (M4-B). The migrator rebuilt the display key as name or type, but the indexer uses name or entity.id or type. Decision/TimelineEvent nodes have no name, so it reported 2826 live facts as ghosts; a single --apply would have marked 2406 valid facts superseded. Fixed: survivors 3871 / ghosts 0 (was 1045 / 2826). Housekeeping migrate_*.py marked APPLIED (kept as re-auditable tools). Superseded draft changelog archived; dead orphan log archived. Verified scripts/release.sh check green: all five test suites pass. Both arbiters resolve to a model the local daemon actually serves.","fileCount":33,"zipByteSize":189093},{"version":"3.6.1","createdAt":"2026-10-07T18:15:33.048Z","changelog":"v3.6.1 — Arbiter follows the operator's real default (2026-10-06) Follow-up to v3.6.0. That release restored conflict arbitration but still listed glm-5.2:cloud first in PREFERRED_MODELS — a name inherited from the v2.2.0 hard-code, not a deliberate choice. It is installed on the daemon (so the fix worked), but it is not the model this deployment actually runs on. Changed PREFERRED_MODELS now follows the operator's real default, deepseek-v4-pro:cloud (the configured agents.defaults.compaction.model), ahead of deepseek-v4.1-flash:cloud, glm-5.2:cloud and the offline qwen2.5:7b. Both conflict_resolver and consolidate_advisor are aligned. An explicit CONFLICT_LLM_MODEL / TRACE_LLM_MODEL / OLLAMA_MODEL still overrides the list, so pinning a model stays a visible, one-line decision. Verified deepseek-v4-pro:cloud returns CONTRADICTION (confidence 0.85) with a correct rationale on the same backup-broken/repaired probe that v3.6.0 used — the model swap does not weaken the arbiter. Both modules resolve to deepseek-v4-pro:cloud on this host. v3.6.0 — Conflict arbitration was dead on arrival (2026-10-06) Fix release. Closes the finding that the superseded / disputed lifecycle paths had never executed once. The cause was not the subject fidelity work of v3.5.0 nor the source echo guard: it was a missing model tag. Fixed The arbiter asked Ollama for a model that does not exist. conflict_resolver sent the bare name glm-5.2, while the daemon serves glm-5.2:cloud. Ollama answered HTTP 404, classify_relation() fell through to the conservative heuristic, and every fact came back COMPATIBLE / no confident relation detected. Conflict arbitration had therefore never fired: superseded and disputed were unreachable code paths. Verified against a real contradiction (backup broken → repaired): the LLM now returns CONTRADICTION with a correct rationale, and --apply writes status=superseded + the superseded_by link. consolidate_advisor.py carried the identical defect (OLLAMA_MODEL default \"glm-5.2\"), so the advisor was silently producing nothing for the same reason. Both modules now resolve their model the same way. Changed Model resolution is no longer hard-coded. A hard-coded name — even one with the correct tag — rots on the next model swap. Both modules now resolve against the models the daemon actually serves (GET /api/tags): an explicit CONFLICT_LLM_MODEL / TRACE_LLM_MODEL / OLLAMA_MODEL still wins, otherwise the first served model from a preference list is used, and an unreachable daemon falls back to a tagged preference instead of crashing or sending a bare name. New regression test hybrid-search/test_model_resolution.py pins the three behaviours (explicit env wins / resolves to a served model / offline fallback is tagged). Wired into scripts/sync-skill.sh and the release.sh check gate. Source filter (M4 follow-up): trivially short user turns (go, ok, top, …) are skipped by the trace extractor instead of being mined for durable facts — the 76-turn dry-run contained 18% such turns feeding ~80% operational noise. Verified Real contradiction on a DB copy: [contradiction] Le backup nightly est repare → --apply → id=5134 status=superseded superseded_by=5135. Non-contradictions still classified COMPATIBLE (Kavita 0.9.0 → 0.9.1.4; Dovato morning vs evening \"plus le matin\") — the arbiter discriminates, it does not cry conflict. test_model_resolution.py: 3/3 hold. test_loopback_guard.py: all guards hold (run under the skill venv, which has sqlite_vec). v3.5.0 — Subject fidelity (M4) + source echo guard (2026-10-06) Fix release. Closes the M4 audit finding — the one that made conflict arbitration structurally unable to fire. Three independent defects, one shared root: facts reached the ledger with a subject that did not discriminate, so the resolver's \"is this the same entity?\" test could never return a confident match. Fixed Multi-word subjects were truncated to their first word (auto_capture._normalise_facts, mirrored in the new trace_extractor._norm_subject). '\\w' includes _, so re.split(r\"[^\\w]+\", \"kavita_home\") yielded a SINGLE token, grounding failed, and the fallback took the fact's first substantial word. kavita_home became kavita, backup_cron became backup — so kavita_home and kavita_index (different entities) collided under one key. Grounding now compares against the tokenised text and splits on underscore too. trace_extractor.py produced no subject at all. Its prompt never requested one and its writer never emitted one, so every trace item reached the ledger as subject=NULL. The prompt now requires a grounded subject per item, and the writer emits it as [subject:key] on the note line for the downstream indexer. Machine-generated assistant turns were mined for user facts (echo guard). The only gate was should_capture(user_msg); an assistant turn carrying a tool completion (... executed from ...) or a cron report (Summary: {'added': 0, ...}) was handed to the extractor and returned as a \"durable fact\". assistant_turn_is_echo() now drops such turns — the user half is kept, the machine half is discarded. Verified Echo guard: 8/8 cases (tool logs, cron summaries, === END === markers gated; real conversational turns kept). Subject normalisation: kavita_home/backup_cron/ubuntu_24_04 keep their full key; v3.3 anti-hallucination behaviour (Serveur Prod → serveur) preserved. Notes --apply remains gated behind a real dialogued conflict batch: the superseded/disputed paths are still unproven on live data. v3.4.0 — Point-in-Time Retrieval (--as-of) (2026-10-05) Feature release. Turns the fact-lifecycle ledger into a time machine: the search can now reconstruct the exact cognitive state the agent had on a past date, not just what it believes today. In PLM terms, this is the step from a single \"As-Maintained\" configuration to a retrievable \"As-Built\" baseline. Born from the operator's observation (2026-10-05) that the v3.3 ledger — which marks rows superseded instead of deleting them — already held the history; what was missing was a timestamp for when a fact stopped being current. Added superseded_at column (schema.sql). The ISO timestamp at which a row STOPPED being active (NULL while it is). This is the axis the earlier schema lacked: valid_from records when the fact became true in the world, while superseded_at records the lifecycle of the row — and updated_at cannot serve, because a REDUNDANT confirmation rewrites it without ending anything. Without a dedicated column, point-in-time retrieval is impossible to express correctly. --as-of YYYY-MM-DD on query, search and context. Reconstructs the facts visible on that date instead of the current set. A bare date means end of that day (2026-07-01 → T23:59:59), so a row created at 10:00 on that day is visible; a full timestamp is used verbatim. Verified against a MySQL→PostgreSQL switch: the pre-switch fact is returned for a date before it and the successor for a date after, with no overlap. _as_of_clause() — one helper builds the visibility predicate shared by the lexical and vector paths, so both halves of the hybrid search agree on what \"visible on date D\" means. Changed ensure_lifecycle_columns() now also adds superseded_at (and its index) to an older DB, so the migration is idempotent for existing installations. apply_resolution() and resolve/--confirm/--reject stamp superseded_at at each active → superseded transition, inside the existing atomic transaction (C2). search_lexical() / search_vector() / search_hybrid() take an optional as_of; default (None) behaviour is byte-for-byte the previous \"active only\" query — verified by a regression run against the live database. Data maintenance (real database, 2026-10-05) 3 475 superseded rows backfilled with superseded_at = COALESCE(updated_at, created_at). Backup taken and integrity_check verified before the write; post-run foreign_key_check clean; zero active rows carry a superseded_at. v3.3.0 — Referential Integrity, Transactional Writes, Ontology→DB Sync (2026-10-05) Hardening release. An audit of the fact-lifecycle layer found that the schema declared a state machine (status, confidence, superseded_by) the engine never enforced, that the write paths were not atomic, and that subject arbitration was dead in practice (100 % of indexed facts had subject = NULL). Every defect is fixed at the source and verified against the real database. Fixed Schema now enforces what it declared (C1). status carries a CHECK (status IN ('active','superseded','disputed')), confidence a range CHECK, and superseded_by a FOREIGN KEY … ON DELETE SET NULL. conflict_resolver.apply_resolution() is atomic (C2). The whole resolution is now one BEGIN IMMEDIATE … COMMIT with rollback. Concurrent access is safe (C3). Both connection paths now set busy_timeout=5000, journal_mode=WAL and foreign_keys=ON. add_memory() writes the hot row and its vector atomically (C4). compact.py never archives a still-referenced fact (M2). ontology_compact.py drops orphan relations (M3). Added subject is finally writable and populated (M4). Arbitrable fact categories now sit at 100 % subject coverage. Ontology → DB synchronisation. Every entity that leaves the reference nomenclature has its facts marked superseded in the same run. migrate_subject.py / migrate_ontology_subjects.py — one-shot, idempotent backfill/audit tools. v3.2.1 — Ontology Reindex, Number-Safe Splitting, Local Model Bump (2026-10-05) Maintenance release. Two defects found during the first live Auto-Capture session, both fixed and verified against the real database. Fixed Nested-schema ontology indexing. index_jsonl_file() read name/type at the JSON root, but ontology lines are nested — every graph node was indexed as the literal string \" ()\": 2 786 junk rows, 69 % of the database. Number-safe punctuation split. extract_atomic() split on every period: Ubuntu 24.04 became Ubuntu 24 + 04. A period now splits only when not sandwiched between digits ((?<![0-9])\\.(?![0-9])). Changed Default extraction model: qwen2.5:3b → qwen2.5:7b (still local, loopback-only). Database maintenance: 2 786 empty ontology rows moved to superseded (reversible) and the 898 clean nodes re-indexed. v3.2.0 — Auto-Capture: Session Dialogue → Arbitrated Facts (2026-10-04) Feature release. Adds the write-behind half of the autonomous memory loop: a post-turn pipeline that reads session dialogue, extracts atomic durable facts with a local LLM, and arbitrates them against existing memory. Added hybrid-search/auto_capture.py — post-turn fact extraction. hybrid-search/transcript_adapter.py — reads the OpenClaw per-agent session store (session_transcript_fts), snapshot-first, read-only, secret-redacting. --no-split flag on conflict_resolver.py check/arbitrate.","fileCount":28,"zipByteSize":165686},{"version":"3.1.1","createdAt":"2026-10-04T11:57:59.473Z","changelog":"- Added CONTRIBUTING.md to provide contribution guidelines. - Removed skill-card.md. - Version bump from 3.1.0 to 3.1.1; no functional or interface changes to the pipeline or scripts.","fileCount":22,"zipByteSize":124761}]},"execution":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No published capability contract is available yet."},"installCommand":"clawhub skill install s17bzyvy2hqcrtqhsbxdpbrrf18cpw5t:memory-toolkit","setupComplexity":"low","setupSteps":["Install using `clawhub skill install s17bzyvy2hqcrtqhsbxdpbrrf18cpw5t:memory-toolkit` in an isolated environment before connecting it to live workloads.","No published capability contract is available yet, so validate auth and request/response behavior manually.","Review the upstream CLAWHUB listing at https://clawhub.ai/mistermijarvis/memory-toolkit before using production credentials."],"contract":{"contractStatus":"missing","authModes":[],"requires":[],"forbidden":[],"supportsMcp":false,"supportsA2a":false,"supportsStreaming":false,"inputSchemaRef":null,"outputSchemaRef":null,"dataRegion":null,"contractUpdatedAt":null,"sourceUpdatedAt":null,"freshnessSeconds":null},"invocationGuide":{"preferredApi":{"snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mistermijarvis-memory-toolkit/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mistermijarvis-memory-toolkit/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mistermijarvis-memory-toolkit/trust"},"curlExamples":["curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-mistermijarvis-memory-toolkit/snapshot\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-mistermijarvis-memory-toolkit/contract\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-mistermijarvis-memory-toolkit/trust\""],"jsonRequestTemplate":{"query":"summarize this repo","constraints":{"maxLatencyMs":2000,"protocolPreference":["OPENCLEW"]}},"jsonResponseTemplate":{"ok":true,"result":{"summary":"...","confidence":0.9},"meta":{"source":"CLAWHUB","generatedAt":"2026-10-11T20:56:36.540Z"}},"retryPolicy":{"maxAttempts":3,"backoffMs":[500,1500,3500],"retryableConditions":["HTTP_429","HTTP_503","NETWORK_TIMEOUT"]}},"endpoints":{"dossierUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mistermijarvis-memory-toolkit/dossier","snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mistermijarvis-memory-toolkit/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mistermijarvis-memory-toolkit/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-mistermijarvis-memory-toolkit/trust"}},"reliability":{"evidence":{"source":"runtime-metrics","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No trust, reliability, or runtime telemetry is available."},"trust":{"status":"unavailable","handshakeStatus":"UNKNOWN","verificationFreshnessHours":null,"reputationScore":null,"p95LatencyMs":null,"successRate30d":null,"fallbackRate":null,"attempts30d":null,"trustUpdatedAt":null,"trustConfidence":"unknown","sourceUpdatedAt":null,"freshnessSeconds":null},"decisionGuardrails":{"doNotUseIf":["Contract metadata is missing or unavailable for deterministic execution."],"safeUseWhen":[],"riskFlags":["missing_or_unavailable_contract","trust_data_unavailable","schema_references_missing"],"operationalConfidence":"low"},"executionMetrics":{"observedLatencyMsP50":null,"observedLatencyMsP95":null,"estimatedCostUsd":null,"uptime30d":null,"rateLimitRpm":null,"rateLimitBurst":null,"lastVerifiedAt":null,"verificationSource":null},"runtimeMetrics":{"successRate":null,"avgLatencyMs":null,"avgCostUsd":null,"hallucinationRate":null,"retryRate":null,"disputeRate":null,"p50Latency":null,"p95Latency":null,"lastUpdated":null}},"benchmarks":{"evidence":{"source":"no-benchmark-data","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No benchmark suites or observed failure patterns are available."},"suites":[],"failurePatterns":[]},"artifacts":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T16:14:28.850Z","emptyReason":null},"readme":"Skill: Openclaw Memory Toolkit\n\nOwner: mistermijarvis\n\nSummary: OpenClaw Memory Toolkit is a memory layer for AI agents. It remembers what matters across sessions: it extracts durable facts from your conversations, resolves contradictions instead of hoarding them, and lets you ask what it knew on any past date.\n\nTags: latest:4.0.5\n\nVersion history:\n\nv4.0.5 | 2026-10-11T10:59:04.723Z | user\n\n- Added three new ontology maintenance scripts: `ontology_backfill_llm.py`, `ontology_backfill_orphans.py`, and `ontology_fix_day_nodes.py`.\n- Removed the obsolete `skill-card.md` file.\n- No user-facing pipeline or interface changes; core skill logic remains unchanged.\n- Documentation and version updated to reflect v4.0.5.\n\nv4.0.4 | 2026-10-11T08:44:29.879Z | user\n\n- Added initial test coverage for hybrid search orphan link logic with new file: hybrid-search/test_orphan_link.py.\n- Version bump to 4.0.4; no changes to existing code or functionality.\n\nv4.0.3 | 2026-10-11T08:30:38.078Z | user\n\nv4.0.3 — MEMORY.md can no longer grow unbounded (2026-10-10)\nReal fix\nSize guard in consolidate_advisor.apply_promotions(). MEMORY.md is injected\ninto every main-session context and has a hard 5 KB budget, but the promotion\npath appended entries without ever checking the running total. That is how the\nfile silently reached 19.6 KB (3.8x the limit) before a manual audit caught\nit. The writer now projects the post-write size and refuses to write when it\nwould exceed MEMORY_MAX_SIZE (env-overridable, default 5000):\n\n❌ Size guard: refusing to write — projected MEMORY.md 18689 bytes\n   > limit 5000 bytes (+13710 would be added).\n   Trim/archive MEMORY.md first, or promote fewer entries.\nThe file is left byte-for-byte untouched on refusal. Promotions are suggestions,\nnot obligations: losing an overflow is strictly better than paying for it in\nevery future session's context.\n\nVerified by direct test: a 2-entry lot applies, a 20-entry lot is refused and\nthe file stays intact.\n\nv4.0.2 | 2026-10-10T17:56:24.009Z | user\n\nv4.0.2 — Ontology health check no longer cries wolf (2026-10-10)\nReal fix\nmemory-health.py parser was blind to op=state. The ontology graph is an\nappend-only log whose dominant record is now op=state (879 of 964 lines in the\ncurrent graph); create/upsert are the older forms. check_ontology() counted\nan entity only for op in (\"create\", \"upsert\"), so it saw 50 entities while\nontology_compact.py, which replays the full log, saw 927. The 34 relate\nrecords pointed at the real entities, so the check reported 68 orphan\nrelations on every run — all of them false.\n\nThe parser now accepts state as an entity-bearing op. Both tools agree:\n\nbefore:  50 entities,  68 orphan relations (🟡)\nafter : 928 entities,   0 orphan relations (🟢)\nNo false-positive suppression: the orphan count is still computed and still\nreported when real orphan relations exist. Only the parse was wrong.\n\nNotes\nDiscovered while triaging a health report (10/10): the \"68 orphan relations\"\nwarning was the health check disagreeing with the compactor, not real drift.\nontology_compact.py needed no change — it was already correct.\n\nv4.0.1 | 2026-10-10T16:40:05.698Z | user\n\nSecurity hardening patch. Real fixes plus a cleared scanner false positive.\n\nReal fixes\nA. Loopback guard on llm_resolution.py (SSRF gap). The v4.0 resolver read OLLAMA_URL straight from the environment while every other module routes it through get_safe_ollama_url() — and test_loopback_guard.py even exempted it from the static scan. A crafted environment could point the safety-net endpoint at a remote host. It now uses the same loopback guard, and the exemption is gone.\n\nB. Cloud transport is now opt-in. trace_extractor.py used to send memory and session excerpts to ollama.com whenever an API key was present. Cloud is now used only when the operator explicitly sets TRACE_LLM_ALLOW_CLOUD=1 in addition to the key; otherwise extraction stays on 127.0.0.1:11434. TRACE_LLM_LOCAL_ONLY=1 remains a hard local-only lock.\n\nCleared scanner false positive\nhybrid-search/test_ontology_key_parity.py:61 was flagged suspicious.dynamic_code_execution. It is the standard Python import machinery loading a fixed, literal path in the same repository — no network, no user input, no env var, no argv. The loader was not removed to silence the scanner (that would make the test track a stale copy); an inline explanation and a noqa marker were added instead.\n\nTriaged false positives (documented in CHANGELOG)\nTainted flow / credential exfiltration (×5): flagged values are OLLAMA_URL, a timeout, and a Content-Type header — not secrets.\nAnti-Refusal Statement: prose describing a feature, not an instruction.\nCredential Access (×4): the quoted text is the refusal list (.secrets/*, /etc/passwd are rejected).\nReferenced artifact not completely inspected: the skill documents modules outside the scanned bundle.\n\nv4.0.0 | 2026-10-10T16:00:30.163Z | user\n\nBreaking contract change. Model selection is no longer copied into each script: it is resolved once, at runtime, from the gateway configuration. Every caller now goes through a single module, hybrid-search/llm_resolution.py, so a catalogue rotation or a config change propagates everywhere at once instead of silently breaking one script at a time.\n\nWhy this release exists\nv3.6.0 and v3.6.1 each fixed a symptom: a hard-coded model name that no longer matched what the Ollama daemon served. The fix worked, but the pattern survived. v4.0 removes the pattern.\n\nAdded\nhybrid-search/llm_resolution.py — single source of truth for the effective LLM model. Chain: explicit env var → agents.defaults.model.primary → fallbacks[0] → local safety net (qwen2.5:7b, presence-checked). Exposes resolve_llm_model() and explain_resolution().\nNo-silent-failure invariant: a missing local safety model raises instead of letting Ollama start a synchronous multi-GB pull.\ntest_model_resolution.py (extended), test_extract_atomic.py (new), test_ontology_key_parity.py (new).\nChanged\n4 callers migrated to the shared module: consolidate_advisor.py, hybrid-search/auto_capture.py, hybrid-search/conflict_resolver.py, trace_extractor.py. PREFERRED_MODELS lists removed.\nsync-skill.sh now ships llm_resolution.py (it was missing — callers would have silently fallen back to the old behaviour).\nFixed\nDestructive detection bug in migrate_ontology_subjects.py (M4-B). The migrator rebuilt the display key as name or type, but the indexer uses name or entity.id or type. Decision/TimelineEvent nodes have no name, so it reported 2826 live facts as ghosts; a single --apply would have marked 2406 valid facts superseded. Fixed: survivors 3871 / ghosts 0 (was 1045 / 2826).\nHousekeeping\nmigrate_*.py marked APPLIED (kept as re-auditable tools).\nSuperseded draft changelog archived; dead orphan log archived.\nVerified\nscripts/release.sh check green: all five test suites pass.\nBoth arbiters resolve to a model the local daemon actually serves.\n\nv3.6.1 | 2026-10-07T18:15:33.048Z | user\n\nv3.6.1 — Arbiter follows the operator's real default (2026-10-06)\nFollow-up to v3.6.0. That release restored conflict arbitration but still listed glm-5.2:cloud first in PREFERRED_MODELS — a name inherited from the v2.2.0 hard-code, not a deliberate choice. It is installed on the daemon (so the fix worked), but it is not the model this deployment actually runs on.\nChanged\nPREFERRED_MODELS now follows the operator's real default, deepseek-v4-pro:cloud (the configured agents.defaults.compaction.model), ahead of deepseek-v4.1-flash:cloud, glm-5.2:cloud and the offline qwen2.5:7b. Both conflict_resolver and consolidate_advisor are aligned.\nAn explicit CONFLICT_LLM_MODEL / TRACE_LLM_MODEL / OLLAMA_MODEL still overrides the list, so pinning a model stays a visible, one-line decision.\nVerified\ndeepseek-v4-pro:cloud returns CONTRADICTION (confidence 0.85) with a correct rationale on the same backup-broken/repaired probe that v3.6.0 used — the model swap does not weaken the arbiter.\nBoth modules resolve to deepseek-v4-pro:cloud on this host.\nv3.6.0 — Conflict arbitration was dead on arrival (2026-10-06)\nFix release. Closes the finding that the superseded / disputed lifecycle paths had never executed once. The cause was not the subject fidelity work of v3.5.0 nor the source echo guard: it was a missing model tag.\nFixed\nThe arbiter asked Ollama for a model that does not exist. conflict_resolver sent the bare name glm-5.2, while the daemon serves glm-5.2:cloud. Ollama answered HTTP 404, classify_relation() fell through to the conservative heuristic, and every fact came back COMPATIBLE / no confident relation detected. Conflict arbitration had therefore never fired: superseded and disputed were unreachable code paths. Verified against a real contradiction (backup broken → repaired): the LLM now returns CONTRADICTION with a correct rationale, and --apply writes status=superseded + the superseded_by link.\nconsolidate_advisor.py carried the identical defect (OLLAMA_MODEL default \"glm-5.2\"), so the advisor was silently producing nothing for the same reason. Both modules now resolve their model the same way.\nChanged\nModel resolution is no longer hard-coded. A hard-coded name — even one with the correct tag — rots on the next model swap. Both modules now resolve against the models the daemon actually serves (GET /api/tags): an explicit CONFLICT_LLM_MODEL / TRACE_LLM_MODEL / OLLAMA_MODEL still wins, otherwise the first served model from a preference list is used, and an unreachable daemon falls back to a tagged preference instead of crashing or sending a bare name.\nNew regression test hybrid-search/test_model_resolution.py pins the three behaviours (explicit env wins / resolves to a served model / offline fallback is tagged). Wired into scripts/sync-skill.sh and the release.sh check gate.\nSource filter (M4 follow-up): trivially short user turns (go, ok, top, …) are skipped by the trace extractor instead of being mined for durable facts — the 76-turn dry-run contained 18% such turns feeding ~80% operational noise.\nVerified\nReal contradiction on a DB copy: [contradiction] Le backup nightly est repare → --apply → id=5134 status=superseded superseded_by=5135.\nNon-contradictions still classified COMPATIBLE (Kavita 0.9.0 → 0.9.1.4; Dovato morning vs evening \"plus le matin\") — the arbiter discriminates, it does not cry conflict.\ntest_model_resolution.py: 3/3 hold. test_loopback_guard.py: all guards hold (run under the skill venv, which has sqlite_vec).\nv3.5.0 — Subject fidelity (M4) + source echo guard (2026-10-06)\nFix release. Closes the M4 audit finding — the one that made conflict arbitration structurally unable to fire. Three independent defects, one shared root: facts reached the ledger with a subject that did not discriminate, so the resolver's \"is this the same entity?\" test could never return a confident match.\nFixed\nMulti-word subjects were truncated to their first word (auto_capture._normalise_facts, mirrored in the new trace_extractor._norm_subject). '\\w' includes _, so re.split(r\"[^\\w]+\", \"kavita_home\") yielded a SINGLE token, grounding failed, and the fallback took the fact's first substantial word. kavita_home became kavita, backup_cron became backup — so kavita_home and kavita_index (different entities) collided under one key. Grounding now compares against the tokenised text and splits on underscore too.\ntrace_extractor.py produced no subject at all. Its prompt never requested one and its writer never emitted one, so every trace item reached the ledger as subject=NULL. The prompt now requires a grounded subject per item, and the writer emits it as [subject:key] on the note line for the downstream indexer.\nMachine-generated assistant turns were mined for user facts (echo guard). The only gate was should_capture(user_msg); an assistant turn carrying a tool completion (... executed from ...) or a cron report (Summary: {'added': 0, ...}) was handed to the extractor and returned as a \"durable fact\". assistant_turn_is_echo() now drops such turns — the user half is kept, the machine half is discarded.\nVerified\nEcho guard: 8/8 cases (tool logs, cron summaries, === END === markers gated; real conversational turns kept).\nSubject normalisation: kavita_home/backup_cron/ubuntu_24_04 keep their full key; v3.3 anti-hallucination behaviour (Serveur Prod → serveur) preserved.\nNotes\n--apply remains gated behind a real dialogued conflict batch: the superseded/disputed paths are still unproven on live data.\nv3.4.0 — Point-in-Time Retrieval (--as-of) (2026-10-05)\nFeature release. Turns the fact-lifecycle ledger into a time machine: the search can now reconstruct the exact cognitive state the agent had on a past date, not just what it believes today. In PLM terms, this is the step from a single \"As-Maintained\" configuration to a retrievable \"As-Built\" baseline. Born from the operator's observation (2026-10-05) that the v3.3 ledger — which marks rows superseded instead of deleting them — already held the history; what was missing was a timestamp for when a fact stopped being current.\nAdded\nsuperseded_at column (schema.sql). The ISO timestamp at which a row STOPPED being active (NULL while it is). This is the axis the earlier schema lacked: valid_from records when the fact became true in the world, while superseded_at records the lifecycle of the row — and updated_at cannot serve, because a REDUNDANT confirmation rewrites it without ending anything. Without a dedicated column, point-in-time retrieval is impossible to express correctly.\n--as-of YYYY-MM-DD on query, search and context. Reconstructs the facts visible on that date instead of the current set. A bare date means end of that day (2026-07-01 → T23:59:59), so a row created at 10:00 on that day is visible; a full timestamp is used verbatim. Verified against a MySQL→PostgreSQL switch: the pre-switch fact is returned for a date before it and the successor for a date after, with no overlap.\n_as_of_clause() — one helper builds the visibility predicate shared by the lexical and vector paths, so both halves of the hybrid search agree on what \"visible on date D\" means.\nChanged\nensure_lifecycle_columns() now also adds superseded_at (and its index) to an older DB, so the migration is idempotent for existing installations.\napply_resolution() and resolve/--confirm/--reject stamp superseded_at at each active → superseded transition, inside the existing atomic transaction (C2).\nsearch_lexical() / search_vector() / search_hybrid() take an optional as_of; default (None) behaviour is byte-for-byte the previous \"active only\" query — verified by a regression run against the live database.\nData maintenance (real database, 2026-10-05)\n3 475 superseded rows backfilled with superseded_at = COALESCE(updated_at, created_at). Backup taken and integrity_check verified before the write; post-run foreign_key_check clean; zero active rows carry a superseded_at.\nv3.3.0 — Referential Integrity, Transactional Writes, Ontology→DB Sync (2026-10-05)\nHardening release. An audit of the fact-lifecycle layer found that the schema declared a state machine (status, confidence, superseded_by) the engine never enforced, that the write paths were not atomic, and that subject arbitration was dead in practice (100 % of indexed facts had subject = NULL). Every defect is fixed at the source and verified against the real database.\nFixed\nSchema now enforces what it declared (C1). status carries a CHECK (status IN ('active','superseded','disputed')), confidence a range CHECK, and superseded_by a FOREIGN KEY … ON DELETE SET NULL.\nconflict_resolver.apply_resolution() is atomic (C2). The whole resolution is now one BEGIN IMMEDIATE … COMMIT with rollback.\nConcurrent access is safe (C3). Both connection paths now set busy_timeout=5000, journal_mode=WAL and foreign_keys=ON.\nadd_memory() writes the hot row and its vector atomically (C4).\ncompact.py never archives a still-referenced fact (M2).\nontology_compact.py drops orphan relations (M3).\nAdded\nsubject is finally writable and populated (M4). Arbitrable fact categories now sit at 100 % subject coverage.\nOntology → DB synchronisation. Every entity that leaves the reference nomenclature has its facts marked superseded in the same run.\nmigrate_subject.py / migrate_ontology_subjects.py — one-shot, idempotent backfill/audit tools.\nv3.2.1 — Ontology Reindex, Number-Safe Splitting, Local Model Bump (2026-10-05)\nMaintenance release. Two defects found during the first live Auto-Capture session, both fixed and verified against the real database.\nFixed\nNested-schema ontology indexing. index_jsonl_file() read name/type at the JSON root, but ontology lines are nested — every graph node was indexed as the literal string \" ()\": 2 786 junk rows, 69 % of the database.\nNumber-safe punctuation split. extract_atomic() split on every period: Ubuntu 24.04 became Ubuntu 24 + 04. A period now splits only when not sandwiched between digits ((?<![0-9])\\.(?![0-9])).\nChanged\nDefault extraction model: qwen2.5:3b → qwen2.5:7b (still local, loopback-only).\nDatabase maintenance: 2 786 empty ontology rows moved to superseded (reversible) and the 898 clean nodes re-indexed.\nv3.2.0 — Auto-Capture: Session Dialogue → Arbitrated Facts (2026-10-04)\nFeature release. Adds the write-behind half of the autonomous memory loop: a post-turn pipeline that reads session dialogue, extracts atomic durable facts with a local LLM, and arbitrates them against existing memory.\nAdded\nhybrid-search/auto_capture.py — post-turn fact extraction.\nhybrid-search/transcript_adapter.py — reads the OpenClaw per-agent session store (session_transcript_fts), snapshot-first, read-only, secret-redacting.\n--no-split flag on conflict_resolver.py check/arbitrate.\n\nv3.1.1 | 2026-10-04T11:57:59.473Z | user\n\n- Added CONTRIBUTING.md to provide contribution guidelines.\n- Removed skill-card.md.\n- Version bump from 3.1.0 to 3.1.1; no functional or interface changes to the pipeline or scripts.\n\nv3.1.0 | 2026-10-04T11:27:22.262Z | user\n\n**Summary:** Adds release+sync automation scripts and updates release process documentation.\n\n- Added `scripts/release.sh` and `scripts/sync-skill.sh` for automated skill release and synchronization.\n- Added `hybrid-search/test_loopback_guard.py` (purpose not detailed).\n- Removed `skill-card.md`.\n- SKILL.md now requires all changes to go through `scripts/release.sh`; direct edits to installed skills are disallowed.\n- Updated documentation to reflect new enforcement and workflow for releases.\n\nv3.0.2 | 2026-10-03T12:13:16.781Z | user\n\nv3.0.2 — Security: OLLAMA_GEN_URL bypassed the loopback guard\nRound 8 security scan of the published v3.0.0 returned 51 findings. One was real and is fixed here; the rest are the known false-positive families, re-triaged in docs/SECURITY-AUDIT-NOTES.md §3 so they do not have to be re-litigated.\n\nFixed (real finding — Data Flow Critical 97%, Intent-Code Divergence 98%)\nhybrid-search/conflict_resolver.py read OLLAMA_GEN_URL straight from os.environ, bypassing get_safe_ollama_url():\n\n# before — no guard\nOLLAMA_GEN_URL = os.environ.get(\"OLLAMA_GEN_URL\", OLLAMA_URL + \"/api/generate\")\nclassify_relation() POSTs the content of two memory facts to that URL on every arbitration. Any process able to set the environment variable could redirect memory content to a remote host — while the docstring directly above still claimed \"the destination is fixed to localhost at import time, so a remote endpoint cannot receive memory content.\"\n\n# after — same loopback allowlist as OLLAMA_URL\nOLLAMA_GEN_URL = get_safe_ollama_url(\"OLLAMA_GEN_URL\", OLLAMA_URL.rstrip(\"/\") + \"/api/generate\")\nVerified:\n\nOLLAMA_GEN_URL=\"http://evil.example.com/api/generate\" → ValueError: Host 'evil.example.com' not allowed for OLLAMA_GEN_URL. Only localhost is permitted. (raised at import)\nOLLAMA_GEN_URL=\"http://127.0.0.1:11434/api/generate\" → imports fine.\nNo other os.environ.get(\"OLLAMA…\") bypass remains (grep-verified).\nTriaged as false positives (documented, not code changes)\nTainted flow → urlopen in consolidate_advisor.py and trace_extractor.py's local fallback: sinks are loopback-validated or a fixed literal.\nCredential Access (×9): SECRET_SKIP_PATTERNS are deny-lists that refuse secrets, plus markdown prose documenting a fixed symlink-smuggling defect.\nAnti-Refusal Statement: \"a contested pair can never both stay visible\" is a data invariant (one active fact per subject), not an agent behaviour rule.\nAe1 — artifact not completely inspected: caused by the scan-scope allowlist naming the three files it may read without enumerating skills/*. The allowlist is the control.\nAutonomous Decision Making (×5): the quoted if not sys.stdin.isatty(): … return and --force help strings are the human-in-the-loop guard.\nSession Persistence: prose describing the documented nightly cron, not an installer.\nContext Leakage (--session-file): explicit opt-in, disclosed before send, cancellable with TRACE_LLM_LOCAL_ONLY=1. Accepted & documented (§2.8).\nFiles Modified (2)\nhybrid-search/conflict_resolver.py, docs/SECURITY-AUDIT-NOTES.md.\n\nv3.0.1 | 2026-10-03T12:09:26.906Z | user\n\n- Removed the sample file skill-card.md.\n- No changes to pipeline scripts, features, or behavior.\n- Documentation and usage remain unchanged.\n\nv3.0.0 | 2026-10-03T11:13:58.759Z | user\n\n**Major update: Introduces fact lifecycle management and conflict resolution, with new hybrid search and fact archival components.**\n\n- Added `conflict_resolver.py` for fact contradiction/dispute lifecycle management (NLI classification; supports active/superseded/disputed states)\n- Added `compact.py` for archiving terminal facts and maintaining lean indexes with full audit trace\n- Updated pipeline so facts are no longer only accumulated: only `active` facts are retrieved; weak contradictions escalate to dispute instead of silent removal\n- Expanded hybrid search with new scripts and improved state management\n- Removed outdated skill-card documentation\n\nv2.2.2 | 2026-10-02T20:28:20.807Z | user\n\nRound 7, from the second SkillSpector run on the published v2.2.1 (54 findings).\nMost of the report is scanner triage already covered in\ndocs/SECURITY-AUDIT-NOTES.md; one finding was real, and it was one the scanner\nonly half-saw.\n\nFixed\nThe transport disclosure lied about the transport (trace_extractor.py):\nllm_destination() — the function whose entire job is to warn the operator before\nmemory content leaves the machine — read only os.environ[\"OLLAMA_API_KEY\"],\nwhile the actual sender, get_ollama_api_key(), also resolves the key from\n~/.openclaw/workspace/.secrets/ollama.json and from openclaw.json.\nConsequence: with the key in the secrets file — the common deployment — the banner\nprinted \"local Ollama … else local fallback\" and the content was then posted to\nhttps://ollama.com. The warning was wrong in exactly the configuration it\nexisted to protect. This is worse than an undisclosed transmission: it is a\ndisclosure that actively misleads.\nBoth code paths now share one resolver, _find_ollama_api_key_sources(), which\nreturns (source, key) in one fixed precedence order. The banner names the source\n(key from env, key from secrets-file, key from config) and TRACE_LLM_LOCAL_ONLY=1\nshort-circuits before any lookup.\nIntent/code divergence in the docs (README.md, SKILL.md): the README opened\nwith \"No external API dependencies (Ollama runs locally via HTTP, no cloud APIs)\"\nand the SKILL description ended with \"zero external cloud API dependencies\" — while\nthe extractor's primary transport was Ollama cloud. Six of the 54 findings are\nthis single contradiction. Both files now state the real posture: local by\ndefault, one opt-in cloud path, with the switch and the warning documented at the\ntop, not buried.\n--session-file contradicted the confinement claim (SKILL.md): the notes\nasserted that every script stays inside WORKSPACE/memory/, but --session-file\ndeliberately accepts one absolute path outside it (a session transcript does not\nlive under memory/). The claim is now precise — documented as an explicit,\nnever-automatic exception rather than silently overstated. The same edit records the\nthree-file ALLOWED_SCAN_FILES allowlist so the stated scope equals the real scope.\nOLLAMA_API_KEY was missing from the configuration table (README.md): the\nvariable that turns on the only cloud-capable path was undocumented.\nVerified (executed, not read)\nThe bug, reproduced then closed: with no OLLAMA_API_KEY in the environment and\na key present in .secrets/ollama.json, the old llm_destination() returned\n('cloud', \"…if a key is configured, else local fallback\") — ambiguous at best.\nThe patched version returns\n('cloud', 'Ollama cloud (ollama.com) — key from secrets-file — content leaves this machine').\nLocal-only still wins: TRACE_LLM_LOCAL_ONLY=1 returns\n('local', 'local Ollama (127.0.0.1:11434) — forced by TRACE_LLM_LOCAL_ONLY') before\nany key lookup runs.\nEnv override: OLLAMA_API_KEY set returns ('cloud', '… — key from env — …').\nast.parse() clean on the modified module.\nDocumented (scanner false positives — not defects)\nTainted flow os.environ → urlopen in consolidate_advisor.py (~318) and\ntrace_extractor.py (~359): the first targets OLLAMA_URL, produced by\nget_safe_ollama_url() and constrained to a loopback allowlist at import; the second\ntargets the literal http://127.0.0.1:11434. Loopback sinks, not exfiltration sinks.\n\"Credential Access\" on SECRET_SKIP_PATTERNS / SECRET_PATH_PATTERNS and on the\nmarkdown that documents them: a deny-list that names what it refuses is a control,\nnot a credential read. Firing on the prose of a fix is a category error.\n\"Autonomous Decision Making\" in auto_archive.py / consolidate_advisor.py:\nthe scanner quotes the if not sys.stdin.isatty(): return guard as though it forced\nthe action. It is the human-in-the-loop branch.\nFull dispositions: docs/SECURITY-AUDIT-NOTES.md §2.6.\nFiles Modified (4)\ntrace_extractor.py, README.md, SKILL.md, CHANGELOG.md, plus\ndocs/SECURITY-AUDIT-NOTES.md\n\nv2.2.1 | 2026-10-02T11:13:52.424Z | user\n\nCloses the T09 finding raised by the ClawHub / SkillSpector scan on 2026-10-02:\n\"Undisclosed Cloud Transmission of Memory and Session Content\" in\ntrace_extractor.py. The finding was valid. The extractor's primary transport is\nOllama cloud, so memory and session text leaves the machine — and neither the\ncode nor the docs said so, in a repository that advertises itself as local-first.\n\nFixed\nUndisclosed cloud transmission (trace_extractor.py): the extraction path\nposts to https://ollama.com/api/chat with a bearer key. Every run now prints its\ndestination before sending — [Security] ⚠️ CLOUD TRANSMISSION: … when the target\nis cloud, [Security] … (stays on this machine) when it is local — via the new\nllm_destination() helper.\nNo local-only escape hatch: added TRACE_LLM_LOCAL_ONLY=1, which makes\ncall_ollama_cloud() return early and refuses every cloud call. Local-only mode\ncannot silently fail over to a transport that leaves the machine.\nsanitize_pii() gaps: the filter was regex-only and missed secrets in uncommon\nformats. Extended with JWTs (eyJ…), hex blobs ≥32 chars, base64 blobs ≥40 chars,\nFrench phone numbers, card-like digit runs, access_key/apikey assignments and\nOpenSSH private keys. The docstring now states plainly that scrubbing is\nbest-effort, not a guarantee, and that the transport decision is the primary\ncontrol. A \"local-first, no cloud\" claim is only as good as the transport behind it.\nDocs\nREADME.md, SKILL.md: the transmission, the destination, and the local-only\nswitch are now documented; the blanket \"zero external cloud API\" phrasing is\ncorrected where it did not hold for the extractor. TRACE_LLM_LOCAL_ONLY added to\nthe configuration table.\ndocs/SECURITY-AUDIT-NOTES.md: T09 recorded as a closed real finding (§1.0);\nthe two false-positive families it generated — tainted-flow at urlopen in\ntrace_extractor.py (§2.4) and credential-access hits on the secret deny-lists and\non the audit prose itself (§2.5) — are triaged with dispositions.\nVerified (executed, not read)\nllm_destination() returns ('local', …) under TRACE_LLM_LOCAL_ONLY=1, and\n('cloud', \"… content leaves this machine\") when OLLAMA_API_KEY is set.\nsanitize_pii() redacts all six test classes: JWT, hex-32, base64-40, French phone\nnumber, email, bearer token — 6/6.\nNon-regression: ordinary note text survives (version 2.1.4, exit code 1\nintact); only the embedded email is redacted.\nast.parse() clean on the modified module.\nAcknowledgements\nT09 reported by the ClawHub security scan (SkillSpector). Fixed rather than argued.\n\nv2.1.5 | 2026-10-02T08:47:37.709Z | user\n\nFirst release that includes trace_extractor.py as a shipped artifact rather\nthan a referenced-but-absent step, plus a long-overdue garbage collector for the\nontology op-log. Two latent bugs in the extractor are fixed: IDs that were\nnever stable across processes, and an \"upsert\" that physically appended.\n\nAdded\ntrace_extractor.py — the session/notes extractor is now published. It was\ncited as step 1 of the documented pipeline since v2.0 but the file itself was\nnever in the repository, so cloning the toolkit gave you a README referencing a\nscript no one could run. Categories: decisions, errors, facts, patterns.\nontology_compact.py — garbage collector for memory/ontology/graph.jsonl.\nThe ontology file is an append-only operation log; nothing ever replayed it, so\nthe same entity was rewritten on every run and the file grew without bound.\nThe compactor replays the log into a consolidated state (one line per active\nentity, superseded records dropped), backs up first, validates that the entity\nset and contents are identical, and only then swaps in place. Idempotent: it\nskips when the gain is below --min-gain (default 5%).\nFixed\nNon-deterministic entity IDs (trace_extractor.py): decisions were keyed\nwith hash(what) % 10000. Python randomises hash() per process\n(PYTHONHASHSEED), so the same decision produced a different ID on every\nrun. The if entity_id not in existing_ids guard could never fire — the ID was\nalways new — and the guard's own premise (ID identifies content) was false.\nObserved impact on a production workspace: one episode rewritten 38 times\nunder 38 different IDs, 63 IDs duplicated 2–38×, 52% of all log lines redundant.\nIDs now come from stable_id(), a SHA-256 prefix, stable across processes and\nmachines.\nupsert that appended (trace_extractor.py): records were labelled\n\"op\": \"upsert\" but written with open(path, \"a\"). The operation name\ndescribed an intent the code did not implement — an update was impossible, only\nappends happened. Both writers now go through upsert_entities(), which reads\nthe current file, replaces matching entities in place, appends the rest, and\nwrites atomically via a temp file.\nVerified (executed, not read)\nID stability: stable_id() called from three separate interpreter processes\nreturns the identical digest (dec_20260629_4f0644cd, tl_20260613_873a194b),\nwhere the previous hash()-based scheme returned a different value each run.\nReal upsert: on a two-entity file, updating an existing ID leaves the line\ncount unchanged and replaces the record; adding a new ID grows it by exactly one.\nCompactor on a production graph: 742,364 → 272,518 bytes (−63%),\n2,018 → 912 lines, 1,106 redundant lines dropped, and the reloaded entity set\ncompared equal to the pre-compaction state (912 active entities, 0 lost).\nCompactor idempotence: re-run on the already-compacted file reports 0 lines\ndropped and writes nothing (gain below threshold).\nSyntax: ast.parse() clean on both new scripts.\nNotes\nThe ontology compactor pairs with the parser fix in the sibling release line:\nmemory_health.py and the index builder accept any record carrying an entity,\nso a consolidated \"op\": \"state\" file and a raw operation log both index\ncorrectly. Previously the indexer matched op == \"create\" only, which silently\nindexed zero entities once a log had been compacted.\n\nv2.1.4 | 2026-09-27T19:25:04.215Z | user\n\nv2.1.4 — Allowlist/Index Agreement, Both Directions\nFollow-up found by the v2.1.3 control pass. v2.1.3 fixed an allowlist that was narrower than what was indexed; the fix then made it wider than disk reality. Declared scope must equal indexable scope in both directions.\n\nFixed\nDead entry in ALLOWED_SCAN_FILES (hybrid-search/hybrid_search.py): v2.1.3 declared TOOLS.md unconditionally, but collect_all_files() guards each root file with os.path.exists() — and TOOLS.md does not exist on a standard workspace (its content was merged into AGENTS.md). The allowlist therefore advertised a file that was never indexed: the same intent/code divergence v2.1.3 set out to remove, re-created in the opposite direction. Optional root files are now declared only when present on disk ({f for f in ROOT_CONFIG_FILES if os.path.exists(f)}), so MEMORY.md is always declared and TOOLS.md is declared exactly when it is actually indexable. OWN_SKILL_FILE stays unconditional (it is a shipped artifact).\nVerified (executed, not read)\nTOOLS.md absent (real workspace): dropped from the allowlist; safe_resolve() refuses the path with UnsafeFileError: outside allowed scan dirs.\nTOOLS.md present (temp workspace, WORKSPACE_ALLOW_CUSTOM=1): automatically declared and resolved successfully.\nMEMORY.md (present): still declared and resolved.\nNon-regression: /etc/passwd still refused.\nFiles Modified (1)\nhybrid-search/hybrid_search.py\n\nWhy this release matters: an audit that reads code cannot see the disk. This defect was invisible to static analysis — it took a control pass that executed the guard in a real workspace to surface a declaration that pointed at nothing. A static audit does not replace a runtime pass.\n\nFull context: see CHANGELOG.md for v2.1.3 (runtime confinement, read-only correctness, subprocess isolation) and docs/SECURITY-AUDIT-NOTES.md for the triage of the remaining ClawHub scanner findings as design-confirmed false positives.\n\nv2.1.3 | 2026-09-27T19:22:30.724Z | user\n\nv2.1.4 — Allowlist/Index Agreement, Both Directions\nFollow-up found by the v2.1.3 control pass. v2.1.3 fixed an allowlist that was narrower than what was indexed; the fix then made it wider than disk reality. Declared scope must equal indexable scope in both directions.\n\nFixed\nDead entry in ALLOWED_SCAN_FILES (hybrid-search/hybrid_search.py): v2.1.3 declared TOOLS.md unconditionally, but collect_all_files() guards each root file with os.path.exists() — and TOOLS.md does not exist on a standard workspace (its content was merged into AGENTS.md). The allowlist therefore advertised a file that was never indexed: the same intent/code divergence v2.1.3 set out to remove, re-created in the opposite direction. Optional root files are now declared only when present on disk ({f for f in ROOT_CONFIG_FILES if os.path.exists(f)}), so MEMORY.md is always declared and TOOLS.md is declared exactly when it is actually indexable. OWN_SKILL_FILE stays unconditional (it is a shipped artifact).\nVerified (executed, not read)\nTOOLS.md absent (real workspace): dropped from the allowlist; safe_resolve() refuses the path with UnsafeFileError: outside allowed scan dirs.\nTOOLS.md present (temp workspace, WORKSPACE_ALLOW_CUSTOM=1): automatically declared and resolved successfully.\nMEMORY.md (present): still declared and resolved.\nNon-regression: /etc/passwd still refused.\nFiles Modified (1)\nhybrid-search/hybrid_search.py\n\nWhy this release matters: an audit that reads code cannot see the disk. This defect was invisible to static analysis — it took a control pass that executed the guard in a real workspace to surface a declaration that pointed at nothing. A static audit does not replace a runtime pass.\n\nFull context: see CHANGELOG.md for v2.1.3 (runtime confinement, read-only correctness, subprocess isolation) and docs/SECURITY-AUDIT-NOTES.md for the triage of the remaining ClawHub scanner findings as design-confirmed false positives.\n\nv2.1.2 | 2026-09-27T09:45:58.614Z | user\n\nFollow-up to the public ClawHub security audit. Three real findings closed; the remaining secondary-block items were confirmed as scanner false positives.\n\nSecurity\nAttacker-controllable import path removed (hybrid-search/hybrid_search.py): VEC_VENV_PATH = \"/tmp/vec-test-venv/...\" + sys.path.insert(0, ...) deleted. A world-writable directory at the head of sys.path gives import resolution absolute priority over site-packages, so any local process could drop a sqlite_vec.py and have it executed on the next import. The path did not exist on the production host either — dead and dangerous. sqlite_vec now imports from the active environment, with an actionable ImportError (pip install sqlite-vec).\nsafe_resolve() added to hybrid_search.py: applied to index_file(), index_jsonl_file() and the --dir walk. Guard order: refuse symlinks outright; realpath() to normalise ..; confine to ALLOWED_SCAN_DIRS (memory/ only); match secret patterns against the resolved path, never the literal one; require a regular file. --dir outside scope is rejected before the glob runs. New UnsafeFileError.\nSymlinks refused in the memory scanners (scoring.py, auto_archive.py, consolidate_advisor.py): is_symlink() checked before is_file(); scoring.py gained is_safe_memory_file() — a symlink named like a daily note is the classic route for pulling a private key into a vector index.\nSecret patterns extended: .ssh, .aws, .config/google, id_rsa, id_ed25519, .pem, .key.\nVerified\nSyntax validated on all five modules.\nGuards exercised for real: a legitimate note is accepted; a symlink named 2026-01-01-innocent.md pointing at /etc/passwd is refused; /etc/passwd passed directly is refused as out of scope; api-token-notes.md inside memory/ is refused; --dir /etc is refused before listing.\nNot changed (by design)\nauto_archive.py and consolidate_advisor.py still require --force in non-interactive mode. Intended behaviour, now documented rather than implicit.\n\nv2.1.1 | 2026-08-22T17:48:54.367Z | user\n\nSensitive Data Purge\nDeleted: results/*.json (11 files), results/*.svg (9 files)\nDeleted: hybrid-search/FULL_INDEX_REPORT.md, hybrid-search/test_results.json, hybrid-search/agent_memory.db\nDeleted: hybrid-search-proto/ (entire prototype folder)\nDeleted: __pycache__/ (all .pyc files)\nAll PII removed: personal names, company names, secret paths, domain names, project names\n.gitignore Hardened\nAdded: *.svg, eval_output/, *.log, *.pyc, hybrid-search-proto/, .secrets/\nSubprocess.run Hardening\nPII query replaced with anonymized fixture (project alpha configuration)\nAll script paths validated with Path.resolve().is_relative_to(WORKSPACE) — prevents path traversal\nNo environment variable injection in subprocess calls — fixed argument lists only\nScope Confinement (anti skill enumeration)\nhybrid_search.py: no longer globs skills/*/SKILL.md — only indexes its own SKILL.md\nPersonal files excluded from search index: USER.md, IDENTITY.md, AGENTS.md, SOUL.md, HEARTBEAT.md\nscoring.py, auto_archive.py, consolidate_advisor.py: MEMORY_DIR.is_relative_to(WORKSPACE) validation with ALLOWED_SCAN_DIR\nmemory-health.py READ-ONLY by Default\nNo files written to disk without --output-dir <path> flag\nSVG trend charts, JSON reports, benchmark reports: all gated by the flag\ncheck_drift() no longer auto-creates RESULTS_DIR\nDocstring updated: documents read-only default, --output-dir, --fix as destructive mode\nAnonymized Test Fixtures\nrun_tests.py TEST_QUERIES: AstroCapture → project_alpha, leadership coaching Airbus → team leadership coaching session, 2026-08-17 → sample_note_01, etc.\nBranch Aligned: master → main\nDefault branch aligned to main on GitHub\nOld master branch deleted from remote\nFiles Modified (10)\n.gitignore, README.md, SKILL.md, auto_archive.py, consolidate_advisor.py, hybrid-search/hybrid_search.py, hybrid-search/run_tests.py, memory-health.py, scoring.py, deleted hybrid-search/FULL_INDEX_REPORT.md\nZero residual PII — verified with grep across the entire repo.\nFull changelog: https://github.com/MisterMiJarvis/openclaw-memory-toolkit/blob/main/CHANGELOG.md\n\nv1.0.5 | 2026-08-18T12:31:29.965Z | user\n\n### memory-toolkit 1.0.5\n\n- Major project restructuring: all script files moved to top level and `hybrid-search/`, removing `scripts/`, `ontology/`, and some doc files.\n- Added complete `hybrid-search/` module with detailed index report, schema, tests, and hybrid search implementation.\n- Updated documentation and removed outdated files such as `skill-card.md`, old ontologies, and former script locations.\n- All utility scripts (`auto_archive.py`, `consolidate_advisor.py`, `memory-health.py`, `scoring.py`, `trace-extractor.py`) are now present at the repository root or new structured locations.\n- The skill’s core pipeline, documentation, and usage remain unchanged, but code organization is now clearer and more modular.\n\nv1.0.4 | 2026-08-18T10:20:33.917Z | user\n\n- Added new script: scripts/trace-extractor.py for session trace extraction.\n- Removed skill-card.md file.\n\nv1.0.3 | 2026-08-18T09:47:16.177Z | user\n\n- Added CHANGELOG.md to the project.\n- Removed skill-card.md file.\n- No changes to core pipeline features or script functionality.\n\nv1.0.2 | 2026-08-18T09:32:20.076Z | user\n\n• OLLAMA_EMBED_URL dérivé depuis OLLAMA_URL (plus hardcodé) — consent warnings affichent la vraie destination\n• check_ollama_url() valide les deux variables\n• run_tests.py : snippets de contenu retirés (metadata seulement)\n• \"Safe by default\" corrigé dans README + SKILL\n\nv1.0.1 | 2026-08-18T09:10:13.548Z | user\n\n- Removed the \"skill-card.md\" file from the project.\n- No functional or code changes; documentation only.\n\nv1.0.0 | 2026-08-18T06:58:18.012Z | user\n\n- Removed: ontology directory, all top-level scripts, scripts/trace-extractor.py, and skill-card.md.\n- Major cleanup to remove all bundled Python scripts and ontology files from the skill package.\n- Only documentation and core definitions remain; skill now lacks bundled code and ontology data files.\n\nv0.1.0 | 2026-08-18T02:00:21.981Z | auto\n\nInitial release: Complete local-first memory management pipeline for OpenClaw agents.\n\n- Provides six standalone, composable scripts: extraction, archiving, scoring, consolidation, health monitoring, and hybrid search.\n- No external API dependencies; all processing is local, including LLM and vector search (sqlite-vec & Ollama).\n- Introduces detailed extraction/category logic (decisions, errors, facts, promotions) and daily note archiving.\n- Implements temporal decay memory scoring and consolidation advisor with agent review flow.\n- Offers a robust hybrid BM25+vector search pipeline with RRF, temporal boost, and noise filtering.\n- Includes ontology graph/schema for structured entities and relationships.\n\nArchive index:\n\nArchive v4.0.5: 37 files, 213360 bytes\n\nFiles: .gitignore (180b), auto_archive.py (6847b), CHANGELOG.md (71839b), consolidate_advisor.py (23878b), CONTRIBUTING.md (5312b), docs/_ARCHIVED-CHANGELOG-UNRELEASED-V3.6.1.md (11916b), docs/AUDIT-v3.2.md (8243b), docs/SECURITY-AUDIT-NOTES.md (19262b), hybrid-search/auto_capture.py (27380b), hybrid-search/compact.py (14563b), hybrid-search/conflict_resolver.py (33247b), hybrid-search/hybrid_search.py (56422b), hybrid-search/llm_resolution.py (11841b), hybrid-search/run_tests.py (11727b), hybrid-search/schema.sql (4691b), hybrid-search/test_extract_atomic.py (3526b), hybrid-search/test_loopback_guard.py (4891b), hybrid-search/test_meta_gate.py (4494b), hybrid-search/test_model_resolution.py (10696b), hybrid-search/test_ontology_key_parity.py (4747b), hybrid-search/test_orphan_link.py (6647b), hybrid-search/transcript_adapter.py (8439b), memory-health.py (35879b), migrate_ontology_subjects.py (6295b), migrate_subject.py (3630b), ontology_backfill_llm.py (12326b), ontology_backfill_orphans.py (13248b), ontology_compact.py (9453b), ontology_fix_day_nodes.py (7328b), README.md (25952b), scoring.py (20871b), scripts/release.sh (8275b), scripts/sync-skill.sh (3381b), skill-card.md (1916b), SKILL.md (21124b), trace_extractor.py (49064b), _meta.json (133b)\n\nFile v4.0.5:SKILL.md\n\n---\nname: memory-health\ndescription: Complete memory management pipeline for OpenClaw agents — extraction, archiving, scoring, consolidation, health monitoring, hygiene, and ontology. Local by default (Ollama over local HTTP). Use for memory health checks, hybrid search, fact arbitration, cold-storage compaction, and memory pipeline maintenance.\n---\n\n# Memory Pipeline Skill\n\nComplete memory management pipeline for OpenClaw agents: extraction, archiving,\nscoring, consolidation, health monitoring, and ontology — local by default\n(Ollama runs locally via HTTP).\n\n⚠️ **One opt-in exception**: `trace_extractor.py` can send memory/session excerpts\nto **Ollama cloud** (`https://ollama.com`) when `OLLAMA_API_KEY` is configured.\nWith no key it stays local; `TRACE_LLM_LOCAL_ONLY=1` refuses every cloud call. The\ndestination is printed before each send. See the Security Notes below.\n\n> **⛔ MUST — releasing this skill.** Every change to this skill goes through\n> `scripts/release.sh`, without exception. A change is **not done** until\n> `scripts/release.sh check` passes green. Then, and only then, sync to the\n> installed skill and tag via `scripts/release.sh release vX.Y.Z \"msg\"`.\n> Never edit the installed skill directly. Never tag or publish on a red gate —\n> fix the drift or the invariant, never bypass the gate.\n> Pipeline: **local repo → installed skill → GitHub → ClawHub (manual).**\n\n## Pipeline Overview\n\n```\nNightly Cron (23h)\n  │\n  ├─ 1. trace_extractor.py    # Extract decisions/errors/facts from sessions\n  ├─ 2. auto_archive.py       # Archive daily notes >21 days\n  ├─ 3. scoring.py            # Score all memories with temporal decay\n  ├─ 4. consolidate_advisor.py # Suggest consolidations (agent reviews)\n  ├─ 5. conflict_resolver.py   # Arbitrate contradictory facts (NLI lifecycle)\n  ├─ 6. memory_health.py       # Periodic health check (weekly)\n  └─ 7. hybrid_search.py      # Hybrid search: FTS5 + sqlite-vec + RRF\n```\n\nAll scripts are standalone and composable. Run individually or as a pipeline.\n\n## Fact lifecycle (introduced in v3.0.0) — current release v4.0.5\n\nThe search DB no longer just accumulates facts: every fact carries a lifecycle\n(`active` / `superseded` / `disputed`) and only `active` facts are ever\nretrieved. `conflict_resolver.py` runs the four-step consistency pipeline:\natomic extraction → targeted retrieval of concurrent active facts → NLI\nclassification (`CONTRADICTION` / `REDUNDANT` / `COMPATIBLE`) → traceable state\nupdate. A weak contradiction is escalated to `disputed` rather than silently\ndestroying an established fact; `pending` surfaces the queue and `resolve\n--confirm|--reject` lifts the ambiguity. `compact.py` moves terminal facts into a\ncold archive (`memories_archive` + JSONL audit) so the hot FTS5/vector indexes\nstay lean without losing traceability.\n\n## Scripts\n\n### 1. `trace_extractor.py` — Session extraction\n\nExtracts decisions, errors, facts, and patterns from OpenClaw session\ntranscripts and daily notes. Updates daily notes with extracted items,\nappends entities to the ontology graph.\n\n```bash\n# Nightly (pattern-based, fast ~5s)\npython3 scripts/trace_extractor.py --days 1\n\n# Deep extraction (LLM-powered, ~60-180s)\npython3 scripts/trace_extractor.py --days 3 --llm\n\n# With session transcripts\npython3 scripts/trace_extractor.py --days 1 --llm --session-file /path/to/session.jsonl\n\n# Preview only\npython3 scripts/trace_extractor.py --days 1 --llm --dry-run\n```\n\n**Categories extracted:**\n- 🟢 DECISIONS — new choices, config changes, migrations\n- 🔴 ERRORS — bugs, failures, workarounds\n- 🔵 FACTS — new versions, configs, status changes\n- ⬆️ PROMOTIONS — items worth promoting to MEMORY.md\n\n**Output:** Daily notes updated, ontology entities added, `.trace-extracted` flag.\n\n### 2. `auto_archive.py` — Daily note archiving\n\nMoves daily notes older than N days to `memory/archive/YYYY-MM/` subdirectories.\n\n```bash\npython3 scripts/auto_archive.py                 # Archive notes > 21 days\npython3 scripts/auto_archive.py --days 30       # Custom threshold\npython3 scripts/auto_archive.py --dry-run       # Preview only\npython3 scripts/auto_archive.py --verbose       # Show each file\n```\n\nIdempotent. Only moves `YYYY-MM-DD*.md` files. Zero dependencies.\n\n### 3. `scoring.py` — Temporal decay scoring\n\nScores all memory items using exponential recency decay, category weights,\nfrequency boost, entity boost, and completion penalty.\n\n```bash\npython3 scripts/scoring.py                      # Score all memories\npython3 scripts/scoring.py --verbose            # Show top 20\npython3 scripts/scoring.py --threshold 0.3      # Filter by min score\npython3 scripts/scoring.py --dry-run            # Don't write output\n```\n\n**Scoring formula:**\n```\nscore = weight_category × recency_decay × frequency_boost × entity_boost × completion_penalty\n\nrecency_decay = exp(-ln(2) × days_old / HALF_LIFE_DAYS)\n```\n\n**Category weights:** DECISIONS ×3, ERRORS ×2, FACTS ×1.5, PATTERNS ×1.2, TRANSIENT ×1\n\n**Output:** `memory/scores.json` — full ranking with stats and promotion candidates.\n\n### 4. `consolidate_advisor.py` — Consolidation suggestions\n\nAnalyzes recent daily notes + scores.json to identify clusters, promotions,\nstale items, and duplicates. Writes consolidation_report.json by default.\nModifies MEMORY.md only with --apply-promotions flag (requires confirmation).\n\n```bash\npython3 scripts/consolidate_advisor.py                     # Last 7 days\npython3 scripts/consolidate_advisor.py --days 14           # Custom window\npython3 scripts/consolidate_advisor.py --verbose           # All suggestions\npython3 scripts/consolidate_advisor.py --no-llm            # Skip LLM (fallback)\npython3 scripts/consolidate_advisor.py --apply-promotions  # Write to MEMORY.md\n```\n\n**Output:** `memory/consolidation_report.json` — clusters, promotions, stale items, duplicates.\n\nLLM optional (Ollama) for cluster summaries. Falls back to text-based with `--no-llm`.\n\n### 5. `memory_health.py` — System health check\n\nComprehensive diagnostics: trace extraction, LoCoMo benchmark, MEMORY.md size,\nontology health, daily notes hygiene, index status, drift detection.\n\n**READ-ONLY by default**: writes nothing to disk. Use `--output-dir <path>` to save\nJSON reports and SVG trend charts.\n\n```bash\npython3 scripts/memory_health.py              # Full health check (read-only)\npython3 scripts/memory_health.py --quick      # Skip benchmark & LLM (read-only)\npython3 scripts/memory_health.py --benchmark  # Benchmark only (read-only)\npython3 scripts/memory_health.py --deep       # LLM + sessions + benchmark (weekly)\npython3 scripts/memory_health.py --output-dir results/  # Save reports to disk\npython3 scripts/memory_health.py --fix        # Fix mode (DESTRUCTIVE)\n```\n\n**Output:** `results/YYYY-MM-DD.json` — only with `--output-dir`.\n\n**Destructive actions (`--fix`)**: Moves daily notes >14 days old to `archive/`,\nrewrites ontology file (dedup + clean). Creates timestamped backup in `memory/backup/`\nbefore modifying. Requires interactive confirmation or `--force` flag.\n\n### 6. `ontology_compact.py` — Ontology graph GC\n\nCompacts `memory/ontology/graph.jsonl` (an append-only operation log) by replaying\nit into a consolidated state: one line per active entity, superseded records dropped.\n\n**Safe by design**: backs up first (MD5-verified), writes to a temp file, validates\nthat the entity set and contents are identical, and only then swaps in place.\nIdempotent: skips when the gain is below `--min-gain` (default 5%).\n\n```bash\npython3 scripts/ontology_compact.py --dry-run      # Report only\npython3 scripts/ontology_compact.py                # Compact (threshold 5%)\npython3 scripts/ontology_compact.py --min-gain 10  # Skip unless >=10% smaller\n```\n\nRun weekly. Typical gain on a never-compacted log: **~60-65%**.\n\n### 7. `hybrid_search.py` — Hybrid search (FTS5 + sqlite-vec + RRF)\n\nHybrid memory search combining lexical (BM25 via SQLite FTS5) and semantic\n(vector via sqlite-vec) retrieval using Reciprocal Rank Fusion (RRF, k=60).\n\n```bash\n# Initialize DB with schema\npython3 scripts/hybrid_search.py init\n\n# Index all memory files\npython3 scripts/hybrid_search.py index\n\n# Search\npython3 scripts/hybrid_search.py query \"project_alpha\"\npython3 scripts/hybrid_search.py query \"roadmap EIIDP\" --top 10\n\n# Lexical only (BM25)\npython3 scripts/hybrid_search.py query \"2026-08-17\" --lexical-only\n\n# Vector only (semantic)\npython3 scripts/hybrid_search.py query \"memory decay scoring\" --vector-only\n\n# JSON output for programmatic use\npython3 scripts/hybrid_search.py query \"leadership coaching\" --json\n\n# Stats\npython3 scripts/hybrid_search.py stats\n\n# Index a single file\npython3 scripts/hybrid_search.py add path/to/file.md --category skill\n```\n\n**How it works:**\n\n```\nquery → ┬─ vector_search (nomic-embed-text, top 20) ──┐\n        └─ lexical_search (FTS5/BM25, top 20) ────────┤\n                                                        ↓\n                                              RRF(k=60) fusion\n                                                        ↓\n                                        min_score filter (≥0.015)\n                                                        ↓\n                                        temporal boost (optional)\n                                                        ↓\n                                              source deduplication\n                                                        ↓\n                                                   top K results\n```\n\nRRF ignores raw scores and uses only ranks: `rrf(d) = Σ 1/(k + rank_m(d))`.\nSource deduplication groups by file, returning the best chunk per source.\n\n**Gemini vigilance #1 — min_rrf_score (noise threshold):**\nChunks appearing in neither top-20 list have RRF score ~0 = pure noise.\nFiltered by default at `0.015`. Override with `--min-score 0` to disable.\n\n**Gemini vigilance #3 — temporal_boost (decay weighting):**\nRRF score is multiplied by `(1 + 0.1 * normalized_score)` where `normalized_score`\ncomes from the `score` column (populated by `scoring.py` temporal decay).\nGives slight priority to recent facts when context conflicts.\nDisable with `--no-temporal-boost`.\n\n**Schema:** Single SQLite file with three synchronized tables:\n- `memories` — content, category, layer, source, score, timestamps\n- `memories_fts` — FTS5 virtual table (external content, auto-synced via triggers)\n- `memories_vec` — vec0 virtual table (float[768], nomic-embed-text)\n\n**Layers:** episodic (daily notes), semantic (long-term facts, ontology), procedural (skills, config)\n\n**Requirements:** `sqlite-vec` (pip install in venv), Ollama with `nomic-embed-text`\n\n**Output:** `hybrid-search/agent_memory.db` — SQLite DB with FTS5 + vec0 indexes.\n\n### 8. `conflict_resolver.py` — Fact lifecycle & dispute resolution\n\nFour-step consistency pipeline over the search DB: **atomic extraction →\ntargeted retrieval of concurrent `active` facts → NLI classification → traceable\nstate update**. Only `active` facts are ever retrieved. A new fact that\ncontradicts an active one supersedes it (`superseded_by` now written); a weak\ncontradiction is escalated to `disputed` instead of silently destroying an\nestablished fact.\n\n```bash\n# Analyse one candidate fact (read-only)\npython3 hybrid-search/conflict_resolver.py check \"On a migré la BDD sur MySQL 8\" --subject serveur_prod\n\n# Batch arbitration from JSONL (dry-run; --apply to persist)\npython3 hybrid-search/conflict_resolver.py arbitrate facts.jsonl\npython3 hybrid-search/conflict_resolver.py arbitrate facts.jsonl --apply --force\n\n# What is waiting for a human? (cheap, deterministic)\npython3 hybrid-search/conflict_resolver.py pending\n\n# Lift the ambiguity explicitly\npython3 hybrid-search/conflict_resolver.py resolve 42 --confirm\npython3 hybrid-search/conflict_resolver.py resolve 42 --reject --replacement \"corrected fact\"\n```\n\n`--confirm` restores the wording to `active` **and supersedes any rival on the\nsame subject**; `--reject [--replacement]` supersedes it and optionally inserts a\ncorrected fact. Only `disputed` rows are eligible. `--no-llm` gives a\nconservative lexical fallback; the LLM endpoint is loopback-only.\n\n### 9. `compact.py` — Cold storage & lifecycle compaction\n\nMoves terminal facts (`superseded`, optionally `disputed`) out of the hot tables\ninto a **cold archive** so FTS5/BM25 rank space and the vector index stop carrying\ndead rows while full traceability is kept.\n\n```bash\npython3 hybrid-search/compact.py --stats              # hot vs cold sizes\npython3 hybrid-search/compact.py --dry-run            # what would be archived\npython3 hybrid-search/compact.py --apply --min-age-days 30\npython3 hybrid-search/compact.py --restore 42         # rehydrate one fact\n```\n\nRows are copied verbatim into `memories_archive` in the same SQLite file and\nappended to a dated JSONL audit under `memory/audit/`, then `DELETE`d from the hot\ntable (fires the triggers → removed from FTS5 and `memories_vec`). **Never a hard\ndelete of data, only a move.** Dry-run is the default; mutation requires\n`--apply`; a retention guard refuses rows newer than `--min-age-days`; the DB is\nbacked up via SQLite's `backup()` API and MD5-checked before any write. Requires\n`sqlite-vec` (the vec0 delete trigger fires on `DELETE`).\n\n## Ontology\n\nThe ontology graph stores entities and relations as JSONL. A YAML schema\ndefines allowed types and relations.\n\n**Entity types:** Person, Organization, Project, Task, Document, Event,\nSkill, Device, Service, Tool, Infrastructure, Concept, Location, Pet,\nBugFix, SecurityEvent, Integration, Feature, Software, Configuration\n\n**Relation types:** reports_to, has_owner, includes, depends_on, manages,\nuses, integrated_with, located_at, fixes, monitors\n\n**Files:**\n- `memory/ontology/graph.jsonl` — entity and relation records\n- `memory/ontology/schema.yaml` — type and relation definitions\n- `memory/ontology/graph-index.json` — search index\n\n## Configuration\n\nEnvironment variables with defaults:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `WORKSPACE` | `~/.openclaw/workspace` | OpenClaw workspace path |\n| `OLLAMA_URL` | `http://localhost:11434` | Ollama API URL |\n| `OLLAMA_MODEL` | `glm-5.2` | Model for LLM extraction/summaries |\n\nScoring constants (top of `scoring.py`):\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `HALF_LIFE_DAYS` | 14 | Recency decay half-life |\n| `MAX_SCORE` | 5.0 | Score cap |\n| `PROMOTE_THRESHOLD` | 2.0 | Min score for promotion |\n| `ARCHIVE_THRESHOLD` | 0.05 | Score below = archive candidate |\n\nMemory health thresholds (top of `memory_health.py`):\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `MEMORY_MAX_SIZE` | 5000 | MEMORY.md max size in bytes |\n| `DAILY_NOTES_MAX_AGE` | 14 | Days before archiving |\n\n## Requirements\n\n- Python 3.10+\n- Ollama (optional — LLM extraction and cluster summaries)\n- No pip packages required — pure stdlib (sqlite-vec optional for hybrid search)\n\n## Nightly Cron Integration\n\nRecommended nightly pipeline (after trace extraction):\n\n```bash\n# In nightly cron (23h):\npython3 scripts/trace_extractor.py --days 1\npython3 scripts/auto_archive.py\npython3 scripts/scoring.py\npython3 scripts/consolidate_advisor.py --no-llm  # quiet mode\n```\n\nWeekly health check (Monday, separate cron):\n\n```bash\npython3 scripts/memory_health.py --quick\n```\n\nMonthly deep check (manual):\n\n```bash\npython3 scripts/memory_health.py --deep\n```\n\n## Design Principles\n\n1. **Local by default** — no cloud account, no paid dependency. `trace_extractor.py` is the only cloud-capable script, opt-in via `OLLAMA_API_KEY`, disclosed each run, cancellable via `TRACE_LLM_LOCAL_ONLY=1`\n2. **Composable** — each script is standalone, can run independently\n3. **Safe by default** — dry-run available for all analysis scripts; some nightly cron commands modify files by default (archive, scores, consolidation report). Review cron commands before deploying.\n4. **Human-in-the-loop** — consolidation suggestions, not auto-merge\n5. **Pipeline-friendly** — scripts chain naturally, outputs feed inputs\n6. **Zero dependencies** — pure Python stdlib (except sqlite-vec for hybrid search)\n7. **Strata-aware** — episodic, semantic, and procedural memory are separated\n\n## License\n\nMIT — free to use, modify, and share.\n\n## Security Notes\n\n- ⚠️ **Nightly cron modifies files by default**: `auto_archive.py` moves files, `scoring.py` writes scores.json, `consolidate_advisor.py` writes consolidation_report.json. Review cron commands before deploying.\n- ⚠️ **`--fix` mode is destructive**: `memory-health.py --fix` moves daily notes to archive/ and rewrites ontology. Requires interactive confirmation or `--force` flag. Creates timestamped backups in `memory/backup/` before modifying.\n- ⚠️ **`--force` flag**: The `--force` flag exists on `consolidate_advisor.py` and `memory-health.py` for non-interactive/cron use. It skips confirmation prompts. Only use in trusted automation with backups in place.\n- ⚠️ **`--apply-promotions` modifies MEMORY.md**: `consolidate_advisor.py --apply-promotions` appends entries to MEMORY.md. Requires interactive confirmation or `--force` flag.\n- ⚠️ **Memory and session content IS transmitted to an LLM** (`trace_extractor.py`): extraction sends an excerpt of daily notes (and, with `--session-file`, session transcript text) to a language model. **Primary transport is Ollama cloud** (`https://ollama.com`, `POST /api/chat`) when `OLLAMA_API_KEY` is configured — **content leaves this machine**. Local fallback is Ollama at `127.0.0.1:11434`. Set **`TRACE_LLM_LOCAL_ONLY=1`** to refuse every cloud call and force local-only. The destination is printed on each run (`[Security] ⚠️ CLOUD TRANSMISSION: …`).\n- ⚠️ **PII sanitization is best-effort, not a guarantee**: `sanitize_pii()` removes API keys, tokens, JWTs, emails, passwords, PEM keys, French phone numbers and long opaque blobs before any LLM submission — but regex scrubbing cannot catch every secret format. **The transport decision is the primary control, not the filter.**\n- ⚠️ **OLLAMA_URL should stay localhost**: local LLM calls (cluster summaries, embeddings) send memory text to Ollama. Keep `OLLAMA_URL=http://localhost:11434` to prevent data from leaving the machine.\n- ⚠️ **Subprocess and urlopen are intentional local calls**: Scripts use `subprocess.run` to call other local Python scripts (trace_extractor, locomo_test) and `urllib.request.urlopen` to call the local Ollama HTTP API. These are intentional local-only calls. Keep `OLLAMA_URL` on localhost to prevent data from leaving the machine.\n- ⚠️ **Memory files may contain sensitive data**: Review all files before indexing with hybrid search. The `scoring.py` script skips files matching secret patterns (`.secrets/`, `*.env`, `credentials*`, `*token*`, `*password*`, `.git/`).\n- ⚠️ **Hybrid search consent warnings**: `hybrid_search.py` `index` command displays a consent warning before batch embedding. Use `--yes` to skip in automation. `add` command prints a one-line embedding notice (use `--quiet` to suppress).\n- ⚠️ **`memory-health.py` is READ-ONLY by default**: No files or charts are written to disk without `--output-dir <path>`. SVG trend charts and JSON reports require this flag.\n- ⚠️ **Scope confinement**: All scripts restrict file scanning to the designated memory directory (`WORKSPACE/memory/`), plus an explicit allowlist of three root config files that the indexer legitimately reads (`MEMORY.md`, `TOOLS.md`, the skill's own `SKILL.md`). No parent traversal (`../`) and no sibling skill enumeration (`skills/*/SKILL.md`). Paths are validated with `Path.resolve().is_relative_to(WORKSPACE)`.\n- ⚠️ **`--session-file` is a deliberate, explicit exception**: `trace_extractor.py --session-file <path>` accepts one absolute path outside the workspace, because a session transcript does not live under `memory/`. It is never scanned automatically — no global session directory walk exists. Only pass paths you own and accept sending to the configured LLM transport.\n- ⚠️ **Subprocess calls use fixed argument lists**: All `subprocess.run` calls use hardcoded `[sys.executable, ...]` argument lists — no environment variable injection possible. Script paths are validated against workspace confinement.\n- ⚠️ **No PII in test fixtures**: `run_tests.py` uses anonymized query terms (`project_alpha`, `sample_note_01`) — no real project names, personal names, or sensitive references.\n- ⚠️ **`--fix` mode is destructive**: `memory-health.py --fix` moves daily notes to archive/ and rewrites ontology. Requires interactive confirmation or `--force` flag. Creates timestamped backups in `memory/backup/` before modifying.\n> **Security & audit triage:** see [`docs/SECURITY-AUDIT-NOTES.md`](docs/SECURITY-AUDIT-NOTES.md) for\n> which scanner findings are closed in code and which are accepted false positives.\n\nFile v4.0.5:README.md\n\n# 🧠 OpenClaw Memory Pipeline\n\n**Complete memory management pipeline for OpenClaw agents: extraction, archiving,\nscoring, consolidation, health monitoring, and hybrid search — local by default.**\n\nSeven standalone Python scripts that form a complete memory lifecycle pipeline for\n[OpenClaw](https://github.com/openclaw/openclaw) agents. Everything runs on this\nmachine out of the box: Ollama over local HTTP, no cloud account, no paid dependency\n— works with any local LLM (Ollama, LM Studio, etc.) or fully without LLM in fallback\nmode.\n\n> **One exception, and it is opt-in:** `trace_extractor.py` can use **Ollama cloud**\n> (`https://ollama.com`) when an API key is configured, because that content leaves\n> the machine. With **no key configured it stays local**, and\n> **`TRACE_LLM_LOCAL_ONLY=1` refuses every cloud call outright**. Every run prints its\n> destination first (`[Security] ⚠️ CLOUD TRANSMISSION: …`). See\n> [Security Notes](#security-notes).\n\nBuilt for local-first OpenClaw setups (Ollama/GLM, nomic-embed-text).\n\n## Pipeline\n\n```\nNightly Cron (23h)\n  │\n  ├─ 1. trace_extractor.py     # Extract decisions/errors/facts from sessions\n  ├─ 2. auto_archive.py        # Archive daily notes >21 days\n  ├─ 3. scoring.py             # Score all memories with temporal decay\n  ├─ 4. consolidate_advisor.py # Suggest consolidations (agent reviews)\n  ├─ 5. conflict_resolver.py   # Arbitrate contradictory facts (NLI lifecycle)\n  ├─ 6. memory_health.py       # Periodic health check (weekly)\n  └─ 7. ontology_compact.py    # GC the ontology op-log (weekly)\n```\n\nAll scripts are standalone and composable. Run individually or as a pipeline.\n\n## Contributing\n\nThis README is written for **users**. If you are **maintaining this repository**\n(working on the release pipeline, the gate, or the repo↔skill sync), read\n[CONTRIBUTING.md](CONTRIBUTING.md) instead — it documents the full\nrepo → skill → GitHub → ClawHub flow and the invariants the release gate enforces.\n\n> **MUST — every memory-skill change goes through `scripts/release.sh`.**\n> A change to `skills/memory-health/**` is not \"done\" until\n> `scripts/release.sh check` passes green. Never edit the installed skill\n> directly; never let the repo and the skill drift. Full detail in\n> [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Scripts\n\n### 1. `trace_extractor.py` — Session extraction\n\nExtracts decisions, errors, facts, and patterns from OpenClaw session transcripts\nand daily notes. Updates daily notes, appends entities to the ontology graph.\n\n```bash\n# Nightly (pattern-based, fast ~5s)\npython3 scripts/trace_extractor.py --days 1\n\n# Deep extraction (LLM-powered, ~60-180s)\npython3 scripts/trace_extractor.py --days 3 --llm\n\n# With a specific session transcript file (opt-in, explicit)\npython3 scripts/trace_extractor.py --days 1 --llm --session-file /path/to/session.jsonl\n\n# Preview only\npython3 scripts/trace_extractor.py --days 1 --llm --dry-run\n```\n\n**Categories:** 🟢 DECISIONS, 🔴 ERRORS, 🔵 FACTS, ⬆️ PROMOTIONS\n\n### 2. `auto_archive.py` — Daily note archiving\n\nMoves daily notes older than N days to `memory/archive/YYYY-MM/`.\n\n```bash\npython3 scripts/auto_archive.py                 # Archive notes > 21 days\npython3 scripts/auto_archive.py --days 30       # Custom threshold\npython3 scripts/auto_archive.py --dry-run       # Preview only\npython3 scripts/auto_archive.py --verbose       # Show each file\n```\n\nIdempotent. Only moves `YYYY-MM-DD*.md` files. Zero dependencies.\n\n### 3. `scoring.py` — Temporal decay scoring\n\nScores all memory items using exponential recency decay, category weights,\nfrequency boost, entity boost, and completion penalty.\n\n```bash\npython3 scripts/scoring.py                      # Score all memories\npython3 scripts/scoring.py --verbose            # Show top 20\npython3 scripts/scoring.py --threshold 0.3      # Filter by min score\npython3 scripts/scoring.py --dry-run            # Don't write output\n```\n\n**Formula:** `score = weight_category × recency_decay × frequency_boost × entity_boost × completion_penalty`\n\n**Category weights:** DECISIONS ×3, ERRORS ×2, FACTS ×1.5, PATTERNS ×1.2, TRANSIENT ×1\n\n**Output:** `memory/scores.json` — full ranking with stats and promotion candidates.\n\n### 4. `consolidate_advisor.py` — Consolidation suggestions\n\nAnalyzes recent daily notes + scores.json to identify clusters, promotions,\nstale items, and duplicates. Writes consolidation_report.json by default.\nModifies MEMORY.md only with --apply-promotions flag (requires confirmation).\n\n```bash\npython3 scripts/consolidate_advisor.py                     # Last 7 days\npython3 scripts/consolidate_advisor.py --days 14           # Custom window\npython3 scripts/consolidate_advisor.py --verbose           # All suggestions\npython3 scripts/consolidate_advisor.py --no-llm            # Skip LLM (fallback)\npython3 scripts/consolidate_advisor.py --apply-promotions  # Write to MEMORY.md\n```\n\n**Output:** `memory/consolidation_report.json` — clusters, promotions, stale items, duplicates.\n\nLLM optional (Ollama) for cluster summaries. Falls back to text-based with `--no-llm`.\n\n### 5. `memory_health.py` — System health check\n\nComprehensive diagnostics: trace extraction, benchmark, MEMORY.md size, ontology\nhealth, daily notes hygiene, index status, drift detection.\n\n**READ-ONLY by default**: writes nothing to disk. Use `--output-dir <path>` to save\nJSON reports and SVG trend charts.\n\n```bash\npython3 scripts/memory_health.py              # Full health check (read-only)\npython3 scripts/memory_health.py --quick      # Skip benchmark & LLM (read-only)\npython3 scripts/memory_health.py --benchmark  # Benchmark only (read-only)\npython3 scripts/memory_health.py --deep       # LLM + sessions + benchmark\npython3 scripts/memory_health.py --output-dir results/  # Save reports to disk\npython3 scripts/memory_health.py --fix        # Fix mode (DESTRUCTIVE)\n```\n\n**Output:** `results/YYYY-MM-DD.json` — only with `--output-dir`.\n\n**Destructive actions (`--fix`)**: Moves daily notes >14 days old to `archive/`,\nrewrites ontology file. Creates timestamped backup before modifying. Requires\ninteractive confirmation or `--force` flag.\n\n### 6. `ontology_compact.py` — Ontology graph GC\n\nCompacts `memory/ontology/graph.jsonl` (an append-only operation log) by replaying\nit into a consolidated state: one line per active entity, superseded records dropped.\n\n**Safe by design**: backs up first (MD5-verified), writes to a temp file, validates\nthat the entity set and contents are identical, and only then swaps in place. Skips\nentirely when the gain is below a threshold, so it is idempotent.\n\n```bash\npython3 scripts/ontology_compact.py --dry-run      # Report only\npython3 scripts/ontology_compact.py                # Compact (default threshold 5%)\npython3 scripts/ontology_compact.py --min-gain 10  # Skip unless >=10% smaller\n```\n\n**Output:** rewrites `memory/ontology/graph.jsonl` + a timestamped backup in\n`memory/ontology/backups/`.\n\nTypical gain on an op-log that has never been compacted: **~60-65%**.\nRun it weekly; between runs the file only grows by genuinely new operations.\n\n### 7. `hybrid-search/hybrid_search.py` — Hybrid search engine\n\nFTS5 (BM25) + sqlite-vec (cosine similarity) + Reciprocal Rank Fusion (k=60).\n\n```bash\npython3 hybrid-search/hybrid_search.py init                    # Create index DB\npython3 hybrid-search/hybrid_search.py index                   # Batch index memory files\npython3 hybrid-search/hybrid_search.py add path/to/file.md     # Add single file\npython3 hybrid-search/hybrid_search.py search \"project alpha\"  # Hybrid search\npython3 hybrid-search/hybrid_search.py status                  # Index stats\n```\n\n**Point-in-time retrieval (v3.4):** pass `--as-of YYYY-MM-DD` to `query`,\n`search` or `context` to reconstruct the facts visible on that **past** date —\nnot what the index believes today. This is the difference between an\nAs-Maintained view and a retrievable As-Built baseline: the ledger marks rows\n`superseded` (with a `superseded_at` timestamp) instead of deleting them, so the\nhistory is already there to replay.\n\n```bash\n# What did the agent know on 1 July? (bare date = end of that day)\npython3 hybrid-search/hybrid_search.py query \"database backend\" --as-of 2026-07-01\n\n# Reconstruct context as of a precise instant\npython3 hybrid-search/hybrid_search.py context \"backup script\" --as-of 2026-06-15T08:00:00\n```\n\nA fact created **after** the requested date is invisible; a fact still active on\nthat date stays visible even if it was superseded later. Without `--as-of` the\nbehaviour is unchanged (active facts only). _Limitation:_ `--as-of` cannot go\nback before the first indexing date, since no row predates it (`valid_from`\ncarries historical *world* time, a separate axis).\n\n**Scope:** Only indexes files within `WORKSPACE/memory/` + `MEMORY.md` + `TOOLS.md` + self `SKILL.md`.\nPersonal files (`USER.md`, `IDENTITY.md`, `AGENTS.md`, `SOUL.md`, `HEARTBEAT.md`) are excluded.\nNo sibling skill enumeration (`skills/*/SKILL.md` glob removed).\n\n### 7. `hybrid-search/conflict_resolver.py` — Fact lifecycle & conflict arbitration\n\nImplements the four-step consistency pipeline: **atomic extraction → targeted\nretrieval of concurrent `active` facts → NLI classification → traceable state\nupdate**. A new fact that contradicts an active one marks the old row\n`superseded` (`superseded_by` finally populated) and inserts the new one as\n`active`; a redundant fact refreshes `last_confirmed` instead of duplicating; a\ncompatible fact is added. A **weak** contradiction (confidence below the floor)\nflags the old fact `disputed` and asks for confirmation rather than destroying a\ntruth.\n\n```bash\n# Analyse one fact against active memory (read-only)\npython3 hybrid-search/conflict_resolver.py check \"On a migré la BDD sur MySQL 8\" --subject serveur_prod\n\n# Batch arbitration from a JSONL of {content, subject?} (dry-run)\npython3 hybrid-search/conflict_resolver.py arbitrate facts.jsonl\n\n# Persist resolutions (mutates DB)\npython3 hybrid-search/conflict_resolver.py arbitrate facts.jsonl --apply --force\n\n# Inspect lifecycle states\npython3 hybrid-search/conflict_resolver.py lifecycle --status superseded\n\n# Heuristic-only (no LLM, offline)\npython3 hybrid-search/conflict_resolver.py check \"...\" --subject x --no-llm\n```\n\n**Interactive resolution of blocked facts** — a `disputed` fact is a *suspended*\nstate waiting for a human. `pending` surfaces the queue (cheap, deterministic) so\nthe agent can raise it at the next relevant turn; `resolve` lifts the ambiguity\nexplicitly. `--confirm` restores the wording to `active` **and supersedes any\nrival claim on the same subject**, so the pair can never both stay visible;\n`--reject` supersedes it and optionally inserts a corrected `--replacement`.\nOnly rows currently `disputed` are eligible — resolving an `active` or already\n`superseded` row is refused.\n\n```bash\n# What is waiting for adjudication?\npython3 hybrid-search/conflict_resolver.py pending\npython3 hybrid-search/conflict_resolver.py pending --json\n\n# The wording stands -> active (rivals superseded)\npython3 hybrid-search/conflict_resolver.py resolve 42 --confirm\n\n# The wording was wrong -> superseded, with a corrected fact\npython3 hybrid-search/conflict_resolver.py resolve 42 --reject \\\n    --replacement \"The internal DNS is 10.0.0.99\"\n```\n\n**Safety:** the LLM endpoint is loopback-only (same guard as `hybrid_search.py`);\n`--no-llm` gives a conservative lexical fallback that never auto-supersedes on a\nweak signal; analysis is read-only unless `--apply` is passed. Migrates an older\nDB in place (adds the lifecycle columns idempotently).\n\n### 8. `hybrid-search/compact.py` — Cold storage & lifecycle compaction\n\nThe lifecycle work keeps `superseded`/`disputed` facts out of *retrieval*, but\nthey still occupy the hot tables and their FTS5/vector indexes. Over months that\ninflates BM25 rank space, the `memories_vec` table and the stats. This compactor\nmoves terminal facts into a **cold store** while keeping full traceability.\n\n```bash\n# Hot vs cold sizes\npython3 hybrid-search/compact.py --stats\n\n# What would be archived (superseded older than 30 days)\npython3 hybrid-search/compact.py --dry-run\n\n# Actually archive (MD5-verified backup + JSONL audit, then delete from hot)\npython3 hybrid-search/compact.py --apply --min-age-days 30\npython3 hybrid-search/compact.py --apply --include-disputed\n\n# Resurrect one archived fact\npython3 hybrid-search/compact.py --restore 42\n```\n\n**How \"cold\" works:** rows are copied verbatim into `memories_archive` in the\nsame SQLite file (transactional, no cross-file join) and appended to a dated\nJSONL audit under `memory/audit/`; the hot rows are then `DELETE`d, which fires\nthe existing triggers and removes them from FTS5 and the vector index — the\nactual perf win. **Never a hard delete of data, only a move.**\n\n**Safety:** mutation requires `--apply` (dry-run is the default); the whole DB is\nbacked up via SQLite's backup API (a `copy2` on a live DB can capture a torn WAL)\nand checked before any write; a retention guard refuses to archive rows newer\nthan `--min-age-days`; rows whose age cannot be proven are kept. Requires\n`sqlite-vec` (the vec0 trigger fires on delete) and refuses loudly rather than\nhalf-archiving if it is missing.\n\n### 9. `hybrid-search/run_tests.py` — Search validation\n\nRuns anonymized test queries against the hybrid search index.\n\n```bash\npython3 hybrid-search/run_tests.py          # Run all test queries\npython3 hybrid-search/run_tests.py --verbose # Show scores and metadata\n```\n\n**Test fixtures use anonymized terms** (`project_alpha`, `sample_note_01`, etc.) — no real project names or personal data.\n\nThe same directory ships the regression guards run by the release gate:\n\n| Guard | Pins |\n|-------|------|\n| `test_model_resolution.py` | the full model-resolution chain + raise-on-missing-safety-model |\n| `test_extract_atomic.py` | `extract_atomic()` number protection and clause splitting |\n| `test_loopback_guard.py` | cloud calls never leave except through the opt-in path |\n| `test_meta_gate.py` | meta/pipeline chatter is never captured as a fact |\n| `test_ontology_key_parity.py` | indexer and ontology migrator build the same display key |\n\n### 10. Model resolution — `hybrid-search/llm_resolution.py` (v4.0)\n\n**Single source of truth for the effective LLM model.** No script hard-codes a\nmodel name any more; every caller asks this module, which resolves at runtime and\nreports *where the answer came from*.\n\nResolution chain, in order (first hit wins):\n\n1. an explicit `*_LLM_MODEL` / `OLLAMA_MODEL` env var — **visible and logged**,\n   never silent (the escape hatch is kept on purpose);\n2. the gateway default, `agents.defaults.model.primary` from `openclaw.json`;\n3. the configured fallback, `agents.defaults.model.fallbacks[0]`;\n4. the local safety net (`qwen2.5:7b`), **presence-checked before use** — if the\n   model is absent the module **raises** rather than letting Ollama start a\n   synchronous multi-GB pull that would hang an unattended nightly run.\n\n```python\nfrom llm_resolution import resolve_llm_model, explain_resolution\n\nresolve_llm_model()   # -> \"deepseek-v4.1-flash:cloud\" (a name the daemon serves)\nexplain_resolution()  # -> {\"model\": ..., \"source\": \"gateway:primary\", \"chain\": [...]}\n```\n\n**Callers:** `trace_extractor.py`, `hybrid-search/auto_capture.py`,\n`hybrid-search/conflict_resolver.py`, `consolidate_advisor.py`. The model is\nresolved once per run (`@lru_cache`).\n\n## Ontology\n\nJSONL-based entity and relation graph with YAML schema.\n\n**Entity types:** Person, Organization, Project, Task, Document, Event, Skill,\nDevice, Service, Tool, Infrastructure, Concept, Location, Pet, BugFix,\nSecurityEvent, Integration, Feature, Software, Configuration\n\n**Relation types:** reports_to, has_owner, includes, depends_on, manages, uses,\nintegrated_with, located_at, fixes, monitors\n\n**Files:**\n- `ontology/schema.yaml` — type and relation definitions\n- `memory/ontology/graph.jsonl` — entity and relation records (generated)\n- `memory/ontology/graph-index.json` — search index (generated)\n\n## Configuration\n\nEnvironment variables with defaults:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `WORKSPACE` | `~/.openclaw/workspace` | OpenClaw workspace path |\n| `OLLAMA_URL` | `http://localhost:11434` | Ollama API URL (localhost only) |\n| `OLLAMA_MODEL` | _(from gateway config)_ | **Explicit override** for LLM extraction/summaries. Unset = follow the gateway |\n| `TRACE_LLM_MODEL` | _(from gateway config)_ | **Explicit override** for trace-extractor LLM calls. Unset = follow the gateway |\n| `CONFLICT_LLM_MODEL` | _(from gateway config)_ | **Explicit override** for conflict arbitration (NLI). Unset = follow the gateway |\n| `OPENCLAW_CONFIG` | `~/.openclaw/openclaw.json` | Gateway config read by the model resolver (v4.0) |\n| `MEMORY_DB` | `hybrid-search/agent_memory.db` | Path to the search/lifecycle DB |\n| `TRACE_LLM_LOCAL_ONLY` | _(unset)_ | Set to `1` to refuse cloud LLM calls and force local-only. **Recommended for any privacy-sensitive deployment.** |\n| `OLLAMA_API_KEY` | _(unset)_ | Enables **Ollama cloud** (`ollama.com`) in `trace_extractor.py`. Unset = local only |\n\n## Requirements\n\n- Python 3.10+\n- Ollama (optional — LLM extraction and cluster summaries)\n- No pip packages required for core pipeline. Hybrid search requires sqlite-vec (optional).\n\n> **Recommended local model:** `qwen2.5:7b` for Auto-Capture and conflict\n> arbitration. The smaller `qwen2.5:3b` was found to return empty extractions and\n> to split numeric values (`Ubuntu 24.04`); 7 b extracts whole facts reliably.\n> Both are local — the loopback-only guarantee is unchanged.\n\n## Nightly Cron\n\n```bash\n# Nightly (23h):\npython3 scripts/trace_extractor.py --days 1\npython3 scripts/auto_archive.py\npython3 scripts/scoring.py\npython3 scripts/consolidate_advisor.py --no-llm\npython3 hybrid-search/hybrid_search.py index --yes   # refresh the search index\n\n# Conflict arbitration (batch, after indexing; dry-run first):\n#   emit candidate facts as JSONL, review the dry-run, then --apply\npython3 hybrid-search/conflict_resolver.py arbitrate facts.jsonl           # analyse\npython3 hybrid-search/conflict_resolver.py arbitrate facts.jsonl --apply --force\n\n# Weekly health check (Monday):\npython3 scripts/memory_health.py --quick\npython3 scripts/ontology_compact.py\n\n# Monthly cold-storage compaction (terminal facts out of the hot index):\npython3 hybrid-search/compact.py --dry-run\npython3 hybrid-search/compact.py --apply --min-age-days 30\n\n# Monthly deep check (manual):\npython3 scripts/memory_health.py --deep\n```\n\n## Design Principles\n\n1. **Local by default** — no cloud account, no paid dependency. The single cloud-capable path is `trace_extractor.py` and it is opt-in via `OLLAMA_API_KEY`, disclosed on every run, and cancellable with `TRACE_LLM_LOCAL_ONLY=1`\n2. **Composable** — each script is standalone, can run independently\n3. **Safe by default** — dry-run available for all analysis scripts; `memory-health.py` is read-only by default. Some nightly cron commands modify files by default (archive, scores, consolidation report). Review cron commands before deploying.\n4. **Human-in-the-loop** — consolidation suggestions, not auto-merge\n5. **Pipeline-friendly** — scripts chain naturally, outputs feed inputs\n\n## License\n\nMIT — free to use, modify, and share.\n\n## Acknowledgments\n\n- [OpenClaw](https://github.com/openclaw/openclaw) — the agent framework this was built for\n\n## Security Notes\n\n- 🔒 **v2.1.4 — allowlist/index agreement, both directions**: v2.1.3 declared `TOOLS.md` in `ALLOWED_SCAN_FILES` even though `collect_all_files()` only indexes it when present — advertising a file that was never read. Optional root files are now declared only when they exist on disk, so the allowlist is neither narrower nor wider than what the indexer actually scans.\n- 🔒 **v2.1.3 — runtime confinement, read-only correctness**: the `--workspace` flag no longer bypasses the confinement guard (`validate_paths()` re-runs after the rebind in `auto_archive.py` and `consolidate_advisor.py`); `memory-health.py` passes `--dry-run` to the trace extractor so a passive health check stops appending to the ontology graph; `ALLOWED_SCAN_FILES` declares the root config files the indexer legitimately reads, so the documented scan scope and the actual scan scope agree; `openclaw` subprocesses use a resolved absolute binary instead of trusting `$PATH`.\n- 🔒 **v2.1.3 — scanner false positives documented**: \"credential access\" alerts on the secret deny-lists and \"unsafe defaults\" alerts on this file's own changelog prose are triaged in [`docs/SECURITY-AUDIT-NOTES.md`](docs/SECURITY-AUDIT-NOTES.md). Do not re-report them.\n- 🔒 **v2.1.2 — no attacker-controllable import path**: `hybrid-search/hybrid_search.py` no longer inserts a `/tmp` directory at the head of `sys.path`. A world-writable directory in first position lets any local process shadow a module and get code executed on import. `sqlite_vec` is now imported from the active environment, with an actionable `ImportError` when it is missing (`pip install sqlite-vec`).\n- 🔒 **v2.1.2 — symlinks refused, resolved paths confined**: indexing and scanning validate paths through `safe_resolve()` / `is_safe_memory_file()`, which refuse symlinks outright, normalise the path with `realpath()` (so `..` cannot escape), confine it to the allowed scan directory, and match secret patterns against the **resolved** path — a symlink with an innocuous name cannot smuggle `~/.ssh/id_rsa` into the vector index. `--dir` arguments outside scope are rejected before any file is listed.\n- 🔒 **v2.1.2 — secret patterns extended**: added `.ssh`, `.aws`, `.config/google`, `id_rsa`, `id_ed25519`, `.pem`, `.key` to the skip list.\n- ⚠️ **`memory-health.py` is READ-ONLY by default**: No files, SVG charts, or JSON reports are written to disk without `--output-dir <path>`. `check_drift()` does not auto-create the results directory.\n- ⚠️ **`--fix` mode is destructive**: `memory-health.py --fix` moves daily notes to `archive/` and rewrites ontology. Requires interactive confirmation or `--force` flag. Creates timestamped backups in `memory/backup/` before modifying.\n- ⚠️ **`--force` flag**: Skips confirmation prompts on destructive operations. Only use in trusted automation with backups in place.\n- ⚠️ **`--apply-promotions` modifies MEMORY.md**: `consolidate_advisor.py --apply-promotions` appends entries to MEMORY.md. Requires interactive confirmation or `--force` flag.\n- ⚠️ **Nightly cron modifies files by default**: `auto_archive.py` moves files, `scoring.py` writes `scores.json`, `consolidate_advisor.py` writes `consolidation_report.json`. Review cron commands before deploying.\n- ⚠️ **OLLAMA_URL restricted to localhost**: LLM calls send memory text to Ollama. URL validated to be `localhost`, `127.0.0.1`, or `::1` only — no remote hosts.\n- ⚠️ **Memory and session content IS transmitted to an LLM** (`trace_extractor.py`): extraction sends an excerpt of daily notes (and, with `--session-file`, session transcript text) to a language model. The **primary transport is Ollama cloud** (`https://ollama.com`) when an API key is configured — **content leaves this machine**. The local fallback is Ollama at `127.0.0.1:11434`, which keeps content on the machine. Set **`TRACE_LLM_LOCAL_ONLY=1`** to refuse every cloud call and force local-only operation. The destination is printed on each run (`[Security] ⚠️ CLOUD TRANSMISSION: …`).\n- ⚠️ **PII sanitization before LLM calls is best-effort, not a guarantee**: `trace_extractor.py` and `consolidate_advisor.py` sanitize text with `sanitize_pii()` — regex-based removal of API keys, tokens, JWTs, emails, passwords, PEM keys, French phone numbers and long opaque blobs — before any LLM submission. Regex scrubbing cannot catch every secret in an arbitrary format. **The transport decision is the primary control, not the filter.**\n- ⚠️ **Session transcripts are opt-in only**: `trace-extractor.py` no longer scans `~/.openclaw/agents/` globally. Use `--session-file <path>` to explicitly provide a single transcript file.\n- ⚠️ **`scores.json` stores hashes, not raw text**: `scoring.py` replaces note text with SHA256 hashes (first 16 chars) in all JSON output. File permissions set to `0o600`.\n- ⚠️ **Subprocess calls use fixed argument lists**: All `subprocess.run` calls use hardcoded `[sys.executable, ...]` argument lists — no environment variable injection. Script paths validated with `Path.resolve().is_relative_to(WORKSPACE)`.\n- ⚠️ **Scope confinement**: All scripts restrict file scanning to `WORKSPACE/memory/`. No parent traversal (`../`) or sibling skill enumeration (`skills/*/SKILL.md`). Paths validated with `Path.resolve().is_relative_to(WORKSPACE)`.\n- ⚠️ **Personal files excluded from search index**: `hybrid_search.py` does not index `USER.md`, `IDENTITY.md`, `AGENTS.md`, `SOUL.md`, `HEARTBEAT.md` — only `MEMORY.md`, `TOOLS.md`, and self `SKILL.md` are indexed.\n- ⚠️ **No PII in test fixtures**: `run_tests.py` uses anonymized query terms (`project_alpha`, `sample_note_01`) — no real project names, personal names, or sensitive references.\n- ⚠️ **Secret file filtering**: `scoring.py` skips files matching `.secrets/`, `*.env`, `credentials*`, `*token*`, `*password*`, `.git/`.\n- ⚠️ **Hybrid search consent warnings**: `hybrid_search.py index` displays a consent warning before batch embedding. Use `--yes` to skip in automation. `add` prints a one-line notice (use `--quiet` to suppress).\n- ⚠️ **`EXTRACTION_PROMPT` excludes secrets**: The LLM extraction prompt explicitly instructs the model to never extract credentials, API keys, tokens, passwords, personal data, or session IDs.\n\nFile v4.0.5:_meta.json\n\n{\n  \"ownerId\": \"kn7d3dpn3e5ys39cpa1y06kvq18cp3rk\",\n  \"slug\": \"memory-toolkit\",\n  \"version\": \"4.0.5\",\n  \"publishedAt\": 1791716344723\n}\n\nFile v4.0.5:CHANGELOG.md\n\n# Changelog — OpenClaw Memory Toolkit\n\nAll notable changes to the OpenClaw Memory Toolkit skill.\n\n## v4.0.5 — Dates are validated before they reach the graph (2026-10-11)\n\n### Real fix\n\n**The extraction pipeline trusted the LLM's date and folded it into ids.**\n`stable_id('tl', what, today)` concatenated the string blindly, and the\ndecision write took `dec.get('date')` verbatim. A model asked for `YYYY-MM-DD`\nstill returns `2026-05-31-0913` (date + time), `2026-05-26-roadmap-updates`\n(date + free text) or `2026-04` (month only).\n\nBlind concatenation produced the unmatchable nodes `day_202605310913` /\n`day_20260526roadmapupdates` / `day_202604` — targets no edge can ever match.\nThat is the **dangling-target** defect the 10/10 backfill had to repair by hand\n(158 edge targets pointing at nodes that did not exist). It is now fixed at\nthe source, so it cannot recur.\n\n### Change\n\n`normalize_date()` is the single gate every date passes before it is written\nor folded into an id:\n\n- **Parse first, build second.** A real calendar date is required —\n  `datetime()` rejects `2026-13-01` and `2026-02-30`; a regex alone would not.\n- A leading `YYYY-MM-DD` in a longer string is kept\n  (`2026-05-31-0913` → `2026-05-31`), the trailing noise is dropped.\n- Anything else falls back — the honest answer for \"undated decision\" is today,\n  not a fabricated date.\n\nIt gates both the id builder (`stable_id`) and the decision-date write. 12 unit\ncases cover the boundary.\n\n### Note on the source of truth\n\n`trace_extractor.py` is the one skill file whose **live copy leads**: the fix\nwas written against the running skill then ported live → repo. `release.sh`\nsection 2 asserts the two copies are identical.\n\n## v4.0.4 — No decision enters the ontology as an orphan (2026-10-10)\n\n### Real fix\n\n**M6: every extracted decision now carries an edge.** The ontology held\n**889 orphan entities out of 928, with only 34 relations** — a list of\ndisconnected blocks, not a graph. Root cause: `trace_extractor` created\nDecision / TimelineEvent nodes with no relation at all.\n\nTwo changes, both at the source:\n\n- The extraction prompt now REQUIRES a `related_to` on every item — the id of an\n  existing entity it attaches to, grounded in the note text, never invented.\n  When nothing fits, the item anchors to the catch-all root `daily_notes`.\n- `write_ontology_entities()` appends a `relate` fact for every decision it\n  writes, resolving the root via `_norm_related_to()` (grounded anchors only)\n  then `_root_id_for()`. An invented anchor is refused, not wired.\n\nNothing is deleted: the graph stays append-only, and every auto-link is\nreversible.\n\n### New guard\n\n`hybrid-search/test_orphan_link.py`, wired into `scripts/release.sh`. It asserts\nboth directions: a grounded anchor links, an ungrounded decision still links to\n`daily_notes`, and an INVENTED anchor is refused. A gate that accepted invented\nroots would silently wire the graph wrong.\n\n## v4.0.3 — MEMORY.md can no longer grow unbounded (2026-10-10)\n\n### Real fix\n\n**Size guard in `consolidate_advisor.apply_promotions()`.** MEMORY.md is injected\ninto every main-session context and has a hard 5 KB budget, but the promotion\npath appended entries without ever checking the running total. That is how the\nfile silently reached **19.6 KB (3.8x the limit)** before a manual audit caught\nit. The writer now projects the post-write size and **refuses to write** when it\nwould exceed `MEMORY_MAX_SIZE` (env-overridable, default 5000):\n\n```\n❌ Size guard: refusing to write — projected MEMORY.md 18689 bytes\n   > limit 5000 bytes (+13710 would be added).\n   Trim/archive MEMORY.md first, or promote fewer entries.\n```\n\nThe file is left byte-for-byte untouched on refusal. Promotions are suggestions,\nnot obligations: losing an overflow is strictly better than paying for it in\nevery future session's context.\n\nVerified by direct test: a 2-entry lot applies, a 20-entry lot is refused and\nthe file stays intact.\n\n## v4.0.2 — Ontology health check no longer cries wolf (2026-10-10)\n\n### Real fix\n\n**`memory-health.py` parser was blind to `op=state`.** The ontology graph is an\nappend-only log whose dominant record is now `op=state` (879 of 964 lines in the\ncurrent graph); `create`/`upsert` are the older forms. `check_ontology()` counted\nan entity only for `op in (\"create\", \"upsert\")`, so it saw **50 entities** while\n`ontology_compact.py`, which replays the full log, saw **927**. The 34 `relate`\nrecords pointed at the real entities, so the check reported **68 orphan\nrelations on every run** — all of them false.\n\nThe parser now accepts `state` as an entity-bearing op. Both tools agree:\n\n```\nbefore:  50 entities,  68 orphan relations (🟡)\nafter : 928 entities,   0 orphan relations (🟢)\n```\n\nNo false-positive suppression: the orphan count is still computed and still\nreported when real orphan relations exist. Only the parse was wrong.\n\n### Notes\n\n- Discovered while triaging a health report (10/10): the \"68 orphan relations\"\n  warning was the health check disagreeing with the compactor, not real drift.\n- `ontology_compact.py` needed no change — it was already correct.\n\n## v4.0.1 — Security hardening + static-analysis false positives (2026-10-10)\n\n### Real fixes\n\n**A. Loopback guard on `llm_resolution.py` (SSRF gap).** The v4.0 resolver read\n`OLLAMA_URL` straight from the environment, while every other module routes it\nthrough `get_safe_ollama_url()`. It was even *exempted* from the static scan in\n`test_loopback_guard.py` (`if fn == \"llm_resolution.py\": continue`). A crafted\nenvironment could therefore point the safety-net endpoint at a remote host. It\nnow uses the same loopback guard, and the exemption is removed —\n`llm_resolution` is covered by the guard test like the other modules.\n\n**B. Cloud transport is now opt-in.** `trace_extractor.py` used to send memory\nand session excerpts to `ollama.com` whenever an API key was *present*. A memory\nskill that ships notes off-machine just because a key happens to exist is a\nconsent surprise. Cloud is now used only when the operator explicitly sets\n`TRACE_LLM_ALLOW_CLOUD=1` **in addition** to the key; otherwise extraction stays\non `127.0.0.1:11434`. `TRACE_LLM_LOCAL_ONLY=1` remains a hard local-only lock.\nThe disclosure banner reflects the real transport in all cases.\n\n### Clearing a scanner false positive\n\nClawHub's static analysis flagged\n`hybrid-search/test_ontology_key_parity.py:61` as\n`suspicious.dynamic_code_execution` (critical). It is a false positive: the\nflagged call is the standard Python import machinery loading a **fixed, literal**\npath in the same repository (`migrate_ontology_subjects.py`) — no network, no\nuser input, no environment variable, no argv reaches it.\n\nThe loader was **not** removed to silence the scanner: the test deliberately\nimports the real migrator module so it tracks the real code instead of a stale\ncopy. An inline explanation and a `noqa` marker were added instead.\n\nAlso ignores `hybrid-search/FULL_INDEX_REPORT.md` (a run artifact, like\n`test_results.json`).\n\n### Known false positives (documented, not fixed)\n\nFurther SkillSpector findings are scanner artefacts and are recorded here so a\nfuture review does not waste time re-triaging them:\n\n- **\"Tainted flow … credential exfiltration\" (Critical, ×5).** The flagged\n  values are `OLLAMA_URL`, `GEN_TIMEOUT` and a `Content-Type` header — not\n  secrets. The one genuine nugget was the missing loopback guard on\n  `llm_resolution.py` (fixed above).\n- **\"Anti-Refusal Statement\" (CHANGELOG).** Prose describing the\n  `resolve --confirm` feature; the scanner matched the words \"supersedes any\n  rival claim\". Not an instruction.\n- **\"Credential Access\" (CHANGELOG, ×4).** The quoted text is the *refusal* list\n  (`/etc/passwd`, `.secrets/*`, `SOUL.md` … are rejected). The scanner matched the\n  words, not the meaning. This is a security *proof* read as a crime.\n- **\"Referenced artifact was not completely inspected\" (SKILL.md).** The skill\n  documents modules (e.g. `hybrid_search.py`) that live outside the scanned\n  bundle; the scanner cannot open what was not shipped.\n\n## v4.0 — One resolver, one truth (2026-10-10)\n\n**Breaking contract change.** Model selection is no longer copied into each\nscript: it is *resolved* once, at runtime, from the gateway configuration. Every\ncaller now goes through a single module, `hybrid-search/llm_resolution.py`, so a\ncatalogue rotation or a config change propagates everywhere at once instead of\nsilently breaking one script at a time.\n\n### Why this release exists\n\nv3.6.0 and v3.6.1 each fixed a *symptom*: a hard-coded model name that no longer\nmatched what the Ollama daemon served (`glm-5.2` vs `glm-5.2:cloud`). The fix\nworked, but the *pattern* that caused it survived — model names were still\nrecopied in `PREFERRED_MODELS` lists, in `auto_capture.py`, and in the local\n`trace-extractor` fallback. The next catalogue rotation would have re-broken it.\nv4.0 removes the pattern.\n\n### Added\n\n- **`hybrid-search/llm_resolution.py`** — the single source of truth for the\neffective LLM model. Resolution chain, in order:\n  1. an explicit `*_LLM_MODEL` / `OLLAMA_MODEL` env var (**visible and logged**,\n     never silent — the escape hatch that saved v3.6.0/v3.6.1 is kept);\n  2. the gateway default, `agents.defaults.model.primary` from `openclaw.json`;\n  3. the configured fallback, `agents.defaults.model.fallbacks[0]`;\n  4. the local safety net (`qwen2.5:7b`), **presence-checked before use**.\n  Exposes `resolve_llm_model()` and `explain_resolution() -> {model, source, chain}`\n  for logging and tests. `@lru_cache`d, so a run resolves once.\n- **No-silent-failure invariant (spec §6.1).** A missing local safety model\n  raises a clear error instead of letting Ollama start a synchronous multi-GB\n  pull that would hang an unattended nightly run.\n- **`hybrid-search/test_model_resolution.py` (extended).** Pins the whole\n  precedence chain (explicit > gateway:primary > gateway:fallback > local safety),\n  the served-model guarantee, and the raise-on-missing-safety-model invariant.\n- **`hybrid-search/test_extract_atomic.py` (new).** Covers `extract_atomic()`,\n  a gap flagged in the spec: number protection (`Ubuntu 24.04`, `3.14`, `100%`,\n  `12,5 %`) *and* real clause splitting (semicolons, conjunctions, sentence\n  boundaries).\n- **`hybrid-search/test_ontology_key_parity.py` (new).** Pins indexer/migrator\n  display-key parity. Drift is a failure; a display-key collision (two ids, one\n  key) is reported as data, not drift. It caught a real second issue on first run.\n\n### Changed\n\n- **4 callers migrated to the shared module**, no more hard-coded names:\n  `consolidate_advisor.py`, `hybrid-search/auto_capture.py`,\n  `hybrid-search/conflict_resolver.py`, and `trace_extractor.py` (which also\n  serves the local `trace-extractor` fallback). `qwen2.5:7b` now survives *only*\n  as the last link of the shared chain.\n- **`PREFERRED_MODELS` lists are gone** from the arbiters — the chain replaces\n  them.\n- **`resolve_llm_model()` reads the gateway config** (`agents.defaults.model.*`),\n  so the effective model follows the operator's real default instead of a copy.\n- **`scripts/sync-skill.sh` ships `hybrid-search/llm_resolution.py`** and the new\n  test files to the installed skill. (`llm_resolution.py` had been missing from\n  its `FILES` list: post-sync, each caller's `try/except ImportError` fallback\n  would have silently restored the *old* behaviour — the exact \"silent failure\"\n  this release removes.)\n- **`trace_extractor.py` imports the module via multi-candidate paths**, so it\n  finds `llm_resolution` from both the repo and the installed skill.\n\n### Fixed\n\n- **Destructive detection bug in `migrate_ontology_subjects.py` (M4-B).**\n  `load_graph()` rebuilt the display key as `name or type`, but the indexer\n  (`hybrid_search.index_jsonl_file`) uses `name or entity.id or type`.\n  `Decision` / `TimelineEvent` nodes carry no `name`, so the migrator produced\n  `' (Decision)'`, matched nothing, and reported **2826 live facts as ghosts**.\n  A single `--apply` would have marked **2406 valid facts `superseded`** and\n  pulled them out of active retrieval. Fix: mirror the indexer exactly (add the\n  id fallback). After the fix: survivors **3871** / ghosts **0** (was 1045 / 2826).\n\n### Housekeeping\n\n- `migrate_subject.py` / `migrate_ontology_subjects.py` marked **APPLIED**\n  (one-shot, already run in production). Kept as re-auditable tools — a dry-run\n  still answers \"is anything left to migrate?\" in seconds. Not merged.\n- `docs/CHANGELOG-UNRELEASED-V3.6.1.md` was a superseded draft (content fully\n  present in this CHANGELOG, verified line by line) → renamed `_ARCHIVED-*`.\n- Dead orphan log `memory/nightly-extraction.log` (frozen 2026-07-22) archived to\n  `.archive/retired-scripts/nightly-extraction.log.mort-2026-07-23`.\n\n### Verified\n\n- `scripts/release.sh check` green: syntax, frontmatter, loopback guard, and all\n  five test suites pass.\n- Both arbiters resolve to a model the local daemon actually serves.\n- Migrator dry-run on the live DB: `survivors 3871 / ghosts 0`.\n\n## v3.6.1 — Arbiter follows the operator's real default (2026-10-06)\n\nFollow-up to v3.6.0. That release restored conflict arbitration but still listed\n`glm-5.2:cloud` first in `PREFERRED_MODELS` — a name inherited from the v2.2.0\nhard-code, not a deliberate choice. It is installed on the daemon (so the fix\nworked), but it is not the model this deployment actually runs on.\n\n### Changed\n- **`PREFERRED_MODELS` now follows the operator's real default**, `deepseek-v4-pro:cloud`\n  (the configured `agents.defaults.compaction.model`), ahead of\n  `deepseek-v4.1-flash:cloud`, `glm-5.2:cloud` and the offline `qwen2.5:7b`. Both\n  `conflict_resolver` and `consolidate_advisor` are aligned.\n- An explicit `CONFLICT_LLM_MODEL` / `TRACE_LLM_MODEL` / `OLLAMA_MODEL` still\n  overrides the list, so pinning a model stays a visible, one-line decision.\n\n### Verified\n- `deepseek-v4-pro:cloud` returns `CONTRADICTION` (confidence 0.85) with a correct\n  rationale on the same backup-broken/repaired probe that v3.6.0 used — the model\n  swap does not weaken the arbiter.\n- Both modules resolve to `deepseek-v4-pro:cloud` on this host.\n\n## v3.6.0 — Conflict arbitration was dead on arrival (2026-10-06)\n\nFix release. Closes the finding that the `superseded` / `disputed` lifecycle paths\nhad **never executed once**. The cause was not the subject fidelity work of v3.5.0\nnor the source echo guard: it was a missing model tag.\n\n### Fixed\n- **The arbiter asked Ollama for a model that does not exist.** `conflict_resolver`\n  sent the bare name `glm-5.2`, while the daemon serves `glm-5.2:cloud`. Ollama\n  answered **HTTP 404**, `classify_relation()` fell through to the conservative\n  heuristic, and *every* fact came back `COMPATIBLE / no confident relation detected`.\n  Conflict arbitration had therefore never fired: `superseded` and `disputed` were\n  unreachable code paths. Verified against a real contradiction (backup broken →\n  repaired): the LLM now returns `CONTRADICTION` with a correct rationale, and\n  `--apply` writes `status=superseded` + the `superseded_by` link.\n- **`consolidate_advisor.py` carried the identical defect** (`OLLAMA_MODEL` default\n  `\"glm-5.2\"`), so the advisor was silently producing nothing for the same reason.\n  Both modules now resolve their model the same way.\n\n### Changed\n- **Model resolution is no longer hard-coded.** A hard-coded name — even one with\n  the correct tag — rots on the next model swap. Both modules now resolve against\n  the models the daemon actually serves (`GET /api/tags`): an explicit\n  `CONFLICT_LLM_MODEL` / `TRACE_LLM_MODEL` / `OLLAMA_MODEL` still wins, otherwise\n  the first served model from a preference list is used, and an unreachable daemon\n  falls back to a tagged preference instead of crashing or sending a bare name.\n- **New regression test `hybrid-search/test_model_resolution.py`** pins the three\n  behaviours (explicit env wins / resolves to a served model / offline fallback is\n  tagged). Wired into `scripts/sync-skill.sh` and the `release.sh check` gate.\n- **Source filter (M4 follow-up):** trivially short user turns (`go`, `ok`, `top`, …)\n  are skipped by the trace extractor instead of being mined for durable facts — the\n  76-turn dry-run contained 18% such turns feeding ~80% operational noise.\n\n### Verified\n- Real contradiction on a DB copy: `[contradiction] Le backup nightly est repare`\n  → `--apply` → `id=5134 status=superseded superseded_by=5135`.\n- Non-contradictions still classified `COMPATIBLE` (Kavita `0.9.0 → 0.9.1.4`; Dovato\n  morning vs evening \"plus le matin\") — the arbiter discriminates, it does not cry\n  conflict.\n- `test_model_resolution.py`: 3/3 hold. `test_loopback_guard.py`: all guards hold\n  (run under the skill venv, which has `sqlite_vec`).\n\n## v3.5.0 — Subject fidelity (M4) + source echo guard (2026-10-06)\n\nFix release. Closes the M4 audit finding — the one that made conflict arbitration\nstructurally unable to fire. Three independent defects, one shared root: facts\nreached the ledger with a subject that did not discriminate, so the resolver's\n\"is this the same entity?\" test could never return a confident match.\n\n### Fixed\n- **Multi-word subjects were truncated to their first word** (`auto_capture._normalise_facts`,\n  mirrored in the new `trace_extractor._norm_subject`). `'\\w'` includes `_`, so\n  `re.split(r\"[^\\w]+\", \"kavita_home\")` yielded a SINGLE token, grounding failed,\n  and the fallback took the fact's first substantial word. `kavita_home` became\n  `kavita`, `backup_cron` became `backup` — so `kavita_home` and `kavita_index`\n  (different entities) collided under one key. Grounding now compares against the\n  tokenised text and splits on underscore too.\n- **`trace_extractor.py` produced no subject at all.** Its prompt never requested\n  one and its writer never emitted one, so every trace item reached the ledger as\n  `subject=NULL`. The prompt now requires a grounded `subject` per item, and the\n  writer emits it as `[subject:key]` on the note line for the downstream indexer.\n- **Machine-generated assistant turns were mined for user facts** (echo guard).\n  The only gate was `should_capture(user_msg)`; an assistant turn carrying a tool\n  completion (`... executed from ...`) or a cron report (`Summary: {'added': 0, ...}`)\n  was handed to the extractor and returned as a \"durable fact\". `assistant_turn_is_echo()`\n  now drops such turns — the user half is kept, the machine half is discarded.\n\n### Verified\n- Echo guard: 8/8 cases (tool logs, cron summaries, `=== END ===` markers gated;\n  real conversational turns kept).\n- Subject normalisation: `kavita_home`/`backup_cron`/`ubuntu_24_04` keep their full\n  key; v3.3 anti-hallucination behaviour (`Serveur Prod` → `serveur`) preserved.\n\n### Notes\n- `--apply` remains gated behind a real dialogued conflict batch: the\n  `superseded`/`disputed` paths are still unproven on live data.\n\n## v3.4.0 — Point-in-Time Retrieval (`--as-of`) (2026-10-05)\n\nFeature release. Turns the fact-lifecycle ledger into a time machine: the search\ncan now reconstruct the exact cognitive state the agent had **on a past date**,\nnot just what it believes today. In PLM terms, this is the step from a single\n\"As-Maintained\" configuration to a retrievable \"As-Built\" baseline. Born from\nthe operator's observation (2026-10-05) that the v3.3 ledger — which marks rows\n`superseded` instead of deleting them — already held the history; what was\nmissing was a timestamp for *when* a fact stopped being current.\n\n### Added\n- **`superseded_at` column** (`schema.sql`). The ISO timestamp at which a row\n  STOPPED being active (`NULL` while it is). This is the axis the earlier schema\n  lacked: `valid_from` records when the fact became *true in the world*, while\n  `superseded_at` records the *lifecycle of the row* — and `updated_at` cannot\n  serve, because a REDUNDANT confirmation rewrites it without ending anything.\n  Without a dedicated column, point-in-time retrieval is impossible to express\n  correctly: the operator's first SQL draft referenced `superseded_at` before it\n  existed, which is what surfaced the gap.\n- **`--as-of YYYY-MM-DD`** on `query`, `search` and `context`. Reconstructs the\n  facts visible on that date instead of the current set. A bare date means *end\n  of that day* (`2026-07-01` → `T23:59:59`), so a row created at 10:00 on that\n  day is visible; a full timestamp is used verbatim. Verified against a\n  MySQL→PostgreSQL switch: the pre-switch fact is returned for a date before it\n  and the successor for a date after, with no overlap.\n- **`_as_of_clause()`** — one helper builds the visibility predicate shared by\n  the lexical and vector paths, so both halves of the hybrid search agree on\n  what \"visible on date D\" means.\n\n### Changed\n- **`ensure_lifecycle_columns()`** now also adds `superseded_at` (and its index)\n  to an older DB, so the migration is idempotent for existing installations.\n- **`apply_resolution()` and `resolve`/`--confirm`/`--reject`** stamp\n  `superseded_at` at each `active → superseded` transition, inside the existing\n  atomic transaction (C2). The column is maintained by the real resolution path,\n  not only by a one-off backfill.\n- **`search_lexical()` / `search_vector()` / `search_hybrid()`** take an optional\n  `as_of`; default (`None`) behaviour is byte-for-byte the previous \"active only\"\n  query — verified by a regression run against the live database.\n\n### Data maintenance (real database, 2026-10-05)\n- **3 475 `superseded` rows backfilled** with\n  `superseded_at = COALESCE(updated_at, created_at)`. Backup taken and\n  `integrity_check` verified before the write; post-run `foreign_key_check`\n  clean; zero active rows carry a `superseded_at`.\n- _Note recorded for a future release:_ `--as-of` cannot reconstruct a state\n  older than the first indexing date (`created_at` floor, here 2026-10-03), since\n  no row predates it. Historical *world* time belongs to `valid_from`, a separate\n  axis, not to row lifecycle.\n\n## v3.3.0 — Referential Integrity, Transactional Writes, Ontology→DB Sync (2026-10-05)\n\nHardening release. An audit of the fact-lifecycle layer found that the schema\n*declared* a state machine (status, confidence, superseded_by) the engine never\nenforced, that the write paths were not atomic, and that subject arbitration was\ndead in practice (100 % of indexed facts had `subject = NULL`). Every defect is\nfixed at the source and verified against the real database.\n\n### Fixed\n- **Schema now enforces what it declared (C1).** `status` carries a `CHECK\n  (status IN ('active','superseded','disputed'))`, `confidence` a range `CHECK`,\n  and `superseded_by` a `FOREIGN KEY … ON DELETE SET NULL`. Previously an\n  out-of-range status or a dangling referent was silently accepted. Verified by\n  negative tests: invalid status, `confidence = 5` and a broken referent are all\n  rejected by the engine.\n- **`conflict_resolver.apply_resolution()` is atomic (C2).** It ran its UPDATE\n  (supersede) and its INSERT (successor) as separate statements; a crash between\n  them left a fact superseded by nothing — silently lost. The whole resolution is\n  now one `BEGIN IMMEDIATE` … `COMMIT` with rollback.\n- **Concurrent access is safe (C3).** Both connection paths (`HybridMemoryStore`\n  and the resolver's `connect()`) now set `busy_timeout=5000`, `journal_mode=WAL`\n  and `foreign_keys=ON`. The async capture hook, the resolver and RRF reads can\n  now run in parallel instead of colliding on a locked DB.\n- **`add_memory()` writes the hot row and its vector atomically (C4).** A failure\n  between the two INSERTs used to leave a fact with no embedding — invisible to\n  vector search, still visible to FTS5 (silent index drift). Both INSERTs now\n  share one transaction and roll back together.\n- **`compact.py` never archives a still-referenced fact (M2).** A terminal row\n  that another hot row points at via `superseded_by` is now held back, so\n  archiving cannot dangle a live reference. Verified on a `1←2←3` chain.\n- **`ontology_compact.py` drops orphan relations (M3).** Relations touching a\n  superseded/absent entity used to survive compaction as orphan edges. They are\n  now dropped and reported (`relations: N kept, M orphan(s) dropped`).\n\n### Added\n- **`subject` is finally writable and populated (M4).** The column existed since\n  v2.2.0 but no write path could set it, so **100 % of facts had `subject = NULL`**\n  and conflict arbitration fell back to brittle lexical overlap. `add_memory()`\n  now accepts `subject`; the indexer derives a **deterministic, never-invented**\n  key from the source (`derive_subject()`), and ontology nodes use their own\n  entity id. Arbitrable fact categories now sit at **100 % subject coverage**\n  (daily notes and archives stay subjectless by design — they are episodic logs,\n  not atomic assertions).\n- **Ontology → DB synchronisation (v3.3 causality link).** `ontology_compact.py`\n  now mirrors a compaction into the hot DB: every entity that leaves the reference\n  nomenclature (`graph.jsonl`) has its facts marked `superseded` in the same run.\n  The set is the **diff between the pre- and post-compaction live sets**, so\n  entities that simply vanish are caught — not only explicit `supersede` ops.\n  Best-effort: a missing/locked DB never aborts a successful graph compaction.\n- **`migrate_subject.py` / `migrate_ontology_subjects.py`** — one-shot, idempotent\n  backfill/audit tools, kept in the repo (not installed in the skill). They are\n  what surfaced and repaired the two M4 defects below.\n- **`docs/AUDIT-v3.2.md`** — the reference audit that catalogued C1–C4, M2–M4.\n\n### Changed\n- **Auto-capture subject anti-hallucination relaxed (M4).** The old rule dropped\n  any subject whose tokens did not *all* appear in the fact (`« Serveur Prod »` →\n  tokens `serveur`,`prod`; only `serveur` grounded, so the whole key was thrown\n  away). It now keeps the subject if **any** token is grounded (preferring the\n  grounded token) and falls back to the fact's first substantial word. A subject\n  that grounds nothing is still dropped.\n\n### Data maintenance (real database, 2026-10-05)\n- **898 ontology rows relabelled.** The filename-derived migration had collapsed\n  898 distinct entities under one generic `subject = 'graph'`, which would have\n  made arbitration fetch an absurd mix. Re-mapped to each entity's real id: **209\n  survivors** got their live id, **689 ghosts** (entities absent from the compacted\n  ontology) were moved to `superseded`, then their stale `subject` cleared.\n- **Backups verified before every write** (`integrity_check: ok`), and post-run\n  `foreign_key_check` clean.\n\n## v3.2.1 — Ontology Reindex, Number-Safe Splitting, Local Model Bump (2026-10-05)\n\nMaintenance release. Two defects found during the first live Auto-Capture\nsession, both fixed and verified against the real database.\n\n### Fixed\n- **`hybrid-search/hybrid_search.py` — nested-schema ontology indexing.**\n  `index_jsonl_file()` read `name`/`type` at the JSON root, but ontology lines\n  are nested (`{\"entity\": {\"properties\": {\"name\": …}, \"type\": …}, \"op\": …}`).\n  Every graph node was therefore indexed as the literal string `\" ()\"` —\n  **2 786 junk rows, 69 % of the database**, which also polluted the FTS5 and\n  vector indexes. The function now reads both flat and nested schemas, falls\n  back to `id`, and applies a real minimum-length guard (`len(content) <= 4`);\n  the previous `if not content.strip()` check let `\" ()\"` through because\n  `\"()\"` is not the empty string. A `skipped` counter is now reported.\n- **`hybrid-search/conflict_resolver.py` — number-safe punctuation split.**\n  `extract_atomic()` split on every period, breaking version strings and\n  percentages: `Ubuntu 24.04` became `Ubuntu 24` + `04`, and `… à 100% atteint`\n  lost its tail. A period now splits only when not sandwiched between digits\n  (`(?<![0-9])\\.(?![0-9])`), and any purely-numeric orphan fragment is\n  re-attached as a safety net. Semicolons and coordinating conjunctions still\n  split as before.\n\n### Changed\n- **Default extraction model: `qwen2.5:3b` → `qwen2.5:7b`** (still local).\n  The 3 b model returned empty `{}` extractions in ~20 s and split version\n  numbers; the 7 b model extracts whole facts in ~44 s. Loopback-only\n  guarantee preserved — no cloud model is used.\n- **Database maintenance:** 2 786 empty ontology rows moved to\n  `superseded` (reversible, not deleted) and the 898 clean nodes re-indexed\n  (`0` errors, 34 `relate` lines correctly skipped). Backup taken before the\n  operation.\n\n## v3.2.0 — Auto-Capture: Session Dialogue → Arbitrated Facts (2026-10-04)\n\nFeature release. Adds the write-behind half of the autonomous memory loop: a\npost-turn pipeline that reads session dialogue, extracts atomic durable facts\nwith a local LLM, and arbitrates them against existing memory (superseding or\nflagging contradictions). Complementary to the existing nightly indexer, which\nonly archives/indexes daily notes and never arbitrates.\n\n### Added\n- **`hybrid-search/auto_capture.py`** — post-turn fact extraction. Gating\n  (regex/length) skips trivial exchanges before any LLM call; a local model\n  (`qwen2.5:7b` by default) returns atomic facts; results are handed to\n  `conflict_resolver.py`. Guards: local-only endpoint, user-anchored extraction\n  (echo-loop guard drops assistant speculation), subject anti-hallucination\n  (a subject must appear in the fact text or it is dropped), `--selftest`.\n- **`hybrid-search/transcript_adapter.py`** — reads the OpenClaw per-agent\n  session store (`agents/<agent>/agent/openclaw-agent.sqlite`,\n  `session_transcript_fts`) and emits `{user, assistant}` turns. Snapshot-first\n  (WAL-aware copy; the live DB is never touched), read-only, secret-redacting.\n- **`--no-split`** flag on `conflict_resolver.py` `check`/`arbitrate`: treats an\n  already-atomic fact as-is, avoiding a punctuation split that broke values\n  like `Ubuntu 24.04` into two malformed facts.\n\n### Why\n- Legacy `sessions/*.jsonl` transcripts stopped being written (OpenClaw migrated\n  session storage to SQLite). A cron reading them would run green nightly while\n  capturing nothing. The adapter reads the authoritative store the CLI itself\n  uses.\n- `conflict_resolver.py` had 3100 facts all `active` with no subject: the\n  lifecycle/arbitration path had never been exercised. Auto-Capture feeds it.\n\n## v3.1.1 — README Split: Contributing Moved Out (2026-10-03)\n\nDocumentation-only release. No behaviour change to any script.\n\n### Changed\n- **The \"Release Pipeline\" section moved from `README.md` to a new\n  `CONTRIBUTING.md`.** It documents the repo → skill → GitHub → ClawHub flow, the\n  release gate, and the anti-drift invariants — that is maintainer material, not\n  user material. Someone installing the skill from ClawHub has nothing to do with\n  our internal pipeline; the README should not make them read it.\n- **`README.md` now targets users only** and keeps a one-line pointer to\n  `CONTRIBUTING.md`, plus the short \"MUST go through `scripts/release.sh`\" warning.\n\n### Why\nStandard GitHub split: `README` = what it is / how to use it;\n`CONTRIBUTING` = how to maintain and release it. Keeping the maintenance detail\nout of the user-facing README makes the ClawHub listing cleaner without losing\nany of the release discipline.\n\n## v3.1.0 — Recursive Archive Scan + Nightly Index Guard (2026-10-03)\n\nFeature release. The hybrid search index was silently blind to every archived\nnote filed in a sub-folder, and the nightly job could index nothing at all\nwithout anyone noticing. Both are fixed and both now fail loudly.\n\n### Added\n- **Recursive archive scanning.** `hybrid_search.py` now scans\n  `memory/archive/**/*.md` instead of `memory/archive/*.md`. The non-recursive\n  glob only saw top-level files and missed every month/themed sub-folder\n  (`archive/2026-07/`, `archive/2026-08/`, `april-2026/`, `june-2026/`, …):\n  **174 archived notes were invisible to search**, and the DB covered 28 days\n  instead of ~6 months. Sources keep their relative path\n  (`archive/2026-07/2026-07-15.md`) so `delete_by_source` stays unambiguous.\n- **Hard verification in the nightly job.** The job now captures the chunk count\n  before and after indexing and writes one timestamped line to\n  `logs/nightly-index.log` **every night, success or failure**:\n  `status=<ok|ERROR> files=<n> chunks=<before>-><after> indexed_delta=<d> msg=…`.\n  `status=ok` requires the indexing to have run, `chunks > 0`, and `Last indexed`\n  from the current night; anything else is an explicit `ERROR`.\n- **Native failure alert to Telegram** on the nightly job (after 1 error,\n  1h cooldown), covering crashes/timeouts that the internal check cannot see.\n\n### Fixed\n- **The nightly job ran with an interpreter that could not index.** The job\n  called `python3` (system), which does **not** have `sqlite-vec`; the extraction\n  step worked but the indexing step silently wrote nothing. Job status was `ok`\n  while the index stayed frozen — a silent failure that had gone unnoticed.\n  Both steps now run under `skills/memory-health/.venv/bin/python`.\n\n### Verified\n- Reindex: **68 → 239 files, 1238 → 2167 chunks** (three layers aligned:\n  2167 memories = 2167 FTS = 2167 vec).\n- End-to-end proof: a witness note was indexed by the nightly job and retrieved\n  by the search (`ZORBLAX-7729`), then removed and the orphan chunk deleted\n  (2168 → 2167).\n- Old `scripts/nightly-extraction.py` (last executed 2026-07-22, no remaining\n  caller) retired to `.archive/retired-scripts/`.\n\n## v3.0.3 — trace_extractor Ported From the Local Skill (2026-10-03)\n\nBehaviour fix, ported from the installed skill copy. The skill's\n`trace-extractor.py` (891 lines, written 03/10 07:07) had moved ahead of the repo\n(842 lines); the repo copy was stale and is now identical.\n\n### Fixed\n- **Strict truncation salvage in `parse_llm_output()`.** When the cloud model hits\n  `done_reason=length`, the response is cut mid-JSON. The parser now salvages only\n  the *fully-parsed elements* that appear before the cut, dropping an incomplete\n  object entirely rather than keeping an amputated value — a truncated fact is\n  worse than no fact in long-term memory. It never invents content.\n- **Cloud model.** `deepseek-v4-flash` returned HTTP 410 Gone; switched to\n  `deepseek-v4.1-flash:cloud`.\n\n### Verified\n- The installed version is a functional superset of the repo copy (21 replaced /\n  70 added lines, no repo-only content lost).\n- Repo and skill converge on the same 891-line file (md5-identical).\n\n## v3.0.2 — Security: `OLLAMA_GEN_URL` Loopback Guard (2026-10-03)\n\nRound 8 security scan of the published v3.0.0 returned 51 findings; one was real\nand is fixed here, the rest are triaged in `docs/SECURITY-AUDIT-NOTES.md` §3.\n\n### Fixed\n- **`hybrid-search/conflict_resolver.py` bypassed the loopback guard.**\n  `OLLAMA_GEN_URL` was read directly from `os.environ`, skipping\n  `get_safe_ollama_url()`. `classify_relation()` POSTs the content of two memory\n  facts to that URL on every arbitration, so a crafted environment could redirect\n  memory content to a remote host while the docstring still claimed \"fixed to\n  localhost at import time\". Routed through the same loopback allowlist as\n  `OLLAMA_URL`; a non-loopback override now raises at import.\n\n### Verified\n- `OLLAMA_GEN_URL=\"http://evil.example.com/api/generate\"` → `ValueError: Host\n  'evil.example.com' not allowed for OLLAMA_GEN_URL. Only localhost is permitted.`\n- `OLLAMA_GEN_URL=\"http://127.0.0.1:11434/api/generate\"` → imports fine.\n- No other `os.environ.get(\"OLLAMA…\")` bypass remains (grep-verified).\n\n## v3.0.1 — SKILL.md Actually Ships the v3.0.0 Content (2026-10-03)\n\nDocumentation-only fix. v3.0.0 shipped correct code, CHANGELOG and README, but\n`SKILL.md` — the file ClawHub renders and users read first — was never actually\nupdated. The v3.0.0 commit message claimed \"SKILL/README/CHANGELOG updated\"; on\n`SKILL.md` the edit was a silent no-op (an identical-replacement), and it was not\nre-verified before commit. The published skill therefore advertised `v2.2.0` in\nits lifecycle heading and documented none of the new commands.\n\n### Fixed\n- `SKILL.md` lifecycle heading is now `v3.0.0`.\n- Added the two missing Scripts entries:\n  - **8. `conflict_resolver.py`** — the four-step NLI pipeline, `check` /\n    `arbitrate`, `pending` (surface unresolved disputes) and `resolve --confirm`\n    / `--reject [--replacement]` (only `disputed` rows eligible; `--confirm`\n    supersedes rivals on the same subject).\n  - **9. `compact.py`** — cold-storage compaction: `--stats`, `--dry-run`\n    default, `--apply --min-age-days N`, `--restore <id>`; archive table +\n    JSONL audit, hot-table DELETE so FTS5/vector indexes drop the rows; requires\n    `sqlite-vec`.\n- `SKILL.md` grew 343 → 394 lines.\n\n### Process lesson\nAn `edit` that reports \"no changes made (replacement text is identical)\" is a\n**failure signal for that edit**, not a success. The version marker and the new\nsections are now grep-verified against the file, both locally and against the\npushed remote, before claiming the docs are updated.\n\n## v3.0.0 — Cold Storage & Interactive Dispute Resolution (2026-10-03)\n\nMajor version: the fact lifecycle introduced below is now complete end-to-end —\nfacts enter, get arbitrated, get resolved by a human when ambiguous, and are\nfinally compacted out of the hot index when terminal. The search DB becomes a\nmanaged store with a hot/cold boundary rather than an append-only pile.\n\n### Added\n- **`hybrid-search/compact.py`** — cold-storage compaction of terminal facts.\n  `superseded` (and optionally `disputed`) rows older than `--min-age-days` are\n  copied verbatim into `memories_archive` in the same SQLite file and appended to\n  a dated JSONL audit under `memory/audit/`, then `DELETE`d from the hot table —\n  which fires the existing triggers and removes them from FTS5 and the vector\n  index. That is the actual perf win: BM25 rank space and `memories_vec` no longer\n  carry dead facts. Never a hard delete of data, only a move; `--restore <id>`\n  rehydrates a single archived fact.\n  - Backed up first via SQLite's own `backup()` API (a `copy2` on a live DB can\n    capture a torn WAL), MD5-checked before any write; `--dry-run` is the default\n    and mutation requires `--apply`; a retention guard refuses to sweep rows newer\n    than `--min-age-days`, and rows whose age cannot be proven are kept.\n  - Requires `sqlite-vec`: the `memories_vec_ad` trigger fires on `DELETE`, so\n    without the extension the move would abort mid-transaction with \"no such\n    module: vec0\". The extension is now loaded on every connection, and the script\n    refuses loudly (instead of half-archiving) when it is missing but needed.\n- **Interactive dispute resolution** (`hybrid-search/conflict_resolver.py`):\n  - `pending` — surfaces unresolved `disputed` facts cheaply and deterministically\n    (`--json` for programmatic use), so an agent can raise the queue at the next\n    relevant turn without a runtime hook. This is the tool half of \"resolve at the\n    next pertinent turn\": the skill cannot decide relevance, but it can always\n    answer \"what is waiting for a human?\".\n  - `resolve <id> --confirm` — restores the wording to `active` **and supersedes\n    any rival claim on the same subject**, so a contested pair can never both stay\n    visible.\n  - `resolve <id> --reject [--replacement \"…\"]` — supersedes the wrong wording and\n    optionally inserts a corrected fact as the new `active` one, chaining\n    `superseded_by`.\n  - Only rows currently `disputed` are eligible; resolving an `active` or already\n    `superseded` row is refused, so the command cannot rewrite lifecycle state by\n    accident.\n\n### Changed\n- SKILL/README pipeline diagrams and the nightly cron block document the\n  arbitration, cold-storage and dispute-resolution steps.\n- Version moved to 3.0.0: this is the first release where the lifecycle is closed\n  end-to-end (create → arbitrate → resolve → compact), and it introduces the\n  hot/cold storage boundary — a schema/operational break worth a major bump.\n\n### Verified (executed, not read)\n- **Compaction**: a `superseded` fixture moved with `--apply --min-age-days 0` →\n  hot 4→3, FTS rows 4→3, archive 1, JSONL audit written, DB backup taken. Retention\n  guard: with `--min-age-days 30` on fresh rows, 0 eligible (correctly refused).\n- **Restore**: `--restore <id>` moved the row back (hot 4, archive 0, FTS 4); a\n  second restore refused with `not in archive` (idempotent).\n- **vec0 trigger bug caught by testing**: the first `--apply` failed with \"no such\n  module: vec0\" because the vec trigger fires on delete; fixed by loading the\n  extension on every connection, with an explicit refusal path when it is absent.\n- **Resolve**: `--confirm` on a disputed row restored it to `active` and superseded\n  its active rival (`superseded_rivals: [7]`), leaving one active fact per subject;\n  `--reject --replacement` superseded the wrong fact and inserted the correction\n  with a proper `superseded_by` chain; `pending` then reported none; resolving a\n  non-disputed row was refused (`id 6 is 'active', not 'disputed'`).\n- Syntax validated on all modules (`ast.parse`); CLI `--help` for both new command\n  surfaces inspected.\n\n### Files Modified (5)\nAdded `hybrid-search/compact.py`; modified `hybrid-search/conflict_resolver.py`,\n`README.md`, `SKILL.md`, `CHANGELOG.md`.\n\n## v2.2.0 — Fact Lifecycle, Conflict Arbitration & Tagged Injection (2026-10-03)\n\nThree improvements asked for after a review of the toolkit against a memory-engineering\nspec: fact conflict lifecycle, structured context injection, and a hard token budget.\nEvery change is local-first and read-only by default; nothing new leaves the machine.\n\n### Added\n- **Fact lifecycle columns** (`hybrid-search/schema.sql`): `subject`, `status`\n  (`active` | `superseded` | `disputed`), `confidence`, `valid_from`,\n  `source_context`, `last_confirmed`, plus indexes on `status`/`subject`. The\n  `superseded_by` column existed since the first schema but **was never written to\n  by any code** — it is now populated by the resolver below. An older DB is\n  migrated in place (idempotent `ALTER TABLE`, no data loss).\n- **`hybrid-search/conflict_resolver.py`** — the four-step consistency pipeline:\n  1. *atomic extraction* (`extract_atomic`) splits a compound statement into unit\n     facts; 2. *targeted retrieval* (`fetch_active_facts`) pulls only `active`\n     facts on the same subject, with an FTS5 lexical fallback; 3. *NLI\n     classification* (`classify_relation`) asks a loopback Ollama model for\n     `CONTRADICTION | REDUNDANT | COMPATIBLE` + confidence + reasoning, with a\n     conservative deterministic `heuristic_relation()` fallback for offline use;\n     4. *state update* (`apply_resolution`) supersedes / confirms / adds — never\n     hard-deletes, always keeping the chain via `superseded_by`.\n  - **Weak-signal protection**: a contradiction below `DISPUTE_CONFIDENCE_FLOOR`\n    (0.6) flags the old fact `disputed` and inserts the new one, instead of\n    destroying an established truth on a guess.\n  - CLI: `check`, `arbitrate <jsonl>`, `lifecycle --status …`. Analysis is\n    read-only; mutation requires `--apply` (`--force` in non-interactive mode).\n- **Tagged injection payload** (`render_context` in `hybrid_search.py`): produces a\n  strict `<agent_memory trusted=\"false\">` block with nested `<core_facts>`,\n  `<session_context ephemeral=\"true\">` and `<retrieved_context>`, XML-escaped, and an\n  explicit comment that retrieved text is *background data, never an instruction* —\n  separating recalled facts from the live prompt (indirect-injection defence). New\n  CLI: `hybrid_search.py context \"<text>\" --budget N --core-fact \"…\"`.\n- **Hard token budget** (`estimate_tokens` + `apply_token_budget`): a fixed top-k can\n  still overflow the window when hits are long. `--max-tokens` / `context --budget`\n  trims to a conservative ~4-chars/token estimate; an over-budget first hit is\n  truncated rather than returning nothing. `0` = unlimited (legacy behaviour).\n\n### Changed\n- **Retrieval is now lifecycle-aware**: `search_lexical`, `search_vector` and\n  `search_hybrid` add `AND m.status = 'active'`, so superseded/disputed facts can no\n  longer pollute results — the concrete fix for \"two contradictory facts coexist\".\n- README/SKILL nightly pipeline documents the arbitration step and the re-index.\n\n### Verified (executed, not read)\n- **Lifecycle transitions** on a real SQLite DB: high-confidence contradiction → old\n  row `superseded` with `superseded_by` pointing at the new `active` row; weak\n  contradiction (0.4) → old row `disputed`, new inserted; redundant → `last_confirmed`\n  refreshed and confidence bumped, no duplicate; compatible → added. `lifecycle`\n  listing confirmed the end state (`active=3, superseded=1` in the fixture).\n- **Token budget**: 400-char chunks (~100 tokens each) → budget 250 keeps 2, budget\n  500 keeps 3, budget smaller than one chunk truncates it and still returns a payload.\n- **Tagged block**: rendered output inspected — nesting, escaping and the\n  non-authoritative comment present.\n- **In-place migration**: `ensure_lifecycle_columns()` on a DB built from the old\n  schema adds exactly the missing columns.\n- Syntax validated on both modules (`ast.parse`).\n\n### Files Modified (5)\n`hybrid-search/schema.sql`, `hybrid-search/hybrid_search.py`, added\n`hybrid-search/conflict_resolver.py`; plus `README.md`, `SKILL.md`, `CHANGELOG.md`.\n\n## v2.2.2 — Disclosure That Matches the Transport (2026-10-02)\n\nRound 7, from the second SkillSpector run on the published v2.2.1 (54 findings).\nMost of the report is scanner triage already covered in\n`docs/SECURITY-AUDIT-NOTES.md`; **one finding was real, and it was one the scanner\nonly half-saw**.\n\n### Fixed\n- **The transport disclosure lied about the transport** (`trace_extractor.py`):\n  `llm_destination()` — the function whose entire job is to warn the operator before\n  memory content leaves the machine — read **only** `os.environ[\"OLLAMA_API_KEY\"]`,\n  while the actual sender, `get_ollama_api_key()`, also resolves the key from\n  `~/.openclaw/workspace/.secrets/ollama.json` and from `openclaw.json`.\n  Consequence: with the key in the secrets file — the common deployment — the banner\n  printed *\"local Ollama … else local fallback\"* and the content was then posted to\n  `https://ollama.com`. The warning was wrong **in exactly the configuration it\n  existed to protect**. This is worse than an undisclosed transmission: it is a\n  disclosure that actively misleads.\n  Both code paths now share one resolver, `_find_ollama_api_key_sources()`, which\n  returns `(source, key)` in one fixed precedence order. The banner names the source\n  (`key from env`, `key from secrets-file`, `key from config`) and `TRACE_LLM_LOCAL_ONLY=1`\n  short-circuits before any lookup.\n- **Intent/code divergence in the docs** (`README.md`, `SKILL.md`): the README opened\n  with \"No external API dependencies (Ollama runs locally via HTTP, no cloud APIs)\"\n  and the SKILL description ended with \"zero external cloud API dependencies\" — while\n  the extractor's **primary** transport was Ollama cloud. Six of the 54 findings are\n  this single contradiction. Both files now state the real posture: **local by\n  default, one opt-in cloud path**, with the switch and the warning documented at the\n  top, not buried.\n- **`--session-file` contradicted the confinement claim** (`SKILL.md`): the notes\n  asserted that every script stays inside `WORKSPACE/memory/`, but `--session-file`\n  deliberately accepts one absolute path outside it (a session transcript does not\n  live under `memory/`). The claim is now precise — documented as an explicit,\n  never-automatic exception rather than silently overstated. The same edit records the\n  three-file `ALLOWED_SCAN_FILES` allowlist so the stated scope equals the real scope.\n- **`OLLAMA_API_KEY` was missing from the configuration table** (`README.md`): the\n  variable that turns on the only cloud-capable path was undocumented.\n\n### Verified (executed, not read)\n- **The bug, reproduced then closed**: with no `OLLAMA_API_KEY` in the environment and\n  a key present in `.secrets/ollama.json`, the old `llm_destination()` returned\n  `('cloud', \"…if a key is configured, else local fallback\")` — ambiguous at best.\n  The patched version returns\n  `('cloud', 'Ollama cloud (ollama.com) — key from secrets-file — content leaves this machine')`.\n- **Local-only still wins**: `TRACE_LLM_LOCAL_ONLY=1` returns\n  `('local', 'local Ollama (127.0.0.1:11434) — forced by TRACE_LLM_LOCAL_ONLY')` before\n  any key lookup runs.\n- **Env override**: `OLLAMA_API_KEY` set returns `('cloud', '… — key from env — …')`.\n- `ast.parse()` clean on the modified module.\n\n### Documented (scanner false positives — not defects)\n- **Tainted flow `os.environ` → `urlopen`** in `consolidate_advisor.py` (~318) and\n  `trace_extractor.py` (~359): the first targets `OLLAMA_URL`, produced by\n  `get_safe_ollama_url()` and constrained to a loopback allowlist at import; the second\n  targets the literal `http://127.0.0.1:11434`. Loopback sinks, not exfiltration sinks.\n- **\"Credential Access\"** on `SECRET_SKIP_PATTERNS` / `SECRET_PATH_PATTERNS` and on the\n  markdown that documents them: a deny-list that names what it refuses is a control,\n  not a credential read. Firing on the prose of a fix is a category error.\n- **\"Autonomous Decision Making\"** in `auto_archive.py` / `consolidate_advisor.py`:\n  the scanner quotes the `if not sys.stdin.isatty(): return` guard as though it forced\n  the action. It is the human-in-the-loop branch.\n- Full dispositions: `docs/SECURITY-AUDIT-NOTES.md` §2.6.\n\n### Files Modified (4)\n`trace_extractor.py`, `README.md`, `SKILL.md`, `CHANGELOG.md`, plus\n`docs/SECURITY-AUDIT-NOTES.md`\n\n## v2.2.1 — Disclose the LLM Transport, Harden PII Scrubbing (2026-10-02)\n\nCloses the T09 finding raised by the ClawHub / SkillSpector scan on 2026-10-02:\n**\"Undisclosed Cloud Transmission of Memory and Session Content\"** in\n`trace_ex\n\nFile v4.0.5:CONTRIBUTING.md\n\n# Contributing — OpenClaw Memory Toolkit\n\nThis file is for **maintainers of this repository**. It documents how a change\ntravels from the repo to the published artifact, and the invariants the release\ngate enforces. If you only want to *use* the skill, read the [README](README.md)\ninstead.\n\n## Release Pipeline (repo → skill → GitHub → ClawHub)\n\n> **MUST — every memory-skill change goes through `scripts/release.sh`.**\n> No exception. A change to `skills/memory-health/**` is not \"done\" until\n> `scripts/release.sh check` passes green. Do not tag, do not push a release,\n> do not hand anything to ClawHub before that. If the gate fails, fix the drift\n> or the invariant — never bypass the gate.\n\nOne artifact, one direction, four stages. **Never edit the installed skill directly;\nnever let the repo and the skill drift.**\n\n```\n  local repo          installed skill            GitHub            ClawHub\n  .work/mh-v213  ──▶  skills/memory-health/  ──▶  push main + tag ──▶  manual (Stéphane)\n```\n\n1. **Edit in the repo** (`.work/mh-v213`). Commit there.\n2. **Sync repo → skill.** The installed skill is what the agent and the nightly\n   cron actually load, so it must be updated from the repo, never the reverse.\n   `SKILL.md` is the one exception: the skill copy carries a YAML frontmatter\n   (`name:` / `description:`) that the repo omits, so its **body** is synced while\n   the frontmatter is preserved.\n3. **Run the gate:** `scripts/release.sh check`. It refuses to pass until\n   repo↔skill files are byte-identical (SKILL body), `trace_extractor` is a single\n   source, version markers and OLLAMA_* loopback invariants hold, every script\n   parses, the loopback test passes and the tree is clean.\n4. **Tag + push:** `scripts/release.sh release vX.Y.Z \"message\"`, then create the\n   GitHub release.\n\n> **MUST — a git tag is NOT a release. Create the GitHub Release too.** The\n> operator had to point this out on 2026-10-05: v3.2.1 had its tag pushed but\n> appeared nowhere in the repo's Releases tab. The tag is a bare pointer; the\n> Release is the readable, dated entry a human actually sees. After pushing the\n> tag, always run:\n> ```bash\n> gh release create vX.Y.Z \\\n>   --title \"vX.Y.Z — <title>\" \\\n>   --notes \"<same content as the CHANGELOG entry>\"\n> ```\n> Verify with `gh release list` — the new version must appear as `Latest`. A\n> release is not \"done\" until it is in that list.\n\n5. **ClawHub** is published by the operator, from the repo.\n\n> **MUST — every release updates the CHANGELOG *and* the README.** The operator\n> asked for this explicitly (2026-10-05): a release is not just a tag. Before\n> tagging, always:\n> - **CHANGELOG.md** — add a new entry at the top: `## vX.Y.Z — <title> (<date>)`\n>   with `### Added` / `### Changed` / `### Fixed` sections as applicable. Cite\n>   the cause, not just the change (what broke, why, how it was found). Never\n>   rewrite history: correct an older entry only to fix a factual error.\n> - **README.md** — update anything the release makes stale: version numbers,\n>   recommended models, new scripts, changed flags, requirements.\n> - **SKILL.md** — bump the `current release vX.Y.Z` marker so the gate's\n>   version-markers check passes.\n>\n> Skipping either document leaves GitHub showing a release nobody can read.\n> This is part of \"done\", exactly like the green gate.\n\n## Known single-source exception\n\n`trace_extractor.py` exists in two places: the repo and\n`skills/trace-extractor/trace-extractor.py`. The skill copy is the **live** one\n(the cron uses it). The gate compares them and warns on divergence; port the live\ncopy into the repo before tagging so they converge forward.\n\n## Public repo — no personal or health data, ever\n\nThe repository is **public**. Anything committed to it is world-readable, and a git\nhistory rewrite is expensive and imperfect (forks, caches, GitHub-side copies).\nTwo mistakes were made and repaired on 2026-10-05; do not repeat them.\n\n1. **Never write personal data into a published file — especially health data.**\n   A fix to an adjacent skill mentioned a real medication name in `CHANGELOG.md`.\n   That is health data in a public repo. Describe the *behaviour* (`defaults to a\n   non-existent identifier`), never the identifier. The same goes for hostnames,\n   tokens, account ids, and anything else that identifies a person or a system.\n   Ask: *\"does this belong in this repo, and does it expose anything private?\"*\n   before writing, not after.\n2. **Stay in scope.** This repo documents the memory pipeline. A fix to an\n   unrelated skill does not belong in its CHANGELOG, name or no name. Document\n   what the repo *is*, not everything you happened to touch that day.\n\nIf a leak does happen: scrub the working tree, rewrite history\n(`git filter-repo --replace-text`), delete and recreate the affected tags and\nreleases, force-push, and **verify every one of those steps from the remote** —\nnot from memory.\n\n## Why the gate is non-negotiable\n\nThe repo and the installed skill are two copies of the same artifact. The moment\nthey drift, the cron runs one version while GitHub shows another, and nothing\ntells you which is real. The gate exists to make drift impossible to ship\nsilently. A skipped gate is a defect waiting for a quiet month to surface.\n\nFile v4.0.5:docs/_ARCHIVED-CHANGELOG-UNRELEASED-V3.6.1.md\n\n# Changelog — OpenClaw Memory Toolkit\n\n## v3.6.1 → v3.2.1 — Consolidated release notes (2026-10-05 → 2026-10-06)\n\n> Bloc fusionné pour la publication ClawHub, du `v3.2.1` à la dernière version `v3.6.1`.\n> Ordre anti-chronologique (le plus récent en tête), comme dans le CHANGELOG officiel.\n\n---\n\n## v3.6.1 — Arbiter follows the operator's real default (2026-10-06)\n\nFollow-up to v3.6.0. That release restored conflict arbitration but still listed\n`glm-5.2:cloud` first in `PREFERRED_MODELS` — a name inherited from the v2.2.0\nhard-code, not a deliberate choice. It is installed on the daemon (so the fix\nworked), but it is not the model this deployment actually runs on.\n\n### Changed\n- **`PREFERRED_MODELS` now follows the operator's real default**, `deepseek-v4-pro:cloud`\n  (the configured `agents.defaults.compaction.model`), ahead of\n  `deepseek-v4.1-flash:cloud`, `glm-5.2:cloud` and the offline `qwen2.5:7b`. Both\n  `conflict_resolver` and `consolidate_advisor` are aligned.\n- An explicit `CONFLICT_LLM_MODEL` / `TRACE_LLM_MODEL` / `OLLAMA_MODEL` still\n  overrides the list, so pinning a model stays a visible, one-line decision.\n\n### Verified\n- `deepseek-v4-pro:cloud` returns `CONTRADICTION` (confidence 0.85) with a correct\n  rationale on the same backup-broken/repaired probe that v3.6.0 used — the model\n  swap does not weaken the arbiter.\n- Both modules resolve to `deepseek-v4-pro:cloud` on this host.\n\n## v3.6.0 — Conflict arbitration was dead on arrival (2026-10-06)\n\nFix release. Closes the finding that the `superseded` / `disputed` lifecycle paths\nhad **never executed once**. The cause was not the subject fidelity work of v3.5.0\nnor the source echo guard: it was a missing model tag.\n\n### Fixed\n- **The arbiter asked Ollama for a model that does not exist.** `conflict_resolver`\n  sent the bare name `glm-5.2`, while the daemon serves `glm-5.2:cloud`. Ollama\n  answered **HTTP 404**, `classify_relation()` fell through to the conservative\n  heuristic, and *every* fact came back `COMPATIBLE / no confident relation detected`.\n  Conflict arbitration had therefore never fired: `superseded` and `disputed` were\n  unreachable code paths. Verified against a real contradiction (backup broken →\n  repaired): the LLM now returns `CONTRADICTION` with a correct rationale, and\n  `--apply` writes `status=superseded` + the `superseded_by` link.\n- **`consolidate_advisor.py` carried the identical defect** (`OLLAMA_MODEL` default\n  `\"glm-5.2\"`), so the advisor was silently producing nothing for the same reason.\n  Both modules now resolve their model the same way.\n\n### Changed\n- **Model resolution is no longer hard-coded.** A hard-coded name — even one with\n  the correct tag — rots on the next model swap. Both modules now resolve against\n  the models the daemon actually serves (`GET /api/tags`): an explicit\n  `CONFLICT_LLM_MODEL` / `TRACE_LLM_MODEL` / `OLLAMA_MODEL` still wins, otherwise\n  the first served model from a preference list is used, and an unreachable daemon\n  falls back to a tagged preference instead of crashing or sending a bare name.\n- **New regression test `hybrid-search/test_model_resolution.py`** pins the three\n  behaviours (explicit env wins / resolves to a served model / offline fallback is\n  tagged). Wired into `scripts/sync-skill.sh` and the `release.sh check` gate.\n- **Source filter (M4 follow-up):** trivially short user turns (`go`, `ok`, `top`, …)\n  are skipped by the trace extractor instead of being mined for durable facts — the\n  76-turn dry-run contained 18% such turns feeding ~80% operational noise.\n\n### Verified\n- Real contradiction on a DB copy: `[contradiction] Le backup nightly est repare`\n  → `--apply` → `id=5134 status=superseded superseded_by=5135`.\n- Non-contradictions still classified `COMPATIBLE` (Kavita `0.9.0 → 0.9.1.4`; Dovato\n  morning vs evening \"plus le matin\") — the arbiter discriminates, it does not cry\n  conflict.\n- `test_model_resolution.py`: 3/3 hold. `test_loopback_guard.py`: all guards hold\n  (run under the skill venv, which has `sqlite_vec`).\n\n## v3.5.0 — Subject fidelity (M4) + source echo guard (2026-10-06)\n\nFix release. Closes the M4 audit finding — the one that made conflict arbitration\nstructurally unable to fire. Three independent defects, one shared root: facts\nreached the ledger with a subject that did not discriminate, so the resolver's\n\"is this the same entity?\" test could never return a confident match.\n\n### Fixed\n- **Multi-word subjects were truncated to their first word** (`auto_capture._normalise_facts`,\n  mirrored in the new `trace_extractor._norm_subject`). `'\\w'` includes `_`, so\n  `re.split(r\"[^\\w]+\", \"kavita_home\")` yielded a SINGLE token, grounding failed,\n  and the fallback took the fact's first substantial word. `kavita_home` became\n  `kavita`, `backup_cron` became `backup` — so `kavita_home` and `kavita_index`\n  (different entities) collided under one key. Grounding now compares against the\n  tokenised text and splits on underscore too.\n- **`trace_extractor.py` produced no subject at all.** Its prompt never requested\n  one and its writer never emitted one, so every trace item reached the ledger as\n  `subject=NULL`. The prompt now requires a grounded `subject` per item, and the\n  writer emits it as `[subject:key]` on the note line for the downstream indexer.\n- **Machine-generated assistant turns were mined for user facts** (echo guard).\n  The only gate was `should_capture(user_msg)`; an assistant turn carrying a tool\n  completion (`... executed from ...`) or a cron report (`Summary: {'added': 0, ...}`)\n  was handed to the extractor and returned as a \"durable fact\". `assistant_turn_is_echo()`\n  now drops such turns — the user half is kept, the machine half is discarded.\n\n### Verified\n- Echo guard: 8/8 cases (tool logs, cron summaries, `=== END ===` markers gated;\n  real conversational turns kept).\n- Subject normalisation: `kavita_home`/`backup_cron`/`ubuntu_24_04` keep their full\n  key; v3.3 anti-hallucination behaviour (`Serveur Prod` → `serveur`) preserved.\n\n### Notes\n- `--apply` remains gated behind a real dialogued conflict batch: the\n  `superseded`/`disputed` paths are still unproven on live data.\n\n## v3.4.0 — Point-in-Time Retrieval (`--as-of`) (2026-10-05)\n\nFeature release. Turns the fact-lifecycle ledger into a time machine: the search\ncan now reconstruct the exact cognitive state the agent had **on a past date**,\nnot just what it believes today. In PLM terms, this is the step from a single\n\"As-Maintained\" configuration to a retrievable \"As-Built\" baseline. Born from\nthe operator's observation (2026-10-05) that the v3.3 ledger — which marks rows\n`superseded` instead of deleting them — already held the history; what was\nmissing was a timestamp for *when* a fact stopped being current.\n\n### Added\n- **`superseded_at` column** (`schema.sql`). The ISO timestamp at which a row\n  STOPPED being active (`NULL` while it is). This is the axis the earlier schema\n  lacked: `valid_from` records when the fact became *true in the world*, while\n  `superseded_at` records the *lifecycle of the row* — and `updated_at` cannot\n  serve, because a REDUNDANT confirmation rewrites it without ending anything.\n  Without a dedicated column, point-in-time retrieval is impossible to express\n  correctly.\n- **`--as-of YYYY-MM-DD`** on `query`, `search` and `context`. Reconstructs the\n  facts visible on that date instead of the current set. A bare date means *end\n  of that day* (`2026-07-01` → `T23:59:59`), so a row created at 10:00 on that\n  day is visible; a full timestamp is used verbatim. Verified against a\n  MySQL→PostgreSQL switch: the pre-switch fact is returned for a date before it\n  and the successor for a date after, with no overlap.\n- **`_as_of_clause()`** — one helper builds the visibility predicate shared by\n  the lexical and vector paths, so both halves of the hybrid search agree on\n  what \"visible on date D\" means.\n\n### Changed\n- **`ensure_lifecycle_columns()`** now also adds `superseded_at` (and its index)\n  to an older DB, so the migration is idempotent for existing installations.\n- **`apply_resolution()` and `resolve`/`--confirm`/`--reject`** stamp\n  `superseded_at` at each `active → superseded` transition, inside the existing\n  atomic transaction (C2).\n- **`search_lexical()` / `search_vector()` / `search_hybrid()`** take an optional\n  `as_of`; default (`None`) behaviour is byte-for-byte the previous \"active only\"\n  query — verified by a regression run against the live database.\n\n### Data maintenance (real database, 2026-10-05)\n- **3 475 `superseded` rows backfilled** with\n  `superseded_at = COALESCE(updated_at, created_at)`. Backup taken and\n  `integrity_check` verified before the write; post-run `foreign_key_check`\n  clean; zero active rows carry a `superseded_at`.\n\n## v3.3.0 — Referential Integrity, Transactional Writes, Ontology→DB Sync (2026-10-05)\n\nHardening release. An audit of the fact-lifecycle layer found that the schema\n*declared* a state machine (status, confidence, superseded_by) the engine never\nenforced, that the write paths were not atomic, and that subject arbitration was\ndead in practice (100 % of indexed facts had `subject = NULL`). Every defect is\nfixed at the source and verified against the real database.\n\n### Fixed\n- **Schema now enforces what it declared (C1).** `status` carries a `CHECK\n  (status IN ('active','superseded','disputed'))`, `confidence` a range `CHECK`,\n  and `superseded_by` a `FOREIGN KEY … ON DELETE SET NULL`.\n- **`conflict_resolver.apply_resolution()` is atomic (C2).** The whole resolution\n  is now one `BEGIN IMMEDIATE` … `COMMIT` with rollback.\n- **Concurrent access is safe (C3).** Both connection paths now set\n  `busy_timeout=5000`, `journal_mode=WAL` and `foreign_keys=ON`.\n- **`add_memory()` writes the hot row and its vector atomically (C4).**\n- **`compact.py` never archives a still-referenced fact (M2).**\n- **`ontology_compact.py` drops orphan relations (M3).**\n\n### Added\n- **`subject` is finally writable and populated (M4).** Arbitrable fact categories\n  now sit at **100 % subject coverage**.\n- **Ontology → DB synchronisation.** Every entity that leaves the reference\n  nomenclature has its facts marked `superseded` in the same run.\n- **`migrate_subject.py` / `migrate_ontology_subjects.py`** — one-shot, idempotent\n  backfill/audit tools.\n\n## v3.2.1 — Ontology Reindex, Number-Safe Splitting, Local Model Bump (2026-10-05)\n\nMaintenance release. Two defects found during the first live Auto-Capture\nsession, both fixed and verified against the real database.\n\n### Fixed\n- **Nested-schema ontology indexing.** `index_jsonl_file()` read `name`/`type` at\n  the JSON root, but ontology lines are nested — every graph node was indexed as\n  the literal string `\" ()\"`: **2 786 junk rows, 69 % of the database**.\n- **Number-safe punctuation split.** `extract_atomic()` split on every period:\n  `Ubuntu 24.04` became `Ubuntu 24` + `04`. A period now splits only when not\n  sandwiched between digits (`(?<![0-9])\\.(?![0-9])`).\n\n### Changed\n- **Default extraction model: `qwen2.5:3b` → `qwen2.5:7b`** (still local, loopback-only).\n- **Database maintenance:** 2 786 empty ontology rows moved to `superseded`\n  (reversible) and the 898 clean nodes re-indexed.\n\n## v3.2.0 — Auto-Capture: Session Dialogue → Arbitrated Facts (2026-10-04)\n\nFeature release. Adds the write-behind half of the autonomous memory loop: a\npost-turn pipeline that reads session dialogue, extracts atomic durable facts\nwith a local LLM, and arbitrates them against existing memory.\n\n### Added\n- **`hybrid-search/auto_capture.py`** — post-turn fact extraction.\n- **`hybrid-search/transcript_adapter.py`** — reads the OpenClaw per-agent session\n  store (`session_transcript_fts`), snapshot-first, read-only, secret-redacting.\n- **`--no-split`** flag on `conflict_resolver.py` `check`/`arbitrate`.\n\nFile v4.0.5:docs/AUDIT-v3.2.md\n\n# Audit impitoyable — openclaw-memory-toolkit v3.2\n\n**Date :** 2026-10-05\n**Périmètre :** `SKILL.md`, `schema.sql`, `conflict_resolver.py`, `hybrid_search.py`,\n`compact.py`, `ontology_compact.py`.\n**Base auditée :** `agent_memory.db` — 4 937 faits (2 151 `active`, 2 786 `superseded`),\n`PRAGMA integrity_check` = `ok`.\n**Méthode :** analyse statique des 6 fichiers + inspection live de la DB (PRAGMA,\ncomptages d'intégrité, états impossibles).\n\n> Objectif : briser le cycle de patchs mineurs réactifs avant d'envisager une v3.3,\n> en débusquant les failles **structurelles** plutôt que les symptômes.\n\n---\n\n## 🔴 CRITIQUE (crashs, corruption DB, fuite XML)\n\n### C1 — Aucune contrainte moteur : ni `FOREIGN KEY`, ni `CHECK`\n`schema.sql` déclare `status TEXT DEFAULT 'active'`, `confidence REAL DEFAULT 1.0`,\n`superseded_by INTEGER DEFAULT NULL` — **sans aucune contrainte**. En SQLite les clés\nétrangères sont désactivées par défaut (`PRAGMA foreign_keys` renvoie **0** sur la DB\nlive). Conséquence : rien, au niveau du moteur, n'empêche `status='banane'`,\n`confidence=5`, ou `superseded_by=999999`. **Toute l'intégrité de la machine à états\nrepose sur le code Python.**\n\n**Fix v3.3 :**\n```sql\nstatus TEXT DEFAULT 'active'\n    CHECK (status IN ('active','superseded','disputed')),\nconfidence REAL DEFAULT 1.0\n    CHECK (confidence IS NULL OR confidence BETWEEN 0.0 AND 1.0),\nsuperseded_by INTEGER DEFAULT NULL\n    REFERENCES memories(id)\n```\n+ `PRAGMA foreign_keys = ON` dans chaque `connect()`.\n\n### C2 — `apply_resolution()` : pas de transaction, pas de rollback\n`conflict_resolver.apply_resolution()` exécute des `UPDATE`/`INSERT` nus, puis un unique\n`conn.commit()` final. Si l'`_insert_fact()` échoue **après** le\n`UPDATE ... status='superseded', superseded_by=?`, l'ancien fait devient *superseded\nsans successeur* — perte de fait silencieuse. Même schéma dans `cmd_resolve()`\n(série de `UPDATE` sans `BEGIN`).\n\n**Fix :** `BEGIN IMMEDIATE` … `try/except → conn.rollback()` autour de chaque unité de\nmutation. Un supersede + son successeur sont **atomiques** ou ne se produisent pas.\n\n### C3 — Pas de `busy_timeout` à la connexion → `da...","readmeExcerpt":"Skill: Openclaw Memory Toolkit Owner: mistermijarvis Summary: OpenClaw Memory Toolkit is a memory layer for AI agents. It remembers what matters across sessions: it extracts durable facts from your conversations, resolves contradictions instead of hoarding them, and lets you ask what it knew on any past date. Tags: latest:4.0.5 Version history: v4.0.5 | 2026-10-11T10:59:04.723Z | user - Added three new ontology maint","codeSnippets":[],"executableExamples":[{"language":"text","snippet":"Nightly Cron (23h)\n  │\n  ├─ 1. trace_extractor.py    # Extract decisions/errors/facts from sessions\n  ├─ 2. auto_archive.py       # Archive daily notes >21 days\n  ├─ 3. scoring.py            # Score all memories with temporal decay\n  ├─ 4. consolidate_advisor.py # Suggest consolidations (agent reviews)\n  ├─ 5. conflict_resolver.py   # Arbitrate contradictory facts (NLI lifecycle)\n  ├─ 6. memory_health.py       # Periodic health check (weekly)\n  └─ 7. hybrid_search.py      # Hybrid search: FTS5 + sqlite-vec + RRF"},{"language":"bash","snippet":"# Nightly (pattern-based, fast ~5s)\npython3 scripts/trace_extractor.py --days 1\n\n# Deep extraction (LLM-powered, ~60-180s)\npython3 scripts/trace_extractor.py --days 3 --llm\n\n# With session transcripts\npython3 scripts/trace_extractor.py --days 1 --llm --session-file /path/to/session.jsonl\n\n# Preview only\npython3 scripts/trace_extractor.py --days 1 --llm --dry-run"},{"language":"bash","snippet":"python3 scripts/auto_archive.py                 # Archive notes > 21 days\npython3 scripts/auto_archive.py --days 30       # Custom threshold\npython3 scripts/auto_archive.py --dry-run       # Preview only\npython3 scripts/auto_archive.py --verbose       # Show each file"},{"language":"bash","snippet":"python3 scripts/scoring.py                      # Score all memories\npython3 scripts/scoring.py --verbose            # Show top 20\npython3 scripts/scoring.py --threshold 0.3      # Filter by min score\npython3 scripts/scoring.py --dry-run            # Don't write output"},{"language":"text","snippet":"score = weight_category × recency_decay × frequency_boost × entity_boost × completion_penalty\n\nrecency_decay = exp(-ln(2) × days_old / HALF_LIFE_DAYS)"},{"language":"bash","snippet":"python3 scripts/consolidate_advisor.py                     # Last 7 days\npython3 scripts/consolidate_advisor.py --days 14           # Custom window\npython3 scripts/consolidate_advisor.py --verbose           # All suggestions\npython3 scripts/consolidate_advisor.py --no-llm            # Skip LLM (fallback)\npython3 scripts/consolidate_advisor.py --apply-promotions  # Write to MEMORY.md"}],"parameters":null,"dependencies":[],"permissions":[],"extractedFiles":[{"path":"SKILL.md","content":"---\nname: memory-health\ndescription: Complete memory management pipeline for OpenClaw agents — extraction, archiving, scoring, consolidation, health monitoring, hygiene, and ontology. Local by default (Ollama over local HTTP). Use for memory health checks, hybrid search, fact arbitration, cold-storage compaction, and memory pipeline maintenance.\n---\n\n# Memory Pipeline Skill\n\nComplete memory management pipeline for OpenClaw agents: extraction, archiving,\nscoring, consolidation, health monitoring, and ontology — local by default\n(Ollama runs locally via HTTP).\n\n⚠️ **One opt-in exception**: `trace_extractor.py` can send memory/session excerpts\nto **Ollama cloud** (`https://ollama.com`) when `OLLAMA_API_KEY` is configured.\nWith no key it stays local; `TRACE_LLM_LOCAL_ONLY=1` refuses every cloud call. The\ndestination is printed before each send. See the Security Notes below.\n\n> **⛔ MUST — releasing this skill.** Every change to this skill goes through\n> `scripts/release.sh`, without exception. A change is **not done** until\n> `scripts/release.sh check` passes green. Then, and only then, sync to the\n> installed skill and tag via `scripts/release.sh release vX.Y.Z \"msg\"`.\n> Never edit the installed skill directly. Never tag or publish on a red gate —\n> fix the drift or the invariant, never bypass the gate.\n> Pipeline: **local repo → installed skill → GitHub → ClawHub (manual).**\n\n## Pipeline Overview\n\n```\nNightly Cron (23h)\n  │\n  ├─ 1. trace_extractor.py    # Extract decisions/errors/facts from sessions\n  ├─ 2. auto_archive.py       # Archive daily notes >21 days\n  ├─ 3. scoring.py            # Score all memories with temporal decay\n  ├─ 4. consolidate_advisor.py # Suggest consolidations (agent reviews)\n  ├─ 5. conflict_resolver.py   # Arbitrate contradictory facts (NLI lifecycle)\n  ├─ 6. memory_health.py       # Periodic health check (weekly)\n  └─ 7. hybrid_search.py      # Hybrid search: FTS5 + sqlite-vec + RRF\n```\n\nAll scripts are standalone and composable. Run individually or as a pipeline.\n\n## Fact lifecycle (introduced in v3.0.0) — current release v4.0.5\n\nThe search DB no longer just accumulates facts: every fact carries a lifecycle\n(`active` / `superseded` / `disputed`) and only `active` facts are ever\nretrieved. `conflict_resolver.py` runs the four-step consistency pipeline:\natomic extraction → targeted retrieval of concurrent active facts → NLI\nclassification (`CONTRADICTION` / `REDUNDANT` / `COMPATIBLE`) → traceable state\nupdate. A weak contradiction is escalated to `disputed` rather than silently\ndestroying an established fact; `pending` surfaces the queue and `resolve\n--confirm|--reject` lifts the ambiguity. `compact.py` moves terminal facts into a\ncold archive (`memories_archive` + JSONL audit) so the hot FTS5/vector indexes\nstay lean without losing traceability.\n\n## Scripts\n\n### 1. `trace_extractor.py` — Session extraction\n\nExtracts decisions, errors, facts, and patterns from OpenClaw session\ntranscripts and daily notes. Updates daily note"},{"path":"README.md","content":"# 🧠 OpenClaw Memory Pipeline\n\n**Complete memory management pipeline for OpenClaw agents: extraction, archiving,\nscoring, consolidation, health monitoring, and hybrid search — local by default.**\n\nSeven standalone Python scripts that form a complete memory lifecycle pipeline for\n[OpenClaw](https://github.com/openclaw/openclaw) agents. Everything runs on this\nmachine out of the box: Ollama over local HTTP, no cloud account, no paid dependency\n— works with any local LLM (Ollama, LM Studio, etc.) or fully without LLM in fallback\nmode.\n\n> **One exception, and it is opt-in:** `trace_extractor.py` can use **Ollama cloud**\n> (`https://ollama.com`) when an API key is configured, because that content leaves\n> the machine. With **no key configured it stays local**, and\n> **`TRACE_LLM_LOCAL_ONLY=1` refuses every cloud call outright**. Every run prints its\n> destination first (`[Security] ⚠️ CLOUD TRANSMISSION: …`). See\n> [Security Notes](#security-notes).\n\nBuilt for local-first OpenClaw setups (Ollama/GLM, nomic-embed-text).\n\n## Pipeline\n\n```\nNightly Cron (23h)\n  │\n  ├─ 1. trace_extractor.py     # Extract decisions/errors/facts from sessions\n  ├─ 2. auto_archive.py        # Archive daily notes >21 days\n  ├─ 3. scoring.py             # Score all memories with temporal decay\n  ├─ 4. consolidate_advisor.py # Suggest consolidations (agent reviews)\n  ├─ 5. conflict_resolver.py   # Arbitrate contradictory facts (NLI lifecycle)\n  ├─ 6. memory_health.py       # Periodic health check (weekly)\n  └─ 7. ontology_compact.py    # GC the ontology op-log (weekly)\n```\n\nAll scripts are standalone and composable. Run individually or as a pipeline.\n\n## Contributing\n\nThis README is written for **users**. If you are **maintaining this repository**\n(working on the release pipeline, the gate, or the repo↔skill sync), read\n[CONTRIBUTING.md](CONTRIBUTING.md) instead — it documents the full\nrepo → skill → GitHub → ClawHub flow and the invariants the release gate enforces.\n\n> **MUST — every memory-skill change goes through `scripts/release.sh`.**\n> A change to `skills/memory-health/**` is not \"done\" until\n> `scripts/release.sh check` passes green. Never edit the installed skill\n> directly; never let the repo and the skill drift. Full detail in\n> [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Scripts\n\n### 1. `trace_extractor.py` — Session extraction\n\nExtracts decisions, errors, facts, and patterns from OpenClaw session transcripts\nand daily notes. Updates daily notes, appends entities to the ontology graph.\n\n```bash\n# Nightly (pattern-based, fast ~5s)\npython3 scripts/trace_extractor.py --days 1\n\n# Deep extraction (LLM-powered, ~60-180s)\npython3 scripts/trace_extractor.py --days 3 --llm\n\n# With a specific session transcript file (opt-in, explicit)\npython3 scripts/trace_extractor.py --days 1 --llm --session-file /path/to/session.jsonl\n\n# Preview only\npython3 scripts/trace_extractor.py --days 1 --llm --dry-run\n```\n\n**Categories:** 🟢 DECISIONS, 🔴 ERRORS, 🔵 FACTS, ⬆️ PROMOTIONS\n\n### 2. `auto_ar"},{"path":"_meta.json","content":"{\n  \"ownerId\": \"kn7d3dpn3e5ys39cpa1y06kvq18cp3rk\",\n  \"slug\": \"memory-toolkit\",\n  \"version\": \"4.0.5\",\n  \"publishedAt\": 1791716344723\n}"},{"path":"CHANGELOG.md","content":"# Changelog — OpenClaw Memory Toolkit\n\nAll notable changes to the OpenClaw Memory Toolkit skill.\n\n## v4.0.5 — Dates are validated before they reach the graph (2026-10-11)\n\n### Real fix\n\n**The extraction pipeline trusted the LLM's date and folded it into ids.**\n`stable_id('tl', what, today)` concatenated the string blindly, and the\ndecision write took `dec.get('date')` verbatim. A model asked for `YYYY-MM-DD`\nstill returns `2026-05-31-0913` (date + time), `2026-05-26-roadmap-updates`\n(date + free text) or `2026-04` (month only).\n\nBlind concatenation produced the unmatchable nodes `day_202605310913` /\n`day_20260526roadmapupdates` / `day_202604` — targets no edge can ever match.\nThat is the **dangling-target** defect the 10/10 backfill had to repair by hand\n(158 edge targets pointing at nodes that did not exist). It is now fixed at\nthe source, so it cannot recur.\n\n### Change\n\n`normalize_date()` is the single gate every date passes before it is written\nor folded into an id:\n\n- **Parse first, build second.** A real calendar date is required —\n  `datetime()` rejects `2026-13-01` and `2026-02-30`; a regex alone would not.\n- A leading `YYYY-MM-DD` in a longer string is kept\n  (`2026-05-31-0913` → `2026-05-31`), the trailing noise is dropped.\n- Anything else falls back — the honest answer for \"undated decision\" is today,\n  not a fabricated date.\n\nIt gates both the id builder (`stable_id`) and the decision-date write. 12 unit\ncases cover the boundary.\n\n### Note on the source of truth\n\n`trace_extractor.py` is the one skill file whose **live copy leads**: the fix\nwas written against the running skill then ported live → repo. `release.sh`\nsection 2 asserts the two copies are identical.\n\n## v4.0.4 — No decision enters the ontology as an orphan (2026-10-10)\n\n### Real fix\n\n**M6: every extracted decision now carries an edge.** The ontology held\n**889 orphan entities out of 928, with only 34 relations** — a list of\ndisconnected blocks, not a graph. Root cause: `trace_extractor` created\nDecision / TimelineEvent nodes with no relation at all.\n\nTwo changes, both at the source:\n\n- The extraction prompt now REQUIRES a `related_to` on every item — the id of an\n  existing entity it attaches to, grounded in the note text, never invented.\n  When nothing fits, the item anchors to the catch-all root `daily_notes`.\n- `write_ontology_entities()` appends a `relate` fact for every decision it\n  writes, resolving the root via `_norm_related_to()` (grounded anchors only)\n  then `_root_id_for()`. An invented anchor is refused, not wired.\n\nNothing is deleted: the graph stays append-only, and every auto-link is\nreversible.\n\n### New guard\n\n`hybrid-search/test_orphan_link.py`, wired into `scripts/release.sh`. It asserts\nboth directions: a grounded anchor links, an ungrounded decision still links to\n`daily_notes`, and an INVENTED anchor is refused. A gate that accepted invented\nroots would silently wire the graph wrong.\n\n## v4.0.3 — MEMORY.md can no longer grow unbounded (2026-10-10)\n\n#"},{"path":"CONTRIBUTING.md","content":"# Contributing — OpenClaw Memory Toolkit\n\nThis file is for **maintainers of this repository**. It documents how a change\ntravels from the repo to the published artifact, and the invariants the release\ngate enforces. If you only want to *use* the skill, read the [README](README.md)\ninstead.\n\n## Release Pipeline (repo → skill → GitHub → ClawHub)\n\n> **MUST — every memory-skill change goes through `scripts/release.sh`.**\n> No exception. A change to `skills/memory-health/**` is not \"done\" until\n> `scripts/release.sh check` passes green. Do not tag, do not push a release,\n> do not hand anything to ClawHub before that. If the gate fails, fix the drift\n> or the invariant — never bypass the gate.\n\nOne artifact, one direction, four stages. **Never edit the installed skill directly;\nnever let the repo and the skill drift.**\n\n```\n  local repo          installed skill            GitHub            ClawHub\n  .work/mh-v213  ──▶  skills/memory-health/  ──▶  push main + tag ──▶  manual (Stéphane)\n```\n\n1. **Edit in the repo** (`.work/mh-v213`). Commit there.\n2. **Sync repo → skill.** The installed skill is what the agent and the nightly\n   cron actually load, so it must be updated from the repo, never the reverse.\n   `SKILL.md` is the one exception: the skill copy carries a YAML frontmatter\n   (`name:` / `description:`) that the repo omits, so its **body** is synced while\n   the frontmatter is preserved.\n3. **Run the gate:** `scripts/release.sh check`. It refuses to pass until\n   repo↔skill files are byte-identical (SKILL body), `trace_extractor` is a single\n   source, version markers and OLLAMA_* loopback invariants hold, every script\n   parses, the loopback test passes and the tree is clean.\n4. **Tag + push:** `scripts/release.sh release vX.Y.Z \"message\"`, then create the\n   GitHub release.\n\n> **MUST — a git tag is NOT a release. Create the GitHub Release too.** The\n> operator had to point this out on 2026-10-05: v3.2.1 had its tag pushed but\n> appeared nowhere in the repo's Releases tab. The tag is a bare pointer; the\n> Release is the readable, dated entry a human actually sees. After pushing the\n> tag, always run:\n> ```bash\n> gh release create vX.Y.Z \\\n>   --title \"vX.Y.Z — <title>\" \\\n>   --notes \"<same content as the CHANGELOG entry>\"\n> ```\n> Verify with `gh release list` — the new version must appear as `Latest`. A\n> release is not \"done\" until it is in that list.\n\n5. **ClawHub** is published by the operator, from the repo.\n\n> **MUST — every release updates the CHANGELOG *and* the README.** The operator\n> asked for this explicitly (2026-10-05): a release is not just a tag. Before\n> tagging, always:\n> - **CHANGELOG.md** — add a new entry at the top: `## vX.Y.Z — <title> (<date>)`\n>   with `### Added` / `### Changed` / `### Fixed` sections as applicable. Cite\n>   the cause, not just the change (what broke, why, how it was found). Never\n>   rewrite history: correct an older entry only to fix a factual error.\n> - **README.md** — update anything the release makes "}],"languages":[],"docsSourceLabel":"CLAWHUB","editorialOverview":null,"editorialQuality":{"score":100,"threshold":65,"status":"thin","wordCount":4564,"uniquenessScore":34,"reasons":["uniqueness-below-45"]}},"media":{"evidence":{"source":"no-media","verified":false,"confidence":"low","updatedAt":"2026-10-11T16:14:28.850Z","emptyReason":"No screenshots, media assets, or demo links are available."},"primaryImageUrl":null,"mediaAssetCount":0,"assets":[],"demoUrl":null},"ownerResources":{"evidence":{"source":"unclaimed","verified":false,"confidence":"low","updatedAt":"2026-10-11T16:14:28.850Z","emptyReason":"This page has not been claimed by the agent owner."},"hasCustomPage":false,"customPageUpdatedAt":null,"customLinks":[],"structuredLinks":{"docsUrl":null,"demoUrl":null,"supportUrl":null,"pricingUrl":null,"statusUrl":null},"customPage":null},"relatedAgents":{"evidence":{"source":"protocol-neighbors","verified":false,"confidence":"medium","updatedAt":"2026-10-11T20:56:36.546Z","emptyReason":null},"items":[{"id":"8ebccd8e-3863-4187-8355-c3f14e1f9edf","entityType":"agent","canonicalPath":"/agent/iofficeai-aionui","slug":"iofficeai-aionui","name":"AionUi","description":"Free, local, open-source 24/7 Cowork app and OpenClaw for Gemini CLI, Claude Code, Codex, OpenCode, Qwen Code, Goose CLI, Auggie, and more | 🌟 Star if you like it!","url":"https://github.com/iOfficeAI/AionUi","homepage":"https://www.aionui.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-10-09T19:11:12.944Z","createdAt":"2026-02-25T03:38:16.584Z","downloads":null},{"id":"b917f68a-ebff-438e-84f8-3f4b2494c0bc","entityType":"agent","canonicalPath":"/agent/activepieces-activepieces","slug":"activepieces-activepieces","name":"activepieces","description":"AI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents","url":"https://github.com/activepieces/activepieces","homepage":"https://www.activepieces.com","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-15T02:22:12.426Z","createdAt":"2026-02-25T03:38:12.412Z","downloads":null},{"id":"5cb26759-3a39-483f-94cf-276a98c13bb8","entityType":"agent","canonicalPath":"/agent/cherryhq-cherry-studio","slug":"cherryhq-cherry-studio","name":"cherry-studio","description":"AI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs","url":"https://github.com/CherryHQ/cherry-studio","homepage":"https://cherry-ai.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-11T14:38:40.986Z","createdAt":"2026-02-25T03:38:19.379Z","downloads":null},{"id":"6f6582d0-5d76-4f0f-b81d-86520247950b","entityType":"agent","canonicalPath":"/agent/copilotkit-copilotkit","slug":"copilotkit-copilotkit","name":"CopilotKit","description":"The Frontend for Agents & Generative UI. React + Angular","url":"https://github.com/CopilotKit/CopilotKit","homepage":"https://docs.copilotkit.ai","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-03-25T09:50:57.846Z","createdAt":"2026-02-25T03:39:14.617Z","downloads":null}],"links":{"hub":"/agent","source":"/agent/source/clawhub","protocols":[{"label":"OpenClaw","href":"/agent/protocol/openclew"}]}}}