@ludi-uni/ludi-agent-kit 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.
- package/AGENTS.md +55 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/adapters/codex/README.md +24 -0
- package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
- package/adapters/pi/README.md +88 -0
- package/adapters/pi/browser/agent-browser.mjs +193 -0
- package/adapters/pi/lib/invoke.mjs +55 -0
- package/adapters/pi/lib/list-models.mjs +29 -0
- package/adapters/pi/lib/settings-proposal.mjs +34 -0
- package/adapters/pi/lib/subagent.mjs +175 -0
- package/adapters/pi/loop-guard/index.js +51 -0
- package/adapters/pi/maintenance-policy.json +36 -0
- package/adapters/pi/mcp.template.json +4 -0
- package/adapters/pi/model-catalog.json +97 -0
- package/adapters/pi/models.json +13 -0
- package/adapters/pi/models.local.example.json +14 -0
- package/adapters/pi/orchestrator-ext/command.mjs +14 -0
- package/adapters/pi/orchestrator-ext/index.js +150 -0
- package/adapters/pi/settings.template.json +7 -0
- package/adapters/pi/shell-gate/index.js +70 -0
- package/adapters/pi/sync-pi.ps1 +137 -0
- package/agents/README.md +26 -0
- package/agents/browser.md +64 -0
- package/agents/coder.md +31 -0
- package/agents/orchestrator.md +37 -0
- package/agents/reviewer.md +32 -0
- package/agents/scout.md +35 -0
- package/agents/tester.md +28 -0
- package/agents/visual.md +28 -0
- package/context-pack/SPEC.md +101 -0
- package/context-pack/context-pack.schema.json +79 -0
- package/context-pack/examples/example-fix.md +44 -0
- package/docs/architecture.md +55 -0
- package/docs/migration-from-codex-setting.md +44 -0
- package/docs/model-maintenance.md +401 -0
- package/docs/orchestrator.md +155 -0
- package/docs/phase2-report.md +39 -0
- package/docs/roadmap.md +27 -0
- package/docs/third-party.md +15 -0
- package/lib/agents.mjs +79 -0
- package/lib/context-pack.mjs +215 -0
- package/lib/job.mjs +312 -0
- package/lib/language-policy.mjs +27 -0
- package/lib/maintenance-exec.mjs +377 -0
- package/lib/maintenance-runner.mjs +266 -0
- package/lib/maintenance.mjs +422 -0
- package/lib/normalize.mjs +101 -0
- package/lib/observe/differ.mjs +185 -0
- package/lib/observe/observation.mjs +147 -0
- package/lib/observe/observers.mjs +134 -0
- package/lib/observe/sources.mjs +154 -0
- package/lib/orchestrator/activity.mjs +249 -0
- package/lib/orchestrator/api.mjs +151 -0
- package/lib/orchestrator/contract.mjs +68 -0
- package/lib/orchestrator/escalation.mjs +84 -0
- package/lib/orchestrator/evaluator.mjs +92 -0
- package/lib/orchestrator/failures.mjs +88 -0
- package/lib/orchestrator/health.mjs +53 -0
- package/lib/orchestrator/orchestrator.mjs +483 -0
- package/lib/orchestrator/permissions.mjs +64 -0
- package/lib/orchestrator/planner.mjs +194 -0
- package/lib/orchestrator/policy.mjs +134 -0
- package/lib/orchestrator/router.mjs +45 -0
- package/lib/orchestrator/runner.mjs +278 -0
- package/lib/orchestrator/shell-policy.mjs +52 -0
- package/lib/orchestrator/store.mjs +581 -0
- package/lib/orchestrator/task-store.mjs +79 -0
- package/lib/orchestrator/turn-budget.mjs +63 -0
- package/lib/orchestrator/worktree.mjs +72 -0
- package/lib/pipeline.mjs +279 -0
- package/lib/registry.mjs +63 -0
- package/lib/resolve.mjs +35 -0
- package/lib/routing.mjs +137 -0
- package/lib/telemetry.mjs +222 -0
- package/mcp/README.md +11 -0
- package/mcp/servers.json +13 -0
- package/orchestration/decision-policy.json +66 -0
- package/package.json +56 -0
- package/routing/README.md +24 -0
- package/routing/routing.json +81 -0
- package/routing/routing.schema.json +66 -0
- package/rules/README.md +10 -0
- package/rules/common.md +52 -0
- package/rules/loop-prevention.md +15 -0
- package/rules/repo-local.md +6 -0
- package/scripts/check-environment.ps1 +22 -0
- package/scripts/context-pack.mjs +17 -0
- package/scripts/e2e-investigate-repro.mjs +66 -0
- package/scripts/model-maintenance-job.mjs +59 -0
- package/scripts/observe-models.mjs +97 -0
- package/scripts/orchestrate.mjs +137 -0
- package/scripts/reevaluate-models.mjs +95 -0
- package/scripts/report-model-maintenance.mjs +70 -0
- package/scripts/resolve-capabilities.mjs +39 -0
- package/scripts/run-pipeline.mjs +56 -0
- package/scripts/sync-agents-md.ps1 +10 -0
- package/scripts/validate.mjs +71 -0
- package/skills/README.md +14 -0
- package/skills/pi-workflow/SKILL.md +26 -0
- package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
- package/skills/pi-workflow/references/research.md +14 -0
- package/skills/pi-workflow/references/review.md +11 -0
- package/skills/pi-workflow/references/visual-work.md +14 -0
- package/skills/project-management/SKILL.md +106 -0
- package/skills/project-management/references/operations.md +52 -0
- package/skills/visual-verification/SKILL.md +88 -0
- package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
- package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
- package/skills/visual-verification/scripts/common.ps1 +387 -0
- package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
- package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
- package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
- package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
- package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
- package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
- package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
- package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
- package/skills/visual-verification/scripts/record-av.ps1 +102 -0
- package/skills/visual-verification/scripts/record.ps1 +72 -0
- package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
- package/skills/visual-verification/scripts/waveform.ps1 +450 -0
- package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
- package/tests/activity.test.mjs +252 -0
- package/tests/attempt-budget.test.mjs +102 -0
- package/tests/browser.test.mjs +121 -0
- package/tests/context-pack.test.mjs +98 -0
- package/tests/dirty-gate.test.mjs +211 -0
- package/tests/e2e-browser.mjs +66 -0
- package/tests/e2e-real-orchestrator-resume.mjs +101 -0
- package/tests/e2e-real-orchestrator.mjs +41 -0
- package/tests/e2e-real-pi.mjs +27 -0
- package/tests/e2e-real-tool-orchestrator.mjs +66 -0
- package/tests/fixtures/browser-page/index.html +20 -0
- package/tests/fixtures/maintenance/availability.txt +5 -0
- package/tests/fixtures/maintenance/catalog.json +74 -0
- package/tests/fixtures/maintenance/events.json +13 -0
- package/tests/fixtures/math-repo/README.md +3 -0
- package/tests/fixtures/math-repo/package.json +7 -0
- package/tests/fixtures/math-repo/src/math.js +11 -0
- package/tests/fixtures/math-repo/test/math.test.js +7 -0
- package/tests/fixtures/observe/announcements.json +8 -0
- package/tests/fixtures/orch-concurrent-child.mjs +44 -0
- package/tests/fixtures/orch-persist-child.mjs +61 -0
- package/tests/job.test.mjs +230 -0
- package/tests/kit.test.mjs +79 -0
- package/tests/language-policy.test.mjs +93 -0
- package/tests/loop-guard.test.mjs +60 -0
- package/tests/maintenance-exec.test.mjs +218 -0
- package/tests/maintenance-runner.test.mjs +222 -0
- package/tests/maintenance.test.mjs +195 -0
- package/tests/observe.test.mjs +283 -0
- package/tests/observer-registry.test.mjs +157 -0
- package/tests/orchestrator-cleanup.test.mjs +358 -0
- package/tests/orchestrator-command.test.mjs +14 -0
- package/tests/orchestrator-persist.test.mjs +375 -0
- package/tests/orchestrator-tools.test.mjs +215 -0
- package/tests/orchestrator.test.mjs +396 -0
- package/tests/package.test.mjs +37 -0
- package/tests/pipeline.test.mjs +239 -0
- package/tests/planner-classification.test.mjs +81 -0
- package/tests/planner-split.test.mjs +67 -0
- package/tests/qoder-observer.test.mjs +266 -0
- package/tests/reassign-progression.test.mjs +104 -0
- package/tests/retry-escalation.test.mjs +120 -0
- package/tests/routing.test.mjs +110 -0
- package/tests/sqlite-concurrency.test.mjs +178 -0
- package/tests/task-global-e2e.test.mjs +63 -0
- package/tests/task-global-failed.test.mjs +134 -0
- package/tests/telemetry.test.mjs +173 -0
- package/tests/test-sync-pi.ps1 +56 -0
- package/tests/turn-budget.test.mjs +106 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Catalog differ (Phase 4): compare stored observations against model-catalog.json
|
|
2
|
+
// and produce catalog-diff + catalog-proposal documents. Proposals only — the real
|
|
3
|
+
// catalog is never written. removed/deprecated proposals require >=2 independent
|
|
4
|
+
// sources or one high-trust source; a lone availability listing absence is never
|
|
5
|
+
// enough to propose removal.
|
|
6
|
+
import { TRUST_LEVELS } from './observation.mjs';
|
|
7
|
+
|
|
8
|
+
const TRUST_RANK = Object.fromEntries(TRUST_LEVELS.map((t, i) => [t, TRUST_LEVELS.length - i]));
|
|
9
|
+
const HIGH_TRUST = new Set(['manual_verified', 'provider_api']);
|
|
10
|
+
// Fields whose conflicting values across sources are "major" — never auto-resolved.
|
|
11
|
+
const MAJOR_FIELDS = new Set(['status', 'free', 'inputPricePer1M', 'outputPricePer1M']);
|
|
12
|
+
|
|
13
|
+
// observation field -> catalog field path. Returns {path, toCatalog(value)} pairs.
|
|
14
|
+
const FIELD_MAP = {
|
|
15
|
+
status: { get: e => e.status, set: 'status' },
|
|
16
|
+
free: { get: e => e.cost?.free === true || ((e.cost?.usdPerMInput ?? 1) === 0 && (e.cost?.usdPerMOutput ?? 1) === 0), set: 'cost.free' },
|
|
17
|
+
inputPricePer1M: { get: e => e.cost?.usdPerMInput ?? null, set: 'cost.usdPerMInput' },
|
|
18
|
+
outputPricePer1M: { get: e => e.cost?.usdPerMOutput ?? null, set: 'cost.usdPerMOutput' },
|
|
19
|
+
contextK: { get: e => e.contextK ?? null, set: 'contextK' },
|
|
20
|
+
toolUse: { get: e => e.toolUse ?? null, set: 'toolUse' },
|
|
21
|
+
vision: { get: e => e.vision ?? null, set: 'vision' },
|
|
22
|
+
availability: { get: () => null, set: null }, // availability maps to status inference, not a stored field
|
|
23
|
+
freeUntil: { get: e => e.freeUntil ?? null, set: 'freeUntil' },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Latest observation per (model, field), resolved: newer > trust > confidence. Major ties -> conflict. */
|
|
27
|
+
export function resolveObservations(observations) {
|
|
28
|
+
const groups = new Map(); // key -> [obs]
|
|
29
|
+
for (const o of observations) {
|
|
30
|
+
if (o._corrupt) continue;
|
|
31
|
+
for (const [field, value] of Object.entries(o.changes ?? {})) {
|
|
32
|
+
if (value === null || value === undefined) continue;
|
|
33
|
+
const key = `${o.provider}/${o.model}::${field}`;
|
|
34
|
+
(groups.get(key) ?? groups.set(key, []).get(key)).push({ o, field, value });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const resolved = new Map(); // key -> {winner, conflict}
|
|
38
|
+
for (const [key, items] of groups) {
|
|
39
|
+
const sorted = items.slice().sort((a, b) =>
|
|
40
|
+
b.o.observedAt.localeCompare(a.o.observedAt) ||
|
|
41
|
+
(TRUST_RANK[b.o.source?.trust ?? 'unknown'] - TRUST_RANK[a.o.source?.trust ?? 'unknown']) ||
|
|
42
|
+
(b.o.confidence ?? 0) - (a.o.confidence ?? 0));
|
|
43
|
+
const [best, second] = sorted;
|
|
44
|
+
const conflict = second && best.value !== second.value &&
|
|
45
|
+
best.o.observedAt === second.o.observedAt &&
|
|
46
|
+
(TRUST_RANK[best.o.source?.trust] === TRUST_RANK[second.o.source?.trust]) &&
|
|
47
|
+
MAJOR_FIELDS.has(best.field);
|
|
48
|
+
resolved.set(key, { winner: best, conflict: conflict ? { rival: second } : null, contenders: items.length });
|
|
49
|
+
}
|
|
50
|
+
return resolved;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Diff resolved observations against the catalog. Returns a catalog-diff document. */
|
|
54
|
+
export function diffCatalog(catalog, observations) {
|
|
55
|
+
const resolved = resolveObservations(observations);
|
|
56
|
+
const catalogByKey = new Map((catalog?.models ?? []).map(m => [`${m.provider}/${m.model}`, m]));
|
|
57
|
+
const diffs = [];
|
|
58
|
+
for (const [key, r] of resolved) {
|
|
59
|
+
const { winner, conflict } = r;
|
|
60
|
+
const { provider, model } = winner.o;
|
|
61
|
+
const entry = catalogByKey.get(`${provider}/${model}`);
|
|
62
|
+
const map = FIELD_MAP[winner.field];
|
|
63
|
+
const currentValue = entry ? map.get(entry) : null;
|
|
64
|
+
const base = {
|
|
65
|
+
model: `${provider}/${model}`, field: winner.field,
|
|
66
|
+
currentValue, observedValue: winner.value,
|
|
67
|
+
source: { type: winner.o.source?.type, trust: winner.o.source?.trust, label: winner.o.source?.label },
|
|
68
|
+
confidence: winner.o.confidence ?? null, observedAt: winner.o.observedAt,
|
|
69
|
+
knownModel: Boolean(entry),
|
|
70
|
+
};
|
|
71
|
+
if (conflict) diffs.push({ ...base, status: 'conflict', conflict: { rival: { value: conflict.rival.value, source: conflict.rival.o.source, observedAt: conflict.rival.o.observedAt } } });
|
|
72
|
+
else if (currentValue === winner.value) diffs.push({ ...base, status: 'unchanged' });
|
|
73
|
+
else diffs.push({ ...base, status: 'proposed' });
|
|
74
|
+
}
|
|
75
|
+
return { version: 1, kind: 'catalog-diff', generatedAt: new Date().toISOString(), diffs };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a catalog proposal from a diff. Rules:
|
|
80
|
+
* - 'unchanged' -> ignored listing
|
|
81
|
+
* - 'conflict' -> recorded, never applied
|
|
82
|
+
* - status removed/deprecated -> requires >=2 independent sources OR one high-trust
|
|
83
|
+
* source; availability-only evidence never proposes 'removed'.
|
|
84
|
+
* - unknown model + observed status/fields -> addition proposal
|
|
85
|
+
*/
|
|
86
|
+
export function buildCatalogProposal(catalog, diff, observations) {
|
|
87
|
+
const catalogByKey = new Map((catalog?.models ?? []).map(m => [`${m.provider}/${m.model}`, m]));
|
|
88
|
+
const proposal = { version: 1, kind: 'catalog-proposal', generatedAt: new Date().toISOString(), additions: [], updates: [], deprecations: [], conflicts: [], ignored: [], freshness: {} };
|
|
89
|
+
|
|
90
|
+
const sourcesFor = (modelKey, field) => new Set(observations
|
|
91
|
+
.filter(o => `${o.provider}/${o.model}` === modelKey && o.changes?.[field] !== null && o.changes?.[field] !== undefined)
|
|
92
|
+
.map(o => o.source?.label ?? o.source?.type ?? 'unknown'));
|
|
93
|
+
|
|
94
|
+
for (const d of diff.diffs) {
|
|
95
|
+
if (d.status === 'unchanged') { proposal.ignored.push({ model: d.model, field: d.field, reason: 'same value observed' }); continue; }
|
|
96
|
+
if (d.status === 'conflict') { proposal.conflicts.push(d); continue; }
|
|
97
|
+
const entry = catalogByKey.get(d.model);
|
|
98
|
+
if (d.field === 'availability') {
|
|
99
|
+
// Listing absence is evidence, never a verdict.
|
|
100
|
+
if (d.observedValue === 'unavailable') proposal.ignored.push({ model: d.model, field: d.field, reason: 'absent from a listing is not removal; recorded as evidence only' });
|
|
101
|
+
else proposal.ignored.push({ model: d.model, field: d.field, reason: 'availability confirmed; no catalog field' });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!entry) {
|
|
105
|
+
if (d.field === 'status' && d.observedValue === 'active') {
|
|
106
|
+
proposal.additions.push({ model: d.model, provider: d.model.split('/')[0], modelId: d.model.split('/').slice(1).join('/'), status: 'active', source: d.source, confidence: d.confidence, note: 'new model observed; fill scores/cost before adopting' });
|
|
107
|
+
} else {
|
|
108
|
+
proposal.ignored.push({ model: d.model, field: d.field, reason: 'model not in catalog; field-level update skipped until the model is added' });
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (d.field === 'status' && (d.observedValue === 'removed' || d.observedValue === 'deprecated')) {
|
|
113
|
+
const srcs = sourcesFor(d.model, 'status');
|
|
114
|
+
const highTrust = [...srcs].some(s => observations.some(o => `${o.provider}/${o.model}` === d.model && (o.source?.label ?? o.source?.type) === s && HIGH_TRUST.has(o.source?.trust)));
|
|
115
|
+
if (srcs.size < 2 && !highTrust) {
|
|
116
|
+
proposal.ignored.push({ model: d.model, field: d.field, observedValue: d.observedValue, reason: `${d.observedValue} requires >=2 independent sources or one high-trust source; got ${srcs.size}` });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
proposal.deprecations.push({ model: d.model, field: d.field, currentValue: d.currentValue, observedValue: d.observedValue, source: d.source, evidenceSources: [...srcs], confidence: d.confidence });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
proposal.updates.push({ model: d.model, field: FIELD_MAP[d.field]?.set ?? d.field, currentValue: d.currentValue, observedValue: d.observedValue, source: d.source, confidence: d.confidence });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const ats = observations.filter(o => o.observedAt).map(o => o.observedAt).sort();
|
|
126
|
+
proposal.freshness = { observations: observations.length, oldest: ats[0] ?? null, newest: ats[ats.length - 1] ?? null, diffs: diff.diffs.length, proposed: diff.diffs.filter(d => d.status === 'proposed').length };
|
|
127
|
+
return proposal;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Translate a catalog proposal into maintenance events for the hypothetical preview.
|
|
132
|
+
* A proposed field change is a *condition change* the engine should re-evaluate —
|
|
133
|
+
* free->paid becomes 'free-campaign-ended', status changes map directly, price/context
|
|
134
|
+
* changes become 'price-changed' so the backend is re-scored.
|
|
135
|
+
*/
|
|
136
|
+
export function proposalToEvents(proposal) {
|
|
137
|
+
const events = [];
|
|
138
|
+
const push = (type, model, note) => {
|
|
139
|
+
const [provider, ...rest] = model.split('/');
|
|
140
|
+
events.push({ type, provider, model: rest.join('/'), asOf: proposal.generatedAt, note });
|
|
141
|
+
};
|
|
142
|
+
for (const d of proposal.deprecations ?? []) push(d.observedValue === 'removed' ? 'removed' : 'deprecated', d.model, `observed ${d.observedValue}`);
|
|
143
|
+
for (const u of proposal.updates ?? []) {
|
|
144
|
+
if (u.field === 'cost.free' && u.observedValue === false) push('free-campaign-ended', u.model, 'free ended per observation');
|
|
145
|
+
else if (u.field === 'cost.free' && u.observedValue === true) push('free-campaign-started', u.model, 'free (re)started per observation');
|
|
146
|
+
else if (u.field === 'status' && (u.observedValue === 'deprecated' || u.observedValue === 'removed')) push(u.observedValue, u.model, 'observed status change');
|
|
147
|
+
else if (u.field?.startsWith('cost.') || u.field === 'contextK' || u.field === 'toolUse') push('price-changed', u.model, `${u.field} ${JSON.stringify(u.currentValue)} -> ${JSON.stringify(u.observedValue)}`);
|
|
148
|
+
}
|
|
149
|
+
for (const a of proposal.additions ?? []) push('model-added', a.model, 'new model observed');
|
|
150
|
+
return events;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Apply a proposal to a catalog in memory — the "hypothetical catalog" for the
|
|
155
|
+
// maintenance preview. Returns a NEW catalog object; the input is not mutated.
|
|
156
|
+
*/
|
|
157
|
+
export function applyProposalToCatalog(catalog, proposal) {
|
|
158
|
+
const next = structuredClone(catalog);
|
|
159
|
+
const byKey = new Map(next.models.map(m => [`${m.provider}/${m.model}`, m]));
|
|
160
|
+
const setPath = (obj, path, value) => {
|
|
161
|
+
const parts = path.split('.');
|
|
162
|
+
let cur = obj;
|
|
163
|
+
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]] ??= {};
|
|
164
|
+
cur[parts[parts.length - 1]] = value;
|
|
165
|
+
};
|
|
166
|
+
for (const u of proposal.updates ?? []) {
|
|
167
|
+
const entry = byKey.get(u.model);
|
|
168
|
+
if (entry) setPath(entry, u.field, u.observedValue);
|
|
169
|
+
}
|
|
170
|
+
for (const d of proposal.deprecations ?? []) {
|
|
171
|
+
const entry = byKey.get(d.model);
|
|
172
|
+
if (entry) entry.status = d.observedValue;
|
|
173
|
+
}
|
|
174
|
+
for (const a of proposal.additions ?? []) {
|
|
175
|
+
const key = a.model;
|
|
176
|
+
if (!byKey.has(key)) {
|
|
177
|
+
const entry = { provider: a.provider, model: a.modelId, status: 'active', cost: null, contextK: null, vision: false, toolUse: 'basic', scores: { coding: null, reasoning: null, speed: null }, notes: 'added by catalog proposal; scores/cost unknown' };
|
|
178
|
+
next.models.push(entry);
|
|
179
|
+
byKey.set(key, entry);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
next.updatedAt = proposal.generatedAt;
|
|
183
|
+
next._hypothetical = true;
|
|
184
|
+
return next;
|
|
185
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Observation layer (Phase 4): normalized provider/model observations from external
|
|
2
|
+
// sources, kept separate from the core maintenance engine. Sources produce
|
|
3
|
+
// observations; this module validates, hashes, dedupes and resolves them — never
|
|
4
|
+
// writes model-catalog.json, routing.json or anything outside out/.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const SOURCE_TYPES = new Set(['manual', 'api', 'web', 'cli']);
|
|
10
|
+
export const TRUST_LEVELS = ['manual_verified', 'provider_api', 'provider_local_cache', 'provider_web', 'pi_cli', 'third_party', 'unknown'];
|
|
11
|
+
const TRUST_RANK = Object.fromEntries(TRUST_LEVELS.map((t, i) => [t, TRUST_LEVELS.length - i])); // manual_verified highest
|
|
12
|
+
|
|
13
|
+
// Fields an observation may report. null = not observed (distinct from false/0).
|
|
14
|
+
export const OBSERVED_FIELDS = ['availability', 'status', 'free', 'freeUntil', 'inputPricePer1M', 'outputPricePer1M', 'contextK', 'toolUse', 'vision'];
|
|
15
|
+
const STATUS_VALUES = new Set(['active', 'free-campaign', 'deprecated', 'removed']);
|
|
16
|
+
const AVAIL_VALUES = new Set(['available', 'unavailable', 'unknown']);
|
|
17
|
+
const TOOL_VALUES = new Set(['good', 'basic', 'poor', 'none']);
|
|
18
|
+
|
|
19
|
+
export function validateObservation(o) {
|
|
20
|
+
const errors = [];
|
|
21
|
+
if (!o || typeof o !== 'object' || Array.isArray(o)) return ['observation: must be an object'];
|
|
22
|
+
if (typeof o.provider !== 'string' || !o.provider.trim()) errors.push('observation.provider required');
|
|
23
|
+
if (typeof o.model !== 'string' || !o.model.trim()) errors.push('observation.model required');
|
|
24
|
+
if (typeof o.observedAt !== 'string' || Number.isNaN(Date.parse(o.observedAt))) errors.push('observation.observedAt must be an ISO timestamp');
|
|
25
|
+
const src = o.source;
|
|
26
|
+
if (!src || typeof src !== 'object') errors.push('observation.source required');
|
|
27
|
+
else {
|
|
28
|
+
if (!SOURCE_TYPES.has(src.type)) errors.push(`observation.source.type must be one of ${[...SOURCE_TYPES].join('|')}`);
|
|
29
|
+
if (src.trust !== undefined && !TRUST_LEVELS.includes(src.trust)) errors.push(`observation.source.trust must be one of ${TRUST_LEVELS.join('|')}`);
|
|
30
|
+
}
|
|
31
|
+
const ch = o.changes;
|
|
32
|
+
if (!ch || typeof ch !== 'object' || Array.isArray(ch)) errors.push('observation.changes must be an object');
|
|
33
|
+
else {
|
|
34
|
+
for (const k of Object.keys(ch)) if (!OBSERVED_FIELDS.includes(k)) errors.push(`observation.changes.${k} is not a known field`);
|
|
35
|
+
if (ch.status !== undefined && ch.status !== null && !STATUS_VALUES.has(ch.status)) errors.push('observation.changes.status invalid');
|
|
36
|
+
if (ch.availability !== undefined && ch.availability !== null && !AVAIL_VALUES.has(ch.availability)) errors.push('observation.changes.availability invalid');
|
|
37
|
+
if (ch.toolUse !== undefined && ch.toolUse !== null && !TOOL_VALUES.has(ch.toolUse)) errors.push('observation.changes.toolUse invalid');
|
|
38
|
+
for (const k of ['free', 'vision']) if (ch[k] !== undefined && ch[k] !== null && typeof ch[k] !== 'boolean') errors.push(`observation.changes.${k} must be boolean or null`);
|
|
39
|
+
for (const k of ['inputPricePer1M', 'outputPricePer1M', 'contextK']) if (ch[k] !== undefined && ch[k] !== null && (typeof ch[k] !== 'number' || ch[k] < 0)) errors.push(`observation.changes.${k} must be a number >= 0 or null`);
|
|
40
|
+
if (ch.freeUntil !== undefined && ch.freeUntil !== null && typeof ch.freeUntil !== 'string') errors.push('observation.changes.freeUntil must be a string or null');
|
|
41
|
+
}
|
|
42
|
+
if (o.confidence !== undefined && (typeof o.confidence !== 'number' || o.confidence < 0 || o.confidence > 1)) errors.push('observation.confidence must be 0-1');
|
|
43
|
+
if (o.evidence !== undefined && !Array.isArray(o.evidence)) errors.push('observation.evidence must be an array');
|
|
44
|
+
return errors;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Deterministic identity: provider+model+the exact observed change set. Same facts -> same hash. */
|
|
48
|
+
export function observationHash(o) {
|
|
49
|
+
const stable = {
|
|
50
|
+
p: o.provider, m: o.model,
|
|
51
|
+
c: OBSERVED_FIELDS.filter(f => o.changes?.[f] !== undefined && o.changes[f] !== null).sort().map(f => [f, o.changes[f]]),
|
|
52
|
+
};
|
|
53
|
+
return createHash('sha256').update(JSON.stringify(stable)).digest('hex').slice(0, 16);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Fill defaults and stamp id/hash. Returns a new object. */
|
|
57
|
+
export function normalizeObservation(o, { now } = {}) {
|
|
58
|
+
const n = structuredClone(o);
|
|
59
|
+
n.observedAt = n.observedAt ?? now ?? new Date().toISOString();
|
|
60
|
+
n.confidence = typeof n.confidence === 'number' ? n.confidence : 0.5;
|
|
61
|
+
n.evidence = Array.isArray(n.evidence) ? n.evidence : [];
|
|
62
|
+
n.source = { url: null, label: null, trust: 'unknown', ...n.source };
|
|
63
|
+
n.changes = Object.fromEntries(OBSERVED_FIELDS.map(f => [f, n.changes?.[f] ?? null]));
|
|
64
|
+
// Environment tag: 'production' (default) vs 'test'. Fixture/test observations are
|
|
65
|
+
// marked by their source adapter (sourceFixture) or an explicit environment; the
|
|
66
|
+
// production maintenance pipeline excludes them so a fixture replay can never
|
|
67
|
+
// contaminate the real catalog/routing evaluation.
|
|
68
|
+
n.environment = o.environment === 'test' || o.sourceFixture === true || n.source?.fixture === true ? 'test' : 'production';
|
|
69
|
+
n.sourceFixture = n.environment === 'test';
|
|
70
|
+
n.id = observationHash(n);
|
|
71
|
+
return n;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Observation store — append-only JSONL under out/ (gitignored). No credentials,
|
|
76
|
+
// no prompt text: only normalized observations + validation results.
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export function loadObservationStore(path) {
|
|
80
|
+
if (!existsSync(path)) return { path, observations: [] };
|
|
81
|
+
const observations = [];
|
|
82
|
+
for (const [i, line] of readFileSync(path, 'utf8').split(/\r?\n/).entries()) {
|
|
83
|
+
if (!line.trim()) continue;
|
|
84
|
+
try { observations.push(JSON.parse(line)); }
|
|
85
|
+
catch { observations.push({ _corrupt: true, line: i + 1 }); }
|
|
86
|
+
}
|
|
87
|
+
return { path, observations };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Production view: drop test/fixture observations so a fixture replay can never
|
|
92
|
+
* drive a real catalog/routing proposal. An observation is test when
|
|
93
|
+
* environment==='test', sourceFixture===true, or source.fixture===true. Anything
|
|
94
|
+
* without a marker is treated as production (back-compat with pre-tag records).
|
|
95
|
+
*/
|
|
96
|
+
export function productionObservations(observations) {
|
|
97
|
+
return (observations ?? []).filter(o =>
|
|
98
|
+
o._corrupt || (o.environment !== 'test' && o.sourceFixture !== true && o.source?.fixture !== true));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ingest observations into the store. Dedupe rules:
|
|
103
|
+
* - identical hash already stored -> 'duplicate'
|
|
104
|
+
* - same key+fields with older timestamp than a stored observation -> 'stale'
|
|
105
|
+
* - otherwise appended -> 'stored'
|
|
106
|
+
* Returns { stored, duplicates, stale, invalid }.
|
|
107
|
+
*/
|
|
108
|
+
export function ingestObservations(storePath, rawObservations, { now = () => new Date().toISOString() } = {}) {
|
|
109
|
+
const store = loadObservationStore(storePath);
|
|
110
|
+
const byHash = new Map(store.observations.filter(o => o.id).map(o => [o.id, o]));
|
|
111
|
+
// Newest observedAt per (provider/model, field). Staleness is field-aware: an
|
|
112
|
+
// observation reporting `free` is not stale just because a newer `availability`
|
|
113
|
+
// observation exists for the same model — different fields, different facts.
|
|
114
|
+
const latestByKey = new Map();
|
|
115
|
+
const fieldsOf = o => OBSERVED_FIELDS.filter(f => o.changes?.[f] !== null && o.changes?.[f] !== undefined);
|
|
116
|
+
for (const o of store.observations) {
|
|
117
|
+
if (!o.provider || !o.model || !o.observedAt) continue;
|
|
118
|
+
for (const f of fieldsOf(o)) {
|
|
119
|
+
const k = `${o.provider}/${o.model}::${f}`;
|
|
120
|
+
if (!latestByKey.has(k) || o.observedAt > latestByKey.get(k)) latestByKey.set(k, o.observedAt);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const result = { stored: [], duplicates: [], stale: [], invalid: [] };
|
|
124
|
+
mkdirSync(dirname(storePath), { recursive: true });
|
|
125
|
+
for (const raw of rawObservations) {
|
|
126
|
+
const errors = validateObservation(raw);
|
|
127
|
+
if (errors.length) { result.invalid.push({ observation: raw, errors }); continue; }
|
|
128
|
+
const o = normalizeObservation(raw, { now: now() });
|
|
129
|
+
if (byHash.has(o.id)) { result.duplicates.push(o); continue; }
|
|
130
|
+
// stale only if EVERY reported field is older than the newest stored value for it
|
|
131
|
+
const fields = fieldsOf(o);
|
|
132
|
+
const allStale = fields.length > 0 && fields.every(f => {
|
|
133
|
+
const latest = latestByKey.get(`${o.provider}/${o.model}::${f}`);
|
|
134
|
+
return latest && o.observedAt < latest;
|
|
135
|
+
});
|
|
136
|
+
if (allStale) { result.stale.push(o); continue; }
|
|
137
|
+
const record = { ...o, ingestedAt: now(), validation: 'ok' };
|
|
138
|
+
appendFileSync(storePath, JSON.stringify(record) + '\n');
|
|
139
|
+
byHash.set(o.id, record);
|
|
140
|
+
for (const f of fields) {
|
|
141
|
+
const k = `${o.provider}/${o.model}::${f}`;
|
|
142
|
+
if (!latestByKey.has(k) || o.observedAt > latestByKey.get(k)) latestByKey.set(k, o.observedAt);
|
|
143
|
+
}
|
|
144
|
+
result.stored.push(record);
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Observer registry (Phase 4b): provider/source observers behind a common
|
|
2
|
+
// interface so the CLI and the scheduled job iterate a requested list instead of
|
|
3
|
+
// stacking per-source `if` branches. Adding a provider = adding one registry entry
|
|
4
|
+
// here — no changes to observe-models.mjs or job.mjs.
|
|
5
|
+
//
|
|
6
|
+
// Observer interface:
|
|
7
|
+
// async run(context) -> { observations: [], probeFailed: bool, metadata: {} }
|
|
8
|
+
//
|
|
9
|
+
// context carries everything an observer may need (all optional):
|
|
10
|
+
// { catalog, outDir, now, observedAt, adapterDir, kit,
|
|
11
|
+
// listing, qoderListing, qoderCachePath, ...overrides }
|
|
12
|
+
// `listing`/`qoderListing`/... are test seams: an explicit value overrides the
|
|
13
|
+
// real probe, `false` simulates a failed probe. Provider specifics stay inside
|
|
14
|
+
// the observer; the runner only sees normalized observations + a failure flag.
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { SOURCES, fromPiCli, fromQoderCache } from './sources.mjs';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Observer registry. Each entry: { id, describe, run(context) }.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
export const OBSERVERS = {
|
|
23
|
+
'pi-cli': {
|
|
24
|
+
id: 'pi-cli',
|
|
25
|
+
describe: 'pi --list-models availability (provider presence + model listing)',
|
|
26
|
+
async run(context) {
|
|
27
|
+
const { catalog, observedAt, listing } = context;
|
|
28
|
+
// listing === false simulates a failed probe (tests); undefined = real probe.
|
|
29
|
+
const lst = listing === false ? null
|
|
30
|
+
: listing ?? (await import('../../adapters/pi/lib/list-models.mjs')).fetchPiAvailability?.() ?? null;
|
|
31
|
+
if (!lst) {
|
|
32
|
+
return { observations: [], probeFailed: true, metadata: { reason: 'pi --list-models unavailable' } };
|
|
33
|
+
}
|
|
34
|
+
return { observations: fromPiCli(lst, { catalog, observedAt }), probeFailed: false, metadata: { source: lst.source ?? 'pi --list-models' } };
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
'qoder-cache': {
|
|
39
|
+
id: 'qoder-cache',
|
|
40
|
+
describe: 'qoder-models-cache.json priceFactor -> free/paid campaign state',
|
|
41
|
+
async run(context) {
|
|
42
|
+
const { catalog, outDir, observedAt, qoderListing, qoderCachePath } = context;
|
|
43
|
+
const statePath = join(outDir, 'qoder-observer-state.json');
|
|
44
|
+
let previous = null;
|
|
45
|
+
try { previous = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')).snapshot ?? null : null; } catch { previous = null; }
|
|
46
|
+
let r;
|
|
47
|
+
if (qoderListing === false) r = { state: 'missing', observations: [], snapshot: {} };
|
|
48
|
+
else if (qoderListing !== undefined) r = qoderListing;
|
|
49
|
+
else {
|
|
50
|
+
const { homedir } = await import('node:os');
|
|
51
|
+
r = fromQoderCache(qoderCachePath ?? join(homedir(), '.pi', 'agent', 'qoder-models-cache.json'), { previous, catalog, observedAt });
|
|
52
|
+
}
|
|
53
|
+
if (r.state !== 'ok') {
|
|
54
|
+
return { observations: [], probeFailed: true, metadata: { reason: `qoder cache probe: ${r.state}` } };
|
|
55
|
+
}
|
|
56
|
+
// Persist the new snapshot for next run's transition detection (out/ only).
|
|
57
|
+
if (outDir) {
|
|
58
|
+
try {
|
|
59
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
60
|
+
mkdirSync(outDir, { recursive: true });
|
|
61
|
+
writeFileSync(statePath, JSON.stringify({ version: 1, snapshot: r.snapshot, observedAt }, null, 2) + '\n');
|
|
62
|
+
} catch { /* state write must not break the observer */ }
|
|
63
|
+
}
|
|
64
|
+
return { observations: r.observations, probeFailed: false, metadata: { state: r.state, snapshot: r.snapshot, noTransition: r.observations.length === 0 } };
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Requested-observer resolution + execution.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/** option property -> observer id. Back-compat aliases live here, not in the runner.
|
|
74
|
+
* Keys are the JS option names (checkPi / checkQoder); the CLI maps --check-pi ->
|
|
75
|
+
* checkPi etc. before calling. */
|
|
76
|
+
export const OBSERVER_FLAG_MAP = {
|
|
77
|
+
checkPi: 'pi-cli',
|
|
78
|
+
checkQoder: 'qoder-cache',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the requested observer id list from job/CLI options.
|
|
83
|
+
* options.check — generic list: 'pi-cli,qoder-cache' or ['pi-cli', ...]
|
|
84
|
+
* options.checkPi / options.checkQoder — legacy boolean flags (mapped)
|
|
85
|
+
* options.observers — explicit id list (programmatic)
|
|
86
|
+
* Returns { ids: string[], unknown: string[] }. Order is stable (first-seen),
|
|
87
|
+
* duplicates removed — the same observer never runs twice.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveRequestedObservers(options = {}) {
|
|
90
|
+
const ids = [];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
const push = id => { if (!seen.has(id)) { seen.add(id); ids.push(id); } };
|
|
93
|
+
|
|
94
|
+
// legacy boolean flags -> ids (declaration order -> deterministic)
|
|
95
|
+
for (const [flag, id] of Object.entries(OBSERVER_FLAG_MAP)) {
|
|
96
|
+
if (options[flag] === true) push(id);
|
|
97
|
+
}
|
|
98
|
+
// generic --check list (string or array)
|
|
99
|
+
const generic = options.check;
|
|
100
|
+
const genericList = Array.isArray(generic) ? generic : (typeof generic === 'string' ? generic.split(',') : []);
|
|
101
|
+
for (const id of genericList) push(String(id).trim());
|
|
102
|
+
// explicit programmatic list
|
|
103
|
+
for (const id of options.observers ?? []) push(String(id).trim());
|
|
104
|
+
|
|
105
|
+
const unknown = ids.filter(id => !OBSERVERS[id]);
|
|
106
|
+
return { ids: ids.filter(id => OBSERVERS[id]), unknown };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Run each requested observer once, in order. One observer's failure is isolated:
|
|
111
|
+
* it sets probeFailed + records per-source metadata, but never stops the others.
|
|
112
|
+
* Returns { observations, probeFailed, results: [{id, probeFailed, count, metadata}] }.
|
|
113
|
+
* `results` is the audit trail of which source produced what / failed.
|
|
114
|
+
*/
|
|
115
|
+
export async function runObservers(requestedIds, context = {}) {
|
|
116
|
+
const observations = [];
|
|
117
|
+
const results = [];
|
|
118
|
+
let probeFailed = false;
|
|
119
|
+
for (const id of requestedIds) {
|
|
120
|
+
const observer = OBSERVERS[id];
|
|
121
|
+
if (!observer) { results.push({ id, probeFailed: true, count: 0, metadata: { reason: 'unknown observer' } }); probeFailed = true; continue; }
|
|
122
|
+
try {
|
|
123
|
+
const r = await observer.run(context);
|
|
124
|
+
const list = r.observations ?? [];
|
|
125
|
+
observations.push(...list);
|
|
126
|
+
if (r.probeFailed) probeFailed = true;
|
|
127
|
+
results.push({ id, probeFailed: r.probeFailed === true, count: list.length, metadata: r.metadata ?? {} });
|
|
128
|
+
} catch (e) {
|
|
129
|
+
probeFailed = true;
|
|
130
|
+
results.push({ id, probeFailed: true, count: 0, metadata: { reason: e.message } });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { observations, probeFailed, results };
|
|
134
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Observation source adapters (Phase 4). Each adapter turns a raw source into
|
|
2
|
+
// normalized observations. Sources never write; they only emit observation objects.
|
|
3
|
+
// Interface: (input) -> observation[]. A future HTTP/Web adapter plugs in here —
|
|
4
|
+
// the pipeline only consumes normalized output.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { parseModelList } from '../../adapters/pi/lib/list-models.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* manual source — canonical fallback. Input is a JSON file (or parsed object) of
|
|
10
|
+
* { version:1, observations:[...] } or a bare array. Trust defaults to
|
|
11
|
+
* 'manual_verified' when not stated — a human wrote it on purpose.
|
|
12
|
+
*/
|
|
13
|
+
export function fromManual(input) {
|
|
14
|
+
const doc = typeof input === 'string' ? JSON.parse(readFileSync(input, 'utf8')) : input;
|
|
15
|
+
const list = Array.isArray(doc) ? doc : doc?.observations ?? [];
|
|
16
|
+
return list.map(o => ({
|
|
17
|
+
...o,
|
|
18
|
+
source: { type: 'manual', trust: 'manual_verified', url: null, label: 'manual input', ...o.source, type: 'manual' },
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* pi CLI source — `pi --list-models` text (or a parsed {models,providers} set).
|
|
24
|
+
* Emits availability observations ONLY for models already in the catalog — a model
|
|
25
|
+
* absent from the listing is NOT reported 'unavailable' here (that inference is the
|
|
26
|
+
// differ's job, and it never asserts 'removed' from a listing alone).
|
|
27
|
+
*/
|
|
28
|
+
export function fromPiCli(listing, { catalog, observedAt } = {}) {
|
|
29
|
+
const parsed = typeof listing === 'string' ? parseModelList(listing) : listing;
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const m of catalog?.models ?? []) {
|
|
32
|
+
const key = `${m.provider}/${m.model}`;
|
|
33
|
+
if (!parsed.providers?.has(m.provider)) continue; // provider unseen -> no observation (unknown, not gone)
|
|
34
|
+
out.push({
|
|
35
|
+
provider: m.provider, model: m.model,
|
|
36
|
+
observedAt,
|
|
37
|
+
source: { type: 'cli', trust: 'pi_cli', label: 'pi --list-models' },
|
|
38
|
+
changes: { availability: parsed.models.has(key) ? 'available' : 'unavailable' },
|
|
39
|
+
confidence: 0.9,
|
|
40
|
+
evidence: ['pi --list-models'],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* static/web fixture source — replays provider announcements from a JSON document
|
|
48
|
+
* without real scraping. Accepts the observation shape directly, or a compact
|
|
49
|
+
* announcement form:
|
|
50
|
+
* { "announcements": [{ "provider":"qoder", "model":"Qwen3.8-Flash",
|
|
51
|
+
* "type":"price-changed|free-campaign-started|free-campaign-ended|deprecated|context-changed|model-added",
|
|
52
|
+
* "asOf":"...", "inputPricePer1M":0.3, "outputPricePer1M":1.2, "contextK":256,
|
|
53
|
+
* "url":"...", "label":"..." }] }
|
|
54
|
+
* A real HTTP source would implement the same emitter against fetched pages.
|
|
55
|
+
*/
|
|
56
|
+
export function fromFixture(input, { defaultTrust = 'provider_web' } = {}) {
|
|
57
|
+
const doc = typeof input === 'string' ? JSON.parse(readFileSync(input, 'utf8')) : input;
|
|
58
|
+
const list = doc?.observations ?? [];
|
|
59
|
+
const announcements = doc?.announcements ?? [];
|
|
60
|
+
// Fixture observations are TEST data — mark them so production maintenance excludes them.
|
|
61
|
+
const tag = { environment: 'test', sourceFixture: true };
|
|
62
|
+
const out = list.map(o => ({ ...o, ...tag, source: { type: 'web', trust: defaultTrust, fixture: true, ...o.source } }));
|
|
63
|
+
for (const a of announcements) {
|
|
64
|
+
const changes = {};
|
|
65
|
+
switch (a.type) {
|
|
66
|
+
case 'price-changed':
|
|
67
|
+
if (a.inputPricePer1M !== undefined) changes.inputPricePer1M = a.inputPricePer1M;
|
|
68
|
+
if (a.outputPricePer1M !== undefined) changes.outputPricePer1M = a.outputPricePer1M;
|
|
69
|
+
break;
|
|
70
|
+
case 'free-campaign-started': changes.free = true; if (a.freeUntil) changes.freeUntil = a.freeUntil; break;
|
|
71
|
+
case 'free-campaign-ended': changes.free = false; changes.freeUntil = null; break;
|
|
72
|
+
case 'deprecated': changes.status = 'deprecated'; break;
|
|
73
|
+
case 'removed': changes.status = 'removed'; break;
|
|
74
|
+
case 'context-changed': if (a.contextK !== undefined) changes.contextK = a.contextK; break;
|
|
75
|
+
case 'model-added': changes.status = 'active'; if (a.contextK !== undefined) changes.contextK = a.contextK; if (a.vision !== undefined) changes.vision = a.vision; break;
|
|
76
|
+
default: break;
|
|
77
|
+
}
|
|
78
|
+
out.push({
|
|
79
|
+
provider: a.provider, model: a.model, observedAt: a.asOf, ...tag,
|
|
80
|
+
source: { type: 'web', trust: a.trust ?? defaultTrust, url: a.url ?? null, label: a.label ?? a.type, fixture: true },
|
|
81
|
+
changes, confidence: a.confidence ?? 0.8,
|
|
82
|
+
evidence: [a.url ?? a.label ?? a.type].filter(Boolean),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Qoder provider-metadata source — reads the pi-maintained local cache
|
|
90
|
+
* `~/.pi/agent/qoder-models-cache.json` (schema v2: `{version, updatedAt, models:[{id, priceFactor, ...}]}`).
|
|
91
|
+
* Authority is the OBSERVED schema, not an assumption: `priceFactor` is the provider's
|
|
92
|
+
* subscription cost multiplier; 0 means "currently free". A missing file, unparseable
|
|
93
|
+
* JSON, absent entry, or absent/invalid priceFactor yields NO observation (unknown),
|
|
94
|
+
* never a guessed "not free".
|
|
95
|
+
*
|
|
96
|
+
* Transition detection vs the previous snapshot (a state file the caller owns):
|
|
97
|
+
* 0 -> non-zero : free-campaign ended -> observation {free:false}
|
|
98
|
+
* non-zero -> 0 : free-campaign started -> observation {free:true}
|
|
99
|
+
* same : no observation
|
|
100
|
+
* First observation (no previous snapshot) emits the current free state so the
|
|
101
|
+
* catalog can be reconciled; a null/unknown previous emits nothing (cannot diff).
|
|
102
|
+
*
|
|
103
|
+
* @param {string|object} input cache file path or parsed cache doc
|
|
104
|
+
* @param {object} opts.models [{provider, model}] to observe (default: all catalog qoder entries via opts.catalog)
|
|
105
|
+
* @param {object} opts.previous { "<id>": <priceFactor|null> } prior snapshot, or null
|
|
106
|
+
* @param {string} opts.observedAt ISO timestamp (defaults to cache updatedAt or now)
|
|
107
|
+
* @returns {{observations: object[], snapshot: object, state: string}}
|
|
108
|
+
* observations:normalized obs; snapshot: new {id:priceFactor} to persist; state:
|
|
109
|
+
* 'ok'|'missing'|'malformed'|'no-schema'|'entry-missing'|'priceFactor-missing'
|
|
110
|
+
*/
|
|
111
|
+
export function fromQoderCache(input, { models = null, previous = undefined, observedAt = null, catalog = null } = {}) {
|
|
112
|
+
const out = { observations: [], snapshot: {}, state: 'ok' };
|
|
113
|
+
let doc;
|
|
114
|
+
if (typeof input === 'string') {
|
|
115
|
+
let raw;
|
|
116
|
+
try { raw = readFileSync(input, 'utf8'); } catch { out.state = 'missing'; return out; }
|
|
117
|
+
try { doc = JSON.parse(raw); } catch { out.state = 'malformed'; return out; }
|
|
118
|
+
} else doc = input;
|
|
119
|
+
const entries = Array.isArray(doc?.models) ? doc.models : null;
|
|
120
|
+
if (!entries) { out.state = 'no-schema'; return out; }
|
|
121
|
+
const byId = new Map(entries.filter(e => e && typeof e.id === 'string').map(e => [e.id, e]));
|
|
122
|
+
// observedAt is the read time (now), not the cache's updatedAt — the observation
|
|
123
|
+
// records "we saw this state now"; using updatedAt would stale it against fresher
|
|
124
|
+
// same-model observations from other sources in a combined run.
|
|
125
|
+
const at = observedAt ?? new Date().toISOString();
|
|
126
|
+
const want = models ?? (catalog?.models ?? []).filter(m => m.provider === 'qoder').map(m => ({ provider: m.provider, model: m.model }));
|
|
127
|
+
for (const w of want) {
|
|
128
|
+
const e = byId.get(w.model);
|
|
129
|
+
if (!e) { out.state = out.state === 'ok' ? 'entry-missing' : out.state; continue; }
|
|
130
|
+
const pf = e.priceFactor;
|
|
131
|
+
const valid = typeof pf === 'number' && Number.isFinite(pf) && pf >= 0;
|
|
132
|
+
out.snapshot[w.model] = valid ? pf : null;
|
|
133
|
+
if (!valid) { out.state = out.state === 'ok' ? 'priceFactor-missing' : out.state; continue; } // unknown, not "not free"
|
|
134
|
+
const prev = previous?.[w.model];
|
|
135
|
+
const free = pf === 0;
|
|
136
|
+
const prevFree = prev === 0;
|
|
137
|
+
const prevKnown = typeof prev === 'number' && Number.isFinite(prev);
|
|
138
|
+
// Emit on transition, or on first sighting (prev unknown/absent) so the store
|
|
139
|
+
// holds a baseline. Same value -> nothing.
|
|
140
|
+
const transitioned = prevKnown && prevFree !== free;
|
|
141
|
+
const firstSighting = previous === undefined || previous === null || prev === undefined;
|
|
142
|
+
if (!transitioned && !firstSighting) continue;
|
|
143
|
+
out.observations.push({
|
|
144
|
+
provider: w.provider, model: w.model, observedAt: at,
|
|
145
|
+
source: { type: 'cli', trust: 'provider_local_cache', label: 'qoder-models-cache.json' },
|
|
146
|
+
changes: { free }, confidence: 0.85,
|
|
147
|
+
evidence: [`qoder-models-cache.json priceFactor=${pf}${prevKnown ? ` (was ${prev})` : ''}`],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Registry of source adapters by --source name. */
|
|
154
|
+
export const SOURCES = { manual: fromManual, 'pi-cli': fromPiCli, fixture: fromFixture, 'qoder-cache': fromQoderCache };
|