| 427 | |
| 428 | |
| 429 | def _save_transcript(kb_dir: Path, session: ChatSession, name: str | None) -> Path: |
| 430 | from openkb.lint import ( |
| 431 | build_norm_index, |
| 432 | list_existing_wiki_targets, |
| 433 | strip_ghost_wikilinks, |
| 434 | ) |
| 435 | |
| 436 | explore_dir = kb_dir / "wiki" / "explorations" |
| 437 | explore_dir.mkdir(parents=True, exist_ok=True) |
| 438 | |
| 439 | base = name or session.title or (session.user_turns[0] if session.user_turns else session.id) |
| 440 | slug = re.sub(r"[^a-z0-9]+", "-", base.lower()).strip("-")[:60] or session.id |
| 441 | date = session.created_at[:10].replace("-", "") |
| 442 | path = explore_dir / f"{slug}-{date}.md" |
| 443 | |
| 444 | # Strip ghost wikilinks from assistant responses (the agent's |
| 445 | # instructions encourage [[wikilinks]] but it can reference pages |
| 446 | # that don't exist on disk). User turns are written verbatim — they |
| 447 | # represent intentional user input, not LLM hallucination. |
| 448 | # Build the normalized index once and reuse for every turn — the |
| 449 | # whitelist is the same across the whole session. |
| 450 | known = list_existing_wiki_targets(kb_dir / "wiki") |
| 451 | norm_index = build_norm_index(known) |
| 452 | |
| 453 | lines: list[str] = [ |
| 454 | "---", |
| 455 | f'session: "{session.id}"', |
| 456 | f'model: "{session.model}"', |
| 457 | f'created: "{session.created_at}"', |
| 458 | "---", |
| 459 | "", |
| 460 | f"# Chat transcript {session.title or session.id}", |
| 461 | "", |
| 462 | ] |
| 463 | for i, (u, a) in enumerate(zip(session.user_turns, session.assistant_texts), 1): |
| 464 | lines.append(f"## [{i}] {u}") |
| 465 | lines.append("") |
| 466 | if a: |
| 467 | cleaned_a, _ = strip_ghost_wikilinks(a, known, norm_index=norm_index) |
| 468 | lines.append(cleaned_a) |
| 469 | else: |
| 470 | lines.append("_(no response recorded)_") |
| 471 | lines.append("") |
| 472 | |
| 473 | path.write_text("\n".join(lines), encoding="utf-8") |
| 474 | return path |
| 475 | |
| 476 | |
| 477 | async def _run_add(arg: str, kb_dir: Path, style: Style) -> None: |