diff --git a/README.md b/README.md
index 0fecbe11..2f55acb2 100644
--- a/README.md
+++ b/README.md
@@ -170,6 +170,7 @@ OpenKB commands fall into two layers: the **wiki foundation** (compile + manage
| openkb remove <doc> | Remove a document and clean up its wiki pages, images, registry, and PageIndex state (`--dry-run` to preview, `--keep-raw` / `--keep-empty` to retain artifacts) |
| openkb recompile [<doc>] [--all] | Re-run the compile pipeline on already-indexed docs without re-indexing. Regenerates summaries and rewrites concept pages; manual edits are overwritten (`--dry-run` to preview, `--refresh-schema` to also update `wiki/AGENTS.md`) |
| openkb feedback ["msg"] | File feedback by opening a prefilled GitHub issue (`--type bug/feature/question` to tag it) |
+| `openkb migrate-images` | One-time migration for KBs ingested before the note-relative image-link change (#181): rewrite old image links in sources pages so they render in Obsidian/GitHub/VS Code (`--dry-run` to preview) |
diff --git a/openkb/cli.py b/openkb/cli.py
index 6a37350a..78babd6b 100644
--- a/openkb/cli.py
+++ b/openkb/cli.py
@@ -1962,6 +1962,48 @@ def lint(ctx, fix):
asyncio.run(run_lint(kb_dir))
+@cli.command("migrate-images")
+@click.option(
+ "--dry-run",
+ is_flag=True,
+ default=False,
+ help="Report the files and link counts that would change without writing.",
+)
+@click.pass_context
+def migrate_images(ctx, dry_run):
+ """Migrate old wiki-root-relative image links in sources pages.
+
+ KBs ingested before the note-relative image-link change embedded
+ images in wiki/sources/.md with ``sources/images/...`` links,
+ which render broken in Obsidian, GitHub, and VS Code. This one-time
+ migration rewrites them to the ``images/...`` form now emitted at
+ ingest. Idempotent — re-running finds nothing to do.
+ """
+ kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override"))
+ if kb_dir is None:
+ click.echo("No knowledge base found. Run `openkb init` first.")
+ return
+ from openkb.migrate import migrate_source_image_links
+
+ wiki = kb_dir / "wiki"
+ if dry_run:
+ changed = migrate_source_image_links(wiki, dry_run=True)
+ else:
+ with kb_ingest_lock(kb_dir / ".openkb"):
+ changed = migrate_source_image_links(wiki)
+ if changed:
+ total = sum(count for _, count in changed)
+ append_log(wiki, "migrate-images", f"{total} link(s) across {len(changed)} file(s)")
+ if not changed:
+ click.echo("Nothing to migrate — sources image links are already note-relative.")
+ return
+ for path, count in changed:
+ click.echo(f" {path.relative_to(wiki)}: {count} link(s)")
+ total = sum(count for _, count in changed)
+ verb = "Would rewrite" if dry_run else "Rewrote"
+ click.echo(f"{verb} {total} image link(s) across {len(changed)} file(s).")
+
+
@cli.command()
@click.option(
"--open/--no-open",
diff --git a/openkb/migrate.py b/openkb/migrate.py
new file mode 100644
index 00000000..e294e743
--- /dev/null
+++ b/openkb/migrate.py
@@ -0,0 +1,57 @@
+"""One-time migrations for knowledge bases created by older OpenKB versions."""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from openkb.locks import atomic_write_text
+
+# Inline-link opener followed by the old wiki-root-relative image prefix.
+# Only the prefix is swapped (`](sources/images/` → `](images/`), so the
+# path tail up to the closing paren never needs parsing, and image embeds
+# (``) and plain links are handled alike.
+_OLD_IMAGE_PREFIX_RE = re.compile(r"\]\(sources/images/")
+
+
+def migrate_source_image_links(wiki: Path, *, dry_run: bool = False) -> list[tuple[Path, int]]:
+ """Rewrite wiki-root-relative image links in sources pages to note-relative.
+
+ KBs ingested before the ``md_image_ref()`` change embedded images in
+ ``wiki/sources/.md`` as ````.
+ Renderers resolve links relative to the containing file, so from a
+ sources page those pointed at the non-existent
+ ``sources/sources/images/...`` and rendered broken. This rewrites them
+ to the ``images//`` form now emitted at ingest.
+
+ Only ``.md`` files directly under ``wiki/sources/`` are touched — the
+ per-page JSON of long documents intentionally keeps wiki-root-relative
+ paths (internal metadata, resolved against the wiki root), and pages
+ outside ``sources/`` never carried the old prefix. Idempotent: already
+ note-relative links don't match the old prefix.
+
+ Args:
+ wiki: Path to the wiki root directory.
+ dry_run: When True, report what would change without writing.
+
+ Returns:
+ List of ``(path, links_rewritten)`` for each file that changed
+ (or would change, under ``dry_run``).
+ """
+ sources = wiki / "sources"
+ changed: list[tuple[Path, int]] = []
+ if not sources.is_dir():
+ return changed
+
+ for md in sorted(sources.glob("*.md")):
+ try:
+ text = md.read_text(encoding="utf-8")
+ except OSError:
+ continue
+ migrated, count = _OLD_IMAGE_PREFIX_RE.subn("](images/", text)
+ if count == 0:
+ continue
+ if not dry_run:
+ atomic_write_text(md, migrated)
+ changed.append((md, count))
+ return changed
diff --git a/tests/test_migrate.py b/tests/test_migrate.py
new file mode 100644
index 00000000..124f4f92
--- /dev/null
+++ b/tests/test_migrate.py
@@ -0,0 +1,153 @@
+"""Tests for openkb.migrate and the migrate-images CLI command."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from unittest.mock import patch
+
+from click.testing import CliRunner
+
+from openkb.cli import cli
+from openkb.migrate import migrate_source_image_links
+
+OLD_PAGE = (
+ "# Doc\n\n"
+ "Intro text.\n\n"
+ "\n\n"
+ "More text with an inline  here.\n"
+)
+
+MIGRATED_PAGE = (
+ "# Doc\n\n"
+ "Intro text.\n\n"
+ "\n\n"
+ "More text with an inline  here.\n"
+)
+
+
+def _make_wiki(tmp_path: Path) -> Path:
+ wiki = tmp_path / "wiki"
+ (wiki / "sources" / "images").mkdir(parents=True)
+ (wiki / "summaries").mkdir(parents=True)
+ return wiki
+
+
+class TestMigrateSourceImageLinks:
+ def test_rewrites_old_links_preserving_alt_and_text(self, tmp_path):
+ wiki = _make_wiki(tmp_path)
+ page = wiki / "sources" / "doc.md"
+ page.write_text(OLD_PAGE, encoding="utf-8")
+
+ changed = migrate_source_image_links(wiki)
+
+ assert changed == [(page, 2)]
+ assert page.read_text(encoding="utf-8") == MIGRATED_PAGE
+
+ def test_idempotent_on_migrated_page(self, tmp_path):
+ wiki = _make_wiki(tmp_path)
+ page = wiki / "sources" / "doc.md"
+ page.write_text(MIGRATED_PAGE, encoding="utf-8")
+
+ assert migrate_source_image_links(wiki) == []
+ assert page.read_text(encoding="utf-8") == MIGRATED_PAGE
+
+ def test_dry_run_reports_without_writing(self, tmp_path):
+ wiki = _make_wiki(tmp_path)
+ page = wiki / "sources" / "doc.md"
+ page.write_text(OLD_PAGE, encoding="utf-8")
+
+ changed = migrate_source_image_links(wiki, dry_run=True)
+
+ assert changed == [(page, 2)]
+ assert page.read_text(encoding="utf-8") == OLD_PAGE
+
+ def test_long_doc_json_left_untouched(self, tmp_path):
+ # Per-page JSON keeps wiki-root-relative paths by design.
+ wiki = _make_wiki(tmp_path)
+ payload = json.dumps([{"page": 1, "content": ""}])
+ doc_json = wiki / "sources" / "doc.json"
+ doc_json.write_text(payload, encoding="utf-8")
+
+ assert migrate_source_image_links(wiki) == []
+ assert doc_json.read_text(encoding="utf-8") == payload
+
+ def test_pages_outside_sources_left_untouched(self, tmp_path):
+ # Note-relative resolution differs outside sources/ — out of scope.
+ wiki = _make_wiki(tmp_path)
+ summary = wiki / "summaries" / "doc.md"
+ summary.write_text(OLD_PAGE, encoding="utf-8")
+
+ assert migrate_source_image_links(wiki) == []
+ assert summary.read_text(encoding="utf-8") == OLD_PAGE
+
+ def test_missing_sources_dir_is_noop(self, tmp_path):
+ wiki = tmp_path / "wiki"
+ wiki.mkdir()
+
+ assert migrate_source_image_links(wiki) == []
+
+ def test_multiple_files_sorted(self, tmp_path):
+ wiki = _make_wiki(tmp_path)
+ page_b = wiki / "sources" / "b.md"
+ page_a = wiki / "sources" / "a.md"
+ page_b.write_text("", encoding="utf-8")
+ page_a.write_text(OLD_PAGE, encoding="utf-8")
+
+ changed = migrate_source_image_links(wiki)
+
+ assert changed == [(page_a, 2), (page_b, 1)]
+
+
+class TestMigrateImagesCommand:
+ def _setup_kb(self, tmp_path: Path) -> Path:
+ kb_dir = tmp_path
+ (kb_dir / "wiki" / "sources" / "images").mkdir(parents=True)
+ (kb_dir / ".openkb").mkdir()
+ return kb_dir
+
+ def test_no_kb(self, tmp_path):
+ runner = CliRunner()
+ with (
+ runner.isolated_filesystem(temp_dir=tmp_path),
+ patch("openkb.cli._find_kb_dir", return_value=None),
+ ):
+ result = runner.invoke(cli, ["migrate-images"])
+ assert result.exit_code == 0
+ assert "No knowledge base found" in result.output
+
+ def test_nothing_to_migrate(self, tmp_path):
+ kb_dir = self._setup_kb(tmp_path)
+ (kb_dir / "wiki" / "sources" / "doc.md").write_text(MIGRATED_PAGE, encoding="utf-8")
+ runner = CliRunner()
+ with patch("openkb.cli._find_kb_dir", return_value=kb_dir):
+ result = runner.invoke(cli, ["migrate-images"])
+ assert result.exit_code == 0
+ assert "Nothing to migrate" in result.output
+
+ def test_migrates_and_logs(self, tmp_path):
+ kb_dir = self._setup_kb(tmp_path)
+ page = kb_dir / "wiki" / "sources" / "doc.md"
+ page.write_text(OLD_PAGE, encoding="utf-8")
+ runner = CliRunner()
+ with patch("openkb.cli._find_kb_dir", return_value=kb_dir):
+ result = runner.invoke(cli, ["migrate-images"])
+ assert result.exit_code == 0
+ assert "Rewrote 2 image link(s) across 1 file(s)." in result.output
+ assert "doc.md: 2 link(s)" in result.output
+ assert page.read_text(encoding="utf-8") == MIGRATED_PAGE
+ log = kb_dir / "wiki" / "log.md"
+ assert log.exists()
+ assert "migrate-images" in log.read_text(encoding="utf-8")
+
+ def test_dry_run_does_not_write(self, tmp_path):
+ kb_dir = self._setup_kb(tmp_path)
+ page = kb_dir / "wiki" / "sources" / "doc.md"
+ page.write_text(OLD_PAGE, encoding="utf-8")
+ runner = CliRunner()
+ with patch("openkb.cli._find_kb_dir", return_value=kb_dir):
+ result = runner.invoke(cli, ["migrate-images", "--dry-run"])
+ assert result.exit_code == 0
+ assert "Would rewrite 2 image link(s) across 1 file(s)." in result.output
+ assert page.read_text(encoding="utf-8") == OLD_PAGE
+ assert not (kb_dir / "wiki" / "log.md").exists()