@molecule/api-agent-transcript-autodetect 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-09-24T06:06:46.371Z
6
+ Generated: 2026-09-24T11:13:33.139Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-agent-transcript-autodetect
@@ -18,22 +18,103 @@ Implements `@molecule/api-agent-transcript` by trying each bundled reader's
18
18
  `detect()` and delegating to the first that recognizes the file: Claude Code
19
19
  (`/export` text, session `.jsonl`), Codex CLI (Markdown export, rollout
20
20
  `.jsonl`) and the Molecule IDE (stored conversation JSON). `createReader()`
21
- composes any other list, including readers of your own.
21
+ composes any other list, including readers of your own. The example is the
22
+ usual job: read a post's folder of transcript exports at build time and
23
+ attribute its paragraphs with `@molecule/api-text-provenance`, writing
24
+ `provenance.json`.
22
25
 
23
26
  ## Quick Start
24
27
 
25
28
  ```typescript
26
- import { readdirSync, readFileSync } from 'node:fs'
27
- import { join } from 'node:path'
28
- import { readTranscript, setProvider } from '@molecule/api-agent-transcript'
29
- import { provider } from '@molecule/api-agent-transcript-autodetect'
29
+ // provenance.ts — runs in Node at build time (a build script or a Vite plugin), never in the page.
30
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
31
+ import { dirname, join } from 'node:path'
32
+
33
+ import {
34
+ canReadTranscript,
35
+ readTranscript,
36
+ setProvider as setTranscriptReader,
37
+ } from '@molecule/api-agent-transcript'
38
+ import { provider as anyTranscript } from '@molecule/api-agent-transcript-autodetect'
39
+ import { attributeText, setProvider as setAttribution } from '@molecule/api-text-provenance'
40
+ import { provider as wordOverlap } from '@molecule/api-text-provenance-overlap'
41
+
42
+ setTranscriptReader(anyTranscript) // reads Claude Code, Codex and Molecule IDE exports
43
+ setAttribution(wordOverlap)
44
+
45
+ // One block of the post, in page order. `prompt` and `model` are on every ai span.
46
+ export interface ProvenanceSpan {
47
+ text: string // the block's markdown: a paragraph, heading, list or code block
48
+ origin: 'human' | 'ai'
49
+ prompt?: string // the person's message the AI was answering, as typed
50
+ model?: string // the model that wrote it, as the transcript names it
51
+ }
30
52
 
31
- setProvider(provider)
53
+ // What /<slug>/provenance.json holds.
54
+ export interface Provenance {
55
+ aiShare: number // 0..1, the share of the post's words the AI wrote
56
+ words: number
57
+ aiWords: number
58
+ prompts: string[] // the distinct prompts behind the ai spans, in page order
59
+ spans: ProvenanceSpan[]
60
+ }
61
+
62
+ // The post's top-level blocks: front matter dropped, split on blank lines, never inside a code fence.
63
+ export function markdownBlocks(markdown: string): string[] {
64
+ const body = markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '')
65
+ const blocks: string[] = []
66
+ let lines: string[] = []
67
+ let inFence = false
68
+ for (const line of body.split(/\r?\n/)) {
69
+ if (/^\s*(`{3}|~{3})/.test(line)) inFence = !inFence
70
+ if (!inFence && line.trim() === '') {
71
+ if (lines.length > 0) blocks.push(lines.join('\n'))
72
+ lines = []
73
+ } else {
74
+ lines.push(line)
75
+ }
76
+ }
77
+ if (lines.length > 0) blocks.push(lines.join('\n'))
78
+ return blocks
79
+ }
80
+
81
+ // Attribute one post from its markdown file and the folder holding its transcript exports.
82
+ // A missing or empty folder is a 100% human post; files that are not transcripts are skipped.
83
+ export function postProvenance(markdownFile: string, transcriptDir: string): Provenance {
84
+ const blocks = markdownBlocks(readFileSync(markdownFile, 'utf8'))
85
+ const files = existsSync(transcriptDir)
86
+ ? readdirSync(transcriptDir, { withFileTypes: true })
87
+ .filter((entry) => entry.isFile())
88
+ .map((entry) => entry.name)
89
+ .sort()
90
+ : []
91
+ const sessions = files
92
+ .map((name) => ({ text: readFileSync(join(transcriptDir, name), 'utf8'), fileName: name }))
93
+ .filter((input) => canReadTranscript(input))
94
+ .map((input) => readTranscript(input))
95
+ const result = attributeText({ paragraphs: blocks, sessions })
96
+ return {
97
+ aiShare: result.aiShare,
98
+ words: result.words,
99
+ aiWords: result.aiWords,
100
+ prompts: result.prompts,
101
+ spans: result.paragraphs.map((p): ProvenanceSpan => {
102
+ const text = blocks[p.index]
103
+ return p.origin === 'ai'
104
+ ? { text, origin: 'ai', prompt: p.prompt, model: p.model }
105
+ : { text, origin: 'human' }
106
+ }),
107
+ }
108
+ }
109
+
110
+ // Write provenance.json, creating its folder.
111
+ export function writeProvenance(outFile: string, provenance: Provenance): void {
112
+ mkdirSync(dirname(outFile), { recursive: true })
113
+ writeFileSync(outFile, `${JSON.stringify(provenance, null, 2)}\n`)
114
+ }
32
115
 
33
- const dir = 'transcripts/my-post'
34
- const sessions = readdirSync(dir).map((f) =>
35
- readTranscript({ text: readFileSync(join(dir, f), 'utf8'), fileName: f }),
36
- )
116
+ // In the build, for each PUBLISHED post (skip drafts), after the site's own build has written dist/:
117
+ // writeProvenance('dist/my-post/provenance.json', postProvenance('posts/my-post.md', 'transcripts/my-post'))
37
118
  ```
38
119
 
39
120
  ## Type
@@ -104,8 +185,16 @@ Peer dependencies:
104
185
  - `@molecule/api-agent-transcript-codex`
105
186
  - `@molecule/api-agent-transcript-molecule-ide`
106
187
 
107
- - A file no reader recognizes throws, naming the file and the readers
108
- tried — it is never read as an empty session. Check `canReadTranscript()`
109
- first to skip non-transcript files (a README beside the exports).
188
+ - **Do NOT call `readTranscript` on every file in a folder.** A file no
189
+ reader recognizes throws, naming the file and the readers tried — it is
190
+ never read as an empty session. Filter with `canReadTranscript()` first,
191
+ as the example does.
192
+ - **Do NOT parse an export yourself or bond a single-format reader "to be
193
+ safe".** This bond reads all three formats; a user turn is already only
194
+ what the person typed.
195
+ - **Do NOT import this from page or client code** — it is server-only and
196
+ throws in a browser bundle.
197
+ - Install all four packages the example uses:
198
+ `npm install @molecule/api-agent-transcript @molecule/api-agent-transcript-autodetect @molecule/api-text-provenance @molecule/api-text-provenance-overlap`.
110
199
  - Order matters only for a file two readers would both accept; the bundled
111
200
  readers accept disjoint formats.
package/dist/index.d.ts CHANGED
@@ -5,27 +5,116 @@
5
5
  * `detect()` and delegating to the first that recognizes the file: Claude Code
6
6
  * (`/export` text, session `.jsonl`), Codex CLI (Markdown export, rollout
7
7
  * `.jsonl`) and the Molecule IDE (stored conversation JSON). `createReader()`
8
- * composes any other list, including readers of your own.
8
+ * composes any other list, including readers of your own. The example is the
9
+ * usual job: read a post's folder of transcript exports at build time and
10
+ * attribute its paragraphs with `@molecule/api-text-provenance`, writing
11
+ * `provenance.json`.
9
12
  *
10
13
  * @example
11
14
  * ```typescript
12
- * import { readdirSync, readFileSync } from 'node:fs'
13
- * import { join } from 'node:path'
14
- * import { readTranscript, setProvider } from '@molecule/api-agent-transcript'
15
- * import { provider } from '@molecule/api-agent-transcript-autodetect'
15
+ * // provenance.ts — runs in Node at build time (a build script or a Vite plugin), never in the page.
16
+ * import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
17
+ * import { dirname, join } from 'node:path'
16
18
  *
17
- * setProvider(provider)
19
+ * import {
20
+ * canReadTranscript,
21
+ * readTranscript,
22
+ * setProvider as setTranscriptReader,
23
+ * } from '@molecule/api-agent-transcript'
24
+ * import { provider as anyTranscript } from '@molecule/api-agent-transcript-autodetect'
25
+ * import { attributeText, setProvider as setAttribution } from '@molecule/api-text-provenance'
26
+ * import { provider as wordOverlap } from '@molecule/api-text-provenance-overlap'
18
27
  *
19
- * const dir = 'transcripts/my-post'
20
- * const sessions = readdirSync(dir).map((f) =>
21
- * readTranscript({ text: readFileSync(join(dir, f), 'utf8'), fileName: f }),
22
- * )
28
+ * setTranscriptReader(anyTranscript) // reads Claude Code, Codex and Molecule IDE exports
29
+ * setAttribution(wordOverlap)
30
+ *
31
+ * // One block of the post, in page order. `prompt` and `model` are on every ai span.
32
+ * export interface ProvenanceSpan {
33
+ * text: string // the block's markdown: a paragraph, heading, list or code block
34
+ * origin: 'human' | 'ai'
35
+ * prompt?: string // the person's message the AI was answering, as typed
36
+ * model?: string // the model that wrote it, as the transcript names it
37
+ * }
38
+ *
39
+ * // What /<slug>/provenance.json holds.
40
+ * export interface Provenance {
41
+ * aiShare: number // 0..1, the share of the post's words the AI wrote
42
+ * words: number
43
+ * aiWords: number
44
+ * prompts: string[] // the distinct prompts behind the ai spans, in page order
45
+ * spans: ProvenanceSpan[]
46
+ * }
47
+ *
48
+ * // The post's top-level blocks: front matter dropped, split on blank lines, never inside a code fence.
49
+ * export function markdownBlocks(markdown: string): string[] {
50
+ * const body = markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '')
51
+ * const blocks: string[] = []
52
+ * let lines: string[] = []
53
+ * let inFence = false
54
+ * for (const line of body.split(/\r?\n/)) {
55
+ * if (/^\s*(`{3}|~{3})/.test(line)) inFence = !inFence
56
+ * if (!inFence && line.trim() === '') {
57
+ * if (lines.length > 0) blocks.push(lines.join('\n'))
58
+ * lines = []
59
+ * } else {
60
+ * lines.push(line)
61
+ * }
62
+ * }
63
+ * if (lines.length > 0) blocks.push(lines.join('\n'))
64
+ * return blocks
65
+ * }
66
+ *
67
+ * // Attribute one post from its markdown file and the folder holding its transcript exports.
68
+ * // A missing or empty folder is a 100% human post; files that are not transcripts are skipped.
69
+ * export function postProvenance(markdownFile: string, transcriptDir: string): Provenance {
70
+ * const blocks = markdownBlocks(readFileSync(markdownFile, 'utf8'))
71
+ * const files = existsSync(transcriptDir)
72
+ * ? readdirSync(transcriptDir, { withFileTypes: true })
73
+ * .filter((entry) => entry.isFile())
74
+ * .map((entry) => entry.name)
75
+ * .sort()
76
+ * : []
77
+ * const sessions = files
78
+ * .map((name) => ({ text: readFileSync(join(transcriptDir, name), 'utf8'), fileName: name }))
79
+ * .filter((input) => canReadTranscript(input))
80
+ * .map((input) => readTranscript(input))
81
+ * const result = attributeText({ paragraphs: blocks, sessions })
82
+ * return {
83
+ * aiShare: result.aiShare,
84
+ * words: result.words,
85
+ * aiWords: result.aiWords,
86
+ * prompts: result.prompts,
87
+ * spans: result.paragraphs.map((p): ProvenanceSpan => {
88
+ * const text = blocks[p.index]
89
+ * return p.origin === 'ai'
90
+ * ? { text, origin: 'ai', prompt: p.prompt, model: p.model }
91
+ * : { text, origin: 'human' }
92
+ * }),
93
+ * }
94
+ * }
95
+ *
96
+ * // Write provenance.json, creating its folder.
97
+ * export function writeProvenance(outFile: string, provenance: Provenance): void {
98
+ * mkdirSync(dirname(outFile), { recursive: true })
99
+ * writeFileSync(outFile, `${JSON.stringify(provenance, null, 2)}\n`)
100
+ * }
101
+ *
102
+ * // In the build, for each PUBLISHED post (skip drafts), after the site's own build has written dist/:
103
+ * // writeProvenance('dist/my-post/provenance.json', postProvenance('posts/my-post.md', 'transcripts/my-post'))
23
104
  * ```
24
105
  *
25
106
  * @remarks
26
- * - A file no reader recognizes throws, naming the file and the readers
27
- * tried — it is never read as an empty session. Check `canReadTranscript()`
28
- * first to skip non-transcript files (a README beside the exports).
107
+ * - **Do NOT call `readTranscript` on every file in a folder.** A file no
108
+ * reader recognizes throws, naming the file and the readers tried — it is
109
+ * never read as an empty session. Filter with `canReadTranscript()` first,
110
+ * as the example does.
111
+ * - **Do NOT parse an export yourself or bond a single-format reader "to be
112
+ * safe".** This bond reads all three formats; a user turn is already only
113
+ * what the person typed.
114
+ * - **Do NOT import this from page or client code** — it is server-only and
115
+ * throws in a browser bundle.
116
+ * - Install all four packages the example uses:
117
+ * `npm install @molecule/api-agent-transcript @molecule/api-agent-transcript-autodetect @molecule/api-text-provenance @molecule/api-text-provenance-overlap`.
29
118
  * - Order matters only for a file two readers would both accept; the bundled
30
119
  * readers accept disjoint formats.
31
120
  *
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,eAAe,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyHG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,eAAe,CAAA"}
package/dist/index.js CHANGED
@@ -5,27 +5,116 @@
5
5
  * `detect()` and delegating to the first that recognizes the file: Claude Code
6
6
  * (`/export` text, session `.jsonl`), Codex CLI (Markdown export, rollout
7
7
  * `.jsonl`) and the Molecule IDE (stored conversation JSON). `createReader()`
8
- * composes any other list, including readers of your own.
8
+ * composes any other list, including readers of your own. The example is the
9
+ * usual job: read a post's folder of transcript exports at build time and
10
+ * attribute its paragraphs with `@molecule/api-text-provenance`, writing
11
+ * `provenance.json`.
9
12
  *
10
13
  * @example
11
14
  * ```typescript
12
- * import { readdirSync, readFileSync } from 'node:fs'
13
- * import { join } from 'node:path'
14
- * import { readTranscript, setProvider } from '@molecule/api-agent-transcript'
15
- * import { provider } from '@molecule/api-agent-transcript-autodetect'
15
+ * // provenance.ts — runs in Node at build time (a build script or a Vite plugin), never in the page.
16
+ * import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
17
+ * import { dirname, join } from 'node:path'
16
18
  *
17
- * setProvider(provider)
19
+ * import {
20
+ * canReadTranscript,
21
+ * readTranscript,
22
+ * setProvider as setTranscriptReader,
23
+ * } from '@molecule/api-agent-transcript'
24
+ * import { provider as anyTranscript } from '@molecule/api-agent-transcript-autodetect'
25
+ * import { attributeText, setProvider as setAttribution } from '@molecule/api-text-provenance'
26
+ * import { provider as wordOverlap } from '@molecule/api-text-provenance-overlap'
18
27
  *
19
- * const dir = 'transcripts/my-post'
20
- * const sessions = readdirSync(dir).map((f) =>
21
- * readTranscript({ text: readFileSync(join(dir, f), 'utf8'), fileName: f }),
22
- * )
28
+ * setTranscriptReader(anyTranscript) // reads Claude Code, Codex and Molecule IDE exports
29
+ * setAttribution(wordOverlap)
30
+ *
31
+ * // One block of the post, in page order. `prompt` and `model` are on every ai span.
32
+ * export interface ProvenanceSpan {
33
+ * text: string // the block's markdown: a paragraph, heading, list or code block
34
+ * origin: 'human' | 'ai'
35
+ * prompt?: string // the person's message the AI was answering, as typed
36
+ * model?: string // the model that wrote it, as the transcript names it
37
+ * }
38
+ *
39
+ * // What /<slug>/provenance.json holds.
40
+ * export interface Provenance {
41
+ * aiShare: number // 0..1, the share of the post's words the AI wrote
42
+ * words: number
43
+ * aiWords: number
44
+ * prompts: string[] // the distinct prompts behind the ai spans, in page order
45
+ * spans: ProvenanceSpan[]
46
+ * }
47
+ *
48
+ * // The post's top-level blocks: front matter dropped, split on blank lines, never inside a code fence.
49
+ * export function markdownBlocks(markdown: string): string[] {
50
+ * const body = markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '')
51
+ * const blocks: string[] = []
52
+ * let lines: string[] = []
53
+ * let inFence = false
54
+ * for (const line of body.split(/\r?\n/)) {
55
+ * if (/^\s*(`{3}|~{3})/.test(line)) inFence = !inFence
56
+ * if (!inFence && line.trim() === '') {
57
+ * if (lines.length > 0) blocks.push(lines.join('\n'))
58
+ * lines = []
59
+ * } else {
60
+ * lines.push(line)
61
+ * }
62
+ * }
63
+ * if (lines.length > 0) blocks.push(lines.join('\n'))
64
+ * return blocks
65
+ * }
66
+ *
67
+ * // Attribute one post from its markdown file and the folder holding its transcript exports.
68
+ * // A missing or empty folder is a 100% human post; files that are not transcripts are skipped.
69
+ * export function postProvenance(markdownFile: string, transcriptDir: string): Provenance {
70
+ * const blocks = markdownBlocks(readFileSync(markdownFile, 'utf8'))
71
+ * const files = existsSync(transcriptDir)
72
+ * ? readdirSync(transcriptDir, { withFileTypes: true })
73
+ * .filter((entry) => entry.isFile())
74
+ * .map((entry) => entry.name)
75
+ * .sort()
76
+ * : []
77
+ * const sessions = files
78
+ * .map((name) => ({ text: readFileSync(join(transcriptDir, name), 'utf8'), fileName: name }))
79
+ * .filter((input) => canReadTranscript(input))
80
+ * .map((input) => readTranscript(input))
81
+ * const result = attributeText({ paragraphs: blocks, sessions })
82
+ * return {
83
+ * aiShare: result.aiShare,
84
+ * words: result.words,
85
+ * aiWords: result.aiWords,
86
+ * prompts: result.prompts,
87
+ * spans: result.paragraphs.map((p): ProvenanceSpan => {
88
+ * const text = blocks[p.index]
89
+ * return p.origin === 'ai'
90
+ * ? { text, origin: 'ai', prompt: p.prompt, model: p.model }
91
+ * : { text, origin: 'human' }
92
+ * }),
93
+ * }
94
+ * }
95
+ *
96
+ * // Write provenance.json, creating its folder.
97
+ * export function writeProvenance(outFile: string, provenance: Provenance): void {
98
+ * mkdirSync(dirname(outFile), { recursive: true })
99
+ * writeFileSync(outFile, `${JSON.stringify(provenance, null, 2)}\n`)
100
+ * }
101
+ *
102
+ * // In the build, for each PUBLISHED post (skip drafts), after the site's own build has written dist/:
103
+ * // writeProvenance('dist/my-post/provenance.json', postProvenance('posts/my-post.md', 'transcripts/my-post'))
23
104
  * ```
24
105
  *
25
106
  * @remarks
26
- * - A file no reader recognizes throws, naming the file and the readers
27
- * tried — it is never read as an empty session. Check `canReadTranscript()`
28
- * first to skip non-transcript files (a README beside the exports).
107
+ * - **Do NOT call `readTranscript` on every file in a folder.** A file no
108
+ * reader recognizes throws, naming the file and the readers tried — it is
109
+ * never read as an empty session. Filter with `canReadTranscript()` first,
110
+ * as the example does.
111
+ * - **Do NOT parse an export yourself or bond a single-format reader "to be
112
+ * safe".** This bond reads all three formats; a user turn is already only
113
+ * what the person typed.
114
+ * - **Do NOT import this from page or client code** — it is server-only and
115
+ * throws in a browser bundle.
116
+ * - Install all four packages the example uses:
117
+ * `npm install @molecule/api-agent-transcript @molecule/api-agent-transcript-autodetect @molecule/api-text-provenance @molecule/api-text-provenance-overlap`.
29
118
  * - Order matters only for a file two readers would both accept; the bundled
30
119
  * readers accept disjoint formats.
31
120
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-agent-transcript-autodetect",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Picks the reader that recognizes a transcript and delegates to it; bundles the Claude Code, Codex and Molecule IDE readers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -39,7 +39,7 @@
39
39
  "@molecule/api-agent-transcript": "^1.0.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@molecule/api-agent-transcript": "1.0.0",
42
+ "@molecule/api-agent-transcript": "1.0.1",
43
43
  "@types/node": "26.1.2",
44
44
  "typescript": "6.0.3",
45
45
  "vitest": "4.1.11"