Skip to content

feat(import): add conversation-export import command#442

Open
dale053 wants to merge 2 commits into
vouchdev:mainfrom
dale053:feat/431-import-conversation-exporters
Open

feat(import): add conversation-export import command#442
dale053 wants to merge 2 commits into
vouchdev:mainfrom
dale053:feat/431-import-conversation-exporters

Conversation

@dale053

@dale053 dale053 commented Jul 8, 2026

Copy link
Copy Markdown

What changed

added a new vouch import <format> <path> cli command for conversation-export ingestion with three formats: chat-json, markdown-vault, and memory-export. the implementation lives in a new src/vouch/corpus_import.py module that normalizes each format into candidates, routes everything through proposals.propose_claim / proposals.propose_page, supports --dry-run, enforces --max-proposals, and reports dedup/cap outcomes. tests were added in tests/test_import.py, and CHANGELOG.md was updated under [Unreleased].

Why

this fixes #431 by giving users a migration path from existing agent history into vouch without bypassing the review gate. users can now seed a kb from prior exports while keeping the same propose-only workflow, with controls (--dry-run, --max-proposals) to avoid flooding review and dedup behavior to reduce repeated claims/pages.

What might break

no breaking surface change is expected for existing .vouch/ directories: no file moves, no on-disk schema migration, and no changes to existing kb.* method behavior. this adds a new cli entrypoint and writes pending proposals/sources using existing storage + proposal flows.

VEP

not required for this change. no object-model, kb.* method list, on-disk layout, bundle format, or audit-log shape change was introduced.

Tests

  • make check passes locally (lint + mypy + pytest)
  • New / changed behaviour has a test
  • CHANGELOG.md updated under ## [Unreleased]

Summary by CodeRabbit

  • New Features

    • Added a new vouch import command to import conversation exports from supported formats.
    • Supports dry-run previews, --max-proposals caps, and --json output for automation.
    • Deduplicates imported claims/pages and routes them through the existing review gate.
    • Input formats added: markdown vault, chat-style JSON, and memory export.
  • Bug Fixes

    • Prevents duplicate proposals by comparing against existing approved artifacts and pending pages.

seed prior agent history into the review queue without bypassing approval. this adds format-specific importers with dry-run, proposal caps, and dedup against approved claims.

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c7bfaf40-f813-4682-87ef-32a9cdef6fd6

📥 Commits

Reviewing files that changed from the base of the PR and between c6fd18d and 12c776f.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • src/vouch/cli.py
💤 Files with no reviewable changes (1)
  • src/vouch/cli.py

📝 Walkthrough

Walkthrough

Adds a new corpus_import.py module for conversation-export importers, wires a vouch import CLI command, and adds tests and changelog notes. The importer parses supported export formats into claim/page candidates, dedupes them, and routes them through the existing proposal review flow.

Changes

Conversation-export import feature

Layer / File(s) Summary
Import data contracts and module setup
src/vouch/corpus_import.py
Adds module setup, ImportFormat/IMPORT_FORMATS, CorpusImportError, ImportCandidate/ImportResult, and _slugify.
Format-specific candidate extraction
src/vouch/corpus_import.py
Implements markdown-vault, chat-json, and memory-export parsing plus the load_candidates dispatcher.
Dedup, source registration, and run_import orchestration
src/vouch/corpus_import.py
Adds dedup helpers, file source registration, and run_import, which caps proposals, dedupes candidates, proposes claims/pages, and returns ImportResult.
vouch import CLI command
src/vouch/cli.py
Registers import_cmd, wires run_import, handles CorpusImportError, and emits JSON or text summaries.
Tests and changelog
tests/test_import.py, CHANGELOG.md
Adds behavioral, dedup, parsing, and CLI tests plus a changelog entry for the new import command.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant CLI as import_cmd
  participant Importer as run_import
  participant Store as KBStore
  participant Proposals as proposals.propose_claim/propose_page

  User->>CLI: vouch import <format> <path> [--dry-run] [--max-proposals N]
  CLI->>Importer: run_import(store, format, path, dry_run, max_proposals, actor)
  Importer->>Importer: load_candidates(format, path)
  Importer->>Store: _register_file_source() (chat-json/memory-export)
  Importer->>Importer: dedup claims/pages
  Importer->>Proposals: propose_claim/propose_page per candidate
  Proposals-->>Importer: proposal_id
  Importer-->>CLI: ImportResult(counts, proposal_ids, warnings, cap_hit)
  CLI-->>User: JSON or text summary
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the new conversation-export import command.
Linked Issues check ✅ Passed The PR implements proposal-only importers for chat-json, markdown-vault, and memory-export with dry-run, max-proposals, and dedup as required.
Out of Scope Changes check ✅ Passed The changes stay focused on the import feature, its CLI wiring, changelog entry, and tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added docs documentation, specs, examples, and repo guidance cli command line interface tests tests and fixtures size: L 500-999 changed non-doc lines labels Jul 8, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
tests/test_import.py (1)

177-192: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider broader CLI test coverage.

The CLI integration test only covers the JSON happy path. Adding tests for text output mode, CorpusImportErrorClickException mapping, and --dry-run/--max-proposals through the CLI would verify the command's full surface and error handling paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_import.py` around lines 177 - 192, The CLI import test currently
only validates the JSON success path in test_cli_import_json_output, so expand
coverage for the import command’s other behaviors. Add tests around the cli
invoke flow for plain text output, error mapping from CorpusImportError to
ClickException, and option handling for --dry-run and --max-proposals, using the
existing cli, CliRunner, and import/chat-json command path as the entry points.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vouch/cli.py`:
- Around line 2053-2058: The --max-proposals option in the click option
definition currently accepts 0 and negative integers because it uses plain
type=int with default=None. Update the CLI validation in the option decorator
for the run_import-related command to use a positive integer constraint such as
click.IntRange(min=1), while keeping None as the default so the cap remains
optional. This change should be made in the click.option declaration for
--max-proposals so invalid values are rejected before run_import is called.

In `@src/vouch/corpus_import.py`:
- Around line 333-335: Dry-run still mutates state because
`_register_file_source()` calls `store.put_source()` before proposals are
skipped. Update the import flow in `corpus_import.py` so the `json_source_id`
registration happens only when `dry_run` is false, or make
`_register_file_source()` a no-op for dry runs. Apply the same guard in both the
chat-json/memory-export path and the other duplicate call site, using the
existing `dry_run` flag and `_register_file_source` helper to keep imports fully
non-mutating.
- Line 339: The proposal cap handling in corpus_import should not treat negative
max_proposals values as unlimited; update the logic around the cap assignment in
the import path so that only None means no cap and any negative value is
explicitly rejected or clamped as invalid. Use the existing corpus_import
proposal limit flow and the cap/max_proposals check to enforce validation before
continuing, so callers cannot bypass flood protection by passing -1.
- Around line 378-388: The page proposal path in propose_pages currently only
attaches source_ids from candidate.source_path, so JSON-backed chat imports lose
their registered source. Update the candidate handling in propose_pages to
preserve and pass through the JSON source identifier for generated pages as
well, reusing the existing _register_file_source and source_ids flow so both
markdown and chat-json candidates attach sources consistently.
- Around line 102-106: The markdown/frontmatter parsing path in corpus_import
should convert raw read/decode/YAML failures into CorpusImportError so the CLI
sees a consistent import error contract. Update the logic around the
path.read_text, _FRONTMATTER_RE.match, and yaml.safe_load flow to catch
unreadable file, decoding, and invalid frontmatter exceptions, then re-raise
them as CorpusImportError with the original exception chained.

---

Nitpick comments:
In `@tests/test_import.py`:
- Around line 177-192: The CLI import test currently only validates the JSON
success path in test_cli_import_json_output, so expand coverage for the import
command’s other behaviors. Add tests around the cli invoke flow for plain text
output, error mapping from CorpusImportError to ClickException, and option
handling for --dry-run and --max-proposals, using the existing cli, CliRunner,
and import/chat-json command path as the entry points.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 366d2a7b-7fad-4ebb-aa49-2740d5242ebd

📥 Commits

Reviewing files that changed from the base of the PR and between 5c98a3a and c6fd18d.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • src/vouch/cli.py
  • src/vouch/corpus_import.py
  • tests/test_import.py

Comment thread src/vouch/cli.py
Comment on lines +2053 to +2058
@click.option(
"--max-proposals",
type=int,
default=None,
help="Cap proposals filed in one run (claims + pages combined).",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate --max-proposals against non-positive values.

type=int with default=None accepts 0 and negative values, which could cause confusing behavior depending on how run_import handles them. Using click.IntRange(min=1) rejects invalid values at the CLI layer with a clear error message, while still allowing None (no cap) as the default.

🛡️ Proposed fix
 `@click.option`(
     "--max-proposals",
-    type=int,
+    type=click.IntRange(min=1),
     default=None,
     help="Cap proposals filed in one run (claims + pages combined).",
 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@click.option(
"--max-proposals",
type=int,
default=None,
help="Cap proposals filed in one run (claims + pages combined).",
)
`@click.option`(
"--max-proposals",
type=click.IntRange(min=1),
default=None,
help="Cap proposals filed in one run (claims + pages combined).",
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/cli.py` around lines 2053 - 2058, The --max-proposals option in the
click option definition currently accepts 0 and negative integers because it
uses plain type=int with default=None. Update the CLI validation in the option
decorator for the run_import-related command to use a positive integer
constraint such as click.IntRange(min=1), while keeping None as the default so
the cap remains optional. This change should be made in the click.option
declaration for --max-proposals so invalid values are rejected before run_import
is called.

Comment on lines +102 to +106
text = path.read_text(encoding="utf-8").replace("\r\n", "\n")
m = _FRONTMATTER_RE.match(text)
if m:
meta = yaml.safe_load(m.group(1)) or {}
if not isinstance(meta, dict):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Wrap markdown parse failures in CorpusImportError.

Invalid frontmatter YAML, unreadable files, or decode failures currently escape as raw exceptions, bypassing the import error contract used by the CLI.

Suggested fix
 def parse_markdown_file(path: Path) -> tuple[str, str, dict[str, Any]]:
     """Return ``(title, body, frontmatter)`` for one markdown file."""
-    text = path.read_text(encoding="utf-8").replace("\r\n", "\n")
-    m = _FRONTMATTER_RE.match(text)
-    if m:
-        meta = yaml.safe_load(m.group(1)) or {}
+    try:
+        text = path.read_text(encoding="utf-8").replace("\r\n", "\n")
+        m = _FRONTMATTER_RE.match(text)
+        if not m:
+            return path.stem, text, {}
+        meta = yaml.safe_load(m.group(1)) or {}
+    except (OSError, UnicodeDecodeError, yaml.YAMLError) as e:
+        raise CorpusImportError(f"cannot parse markdown file at {path}: {e}") from e
+    if m:
         if not isinstance(meta, dict):
             meta = {}
         body = m.group(2)
         title = str(meta.get("title") or path.stem)
         return title, body, meta
-    return path.stem, text, {}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
text = path.read_text(encoding="utf-8").replace("\r\n", "\n")
m = _FRONTMATTER_RE.match(text)
if m:
meta = yaml.safe_load(m.group(1)) or {}
if not isinstance(meta, dict):
try:
text = path.read_text(encoding="utf-8").replace("\r\n", "\n")
m = _FRONTMATTER_RE.match(text)
if not m:
return path.stem, text, {}
meta = yaml.safe_load(m.group(1)) or {}
except (OSError, UnicodeDecodeError, yaml.YAMLError) as e:
raise CorpusImportError(f"cannot parse markdown file at {path}: {e}") from e
if m:
if not isinstance(meta, dict):
meta = {}
body = m.group(2)
title = str(meta.get("title") or path.stem)
return title, body, meta
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/corpus_import.py` around lines 102 - 106, The markdown/frontmatter
parsing path in corpus_import should convert raw read/decode/YAML failures into
CorpusImportError so the CLI sees a consistent import error contract. Update the
logic around the path.read_text, _FRONTMATTER_RE.match, and yaml.safe_load flow
to catch unreadable file, decoding, and invalid frontmatter exceptions, then
re-raise them as CorpusImportError with the original exception chained.

Comment on lines +333 to +335
json_source_id: str | None = None
if format in {"chat-json", "memory-export"}:
json_source_id = _register_file_source(store, resolved)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep dry-run fully non-mutating.

_register_file_source() writes via store.put_source(), so dry_run=True still persists imported source files before proposals are skipped. This violates the dry-run objective.

Suggested fix
     json_source_id: str | None = None
-    if format in {"chat-json", "memory-export"}:
+    if not dry_run and format in {"chat-json", "memory-export"}:
         json_source_id = _register_file_source(store, resolved)
@@
         source_ids: list[str] = []
-        if candidate.source_path is not None:
+        if not dry_run and candidate.source_path is not None:
             source_ids = [_register_file_source(store, candidate.source_path, root=vault_root)]

Also applies to: 378-380

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/corpus_import.py` around lines 333 - 335, Dry-run still mutates
state because `_register_file_source()` calls `store.put_source()` before
proposals are skipped. Update the import flow in `corpus_import.py` so the
`json_source_id` registration happens only when `dry_run` is false, or make
`_register_file_source()` a no-op for dry runs. Apply the same guard in both the
chat-json/memory-export path and the other duplicate call site, using the
existing `dry_run` flag and `_register_file_source` helper to keep imports fully
non-mutating.


vault_root = resolved if resolved.is_dir() else resolved.parent
proposed = 0
cap = max_proposals if max_proposals is not None and max_proposals >= 0 else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject negative proposal caps instead of disabling the cap.

max_proposals=-1 currently becomes unlimited, which can bypass the flood-protection cap if called outside a validating CLI layer.

Suggested fix
-    cap = max_proposals if max_proposals is not None and max_proposals >= 0 else None
+    if max_proposals is not None and max_proposals < 0:
+        raise CorpusImportError("max_proposals must be non-negative")
+    cap = max_proposals
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cap = max_proposals if max_proposals is not None and max_proposals >= 0 else None
if max_proposals is not None and max_proposals < 0:
raise CorpusImportError("max_proposals must be non-negative")
cap = max_proposals
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/corpus_import.py` at line 339, The proposal cap handling in
corpus_import should not treat negative max_proposals values as unlimited;
update the logic around the cap assignment in the import path so that only None
means no cap and any negative value is explicitly rejected or clamped as
invalid. Use the existing corpus_import proposal limit flow and the
cap/max_proposals check to enforce validation before continuing, so callers
cannot bypass flood protection by passing -1.

Comment on lines +378 to +388
source_ids: list[str] = []
if candidate.source_path is not None:
source_ids = [_register_file_source(store, candidate.source_path, root=vault_root)]

page = propose_page(
store,
title=title,
body=candidate.body or "",
page_type="concept",
source_ids=source_ids,
tags=candidate.tags,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Attach JSON import sources to generated pages.

Long chat-json assistant messages are converted to page proposals, but only markdown candidates get source_ids; the registered JSON source is dropped for those pages.

Suggested fix
         source_ids: list[str] = []
-        if candidate.source_path is not None:
+        if json_source_id is not None:
+            source_ids = [json_source_id]
+        elif candidate.source_path is not None:
             source_ids = [_register_file_source(store, candidate.source_path, root=vault_root)]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
source_ids: list[str] = []
if candidate.source_path is not None:
source_ids = [_register_file_source(store, candidate.source_path, root=vault_root)]
page = propose_page(
store,
title=title,
body=candidate.body or "",
page_type="concept",
source_ids=source_ids,
tags=candidate.tags,
source_ids: list[str] = []
if json_source_id is not None:
source_ids = [json_source_id]
elif candidate.source_path is not None:
source_ids = [_register_file_source(store, candidate.source_path, root=vault_root)]
page = propose_page(
store,
title=title,
body=candidate.body or "",
page_type="concept",
source_ids=source_ids,
tags=candidate.tags,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/corpus_import.py` around lines 378 - 388, The page proposal path in
propose_pages currently only attaches source_ids from candidate.source_path, so
JSON-backed chat imports lose their registered source. Update the candidate
handling in propose_pages to preserve and pass through the JSON source
identifier for generated pages as well, reusing the existing
_register_file_source and source_ids flow so both markdown and chat-json
candidates attach sources consistently.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli command line interface docs documentation, specs, examples, and repo guidance size: L 500-999 changed non-doc lines tests tests and fixtures

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(import): conversation-export importers — reconstruct proposals from prior agent history

1 participant