Skip to content

Add engram import, for transcripts a harness exported - #14

Merged
UnbreakableMJ merged 1 commit into
mainfrom
add-import-command
Aug 25, 2026
Merged

Add engram import, for transcripts a harness exported#14
UnbreakableMJ merged 1 commit into
mainfrom
add-import-command

Conversation

@UnbreakableMJ

Copy link
Copy Markdown
Contributor

ingest reads the session file a harness writes for itself — which reaches a harness engram has a reader for, and nothing else. Two gaps followed:

  • A harness may keep history in a form engram cannot parse (Antigravity's protobuf, VS Code's workspace state) while still offering its own export command.
  • An archive is not a live session. save-chat wrote .texi files engram had no way to read back — engram's own output was the one format it could not ingest.
engram import <PATH>... [--scope S] [--input-format F] [--recursive] [--dry-run]

Not a ReaderKind, on purpose

That enum is built around "one path per installed harness", discovered from a HarnessSpec plus a working directory. A file somebody hands you fits none of it. import feeds the shared pipeline directly — normalize_textredact::scrubingest_turns — so an archive gets the same normalization and redaction a live transcript does. An archive is not more trustworthy for being old.

Identity is the content, not the file

import_id is a v5 uuid over (scope, agent, role, created_at, text). Every dedupe requirement then falls out of INSERT OR IGNORE, with no separate dedupe pass:

Case Result
Re-import the same archive 0 inserted
13 byte-identical copies across projects imported once per scope
Archive holding two concatenated copies of itself collapses to one set

That last one is real — residue of an earlier save-chat that appended rather than rewrote, still on disk.

Three findings, each worth a debugging pass

A structural line is never message content. Escaping doubles every literal @, so a single leading @ is markup. Without that filter, the trailing @c Signed by: / @chapter Chat history… of one document became the tail of the previous message's body — which is exactly why the double-appended archive failed to deduplicate at first: the last message of each copy differed by the next copy's header. Caught because the numbers said collapsed=1 where 2 was expected.

--input-format, not --format. The global output-format flag already owns that name, and a subcommand reusing it makes clap panic rather than shadow it:

Mismatch between definition and access of `format`. Could not downcast to TypeId(…)

Canonicalize before resolving scope. import ./chat resolves its parent to ., whose basename is empty — the scope silently became default. One relative path away from filing an entire corpus under the wrong name.

Formats — sniffed by content, never extension

Family Parser
engram Texinfo, both dialects (@chapter current, @section legacy) one heading grammar covers all 7,163 corpus messages
Opencode/Kilo Markdown anchored on ^## (User|Assistant\b)
Claude Code scrollback glyph in column 0; lossy, --input-format gated

A real chat/ directory held a standalone HTML palette editor next to genuine archives; extension-matching would have ingested it. Several vendored source trees merely named chat (Kotlin, TypeScript, Rust) are skipped the same way. Everything unrecognised is reported with its reason — the difference between "found nothing" and "could not read nine files".

Anchoring on the two literal speakers matters: the 294 KB Opencode export contains ## Root Cause Analysis, ## Next Move inside assistant replies. A naive ^## split shatters one message into a dozen fake turns.

Roles are not binary

The corpus holds assistant 6,395 · user 632 · note 136. The third is carried through, not coerced into one of the other two.

Timestamps

Markdown and scrollback record none. Each message is offset one second from a file-level anchor — the export's own session time, else the file's mtime — and approximate_times is reported per file. Ordering is exact; absolute values are approximate. Stamping now() would collapse a conversation into one instant and destroy reading order, the same failure transcript refuses a wall-clock fallback to avoid.

Verified against the real corpus

files parsed: 100   messages: 9,907   scopes: 15

Two variants remain unhandled and are reported as skips, not silently dropped: older Claude Code scrollback with no marker (forcing the format recovers its assistant turns), and Goose terminal output.

Gates

fmt · clippy -D warnings · 278 tests · REUSE 3.3 · makeinfo clean

🤖 Generated with Claude Code

https://claude.ai/code/session_016i16R4GhdSffsboRYq97Fs

`ingest` reads the session file a harness writes for itself, which reaches a
harness engram has a reader for and nothing else. That left two gaps. A harness
may keep its history in a form engram cannot parse — protocol buffers, editor
workspace state — while still offering its own export command. And an archive
is not a live session: `save-chat` wrote `.texi` files engram had no way to read
back, so engram's own output was the one format it could not ingest.

`import` is deliberately not a `ReaderKind`. That enum is built around "one path
per installed harness", discovered from a HarnessSpec and a working directory,
and a file somebody hands you fits none of it. It feeds the shared pipeline
directly instead: normalize_text, redact::scrub, ingest_turns.

Identity is the message's content, not its file — a v5 uuid over scope, agent,
role, timestamp and text. Every dedupe requirement then falls out of INSERT OR
IGNORE with no separate pass: re-importing inserts 0, thirteen byte-identical
copies of one archive import once, and the archive holding two concatenated
copies of itself — residue of an earlier save-chat that appended rather than
rewrote — collapses to one set of messages.

Formats are sniffed by content, never by extension: a real chat/ directory held
a standalone HTML palette editor next to genuine archives, and several source
trees merely named `chat` are skipped the same way. Everything unrecognised is
reported with its reason, which is the difference between "found nothing" and
"could not read nine files".

Three findings worth recording because each cost a debugging pass:

A structural line is never message content — escaping doubles every literal `@`,
so a single leading `@` is markup. Without that filter the trailing `@c Signed
by:` and `@chapter Chat history…` of one document became the tail of the
previous message's body, which is exactly why the double-appended archive did
not deduplicate at first: the last message of each copy differed by the next
copy's header.

`--input-format`, not `--format`: the global output-format flag already owns
that name, and a subcommand reusing it makes clap panic on the duplicate
argument id rather than shadow it.

The base directory is canonicalized before scope resolution. `import ./chat`
resolves its parent to `.`, whose basename is empty, and the scope silently
became `default` — one relative path away from filing a corpus under the wrong
name.

Missing timestamps are synthesised in order from a file-level anchor and
flagged per file, never taken from the clock: stamping now() would collapse a
conversation into one instant and destroy reading order, the same failure
`transcript` refuses a wall-clock fallback to avoid.

Verified against the real corpus: 100 files, 9,907 messages, 15 scopes.

Gates: fmt, clippy -D warnings, 278 tests, REUSE 3.3, makeinfo clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016i16R4GhdSffsboRYq97Fs
@UnbreakableMJ
UnbreakableMJ merged commit 54d06f1 into main Aug 25, 2026
5 checks passed
@UnbreakableMJ
UnbreakableMJ deleted the add-import-command branch August 25, 2026 20:16

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8ce264c362

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/import.rs
Comment on lines +481 to +483
"engram-import:{scope}:{}:{}:{}:{}",
m.agent, m.role, m.created_at, m.text
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep approximate-message IDs stable across file copies

For Markdown exports without a Created header and for Claude scrollback, created_at comes from the file's mtime, and including it here makes the UUID change whenever an identical file is copied without preserving metadata or merely touched. Re-importing that byte-identical transcript then inserts every message again instead of deduplicating it, so approximate messages need identity based on stable export content and position rather than the mtime-derived timestamp.

AGENTS.md reference: AGENTS.md:L275-L275

Useful? React with 👍 / 👎.

Comment thread src/main.rs
Comment on lines +1699 to +1701
let base = path.parent().unwrap_or(&path);
let base = std::fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
let resolved = rules::resolve_scope_in(scope.as_deref(), &base);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve default chat archives from the non-Git project directory

When the default save-chat output /work/foo/chat/archive.texi belongs to a non-Git project and --scope is omitted, resolving from the file's immediate parent makes the cwd fallback produce scope chat, not foo; a save/import round trip therefore silently files the history under the wrong scope. The base needs to reflect the containing project, including the parent of the managed chat/ directory when no Git root exists.

AGENTS.md reference: AGENTS.md:L85-L90

Useful? React with 👍 / 👎.

Comment thread src/import.rs
Comment on lines +127 to +130
if text.contains("**Session ID:**")
|| text
.lines()
.any(|l| l.starts_with("## Assistant (") || l.trim_end() == "## Assistant")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require a stronger signature before importing Markdown

With --recursive, any ordinary Markdown document containing a line exactly equal to ## Assistant or beginning ## Assistant ( is classified as an Opencode transcript even when it has no session header, and all following text is stored as an assistant memory. This can contaminate scopes when source trees contain prompt documentation, so automatic detection should require the documented session marker or another combination specific to an actual export.

AGENTS.md reference: AGENTS.md:L276-L276

Useful? React with 👍 / 👎.

Comment thread src/main.rs
Comment on lines +1630 to +1631
let Ok(entries) = std::fs::read_dir(&p) else {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report unreadable input paths instead of succeeding empty

If a requested path is misspelled, missing, a broken symlink, or an unreadable directory, is_file() is false and the read_dir error is silently discarded here, so engram import missing-path exits successfully with empty files and skipped arrays. This makes scripts treat a failed import as a successful no-op; preserve these traversal failures as skipped entries or return a structured error.

Useful? React with 👍 / 👎.

Comment thread src/main.rs
Comment on lines +1578 to +1583
path: String,
format: &'static str,
scope: String,
messages: usize,
inserted: usize,
skipped_existing: usize,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include scope origins in import results

Each file resolves a ResolvedScope, but the response retains only its name, so callers cannot tell whether an explicit value, ENGRAM_SCOPE, a Git root, or the directory fallback selected the destination. This is particularly important for multi-project imports where an environment override can collapse all files into one scope, and it violates the response contract that scope resolution reports scope_origin; add the origin to each imported-file result.

AGENTS.md reference: AGENTS.md:L85-L87

Useful? React with 👍 / 👎.

Comment thread src/cli.rs
Comment on lines +350 to +354
/// Import chat transcripts that were exported to files.
///
/// Reads what a harness's own export command wrote, and what `save-chat`
/// archived, for the harnesses engram has no reader for.
Import {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Advertise the import command in the capability manifest

Adding this CLI command without updating Command::Describe leaves import absent from both the manifest's commands list and its mcp.cli_only list. Clients using the JSON capability manifest for discovery will conclude that this command does not exist, so the new surface must be added to that manifest alongside the other CLI-only commands.

AGENTS.md reference: AGENTS.md:L38-L38

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant