@kontourai/lookout 0.1.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.
@@ -0,0 +1,138 @@
1
+ import { createHash } from "node:crypto";
2
+ import { SurveyInputBuilder } from "@kontourai/survey";
3
+ import { diffProposalSets } from "./proposal-diff.js";
4
+ function id(...parts) { return createHash("sha256").update(JSON.stringify(parts)).digest("hex"); }
5
+ function eventValue(event) {
6
+ return event.kind === "new-entity-appeared"
7
+ ? { kind: event.kind, entityKey: event.entityKey, currentValues: event.current.map((item) => ({ fieldKey: item.fieldKey, value: item.value })) }
8
+ : { kind: event.kind, changeKind: event.changeKind, entityKey: event.entityKey, fieldKey: event.fieldKey, ...(event.prior ? { priorValue: event.prior.value } : {}), ...(event.current ? { currentValue: event.current.value } : {}) };
9
+ }
10
+ function observationFor(source, prior, current, event, generatedAt, index) {
11
+ const eventId = id(source.id, prior.observationId, current.snapshotRef, event.kind, event.entityKey, event.kind === "field-changed" ? event.fieldKey : "", index);
12
+ const evidence = event.kind === "new-entity-appeared" ? event.current : [event.prior, event.current].filter((item) => item !== undefined);
13
+ const primary = event.kind === "new-entity-appeared" ? event.current[0] : event.current ?? event.prior;
14
+ const metadata = {
15
+ sourceId: source.id, eventKind: event.kind, priorObservationId: prior.observationId,
16
+ priorSnapshotRef: prior.snapshotRef, currentSnapshotRef: current.snapshotRef,
17
+ evidence: evidence.map((item) => ({ snapshotRef: item.snapshotRef, locator: item.provenance.locator, excerpt: item.provenance.excerpt, extractor: item.extractor, fieldPath: item.fieldPath })),
18
+ ...(event.kind === "field-changed" ? { changeKind: event.changeKind } : {}),
19
+ };
20
+ return {
21
+ id: `lookout-${eventId}`,
22
+ rawSource: { kind: source.kind, resolution: "observation", sourceRef: current.snapshotRef, observedAt: current.observedAt, locatorScheme: source.kind === "web-page" ? "html" : "structured-field", metadata },
23
+ extraction: { target: `lookout:${event.entityKey}:${event.kind === "field-changed" ? event.fieldKey : "appearance"}`, value: eventValue(event), confidence: primary?.confidence, locator: primary?.provenance.locator, excerpt: primary?.provenance.excerpt, extractor: primary?.extractor ?? "lookout:proposal-diff", extractedAt: generatedAt, metadata },
24
+ candidateSet: { status: "needs-review", metadata },
25
+ claim: { subjectType: "lookout.observed-entity", subjectId: event.entityKey, facet: "lookout.source-change", claimType: event.kind === "new-entity-appeared" ? "lookout.new-entity-appeared" : "lookout.field-changed", fieldOrBehavior: event.kind === "field-changed" ? event.fieldKey : event.entityKey, value: eventValue(event), status: "proposed", impactLevel: "low", collectedBy: "@kontourai/lookout", createdAt: generatedAt, evidenceMethod: "observation", metadata },
26
+ };
27
+ }
28
+ function containsAuthorizing(value) {
29
+ if (Array.isArray(value))
30
+ return value.some(containsAuthorizing);
31
+ if (!value || typeof value !== "object")
32
+ return false;
33
+ return Object.entries(value).some(([key, item]) => key === "authorizing" || containsAuthorizing(item));
34
+ }
35
+ function validateSurveyInput(input, source, current, prior, events) {
36
+ if (input.reviewOutcomes.length !== 0 || containsAuthorizing(input))
37
+ return false;
38
+ if (input.rawSources.length !== events.length || input.claims.length !== events.length)
39
+ return false;
40
+ for (const raw of input.rawSources)
41
+ if (raw.kind !== source.kind || raw.resolution !== "observation" || raw.sourceRef !== current.snapshotRef)
42
+ return false;
43
+ for (let index = 0; index < events.length; index += 1) {
44
+ const claim = input.claims[index];
45
+ const event = events[index];
46
+ if (!claim || !event || claim.status !== "proposed" || claim.metadata?.priorSnapshotRef !== prior.snapshotRef || claim.metadata?.currentSnapshotRef !== current.snapshotRef)
47
+ return false;
48
+ const evidence = claim.metadata.evidence;
49
+ if (!Array.isArray(evidence))
50
+ return false;
51
+ if (event.kind === "new-entity-appeared" && !event.current.every((side) => evidence.some((item) => item && typeof item === "object" && item.snapshotRef === side.snapshotRef)))
52
+ return false;
53
+ if (event.kind === "field-changed") {
54
+ if (event.prior && !evidence.some((item) => item && typeof item === "object" && item.snapshotRef === event.prior.snapshotRef))
55
+ return false;
56
+ if (event.current && !evidence.some((item) => item && typeof item === "object" && item.snapshotRef === event.current.snapshotRef))
57
+ return false;
58
+ if (!event.current && !input.rawSources[index] || input.rawSources[index]?.sourceRef !== current.snapshotRef)
59
+ return false;
60
+ }
61
+ }
62
+ return true;
63
+ }
64
+ function stableJson(value) {
65
+ if (Array.isArray(value))
66
+ return `[${value.map(stableJson).join(",")}]`;
67
+ if (value && typeof value === "object")
68
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
69
+ return JSON.stringify(value) ?? "undefined";
70
+ }
71
+ function normalizeDiff(value) {
72
+ const sorted = (items) => [...items].sort((a, b) => stableJson(a).localeCompare(stableJson(b)));
73
+ return { events: sorted(value.events), facts: { retainedProposalOccurrences: sorted(value.facts.retainedProposalOccurrences), addedProposalOccurrences: sorted(value.facts.addedProposalOccurrences), removedProposalOccurrences: sorted(value.facts.removedProposalOccurrences), provenanceChanges: sorted(value.facts.provenanceChanges), removedEntities: [...value.facts.removedEntities].sort() } };
74
+ }
75
+ export function createSurveyEmitter(options) {
76
+ const now = options.now ?? (() => new Date().toISOString());
77
+ const diff = options.diff ?? diffProposalSets;
78
+ return { async emit(input) {
79
+ try {
80
+ if (options.resolution !== undefined && options.resolution !== "observation")
81
+ return { ok: false, error: { kind: "invalid-input", message: "Lookout may author only observation resolution" } };
82
+ if (!input.source || input.source.id !== input.current?.sourceId || input.check?.currentSnapshotRef !== input.current?.snapshotRef)
83
+ return { ok: false, error: { kind: "invalid-input", message: "Registry source, observation, and check anchor must agree" } };
84
+ const loaded = await options.store.loadLatest(input.source.id);
85
+ if (!loaded.ok)
86
+ return { ok: false, error: { kind: "prior-state-error", message: loaded.error.message, cause: loaded.error } };
87
+ const recordedAt = now();
88
+ let events = [];
89
+ let facts;
90
+ let surveyInput = null;
91
+ if (loaded.value === null) {
92
+ facts = [{ kind: "baseline-established", sourceId: input.source.id, snapshotRef: input.current.snapshotRef, observedAt: input.current.observedAt, origin: input.source.kind, resolution: "observation", proposalCount: input.current.proposals.length }];
93
+ }
94
+ else {
95
+ let derived;
96
+ try {
97
+ derived = diff({ prior: { sourceId: loaded.value.sourceId, snapshotRef: loaded.value.snapshotRef, observedAt: loaded.value.observedAt, proposals: loaded.value.proposals }, current: input.current, ...input.callbacks });
98
+ }
99
+ catch (cause) {
100
+ return { ok: false, error: { kind: "diff-error", message: "Proposal diff threw", cause } };
101
+ }
102
+ if (!derived.ok)
103
+ return { ok: false, error: { kind: "diff-error", message: derived.error.message, cause: derived.error } };
104
+ const normalized = normalizeDiff(derived.value);
105
+ events = normalized.events;
106
+ facts = [{ kind: "proposal-set-facts", priorSnapshotRef: loaded.value.snapshotRef, currentSnapshotRef: input.current.snapshotRef, origin: input.source.kind, resolution: "observation", value: normalized.facts }];
107
+ if (events.length > 0) {
108
+ try {
109
+ const builder = new SurveyInputBuilder({ source: "@kontourai/lookout", generatedAt: recordedAt });
110
+ builder.addObservations(events.map((event, index) => observationFor(input.source, loaded.value, input.current, event, recordedAt, index)));
111
+ surveyInput = builder.build();
112
+ if (options.transformSurveyInput)
113
+ surveyInput = options.transformSurveyInput(surveyInput);
114
+ JSON.stringify(surveyInput);
115
+ if (!validateSurveyInput(surveyInput, input.source, input.current, loaded.value, events))
116
+ return { ok: false, error: { kind: "authoring-error", message: "SurveyInput violated observation-only provenance" } };
117
+ }
118
+ catch (cause) {
119
+ return { ok: false, error: { kind: "authoring-error", message: "Could not author SurveyInput", cause } };
120
+ }
121
+ }
122
+ }
123
+ try {
124
+ JSON.stringify({ events, facts, surveyInput });
125
+ }
126
+ catch (cause) {
127
+ return { ok: false, error: { kind: "authoring-error", message: "Emission result is not serializable", cause } };
128
+ }
129
+ const committed = await options.store.commit({ observation: input.current, recordedAt, check: input.check }, loaded.value?.observationId ?? null);
130
+ if (!committed.ok)
131
+ return { ok: false, error: { kind: "persistence-error", message: committed.error.message, cause: committed.error } };
132
+ return { ok: true, value: { sourceId: input.source.id, events, facts, surveyInput, committedObservation: committed.value, warnings: committed.warnings ?? [] } };
133
+ }
134
+ catch (cause) {
135
+ return { ok: false, error: { kind: "authoring-error", message: "Emission failed", cause } };
136
+ }
137
+ } };
138
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@kontourai/lookout",
3
+ "version": "0.1.0",
4
+ "description": "A small source registry and drift-check runner built on Traverse snapshots.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "private": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kontourai/lookout.git"
11
+ },
12
+ "homepage": "https://github.com/kontourai/lookout#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/kontourai/lookout/issues"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/src/index.d.ts",
22
+ "default": "./dist/src/index.js"
23
+ }
24
+ },
25
+ "bin": {
26
+ "lookout": "bin/lookout.mjs"
27
+ },
28
+ "files": [
29
+ "dist/src/",
30
+ "bin/",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "build": "rm -rf dist && tsc",
36
+ "prepare": "npm run build",
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "npm run build && node --test dist/tests/*.test.js",
39
+ "verify": "npm run check:content-boundary && npm run check:decisions && npm run typecheck && npm test && npm run check:pack",
40
+ "check:content-boundary": "node scripts/check-content-boundary.cjs",
41
+ "check:decisions": "node scripts/check-decisions.cjs check",
42
+ "gen:decisions-index": "node scripts/check-decisions.cjs gen-index",
43
+ "check:pack": "node scripts/check-package-contents.mjs"
44
+ },
45
+ "dependencies": {
46
+ "@kontourai/datum": "0.3.0",
47
+ "@kontourai/survey": "1.7.0",
48
+ "@kontourai/traverse": "0.14.1"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^25.6.0",
52
+ "typescript": "^5.8.0"
53
+ },
54
+ "engines": {
55
+ "node": ">=22"
56
+ }
57
+ }