1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124import { writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { loadAgent, type AgentSpec } from "../agents/loader.ts";
import { extractJson } from "../util/json.ts";
import { feedbackPreamble, loadImage, type InputImage, type PipelineContext } from "./context.ts";
import { ACCESSIBILITY_REQUIREMENTS } from "./accessibility.ts";
import { buildAgent } from "./builder.ts";
import type { Fragment } from "./fragment.ts";
import type { TriageNotes } from "./triage.ts";
export interface NoContentSignal {
agent: string;
image: string;
}
export interface ExtractionResult {
fragments: Fragment[];
noContent: NoContentSignal[];
}
interface AgentJson {
no_content?: boolean;
fragments?: { html?: string; fragment_edges?: string[]; log?: string }[];
}
function outputInstruction(notes: TriageNotes): string {
// Per PRD §7.4 the content agent receives the full source image plus the
// notes file for that image — we pass the notes markdown verbatim from disk.
const notesFile = readFileSync(notes.notesPath, "utf8");
return `You are processing source image "${notes.image}". Its triage notes file:
\`\`\`markdown
${notesFile}
\`\`\`
Extract ONLY content matching your declared content type from the full image. If none is
present, return {"no_content": true}. Otherwise respond with ONLY this JSON:
{
"no_content": false,
"fragments": [
{ "html": "<accessible HTML for this block, no provenance comments>",
"fragment_edges": ["bottom-edge"],
"log": "note any cut-off edges with enough context for reconciliation" }
]
}`;
}
// PRD §7.3 + §7.4: read each notes file, dispatch the listed content agents
// against the full source image, build missing agents, and collect fragments.
export async function runExtraction(
ctx: PipelineContext,
triage: TriageNotes[],
): Promise<ExtractionResult> {
const fragments: Fragment[] = [];
const noContent: NoContentSignal[] = [];
// Cache built agents so the same type is reused for later images (§7.5).
const builtCache = new Map<string, AgentSpec>();
for (const notes of triage) {
const img = ctx.images.find((i) => i.name === notes.image)!;
const regionCounters = new Map<string, number>();
for (const agentFile of notes.agentCalls) {
const logical = agentFile.replace(/\.md$/, "");
let agent =
loadAgent(agentFile, {
agentsDir: ctx.paths.agentsDir,
tmpAgentsDir: ctx.paths.tmpAgentsDir(ctx.sessionId),
}) ?? builtCache.get(logical) ?? null;
// No matching agent file -> invoke the Builder Agent and resume (§7.3).
if (!agent) {
agent = await buildAgent(ctx, logical, img);
builtCache.set(logical, agent);
}
const system = `${agent.content}\n\n${ACCESSIBILITY_REQUIREMENTS}`;
const user = outputInstruction(notes) + feedbackPreamble(ctx);
const capability = agent.capabilities.includes("vision") ? "vision" : "text";
const res = await ctx.router.complete(
agent.name,
capability,
[
{ role: "system", content: system },
{ role: "user", content: user },
],
{ images: [loadImage(img)] },
);
ctx.log.agentCall({ agent, phase: "extraction", image: img.name, output: res.text });
const parsed = extractJson<AgentJson>(res.text);
if (!parsed || parsed.no_content || !parsed.fragments?.length) {
// no-content signal: logged and surfaced to the Reader later (§7.3).
noContent.push({ agent: agent.file, image: img.name });
ctx.log.event("no_content", { agent: agent.file, image: img.name });
continue;
}
for (const fr of parsed.fragments) {
if (!fr.html) continue;
const n = (regionCounters.get(logical) ?? 0) + 1;
regionCounters.set(logical, n);
fragments.push({
image: img.name,
order: img.order,
agent: agent.file,
region: `region-${logical}-${n}`,
innerHtml: fr.html,
edges: fr.fragment_edges ?? [],
log: fr.log ?? "",
});
}
}
}
// Persist the fragment log (PRD §8.1 fragments/).
writeFileSync(
join(ctx.paths.sessionFragments(ctx.sessionId), "fragments.json"),
JSON.stringify(fragments, null, 2),
);
return { fragments, noContent };
}