#!/usr/bin/env python3 """ Distill conversation memory into wiki topic pages. Reads recent memory/YYYY-MM-DD.md files, extracts insights worth long-term preservation (decisions, workflows, principles), and either stages them for review or applies approved entries to wiki topics. Usage: python3 distill_memory_to_wiki.py --dry-run [--days 3] python3 distill_memory_to_wiki.py --stage [--days 2] python3 distill_memory_to_wiki.py --apply --staging-file wiki/_staging/2026-04-06-candidates.md """ from __future__ import annotations import argparse import json import os import re import sys from datetime import datetime, timedelta, timezone from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import xkb_paths WORKSPACE = xkb_paths.WORKSPACE _SKILL_DIR = Path(__file__).resolve().parent.parent WIKI_DIR = xkb_paths.WIKI_DIR # ── Unified LLM helper ──────────────────────────────────────────────────────── sys.path.insert(0, str(_SKILL_DIR / "scripts")) from _llm import call as _llm_backend TOPICS_DIR = WIKI_DIR / "topics" STAGING_DIR = WIKI_DIR / "_staging" INDEX_PATH = WIKI_DIR / "index.md" LOG_PATH = WIKI_DIR / "log.md" MEMORY_DIR = WORKSPACE / "memory" def load_env_key() -> str: return "" # auth handled by _llm.py via openclaw CLI def llm_call(system: str, user: str, api_key: str = "") -> str: raw = _llm_backend(system, user) return re.sub(r".*?", "", raw, flags=re.DOTALL).strip() def load_topic_slugs() -> list[str]: from xkb_review import topic_accepts_candidates return [p.stem for p in TOPICS_DIR.glob("*.md") if topic_accepts_candidates(p)] if TOPICS_DIR.exists() else [] def append_log(entry: str) -> None: LOG_PATH.parent.mkdir(parents=True, exist_ok=True) existing = LOG_PATH.read_text(encoding="utf-8") if LOG_PATH.exists() else "# Wiki Log\n---\n" LOG_PATH.write_text(existing.rstrip() + "\n" + entry + "\n", encoding="utf-8") def load_recent_memory(days: int) -> list[tuple[str, str]]: """Return [(date_str, content)] for the last N days of memory files.""" entries = [] today = datetime.now(timezone.utc).date() for i in range(days): d = today - timedelta(days=i) fname = MEMORY_DIR / f"{d}.md" if fname.exists(): entries.append((str(d), fname.read_text(encoding="utf-8"))) return entries L1_TRACE_DIR = xkb_paths.XKB_DATA_DIR / "runtime" / "l1-traces" def _trace_message_text(message: object) -> str: """Convert an L1 message into compact, distillable conversation text. L1 traces intentionally retain tool calls and metadata for provenance, but those are noise for the daily curator. Keep human-readable user/assistant text and skip tool-call payloads here; the original trace remains on disk. """ if not isinstance(message, dict): return "" role = str(message.get("role") or "").strip() if role not in {"user", "assistant"}: return "" content = message.get("content", "") if isinstance(content, str): text = content.strip() elif isinstance(content, list): parts = [] for item in content: if isinstance(item, dict) and item.get("type") == "text": value = str(item.get("text") or "").strip() if value: parts.append(value) text = "\n".join(parts) else: text = "" return f"{role}: {text}" if text else "" def load_recent_l1_traces(days: int) -> list[tuple[str, str]]: """Load the newest complete trace per session for the recent date window. agent_end traces are cumulative snapshots of a session, so loading every file would repeat the same conversation many times. The newest snapshot per episode is the canonical input; source files remain immutable evidence. """ if not L1_TRACE_DIR.exists(): return [] cutoff = datetime.now(timezone.utc) - timedelta(days=days) newest_by_episode: dict[str, tuple[datetime, Path, dict]] = {} for path in L1_TRACE_DIR.glob("*.json"): try: trace = json.loads(path.read_text(encoding="utf-8")) observed = datetime.fromisoformat(str(trace.get("observed_at", "")).replace("Z", "+00:00")) if observed < cutoff or trace.get("schema") != "xkb-l1-trace.v1": continue episode = str(trace.get("episode_id") or trace.get("session_id") or path.name) previous = newest_by_episode.get(episode) if previous is None or observed > previous[0]: newest_by_episode[episode] = (observed, path, trace) except (OSError, ValueError, json.JSONDecodeError, TypeError): continue entries: list[tuple[str, str]] = [] for observed, path, trace in sorted(newest_by_episode.values(), key=lambda item: item[0]): messages = trace.get("content", []) if not isinstance(messages, list): continue rendered = [line for message in messages if (line := _trace_message_text(message))] if not rendered: continue source_date = observed.date().isoformat() header = f"[L1 trace: {path.name}; episode: {trace.get('episode_id') or trace.get('session_id')}]" entries.append((source_date, header + "\n" + "\n".join(rendered))) return entries def load_recent_conversations(days: int) -> list[tuple[str, str]]: """Recent conversations, read from the store the hook actually writes to. One entry per session, rendered the way a transcript reads, because a single turn out of context rarely carries a decision — the reasoning is usually spread across the exchange either side of it. """ if not xkb_paths.SERVICE_DB.exists(): return [] import sqlite3 since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() try: with sqlite3.connect(f"file:{xkb_paths.SERVICE_DB}?mode=ro", uri=True) as db: rows = db.execute( """SELECT t.session_id, t.query, t.answer, t.completed_at FROM turns t WHERE t.completed_at > ? AND t.status = 'succeeded' ORDER BY t.session_id, t.completed_at""", (since,), ).fetchall() except sqlite3.Error as err: print(f"[WARN] could not read conversations: {err}") return [] sessions: dict[str, list[str]] = {} dates: dict[str, str] = {} for session_id, query, answer, completed_at in rows: lines = sessions.setdefault(session_id, []) if query: lines.append(f"user: {query.strip()}") if answer: lines.append(f"assistant: {answer.strip()}") dates.setdefault(session_id, (completed_at or "")[:10]) return [ (dates[session_id], "\n\n".join(lines)) for session_id, lines in sessions.items() if lines ] def load_recent_inputs(days: int) -> list[tuple[str, str]]: """Daily memory, plus the recent conversations, from wherever they are. The knowledge service is the live store. The trace directory is the older inbox — still read when there is no service on this host, because an external writer may be filling it. """ entries = load_recent_memory(days) conversations = load_recent_conversations(days) if conversations: print(f"Loaded {len(conversations)} recent conversation(s)") entries.extend(conversations) return entries traces = load_recent_l1_traces(days) if traces: print(f"Loaded {len(traces)} recent L1 trace snapshot(s)") entries.extend(traces) return entries EXTRACT_SYSTEM = """You are a wiki knowledge curator for an AI agent system. Extract insights worth long-term preservation from conversation memory logs. Inclusion criteria (ALL must be met): 1. Contains a technical decision with reasoning, reusable workflow/command/rule, or confirmed principle/conclusion 2. Adds real new dimension to existing knowledge (not just paraphrasing) 3. Useful 6 months from now (exclude time-sensitive news, cron logs, daily chores) Exclusion list: - Daily activity logs - Cron / heartbeat execution records - Unresolved discussions - Emotional reactions or temporary statements - Anything only meaningful today Output ONLY valid JSON, no markdown fences, no explanation.""" EXTRACT_USER_TMPL = """Memory file date: {date} Content: {content} Existing wiki topics: {topics} Extract wiki-worthy insights as JSON: {{ "insights": [ {{ "topic_slug": "most relevant existing topic slug (from list above), or null if none fits", "topic_suggestion": "if topic_slug is null, suggest new topic name in kebab-case", "section": "target section: 核心概念 / 做法-Workflow / 案例 / 矛盾與未解問題", "content": "synthesized insight (1-3 sentences, not a log entry)", "confidence": "high / medium / low", "source_date": "{date}" }} ] }} If nothing qualifies, output: {{"insights": []}}""" def load_todays_staged_content(date_str: str) -> str: """Return already-staged insight summaries for today (for dedup context).""" if not STAGING_DIR.exists(): return "" parts = [] for f in sorted(STAGING_DIR.glob(f"{date_str}-*-candidates.md")): # Extract just the content lines from each candidate block text = f.read_text(encoding="utf-8") # Grab lines after "Status:" lines (the actual insight text) in_content = False for line in text.splitlines(): if line.startswith("- **Status:**"): in_content = True continue if in_content: if line.startswith("- **") or line.startswith("---") or line.startswith("##"): in_content = False elif line.strip(): parts.append(line.strip()) return "\n".join(parts) def extract_conversation_content(content: str) -> str: """ Strip dreaming metadata from memory files, keeping only the actual conversation content lines (Candidate: User/Assistant) plus any non-dreaming plain text. Removes: confidence, evidence, recalls, status lines after each Candidate entry. """ lines = content.splitlines() result = [] skip_next_metadata = False for line in lines: stripped = line.strip() # Skip dreaming metadata lines that follow a Candidate entry if skip_next_metadata: if stripped.startswith("- confidence:") or \ stripped.startswith("- evidence:") or \ stripped.startswith("- recalls:") or \ stripped.startswith("- status:") or \ stripped.startswith("- note:"): continue else: skip_next_metadata = False # Keep Candidate lines but strip the "- Candidate: " prefix if stripped.startswith("- Candidate:"): text = stripped[len("- Candidate:"):].strip() if text: result.append(text) skip_next_metadata = True continue # Skip dreaming block markers and low-value headers if stripped.startswith("" section_header = f"## {section}" today = datetime.now(timezone.utc).strftime("%Y-%m-%d") if section_header in content: content = content.replace( section_header, f"{section_header}\n\n- {entry} *(self-derived · memory/{source_date}.md)*", 1, ) else: content = content.rstrip() + f"\n\n{section_header}\n\n- {entry} *(self-derived · memory/{source_date}.md)*\n" content = re.sub(r"(last_updated:\s*)\S+", f"\\g<1>{today}", content) topic_path.write_text(content, encoding="utf-8") print(f" [OK] {slug} / {section}") return True def main() -> None: parser = argparse.ArgumentParser(description="Distill memory into wiki") mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--dry-run", action="store_true") mode.add_argument("--stage", action="store_true") mode.add_argument("--apply", action="store_true") parser.add_argument("--days", type=int, default=2) parser.add_argument("--label", default="", help="Staging filename suffix to avoid overwriting (e.g. 'morning', 'evening')") parser.add_argument("--input", metavar="TEXT", help="Distill inline text instead of daily memory files") parser.add_argument("--input-file", metavar="PATH", help="Distill content from a file instead of daily memory files") parser.add_argument("--staging-file") parser.add_argument("--approve-all", action="store_true", help="Approve all candidates without checking [x] marks") parser.add_argument("--auto-apply-high", action="store_true", help="After --stage, automatically apply high-confidence candidates to wiki " "without manual review. Low/medium candidates remain in staging.") parser.add_argument("--no-llm", action="store_true") parser.add_argument("--verbose", action="store_true") args = parser.parse_args() if args.apply: if not args.staging_file: parser.error("--apply requires --staging-file") staging_path = Path(args.staging_file) applied, skipped, slugs = apply_staging_file( staging_path, approve_all=args.approve_all, auto_approve_high=getattr(args, 'auto_approve_high', False), ) print(f"\nApply results:") print(f" Applied : {applied}") print(f" Skipped : {skipped}") if slugs: print(f" Topics updated: {', '.join(slugs)}") if applied > 0: now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M") append_log( f"\n## [{now}] apply-staging | {applied} candidate(s) applied" f" from {staging_path.name} → {', '.join(slugs)}" ) print(f" Log updated.") return # Determine input source: --input TEXT, --input-file PATH, or daily memory files today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") if args.input: entries = [(today_str, args.input)] print(f"Input mode: inline text ({len(args.input)} chars)") elif args.input_file: input_path = Path(args.input_file) if not input_path.exists(): print(f"[ERROR] --input-file not found: {input_path}") return content = input_path.read_text(encoding="utf-8") entries = [(today_str, content)] print(f"Input mode: file {input_path.name} ({len(content)} chars)") else: entries = load_recent_inputs(args.days) if not entries: print(f"No memory files or L1 traces found for last {args.days} days.") return print(f"Loaded {len(entries)} input item(s): {[e[0] for e in entries]}") if args.no_llm: for date, content in entries: print(f"\n=== {date} ({len(content)} chars) ===") print(content[:400] + "..." if len(content) > 400 else content) return api_key = "" # auth handled by _llm.py via openclaw CLI # Load today's already-staged content for dedup (only for memory-based runs, not --input) already_staged = "" if not args.input and not args.input_file: already_staged = load_todays_staged_content(today_str) if already_staged: print(f"Dedup context: found existing staging for {today_str}, will skip duplicates") insights = extract_insights(entries, api_key, verbose=args.verbose, already_staged=already_staged) high = [i for i in insights if i.get("confidence") == "high"] print(f"\nInsights found: {len(insights)} ({len(high)} high-confidence)") if args.dry_run: for ins in insights: slug = ins.get("topic_slug") or f"[NEW: {ins.get('topic_suggestion')}]" print(f"\n [{ins.get('confidence','?')}] {slug} / {ins.get('section','?')}") print(f" {ins.get('content','')}") return if args.stage: today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") # Auto-label inline/file input so it doesn't collide with memory-based staging files label = args.label if not label and (args.input or args.input_file): label = "input" out = write_staging(insights, today_str, label=label) print(f"\nStaged to: {out}") now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M") source_desc = "inline input" if (args.input or args.input_file) else f"last {args.days}d" append_log(f"\n## [{now}] ingest-memory | staged {len(insights)} candidates from {source_desc} → {out.name}") # Auto-apply high-confidence candidates immediately after staging if getattr(args, 'auto_apply_high', False): high_count = sum(1 for ins in insights if ins.get("confidence") == "high") print(f"\nAuto-applying {high_count} high-confidence candidate(s)...") applied, skipped, slugs = apply_staging_file(out, auto_approve_high=True) print(f" Applied : {applied}") print(f" Skipped : {skipped} (medium/low — review manually)") if slugs: print(f" Topics updated: {', '.join(slugs)}") if applied > 0: append_log( f"\n## [{now}] auto-apply-high | {applied} high-confidence candidate(s) " f"applied from {out.name} → {', '.join(slugs)}" ) else: print(f"Run: python3 distill_memory_to_wiki.py --apply --staging-file {out}") import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) main()