@kontourai/lookout 0.6.0 → 0.7.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/README.md CHANGED
@@ -180,25 +180,54 @@ resource-scoped by Forage's validators, so only the hash path needs this
180
180
  guard.)
181
181
 
182
182
  A fetch that repeats the prior capture exactly is not appended to snapshot
183
- history: same URL, status, body bytes, redirects, render state, and `etag` /
184
- `last-modified` validators, with only the fetch time differing. Its
183
+ history: same URL, status, body bytes, text decoding, redirects, render state,
184
+ and `etag` / `last-modified` validators, with only the fetch time differing. Its
185
185
  `unchanged-hash` result names the stored capture as both `priorSnapshotRef` and
186
186
  `currentSnapshotRef`, and `checkedAt` records the check. Other response headers
187
187
  of the repeat, such as `Date`, are not kept. A stable source therefore adds no
188
- records, however often it is checked.
188
+ records, however often it is checked. The text decoding is the encoding the
189
+ declared `Content-Type` charset resolves to, so the same bytes under another
190
+ charset (other text for an extractor) are a new capture, while `utf8` and
191
+ `utf-8` are the same decoding.
192
+
193
+ Changed captures still grow history. Pass `retention` to bound it with Forage's
194
+ `prune`: after each stored capture, the runner keeps the newest `keepLast`
195
+ snapshots, the latest, the prior capture its result names, and every reference
196
+ `cited(sourceId)` returns. Cite what your observations, review rounds, and
197
+ exported receipts still reference. If `cited` throws or returns anything but
198
+ snapshot references, nothing is pruned and the result carries a warning.
199
+ `cited` is read just before each prune, outside the store's lock, so record a
200
+ citation before handing its reference out.
201
+
202
+ ```ts
203
+ const runner = createCheckRunner({
204
+ store,
205
+ retention: { keepLast: 20, cited: (sourceId) => loadCitedSnapshotRefs(sourceId) },
206
+ });
207
+ ```
208
+
209
+ **Upgrading to 0.7 (Forage 1.0).** New text captures hash their exact bytes.
210
+ A text page whose bytes are not plain UTF-8 (a non-UTF-8 charset, a byte-order
211
+ mark, or invalid UTF-8) therefore reports `changed` once, on its first check
212
+ after the upgrade, and is extracted again. Drift comes from proposal diffs, so
213
+ a page whose extracted content is identical emits nothing. Plain UTF-8 pages
214
+ keep their stored capture.
189
215
 
190
216
  `error` results preserve provenance: `origin: "forage"` carries Forage's
191
217
  discriminated `FetchError` verbatim (its `kind`, and `status` when present);
192
218
  `origin: "lookout"` carries a typed `kind` — `prior-read`, `persistence`,
193
- `dependency-contract`, or `unexpected`.
219
+ `history-full`, `dependency-contract`, or `unexpected`. `history-full` means the
220
+ source holds Forage's `maxHistoryFiles` records; with `retention` the runner
221
+ prunes once and retries before reporting it.
194
222
 
195
223
  Snapshot references (`snapshotRef` / `priorSnapshotRef` / `currentSnapshotRef`)
196
224
  are portable logical refs from Forage's `buildSnapshotSourceRef` — never
197
225
  filesystem paths. Snapshots are stored via Forage's filesystem store, rooted by
198
226
  default at `<cwd>/.kontourai/lookout/snapshots` (override with the CLI
199
227
  `--snapshot-root` flag or by injecting a store in library use). Lookout adds no
200
- custom filenames or retention; `createLookoutSnapshotStore(root, {
201
- maxHistoryFiles })` passes Forage's per-source record ceiling through. `resolveLookoutSnapshot()` replays one exact
228
+ custom filenames or storage format; `createLookoutSnapshotStore(root, {
229
+ maxHistoryFiles })` passes Forage's per-source record ceiling through and
230
+ returns a store with Forage's `prune`. `resolveLookoutSnapshot()` replays one exact
202
231
  reference through an injected store or Lookout snapshot root, authenticates its
203
232
  body and replay metadata, and never fetches. References emitted before Forage's
204
233
  replay-envelope digest remain resolvable with `integrity: "body-and-identity"`;
@@ -366,6 +395,8 @@ import { createObserveExtractDiff } from "@kontourai/lookout";
366
395
 
367
396
  const composition = createObserveExtractDiff({
368
397
  acquisition: { check: runner.check },
398
+ // The store acquisition persists to; used to compare captures by reference.
399
+ snapshots: store,
369
400
  extraction: {
370
401
  async extract({ source, snapshotRef }) {
371
402
  // Resolve `snapshotRef`, prepare content, and invoke a caller-selected
@@ -390,8 +421,10 @@ const result = await composition.observe(source);
390
421
 
391
422
  `unchanged-304` and `unchanged-hash` are recorded as `unchanged` and never call
392
423
  the extraction capability, so they make zero preparation and provider calls,
393
- when the capture has the same URL and body hash as the recorder's
394
- `lastExtractedSnapshotRef`. That is the last snapshot an observation fully
424
+ when the capture has the same URL, body hash, and text decoding as the
425
+ recorder's `lastExtractedSnapshotRef`. Both references are resolved through
426
+ `snapshots`; one that does not resolve (for example, pruned) counts as a
427
+ different capture and is extracted. That is the last snapshot an observation fully
395
428
  handled: the current snapshot of a `completed`, `partial`, or `unchanged`
396
429
  observation, as `extractedSnapshotRef(observation)` returns. Otherwise the
397
430
  capture was persisted but never extracted (for example, a provider failed on the
@@ -0,0 +1,12 @@
1
+ import type { Snapshot } from "@kontourai/forage/fetch";
2
+ /**
3
+ * The encoding a text capture's body was decoded with, or `null` for a binary
4
+ * capture. Two captures with the same bytes but a different decoding give the
5
+ * extractor different text, so they are different captures.
6
+ *
7
+ * Text captures that carry `bytes` were decoded with their declared charset;
8
+ * the label is resolved the way Forage decodes it, so `utf8` and `utf-8` are
9
+ * the same decoding. Text captures without `bytes` (the earlier Forage record
10
+ * format, and rendered pages) were decoded or serialized as UTF-8.
11
+ */
12
+ export declare function captureDecoding(snapshot: Snapshot): string | null;
@@ -0,0 +1,18 @@
1
+ import { decodeTextBody } from "@kontourai/forage/fetch";
2
+ /**
3
+ * The encoding a text capture's body was decoded with, or `null` for a binary
4
+ * capture. Two captures with the same bytes but a different decoding give the
5
+ * extractor different text, so they are different captures.
6
+ *
7
+ * Text captures that carry `bytes` were decoded with their declared charset;
8
+ * the label is resolved the way Forage decodes it, so `utf8` and `utf-8` are
9
+ * the same decoding. Text captures without `bytes` (the earlier Forage record
10
+ * format, and rendered pages) were decoded or serialized as UTF-8.
11
+ */
12
+ export function captureDecoding(snapshot) {
13
+ if (typeof snapshot.body !== "string")
14
+ return null;
15
+ if (snapshot.bytes === undefined)
16
+ return "utf-8";
17
+ return decodeTextBody(new Uint8Array(0), snapshot.declaredCharset ?? null).encoding;
18
+ }
@@ -20,7 +20,7 @@ export interface ChangedResult extends CheckResultCommon {
20
20
  currentSnapshotRef: string;
21
21
  changeBasis: "initial" | "hash";
22
22
  }
23
- export type LookoutErrorKind = "prior-read" | "persistence" | "dependency-contract" | "unexpected";
23
+ export type LookoutErrorKind = "prior-read" | "persistence" | "history-full" | "dependency-contract" | "unexpected";
24
24
  export interface ErrorResult extends CheckResultCommon {
25
25
  kind: "error";
26
26
  origin: "forage" | "lookout";
@@ -3,8 +3,28 @@ import type { CheckResult } from "./check-result.js";
3
3
  import type { ProviderResolver } from "./provider-resolution.js";
4
4
  import type { LookoutSource } from "./registry.js";
5
5
  export type FetchSource = (config: SourceConfig, options?: FetchSourceOptions) => Promise<FetchResult>;
6
+ /**
7
+ * Bounds each source's snapshot history with the store's `prune` capability.
8
+ * After every stored capture, all but the newest `keepLast` snapshots are
9
+ * removed, except the latest, the captures the result names, and every
10
+ * snapshot `cited` returns. `cited` is read just before the prune, outside the
11
+ * store's lock: a citation recorded after that read is not protected by that
12
+ * prune, so record a citation before handing its reference out.
13
+ */
14
+ export interface SnapshotRetention {
15
+ /** Newest snapshots kept per source (a non-negative integer; the latest is always kept). */
16
+ readonly keepLast: number;
17
+ /**
18
+ * Snapshot references the caller still cites for this source: recorded
19
+ * observations, review rounds, exported receipts. Never pruned. When this
20
+ * throws or returns anything but canonical references, nothing is pruned.
21
+ */
22
+ readonly cited: (sourceId: string) => Promise<readonly string[]> | readonly string[];
23
+ }
6
24
  export interface CreateCheckRunnerOptions {
7
25
  store: SnapshotStore;
26
+ /** Prune history after each stored capture. Without it, history is kept in full. */
27
+ retention?: SnapshotRetention;
8
28
  fetchSource?: FetchSource;
9
29
  fetchOptions?: Omit<FetchSourceOptions, "store">;
10
30
  clock?: () => string;
@@ -1,5 +1,6 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
- import { buildSnapshotSourceRef, fetchSource as forageFetchSource, } from "@kontourai/forage/fetch";
2
+ import { buildSnapshotSourceRef, fetchSource as forageFetchSource, isSnapshotHistoryFullError, parseSnapshotSourceRef, } from "@kontourai/forage/fetch";
3
+ import { captureDecoding } from "./capture-decoding.js";
3
4
  /**
4
5
  * The egress policy for lookout's registered-source fetches. Registered source
5
6
  * URLs are operator- / aggregator-supplied and not fully trusted, so a source
@@ -15,6 +16,40 @@ export function createCheckRunner(options) {
15
16
  const fetchImpl = options.fetchSource ?? forageFetchSource;
16
17
  const clock = options.clock ?? (() => new Date().toISOString());
17
18
  const fetchOptions = { ...options.fetchOptions };
19
+ const retention = options.retention;
20
+ if (retention !== undefined && (!Number.isSafeInteger(retention.keepLast) || retention.keepLast < 0 || typeof retention.cited !== "function")) {
21
+ throw new TypeError("retention requires a non-negative integer keepLast and a cited function");
22
+ }
23
+ /** Apply retention for one source. Returns a warning instead of throwing: the capture is already stored. */
24
+ async function prune(sourceId, keepAlso) {
25
+ if (retention === undefined)
26
+ return undefined;
27
+ if (typeof options.store.prune !== "function")
28
+ return "snapshot retention skipped: the store cannot prune";
29
+ const keep = keepAlso.map((snapshot) => lookupOf(buildSnapshotSourceRef(snapshot)));
30
+ try {
31
+ const cited = await retention.cited(sourceId);
32
+ if (!Array.isArray(cited))
33
+ return "snapshot retention skipped: cited references are not a list";
34
+ for (const reference of cited) {
35
+ const lookup = lookupOf(reference);
36
+ if (lookup === undefined)
37
+ return "snapshot retention skipped: a cited reference is not a snapshot reference";
38
+ if (lookup.sourceId === sourceId)
39
+ keep.push(lookup);
40
+ }
41
+ }
42
+ catch (error) {
43
+ return `snapshot retention skipped: cited references could not be read: ${error instanceof Error ? error.message : String(error)}`;
44
+ }
45
+ try {
46
+ await options.store.prune(sourceId, { keepLast: retention.keepLast, keep });
47
+ return undefined;
48
+ }
49
+ catch (error) {
50
+ return `snapshot retention failed: ${error instanceof Error ? error.message : String(error)}`;
51
+ }
52
+ }
18
53
  async function check(source) {
19
54
  const common = () => ({
20
55
  sourceId: source.id,
@@ -73,12 +108,36 @@ export function createCheckRunner(options) {
73
108
  const priorSnapshotRef = buildSnapshotSourceRef(prior);
74
109
  return { ...base, kind: "unchanged-hash", priorSnapshotRef, currentSnapshotRef: priorSnapshotRef };
75
110
  }
111
+ // The capture just stored is kept even when it is not the newest (clock
112
+ // skew, or another check storing a later capture first): its result names it.
113
+ const keepAlso = prior === undefined ? [snapshot] : [snapshot, prior];
76
114
  try {
77
115
  await options.store.put(snapshot);
78
116
  }
79
117
  catch (error) {
80
- return lookoutError(base, "persistence", error);
118
+ if (!isSnapshotHistoryFullError(error))
119
+ return lookoutError(base, "persistence", error);
120
+ // A full history refuses every later capture. Retention frees space
121
+ // (for example when it is enabled on a store that already reached the
122
+ // ceiling), so prune once and retry; otherwise say what to do.
123
+ const warning = await prune(source.id, keepAlso);
124
+ if (retention === undefined || warning !== undefined) {
125
+ if (warning !== undefined)
126
+ base.warnings.push(warning);
127
+ return historyFull(base, error.maxHistoryFiles);
128
+ }
129
+ try {
130
+ await options.store.put(snapshot);
131
+ }
132
+ catch (retryError) {
133
+ return isSnapshotHistoryFullError(retryError)
134
+ ? historyFull(base, retryError.maxHistoryFiles)
135
+ : lookoutError(base, "persistence", retryError);
136
+ }
81
137
  }
138
+ const retentionWarning = await prune(source.id, keepAlso);
139
+ if (retentionWarning !== undefined)
140
+ base.warnings.push(retentionWarning);
82
141
  const currentSnapshotRef = buildSnapshotSourceRef(snapshot);
83
142
  if (!prior) {
84
143
  return {
@@ -123,10 +182,24 @@ function isRepeatCapture(prior, current) {
123
182
  prior.url === current.url &&
124
183
  prior.status === current.status &&
125
184
  prior.bodyHash === current.bodyHash &&
185
+ // Same bytes under another charset decode to other text: a new capture.
186
+ captureDecoding(prior) === captureDecoding(current) &&
126
187
  prior.rendered === current.rendered &&
127
188
  isDeepStrictEqual(prior.redirects, current.redirects) &&
128
189
  REVALIDATION_HEADERS.every((name) => prior.headers?.[name] === current.headers?.[name]);
129
190
  }
191
+ function lookupOf(reference) {
192
+ if (typeof reference !== "string")
193
+ return undefined;
194
+ const parsed = parseSnapshotSourceRef(reference);
195
+ if (parsed === undefined)
196
+ return undefined;
197
+ const { sourceId, url, bodyHash, fetchedAt, snapshotDigest } = parsed;
198
+ return { sourceId, url, bodyHash, fetchedAt, ...(snapshotDigest === undefined ? {} : { snapshotDigest }) };
199
+ }
200
+ function historyFull(common, maxHistoryFiles) {
201
+ return lookoutError(common, "history-full", `snapshot history for this source holds its maximum of ${maxHistoryFiles} records; configure snapshot retention or prune the store`);
202
+ }
130
203
  function bodyEncoding(snapshot) {
131
204
  const descriptor = Object.getOwnPropertyDescriptor(snapshot, "body");
132
205
  if (descriptor === undefined || !("value" in descriptor))
@@ -1,7 +1,7 @@
1
1
  export { runCli } from "./cli.js";
2
2
  export type { RunCliOptions } from "./cli.js";
3
3
  export { createCheckRunner } from "./check-runner.js";
4
- export type { CheckRunner, CreateCheckRunnerOptions, FetchSource } from "./check-runner.js";
4
+ export type { CheckRunner, CreateCheckRunnerOptions, FetchSource, SnapshotRetention } from "./check-runner.js";
5
5
  export type { ChangedResult, CheckResult, CheckResultCommon, ErrorResult, LookoutErrorKind, Unchanged304Result, UnchangedHashResult, } from "./check-result.js";
6
6
  export { defaultProviderResolver } from "./provider-resolution.js";
7
7
  export type { ProviderResolver } from "./provider-resolution.js";
@@ -1,4 +1,5 @@
1
1
  import type { ExtractionPartial, ExtractionProviderFailure, ExtractionResult, PreparedArtifact } from "@kontourai/traverse";
2
+ import type { SnapshotStore } from "@kontourai/forage/fetch";
2
3
  import type { CheckResult } from "./check-result.js";
3
4
  import type { ProposalSetObservation } from "./proposal-diff.js";
4
5
  import type { LookoutSource } from "./registry.js";
@@ -69,8 +70,8 @@ export interface ObserveExtractRecorder {
69
70
  * for which `extractedSnapshotRef(observation)` is not null, or `null` when
70
71
  * there is none.
71
72
  *
72
- * An unchanged check is only skipped when its capture has the same URL and
73
- * body hash as this snapshot. Otherwise a change that acquisition already
73
+ * An unchanged check is only skipped when its capture has the same URL, body
74
+ * hash, and text decoding as this snapshot. Otherwise a change that acquisition already
74
75
  * persisted, but that was never extracted (for example because a provider
75
76
  * failed), would be reported as unchanged forever.
76
77
  */
@@ -86,6 +87,14 @@ export interface ObserveExtractDiffOptions {
86
87
  readonly acquisition: ObserveExtractAcquisition;
87
88
  readonly extraction: ObserveExtractExtraction;
88
89
  readonly recorder: ObserveExtractRecorder;
90
+ /**
91
+ * The snapshot store acquisition persists to. An unchanged check is compared
92
+ * with the last extracted capture by resolving both references here, since a
93
+ * reference does not name the charset its text was decoded with. A reference
94
+ * that does not resolve counts as a different capture, so it is extracted.
95
+ * With snapshot retention, cite the last extracted reference to keep it.
96
+ */
97
+ readonly snapshots: SnapshotStore;
89
98
  }
90
99
  export type ObserveExtractErrorKind = "acquisition-threw" | "recording-failed" | "dependency-contract";
91
100
  export interface ObserveExtractError {
@@ -1,5 +1,6 @@
1
1
  import { validatePreparedArtifact } from "@kontourai/traverse";
2
- import { parseSnapshotSourceRef } from "@kontourai/forage/fetch";
2
+ import { resolveSnapshotSourceRef } from "@kontourai/forage/fetch";
3
+ import { captureDecoding } from "./capture-decoding.js";
3
4
  /**
4
5
  * The snapshot an observation's extraction fully handled: the current snapshot
5
6
  * of a `completed`, `partial`, or `unchanged` observation, else `null`.
@@ -10,6 +11,11 @@ export function extractedSnapshotRef(observation) {
10
11
  return handled && observation.sourceSnapshot !== null ? observation.sourceSnapshot.currentSnapshotRef : null;
11
12
  }
12
13
  export function createObserveExtractDiff(options) {
14
+ // Without a store every unchanged capture would silently count as new and be
15
+ // extracted again, so a missing one is refused up front.
16
+ if (typeof options?.snapshots?.findExact !== "function") {
17
+ throw new TypeError("createObserveExtractDiff requires snapshots: the snapshot store acquisition persists to, with exact lookup");
18
+ }
13
19
  return {
14
20
  async observe(source) {
15
21
  try {
@@ -44,7 +50,7 @@ export function createObserveExtractDiff(options) {
44
50
  if (extracted !== null && (typeof extracted !== "string" || extracted === "")) {
45
51
  return { ok: false, error: error("dependency-contract", "Observation recorder returned an invalid last extracted snapshot reference") };
46
52
  }
47
- if (extracted !== null && sameCapture(extracted, sourceSnapshot.currentSnapshotRef)) {
53
+ if (extracted !== null && await sameCapture(options.snapshots, extracted, sourceSnapshot.currentSnapshotRef)) {
48
54
  return record(options.recorder, baseObservation(source, check, "unchanged", sourceSnapshot, null, null, null));
49
55
  }
50
56
  sourceSnapshot = { priorSnapshotRef: extracted, currentSnapshotRef: sourceSnapshot.currentSnapshotRef };
@@ -122,14 +128,19 @@ async function record(recorder, observation) {
122
128
  function observationSource(source) {
123
129
  return { id: source.id, url: source.url, kind: source.kind };
124
130
  }
125
- /** Two references name the same capture content: same source, resource URL, and body hash. */
126
- function sameCapture(left, right) {
131
+ /**
132
+ * Two references name the same capture content: same source, resource URL,
133
+ * body hash, and text decoding. Same bytes under another declared charset are
134
+ * other text, so they are a different capture.
135
+ */
136
+ async function sameCapture(store, left, right) {
127
137
  if (left === right)
128
138
  return true;
129
- const a = parseSnapshotSourceRef(left);
130
- const b = parseSnapshotSourceRef(right);
131
- return a !== undefined && b !== undefined &&
132
- a.sourceId === b.sourceId && a.url === b.url && a.bodyHash === b.bodyHash;
139
+ const [a, b] = await Promise.all([resolveSnapshotSourceRef(store, left), resolveSnapshotSourceRef(store, right)]);
140
+ if (!a.ok || !b.ok)
141
+ return false;
142
+ return a.snapshot.sourceId === b.snapshot.sourceId && a.snapshot.url === b.snapshot.url &&
143
+ a.snapshot.bodyHash === b.snapshot.bodyHash && captureDecoding(a.snapshot) === captureDecoding(b.snapshot);
133
144
  }
134
145
  function snapshotFor(check) {
135
146
  if (check.kind === "unchanged-304")
@@ -1,4 +1,4 @@
1
- import type { SnapshotStore } from "@kontourai/forage";
1
+ import type { ExactSnapshotStore, PrunableSnapshotStore, SnapshotStore } from "@kontourai/forage";
2
2
  import { type SnapshotSourceRefResolution } from "@kontourai/forage/fetch";
3
3
  export interface LookoutSnapshotStoreOptions {
4
4
  /**
@@ -8,7 +8,7 @@ export interface LookoutSnapshotStoreOptions {
8
8
  */
9
9
  readonly maxHistoryFiles?: number;
10
10
  }
11
- export declare function createLookoutSnapshotStore(root?: string, options?: LookoutSnapshotStoreOptions): SnapshotStore;
11
+ export declare function createLookoutSnapshotStore(root?: string, options?: LookoutSnapshotStoreOptions): ExactSnapshotStore & PrunableSnapshotStore;
12
12
  export type ResolveLookoutSnapshotOptions = {
13
13
  store: SnapshotStore;
14
14
  root?: never;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/lookout",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A small source registry and drift-check runner built on Forage snapshots.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -46,12 +46,12 @@
46
46
  "workflow:validate-artifacts": "flow-agents-validate-artifacts"
47
47
  },
48
48
  "dependencies": {
49
- "@kontourai/datum": "0.7.0",
50
- "@kontourai/forage": "0.6.1",
49
+ "@kontourai/datum": "0.8.0",
50
+ "@kontourai/forage": "1.0.0",
51
51
  "@kontourai/traverse": "0.25.1"
52
52
  },
53
53
  "devDependencies": {
54
- "@kontourai/flow-agents": "5.9.0",
54
+ "@kontourai/flow-agents": "6.4.0",
55
55
  "@kontourai/survey": "3.0.0",
56
56
  "@types/node": "^26.1.2",
57
57
  "ajv": "8.20.0",