@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.
Files changed (172) hide show
  1. package/AGENTS.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/adapters/codex/README.md +24 -0
  5. package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
  6. package/adapters/pi/README.md +88 -0
  7. package/adapters/pi/browser/agent-browser.mjs +193 -0
  8. package/adapters/pi/lib/invoke.mjs +55 -0
  9. package/adapters/pi/lib/list-models.mjs +29 -0
  10. package/adapters/pi/lib/settings-proposal.mjs +34 -0
  11. package/adapters/pi/lib/subagent.mjs +175 -0
  12. package/adapters/pi/loop-guard/index.js +51 -0
  13. package/adapters/pi/maintenance-policy.json +36 -0
  14. package/adapters/pi/mcp.template.json +4 -0
  15. package/adapters/pi/model-catalog.json +97 -0
  16. package/adapters/pi/models.json +13 -0
  17. package/adapters/pi/models.local.example.json +14 -0
  18. package/adapters/pi/orchestrator-ext/command.mjs +14 -0
  19. package/adapters/pi/orchestrator-ext/index.js +150 -0
  20. package/adapters/pi/settings.template.json +7 -0
  21. package/adapters/pi/shell-gate/index.js +70 -0
  22. package/adapters/pi/sync-pi.ps1 +137 -0
  23. package/agents/README.md +26 -0
  24. package/agents/browser.md +64 -0
  25. package/agents/coder.md +31 -0
  26. package/agents/orchestrator.md +37 -0
  27. package/agents/reviewer.md +32 -0
  28. package/agents/scout.md +35 -0
  29. package/agents/tester.md +28 -0
  30. package/agents/visual.md +28 -0
  31. package/context-pack/SPEC.md +101 -0
  32. package/context-pack/context-pack.schema.json +79 -0
  33. package/context-pack/examples/example-fix.md +44 -0
  34. package/docs/architecture.md +55 -0
  35. package/docs/migration-from-codex-setting.md +44 -0
  36. package/docs/model-maintenance.md +401 -0
  37. package/docs/orchestrator.md +155 -0
  38. package/docs/phase2-report.md +39 -0
  39. package/docs/roadmap.md +27 -0
  40. package/docs/third-party.md +15 -0
  41. package/lib/agents.mjs +79 -0
  42. package/lib/context-pack.mjs +215 -0
  43. package/lib/job.mjs +312 -0
  44. package/lib/language-policy.mjs +27 -0
  45. package/lib/maintenance-exec.mjs +377 -0
  46. package/lib/maintenance-runner.mjs +266 -0
  47. package/lib/maintenance.mjs +422 -0
  48. package/lib/normalize.mjs +101 -0
  49. package/lib/observe/differ.mjs +185 -0
  50. package/lib/observe/observation.mjs +147 -0
  51. package/lib/observe/observers.mjs +134 -0
  52. package/lib/observe/sources.mjs +154 -0
  53. package/lib/orchestrator/activity.mjs +249 -0
  54. package/lib/orchestrator/api.mjs +151 -0
  55. package/lib/orchestrator/contract.mjs +68 -0
  56. package/lib/orchestrator/escalation.mjs +84 -0
  57. package/lib/orchestrator/evaluator.mjs +92 -0
  58. package/lib/orchestrator/failures.mjs +88 -0
  59. package/lib/orchestrator/health.mjs +53 -0
  60. package/lib/orchestrator/orchestrator.mjs +483 -0
  61. package/lib/orchestrator/permissions.mjs +64 -0
  62. package/lib/orchestrator/planner.mjs +194 -0
  63. package/lib/orchestrator/policy.mjs +134 -0
  64. package/lib/orchestrator/router.mjs +45 -0
  65. package/lib/orchestrator/runner.mjs +278 -0
  66. package/lib/orchestrator/shell-policy.mjs +52 -0
  67. package/lib/orchestrator/store.mjs +581 -0
  68. package/lib/orchestrator/task-store.mjs +79 -0
  69. package/lib/orchestrator/turn-budget.mjs +63 -0
  70. package/lib/orchestrator/worktree.mjs +72 -0
  71. package/lib/pipeline.mjs +279 -0
  72. package/lib/registry.mjs +63 -0
  73. package/lib/resolve.mjs +35 -0
  74. package/lib/routing.mjs +137 -0
  75. package/lib/telemetry.mjs +222 -0
  76. package/mcp/README.md +11 -0
  77. package/mcp/servers.json +13 -0
  78. package/orchestration/decision-policy.json +66 -0
  79. package/package.json +56 -0
  80. package/routing/README.md +24 -0
  81. package/routing/routing.json +81 -0
  82. package/routing/routing.schema.json +66 -0
  83. package/rules/README.md +10 -0
  84. package/rules/common.md +52 -0
  85. package/rules/loop-prevention.md +15 -0
  86. package/rules/repo-local.md +6 -0
  87. package/scripts/check-environment.ps1 +22 -0
  88. package/scripts/context-pack.mjs +17 -0
  89. package/scripts/e2e-investigate-repro.mjs +66 -0
  90. package/scripts/model-maintenance-job.mjs +59 -0
  91. package/scripts/observe-models.mjs +97 -0
  92. package/scripts/orchestrate.mjs +137 -0
  93. package/scripts/reevaluate-models.mjs +95 -0
  94. package/scripts/report-model-maintenance.mjs +70 -0
  95. package/scripts/resolve-capabilities.mjs +39 -0
  96. package/scripts/run-pipeline.mjs +56 -0
  97. package/scripts/sync-agents-md.ps1 +10 -0
  98. package/scripts/validate.mjs +71 -0
  99. package/skills/README.md +14 -0
  100. package/skills/pi-workflow/SKILL.md +26 -0
  101. package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
  102. package/skills/pi-workflow/references/research.md +14 -0
  103. package/skills/pi-workflow/references/review.md +11 -0
  104. package/skills/pi-workflow/references/visual-work.md +14 -0
  105. package/skills/project-management/SKILL.md +106 -0
  106. package/skills/project-management/references/operations.md +52 -0
  107. package/skills/visual-verification/SKILL.md +88 -0
  108. package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
  109. package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
  110. package/skills/visual-verification/scripts/common.ps1 +387 -0
  111. package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
  112. package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
  113. package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
  114. package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
  115. package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
  116. package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
  117. package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
  118. package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
  119. package/skills/visual-verification/scripts/record-av.ps1 +102 -0
  120. package/skills/visual-verification/scripts/record.ps1 +72 -0
  121. package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
  122. package/skills/visual-verification/scripts/waveform.ps1 +450 -0
  123. package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
  124. package/tests/activity.test.mjs +252 -0
  125. package/tests/attempt-budget.test.mjs +102 -0
  126. package/tests/browser.test.mjs +121 -0
  127. package/tests/context-pack.test.mjs +98 -0
  128. package/tests/dirty-gate.test.mjs +211 -0
  129. package/tests/e2e-browser.mjs +66 -0
  130. package/tests/e2e-real-orchestrator-resume.mjs +101 -0
  131. package/tests/e2e-real-orchestrator.mjs +41 -0
  132. package/tests/e2e-real-pi.mjs +27 -0
  133. package/tests/e2e-real-tool-orchestrator.mjs +66 -0
  134. package/tests/fixtures/browser-page/index.html +20 -0
  135. package/tests/fixtures/maintenance/availability.txt +5 -0
  136. package/tests/fixtures/maintenance/catalog.json +74 -0
  137. package/tests/fixtures/maintenance/events.json +13 -0
  138. package/tests/fixtures/math-repo/README.md +3 -0
  139. package/tests/fixtures/math-repo/package.json +7 -0
  140. package/tests/fixtures/math-repo/src/math.js +11 -0
  141. package/tests/fixtures/math-repo/test/math.test.js +7 -0
  142. package/tests/fixtures/observe/announcements.json +8 -0
  143. package/tests/fixtures/orch-concurrent-child.mjs +44 -0
  144. package/tests/fixtures/orch-persist-child.mjs +61 -0
  145. package/tests/job.test.mjs +230 -0
  146. package/tests/kit.test.mjs +79 -0
  147. package/tests/language-policy.test.mjs +93 -0
  148. package/tests/loop-guard.test.mjs +60 -0
  149. package/tests/maintenance-exec.test.mjs +218 -0
  150. package/tests/maintenance-runner.test.mjs +222 -0
  151. package/tests/maintenance.test.mjs +195 -0
  152. package/tests/observe.test.mjs +283 -0
  153. package/tests/observer-registry.test.mjs +157 -0
  154. package/tests/orchestrator-cleanup.test.mjs +358 -0
  155. package/tests/orchestrator-command.test.mjs +14 -0
  156. package/tests/orchestrator-persist.test.mjs +375 -0
  157. package/tests/orchestrator-tools.test.mjs +215 -0
  158. package/tests/orchestrator.test.mjs +396 -0
  159. package/tests/package.test.mjs +37 -0
  160. package/tests/pipeline.test.mjs +239 -0
  161. package/tests/planner-classification.test.mjs +81 -0
  162. package/tests/planner-split.test.mjs +67 -0
  163. package/tests/qoder-observer.test.mjs +266 -0
  164. package/tests/reassign-progression.test.mjs +104 -0
  165. package/tests/retry-escalation.test.mjs +120 -0
  166. package/tests/routing.test.mjs +110 -0
  167. package/tests/sqlite-concurrency.test.mjs +178 -0
  168. package/tests/task-global-e2e.test.mjs +63 -0
  169. package/tests/task-global-failed.test.mjs +134 -0
  170. package/tests/telemetry.test.mjs +173 -0
  171. package/tests/test-sync-pi.ps1 +56 -0
  172. 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 };