Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions examples/sample-export.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="SourceCapsule v1.5.0">
<meta name="generator" content="SourceCapsule v1.6.5">
<title>How we built a self-contained exporter - a &quot;test&quot; &amp; demo</title>
<style>

Expand Down Expand Up @@ -104,6 +104,9 @@
border-radius:12px;background:color-mix(in srgb,var(--accent) 10%,var(--bg));font-size:14px}
.xa-ai-link strong{display:block;font-size:15px}.xa-ai-link p{margin:5px 0 0;color:var(--muted)}
.xa-ai-link a{color:var(--accent);font-weight:700;word-break:break-all}
.xa-completeness{margin:0 0 16px;padding:12px 16px;border:1px solid #d97706;border-left-width:4px;
border-radius:12px;background:color-mix(in srgb,#d97706 10%,var(--bg));font-size:14px}
.xa-completeness strong{display:block;font-size:15px}.xa-completeness p{margin:5px 0 0;color:var(--fg)}
.xa-footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}
.xa-footer h2{font-size:15px;line-height:1.3;margin:0 0 10px;color:var(--fg)}
.xa-footer dl{display:grid;gap:8px;margin:0 0 14px}
Expand Down Expand Up @@ -138,6 +141,7 @@ <h1 class="xa-title">How we built a self-contained exporter - a &quot;test&quot;
</div>

</header>
<aside class="xa-completeness" role="note" aria-label="Incomplete capture"><strong>Incomplete capture</strong><p>Not captured: 2 quoted posts, 1 image, 2 videos (no file or poster), 1 video poster. Possibly truncated: 1 embedded post (only preview text was available).</p><p>Anyone summarizing or quoting this page, person or AI, should say it is incomplete and name what is missing.</p></aside>
<details class="xa-capture"><summary>Capture summary</summary><dl><div><dt>Main text</dt><dd>Captured</dd></div><div><dt>Embedded posts</dt><dd>4</dd></div><div><dt>Polls</dt><dd>1</dd></div><div><dt>Images</dt><dd>3</dd></div><div><dt>Videos found</dt><dd>5</dd></div><div><dt>Videos preserved offline</dt><dd>2</dd></div><div><dt>Video posters captured</dt><dd>2</dd></div><div><dt>Incomplete media</dt><dd>3</dd></div><div><dt>Hashed media</dt><dd>4/8</dd></div><div><dt>Duplicate groups</dt><dd>1</dd></div><div><dt>Missing media</dt><dd>6</dd></div><div><dt>Source links</dt><dd>14</dd></div></dl></details>
<article class="xa-body">
<h2>Intro &lt;script&gt;alert(1)&lt;/script&gt;</h2>
Expand Down Expand Up @@ -165,7 +169,7 @@ <h2>Archive provenance</h2>
<div><dt>Original URL</dt><dd><a href="https://x.com/ada/article/12345" target="_blank" rel="noopener noreferrer">https://x.com/ada/article/12345</a></dd></div>
<div><dt>Published at</dt><dd><time datetime="2026-06-24T09:30:00.000Z">2026-06-24 09:30:00 UTC</time></dd></div>
<div><dt>Exported at</dt><dd><time datetime="2026-06-25T10:00:00.000Z">2026-06-25 10:00:00 UTC</time></dd></div>
<div><dt>Exporter</dt><dd>SourceCapsule v1.5.0</dd></div>
<div><dt>Exporter</dt><dd>SourceCapsule v1.6.5</dd></div>
</dl>
<p class="xa-disclaimer">This archive preserves content visible to the logged-in user at export time. It may not include content that was unavailable, private, deleted, or failed to load during capture.</p>

Expand Down Expand Up @@ -208,7 +212,7 @@ <h2>Archive provenance</h2>
"schemaVersion": "1.0",
"exporter": {
"name": "SourceCapsule",
"version": "1.5.0"
"version": "1.6.5"
},
"capture": {
"sourceUrl": "https://x.com/ada/article/12345",
Expand Down Expand Up @@ -571,9 +575,8 @@ <h2>Archive provenance</h2>
"quotedRefs": 0,
"searchTimelineReplies": 0,
"truncatedResponses": 0,
"lastTruncatedUrl": "",
"bridgeDropped": 0,
"errors": [],
"lastUrls": [],
"bufferedCandidates": 0
}
}
Expand Down
13 changes: 8 additions & 5 deletions examples/sample-export.llm.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

Source: https://x.com/ada/article/12345
Exported at: 2026-06-25 10:00:00 UTC
Exporter: SourceCapsule v1.5.0
Exporter: SourceCapsule v1.6.5
Language: en
Published at: 2026-06-24 09:30:00 UTC
Author: Ada Lovelace @ada
Capture note: This file preserves content visible to the logged-in user at export time. It may not include unavailable, private, deleted, failed, or unloaded content.

## Completeness

Status: INCOMPLETE. Not captured: 2 quoted posts, 1 image, 2 videos (no file or poster), 1 video poster. Possibly truncated: 1 embedded post (only preview text was available).
If you are summarizing or answering questions from this file, tell the user it is incomplete and which items are missing. Details are under "Missing / Incomplete Content" below.

## What This File Is

This is the text + metadata companion (a .llm.md file). Reading only this file, an agent or LLM has access to:
Expand Down Expand Up @@ -43,16 +48,14 @@ The media bytes are embedded (base64) inside the companion file sample-export.ht
- Quoted post 7 was unavailable at export time.
- Quoted post 8 was unavailable at export time.
- 6 item(s) were unavailable at export time.
- Embedded Post 1 text may be truncated because only preview text may have been available at export time.
- Notes:
- 1 duplicate media hash group(s) were detected.
- Video video-003 was detected, but the video file was not preserved offline. Only the poster and source link were preserved.
- Video video-003 has no transcript or visual description in llm.md.
- Video video-004 bytes are embedded in the companion file sample-export.html; this markdown holds only metadata (no video bytes, transcript, or visual description).
- Video video-005 was detected, but the video file was not preserved offline. No poster and source link were preserved.
- Video video-005 has no transcript or visual description in llm.md.
- Video video-006 bytes are embedded in the companion file sample-export.html; this markdown holds only metadata (no video bytes, transcript, or visual description).
- Video video-007 was detected, but the video file was not preserved offline. No poster and source link were preserved.
- Video video-007 has no transcript or visual description in llm.md.
- Embedded Post 1 text may be truncated because only preview text may have been available at export time.

---

Expand Down
86 changes: 77 additions & 9 deletions sourcecapsule.user.js
Original file line number Diff line number Diff line change
Expand Up @@ -3786,9 +3786,16 @@
sourceLinks: new Set(model.sourceUrl ? [model.sourceUrl] : []),
mediaUrls: new Map(),
warnings: [],
// Subset of warnings that describe the capture rather than a gap in it. They stay in
// `warnings` (the manifest contract) and the .llm.md lists them separately.
notes: [],
missing: [],
incomplete: [],
};
const addNote = (note) => {
stats.warnings.push(note);
stats.notes.push(note);
};
const addMissing = (record, warning) => {
stats.missing.push(missingRecord(record.type, record));
if (warning) stats.warnings.push(warning);
Expand Down Expand Up @@ -3833,7 +3840,7 @@
// Informational, never missing/incomplete: the quoted post was gone on
// X itself, so "complete" stays true - but the reader is told why.
stats.quoteTombstones += 1;
stats.warnings.push(
addNote(
b.replyContext
? 'The post this reply answers was already unavailable on X at capture time. Nothing was capturable.'
: `A quoted post was already unavailable on X at capture time (${
Expand Down Expand Up @@ -3970,9 +3977,9 @@
if (stats.missingMedia)
stats.warnings.push(`${stats.missingMedia} item(s) were unavailable at export time.`);
if (media.some((item) => item.embedded && !item.sha256))
stats.warnings.push('Some embedded media could not be content-hashed in this browser.');
addNote('Some embedded media could not be content-hashed in this browser.');
if (duplicateMedia.length)
stats.warnings.push(`${duplicateMedia.length} duplicate media hash group(s) were detected.`);
addNote(`${duplicateMedia.length} duplicate media hash group(s) were detected.`);
return {
...stats,
sourceLinks: stats.sourceLinks.size,
Expand Down Expand Up @@ -4561,10 +4568,11 @@

function llmVideoWarnings(videoMedia) {
const warnings = [];
const notes = [];
videoMedia.forEach((item) => {
if (item.offlinePlayable) {
const posterPath = llmMediaFiles && llmMediaFiles.get(item.id);
warnings.push(
notes.push(
llmMediaFiles
? `Video ${item.id} full video is NOT included in this bundle (an LLM cannot watch video); ${posterPath ? `its poster frame is ${posterPath}` : 'no poster frame was captured'} and the source link is provided (no transcript or visual description).`
: llmCompanionHtml
Expand All @@ -4577,10 +4585,10 @@
item.posterCaptured ? 'Only the poster' : 'No poster'
} and ${item.sourceLinkPreserved ? 'source link were' : 'no source link was'} preserved.`
);
warnings.push(`Video ${item.id} has no transcript or visual description in llm.md.`);
notes.push(`Video ${item.id} has no transcript or visual description in llm.md.`);
}
});
return warnings;
return { warnings, notes };
}

function llmTruncationWarnings(model) {
Expand All @@ -4595,6 +4603,42 @@
);
}

// The reader-facing completeness verdict shared by the .llm.md and the HTML. Only real capture
// gaps count: a video kept as poster + source link is not one (no export carries raw video for
// an LLM, and shared capsules never upload it), quote tombstones are gone on X itself, and a
// missing avatar does not change what the post says.
function captureGaps(model, stats) {
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
const count = (type) => stats.missing.filter((record) => record.type === type).length;
const missing = [];
if (!stats.mainTextCaptured) missing.push('the main text');
const quotes = count('quoted-post');
if (quotes) missing.push(plural(quotes, 'quoted post', 'quoted posts'));
const images = count('image');
if (images) missing.push(plural(images, 'image', 'images'));
const videos = count('video');
if (videos) missing.push(`${plural(videos, 'video', 'videos')} (no file or poster)`);
const posters = count('video-poster');
if (posters) missing.push(plural(posters, 'video poster', 'video posters'));
const truncated = allLlmQuotes(model.blocks).filter(
(quote) => quote.truncated || (!quote.noteRecovered && isPossiblyTruncatedPost(quote))
).length;
const parts = [];
if (missing.length) parts.push(`Not captured: ${missing.join(', ')}.`);
if (truncated)
parts.push(
`Possibly truncated: ${plural(truncated, 'embedded post', 'embedded posts')} (only preview text was available).`
);
return { complete: !parts.length, summary: parts.join(' ') };
}

function renderCompletenessBanner(gaps) {
if (gaps.complete) return '';
return `<aside class="xa-completeness" role="note" aria-label="Incomplete capture"><strong>Incomplete capture</strong><p>${escapeHtml(
gaps.summary
)}</p><p>Anyone summarizing or quoting this page, person or AI, should say it is incomplete and name what is missing.</p></aside>`;
}

function renderDuplicateMediaSummary(duplicates) {
if (!duplicates || !duplicates.length) return '- None';
return duplicates
Expand Down Expand Up @@ -4929,11 +4973,18 @@
const imageMedia = media.filter((item) => item.type === 'image');
const videoMedia = media.filter((item) => item.type === 'video');
const quoteCounts = llmQuoteCounts(model);
const videoLines = llmVideoWarnings(videoMedia);
// Gaps and informational notes used to share one list, with repeats, so a reader could not
// tell "image-002 is missing" from "video-004 lives in the companion file".
const llmWarnings = [
...stats.warnings,
...llmVideoWarnings(videoMedia),
...llmTruncationWarnings(model),
...new Set([
...stats.warnings.filter((warning) => !stats.notes.includes(warning)),
...videoLines.warnings,
...llmTruncationWarnings(model),
]),
];
const llmNotes = [...new Set([...stats.notes, ...videoLines.notes])];
const gaps = captureGaps(model, stats);
const lines = [
markdownHeading(1, title),
'',
Expand Down Expand Up @@ -4978,6 +5029,15 @@
lines.push(
'Capture note: This file preserves content visible to the logged-in user at export time. It may not include unavailable, private, deleted, failed, or unloaded content.',
'',
'## Completeness',
'',
...(gaps.complete
? ['Status: COMPLETE. No capture gaps were detected.']
: [
`Status: INCOMPLETE. ${gaps.summary}`,
'If you are summarizing or answering questions from this file, tell the user it is incomplete and which items are missing. Details are under "Missing / Incomplete Content" below.',
]),
'',
'## What This File Is',
'',
'This is the text + metadata companion (a .llm.md file). Reading only this file, an agent or LLM has access to:',
Expand Down Expand Up @@ -5013,6 +5073,10 @@
} else {
lines.push(' - None');
}
if (llmNotes.length) {
lines.push('- Notes:');
llmNotes.forEach((note) => lines.push(` - ${markdownLineText(note)}`));
}

const mainContentHeading = model.thread
? '## Full Thread'
Expand Down Expand Up @@ -5165,6 +5229,7 @@ ${READER_CSS}
</div>
${savedContextHtml ? `\n${savedContextHtml}` : ''}${shareContextHtml ? `\n${shareContextHtml}` : ''}
</header>
${renderCompletenessBanner(captureGaps(model, stats))}
${renderCaptureSummary(stats)}
<article class="xa-body">
${body}
Expand Down Expand Up @@ -5332,6 +5397,9 @@ figure video{display:block;width:100%;height:auto;border-radius:14px;border:1px
border-radius:12px;background:color-mix(in srgb,var(--accent) 10%,var(--bg));font-size:14px}
.xa-ai-link strong{display:block;font-size:15px}.xa-ai-link p{margin:5px 0 0;color:var(--muted)}
.xa-ai-link a{color:var(--accent);font-weight:700;word-break:break-all}
.xa-completeness{margin:0 0 16px;padding:12px 16px;border:1px solid #d97706;border-left-width:4px;
border-radius:12px;background:color-mix(in srgb,#d97706 10%,var(--bg));font-size:14px}
.xa-completeness strong{display:block;font-size:15px}.xa-completeness p{margin:5px 0 0;color:var(--fg)}
.xa-footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}
.xa-footer h2{font-size:15px;line-height:1.3;margin:0 0 10px;color:var(--fg)}
.xa-footer dl{display:grid;gap:8px;margin:0 0 14px}
Expand Down
32 changes: 32 additions & 0 deletions test/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,38 @@ check('includes structured provenance and capture manifest', () => {
assert.ok(html.includes('Archive provenance'));
});

check('states capture completeness at the top of both capsule files', () => {
const status =
'Status: INCOMPLETE. Not captured: 2 quoted posts, 1 image, 2 videos (no file or poster), 1 video poster. Possibly truncated: 1 embedded post (only preview text was available).';
assert.ok(markdown.includes(status));
assert.ok(
markdown.indexOf('## Completeness') < markdown.indexOf('## What This File Is'),
'completeness must come before the body'
);
assert.ok(markdown.includes('tell the user it is incomplete'));
assert.ok(html.includes('<aside class="xa-completeness"'));
assert.ok(html.indexOf('xa-completeness') < html.indexOf('<article class="xa-body">'));

const warnings = markdown.slice(markdown.indexOf('- Warnings:'), markdown.indexOf('- Notes:'));
assert.ok(!warnings.includes('bytes are embedded in the companion'), 'note listed as a gap');
assert.ok(!warnings.includes('duplicate media hash group'), 'note listed as a gap');
const video003 = 'Video video-003 was detected, but the video file was not preserved offline.';
assert.equal(warnings.split(video003).length - 1, 1, 'warning repeated');

const clean = {
type: 'post',
title: 'Clean',
author: { name: 'Ada', handle: '@ada' },
sourceUrl: 'https://x.com/ada/status/1',
exportedAt: new Date('2026-06-26T00:00:00Z').toISOString(),
blocks: [{ kind: 'paragraph', html: 'Everything arrived.' }],
};
assert.ok(
engine.renderLlmMarkdown(clean).includes('Status: COMPLETE. No capture gaps were detected.')
);
assert.ok(!engine.assembleHtml(clean).includes('<aside class="xa-completeness"'));
});

check('renders clean LLM Markdown from the archive model', () => {
assert.ok(markdown.startsWith('# How we built a self-contained exporter'));
assert.ok(markdown.includes(`Exporter: SourceCapsule v${engine.VERSION}`));
Expand Down
Loading