auxilo-mcp 0.9.0 → 0.9.2

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/bin/auxilo-cli.js CHANGED
@@ -90,12 +90,21 @@ const CONSENT_TEXT = `
90
90
  --------------------------------
91
91
  If you opt in, a session-end hook runs after each session in your wired
92
92
  clients (Claude Code, Cursor, Gemini CLI, …) and a local runner:
93
- • READS the session transcript from your machine,
94
- • SCRUBS it locally (sensitivity filter: API keys, tokens, emails, PII
95
- redacted BEFORE anything leaves your machine),
96
- UPLOADS only the scrubbed transcript to Auxilo (${'POST /extract'}),
97
- where reusable learnings are extracted, quality-gated, moderated, and
98
- published to the marketplace under your account. You earn 70% of sales.
93
+ • READS the session transcript on your machine,
94
+ • SCRUBS it locally (sensitivity filter: API keys, tokens, emails, PII
95
+ are redacted first),
96
+ EXTRACTS reusable learnings locally using your own claude CLI (your
97
+ existing subscription). For this step your transcript is processed
98
+ only by your own model provider the same way your normal sessions
99
+ are, and is never sent to Auxilo, raw or scrubbed.
100
+ • UPLOADS only the finished learning drafts (title, body, category,
101
+ tags, task context, outcome) to Auxilo (${'POST /learn'}). A draft
102
+ that passes every screen publishes to the marketplace immediately
103
+ under your account, and you can retract it for 7 days. A draft that
104
+ any screen flags (sensitive, duplicate, uncertain quality) waits in
105
+ your private queue for \`auxilo review\`. Manual mode (approve first,
106
+ for everything) is available in your account settings. You earn 70%
107
+ of sales.
99
108
  You can stop any time with \`auxilo disable\` (local kill-switch) and review
100
109
  every run in ~/.auxilo/extract.log. Saying No installs the MCP server only.
101
110
  `;
@@ -338,20 +347,108 @@ async function cmdDisable(flags) {
338
347
  // The human gate that makes background extraction safe to re-enable:
339
348
  // extract -> pending_review -> `auxilo review` -> approve the safe ones.
340
349
  // Operates ONLY on the caller's own pending learnings (account-scoped API key).
350
+ //
351
+ // Review-seamless (2026-07-18): the default view is now a triage summary table
352
+ // (quality desc, clean before flagged), with bulk modes that batch through
353
+ // POST /account/pending/bulk. HARD RULE for every bulk APPROVE path: print the
354
+ // exact list and count first, then require the operator to TYPE THE COUNT.
355
+ // There is no --yes bypass on any approve path; publishing always costs one
356
+ // typed number (2026-06-10 mass-publish lesson).
357
+
358
+ /** Truncate + pad a string to an exact width (for the triage table). */
359
+ function fit(s, width) {
360
+ const str = String(s == null ? '' : s);
361
+ if (str.length > width) return str.slice(0, Math.max(0, width - 1)) + '…';
362
+ return str.padEnd(width);
363
+ }
364
+
365
+ /** Short flag codes for a summary row: 'clean' or e.g. 'inj+sens'. */
366
+ function shortFlags(row) {
367
+ if (row.screens_passed) return 'clean';
368
+ const map = { injection: 'inj', content_sensitivity: 'sens', near_duplicate: 'dup' };
369
+ return (row.flags || []).map((f) => map[f] || f).join('+') || 'flagged';
370
+ }
371
+
372
+ /** One compact triage line (shared by the table and rapid mode). */
373
+ function triageLine(row, n, total) {
374
+ const q = row.quality == null ? ' --' : String(row.quality).padStart(3);
375
+ return ` ${String(n).padStart(String(total).length)}. q=${q} ${fit(shortFlags(row), 9)} ${fit(row.category || '', 20)} ${fit(row.title || '(no title)', 52)}`;
376
+ }
377
+
378
+ /** Print the triage summary: counts, then clean rows, then flagged rows. */
379
+ function printSummaryTable(summary) {
380
+ const items = summary.items || [];
381
+ const clean = items.filter((r) => r.screens_passed);
382
+ const flagged = items.filter((r) => !r.screens_passed);
383
+
384
+ console.log(`\n${summary.pending_count} learning(s) pending your review: ${clean.length} passed every platform screen, ${flagged.length} flagged.`);
385
+ const bands = summary.counts && summary.counts.by_quality_band;
386
+ if (bands) {
387
+ console.log(`Quality bands: 18-20: ${bands['18-20'] || 0} · 14-17: ${bands['14-17'] || 0} · 10-13: ${bands['10-13'] || 0} · below 10: ${bands.below_10 || 0} · unscored: ${bands.unscored || 0}`);
388
+ }
389
+ if (Array.isArray(summary.near_dup_clusters) && summary.near_dup_clusters.length > 0) {
390
+ console.log(`Near-duplicate clusters among your pending items: ${summary.near_dup_clusters.length} (review these together)`);
391
+ }
392
+
393
+ if (clean.length > 0) {
394
+ console.log(`\nCLEAN (passed every screen) - sorted by quality:`);
395
+ clean.forEach((r, i) => console.log(triageLine(r, i + 1, clean.length)));
396
+ }
397
+ if (flagged.length > 0) {
398
+ console.log(`\nFLAGGED (a platform screen raised a signal) - review individually:`);
399
+ flagged.forEach((r, i) => console.log(triageLine(r, i + 1, flagged.length)));
400
+ }
401
+ console.log('');
402
+ }
403
+
404
+ /**
405
+ * The counted confirmation: print the exact selection, then require the
406
+ * operator to type the exact count. Returns true ONLY on an exact match.
407
+ * No flag bypasses this for approve paths.
408
+ */
409
+ async function confirmByTypedCount(rows, actionLabel) {
410
+ console.log(`\n${actionLabel} the following ${rows.length} learning(s):`);
411
+ rows.forEach((r) => {
412
+ const q = r.quality == null ? '--' : r.quality;
413
+ console.log(` ${r.id} q=${q} [${shortFlags(r)}] ${r.title || '(no title)'}`);
414
+ });
415
+ console.log(`\nCount: ${rows.length}.`);
416
+ const answer = await ask(`Type the count (${rows.length}) to confirm, anything else aborts: `);
417
+ if (answer !== String(rows.length)) {
418
+ console.log('Aborted. Nothing changed.');
419
+ return false;
420
+ }
421
+ return true;
422
+ }
423
+
424
+ /** Run a confirmed selection through the bulk endpoint in chunks, with progress. */
425
+ async function runBulk({ apiKey, baseUrl, rows, decision, reason }) {
426
+ const decisions = rows.map((r) => (decision === 'reject' && reason)
427
+ ? { id: r.id, decision, reason }
428
+ : { id: r.id, decision });
429
+ const totals = await review.submitBulkChunked({
430
+ apiKey,
431
+ baseUrl,
432
+ decisions,
433
+ onChunk: ({ chunkIndex, chunkCount, response }) => {
434
+ console.log(` chunk ${chunkIndex + 1}/${chunkCount}: approved ${response.approved || 0}, rejected ${response.rejected || 0}, already done ${response.idempotent || 0}, failed ${response.failed || 0}`);
435
+ },
436
+ });
437
+ for (const r of totals.results) {
438
+ if (!r.ok) console.error(` ! ${r.id || `entry #${r.index}`}: ${r.error || r.code}`);
439
+ }
440
+ return totals;
441
+ }
341
442
 
342
- /** Print one candidate's full detail for the interactive reviewer. */
343
- function printCandidate(l, n, total) {
344
- console.log(`\n──────────────────────────────────────────────────────────────`);
345
- console.log(`[${n}/${total}] ${l.title}`);
346
- console.log(` id: ${l.id}`);
347
- console.log(` category: ${l.category}${l.outcome ? ` outcome: ${l.outcome}` : ''}`);
348
- if (l.tags && l.tags.length) console.log(` tags: ${l.tags.join(', ')}`);
349
- if (l.created_at) console.log(` created: ${l.created_at}`);
350
- const flags = review.formatFlags(l);
351
- if (flags) console.log(` ⚠ flags: ${flags}`);
352
- console.log(` ----------------------------------------------------------`);
353
- console.log(l.body || '(no body)');
354
- console.log(` ----------------------------------------------------------`);
443
+ /** Parse --min-quality into an integer 0-20 (default DEFAULT_QUALITY_THRESHOLD). */
444
+ function parseMinQuality(flags) {
445
+ if (flags['min-quality'] === undefined) return review.DEFAULT_QUALITY_THRESHOLD;
446
+ const n = parseInt(flags['min-quality'], 10);
447
+ if (!Number.isFinite(n) || n < 0 || n > 20) {
448
+ console.error('Invalid --min-quality (expected an integer 0-20).');
449
+ process.exit(1);
450
+ }
451
+ return n;
355
452
  }
356
453
 
357
454
  async function cmdReview(flags) {
@@ -363,80 +460,129 @@ async function cmdReview(flags) {
363
460
  const baseUrl = resolveBaseUrl(flags);
364
461
  const apiKey = creds.api_key;
365
462
 
366
- let res;
463
+ // ── Summary FIRST, always (triage before any decision surface) ────────────
464
+ let summary;
367
465
  try {
368
- res = await review.fetchPending({ apiKey, baseUrl, limit: 200 });
466
+ summary = await review.fetchPendingSummary({ apiKey, baseUrl });
369
467
  } catch (err) {
370
- console.error(`Could not fetch pending review queue: ${err.message}`);
468
+ console.error(`Could not fetch pending review summary: ${err.message}`);
371
469
  process.exit(1);
372
470
  }
373
471
 
374
- const items = res.learnings || [];
375
- const total = res.pending_count != null ? res.pending_count : items.length;
376
-
377
- if (items.length === 0) {
472
+ const rows = summary.items || [];
473
+ if (rows.length === 0) {
378
474
  console.log('No learnings pending your review. Nothing to do.');
379
475
  return;
380
476
  }
381
477
 
382
- // ── --list: non-interactive inspection, no mutations ──────────────────────
383
- if (flags.list) {
384
- console.log(`\n${total} learning(s) pending your review:\n`);
385
- items.forEach((l, i) => {
386
- const flagStr = review.formatFlags(l);
387
- console.log(` ${i + 1}. ${l.title} [${l.id}]${flagStr ? ` ⚠ ${flagStr}` : ''}`);
388
- });
389
- console.log('');
478
+ printSummaryTable(summary);
479
+
480
+ // ── --list: summary only, no mutations ────────────────────────────────────
481
+ if (flags.list) return;
482
+
483
+ // ── --approve-clean: bulk-approve items that passed EVERY platform screen
484
+ // AND clear the quality threshold. Typed-count confirmation, no bypass. ──
485
+ if (flags['approve-clean']) {
486
+ const minQuality = parseMinQuality(flags);
487
+ const sel = review.selectForBulkApprove(rows, { mode: 'clean', minQuality });
488
+ console.log(`approve-clean selection: ${sel.selected.length} of ${rows.length} pending (threshold: quality >= ${minQuality}).`);
489
+ if (sel.excluded_flagged.length) console.log(` excluded ${sel.excluded_flagged.length} screen-flagged item(s) (review those individually).`);
490
+ if (sel.excluded_low_quality.length) console.log(` excluded ${sel.excluded_low_quality.length} below the quality threshold.`);
491
+ if (sel.excluded_unscored.length) console.log(` excluded ${sel.excluded_unscored.length} with no quality score (pass --min-quality 0 to include).`);
492
+ if (sel.selected.length === 0) { console.log('Nothing qualifies. No changes made.'); return; }
493
+
494
+ if (!await confirmByTypedCount(sel.selected, 'APPROVE and PUBLISH')) return;
495
+ const totals = await runBulk({ apiKey, baseUrl, rows: sel.selected, decision: 'approve' });
496
+ console.log(`\nDone. Approved ${totals.approved} (already approved earlier: ${totals.idempotent}, failed: ${totals.failed}). Flagged and below-threshold items stay pending.`);
390
497
  return;
391
498
  }
392
499
 
393
- // ── --all-reject: bulk reject the whole batch (incident escape hatch) ─────
394
- if (flags['all-reject']) {
395
- const ok = flags.yes || await askYesNo(`Reject ALL ${items.length} pending learning(s)? This cannot be undone`, false);
396
- if (!ok) { console.log('Aborted nothing changed.'); return; }
397
- let rejected = 0;
398
- for (const l of items) {
399
- try {
400
- await review.submitDecision({ apiKey, baseUrl, id: l.id, decision: 'reject' });
401
- rejected += 1;
402
- console.log(` ✗ rejected (${rejected}/${items.length}): ${l.title}`);
403
- } catch (err) {
404
- console.error(` ! failed to reject ${l.id}: ${err.message}`);
405
- }
500
+ // ── --all: bulk-approve everything EXCEPT screen-flagged items unless
501
+ // --include-flagged. Typed-count confirmation, no bypass. ───────────────
502
+ if (flags.all) {
503
+ const includeFlagged = flags['include-flagged'] === true;
504
+ const sel = review.selectForBulkApprove(rows, { mode: 'all', includeFlagged });
505
+ if (includeFlagged && sel.selected.some((r) => !r.screens_passed)) {
506
+ console.log('WARNING: --include-flagged is set. This selection INCLUDES items a platform screen flagged (possible injection, sensitive content, or near-duplicates). Approving publishes them publicly.');
507
+ } else if (sel.excluded_flagged.length) {
508
+ console.log(`Excluding ${sel.excluded_flagged.length} screen-flagged item(s) (pass --include-flagged to include them; safer to review those individually).`);
406
509
  }
407
- console.log(`\nDone. Rejected ${rejected} of ${items.length}.`);
510
+ if (sel.selected.length === 0) { console.log('Nothing qualifies. No changes made.'); return; }
511
+
512
+ if (!await confirmByTypedCount(sel.selected, 'APPROVE and PUBLISH')) return;
513
+ const totals = await runBulk({ apiKey, baseUrl, rows: sel.selected, decision: 'approve' });
514
+ console.log(`\nDone. Approved ${totals.approved} (already approved earlier: ${totals.idempotent}, failed: ${totals.failed}).`);
408
515
  return;
409
516
  }
410
517
 
411
- // ── interactive (default) ─────────────────────────────────────────────────
412
- console.log(`\n${total} learning(s) pending your review.`);
413
- console.log('For each: [a]pprove (go live) · [r]eject (stays private) · [s]kip · [q]uit\n');
518
+ // ── --all-reject: bulk reject the whole batch (incident escape hatch).
519
+ // Now batched through the bulk endpoint. --yes keeps its scripted-incident
520
+ // bypass because rejection is the SAFE direction (nothing goes public). ──
521
+ if (flags['all-reject']) {
522
+ const ok = flags.yes || await confirmByTypedCount(rows, 'REJECT (keep private)');
523
+ if (!ok) return;
524
+ const totals = await runBulk({ apiKey, baseUrl, rows, decision: 'reject' });
525
+ console.log(`\nDone. Rejected ${totals.rejected} of ${rows.length} (already rejected earlier: ${totals.idempotent}, failed: ${totals.failed}).`);
526
+ return;
527
+ }
414
528
 
415
- let approved = 0, rejected = 0, skipped = 0, seen = 0;
416
- for (const l of items) {
417
- seen += 1;
418
- printCandidate(l, seen, items.length);
529
+ // ── interactive rapid mode (default): y/n/s per item, one line each.
530
+ // [v] shows the full body before deciding; decisions post one at a time
531
+ // through the same single-item endpoints as before. ──────────────────────
532
+ const bodies = new Map();
533
+ try {
534
+ let offset = 0;
535
+ const pageLimit = 200;
536
+ for (;;) {
537
+ const page = await review.fetchPending({ apiKey, baseUrl, limit: pageLimit, offset });
538
+ for (const l of page.learnings || []) bodies.set(l.id, l);
539
+ offset += (page.learnings || []).length;
540
+ if (!page.learnings || page.learnings.length === 0 || offset >= (page.pending_count || 0)) break;
541
+ }
542
+ } catch (err) {
543
+ console.error(`Could not fetch pending bodies: ${err.message}`);
544
+ process.exit(1);
545
+ }
546
+
547
+ console.log('Rapid review. Per item: [y] approve (goes live) · [n] reject (stays private) · [v] view full body · [s] skip · [q] quit\n');
548
+
549
+ let approved = 0, rejected = 0, skipped = 0;
550
+ const ordered = rows; // clean-first, quality desc (server order preserved by printSummaryTable groups)
551
+ for (let i = 0; i < ordered.length; i++) {
552
+ const row = ordered[i];
553
+ console.log(triageLine(row, i + 1, ordered.length));
419
554
  let choice = '';
420
- while (!['a', 'r', 's', 'q'].includes(choice)) {
421
- choice = (await ask(' approve / reject / skip / quit [a/r/s/q]? ')).toLowerCase().slice(0, 1);
555
+ for (;;) {
556
+ choice = (await ask(' [y/n/v/s/q]? ')).toLowerCase().slice(0, 1);
557
+ if (choice === 'v') {
558
+ const full = bodies.get(row.id);
559
+ console.log(' ------------------------------------------------------------');
560
+ console.log(full && full.body ? full.body : '(body not loaded)');
561
+ if (full) {
562
+ const flagStr = review.formatFlags(full);
563
+ if (flagStr) console.log(` ⚠ flags: ${flagStr}`);
564
+ }
565
+ console.log(' ------------------------------------------------------------');
566
+ continue;
567
+ }
568
+ if (['y', 'n', 's', 'q'].includes(choice)) break;
422
569
  }
423
570
  if (choice === 'q') { console.log(' Stopping. Remaining items left pending.'); break; }
424
- if (choice === 's') { skipped += 1; console.log(' → skipped (still pending)'); continue; }
571
+ if (choice === 's') { skipped += 1; continue; }
425
572
  try {
426
- if (choice === 'a') {
427
- await review.submitDecision({ apiKey, baseUrl, id: l.id, decision: 'approve' });
428
- approved += 1; console.log(' ✓ approved now live');
573
+ if (choice === 'y') {
574
+ await review.submitDecision({ apiKey, baseUrl, id: row.id, decision: 'approve' });
575
+ approved += 1; console.log(' ✓ approved, now live');
429
576
  } else {
430
- const reason = await ask(' reason (optional, Enter to skip): ');
431
- await review.submitDecision({ apiKey, baseUrl, id: l.id, decision: 'reject', reason: reason || undefined });
432
- rejected += 1; console.log(' ✗ rejected — stays private');
577
+ await review.submitDecision({ apiKey, baseUrl, id: row.id, decision: 'reject' });
578
+ rejected += 1; console.log(' ✗ rejected, stays private');
433
579
  }
434
580
  } catch (err) {
435
- console.error(` ! decision failed: ${err.message} (item left pending)`);
581
+ console.error(` ! decision failed: ${err.message} (item left pending)`);
436
582
  }
437
583
  }
438
584
 
439
- console.log(`\nReview complete: approved ${approved}, rejected ${rejected}, skipped ${skipped} of ${items.length}.`);
585
+ console.log(`\nReview complete: approved ${approved}, rejected ${rejected}, skipped ${skipped} of ${ordered.length}.`);
440
586
  }
441
587
 
442
588
  // ─── Entry point ────────────────────────────────────────────────────────────
@@ -451,8 +597,24 @@ Commands:
451
597
  Flags: --re-auth, --base-url <url>
452
598
  status Show install/auth/extraction status.
453
599
  review Review YOUR pending-review learnings (from background extraction)
454
- and approve/reject each before it goes public.
455
- Flags: --list (inspect only), --all-reject [--yes], --base-url <url>
600
+ and approve/reject before anything goes public. Default: triage
601
+ summary table first (quality desc, clean vs flagged), then rapid
602
+ y/n/s review.
603
+ Flags:
604
+ --list summary table only, no changes
605
+ --approve-clean bulk-approve items that passed EVERY platform
606
+ screen AND quality >= threshold (default 14;
607
+ tune with --min-quality N). Prints the exact
608
+ list, then requires typing the count.
609
+ --all bulk-approve everything EXCEPT screen-flagged
610
+ items (add --include-flagged to include them).
611
+ Same typed-count confirmation.
612
+ --min-quality N quality threshold for --approve-clean (0-20)
613
+ --all-reject reject the whole batch [--yes for scripted
614
+ incident response; rejects stay private]
615
+ --base-url <url>
616
+ Bulk approvals ALWAYS require typing the exact count. No flag
617
+ skips that step.
456
618
  disable Turn off background extraction (local kill-switch; optional
457
619
  server-side consent revoke).
458
620
 
package/lib/installer.js CHANGED
@@ -31,7 +31,7 @@ const path = require('path');
31
31
  const MCP_ENTRY = Object.freeze({ command: 'npx', args: ['auxilo-mcp'] });
32
32
 
33
33
  /** Default production API base (README.md / openapi.json servers[0]). */
34
- const DEFAULT_BASE_URL = 'https://api.auxilo.io';
34
+ const DEFAULT_BASE_URL = 'https://auxilo.io';
35
35
 
36
36
  /** Root of the installed npm package (or the repo, when run from a clone). */
37
37
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
@@ -47,6 +47,10 @@ const RUNNER_STACK = Object.freeze([
47
47
  // [package-relative source, bin-relative dest, mode]
48
48
  ['scripts/runner.js', 'scripts/runner.js', 0o755],
49
49
  ['scripts/capture-core.js', 'scripts/capture-core.js', 0o755], // UC-1 shim target
50
+ // Client-side extraction module: runner.js postExtract() requires './extract-local.js'
51
+ // at first extraction; omitting it from the installed stack is a MODULE_NOT_FOUND
52
+ // for every npm-installed user (test/runner-packaging-closure.test.js guards this).
53
+ ['scripts/extract-local.js', 'scripts/extract-local.js', 0o644],
50
54
  ['scripts/sources/source.interface.js', 'scripts/sources/source.interface.js', 0o644],
51
55
  ['scripts/sources/claude-code.js', 'scripts/sources/claude-code.js', 0o644],
52
56
  ['scripts/sources/openclaw.js', 'scripts/sources/openclaw.js', 0o644],
package/lib/review.js CHANGED
@@ -11,11 +11,14 @@
11
11
  *
12
12
  * extract -> pending_review -> `auxilo review` (this) -> approve/reject
13
13
  *
14
- * That loop is what makes background extraction safe to re-enable: nothing the
15
- * contributor extracted goes public until they personally approve it here.
14
+ * That loop is what keeps background extraction safe to leave on: anything a
15
+ * platform screen flags stays 'pending_review' (out of the public catalog)
16
+ * until the contributor personally approves it here. Clean extractions publish
17
+ * seamlessly with a 7-day retraction window and never enter this queue; manual
18
+ * mode (account settings) routes every draft through it instead.
16
19
  */
17
20
 
18
- const DEFAULT_BASE_URL = 'https://api.auxilo.io';
21
+ const DEFAULT_BASE_URL = 'https://auxilo.io';
19
22
 
20
23
  function normBase(baseUrl) {
21
24
  return (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
@@ -116,9 +119,173 @@ function formatFlags(l) {
116
119
  return parts.join(' · ');
117
120
  }
118
121
 
122
+ // ── Review-seamless additions (2026-07-18) ──────────────────────────────────
123
+ //
124
+ // Summary + bulk network calls, plus the pure approve-clean selection logic
125
+ // shared by the CLI, the MCP auxilo_review tool, and the triage report script.
126
+ // One selection implementation everywhere, so what a dry run PRINTS is exactly
127
+ // what a confirmed run DOES.
128
+
129
+ /** Server-enforced max decisions per bulk call; clients chunk at this size. */
130
+ const BULK_CHUNK = 100;
131
+
132
+ /** Default approve-clean quality threshold (repo quality gate: total >= 14/20). */
133
+ const DEFAULT_QUALITY_THRESHOLD = 14;
134
+
135
+ /**
136
+ * GET /account/pending/summary: compact triage rows + counts (no bodies).
137
+ *
138
+ * @param {object} opts { apiKey, baseUrl?, fetchImpl? }
139
+ * @returns {Promise<object>} summary body (pending_count, clean_count, items, ...)
140
+ */
141
+ async function fetchPendingSummary(opts = {}) {
142
+ const { apiKey } = opts;
143
+ if (!apiKey) throw new Error('fetchPendingSummary: apiKey is required');
144
+ const baseUrl = normBase(opts.baseUrl);
145
+ const fetchImpl = opts.fetchImpl || fetch;
146
+
147
+ const res = await fetchImpl(`${baseUrl}/account/pending/summary`, {
148
+ method: 'GET',
149
+ headers: { 'X-API-Key': apiKey },
150
+ });
151
+ let body = {};
152
+ try { body = await res.json(); } catch { /* non-JSON error body */ }
153
+ if (!res.ok) {
154
+ throw new Error(`Fetch pending summary failed (HTTP ${res.status}): ${body.error || 'unknown error'}`);
155
+ }
156
+ return body;
157
+ }
158
+
159
+ /**
160
+ * POST /account/pending/bulk: submit ONE chunk of decisions (<= BULK_CHUNK).
161
+ * confirm_count is set to decisions.length here because by the time this is
162
+ * called, the human-facing layer has already shown the exact list and count
163
+ * and received the operator's counted confirmation. This function never
164
+ * invents decisions; it transmits what was confirmed.
165
+ *
166
+ * @param {object} opts { apiKey, decisions, baseUrl?, fetchImpl? }
167
+ * @returns {Promise<object>} server response (counts + per-id results)
168
+ */
169
+ async function submitBulk(opts = {}) {
170
+ const { apiKey, decisions } = opts;
171
+ if (!apiKey) throw new Error('submitBulk: apiKey is required');
172
+ if (!Array.isArray(decisions) || decisions.length === 0) {
173
+ throw new Error('submitBulk: decisions must be a non-empty array');
174
+ }
175
+ if (decisions.length > BULK_CHUNK) {
176
+ throw new Error(`submitBulk: at most ${BULK_CHUNK} decisions per call (use chunkDecisions)`);
177
+ }
178
+ const baseUrl = normBase(opts.baseUrl);
179
+ const fetchImpl = opts.fetchImpl || fetch;
180
+
181
+ const res = await fetchImpl(`${baseUrl}/account/pending/bulk`, {
182
+ method: 'POST',
183
+ headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
184
+ body: JSON.stringify({ decisions, confirm_count: decisions.length }),
185
+ });
186
+ let body = {};
187
+ try { body = await res.json(); } catch { /* non-JSON error body */ }
188
+ if (!res.ok) {
189
+ throw new Error(`Bulk review failed (HTTP ${res.status}): ${body.error || 'unknown error'}`);
190
+ }
191
+ return body;
192
+ }
193
+
194
+ /** Split an array into chunks of at most `size` (default BULK_CHUNK). */
195
+ function chunkDecisions(list, size = BULK_CHUNK) {
196
+ const n = Math.max(1, size);
197
+ const out = [];
198
+ for (let i = 0; i < list.length; i += n) out.push(list.slice(i, i + n));
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * Submit decisions through the bulk endpoint in sequential chunks, aggregating
204
+ * counts and per-id results. onChunk (optional) is called after each chunk for
205
+ * progress rendering. A failed chunk stops the run (already-applied chunks
206
+ * stay applied; the endpoint is idempotent per id, so re-running is safe).
207
+ *
208
+ * @param {object} opts { apiKey, decisions, baseUrl?, fetchImpl?, onChunk? }
209
+ * @returns {Promise<{approved:number, rejected:number, idempotent:number, failed:number, results:Array<object>}>}
210
+ */
211
+ async function submitBulkChunked(opts = {}) {
212
+ const { decisions } = opts;
213
+ const totals = { approved: 0, rejected: 0, idempotent: 0, failed: 0, results: [] };
214
+ const chunks = chunkDecisions(decisions || []);
215
+ for (let i = 0; i < chunks.length; i++) {
216
+ const resp = await submitBulk({ ...opts, decisions: chunks[i] });
217
+ totals.approved += resp.approved || 0;
218
+ totals.rejected += resp.rejected || 0;
219
+ totals.idempotent += resp.idempotent || 0;
220
+ totals.failed += resp.failed || 0;
221
+ if (Array.isArray(resp.results)) totals.results.push(...resp.results);
222
+ if (typeof opts.onChunk === 'function') {
223
+ opts.onChunk({ chunkIndex: i, chunkCount: chunks.length, chunkSize: chunks[i].length, response: resp });
224
+ }
225
+ }
226
+ return totals;
227
+ }
228
+
229
+ /** True when a summary row's quality clears the threshold (threshold <= 0 accepts unscored). */
230
+ function qualityClears(row, minQuality) {
231
+ if (minQuality <= 0) return true;
232
+ return row.quality != null && row.quality >= minQuality;
233
+ }
234
+
235
+ /**
236
+ * THE approve-clean selection. Operates on summary rows (screens_passed,
237
+ * flags, quality) from GET /account/pending/summary.
238
+ *
239
+ * mode 'clean' (approve-clean): screens_passed AND quality >= minQuality.
240
+ * mode 'all': every row EXCEPT screens-flagged ones unless includeFlagged
241
+ * is set; no quality gate.
242
+ *
243
+ * Returns the selection plus what was excluded and why, so every confirmation
244
+ * surface can show the operator the exact consequences before anything runs.
245
+ *
246
+ * @param {Array<object>} rows
247
+ * @param {object} [opts] { mode: 'clean'|'all', minQuality, includeFlagged }
248
+ * @returns {{selected:Array<object>, excluded_flagged:Array<object>, excluded_low_quality:Array<object>, excluded_unscored:Array<object>, min_quality:number|null}}
249
+ */
250
+ function selectForBulkApprove(rows, opts = {}) {
251
+ const mode = opts.mode === 'all' ? 'all' : 'clean';
252
+ const minQuality = mode === 'clean'
253
+ ? (Number.isFinite(opts.minQuality) ? opts.minQuality : DEFAULT_QUALITY_THRESHOLD)
254
+ : null;
255
+ const includeFlagged = mode === 'all' && opts.includeFlagged === true;
256
+
257
+ const selected = [];
258
+ const excluded_flagged = [];
259
+ const excluded_low_quality = [];
260
+ const excluded_unscored = [];
261
+
262
+ for (const row of rows || []) {
263
+ if (!row || !row.id) continue;
264
+ if (!row.screens_passed && !includeFlagged) {
265
+ excluded_flagged.push(row);
266
+ continue;
267
+ }
268
+ if (mode === 'clean' && !qualityClears(row, minQuality)) {
269
+ (row.quality == null ? excluded_unscored : excluded_low_quality).push(row);
270
+ continue;
271
+ }
272
+ selected.push(row);
273
+ }
274
+
275
+ return { selected, excluded_flagged, excluded_low_quality, excluded_unscored, min_quality: minQuality };
276
+ }
277
+
119
278
  module.exports = {
120
279
  DEFAULT_BASE_URL,
280
+ BULK_CHUNK,
281
+ DEFAULT_QUALITY_THRESHOLD,
121
282
  fetchPending,
122
283
  submitDecision,
123
284
  formatFlags,
285
+ fetchPendingSummary,
286
+ submitBulk,
287
+ chunkDecisions,
288
+ submitBulkChunked,
289
+ qualityClears,
290
+ selectForBulkApprove,
124
291
  };