@yemi33/minions 0.1.2348 → 0.1.2350

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/engine/kb-sweep.js +137 -17
  2. package/package.json +1 -1
@@ -29,6 +29,7 @@ const COMPRESS_THRESHOLD_BYTES = 5000;
29
29
  const LLM_BATCH_SIZE = 30;
30
30
  const NORMALIZE_CONCURRENCY = 5;
31
31
  const SWEPT_FLAG_KEY = '_swept'; // frontmatter key — entries with this skip the rewrite pass
32
+ const LLM_SCAN_FLAG_KEY = '_llmScannedAt'; // frontmatter key — entries with this (and an unchanged mtime) skip the LLM batch dedup/reclassify pass
32
33
 
33
34
  function _hashEntry(content) {
34
35
  const normalized = String(content || '').replace(/\s+/g, ' ').trim().slice(0, 500);
@@ -420,27 +421,99 @@ function _structuralConsolidate(manifest, opts = {}) {
420
421
  return { survivors, consolidated, groupsCollapsed, digestsWritten };
421
422
  }
422
423
 
423
- /** Batched LLM sweep — finds within-batch dupes, reclassifies, removes stale. */
424
+ /**
425
+ * Split a manifest into entries that need a full-content LLM dedup/reclassify
426
+ * scan ("changed") vs entries that were already scanned in a prior sweep and
427
+ * haven't been modified since ("unchanged"). Mirrors the `_rewritePass` /
428
+ * `SWEPT_FLAG_KEY` skip pattern, but tracks it under a separate frontmatter
429
+ * key (`LLM_SCAN_FLAG_KEY`) since the two passes run independently and an
430
+ * entry can be llm-scanned without yet being rewritten (or vice versa).
431
+ *
432
+ * @returns {{ changedIdx:number[], unchangedIdx:number[] }} indices into `manifest`
433
+ */
434
+ function _classifyLlmScanFreshness(manifest) {
435
+ const changedIdx = [];
436
+ const unchangedIdx = [];
437
+ for (let i = 0; i < manifest.length; i++) {
438
+ const e = manifest[i];
439
+ const fp = path.join(KB_DIR, e.category, e.file);
440
+ let scannedAt = null;
441
+ try {
442
+ const content = safeRead(fp);
443
+ if (content != null) {
444
+ const { fm } = _parseFrontmatter(content);
445
+ if (fm[LLM_SCAN_FLAG_KEY]) {
446
+ const parsed = Date.parse(fm[LLM_SCAN_FLAG_KEY]);
447
+ if (Number.isFinite(parsed)) scannedAt = parsed;
448
+ }
449
+ }
450
+ } catch { /* treat as unscanned */ }
451
+ let mtime = 0;
452
+ try { mtime = fs.statSync(fp).mtimeMs; } catch { /* missing/unreadable — treat as changed */ }
453
+ if (scannedAt != null && mtime > 0 && mtime <= scannedAt + 1000) {
454
+ unchangedIdx.push(i);
455
+ } else {
456
+ changedIdx.push(i);
457
+ }
458
+ }
459
+ return { changedIdx, unchangedIdx };
460
+ }
461
+
462
+ /**
463
+ * Batched LLM sweep — finds within-batch dupes, reclassifies, removes stale.
464
+ *
465
+ * Incremental-scan optimization (W-mrb1c2r20004883a): previously this re-sent
466
+ * the ENTIRE current KB manifest through the LLM on every sweep cycle
467
+ * (AUTO_SWEEP_INTERVAL_MS = 4h), even for entries scanned in the prior cycle
468
+ * with no changes since — cost scaled with (KB size) x (sweep count),
469
+ * unbounded. Now only entries that are new or modified since their last scan
470
+ * (`LLM_SCAN_FLAG_KEY` frontmatter, mtime-gated like `SWEPT_FLAG_KEY`) get
471
+ * full-content batches sent to the LLM. Previously-scanned entries are still
472
+ * included as lightweight (title/date/category only, no content) roster
473
+ * context in every batch so cross-entry duplicate detection against the full
474
+ * KB — not just the new/changed subset — remains correct.
475
+ *
476
+ * @returns {{ plan:object, scannedIdx:number[] }} scannedIdx are manifest
477
+ * indices actually analyzed this run (used by the caller to stamp
478
+ * LLM_SCAN_FLAG_KEY once the whole sweep — including the rewrite pass — has
479
+ * finished, avoiding a same-run mtime race with `_rewritePass`).
480
+ */
424
481
  async function _llmBatchSweep(manifest, callLLM, trackEngineUsage, opts = {}) {
425
482
  const plan = { duplicates: [], reclassify: [], remove: [] };
483
+ const { changedIdx, unchangedIdx } = _classifyLlmScanFreshness(manifest);
484
+
485
+ if (changedIdx.length === 0) {
486
+ // Nothing new or modified since the last scan — no LLM calls needed.
487
+ return { plan, scannedIdx: [] };
488
+ }
489
+
490
+ const rosterLine = (i) => {
491
+ const m = manifest[i];
492
+ return `[${i}] ${m.category}/${m.file} | ${m.title} | ${m.date} | ${m.agent || '?'}`;
493
+ };
494
+
426
495
  const batches = [];
427
- for (let i = 0; i < manifest.length; i += LLM_BATCH_SIZE) {
428
- batches.push(manifest.slice(i, i + LLM_BATCH_SIZE));
496
+ for (let i = 0; i < changedIdx.length; i += LLM_BATCH_SIZE) {
497
+ batches.push(changedIdx.slice(i, i + LLM_BATCH_SIZE));
429
498
  }
430
- for (let b = 0; b < batches.length; b++) {
431
- const batch = batches[b];
432
- const offset = b * LLM_BATCH_SIZE;
433
- const prompt = `You are a knowledge base curator. Analyze these ${batch.length} entries (batch ${b + 1}/${batches.length}, indices ${offset}-${offset + batch.length - 1}) and produce a cleanup plan.
434
499
 
435
- ## Entries
500
+ const scannedIdx = [];
501
+ for (let b = 0; b < batches.length; b++) {
502
+ const batchIdxs = batches[b];
503
+ const rosterSection = unchangedIdx.length
504
+ ? `\n## Existing Entries (context only — already scanned previously, title/date/category only, no content)\n\n${unchangedIdx.map(rosterLine).join('\n')}\n`
505
+ : '';
506
+ const prompt = `You are a knowledge base curator. Analyze the ${batchIdxs.length} NEW/CHANGED entries below (batch ${b + 1}/${batches.length}) and produce a cleanup plan. ${unchangedIdx.length} additional EXISTING entries are listed below for context only (already scanned in a prior sweep) — use them ONLY to detect whether a new/changed entry duplicates one of them; do not flag pairs where BOTH sides are existing-only entries.
436
507
 
437
- ${batch.map((m, i) => `[${offset + i}] ${m.category}/${m.file} | ${m.title} | ${m.date} | ${m.agent || '?'} | ${(m.content || '').slice(0, 200).replace(/\n/g, ' ')}`).join('\n')}
508
+ ## New/Changed Entries (analyze content)
438
509
 
510
+ ${batchIdxs.map(i => `[${i}] ${manifest[i].category}/${manifest[i].file} | ${manifest[i].title} | ${manifest[i].date} | ${manifest[i].agent || '?'} | ${(manifest[i].content || '').slice(0, 200).replace(/\n/g, ' ')}`).join('\n')}
511
+ ${rosterSection}
439
512
  ## Instructions
440
513
 
441
- 1. **Find duplicates**: entries with substantially the same content (same findings, different agents/runs). List pairs by index. Prefer keeping the more recent entry.
442
- 2. **Find misclassified**: entries in the wrong category.
443
- 3. **Find stale/empty**: entries with no actionable content (boilerplate, bail-out notes, "no changes needed").
514
+ 1. **Find duplicates**: entries with substantially the same content (same findings, different agents/runs). At least one side of each pair must be a New/Changed entry. List pairs by index. Prefer keeping the more recent entry.
515
+ 2. **Find misclassified**: New/Changed entries in the wrong category.
516
+ 3. **Find stale/empty**: New/Changed entries with no actionable content (boilerplate, bail-out notes, "no changes needed").
444
517
 
445
518
  Respond with ONLY valid JSON: { "duplicates": [{ "keep": N, "remove": [N], "reason": "..." }], "reclassify": [{ "index": N, "from": "cat", "to": "cat", "reason": "..." }], "remove": [{ "index": N, "reason": "..." }] }
446
519
  If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
@@ -468,8 +541,34 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
468
541
  if (batchPlan.duplicates) plan.duplicates.push(...batchPlan.duplicates);
469
542
  if (batchPlan.reclassify) plan.reclassify.push(...batchPlan.reclassify);
470
543
  if (batchPlan.remove) plan.remove.push(...batchPlan.remove);
544
+ scannedIdx.push(...batchIdxs);
545
+ }
546
+ return { plan, scannedIdx };
547
+ }
548
+
549
+ /**
550
+ * Stamp `LLM_SCAN_FLAG_KEY` on entries that were successfully analyzed by
551
+ * `_llmBatchSweep` this run. Called at the very end of the sweep (after the
552
+ * rewrite pass) so the rewrite pass's own content/mtime update doesn't
553
+ * immediately invalidate the freshness marker we're about to write.
554
+ * Entries that no longer exist at their original path (removed as
555
+ * stale/duplicate, or moved by reclassify) are skipped — best-effort only.
556
+ */
557
+ function _markLlmScanned(manifest, scannedIdx, opts = {}) {
558
+ if (opts.dryRun || !Array.isArray(scannedIdx) || scannedIdx.length === 0) return;
559
+ const now = new Date().toISOString();
560
+ for (const i of scannedIdx) {
561
+ const e = manifest[i];
562
+ if (!e) continue;
563
+ const fp = path.join(KB_DIR, e.category, e.file);
564
+ try {
565
+ const content = safeRead(fp);
566
+ if (content == null) continue; // removed/reclassified elsewhere — skip
567
+ const { fm, body } = _parseFrontmatter(content);
568
+ fm[LLM_SCAN_FLAG_KEY] = now;
569
+ safeWrite(fp, _serializeFrontmatter(fm, body));
570
+ } catch (err) { log('warn', `[kb-sweep] mark-llm-scanned ${e.category}/${e.file}: ${err.message}`); }
471
571
  }
472
- return plan;
473
572
  }
474
573
 
475
574
  /**
@@ -535,7 +634,7 @@ ${body}`;
535
634
  timeout: 120000, label: 'kb-rewrite', model: 'haiku', maxTurns: 1, direct: true,
536
635
  engineConfig: opts.engineConfig,
537
636
  });
538
- trackEngineUsage('kb-sweep', result.usage);
637
+ trackEngineUsage('kb-rewrite', result.usage);
539
638
  if (result.missingRuntime) {
540
639
  log('warn', `[kb-sweep] rewrite ${c.entry.category}/${c.entry.file} skipped: ${result.text || result.stderr || 'runtime unavailable'}`);
541
640
  continue;
@@ -559,8 +658,14 @@ ${body}`;
559
658
  return { processed, bytesBefore, bytesAfter };
560
659
  }
561
660
 
562
- function _applyLlmPlan(plan, manifest, opts = {}) {
661
+ function _applyLlmPlan(plan, manifest, changedIdx, opts = {}) {
563
662
  let removed = 0, merged = 0, reclassified = 0;
663
+ // changedIdx is the set of manifest indices actually content-analyzed by
664
+ // the LLM this batch (see _llmBatchSweep's scannedIdx). Duplicate pairs
665
+ // referencing only unanalyzed indices (LLM hallucination / stale index
666
+ // mis-reference) must never be archived — a pair is only trustworthy if
667
+ // at least one side was actually analyzed this batch (W-mrb447th00062813).
668
+ const changedSet = new Set(changedIdx || []);
564
669
  for (const r of (plan.remove || [])) {
565
670
  const entry = manifest[r.index];
566
671
  if (!entry) continue;
@@ -568,6 +673,11 @@ function _applyLlmPlan(plan, manifest, opts = {}) {
568
673
  if (_archiveKbFile(path.join(KB_DIR, entry.category, entry.file), `stale: ${r.reason || ''}`)) removed++;
569
674
  }
570
675
  for (const d of (plan.duplicates || [])) {
676
+ const pairIdx = [d.keep, ...(d.remove || [])];
677
+ if (!pairIdx.some((idx) => changedSet.has(idx))) {
678
+ log('warn', `[kb-sweep] duplicate pair (keep=${d.keep}, remove=${JSON.stringify(d.remove)}) references no analyzed (changedIdx) entry — skipping archive to avoid acting on a hallucinated/stale LLM reference`);
679
+ continue;
680
+ }
571
681
  for (const idx of (d.remove || [])) {
572
682
  const entry = manifest[idx];
573
683
  if (!entry) continue;
@@ -854,8 +964,8 @@ async function _runKbSweepImpl(opts = {}) {
854
964
  // 2. LLM batch sweep — within-batch dupes + reclassify + remove stale
855
965
  // Only runs against survivors, but we need indices that match the LIST sent to the LLM
856
966
  const llmManifest = afterConsolidate;
857
- const plan = await _llmBatchSweep(llmManifest, callLLM, trackEngineUsage, opts);
858
- const llmActions = _applyLlmPlan(plan, llmManifest, opts);
967
+ const { plan, scannedIdx: llmScannedIdx } = await _llmBatchSweep(llmManifest, callLLM, trackEngineUsage, opts);
968
+ const llmActions = _applyLlmPlan(plan, llmManifest, llmScannedIdx, opts);
859
969
  summary.llmDuplicatesArchived = llmActions.merged;
860
970
  summary.staleRemoved = llmActions.removed;
861
971
  summary.reclassified = llmActions.reclassified;
@@ -877,6 +987,11 @@ async function _runKbSweepImpl(opts = {}) {
877
987
  summary.rewriteBytesBefore = rewriteResult.bytesBefore;
878
988
  summary.rewriteBytesAfter = rewriteResult.bytesAfter;
879
989
 
990
+ // Stamp LLM_SCAN_FLAG_KEY on entries analyzed by _llmBatchSweep this run —
991
+ // deferred until after the rewrite pass so its content/mtime update doesn't
992
+ // race the freshness marker we're about to write (see _markLlmScanned doc).
993
+ _markLlmScanned(llmManifest, llmScannedIdx, opts);
994
+
880
995
  // 4. Prune old swept files (>30 days)
881
996
  summary.sweptArchivePruned = _pruneOldSwept();
882
997
 
@@ -1063,6 +1178,11 @@ module.exports = {
1063
1178
  _digestRow,
1064
1179
  _isDigestEntry,
1065
1180
  _extractHeadField,
1181
+ _llmBatchSweep,
1182
+ _classifyLlmScanFreshness,
1183
+ _markLlmScanned,
1184
+ _applyLlmPlan,
1185
+ LLM_SCAN_FLAG_KEY,
1066
1186
  CONSOLIDATION_DIGEST_PREFIX,
1067
1187
  COMPRESS_THRESHOLD_BYTES,
1068
1188
  SWEPT_FLAG_KEY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2348",
3
+ "version": "0.1.2350",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"