@crewhaus/citation-tracker 0.1.0

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/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@crewhaus/citation-tracker",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Per-research-run citation persistence + URL dedup (Section 23 RES)",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0"
16
+ },
17
+ "license": "Apache-2.0",
18
+ "author": {
19
+ "name": "Max Meier",
20
+ "email": "max@studiomax.io",
21
+ "url": "https://studiomax.io"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/crewhaus/factory.git",
26
+ "directory": "packages/citation-tracker"
27
+ },
28
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/citation-tracker#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/crewhaus/factory/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "restricted"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ]
41
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { CitationTrackerError, createCitationTracker, newRunId } from "./index.js";
6
+
7
+ function newRoot(): string {
8
+ return mkdtempSync(join(tmpdir(), "citation-tracker-"));
9
+ }
10
+
11
+ describe("createCitationTracker", () => {
12
+ test("hasFetched returns false for fresh tracker, true after recordFetch", () => {
13
+ const root = newRoot();
14
+ try {
15
+ const t = createCitationTracker({ rootDir: root });
16
+ expect(t.hasFetched("https://example.com/a")).toBe(false);
17
+ t.recordFetch({ url: "https://example.com/a", content: "hello world" });
18
+ expect(t.hasFetched("https://example.com/a")).toBe(true);
19
+ } finally {
20
+ rmSync(root, { recursive: true, force: true });
21
+ }
22
+ });
23
+
24
+ test("recordFetch is idempotent — re-recording same URL returns first record", () => {
25
+ const root = newRoot();
26
+ try {
27
+ const t = createCitationTracker({ rootDir: root });
28
+ const first = t.recordFetch({ url: "u", content: "v1" });
29
+ const second = t.recordFetch({ url: "u", content: "v2-different" });
30
+ // First write wins; the second record is the same as the first.
31
+ expect(second).toEqual(first);
32
+ expect(t.getFetchedContent("u")).toBe("v1");
33
+ } finally {
34
+ rmSync(root, { recursive: true, force: true });
35
+ }
36
+ });
37
+
38
+ test("getFetchedContent returns the original body verbatim (T4 cache hit)", () => {
39
+ const root = newRoot();
40
+ try {
41
+ const t = createCitationTracker({ rootDir: root });
42
+ const body = "some\nmulti\nline\ncontent";
43
+ t.recordFetch({ url: "u", content: body });
44
+ expect(t.getFetchedContent("u")).toBe(body);
45
+ } finally {
46
+ rmSync(root, { recursive: true, force: true });
47
+ }
48
+ });
49
+
50
+ test("kill-and-resume: a new tracker against the same runId/rootDir picks up prior fetches (T4)", () => {
51
+ const root = newRoot();
52
+ const runId = newRunId();
53
+ try {
54
+ const t1 = createCitationTracker({ runId, rootDir: root });
55
+ t1.recordFetch({ url: "u1", content: "body-1" });
56
+ t1.recordCitation({ url: "u1", snippet: "snippet-1", branchId: "b1" });
57
+
58
+ // Simulate kill by dropping t1; new tracker reads files back.
59
+ const t2 = createCitationTracker({ runId, rootDir: root });
60
+ expect(t2.hasFetched("u1")).toBe(true);
61
+ expect(t2.getFetchedContent("u1")).toBe("body-1");
62
+ const cs = t2.listCitationsOrdered();
63
+ expect(cs).toHaveLength(1);
64
+ expect(cs[0]?.url).toBe("u1");
65
+ expect(cs[0]?.snippet).toBe("snippet-1");
66
+ } finally {
67
+ rmSync(root, { recursive: true, force: true });
68
+ }
69
+ });
70
+
71
+ test("listCitationsOrdered returns citations in append order — deterministic across re-runs", () => {
72
+ const root = newRoot();
73
+ const runId = newRunId();
74
+ try {
75
+ const t = createCitationTracker({ runId, rootDir: root });
76
+ t.recordFetch({ url: "a", content: "ka" });
77
+ t.recordFetch({ url: "b", content: "kb" });
78
+ t.recordCitation({ url: "a", snippet: "fact a-1" });
79
+ t.recordCitation({ url: "b", snippet: "fact b-1" });
80
+ t.recordCitation({ url: "a", snippet: "fact a-2" });
81
+
82
+ const reopened = createCitationTracker({ runId, rootDir: root });
83
+ const order = reopened.listCitationsOrdered().map((c) => c.snippet);
84
+ expect(order).toEqual(["fact a-1", "fact b-1", "fact a-2"]);
85
+ } finally {
86
+ rmSync(root, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ test("recordCitation pulls sha256 from the matching fetch record when present", () => {
91
+ const root = newRoot();
92
+ try {
93
+ const t = createCitationTracker({ rootDir: root });
94
+ const f = t.recordFetch({ url: "u", content: "the body text" });
95
+ const c = t.recordCitation({ url: "u", snippet: "body" });
96
+ expect(c.sha256).toBe(f.sha256);
97
+ expect(c.retrievedAt).toBe(f.retrievedAt);
98
+ } finally {
99
+ rmSync(root, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test("recordCitation with no prior fetch hashes the snippet itself", () => {
104
+ const root = newRoot();
105
+ try {
106
+ const t = createCitationTracker({ rootDir: root });
107
+ const c = t.recordCitation({ url: "u", snippet: "abc" });
108
+ expect(c.sha256).toMatch(/^[0-9a-f]{64}$/);
109
+ } finally {
110
+ rmSync(root, { recursive: true, force: true });
111
+ }
112
+ });
113
+
114
+ test("invalid runId throws", () => {
115
+ const root = newRoot();
116
+ try {
117
+ expect(() => createCitationTracker({ runId: "bad", rootDir: root })).toThrow(/invalid runId/);
118
+ } finally {
119
+ rmSync(root, { recursive: true, force: true });
120
+ }
121
+ });
122
+
123
+ test("malformed jsonl on disk surfaces as a CitationTrackerError", async () => {
124
+ const root = newRoot();
125
+ const runId = newRunId();
126
+ const path = join(root, runId, "fetches.jsonl");
127
+ try {
128
+ // Create the dir + a malformed file.
129
+ const t = createCitationTracker({ runId, rootDir: root });
130
+ t.recordFetch({ url: "u", content: "ok" });
131
+ // Append garbage and re-open.
132
+ const fs = await import("node:fs");
133
+ fs.appendFileSync(path, "not-json\n");
134
+ expect(() => createCitationTracker({ runId, rootDir: root })).toThrow(CitationTrackerError);
135
+ } finally {
136
+ rmSync(root, { recursive: true, force: true });
137
+ }
138
+ });
139
+ });
package/src/index.ts ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Catalog R-orch `citation-tracker` — Section 23 RES.
3
+ *
4
+ * Persistent per-research-run record of every URL the crawler has
5
+ * touched, plus every fact the agent has cited. The same instance does
6
+ * two jobs that the kickoff treats as one:
7
+ *
8
+ * 1. **Fetch dedup** — `hasFetched(url)` lets the crawler skip a HTTP
9
+ * round-trip on resume. `recordFetch({url, content})` stores the
10
+ * sha256 + the content body so a kill-and-resume returns identical
11
+ * bytes from cache rather than re-pulling the URL.
12
+ * 2. **Citation registry** — `recordCitation({url, snippet, branchId?})`
13
+ * appends a fact to the run's citation log. `listCitationsOrdered()`
14
+ * returns citations in first-seen order (deterministic across
15
+ * re-runs because of the JSONL append-only file).
16
+ *
17
+ * Storage layout:
18
+ * `<rootDir>/<runId>/fetches.jsonl` one line per URL ever fetched
19
+ * `<rootDir>/<runId>/citations.jsonl` one line per `recordCitation` call
20
+ *
21
+ * Both files are append-only, mode 0o600, atomic-per-line on POSIX
22
+ * (mirrors `event-log`). On boot the tracker reads each file once into
23
+ * an in-memory cache; subsequent calls hit memory.
24
+ *
25
+ * The smoke test's "byte-identical citation block across re-runs"
26
+ * invariant is anchored here: citations are ordered by their first
27
+ * append timestamp, NOT by retrievedAt, so even if the model takes
28
+ * different routes on the second pass, the citation list still ends up
29
+ * in stable order keyed off the FIRST-seen URL.
30
+ */
31
+ import { createHash, randomUUID } from "node:crypto";
32
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { CrewhausError, RuntimeError } from "@crewhaus/errors";
35
+
36
+ export const DEFAULT_ROOT_DIR = ".crewhaus/research";
37
+ const RUN_ID_RE = /^run_[0-9a-f]{16}$/;
38
+
39
+ export type RunId = string;
40
+
41
+ export type FetchRecord = {
42
+ readonly version: 1;
43
+ readonly url: string;
44
+ readonly retrievedAt: string;
45
+ readonly sha256: string;
46
+ readonly contentBytes: number;
47
+ /** Branch (sub-question) that initiated this fetch, if known. */
48
+ readonly branchId?: string;
49
+ };
50
+
51
+ export type Citation = {
52
+ readonly version: 1;
53
+ readonly url: string;
54
+ readonly snippet: string;
55
+ readonly retrievedAt: string;
56
+ readonly sha256: string;
57
+ readonly branchId?: string;
58
+ /** Free-form supporting-claim label (e.g., "supports: target shapes list"). */
59
+ readonly supportingClaim?: string;
60
+ };
61
+
62
+ export type RecordFetchInput = {
63
+ readonly url: string;
64
+ readonly content: string;
65
+ readonly branchId?: string;
66
+ };
67
+
68
+ export type RecordCitationInput = {
69
+ readonly url: string;
70
+ readonly snippet: string;
71
+ readonly branchId?: string;
72
+ readonly supportingClaim?: string;
73
+ };
74
+
75
+ export interface CitationTracker {
76
+ readonly runId: RunId;
77
+
78
+ /** True iff the tracker already has a content body for this URL. */
79
+ hasFetched(url: string): boolean;
80
+ /** Cached content body for a URL (matched by exact string). */
81
+ getFetchedContent(url: string): string | undefined;
82
+ /** Cached fetch metadata (sha256 + retrievedAt) for a URL. */
83
+ getFetchRecord(url: string): FetchRecord | undefined;
84
+ /** Idempotent: re-recording the same URL is a no-op (preserves first sha256). */
85
+ recordFetch(input: RecordFetchInput): FetchRecord;
86
+
87
+ recordCitation(input: RecordCitationInput): Citation;
88
+ /** Citations in append-order (deterministic across re-runs). */
89
+ listCitationsOrdered(): ReadonlyArray<Citation>;
90
+ /** All fetch records in append-order. */
91
+ listFetches(): ReadonlyArray<FetchRecord>;
92
+ }
93
+
94
+ export type CreateCitationTrackerOptions = {
95
+ readonly runId?: RunId;
96
+ readonly rootDir?: string;
97
+ readonly now?: () => Date;
98
+ };
99
+
100
+ function validateRunId(runId: string): void {
101
+ if (!RUN_ID_RE.test(runId)) {
102
+ throw new RuntimeError(`citation-tracker: invalid runId "${runId}" — expected run_<16 hex>`);
103
+ }
104
+ }
105
+
106
+ export function newRunId(): RunId {
107
+ // 16 hex chars. UUID is 32 hex (with dashes); strip dashes and take 16.
108
+ const id = randomUUID().replace(/-/g, "").slice(0, 16);
109
+ return `run_${id}`;
110
+ }
111
+
112
+ export class CitationTrackerError extends CrewhausError {
113
+ override readonly name = "CitationTrackerError";
114
+ constructor(message: string, cause?: unknown) {
115
+ super("runtime", message, cause);
116
+ }
117
+ }
118
+
119
+ export function createCitationTracker(opts: CreateCitationTrackerOptions = {}): CitationTracker {
120
+ const runId = opts.runId ?? newRunId();
121
+ validateRunId(runId);
122
+ const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
123
+ const runDir = join(rootDir, runId);
124
+ const fetchesPath = join(runDir, "fetches.jsonl");
125
+ const citationsPath = join(runDir, "citations.jsonl");
126
+ const now = opts.now ?? (() => new Date());
127
+
128
+ mkdirSync(runDir, { recursive: true });
129
+
130
+ // Boot-time replay of prior records so a resume sees the same dedup state.
131
+ const fetchByUrl = new Map<string, FetchRecord>();
132
+ const fetchOrder: FetchRecord[] = [];
133
+ const citations: Citation[] = [];
134
+
135
+ if (existsSync(fetchesPath)) {
136
+ for (const raw of readFileSync(fetchesPath, "utf8").split("\n")) {
137
+ const line = raw.trim();
138
+ if (line.length === 0) continue;
139
+ try {
140
+ const rec = JSON.parse(line) as FetchRecord;
141
+ if (typeof rec.url === "string" && !fetchByUrl.has(rec.url)) {
142
+ fetchByUrl.set(rec.url, rec);
143
+ fetchOrder.push(rec);
144
+ }
145
+ } catch (err) {
146
+ throw new CitationTrackerError(`malformed JSON in ${fetchesPath}`, err);
147
+ }
148
+ }
149
+ }
150
+ if (existsSync(citationsPath)) {
151
+ for (const raw of readFileSync(citationsPath, "utf8").split("\n")) {
152
+ const line = raw.trim();
153
+ if (line.length === 0) continue;
154
+ try {
155
+ const rec = JSON.parse(line) as Citation;
156
+ citations.push(rec);
157
+ } catch (err) {
158
+ throw new CitationTrackerError(`malformed JSON in ${citationsPath}`, err);
159
+ }
160
+ }
161
+ }
162
+
163
+ // Per-fetch content cache — written separately under <runDir>/cache/ so the
164
+ // JSONL stays small (citations only carry sha256 + snippet, not full bodies).
165
+ const cacheDir = join(runDir, "cache");
166
+ mkdirSync(cacheDir, { recursive: true });
167
+
168
+ function cachePathFor(sha256: string): string {
169
+ return join(cacheDir, `${sha256}.txt`);
170
+ }
171
+
172
+ function tryReadCache(rec: FetchRecord): string | undefined {
173
+ const p = cachePathFor(rec.sha256);
174
+ if (!existsSync(p)) return undefined;
175
+ return readFileSync(p, "utf8");
176
+ }
177
+
178
+ return {
179
+ runId,
180
+
181
+ hasFetched(url) {
182
+ return fetchByUrl.has(url);
183
+ },
184
+
185
+ getFetchedContent(url) {
186
+ const rec = fetchByUrl.get(url);
187
+ if (rec === undefined) return undefined;
188
+ return tryReadCache(rec);
189
+ },
190
+
191
+ getFetchRecord(url) {
192
+ return fetchByUrl.get(url);
193
+ },
194
+
195
+ recordFetch(input) {
196
+ const existing = fetchByUrl.get(input.url);
197
+ if (existing !== undefined) return existing;
198
+ const sha256 = createHash("sha256").update(input.content).digest("hex");
199
+ const rec: FetchRecord = {
200
+ version: 1,
201
+ url: input.url,
202
+ retrievedAt: now().toISOString(),
203
+ sha256,
204
+ contentBytes: Buffer.byteLength(input.content, "utf8"),
205
+ ...(input.branchId !== undefined ? { branchId: input.branchId } : {}),
206
+ };
207
+ fetchByUrl.set(input.url, rec);
208
+ fetchOrder.push(rec);
209
+ appendFileSync(fetchesPath, `${JSON.stringify(rec)}\n`, { mode: 0o600 });
210
+ // Cache content for resume by sha256 — same content across runs ends up
211
+ // as the same file, so a re-fetch into a different tracker still hits
212
+ // the same cache directory if the rootDir is shared.
213
+ writeFileSync(cachePathFor(sha256), input.content, { mode: 0o600 });
214
+ return rec;
215
+ },
216
+
217
+ recordCitation(input) {
218
+ // Resolve sha256: prefer the matching fetch record's hash so a citation
219
+ // anchors to a specific content version. If no matching fetch, hash
220
+ // the snippet itself.
221
+ const fetch = fetchByUrl.get(input.url);
222
+ const sha256 =
223
+ fetch !== undefined
224
+ ? fetch.sha256
225
+ : createHash("sha256").update(input.snippet).digest("hex");
226
+ const c: Citation = {
227
+ version: 1,
228
+ url: input.url,
229
+ snippet: input.snippet,
230
+ retrievedAt: fetch !== undefined ? fetch.retrievedAt : now().toISOString(),
231
+ sha256,
232
+ ...(input.branchId !== undefined ? { branchId: input.branchId } : {}),
233
+ ...(input.supportingClaim !== undefined ? { supportingClaim: input.supportingClaim } : {}),
234
+ };
235
+ citations.push(c);
236
+ appendFileSync(citationsPath, `${JSON.stringify(c)}\n`, { mode: 0o600 });
237
+ return c;
238
+ },
239
+
240
+ listCitationsOrdered() {
241
+ // Stable order: append order. Same-URL citations are kept (the agent
242
+ // may cite multiple snippets from one source); the report-writer's
243
+ // numbering layer dedups by URL.
244
+ return [...citations];
245
+ },
246
+
247
+ listFetches() {
248
+ return [...fetchOrder];
249
+ },
250
+ };
251
+ }