@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 +41 -8
- package/dist/src/capture-decoding.d.ts +12 -0
- package/dist/src/capture-decoding.js +18 -0
- package/dist/src/check-result.d.ts +1 -1
- package/dist/src/check-runner.d.ts +20 -0
- package/dist/src/check-runner.js +75 -2
- package/dist/src/index.d.ts +1 -1
- package/dist/src/observe-extract-diff.d.ts +11 -2
- package/dist/src/observe-extract-diff.js +19 -8
- package/dist/src/snapshot-store.d.ts +2 -2
- package/package.json +4 -4
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,
|
|
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
|
|
201
|
-
maxHistoryFiles })` passes Forage's per-source record ceiling through
|
|
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
|
|
394
|
-
`lastExtractedSnapshotRef`.
|
|
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;
|
package/dist/src/check-runner.js
CHANGED
|
@@ -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
|
-
|
|
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))
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
|
73
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
126
|
-
|
|
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 =
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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):
|
|
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.
|
|
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.
|
|
50
|
-
"@kontourai/forage": "0.
|
|
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": "
|
|
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",
|