auxilo-mcp 0.9.7 → 0.9.8

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/README.md CHANGED
@@ -45,13 +45,16 @@ Extraction defaults to seamless: a draft that passes every screen (secrets, sens
45
45
 
46
46
  ```bash
47
47
  npx auxilo review --list # show all three lanes and each exception reason
48
- npx auxilo review --approve-ready # select exactly the server's ready_to_publish lane
49
- npx auxilo review # approve, reject, view, or skip one draft at a time
48
+ npx auxilo review --approve-ready # approve public-destined ready_to_publish rows
49
+ npx auxilo review --keep-private # keep Needs your eyes owner-only at $0 recall
50
+ npx auxilo review # approve, reject, keep private, view, or skip
50
51
  npx auxilo status # clients, hooks, queue depth, consent state
51
52
  npx auxilo disable # kill switch: extraction stops immediately
52
53
  ```
53
54
 
54
- Every bulk approval prints the exact selection and requires you to type its count. `--min-quality 16` narrows the ready lane; values below 14 explicitly reach into Needs a score and print a warning before the same counted confirmation. Approve a queued draft and it goes live in the marketplace. Reject it and it stays private. Prefer approve-first for everything? Switch your account to manual mode in account settings and every draft waits for you.
55
+ Every bulk decision prints the exact selection and requires you to type its count. `--min-quality 16` narrows the ready lane; values below 14 explicitly reach into Needs a score and print a warning before the same counted confirmation. Private-destined rows are excluded from approval: keep one private for owner-only $0 recall, or sanitize and promote a corrected replacement through public review. A private-destined row can never go public through Approve.
56
+
57
+ Private extraction is opt-in: set `AUXILO_CAPTURE_VISIBILITY=private`, or set `"capture_visibility": "private"` in `~/.auxilo/credentials.json`. It can retain reusable non-technical candidates in your owner-only lane while preserving the same mandatory local sensitivity scrub and dedup pipeline. With the setting absent, extraction remains public-destined and technical-only.
55
58
 
56
59
  Extraction off? Your agent can still contribute in-session: tell it to submit a learning with the `auxilo_contribute` tool.
57
60
 
package/bin/auxilo-cli.js CHANGED
@@ -551,8 +551,9 @@ function fit(s, width) {
551
551
  /** Short lane/flag codes for a summary row, e.g. 'ready' or 'inj+sens'. */
552
552
  function shortFlags(row) {
553
553
  const resolved = review.reviewLane(row);
554
- if (resolved.lane === 'ready_to_publish') return 'ready';
555
- if (resolved.lane === 'needs_score') return 'score';
554
+ const visibility = row.visibility === 'private' ? ['priv'] : [];
555
+ if (resolved.lane === 'ready_to_publish') return visibility.concat('ready').join('+');
556
+ if (resolved.lane === 'needs_score') return visibility.concat('score').join('+');
556
557
  const map = {
557
558
  injection: 'inj',
558
559
  content_sensitivity: 'sens',
@@ -560,7 +561,7 @@ function shortFlags(row) {
560
561
  process_advice: 'advice',
561
562
  account_vocab: 'vocab',
562
563
  };
563
- return (row.flags || []).map((f) => map[f] || f).join('+') || 'flagged';
564
+ return visibility.concat((row.flags || []).map((f) => map[f] || f)).join('+') || 'flagged';
564
565
  }
565
566
 
566
567
  /** One compact triage line (shared by the table and rapid mode). */
@@ -660,7 +661,7 @@ async function runBulk({ apiKey, baseUrl, rows, decision, reason }) {
660
661
  baseUrl,
661
662
  decisions,
662
663
  onChunk: ({ chunkIndex, chunkCount, response }) => {
663
- console.log(` chunk ${chunkIndex + 1}/${chunkCount}: approved ${response.approved || 0}, rejected ${response.rejected || 0}, already done ${response.idempotent || 0}, failed ${response.failed || 0}`);
664
+ console.log(` chunk ${chunkIndex + 1}/${chunkCount}: approved ${response.approved || 0}, kept private ${response.kept_private || 0}, rejected ${response.rejected || 0}, already done ${response.idempotent || 0}, failed ${response.failed || 0}`);
664
665
  },
665
666
  });
666
667
  for (const r of totals.results) {
@@ -720,6 +721,9 @@ async function cmdReview(flags) {
720
721
  const minQuality = parseMinQuality(flags);
721
722
  const sel = review.selectForBulkApprove(rows, { mode: 'ready', minQuality });
722
723
  console.log(`approve-ready selection: ${sel.selected.length} of ${rows.length} pending (threshold: quality >= ${minQuality}).`);
724
+ if (sel.excluded_private.length) {
725
+ console.log(` excluded ${sel.excluded_private.length} private-destined item(s); use [p]/--keep-private to keep them owner-only, or sanitize-promote them for public review.`);
726
+ }
723
727
  if (minQuality < review.DEFAULT_QUALITY_THRESHOLD) {
724
728
  const approvableCount = Number.isFinite(summary.approvable_count)
725
729
  ? summary.approvable_count
@@ -742,6 +746,9 @@ async function cmdReview(flags) {
742
746
  if (flags.all) {
743
747
  const includeFlagged = flags['include-flagged'] === true;
744
748
  const sel = review.selectForBulkApprove(rows, { mode: 'all', includeFlagged });
749
+ if (sel.excluded_private.length) {
750
+ console.log(`Excluded ${sel.excluded_private.length} private-destined item(s); use [p]/--keep-private to keep them owner-only, or sanitize-promote them for public review.`);
751
+ }
745
752
  if (includeFlagged && sel.selected.some((r) => review.reviewLane(r).lane === 'needs_your_eyes')) {
746
753
  console.log('WARNING: --include-flagged is set. This selection INCLUDES needs_your_eyes items (possible injection, sensitive content, process advice, account vocabulary, or near-duplicates). Approving publishes them publicly.');
747
754
  } else if (sel.excluded_flagged.length) {
@@ -755,6 +762,30 @@ async function cmdReview(flags) {
755
762
  return;
756
763
  }
757
764
 
765
+ // ── --keep-private: finalize one explicit server lane as owner-only.
766
+ // Defaults to needs_your_eyes. Because this never publishes, --yes may
767
+ // bypass its counted confirmation; all approve paths retain the rail. ───
768
+ if (flags['keep-private']) {
769
+ let sel;
770
+ try {
771
+ sel = review.selectForKeepPrivate(rows, { lane: flags.lane });
772
+ } catch (err) {
773
+ console.error(err.message);
774
+ process.exit(1);
775
+ }
776
+ console.log(`keep-private selection: ${sel.selected.length} of ${rows.length} pending (lane: ${sel.lane}).`);
777
+ for (const lane of ['ready_to_publish', 'needs_score', 'needs_your_eyes']) {
778
+ const excluded = sel.excluded_by_lane[lane];
779
+ if (excluded.length) console.log(` excluded ${excluded.length} ${lane} item(s).`);
780
+ }
781
+ if (sel.selected.length === 0) { console.log('Nothing qualifies. No changes made.'); return; }
782
+ const ok = flags.yes || await confirmByTypedCount(sel.selected, 'KEEP PRIVATE (owner-only, $0 recall)');
783
+ if (!ok) return;
784
+ const totals = await runBulk({ apiKey, baseUrl, rows: sel.selected, decision: 'keep_private' });
785
+ console.log(`\nDone. Kept private ${totals.kept_private} of ${sel.selected.length} (already done earlier: ${totals.idempotent}, failed: ${totals.failed}).`);
786
+ return;
787
+ }
788
+
758
789
  // ── --all-reject: bulk reject the whole batch (incident escape hatch).
759
790
  // Now batched through the bulk endpoint. --yes keeps its scripted-incident
760
791
  // bypass because rejection is the SAFE direction (nothing goes public). ──
@@ -784,16 +815,16 @@ async function cmdReview(flags) {
784
815
  process.exit(1);
785
816
  }
786
817
 
787
- console.log('Rapid review. Per item: [y] approve (goes live) · [n] reject (stays private) · [v] view full body · [s] skip · [q] quit\n');
818
+ console.log('Rapid review. Per item: [y] approve (goes public) · [n] reject · [p] keep private (owner-only, $0 recall) · [v] view · [s] skip · [q] quit\n');
788
819
 
789
- let approved = 0, rejected = 0, skipped = 0;
820
+ let approved = 0, keptPrivate = 0, rejected = 0, skipped = 0;
790
821
  const ordered = rows; // quality-desc server order; the summary above groups lanes
791
822
  for (let i = 0; i < ordered.length; i++) {
792
823
  const row = ordered[i];
793
824
  console.log(triageLine(row, i + 1, ordered.length));
794
825
  let choice = '';
795
826
  for (;;) {
796
- choice = (await ask(' [y/n/v/s/q]? ')).toLowerCase().slice(0, 1);
827
+ choice = (await ask(' [y/n/p/v/s/q]? ')).toLowerCase().slice(0, 1);
797
828
  if (!choice && readlineEnded) choice = 'q';
798
829
  if (choice === 'v') {
799
830
  const full = bodies.get(row.id);
@@ -806,7 +837,7 @@ async function cmdReview(flags) {
806
837
  console.log(' ------------------------------------------------------------');
807
838
  continue;
808
839
  }
809
- if (['y', 'n', 's', 'q'].includes(choice)) break;
840
+ if (['y', 'n', 'p', 's', 'q'].includes(choice)) break;
810
841
  }
811
842
  if (choice === 'q') { console.log(' Stopping. Remaining items left pending.'); break; }
812
843
  if (choice === 's') { skipped += 1; continue; }
@@ -814,6 +845,9 @@ async function cmdReview(flags) {
814
845
  if (choice === 'y') {
815
846
  await review.submitDecision({ apiKey, baseUrl, id: row.id, decision: 'approve' });
816
847
  approved += 1; console.log(' ✓ approved, now live');
848
+ } else if (choice === 'p') {
849
+ await review.submitDecision({ apiKey, baseUrl, id: row.id, decision: 'keep_private' });
850
+ keptPrivate += 1; console.log(' ✓ kept private (owner-only)');
817
851
  } else {
818
852
  await review.submitDecision({ apiKey, baseUrl, id: row.id, decision: 'reject' });
819
853
  rejected += 1; console.log(' ✗ rejected, stays private');
@@ -823,7 +857,7 @@ async function cmdReview(flags) {
823
857
  }
824
858
  }
825
859
 
826
- console.log(`\nReview complete: approved ${approved}, rejected ${rejected}, skipped ${skipped} of ${ordered.length}.`);
860
+ console.log(`\nReview complete: approved ${approved}, kept private ${keptPrivate}, rejected ${rejected}, skipped ${skipped} of ${ordered.length}.`);
827
861
  }
828
862
 
829
863
  // ─── Entry point ────────────────────────────────────────────────────────────
@@ -856,10 +890,16 @@ Flags:
856
890
  --min-quality N quality threshold for --approve-ready (0-20)
857
891
  --all approve everything except needs_your_eyes items
858
892
  --include-flagged include needs_your_eyes with --all
893
+ --keep-private keep one lane owner-only (default: needs_your_eyes)
894
+ --lane <lane> lane for --keep-private: ready_to_publish, needs_score,
895
+ or needs_your_eyes
859
896
  --all-reject reject the whole batch [--yes for scripted incident use]
897
+ --yes bypass count only for --keep-private or --all-reject
860
898
  --base-url <url>
861
899
 
862
- Every approval path prints the exact list and requires typing its count.`,
900
+ Every bulk path prints the exact list and requires typing its count. Approval
901
+ never accepts --yes. Private-destined rows are excluded from approval; keep
902
+ them private or sanitize-promote a corrected replacement.`,
863
903
  disable: `Usage: auxilo disable [--base-url <url>]
864
904
 
865
905
  Disable background extraction locally and optionally revoke server consent.`,
@@ -902,6 +942,8 @@ Commands:
902
942
  (add --include-flagged to include that lane).
903
943
  Same typed-count confirmation.
904
944
  --min-quality N quality threshold for --approve-ready (0-20)
945
+ --keep-private keep needs_your_eyes owner-only by default
946
+ --lane <lane> choose a server lane for --keep-private
905
947
  --all-reject reject the whole batch [--yes for scripted
906
948
  incident response; rejects stay private]
907
949
  --base-url <url>
@@ -405,6 +405,7 @@ function filterIndexedNearDuplicates(candidates, indexState, opts = {}) {
405
405
  if (!indexState || !indexState.usable) {
406
406
  return { kept: input.slice(), dropped: [], disabled: true };
407
407
  }
408
+ const comparisonAccountId = opts.contributorAccountId || '__local_index_owner';
408
409
  const localRows = indexState.rows
409
410
  .filter((row) => typeof row.body === 'string' && row.body.trim())
410
411
  .map((row, index) => ({
@@ -413,6 +414,8 @@ function filterIndexedNearDuplicates(candidates, indexState, opts = {}) {
413
414
  body: row.body,
414
415
  category: row.category,
415
416
  status: row.status || null,
417
+ visibility: row.visibility,
418
+ contributor_account_id: row.contributor_account_id || comparisonAccountId,
416
419
  }));
417
420
  if (!localRows.length) return { kept: input.slice(), dropped: [], disabled: false };
418
421
 
@@ -420,7 +423,9 @@ function filterIndexedNearDuplicates(candidates, indexState, opts = {}) {
420
423
  const kept = [];
421
424
  const dropped = [];
422
425
  for (const candidate of input) {
423
- const result = findNearDuplicate(candidate, localRows);
426
+ const result = findNearDuplicate(candidate, localRows, {
427
+ contributorAccountId: comparisonAccountId,
428
+ });
424
429
  if (result.verdict === 'flag') {
425
430
  const matchedRow = localRows.find((row) => row.id === result.match.id);
426
431
  dropped.push({
package/lib/review.js CHANGED
@@ -59,12 +59,12 @@ async function fetchPending(opts = {}) {
59
59
  }
60
60
 
61
61
  /**
62
- * POST /account/pending/:id/approve|reject — submit a self-review decision.
62
+ * POST /account/pending/:id/approve|reject|keep-private — submit a self-review decision.
63
63
  *
64
64
  * @param {object} opts
65
65
  * @param {string} opts.apiKey
66
66
  * @param {string} opts.id
67
- * @param {'approve'|'reject'} opts.decision
67
+ * @param {'approve'|'reject'|'keep_private'} opts.decision
68
68
  * @param {string} [opts.reason] only sent on reject
69
69
  * @param {string} [opts.baseUrl]
70
70
  * @param {Function} [opts.fetchImpl]
@@ -74,8 +74,8 @@ async function submitDecision(opts = {}) {
74
74
  const { apiKey, id, decision, reason } = opts;
75
75
  if (!apiKey) throw new Error('submitDecision: apiKey is required');
76
76
  if (!id) throw new Error('submitDecision: id is required');
77
- if (decision !== 'approve' && decision !== 'reject') {
78
- throw new Error('submitDecision: decision must be "approve" or "reject"');
77
+ if (decision !== 'approve' && decision !== 'reject' && decision !== 'keep_private') {
78
+ throw new Error('submitDecision: decision must be "approve", "reject", or "keep_private"');
79
79
  }
80
80
  const baseUrl = normBase(opts.baseUrl);
81
81
  const fetchImpl = opts.fetchImpl || fetch;
@@ -89,10 +89,14 @@ async function submitDecision(opts = {}) {
89
89
  init.body = JSON.stringify(reason ? { reason } : {});
90
90
  }
91
91
 
92
- const res = await fetchImpl(`${baseUrl}/account/pending/${encodeURIComponent(id)}/${decision}`, init);
92
+ const routeDecision = decision === 'keep_private' ? 'keep-private' : decision;
93
+ const res = await fetchImpl(`${baseUrl}/account/pending/${encodeURIComponent(id)}/${routeDecision}`, init);
93
94
  let body = {};
94
95
  try { body = await res.json(); } catch { /* non-JSON error body */ }
95
96
  if (!res.ok) {
97
+ if (decision === 'keep_private' && res.status === 400 && body.code === 'bad_decision') {
98
+ throw new Error('server predates the private tier — update the server');
99
+ }
96
100
  throw new Error(`${decision} failed for ${id} (HTTP ${res.status}): ${body.error || 'unknown error'}`);
97
101
  }
98
102
  return body;
@@ -210,15 +214,16 @@ function chunkDecisions(list, size = BULK_CHUNK) {
210
214
  * stay applied; the endpoint is idempotent per id, so re-running is safe).
211
215
  *
212
216
  * @param {object} opts { apiKey, decisions, baseUrl?, fetchImpl?, onChunk? }
213
- * @returns {Promise<{approved:number, rejected:number, idempotent:number, failed:number, results:Array<object>}>}
217
+ * @returns {Promise<{approved:number, kept_private:number, rejected:number, idempotent:number, failed:number, results:Array<object>}>}
214
218
  */
215
219
  async function submitBulkChunked(opts = {}) {
216
220
  const { decisions } = opts;
217
- const totals = { approved: 0, rejected: 0, idempotent: 0, failed: 0, results: [] };
221
+ const totals = { approved: 0, kept_private: 0, rejected: 0, idempotent: 0, failed: 0, results: [] };
218
222
  const chunks = chunkDecisions(decisions || []);
219
223
  for (let i = 0; i < chunks.length; i++) {
220
224
  const resp = await submitBulk({ ...opts, decisions: chunks[i] });
221
225
  totals.approved += resp.approved || 0;
226
+ totals.kept_private += resp.kept_private || 0;
222
227
  totals.rejected += resp.rejected || 0;
223
228
  totals.idempotent += resp.idempotent || 0;
224
229
  totals.failed += resp.failed || 0;
@@ -270,7 +275,7 @@ function reviewLane(row) {
270
275
  *
271
276
  * @param {Array<object>} rows
272
277
  * @param {object} [opts] { mode: 'ready'|'clean'|'all', minQuality, includeFlagged }
273
- * @returns {{selected:Array<object>, included_beyond_verdict:Array<object>, excluded_flagged:Array<object>, excluded_low_quality:Array<object>, excluded_unscored:Array<object>, min_quality:number|null, version_skew:boolean}}
278
+ * @returns {{selected:Array<object>, included_beyond_verdict:Array<object>, excluded_private:Array<object>, excluded_flagged:Array<object>, excluded_low_quality:Array<object>, excluded_unscored:Array<object>, min_quality:number|null, version_skew:boolean}}
274
279
  */
275
280
  function selectForBulkApprove(rows, opts = {}) {
276
281
  const mode = opts.mode === 'all' ? 'all' : 'ready';
@@ -281,6 +286,7 @@ function selectForBulkApprove(rows, opts = {}) {
281
286
 
282
287
  const selected = [];
283
288
  const included_beyond_verdict = [];
289
+ const excluded_private = [];
284
290
  const excluded_flagged = [];
285
291
  const excluded_low_quality = [];
286
292
  const excluded_unscored = [];
@@ -288,6 +294,10 @@ function selectForBulkApprove(rows, opts = {}) {
288
294
 
289
295
  for (const row of rows || []) {
290
296
  if (!row || !row.id) continue;
297
+ if (row.visibility === 'private') {
298
+ excluded_private.push(row);
299
+ continue;
300
+ }
291
301
  const resolved = reviewLane(row);
292
302
  version_skew = version_skew || resolved.version_skew;
293
303
 
@@ -320,6 +330,7 @@ function selectForBulkApprove(rows, opts = {}) {
320
330
  return {
321
331
  selected,
322
332
  included_beyond_verdict,
333
+ excluded_private,
323
334
  excluded_flagged,
324
335
  excluded_low_quality,
325
336
  excluded_unscored,
@@ -328,6 +339,32 @@ function selectForBulkApprove(rows, opts = {}) {
328
339
  };
329
340
  }
330
341
 
342
+ /**
343
+ * Select one server lane to finalize as owner-only private. The default is
344
+ * needs_your_eyes; another lane requires an explicit --lane value.
345
+ */
346
+ function selectForKeepPrivate(rows, opts = {}) {
347
+ const lane = opts.lane || LANE_NEEDS_EYES;
348
+ if (!REVIEW_LANES.has(lane)) {
349
+ throw new Error(`Invalid --lane "${lane}" (expected ${Array.from(REVIEW_LANES).join(', ')})`);
350
+ }
351
+ const selected = [];
352
+ const excluded_by_lane = {
353
+ [LANE_READY]: [],
354
+ [LANE_NEEDS_SCORE]: [],
355
+ [LANE_NEEDS_EYES]: [],
356
+ };
357
+ let version_skew = false;
358
+ for (const row of rows || []) {
359
+ if (!row || !row.id) continue;
360
+ const resolved = reviewLane(row);
361
+ version_skew = version_skew || resolved.version_skew;
362
+ if (resolved.lane === lane) selected.push(row);
363
+ else excluded_by_lane[resolved.lane].push(row);
364
+ }
365
+ return { selected, excluded_by_lane, lane, version_skew };
366
+ }
367
+
331
368
  module.exports = {
332
369
  DEFAULT_BASE_URL,
333
370
  BULK_CHUNK,
@@ -342,4 +379,5 @@ module.exports = {
342
379
  qualityClears,
343
380
  reviewLane,
344
381
  selectForBulkApprove,
382
+ selectForKeepPrivate,
345
383
  };
package/lib/similarity.js CHANGED
@@ -204,13 +204,39 @@ function similarityScore(a, b) {
204
204
  }
205
205
 
206
206
  /**
207
- * Scan the full catalog for a near-verbatim match of `candidate`.
208
- * Rejected and cross-category predecessors are intentionally included.
207
+ * SPEC3-G1 comparison-set doctrine. Public predecessors collide for every
208
+ * caller; any non-public predecessor (pending, rejected, retracted, or
209
+ * approved-private) is visible to duplicate screening only for its owner.
210
+ * Missing visibility is the zero-migration public default.
211
+ */
212
+ function isPublicComparisonPredecessor(learning) {
213
+ return !!learning &&
214
+ (!learning.status || learning.status === 'approved') &&
215
+ learning.visibility !== 'private';
216
+ }
217
+
218
+ function isComparisonEligible(learning, contributorAccountId) {
219
+ if (isPublicComparisonPredecessor(learning)) return true;
220
+ return !!contributorAccountId &&
221
+ learning &&
222
+ learning.contributor_account_id === contributorAccountId;
223
+ }
224
+
225
+ function comparisonCatalog(catalog, contributorAccountId) {
226
+ return (catalog || []).filter((learning) =>
227
+ isComparisonEligible(learning, contributorAccountId));
228
+ }
229
+
230
+ /**
231
+ * Scan the caller-safe comparison catalog for a near-verbatim match.
232
+ * Public predecessors compare for everyone. Non-public predecessors compare
233
+ * only for their contributor account.
209
234
  *
210
235
  * @param {{title:string, body:string, category:string}} candidate
211
236
  * @param {Array<object>} catalog existing learnings
212
237
  * @param {{flagThreshold?:number, tfCosineThreshold?:number,
213
- * compositeThreshold?:number, excludeId?:string}} [opts]
238
+ * compositeThreshold?:number, excludeId?:string,
239
+ * contributorAccountId?:string}} [opts]
214
240
  * @returns {{verdict:'flag'|'clean', match: null|object}}
215
241
  */
216
242
  function findNearDuplicate(candidate, catalog, opts = {}) {
@@ -223,6 +249,7 @@ function findNearDuplicate(candidate, catalog, opts = {}) {
223
249
  for (const existing of catalog || []) {
224
250
  if (!existing) continue;
225
251
  if (opts.excludeId && existing.id === opts.excludeId) continue;
252
+ if (!isComparisonEligible(existing, opts.contributorAccountId)) continue;
226
253
 
227
254
  const channels = scorePrepared(candidatePrepared, prepareLearning(existing));
228
255
  const shingleFlagged = channels.d1 >= flagAt;
@@ -232,6 +259,7 @@ function findNearDuplicate(candidate, catalog, opts = {}) {
232
259
  best = {
233
260
  id: existing.id,
234
261
  status: existing.status || null,
262
+ visibility: existing.visibility === 'private' ? 'private' : 'public',
235
263
  category: existing.category || null,
236
264
  similarity: channels.composite,
237
265
  channel: shingleFlagged ? 'shingle' : 'near_verbatim',
@@ -251,6 +279,9 @@ function findNearDuplicate(candidate, catalog, opts = {}) {
251
279
  module.exports = {
252
280
  similarityScore,
253
281
  findNearDuplicate,
282
+ isPublicComparisonPredecessor,
283
+ isComparisonEligible,
284
+ comparisonCatalog,
254
285
  tokenize,
255
286
  tokenSet,
256
287
  jaccard,
package/mcp-server.js CHANGED
@@ -134,10 +134,46 @@ function planApproveClean(summary, opts = {}) {
134
134
  };
135
135
  }
136
136
 
137
+ // Private-tier counterpart to planApproveClean. Selection is delegated to the
138
+ // same lib/review.js helper as the CLI; the default is needs_your_eyes.
139
+ function planKeepPrivate(summary, opts = {}) {
140
+ const sel = reviewLib.selectForKeepPrivate((summary && summary.items) || [], {
141
+ lane: opts.lane,
142
+ });
143
+ const brief = (r) => ({
144
+ id: r.id,
145
+ title: r.title,
146
+ category: r.category,
147
+ quality: r.quality,
148
+ visibility: r.visibility,
149
+ lane: reviewLib.reviewLane(r).lane,
150
+ });
151
+ return {
152
+ dry_run: true,
153
+ lane: sel.lane,
154
+ pending_count: summary ? summary.pending_count : 0,
155
+ would_keep_private_count: sel.selected.length,
156
+ would_keep_private: sel.selected.map(brief),
157
+ excluded_by_lane: Object.fromEntries(
158
+ Object.entries(sel.excluded_by_lane).map(([lane, rows]) => [lane, rows.map(brief)])
159
+ ),
160
+ next_step: sel.selected.length > 0
161
+ ? `Show the operator this list and count (${sel.selected.length}). After confirmation, call auxilo_review again with {action:"keep_private", dry_run:false, confirm:true, expected_count:${sel.selected.length}}. Each item stays yours; owner-only recall at $0; never published.`
162
+ : 'Nothing is in the selected lane. No follow-up call needed.',
163
+ };
164
+ }
165
+
166
+ function keepPrivateDecisions(plan) {
167
+ return (plan && plan.would_keep_private || []).map((row) => ({
168
+ id: row.id,
169
+ decision: 'keep_private',
170
+ }));
171
+ }
172
+
137
173
  // Review-seamless: POST one confirmed decision batch through the counted bulk
138
174
  // endpoint, chunked at the server cap. Used by approve/reject/approve_clean.
139
175
  async function postBulkChunks(headers, decisions) {
140
- const totals = { approved: 0, rejected: 0, idempotent: 0, failed: 0, results: [] };
176
+ const totals = { approved: 0, kept_private: 0, rejected: 0, idempotent: 0, failed: 0, results: [] };
141
177
  for (const chunk of reviewLib.chunkDecisions(decisions)) {
142
178
  const resp = await fetch(`${AUXILO_BASE}/account/pending/bulk`, {
143
179
  method: 'POST',
@@ -151,6 +187,7 @@ async function postBulkChunks(headers, decisions) {
151
187
  return totals;
152
188
  }
153
189
  totals.approved += data.approved || 0;
190
+ totals.kept_private += data.kept_private || 0;
154
191
  totals.rejected += data.rejected || 0;
155
192
  totals.idempotent += data.idempotent || 0;
156
193
  totals.failed += data.failed || 0;
@@ -160,7 +197,7 @@ async function postBulkChunks(headers, decisions) {
160
197
  }
161
198
 
162
199
  const server = new Server(
163
- { name: 'auxilo', version: '0.9.7' },
200
+ { name: 'auxilo', version: '0.9.8' },
164
201
  {
165
202
  capabilities: { tools: {} },
166
203
  instructions: `You are connected to Auxilo, a knowledge marketplace where AI agents buy and sell operational learnings.
@@ -394,21 +431,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
394
431
  },
395
432
  {
396
433
  name: 'auxilo_review',
397
- description: 'Review YOUR OWN pending-review learnings (from background extraction) so they can be approved to the public marketplace or rejected to stay private. Account-scoped: only the authenticated account\'s own pending items are ever visible or affected. ACTIONS: "list" returns the triage summary (counts incl. by_signal + compact rows with quality score, lane, a one-sentence why for flagged items, and platform screen verdicts: injection, content sensitivity, near-duplicate). "approve" / "reject" apply explicit decisions to the ids you pass (the operator must have named or confirmed these items). "approve_clean" selects every item that passed ALL platform screens AND has quality >= min_quality (default 14/20); it is DRY-RUN BY DEFAULT and returns exactly what WOULD be approved. "reject_by_signal" bulk-rejects every pending item carrying one flag signal (e.g. social_handle) — REJECT ONLY (items stay private; there is deliberately no bulk approve by class): the operator must confirm the signal AND its by_signal count from "list", and you pass that count as expected_count — the server refuses if the live selection differs. "sanitize" resubmits ONE operator-corrected item through EVERY screen with lineage (the original is retired to private, reason sanitize-resubmit; the replacement is ALWAYS held for the operator\'s explicit approval — never auto-published): only call it with a correction the operator reviewed. CONSENT CONTRACT: nothing goes public without the contributor\'s explicit approval. So before executing approve_clean you MUST show the operator the dry-run list and count and get their confirmation, then call again with dry_run:false, confirm:true, and expected_count set to the dry-run count. The server also enforces a counted-confirmation gate on every bulk call. Requires your configured API key (or session_token).',
434
+ description: 'Review YOUR OWN pending-review learnings (from background extraction) so they can be approved to the public marketplace, rejected, or kept private. Account-scoped: only the authenticated account\'s own pending items are ever visible or affected. ACTIONS: "list" returns the triage summary (counts incl. by_signal + compact rows with quality score, lane, a one-sentence why for flagged items, and platform screen verdicts: injection, content sensitivity, near-duplicate). "approve" / "reject" apply explicit decisions to the ids you pass (the operator must have named or confirmed these items). "keep_private" finalizes one id or the selected lane (needs_your_eyes by default): it stays yours; owner-only recall at $0; never published. Bulk keep_private is DRY-RUN BY DEFAULT and uses the same selection helper as the CLI. "approve_clean" selects every item that passed ALL platform screens AND has quality >= min_quality (default 14/20); it is DRY-RUN BY DEFAULT and returns exactly what WOULD be approved. "reject_by_signal" bulk-rejects every pending item carrying one flag signal (e.g. social_handle) — REJECT ONLY (items stay private; there is deliberately no bulk approve by class): the operator must confirm the signal AND its by_signal count from "list", and you pass that count as expected_count — the server refuses if the live selection differs. "sanitize" resubmits ONE operator-corrected item through EVERY screen with lineage (the original is retired to private, reason sanitize-resubmit; the replacement is ALWAYS held for the operator\'s explicit approval — never auto-published): only call it with a correction the operator reviewed. CONSENT CONTRACT: nothing goes public without the contributor\'s explicit approval. Before executing approve_clean or bulk keep_private, show the operator the dry-run list and count, then call again with dry_run:false, confirm:true, and expected_count set to the dry-run count. The server also enforces a counted-confirmation gate on every bulk call. Requires your configured API key (or session_token).',
398
435
  inputSchema: {
399
436
  type: 'object',
400
437
  properties: {
401
- action: { type: 'string', enum: ['list', 'approve', 'reject', 'approve_clean', 'reject_by_signal', 'sanitize'], description: 'What to do. Start with "list".' },
438
+ action: { type: 'string', enum: ['list', 'approve', 'reject', 'keep_private', 'approve_clean', 'reject_by_signal', 'sanitize'], description: 'What to do. Start with "list".' },
402
439
  ids: { type: 'array', items: { type: 'string' }, description: 'Learning ids for action approve/reject. These must be items the operator explicitly chose.' },
403
440
  reason: { type: 'string', description: 'Optional rejection reason (actions reject / reject_by_signal; max 500 chars).' },
404
441
  signal: { type: 'string', description: 'reject_by_signal only. The flag signal to reject by (a name from counts.by_signal, e.g. social_handle, person_name, injection).' },
405
- id: { type: 'string', description: 'sanitize only. The pending/rejected learning being corrected (operator-chosen).' },
442
+ id: { type: 'string', description: 'keep_private single-item or sanitize. The operator-chosen learning id.' },
406
443
  title: { type: 'string', description: 'sanitize only. Corrected title (omit to keep the original).' },
407
444
  body: { type: 'string', description: 'sanitize only. Corrected body (omit to keep the original). At least one of title/body is required.' },
408
445
  tags: { type: 'array', items: { type: 'string' }, description: 'sanitize only. Corrected tags (omit to keep the original).' },
409
- dry_run: { type: 'boolean', description: 'approve_clean only. Default TRUE: report what would be approved without changing anything. Set false only together with confirm:true and expected_count after the operator confirmed the dry-run list.' },
410
- confirm: { type: 'boolean', description: 'approve_clean only. Must be exactly true to execute. Never set this without the operator\'s explicit go-ahead on the dry-run output.' },
411
- expected_count: { type: 'number', description: 'approve_clean execute + reject_by_signal. The count the operator confirmed (dry-run count / by_signal count), echoed back. If the live selection differs (queue changed), nothing is mutated.' },
446
+ lane: { type: 'string', enum: ['ready_to_publish', 'needs_score', 'needs_your_eyes'], description: 'keep_private bulk only. Lane to select; defaults to needs_your_eyes.' },
447
+ dry_run: { type: 'boolean', description: 'approve_clean or bulk keep_private. Default TRUE: report exactly what would change. Set false only with confirm:true and expected_count after operator confirmation.' },
448
+ confirm: { type: 'boolean', description: 'approve_clean or bulk keep_private. Must be exactly true to execute after operator confirmation.' },
449
+ expected_count: { type: 'number', description: 'approve_clean, bulk keep_private, or reject_by_signal. The confirmed selection count. If the live selection differs, nothing is mutated.' },
412
450
  min_quality: { type: 'number', description: 'approve_clean quality threshold 0-20 (default 14). 0 includes unscored items.' },
413
451
  session_token: { type: 'string', description: 'Optional JWT session token from /auth/verify. If omitted, your configured API key authenticates the account.' },
414
452
  },
@@ -665,6 +703,58 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
665
703
  return text({ action: args.action, submitted: decisions.length, ...totals });
666
704
  }
667
705
 
706
+ if (args.action === 'keep_private') {
707
+ // A named id is an explicit single-item decision. It uses the
708
+ // existing single route and therefore surfaces server 4xx truth.
709
+ if (typeof args.id === 'string' && args.id) {
710
+ const resp = await fetch(
711
+ `${AUXILO_BASE}/account/pending/${encodeURIComponent(args.id)}/keep-private`,
712
+ { method: 'POST', headers }
713
+ );
714
+ const data = await resp.json();
715
+ return text(data);
716
+ }
717
+
718
+ const summaryResp = await fetch(`${AUXILO_BASE}/account/pending/summary`, { headers });
719
+ const summary = await summaryResp.json();
720
+ if (!summaryResp.ok) return text(summary);
721
+
722
+ let plan;
723
+ try {
724
+ plan = planKeepPrivate(summary, args);
725
+ } catch (err) {
726
+ return text({ error: err.message });
727
+ }
728
+ if (args.dry_run !== false || args.confirm !== true) {
729
+ return text(plan);
730
+ }
731
+ if (!Number.isInteger(args.expected_count)) {
732
+ return text({
733
+ error: 'expected_count is required to execute keep_private: echo the would_keep_private_count from the dry run.',
734
+ ...plan,
735
+ });
736
+ }
737
+ if (args.expected_count !== plan.would_keep_private_count) {
738
+ return text({
739
+ error: `Selection changed since the dry run (expected ${args.expected_count}, now ${plan.would_keep_private_count}). Nothing was changed. Re-run the dry run and re-confirm.`,
740
+ ...plan,
741
+ });
742
+ }
743
+ const decisions = keepPrivateDecisions(plan);
744
+ if (decisions.length === 0) {
745
+ return text({ action: 'keep_private', kept_private: 0, message: 'Nothing is in the selected lane.' });
746
+ }
747
+ const totals = await postBulkChunks(headers, decisions);
748
+ return text({
749
+ action: 'keep_private',
750
+ executed: true,
751
+ lane: plan.lane,
752
+ submitted: decisions.length,
753
+ ...totals,
754
+ note: 'These items stay yours; owner-only recall at $0; never published.',
755
+ });
756
+ }
757
+
668
758
  if (args.action === 'approve_clean') {
669
759
  const summaryResp = await fetch(`${AUXILO_BASE}/account/pending/summary`, { headers });
670
760
  const summary = await summaryResp.json();
@@ -752,7 +842,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
752
842
  return text(await resp.json());
753
843
  }
754
844
 
755
- return text({ error: `Unknown action: ${args.action}. Use list, approve, reject, approve_clean, reject_by_signal, or sanitize.` });
845
+ return text({ error: `Unknown action: ${args.action}. Use list, approve, reject, keep_private, approve_clean, reject_by_signal, or sanitize.` });
756
846
  }
757
847
 
758
848
  case 'get_knowledge_stats': {
@@ -775,7 +865,17 @@ function text(obj) {
775
865
  // LW-3(a): export the pure helpers so they can be unit-tested without starting
776
866
  // the stdio transport. When this file is required (not run directly), stop here
777
867
  // before the CLI dispatch and MCP startup below.
778
- module.exports = { fenceUnlockResult, UNTRUSTED_CONTENT_ADVISORY, baseHeaders, planApproveClean, unlockPaymentRequired, shapeWithdrawStatus, verifyWalletRequestBody };
868
+ module.exports = {
869
+ fenceUnlockResult,
870
+ UNTRUSTED_CONTENT_ADVISORY,
871
+ baseHeaders,
872
+ planApproveClean,
873
+ planKeepPrivate,
874
+ keepPrivateDecisions,
875
+ unlockPaymentRequired,
876
+ shapeWithdrawStatus,
877
+ verifyWalletRequestBody,
878
+ };
779
879
  if (require.main !== module) {
780
880
  return;
781
881
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auxilo-mcp",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "mcpName": "io.github.silent-architects/auxilo",
5
5
  "description": "MCP server for Auxilo. Your agent stops solving the same problem twice: auto-extracted learnings, free self-unlocks, and earnings when other agents unlock yours.",
6
6
  "main": "mcp-server.js",
@@ -32,6 +32,7 @@ const {
32
32
  // lists are duplicated here from lib/category-scope-migration.js (server truth);
33
33
  // test/ci5-scope-enforcement.test.js pins the copies equal.
34
34
  const CATEGORIES = ['data-processing', 'web-interaction', 'code-execution', 'storage-state', 'payment-financial', 'monitoring'];
35
+ const PRIVATE_CATEGORIES = [...CATEGORIES, 'non-technical'];
35
36
  const RETIRED_CATEGORIES = ['communication', 'content-generation'];
36
37
 
37
38
  /**
@@ -76,6 +77,23 @@ Output STRICT JSON ONLY — an object with:
76
77
  "matched_title": "<exact matched title>"}
77
78
  Scope/quality/sensitivity skips are not dedup_drops.`;
78
79
 
80
+ const PUBLIC_SCOPE_BLOCK = `HARD SCOPE RULE — TECHNICAL LEARNINGS ONLY (the marketplace accepts nothing else): extract ONLY technical/operational learnings — APIs, developer tools, code, infrastructure, data pipelines, monitoring/observability, payment/crypto TECHNOLOGY, debugging. NEVER extract interpersonal or communication strategy, copywriting/content/marketing insights, business or negotiation strategy, personal matters, or creative-writing technique — DROP such candidates entirely, do not relabel them. A technical learning about a messaging/email/notification API belongs under "web-interaction" or "code-execution"; content/data pipeline TECH belongs under "data-processing".
81
+
82
+ SYSTEM-FACT TEST (CI-7): Extract ONLY when a system and a symptom are at the core — an error, an undocumented limitation, a reproducible behavior of an external tool/API/OS. If the candidate is advice about how to work (process, workflow, methodology, decision practice), do NOT extract it. "Odesli cannot resolve Tidal artist URLs" is a learning; "use a two-phase consultation workflow" is not, no matter how well it would score.`;
83
+
84
+ const PRIVATE_SCOPE_BLOCK = `PRIVATE CAPTURE SCOPE — OWNER-ONLY: extract reusable technical OR non-technical operational learnings. Non-technical process, workflow, communication, content, business, or creative learnings may use category "non-technical"; do not drop a genuine reusable candidate solely because it is non-technical. This private lane is never published unless the owner later sanitizes and promotes an item through public review. The mandatory sensitivity screen still applies without exception.`;
85
+
86
+ function promptBaseForVisibility(captureVisibility) {
87
+ if (captureVisibility !== 'private') return EXTRACTION_PROMPT_BASE;
88
+ return EXTRACTION_PROMPT_BASE
89
+ .replace(
90
+ "to publish to a PUBLIC knowledge marketplace read by other AI agents.",
91
+ "for the owner's private, owner-only knowledge lane."
92
+ )
93
+ .replace(PUBLIC_SCOPE_BLOCK, PRIVATE_SCOPE_BLOCK)
94
+ .replace(JSON.stringify(CATEGORIES), JSON.stringify(PRIVATE_CATEGORIES));
95
+ }
96
+
79
97
  /** A1: rubric addendum — appended ONLY when scoreExtractionEnabled(). */
80
98
  const QUALITY_RUBRIC_ADDENDUM = `
81
99
  "quality_self_assessment": an object scoring the learning honestly on four
@@ -105,7 +123,7 @@ function buildExtractionPrompt(opts = {}) {
105
123
  const memory = typeof opts.previousLessonsSection === 'string'
106
124
  ? opts.previousLessonsSection
107
125
  : '';
108
- return EXTRACTION_PROMPT_BASE +
126
+ return promptBaseForVisibility(opts.captureVisibility) +
109
127
  (withScore ? QUALITY_RUBRIC_ADDENDUM : '') +
110
128
  (memory ? `\n\n${memory}` : '') +
111
129
  PROMPT_SUFFIX;
@@ -144,6 +162,7 @@ function extractWithClaudeCode(transcript, opts = {}) {
144
162
  ? opts.prompt
145
163
  : buildExtractionPrompt({
146
164
  previousLessonsSection: opts.previousLessonsSection,
165
+ captureVisibility: opts.captureVisibility,
147
166
  ...(opts.scoreExtraction !== undefined && { scoreExtraction: opts.scoreExtraction }),
148
167
  });
149
168
  const input = prompt + String(transcript).slice(0, 200000);
@@ -226,11 +245,15 @@ function normalizeLearningArray(arr, opts = {}) {
226
245
  // would launder a non-tech candidate (e.g. one the model labeled
227
246
  // 'communication') into the catalog wearing a tech label. Category-based,
228
247
  // so it applies identically in BOTH score-gate states.
229
- const inScope = shaped.filter(l => CATEGORIES.includes(l.category));
248
+ const allowedCategories = opts.captureVisibility === 'private' ? PRIVATE_CATEGORIES : CATEGORIES;
249
+ const inScope = shaped.filter(l => allowedCategories.includes(l.category));
230
250
  // Gate-A F5: make the drop observable — count to stderr (never stdout; the
231
251
  // hook log captures it) so a silently over-dropping prompt is diagnosable.
232
252
  const dropped = shaped.length - inScope.length;
233
- if (dropped > 0) console.error(`[extract-local] dropped ${dropped} candidate(s) outside the technical category set (CI-5 scope)`);
253
+ if (dropped > 0) {
254
+ const scope = opts.captureVisibility === 'private' ? 'private category set' : 'technical category set (CI-5 scope)';
255
+ console.error(`[extract-local] dropped ${dropped} candidate(s) outside the ${scope}`);
256
+ }
234
257
  return inScope
235
258
  .map(l => {
236
259
  const out = {
@@ -570,6 +593,7 @@ async function extractLocally(transcript, sourceType, opts = {}) {
570
593
  };
571
594
  const prompt = buildExtractionPrompt({
572
595
  previousLessonsSection: promptMemory.section,
596
+ captureVisibility: opts.captureVisibility,
573
597
  ...(opts.scoreExtraction !== undefined && { scoreExtraction: opts.scoreExtraction }),
574
598
  });
575
599
  const invokeModel = typeof opts.invokeModel === 'function'
@@ -639,7 +663,7 @@ async function extractLocally(transcript, sourceType, opts = {}) {
639
663
 
640
664
  module.exports = {
641
665
  extractLocally, parseLearnings, parseExtractionOutput, resolveClaudeBin,
642
- CATEGORIES, RETIRED_CATEGORIES,
666
+ CATEGORIES, PRIVATE_CATEGORIES, RETIRED_CATEGORIES,
643
667
  EXTRACTION_PROMPT, buildExtractionPrompt, scoreExtractionEnabled,
644
668
  validateQualityAssessment, QUALITY_DIMENSIONS,
645
669
  buildAnchoredJudgePrompt, parseJudgeDecisions, runAnchoredJudge,
package/scripts/runner.js CHANGED
@@ -59,6 +59,13 @@ const CREDS_PATH = path.join(AUXILO_DIR, 'credentials.json');
59
59
  * 2. credentials.json .base_url
60
60
  * 3. http://localhost:49152 (dev default)
61
61
  */
62
+ function resolveCaptureVisibility(env = {}, fileCreds = {}) {
63
+ const value = Object.prototype.hasOwnProperty.call(env, 'AUXILO_CAPTURE_VISIBILITY')
64
+ ? env.AUXILO_CAPTURE_VISIBILITY
65
+ : fileCreds.capture_visibility;
66
+ return value === 'private' ? 'private' : 'public';
67
+ }
68
+
62
69
  function loadCredentials() {
63
70
  let fileCreds = {};
64
71
  try {
@@ -70,10 +77,16 @@ function loadCredentials() {
70
77
  apiKey: process.env.AUXILO_API_KEY || fileCreds.api_key || null,
71
78
  baseUrl: process.env.AUXILO_BASE_URL || fileCreds.base_url || 'http://localhost:49152',
72
79
  accountLabel: fileCreds.label || fileCreds.account_id || null,
80
+ captureVisibility: resolveCaptureVisibility(process.env, fileCreds),
73
81
  };
74
82
  }
75
83
 
76
- const { apiKey: API_KEY, baseUrl: BASE_URL, accountLabel: ACCOUNT_LABEL } = loadCredentials();
84
+ const {
85
+ apiKey: API_KEY,
86
+ baseUrl: BASE_URL,
87
+ accountLabel: ACCOUNT_LABEL,
88
+ captureVisibility: CAPTURE_VISIBILITY,
89
+ } = loadCredentials();
77
90
 
78
91
  // Parsing contract with jobs/daily-digest.js readLogRows(): digest-relevant log
79
92
  // lines must carry `account=` (builder attribution) and, on publish lines,
@@ -290,7 +303,7 @@ function listPendingFiles() {
290
303
  *
291
304
  * @param {Array<object>} learnings
292
305
  * @param {string} sourceType
293
- * @param {object} [opts] { fetchImpl, baseUrl, apiKey, indexPath, now } —
306
+ * @param {object} [opts] { fetchImpl, baseUrl, apiKey, captureVisibility, indexPath, now } —
294
307
  * injectable for tests
295
308
  * @returns {Promise<{published:number, held:number, rejected:number}>}
296
309
  */
@@ -298,6 +311,7 @@ async function submitLearnings(learnings, sourceType, opts = {}) {
298
311
  const fetchImpl = opts.fetchImpl || fetch;
299
312
  const baseUrl = opts.baseUrl || BASE_URL;
300
313
  const apiKey = opts.apiKey !== undefined ? opts.apiKey : API_KEY;
314
+ const captureVisibility = opts.captureVisibility || CAPTURE_VISIBILITY;
301
315
 
302
316
  let published = 0;
303
317
  let held = 0;
@@ -320,6 +334,7 @@ async function submitLearnings(learnings, sourceType, opts = {}) {
320
334
  outcome: l.outcome,
321
335
  contributor_agent: `auxilo-hook/${sourceType}`,
322
336
  submission_channel: 'extraction',
337
+ ...(captureVisibility === 'private' && { visibility: 'private' }),
323
338
  ...(l.quality_self_assessment && { quality_self_assessment: l.quality_self_assessment }),
324
339
  }),
325
340
  });
@@ -341,7 +356,7 @@ async function submitLearnings(learnings, sourceType, opts = {}) {
341
356
  return { published, held, rejected };
342
357
  }
343
358
 
344
- async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
359
+ async function postExtract(transcript, sessionId, sourceType, _scrubReport, opts = {}) {
345
360
  // CLIENT-SIDE extraction (2026-07-02). Server /extract is deprecated (410) — Auxilo
346
361
  // does not pay to extract. The local model (via `claude -p`) extracts + self-screens
347
362
  // the already-client-scrubbed transcript, and we submit finished learnings to /learn.
@@ -365,8 +380,9 @@ async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
365
380
  judge_prompt_tokens: judgePromptTokens = 0,
366
381
  judge_completion_tokens: judgeCompletionTokens = 0,
367
382
  } = await extractLocally(transcript, sourceType, {
368
- baseUrl: BASE_URL,
369
- apiKey: API_KEY,
383
+ baseUrl: opts.baseUrl || BASE_URL,
384
+ apiKey: opts.apiKey !== undefined ? opts.apiKey : API_KEY,
385
+ captureVisibility: opts.captureVisibility || CAPTURE_VISIBILITY,
370
386
  log,
371
387
  auditLog: auditDropLog,
372
388
  }));
@@ -378,7 +394,10 @@ async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
378
394
  return { learnings_published: 0, learnings_held: 0, learnings_rejected: 0, extraction_id: 'client-skip' };
379
395
  }
380
396
 
381
- const { published, held, rejected } = await submitLearnings(learnings, sourceType);
397
+ const { published, held, rejected } = await submitLearnings(learnings, sourceType, {
398
+ ...opts,
399
+ captureVisibility: opts.captureVisibility || CAPTURE_VISIBILITY,
400
+ });
382
401
  // NOTE: token-free prose — the digest-parsed `published=`/`held=`/`rejected=`
383
402
  // tokens live ONLY on the per-run caller log lines. This line previously
384
403
  // carried `published=` too and the digest double-counted every extraction
@@ -924,7 +943,8 @@ async function main() {
924
943
  try {
925
944
  const payload = JSON.parse(fs.readFileSync(qf, 'utf-8'));
926
945
  const result = await postExtract(
927
- payload.transcript, payload.sessionId, payload.source, payload.scrubReport
946
+ payload.transcript, payload.sessionId, payload.source, payload.scrubReport,
947
+ { captureVisibility: payload.capture_visibility || CAPTURE_VISIBILITY }
928
948
  );
929
949
  log(`[runner] ✓ Flushed ${path.basename(qf)}: published=${result.learnings_published || 0} held=${result.learnings_held || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT}`);
930
950
  flushHeld += result.learnings_held || 0;
@@ -1059,6 +1079,7 @@ async function main() {
1059
1079
  scrubReport: report,
1060
1080
  mtime: sessionRef.mtime,
1061
1081
  queuedAt: new Date().toISOString(),
1082
+ ...(CAPTURE_VISIBILITY === 'private' && { capture_visibility: 'private' }),
1062
1083
  });
1063
1084
 
1064
1085
  try {
@@ -1093,6 +1114,7 @@ module.exports = {
1093
1114
  loadLedger, saveLedger, ledgerHighWater, ledgerHas, ledgerMark,
1094
1115
  installHooks, installSweeper, installDigest, printStatus, scrubAndVerify, enumerateActiveSources,
1095
1116
  loadSources, SOURCES, sweeperManifest, submitLearnings, notifyHeld,
1117
+ resolveCaptureVisibility, postExtract,
1096
1118
  KILL_SWITCH_PATH, PENDING_DIR, LEDGER_PATH,
1097
1119
  };
1098
1120