@crewhaus/citation-tracker 0.1.4 → 0.1.6

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,60 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ export declare const DEFAULT_ROOT_DIR = ".crewhaus/research";
3
+ export type RunId = string;
4
+ export type FetchRecord = {
5
+ readonly version: 1;
6
+ readonly url: string;
7
+ readonly retrievedAt: string;
8
+ readonly sha256: string;
9
+ readonly contentBytes: number;
10
+ /** Branch (sub-question) that initiated this fetch, if known. */
11
+ readonly branchId?: string;
12
+ };
13
+ export type Citation = {
14
+ readonly version: 1;
15
+ readonly url: string;
16
+ readonly snippet: string;
17
+ readonly retrievedAt: string;
18
+ readonly sha256: string;
19
+ readonly branchId?: string;
20
+ /** Free-form supporting-claim label (e.g., "supports: target shapes list"). */
21
+ readonly supportingClaim?: string;
22
+ };
23
+ export type RecordFetchInput = {
24
+ readonly url: string;
25
+ readonly content: string;
26
+ readonly branchId?: string;
27
+ };
28
+ export type RecordCitationInput = {
29
+ readonly url: string;
30
+ readonly snippet: string;
31
+ readonly branchId?: string;
32
+ readonly supportingClaim?: string;
33
+ };
34
+ export interface CitationTracker {
35
+ readonly runId: RunId;
36
+ /** True iff the tracker already has a content body for this URL. */
37
+ hasFetched(url: string): boolean;
38
+ /** Cached content body for a URL (matched by exact string). */
39
+ getFetchedContent(url: string): string | undefined;
40
+ /** Cached fetch metadata (sha256 + retrievedAt) for a URL. */
41
+ getFetchRecord(url: string): FetchRecord | undefined;
42
+ /** Idempotent: re-recording the same URL is a no-op (preserves first sha256). */
43
+ recordFetch(input: RecordFetchInput): FetchRecord;
44
+ recordCitation(input: RecordCitationInput): Citation;
45
+ /** Citations in append-order (deterministic across re-runs). */
46
+ listCitationsOrdered(): ReadonlyArray<Citation>;
47
+ /** All fetch records in append-order. */
48
+ listFetches(): ReadonlyArray<FetchRecord>;
49
+ }
50
+ export type CreateCitationTrackerOptions = {
51
+ readonly runId?: RunId;
52
+ readonly rootDir?: string;
53
+ readonly now?: () => Date;
54
+ };
55
+ export declare function newRunId(): RunId;
56
+ export declare class CitationTrackerError extends CrewhausError {
57
+ readonly name = "CitationTrackerError";
58
+ constructor(message: string, cause?: unknown);
59
+ }
60
+ export declare function createCitationTracker(opts?: CreateCitationTrackerOptions): CitationTracker;
package/dist/index.js ADDED
@@ -0,0 +1,189 @@
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
+ export const DEFAULT_ROOT_DIR = ".crewhaus/research";
36
+ const RUN_ID_RE = /^run_[0-9a-f]{16}$/;
37
+ /**
38
+ * A sha256 digest is always exactly 64 lowercase hex chars. We re-validate it
39
+ * before interpolating into the cache file path because the value can originate
40
+ * from a JSONL line read off disk (boot-time replay), and a corrupted or
41
+ * tampered `fetches.jsonl` must not be able to steer a read outside `cache/`
42
+ * (e.g. a `sha256` of "../../../etc/passwd"). Legitimate records always match.
43
+ */
44
+ const SHA256_RE = /^[0-9a-f]{64}$/;
45
+ function validateRunId(runId) {
46
+ if (!RUN_ID_RE.test(runId)) {
47
+ throw new RuntimeError(`citation-tracker: invalid runId "${runId}" — expected run_<16 hex>`);
48
+ }
49
+ }
50
+ export function newRunId() {
51
+ // 16 hex chars. UUID is 32 hex (with dashes); strip dashes and take 16.
52
+ const id = randomUUID().replace(/-/g, "").slice(0, 16);
53
+ return `run_${id}`;
54
+ }
55
+ export class CitationTrackerError extends CrewhausError {
56
+ name = "CitationTrackerError";
57
+ constructor(message, cause) {
58
+ super("runtime", message, cause);
59
+ }
60
+ }
61
+ export function createCitationTracker(opts = {}) {
62
+ const runId = opts.runId ?? newRunId();
63
+ validateRunId(runId);
64
+ const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
65
+ const runDir = join(rootDir, runId);
66
+ const fetchesPath = join(runDir, "fetches.jsonl");
67
+ const citationsPath = join(runDir, "citations.jsonl");
68
+ const now = opts.now ?? (() => new Date());
69
+ mkdirSync(runDir, { recursive: true });
70
+ // Boot-time replay of prior records so a resume sees the same dedup state.
71
+ const fetchByUrl = new Map();
72
+ const fetchOrder = [];
73
+ const citations = [];
74
+ if (existsSync(fetchesPath)) {
75
+ for (const raw of readFileSync(fetchesPath, "utf8").split("\n")) {
76
+ const line = raw.trim();
77
+ if (line.length === 0)
78
+ continue;
79
+ try {
80
+ const rec = JSON.parse(line);
81
+ if (typeof rec.url === "string" && !fetchByUrl.has(rec.url)) {
82
+ fetchByUrl.set(rec.url, rec);
83
+ fetchOrder.push(rec);
84
+ }
85
+ }
86
+ catch (err) {
87
+ throw new CitationTrackerError(`malformed JSON in ${fetchesPath}`, err);
88
+ }
89
+ }
90
+ }
91
+ if (existsSync(citationsPath)) {
92
+ for (const raw of readFileSync(citationsPath, "utf8").split("\n")) {
93
+ const line = raw.trim();
94
+ if (line.length === 0)
95
+ continue;
96
+ try {
97
+ const rec = JSON.parse(line);
98
+ citations.push(rec);
99
+ }
100
+ catch (err) {
101
+ throw new CitationTrackerError(`malformed JSON in ${citationsPath}`, err);
102
+ }
103
+ }
104
+ }
105
+ // Per-fetch content cache — written separately under <runDir>/cache/ so the
106
+ // JSONL stays small (citations only carry sha256 + snippet, not full bodies).
107
+ const cacheDir = join(runDir, "cache");
108
+ mkdirSync(cacheDir, { recursive: true });
109
+ function cachePathFor(sha256) {
110
+ return join(cacheDir, `${sha256}.txt`);
111
+ }
112
+ function tryReadCache(rec) {
113
+ // Defend the cache read against a tampered fetches.jsonl: a sha256 that is
114
+ // not a clean 64-hex digest could otherwise traverse out of cache/.
115
+ if (typeof rec.sha256 !== "string" || !SHA256_RE.test(rec.sha256))
116
+ return undefined;
117
+ const p = cachePathFor(rec.sha256);
118
+ if (!existsSync(p))
119
+ return undefined;
120
+ return readFileSync(p, "utf8");
121
+ }
122
+ return {
123
+ runId,
124
+ hasFetched(url) {
125
+ return fetchByUrl.has(url);
126
+ },
127
+ getFetchedContent(url) {
128
+ const rec = fetchByUrl.get(url);
129
+ if (rec === undefined)
130
+ return undefined;
131
+ return tryReadCache(rec);
132
+ },
133
+ getFetchRecord(url) {
134
+ return fetchByUrl.get(url);
135
+ },
136
+ recordFetch(input) {
137
+ const existing = fetchByUrl.get(input.url);
138
+ if (existing !== undefined)
139
+ return existing;
140
+ const sha256 = createHash("sha256").update(input.content).digest("hex");
141
+ const rec = {
142
+ version: 1,
143
+ url: input.url,
144
+ retrievedAt: now().toISOString(),
145
+ sha256,
146
+ contentBytes: Buffer.byteLength(input.content, "utf8"),
147
+ ...(input.branchId !== undefined ? { branchId: input.branchId } : {}),
148
+ };
149
+ fetchByUrl.set(input.url, rec);
150
+ fetchOrder.push(rec);
151
+ appendFileSync(fetchesPath, `${JSON.stringify(rec)}\n`, { mode: 0o600 });
152
+ // Cache content for resume by sha256 — same content across runs ends up
153
+ // as the same file, so a re-fetch into a different tracker still hits
154
+ // the same cache directory if the rootDir is shared.
155
+ writeFileSync(cachePathFor(sha256), input.content, { mode: 0o600 });
156
+ return rec;
157
+ },
158
+ recordCitation(input) {
159
+ // Resolve sha256: prefer the matching fetch record's hash so a citation
160
+ // anchors to a specific content version. If no matching fetch, hash
161
+ // the snippet itself.
162
+ const fetch = fetchByUrl.get(input.url);
163
+ const sha256 = fetch !== undefined
164
+ ? fetch.sha256
165
+ : createHash("sha256").update(input.snippet).digest("hex");
166
+ const c = {
167
+ version: 1,
168
+ url: input.url,
169
+ snippet: input.snippet,
170
+ retrievedAt: fetch !== undefined ? fetch.retrievedAt : now().toISOString(),
171
+ sha256,
172
+ ...(input.branchId !== undefined ? { branchId: input.branchId } : {}),
173
+ ...(input.supportingClaim !== undefined ? { supportingClaim: input.supportingClaim } : {}),
174
+ };
175
+ citations.push(c);
176
+ appendFileSync(citationsPath, `${JSON.stringify(c)}\n`, { mode: 0o600 });
177
+ return c;
178
+ },
179
+ listCitationsOrdered() {
180
+ // Stable order: append order. Same-URL citations are kept (the agent
181
+ // may cite multiple snippets from one source); the report-writer's
182
+ // numbering layer dedups by URL.
183
+ return [...citations];
184
+ },
185
+ listFetches() {
186
+ return [...fetchOrder];
187
+ },
188
+ };
189
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/citation-tracker",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Per-research-run citation persistence + URL dedup (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/errors": "0.1.4"
18
+ "@crewhaus/errors": "0.1.6"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }
package/src/index.test.ts DELETED
@@ -1,290 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { appendFileSync, mkdtempSync, rmSync, writeFileSync } 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
-
140
- test("malformed jsonl in citations.jsonl surfaces as a CitationTrackerError", () => {
141
- const root = newRoot();
142
- const runId = newRunId();
143
- const path = join(root, runId, "citations.jsonl");
144
- try {
145
- const t = createCitationTracker({ runId, rootDir: root });
146
- t.recordCitation({ url: "u", snippet: "s" });
147
- appendFileSync(path, "{not valid json\n");
148
- expect(() => createCitationTracker({ runId, rootDir: root })).toThrow(CitationTrackerError);
149
- } finally {
150
- rmSync(root, { recursive: true, force: true });
151
- }
152
- });
153
-
154
- test("getFetchRecord returns metadata for a known URL and undefined otherwise", () => {
155
- const root = newRoot();
156
- try {
157
- const t = createCitationTracker({ rootDir: root });
158
- expect(t.getFetchRecord("missing")).toBeUndefined();
159
- const rec = t.recordFetch({ url: "u", content: "the body", branchId: "b7" });
160
- const got = t.getFetchRecord("u");
161
- expect(got).toEqual(rec);
162
- expect(got?.sha256).toMatch(/^[0-9a-f]{64}$/);
163
- expect(got?.contentBytes).toBe(Buffer.byteLength("the body", "utf8"));
164
- expect(got?.branchId).toBe("b7");
165
- } finally {
166
- rmSync(root, { recursive: true, force: true });
167
- }
168
- });
169
-
170
- test("getFetchedContent returns undefined for an unknown URL", () => {
171
- const root = newRoot();
172
- try {
173
- const t = createCitationTracker({ rootDir: root });
174
- expect(t.getFetchedContent("never-fetched")).toBeUndefined();
175
- } finally {
176
- rmSync(root, { recursive: true, force: true });
177
- }
178
- });
179
-
180
- test("listFetches returns all fetch records in append order", () => {
181
- const root = newRoot();
182
- try {
183
- const t = createCitationTracker({ rootDir: root });
184
- expect(t.listFetches()).toEqual([]);
185
- t.recordFetch({ url: "first", content: "1" });
186
- t.recordFetch({ url: "second", content: "2" });
187
- // Idempotent re-record must NOT add a second entry.
188
- t.recordFetch({ url: "first", content: "1-again" });
189
- const urls = t.listFetches().map((r) => r.url);
190
- expect(urls).toEqual(["first", "second"]);
191
- } finally {
192
- rmSync(root, { recursive: true, force: true });
193
- }
194
- });
195
-
196
- test("listFetches survives kill-and-resume in append order", () => {
197
- const root = newRoot();
198
- const runId = newRunId();
199
- try {
200
- const t1 = createCitationTracker({ runId, rootDir: root });
201
- t1.recordFetch({ url: "a", content: "ka" });
202
- t1.recordFetch({ url: "b", content: "kb" });
203
- const t2 = createCitationTracker({ runId, rootDir: root });
204
- expect(t2.listFetches().map((r) => r.url)).toEqual(["a", "b"]);
205
- } finally {
206
- rmSync(root, { recursive: true, force: true });
207
- }
208
- });
209
-
210
- test("recordCitation carries branchId and supportingClaim through to disk", () => {
211
- const root = newRoot();
212
- const runId = newRunId();
213
- try {
214
- const t = createCitationTracker({ runId, rootDir: root });
215
- const c = t.recordCitation({
216
- url: "u",
217
- snippet: "s",
218
- branchId: "branch-9",
219
- supportingClaim: "supports: target shapes list",
220
- });
221
- expect(c.branchId).toBe("branch-9");
222
- expect(c.supportingClaim).toBe("supports: target shapes list");
223
-
224
- // Round-trips verbatim through the JSONL on resume.
225
- const reopened = createCitationTracker({ runId, rootDir: root });
226
- const persisted = reopened.listCitationsOrdered()[0];
227
- expect(persisted?.branchId).toBe("branch-9");
228
- expect(persisted?.supportingClaim).toBe("supports: target shapes list");
229
- } finally {
230
- rmSync(root, { recursive: true, force: true });
231
- }
232
- });
233
-
234
- test("now() injection makes retrievedAt deterministic", () => {
235
- const root = newRoot();
236
- try {
237
- const fixed = new Date("2030-01-02T03:04:05.000Z");
238
- const t = createCitationTracker({ rootDir: root, now: () => fixed });
239
- const f = t.recordFetch({ url: "u", content: "x" });
240
- expect(f.retrievedAt).toBe("2030-01-02T03:04:05.000Z");
241
- // Citation with no prior fetch also stamps via now().
242
- const c = t.recordCitation({ url: "other", snippet: "s" });
243
- expect(c.retrievedAt).toBe("2030-01-02T03:04:05.000Z");
244
- } finally {
245
- rmSync(root, { recursive: true, force: true });
246
- }
247
- });
248
-
249
- test("newRunId produces well-formed, accepted run ids", () => {
250
- const root = newRoot();
251
- try {
252
- const id = newRunId();
253
- expect(id).toMatch(/^run_[0-9a-f]{16}$/);
254
- // A freshly-minted id must be accepted by the validator.
255
- const t = createCitationTracker({ runId: id, rootDir: root });
256
- expect(t.runId).toBe(id);
257
- } finally {
258
- rmSync(root, { recursive: true, force: true });
259
- }
260
- });
261
-
262
- test("security: a tampered sha256 in fetches.jsonl cannot traverse out of the cache dir", () => {
263
- const root = newRoot();
264
- const runId = newRunId();
265
- try {
266
- // Boot once to create the run dir, then overwrite fetches.jsonl with a
267
- // record whose sha256 is a path-traversal payload. A secret file lives
268
- // two levels up (the runId dir) at a guessable name.
269
- createCitationTracker({ runId, rootDir: root });
270
- const secretPath = join(root, runId, "stolen.txt");
271
- writeFileSync(secretPath, "TOP SECRET CONTENTS");
272
- // cache/<sha256>.txt -> cache/../stolen.txt resolves to the secret file.
273
- const evilRecord = {
274
- version: 1,
275
- url: "https://victim.example/x",
276
- retrievedAt: "2030-01-01T00:00:00.000Z",
277
- sha256: "../stolen",
278
- contentBytes: 19,
279
- };
280
- writeFileSync(join(root, runId, "fetches.jsonl"), `${JSON.stringify(evilRecord)}\n`);
281
-
282
- const t = createCitationTracker({ runId, rootDir: root });
283
- expect(t.hasFetched("https://victim.example/x")).toBe(true);
284
- // The guard must refuse the malformed sha256 rather than read the secret.
285
- expect(t.getFetchedContent("https://victim.example/x")).toBeUndefined();
286
- } finally {
287
- rmSync(root, { recursive: true, force: true });
288
- }
289
- });
290
- });
package/src/index.ts DELETED
@@ -1,262 +0,0 @@
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
- * A sha256 digest is always exactly 64 lowercase hex chars. We re-validate it
40
- * before interpolating into the cache file path because the value can originate
41
- * from a JSONL line read off disk (boot-time replay), and a corrupted or
42
- * tampered `fetches.jsonl` must not be able to steer a read outside `cache/`
43
- * (e.g. a `sha256` of "../../../etc/passwd"). Legitimate records always match.
44
- */
45
- const SHA256_RE = /^[0-9a-f]{64}$/;
46
-
47
- export type RunId = string;
48
-
49
- export type FetchRecord = {
50
- readonly version: 1;
51
- readonly url: string;
52
- readonly retrievedAt: string;
53
- readonly sha256: string;
54
- readonly contentBytes: number;
55
- /** Branch (sub-question) that initiated this fetch, if known. */
56
- readonly branchId?: string;
57
- };
58
-
59
- export type Citation = {
60
- readonly version: 1;
61
- readonly url: string;
62
- readonly snippet: string;
63
- readonly retrievedAt: string;
64
- readonly sha256: string;
65
- readonly branchId?: string;
66
- /** Free-form supporting-claim label (e.g., "supports: target shapes list"). */
67
- readonly supportingClaim?: string;
68
- };
69
-
70
- export type RecordFetchInput = {
71
- readonly url: string;
72
- readonly content: string;
73
- readonly branchId?: string;
74
- };
75
-
76
- export type RecordCitationInput = {
77
- readonly url: string;
78
- readonly snippet: string;
79
- readonly branchId?: string;
80
- readonly supportingClaim?: string;
81
- };
82
-
83
- export interface CitationTracker {
84
- readonly runId: RunId;
85
-
86
- /** True iff the tracker already has a content body for this URL. */
87
- hasFetched(url: string): boolean;
88
- /** Cached content body for a URL (matched by exact string). */
89
- getFetchedContent(url: string): string | undefined;
90
- /** Cached fetch metadata (sha256 + retrievedAt) for a URL. */
91
- getFetchRecord(url: string): FetchRecord | undefined;
92
- /** Idempotent: re-recording the same URL is a no-op (preserves first sha256). */
93
- recordFetch(input: RecordFetchInput): FetchRecord;
94
-
95
- recordCitation(input: RecordCitationInput): Citation;
96
- /** Citations in append-order (deterministic across re-runs). */
97
- listCitationsOrdered(): ReadonlyArray<Citation>;
98
- /** All fetch records in append-order. */
99
- listFetches(): ReadonlyArray<FetchRecord>;
100
- }
101
-
102
- export type CreateCitationTrackerOptions = {
103
- readonly runId?: RunId;
104
- readonly rootDir?: string;
105
- readonly now?: () => Date;
106
- };
107
-
108
- function validateRunId(runId: string): void {
109
- if (!RUN_ID_RE.test(runId)) {
110
- throw new RuntimeError(`citation-tracker: invalid runId "${runId}" — expected run_<16 hex>`);
111
- }
112
- }
113
-
114
- export function newRunId(): RunId {
115
- // 16 hex chars. UUID is 32 hex (with dashes); strip dashes and take 16.
116
- const id = randomUUID().replace(/-/g, "").slice(0, 16);
117
- return `run_${id}`;
118
- }
119
-
120
- export class CitationTrackerError extends CrewhausError {
121
- override readonly name = "CitationTrackerError";
122
- constructor(message: string, cause?: unknown) {
123
- super("runtime", message, cause);
124
- }
125
- }
126
-
127
- export function createCitationTracker(opts: CreateCitationTrackerOptions = {}): CitationTracker {
128
- const runId = opts.runId ?? newRunId();
129
- validateRunId(runId);
130
- const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
131
- const runDir = join(rootDir, runId);
132
- const fetchesPath = join(runDir, "fetches.jsonl");
133
- const citationsPath = join(runDir, "citations.jsonl");
134
- const now = opts.now ?? (() => new Date());
135
-
136
- mkdirSync(runDir, { recursive: true });
137
-
138
- // Boot-time replay of prior records so a resume sees the same dedup state.
139
- const fetchByUrl = new Map<string, FetchRecord>();
140
- const fetchOrder: FetchRecord[] = [];
141
- const citations: Citation[] = [];
142
-
143
- if (existsSync(fetchesPath)) {
144
- for (const raw of readFileSync(fetchesPath, "utf8").split("\n")) {
145
- const line = raw.trim();
146
- if (line.length === 0) continue;
147
- try {
148
- const rec = JSON.parse(line) as FetchRecord;
149
- if (typeof rec.url === "string" && !fetchByUrl.has(rec.url)) {
150
- fetchByUrl.set(rec.url, rec);
151
- fetchOrder.push(rec);
152
- }
153
- } catch (err) {
154
- throw new CitationTrackerError(`malformed JSON in ${fetchesPath}`, err);
155
- }
156
- }
157
- }
158
- if (existsSync(citationsPath)) {
159
- for (const raw of readFileSync(citationsPath, "utf8").split("\n")) {
160
- const line = raw.trim();
161
- if (line.length === 0) continue;
162
- try {
163
- const rec = JSON.parse(line) as Citation;
164
- citations.push(rec);
165
- } catch (err) {
166
- throw new CitationTrackerError(`malformed JSON in ${citationsPath}`, err);
167
- }
168
- }
169
- }
170
-
171
- // Per-fetch content cache — written separately under <runDir>/cache/ so the
172
- // JSONL stays small (citations only carry sha256 + snippet, not full bodies).
173
- const cacheDir = join(runDir, "cache");
174
- mkdirSync(cacheDir, { recursive: true });
175
-
176
- function cachePathFor(sha256: string): string {
177
- return join(cacheDir, `${sha256}.txt`);
178
- }
179
-
180
- function tryReadCache(rec: FetchRecord): string | undefined {
181
- // Defend the cache read against a tampered fetches.jsonl: a sha256 that is
182
- // not a clean 64-hex digest could otherwise traverse out of cache/.
183
- if (typeof rec.sha256 !== "string" || !SHA256_RE.test(rec.sha256)) return undefined;
184
- const p = cachePathFor(rec.sha256);
185
- if (!existsSync(p)) return undefined;
186
- return readFileSync(p, "utf8");
187
- }
188
-
189
- return {
190
- runId,
191
-
192
- hasFetched(url) {
193
- return fetchByUrl.has(url);
194
- },
195
-
196
- getFetchedContent(url) {
197
- const rec = fetchByUrl.get(url);
198
- if (rec === undefined) return undefined;
199
- return tryReadCache(rec);
200
- },
201
-
202
- getFetchRecord(url) {
203
- return fetchByUrl.get(url);
204
- },
205
-
206
- recordFetch(input) {
207
- const existing = fetchByUrl.get(input.url);
208
- if (existing !== undefined) return existing;
209
- const sha256 = createHash("sha256").update(input.content).digest("hex");
210
- const rec: FetchRecord = {
211
- version: 1,
212
- url: input.url,
213
- retrievedAt: now().toISOString(),
214
- sha256,
215
- contentBytes: Buffer.byteLength(input.content, "utf8"),
216
- ...(input.branchId !== undefined ? { branchId: input.branchId } : {}),
217
- };
218
- fetchByUrl.set(input.url, rec);
219
- fetchOrder.push(rec);
220
- appendFileSync(fetchesPath, `${JSON.stringify(rec)}\n`, { mode: 0o600 });
221
- // Cache content for resume by sha256 — same content across runs ends up
222
- // as the same file, so a re-fetch into a different tracker still hits
223
- // the same cache directory if the rootDir is shared.
224
- writeFileSync(cachePathFor(sha256), input.content, { mode: 0o600 });
225
- return rec;
226
- },
227
-
228
- recordCitation(input) {
229
- // Resolve sha256: prefer the matching fetch record's hash so a citation
230
- // anchors to a specific content version. If no matching fetch, hash
231
- // the snippet itself.
232
- const fetch = fetchByUrl.get(input.url);
233
- const sha256 =
234
- fetch !== undefined
235
- ? fetch.sha256
236
- : createHash("sha256").update(input.snippet).digest("hex");
237
- const c: Citation = {
238
- version: 1,
239
- url: input.url,
240
- snippet: input.snippet,
241
- retrievedAt: fetch !== undefined ? fetch.retrievedAt : now().toISOString(),
242
- sha256,
243
- ...(input.branchId !== undefined ? { branchId: input.branchId } : {}),
244
- ...(input.supportingClaim !== undefined ? { supportingClaim: input.supportingClaim } : {}),
245
- };
246
- citations.push(c);
247
- appendFileSync(citationsPath, `${JSON.stringify(c)}\n`, { mode: 0o600 });
248
- return c;
249
- },
250
-
251
- listCitationsOrdered() {
252
- // Stable order: append order. Same-URL citations are kept (the agent
253
- // may cite multiple snippets from one source); the report-writer's
254
- // numbering layer dedups by URL.
255
- return [...citations];
256
- },
257
-
258
- listFetches() {
259
- return [...fetchOrder];
260
- },
261
- };
262
- }