@kontourai/lookout 0.3.0 → 0.3.1
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 +102 -31
- package/dist/src/check-result.d.ts +2 -2
- package/dist/src/check-runner.d.ts +1 -1
- package/dist/src/check-runner.js +39 -26
- package/dist/src/index.d.ts +6 -2
- package/dist/src/index.js +2 -1
- package/dist/src/observe-extract-diff.d.ts +90 -0
- package/dist/src/observe-extract-diff.js +154 -0
- package/dist/src/registry.d.ts +16 -4
- package/dist/src/registry.js +107 -41
- package/dist/src/snapshot-store.d.ts +11 -1
- package/dist/src/snapshot-store.js +18 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
**Cheap, honest drift detection for content you re-check over time — "did this source change since we last looked?"**
|
|
4
4
|
|
|
5
5
|
A small **source registry** and **non-throwing drift-check runner** built on
|
|
6
|
-
[
|
|
7
|
-
|
|
6
|
+
[Forage](https://github.com/kontourai/forage) snapshots. It composes Forage for
|
|
7
|
+
fetching and snapshot storage, provides deterministic proposal diffing, and
|
|
8
8
|
emits neutral, typed drift — its own vocabulary, in its own dependency-free
|
|
9
|
-
package. It does **not**
|
|
10
|
-
Surface, review claims, notify, crawl, or schedule.
|
|
11
|
-
returned as typed results.
|
|
9
|
+
package. It does **not** implement acquisition or extraction, author trust-layer
|
|
10
|
+
records, project to Surface, review claims, notify, crawl, or schedule.
|
|
11
|
+
Operational failures are returned as typed results.
|
|
12
12
|
|
|
13
13
|
## Why it's different
|
|
14
14
|
|
|
@@ -21,7 +21,7 @@ as "no change"). Lookout is the opposite on all three:
|
|
|
21
21
|
|---|---|---|
|
|
22
22
|
| Cost | full re-download every check | **conditional `304`** — often no download at all |
|
|
23
23
|
| Signal | raw bytes (ads/timestamps = "changed") | **proposal-identity diff** — "a *new entity appeared*" vs "a byte moved" |
|
|
24
|
-
| Honesty | a crash or false-`304` silently reads as "unchanged" | **typed results, never throws**;
|
|
24
|
+
| Honesty | a crash or false-`304` silently reads as "unchanged" | **typed results, never throws**; Forage scopes validators to the exact prior resource and Lookout verifies the returned capture identity |
|
|
25
25
|
| Output | your problem to shape | **neutral, typed drift** — events already Hachure-evidence-shaped, ready for a consumer to lift into a trust bundle |
|
|
26
26
|
|
|
27
27
|
So the point isn't "diffing" — it's *cheap + honest + semantic + review-ready*
|
|
@@ -37,8 +37,7 @@ One layer in a four-verb stack; each repo owns one verb and is usable alone:
|
|
|
37
37
|
- **lookout** — CHANGE: did this registered source drift since last look?
|
|
38
38
|
- **survey** — the SHAPE: what reviewed truth looks like (claims / review / resolution)
|
|
39
39
|
|
|
40
|
-
Lookout composes
|
|
41
|
-
`forage` as its single-source fetch surface lands) for cheap `304`-aware re-checks,
|
|
40
|
+
Lookout composes Forage's `/fetch` surface for cheap `304`-aware re-checks,
|
|
42
41
|
and Traverse's `ExtractionProposal` identity for the semantic diff. Dependency
|
|
43
42
|
arrows point only downward — no cycles. Lookout itself depends on nothing in the
|
|
44
43
|
trust layer: its events are already Hachure-evidence-shaped (`snapshotRef` /
|
|
@@ -58,12 +57,11 @@ owns its transport and opts out of the default guard.
|
|
|
58
57
|
## Requirements
|
|
59
58
|
|
|
60
59
|
- Node.js `>= 22`.
|
|
61
|
-
- `@kontourai/
|
|
62
|
-
trustworthy `unchanged-304`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
different URL and report a **false** `304`, so do not downgrade.
|
|
60
|
+
- `@kontourai/forage` `>= 0.4.0`. Exact durable-reference replay requires the
|
|
61
|
+
resolver first released in `0.4.0`; trustworthy `unchanged-304`
|
|
62
|
+
classification also depends on Forage's validator-scoped revalidation.
|
|
63
|
+
Lookout exact-pins Traverse separately for schema and extraction-proposal
|
|
64
|
+
contracts.
|
|
67
65
|
|
|
68
66
|
## Registry
|
|
69
67
|
|
|
@@ -81,6 +79,13 @@ override with the library path argument or the CLI `--registry` flag):
|
|
|
81
79
|
"targetSchema": [{ "path": "title", "type": "string", "required": true }],
|
|
82
80
|
"cadenceHint": "daily",
|
|
83
81
|
"renderPolicy": "never"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"id": "published-results",
|
|
85
|
+
"kind": "structured-file",
|
|
86
|
+
"format": "yaml",
|
|
87
|
+
"url": "https://raw.githubusercontent.com/example/project/0123456789abcdef0123456789abcdef01234567/results.yml",
|
|
88
|
+
"cadenceHint": "weekly"
|
|
84
89
|
}
|
|
85
90
|
]
|
|
86
91
|
}
|
|
@@ -91,11 +96,17 @@ Fields per source:
|
|
|
91
96
|
| Field | Meaning |
|
|
92
97
|
| --- | --- |
|
|
93
98
|
| `id` | Non-empty, unique within the document. Used for exact lookup and snapshot identity. |
|
|
94
|
-
| `kind` | `web-page` or `
|
|
99
|
+
| `kind` | `web-page`, `api-record`, or `structured-file`. |
|
|
95
100
|
| `url` | Absolute HTTP(S) URL. |
|
|
96
|
-
| `
|
|
101
|
+
| `format` | Required only for `structured-file`: `yaml`, `json`, or `csv`. Lookout does not parse it. |
|
|
102
|
+
| `targetSchema` | Required for `web-page` and `api-record`: Traverse `TargetFieldSchema[]`. Stored inert at L1 (no extraction yet). |
|
|
97
103
|
| `cadenceHint` | Non-empty string; advisory only — Lookout does not schedule. |
|
|
98
|
-
| `renderPolicy` | `never` \| `on-shell-warning` \| `always`. **Inert** at L1
|
|
104
|
+
| `renderPolicy` | Required for `web-page` and `api-record`: `never` \| `on-shell-warning` \| `always`. **Inert** at L1. |
|
|
105
|
+
|
|
106
|
+
Structured-file entries retain raw fetched bytes and use the same guarded fetch,
|
|
107
|
+
snapshot, comparison, and replay path as every other source. Parsing and domain
|
|
108
|
+
normalization stay downstream. Prefer immutable, commit-pinned artifact URLs so
|
|
109
|
+
an upstream branch move cannot silently redefine the cited source location.
|
|
99
110
|
|
|
100
111
|
Validation reports **every** deterministic issue at once (with index/id context)
|
|
101
112
|
and never reads the network. Lookup is exact-id; listing preserves file order —
|
|
@@ -104,35 +115,39 @@ no merging, discovery, or remote registries.
|
|
|
104
115
|
## Check results
|
|
105
116
|
|
|
106
117
|
A check classifies each source into exactly one of four kinds. Every result
|
|
107
|
-
carries `sourceId`, the registered `sourceUrl`, `checkedAt`, and
|
|
108
|
-
|
|
118
|
+
carries `sourceId`, the registered `sourceUrl`, `checkedAt`, and Forage fetch
|
|
119
|
+
warnings.
|
|
109
120
|
|
|
110
121
|
| `kind` | When | Extra fields |
|
|
111
122
|
| --- | --- | --- |
|
|
112
123
|
| `unchanged-304` | A validator-backed conditional request returned `304`; zero body transfer; the prior snapshot is not re-persisted. | `snapshotRef` |
|
|
113
124
|
| `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` |
|
|
114
125
|
| `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` |
|
|
115
|
-
| `error` | Any operational failure — contained so the runner never rejects. | `origin` (`
|
|
126
|
+
| `error` | Any operational failure — contained so the runner never rejects. | `origin` (`forage` \| `lookout`), `error` |
|
|
116
127
|
|
|
117
128
|
`unchanged-hash` asserts same-resource continuity, so it requires **both** an
|
|
118
129
|
identical `bodyHash` **and** the same resource URL as the prior snapshot. If a
|
|
119
130
|
source has moved — the prior snapshot's URL differs from this fetch's — the
|
|
120
131
|
result is `changed` even when the bytes are byte-identical, and the fresh
|
|
121
132
|
snapshot is persisted as the new baseline. (The `unchanged-304` path is already
|
|
122
|
-
resource-scoped by
|
|
133
|
+
resource-scoped by Forage's validators, so only the hash path needs this
|
|
123
134
|
guard.)
|
|
124
135
|
|
|
125
|
-
`error` results preserve provenance: `origin: "
|
|
136
|
+
`error` results preserve provenance: `origin: "forage"` carries Forage's
|
|
126
137
|
discriminated `FetchError` verbatim (its `kind`, and `status` when present);
|
|
127
138
|
`origin: "lookout"` carries a typed `kind` — `prior-read`, `persistence`,
|
|
128
139
|
`dependency-contract`, or `unexpected`.
|
|
129
140
|
|
|
130
141
|
Snapshot references (`snapshotRef` / `priorSnapshotRef` / `currentSnapshotRef`)
|
|
131
|
-
are portable logical refs from
|
|
132
|
-
filesystem paths. Snapshots are stored via
|
|
142
|
+
are portable logical refs from Forage's `buildSnapshotSourceRef` — never
|
|
143
|
+
filesystem paths. Snapshots are stored via Forage's filesystem store, rooted by
|
|
133
144
|
default at `<cwd>/.kontourai/lookout/snapshots` (override with the CLI
|
|
134
145
|
`--snapshot-root` flag or by injecting a store in library use). Lookout adds no
|
|
135
|
-
custom filenames or retention.
|
|
146
|
+
custom filenames or retention. `resolveLookoutSnapshot()` replays one exact
|
|
147
|
+
reference through an injected store or Lookout snapshot root, authenticates its
|
|
148
|
+
body and replay metadata, and never fetches. References emitted before Forage's
|
|
149
|
+
replay-envelope digest remain resolvable with `integrity: "body-and-identity"`;
|
|
150
|
+
new references report `integrity: "snapshot-envelope"`.
|
|
136
151
|
|
|
137
152
|
## Schema coverage
|
|
138
153
|
|
|
@@ -144,6 +159,7 @@ can't catch this. `checkSchemaCoverage` is the static complement:
|
|
|
144
159
|
```ts
|
|
145
160
|
import { checkSchemaCoverage } from "@kontourai/lookout";
|
|
146
161
|
|
|
162
|
+
if (source.kind === "structured-file") throw new Error("structured sources are parsed downstream");
|
|
147
163
|
const { covered, gaps } = checkSchemaCoverage(source.targetSchema, proposals);
|
|
148
164
|
// covered: declared paths at least one proposal produced (schema order)
|
|
149
165
|
// gaps: declared paths that produced none, each with its `required` flag
|
|
@@ -213,17 +229,72 @@ const results = await runner.checkAll(registry.list());
|
|
|
213
229
|
```
|
|
214
230
|
|
|
215
231
|
`createCheckRunner` accepts injected seams — `store`, `fetchSource` (defaults to
|
|
216
|
-
|
|
232
|
+
Forage's SSRF-guarded fetcher), `fetchOptions`, and
|
|
217
233
|
`clock` — so checks run with no live network or timers in tests. Injecting either
|
|
218
234
|
`fetchSource` or `fetchOptions.fetch` overrides the default guarded transport.
|
|
219
235
|
|
|
236
|
+
## Observe changed sources without re-extracting unchanged ones
|
|
237
|
+
|
|
238
|
+
`createObserveExtractDiff` is an optional library composition for callers that
|
|
239
|
+
want one typed observation per check. Supply acquisition, extraction, and
|
|
240
|
+
recording capabilities; Lookout does not choose a content-preparation method,
|
|
241
|
+
provider, or observation database.
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import { createObserveExtractDiff } from "@kontourai/lookout";
|
|
245
|
+
|
|
246
|
+
const composition = createObserveExtractDiff({
|
|
247
|
+
acquisition: { check: runner.check },
|
|
248
|
+
extraction: {
|
|
249
|
+
async extract({ source, snapshotRef }) {
|
|
250
|
+
// Resolve `snapshotRef`, prepare content, and invoke a caller-selected
|
|
251
|
+
// extraction implementation. Return its public Traverse result.
|
|
252
|
+
return extractChangedSource(source, snapshotRef);
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
recorder: {
|
|
256
|
+
async record(observation) {
|
|
257
|
+
// Caller-owned continuity and durable storage.
|
|
258
|
+
return saveObservation(observation);
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const result = await composition.observe(source);
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`unchanged-304` and `unchanged-hash` are recorded as `unchanged` and never call
|
|
267
|
+
the extraction capability, so they make zero preparation and provider calls.
|
|
268
|
+
Changed observations retain source and snapshot references, Traverse's prepared
|
|
269
|
+
artifact identity, the proposal set, and the current/prior observation
|
|
270
|
+
identities returned by the recorder. `partial`, `provider-failure`, mixed
|
|
271
|
+
`partial-provider-failure`, and `extraction-failure` remain distinct typed
|
|
272
|
+
outcomes. Provider failures are reduced to provider-neutral `kind` and
|
|
273
|
+
`retryable` classifications; provider names, messages, native diagnostics,
|
|
274
|
+
free-form extraction warnings, raw responses, and thrown-error text are not
|
|
275
|
+
copied into the durable observation.
|
|
276
|
+
A first changed observation
|
|
277
|
+
has `priorObservationId: null`; it is a baseline observation, not a fabricated
|
|
278
|
+
list of additions or removals.
|
|
279
|
+
|
|
280
|
+
The composition validates that the check identifies the requested registered
|
|
281
|
+
source and that Traverse validates the prepared artifact whose snapshot anchor
|
|
282
|
+
matches the current check. This prevents mismatched capability results from
|
|
283
|
+
being recorded as one observation. Proposal excerpts, source URLs, and
|
|
284
|
+
acquisition check warnings can still contain sensitive source material; callers
|
|
285
|
+
must apply their own retention and access policy before durable storage.
|
|
286
|
+
|
|
287
|
+
The existing `createCheckRunner` and `createDriftEmitter` entrypoints remain
|
|
288
|
+
available. Use `createDriftEmitter` when the caller wants deterministic
|
|
289
|
+
proposal-diff events with its own identity callbacks.
|
|
290
|
+
|
|
220
291
|
## Non-goals
|
|
221
292
|
|
|
222
|
-
|
|
223
|
-
authority policy, rendered-fetch wiring
|
|
224
|
-
(`renderPolicy` is inert
|
|
225
|
-
|
|
226
|
-
|
|
293
|
+
Acquisition and extraction implementations, Surface projection, notifications,
|
|
294
|
+
crawling, review/escalation or authority policy, rendered-fetch wiring
|
|
295
|
+
(`renderPolicy` is inert), and scheduling. Forage retains all fetch politeness,
|
|
296
|
+
robots, redirects, retries, timeouts, headers, user-agent, and rendered-fetch
|
|
297
|
+
behavior.
|
|
227
298
|
|
|
228
299
|
## Development
|
|
229
300
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FetchError } from "@kontourai/
|
|
1
|
+
import type { FetchError } from "@kontourai/forage/fetch";
|
|
2
2
|
export interface CheckResultCommon {
|
|
3
3
|
sourceId: string;
|
|
4
4
|
sourceUrl: string;
|
|
@@ -23,7 +23,7 @@ export interface ChangedResult extends CheckResultCommon {
|
|
|
23
23
|
export type LookoutErrorKind = "prior-read" | "persistence" | "dependency-contract" | "unexpected";
|
|
24
24
|
export interface ErrorResult extends CheckResultCommon {
|
|
25
25
|
kind: "error";
|
|
26
|
-
origin: "
|
|
26
|
+
origin: "forage" | "lookout";
|
|
27
27
|
error: FetchError | {
|
|
28
28
|
kind: LookoutErrorKind;
|
|
29
29
|
message: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FetchResult, FetchSourceOptions, SnapshotStore, SourceConfig } from "@kontourai/
|
|
1
|
+
import type { FetchResult, FetchSourceOptions, SnapshotStore, SourceConfig } from "@kontourai/forage/fetch";
|
|
2
2
|
import type { CheckResult } from "./check-result.js";
|
|
3
3
|
import type { ProviderResolver } from "./provider-resolution.js";
|
|
4
4
|
import type { LookoutSource } from "./registry.js";
|
package/dist/src/check-runner.js
CHANGED
|
@@ -1,30 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { buildSnapshotSourceRef, fetchSource as forageFetchSource, } from "@kontourai/forage/fetch";
|
|
3
3
|
/**
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* The egress policy for lookout's registered-source fetches. Registered source
|
|
5
|
+
* URLs are operator- / aggregator-supplied and not fully trusted, so a source
|
|
6
|
+
* pointing at a private, link-local, loopback, or cloud-metadata host must be
|
|
7
|
+
* refused before any connection — a drift check can never be turned into an
|
|
8
|
+
* SSRF vector. `forage`'s `fetchSource` builds its own SSRF-pinned guarded
|
|
9
|
+
* transport from this policy whenever the caller doesn't inject a custom
|
|
10
|
+
* `fetch` (e.g. tests), so this is the single shared policy literal — never
|
|
11
|
+
* scattered per call site, never `{ guarded: false }`.
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
function defaultGuardedFetch() {
|
|
15
|
-
cachedGuardedFetch ??= createGuardedFetch();
|
|
16
|
-
return cachedGuardedFetch;
|
|
17
|
-
}
|
|
13
|
+
const GUARDED_EGRESS = { guarded: true };
|
|
18
14
|
export function createCheckRunner(options) {
|
|
19
|
-
const
|
|
20
|
-
const fetchImpl = options.fetchSource ?? traverseFetchSource;
|
|
15
|
+
const fetchImpl = options.fetchSource ?? forageFetchSource;
|
|
21
16
|
const clock = options.clock ?? (() => new Date().toISOString());
|
|
22
|
-
// Guard the default traverse fetcher's egress with forage. A caller-supplied
|
|
23
|
-
// fetcher or `fetchOptions.fetch` owns its own transport and is left as-is.
|
|
24
17
|
const fetchOptions = { ...options.fetchOptions };
|
|
25
|
-
if (usingDefaultFetcher && fetchOptions.fetch === undefined) {
|
|
26
|
-
fetchOptions.fetch = defaultGuardedFetch();
|
|
27
|
-
}
|
|
28
18
|
async function check(source) {
|
|
29
19
|
const common = () => ({
|
|
30
20
|
sourceId: source.id,
|
|
@@ -41,17 +31,17 @@ export function createCheckRunner(options) {
|
|
|
41
31
|
}
|
|
42
32
|
let fetched;
|
|
43
33
|
try {
|
|
44
|
-
fetched = await fetchImpl({ id: source.id, url: source.url,
|
|
34
|
+
fetched = await fetchImpl({ id: source.id, url: source.url, egress: GUARDED_EGRESS }, { ...fetchOptions, store: options.store });
|
|
45
35
|
}
|
|
46
36
|
catch (error) {
|
|
47
37
|
return lookoutError(common(), "unexpected", error);
|
|
48
38
|
}
|
|
49
39
|
const base = { ...common(), warnings: Array.isArray(fetched?.warnings) ? [...fetched.warnings] : [] };
|
|
50
40
|
if (!isFetchResult(fetched)) {
|
|
51
|
-
return lookoutError(base, "dependency-contract", "
|
|
41
|
+
return lookoutError(base, "dependency-contract", "Forage returned neither exactly one snapshot nor exactly one error");
|
|
52
42
|
}
|
|
53
43
|
if (fetched.error) {
|
|
54
|
-
return { ...base, kind: "error", origin: "
|
|
44
|
+
return { ...base, kind: "error", origin: "forage", error: fetched.error };
|
|
55
45
|
}
|
|
56
46
|
// Defense-in-depth: even if a future guard gap let a malformed snapshot
|
|
57
47
|
// through, any stray throw in classification/ref-building becomes a typed
|
|
@@ -59,7 +49,22 @@ export function createCheckRunner(options) {
|
|
|
59
49
|
try {
|
|
60
50
|
const snapshot = fetched.snapshot;
|
|
61
51
|
if (snapshot.notModified === true) {
|
|
62
|
-
|
|
52
|
+
const snapshotBodyEncoding = bodyEncoding(snapshot);
|
|
53
|
+
const priorBodyEncoding = prior === undefined ? undefined : bodyEncoding(prior);
|
|
54
|
+
if (prior === undefined ||
|
|
55
|
+
snapshotBodyEncoding === undefined ||
|
|
56
|
+
snapshotBodyEncoding !== priorBodyEncoding ||
|
|
57
|
+
snapshot.sourceId !== prior.sourceId ||
|
|
58
|
+
snapshot.url !== prior.url ||
|
|
59
|
+
snapshot.status !== prior.status ||
|
|
60
|
+
snapshot.fetchedAt !== prior.fetchedAt ||
|
|
61
|
+
snapshot.bodyHash !== prior.bodyHash ||
|
|
62
|
+
!isDeepStrictEqual(snapshot.headers, prior.headers) ||
|
|
63
|
+
!isDeepStrictEqual(snapshot.redirects, prior.redirects) ||
|
|
64
|
+
snapshot.rendered !== prior.rendered) {
|
|
65
|
+
return lookoutError(base, "dependency-contract", "Forage returned a 304 snapshot without the matching prior capture");
|
|
66
|
+
}
|
|
67
|
+
return { ...base, kind: "unchanged-304", snapshotRef: buildSnapshotSourceRef(prior) };
|
|
63
68
|
}
|
|
64
69
|
try {
|
|
65
70
|
await options.store.put(snapshot);
|
|
@@ -99,6 +104,14 @@ export function createCheckRunner(options) {
|
|
|
99
104
|
}
|
|
100
105
|
return { check, checkAll };
|
|
101
106
|
}
|
|
107
|
+
function bodyEncoding(snapshot) {
|
|
108
|
+
const descriptor = Object.getOwnPropertyDescriptor(snapshot, "body");
|
|
109
|
+
if (descriptor === undefined || !("value" in descriptor))
|
|
110
|
+
return undefined;
|
|
111
|
+
if (typeof descriptor.value === "string")
|
|
112
|
+
return "utf8";
|
|
113
|
+
return descriptor.value instanceof Uint8Array ? "bytes" : undefined;
|
|
114
|
+
}
|
|
102
115
|
function isFetchResult(value) {
|
|
103
116
|
if (typeof value !== "object" || value === null)
|
|
104
117
|
return false;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -6,8 +6,10 @@ export type { ChangedResult, CheckResult, CheckResultCommon, ErrorResult, Lookou
|
|
|
6
6
|
export { defaultProviderResolver } from "./provider-resolution.js";
|
|
7
7
|
export type { ProviderResolver } from "./provider-resolution.js";
|
|
8
8
|
export { loadRegistry, LookoutRegistry, parseRegistry, RegistryValidationError, } from "./registry.js";
|
|
9
|
-
export type { LookoutRegistryDocument, LookoutSource, LookoutSourceKind, RenderPolicy, } from "./registry.js";
|
|
10
|
-
export { createLookoutSnapshotStore } from "./snapshot-store.js";
|
|
9
|
+
export type { ExtractableLookoutSource, LookoutRegistryDocument, LookoutSource, LookoutSourceKind, RenderPolicy, StructuredFileFormat, StructuredFileLookoutSource, } from "./registry.js";
|
|
10
|
+
export { createLookoutSnapshotStore, resolveLookoutSnapshot, } from "./snapshot-store.js";
|
|
11
|
+
export type { ResolveLookoutSnapshotOptions } from "./snapshot-store.js";
|
|
12
|
+
export type { SnapshotSourceRefResolution } from "@kontourai/forage/fetch";
|
|
11
13
|
export { canonicalValueKey } from "./canonical-value.js";
|
|
12
14
|
export type { CanonicalValueKey, CanonicalValueFormatVersion, CanonicalValueResult, DiffKernelError, DiffKernelErrorKind, DiffResult, IdentityResult, } from "./canonical-value.js";
|
|
13
15
|
export { compareStructural, diffKeyedMultiset } from "./structural-diff.js";
|
|
@@ -20,3 +22,5 @@ export { createDriftEmitter } from "./drift-emission.js";
|
|
|
20
22
|
export type { BaselineEstablishedFact, CreateDriftEmitterOptions, DriftEmitter, DriftError, DriftErrorKind, DriftFact, DriftResult, DriftSuccess, EmitDriftInput } from "./drift-emission.js";
|
|
21
23
|
export { checkSchemaCoverage } from "./coverage.js";
|
|
22
24
|
export type { SchemaCoverageGap, SchemaCoverageResult } from "./coverage.js";
|
|
25
|
+
export { createObserveExtractDiff } from "./observe-extract-diff.js";
|
|
26
|
+
export type { ObserveExtractAcquisition, ObserveExtractAttempt, ObserveExtractDiff, ObserveExtractDiffOptions, ObserveExtractError, ObserveExtractErrorKind, ObserveExtractExtraction, ObserveExtractExtractionInput, ObserveExtractObservation, ObserveExtractObservationIdentity, ObserveExtractOutcome, ObserveExtractProviderFailure, ObserveExtractRecorder, ObserveExtractResult, ObserveExtractSource, ObserveExtractSourceSnapshot, } from "./observe-extract-diff.js";
|
package/dist/src/index.js
CHANGED
|
@@ -2,10 +2,11 @@ export { runCli } from "./cli.js";
|
|
|
2
2
|
export { createCheckRunner } from "./check-runner.js";
|
|
3
3
|
export { defaultProviderResolver } from "./provider-resolution.js";
|
|
4
4
|
export { loadRegistry, LookoutRegistry, parseRegistry, RegistryValidationError, } from "./registry.js";
|
|
5
|
-
export { createLookoutSnapshotStore } from "./snapshot-store.js";
|
|
5
|
+
export { createLookoutSnapshotStore, resolveLookoutSnapshot, } from "./snapshot-store.js";
|
|
6
6
|
export { canonicalValueKey } from "./canonical-value.js";
|
|
7
7
|
export { compareStructural, diffKeyedMultiset } from "./structural-diff.js";
|
|
8
8
|
export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js";
|
|
9
9
|
export { createObservationStore } from "./observation-store.js";
|
|
10
10
|
export { createDriftEmitter } from "./drift-emission.js";
|
|
11
11
|
export { checkSchemaCoverage } from "./coverage.js";
|
|
12
|
+
export { createObserveExtractDiff } from "./observe-extract-diff.js";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ExtractionPartial, ExtractionProviderFailure, ExtractionResult, PreparedArtifact } from "@kontourai/traverse";
|
|
2
|
+
import type { CheckResult } from "./check-result.js";
|
|
3
|
+
import type { ProposalSetObservation } from "./proposal-diff.js";
|
|
4
|
+
import type { LookoutSource } from "./registry.js";
|
|
5
|
+
/** Acquisition is supplied by the caller; Lookout does not add another fetcher. */
|
|
6
|
+
export interface ObserveExtractAcquisition {
|
|
7
|
+
check(source: LookoutSource): Promise<CheckResult>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Extraction is supplied by the caller. The input carries only immutable source
|
|
11
|
+
* identity, leaving snapshot resolution, preparation, and provider selection
|
|
12
|
+
* outside Lookout.
|
|
13
|
+
*/
|
|
14
|
+
export interface ObserveExtractExtraction {
|
|
15
|
+
extract(input: ObserveExtractExtractionInput): Promise<ExtractionResult>;
|
|
16
|
+
}
|
|
17
|
+
export interface ObserveExtractExtractionInput {
|
|
18
|
+
readonly source: LookoutSource;
|
|
19
|
+
readonly snapshotRef: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ObserveExtractAttempt {
|
|
22
|
+
readonly extractedAt: string;
|
|
23
|
+
readonly providerCalls: number;
|
|
24
|
+
readonly totalTokensUsed: number;
|
|
25
|
+
readonly partial?: ExtractionPartial;
|
|
26
|
+
readonly providerFailures?: readonly ObserveExtractProviderFailure[];
|
|
27
|
+
}
|
|
28
|
+
/** Provider-neutral durable classification; diagnostic payloads stay at the capability boundary. */
|
|
29
|
+
export interface ObserveExtractProviderFailure {
|
|
30
|
+
readonly kind: ExtractionProviderFailure["kind"];
|
|
31
|
+
readonly retryable: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface ObserveExtractSourceSnapshot {
|
|
34
|
+
readonly priorSnapshotRef: string | null;
|
|
35
|
+
readonly currentSnapshotRef: string;
|
|
36
|
+
}
|
|
37
|
+
export interface ObserveExtractSource {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly url: string;
|
|
40
|
+
readonly kind: LookoutSource["kind"];
|
|
41
|
+
}
|
|
42
|
+
export type ObserveExtractOutcome = "acquisition-error" | "unchanged" | "completed" | "partial" | "partial-provider-failure" | "provider-failure" | "extraction-failure";
|
|
43
|
+
/**
|
|
44
|
+
* One source observation, ready for caller-owned durable recording. Raw
|
|
45
|
+
* provider responses and source bodies are intentionally not copied here.
|
|
46
|
+
*/
|
|
47
|
+
export interface ObserveExtractObservation {
|
|
48
|
+
readonly source: ObserveExtractSource;
|
|
49
|
+
readonly check: CheckResult;
|
|
50
|
+
readonly outcome: ObserveExtractOutcome;
|
|
51
|
+
readonly sourceSnapshot: ObserveExtractSourceSnapshot | null;
|
|
52
|
+
readonly preparedArtifact: PreparedArtifact | null;
|
|
53
|
+
readonly proposalSet: ProposalSetObservation | null;
|
|
54
|
+
readonly attempt: ObserveExtractAttempt | null;
|
|
55
|
+
}
|
|
56
|
+
export interface ObserveExtractObservationIdentity {
|
|
57
|
+
readonly observationId: string;
|
|
58
|
+
readonly priorObservationId: string | null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Lookout passes each completed observation to a caller-owned recorder. The
|
|
62
|
+
* recorder controls durable storage and continuity while this composition never
|
|
63
|
+
* supplies or configures the injected acquisition or extraction capabilities.
|
|
64
|
+
*/
|
|
65
|
+
export interface ObserveExtractRecorder {
|
|
66
|
+
record(observation: ObserveExtractObservation): Promise<ObserveExtractObservationIdentity>;
|
|
67
|
+
}
|
|
68
|
+
export interface ObserveExtractDiffOptions {
|
|
69
|
+
readonly acquisition: ObserveExtractAcquisition;
|
|
70
|
+
readonly extraction: ObserveExtractExtraction;
|
|
71
|
+
readonly recorder: ObserveExtractRecorder;
|
|
72
|
+
}
|
|
73
|
+
export type ObserveExtractErrorKind = "acquisition-threw" | "recording-failed" | "dependency-contract";
|
|
74
|
+
export interface ObserveExtractError {
|
|
75
|
+
readonly kind: ObserveExtractErrorKind;
|
|
76
|
+
readonly message: string;
|
|
77
|
+
readonly cause?: unknown;
|
|
78
|
+
}
|
|
79
|
+
export type ObserveExtractResult = {
|
|
80
|
+
readonly ok: true;
|
|
81
|
+
readonly value: ObserveExtractObservation & ObserveExtractObservationIdentity;
|
|
82
|
+
} | {
|
|
83
|
+
readonly ok: false;
|
|
84
|
+
readonly error: ObserveExtractError;
|
|
85
|
+
readonly observation?: ObserveExtractObservation;
|
|
86
|
+
};
|
|
87
|
+
export interface ObserveExtractDiff {
|
|
88
|
+
observe(source: LookoutSource): Promise<ObserveExtractResult>;
|
|
89
|
+
}
|
|
90
|
+
export declare function createObserveExtractDiff(options: ObserveExtractDiffOptions): ObserveExtractDiff;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { validatePreparedArtifact } from "@kontourai/traverse";
|
|
2
|
+
export function createObserveExtractDiff(options) {
|
|
3
|
+
return {
|
|
4
|
+
async observe(source) {
|
|
5
|
+
try {
|
|
6
|
+
let check;
|
|
7
|
+
try {
|
|
8
|
+
check = await options.acquisition.check(source);
|
|
9
|
+
}
|
|
10
|
+
catch (cause) {
|
|
11
|
+
return { ok: false, error: error("acquisition-threw", "Acquisition capability threw", cause) };
|
|
12
|
+
}
|
|
13
|
+
if (!isCheckResult(check)) {
|
|
14
|
+
return { ok: false, error: error("dependency-contract", "Acquisition capability returned an invalid check result") };
|
|
15
|
+
}
|
|
16
|
+
if (check.sourceId !== source.id || check.sourceUrl !== source.url) {
|
|
17
|
+
return { ok: false, error: error("dependency-contract", "Acquisition result does not identify the requested source") };
|
|
18
|
+
}
|
|
19
|
+
if (check.kind === "error") {
|
|
20
|
+
return record(options.recorder, baseObservation(source, check, "acquisition-error", null, null, null, null));
|
|
21
|
+
}
|
|
22
|
+
if (check.kind === "unchanged-304" || check.kind === "unchanged-hash") {
|
|
23
|
+
return record(options.recorder, baseObservation(source, check, "unchanged", snapshotFor(check), null, null, null));
|
|
24
|
+
}
|
|
25
|
+
const sourceSnapshot = snapshotFor(check);
|
|
26
|
+
let extraction;
|
|
27
|
+
try {
|
|
28
|
+
extraction = await options.extraction.extract({ source, snapshotRef: sourceSnapshot.currentSnapshotRef });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return record(options.recorder, baseObservation(source, check, "extraction-failure", sourceSnapshot, null, null, null));
|
|
32
|
+
}
|
|
33
|
+
if (!isExtractionResult(extraction)) {
|
|
34
|
+
return { ok: false, error: error("dependency-contract", "Extraction capability returned an invalid extraction result") };
|
|
35
|
+
}
|
|
36
|
+
const attempt = attemptFor(extraction);
|
|
37
|
+
const proposalSet = {
|
|
38
|
+
sourceId: source.id,
|
|
39
|
+
snapshotRef: sourceSnapshot.currentSnapshotRef,
|
|
40
|
+
observedAt: extraction.extractedAt,
|
|
41
|
+
proposals: extraction.proposals,
|
|
42
|
+
};
|
|
43
|
+
const outcome = outcomeFor(extraction);
|
|
44
|
+
if (outcome !== "extraction-failure") {
|
|
45
|
+
if (extraction.preparedArtifact === undefined) {
|
|
46
|
+
return { ok: false, error: error("dependency-contract", "Extraction result is missing its prepared artifact") };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (extraction.preparedArtifact !== undefined) {
|
|
50
|
+
const validation = validatePreparedArtifact(extraction.preparedArtifact);
|
|
51
|
+
if (validation.status !== "valid") {
|
|
52
|
+
return { ok: false, error: error("dependency-contract", `Extraction result has an invalid prepared artifact: ${validation.status}`) };
|
|
53
|
+
}
|
|
54
|
+
if (validation.artifact.sourceSnapshotRef !== sourceSnapshot.currentSnapshotRef) {
|
|
55
|
+
return { ok: false, error: error("dependency-contract", "Prepared artifact does not identify the current source snapshot") };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return record(options.recorder, baseObservation(source, check, outcome, sourceSnapshot, extraction.preparedArtifact ?? null, proposalSet, attempt));
|
|
59
|
+
}
|
|
60
|
+
catch (cause) {
|
|
61
|
+
return { ok: false, error: error("dependency-contract", "Observe-extract composition could not inspect a capability result", cause) };
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function baseObservation(source, check, outcome, sourceSnapshot, preparedArtifact, proposalSet, attempt) {
|
|
67
|
+
return {
|
|
68
|
+
source: { id: source.id, url: source.url, kind: source.kind },
|
|
69
|
+
check,
|
|
70
|
+
outcome,
|
|
71
|
+
sourceSnapshot,
|
|
72
|
+
preparedArtifact,
|
|
73
|
+
proposalSet,
|
|
74
|
+
attempt,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function record(recorder, observation) {
|
|
78
|
+
try {
|
|
79
|
+
const identity = await recorder.record(observation);
|
|
80
|
+
if (!isIdentity(identity)) {
|
|
81
|
+
return { ok: false, error: error("dependency-contract", "Observation recorder returned an invalid identity"), observation };
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
value: {
|
|
86
|
+
...observation,
|
|
87
|
+
observationId: identity.observationId,
|
|
88
|
+
priorObservationId: identity.priorObservationId,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch (cause) {
|
|
93
|
+
return { ok: false, error: error("recording-failed", "Observation recorder failed", cause), observation };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function snapshotFor(check) {
|
|
97
|
+
if (check.kind === "unchanged-304")
|
|
98
|
+
return { priorSnapshotRef: check.snapshotRef, currentSnapshotRef: check.snapshotRef };
|
|
99
|
+
return { priorSnapshotRef: check.priorSnapshotRef, currentSnapshotRef: check.currentSnapshotRef };
|
|
100
|
+
}
|
|
101
|
+
function attemptFor(result) {
|
|
102
|
+
return {
|
|
103
|
+
extractedAt: result.extractedAt,
|
|
104
|
+
providerCalls: result.providerCalls,
|
|
105
|
+
totalTokensUsed: result.totalTokensUsed,
|
|
106
|
+
...(result.partial === undefined ? {} : { partial: result.partial }),
|
|
107
|
+
...(result.providerFailures === undefined ? {} : {
|
|
108
|
+
providerFailures: result.providerFailures.map(({ kind, retryable }) => ({ kind, retryable })),
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function outcomeFor(result) {
|
|
113
|
+
const hasProviderFailures = (result.providerFailures?.length ?? 0) > 0;
|
|
114
|
+
if (result.partial !== undefined && hasProviderFailures)
|
|
115
|
+
return "partial-provider-failure";
|
|
116
|
+
if (hasProviderFailures)
|
|
117
|
+
return "provider-failure";
|
|
118
|
+
if (result.error !== undefined)
|
|
119
|
+
return "extraction-failure";
|
|
120
|
+
return result.partial === undefined ? "completed" : "partial";
|
|
121
|
+
}
|
|
122
|
+
function isCheckResult(value) {
|
|
123
|
+
if (typeof value !== "object" || value === null)
|
|
124
|
+
return false;
|
|
125
|
+
const result = value;
|
|
126
|
+
if (typeof result.sourceId !== "string" || typeof result.sourceUrl !== "string" ||
|
|
127
|
+
typeof result.checkedAt !== "string" || !Array.isArray(result.warnings))
|
|
128
|
+
return false;
|
|
129
|
+
if (result.kind === "unchanged-304")
|
|
130
|
+
return typeof result.snapshotRef === "string";
|
|
131
|
+
if (result.kind === "unchanged-hash")
|
|
132
|
+
return typeof result.priorSnapshotRef === "string" && typeof result.currentSnapshotRef === "string";
|
|
133
|
+
if (result.kind === "changed")
|
|
134
|
+
return (result.priorSnapshotRef === null || typeof result.priorSnapshotRef === "string") &&
|
|
135
|
+
typeof result.currentSnapshotRef === "string" && (result.changeBasis === "initial" || result.changeBasis === "hash");
|
|
136
|
+
return result.kind === "error" && (result.origin === "forage" || result.origin === "lookout") && result.error !== undefined;
|
|
137
|
+
}
|
|
138
|
+
function isExtractionResult(value) {
|
|
139
|
+
if (typeof value !== "object" || value === null)
|
|
140
|
+
return false;
|
|
141
|
+
const result = value;
|
|
142
|
+
return Array.isArray(result.proposals) && typeof result.extractedAt === "string" &&
|
|
143
|
+
Number.isFinite(result.providerCalls) && Number.isFinite(result.totalTokensUsed);
|
|
144
|
+
}
|
|
145
|
+
function isIdentity(value) {
|
|
146
|
+
if (typeof value !== "object" || value === null)
|
|
147
|
+
return false;
|
|
148
|
+
const identity = value;
|
|
149
|
+
return typeof identity.observationId === "string" && identity.observationId !== "" &&
|
|
150
|
+
(identity.priorObservationId === null || (typeof identity.priorObservationId === "string" && identity.priorObservationId !== ""));
|
|
151
|
+
}
|
|
152
|
+
function error(kind, text, cause) {
|
|
153
|
+
return { kind, message: text, ...(cause === undefined ? {} : { cause }) };
|
|
154
|
+
}
|
package/dist/src/registry.d.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import type { TargetFieldSchema } from "@kontourai/traverse";
|
|
2
|
-
export type LookoutSourceKind = "web-page" | "api-record";
|
|
2
|
+
export type LookoutSourceKind = "web-page" | "api-record" | "structured-file";
|
|
3
|
+
export type StructuredFileFormat = "yaml" | "json" | "csv";
|
|
3
4
|
export type RenderPolicy = "never" | "on-shell-warning" | "always";
|
|
4
|
-
|
|
5
|
+
interface LookoutSourceBase {
|
|
5
6
|
id: string;
|
|
6
|
-
kind: LookoutSourceKind;
|
|
7
7
|
url: string;
|
|
8
|
-
targetSchema: TargetFieldSchema[];
|
|
9
8
|
cadenceHint: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ExtractableLookoutSource extends LookoutSourceBase {
|
|
11
|
+
kind: "web-page" | "api-record";
|
|
12
|
+
targetSchema: TargetFieldSchema[];
|
|
10
13
|
renderPolicy: RenderPolicy;
|
|
14
|
+
format?: never;
|
|
15
|
+
}
|
|
16
|
+
export interface StructuredFileLookoutSource extends LookoutSourceBase {
|
|
17
|
+
kind: "structured-file";
|
|
18
|
+
format: StructuredFileFormat;
|
|
19
|
+
targetSchema?: never;
|
|
20
|
+
renderPolicy?: never;
|
|
11
21
|
}
|
|
22
|
+
export type LookoutSource = ExtractableLookoutSource | StructuredFileLookoutSource;
|
|
12
23
|
export interface LookoutRegistryDocument {
|
|
13
24
|
version: 1;
|
|
14
25
|
sources: LookoutSource[];
|
|
@@ -27,3 +38,4 @@ export declare class LookoutRegistry {
|
|
|
27
38
|
}
|
|
28
39
|
export declare function loadRegistry(registryPath?: string): Promise<LookoutRegistry>;
|
|
29
40
|
export declare function parseRegistry(value: unknown): LookoutRegistry;
|
|
41
|
+
export {};
|
package/dist/src/registry.js
CHANGED
|
@@ -49,55 +49,120 @@ export function parseRegistry(value) {
|
|
|
49
49
|
const ids = new Map();
|
|
50
50
|
const sources = [];
|
|
51
51
|
value.sources.forEach((candidate, index) => {
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
const id = candidate.id;
|
|
58
|
-
const label = typeof id === "string" && id.trim() ? `${context} (id "${id}")` : context;
|
|
59
|
-
if (typeof id !== "string" || id.trim() === "") {
|
|
60
|
-
issues.push(`${context}.id must be a non-empty string`);
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
const first = ids.get(id);
|
|
64
|
-
if (first !== undefined)
|
|
65
|
-
issues.push(`${label}.id duplicates sources[${first}].id`);
|
|
66
|
-
else
|
|
67
|
-
ids.set(id, index);
|
|
68
|
-
}
|
|
69
|
-
if (candidate.kind !== "web-page" && candidate.kind !== "api-record") {
|
|
70
|
-
issues.push(`${label}.kind must be "web-page" or "api-record"`);
|
|
71
|
-
}
|
|
72
|
-
if (!isHttpUrl(candidate.url)) {
|
|
73
|
-
issues.push(`${label}.url must be an absolute HTTP(S) URL`);
|
|
74
|
-
}
|
|
75
|
-
if (typeof candidate.cadenceHint !== "string" || candidate.cadenceHint.trim() === "") {
|
|
76
|
-
issues.push(`${label}.cadenceHint must be a non-empty string`);
|
|
77
|
-
}
|
|
78
|
-
if (candidate.renderPolicy !== "never" &&
|
|
79
|
-
candidate.renderPolicy !== "on-shell-warning" &&
|
|
80
|
-
candidate.renderPolicy !== "always") {
|
|
81
|
-
issues.push(`${label}.renderPolicy must be "never", "on-shell-warning", or "always"`);
|
|
82
|
-
}
|
|
83
|
-
validateTargetSchema(candidate.targetSchema, label, issues);
|
|
84
|
-
if (typeof id === "string" && id.trim() !== "" &&
|
|
85
|
-
(candidate.kind === "web-page" || candidate.kind === "api-record") &&
|
|
86
|
-
isHttpUrl(candidate.url) &&
|
|
87
|
-
typeof candidate.cadenceHint === "string" && candidate.cadenceHint.trim() !== "" &&
|
|
88
|
-
(candidate.renderPolicy === "never" || candidate.renderPolicy === "on-shell-warning" || candidate.renderPolicy === "always") &&
|
|
89
|
-
Array.isArray(candidate.targetSchema)) {
|
|
90
|
-
sources.push(candidate);
|
|
91
|
-
}
|
|
52
|
+
const source = parseSource(candidate, index, ids, issues);
|
|
53
|
+
if (source !== undefined)
|
|
54
|
+
sources.push(source);
|
|
92
55
|
});
|
|
93
56
|
if (issues.length > 0)
|
|
94
57
|
throw new RegistryValidationError(issues);
|
|
95
58
|
return new LookoutRegistry(sources);
|
|
96
59
|
}
|
|
60
|
+
function parseSource(candidate, index, ids, issues) {
|
|
61
|
+
const context = `sources[${index}]`;
|
|
62
|
+
if (!isRecord(candidate)) {
|
|
63
|
+
issues.push(`${context} must be an object`);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const firstIssue = issues.length;
|
|
67
|
+
const common = validateCommonSource(candidate, index, ids, issues);
|
|
68
|
+
const source = candidate.kind === "structured-file"
|
|
69
|
+
? parseStructuredFileSource(candidate, common, issues)
|
|
70
|
+
: candidate.kind === "web-page" || candidate.kind === "api-record"
|
|
71
|
+
? parseExtractableSource(candidate, candidate.kind, common, issues)
|
|
72
|
+
: invalidSourceKind(candidate, common.label, issues);
|
|
73
|
+
return issues.length === firstIssue ? source : undefined;
|
|
74
|
+
}
|
|
75
|
+
function validateCommonSource(candidate, index, ids, issues) {
|
|
76
|
+
const context = `sources[${index}]`;
|
|
77
|
+
const id = typeof candidate.id === "string" && candidate.id.trim() !== "" ? candidate.id : undefined;
|
|
78
|
+
const label = id === undefined ? context : `${context} (id "${id}")`;
|
|
79
|
+
if (id === undefined) {
|
|
80
|
+
issues.push(`${context}.id must be a non-empty string`);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const first = ids.get(id);
|
|
84
|
+
if (first !== undefined)
|
|
85
|
+
issues.push(`${label}.id duplicates sources[${first}].id`);
|
|
86
|
+
else
|
|
87
|
+
ids.set(id, index);
|
|
88
|
+
}
|
|
89
|
+
const url = isHttpUrl(candidate.url) ? candidate.url : undefined;
|
|
90
|
+
if (url === undefined) {
|
|
91
|
+
issues.push(`${label}.url must be an absolute HTTP(S) URL`);
|
|
92
|
+
}
|
|
93
|
+
const cadenceHint = typeof candidate.cadenceHint === "string" && candidate.cadenceHint.trim() !== ""
|
|
94
|
+
? candidate.cadenceHint
|
|
95
|
+
: undefined;
|
|
96
|
+
if (cadenceHint === undefined) {
|
|
97
|
+
issues.push(`${label}.cadenceHint must be a non-empty string`);
|
|
98
|
+
}
|
|
99
|
+
return { id, url, cadenceHint, label };
|
|
100
|
+
}
|
|
101
|
+
function parseStructuredFileSource(candidate, common, issues) {
|
|
102
|
+
if (!isStructuredFileFormat(candidate.format)) {
|
|
103
|
+
issues.push(`${common.label}.format must be "yaml", "json", or "csv"`);
|
|
104
|
+
}
|
|
105
|
+
if (candidate.targetSchema !== undefined) {
|
|
106
|
+
issues.push(`${common.label}.targetSchema is not allowed for structured-file sources`);
|
|
107
|
+
}
|
|
108
|
+
if (candidate.renderPolicy !== undefined) {
|
|
109
|
+
issues.push(`${common.label}.renderPolicy is not allowed for structured-file sources`);
|
|
110
|
+
}
|
|
111
|
+
if (common.id === undefined || common.url === undefined || common.cadenceHint === undefined ||
|
|
112
|
+
!isStructuredFileFormat(candidate.format))
|
|
113
|
+
return undefined;
|
|
114
|
+
return {
|
|
115
|
+
id: common.id,
|
|
116
|
+
kind: "structured-file",
|
|
117
|
+
format: candidate.format,
|
|
118
|
+
url: common.url,
|
|
119
|
+
cadenceHint: common.cadenceHint,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function parseExtractableSource(candidate, kind, common, issues) {
|
|
123
|
+
if (!isRenderPolicy(candidate.renderPolicy)) {
|
|
124
|
+
issues.push(`${common.label}.renderPolicy must be "never", "on-shell-warning", or "always"`);
|
|
125
|
+
}
|
|
126
|
+
const targetSchema = validateTargetSchema(candidate.targetSchema, common.label, issues)
|
|
127
|
+
? candidate.targetSchema
|
|
128
|
+
: undefined;
|
|
129
|
+
if (candidate.format !== undefined) {
|
|
130
|
+
issues.push(`${common.label}.format is only allowed for structured-file sources`);
|
|
131
|
+
}
|
|
132
|
+
if (common.id === undefined || common.url === undefined || common.cadenceHint === undefined ||
|
|
133
|
+
!isRenderPolicy(candidate.renderPolicy) || targetSchema === undefined)
|
|
134
|
+
return undefined;
|
|
135
|
+
return {
|
|
136
|
+
id: common.id,
|
|
137
|
+
kind,
|
|
138
|
+
url: common.url,
|
|
139
|
+
targetSchema,
|
|
140
|
+
cadenceHint: common.cadenceHint,
|
|
141
|
+
renderPolicy: candidate.renderPolicy,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function invalidSourceKind(candidate, label, issues) {
|
|
145
|
+
issues.push(`${label}.kind must be "web-page", "api-record", or "structured-file"`);
|
|
146
|
+
if (!isRenderPolicy(candidate.renderPolicy)) {
|
|
147
|
+
issues.push(`${label}.renderPolicy must be "never", "on-shell-warning", or "always"`);
|
|
148
|
+
}
|
|
149
|
+
validateTargetSchema(candidate.targetSchema, label, issues);
|
|
150
|
+
if (candidate.format !== undefined) {
|
|
151
|
+
issues.push(`${label}.format is only allowed for structured-file sources`);
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
function isStructuredFileFormat(value) {
|
|
156
|
+
return value === "yaml" || value === "json" || value === "csv";
|
|
157
|
+
}
|
|
158
|
+
function isRenderPolicy(value) {
|
|
159
|
+
return value === "never" || value === "on-shell-warning" || value === "always";
|
|
160
|
+
}
|
|
97
161
|
function validateTargetSchema(value, context, issues) {
|
|
162
|
+
const firstIssue = issues.length;
|
|
98
163
|
if (!Array.isArray(value)) {
|
|
99
164
|
issues.push(`${context}.targetSchema must be an array`);
|
|
100
|
-
return;
|
|
165
|
+
return false;
|
|
101
166
|
}
|
|
102
167
|
const allowedTypes = new Set(["string", "number", "boolean", "date", "enum", "array", "object"]);
|
|
103
168
|
value.forEach((field, index) => {
|
|
@@ -124,6 +189,7 @@ function validateTargetSchema(value, context, issues) {
|
|
|
124
189
|
issues.push(`${label}.inferenceType must be "explicit" or "inferred"`);
|
|
125
190
|
}
|
|
126
191
|
});
|
|
192
|
+
return issues.length === firstIssue;
|
|
127
193
|
}
|
|
128
194
|
function isRecord(value) {
|
|
129
195
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -1,2 +1,12 @@
|
|
|
1
|
-
import type { SnapshotStore } from "@kontourai/
|
|
1
|
+
import type { SnapshotStore } from "@kontourai/forage";
|
|
2
|
+
import { type SnapshotSourceRefResolution } from "@kontourai/forage/fetch";
|
|
2
3
|
export declare function createLookoutSnapshotStore(root?: string): SnapshotStore;
|
|
4
|
+
export type ResolveLookoutSnapshotOptions = {
|
|
5
|
+
store: SnapshotStore;
|
|
6
|
+
root?: never;
|
|
7
|
+
} | {
|
|
8
|
+
store?: never;
|
|
9
|
+
root?: string;
|
|
10
|
+
};
|
|
11
|
+
/** Replay one Lookout-emitted durable reference without any network access. */
|
|
12
|
+
export declare function resolveLookoutSnapshot(reference: string, options?: ResolveLookoutSnapshotOptions): Promise<SnapshotSourceRefResolution>;
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { createFilesystemSnapshotStore } from "@kontourai/
|
|
2
|
+
import { createFilesystemSnapshotStore } from "@kontourai/forage";
|
|
3
|
+
import { resolveSnapshotSourceRef, } from "@kontourai/forage/fetch";
|
|
3
4
|
export function createLookoutSnapshotStore(root = path.join(process.cwd(), ".kontourai", "lookout", "snapshots")) {
|
|
4
5
|
return createFilesystemSnapshotStore({ root });
|
|
5
6
|
}
|
|
7
|
+
/** Replay one Lookout-emitted durable reference without any network access. */
|
|
8
|
+
export async function resolveLookoutSnapshot(reference, options = {}) {
|
|
9
|
+
try {
|
|
10
|
+
const store = options.store ?? createLookoutSnapshotStore(options.root);
|
|
11
|
+
return await resolveSnapshotSourceRef(store, reference);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
error: {
|
|
17
|
+
kind: "snapshot-store-error",
|
|
18
|
+
message: "the supplied snapshot store could not resolve the reference",
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontourai/lookout",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "A small source registry and drift-check runner built on
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "A small source registry and drift-check runner built on Forage snapshots.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"private": false,
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@kontourai/datum": "0.3.0",
|
|
47
|
-
"@kontourai/forage": "0.
|
|
48
|
-
"@kontourai/traverse": "0.
|
|
47
|
+
"@kontourai/forage": "0.4.1",
|
|
48
|
+
"@kontourai/traverse": "0.18.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^25.6.0",
|