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,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/detector/context-probe.ts — ContextInventoryProbe.
|
|
3
|
+
*
|
|
4
|
+
* Reads the always-loaded prefix sources, sizes each in tokens via
|
|
5
|
+
* Math.ceil(utf8_bytes / 4) (attribution_version = "chars4-v1"), and writes
|
|
6
|
+
* per-source rows to context_inventory. Never persists file content — only
|
|
7
|
+
* path + sha1 hash + token count (SEC-101).
|
|
8
|
+
*
|
|
9
|
+
* Sources probed:
|
|
10
|
+
* GLOBAL — keyed to the '__global__' sentinel workspace (INSERT OR IGNORE):
|
|
11
|
+
* - ~/.claude/CLAUDE.md (component: CLAUDE_MD)
|
|
12
|
+
* - Plugin/skill catalog (ESTIMATE) derived from
|
|
13
|
+
* ~/.claude/plugins/installed_plugins.json +
|
|
14
|
+
* enabled plugin cache skills +
|
|
15
|
+
* ~/.claude/skills/ * /SKILL.md frontmatter name+description
|
|
16
|
+
* (component: MCP_SCHEMAS)
|
|
17
|
+
* - Tool-Search config state from ~/.claude/settings.json (component:
|
|
18
|
+
* SETTINGS_SYSTEM; state metadata only, never settings content)
|
|
19
|
+
* PER-WORKSPACE (real workspace_id):
|
|
20
|
+
* - ~/.claude/projects/<slug>/memory/ * .md (component: MEMORY; sum)
|
|
21
|
+
* - <repo_path>/CLAUDE.md (component: CLAUDE_MD; when set)
|
|
22
|
+
*
|
|
23
|
+
* probe_id = "probe-" + sha1(workspace_id|component|file_ref).slice(0, 16)
|
|
24
|
+
* INSERT OR REPLACE keeps the table bounded (re-probe overwrites the row).
|
|
25
|
+
*
|
|
26
|
+
* Injected `now: Date` for probed_at — never call new Date() in this module.
|
|
27
|
+
*/
|
|
28
|
+
import * as crypto from "node:crypto";
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import * as os from "node:os";
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
const ATTRIBUTION_VERSION = "chars4-v1";
|
|
33
|
+
const TOOL_SEARCH_STATE_VERSION = "tool-search-state-v1";
|
|
34
|
+
const MAX_ALWAYS_LOAD_FLAGS = 256;
|
|
35
|
+
const MAX_CATALOG_ITEMS = 10_000;
|
|
36
|
+
/** Sentinel workspace_id and slug for all global sources. */
|
|
37
|
+
export const GLOBAL_WORKSPACE_ID = "__global__";
|
|
38
|
+
export const GLOBAL_PROJECT_SLUG = "__global__";
|
|
39
|
+
/** tokens = ceil(utf8_bytes / 4) — standard cheap heuristic. */
|
|
40
|
+
function countTokens(content) {
|
|
41
|
+
return Math.ceil(Buffer.byteLength(content, "utf8") / 4);
|
|
42
|
+
}
|
|
43
|
+
/** SHA-1 hex of UTF-8 content. */
|
|
44
|
+
function sha1Hex(content) {
|
|
45
|
+
return crypto.createHash("sha1").update(content, "utf8").digest("hex");
|
|
46
|
+
}
|
|
47
|
+
/** Serialize config facts without retaining settings or catalog content. */
|
|
48
|
+
export function encodeToolSearchState(state) {
|
|
49
|
+
return `${TOOL_SEARCH_STATE_VERSION}:${JSON.stringify(state)}`;
|
|
50
|
+
}
|
|
51
|
+
/** Parse the probe's bounded Tool-Search state metadata. */
|
|
52
|
+
export function parseToolSearchState(raw) {
|
|
53
|
+
if (!raw.startsWith(`${TOOL_SEARCH_STATE_VERSION}:`))
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(raw.slice(TOOL_SEARCH_STATE_VERSION.length + 1));
|
|
57
|
+
if (!isRecord(parsed))
|
|
58
|
+
return null;
|
|
59
|
+
const mode = parsed.tool_search_mode;
|
|
60
|
+
const effective = parsed.effective_catalog_state;
|
|
61
|
+
if (mode !== "deferred" &&
|
|
62
|
+
mode !== "threshold" &&
|
|
63
|
+
mode !== "upfront" &&
|
|
64
|
+
mode !== "disabled" &&
|
|
65
|
+
mode !== "unknown") {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (effective !== "deferred" &&
|
|
69
|
+
effective !== "threshold" &&
|
|
70
|
+
effective !== "upfront" &&
|
|
71
|
+
effective !== "alwaysLoad" &&
|
|
72
|
+
effective !== "unknown") {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(parsed.always_load_flags))
|
|
76
|
+
return null;
|
|
77
|
+
const flags = parsed.always_load_flags.filter((flag) => isRecord(flag) &&
|
|
78
|
+
typeof flag.server_id_hash === "string" &&
|
|
79
|
+
/^[0-9a-f]{16}$/.test(flag.server_id_hash) &&
|
|
80
|
+
typeof flag.always_load === "boolean");
|
|
81
|
+
if (typeof parsed.configured_value !== "string" && parsed.configured_value !== null) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (typeof parsed.always_load_count !== "number" ||
|
|
85
|
+
typeof parsed.always_load_flags_truncated !== "boolean" ||
|
|
86
|
+
typeof parsed.catalog_item_count !== "number" ||
|
|
87
|
+
typeof parsed.catalog_item_count_truncated !== "boolean" ||
|
|
88
|
+
(typeof parsed.catalog_hash !== "string" && parsed.catalog_hash !== null)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
tool_search_mode: mode,
|
|
93
|
+
effective_catalog_state: effective,
|
|
94
|
+
configured_value: parsed.configured_value,
|
|
95
|
+
always_load_flags: flags.slice(0, MAX_ALWAYS_LOAD_FLAGS),
|
|
96
|
+
always_load_count: parsed.always_load_count,
|
|
97
|
+
always_load_flags_truncated: parsed.always_load_flags_truncated,
|
|
98
|
+
catalog_item_count: parsed.catalog_item_count,
|
|
99
|
+
catalog_item_count_truncated: parsed.catalog_item_count_truncated,
|
|
100
|
+
catalog_hash: parsed.catalog_hash,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Deterministic probe_id: "probe-" + sha1(workspace_id|component|file_ref).slice(0,16). */
|
|
108
|
+
export function makeProbeId(workspaceId, component, fileRef) {
|
|
109
|
+
const hash = crypto
|
|
110
|
+
.createHash("sha1")
|
|
111
|
+
.update(`${workspaceId}|${component}|${fileRef}`)
|
|
112
|
+
.digest("hex")
|
|
113
|
+
.slice(0, 16);
|
|
114
|
+
return `probe-${hash}`;
|
|
115
|
+
}
|
|
116
|
+
/** Read a file, returning its UTF-8 text, or null if missing/unreadable. */
|
|
117
|
+
function tryReadFile(filePath) {
|
|
118
|
+
try {
|
|
119
|
+
return fs.readFileSync(filePath, "utf8");
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Read all *.md files in `dir`, sorted, and return their concatenated content. */
|
|
126
|
+
function readMemoryDir(dir) {
|
|
127
|
+
let files;
|
|
128
|
+
try {
|
|
129
|
+
files = fs
|
|
130
|
+
.readdirSync(dir)
|
|
131
|
+
.filter((f) => f.endsWith(".md"))
|
|
132
|
+
.sort();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (files.length === 0)
|
|
138
|
+
return null;
|
|
139
|
+
const parts = [];
|
|
140
|
+
for (const f of files) {
|
|
141
|
+
const content = tryReadFile(path.join(dir, f));
|
|
142
|
+
if (content !== null)
|
|
143
|
+
parts.push(content);
|
|
144
|
+
}
|
|
145
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Parse YAML frontmatter from a markdown string.
|
|
149
|
+
* Returns a flat key→value map, or null if no frontmatter block is present.
|
|
150
|
+
* Values `true`/`false` are returned as booleans; everything else as strings.
|
|
151
|
+
*/
|
|
152
|
+
function parseFrontmatter(content) {
|
|
153
|
+
if (!content.startsWith("---"))
|
|
154
|
+
return null;
|
|
155
|
+
const end = content.indexOf("---", 3);
|
|
156
|
+
if (end === -1)
|
|
157
|
+
return null;
|
|
158
|
+
const block = content.slice(3, end).trim();
|
|
159
|
+
const result = {};
|
|
160
|
+
for (const line of block.split("\n")) {
|
|
161
|
+
const colonIdx = line.indexOf(":");
|
|
162
|
+
if (colonIdx === -1)
|
|
163
|
+
continue;
|
|
164
|
+
const key = line.slice(0, colonIdx).trim();
|
|
165
|
+
const val = line.slice(colonIdx + 1).trim();
|
|
166
|
+
if (val === "true")
|
|
167
|
+
result[key] = true;
|
|
168
|
+
else if (val === "false")
|
|
169
|
+
result[key] = false;
|
|
170
|
+
else
|
|
171
|
+
result[key] = val;
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Load the `enabledPlugins` map from ~/.claude/settings.json.
|
|
177
|
+
* Returns a map of plugin-key → boolean (true = enabled, false = disabled).
|
|
178
|
+
* Returns an empty map if settings.json is absent, unreadable, or has no enabledPlugins key.
|
|
179
|
+
*/
|
|
180
|
+
function loadEnabledPlugins(claudeDir) {
|
|
181
|
+
const settingsPath = path.join(claudeDir, "settings.json");
|
|
182
|
+
const raw = tryReadFile(settingsPath);
|
|
183
|
+
if (raw === null)
|
|
184
|
+
return new Map();
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(raw);
|
|
187
|
+
if (!parsed || typeof parsed !== "object")
|
|
188
|
+
return new Map();
|
|
189
|
+
const ep = parsed.enabledPlugins;
|
|
190
|
+
if (!ep || typeof ep !== "object" || Array.isArray(ep))
|
|
191
|
+
return new Map();
|
|
192
|
+
const result = new Map();
|
|
193
|
+
for (const [k, v] of Object.entries(ep)) {
|
|
194
|
+
if (typeof v === "boolean")
|
|
195
|
+
result.set(k, v);
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return new Map();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function isRecord(value) {
|
|
204
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
205
|
+
}
|
|
206
|
+
function readSettings(claudeDir) {
|
|
207
|
+
const raw = tryReadFile(path.join(claudeDir, "settings.json"));
|
|
208
|
+
if (raw === null)
|
|
209
|
+
return { exists: false, value: {} };
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(raw);
|
|
212
|
+
return { exists: true, value: isRecord(parsed) ? parsed : {} };
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return { exists: true, value: {} };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function normalizeToolSearchSetting(value) {
|
|
219
|
+
if (value === undefined)
|
|
220
|
+
return { mode: "deferred", configuredValue: null };
|
|
221
|
+
const configuredValue = typeof value === "boolean"
|
|
222
|
+
? String(value)
|
|
223
|
+
: typeof value === "string"
|
|
224
|
+
? value.trim().toLowerCase().slice(0, 32)
|
|
225
|
+
: null;
|
|
226
|
+
if (configuredValue === "true")
|
|
227
|
+
return { mode: "deferred", configuredValue };
|
|
228
|
+
if (configuredValue === "false")
|
|
229
|
+
return { mode: "disabled", configuredValue };
|
|
230
|
+
if (configuredValue === "auto" || /^auto:(?:[0-9]|[1-9][0-9]|100)$/.test(configuredValue ?? "")) {
|
|
231
|
+
return { mode: "threshold", configuredValue };
|
|
232
|
+
}
|
|
233
|
+
return { mode: "unknown", configuredValue };
|
|
234
|
+
}
|
|
235
|
+
/** Collect only server-level flags; server names are hashed before persistence. */
|
|
236
|
+
function readAlwaysLoadFlags(settings) {
|
|
237
|
+
const flags = new Map();
|
|
238
|
+
const servers = settings.mcpServers;
|
|
239
|
+
if (isRecord(servers)) {
|
|
240
|
+
for (const [serverName, config] of Object.entries(servers)) {
|
|
241
|
+
if (isRecord(config) && typeof config.alwaysLoad === "boolean") {
|
|
242
|
+
flags.set(serverName, config.alwaysLoad);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Accept a compact top-level map as well. This keeps the probe tolerant of
|
|
247
|
+
// settings snapshots that flatten MCP server flags for diagnostics.
|
|
248
|
+
const flattened = settings.alwaysLoad;
|
|
249
|
+
if (isRecord(flattened)) {
|
|
250
|
+
for (const [serverName, value] of Object.entries(flattened)) {
|
|
251
|
+
if (typeof value === "boolean" && !flags.has(serverName))
|
|
252
|
+
flags.set(serverName, value);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const entries = [...flags.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
256
|
+
const truncated = entries.length > MAX_ALWAYS_LOAD_FLAGS;
|
|
257
|
+
// Count over the full set, not the persisted 256-cap slice: effectiveCatalogState
|
|
258
|
+
// must stay "alwaysLoad" even when every always-load server sorts past the cap.
|
|
259
|
+
const alwaysLoadCount = entries.reduce((n, [, alwaysLoad]) => n + (alwaysLoad ? 1 : 0), 0);
|
|
260
|
+
return {
|
|
261
|
+
flags: entries.slice(0, MAX_ALWAYS_LOAD_FLAGS).map(([serverName, alwaysLoad]) => ({
|
|
262
|
+
server_id_hash: sha1Hex(serverName).slice(0, 16),
|
|
263
|
+
always_load: alwaysLoad,
|
|
264
|
+
})),
|
|
265
|
+
truncated,
|
|
266
|
+
alwaysLoadCount,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function buildToolSearchState(claudeDir, catalog) {
|
|
270
|
+
const settings = readSettings(claudeDir);
|
|
271
|
+
if (!settings.exists && catalog.content.length === 0)
|
|
272
|
+
return null;
|
|
273
|
+
const env = isRecord(settings.value.env) ? settings.value.env : {};
|
|
274
|
+
const configured = env.ENABLE_TOOL_SEARCH ?? settings.value.ENABLE_TOOL_SEARCH ?? settings.value.enableToolSearch;
|
|
275
|
+
const normalized = normalizeToolSearchSetting(configured);
|
|
276
|
+
const permissions = isRecord(settings.value.permissions) ? settings.value.permissions : {};
|
|
277
|
+
const denied = Array.isArray(permissions.deny) && permissions.deny.some((entry) => entry === "ToolSearch");
|
|
278
|
+
const toolSearchMode = denied ? "disabled" : normalized.mode;
|
|
279
|
+
const alwaysLoadResult = readAlwaysLoadFlags(settings.value);
|
|
280
|
+
const alwaysLoadFlags = alwaysLoadResult.flags;
|
|
281
|
+
const alwaysLoadCount = alwaysLoadResult.alwaysLoadCount;
|
|
282
|
+
const effectiveCatalogState = alwaysLoadCount > 0 ? "alwaysLoad" : toolSearchMode === "disabled" ? "upfront" : toolSearchMode;
|
|
283
|
+
const catalogHash = catalog.content.length > 0 ? sha1Hex(catalog.content) : null;
|
|
284
|
+
const state = {
|
|
285
|
+
tool_search_mode: toolSearchMode,
|
|
286
|
+
effective_catalog_state: effectiveCatalogState,
|
|
287
|
+
configured_value: denied ? "denied" : normalized.configuredValue,
|
|
288
|
+
always_load_flags: alwaysLoadFlags,
|
|
289
|
+
always_load_count: alwaysLoadCount,
|
|
290
|
+
always_load_flags_truncated: alwaysLoadResult.truncated,
|
|
291
|
+
catalog_item_count: catalog.itemCount,
|
|
292
|
+
catalog_item_count_truncated: catalog.itemCountTruncated,
|
|
293
|
+
catalog_hash: catalogHash,
|
|
294
|
+
};
|
|
295
|
+
const encoded = encodeToolSearchState(state);
|
|
296
|
+
return {
|
|
297
|
+
state,
|
|
298
|
+
encoded,
|
|
299
|
+
hash: sha1Hex(encoded),
|
|
300
|
+
settingsExists: settings.exists,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/** Resolve an existing path only when its real path stays within `root`. */
|
|
304
|
+
function resolveWithin(root, candidate) {
|
|
305
|
+
try {
|
|
306
|
+
const realRoot = fs.realpathSync(root);
|
|
307
|
+
const realCandidate = fs.realpathSync(candidate);
|
|
308
|
+
const relative = path.relative(realRoot, realCandidate);
|
|
309
|
+
if (relative === "" ||
|
|
310
|
+
(!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))) {
|
|
311
|
+
return realCandidate;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// Missing/unreadable roots and candidates are not catalogued.
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
/** Add direct child skills from a skills directory in deterministic order. */
|
|
320
|
+
function appendSkills(parts, skillsDir, boundaryRoot, seenSkillFiles) {
|
|
321
|
+
try {
|
|
322
|
+
const entries = fs
|
|
323
|
+
.readdirSync(skillsDir, { withFileTypes: true })
|
|
324
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
325
|
+
for (const entry of entries) {
|
|
326
|
+
if (!entry.isDirectory())
|
|
327
|
+
continue;
|
|
328
|
+
const skillMd = path.join(skillsDir, entry.name, "SKILL.md");
|
|
329
|
+
const resolvedSkill = resolveWithin(boundaryRoot ?? skillsDir, skillMd);
|
|
330
|
+
if (resolvedSkill === null || seenSkillFiles.has(resolvedSkill))
|
|
331
|
+
continue;
|
|
332
|
+
const skillContent = tryReadFile(resolvedSkill);
|
|
333
|
+
if (skillContent === null)
|
|
334
|
+
continue;
|
|
335
|
+
seenSkillFiles.add(resolvedSkill);
|
|
336
|
+
const fm = parseFrontmatter(skillContent);
|
|
337
|
+
if (fm !== null && fm.enabled === false)
|
|
338
|
+
continue;
|
|
339
|
+
const name = fm !== null && typeof fm.name === "string" ? fm.name : entry.name;
|
|
340
|
+
const desc = fm !== null && typeof fm.description === "string" ? fm.description : "";
|
|
341
|
+
parts.push(`skill:${name}${desc ? ` ${desc}` : ""}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// Missing/unreadable skills directories are skipped.
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Build the plugin/skill catalog text (ESTIMATE).
|
|
350
|
+
*
|
|
351
|
+
* Concatenates name+description from:
|
|
352
|
+
* - ~/.claude/plugins/installed_plugins.json (plugin list)
|
|
353
|
+
* - enabled plugin cache installPath/skills/ * /SKILL.md entries
|
|
354
|
+
* - ~/.claude/skills/ * /SKILL.md frontmatter (skill catalog)
|
|
355
|
+
*
|
|
356
|
+
* Plugins whose key appears in ~/.claude/settings.json `enabledPlugins` with value false
|
|
357
|
+
* are excluded. Frontmatter `enabled: false` skill entries are also skipped.
|
|
358
|
+
* The resulting string is sized in tokens; its content is never stored.
|
|
359
|
+
*/
|
|
360
|
+
function buildPluginCatalog(claudeDir) {
|
|
361
|
+
const parts = [];
|
|
362
|
+
const enabledPlugins = loadEnabledPlugins(claudeDir);
|
|
363
|
+
const pluginCacheRoot = path.join(claudeDir, "plugins", "cache");
|
|
364
|
+
const pluginInstallPaths = [];
|
|
365
|
+
const seenSkillFiles = new Set();
|
|
366
|
+
// Plugins from installed_plugins.json
|
|
367
|
+
const pluginsPath = path.join(claudeDir, "plugins", "installed_plugins.json");
|
|
368
|
+
const pluginsRaw = tryReadFile(pluginsPath);
|
|
369
|
+
if (pluginsRaw !== null) {
|
|
370
|
+
try {
|
|
371
|
+
const parsed = JSON.parse(pluginsRaw);
|
|
372
|
+
if (Array.isArray(parsed)) {
|
|
373
|
+
for (const p of parsed) {
|
|
374
|
+
if (isRecord(p)) {
|
|
375
|
+
const name = p.name;
|
|
376
|
+
const desc = p.description;
|
|
377
|
+
if (typeof name === "string") {
|
|
378
|
+
// Exclude plugins explicitly disabled in settings.json enabledPlugins
|
|
379
|
+
if (enabledPlugins.get(name) === false)
|
|
380
|
+
continue;
|
|
381
|
+
parts.push(`plugin:${name}${typeof desc === "string" ? ` ${desc}` : ""}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else if (isRecord(parsed) &&
|
|
387
|
+
typeof parsed.version === "number" &&
|
|
388
|
+
isRecord(parsed.plugins)) {
|
|
389
|
+
// Current Claude Code format: plugin key -> installation records.
|
|
390
|
+
for (const [name, installs] of Object.entries(parsed.plugins).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
391
|
+
if (enabledPlugins.get(name) === false)
|
|
392
|
+
continue;
|
|
393
|
+
parts.push(`plugin:${name}`);
|
|
394
|
+
if (!Array.isArray(installs))
|
|
395
|
+
continue;
|
|
396
|
+
for (const install of installs) {
|
|
397
|
+
if (!isRecord(install) || typeof install.installPath !== "string")
|
|
398
|
+
continue;
|
|
399
|
+
const resolvedInstall = resolveWithin(pluginCacheRoot, install.installPath);
|
|
400
|
+
if (resolvedInstall !== null)
|
|
401
|
+
pluginInstallPaths.push(resolvedInstall);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
else if (isRecord(parsed)) {
|
|
406
|
+
for (const [name, info] of Object.entries(parsed).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
407
|
+
// Exclude plugins explicitly disabled in settings.json enabledPlugins
|
|
408
|
+
if (enabledPlugins.get(name) === false)
|
|
409
|
+
continue;
|
|
410
|
+
const desc = isRecord(info) ? info.description : undefined;
|
|
411
|
+
parts.push(`plugin:${name}${typeof desc === "string" ? ` ${desc}` : ""}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// Malformed JSON: include the raw bytes in the token estimate
|
|
417
|
+
parts.push(pluginsRaw);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Plugin skills: traverse only cache installs named by installed_plugins.json.
|
|
421
|
+
// Canonical-file deduplication prevents repeated installation records from counting the same skill.
|
|
422
|
+
for (const installPath of [...new Set(pluginInstallPaths)].sort()) {
|
|
423
|
+
appendSkills(parts, path.join(installPath, "skills"), installPath, seenSkillFiles);
|
|
424
|
+
}
|
|
425
|
+
// Standalone skills from ~/.claude/skills/*/SKILL.md frontmatter.
|
|
426
|
+
const skillsDir = path.join(claudeDir, "skills");
|
|
427
|
+
try {
|
|
428
|
+
// Sort by name so the catalog string (and thus file_hash) is deterministic across
|
|
429
|
+
// machines/re-probes — readdirSync order is filesystem-dependent (matches readMemoryDir).
|
|
430
|
+
const entries = fs
|
|
431
|
+
.readdirSync(skillsDir, { withFileTypes: true })
|
|
432
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
433
|
+
for (const entry of entries) {
|
|
434
|
+
if (!entry.isDirectory())
|
|
435
|
+
continue;
|
|
436
|
+
const skillMd = path.join(skillsDir, entry.name, "SKILL.md");
|
|
437
|
+
const skillContent = tryReadFile(skillMd);
|
|
438
|
+
if (skillContent === null)
|
|
439
|
+
continue;
|
|
440
|
+
const fm = parseFrontmatter(skillContent);
|
|
441
|
+
// Skip explicitly disabled skills
|
|
442
|
+
if (fm !== null && fm.enabled === false)
|
|
443
|
+
continue;
|
|
444
|
+
const name = fm !== null && typeof fm.name === "string" ? fm.name : entry.name;
|
|
445
|
+
const desc = fm !== null && typeof fm.description === "string" ? fm.description : "";
|
|
446
|
+
parts.push(`skill:${name}${desc ? ` ${desc}` : ""}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
// skills directory missing or unreadable — skip
|
|
451
|
+
}
|
|
452
|
+
// Canonical-file and repeated-install deduplication happen above. Preserve distinct
|
|
453
|
+
// catalog entries even when two skill files render the same name/description.
|
|
454
|
+
const itemCountTruncated = parts.length > MAX_CATALOG_ITEMS;
|
|
455
|
+
return {
|
|
456
|
+
content: parts.sort().join("\n"),
|
|
457
|
+
itemCount: Math.min(parts.length, MAX_CATALOG_ITEMS),
|
|
458
|
+
itemCountTruncated,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/** Upsert one context_inventory row. Content is discarded after sizing/hashing (SEC-101).
|
|
462
|
+
*
|
|
463
|
+
* Also appends a row to context_inventory_history whenever the file_hash differs from the
|
|
464
|
+
* previously recorded hash (one row per distinct version — bounded change-log, not one-per-probe).
|
|
465
|
+
*/
|
|
466
|
+
function upsertInventoryRow(db, workspaceId, component, fileRef, content, probedAt) {
|
|
467
|
+
const tokens = countTokens(content);
|
|
468
|
+
const fileHash = sha1Hex(content);
|
|
469
|
+
const probeId = makeProbeId(workspaceId, component, fileRef);
|
|
470
|
+
// Check whether file_hash has changed from the stored value (or this is a new source).
|
|
471
|
+
const existing = db
|
|
472
|
+
.prepare("SELECT file_hash FROM context_inventory WHERE probe_id = ?")
|
|
473
|
+
.get(probeId);
|
|
474
|
+
if (existing === undefined || existing.file_hash !== fileHash) {
|
|
475
|
+
db.prepare(`INSERT INTO context_inventory_history
|
|
476
|
+
(workspace_id, component, file_ref, file_hash, tokens, attribution_version, observed_at)
|
|
477
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(workspaceId, component, fileRef, fileHash, tokens, ATTRIBUTION_VERSION, probedAt);
|
|
478
|
+
}
|
|
479
|
+
db.prepare(`INSERT OR REPLACE INTO context_inventory
|
|
480
|
+
(probe_id, workspace_id, probed_at, component, file_ref, file_hash, tokens, attribution_version)
|
|
481
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(probeId, workspaceId, probedAt, component, fileRef, fileHash, tokens, ATTRIBUTION_VERSION);
|
|
482
|
+
}
|
|
483
|
+
/** Upsert a bounded metadata-only inventory row (zero context tokens). */
|
|
484
|
+
function upsertInventoryMetadataRow(db, workspaceId, component, fileRef, metadata, attributionVersion, probedAt) {
|
|
485
|
+
const fileHash = sha1Hex(metadata);
|
|
486
|
+
const probeId = makeProbeId(workspaceId, component, fileRef);
|
|
487
|
+
const existing = db
|
|
488
|
+
.prepare("SELECT file_hash FROM context_inventory WHERE probe_id = ?")
|
|
489
|
+
.get(probeId);
|
|
490
|
+
if (existing === undefined || existing.file_hash !== fileHash) {
|
|
491
|
+
db.prepare(`INSERT INTO context_inventory_history
|
|
492
|
+
(workspace_id, component, file_ref, file_hash, tokens, attribution_version, observed_at)
|
|
493
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(workspaceId, component, fileRef, fileHash, 0, attributionVersion, probedAt);
|
|
494
|
+
}
|
|
495
|
+
db.prepare(`INSERT OR REPLACE INTO context_inventory
|
|
496
|
+
(probe_id, workspace_id, probed_at, component, file_ref, file_hash, tokens, attribution_version)
|
|
497
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(probeId, workspaceId, probedAt, component, fileRef, fileHash, 0, attributionVersion);
|
|
498
|
+
}
|
|
499
|
+
/** Ensure the __global__ sentinel workspace row exists. */
|
|
500
|
+
function ensureGlobalWorkspace(db, now) {
|
|
501
|
+
db.prepare(`INSERT OR IGNORE INTO workspaces (workspace_id, project_slug, registered_at)
|
|
502
|
+
VALUES (?, ?, ?)`).run(GLOBAL_WORKSPACE_ID, GLOBAL_PROJECT_SLUG, now);
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Run the ContextInventoryProbe: read always-loaded prefix sources, size each in
|
|
506
|
+
* tokens, and upsert into context_inventory. Never throws — wrap call sites in
|
|
507
|
+
* try/catch for best-effort semantics.
|
|
508
|
+
*
|
|
509
|
+
* @param db Open database (migrations applied).
|
|
510
|
+
* @param now Injected evaluation instant (used for probed_at and registered_at).
|
|
511
|
+
* @param opts Optional overrides (claudeDir for testing).
|
|
512
|
+
*/
|
|
513
|
+
export function runContextProbe(db, now, opts) {
|
|
514
|
+
const claudeDir = opts?.claudeDir ?? path.join(os.homedir(), ".claude");
|
|
515
|
+
const probedAt = now.toISOString();
|
|
516
|
+
let rows = 0;
|
|
517
|
+
db.transaction(() => {
|
|
518
|
+
// ── Ensure global sentinel workspace ────────────────────────────────────
|
|
519
|
+
ensureGlobalWorkspace(db, probedAt);
|
|
520
|
+
// ── 1. Global CLAUDE.md ─────────────────────────────────────────────────
|
|
521
|
+
const globalClaudeMdPath = path.join(claudeDir, "CLAUDE.md");
|
|
522
|
+
const globalClaudeMdContent = tryReadFile(globalClaudeMdPath);
|
|
523
|
+
if (globalClaudeMdContent !== null) {
|
|
524
|
+
upsertInventoryRow(db, GLOBAL_WORKSPACE_ID, "CLAUDE_MD", globalClaudeMdPath, globalClaudeMdContent, probedAt);
|
|
525
|
+
rows++;
|
|
526
|
+
}
|
|
527
|
+
// ── 2. Plugin/skill catalog (ESTIMATE) ──────────────────────────────────
|
|
528
|
+
const catalog = buildPluginCatalog(claudeDir);
|
|
529
|
+
const catalogRef = path.join(claudeDir, "plugins", "skill-catalog");
|
|
530
|
+
if (catalog.content.length > 0) {
|
|
531
|
+
// Synthetic stable file_ref — not a real file path (catalog is derived)
|
|
532
|
+
upsertInventoryRow(db, GLOBAL_WORKSPACE_ID, "MCP_SCHEMAS", catalogRef, catalog.content, probedAt);
|
|
533
|
+
rows++;
|
|
534
|
+
}
|
|
535
|
+
// Store only normalized state metadata. The zero token count keeps config
|
|
536
|
+
// facts out of always-loaded context totals, while the hash makes changes
|
|
537
|
+
// auditable without retaining settings or catalog text.
|
|
538
|
+
const toolSearchState = buildToolSearchState(claudeDir, catalog);
|
|
539
|
+
const settingsRef = path.join(claudeDir, "settings.json");
|
|
540
|
+
if (toolSearchState !== null) {
|
|
541
|
+
upsertInventoryMetadataRow(db, GLOBAL_WORKSPACE_ID, "SETTINGS_SYSTEM", settingsRef, toolSearchState.encoded, toolSearchState.encoded, probedAt);
|
|
542
|
+
rows++;
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
// This state must not survive removal of both settings and catalog
|
|
546
|
+
// inputs or D10 could report stale behavior.
|
|
547
|
+
db.prepare("DELETE FROM context_inventory WHERE probe_id = ?").run(makeProbeId(GLOBAL_WORKSPACE_ID, "SETTINGS_SYSTEM", settingsRef));
|
|
548
|
+
}
|
|
549
|
+
// ── 3. Per-workspace sources ─────────────────────────────────────────────
|
|
550
|
+
const workspaces = db
|
|
551
|
+
.prepare(`SELECT workspace_id, project_slug, repo_path FROM workspaces
|
|
552
|
+
WHERE workspace_id != ?`)
|
|
553
|
+
.all(GLOBAL_WORKSPACE_ID);
|
|
554
|
+
for (const ws of workspaces) {
|
|
555
|
+
// 3a. Project memory (sum of all *.md under memory/)
|
|
556
|
+
const memoryDir = path.join(claudeDir, "projects", ws.project_slug, "memory");
|
|
557
|
+
const memoryContent = readMemoryDir(memoryDir);
|
|
558
|
+
if (memoryContent !== null) {
|
|
559
|
+
upsertInventoryRow(db, ws.workspace_id, "MEMORY", memoryDir, memoryContent, probedAt);
|
|
560
|
+
rows++;
|
|
561
|
+
}
|
|
562
|
+
// 3b. Project CLAUDE.md (when repo_path is set and file exists)
|
|
563
|
+
if (ws.repo_path) {
|
|
564
|
+
const projectClaudeMdPath = path.join(ws.repo_path, "CLAUDE.md");
|
|
565
|
+
const projectClaudeMdContent = tryReadFile(projectClaudeMdPath);
|
|
566
|
+
if (projectClaudeMdContent !== null) {
|
|
567
|
+
upsertInventoryRow(db, ws.workspace_id, "CLAUDE_MD", projectClaudeMdPath, projectClaudeMdContent, probedAt);
|
|
568
|
+
rows++;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
})();
|
|
573
|
+
return { rows };
|
|
574
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the source tuple emitted in D1 evidence.
|
|
3
|
+
*
|
|
4
|
+
* This intentionally preserves the measurement path's permissive compatibility
|
|
5
|
+
* contract: both fields must be strings, but empty strings are not rejected.
|
|
6
|
+
*/
|
|
7
|
+
export function parseD1SourceIdentity(evidenceJson) {
|
|
8
|
+
try {
|
|
9
|
+
const value = JSON.parse(evidenceJson);
|
|
10
|
+
if (value &&
|
|
11
|
+
typeof value === "object" &&
|
|
12
|
+
typeof value.component === "string" &&
|
|
13
|
+
typeof value.file_ref === "string") {
|
|
14
|
+
return { component: value.component, fileRef: value.file_ref };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Malformed evidence has no usable source identity.
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/** D1 recommendations, including the legacy target-metric encoding. */
|
|
23
|
+
export function isD1SourceBackedRecommendation(rec) {
|
|
24
|
+
return rec.detector_id === "D1" || (rec.target_metric ?? "").startsWith("CONTEXT_TOKENS:");
|
|
25
|
+
}
|