@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,283 @@
|
|
|
1
|
+
// Phase 4: observation layer — schema, dedupe/freshness, trust resolution,
|
|
2
|
+
// catalog diff/proposal, and hypothetical-catalog maintenance preview.
|
|
3
|
+
// Cases A–J. Nothing here writes outside a temp dir; real catalog/routing untouched.
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { resolve, dirname, join } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { validateObservation, normalizeObservation, observationHash, ingestObservations, loadObservationStore } from '../lib/observe/observation.mjs';
|
|
11
|
+
import { fromManual, fromPiCli, fromFixture } from '../lib/observe/sources.mjs';
|
|
12
|
+
import { resolveObservations, diffCatalog, buildCatalogProposal, applyProposalToCatalog } from '../lib/observe/differ.mjs';
|
|
13
|
+
import { runMaintenancePlan, selectTierModel, DEFAULT_POLICY } from '../lib/maintenance-exec.mjs';
|
|
14
|
+
import { evaluateMaintenance, effectiveCatalog, loadCatalog } from '../lib/maintenance.mjs';
|
|
15
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
16
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
17
|
+
import { mergeRegistries } from '../lib/registry.mjs';
|
|
18
|
+
|
|
19
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
21
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
22
|
+
const tmp = () => mkdtempSync(join(tmpdir(), 'observe-'));
|
|
23
|
+
|
|
24
|
+
const CAT = {
|
|
25
|
+
version: 1, updatedAt: '2026-03-01',
|
|
26
|
+
models: [
|
|
27
|
+
{ provider: 'qoder', model: 'Qwen3.8-Flash', status: 'free-campaign', cost: { free: true }, postCampaignCost: { usdPerMInput: 0.3, usdPerMOutput: 1.2 }, contextK: 256, vision: false, toolUse: 'good', location: 'cloud', scores: { coding: 62, reasoning: 55, speed: 85 } },
|
|
28
|
+
{ provider: 'cloudp', model: 'cheap-ok', status: 'active', cost: { usdPerMInput: 0.2, usdPerMOutput: 0.8 }, contextK: 256, vision: false, toolUse: 'good', location: 'cloud', scores: { coding: 68, reasoning: 62, speed: 75 } },
|
|
29
|
+
{ provider: 'cloudp', model: 'pro-strong', status: 'active', cost: { usdPerMInput: 5, usdPerMOutput: 20 }, contextK: 400, vision: false, toolUse: 'good', location: 'cloud', scores: { coding: 90, reasoning: 88, speed: 60 } },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
const obs = (provider, model, changes, over = {}) => normalizeObservation({
|
|
33
|
+
provider, model, observedAt: '2026-03-05T00:00:00Z',
|
|
34
|
+
source: { type: 'manual', trust: 'manual_verified', label: 'test' },
|
|
35
|
+
changes, confidence: 0.9, ...over,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// --- A: same price re-observed -> dedupe + unchanged -------------------------
|
|
39
|
+
test('A: re-observing the same price dedupes and diffs unchanged', () => {
|
|
40
|
+
const dir = tmp();
|
|
41
|
+
const store = join(dir, 'obs.jsonl');
|
|
42
|
+
const o = obs('cloudp', 'cheap-ok', { inputPricePer1M: 0.2 });
|
|
43
|
+
const r1 = ingestObservations(store, [o]);
|
|
44
|
+
const r2 = ingestObservations(store, [o]);
|
|
45
|
+
assert.equal(r1.stored.length, 1);
|
|
46
|
+
assert.equal(r2.duplicates.length, 1);
|
|
47
|
+
assert.equal(r2.stored.length, 0);
|
|
48
|
+
const diff = diffCatalog(CAT, loadObservationStore(store).observations);
|
|
49
|
+
const d = diff.diffs.find(x => x.field === 'inputPricePer1M');
|
|
50
|
+
assert.equal(d.status, 'unchanged');
|
|
51
|
+
rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// --- B: free campaign end -> proposed update + preview -----------------------
|
|
55
|
+
test('B: free-campaign end observation -> proposed update, preview runs on hypothetical catalog', () => {
|
|
56
|
+
const dir = tmp();
|
|
57
|
+
const store = join(dir, 'obs.jsonl');
|
|
58
|
+
ingestObservations(store, [obs('qoder', 'Qwen3.8-Flash', { free: false, inputPricePer1M: 0.3, outputPricePer1M: 1.2 })]);
|
|
59
|
+
const observations = loadObservationStore(store).observations;
|
|
60
|
+
const diff = diffCatalog(CAT, observations);
|
|
61
|
+
const proposal = buildCatalogProposal(CAT, diff, observations);
|
|
62
|
+
assert.ok(proposal.updates.some(u => u.model === 'qoder/Qwen3.8-Flash' && u.field === 'cost.free' && u.observedValue === false));
|
|
63
|
+
const hypothetical = applyProposalToCatalog(CAT, proposal);
|
|
64
|
+
assert.equal(hypothetical.models.find(m => m.model === 'Qwen3.8-Flash').cost.free, false);
|
|
65
|
+
const registry = mergeRegistries(null, { version: 1, backends: { qoder: { provider: 'qoder', model: 'Qwen3.8-Flash' } } });
|
|
66
|
+
const run = runMaintenancePlan({ routing, registry, agents, catalog: hypothetical, events: [], policy: DEFAULT_POLICY });
|
|
67
|
+
assert.ok(['proposal', 'evaluated-no-change', 'no-change'].includes(run.outcome));
|
|
68
|
+
assert.equal(CAT.models.find(m => m.model === 'Qwen3.8-Flash').cost.free, true); // real catalog untouched
|
|
69
|
+
rmSync(dir, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// --- C: price drop -> proposed, preview shows selection may change -----------
|
|
73
|
+
test('C: price drop observation -> proposed update; preview reflects cheaper model', () => {
|
|
74
|
+
const dir = tmp();
|
|
75
|
+
const store = join(dir, 'obs.jsonl');
|
|
76
|
+
ingestObservations(store, [obs('cloudp', 'pro-strong', { inputPricePer1M: 0.1, outputPricePer1M: 0.4 })]);
|
|
77
|
+
const observations = loadObservationStore(store).observations;
|
|
78
|
+
const proposal = buildCatalogProposal(CAT, diffCatalog(CAT, observations), observations);
|
|
79
|
+
assert.ok(proposal.updates.some(u => u.field === 'cost.usdPerMInput' && u.observedValue === 0.1));
|
|
80
|
+
const hypothetical = applyProposalToCatalog(CAT, proposal);
|
|
81
|
+
const sel = runMaintenancePlan({ routing, registry: mergeRegistries(null, { version: 1, backends: {} }), agents, catalog: hypothetical, events: [], policy: DEFAULT_POLICY });
|
|
82
|
+
// pro-strong now costs ~$0.2/run blended — cheapest-sufficient for evaluate tier
|
|
83
|
+
const evalTier = sel.tiers.find(t => t.role === 'monitor');
|
|
84
|
+
assert.ok(evalTier);
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// --- D: absent from pi listing -> NOT removed --------------------------------
|
|
89
|
+
test('D: model absent from pi listing -> availability observation only, never removed', () => {
|
|
90
|
+
const listing = { models: new Set(['cloudp/cheap-ok']), providers: new Set(['cloudp', 'qoder']) };
|
|
91
|
+
const observations = fromPiCli(listing, { catalog: CAT, observedAt: '2026-03-05T00:00:00Z' });
|
|
92
|
+
const qoder = observations.find(o => o.model === 'Qwen3.8-Flash');
|
|
93
|
+
assert.equal(qoder.changes.availability, 'unavailable');
|
|
94
|
+
const diff = diffCatalog(CAT, observations);
|
|
95
|
+
const proposal = buildCatalogProposal(CAT, diff, observations);
|
|
96
|
+
assert.equal(proposal.deprecations.length, 0);
|
|
97
|
+
assert.ok(proposal.ignored.some(i => i.field === 'availability' && /not removal/.test(i.reason)));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// --- E: high-trust deprecated -> deprecation proposal -------------------------
|
|
101
|
+
test('E: provider_api deprecated observation -> deprecation proposal', () => {
|
|
102
|
+
const o = obs('cloudp', 'cheap-ok', { status: 'deprecated' }, { source: { type: 'api', trust: 'provider_api', label: 'provider status endpoint' } });
|
|
103
|
+
const diff = diffCatalog(CAT, [o]);
|
|
104
|
+
const proposal = buildCatalogProposal(CAT, diff, [o]);
|
|
105
|
+
assert.equal(proposal.deprecations.length, 1);
|
|
106
|
+
assert.equal(proposal.deprecations[0].observedValue, 'deprecated');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// --- F: low vs high trust conflict -------------------------------------------
|
|
110
|
+
test('F: conflicting sources — high trust wins; equal-trust major conflict is recorded', () => {
|
|
111
|
+
const low = obs('cloudp', 'cheap-ok', { inputPricePer1M: 9.9 }, { source: { type: 'web', trust: 'third_party' }, confidence: 0.4 });
|
|
112
|
+
const high = obs('cloudp', 'cheap-ok', { inputPricePer1M: 0.25 }, { source: { type: 'api', trust: 'provider_api' }, confidence: 0.9 });
|
|
113
|
+
const resolved = resolveObservations([low, high]);
|
|
114
|
+
const r = resolved.get('cloudp/cheap-ok::inputPricePer1M');
|
|
115
|
+
assert.equal(r.winner.value, 0.25); // same timestamp -> trust decides
|
|
116
|
+
assert.equal(r.conflict, null);
|
|
117
|
+
// equal trust + equal timestamp + different major value -> conflict
|
|
118
|
+
const a = obs('cloudp', 'cheap-ok', { status: 'deprecated' }, { source: { type: 'api', trust: 'provider_api', label: 'a' } });
|
|
119
|
+
const b = obs('cloudp', 'cheap-ok', { status: 'active' }, { source: { type: 'api', trust: 'provider_api', label: 'b' } });
|
|
120
|
+
const r2 = resolveObservations([a, b]).get('cloudp/cheap-ok::status');
|
|
121
|
+
assert.ok(r2.conflict);
|
|
122
|
+
const proposal = buildCatalogProposal(CAT, diffCatalog(CAT, [a, b]), [a, b]);
|
|
123
|
+
assert.equal(proposal.conflicts.length, 1);
|
|
124
|
+
assert.equal(proposal.updates.filter(u => u.field === 'status').length, 0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// --- G: stale observation does not overwrite newer ----------------------------
|
|
128
|
+
test('G: older observation is stale and never wins resolution', () => {
|
|
129
|
+
const dir = tmp();
|
|
130
|
+
const store = join(dir, 'obs.jsonl');
|
|
131
|
+
ingestObservations(store, [obs('cloudp', 'cheap-ok', { inputPricePer1M: 0.5 }, { observedAt: '2026-03-10T00:00:00Z' })]);
|
|
132
|
+
const r = ingestObservations(store, [obs('cloudp', 'cheap-ok', { inputPricePer1M: 0.1 }, { observedAt: '2026-03-01T00:00:00Z' })]);
|
|
133
|
+
assert.equal(r.stale.length, 1);
|
|
134
|
+
const resolved = resolveObservations(loadObservationStore(store).observations);
|
|
135
|
+
assert.equal(resolved.get('cloudp/cheap-ok::inputPricePer1M').winner.value, 0.5);
|
|
136
|
+
rmSync(dir, { recursive: true, force: true });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// --- H: duplicate re-ingest ignored ------------------------------------------
|
|
140
|
+
test('H: identical observation re-ingested -> duplicate', () => {
|
|
141
|
+
const dir = tmp();
|
|
142
|
+
const store = join(dir, 'obs.jsonl');
|
|
143
|
+
const o = obs('cloudp', 'cheap-ok', { contextK: 512 });
|
|
144
|
+
ingestObservations(store, [o]);
|
|
145
|
+
const r = ingestObservations(store, [o]);
|
|
146
|
+
assert.equal(r.duplicates.length, 1);
|
|
147
|
+
assert.equal(loadObservationStore(store).observations.length, 1);
|
|
148
|
+
rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// --- I: new model -> addition proposal, no auto-adoption ----------------------
|
|
152
|
+
test('I: unknown model observed active -> addition proposal; routing unchanged', () => {
|
|
153
|
+
const o = obs('newp', 'Brand-New-1', { status: 'active', contextK: 512 }, { source: { type: 'api', trust: 'provider_api' } });
|
|
154
|
+
const proposal = buildCatalogProposal(CAT, diffCatalog(CAT, [o]), [o]);
|
|
155
|
+
assert.equal(proposal.additions.length, 1);
|
|
156
|
+
assert.equal(proposal.additions[0].model, 'newp/Brand-New-1');
|
|
157
|
+
const hypothetical = applyProposalToCatalog(CAT, proposal);
|
|
158
|
+
assert.ok(hypothetical.models.some(m => m.model === 'Brand-New-1'));
|
|
159
|
+
assert.equal(CAT.models.length, 3); // real catalog untouched
|
|
160
|
+
assert.equal(routing.capabilities['cheap-code'].primary, 'qoder'); // routing untouched
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// --- J: preview on hypothetical catalog leaves real files unchanged -----------
|
|
164
|
+
test('J: maintenance preview on hypothetical catalog; real catalog/routing unchanged', () => {
|
|
165
|
+
const o = obs('qoder', 'Qwen3.8-Flash', { status: 'removed' }, { source: { type: 'api', trust: 'provider_api' } });
|
|
166
|
+
const proposal = buildCatalogProposal(CAT, diffCatalog(CAT, [o]), [o]);
|
|
167
|
+
const hypothetical = applyProposalToCatalog(CAT, proposal);
|
|
168
|
+
assert.equal(hypothetical._hypothetical, true);
|
|
169
|
+
const registry = mergeRegistries(null, { version: 1, backends: { qoder: { provider: 'qoder', model: 'Qwen3.8-Flash' } } });
|
|
170
|
+
const run = runMaintenancePlan({ routing, registry, agents, catalog: hypothetical, events: [], policy: DEFAULT_POLICY });
|
|
171
|
+
const cheap = run.proposal?.decisions.find(d => d.backend === 'qoder');
|
|
172
|
+
assert.ok(cheap && cheap.decision === 'propose'); // forced migration previewed
|
|
173
|
+
// real objects unchanged
|
|
174
|
+
assert.equal(CAT.models.find(m => m.model === 'Qwen3.8-Flash').status, 'free-campaign');
|
|
175
|
+
assert.ok(!('_hypothetical' in CAT));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// --- K: Qoder free-campaign expiry end-to-end (observe -> diff -> proposal ->
|
|
179
|
+
// hypothetical catalog -> maintenance reevaluation -> routing decision).
|
|
180
|
+
// Nothing real is written: the shipped catalog and routing stay untouched. --
|
|
181
|
+
test('K: Qwen3.8-Flash free-campaign expiry is observed, proposed and re-evaluated without applying anything', () => {
|
|
182
|
+
const dir = tmp();
|
|
183
|
+
const store = join(dir, 'obs.jsonl');
|
|
184
|
+
const realCatalog = loadCatalog(join(kit, 'adapters/pi/model-catalog.json'));
|
|
185
|
+
const qwen = () => realCatalog.models.find(m => m.provider === 'qoder' && m.model === 'Qwen3.8-Flash');
|
|
186
|
+
assert.equal(qwen().status, 'free-campaign');
|
|
187
|
+
assert.equal(qwen().cost.free, true);
|
|
188
|
+
|
|
189
|
+
// before: free cloud wins the monitor-tier selection
|
|
190
|
+
const before = selectTierModel(effectiveCatalog(realCatalog, [], '2026-09-25T00:00:00Z'), 'monitor', DEFAULT_POLICY);
|
|
191
|
+
assert.equal(before.selectionPath, 'free-cloud');
|
|
192
|
+
assert.equal(before.selected.model, 'qoder/Qwen3.8-Flash');
|
|
193
|
+
|
|
194
|
+
// observation: campaign ended
|
|
195
|
+
const o = obs('qoder', 'Qwen3.8-Flash', { free: false }, { source: { type: 'web', trust: 'provider_web', label: 'qoder pricing page' } });
|
|
196
|
+
ingestObservations(store, [o]);
|
|
197
|
+
const observations = loadObservationStore(store).observations;
|
|
198
|
+
const diff = diffCatalog(realCatalog, observations);
|
|
199
|
+
const d = diff.diffs.find(x => x.model === 'qoder/Qwen3.8-Flash' && x.field === 'free');
|
|
200
|
+
assert.equal(d.status, 'proposed');
|
|
201
|
+
assert.equal(d.currentValue, true);
|
|
202
|
+
assert.equal(d.observedValue, false);
|
|
203
|
+
|
|
204
|
+
const proposal = buildCatalogProposal(realCatalog, diff, observations);
|
|
205
|
+
assert.ok(proposal.updates.some(u => u.model === 'qoder/Qwen3.8-Flash' && u.field === 'cost.free' && u.observedValue === false));
|
|
206
|
+
|
|
207
|
+
// hypothetical catalog: paid now; postCampaignCost is null so the entry becomes costUnknown
|
|
208
|
+
const hypothetical = applyProposalToCatalog(realCatalog, proposal);
|
|
209
|
+
const hq = hypothetical.models.find(m => m.provider === 'qoder' && m.model === 'Qwen3.8-Flash');
|
|
210
|
+
assert.equal(hq.cost.free, false);
|
|
211
|
+
assert.equal(hypothetical._hypothetical, true);
|
|
212
|
+
|
|
213
|
+
// maintenance preview on the hypothetical catalog: cheapest-sufficient re-evaluated.
|
|
214
|
+
// The registry binds cheap->qoder/Qwen3.8-Flash; the event-equivalent is the proposal.
|
|
215
|
+
const registry = mergeRegistries(null, { version: 1, backends: {
|
|
216
|
+
local: { provider: 'freetoken', model: 'Qwen3.6-35B-A3B-NVFP4', thinking: 'off' },
|
|
217
|
+
cheap: { provider: 'qoder', model: 'Qwen3.8-Flash', thinking: 'low' },
|
|
218
|
+
qoder: { provider: 'qoder', model: 'Qwen3.8-Flash', thinking: 'low' },
|
|
219
|
+
devin: { provider: 'devin', model: 'swe-2-high', thinking: 'high' },
|
|
220
|
+
sol: { provider: 'openai-codex', model: 'gpt-5.6-sol', thinking: 'medium' },
|
|
221
|
+
astra: { provider: 'openai-codex', model: 'gpt-6-astra', thinking: 'medium', vision: true },
|
|
222
|
+
codex: { provider: 'openai-codex', model: 'gpt-5.5', thinking: 'high' },
|
|
223
|
+
} });
|
|
224
|
+
const events = [{ type: 'free-campaign-ended', provider: 'qoder', model: 'Qwen3.8-Flash', asOf: '2026-09-24' }];
|
|
225
|
+
const run = runMaintenancePlan({ routing, registry, agents, catalog: realCatalog, events, policy: DEFAULT_POLICY, asOf: '2026-09-25T00:00:00Z' });
|
|
226
|
+
assert.equal(run.monitor.changed, true);
|
|
227
|
+
// the qoder backend (primary of cheap-code) was re-evaluated against the post-event cost
|
|
228
|
+
const qoderDecision = run.proposal.decisions.find(x => x.backend === 'qoder');
|
|
229
|
+
assert.ok(qoderDecision, 'qoder backend must appear in maintenance decisions');
|
|
230
|
+
assert.ok(['keep', 'propose', 'insufficient-data'].includes(qoderDecision.decision));
|
|
231
|
+
// Qoder's offer ended; the separately free Devin campaign may still win.
|
|
232
|
+
const after = selectTierModel(effectiveCatalog(realCatalog, events, '2026-09-25T00:00:00Z'), 'monitor', DEFAULT_POLICY);
|
|
233
|
+
assert.notEqual(after.selected.model, 'qoder/Qwen3.8-Flash');
|
|
234
|
+
assert.equal(after.selected.model, 'devin/swe-2-high');
|
|
235
|
+
// devin stays the strong-code/deep-review primary (its own evaluation is independent)
|
|
236
|
+
const devinDecision = run.proposal.decisions.find(x => x.backend === 'devin');
|
|
237
|
+
assert.ok(!devinDecision || devinDecision.decision !== 'propose' || devinDecision.proposed?.provider !== undefined);
|
|
238
|
+
|
|
239
|
+
// nothing applied: shipped catalog + routing untouched
|
|
240
|
+
assert.equal(qwen().cost.free, true);
|
|
241
|
+
assert.equal(qwen().status, 'free-campaign');
|
|
242
|
+
assert.ok(!('_hypothetical' in realCatalog));
|
|
243
|
+
assert.equal(routing.capabilities['cheap-code'].primary, 'qoder');
|
|
244
|
+
rmSync(dir, { recursive: true, force: true });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// --- schema + sources ---------------------------------------------------------
|
|
248
|
+
test('observation validation: null vs false/0 are distinct, unknown fields rejected', () => {
|
|
249
|
+
assert.deepEqual(validateObservation(obs('p', 'm', { free: false, inputPricePer1M: 0 })), []);
|
|
250
|
+
assert.ok(validateObservation({ provider: 'p', model: 'm', observedAt: 'x', source: { type: 'manual' }, changes: { free: 'yes' } }).some(e => e.includes('boolean')));
|
|
251
|
+
assert.ok(validateObservation({ provider: 'p', model: 'm', observedAt: '2026-01-01T00:00:00Z', source: { type: 'manual' }, changes: { bogusField: 1 } }).some(e => e.includes('bogusField')));
|
|
252
|
+
const n = normalizeObservation({ provider: 'p', model: 'm', source: { type: 'manual' }, changes: { free: false } });
|
|
253
|
+
assert.equal(n.changes.free, false);
|
|
254
|
+
assert.equal(n.changes.contextK, null); // unobserved stays null, not 0/false
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('fixture source replays announcements deterministically', () => {
|
|
258
|
+
const doc = { announcements: [
|
|
259
|
+
{ provider: 'qoder', model: 'Qwen3.8-Flash', type: 'free-campaign-ended', asOf: '2026-03-01' },
|
|
260
|
+
{ provider: 'cloudp', model: 'cheap-ok', type: 'price-changed', inputPricePer1M: 0.15, outputPricePer1M: 0.6, asOf: '2026-03-02' },
|
|
261
|
+
{ provider: 'newp', model: 'X-1', type: 'model-added', contextK: 512, asOf: '2026-03-03' },
|
|
262
|
+
] };
|
|
263
|
+
const out = fromFixture(doc);
|
|
264
|
+
assert.equal(out.length, 3);
|
|
265
|
+
assert.equal(out[0].changes.free, false);
|
|
266
|
+
assert.equal(out[1].changes.inputPricePer1M, 0.15);
|
|
267
|
+
assert.equal(out[2].changes.status, 'active');
|
|
268
|
+
assert.equal(out[0].source.trust, 'provider_web');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('manual source defaults trust to manual_verified', () => {
|
|
272
|
+
const out = fromManual({ observations: [{ provider: 'p', model: 'm', observedAt: '2026-01-01T00:00:00Z', changes: { contextK: 128 } }] });
|
|
273
|
+
assert.equal(out[0].source.trust, 'manual_verified');
|
|
274
|
+
assert.equal(out[0].source.type, 'manual');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('observationHash is deterministic and field-sensitive', () => {
|
|
278
|
+
const a = obs('p', 'm', { contextK: 128 });
|
|
279
|
+
const b = obs('p', 'm', { contextK: 128 });
|
|
280
|
+
const c = obs('p', 'm', { contextK: 256 });
|
|
281
|
+
assert.equal(observationHash(a), observationHash(b));
|
|
282
|
+
assert.notEqual(observationHash(a), observationHash(c));
|
|
283
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Observer registry: resolution, ordering, dedupe, failure isolation, unknown ids,
|
|
2
|
+
// and parity between the standalone observe path and the scheduled job.
|
|
3
|
+
// Nothing real is written — temp outDirs only; catalog/routing/settings untouched.
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { resolve, dirname, join } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { OBSERVERS, OBSERVER_FLAG_MAP, resolveRequestedObservers, runObservers } from '../lib/observe/observers.mjs';
|
|
11
|
+
import { loadObservationStore } from '../lib/observe/observation.mjs';
|
|
12
|
+
import { fromQoderCache } from '../lib/observe/sources.mjs';
|
|
13
|
+
import { runMaintenanceJob } from '../lib/job.mjs';
|
|
14
|
+
import { DEFAULT_POLICY } from '../lib/maintenance-exec.mjs';
|
|
15
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
16
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
17
|
+
import { mergeRegistries } from '../lib/registry.mjs';
|
|
18
|
+
|
|
19
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
21
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
22
|
+
const tmp = () => mkdtempSync(join(tmpdir(), 'obsv-'));
|
|
23
|
+
|
|
24
|
+
const CAT = {
|
|
25
|
+
version: 1, updatedAt: '2026-03-01',
|
|
26
|
+
models: [
|
|
27
|
+
{ provider: 'qoder', model: 'Qwen3.8-Flash', status: 'free-campaign', cost: { free: true }, contextK: 1024, toolUse: 'good', location: 'cloud', scores: { coding: 62, reasoning: 55, speed: 85 } },
|
|
28
|
+
{ provider: 'cloudp', model: 'cheap-ok', status: 'active', cost: { usdPerMInput: 0.2, usdPerMOutput: 0.8 }, contextK: 256, toolUse: 'good', location: 'cloud', scores: { coding: 68, reasoning: 62, speed: 75 } },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
const REG = mergeRegistries(null, { version: 1, backends: { qoder: { provider: 'qoder', model: 'Qwen3.8-Flash' }, cheap: { provider: 'cloudp', model: 'cheap-ok' } } });
|
|
32
|
+
const PI_LISTING = { models: new Set(['qoder/Qwen3.8-Flash', 'cloudp/cheap-ok']), providers: new Set(['qoder', 'cloudp']), source: 'test' };
|
|
33
|
+
const qoderCache = pf => ({ version: 2, updatedAt: Date.now(), models: [{ id: 'Qwen3.8-Flash', priceFactor: pf, provider: 'qoder' }] });
|
|
34
|
+
const qoderResult = pf => fromQoderCache(qoderCache(pf), { models: [{ provider: 'qoder', model: 'Qwen3.8-Flash' }], observedAt: new Date().toISOString() });
|
|
35
|
+
const jobBase = dir => ({ outDir: dir, adapterDir: join(kit, 'adapters/pi'), kit, catalog: CAT, routing, registry: REG, agents, policy: DEFAULT_POLICY });
|
|
36
|
+
|
|
37
|
+
// --- A: pi-cli only -----------------------------------------------------------
|
|
38
|
+
test('A: only --check-pi resolves to and runs only the pi-cli observer', async () => {
|
|
39
|
+
const { ids, unknown } = resolveRequestedObservers({ checkPi: true });
|
|
40
|
+
assert.deepEqual(ids, ['pi-cli']);
|
|
41
|
+
assert.deepEqual(unknown, []);
|
|
42
|
+
const dir = tmp();
|
|
43
|
+
const run = await runMaintenanceJob({ ...jobBase(dir), checkPi: true, listing: PI_LISTING });
|
|
44
|
+
assert.deepEqual(run.observers.map(o => o.id), ['pi-cli']);
|
|
45
|
+
const store = loadObservationStore(join(dir, 'model-observations.jsonl')).observations;
|
|
46
|
+
assert.ok(store.every(o => o.source?.trust === 'pi_cli'));
|
|
47
|
+
rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// --- B: qoder-cache only --------------------------------------------------------
|
|
51
|
+
test('B: only --check-qoder resolves to and runs only the qoder-cache observer', async () => {
|
|
52
|
+
const { ids } = resolveRequestedObservers({ checkQoder: true });
|
|
53
|
+
assert.deepEqual(ids, ['qoder-cache']);
|
|
54
|
+
const dir = tmp();
|
|
55
|
+
const run = await runMaintenanceJob({ ...jobBase(dir), checkQoder: true, qoderListing: qoderResult(0) });
|
|
56
|
+
assert.deepEqual(run.observers.map(o => o.id), ['qoder-cache']);
|
|
57
|
+
const store = loadObservationStore(join(dir, 'model-observations.jsonl')).observations;
|
|
58
|
+
assert.ok(store.every(o => o.source?.trust === 'provider_local_cache'));
|
|
59
|
+
rmSync(dir, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// --- C: both run once each ------------------------------------------------------
|
|
63
|
+
test('C: --check-pi + --check-qoder run both observers exactly once, in order', async () => {
|
|
64
|
+
const { ids } = resolveRequestedObservers({ checkPi: true, checkQoder: true });
|
|
65
|
+
assert.deepEqual(ids, ['pi-cli', 'qoder-cache']);
|
|
66
|
+
const dir = tmp();
|
|
67
|
+
const run = await runMaintenanceJob({ ...jobBase(dir), checkPi: true, listing: PI_LISTING, checkQoder: true, qoderListing: qoderResult(0) });
|
|
68
|
+
assert.deepEqual(run.observers.map(o => o.id), ['pi-cli', 'qoder-cache']);
|
|
69
|
+
assert.equal(run.observers.filter(o => o.id === 'pi-cli').length, 1);
|
|
70
|
+
assert.equal(run.observers.filter(o => o.id === 'qoder-cache').length, 1);
|
|
71
|
+
const store = loadObservationStore(join(dir, 'model-observations.jsonl')).observations;
|
|
72
|
+
assert.ok(store.some(o => o.changes?.availability === 'available'));
|
|
73
|
+
assert.ok(store.some(o => o.changes?.free === true));
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// --- D: duplicate observer deduped ------------------------------------------------
|
|
78
|
+
test('D: the same observer via flag + generic list runs once', async () => {
|
|
79
|
+
const { ids } = resolveRequestedObservers({ checkPi: true, check: 'pi-cli,qoder-cache' });
|
|
80
|
+
assert.deepEqual(ids, ['pi-cli', 'qoder-cache']); // pi-cli not duplicated
|
|
81
|
+
const dir = tmp();
|
|
82
|
+
const run = await runMaintenanceJob({ ...jobBase(dir), checkPi: true, check: 'pi-cli,qoder-cache', listing: PI_LISTING, qoderListing: qoderResult(0) });
|
|
83
|
+
assert.equal(run.observers.filter(o => o.id === 'pi-cli').length, 1);
|
|
84
|
+
rmSync(dir, { recursive: true, force: true });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// --- E: pi ok + qoder fail -> pi kept, job continues ------------------------------
|
|
88
|
+
test('E: pi-cli success + qoder-cache failure -> pi observations kept, job continues', async () => {
|
|
89
|
+
const dir = tmp();
|
|
90
|
+
const run = await runMaintenanceJob({ ...jobBase(dir), checkPi: true, listing: PI_LISTING, checkQoder: true, qoderListing: false });
|
|
91
|
+
assert.equal(run.status, 'ok');
|
|
92
|
+
assert.equal(run.ingest.probeFailed, true);
|
|
93
|
+
const qoderRes = run.observers.find(o => o.id === 'qoder-cache');
|
|
94
|
+
assert.equal(qoderRes.probeFailed, true);
|
|
95
|
+
const store = loadObservationStore(join(dir, 'model-observations.jsonl')).observations;
|
|
96
|
+
assert.ok(store.some(o => o.changes?.availability === 'available'), 'pi observations kept');
|
|
97
|
+
rmSync(dir, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// --- F: qoder ok + pi fail -> qoder kept, job continues ---------------------------
|
|
101
|
+
test('F: qoder-cache success + pi-cli failure -> qoder observations kept, job continues', async () => {
|
|
102
|
+
const dir = tmp();
|
|
103
|
+
const run = await runMaintenanceJob({ ...jobBase(dir), checkPi: true, listing: false, checkQoder: true, qoderListing: qoderResult(0) });
|
|
104
|
+
assert.equal(run.status, 'ok');
|
|
105
|
+
assert.equal(run.ingest.probeFailed, true);
|
|
106
|
+
const piRes = run.observers.find(o => o.id === 'pi-cli');
|
|
107
|
+
assert.equal(piRes.probeFailed, true);
|
|
108
|
+
const store = loadObservationStore(join(dir, 'model-observations.jsonl')).observations;
|
|
109
|
+
assert.ok(store.some(o => o.changes?.free === true), 'qoder observations kept');
|
|
110
|
+
rmSync(dir, { recursive: true, force: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// --- G: unknown observer id -> explicit failure -----------------------------------
|
|
114
|
+
test('G: unknown observer id -> structured failure, never silent', async () => {
|
|
115
|
+
const { ids, unknown } = resolveRequestedObservers({ check: 'pi-cli,bogus-observer' });
|
|
116
|
+
assert.deepEqual(ids, ['pi-cli']);
|
|
117
|
+
assert.deepEqual(unknown, ['bogus-observer']);
|
|
118
|
+
const dir = tmp();
|
|
119
|
+
const run = await runMaintenanceJob({ ...jobBase(dir), check: 'pi-cli,bogus-observer', listing: PI_LISTING });
|
|
120
|
+
assert.equal(run.status, 'error');
|
|
121
|
+
assert.match(run.error, /unknown observer/);
|
|
122
|
+
rmSync(dir, { recursive: true, force: true });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// --- H: deterministic ordering ----------------------------------------------------
|
|
126
|
+
test('H: observer execution order follows the requested order deterministically', async () => {
|
|
127
|
+
// requested order is honoured, not registry order
|
|
128
|
+
const { ids } = resolveRequestedObservers({ check: 'qoder-cache,pi-cli' });
|
|
129
|
+
assert.deepEqual(ids, ['qoder-cache', 'pi-cli']);
|
|
130
|
+
const dir = tmp();
|
|
131
|
+
const run = await runMaintenanceJob({ ...jobBase(dir), check: 'qoder-cache,pi-cli', listing: PI_LISTING, qoderListing: qoderResult(0) });
|
|
132
|
+
assert.deepEqual(run.observers.map(o => o.id), ['qoder-cache', 'pi-cli']);
|
|
133
|
+
rmSync(dir, { recursive: true, force: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// --- I: standalone CLI and job resolve identically --------------------------------
|
|
137
|
+
test('I: standalone observe options and job options resolve to the same observer set', () => {
|
|
138
|
+
// simulate the CLI's option mapping (has('check-pi') -> checkPi:true, --check -> check)
|
|
139
|
+
const cliOptions = { checkPi: true, checkQoder: true, check: null };
|
|
140
|
+
const jobOptions = { checkPi: true, checkQoder: true, check: null };
|
|
141
|
+
assert.deepEqual(resolveRequestedObservers(cliOptions), resolveRequestedObservers(jobOptions));
|
|
142
|
+
// and the generic form maps to the same ids
|
|
143
|
+
assert.deepEqual(resolveRequestedObservers({ check: 'pi-cli,qoder-cache' }).ids, ['pi-cli', 'qoder-cache']);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// --- registry shape ---------------------------------------------------------------
|
|
147
|
+
test('registry: every observer exposes the common interface', () => {
|
|
148
|
+
for (const [id, o] of Object.entries(OBSERVERS)) {
|
|
149
|
+
assert.equal(o.id, id);
|
|
150
|
+
assert.equal(typeof o.run, 'function');
|
|
151
|
+
assert.equal(typeof o.describe, 'string');
|
|
152
|
+
}
|
|
153
|
+
assert.ok(OBSERVERS['pi-cli']);
|
|
154
|
+
assert.ok(OBSERVERS['qoder-cache']);
|
|
155
|
+
assert.equal(OBSERVER_FLAG_MAP.checkPi, 'pi-cli');
|
|
156
|
+
assert.equal(OBSERVER_FLAG_MAP.checkQoder, 'qoder-cache');
|
|
157
|
+
});
|