@crewhaus/report-writer 0.1.4 → 0.1.5

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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Catalog R-orch `report-writer` — Section 23 RES.
3
+ *
4
+ * Assembles a research run's markdown + JSON report from:
5
+ * - the goal,
6
+ * - the planner's sub-questions,
7
+ * - one answer per branch,
8
+ * - the citation tracker.
9
+ *
10
+ * Citation numbering is the load-bearing detail: numbers are assigned by
11
+ * URL on first appearance (NOT by the order the agent emitted citations
12
+ * within a branch), so a re-run that takes a slightly different path
13
+ * but ends up with the same citation set produces a byte-identical
14
+ * citation block — the smoke's "deterministic ordering" assertion.
15
+ *
16
+ * The markdown report is structured for terminal `less` reading:
17
+ * # <goal>
18
+ *
19
+ * ## <sub-question 1>
20
+ * <answer 1, with [N] markers>
21
+ *
22
+ * ## <sub-question 2>
23
+ * <answer 2, with [N] markers>
24
+ *
25
+ * ## Citations
26
+ * [1] <url>
27
+ * <snippet>
28
+ * [2] <url>
29
+ * <snippet>
30
+ *
31
+ * The JSON report carries the same data programmatically so studio /
32
+ * downstream consumers don't have to re-parse markdown.
33
+ */
34
+ import type { Citation } from "@crewhaus/citation-tracker";
35
+ export type BranchAnswer = {
36
+ readonly question: string;
37
+ readonly answer: string;
38
+ /** URLs the agent emitted citations against during this branch. */
39
+ readonly citationUrls: ReadonlyArray<string>;
40
+ };
41
+ export type Report = {
42
+ readonly markdown: string;
43
+ readonly json: ReportJson;
44
+ };
45
+ export type ReportJson = {
46
+ readonly version: 1;
47
+ readonly goal: string;
48
+ readonly subAnswers: ReadonlyArray<{
49
+ readonly question: string;
50
+ readonly answer: string;
51
+ readonly citationNumbers: ReadonlyArray<number>;
52
+ }>;
53
+ readonly citations: ReadonlyArray<NumberedCitation>;
54
+ readonly generatedAt: string;
55
+ };
56
+ export type NumberedCitation = {
57
+ readonly number: number;
58
+ readonly url: string;
59
+ readonly snippet: string;
60
+ readonly sha256: string;
61
+ readonly retrievedAt: string;
62
+ };
63
+ export type WriteReportArgs = {
64
+ readonly goal: string;
65
+ readonly branches: ReadonlyArray<BranchAnswer>;
66
+ /** Append-ordered list of citations from the tracker. */
67
+ readonly citations: ReadonlyArray<Citation>;
68
+ readonly now?: () => Date;
69
+ };
70
+ export declare function writeReport(args: WriteReportArgs): Report;
package/dist/index.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Number citations by URL on first appearance. Same URL appearing in
3
+ * multiple branches reuses the first-assigned number; the snippet text
4
+ * for the numbered entry comes from the FIRST citation against that URL.
5
+ */
6
+ function numberCitations(citations) {
7
+ const numberByUrl = new Map();
8
+ const numbered = [];
9
+ for (const c of citations) {
10
+ if (numberByUrl.has(c.url))
11
+ continue;
12
+ const n = numberByUrl.size + 1;
13
+ numberByUrl.set(c.url, n);
14
+ numbered.push({
15
+ number: n,
16
+ url: c.url,
17
+ snippet: c.snippet,
18
+ sha256: c.sha256,
19
+ retrievedAt: c.retrievedAt,
20
+ });
21
+ }
22
+ return { numbered, numberByUrl };
23
+ }
24
+ function citationNumbersForBranch(branch, numberByUrl) {
25
+ const seen = new Set();
26
+ const out = [];
27
+ for (const url of branch.citationUrls) {
28
+ const n = numberByUrl.get(url);
29
+ if (n === undefined)
30
+ continue;
31
+ if (seen.has(n))
32
+ continue;
33
+ seen.add(n);
34
+ out.push(n);
35
+ }
36
+ out.sort((a, b) => a - b);
37
+ return out;
38
+ }
39
+ function renderMarkdown(args) {
40
+ const lines = [];
41
+ lines.push(`# ${args.goal}`);
42
+ lines.push("");
43
+ for (const b of args.branches) {
44
+ lines.push(`## ${b.question}`);
45
+ lines.push("");
46
+ lines.push(b.answer.trim());
47
+ lines.push("");
48
+ }
49
+ if (args.numbered.length > 0) {
50
+ lines.push("## Citations");
51
+ lines.push("");
52
+ for (const c of args.numbered) {
53
+ lines.push(`[${c.number}] ${c.url}`);
54
+ const trimmed = c.snippet.trim().replace(/\n+/g, " ");
55
+ lines.push(` ${trimmed}`);
56
+ }
57
+ }
58
+ return `${lines.join("\n").trimEnd()}\n`;
59
+ }
60
+ export function writeReport(args) {
61
+ const { numbered, numberByUrl } = numberCitations(args.citations);
62
+ const subAnswers = args.branches.map((b) => ({
63
+ question: b.question,
64
+ answer: b.answer,
65
+ citationNumbers: citationNumbersForBranch(b, numberByUrl),
66
+ }));
67
+ const now = args.now ?? (() => new Date());
68
+ const json = {
69
+ version: 1,
70
+ goal: args.goal,
71
+ subAnswers,
72
+ citations: numbered,
73
+ generatedAt: now().toISOString(),
74
+ };
75
+ const markdown = renderMarkdown({ goal: args.goal, branches: args.branches, numbered });
76
+ return { markdown, json };
77
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/report-writer",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Markdown + JSON report assembly from citations + branch answers (Section 23 RES)",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/citation-tracker": "0.1.4",
16
- "@crewhaus/errors": "0.1.4"
18
+ "@crewhaus/citation-tracker": "0.1.5",
19
+ "@crewhaus/errors": "0.1.5"
17
20
  },
18
21
  "license": "Apache-2.0",
19
22
  "author": {
@@ -33,5 +36,5 @@
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
37
40
  }
package/src/index.test.ts DELETED
@@ -1,107 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { Citation } from "@crewhaus/citation-tracker";
3
- import { writeReport } from "./index.js";
4
-
5
- function citation(url: string, snippet: string, branchId?: string): Citation {
6
- return {
7
- version: 1,
8
- url,
9
- snippet,
10
- retrievedAt: "2026-05-08T00:00:00.000Z",
11
- sha256: "0".repeat(64),
12
- ...(branchId !== undefined ? { branchId } : {}),
13
- };
14
- }
15
-
16
- describe("writeReport", () => {
17
- test("numbers citations by URL on first appearance", () => {
18
- const citations: Citation[] = [
19
- citation("https://a.example.com", "snippet A"),
20
- citation("https://b.example.com", "snippet B"),
21
- citation("https://a.example.com", "snippet A again"),
22
- ];
23
- const r = writeReport({
24
- goal: "g",
25
- branches: [
26
- {
27
- question: "q1",
28
- answer: "answer 1",
29
- citationUrls: ["https://a.example.com", "https://b.example.com"],
30
- },
31
- ],
32
- citations,
33
- });
34
- expect(r.json.citations.map((c) => `${c.number}:${c.url}`)).toEqual([
35
- "1:https://a.example.com",
36
- "2:https://b.example.com",
37
- ]);
38
- // Branch's citationNumbers are sorted, with same-URL collapsed.
39
- expect(r.json.subAnswers[0]?.citationNumbers).toEqual([1, 2]);
40
- });
41
-
42
- test("re-running with identical citation set produces byte-identical citation blocks (deterministic ordering)", () => {
43
- const citationsA: Citation[] = [
44
- citation("https://a.example.com", "snip a"),
45
- citation("https://b.example.com", "snip b"),
46
- citation("https://c.example.com", "snip c"),
47
- ];
48
- const citationsB: Citation[] = [...citationsA];
49
- const ra = writeReport({
50
- goal: "g",
51
- branches: [{ question: "q1", answer: "ans", citationUrls: ["https://a.example.com"] }],
52
- citations: citationsA,
53
- now: () => new Date("2026-01-01T00:00:00Z"),
54
- });
55
- const rb = writeReport({
56
- goal: "g",
57
- branches: [{ question: "q1", answer: "ans", citationUrls: ["https://a.example.com"] }],
58
- citations: citationsB,
59
- now: () => new Date("2026-01-01T00:00:00Z"),
60
- });
61
- // Citation block (JSON-side): identical.
62
- expect(JSON.stringify(ra.json.citations)).toBe(JSON.stringify(rb.json.citations));
63
- // Markdown citation footer: identical.
64
- const footer = (s: string): string => s.split("## Citations")[1] ?? "";
65
- expect(footer(ra.markdown)).toBe(footer(rb.markdown));
66
- });
67
-
68
- test("markdown structure: goal H1, sub-question H2 per branch, citations footer", () => {
69
- const r = writeReport({
70
- goal: "Top risks of crews",
71
- branches: [
72
- { question: "What are the technical risks?", answer: "answer 1", citationUrls: [] },
73
- { question: "What are the operational risks?", answer: "answer 2", citationUrls: [] },
74
- ],
75
- citations: [],
76
- });
77
- expect(r.markdown).toMatch(/^# Top risks of crews\n/);
78
- expect(r.markdown).toContain("## What are the technical risks?");
79
- expect(r.markdown).toContain("## What are the operational risks?");
80
- // No citations footer when there are zero citations.
81
- expect(r.markdown.includes("## Citations")).toBe(false);
82
- });
83
-
84
- test("produces a stable JSON shape (snapshot-friendly)", () => {
85
- const r = writeReport({
86
- goal: "G",
87
- branches: [{ question: "Q", answer: "A", citationUrls: ["https://x"] }],
88
- citations: [citation("https://x", "snip")],
89
- now: () => new Date("2026-01-01T00:00:00Z"),
90
- });
91
- expect(r.json).toEqual({
92
- version: 1,
93
- goal: "G",
94
- subAnswers: [{ question: "Q", answer: "A", citationNumbers: [1] }],
95
- citations: [
96
- {
97
- number: 1,
98
- url: "https://x",
99
- snippet: "snip",
100
- sha256: "0".repeat(64),
101
- retrievedAt: "2026-05-08T00:00:00.000Z",
102
- },
103
- ],
104
- generatedAt: "2026-01-01T00:00:00.000Z",
105
- });
106
- });
107
- });
package/src/index.ts DELETED
@@ -1,162 +0,0 @@
1
- /**
2
- * Catalog R-orch `report-writer` — Section 23 RES.
3
- *
4
- * Assembles a research run's markdown + JSON report from:
5
- * - the goal,
6
- * - the planner's sub-questions,
7
- * - one answer per branch,
8
- * - the citation tracker.
9
- *
10
- * Citation numbering is the load-bearing detail: numbers are assigned by
11
- * URL on first appearance (NOT by the order the agent emitted citations
12
- * within a branch), so a re-run that takes a slightly different path
13
- * but ends up with the same citation set produces a byte-identical
14
- * citation block — the smoke's "deterministic ordering" assertion.
15
- *
16
- * The markdown report is structured for terminal `less` reading:
17
- * # <goal>
18
- *
19
- * ## <sub-question 1>
20
- * <answer 1, with [N] markers>
21
- *
22
- * ## <sub-question 2>
23
- * <answer 2, with [N] markers>
24
- *
25
- * ## Citations
26
- * [1] <url>
27
- * <snippet>
28
- * [2] <url>
29
- * <snippet>
30
- *
31
- * The JSON report carries the same data programmatically so studio /
32
- * downstream consumers don't have to re-parse markdown.
33
- */
34
- import type { Citation } from "@crewhaus/citation-tracker";
35
-
36
- export type BranchAnswer = {
37
- readonly question: string;
38
- readonly answer: string;
39
- /** URLs the agent emitted citations against during this branch. */
40
- readonly citationUrls: ReadonlyArray<string>;
41
- };
42
-
43
- export type Report = {
44
- readonly markdown: string;
45
- readonly json: ReportJson;
46
- };
47
-
48
- export type ReportJson = {
49
- readonly version: 1;
50
- readonly goal: string;
51
- readonly subAnswers: ReadonlyArray<{
52
- readonly question: string;
53
- readonly answer: string;
54
- readonly citationNumbers: ReadonlyArray<number>;
55
- }>;
56
- readonly citations: ReadonlyArray<NumberedCitation>;
57
- readonly generatedAt: string;
58
- };
59
-
60
- export type NumberedCitation = {
61
- readonly number: number;
62
- readonly url: string;
63
- readonly snippet: string;
64
- readonly sha256: string;
65
- readonly retrievedAt: string;
66
- };
67
-
68
- export type WriteReportArgs = {
69
- readonly goal: string;
70
- readonly branches: ReadonlyArray<BranchAnswer>;
71
- /** Append-ordered list of citations from the tracker. */
72
- readonly citations: ReadonlyArray<Citation>;
73
- readonly now?: () => Date;
74
- };
75
-
76
- /**
77
- * Number citations by URL on first appearance. Same URL appearing in
78
- * multiple branches reuses the first-assigned number; the snippet text
79
- * for the numbered entry comes from the FIRST citation against that URL.
80
- */
81
- function numberCitations(citations: ReadonlyArray<Citation>): {
82
- numbered: NumberedCitation[];
83
- numberByUrl: Map<string, number>;
84
- } {
85
- const numberByUrl = new Map<string, number>();
86
- const numbered: NumberedCitation[] = [];
87
- for (const c of citations) {
88
- if (numberByUrl.has(c.url)) continue;
89
- const n = numberByUrl.size + 1;
90
- numberByUrl.set(c.url, n);
91
- numbered.push({
92
- number: n,
93
- url: c.url,
94
- snippet: c.snippet,
95
- sha256: c.sha256,
96
- retrievedAt: c.retrievedAt,
97
- });
98
- }
99
- return { numbered, numberByUrl };
100
- }
101
-
102
- function citationNumbersForBranch(
103
- branch: BranchAnswer,
104
- numberByUrl: Map<string, number>,
105
- ): number[] {
106
- const seen = new Set<number>();
107
- const out: number[] = [];
108
- for (const url of branch.citationUrls) {
109
- const n = numberByUrl.get(url);
110
- if (n === undefined) continue;
111
- if (seen.has(n)) continue;
112
- seen.add(n);
113
- out.push(n);
114
- }
115
- out.sort((a, b) => a - b);
116
- return out;
117
- }
118
-
119
- function renderMarkdown(args: {
120
- goal: string;
121
- branches: ReadonlyArray<BranchAnswer>;
122
- numbered: ReadonlyArray<NumberedCitation>;
123
- }): string {
124
- const lines: string[] = [];
125
- lines.push(`# ${args.goal}`);
126
- lines.push("");
127
- for (const b of args.branches) {
128
- lines.push(`## ${b.question}`);
129
- lines.push("");
130
- lines.push(b.answer.trim());
131
- lines.push("");
132
- }
133
- if (args.numbered.length > 0) {
134
- lines.push("## Citations");
135
- lines.push("");
136
- for (const c of args.numbered) {
137
- lines.push(`[${c.number}] ${c.url}`);
138
- const trimmed = c.snippet.trim().replace(/\n+/g, " ");
139
- lines.push(` ${trimmed}`);
140
- }
141
- }
142
- return `${lines.join("\n").trimEnd()}\n`;
143
- }
144
-
145
- export function writeReport(args: WriteReportArgs): Report {
146
- const { numbered, numberByUrl } = numberCitations(args.citations);
147
- const subAnswers = args.branches.map((b) => ({
148
- question: b.question,
149
- answer: b.answer,
150
- citationNumbers: citationNumbersForBranch(b, numberByUrl),
151
- }));
152
- const now = args.now ?? (() => new Date());
153
- const json: ReportJson = {
154
- version: 1,
155
- goal: args.goal,
156
- subAnswers,
157
- citations: numbered,
158
- generatedAt: now().toISOString(),
159
- };
160
- const markdown = renderMarkdown({ goal: args.goal, branches: args.branches, numbered });
161
- return { markdown, json };
162
- }