@davesheffer/hunch 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.
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Pluggable synthesis provider for the WRITE path (DESIGN.md §4 / §7).
3
+ *
4
+ * LLM synthesis is driven by the user's Claude **subscription** via the `claude`
5
+ * CLI — never the pay-per-token Anthropic API. We try, in order:
6
+ * claude-cli → deterministic-fallback. The fallback always works (no creds, no
7
+ * network) and emits a LOW-confidence draft, honoring the design rule that
8
+ * auto-captured memory is advisory and cheap to discard.
9
+ *
10
+ * Subscription, not API: Claude Code's auth precedence puts `ANTHROPIC_API_KEY`
11
+ * (and `ANTHROPIC_AUTH_TOKEN`) ABOVE subscription OAuth, and in headless `-p`
12
+ * mode the API key is *always* used when present. So we strip those vars from
13
+ * the child env (see ClaudeCliProvider.run) to force the CLI down to subscription
14
+ * OAuth / CLAUDE_CODE_OAUTH_TOKEN. There is intentionally NO API-key provider.
15
+ *
16
+ * Every provider returns the same shape so the rest of the system never knows
17
+ * (or cares) which one ran.
18
+ */
19
+ import { execFile } from "node:child_process";
20
+ import { promisify } from "node:util";
21
+ import { tmpdir } from "node:os";
22
+ import { summarizeDiff } from "../extractors/diff.js";
23
+ const pexec = promisify(execFile);
24
+ const SYSTEM = `You are the synthesis engine of an Engineering Memory OS. You turn raw
25
+ developer activity (a git commit diff, or a test failure) into a single structured
26
+ "why" record. Be precise and evidence-grounded; never invent facts not supported by
27
+ the input. Prefer short, concrete statements. If intent is unclear, say so plainly
28
+ rather than guessing.`;
29
+ const DECISION_TOOL = {
30
+ name: "emit_decision",
31
+ description: "Emit the structured Decision (ADR) distilled from this commit.",
32
+ input_schema: {
33
+ type: "object",
34
+ properties: {
35
+ title: { type: "string", description: "Imperative, specific (<=80 chars)." },
36
+ context: { type: "string", description: "Why this change was needed." },
37
+ decision: { type: "string", description: "What was actually decided/changed." },
38
+ consequences: { type: "array", items: { type: "string" } },
39
+ alternatives_rejected: { type: "array", items: { type: "string" } },
40
+ nontrivial: { type: "boolean", description: "Is this a real design decision worth remembering?" },
41
+ },
42
+ required: ["title", "context", "decision", "consequences", "alternatives_rejected", "nontrivial"],
43
+ },
44
+ };
45
+ const BUG_TOOL = {
46
+ name: "emit_bug",
47
+ description: "Emit the structured Bug distilled from this test failure.",
48
+ input_schema: {
49
+ type: "object",
50
+ properties: {
51
+ title: { type: "string" },
52
+ symptom: { type: "string" },
53
+ root_cause: { type: "string", description: "Best hypothesis; mark uncertainty." },
54
+ severity: { type: "string", enum: ["low", "medium", "high", "critical"] },
55
+ },
56
+ required: ["title", "symptom", "root_cause", "severity"],
57
+ },
58
+ };
59
+ // --------------------------------------------------------------------------
60
+ // Provider A: headless `claude -p` CLI — billed to the user's Claude subscription
61
+ // --------------------------------------------------------------------------
62
+ class ClaudeCliProvider {
63
+ name = "claude-cli";
64
+ // Default to the `haiku` alias (cheap/fast, and survives model retirements)
65
+ // rather than a pinned dated id; override with HUNCH_SYNTH_MODEL if needed.
66
+ model = process.env.HUNCH_SYNTH_MODEL || "haiku";
67
+ async available() {
68
+ try {
69
+ await pexec("claude", ["--version"], { timeout: 8000 });
70
+ return true;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ async run(prompt) {
77
+ // Force SUBSCRIPTION auth: ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN outrank
78
+ // subscription OAuth in Claude Code's precedence and are *always* used in
79
+ // headless `-p` mode when present. Strip them so the CLI falls through to
80
+ // CLAUDE_CODE_OAUTH_TOKEN / `/login` subscription credentials. (We never
81
+ // bill the pay-per-token API — that's the whole point of this provider.)
82
+ const childEnv = { ...process.env };
83
+ delete childEnv.ANTHROPIC_API_KEY;
84
+ delete childEnv.ANTHROPIC_AUTH_TOKEN;
85
+ // Single-shot text synthesis: no tools, no agentic loop. The prompt carries
86
+ // all needed context inline, so run from a neutral cwd to avoid loading this
87
+ // repo's own hunch MCP server / CLAUDE.md on every commit (cheaper, and no
88
+ // risk of the synthesis call recursing through the Hunch). Auth lives in the
89
+ // user's home config, not cwd, so this doesn't affect subscription billing.
90
+ const args = ["-p", prompt, "--output-format", "json", "--model", this.model, "--max-turns", "1"];
91
+ const { stdout } = await pexec("claude", args, {
92
+ env: childEnv,
93
+ cwd: tmpdir(),
94
+ maxBuffer: 16 * 1024 * 1024,
95
+ timeout: 120_000,
96
+ });
97
+ // Headless JSON envelope: { result, is_error, subtype, ... }. Keep the
98
+ // parse-failure fallback (non-JSON stdout → hand it to the mapper) SEPARATE
99
+ // from the error signal: an error envelope (max-turns/budget/exec error, which
100
+ // also OMITS `result`) must THROW so the safe wrapper falls back — not be
101
+ // returned as if it were assistant text. (The old single try/catch swallowed
102
+ // its own `throw`, making the is_error guard dead code.)
103
+ let envelope;
104
+ try {
105
+ envelope = JSON.parse(stdout);
106
+ }
107
+ catch {
108
+ return stdout; // not the JSON envelope — let the mapper attempt extraction
109
+ }
110
+ if (envelope.is_error || (envelope.subtype && envelope.subtype !== "success")) {
111
+ throw new Error(`claude -p reported an error${envelope.subtype ? `: ${envelope.subtype}` : ""}`);
112
+ }
113
+ return envelope.result ?? stdout;
114
+ }
115
+ async draftDecision(input) {
116
+ const text = await this.run(`${SYSTEM}\n\n${commitPrompt(input)}\n\n${jsonInstruction(DECISION_TOOL.input_schema)}`);
117
+ const draft = decisionDraftFromText(text, input.subject);
118
+ // No usable LLM JSON (truncation, refusal, prose-only) → THROW so the safe
119
+ // wrapper falls back to the deterministic provider, whose diff-structured
120
+ // draft is both more useful AND honestly labeled ("inferred", low confidence)
121
+ // than a hollow record mislabeled as an LLM draft.
122
+ if (!draft)
123
+ throw new Error("claude-cli: no usable decision JSON in output");
124
+ // For a LARGE diff the model only saw the structured summary + a sample, not
125
+ // the full patch (commitPrompt → renderDiff). The "why" is therefore lower-
126
+ // fidelity than a draft made from the whole diff, so haircut the confidence
127
+ // and tag the source — keeping provenance honest (a summary-sourced draft must
128
+ // not masquerade as a full-fidelity llm_draft at 0.65).
129
+ if (input.diff.length > LARGE_DIFF_CHARS) {
130
+ return { ...draft, confidence: Math.min(draft.confidence, 0.5), source: `${draft.source}+summary` };
131
+ }
132
+ return draft;
133
+ }
134
+ async draftBug(input) {
135
+ const text = await this.run(`${SYSTEM}\n\n${failurePrompt(input)}\n\n${jsonInstruction(BUG_TOOL.input_schema)}`);
136
+ const draft = bugDraftFromText(text, input.test, input.message);
137
+ if (!draft)
138
+ throw new Error("claude-cli: no usable bug JSON in output");
139
+ return draft;
140
+ }
141
+ }
142
+ // --------------------------------------------------------------------------
143
+ // Provider C: deterministic fallback (no LLM, always available)
144
+ // --------------------------------------------------------------------------
145
+ export class DeterministicProvider {
146
+ name = "deterministic";
147
+ async available() {
148
+ return true;
149
+ }
150
+ async draftDecision(input) {
151
+ const dirs = topDirs(input.files);
152
+ const a = input.analysis;
153
+ const summary = a ? summarizeDiff(a) : "";
154
+ const verb = /^(add|introduce|create|feat)/i.test(input.subject) ? "introduced"
155
+ : /^(remove|delete|drop)/i.test(input.subject) ? "removed"
156
+ : /^(refactor|rework|restructure)/i.test(input.subject) ? "refactored"
157
+ : "changed";
158
+ const consequences = [];
159
+ if (a?.addedDeps.length)
160
+ consequences.push(`Adds dependency: ${a.addedDeps.join(", ")}.`);
161
+ if (a?.removedDeps.length)
162
+ consequences.push(`Drops dependency: ${a.removedDeps.join(", ")}.`);
163
+ if (a?.removedSymbols.length)
164
+ consequences.push(`Removes ${a.removedSymbols.map((s) => s.name).join(", ")} — potential breaking change for callers.`);
165
+ if (a?.changedSymbols.length)
166
+ consequences.push(`Changes ${a.changedSymbols.map((s) => s.name).join(", ")} (signature/behavior).`);
167
+ // We extracted real structure → a bit more trustworthy than a blind heuristic.
168
+ const informative = !!(a && (a.addedSymbols.length || a.removedSymbols.length || a.changedSymbols.length || a.addedDeps.length || a.removedDeps.length));
169
+ return {
170
+ title: input.subject || "Code change",
171
+ context: [input.body, summary && `What changed: ${summary}.`].filter(Boolean).join(" ").slice(0, 500)
172
+ || `Touched ${input.files.length} file(s) across ${dirs.join(", ") || "the repo"}.`,
173
+ decision: summary
174
+ ? `${cap(verb)} ${dirs.join(", ") || "the repo"}: ${summary}.`
175
+ : `${cap(verb)} code in ${dirs.join(", ") || "the repo"} (${input.files.length} file(s)).`,
176
+ consequences,
177
+ alternatives_rejected: [],
178
+ // advisory either way, but real extraction earns a touch more confidence
179
+ confidence: informative ? 0.45 : 0.3,
180
+ source: "inferred",
181
+ };
182
+ }
183
+ async draftBug(input) {
184
+ return {
185
+ title: `Test failure: ${input.test}`,
186
+ symptom: input.message.slice(0, 300),
187
+ root_cause: input.suspects.length ? `Suspected in: ${input.suspects.join(", ")}` : "Unknown (no LLM available).",
188
+ severity: "medium",
189
+ confidence: 0.25,
190
+ source: "test_failure",
191
+ };
192
+ }
193
+ }
194
+ const PROVIDERS = [new ClaudeCliProvider(), new DeterministicProvider()];
195
+ /** Choose the first available provider, honoring HUNCH_SYNTH_PROVIDER override. */
196
+ export async function selectProvider() {
197
+ const forced = process.env.HUNCH_SYNTH_PROVIDER;
198
+ if (forced) {
199
+ const p = PROVIDERS.find((x) => x.name === forced);
200
+ if (p && (await p.available()))
201
+ return p;
202
+ }
203
+ for (const p of PROVIDERS) {
204
+ try {
205
+ if (await p.available())
206
+ return p;
207
+ }
208
+ catch {
209
+ /* try next */
210
+ }
211
+ }
212
+ return new DeterministicProvider();
213
+ }
214
+ // ---- prompt + parsing helpers --------------------------------------------
215
+ // Above this size we stop shipping the raw patch and lean on the deterministic
216
+ // STRUCTURED CHANGES summary + a small sample. A truncated head-slice of a giant
217
+ // diff is an arbitrary fragment; the summary describes the WHOLE change for far
218
+ // fewer tokens. (Draft confidence is haircut when this path is taken — see
219
+ // ClaudeCliProvider.draftDecision.)
220
+ const LARGE_DIFF_CHARS = 20_000;
221
+ const DIFF_SAMPLE_CHARS = 6_000;
222
+ /** The DIFF section of the commit prompt: the full patch for normal commits, or
223
+ * (for a large diff) a small sample with a pointer to STRUCTURED CHANGES. */
224
+ function renderDiff(input) {
225
+ if (input.diff.length <= LARGE_DIFF_CHARS)
226
+ return `DIFF:\n${input.diff}`;
227
+ return `DIFF (large patch — first ${DIFF_SAMPLE_CHARS} of ${input.diff.length} chars; rely on STRUCTURED CHANGES above for the rest):\n${input.diff.slice(0, DIFF_SAMPLE_CHARS)}`;
228
+ }
229
+ function commitPrompt(input) {
230
+ return [
231
+ `COMMIT SUBJECT: ${input.subject}`,
232
+ input.body ? `COMMIT BODY:\n${input.body}` : "",
233
+ input.analysis ? `STRUCTURED CHANGES: ${summarizeDiff(input.analysis)}` : "",
234
+ `FILES CHANGED (${input.files.length}):\n${input.files.slice(0, 40).join("\n")}`,
235
+ renderDiff(input),
236
+ `\nDistill the single most important design decision this commit represents.`,
237
+ ].filter(Boolean).join("\n\n");
238
+ }
239
+ function failurePrompt(input) {
240
+ return [
241
+ `FAILING TEST: ${input.test}`,
242
+ `FAILURE MESSAGE:\n${input.message.slice(0, 2000)}`,
243
+ input.suspects.length ? `SUSPECT SYMBOLS (ranked by churn×recency×fan-in):\n${input.suspects.join("\n")}` : "",
244
+ input.recentDiff ? `RECENT DIFF:\n${input.recentDiff.slice(0, 8000)}` : "",
245
+ `\nDraft a Bug record: symptom, best-hypothesis root cause (mark uncertainty), severity.`,
246
+ ].filter(Boolean).join("\n\n");
247
+ }
248
+ function jsonInstruction(schema) {
249
+ return `Respond with ONLY a single JSON object matching this schema (no prose, no code fence):\n${JSON.stringify(schema)}`;
250
+ }
251
+ /** Index of the `}` that closes the balanced object opened at `start`, or -1.
252
+ * String-aware: braces and quotes INSIDE a JSON string literal don't count, so
253
+ * `{"context":"closes the } brace"}` balances whole (a naive depth counter
254
+ * closes early on the inner `}`). */
255
+ function balancedEnd(text, start) {
256
+ let depth = 0;
257
+ let inStr = false;
258
+ let esc = false;
259
+ for (let i = start; i < text.length; i++) {
260
+ const ch = text[i];
261
+ if (inStr) {
262
+ if (esc)
263
+ esc = false;
264
+ else if (ch === "\\")
265
+ esc = true;
266
+ else if (ch === '"')
267
+ inStr = false;
268
+ continue;
269
+ }
270
+ if (ch === '"')
271
+ inStr = true;
272
+ else if (ch === "{")
273
+ depth++;
274
+ else if (ch === "}" && --depth === 0)
275
+ return i;
276
+ }
277
+ return -1; // never balanced (e.g. truncated output)
278
+ }
279
+ /** True if the `{` at `start` begins something that looks like a JSON object —
280
+ * the next non-space char is a `"` (a quoted key) or `}` (empty object). Lets us
281
+ * tell a real, possibly-truncated JSON object from a stray PROSE brace such as
282
+ * `interface Foo {`, `() => {`, or `{set}` (whose next char is a letter). */
283
+ function looksLikeJsonObject(text, start) {
284
+ let j = start + 1;
285
+ while (j < text.length && /\s/.test(text[j]))
286
+ j++;
287
+ return j < text.length && (text[j] === '"' || text[j] === "}");
288
+ }
289
+ /** Every balanced top-level `{...}` slice in `text`, in order. A brace that does
290
+ * NOT look like a JSON opener (a prose brace) is SKIPPED and the scan continues,
291
+ * so junk before the answer never hides the object that follows. But a brace that
292
+ * looks like JSON yet never closes is a TRUNCATED object — we stop there rather
293
+ * than descend into it, which would surface a nested child as a fake top-level
294
+ * answer (`{"decision":"REAL","meta":{...}` must not leak the inner `meta`). */
295
+ function topLevelJsonSlices(text) {
296
+ const slices = [];
297
+ let i = 0;
298
+ for (;;) {
299
+ const start = text.indexOf("{", i);
300
+ if (start < 0)
301
+ break;
302
+ if (!looksLikeJsonObject(text, start)) {
303
+ i = start + 1; // prose brace (`interface Foo {`, `{set}`) — skip, keep scanning
304
+ continue;
305
+ }
306
+ const end = balancedEnd(text, start);
307
+ if (end < 0)
308
+ break; // a JSON-looking object that never closes → truncated tail
309
+ slices.push(text.slice(start, end + 1));
310
+ i = end + 1;
311
+ }
312
+ return slices;
313
+ }
314
+ /** Strip trailing commas (`,}` / `,]`) WITHOUT touching string contents — the
315
+ * single most common reason a model's near-valid JSON fails strict parse. A
316
+ * blanket regex would also eat a comma that legitimately lives inside a string
317
+ * value (e.g. "see {a, b, }"), silently corrupting the captured text, so this
318
+ * reuses the same in-string/escape state machine as the slicer. */
319
+ function stripTrailingCommas(s) {
320
+ let out = "";
321
+ let inStr = false;
322
+ let esc = false;
323
+ for (let i = 0; i < s.length; i++) {
324
+ const ch = s[i];
325
+ if (inStr) {
326
+ out += ch;
327
+ if (esc)
328
+ esc = false;
329
+ else if (ch === "\\")
330
+ esc = true;
331
+ else if (ch === '"')
332
+ inStr = false;
333
+ continue;
334
+ }
335
+ if (ch === '"') {
336
+ inStr = true;
337
+ out += ch;
338
+ continue;
339
+ }
340
+ if (ch === ",") {
341
+ let k = i + 1;
342
+ while (k < s.length && /\s/.test(s[k]))
343
+ k++;
344
+ if (k < s.length && (s[k] === "}" || s[k] === "]"))
345
+ continue; // drop the comma
346
+ }
347
+ out += ch;
348
+ }
349
+ return out;
350
+ }
351
+ function tryParseObject(s) {
352
+ try {
353
+ const v = JSON.parse(s);
354
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
355
+ }
356
+ catch {
357
+ return null;
358
+ }
359
+ }
360
+ /** Strict JSON first, then one string-aware lenient pass (trailing commas). */
361
+ function parseObjectLoose(slice) {
362
+ return tryParseObject(slice) ?? tryParseObject(stripTrailingCommas(slice));
363
+ }
364
+ /** Every parseable top-level JSON object in arbitrary model text, in order. */
365
+ export function extractJsonObjects(text) {
366
+ return topLevelJsonSlices(text)
367
+ .map(parseObjectLoose)
368
+ .filter((o) => o !== null);
369
+ }
370
+ /** Convenience: the FIRST parseable top-level object, or null. (The mappers below
371
+ * do their own content-based selection; this is just a generic accessor.) */
372
+ export function extractJson(text) {
373
+ return extractJsonObjects(text)[0] ?? null;
374
+ }
375
+ const SEVERITIES = ["low", "medium", "high", "critical"];
376
+ const SEV_RANK = { low: 1, medium: 2, high: 3, critical: 4 };
377
+ function asSeverity(v) {
378
+ return typeof v === "string" && SEVERITIES.includes(v) ? v : null;
379
+ }
380
+ /** Values a model emits as a fill-in TEMPLATE rather than a real answer. We can't
381
+ * use position (a template/recap may lead OR trail the answer), so we recognize
382
+ * placeholder-shaped values and treat the field as empty. Kept deliberately narrow
383
+ * so a terse REAL answer isn't mistaken for a template: an angle-bracket value only
384
+ * counts if it's a short metavariable (≤2 words, e.g. `<what>`, `<best hypothesis>`)
385
+ * — not a bracketed sentence — and the word list holds only unambiguous markers. */
386
+ function isPlaceholder(s) {
387
+ const t = s.trim();
388
+ if (!t)
389
+ return true;
390
+ if (/^\.{2,}$/.test(t))
391
+ return true; // "..", "..."
392
+ const meta = /^<(.+)>$/.exec(t);
393
+ if (meta && meta[1].trim().split(/\s+/).length <= 2)
394
+ return true; // <what>, <best hypothesis>
395
+ return ["see above", "recap", "placeholder"].includes(t.toLowerCase());
396
+ }
397
+ /** A string field, blanked when empty OR a template placeholder. */
398
+ function realStr(v) {
399
+ const s = str(v, "");
400
+ return s && !isPlaceholder(s) ? s : "";
401
+ }
402
+ /** Map model text → DecisionDraft, or null when there's nothing usable to keep.
403
+ * Null (not a hollow draft) is the signal for the caller to fall back to the
404
+ * deterministic provider — we only claim "llm_draft" when the LLM actually
405
+ * produced substance. The model is asked for ONE object; if it emits several (a
406
+ * template/example plus the answer), take the FIRST with real (non-placeholder)
407
+ * substance — robust whether the junk leads or trails the answer. */
408
+ export function decisionDraftFromText(text, fallbackTitle) {
409
+ const candidates = [];
410
+ for (const obj of extractJsonObjects(text)) {
411
+ const decision = realStr(obj.decision);
412
+ const context = realStr(obj.context);
413
+ if (!decision && !context)
414
+ continue; // template / placeholder / unrelated object
415
+ // Coerce explicitly: a model may emit the boolean as the string "false",
416
+ // which is JS-truthy — keying confidence off raw truthiness would invert it.
417
+ const nontrivial = obj.nontrivial === true || obj.nontrivial === "true";
418
+ candidates.push({ obj, decision, context, nontrivial });
419
+ }
420
+ if (!candidates.length)
421
+ return null;
422
+ // Prefer the object the model FLAGGED as a real decision over a generic worked
423
+ // example that may precede it; else the first substantive object.
424
+ const pick = candidates.find((c) => c.nontrivial) ?? candidates[0];
425
+ return {
426
+ title: realStr(pick.obj.title) || fallbackTitle,
427
+ context: pick.context,
428
+ decision: pick.decision,
429
+ consequences: asStrArr(pick.obj.consequences),
430
+ alternatives_rejected: asStrArr(pick.obj.alternatives_rejected),
431
+ confidence: pick.nontrivial ? 0.65 : 0.4,
432
+ source: "llm_draft",
433
+ };
434
+ }
435
+ /** Map model text → BugDraft, or null. A root_cause is the LLM's full value-add;
436
+ * a deliberate non-"medium" severity is worth keeping on its own (it carries the
437
+ * LLM's classification into the bug record rather than the deterministic "medium").
438
+ * Pick the BEST candidate object — most substantiated (root-caused) then most
439
+ * severe — NOT the positionally first/last, so a trailing low-severity recap can't
440
+ * downgrade a real critical finding. Without a root_cause the draft is labeled
441
+ * honestly as partial at lower confidence; constraint promotion is gated on a real
442
+ * root_cause downstream (see shouldPromoteConstraint), so a bare severity label
443
+ * preserves its signal in the record without auto-minting an invariant. */
444
+ export function bugDraftFromText(text, fallbackTitle, fallbackSymptom) {
445
+ let best = null;
446
+ let bestScore = -1;
447
+ for (const obj of extractJsonObjects(text)) {
448
+ const root_cause = realStr(obj.root_cause);
449
+ const severity = asSeverity(obj.severity);
450
+ const deliberate = severity !== null && severity !== "medium";
451
+ if (!root_cause && !deliberate)
452
+ continue; // only echoes the input → not a candidate
453
+ const score = (root_cause ? 100 : 0) + (severity ? SEV_RANK[severity] : 0);
454
+ if (score > bestScore) {
455
+ bestScore = score;
456
+ best = { obj, root_cause, severity };
457
+ }
458
+ }
459
+ if (!best)
460
+ return null;
461
+ const full = !!best.root_cause;
462
+ return {
463
+ title: realStr(best.obj.title) || fallbackTitle,
464
+ symptom: realStr(best.obj.symptom) || fallbackSymptom,
465
+ root_cause: best.root_cause,
466
+ severity: best.severity ?? "medium",
467
+ confidence: full ? 0.55 : 0.4,
468
+ source: full ? "test_failure+llm" : "test_failure+llm_partial",
469
+ };
470
+ }
471
+ function asStrArr(v) {
472
+ return Array.isArray(v) ? v.map((x) => String(x)).filter(Boolean) : [];
473
+ }
474
+ function str(v, fallback) {
475
+ return typeof v === "string" && v.trim() ? v : fallback;
476
+ }
477
+ function topDirs(files) {
478
+ const set = new Set();
479
+ for (const f of files) {
480
+ const parts = f.split("/");
481
+ set.add(parts.length > 1 ? parts.slice(0, 2).join("/") : parts[0] ?? f);
482
+ }
483
+ return [...set].slice(0, 6);
484
+ }
485
+ function cap(s) {
486
+ return s ? s[0].toUpperCase() + s.slice(1) : s;
487
+ }
488
+ //# sourceMappingURL=provider.js.map