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.
Files changed (157) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +116 -0
  3. package/dist/apply/jobs.js +429 -0
  4. package/dist/apply/open-terminal-child.mjs +98 -0
  5. package/dist/apply/open-terminal.js +221 -0
  6. package/dist/apply/settings-gen.js +35 -0
  7. package/dist/cli/agentwrangler.js +18 -0
  8. package/dist/daemon/config.js +51 -0
  9. package/dist/daemon/http.js +258 -0
  10. package/dist/daemon/index.js +372 -0
  11. package/dist/daemon/outcomes-pass.js +82 -0
  12. package/dist/daemon/readiness.js +15 -0
  13. package/dist/daemon/router.js +756 -0
  14. package/dist/daemon/static.js +146 -0
  15. package/dist/db/migrate.js +72 -0
  16. package/dist/db/migrations/001_observe.sql +196 -0
  17. package/dist/db/migrations/002_indexes.sql +6 -0
  18. package/dist/db/migrations/003_context_inventory_history.sql +20 -0
  19. package/dist/db/migrations/004_apply_jobs.sql +17 -0
  20. package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
  21. package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
  22. package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
  23. package/dist/db/migrations/008_thinking_tokens.sql +1 -0
  24. package/dist/db/migrations/009_user_turn_count.sql +1 -0
  25. package/dist/db/migrations/010_workspace_cwd.sql +1 -0
  26. package/dist/db/migrations/011_reports.sql +1 -0
  27. package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
  28. package/dist/db/migrations/013_friction_fields.sql +5 -0
  29. package/dist/db/migrations/014_session_churn.sql +11 -0
  30. package/dist/db/migrations/015_gap_aggregates.sql +6 -0
  31. package/dist/db/open.js +30 -0
  32. package/dist/detector/benchmark-anchors.js +36 -0
  33. package/dist/detector/calibration.js +302 -0
  34. package/dist/detector/context-history-retention.js +312 -0
  35. package/dist/detector/context-probe.js +574 -0
  36. package/dist/detector/d1-source-identity.js +25 -0
  37. package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
  38. package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
  39. package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
  40. package/dist/detector/detectors/d4_model_mismatch.js +258 -0
  41. package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
  42. package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
  43. package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
  44. package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
  45. package/dist/detector/detectors/d9_idle_background_session.js +101 -0
  46. package/dist/detector/engine.js +88 -0
  47. package/dist/detector/index.js +17 -0
  48. package/dist/detector/measurement.js +426 -0
  49. package/dist/detector/practice-registry.js +259 -0
  50. package/dist/detector/registry.js +32 -0
  51. package/dist/detector/savings.js +249 -0
  52. package/dist/detector/types.js +14 -0
  53. package/dist/evidence/common/approved-input.js +632 -0
  54. package/dist/evidence/common/boundary.js +84 -0
  55. package/dist/evidence/common/canonical.js +55 -0
  56. package/dist/evidence/common/redaction.js +321 -0
  57. package/dist/evidence/common/sqlite.js +25 -0
  58. package/dist/evidence/common/state.js +29 -0
  59. package/dist/evidence/cond1/cli.js +289 -0
  60. package/dist/evidence/cond1/packet.js +407 -0
  61. package/dist/evidence/cond1/prepare.js +295 -0
  62. package/dist/evidence/cond1/score.js +349 -0
  63. package/dist/evidence/cond1/types.js +1 -0
  64. package/dist/evidence/create-approval.js +365 -0
  65. package/dist/evidence/create-scratch.js +542 -0
  66. package/dist/evidence/d7/cli.js +113 -0
  67. package/dist/evidence/d7/measure.js +193 -0
  68. package/dist/evidence/d7/types.js +1 -0
  69. package/dist/evidence/discover-approval.js +492 -0
  70. package/dist/evidence/g2/adjudicate.js +20 -0
  71. package/dist/evidence/g2/cli.js +207 -0
  72. package/dist/evidence/g2/kappa.js +39 -0
  73. package/dist/evidence/g2/pipeline.js +92 -0
  74. package/dist/evidence/g2/store.js +14 -0
  75. package/dist/evidence/github/client.js +1 -0
  76. package/dist/evidence/github/gh-cli-client.js +301 -0
  77. package/dist/evidence/r3/cli.js +209 -0
  78. package/dist/evidence/r3/evaluate.js +417 -0
  79. package/dist/evidence/r3/packet.js +162 -0
  80. package/dist/evidence/r3/prepare.js +405 -0
  81. package/dist/evidence/r3/score.js +341 -0
  82. package/dist/evidence/r3/transcript.js +155 -0
  83. package/dist/evidence/r3/types.js +4 -0
  84. package/dist/hook/context-budget-hook.mjs +138 -0
  85. package/dist/hook/danger-guard-denylist.json +27 -0
  86. package/dist/hook/danger-guard-hook.mjs +167 -0
  87. package/dist/hook/install.js +0 -0
  88. package/dist/hook/limit-burn-hook.mjs +127 -0
  89. package/dist/hook/loop-guard-hook.mjs +104 -0
  90. package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
  91. package/dist/ingest/churn-collector.js +122 -0
  92. package/dist/ingest/detector-hook.js +52 -0
  93. package/dist/ingest/discovery.js +207 -0
  94. package/dist/ingest/health.js +43 -0
  95. package/dist/ingest/index.js +28 -0
  96. package/dist/ingest/ingestor.js +509 -0
  97. package/dist/ingest/parser.js +344 -0
  98. package/dist/ingest/pricing.js +153 -0
  99. package/dist/ingest/reconcile.js +52 -0
  100. package/dist/ingest/tail.js +152 -0
  101. package/dist/ingest/types.js +24 -0
  102. package/dist/ingest/workspace-mapping.js +114 -0
  103. package/dist/oauth/anthropic-api-key.js +88 -0
  104. package/dist/oauth/count-tokens.js +86 -0
  105. package/dist/oauth/credentials.js +171 -0
  106. package/dist/oauth/judge-g2-client.js +154 -0
  107. package/dist/oauth/usage.js +167 -0
  108. package/dist/outcomes/branch-key.js +49 -0
  109. package/dist/outcomes/conclusions.js +45 -0
  110. package/dist/outcomes/derive.js +94 -0
  111. package/dist/outcomes/finding-extractors.js +131 -0
  112. package/dist/outcomes/findings.js +237 -0
  113. package/dist/outcomes/github/client.js +367 -0
  114. package/dist/outcomes/github/credential.js +195 -0
  115. package/dist/outcomes/github/gh-cli-client.js +340 -0
  116. package/dist/outcomes/linker.js +486 -0
  117. package/dist/outcomes/pool.js +24 -0
  118. package/dist/outcomes/sync.js +276 -0
  119. package/dist/query/api/agents-liveness.js +182 -0
  120. package/dist/query/api/burn-status.js +50 -0
  121. package/dist/query/api/context-budget.js +114 -0
  122. package/dist/query/api/context-composition.js +67 -0
  123. package/dist/query/api/cost-per-success.js +104 -0
  124. package/dist/query/api/delivery.js +92 -0
  125. package/dist/query/api/effectiveness.js +254 -0
  126. package/dist/query/api/efficiency-headroom.js +74 -0
  127. package/dist/query/api/headroom-trend.js +105 -0
  128. package/dist/query/api/hook-config.js +75 -0
  129. package/dist/query/api/hook-install.js +8 -0
  130. package/dist/query/api/hot-sessions.js +17 -0
  131. package/dist/query/api/idle-sessions.js +52 -0
  132. package/dist/query/api/index.js +40 -0
  133. package/dist/query/api/loop-guard.js +90 -0
  134. package/dist/query/api/offload-share.js +41 -0
  135. package/dist/query/api/outcomes.js +218 -0
  136. package/dist/query/api/overview.js +535 -0
  137. package/dist/query/api/rec-prompt.js +138 -0
  138. package/dist/query/api/recommendations-ledger.js +111 -0
  139. package/dist/query/api/recommendations.js +514 -0
  140. package/dist/query/api/reports.js +78 -0
  141. package/dist/query/api/self-churn.js +77 -0
  142. package/dist/query/api/self-percentiles.js +109 -0
  143. package/dist/query/api/session-drivers.js +153 -0
  144. package/dist/query/api/settings.js +85 -0
  145. package/dist/query/api/spend-flavor.js +234 -0
  146. package/dist/query/api/trends.js +155 -0
  147. package/dist/query/cap-weighted.js +119 -0
  148. package/dist/query/db-context.js +42 -0
  149. package/dist/query/envelope.js +71 -0
  150. package/dist/query/forecast.js +191 -0
  151. package/dist/query/settings-store.js +441 -0
  152. package/dist/query/spend.js +171 -0
  153. package/dist/query/trends.js +194 -0
  154. package/dist/ui/assets/index-DnRKgc21.css +1 -0
  155. package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
  156. package/dist/ui/index.html +39 -0
  157. package/package.json +59 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * src/detector/calibration.ts — Bytes→token calibration for D6 (R12).
3
+ *
4
+ * Calibrates the bytes-per-token ratio used by D6 TOOL_RESULT_BLOAT by sampling
5
+ * real tool-result content blocks from the user's own transcripts and counting
6
+ * their tokens via Anthropic's free count_tokens API (no generation, no charge).
7
+ *
8
+ * Privacy gate (opt-in, OFF by default):
9
+ * Calibration sends sampled tool-result TEXT to Anthropic count_tokens.
10
+ * AgentWrangler's invariant is "no data leaves your machine," so calibration
11
+ * requires explicit opt-in via user_config.bytes_per_token_calibration_enabled.
12
+ * When disabled: no text leaves, D6 uses the default ratio, modeled_savings=null.
13
+ *
14
+ * SEC-101:
15
+ * Sampled text lives in memory ONLY for the count_tokens call and is immediately
16
+ * discarded. Only byte length, token count, the final ratio, and the provenance
17
+ * string are stored (in user_config). No raw content ever reaches the DB, a file,
18
+ * or any log.
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { countTokens } from "../oauth/count-tokens.js";
23
+ // ---------------------------------------------------------------------------
24
+ // Default constants
25
+ // ---------------------------------------------------------------------------
26
+ /** Default bytes-per-token when no calibration has been run. */
27
+ export const DEFAULT_BYTES_PER_TOKEN = 4;
28
+ /** Fallback model when the DB has no turns or the auto-picked model 404s. */
29
+ const FALLBACK_MODEL = "claude-sonnet-4-6";
30
+ /** Minimum successful samples before we trust the median. */
31
+ const MIN_SAMPLES = 30;
32
+ /** Default maximum samples to collect. */
33
+ const DEFAULT_MAX_SAMPLES = 150;
34
+ /** Skip blocks larger than this (bytes). */
35
+ const DEFAULT_MAX_BLOCK_BYTES = 100_000;
36
+ // ---------------------------------------------------------------------------
37
+ // user_config helpers (local; mirrors settings-store pattern)
38
+ // ---------------------------------------------------------------------------
39
+ export function configGet(db, key) {
40
+ const row = db.prepare("SELECT value FROM user_config WHERE key = ?").get(key);
41
+ return row?.value ?? null;
42
+ }
43
+ export function configSet(db, key, value) {
44
+ db.prepare(`INSERT INTO user_config (key, value, updated_at) VALUES (?, ?, ?)
45
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`).run(key, value, new Date().toISOString());
46
+ }
47
+ /**
48
+ * Resolve the bytes-per-token ratio from user_config.
49
+ * Falls back to DEFAULT_BYTES_PER_TOKEN (4) when no calibration has been run.
50
+ */
51
+ export function resolveBytesPerToken(db) {
52
+ const raw = configGet(db, "bytes_per_token");
53
+ const measuredAt = configGet(db, "bytes_per_token_measured_at");
54
+ const provenance = configGet(db, "bytes_per_token_provenance");
55
+ // Extract model from provenance ("calibrated {date} via count_tokens · model {model} · ...")
56
+ let model = null;
57
+ if (provenance !== null) {
58
+ const m = /· model ([^ ·]+)/.exec(provenance);
59
+ if (m)
60
+ model = m[1] ?? null;
61
+ }
62
+ if (raw === null) {
63
+ return {
64
+ ratio: DEFAULT_BYTES_PER_TOKEN,
65
+ calibrated: false,
66
+ measuredAt: null,
67
+ model: null,
68
+ provenance: null,
69
+ };
70
+ }
71
+ const parsed = Number(raw);
72
+ if (!Number.isFinite(parsed) || parsed <= 0) {
73
+ return {
74
+ ratio: DEFAULT_BYTES_PER_TOKEN,
75
+ calibrated: false,
76
+ measuredAt: null,
77
+ model: null,
78
+ provenance: null,
79
+ };
80
+ }
81
+ return { ratio: parsed, calibrated: true, measuredAt, model, provenance };
82
+ }
83
+ /** Resolve text content from a tool_result block (string or content[].type=text). */
84
+ function extractToolResultText(b) {
85
+ const content = b.content;
86
+ if (typeof content === "string")
87
+ return content || null;
88
+ if (Array.isArray(content)) {
89
+ const parts = [];
90
+ for (const c of content) {
91
+ if (typeof c === "object" &&
92
+ c !== null &&
93
+ c.type === "text" &&
94
+ typeof c.text === "string") {
95
+ parts.push(c.text);
96
+ }
97
+ }
98
+ return parts.length > 0 ? parts.join("") : null;
99
+ }
100
+ return null;
101
+ }
102
+ /** Recursively collect JSONL file paths under a directory. */
103
+ function collectJsonlPaths(dir, out, limit) {
104
+ if (out.length >= limit)
105
+ return;
106
+ let entries;
107
+ try {
108
+ entries = fs.readdirSync(dir, { withFileTypes: true });
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ for (const e of entries) {
114
+ if (out.length >= limit)
115
+ break;
116
+ if (e.isFile() && e.name.endsWith(".jsonl")) {
117
+ out.push(path.join(dir, e.name));
118
+ }
119
+ }
120
+ for (const e of entries) {
121
+ if (out.length >= limit)
122
+ break;
123
+ if (e.isDirectory()) {
124
+ collectJsonlPaths(path.join(dir, e.name), out, limit);
125
+ }
126
+ }
127
+ }
128
+ /**
129
+ * Sample tool_result text blocks from JSONL files under scanRoots.
130
+ *
131
+ * SEC-101: returned text is in-memory only; never written to disk or DB.
132
+ * Callers must discard text immediately after counting tokens.
133
+ */
134
+ export function sampleToolResultBlocks(scanRoots, opts = {}) {
135
+ const maxSamples = opts.maxSamples ?? DEFAULT_MAX_SAMPLES;
136
+ const maxBlockBytes = opts.maxBlockBytes ?? DEFAULT_MAX_BLOCK_BYTES;
137
+ const maxFiles = opts.maxFiles ?? 500;
138
+ // Collect JSONL file paths across all scan roots.
139
+ const filePaths = [];
140
+ for (const root of scanRoots) {
141
+ collectJsonlPaths(root, filePaths, maxFiles);
142
+ if (filePaths.length >= maxFiles)
143
+ break;
144
+ }
145
+ const samples = [];
146
+ for (const filePath of filePaths) {
147
+ if (samples.length >= maxSamples)
148
+ break;
149
+ let content;
150
+ try {
151
+ content = fs.readFileSync(filePath, "utf8");
152
+ }
153
+ catch {
154
+ continue;
155
+ }
156
+ const lines = content.split("\n");
157
+ for (const line of lines) {
158
+ if (samples.length >= maxSamples)
159
+ break;
160
+ const trimmed = line.trim();
161
+ if (trimmed === "")
162
+ continue;
163
+ let rec;
164
+ try {
165
+ rec = JSON.parse(trimmed);
166
+ }
167
+ catch {
168
+ continue;
169
+ }
170
+ if (typeof rec !== "object" || rec === null)
171
+ continue;
172
+ const r = rec;
173
+ const message = typeof r.message === "object" && r.message !== null
174
+ ? r.message
175
+ : null;
176
+ if (!message || !Array.isArray(message.content))
177
+ continue;
178
+ for (const block of message.content) {
179
+ if (samples.length >= maxSamples)
180
+ break;
181
+ if (typeof block !== "object" || block === null)
182
+ continue;
183
+ const b = block;
184
+ if (b.type !== "tool_result")
185
+ continue;
186
+ const text = extractToolResultText(b);
187
+ if (text === null || text.length === 0)
188
+ continue;
189
+ // Measure UTF-8 byte length.
190
+ const bytes = Buffer.byteLength(text, "utf8");
191
+ if (bytes > maxBlockBytes)
192
+ continue;
193
+ // SEC-101: text is in-memory only; will be discarded after counter() call.
194
+ samples.push({ text, bytes });
195
+ }
196
+ }
197
+ }
198
+ return samples;
199
+ }
200
+ // ---------------------------------------------------------------------------
201
+ // Main calibration function
202
+ // ---------------------------------------------------------------------------
203
+ /**
204
+ * Calibrate the bytes-per-token ratio by sampling real tool-result content
205
+ * blocks and counting tokens via Anthropic's count_tokens API.
206
+ *
207
+ * Steps:
208
+ * 1. Guard: opt-in flag must be enabled.
209
+ * 2. Determine model (most-frequent turns.model; fall back to FALLBACK_MODEL).
210
+ * 3. Sample blocks; count tokens; require ≥ MIN_SAMPLES successful counts.
211
+ * 4. Compute median bytes/input_tokens ratio.
212
+ * 5. Persist to user_config (counts/ratio only; no text).
213
+ * 6. Return {ok, ratio, n, model, provenance}.
214
+ *
215
+ * @param db The SQLite database (for opt-in flag + model query + persistence).
216
+ * @param opts.model Override the auto-picked model (tests / manual trigger).
217
+ * @param opts.counter Injectable token counter (defaults to countTokens).
218
+ * @param opts.sampler Injectable block sampler (defaults to sampleToolResultBlocks).
219
+ * @param opts.scanRoots Override scan roots (defaults to user_config.scan_roots + daemon config).
220
+ */
221
+ export async function calibrateBytesPerToken(db, opts = {}) {
222
+ // 1. Guard: opt-in required.
223
+ const enabled = configGet(db, "bytes_per_token_calibration_enabled");
224
+ if (enabled !== "true") {
225
+ return { ok: false, reason: "calibration disabled — enable in Settings first" };
226
+ }
227
+ // 2. Determine model.
228
+ let model = opts.model ?? null;
229
+ if (model === null) {
230
+ const row = db
231
+ .prepare(`SELECT model, COUNT(*) AS cnt
232
+ FROM turns
233
+ WHERE model IS NOT NULL AND model != ''
234
+ GROUP BY model
235
+ ORDER BY cnt DESC
236
+ LIMIT 1`)
237
+ .get();
238
+ model = row?.model ?? FALLBACK_MODEL;
239
+ }
240
+ // 3. Sample blocks and count tokens.
241
+ const counter = opts.counter ?? countTokens;
242
+ const sampler = opts.sampler ?? sampleToolResultBlocks;
243
+ // Resolve scan roots: from user_config or caller override.
244
+ let scanRoots = opts.scanRoots ?? [];
245
+ if (scanRoots.length === 0) {
246
+ const raw = configGet(db, "scan_roots");
247
+ if (raw !== null) {
248
+ try {
249
+ const parsed = JSON.parse(raw);
250
+ if (Array.isArray(parsed))
251
+ scanRoots = parsed;
252
+ }
253
+ catch {
254
+ // ignore
255
+ }
256
+ }
257
+ }
258
+ // Collect samples (text in memory only — SEC-101).
259
+ const blocks = sampler(scanRoots, { maxSamples: DEFAULT_MAX_SAMPLES });
260
+ // Try the auto-picked model first; fall back to FALLBACK_MODEL on 404.
261
+ const usedModel = model;
262
+ const ratios = [];
263
+ let fallbackUsed = false;
264
+ for (const block of blocks) {
265
+ // SEC-101: block.text lives here only for this count call.
266
+ let result = await counter(block.text, usedModel);
267
+ // Fall back to stable model on 404 (old model id).
268
+ if (!result.ok && result.status === 404 && usedModel !== FALLBACK_MODEL) {
269
+ result = await counter(block.text, FALLBACK_MODEL);
270
+ fallbackUsed = true;
271
+ }
272
+ if (!result.ok)
273
+ continue;
274
+ if (result.input_tokens <= 0)
275
+ continue;
276
+ ratios.push(block.bytes / result.input_tokens);
277
+ }
278
+ // 4. Require minimum sample count.
279
+ if (ratios.length < MIN_SAMPLES) {
280
+ return {
281
+ ok: false,
282
+ reason: `Only ${ratios.length} successful sample(s) — need ≥${MIN_SAMPLES} (check OAuth login or increase scan roots).`,
283
+ };
284
+ }
285
+ // 5. Compute median.
286
+ const sorted = [...ratios].sort((a, b) => a - b);
287
+ const mid = Math.floor(sorted.length / 2);
288
+ const median = sorted.length % 2 === 1
289
+ ? sorted[mid]
290
+ : (sorted[mid - 1] + sorted[mid]) / 2;
291
+ const effectiveModel = fallbackUsed ? FALLBACK_MODEL : usedModel;
292
+ const date = new Date().toISOString().slice(0, 10);
293
+ const ratioStr = median.toFixed(4);
294
+ const provenance = `calibrated ${date} via count_tokens · model ${effectiveModel} · N=${ratios.length} · median ${ratioStr}`;
295
+ // 6. Persist (counts/ratio/provenance only — no text).
296
+ db.transaction(() => {
297
+ configSet(db, "bytes_per_token", String(median));
298
+ configSet(db, "bytes_per_token_measured_at", new Date().toISOString());
299
+ configSet(db, "bytes_per_token_provenance", provenance);
300
+ })();
301
+ return { ok: true, ratio: median, n: ratios.length, model: effectiveModel, provenance };
302
+ }
@@ -0,0 +1,312 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { GLOBAL_WORKSPACE_ID } from "./context-probe.js";
3
+ import { isD1SourceBackedRecommendation, parseD1SourceIdentity } from "./d1-source-identity.js";
4
+ import { AFTER_WINDOW_DAYS } from "./measurement.js";
5
+ export const CONTEXT_HISTORY_RETENTION_VERSION = "context-history-retention-v1";
6
+ export const DEFAULT_CONTEXT_HISTORY_RETENTION_POLICY = Object.freeze({
7
+ maxAgeDays: 90,
8
+ maxUnprotectedRowsPerSource: 64,
9
+ });
10
+ const COMPONENTS = new Set([
11
+ "CLAUDE_MD",
12
+ "RULES",
13
+ "MCP_SCHEMAS",
14
+ "SETTINGS_SYSTEM",
15
+ "MEMORY",
16
+ "OTHER",
17
+ ]);
18
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
19
+ const DELETE_CHUNK_SIZE = 500;
20
+ class RetentionPlanError extends Error {
21
+ failureClass;
22
+ malformedOpenEffectN;
23
+ constructor(failureClass, malformedOpenEffectN = 0) {
24
+ super(failureClass);
25
+ this.failureClass = failureClass;
26
+ this.malformedOpenEffectN = malformedOpenEffectN;
27
+ }
28
+ }
29
+ function sourceKey(workspaceId, component, fileRef) {
30
+ return JSON.stringify([workspaceId, component, fileRef]);
31
+ }
32
+ function canonicalTimestamp(value) {
33
+ if (typeof value !== "string")
34
+ return null;
35
+ const ms = Date.parse(value);
36
+ if (!Number.isFinite(ms) || new Date(ms).toISOString() !== value)
37
+ return null;
38
+ return ms;
39
+ }
40
+ function validateInputs(now, policy) {
41
+ const nowMs = now.getTime();
42
+ if (!Number.isFinite(nowMs))
43
+ throw new RetentionPlanError("invalid_clock");
44
+ if (!Number.isSafeInteger(policy.maxAgeDays) ||
45
+ policy.maxAgeDays <= 0 ||
46
+ !Number.isSafeInteger(policy.maxAgeDays * MS_PER_DAY) ||
47
+ !Number.isSafeInteger(policy.maxUnprotectedRowsPerSource) ||
48
+ policy.maxUnprotectedRowsPerSource < 0) {
49
+ throw new RetentionPlanError("invalid_policy");
50
+ }
51
+ return { nowMs, startedAt: now.toISOString() };
52
+ }
53
+ function validateHistoryRows(db) {
54
+ const rows = db
55
+ .prepare(`SELECT id, workspace_id, component, file_ref, observed_at
56
+ FROM context_inventory_history
57
+ ORDER BY id ASC`)
58
+ .all();
59
+ return rows.map((row) => {
60
+ const observedMs = canonicalTimestamp(row.observed_at);
61
+ if (!Number.isSafeInteger(row.id) ||
62
+ row.id <= 0 ||
63
+ typeof row.workspace_id !== "string" ||
64
+ row.workspace_id.length === 0 ||
65
+ !COMPONENTS.has(row.component) ||
66
+ typeof row.file_ref !== "string" ||
67
+ row.file_ref.length === 0 ||
68
+ observedMs === null) {
69
+ throw new RetentionPlanError("invalid_history_row");
70
+ }
71
+ const component = row.component;
72
+ return {
73
+ ...row,
74
+ component,
75
+ observedMs,
76
+ sourceKey: sourceKey(row.workspace_id, component, row.file_ref),
77
+ };
78
+ });
79
+ }
80
+ function readOpenEffects(db) {
81
+ const recs = db
82
+ .prepare(`SELECT rec_id, detector_id, scope_workspace_id, evidence_json, target_metric, adopted_at
83
+ FROM recommendations
84
+ WHERE state IN ('ADOPTED', 'MEASURING')
85
+ ORDER BY rec_id ASC`)
86
+ .all();
87
+ const sourceBacked = recs.filter(isD1SourceBackedRecommendation);
88
+ const effects = [];
89
+ let malformedN = 0;
90
+ for (const rec of sourceBacked) {
91
+ const adoptedMs = canonicalTimestamp(rec.adopted_at);
92
+ const identity = parseD1SourceIdentity(rec.evidence_json);
93
+ const component = identity?.component;
94
+ const workspaceId = rec.scope_workspace_id ?? GLOBAL_WORKSPACE_ID;
95
+ const openRows = db
96
+ .prepare(`SELECT measured_at, before_from, before_to, after_from, after_to
97
+ FROM recommendation_effects
98
+ WHERE rec_id = ? AND verdict IS NULL
99
+ ORDER BY measured_at ASC`)
100
+ .all(rec.rec_id);
101
+ if (adoptedMs === null ||
102
+ identity === null ||
103
+ identity.fileRef.length === 0 ||
104
+ !COMPONENTS.has(component) ||
105
+ workspaceId.length === 0 ||
106
+ openRows.length !== 1) {
107
+ malformedN++;
108
+ continue;
109
+ }
110
+ const effect = openRows[0];
111
+ if (effect === undefined) {
112
+ malformedN++;
113
+ continue;
114
+ }
115
+ const measuredMs = canonicalTimestamp(effect.measured_at);
116
+ const beforeFromMs = canonicalTimestamp(effect.before_from);
117
+ const beforeToMs = canonicalTimestamp(effect.before_to);
118
+ const afterFromMs = canonicalTimestamp(effect.after_from);
119
+ const afterToMs = canonicalTimestamp(effect.after_to);
120
+ if (measuredMs === null ||
121
+ beforeFromMs === null ||
122
+ beforeToMs === null ||
123
+ afterFromMs === null ||
124
+ afterToMs === null ||
125
+ effect.measured_at !== rec.adopted_at ||
126
+ measuredMs !== adoptedMs ||
127
+ beforeFromMs > beforeToMs ||
128
+ beforeToMs !== adoptedMs ||
129
+ afterFromMs !== adoptedMs ||
130
+ afterToMs !== adoptedMs + AFTER_WINDOW_DAYS * MS_PER_DAY) {
131
+ malformedN++;
132
+ continue;
133
+ }
134
+ effects.push({
135
+ sourceKey: sourceKey(workspaceId, component, identity.fileRef),
136
+ beforeToMs,
137
+ afterFromMs,
138
+ afterToMs,
139
+ });
140
+ }
141
+ if (malformedN > 0)
142
+ throw new RetentionPlanError("invalid_open_effect", malformedN);
143
+ return effects;
144
+ }
145
+ function emptySummary(mode, startedAt, failureClass, malformedOpenEffectN = 0) {
146
+ return {
147
+ policy_version: CONTEXT_HISTORY_RETENTION_VERSION,
148
+ mode,
149
+ started_at: startedAt,
150
+ source_n: 0,
151
+ rows_before: 0,
152
+ latest_protected_n: 0,
153
+ open_window_protected_n: 0,
154
+ recent_retained_n: 0,
155
+ count_retained_n: 0,
156
+ delete_candidate_n: 0,
157
+ rows_deleted: 0,
158
+ rows_after: 0,
159
+ malformed_open_effect_n: malformedOpenEffectN,
160
+ duration_ms: 0,
161
+ failure_class: failureClass,
162
+ };
163
+ }
164
+ function planRetention(db, now, policy, mode) {
165
+ const { nowMs, startedAt } = validateInputs(now, policy);
166
+ const rows = validateHistoryRows(db);
167
+ const effects = readOpenEffects(db);
168
+ const bySource = new Map();
169
+ for (const row of rows) {
170
+ const group = bySource.get(row.sourceKey);
171
+ if (group === undefined)
172
+ bySource.set(row.sourceKey, [row]);
173
+ else
174
+ group.push(row);
175
+ }
176
+ for (const group of bySource.values()) {
177
+ group.sort((a, b) => b.observedMs - a.observedMs || b.id - a.id);
178
+ }
179
+ const latestIds = new Set();
180
+ for (const group of bySource.values()) {
181
+ const latest = group[0];
182
+ if (latest === undefined)
183
+ throw new RetentionPlanError("latest_invariant");
184
+ latestIds.add(latest.id);
185
+ }
186
+ const openWindowIds = new Set();
187
+ for (const effect of effects) {
188
+ const group = bySource.get(effect.sourceKey) ?? [];
189
+ const baseline = group.find((row) => row.observedMs <= effect.beforeToMs);
190
+ if (baseline !== undefined)
191
+ openWindowIds.add(baseline.id);
192
+ for (const row of group) {
193
+ if (row.observedMs > effect.afterFromMs && row.observedMs <= effect.afterToMs) {
194
+ openWindowIds.add(row.id);
195
+ }
196
+ }
197
+ }
198
+ const cutoffMs = nowMs - policy.maxAgeDays * MS_PER_DAY;
199
+ const recentIds = new Set();
200
+ const ordinaryRetainedIds = new Set();
201
+ const candidateIds = [];
202
+ for (const group of bySource.values()) {
203
+ const unprotected = group.filter((row) => !latestIds.has(row.id) && !openWindowIds.has(row.id));
204
+ for (const row of unprotected) {
205
+ if (row.observedMs >= cutoffMs)
206
+ recentIds.add(row.id);
207
+ }
208
+ for (const row of unprotected.slice(0, policy.maxUnprotectedRowsPerSource)) {
209
+ if (row.observedMs >= cutoffMs)
210
+ ordinaryRetainedIds.add(row.id);
211
+ }
212
+ for (const row of unprotected) {
213
+ if (!ordinaryRetainedIds.has(row.id))
214
+ candidateIds.push(row.id);
215
+ }
216
+ }
217
+ candidateIds.sort((a, b) => a - b);
218
+ const summary = {
219
+ ...emptySummary(mode, startedAt, null),
220
+ source_n: bySource.size,
221
+ rows_before: rows.length,
222
+ latest_protected_n: latestIds.size,
223
+ open_window_protected_n: openWindowIds.size,
224
+ recent_retained_n: recentIds.size,
225
+ count_retained_n: ordinaryRetainedIds.size,
226
+ delete_candidate_n: candidateIds.length,
227
+ rows_after: rows.length,
228
+ };
229
+ return { candidateIds, latestIds: [...latestIds].sort((a, b) => a - b), summary };
230
+ }
231
+ function failureSummary(mode, now, error, attempted) {
232
+ const retentionError = error instanceof RetentionPlanError ? error : null;
233
+ const startedAt = Number.isFinite(now.getTime()) ? now.toISOString() : "";
234
+ return {
235
+ ...(attempted ??
236
+ emptySummary(mode, startedAt, retentionError?.failureClass ?? "database_error", retentionError?.malformedOpenEffectN ?? 0)),
237
+ mode,
238
+ rows_deleted: 0,
239
+ rows_after: attempted?.rows_before ?? 0,
240
+ malformed_open_effect_n: retentionError?.malformedOpenEffectN ?? attempted?.malformed_open_effect_n ?? 0,
241
+ failure_class: retentionError?.failureClass ?? "database_error",
242
+ };
243
+ }
244
+ function withDuration(summary, startedAtMs) {
245
+ return {
246
+ ...summary,
247
+ duration_ms: Math.max(0, Math.round(performance.now() - startedAtMs)),
248
+ };
249
+ }
250
+ export function inspectContextHistoryRetention(db, now, policy = DEFAULT_CONTEXT_HISTORY_RETENTION_POLICY) {
251
+ const startedAtMs = performance.now();
252
+ try {
253
+ const plan = planRetention(db, now, policy, "inspect");
254
+ return {
255
+ ok: true,
256
+ summary: withDuration(plan.summary, startedAtMs),
257
+ candidate_ids: plan.candidateIds,
258
+ };
259
+ }
260
+ catch (error) {
261
+ return {
262
+ ok: false,
263
+ summary: withDuration(failureSummary("inspect", now, error), startedAtMs),
264
+ candidate_ids: [],
265
+ };
266
+ }
267
+ }
268
+ export function compactContextHistory(db, now, policy = DEFAULT_CONTEXT_HISTORY_RETENTION_POLICY) {
269
+ const startedAtMs = performance.now();
270
+ let attempted;
271
+ try {
272
+ const run = db.transaction(() => {
273
+ const plan = planRetention(db, now, policy, "compact");
274
+ attempted = plan.summary;
275
+ let rowsDeleted = 0;
276
+ for (let offset = 0; offset < plan.candidateIds.length; offset += DELETE_CHUNK_SIZE) {
277
+ const chunk = plan.candidateIds.slice(offset, offset + DELETE_CHUNK_SIZE);
278
+ const placeholders = chunk.map(() => "?").join(",");
279
+ const result = db
280
+ .prepare(`DELETE FROM context_inventory_history WHERE id IN (${placeholders})`)
281
+ .run(...chunk);
282
+ rowsDeleted += result.changes;
283
+ }
284
+ if (rowsDeleted !== plan.candidateIds.length) {
285
+ throw new RetentionPlanError("delete_count_invariant");
286
+ }
287
+ const rowsAfter = db.prepare("SELECT COUNT(*) AS n FROM context_inventory_history").get().n;
288
+ if (rowsAfter !== plan.summary.rows_before - rowsDeleted) {
289
+ throw new RetentionPlanError("row_count_invariant");
290
+ }
291
+ for (const latestId of plan.latestIds) {
292
+ const retained = db
293
+ .prepare("SELECT 1 AS present FROM context_inventory_history WHERE id = ?")
294
+ .get(latestId);
295
+ if (retained === undefined)
296
+ throw new RetentionPlanError("latest_invariant");
297
+ }
298
+ return {
299
+ ...plan.summary,
300
+ rows_deleted: rowsDeleted,
301
+ rows_after: rowsAfter,
302
+ };
303
+ });
304
+ return { ok: true, summary: withDuration(run.immediate(), startedAtMs) };
305
+ }
306
+ catch (error) {
307
+ return {
308
+ ok: false,
309
+ summary: withDuration(failureSummary("compact", now, error, attempted), startedAtMs),
310
+ };
311
+ }
312
+ }