@nebulacomponents/citable 1.3.1 → 1.5.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/CHANGELOG.md +45 -0
- package/README.md +49 -9
- package/dist/universal/.agents/skills/citable/SKILL.md +14 -3
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/commands/README.md +8 -2
- package/dist/universal/.agents/skills/citable/commands/measurement.md +22 -5
- package/dist/universal/.agents/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.agents/skills/citable/manifest.json +15 -8
- package/dist/universal/.agents/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.agents/skills/citable/schemas/connection.schema.json +32 -0
- package/dist/universal/.agents/skills/citable/schemas/intervention.schema.json +33 -0
- package/dist/universal/.agents/skills/citable/schemas/metric.schema.json +42 -0
- package/dist/universal/.agents/skills/citable/schemas/objective.schema.json +65 -0
- package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.agents/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.claude/skills/citable/SKILL.md +14 -3
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/commands/README.md +8 -2
- package/dist/universal/.claude/skills/citable/commands/measurement.md +22 -5
- package/dist/universal/.claude/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.claude/skills/citable/manifest.json +15 -8
- package/dist/universal/.claude/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.claude/skills/citable/schemas/connection.schema.json +32 -0
- package/dist/universal/.claude/skills/citable/schemas/intervention.schema.json +33 -0
- package/dist/universal/.claude/skills/citable/schemas/metric.schema.json +42 -0
- package/dist/universal/.claude/skills/citable/schemas/objective.schema.json +65 -0
- package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.claude/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.cursor/skills/citable/SKILL.md +14 -3
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/commands/README.md +8 -2
- package/dist/universal/.cursor/skills/citable/commands/measurement.md +22 -5
- package/dist/universal/.cursor/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.cursor/skills/citable/manifest.json +15 -8
- package/dist/universal/.cursor/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.cursor/skills/citable/schemas/connection.schema.json +32 -0
- package/dist/universal/.cursor/skills/citable/schemas/intervention.schema.json +33 -0
- package/dist/universal/.cursor/skills/citable/schemas/metric.schema.json +42 -0
- package/dist/universal/.cursor/skills/citable/schemas/objective.schema.json +65 -0
- package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.cursor/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.gemini/skills/citable/SKILL.md +14 -3
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/commands/README.md +8 -2
- package/dist/universal/.gemini/skills/citable/commands/measurement.md +22 -5
- package/dist/universal/.gemini/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.gemini/skills/citable/manifest.json +15 -8
- package/dist/universal/.gemini/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.gemini/skills/citable/schemas/connection.schema.json +32 -0
- package/dist/universal/.gemini/skills/citable/schemas/intervention.schema.json +33 -0
- package/dist/universal/.gemini/skills/citable/schemas/metric.schema.json +42 -0
- package/dist/universal/.gemini/skills/citable/schemas/objective.schema.json +65 -0
- package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.gemini/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.github/skills/citable/SKILL.md +14 -3
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/commands/README.md +8 -2
- package/dist/universal/.github/skills/citable/commands/measurement.md +22 -5
- package/dist/universal/.github/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.github/skills/citable/manifest.json +15 -8
- package/dist/universal/.github/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.github/skills/citable/schemas/connection.schema.json +32 -0
- package/dist/universal/.github/skills/citable/schemas/intervention.schema.json +33 -0
- package/dist/universal/.github/skills/citable/schemas/metric.schema.json +42 -0
- package/dist/universal/.github/skills/citable/schemas/objective.schema.json +65 -0
- package/dist/universal/.github/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.github/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.kiro/skills/citable/SKILL.md +14 -3
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/commands/README.md +8 -2
- package/dist/universal/.kiro/skills/citable/commands/measurement.md +22 -5
- package/dist/universal/.kiro/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.kiro/skills/citable/manifest.json +15 -8
- package/dist/universal/.kiro/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.kiro/skills/citable/schemas/connection.schema.json +32 -0
- package/dist/universal/.kiro/skills/citable/schemas/intervention.schema.json +33 -0
- package/dist/universal/.kiro/skills/citable/schemas/metric.schema.json +42 -0
- package/dist/universal/.kiro/skills/citable/schemas/objective.schema.json +65 -0
- package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.kiro/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.opencode/skills/citable/SKILL.md +14 -3
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/commands/README.md +8 -2
- package/dist/universal/.opencode/skills/citable/commands/measurement.md +22 -5
- package/dist/universal/.opencode/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.opencode/skills/citable/manifest.json +15 -8
- package/dist/universal/.opencode/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.opencode/skills/citable/schemas/connection.schema.json +32 -0
- package/dist/universal/.opencode/skills/citable/schemas/intervention.schema.json +33 -0
- package/dist/universal/.opencode/skills/citable/schemas/metric.schema.json +42 -0
- package/dist/universal/.opencode/skills/citable/schemas/objective.schema.json +65 -0
- package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.opencode/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +14 -3
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/commands/README.md +8 -2
- package/dist/universal/.pi/agent/skills/citable/commands/measurement.md +22 -5
- package/dist/universal/.pi/agent/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.pi/agent/skills/citable/manifest.json +15 -8
- package/dist/universal/.pi/agent/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.pi/agent/skills/citable/schemas/connection.schema.json +32 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/intervention.schema.json +33 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/metric.schema.json +42 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/objective.schema.json +65 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.qoder/skills/citable/SKILL.md +14 -3
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/commands/README.md +8 -2
- package/dist/universal/.qoder/skills/citable/commands/measurement.md +22 -5
- package/dist/universal/.qoder/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.qoder/skills/citable/manifest.json +15 -8
- package/dist/universal/.qoder/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.qoder/skills/citable/schemas/connection.schema.json +32 -0
- package/dist/universal/.qoder/skills/citable/schemas/intervention.schema.json +33 -0
- package/dist/universal/.qoder/skills/citable/schemas/metric.schema.json +42 -0
- package/dist/universal/.qoder/skills/citable/schemas/objective.schema.json +65 -0
- package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.qoder/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.rovodev/skills/citable/SKILL.md +14 -3
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/commands/README.md +8 -2
- package/dist/universal/.rovodev/skills/citable/commands/measurement.md +22 -5
- package/dist/universal/.rovodev/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.rovodev/skills/citable/manifest.json +15 -8
- package/dist/universal/.rovodev/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.rovodev/skills/citable/schemas/connection.schema.json +32 -0
- package/dist/universal/.rovodev/skills/citable/schemas/intervention.schema.json +33 -0
- package/dist/universal/.rovodev/skills/citable/schemas/metric.schema.json +42 -0
- package/dist/universal/.rovodev/skills/citable/schemas/objective.schema.json +65 -0
- package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.rovodev/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.trae/skills/citable/SKILL.md +14 -3
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/commands/README.md +8 -2
- package/dist/universal/.trae/skills/citable/commands/measurement.md +22 -5
- package/dist/universal/.trae/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.trae/skills/citable/manifest.json +15 -8
- package/dist/universal/.trae/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.trae/skills/citable/schemas/connection.schema.json +32 -0
- package/dist/universal/.trae/skills/citable/schemas/intervention.schema.json +33 -0
- package/dist/universal/.trae/skills/citable/schemas/metric.schema.json +42 -0
- package/dist/universal/.trae/skills/citable/schemas/objective.schema.json +65 -0
- package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.trae/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +14 -3
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/commands/README.md +8 -2
- package/dist/universal/.trae-cn/skills/citable/commands/measurement.md +22 -5
- package/dist/universal/.trae-cn/skills/citable/commands/observe-and-act.md +40 -0
- package/dist/universal/.trae-cn/skills/citable/manifest.json +15 -8
- package/dist/universal/.trae-cn/skills/citable/references/capability-boundaries.md +8 -8
- package/dist/universal/.trae-cn/skills/citable/schemas/connection.schema.json +32 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/intervention.schema.json +33 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/metric.schema.json +42 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/objective.schema.json +65 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +20 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/remediation-spec.schema.json +28 -0
- package/dist/universal/manifest.json +183 -99
- package/package.json +12 -1
- package/schemas/connection.schema.json +32 -0
- package/schemas/intervention.schema.json +33 -0
- package/schemas/metric.schema.json +42 -0
- package/schemas/objective.schema.json +65 -0
- package/schemas/observation.schema.json +20 -0
- package/schemas/remediation-spec.schema.json +28 -0
- package/skill/SKILL.md +14 -3
- package/skill/commands/README.md +8 -2
- package/skill/commands/measurement.md +22 -5
- package/skill/commands/observe-and-act.md +40 -0
- package/skill/references/capability-boundaries.md +8 -8
- package/src/cli/index.js +67 -0
- package/src/commands/applyRemediation.js +57 -0
- package/src/commands/measurement.js +205 -0
- package/src/commands/monitor.js +40 -0
- package/src/commands/observe.js +235 -0
- package/src/evidence/run.js +1 -1
- package/src/installer/index.js +35 -7
- package/src/installer/providers.js +1 -1
- package/src/observations/common.js +61 -0
- package/src/registries/index.js +13 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse as parseCsv } from 'csv-parse/sync';
|
|
4
|
+
import { loadRegistries, saveRegistry } from '../registries/index.js';
|
|
5
|
+
import { envelope, observationRun } from '../observations/common.js';
|
|
6
|
+
import { parseRefDate, readJson, readYaml } from '../shared/io.js';
|
|
7
|
+
import { validateAgainst } from '../shared/schemaValidator.js';
|
|
8
|
+
|
|
9
|
+
function inputDocument(file) {
|
|
10
|
+
if (!file) throw new Error('--input <file> is required');
|
|
11
|
+
if (!fs.existsSync(file)) throw new Error(`input not found: ${file}`);
|
|
12
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
13
|
+
const ext = path.extname(file).toLowerCase();
|
|
14
|
+
if (ext === '.csv') return { raw, rows: parseCsv(raw, { columns: true, skip_empty_lines: true, trim: true }), file: path.resolve(file) };
|
|
15
|
+
const value = ext === '.yaml' || ext === '.yml' ? readYaml(file) : readJson(file);
|
|
16
|
+
return { raw, rows: Array.isArray(value) ? value : value.rows || value.entries || [value], file: path.resolve(file) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function numericValue(raw, type, rowNumber) {
|
|
20
|
+
if (raw === '' || raw == null) throw new Error(`row ${rowNumber}: value is required`);
|
|
21
|
+
const value = Number(raw);
|
|
22
|
+
if (!Number.isFinite(value)) throw new Error(`row ${rowNumber}: value must be numeric`);
|
|
23
|
+
if (type === 'integer' && !Number.isInteger(value)) throw new Error(`row ${rowNumber}: value must be an integer`);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isoTime(raw, field, rowNumber) {
|
|
28
|
+
if (!raw || Number.isNaN(new Date(raw).getTime())) throw new Error(`row ${rowNumber}: ${field} must be a valid date-time`);
|
|
29
|
+
return new Date(raw).toISOString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function importMetrics(root, { input, provider }) {
|
|
33
|
+
if (!provider) throw new Error('metrics import requires --provider <name>');
|
|
34
|
+
const source = inputDocument(input);
|
|
35
|
+
if (!source.rows.length) throw new Error('metric import contains no rows');
|
|
36
|
+
const { registries, problems } = loadRegistries(root);
|
|
37
|
+
if (problems.length) throw new Error(`registry validation failed: ${problems.join('; ')}`);
|
|
38
|
+
const definitions = new Map(registries.metrics.entries.map((metric) => [metric.metric_id, metric]));
|
|
39
|
+
const observations = source.rows.map((row, index) => {
|
|
40
|
+
const rowNumber = index + 1;
|
|
41
|
+
const metric = definitions.get(row.metric_id);
|
|
42
|
+
if (!metric) throw new Error(`row ${rowNumber}: unknown metric_id ${row.metric_id || '(missing)'}`);
|
|
43
|
+
if (metric.status === 'deprecated') throw new Error(`row ${rowNumber}: metric ${metric.metric_id} is deprecated`);
|
|
44
|
+
if (metric.provider.toLowerCase() !== String(provider).toLowerCase()) {
|
|
45
|
+
throw new Error(`row ${rowNumber}: metric ${metric.metric_id} belongs to ${metric.provider}, not ${provider}`);
|
|
46
|
+
}
|
|
47
|
+
const observedAt = isoTime(row.observed_at || row.date, 'observed_at', rowNumber);
|
|
48
|
+
const dimensions = {};
|
|
49
|
+
for (const dimension of metric.dimensions) {
|
|
50
|
+
if (row[dimension] != null && row[dimension] !== '') dimensions[dimension] = String(row[dimension]);
|
|
51
|
+
}
|
|
52
|
+
const data = {
|
|
53
|
+
metric_id: metric.metric_id,
|
|
54
|
+
provider: metric.provider,
|
|
55
|
+
external_name: metric.external_name,
|
|
56
|
+
value: numericValue(row.value, metric.value_type, rowNumber),
|
|
57
|
+
unit: metric.unit === 'custom' ? metric.custom_unit : metric.unit,
|
|
58
|
+
aggregation: metric.aggregation,
|
|
59
|
+
observed_at: observedAt,
|
|
60
|
+
period_start: row.period_start ? isoTime(row.period_start, 'period_start', rowNumber) : observedAt,
|
|
61
|
+
period_end: row.period_end ? isoTime(row.period_end, 'period_end', rowNumber) : observedAt,
|
|
62
|
+
dimensions,
|
|
63
|
+
source_row: rowNumber,
|
|
64
|
+
};
|
|
65
|
+
if (new Date(data.period_start) > new Date(data.period_end)) throw new Error(`row ${rowNumber}: period_start is after period_end`);
|
|
66
|
+
return envelope('metric', data, {
|
|
67
|
+
method: 'owner_import',
|
|
68
|
+
source: source.file,
|
|
69
|
+
raw: JSON.stringify(row),
|
|
70
|
+
limitations: metric.limitations,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
return observationRun(root, 'metrics import', source.file, observations, { rawInputs: { metric_import: source.raw } });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readObjectiveInput(input) {
|
|
77
|
+
const document = inputDocument(input);
|
|
78
|
+
if (document.rows.length !== 1) throw new Error('objectives init requires exactly one objective');
|
|
79
|
+
return document.rows[0];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function initializeObjective(root, { input, write = false }) {
|
|
83
|
+
const objective = readObjectiveInput(input);
|
|
84
|
+
const { registries, problems } = loadRegistries(root);
|
|
85
|
+
if (problems.length) throw new Error(`registry validation failed: ${problems.join('; ')}`);
|
|
86
|
+
const candidate = { ...registries.objectives, entries: [...registries.objectives.entries, objective] };
|
|
87
|
+
const check = validateAgainst('objective.schema.json', candidate);
|
|
88
|
+
if (!check.valid) throw new Error(`objective violates contract: ${check.errors.join('; ')}`);
|
|
89
|
+
if (registries.objectives.entries.some((item) => item.objective_id === objective.objective_id)) throw new Error(`objective ${objective.objective_id} already exists`);
|
|
90
|
+
const metricIds = new Set(registries.metrics.entries.map((item) => item.metric_id));
|
|
91
|
+
const refs = [...objective.primary_metrics, ...objective.supporting_metrics, ...objective.guardrails.map((item) => item.metric_id)];
|
|
92
|
+
const unknown = [...new Set(refs.filter((ref) => !metricIds.has(ref)))];
|
|
93
|
+
if (unknown.length) throw new Error(`objective references unknown metrics: ${unknown.join(', ')}`);
|
|
94
|
+
if (write) saveRegistry(root, 'objectives', candidate);
|
|
95
|
+
return { objective, written: write, file: path.join(root, '.citable', 'objectives.yaml') };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function validateObjectives(root) {
|
|
99
|
+
const { registries, problems } = loadRegistries(root);
|
|
100
|
+
const objectiveProblems = problems.filter((problem) => problem.startsWith('objectives/') || problem.startsWith('objectives.yaml'));
|
|
101
|
+
return { ok: objectiveProblems.length === 0, problems: objectiveProblems, count: registries.objectives.entries.length };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function metricObservations(root) {
|
|
105
|
+
const runs = path.join(root, '.citable', 'runs');
|
|
106
|
+
if (!fs.existsSync(runs)) return [];
|
|
107
|
+
const items = [];
|
|
108
|
+
for (const run of fs.readdirSync(runs)) {
|
|
109
|
+
const dir = path.join(runs, run, 'observations');
|
|
110
|
+
if (!fs.existsSync(dir)) continue;
|
|
111
|
+
for (const file of fs.readdirSync(dir).filter((name) => name.endsWith('-metric.json'))) {
|
|
112
|
+
const item = readJson(path.join(dir, file));
|
|
113
|
+
if (item.kind === 'metric') items.push(item);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [...new Map(items.map((item) => [item.evidence_hash, item])).values()];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function inCohort(data, cohort) {
|
|
120
|
+
const url = data.dimensions.url || data.dimensions.page || null;
|
|
121
|
+
const query = data.dimensions.query || null;
|
|
122
|
+
const labels = String(data.dimensions.labels || data.dimensions.label || '').split(',').map((label) => label.trim()).filter(Boolean);
|
|
123
|
+
const urlMatch = !cohort.urls.length || (url && cohort.urls.some((pattern) => pattern.endsWith('*') ? url.startsWith(pattern.slice(0, -1)) : url === pattern));
|
|
124
|
+
const queryMatch = !cohort.queries.length || (query && cohort.queries.includes(query));
|
|
125
|
+
const labelMatch = !cohort.labels.length || cohort.labels.some((label) => labels.includes(label));
|
|
126
|
+
return urlMatch && queryMatch && labelMatch;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function aggregate(values, method) {
|
|
130
|
+
if (!values.length) return null;
|
|
131
|
+
if (method === 'sum') return values.reduce((sum, value) => sum + value, 0);
|
|
132
|
+
if (method === 'minimum') return Math.min(...values);
|
|
133
|
+
if (method === 'maximum') return Math.max(...values);
|
|
134
|
+
if (method === 'latest') return values.at(-1);
|
|
135
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function evaluateGuardrail(guardrail, metric, definition) {
|
|
139
|
+
if (metric.state !== 'observed') return { ...guardrail, state: 'inconclusive', passed: null, reason: 'metric comparison is inconclusive' };
|
|
140
|
+
let passed;
|
|
141
|
+
if (guardrail.operator === 'gte') passed = metric.evaluation >= guardrail.threshold;
|
|
142
|
+
else if (guardrail.operator === 'lte') passed = metric.evaluation <= guardrail.threshold;
|
|
143
|
+
else if (guardrail.operator === 'increase_by') passed = metric.relative_change != null && metric.relative_change >= guardrail.threshold;
|
|
144
|
+
else if (guardrail.operator === 'decrease_by') passed = metric.relative_change != null && metric.relative_change <= -guardrail.threshold;
|
|
145
|
+
else if (definition.direction === 'increase') passed = metric.evaluation >= metric.baseline;
|
|
146
|
+
else if (definition.direction === 'decrease') passed = metric.evaluation <= metric.baseline;
|
|
147
|
+
else if (definition.direction === 'maintain') passed = metric.relative_change != null && Math.abs(metric.relative_change) <= guardrail.threshold;
|
|
148
|
+
else return { ...guardrail, state: 'inconclusive', passed: null, reason: 'contextual metric requires a user-defined directional guardrail' };
|
|
149
|
+
return { ...guardrail, state: 'observed', passed, reason: passed ? 'observed condition met' : 'observed condition not met' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function evaluateObjective(root, { objectiveId, refDate }) {
|
|
153
|
+
const { registries, problems } = loadRegistries(root);
|
|
154
|
+
if (problems.length) throw new Error(`registry validation failed: ${problems.join('; ')}`);
|
|
155
|
+
const objective = objectiveId ? registries.objectives.entries.find((item) => item.objective_id === objectiveId) : registries.objectives.entries[0];
|
|
156
|
+
if (!objective) throw new Error(objectiveId ? `objective not found: ${objectiveId}` : 'no objective configured');
|
|
157
|
+
const definitions = new Map(registries.metrics.entries.map((item) => [item.metric_id, item]));
|
|
158
|
+
const end = parseRefDate(refDate);
|
|
159
|
+
end.setUTCHours(23, 59, 59, 999);
|
|
160
|
+
const evaluationStart = new Date(end); evaluationStart.setUTCDate(evaluationStart.getUTCDate() - objective.comparison.evaluation_days + 1); evaluationStart.setUTCHours(0, 0, 0, 0);
|
|
161
|
+
const baselineEnd = new Date(evaluationStart.getTime() - 1);
|
|
162
|
+
const baselineStart = new Date(baselineEnd); baselineStart.setUTCDate(baselineStart.getUTCDate() - objective.comparison.baseline_days + 1); baselineStart.setUTCHours(0, 0, 0, 0);
|
|
163
|
+
const all = metricObservations(root).filter((item) => inCohort(item.data, objective.cohort));
|
|
164
|
+
const metricIds = [...new Set([...objective.primary_metrics, ...objective.supporting_metrics, ...objective.guardrails.map((item) => item.metric_id)])];
|
|
165
|
+
const metrics = metricIds.map((metricId) => {
|
|
166
|
+
const definition = definitions.get(metricId);
|
|
167
|
+
const observations = all.filter((item) => item.data.metric_id === metricId).sort((a, b) => a.data.observed_at.localeCompare(b.data.observed_at));
|
|
168
|
+
const baselineRows = observations.filter((item) => new Date(item.data.observed_at) >= baselineStart && new Date(item.data.observed_at) <= baselineEnd);
|
|
169
|
+
const evaluationRows = observations.filter((item) => new Date(item.data.observed_at) >= evaluationStart && new Date(item.data.observed_at) <= end);
|
|
170
|
+
const aggregatable = definition.aggregation !== 'provider_defined';
|
|
171
|
+
const enough = aggregatable && baselineRows.length >= objective.comparison.minimum_observations && evaluationRows.length >= objective.comparison.minimum_observations;
|
|
172
|
+
const baseline = enough ? aggregate(baselineRows.map((item) => item.data.value), definition.aggregation) : null;
|
|
173
|
+
const evaluation = enough ? aggregate(evaluationRows.map((item) => item.data.value), definition.aggregation) : null;
|
|
174
|
+
return {
|
|
175
|
+
metric_id: metricId,
|
|
176
|
+
provider: definition.provider,
|
|
177
|
+
state: enough ? 'observed' : 'inconclusive',
|
|
178
|
+
baseline,
|
|
179
|
+
evaluation,
|
|
180
|
+
absolute_change: enough ? evaluation - baseline : null,
|
|
181
|
+
relative_change: enough && baseline !== 0 ? (evaluation - baseline) / Math.abs(baseline) : null,
|
|
182
|
+
baseline_observations: baselineRows.length,
|
|
183
|
+
evaluation_observations: evaluationRows.length,
|
|
184
|
+
limitations: [
|
|
185
|
+
...definition.limitations,
|
|
186
|
+
...(!aggregatable ? ['provider_defined aggregation requires an explicit normalized aggregation before evaluation'] : []),
|
|
187
|
+
...(aggregatable && !enough ? [`requires at least ${objective.comparison.minimum_observations} observations in each window`] : []),
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
const guardrails = objective.guardrails.map((guardrail) => {
|
|
192
|
+
const metric = metrics.find((item) => item.metric_id === guardrail.metric_id);
|
|
193
|
+
return evaluateGuardrail(guardrail, metric, definitions.get(guardrail.metric_id));
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
objective_id: objective.objective_id,
|
|
197
|
+
objective_name: objective.name,
|
|
198
|
+
windows: { baseline_start: baselineStart.toISOString(), baseline_end: baselineEnd.toISOString(), evaluation_start: evaluationStart.toISOString(), evaluation_end: end.toISOString() },
|
|
199
|
+
metrics,
|
|
200
|
+
guardrails,
|
|
201
|
+
guardrail_status: guardrails.some((item) => item.state === 'inconclusive') ? 'inconclusive' : guardrails.some((item) => !item.passed) ? 'not_met' : 'met',
|
|
202
|
+
status: metrics.every((metric) => metric.state === 'observed') ? 'observed' : 'inconclusive',
|
|
203
|
+
interpretation: 'Changes are temporal associations. This evaluation does not establish that an intervention caused an observed outcome.',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readJson, writeJson, nowIso } from '../shared/io.js';
|
|
4
|
+
|
|
5
|
+
function observations(dir) {
|
|
6
|
+
const folder = path.join(dir, 'observations');
|
|
7
|
+
if (!fs.existsSync(folder)) return [];
|
|
8
|
+
return fs.readdirSync(folder).filter((f) => f.endsWith('.json')).sort().map((f) => readJson(path.join(folder, f)));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function key(item) {
|
|
12
|
+
const d = item.data || {};
|
|
13
|
+
if (item.kind === 'citation') return [item.kind, d.provider, d.product_mode, d.prompt_id, d.run_index].join(':');
|
|
14
|
+
return [item.kind, d.url || d.citation_url || d.prompt_id || d.timestamp || item.observation_id].join(':');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function monitor(root, { runA, runB } = {}) {
|
|
18
|
+
const runsDir = path.join(root, '.citable', 'runs');
|
|
19
|
+
if (!fs.existsSync(runsDir)) throw new Error('no runs available to monitor');
|
|
20
|
+
const candidates = fs.readdirSync(runsDir).filter((r) => fs.existsSync(path.join(runsDir, r, 'observations'))).sort();
|
|
21
|
+
const b = runB || candidates.at(-1), a = runA || candidates.at(-2);
|
|
22
|
+
if (!a || !b) throw new Error('monitor requires two observation runs');
|
|
23
|
+
const before = new Map(observations(path.join(runsDir, a)).map((o) => [key(o), o]));
|
|
24
|
+
const after = new Map(observations(path.join(runsDir, b)).map((o) => [key(o), o]));
|
|
25
|
+
const alerts = [];
|
|
26
|
+
for (const [k, current] of after) {
|
|
27
|
+
const previous = before.get(k);
|
|
28
|
+
if (!previous) alerts.push({ severity: 'informational', type: 'new_observation', key: k, current_state: current.state });
|
|
29
|
+
else if (previous.state !== current.state) alerts.push({ severity: ['failed', 'not_observed'].includes(current.state) ? 'high' : 'medium', type: 'state_change', key: k, previous_state: previous.state, current_state: current.state });
|
|
30
|
+
if (current.kind === 'index' && previous?.data?.indexed === true && current.data.indexed === false) alerts.push({ severity: 'high', type: 'index_loss', key: k });
|
|
31
|
+
if (current.kind === 'canonical_freshness' && previous?.data?.canonical_consensus === true && current.data.canonical_consensus === false) alerts.push({ severity: 'high', type: 'canonical_regression', key: k });
|
|
32
|
+
if (current.kind === 'citation' && previous?.data?.property_cited === true && current.data.property_cited === false) alerts.push({ severity: 'medium', type: 'citation_presence_change', key: k });
|
|
33
|
+
}
|
|
34
|
+
for (const k of before.keys()) if (!after.has(k)) alerts.push({ severity: 'medium', type: 'observation_missing', key: k });
|
|
35
|
+
const result = { generated_at: nowIso(), run_a: a, run_b: b, summary: { alerts: alerts.length, critical_or_high: alerts.filter((x) => ['critical', 'high'].includes(x.severity)).length }, alerts };
|
|
36
|
+
const dir = path.join(root, '.citable', 'monitoring');
|
|
37
|
+
writeJson(path.join(dir, `${a}--${b}.json`), result);
|
|
38
|
+
writeJson(path.join(dir, 'latest.json'), result);
|
|
39
|
+
return { ...result, dir };
|
|
40
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { buildContext } from './context.js';
|
|
2
|
+
import { envelope, observationRun, readInput } from '../observations/common.js';
|
|
3
|
+
import { sha256 } from '../shared/io.js';
|
|
4
|
+
import { fetchUrl } from '../crawler/fetch.js';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
|
|
7
|
+
const originOf = (value) => { try { return new URL(value).origin; } catch { return null; } };
|
|
8
|
+
const words = (text) => String(text || '').trim().split(/\s+/).filter(Boolean);
|
|
9
|
+
|
|
10
|
+
function canonicalReview(raw, targetOrigin) {
|
|
11
|
+
const citations = raw.citations || [];
|
|
12
|
+
return citations.map((citation, index) => {
|
|
13
|
+
const url = typeof citation === 'string' ? citation : citation.url;
|
|
14
|
+
const canonical = typeof citation === 'object' ? citation.canonical_url || url : url;
|
|
15
|
+
const supports = typeof citation === 'object' ? citation.support_status || 'review_required' : 'review_required';
|
|
16
|
+
return {
|
|
17
|
+
citation_url: url, canonical_url: canonical, citation_order: index + 1,
|
|
18
|
+
first_party: Boolean(targetOrigin && originOf(canonical) === targetOrigin),
|
|
19
|
+
support_status: supports,
|
|
20
|
+
answer_claim: typeof citation === 'object' ? citation.answer_claim || null : null,
|
|
21
|
+
source_passage: typeof citation === 'object' ? citation.source_passage || null : null,
|
|
22
|
+
reviewer: typeof citation === 'object' ? citation.reviewer || null : null,
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function observeRender(root, options) {
|
|
28
|
+
if (!options.target || !/^https?:\/\//.test(options.target)) throw new Error('render requires --target <http(s) URL>');
|
|
29
|
+
let playwright;
|
|
30
|
+
try { playwright = await import('playwright'); } catch {
|
|
31
|
+
const item = envelope('render', { url: options.target }, { method: 'browser', source: 'playwright', state: 'not_evidenced', confidence: 'unknown', limitations: ['Optional Playwright dependency is not installed.'] });
|
|
32
|
+
return observationRun(root, 'observe render', options.target, [item], { incomplete: ['Rendered DOM capture unavailable: install Playwright and a Chromium browser.'] });
|
|
33
|
+
}
|
|
34
|
+
const initial = await fetchUrl(options.target, { timeoutMs: options.timeout || 30000, maxRetries: 1 });
|
|
35
|
+
const browser = await playwright.chromium.launch({ headless: true });
|
|
36
|
+
try {
|
|
37
|
+
const capture = async (name, viewport, isMobile = false) => {
|
|
38
|
+
const context = await browser.newContext({ viewport, isMobile });
|
|
39
|
+
const page = await context.newPage();
|
|
40
|
+
const failures = [];
|
|
41
|
+
page.on('requestfailed', (request) => failures.push({ url: request.url(), error: request.failure()?.errorText || 'unknown' }));
|
|
42
|
+
const response = await page.goto(options.target, { waitUntil: 'networkidle', timeout: options.timeout || 30000 });
|
|
43
|
+
const html = await page.content(), text = await page.locator('body').innerText(), screenshot = await page.screenshot({ fullPage: true });
|
|
44
|
+
const result = { name, final_url: page.url(), status: response?.status() ?? null, viewport, html, text, screenshot, failed_requests: failures };
|
|
45
|
+
await context.close();
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
const desktop = await capture('desktop', { width: 1280, height: 900 });
|
|
49
|
+
const mobile = await capture('mobile', { width: 390, height: 844 }, true);
|
|
50
|
+
const initialText = initial.body.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ').replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
51
|
+
const data = {
|
|
52
|
+
url: options.target, initial: { final_url: initial.url, status: initial.status, html_hash: sha256(initial.body), text_hash: sha256(initialText), word_count: words(initialText).length, redirect_chain: initial.redirectChain },
|
|
53
|
+
desktop: { final_url: desktop.final_url, status: desktop.status, html_hash: sha256(desktop.html), text_hash: sha256(desktop.text), word_count: words(desktop.text).length, failed_requests: desktop.failed_requests },
|
|
54
|
+
mobile: { final_url: mobile.final_url, status: mobile.status, html_hash: sha256(mobile.html), text_hash: sha256(mobile.text), word_count: words(mobile.text).length, failed_requests: mobile.failed_requests },
|
|
55
|
+
parity: { initial_to_desktop_word_ratio: words(desktop.text).length ? Number((words(initialText).length / words(desktop.text).length).toFixed(3)) : null, mobile_to_desktop_word_ratio: words(desktop.text).length ? Number((words(mobile.text).length / words(desktop.text).length).toFixed(3)) : null },
|
|
56
|
+
};
|
|
57
|
+
const item = envelope('render', data, { method: 'browser', source: 'playwright/chromium', raw: `${initial.body}\n${desktop.html}\n${mobile.html}` });
|
|
58
|
+
return observationRun(root, 'observe render', options.target, [item], { artifacts: { 'initial/response.html': initial.body, 'rendered/desktop-dom.html': desktop.html, 'rendered/desktop-text.txt': desktop.text, 'rendered/mobile-dom.html': mobile.html, 'rendered/mobile-text.txt': mobile.text, 'screenshots/desktop.png': desktop.screenshot, 'screenshots/mobile.png': mobile.screenshot } });
|
|
59
|
+
} finally { await browser.close(); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function observeIndex(root, options) {
|
|
63
|
+
if (!options.input) {
|
|
64
|
+
if (!options.target || !options.siteUrl) throw new Error('live index observation requires --target <URL> and --site-url <Search Console property>');
|
|
65
|
+
const token = options.accessToken || process.env.GSC_ACCESS_TOKEN;
|
|
66
|
+
if (!token) {
|
|
67
|
+
const item = envelope('index', { engine: 'google', url: options.target }, { method: 'live_api', source: 'Google Search Console URL Inspection API', state: 'not_evidenced', confidence: 'unknown', limitations: ['GSC_ACCESS_TOKEN is not configured.'] });
|
|
68
|
+
return observationRun(root, 'observe index', options.target, [item], { incomplete: ['Google index inspection unavailable: GSC_ACCESS_TOKEN is not configured.'] });
|
|
69
|
+
}
|
|
70
|
+
const response = await fetch('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ inspectionUrl: options.target, siteUrl: options.siteUrl, languageCode: options.locale || 'en-US' }) });
|
|
71
|
+
const raw = await response.text();
|
|
72
|
+
if (!response.ok) throw new Error(`Search Console returned ${response.status}: ${raw.slice(0, 300)}`);
|
|
73
|
+
const value = JSON.parse(raw).inspectionResult?.indexStatusResult || {};
|
|
74
|
+
const data = { engine: 'google', url: options.target, indexed: value.verdict === 'PASS', selected_canonical: value.googleCanonical || null, declared_canonical: value.userCanonical || null, last_crawl: value.lastCrawlTime || null, fetch_state: value.pageFetchState || null, raw_provider_result: value };
|
|
75
|
+
return observationRun(root, 'observe index', options.target, [envelope('index', data, { method: 'live_api', source: 'Google Search Console URL Inspection API', raw })], { rawInputs: { gsc_response: raw } });
|
|
76
|
+
}
|
|
77
|
+
const input = readInput(options.input);
|
|
78
|
+
const rows = Array.isArray(input.value) ? input.value : input.value.items || [input.value];
|
|
79
|
+
const observations = rows.map((row) => envelope('index', {
|
|
80
|
+
engine: row.engine || options.provider || 'unknown', url: row.url || row.inspectionUrl,
|
|
81
|
+
indexed: row.indexed ?? row.verdict === 'PASS', selected_canonical: row.selected_canonical || row.googleCanonical || null,
|
|
82
|
+
declared_canonical: row.declared_canonical || row.userCanonical || null, last_crawl: row.last_crawl || row.lastCrawlTime || null,
|
|
83
|
+
fetch_state: row.fetch_state || row.pageFetchState || null, raw_provider_result: row,
|
|
84
|
+
}, { method: 'owner_import', source: input.file, raw: JSON.stringify(row), confidence: 'confirmed' }));
|
|
85
|
+
return observationRun(root, 'observe index', input.file, observations, { rawInputs: { index_export: input.raw } });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function observeCitations(root, options) {
|
|
89
|
+
const input = readInput(options.input);
|
|
90
|
+
let rows = Array.isArray(input.value) ? input.value : input.value.observations || [input.value];
|
|
91
|
+
let method = 'owner_import';
|
|
92
|
+
if (options.endpoint) {
|
|
93
|
+
const prompts = input.value.prompts || (Array.isArray(input.value) ? input.value : []);
|
|
94
|
+
if (!prompts.length) throw new Error('citation runner input must contain a prompts array');
|
|
95
|
+
if (!/^https:\/\//.test(options.endpoint) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\//.test(options.endpoint)) throw new Error('citation adapter endpoint must use HTTPS (loopback HTTP is allowed for testing)');
|
|
96
|
+
const repeat = Number.isInteger(options.repeat) && options.repeat > 0 && options.repeat <= 20 ? options.repeat : 3;
|
|
97
|
+
rows = [];
|
|
98
|
+
for (const prompt of prompts) for (let runIndex = 1; runIndex <= repeat; runIndex++) {
|
|
99
|
+
const response = await fetch(options.endpoint, { method: 'POST', headers: { 'content-type': 'application/json', ...(options.accessToken ? { authorization: `Bearer ${options.accessToken}` } : {}) }, body: JSON.stringify({ prompt_id: prompt.prompt_id, prompt_text: prompt.prompt_text, locale: prompt.locale || input.value.locale || 'en-US', run_index: runIndex, runs_in_series: repeat }) });
|
|
100
|
+
const rawResponse = await response.text();
|
|
101
|
+
if (!response.ok) throw new Error(`citation adapter returned ${response.status}: ${rawResponse.slice(0, 300)}`);
|
|
102
|
+
const result = JSON.parse(rawResponse);
|
|
103
|
+
rows.push({ ...result, prompt_id: prompt.prompt_id, prompt_text: prompt.prompt_text, locale: prompt.locale || input.value.locale || 'en-US', provider: result.provider || options.provider || 'custom-adapter', product_mode: result.product_mode || 'api-adapter', run_index: runIndex, runs_in_series: repeat });
|
|
104
|
+
}
|
|
105
|
+
method = 'live_api';
|
|
106
|
+
}
|
|
107
|
+
const targetOrigin = options.target ? originOf(options.target) : input.value.target_origin || null;
|
|
108
|
+
const observations = [];
|
|
109
|
+
for (const row of rows) {
|
|
110
|
+
const reviews = canonicalReview(row, targetOrigin);
|
|
111
|
+
observations.push(envelope('citation', {
|
|
112
|
+
prompt_id: row.prompt_id, prompt_text: row.prompt_text, provider: row.provider || row.engine,
|
|
113
|
+
product_mode: row.product_mode || row.interface || 'unknown', locale: row.locale || 'unknown',
|
|
114
|
+
answer_text: row.answer_text || '', citations: reviews, run_index: row.run_index || 1,
|
|
115
|
+
property_cited: reviews.some((r) => r.first_party),
|
|
116
|
+
}, { method, source: options.endpoint || input.file, raw: JSON.stringify(row) }));
|
|
117
|
+
for (const review of reviews) observations.push(envelope('citation_review', review, {
|
|
118
|
+
method: review.reviewer ? 'human_review' : 'static_analysis', source: options.endpoint || input.file,
|
|
119
|
+
state: review.support_status === 'review_required' ? 'review_required' : 'observed',
|
|
120
|
+
confidence: review.reviewer ? 'confirmed' : 'low', raw: JSON.stringify(review),
|
|
121
|
+
limitations: review.reviewer ? [] : ['Material support requires a named human reviewer.'],
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
return observationRun(root, 'observe citations', input.file, observations, { rawInputs: { citation_results: input.raw } });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function observeLogs(root, options) {
|
|
128
|
+
const input = readInput(options.input);
|
|
129
|
+
const rows = Array.isArray(input.value) ? input.value : input.value.requests || [];
|
|
130
|
+
const ranges = input.value.provider_ranges || {};
|
|
131
|
+
const verify = (row) => {
|
|
132
|
+
const cidrs = ranges[row.user_agent] || [];
|
|
133
|
+
if (!row.source_ip || !cidrs.length) return { verified: false, method: null };
|
|
134
|
+
const family = net.isIP(row.source_ip);
|
|
135
|
+
if (!family) return { verified: false, method: 'invalid source IP' };
|
|
136
|
+
const block = new net.BlockList();
|
|
137
|
+
for (const cidr of cidrs) {
|
|
138
|
+
const [network, prefix] = cidr.split('/');
|
|
139
|
+
const rangeFamily = net.isIP(network);
|
|
140
|
+
if (rangeFamily === family && Number.isInteger(Number(prefix))) block.addSubnet(network, Number(prefix), family === 4 ? 'ipv4' : 'ipv6');
|
|
141
|
+
}
|
|
142
|
+
return { verified: block.check(row.source_ip, family === 4 ? 'ipv4' : 'ipv6'), method: `matched imported ${row.user_agent} CIDR set` };
|
|
143
|
+
};
|
|
144
|
+
const observations = rows.map((row) => {
|
|
145
|
+
const identity = verify(row);
|
|
146
|
+
return envelope('crawler_log', {
|
|
147
|
+
timestamp: row.timestamp, url: row.url, user_agent: row.user_agent, source_ip: row.source_ip,
|
|
148
|
+
status: row.status, bytes: row.bytes ?? null, latency_ms: row.latency_ms ?? null,
|
|
149
|
+
cache_status: row.cache_status ?? null, region: row.region ?? null,
|
|
150
|
+
identity_verified: identity.verified, verification_method: identity.method,
|
|
151
|
+
}, { method: 'owner_import', source: input.file, raw: JSON.stringify(row), confidence: identity.verified ? 'high' : 'low', limitations: identity.verified ? ['CIDR ranges were imported with the log evidence; their provider currency must be established by source metadata.'] : ['User-agent strings alone do not verify crawler identity.'] });
|
|
152
|
+
});
|
|
153
|
+
return observationRun(root, 'observe logs', input.file, observations, { rawInputs: { server_logs: input.raw }, incomplete: observations.some((o) => !o.data.identity_verified) ? ['Some crawler identities are not IP-verified.'] : [] });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function observePassages(root, options) {
|
|
157
|
+
const ctx = await buildContext(root, options);
|
|
158
|
+
if (!ctx.site) throw new Error('passages requires --target <dir|url>');
|
|
159
|
+
const observations = [];
|
|
160
|
+
for (const page of ctx.site.pages) {
|
|
161
|
+
const rawWords = words(page.text).length;
|
|
162
|
+
for (let i = 0; i < page.paragraphs.length; i++) {
|
|
163
|
+
const passage = page.paragraphs.slice(i, i + 3).join(' ');
|
|
164
|
+
const count = words(passage).length;
|
|
165
|
+
if (count < 30) continue;
|
|
166
|
+
const dependencies = [];
|
|
167
|
+
if (/\b(this|that|these|those|it|they|above|below|here)\b/i.test(passage.slice(0, 100))) dependencies.push('possible external referent');
|
|
168
|
+
observations.push(envelope('passage', { url: page.url, passage_index: i, text: passage, word_count: count, independently_extractable: count >= 100 && count <= 300 && dependencies.length === 0, dependencies, content_to_noise_ratio: page.rawHtml.length ? Number((page.text.length / page.rawHtml.length).toFixed(3)) : null }, { method: 'static_analysis', source: page.sourceFile || page.url, raw: passage, confidence: 'medium' }));
|
|
169
|
+
}
|
|
170
|
+
if (!page.paragraphs.length) observations.push(envelope('passage', { url: page.url, word_count: rawWords, independently_extractable: false, dependencies: ['no paragraph passages extracted'] }, { method: 'static_analysis', source: page.sourceFile || page.url, state: 'not_observed', confidence: 'confirmed' }));
|
|
171
|
+
}
|
|
172
|
+
return observationRun(root, 'observe passages', options.target, observations);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function observeConsensus(root, options) {
|
|
176
|
+
const ctx = await buildContext(root, options);
|
|
177
|
+
if (!ctx.site) throw new Error('consensus requires --target <dir|url>');
|
|
178
|
+
const sitemapRows = ctx.site.sitemaps.flatMap((s) => s.parsed?.urls || []);
|
|
179
|
+
const sitemapUrls = new Set(sitemapRows.map((u) => u.loc));
|
|
180
|
+
const observations = ctx.site.pages.map((page) => {
|
|
181
|
+
const declared = page.canonicals[0] || null;
|
|
182
|
+
const sitemapDate = sitemapRows.find((row) => row.loc === page.url)?.lastmod || null;
|
|
183
|
+
const visibleDate = page.metas['article:modified_time']?.[0] || page.metas['date.modified']?.[0] || page.metas['last-modified']?.[0] || null;
|
|
184
|
+
const signals = { final_url: page.url, html_canonical: declared, open_graph_url: page.ogUrl, sitemap_present: sitemapUrls.has(page.url), last_modified_header: page.headers['last-modified'] || null, sitemap_lastmod: sitemapDate, visible_or_meta_modified: visibleDate };
|
|
185
|
+
const urls = [page.url, declared, page.ogUrl].filter(Boolean);
|
|
186
|
+
const dates = [signals.last_modified_header, sitemapDate, visibleDate].filter(Boolean).map((value) => String(value).slice(0, 10));
|
|
187
|
+
return envelope('canonical_freshness', { url: page.url, signals, canonical_consensus: new Set(urls).size <= 1, date_consensus: dates.length >= 2 ? new Set(dates).size === 1 : null, engine_selected_canonical: null }, { method: 'static_analysis', source: page.sourceFile || page.url, raw: JSON.stringify(signals), confidence: 'high', limitations: ['Engine-selected canonical and content-difference date require external observations.'] });
|
|
188
|
+
});
|
|
189
|
+
return observationRun(root, 'observe consensus', options.target, observations);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function observePerformance(root, options) {
|
|
193
|
+
if (options.input) {
|
|
194
|
+
const input = readInput(options.input);
|
|
195
|
+
const rows = Array.isArray(input.value) ? input.value : [input.value];
|
|
196
|
+
const observations = rows.map((row) => {
|
|
197
|
+
const provider = row.provider || (row.lighthouseVersion || row.categories ? 'Lighthouse' : row.record ? 'CrUX' : 'unknown');
|
|
198
|
+
const evidenceType = /lighthouse/i.test(provider) ? 'lab' : /crux|chrome ux/i.test(provider) ? 'field' : 'imported_unknown';
|
|
199
|
+
return envelope('performance', { ...row, provider, evidence_type: evidenceType }, { method: 'owner_import', source: input.file, raw: JSON.stringify(row), limitations: evidenceType === 'imported_unknown' ? ['Performance evidence was not identified as field or lab data.'] : [] });
|
|
200
|
+
});
|
|
201
|
+
return observationRun(root, 'observe performance', input.file, observations, { rawInputs: { performance_export: input.raw } });
|
|
202
|
+
}
|
|
203
|
+
if (!options.target) throw new Error('performance requires --target <URL> or --input <json>');
|
|
204
|
+
const key = options.apiKey || process.env.CRUX_API_KEY;
|
|
205
|
+
if (!key) {
|
|
206
|
+
const item = envelope('performance', { url: options.target, provider: 'CrUX' }, { method: 'live_api', source: 'CrUX API', state: 'not_evidenced', confidence: 'unknown', limitations: ['CRUX_API_KEY is not configured.'] });
|
|
207
|
+
return observationRun(root, 'observe performance', options.target, [item], { incomplete: ['CrUX collection unavailable: CRUX_API_KEY is not configured.'] });
|
|
208
|
+
}
|
|
209
|
+
const response = await fetch(`https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${encodeURIComponent(key)}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: options.target }) });
|
|
210
|
+
const raw = await response.text();
|
|
211
|
+
if (!response.ok) throw new Error(`CrUX API returned ${response.status}: ${raw.slice(0, 300)}`);
|
|
212
|
+
const data = JSON.parse(raw);
|
|
213
|
+
return observationRun(root, 'observe performance', options.target, [envelope('performance', { ...data, provider: 'CrUX', evidence_type: 'field' }, { method: 'live_api', source: 'CrUX API', raw })], { rawInputs: { crux_response: raw } });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function observeCorroboration(root, options) {
|
|
217
|
+
const input = readInput(options.input);
|
|
218
|
+
const rows = Array.isArray(input.value) ? input.value : input.value.mentions || [];
|
|
219
|
+
const observations = rows.map((row) => envelope('corroboration', row, { method: 'owner_import', source: input.file, raw: JSON.stringify(row), confidence: row.independent === true ? 'high' : 'low', limitations: row.independent === true ? [] : ['Source independence is not established.'] }));
|
|
220
|
+
return observationRun(root, 'observe corroboration', input.file, observations, { rawInputs: { corroboration_export: input.raw } });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function observe(root, mode, options = {}) {
|
|
224
|
+
switch (mode) {
|
|
225
|
+
case 'render': return observeRender(root, options);
|
|
226
|
+
case 'index': return observeIndex(root, options);
|
|
227
|
+
case 'citations': return observeCitations(root, options);
|
|
228
|
+
case 'logs': return observeLogs(root, options);
|
|
229
|
+
case 'passages': return observePassages(root, options);
|
|
230
|
+
case 'consensus': return observeConsensus(root, options);
|
|
231
|
+
case 'performance': return observePerformance(root, options);
|
|
232
|
+
case 'corroboration': return observeCorroboration(root, options);
|
|
233
|
+
default: throw new Error('observe mode must be render, index, citations, logs, passages, consensus, performance, or corroboration');
|
|
234
|
+
}
|
|
235
|
+
}
|
package/src/evidence/run.js
CHANGED
|
@@ -61,7 +61,7 @@ export function createRun(root, { command, argv = [], target, locale = process.e
|
|
|
61
61
|
writeArtifact(relPath, data) {
|
|
62
62
|
const file = path.join(dir, relPath);
|
|
63
63
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
64
|
-
if (typeof data === 'string') fs.writeFileSync(file, data);
|
|
64
|
+
if (typeof data === 'string' || Buffer.isBuffer(data)) fs.writeFileSync(file, data);
|
|
65
65
|
else writeJson(file, data);
|
|
66
66
|
manifest.output_hashes[relPath] = sha256File(file);
|
|
67
67
|
return file;
|
package/src/installer/index.js
CHANGED
|
@@ -105,6 +105,8 @@ export function parseInstallerArgs(argv) {
|
|
|
105
105
|
help: false,
|
|
106
106
|
version: false,
|
|
107
107
|
failOnUpdate: false,
|
|
108
|
+
installMode: 'copy',
|
|
109
|
+
skillSelection: null,
|
|
108
110
|
_: [],
|
|
109
111
|
};
|
|
110
112
|
|
|
@@ -118,17 +120,29 @@ export function parseInstallerArgs(argv) {
|
|
|
118
120
|
else if (arg === '--dry-run') args.dryRun = true;
|
|
119
121
|
else if (arg === '--force') args.force = true;
|
|
120
122
|
else if (arg === '--json') args.json = true;
|
|
121
|
-
else if (arg === '--all') args.all = true;
|
|
123
|
+
else if (arg === '--all') { args.all = true; args.yes = true; }
|
|
122
124
|
else if (arg === '--help' || arg === '-h') args.help = true;
|
|
123
125
|
else if (arg === '--version' || arg === '-v') args.version = true;
|
|
124
|
-
else if (arg === '--project' || arg === '--local') args.scope = mergeScope(args.scope, 'project');
|
|
125
|
-
else if (arg === '--global' || arg === '--user') args.scope = mergeScope(args.scope, 'global');
|
|
126
|
+
else if (arg === '--project' || arg === '--local' || arg === '-p') args.scope = mergeScope(args.scope, 'project');
|
|
127
|
+
else if (arg === '--global' || arg === '--user' || arg === '-g') args.scope = mergeScope(args.scope, 'global');
|
|
128
|
+
else if (arg === '--copy') args.installMode = 'copy';
|
|
129
|
+
else if (arg === '--symlink') throw new CitableInstallerError('--symlink is not supported: Citable uses hashed managed copies for atomic validation and safe uninstall', EXIT_CODES.invalidArguments);
|
|
126
130
|
else if (arg === '--fail-on-update') args.failOnUpdate = true;
|
|
127
131
|
else if (arg === '--providers') {
|
|
128
132
|
if (i + 1 >= argv.length) throw new CitableInstallerError('--providers requires a value', EXIT_CODES.invalidArguments);
|
|
129
133
|
args.providersRaw = argv[++i];
|
|
130
134
|
} else if (arg.startsWith('--providers=')) {
|
|
131
135
|
args.providersRaw = arg.slice('--providers='.length);
|
|
136
|
+
} else if (arg === '--agent' || arg === '-a') {
|
|
137
|
+
if (i + 1 >= argv.length) throw new CitableInstallerError(`${arg} requires a value`, EXIT_CODES.invalidArguments);
|
|
138
|
+
args.providersRaw = [args.providersRaw, argv[++i]].filter(Boolean).join(',');
|
|
139
|
+
} else if (arg.startsWith('--agent=')) {
|
|
140
|
+
args.providersRaw = [args.providersRaw, arg.slice('--agent='.length)].filter(Boolean).join(',');
|
|
141
|
+
} else if (arg === '--skill' || arg === '-s') {
|
|
142
|
+
if (i + 1 >= argv.length) throw new CitableInstallerError(`${arg} requires a value`, EXIT_CODES.invalidArguments);
|
|
143
|
+
args.skillSelection = argv[++i];
|
|
144
|
+
} else if (arg.startsWith('--skill=')) {
|
|
145
|
+
args.skillSelection = arg.slice('--skill='.length);
|
|
132
146
|
} else if (arg === '--scope') {
|
|
133
147
|
if (i + 1 >= argv.length) throw new CitableInstallerError('--scope requires a value', EXIT_CODES.invalidArguments);
|
|
134
148
|
args.scope = mergeScope(args.scope, normalizeScopeValue(argv[++i]));
|
|
@@ -141,7 +155,16 @@ export function parseInstallerArgs(argv) {
|
|
|
141
155
|
}
|
|
142
156
|
}
|
|
143
157
|
|
|
144
|
-
if (args.
|
|
158
|
+
if (args.skillSelection !== null && !['citable', '*'].includes(String(args.skillSelection).trim().toLowerCase())) {
|
|
159
|
+
throw new CitableInstallerError(`this package contains only the citable skill; unsupported --skill value: ${args.skillSelection}`, EXIT_CODES.invalidArguments);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (args.all) {
|
|
163
|
+
if (args.providersRaw !== null) throw new CitableInstallerError('--all cannot be combined with --providers or --agent', EXIT_CODES.invalidArguments);
|
|
164
|
+
args.providerSelection = { kind: 'all', providers: PROVIDER_IDS, unknown: [] };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (args.providersRaw !== null && !args.providerSelection) {
|
|
145
168
|
const parsed = parseProviderList(args.providersRaw);
|
|
146
169
|
if (parsed.unknown.length) {
|
|
147
170
|
const supported = PROVIDER_IDS.join(', ');
|
|
@@ -1102,12 +1125,17 @@ Install examples
|
|
|
1102
1125
|
npx @nebulacomponents/citable install
|
|
1103
1126
|
npx @nebulacomponents/citable install --yes
|
|
1104
1127
|
npx @nebulacomponents/citable install --providers=claude,codex,cursor --project --yes
|
|
1128
|
+
npx @nebulacomponents/citable install --agent claude-code --agent codex --skill citable --copy -p -y
|
|
1105
1129
|
npx @nebulacomponents/citable install --providers=all --global --dry-run
|
|
1106
1130
|
|
|
1107
1131
|
Options
|
|
1108
|
-
--providers=<list> Provider ids, detected, or
|
|
1109
|
-
--
|
|
1110
|
-
--
|
|
1132
|
+
--providers=<list> Provider ids, detected, all, or *
|
|
1133
|
+
--agent=<id>, -a <id> Repeatable provider alias compatible with skills CLI
|
|
1134
|
+
--skill=<name>, -s <name> Select citable or * (this package contains one skill)
|
|
1135
|
+
--all Select every provider and confirm non-interactively
|
|
1136
|
+
--copy Use managed copies (default and only install mode)
|
|
1137
|
+
--project, --local, -p Install/check project-local skill locations
|
|
1138
|
+
--global, --user, -g Install/check user-global skill locations
|
|
1111
1139
|
--scope=<project|global> Scope equivalent
|
|
1112
1140
|
--yes, -y Confirm non-interactively
|
|
1113
1141
|
--dry-run Preview filesystem changes without mutation
|
|
@@ -130,7 +130,7 @@ export function parseProviderList(value) {
|
|
|
130
130
|
if (!raw) return { kind: 'explicit', providers: [], unknown: [] };
|
|
131
131
|
const lowered = raw.toLowerCase();
|
|
132
132
|
if (lowered === 'detected') return { kind: 'detected', providers: [], unknown: [] };
|
|
133
|
-
if (lowered === 'all') return { kind: 'all', providers: PROVIDER_IDS, unknown: [] };
|
|
133
|
+
if (lowered === 'all' || lowered === '*') return { kind: 'all', providers: PROVIDER_IDS, unknown: [] };
|
|
134
134
|
|
|
135
135
|
const providers = [];
|
|
136
136
|
const unknown = [];
|