agentwrangler 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/LICENSE +191 -0
- package/README.md +116 -0
- package/dist/apply/jobs.js +429 -0
- package/dist/apply/open-terminal-child.mjs +98 -0
- package/dist/apply/open-terminal.js +221 -0
- package/dist/apply/settings-gen.js +35 -0
- package/dist/cli/agentwrangler.js +18 -0
- package/dist/daemon/config.js +51 -0
- package/dist/daemon/http.js +258 -0
- package/dist/daemon/index.js +372 -0
- package/dist/daemon/outcomes-pass.js +82 -0
- package/dist/daemon/readiness.js +15 -0
- package/dist/daemon/router.js +756 -0
- package/dist/daemon/static.js +146 -0
- package/dist/db/migrate.js +72 -0
- package/dist/db/migrations/001_observe.sql +196 -0
- package/dist/db/migrations/002_indexes.sql +6 -0
- package/dist/db/migrations/003_context_inventory_history.sql +20 -0
- package/dist/db/migrations/004_apply_jobs.sql +17 -0
- package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
- package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
- package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
- package/dist/db/migrations/008_thinking_tokens.sql +1 -0
- package/dist/db/migrations/009_user_turn_count.sql +1 -0
- package/dist/db/migrations/010_workspace_cwd.sql +1 -0
- package/dist/db/migrations/011_reports.sql +1 -0
- package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
- package/dist/db/migrations/013_friction_fields.sql +5 -0
- package/dist/db/migrations/014_session_churn.sql +11 -0
- package/dist/db/migrations/015_gap_aggregates.sql +6 -0
- package/dist/db/open.js +30 -0
- package/dist/detector/benchmark-anchors.js +36 -0
- package/dist/detector/calibration.js +302 -0
- package/dist/detector/context-history-retention.js +312 -0
- package/dist/detector/context-probe.js +574 -0
- package/dist/detector/d1-source-identity.js +25 -0
- package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
- package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
- package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
- package/dist/detector/detectors/d4_model_mismatch.js +258 -0
- package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
- package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
- package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
- package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
- package/dist/detector/detectors/d9_idle_background_session.js +101 -0
- package/dist/detector/engine.js +88 -0
- package/dist/detector/index.js +17 -0
- package/dist/detector/measurement.js +426 -0
- package/dist/detector/practice-registry.js +259 -0
- package/dist/detector/registry.js +32 -0
- package/dist/detector/savings.js +249 -0
- package/dist/detector/types.js +14 -0
- package/dist/evidence/common/approved-input.js +632 -0
- package/dist/evidence/common/boundary.js +84 -0
- package/dist/evidence/common/canonical.js +55 -0
- package/dist/evidence/common/redaction.js +321 -0
- package/dist/evidence/common/sqlite.js +25 -0
- package/dist/evidence/common/state.js +29 -0
- package/dist/evidence/cond1/cli.js +289 -0
- package/dist/evidence/cond1/packet.js +407 -0
- package/dist/evidence/cond1/prepare.js +295 -0
- package/dist/evidence/cond1/score.js +349 -0
- package/dist/evidence/cond1/types.js +1 -0
- package/dist/evidence/create-approval.js +365 -0
- package/dist/evidence/create-scratch.js +542 -0
- package/dist/evidence/d7/cli.js +113 -0
- package/dist/evidence/d7/measure.js +193 -0
- package/dist/evidence/d7/types.js +1 -0
- package/dist/evidence/discover-approval.js +492 -0
- package/dist/evidence/g2/adjudicate.js +20 -0
- package/dist/evidence/g2/cli.js +207 -0
- package/dist/evidence/g2/kappa.js +39 -0
- package/dist/evidence/g2/pipeline.js +92 -0
- package/dist/evidence/g2/store.js +14 -0
- package/dist/evidence/github/client.js +1 -0
- package/dist/evidence/github/gh-cli-client.js +301 -0
- package/dist/evidence/r3/cli.js +209 -0
- package/dist/evidence/r3/evaluate.js +417 -0
- package/dist/evidence/r3/packet.js +162 -0
- package/dist/evidence/r3/prepare.js +405 -0
- package/dist/evidence/r3/score.js +341 -0
- package/dist/evidence/r3/transcript.js +155 -0
- package/dist/evidence/r3/types.js +4 -0
- package/dist/hook/context-budget-hook.mjs +138 -0
- package/dist/hook/danger-guard-denylist.json +27 -0
- package/dist/hook/danger-guard-hook.mjs +167 -0
- package/dist/hook/install.js +0 -0
- package/dist/hook/limit-burn-hook.mjs +127 -0
- package/dist/hook/loop-guard-hook.mjs +104 -0
- package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
- package/dist/ingest/churn-collector.js +122 -0
- package/dist/ingest/detector-hook.js +52 -0
- package/dist/ingest/discovery.js +207 -0
- package/dist/ingest/health.js +43 -0
- package/dist/ingest/index.js +28 -0
- package/dist/ingest/ingestor.js +509 -0
- package/dist/ingest/parser.js +344 -0
- package/dist/ingest/pricing.js +153 -0
- package/dist/ingest/reconcile.js +52 -0
- package/dist/ingest/tail.js +152 -0
- package/dist/ingest/types.js +24 -0
- package/dist/ingest/workspace-mapping.js +114 -0
- package/dist/oauth/anthropic-api-key.js +88 -0
- package/dist/oauth/count-tokens.js +86 -0
- package/dist/oauth/credentials.js +171 -0
- package/dist/oauth/judge-g2-client.js +154 -0
- package/dist/oauth/usage.js +167 -0
- package/dist/outcomes/branch-key.js +49 -0
- package/dist/outcomes/conclusions.js +45 -0
- package/dist/outcomes/derive.js +94 -0
- package/dist/outcomes/finding-extractors.js +131 -0
- package/dist/outcomes/findings.js +237 -0
- package/dist/outcomes/github/client.js +367 -0
- package/dist/outcomes/github/credential.js +195 -0
- package/dist/outcomes/github/gh-cli-client.js +340 -0
- package/dist/outcomes/linker.js +486 -0
- package/dist/outcomes/pool.js +24 -0
- package/dist/outcomes/sync.js +276 -0
- package/dist/query/api/agents-liveness.js +182 -0
- package/dist/query/api/burn-status.js +50 -0
- package/dist/query/api/context-budget.js +114 -0
- package/dist/query/api/context-composition.js +67 -0
- package/dist/query/api/cost-per-success.js +104 -0
- package/dist/query/api/delivery.js +92 -0
- package/dist/query/api/effectiveness.js +254 -0
- package/dist/query/api/efficiency-headroom.js +74 -0
- package/dist/query/api/headroom-trend.js +105 -0
- package/dist/query/api/hook-config.js +75 -0
- package/dist/query/api/hook-install.js +8 -0
- package/dist/query/api/hot-sessions.js +17 -0
- package/dist/query/api/idle-sessions.js +52 -0
- package/dist/query/api/index.js +40 -0
- package/dist/query/api/loop-guard.js +90 -0
- package/dist/query/api/offload-share.js +41 -0
- package/dist/query/api/outcomes.js +218 -0
- package/dist/query/api/overview.js +535 -0
- package/dist/query/api/rec-prompt.js +138 -0
- package/dist/query/api/recommendations-ledger.js +111 -0
- package/dist/query/api/recommendations.js +514 -0
- package/dist/query/api/reports.js +78 -0
- package/dist/query/api/self-churn.js +77 -0
- package/dist/query/api/self-percentiles.js +109 -0
- package/dist/query/api/session-drivers.js +153 -0
- package/dist/query/api/settings.js +85 -0
- package/dist/query/api/spend-flavor.js +234 -0
- package/dist/query/api/trends.js +155 -0
- package/dist/query/cap-weighted.js +119 -0
- package/dist/query/db-context.js +42 -0
- package/dist/query/envelope.js +71 -0
- package/dist/query/forecast.js +191 -0
- package/dist/query/settings-store.js +441 -0
- package/dist/query/spend.js +171 -0
- package/dist/query/trends.js +194 -0
- package/dist/ui/assets/index-DnRKgc21.css +1 -0
- package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
- package/dist/ui/index.html +39 -0
- package/package.json +59 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/detector/detectors/d4_model_mismatch.ts — D4 MODEL_MISMATCH.
|
|
3
|
+
*
|
|
4
|
+
* Fires PER-WORKSPACE when a sustained fraction of Opus turns in the trailing
|
|
5
|
+
* week match the "high-context, low-output" pattern — turns where Opus is reading
|
|
6
|
+
* a large context but producing little output, a signal that Sonnet could serve
|
|
7
|
+
* those turns at substantially lower cost.
|
|
8
|
+
*
|
|
9
|
+
* Conservative thresholds (all UNVALIDATED defaults, labeled in every rec):
|
|
10
|
+
* min_opus_turns: 5 — cold-start guard; avoids firing on sparse data
|
|
11
|
+
* mismatch_fraction: 40% — sustained pattern, not isolated Opus use
|
|
12
|
+
* min_context_tokens: 50,000 — well above trivial lookups; below Opus-4-8 avg 155K
|
|
13
|
+
* max_output_tokens: 500 — short answer from large context ≈ lookup style
|
|
14
|
+
*
|
|
15
|
+
* False-positive guard: only turns where BOTH conditions hold (large context AND
|
|
16
|
+
* small output) are flagged. Opus turns with large output, or turns below the
|
|
17
|
+
* context threshold, are never counted as mismatch — preserving legitimate
|
|
18
|
+
* architecture/reasoning/security use of Opus.
|
|
19
|
+
*
|
|
20
|
+
* Savings model: per-turn cost differential (Opus − Sonnet) on mismatch turns,
|
|
21
|
+
* scaled by mismatch_turns_per_week × reduction_fraction (0.50 UNVALIDATED).
|
|
22
|
+
* Prices are derived from pricing_snapshots, not hard-coded. Falls back to
|
|
23
|
+
* LIST_PRICES when no snapshot is present for a tier.
|
|
24
|
+
*
|
|
25
|
+
* scopeKey: "D4|<workspace_id>" — one rec per workspace per window.
|
|
26
|
+
*/
|
|
27
|
+
import { LIST_PRICES } from "../../ingest/pricing.js";
|
|
28
|
+
import { D4_MISMATCH_MAX_OUTPUT_TOKENS, D4_MISMATCH_MIN_CONTEXT_TOKENS, D4_MISMATCH_MIN_FRACTION, D4_OPUS_MIN_TURNS, D4_REDUCTION_FRACTION, d4Savings, } from "../savings.js";
|
|
29
|
+
/**
|
|
30
|
+
* Fallback prices from LIST_PRICES when pricing_snapshots is missing a tier.
|
|
31
|
+
* Sonnet uses the sonnet-4 tier as the conservative routing target — its higher
|
|
32
|
+
* base rate ($3/$15 vs sonnet-5's $2/$10) yields the smaller Opus−Sonnet
|
|
33
|
+
* differential, so the modeled routing savings are never overstated (BM0).
|
|
34
|
+
*/
|
|
35
|
+
const OPUS_INPUT_FALLBACK = LIST_PRICES.opus?.[0] ?? 5;
|
|
36
|
+
const OPUS_OUTPUT_FALLBACK = LIST_PRICES.opus?.[1] ?? 25;
|
|
37
|
+
const SONNET_INPUT_FALLBACK = LIST_PRICES["sonnet-4"]?.[0] ?? 3;
|
|
38
|
+
const SONNET_OUTPUT_FALLBACK = LIST_PRICES["sonnet-4"]?.[1] ?? 15;
|
|
39
|
+
/** Read a fresh, well-formed /usage snapshot once; fail open on any problem. */
|
|
40
|
+
function getPerModelSnapshot(db, now) {
|
|
41
|
+
try {
|
|
42
|
+
const row = db
|
|
43
|
+
.prepare("SELECT value FROM user_config WHERE key = 'per_model_snapshot'")
|
|
44
|
+
.get();
|
|
45
|
+
if (!row || typeof row.value !== "string")
|
|
46
|
+
return null;
|
|
47
|
+
const snapshot = JSON.parse(row.value);
|
|
48
|
+
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot))
|
|
49
|
+
return null;
|
|
50
|
+
const candidate = snapshot;
|
|
51
|
+
if (typeof candidate.captured_at !== "string" ||
|
|
52
|
+
typeof candidate.seven_day_util !== "number" ||
|
|
53
|
+
!Number.isFinite(candidate.seven_day_util) ||
|
|
54
|
+
typeof candidate.five_hour_util !== "number" ||
|
|
55
|
+
!Number.isFinite(candidate.five_hour_util) ||
|
|
56
|
+
!Array.isArray(candidate.per_model)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const capturedAtMs = Date.parse(candidate.captured_at);
|
|
60
|
+
if (!Number.isFinite(capturedAtMs) || now.getTime() - capturedAtMs > 24 * 60 * 60 * 1000) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const perModel = [];
|
|
64
|
+
for (const entry of candidate.per_model) {
|
|
65
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
66
|
+
return null;
|
|
67
|
+
const model = entry;
|
|
68
|
+
if (typeof model.model !== "string" ||
|
|
69
|
+
typeof model.utilization !== "number" ||
|
|
70
|
+
!Number.isFinite(model.utilization)) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
perModel.push({ model: model.model, utilization: model.utilization });
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
captured_at: candidate.captured_at,
|
|
77
|
+
seven_day_util: candidate.seven_day_util,
|
|
78
|
+
five_hour_util: candidate.five_hour_util,
|
|
79
|
+
per_model: perModel,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Parse a unit_prices_json array and return [input, output, cacheRead] prices. */
|
|
87
|
+
function parsePrices(json) {
|
|
88
|
+
if (!json)
|
|
89
|
+
return [0, 0];
|
|
90
|
+
try {
|
|
91
|
+
const arr = JSON.parse(json);
|
|
92
|
+
return [arr[0] ?? 0, arr[1] ?? 0];
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return [0, 0];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Get the freshest pricing snapshot for a given model tier, or null. */
|
|
99
|
+
function getTierPrices(db, tier) {
|
|
100
|
+
const row = db
|
|
101
|
+
.prepare(`SELECT unit_prices_json FROM pricing_snapshots
|
|
102
|
+
WHERE model_tier = ?
|
|
103
|
+
ORDER BY stale_after DESC
|
|
104
|
+
LIMIT 1`)
|
|
105
|
+
.get(tier);
|
|
106
|
+
if (!row)
|
|
107
|
+
return null;
|
|
108
|
+
return parsePrices(row.unit_prices_json);
|
|
109
|
+
}
|
|
110
|
+
export const d4Detector = {
|
|
111
|
+
id: "D4",
|
|
112
|
+
name: "MODEL_MISMATCH",
|
|
113
|
+
evaluate(db, ctx) {
|
|
114
|
+
// Cap attribution is advisory metadata only; any read or parse failure fails open.
|
|
115
|
+
const perModelSnapshot = getPerModelSnapshot(db, ctx.now);
|
|
116
|
+
// All non-global workspaces with at least one turn in the window.
|
|
117
|
+
const workspaces = db
|
|
118
|
+
.prepare(`SELECT DISTINCT workspace_id
|
|
119
|
+
FROM turns
|
|
120
|
+
WHERE ts >= ? AND ts < ?
|
|
121
|
+
AND model LIKE '%opus%'
|
|
122
|
+
AND provisional = 0`)
|
|
123
|
+
.all(ctx.fromIso, ctx.toIso);
|
|
124
|
+
if (workspaces.length === 0) {
|
|
125
|
+
return {
|
|
126
|
+
fired: [],
|
|
127
|
+
status: "INACTIVE",
|
|
128
|
+
note: "no Opus turns in window",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Resolve prices once (shared across all workspace evaluations).
|
|
132
|
+
const opusPrices = getTierPrices(db, "opus");
|
|
133
|
+
const sonnetPrices = getTierPrices(db, "sonnet-4");
|
|
134
|
+
const opusInputPrice = opusPrices?.[0] ?? OPUS_INPUT_FALLBACK;
|
|
135
|
+
const opusOutputPrice = opusPrices?.[1] ?? OPUS_OUTPUT_FALLBACK;
|
|
136
|
+
const sonnetInputPrice = sonnetPrices?.[0] ?? SONNET_INPUT_FALLBACK;
|
|
137
|
+
const sonnetOutputPrice = sonnetPrices?.[1] ?? SONNET_OUTPUT_FALLBACK;
|
|
138
|
+
const fired = [];
|
|
139
|
+
for (const { workspace_id } of workspaces) {
|
|
140
|
+
// Count ALL Opus turns for this workspace in the window (denominator for fraction).
|
|
141
|
+
const totalOpus = db
|
|
142
|
+
.prepare(`SELECT COUNT(*) AS n FROM turns
|
|
143
|
+
WHERE workspace_id = ?
|
|
144
|
+
AND model LIKE '%opus%'
|
|
145
|
+
AND ts >= ? AND ts < ?
|
|
146
|
+
AND provisional = 0`)
|
|
147
|
+
.get(workspace_id, ctx.fromIso, ctx.toIso).n;
|
|
148
|
+
// Cold-start guard: skip workspaces with too few Opus turns.
|
|
149
|
+
if (totalOpus < D4_OPUS_MIN_TURNS)
|
|
150
|
+
continue;
|
|
151
|
+
// Count mismatch turns (high context AND low output).
|
|
152
|
+
const mismatch = db
|
|
153
|
+
.prepare(`SELECT
|
|
154
|
+
COUNT(*) AS mismatch_count,
|
|
155
|
+
AVG(input_tokens) AS avg_input_tokens,
|
|
156
|
+
AVG(output_tokens) AS avg_output_tokens
|
|
157
|
+
FROM turns
|
|
158
|
+
WHERE workspace_id = ?
|
|
159
|
+
AND model LIKE '%opus%'
|
|
160
|
+
AND context_tokens >= ?
|
|
161
|
+
AND output_tokens <= ?
|
|
162
|
+
AND ts >= ? AND ts < ?
|
|
163
|
+
AND provisional = 0`)
|
|
164
|
+
.get(workspace_id, D4_MISMATCH_MIN_CONTEXT_TOKENS, D4_MISMATCH_MAX_OUTPUT_TOKENS, ctx.fromIso, ctx.toIso);
|
|
165
|
+
const mismatchCount = mismatch.mismatch_count ?? 0;
|
|
166
|
+
if (mismatchCount === 0)
|
|
167
|
+
continue;
|
|
168
|
+
const mismatchFraction = mismatchCount / totalOpus;
|
|
169
|
+
if (mismatchFraction < D4_MISMATCH_MIN_FRACTION)
|
|
170
|
+
continue;
|
|
171
|
+
const avgInputTokens = Math.round(mismatch.avg_input_tokens ?? 0);
|
|
172
|
+
const avgOutputTokens = Math.round(mismatch.avg_output_tokens ?? 0);
|
|
173
|
+
const { savingsU, formula } = d4Savings(avgInputTokens, avgOutputTokens, mismatchCount, opusInputPrice, opusOutputPrice, sonnetInputPrice, sonnetOutputPrice, D4_REDUCTION_FRACTION);
|
|
174
|
+
// Skip zero-savings recs (e.g., prices not loaded). The computed figure is
|
|
175
|
+
// retained as DIAGNOSTIC evidence only (see advisory gate below).
|
|
176
|
+
if (savingsU <= 0)
|
|
177
|
+
continue;
|
|
178
|
+
// This is evidence for an already-fired D4 recommendation, not another
|
|
179
|
+
// qualification rule. Keep its scope and time window aligned with D4.
|
|
180
|
+
const sidechainPremiumTurns = db
|
|
181
|
+
.prepare(`SELECT COUNT(*) AS n FROM turns
|
|
182
|
+
WHERE workspace_id = ?
|
|
183
|
+
AND model LIKE '%opus%'
|
|
184
|
+
AND is_sidechain = 1
|
|
185
|
+
AND ts >= ? AND ts < ?
|
|
186
|
+
AND provisional = 0`)
|
|
187
|
+
.get(workspace_id, ctx.fromIso, ctx.toIso).n;
|
|
188
|
+
const mismatchPct = Math.round(mismatchFraction * 100);
|
|
189
|
+
const sonnetEntries = perModelSnapshot?.per_model.filter((entry) => entry.model.toLowerCase().includes("sonnet"));
|
|
190
|
+
const bindingSonnet = sonnetEntries?.find((entry) => entry.utilization >= (perModelSnapshot?.seven_day_util ?? Number.POSITIVE_INFINITY));
|
|
191
|
+
const capEvidence = bindingSonnet && perModelSnapshot
|
|
192
|
+
? {
|
|
193
|
+
withheld: true,
|
|
194
|
+
withheld_reason: `Sonnet weekly cap is the binding constraint (Sonnet util ${bindingSonnet.utilization} >= all-models ${perModelSnapshot.seven_day_util}) — routing Opus->Sonnet would worsen it`,
|
|
195
|
+
title: `[withheld] Route Opus→Sonnet: ${mismatchPct}% of turns are high-context low-output`,
|
|
196
|
+
}
|
|
197
|
+
: perModelSnapshot && sonnetEntries && sonnetEntries.length > 0
|
|
198
|
+
? { cap_attribution: "all_models_or_opus_binds" }
|
|
199
|
+
: undefined;
|
|
200
|
+
fired.push({
|
|
201
|
+
scopeKey: `D4|${workspace_id}`,
|
|
202
|
+
category: "MODEL",
|
|
203
|
+
scope_workspace_id: workspace_id,
|
|
204
|
+
// Advisory gate (W0.3): which cap binds is NOT inferable from JSONL. Conditional lever.
|
|
205
|
+
lever: "If your all-models / Opus / 5h cap is the one binding — check /usage — these high-context low-output Opus turns are Sonnet-movable. This does NOT help, and can hurt, if your Sonnet-specific weekly cap is the binding constraint.",
|
|
206
|
+
target_metric: "model_mix_opus_fraction",
|
|
207
|
+
// Advisory gate: suppress the crisp $/wk headline until live /usage cap-attribution exists.
|
|
208
|
+
modeled_savings_u_per_wk: null,
|
|
209
|
+
// Retain the computation as a DIAGNOSTIC formula (kind ADVISORY), not a headline.
|
|
210
|
+
// Destructure out result_usd_per_wk so the advisory formula carries no crisp $/wk figure.
|
|
211
|
+
modeled_formula: (({ result_usd_per_wk: _, ...rest }) => ({ ...rest, kind: "ADVISORY" }))(formula),
|
|
212
|
+
evidence: {
|
|
213
|
+
title: `Route Opus→Sonnet: ${mismatchPct}% of turns are high-context low-output`,
|
|
214
|
+
workspace_id,
|
|
215
|
+
total_opus_turns_per_week: totalOpus,
|
|
216
|
+
mismatch_turns_per_week: mismatchCount,
|
|
217
|
+
sidechain_premium_turns: sidechainPremiumTurns,
|
|
218
|
+
mismatch_fraction: Number(mismatchFraction.toFixed(4)),
|
|
219
|
+
min_context_tokens_threshold: D4_MISMATCH_MIN_CONTEXT_TOKENS,
|
|
220
|
+
max_output_tokens_threshold: D4_MISMATCH_MAX_OUTPUT_TOKENS,
|
|
221
|
+
mismatch_fraction_threshold: D4_MISMATCH_MIN_FRACTION,
|
|
222
|
+
min_opus_turns_threshold: D4_OPUS_MIN_TURNS,
|
|
223
|
+
avg_input_tokens: avgInputTokens,
|
|
224
|
+
avg_output_tokens: avgOutputTokens,
|
|
225
|
+
opus_input_price_usd_per_mtok: opusInputPrice,
|
|
226
|
+
opus_output_price_usd_per_mtok: opusOutputPrice,
|
|
227
|
+
sonnet_input_price_usd_per_mtok: sonnetInputPrice,
|
|
228
|
+
sonnet_output_price_usd_per_mtok: sonnetOutputPrice,
|
|
229
|
+
reduction_fraction: D4_REDUCTION_FRACTION,
|
|
230
|
+
// Advisory framing: crisp savings suppressed; computed figure is diagnostic only.
|
|
231
|
+
advisory: true,
|
|
232
|
+
requires_usage_cap_data: true,
|
|
233
|
+
diagnostic_savings_u_per_wk_if_all_models_cap_binds: savingsU,
|
|
234
|
+
advisory_note: "Routing savings are only real if the all-models/Opus/5h cap is binding — check /usage. No crisp $/wk is emitted from transcripts alone.",
|
|
235
|
+
steps: [
|
|
236
|
+
"Check /usage to see which cap (all-models/Opus/5h vs Sonnet weekly) is actually binding",
|
|
237
|
+
"Only if the all-models/Opus cap binds: review the highest-Opus sessions in this workspace",
|
|
238
|
+
"Identify turns where the task is lookup, summarization, or formatting (not deep reasoning)",
|
|
239
|
+
"Route those turns to Sonnet; re-check this workspace's Opus fraction after 7 days",
|
|
240
|
+
],
|
|
241
|
+
...capEvidence,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (fired.length === 0) {
|
|
246
|
+
return {
|
|
247
|
+
fired: [],
|
|
248
|
+
status: "INACTIVE",
|
|
249
|
+
note: `no workspace met the mismatch threshold (≥${D4_OPUS_MIN_TURNS} Opus turns, ≥${Math.round(D4_MISMATCH_MIN_FRACTION * 100)}% high-context-low-output)`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
fired,
|
|
254
|
+
status: "ACTIVE",
|
|
255
|
+
note: `${fired.length} workspace(s) with sustained Opus mismatch pattern`,
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/detector/detectors/d5_limit_burn_forecast.ts — D5 LIMIT_BURN_FORECAST.
|
|
3
|
+
*
|
|
4
|
+
* Warning-class detector (no savings model). Uses the existing forecastFromDb
|
|
5
|
+
* (ADR-107 §D-5) under the injected clock. Burn figures are CAP-WEIGHTED
|
|
6
|
+
* (cache reads × COEFF, unverified — see src/query/cap-weighted.ts); the
|
|
7
|
+
* caveat travels in the fired evidence via `token_metric`. Fires (global scope,
|
|
8
|
+
* LIMIT category) when the forecast state is WARNING or EXCEEDED. state OFF
|
|
9
|
+
* (no :limit_tokens) → degraded burn-trend row when history exists, otherwise
|
|
10
|
+
* BLOCKED; OK / NO_BURN / COLD_START → INACTIVE.
|
|
11
|
+
*/
|
|
12
|
+
import { capWeightExprSql } from "../../query/cap-weighted.js";
|
|
13
|
+
import { forecastFromDb } from "../../query/forecast.js";
|
|
14
|
+
import { d5Formula } from "../savings.js";
|
|
15
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
16
|
+
const RECENT_BURN_WINDOW_DAYS = 1;
|
|
17
|
+
const BASELINE_BURN_WINDOW_DAYS = 7;
|
|
18
|
+
const CALIBRATION_NUDGE = "Calibrate to get a real limit";
|
|
19
|
+
function aggregateCapWeightedBurn(db, fromMs, toMs, coeff) {
|
|
20
|
+
const row = db
|
|
21
|
+
.prepare(`SELECT COALESCE(SUM(${capWeightExprSql("turns", coeff)}), 0) AS tokens,
|
|
22
|
+
COUNT(*) AS turns
|
|
23
|
+
FROM turns
|
|
24
|
+
WHERE ts >= ? AND ts < ?`)
|
|
25
|
+
.get(new Date(fromMs).toISOString(), new Date(toMs).toISOString());
|
|
26
|
+
return { tokens: row.tokens, turns: row.turns };
|
|
27
|
+
}
|
|
28
|
+
/** Compare the latest calendar day with the user's own preceding seven-day baseline. */
|
|
29
|
+
function degradedBurnTrend(db, now, coeff) {
|
|
30
|
+
const recentToMs = now.getTime();
|
|
31
|
+
const recentFromMs = recentToMs - RECENT_BURN_WINDOW_DAYS * MS_PER_DAY;
|
|
32
|
+
const baselineFromMs = recentFromMs - BASELINE_BURN_WINDOW_DAYS * MS_PER_DAY;
|
|
33
|
+
const recent = aggregateCapWeightedBurn(db, recentFromMs, recentToMs, coeff);
|
|
34
|
+
const baseline = aggregateCapWeightedBurn(db, baselineFromMs, recentFromMs, coeff);
|
|
35
|
+
if (recent.turns === 0 && baseline.turns === 0)
|
|
36
|
+
return null;
|
|
37
|
+
const recentTokensPerDay = recent.tokens / RECENT_BURN_WINDOW_DAYS;
|
|
38
|
+
const baselineTokensPerDay = baseline.tokens / BASELINE_BURN_WINDOW_DAYS;
|
|
39
|
+
const deltaTokensPerDay = recentTokensPerDay - baselineTokensPerDay;
|
|
40
|
+
const direction = deltaTokensPerDay > 0 ? "RISING" : deltaTokensPerDay < 0 ? "FALLING" : "FLAT";
|
|
41
|
+
return {
|
|
42
|
+
direction,
|
|
43
|
+
recentTokens: recent.tokens,
|
|
44
|
+
recentTokensPerDay,
|
|
45
|
+
baselineTokens: baseline.tokens,
|
|
46
|
+
baselineTokensPerDay,
|
|
47
|
+
deltaTokensPerDay,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function directionLabel(direction) {
|
|
51
|
+
switch (direction) {
|
|
52
|
+
case "RISING":
|
|
53
|
+
return "rising";
|
|
54
|
+
case "FALLING":
|
|
55
|
+
return "falling";
|
|
56
|
+
case "FLAT":
|
|
57
|
+
return "flat";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export const d5Detector = {
|
|
61
|
+
id: "D5",
|
|
62
|
+
name: "LIMIT_BURN_FORECAST",
|
|
63
|
+
evaluate(db, ctx) {
|
|
64
|
+
const forecast = forecastFromDb(db, { now: ctx.now });
|
|
65
|
+
if (forecast.state === "OFF") {
|
|
66
|
+
const trend = degradedBurnTrend(db, ctx.now, forecast.cap_read_coeff);
|
|
67
|
+
if (trend === null) {
|
|
68
|
+
return { fired: [], status: "BLOCKED", note: "Weekly token limit is not configured" };
|
|
69
|
+
}
|
|
70
|
+
const fired = {
|
|
71
|
+
scopeKey: "D5|global|LIMIT_BURN_FORECAST",
|
|
72
|
+
category: "LIMIT",
|
|
73
|
+
scope_workspace_id: null,
|
|
74
|
+
lever: `${CALIBRATION_NUDGE}, then use this burn trend to guide workload.`,
|
|
75
|
+
target_metric: "burn_trend",
|
|
76
|
+
modeled_savings_u_per_wk: null,
|
|
77
|
+
modeled_formula: d5Formula(),
|
|
78
|
+
evidence: {
|
|
79
|
+
title: `Burn trend ${directionLabel(trend.direction)}: ${CALIBRATION_NUDGE}`,
|
|
80
|
+
state: "DEGRADED",
|
|
81
|
+
forecast_state: forecast.state,
|
|
82
|
+
tokens_used: forecast.tokens_used,
|
|
83
|
+
limit_tokens: null,
|
|
84
|
+
projected_exhaustion_jd: null,
|
|
85
|
+
token_metric: forecast.token_metric,
|
|
86
|
+
cap_weighted: forecast.cap_weighted,
|
|
87
|
+
burn_trend: trend.direction,
|
|
88
|
+
recent_cap_weighted_tokens: trend.recentTokens,
|
|
89
|
+
recent_cap_weighted_tokens_per_day: trend.recentTokensPerDay,
|
|
90
|
+
baseline_cap_weighted_tokens: trend.baselineTokens,
|
|
91
|
+
baseline_cap_weighted_tokens_per_day: trend.baselineTokensPerDay,
|
|
92
|
+
baseline_window_days: BASELINE_BURN_WINDOW_DAYS,
|
|
93
|
+
trend_delta_cap_weighted_tokens_per_day: trend.deltaTokensPerDay,
|
|
94
|
+
calibration_nudge: CALIBRATION_NUDGE,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
fired: [fired],
|
|
99
|
+
status: "ACTIVE",
|
|
100
|
+
note: `degraded burn trend ${directionLabel(trend.direction)}; calibrate to get a real limit`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (forecast.state === "WARNING" || forecast.state === "EXCEEDED") {
|
|
104
|
+
const stateLabel = forecast.state === "EXCEEDED" ? "EXCEEDED" : "warning";
|
|
105
|
+
const fired = {
|
|
106
|
+
scopeKey: "D5|global|LIMIT_BURN_FORECAST",
|
|
107
|
+
category: "LIMIT",
|
|
108
|
+
scope_workspace_id: null,
|
|
109
|
+
lever: "Scope work to the top-burn workspace/sessions before the weekly reset; reduce burn rate.",
|
|
110
|
+
target_metric: "forecast_margin",
|
|
111
|
+
modeled_savings_u_per_wk: null,
|
|
112
|
+
modeled_formula: d5Formula(),
|
|
113
|
+
evidence: {
|
|
114
|
+
title: `Rate-limit ${stateLabel}: reduce burn before weekly reset`,
|
|
115
|
+
state: forecast.state,
|
|
116
|
+
tokens_used: forecast.tokens_used,
|
|
117
|
+
limit_tokens: forecast.limit_tokens,
|
|
118
|
+
projected_exhaustion_jd: forecast.projected_exhaustion_jd,
|
|
119
|
+
token_metric: forecast.token_metric,
|
|
120
|
+
cap_weighted: true,
|
|
121
|
+
// Legacy limit-scale honesty flag (review P1): true when the stored
|
|
122
|
+
// limit was calibrated under the old full-weight meter, so the fired
|
|
123
|
+
// evidence carries the caveat instead of silently trusting the number.
|
|
124
|
+
limit_scale_legacy: forecast.limit_scale_legacy,
|
|
125
|
+
...(forecast.limit_scale_note !== null
|
|
126
|
+
? { limit_scale_note: forecast.limit_scale_note }
|
|
127
|
+
: {}),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
return { fired: [fired], status: "ACTIVE", note: `burn forecast ${forecast.state}` };
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
fired: [],
|
|
134
|
+
status: "INACTIVE",
|
|
135
|
+
note: `burn forecast ${forecast.state} (no exhaustion projected within the warn window)`,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
};
|