@molecule/api-agent-transcript-autodetect

Provider bond · agent-transcript · API (Node) · v1.2.2 · Apache-2.0

Picks the reader that recognizes a transcript and delegates to it; bundles readers for Claude Code, Codex CLI, the Molecule IDE, Gemini CLI, Cline / Roo Code, OpenCode, Pi, GitHub Copilot Chat, Cursor and Aider, plus a generic Markdown chat reader

npm install @molecule/api-agent-transcript-autodetect

npm · Source on GitHub · Implements @molecule/api-agent-transcript

How it works

@molecule/api-agent-transcript-autodetect is a provider bond on the API (Node) side: it implements the agent-transcript core interface (@molecule/api-agent-transcript) with a concrete vendor or library behind it.

Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.

// provenance.ts — runs in Node at build time (a build script or a Vite plugin), never in the page.
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'

import {
  canReadTranscript,
  readTranscript,
  setProvider as setTranscriptReader,
} from '@molecule/api-agent-transcript'
import { provider as anyTranscript } from '@molecule/api-agent-transcript-autodetect'
import { attributeText, setProvider as setAttribution } from '@molecule/api-text-provenance'
import { provider as wordOverlap } from '@molecule/api-text-provenance-overlap'

setTranscriptReader(anyTranscript) // reads every supported harness, plus plain Markdown chats
setAttribution(wordOverlap)

// One block of the post, in page order. `prompt` and `model` are on every ai span.
export interface ProvenanceSpan {
  text: string // the block's markdown: a paragraph, heading, list or code block
  origin: 'human' | 'ai'
  prompt?: string // the person's message the AI was answering, as typed
  model?: string // the model that wrote it, as the transcript names it
}

// What /<slug>/provenance.json holds.
export interface Provenance {
  aiShare: number // 0..1, the share of the post's words the AI wrote
  words: number
  aiWords: number
  prompts: string[] // the distinct prompts behind the ai spans, in page order
  spans: ProvenanceSpan[]
}

// The post's top-level blocks: front matter dropped, split on blank lines, never inside a code fence.
export function markdownBlocks(markdown: string): string[] {
  const body = markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '')
  const blocks: string[] = []
  let lines: string[] = []
  let inFence = false
  for (const line of body.split(/\r?\n/)) {
    if (/^\s*(`{3}|~{3})/.test(line)) inFence = !inFence
    if (!inFence && line.trim() === '') {
      if (lines.length > 0) blocks.push(lines.join('\n'))
      lines = []
    } else {
      lines.push(line)
    }
  }
  if (lines.length > 0) blocks.push(lines.join('\n'))
  return blocks
}

// Attribute one post from its markdown file and the folder holding its transcript exports.
// A missing or empty folder is a 100% human post; files that are not transcripts are skipped.
export function postProvenance(markdownFile: string, transcriptDir: string): Provenance {
  const blocks = markdownBlocks(readFileSync(markdownFile, 'utf8'))
  const files = existsSync(transcriptDir)
    ? readdirSync(transcriptDir, { withFileTypes: true })
        .filter((entry) => entry.isFile())
        .map((entry) => entry.name)
        .sort()
    : []
  const sessions = files
    .map((name) => ({ text: readFileSync(join(transcriptDir, name), 'utf8'), fileName: name }))
    .filter((input) => canReadTranscript(input))
    .map((input) => readTranscript(input))
  const result = attributeText({ paragraphs: blocks, sessions })
  return {
    aiShare: result.aiShare,
    words: result.words,
    aiWords: result.aiWords,
    prompts: result.prompts,
    spans: result.paragraphs.map((p): ProvenanceSpan => {
      const text = blocks[p.index]
      return p.origin === 'ai'
        ? { text, origin: 'ai', prompt: p.prompt, model: p.model }
        : { text, origin: 'human' }
    }),
  }
}

// Write provenance.json, creating its folder.
export function writeProvenance(outFile: string, provenance: Provenance): void {
  mkdirSync(dirname(outFile), { recursive: true })
  writeFileSync(outFile, `${JSON.stringify(provenance, null, 2)}\n`)
}

// In the build, for each PUBLISHED post (skip drafts), after the site's own build has written dist/:
// writeProvenance('dist/my-post/provenance.json', postProvenance('posts/my-post.md', 'transcripts/my-post'))

Works with: @molecule/api-agent-transcript

Live demos

Running apps built with Molecule that install this package — open the app or study the template.

Reference

The transcript reader to bond when files can come from more than one harness.

Implements @molecule/api-agent-transcript by trying each bundled reader's detect() and delegating to the first that recognizes the file:

HarnessFiles
Claude Code/export text, session .jsonl
Codex CLIMarkdown export, rollout .jsonl
Molecule IDEstored conversation JSON
Gemini CLIsession .jsonl / .json, /chat save checkpoint
Cline / Roo Codea task's ui_messages.json
OpenCodeopencode export JSON
Pisession .jsonl, pi --mode json output
GitHub Copilot ChatVS Code's "Export Chat…" chat.json
Cursor"Export Chat" Markdown
Aider.aider.chat.history.md
anything elsea Markdown / text chat with ## User, **User:** or User: markers (tried last)

createReader() composes any other list, including readers of your own; harnessReaders is the list without the generic Markdown chat reader. The example is the usual job: read a post's folder of transcript exports at build time and attribute its paragraphs with @molecule/api-text-provenance, writing provenance.json.

Quick Start

// provenance.ts — runs in Node at build time (a build script or a Vite plugin), never in the page.
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'

import {
  canReadTranscript,
  readTranscript,
  setProvider as setTranscriptReader,
} from '@molecule/api-agent-transcript'
import { provider as anyTranscript } from '@molecule/api-agent-transcript-autodetect'
import { attributeText, setProvider as setAttribution } from '@molecule/api-text-provenance'
import { provider as wordOverlap } from '@molecule/api-text-provenance-overlap'

setTranscriptReader(anyTranscript) // reads every supported harness, plus plain Markdown chats
setAttribution(wordOverlap)

// One block of the post, in page order. `prompt` and `model` are on every ai span.
export interface ProvenanceSpan {
  text: string // the block's markdown: a paragraph, heading, list or code block
  origin: 'human' | 'ai'
  prompt?: string // the person's message the AI was answering, as typed
  model?: string // the model that wrote it, as the transcript names it
}

// What /<slug>/provenance.json holds.
export interface Provenance {
  aiShare: number // 0..1, the share of the post's words the AI wrote
  words: number
  aiWords: number
  prompts: string[] // the distinct prompts behind the ai spans, in page order
  spans: ProvenanceSpan[]
}

// The post's top-level blocks: front matter dropped, split on blank lines, never inside a code fence.
export function markdownBlocks(markdown: string): string[] {
  const body = markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '')
  const blocks: string[] = []
  let lines: string[] = []
  let inFence = false
  for (const line of body.split(/\r?\n/)) {
    if (/^\s*(`{3}|~{3})/.test(line)) inFence = !inFence
    if (!inFence && line.trim() === '') {
      if (lines.length > 0) blocks.push(lines.join('\n'))
      lines = []
    } else {
      lines.push(line)
    }
  }
  if (lines.length > 0) blocks.push(lines.join('\n'))
  return blocks
}

// Attribute one post from its markdown file and the folder holding its transcript exports.
// A missing or empty folder is a 100% human post; files that are not transcripts are skipped.
export function postProvenance(markdownFile: string, transcriptDir: string): Provenance {
  const blocks = markdownBlocks(readFileSync(markdownFile, 'utf8'))
  const files = existsSync(transcriptDir)
    ? readdirSync(transcriptDir, { withFileTypes: true })
        .filter((entry) => entry.isFile())
        .map((entry) => entry.name)
        .sort()
    : []
  const sessions = files
    .map((name) => ({ text: readFileSync(join(transcriptDir, name), 'utf8'), fileName: name }))
    .filter((input) => canReadTranscript(input))
    .map((input) => readTranscript(input))
  const result = attributeText({ paragraphs: blocks, sessions })
  return {
    aiShare: result.aiShare,
    words: result.words,
    aiWords: result.aiWords,
    prompts: result.prompts,
    spans: result.paragraphs.map((p): ProvenanceSpan => {
      const text = blocks[p.index]
      return p.origin === 'ai'
        ? { text, origin: 'ai', prompt: p.prompt, model: p.model }
        : { text, origin: 'human' }
    }),
  }
}

// Write provenance.json, creating its folder.
export function writeProvenance(outFile: string, provenance: Provenance): void {
  mkdirSync(dirname(outFile), { recursive: true })
  writeFileSync(outFile, `${JSON.stringify(provenance, null, 2)}\n`)
}

// In the build, for each PUBLISHED post (skip drafts), after the site's own build has written dist/:
// writeProvenance('dist/my-post/provenance.json', postProvenance('posts/my-post.md', 'transcripts/my-post'))

Type

provider

Installation

npm install @molecule/api-agent-transcript-autodetect @molecule/api-agent-transcript @molecule/api-agent-transcript-aider @molecule/api-agent-transcript-claude-code @molecule/api-agent-transcript-cline @molecule/api-agent-transcript-codex @molecule/api-agent-transcript-copilot-chat @molecule/api-agent-transcript-cursor @molecule/api-agent-transcript-gemini-cli @molecule/api-agent-transcript-markdown-chat @molecule/api-agent-transcript-molecule-ide @molecule/api-agent-transcript-opencode @molecule/api-agent-transcript-pi

API

Functions

createReader(readers)

Compose readers: the first whose detect() accepts the input reads it.

function createReader(readers: readonly AgentTranscriptReader[]): AgentTranscriptReader
  • readers — The readers to try, in order.

Returns: One reader over all of them.

Constants

harnessReaders

Every harness-specific reader, in the order they are tried. Each accepts only its own harness's files, so the order only matters for the generic Markdown chat reader, which is not in this list. Compose createReader(harnessReaders) when only a real harness's own file should be read.

const harnessReaders: readonly AgentTranscriptReader[]

provider

Reads a transcript from any supported harness — Claude Code, Codex CLI, the Molecule IDE, Gemini CLI, Cline / Roo Code, OpenCode, Pi, GitHub Copilot Chat, Cursor and Aider — and, last, any plain Markdown / text chat with User / Assistant speaker markers.

const provider: AgentTranscriptReader

Core Interface

Implements @molecule/api-agent-transcript interface.

Bond Wiring

Setup function to register this provider with the core interface:

import { setProvider } from '@molecule/api-agent-transcript'
import { provider } from '@molecule/api-agent-transcript-autodetect'

export function setupAgentTranscriptAutodetect(): void {
  setProvider(provider)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-agent-transcript ^1.0.0
  • @molecule/api-agent-transcript-pi ^1.0.0

Runtime Dependencies

  • @molecule/api-agent-transcript

  • @molecule/api-agent-transcript-aider

  • @molecule/api-agent-transcript-claude-code

  • @molecule/api-agent-transcript-cline

  • @molecule/api-agent-transcript-codex

  • @molecule/api-agent-transcript-copilot-chat

  • @molecule/api-agent-transcript-cursor

  • @molecule/api-agent-transcript-gemini-cli

  • @molecule/api-agent-transcript-markdown-chat

  • @molecule/api-agent-transcript-molecule-ide

  • @molecule/api-agent-transcript-opencode

  • @molecule/api-agent-transcript-pi

  • Do NOT call readTranscript on every file in a folder. A file no reader recognizes throws, naming the file and the readers tried — it is never read as an empty session. Filter with canReadTranscript() first, as the example does.

  • Do NOT parse an export yourself or bond a single-format reader "to be safe". This bond reads every format above; a user turn is already only what the person typed.

  • A plain chat with speaker markers is read too — as markdown-chat, never as a harness. When only a real harness's own file may count (attributing text to a model, say), check session.format !== 'markdown-chat', or bond createReader(harnessReaders) instead.

  • Do NOT import this from page or client code — it is server-only and throws in a browser bundle.

  • Install all four packages the example uses: npm install @molecule/api-agent-transcript @molecule/api-agent-transcript-autodetect @molecule/api-text-provenance @molecule/api-text-provenance-overlap.

  • Order matters only for a file two readers would both accept. The harness readers accept disjoint formats; the generic Markdown chat reader is tried last, so it never claims a harness's own file.