@yemi33/minions 0.1.2348 → 0.1.2349

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 +123 -15
  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;
@@ -854,7 +953,7 @@ async function _runKbSweepImpl(opts = {}) {
854
953
  // 2. LLM batch sweep — within-batch dupes + reclassify + remove stale
855
954
  // Only runs against survivors, but we need indices that match the LIST sent to the LLM
856
955
  const llmManifest = afterConsolidate;
857
- const plan = await _llmBatchSweep(llmManifest, callLLM, trackEngineUsage, opts);
956
+ const { plan, scannedIdx: llmScannedIdx } = await _llmBatchSweep(llmManifest, callLLM, trackEngineUsage, opts);
858
957
  const llmActions = _applyLlmPlan(plan, llmManifest, opts);
859
958
  summary.llmDuplicatesArchived = llmActions.merged;
860
959
  summary.staleRemoved = llmActions.removed;
@@ -877,6 +976,11 @@ async function _runKbSweepImpl(opts = {}) {
877
976
  summary.rewriteBytesBefore = rewriteResult.bytesBefore;
878
977
  summary.rewriteBytesAfter = rewriteResult.bytesAfter;
879
978
 
979
+ // Stamp LLM_SCAN_FLAG_KEY on entries analyzed by _llmBatchSweep this run —
980
+ // deferred until after the rewrite pass so its content/mtime update doesn't
981
+ // race the freshness marker we're about to write (see _markLlmScanned doc).
982
+ _markLlmScanned(llmManifest, llmScannedIdx, opts);
983
+
880
984
  // 4. Prune old swept files (>30 days)
881
985
  summary.sweptArchivePruned = _pruneOldSwept();
882
986
 
@@ -1063,6 +1167,10 @@ module.exports = {
1063
1167
  _digestRow,
1064
1168
  _isDigestEntry,
1065
1169
  _extractHeadField,
1170
+ _llmBatchSweep,
1171
+ _classifyLlmScanFreshness,
1172
+ _markLlmScanned,
1173
+ LLM_SCAN_FLAG_KEY,
1066
1174
  CONSOLIDATION_DIGEST_PREFIX,
1067
1175
  COMPRESS_THRESHOLD_BYTES,
1068
1176
  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.2349",
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"