scripts/distill_memory_to_wiki.py
Rama main · réplica de lectura del repositorio original.
#!/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"<think>.*?</think>", "", 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("<!-- openclaw:dreaming") or \
stripped in ("## Light Sleep", "## REM Sleep", "### Reflections", "### Possible Lasting Truths"):
continue
# Keep everything else (plain text sections, non-dreaming headers)
result.append(line)
return "\n".join(result).strip()
CHUNK_SIZE = 4000 # chars per LLM call
def extract_insights(entries: list[tuple[str, str]], api_key: str, verbose: bool = False,
already_staged: str = "") -> list[dict]:
existing_topics = load_topic_slugs()
topics_str = "\n".join(f"- {s}" for s in existing_topics) if existing_topics else "(no topics yet)"
all_insights = []
for date, content in entries:
cleaned = extract_conversation_content(content)
if verbose:
print(f" [LLM] scanning {date} ({len(content)} chars raw → {len(cleaned)} chars cleaned)...")
# Chunk into CHUNK_SIZE segments so the full file is always processed
chunks = [cleaned[i:i + CHUNK_SIZE] for i in range(0, max(len(cleaned), 1), CHUNK_SIZE)]
seen_contents: set[str] = set()
for chunk_i, chunk in enumerate(chunks):
if not chunk.strip():
continue
user_prompt = EXTRACT_USER_TMPL.format(date=date, content=chunk, topics=topics_str)
if already_staged:
user_prompt += f"\n\nAlready staged today (SKIP these, do not re-surface):\n{already_staged[:800]}"
try:
raw = llm_call(EXTRACT_SYSTEM, user_prompt, api_key)
raw = re.sub(r"^```(?:json)?\s*", "", raw).rstrip("` \n")
parsed = json.loads(raw)
insights = parsed.get("insights", [])
# Dedup by content to avoid the same insight surfacing across chunks
for ins in insights:
key = ins.get("content", "")[:120]
if key and key not in seen_contents:
seen_contents.add(key)
all_insights.append(ins)
if verbose:
print(f" [LLM] {date} chunk {chunk_i + 1}/{len(chunks)}: {len(insights)} insight(s)")
except Exception as e:
print(f" [WARN] LLM failed for {date} chunk {chunk_i + 1}: {e}")
return all_insights
def write_staging(insights: list[dict], date_str: str, label: str = "") -> Path:
"""Write staging file. label (e.g. 'morning', 'evening') prevents same-day overwrites."""
STAGING_DIR.mkdir(parents=True, exist_ok=True)
suffix = f"-{label}" if label else ""
out_path = STAGING_DIR / f"{date_str}{suffix}-candidates.md"
lines = [
f"# Memory Distillation Candidates — {date_str}",
"",
f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
f"Insights found: {len(insights)}",
"",
"---",
"",
"## Review Instructions",
"- Mark each item: `[x] approve` or leave `[ ] skip`",
"- Run: `python3 distill_memory_to_wiki.py --apply --staging-file <this file>`",
"",
]
existing_topics = set(load_topic_slugs())
for i, ins in enumerate(insights, 1):
proposed = str(ins.get("topic_slug") or ins.get("topic_suggestion") or "?").strip()
slug = proposed if proposed in existing_topics else f"[NEW: {proposed}]"
lines += [
f"## Candidate {i}",
f"- **Topic:** {slug}",
f"- **Section:** {ins.get('section', '核心概念')}",
f"- **Confidence:** {ins.get('confidence', 'medium')}",
f"- **Source date:** {ins.get('source_date', '')}",
f"- **Status:** [ ] approve [ ] skip",
"",
ins.get("content", ""),
"",
"---",
"",
]
out_path.write_text("\n".join(lines), encoding="utf-8")
return out_path
def apply_staging_file(
staging_path: Path,
approve_all: bool = False,
auto_approve_high: bool = False,
) -> tuple[int, int, list[str]]:
"""
Apply approved candidates from staging file to wiki topics.
Returns (applied, skipped, updated_slugs).
- approve_all: treat every candidate as approved regardless of status/confidence
- auto_approve_high: automatically approve candidates with confidence=high;
others still require manual [x] approve in the staging file
"""
content = staging_path.read_text(encoding="utf-8")
from xkb_review import (_split_candidates, stable_candidate_id, _body, _field,
load_topic_resolutions, resolved_topic)
from xkb_provenance import candidate_marker
blocks = _split_candidates(content)
try:
source_file = staging_path.resolve().relative_to(STAGING_DIR.resolve()).as_posix()
except ValueError:
source_file = staging_path.resolve().as_posix()
resolutions = load_topic_resolutions()
# Validate every override before the first write, using the same identity
# and content guard as governance. Never rewrite the source Topic field.
resolved = {number: resolved_topic(resolutions, stable_candidate_id(source_file, number),
_body(block), _field(block, "Topic"))[0]
for number, block in blocks}
applied = 0
skipped = 0
updated_slugs: list[str] = []
for number, block in blocks:
topic_m = re.search(r"\*\*Topic:\*\* (.+)", block)
section_m = re.search(r"\*\*Section:\*\* (.+)", block)
status_m = re.search(r"\*\*Status:\*\* \[x\] approve", block, re.IGNORECASE)
source_m = re.search(r"\*\*Source date:\*\* (.+)", block)
conf_m = re.search(r"\*\*Confidence:\*\* (\w+)", block)
confidence = conf_m.group(1).strip() if conf_m else "medium"
is_auto_approved = auto_approve_high and confidence == "high"
if not status_m and not approve_all and not is_auto_approved:
skipped += 1
continue
slug = resolved[number]
section = section_m.group(1).strip() if section_m else "核心概念"
source_date = source_m.group(1).strip() if source_m else "unknown"
content_lines = []
in_content = False
for line in block.splitlines():
if line.startswith("- **") or line.startswith("---"):
in_content = False
if in_content and line.strip():
content_lines.append(line)
if line.startswith("- **Status:**"):
in_content = True
entry_text = "\n".join(content_lines).strip()
if not entry_text or not slug or slug.startswith("[NEW:"):
print(f" [SKIP] Missing slug or content")
skipped += 1
continue
marker = candidate_marker(stable_candidate_id(source_file, number))
if not upsert_wiki_section(slug, section, entry_text, source_date, marker=marker):
skipped += 1
continue
applied += 1
if slug not in updated_slugs:
updated_slugs.append(slug)
# Mark staging file as applied
if applied > 0:
applied_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
header_addition = f"\n> ✅ Applied {applied} candidate(s) at {applied_at}\n"
staging_path.write_text(content.replace(
"## Review Instructions",
f"## Apply Status{header_addition}\n## Review Instructions",
1,
), encoding="utf-8")
return applied, skipped, updated_slugs
def upsert_wiki_section(slug: str, section: str, entry: str, source_date: str,
*, marker: str = "") -> bool:
from xkb_review import topic_accepts_candidates
topic_path = TOPICS_DIR / f"{slug}.md"
if not topic_accepts_candidates(topic_path):
print(f" [SKIP] Topic {slug} is missing, archived or a redirect")
return False
content = topic_path.read_text(encoding="utf-8")
if marker and marker in content:
return False
if marker:
entry += f" <!-- {marker} -->"
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()