@kontourai/lookout 0.5.2 → 0.6.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
@@ -167,7 +167,7 @@ warnings.
167
167
  | `kind` | When | Extra fields |
168
168
  | --- | --- | --- |
169
169
  | `unchanged-304` | A validator-backed conditional request returned `304`; zero body transfer; the prior snapshot is not re-persisted. | `snapshotRef` |
170
- | `unchanged-hash` | A full body was fetched and persisted, but its sha256 `bodyHash` equals the prior, **and** it came from the same resource URL — established by Lookout's own comparison. | `priorSnapshotRef`, `currentSnapshotRef` |
170
+ | `unchanged-hash` | A full body was fetched, but its sha256 `bodyHash` equals the prior, **and** it came from the same resource URL — established by Lookout's own comparison. A byte-identical repeat of the prior capture is not persisted again (see below). | `priorSnapshotRef`, `currentSnapshotRef` |
171
171
  | `changed` | The fresh body differs from the prior (`changeBasis: "hash"`), **or** it is the first successful observation (`changeBasis: "initial"`, `priorSnapshotRef: null`). | `priorSnapshotRef` (nullable), `currentSnapshotRef`, `changeBasis` |
172
172
  | `error` | Any operational failure — contained so the runner never rejects. | `origin` (`forage` \| `lookout`), `error` |
173
173
 
@@ -179,6 +179,14 @@ snapshot is persisted as the new baseline. (The `unchanged-304` path is already
179
179
  resource-scoped by Forage's validators, so only the hash path needs this
180
180
  guard.)
181
181
 
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
185
+ `unchanged-hash` result names the stored capture as both `priorSnapshotRef` and
186
+ `currentSnapshotRef`, and `checkedAt` records the check. Other response headers
187
+ of the repeat, such as `Date`, are not kept. A stable source therefore adds no
188
+ records, however often it is checked.
189
+
182
190
  `error` results preserve provenance: `origin: "forage"` carries Forage's
183
191
  discriminated `FetchError` verbatim (its `kind`, and `status` when present);
184
192
  `origin: "lookout"` carries a typed `kind` — `prior-read`, `persistence`,
@@ -189,7 +197,8 @@ are portable logical refs from Forage's `buildSnapshotSourceRef` — never
189
197
  filesystem paths. Snapshots are stored via Forage's filesystem store, rooted by
190
198
  default at `<cwd>/.kontourai/lookout/snapshots` (override with the CLI
191
199
  `--snapshot-root` flag or by injecting a store in library use). Lookout adds no
192
- custom filenames or retention. `resolveLookoutSnapshot()` replays one exact
200
+ custom filenames or retention; `createLookoutSnapshotStore(root, {
201
+ maxHistoryFiles })` passes Forage's per-source record ceiling through. `resolveLookoutSnapshot()` replays one exact
193
202
  reference through an injected store or Lookout snapshot root, authenticates its
194
203
  body and replay metadata, and never fetches. References emitted before Forage's
195
204
  replay-envelope digest remain resolvable with `integrity: "body-and-identity"`;
@@ -257,6 +266,15 @@ Hachure-evidence-shaped; a consumer or product lifts them into a Hachure
257
266
  `TrustBundle` with `@kontourai/surface`'s `TrustBundleBuilder` when it wants
258
267
  Surface projection — lookout itself authors nothing in the trust layer.
259
268
 
269
+ An observation id is the SHA-256 of the record's canonical JSON: object keys
270
+ and proposals in UTF-16 code-unit order, never the host locale's collation.
271
+ New records are `version: 2`. Records written before this change are
272
+ `version: 1`, whose digest used the writing host's locale collation. They
273
+ still load on a host whose locale collates their keys the same way, and the
274
+ next commit replaces a `version: 1` prior with a `version: 2` record. Under a
275
+ different locale, a `version: 1` record with non-ASCII or mixed-case keys or
276
+ values can read as `corrupt-state`.
277
+
260
278
  ### Verified proposal head witnesses
261
279
 
262
280
  The concrete filesystem observation store additionally exposes
@@ -360,6 +378,10 @@ const composition = createObserveExtractDiff({
360
378
  // Caller-owned continuity and durable storage.
361
379
  return saveObservation(observation);
362
380
  },
381
+ async lastExtractedSnapshotRef(source) {
382
+ // The newest non-null extractedSnapshotRef(observation) you recorded.
383
+ return loadLastExtractedSnapshotRef(source.id);
384
+ },
363
385
  },
364
386
  });
365
387
 
@@ -367,7 +389,14 @@ const result = await composition.observe(source);
367
389
  ```
368
390
 
369
391
  `unchanged-304` and `unchanged-hash` are recorded as `unchanged` and never call
370
- the extraction capability, so they make zero preparation and provider calls.
392
+ 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
395
+ handled: the current snapshot of a `completed`, `partial`, or `unchanged`
396
+ observation, as `extractedSnapshotRef(observation)` returns. Otherwise the
397
+ capture was persisted but never extracted (for example, a provider failed on the
398
+ check that first saw it), so it is extracted now against that baseline instead
399
+ of being reported as unchanged forever.
371
400
  Changed observations retain source and snapshot references, Traverse's prepared
372
401
  artifact identity, the proposal set, and the current/prior observation
373
402
  identities returned by the recorder. `partial`, `provider-failure`, mixed
@@ -442,10 +471,12 @@ behavior.
442
471
  ## Development
443
472
 
444
473
  ```sh
445
- npm ci
474
+ pnpm install
446
475
  npm run verify # content-boundary + decisions + typecheck + test + pack sanity
447
476
  ```
448
477
 
478
+ The pnpm version is pinned in `package.json` (`packageManager`). Dependency install scripts are blocked by default; the only packages allowed to run one are listed under `allowBuilds` in `pnpm-workspace.yaml`, pinned by version. Scripts are still run with `npm run …` — that only invokes `package.json` scripts and does not depend on which tool installed `node_modules`.
479
+
449
480
  Individual gates: `npm test`, `npm run typecheck`, `npm run check:pack`,
450
481
  `npm run check:decisions`, `npm run check:content-boundary`.
451
482
 
@@ -0,0 +1,8 @@
1
+ /** Orders strings by UTF-16 code unit. Unlike `localeCompare`, it ignores the host locale. */
2
+ export declare function compareCodeUnits(left: string, right: string): number;
3
+ /**
4
+ * JSON text with every object's keys in code-unit order, written directly.
5
+ * Rebuilding an object and calling JSON.stringify cannot give this order,
6
+ * because JavaScript always lists integer-like keys ("9", "10") first.
7
+ */
8
+ export declare function canonicalJson(value: unknown): string;
@@ -0,0 +1,36 @@
1
+ /** Orders strings by UTF-16 code unit. Unlike `localeCompare`, it ignores the host locale. */
2
+ export function compareCodeUnits(left, right) {
3
+ return left < right ? -1 : left > right ? 1 : 0;
4
+ }
5
+ function encode(input, key) {
6
+ // Mirrors JSON.stringify apart from key order: toJSON is honoured (a Date
7
+ // becomes its ISO string); undefined, functions and symbols have no encoding
8
+ // (omitted from objects, null in arrays); array holes and non-finite numbers
9
+ // become null; bigint throws.
10
+ const value = input !== null && typeof input === "object" && typeof input.toJSON === "function"
11
+ ? input.toJSON(key)
12
+ : input;
13
+ if (value === null || typeof value !== "object")
14
+ return JSON.stringify(value);
15
+ // Array.from visits holes; Array.prototype.map would skip them and write `[,1]`.
16
+ if (Array.isArray(value))
17
+ return `[${Array.from(value, (item, index) => encode(item, String(index)) ?? "null").join(",")}]`;
18
+ const members = [];
19
+ for (const key of Object.keys(value).sort(compareCodeUnits)) {
20
+ const item = encode(value[key], key);
21
+ if (item !== undefined)
22
+ members.push(`${JSON.stringify(key)}:${item}`);
23
+ }
24
+ return `{${members.join(",")}}`;
25
+ }
26
+ /**
27
+ * JSON text with every object's keys in code-unit order, written directly.
28
+ * Rebuilding an object and calling JSON.stringify cannot give this order,
29
+ * because JavaScript always lists integer-like keys ("9", "10") first.
30
+ */
31
+ export function canonicalJson(value) {
32
+ const text = encode(value, "");
33
+ if (text === undefined)
34
+ throw new TypeError("Value has no JSON encoding");
35
+ return text;
36
+ }
@@ -66,6 +66,13 @@ export function createCheckRunner(options) {
66
66
  }
67
67
  return { ...base, kind: "unchanged-304", snapshotRef: buildSnapshotSourceRef(prior) };
68
68
  }
69
+ // A byte-identical repeat of the stored capture is not appended: its only
70
+ // new information is the check time, which the result carries. Both
71
+ // refs then name the stored capture, which stays replayable.
72
+ if (prior !== undefined && isRepeatCapture(prior, snapshot)) {
73
+ const priorSnapshotRef = buildSnapshotSourceRef(prior);
74
+ return { ...base, kind: "unchanged-hash", priorSnapshotRef, currentSnapshotRef: priorSnapshotRef };
75
+ }
69
76
  try {
70
77
  await options.store.put(snapshot);
71
78
  }
@@ -104,6 +111,22 @@ export function createCheckRunner(options) {
104
111
  }
105
112
  return { check, checkAll };
106
113
  }
114
+ // Response headers other than the validators a later conditional request
115
+ // reuses (e.g. Date) are not compared: they differ on nearly every response.
116
+ const REVALIDATION_HEADERS = ["etag", "last-modified"];
117
+ /** Same resource, same body, and nothing a later check reads differs. */
118
+ function isRepeatCapture(prior, current) {
119
+ const priorEncoding = bodyEncoding(prior);
120
+ return priorEncoding !== undefined &&
121
+ priorEncoding === bodyEncoding(current) &&
122
+ prior.sourceId === current.sourceId &&
123
+ prior.url === current.url &&
124
+ prior.status === current.status &&
125
+ prior.bodyHash === current.bodyHash &&
126
+ prior.rendered === current.rendered &&
127
+ isDeepStrictEqual(prior.redirects, current.redirects) &&
128
+ REVALIDATION_HEADERS.every((name) => prior.headers?.[name] === current.headers?.[name]);
129
+ }
107
130
  function bodyEncoding(snapshot) {
108
131
  const descriptor = Object.getOwnPropertyDescriptor(snapshot, "body");
109
132
  if (descriptor === undefined || !("value" in descriptor))
@@ -1,6 +1,6 @@
1
1
  import type { LookoutSource } from "./registry.js";
2
2
  import { type ProposalDiffEvent, type ProposalSetDiff, type ProposalSetDiffInput, type ProposalSetFacts, type ProposalSetObservation } from "./proposal-diff.js";
3
- import type { ObservationCheckAnchor, ObservationStore, StoredProposalObservationV1 } from "./observation-store.js";
3
+ import type { ObservationCheckAnchor, ObservationStore, StoredProposalObservation } from "./observation-store.js";
4
4
  import type { SnapshotStore } from "@kontourai/forage";
5
5
  export interface BaselineEstablishedFact {
6
6
  readonly kind: "baseline-established";
@@ -25,7 +25,7 @@ export interface DriftSuccess {
25
25
  readonly facts: readonly DriftFact[];
26
26
  /** The prior observation this drift was diffed against, or null on a first-ever (baseline) observation. */
27
27
  readonly priorObservationId: string | null;
28
- readonly committedObservation: StoredProposalObservationV1;
28
+ readonly committedObservation: StoredProposalObservation;
29
29
  readonly warnings: readonly string[];
30
30
  }
31
31
  export type DriftErrorKind = "invalid-input" | "prior-state-error" | "diff-error" | "persistence-error" | "serialization-error" | "unexpected";
@@ -1,14 +1,15 @@
1
1
  import { diffProposalSets } from "./proposal-diff.js";
2
+ import { compareCodeUnits } from "./canonical-json.js";
2
3
  import { admitProposalObservation } from "./observation-admission.js";
3
4
  function stableJson(value) {
4
5
  if (Array.isArray(value))
5
6
  return `[${value.map(stableJson).join(",")}]`;
6
7
  if (value && typeof value === "object")
7
- return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
8
+ return `{${Object.entries(value).sort(([a], [b]) => compareCodeUnits(a, b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
8
9
  return JSON.stringify(value) ?? "undefined";
9
10
  }
10
11
  function normalizeDiff(value) {
11
- const sorted = (items) => [...items].sort((a, b) => stableJson(a).localeCompare(stableJson(b)));
12
+ const sorted = (items) => [...items].sort((a, b) => compareCodeUnits(stableJson(a), stableJson(b)));
12
13
  return {
13
14
  events: sorted(value.events),
14
15
  facts: {
@@ -10,7 +10,7 @@ export { inMemorySourceStore } from "./source-store.js";
10
10
  export type { SourceStore } from "./source-store.js";
11
11
  export type { ExtractableLookoutSource, LookoutRegistryDocument, LookoutSource, LookoutSourceKind, RenderPolicy, StructuredFileFormat, StructuredFileLookoutSource, } from "./registry.js";
12
12
  export { createLookoutSnapshotStore, resolveLookoutSnapshot, } from "./snapshot-store.js";
13
- export type { ResolveLookoutSnapshotOptions } from "./snapshot-store.js";
13
+ export type { LookoutSnapshotStoreOptions, ResolveLookoutSnapshotOptions } from "./snapshot-store.js";
14
14
  export { admitProposalObservation } from "./observation-admission.js";
15
15
  export type { AdmitProposalObservationInput, AdmittedProposalObservation, AdmittedSnapshotIdentity, ObservationAdmissionError, ObservationAdmissionErrorKind, ObservationAdmissionResult } from "./observation-admission.js";
16
16
  export { admitSourceCapture, admitSourceCheck } from "./source-admission.js";
@@ -23,12 +23,12 @@ export type { KeyedMultisetFacts, KeyedMultisetOptions, StructuralComparison, St
23
23
  export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js";
24
24
  export type { FieldChangedEvent, FieldChangeKind, NewEntityAppearedEvent, ProposalDiffEvent, ProposalEvidence, ProposalIdentity, ProposalOccurrencePair, ProposalSetDiff, ProposalSetDiffInput, ProposalSetFacts, ProposalSetObservation, ProvenanceChangeFact, } from "./proposal-diff.js";
25
25
  export { createObservationStore } from "./observation-store.js";
26
- export type { CreateObservationStoreOptions, HeadWitnessComparison, ObservationCheckAnchor, ObservationStore, ObservationStoreError, ObservationStoreErrorKind, ObservationStoreResult, ProposalHeadWitnessV1, ProposalObservationRecordInput, StoredProposalObservationV1, VerifiedHeadLimits, VerifiedHeadObservationStore, VerifiedHeadRead } from "./observation-store.js";
26
+ export type { CreateObservationStoreOptions, HeadWitnessComparison, ObservationCheckAnchor, ObservationStore, ObservationStoreError, ObservationStoreErrorKind, ObservationStoreResult, ProposalHeadWitnessV1, ProposalObservationRecordInput, StoredProposalObservation, StoredProposalObservationV1, StoredProposalObservationV2, VerifiedHeadLimits, VerifiedHeadObservationStore, VerifiedHeadRead } from "./observation-store.js";
27
27
  export { createDriftEmitter } from "./drift-emission.js";
28
28
  export type { BaselineEstablishedFact, CreateDriftEmitterOptions, DriftEmitter, DriftError, DriftErrorKind, DriftFact, DriftResult, DriftSuccess, EmitDriftInput } from "./drift-emission.js";
29
29
  export { checkSchemaCoverage } from "./coverage.js";
30
30
  export type { SchemaCoverageGap, SchemaCoverageResult } from "./coverage.js";
31
- export { createObserveExtractDiff } from "./observe-extract-diff.js";
31
+ export { createObserveExtractDiff, extractedSnapshotRef } from "./observe-extract-diff.js";
32
32
  export type { ObserveExtractAcquisition, ObserveExtractAttempt, ObserveExtractDiff, ObserveExtractDiffOptions, ObserveExtractError, ObserveExtractErrorKind, ObserveExtractExtraction, ObserveExtractExtractionInput, ObserveExtractObservation, ObserveExtractObservationIdentity, ObserveExtractOutcome, ObserveExtractProviderFailure, ObserveExtractRecorder, ObserveExtractResult, ObserveExtractSource, ObserveExtractSourceSnapshot, } from "./observe-extract-diff.js";
33
33
  export { buildSemanticReviewWork, semanticReviewApiVersion } from "./semantic-review-work.js";
34
34
  export type { BuildSemanticReviewWorkInput, SemanticClaimTarget, SemanticObservationIdentity, SemanticReviewCandidate, SemanticReviewChange, SemanticReviewItem, SemanticReviewKind, SemanticReviewWork, } from "./semantic-review-work.js";
package/dist/src/index.js CHANGED
@@ -12,5 +12,5 @@ export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js
12
12
  export { createObservationStore } from "./observation-store.js";
13
13
  export { createDriftEmitter } from "./drift-emission.js";
14
14
  export { checkSchemaCoverage } from "./coverage.js";
15
- export { createObserveExtractDiff } from "./observe-extract-diff.js";
15
+ export { createObserveExtractDiff, extractedSnapshotRef } from "./observe-extract-diff.js";
16
16
  export { buildSemanticReviewWork, semanticReviewApiVersion } from "./semantic-review-work.js";
@@ -1,6 +1,6 @@
1
1
  import type { SnapshotStore } from "@kontourai/forage";
2
2
  import type { LookoutSource } from "./registry.js";
3
- import type { StoredProposalObservationV1, ObservationCheckAnchor } from "./observation-store.js";
3
+ import type { StoredProposalObservation, ObservationCheckAnchor } from "./observation-store.js";
4
4
  import type { ProposalSetObservation } from "./proposal-diff.js";
5
5
  /** A resolved snapshot identity suitable for durable observation metadata. */
6
6
  export interface AdmittedSnapshotIdentity {
@@ -44,7 +44,7 @@ export interface AdmitProposalObservationInput {
44
44
  readonly current: ProposalSetObservation;
45
45
  readonly check: ObservationCheckAnchor;
46
46
  /** The already-selected observation-store record; admission never selects or persists continuity. */
47
- readonly prior: StoredProposalObservationV1 | null;
47
+ readonly prior: StoredProposalObservation | null;
48
48
  /** Explicit capability: admission never chooses a snapshot root or storage implementation. */
49
49
  readonly snapshotStore: SnapshotStore;
50
50
  }
@@ -21,6 +21,15 @@ export interface StoredProposalObservationV1 {
21
21
  readonly check: ObservationCheckAnchor;
22
22
  readonly proposals: readonly ExtractionProposal[];
23
23
  }
24
+ /**
25
+ * Same fields as version 1. The observationId is computed over code-unit-ordered
26
+ * canonical JSON, so it does not depend on the host locale. New records are
27
+ * always version 2.
28
+ */
29
+ export interface StoredProposalObservationV2 extends Omit<StoredProposalObservationV1, "version"> {
30
+ readonly version: 2;
31
+ }
32
+ export type StoredProposalObservation = StoredProposalObservationV1 | StoredProposalObservationV2;
24
33
  export type ObservationStoreErrorKind = "invalid-input" | "corrupt-state" | "continuity-conflict" | "io-error";
25
34
  export interface ObservationStoreError {
26
35
  readonly kind: ObservationStoreErrorKind;
@@ -36,8 +45,8 @@ export type ObservationStoreResult<T> = {
36
45
  readonly error: ObservationStoreError;
37
46
  };
38
47
  export interface ObservationStore {
39
- loadLatest(sourceId: string): Promise<ObservationStoreResult<StoredProposalObservationV1 | null>>;
40
- commit(input: ProposalObservationRecordInput, expectedPriorId: string | null): Promise<ObservationStoreResult<StoredProposalObservationV1>>;
48
+ loadLatest(sourceId: string): Promise<ObservationStoreResult<StoredProposalObservation | null>>;
49
+ commit(input: ProposalObservationRecordInput, expectedPriorId: string | null): Promise<ObservationStoreResult<StoredProposalObservation>>;
41
50
  }
42
51
  /** Finite caller-controlled ceilings for a verified proposal-head read. */
43
52
  export interface VerifiedHeadLimits {
@@ -3,19 +3,27 @@ import { constants } from "node:fs";
3
3
  import { lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, unlink } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { types } from "node:util";
6
+ import { canonicalJson, compareCodeUnits } from "./canonical-json.js";
6
7
  const VERIFIED_HEAD_MAX = Object.freeze({ maxEntries: 8, maxIndexBytes: 8 * 1024, maxPointerBytes: 8 * 1024, maxRecordBytes: 1024 * 1024 });
7
8
  function sourceKey(sourceId) {
8
9
  return `${encodeURIComponent(sourceId).replaceAll("%", "_").slice(0, 48)}-${createHash("sha256").update(sourceId).digest("hex").slice(0, 16)}`;
9
10
  }
10
- function stable(value) {
11
+ function canonical(value) { return `${canonicalJson(value)}\n`; }
12
+ function digest(body) {
13
+ return createHash("sha256").update(body.version === 1 ? legacyCanonical(body) : canonical(body)).digest("hex");
14
+ }
15
+ // Version 1 digests only. Version 1 records were hashed with keys sorted by the
16
+ // host locale and then rebuilt with Object.fromEntries, which moves integer-like
17
+ // keys first. A version 1 record therefore verifies only under a locale that
18
+ // collates its keys the way the writing host did. Do not use this for new records.
19
+ function legacyStable(value) {
11
20
  if (Array.isArray(value))
12
- return value.map(stable);
21
+ return value.map(legacyStable);
13
22
  if (value && typeof value === "object")
14
- return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, stable(item)]));
23
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, legacyStable(item)]));
15
24
  return value;
16
25
  }
17
- function canonical(value) { return `${JSON.stringify(stable(value))}\n`; }
18
- function digest(body) { return createHash("sha256").update(canonical(body)).digest("hex"); }
26
+ function legacyCanonical(value) { return `${JSON.stringify(legacyStable(value))}\n`; }
19
27
  function resolveHeadLimits(limits) {
20
28
  try {
21
29
  if (limits !== undefined && (!limits || typeof limits !== "object" || types.isProxy(limits) || Array.isArray(limits)))
@@ -219,8 +227,8 @@ function buildRecord(input) {
219
227
  if (!check || check.currentSnapshotRef !== observation.snapshotRef || typeof check.checkedAt !== "string" || (check.resultKind !== "changed" && check.resultKind !== "unchanged-hash") || typeof input.recordedAt !== "string") {
220
228
  return { ok: false, error: { kind: "invalid-input", message: "Check anchor must match the current observation snapshot" } };
221
229
  }
222
- const proposals = [...observation.proposals].sort((a, b) => canonical(a).localeCompare(canonical(b)));
223
- const body = { version: 1, sourceKey: sourceKey(observation.sourceId), sourceId: observation.sourceId, snapshotRef: observation.snapshotRef, observedAt: observation.observedAt, recordedAt: input.recordedAt, check, proposals };
230
+ const proposals = [...observation.proposals].sort((a, b) => compareCodeUnits(canonical(a), canonical(b)));
231
+ const body = { version: 2, sourceKey: sourceKey(observation.sourceId), sourceId: observation.sourceId, snapshotRef: observation.snapshotRef, observedAt: observation.observedAt, recordedAt: input.recordedAt, check, proposals };
224
232
  try {
225
233
  return { ok: true, value: { ...body, observationId: digest(body) } };
226
234
  }
@@ -249,7 +257,7 @@ function validate(value, expectedSourceId) {
249
257
  if (!value || typeof value !== "object" || Array.isArray(value))
250
258
  return { ok: false, error: { kind: "corrupt-state", message: "Stored observation is not an object" } };
251
259
  const item = value;
252
- if (item.version !== 1 || item.sourceId !== expectedSourceId || item.sourceKey !== sourceKey(expectedSourceId) || typeof item.observationId !== "string" || !/^[a-f0-9]{64}$/.test(item.observationId) || typeof item.snapshotRef !== "string" || item.snapshotRef === "" || typeof item.observedAt !== "string" || item.observedAt === "" || typeof item.recordedAt !== "string" || item.recordedAt === "" || !Array.isArray(item.proposals) || item.proposals.some((proposal) => !validProposal(proposal)) || !item.check || typeof item.check !== "object" || typeof item.check.checkedAt !== "string" || item.check.checkedAt === "" || (item.check.resultKind !== "changed" && item.check.resultKind !== "unchanged-hash") || item.check.currentSnapshotRef !== item.snapshotRef) {
260
+ if ((item.version !== 1 && item.version !== 2) || item.sourceId !== expectedSourceId || item.sourceKey !== sourceKey(expectedSourceId) || typeof item.observationId !== "string" || !/^[a-f0-9]{64}$/.test(item.observationId) || typeof item.snapshotRef !== "string" || item.snapshotRef === "" || typeof item.observedAt !== "string" || item.observedAt === "" || typeof item.recordedAt !== "string" || item.recordedAt === "" || !Array.isArray(item.proposals) || item.proposals.some((proposal) => !validProposal(proposal)) || !item.check || typeof item.check !== "object" || typeof item.check.checkedAt !== "string" || item.check.checkedAt === "" || (item.check.resultKind !== "changed" && item.check.resultKind !== "unchanged-hash") || item.check.currentSnapshotRef !== item.snapshotRef) {
253
261
  return { ok: false, error: { kind: "corrupt-state", message: "Stored observation schema or continuity is invalid" } };
254
262
  }
255
263
  const { observationId, ...body } = item;
@@ -416,7 +424,7 @@ export function createObservationStore(options = {}) {
416
424
  return null;
417
425
  } }).filter((item) => item !== null);
418
426
  const preserve = new Set([record.observationId, expectedPriorId].filter((item) => item !== null));
419
- const extras = valid.filter((item) => !preserve.has(item.id)).sort((a, b) => b.recordedAt.localeCompare(a.recordedAt) || b.id.localeCompare(a.id));
427
+ const extras = valid.filter((item) => !preserve.has(item.id)).sort((a, b) => compareCodeUnits(b.recordedAt, a.recordedAt) || compareCodeUnits(b.id, a.id));
420
428
  await Promise.all(extras.map((item) => rm(path.join(dir, item.name), { force: true })));
421
429
  }
422
430
  catch (cause) {
@@ -64,7 +64,24 @@ export interface ObserveExtractObservationIdentity {
64
64
  */
65
65
  export interface ObserveExtractRecorder {
66
66
  record(observation: ObserveExtractObservation): Promise<ObserveExtractObservationIdentity>;
67
+ /**
68
+ * The snapshot reference of this source's most recent recorded observation
69
+ * for which `extractedSnapshotRef(observation)` is not null, or `null` when
70
+ * there is none.
71
+ *
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
74
+ * persisted, but that was never extracted (for example because a provider
75
+ * failed), would be reported as unchanged forever.
76
+ */
77
+ lastExtractedSnapshotRef(source: ObserveExtractSource): Promise<string | null>;
67
78
  }
79
+ /**
80
+ * The snapshot an observation's extraction fully handled: the current snapshot
81
+ * of a `completed`, `partial`, or `unchanged` observation, else `null`.
82
+ * Recorders use this to answer `lastExtractedSnapshotRef`.
83
+ */
84
+ export declare function extractedSnapshotRef(observation: ObserveExtractObservation): string | null;
68
85
  export interface ObserveExtractDiffOptions {
69
86
  readonly acquisition: ObserveExtractAcquisition;
70
87
  readonly extraction: ObserveExtractExtraction;
@@ -1,4 +1,14 @@
1
1
  import { validatePreparedArtifact } from "@kontourai/traverse";
2
+ import { parseSnapshotSourceRef } from "@kontourai/forage/fetch";
3
+ /**
4
+ * The snapshot an observation's extraction fully handled: the current snapshot
5
+ * of a `completed`, `partial`, or `unchanged` observation, else `null`.
6
+ * Recorders use this to answer `lastExtractedSnapshotRef`.
7
+ */
8
+ export function extractedSnapshotRef(observation) {
9
+ const handled = observation.outcome === "completed" || observation.outcome === "partial" || observation.outcome === "unchanged";
10
+ return handled && observation.sourceSnapshot !== null ? observation.sourceSnapshot.currentSnapshotRef : null;
11
+ }
2
12
  export function createObserveExtractDiff(options) {
3
13
  return {
4
14
  async observe(source) {
@@ -19,10 +29,26 @@ export function createObserveExtractDiff(options) {
19
29
  if (check.kind === "error") {
20
30
  return record(options.recorder, baseObservation(source, check, "acquisition-error", null, null, null, null));
21
31
  }
32
+ let sourceSnapshot = snapshotFor(check);
22
33
  if (check.kind === "unchanged-304" || check.kind === "unchanged-hash") {
23
- return record(options.recorder, baseObservation(source, check, "unchanged", snapshotFor(check), null, null, null));
34
+ // "Unchanged" compares with the latest stored capture, which may never
35
+ // have been extracted. Skip extraction only when the capture matches
36
+ // the last one that was; otherwise extract it against that baseline.
37
+ let extracted;
38
+ try {
39
+ extracted = await options.recorder.lastExtractedSnapshotRef(observationSource(source));
40
+ }
41
+ catch (cause) {
42
+ return { ok: false, error: error("recording-failed", "Observation recorder could not report the last extracted snapshot", cause) };
43
+ }
44
+ if (extracted !== null && (typeof extracted !== "string" || extracted === "")) {
45
+ return { ok: false, error: error("dependency-contract", "Observation recorder returned an invalid last extracted snapshot reference") };
46
+ }
47
+ if (extracted !== null && sameCapture(extracted, sourceSnapshot.currentSnapshotRef)) {
48
+ return record(options.recorder, baseObservation(source, check, "unchanged", sourceSnapshot, null, null, null));
49
+ }
50
+ sourceSnapshot = { priorSnapshotRef: extracted, currentSnapshotRef: sourceSnapshot.currentSnapshotRef };
24
51
  }
25
- const sourceSnapshot = snapshotFor(check);
26
52
  let extraction;
27
53
  try {
28
54
  extraction = await options.extraction.extract({ source, snapshotRef: sourceSnapshot.currentSnapshotRef });
@@ -65,7 +91,7 @@ export function createObserveExtractDiff(options) {
65
91
  }
66
92
  function baseObservation(source, check, outcome, sourceSnapshot, preparedArtifact, proposalSet, attempt) {
67
93
  return {
68
- source: { id: source.id, url: source.url, kind: source.kind },
94
+ source: observationSource(source),
69
95
  check,
70
96
  outcome,
71
97
  sourceSnapshot,
@@ -93,6 +119,18 @@ async function record(recorder, observation) {
93
119
  return { ok: false, error: error("recording-failed", "Observation recorder failed", cause), observation };
94
120
  }
95
121
  }
122
+ function observationSource(source) {
123
+ return { id: source.id, url: source.url, kind: source.kind };
124
+ }
125
+ /** Two references name the same capture content: same source, resource URL, and body hash. */
126
+ function sameCapture(left, right) {
127
+ if (left === right)
128
+ 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;
133
+ }
96
134
  function snapshotFor(check) {
97
135
  if (check.kind === "unchanged-304")
98
136
  return { priorSnapshotRef: check.snapshotRef, currentSnapshotRef: check.snapshotRef };
@@ -1,5 +1,6 @@
1
1
  import { canonicalValueKey, } from "./canonical-value.js";
2
2
  import { compareStructural, diffKeyedMultiset } from "./structural-diff.js";
3
+ import { compareCodeUnits } from "./canonical-json.js";
3
4
  function callbackError(label, cause) {
4
5
  return { kind: "callback-threw", message: `${label} callback threw`, cause };
5
6
  }
@@ -90,7 +91,7 @@ function semanticFieldOrder(items, fieldIdentity) {
90
91
  return encoded;
91
92
  groups.set(fieldKey.value, [...(groups.get(fieldKey.value) ?? []), { item, key: encoded.key, index }]);
92
93
  }
93
- const ordered = [...groups.values()].flatMap((group) => group.sort((left, right) => left.key.localeCompare(right.key) || left.index - right.index));
94
+ const ordered = [...groups.values()].flatMap((group) => group.sort((left, right) => compareCodeUnits(left.key, right.key) || left.index - right.index));
94
95
  return { ok: true, value: ordered.map(({ item }) => item) };
95
96
  }
96
97
  export function diffProposalSets(input) {
@@ -1,6 +1,14 @@
1
1
  import type { SnapshotStore } from "@kontourai/forage";
2
2
  import { type SnapshotSourceRefResolution } from "@kontourai/forage/fetch";
3
- export declare function createLookoutSnapshotStore(root?: string): SnapshotStore;
3
+ export interface LookoutSnapshotStoreOptions {
4
+ /**
5
+ * Per-source record ceiling, passed to Forage's filesystem store (1 to
6
+ * 10,000; Forage's default is 10,000). It cannot change after a source's
7
+ * store directory is initialized.
8
+ */
9
+ readonly maxHistoryFiles?: number;
10
+ }
11
+ export declare function createLookoutSnapshotStore(root?: string, options?: LookoutSnapshotStoreOptions): SnapshotStore;
4
12
  export type ResolveLookoutSnapshotOptions = {
5
13
  store: SnapshotStore;
6
14
  root?: never;
@@ -1,8 +1,11 @@
1
1
  import path from "node:path";
2
2
  import { createFilesystemSnapshotStore } from "@kontourai/forage";
3
3
  import { resolveSnapshotSourceRef, } from "@kontourai/forage/fetch";
4
- export function createLookoutSnapshotStore(root = path.join(process.cwd(), ".kontourai", "lookout", "snapshots")) {
5
- return createFilesystemSnapshotStore({ root });
4
+ export function createLookoutSnapshotStore(root = path.join(process.cwd(), ".kontourai", "lookout", "snapshots"), options = {}) {
5
+ return createFilesystemSnapshotStore({
6
+ root,
7
+ ...(options.maxHistoryFiles === undefined ? {} : { maxHistoryFiles: options.maxHistoryFiles }),
8
+ });
6
9
  }
7
10
  /** Replay one Lookout-emitted durable reference without any network access. */
8
11
  export async function resolveLookoutSnapshot(reference, options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/lookout",
3
- "version": "0.5.2",
3
+ "version": "0.6.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",
@@ -54,9 +54,13 @@
54
54
  "@kontourai/flow-agents": "5.9.0",
55
55
  "@kontourai/survey": "3.0.0",
56
56
  "@types/node": "^26.1.2",
57
+ "ajv": "8.20.0",
58
+ "ajv-formats": "3.0.1",
59
+ "hachure": "0.15.0",
57
60
  "typescript": "^7.0.2"
58
61
  },
59
62
  "engines": {
60
63
  "node": ">=22"
61
- }
64
+ },
65
+ "packageManager": "pnpm@11.25.0"
62
66
  }