@yemi33/minions 0.1.2347 → 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.
- package/dashboard.js +5 -2
- package/engine/kb-sweep.js +123 -15
- package/engine/metrics-store.js +66 -0
- package/package.json +1 -1
package/dashboard.js
CHANGED
|
@@ -9845,8 +9845,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9845
9845
|
const body = await readBody(req);
|
|
9846
9846
|
const message = body && typeof body.message === 'string' ? body.message : '';
|
|
9847
9847
|
if (!message.trim()) return jsonReply(res, 400, { error: 'message required' });
|
|
9848
|
-
|
|
9849
|
-
|
|
9848
|
+
// W-mrb1a3950003d9ea — flat, stable label (no per-watch-id interpolation).
|
|
9849
|
+
// Every other _engine category (kb-sweep, consolidation, command-center, …)
|
|
9850
|
+
// uses one flat label; a dynamic watchId suffix here used to mint a brand-new
|
|
9851
|
+
// permanent metrics.json._engine key per watch firing (20+ confirmed live).
|
|
9852
|
+
const label = 'watch-cc-triage';
|
|
9850
9853
|
|
|
9851
9854
|
const model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
|
|
9852
9855
|
const maxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
|
package/engine/kb-sweep.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 <
|
|
428
|
-
batches.push(
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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/engine/metrics-store.js
CHANGED
|
@@ -172,6 +172,8 @@ function readMetrics() {
|
|
|
172
172
|
try { db = getDb(); }
|
|
173
173
|
catch { return _readJsonObjectFallback(); }
|
|
174
174
|
|
|
175
|
+
migrateWatchCcTriageKeysOnce();
|
|
176
|
+
|
|
175
177
|
_resyncIfJsonDiverged(db);
|
|
176
178
|
|
|
177
179
|
const rows = _readAllRows(db);
|
|
@@ -285,9 +287,73 @@ function _resetMetricsTableForTest() {
|
|
|
285
287
|
_lastMirrorHash = null;
|
|
286
288
|
}
|
|
287
289
|
|
|
290
|
+
// W-mrb1a3950003d9ea — one-time collapse of legacy `watch-cc-triage:<watchId>`
|
|
291
|
+
// per-watch _engine keys (minted before dashboard.js's handleCommandCenterTriage
|
|
292
|
+
// stopped interpolating watchId into the trackEngineUsage label) into a single
|
|
293
|
+
// flat `watch-cc-triage` bucket, matching every other _engine category. Sums
|
|
294
|
+
// calls/cost/tokens/duration/errorsByCode across all matching keys, then deletes
|
|
295
|
+
// the per-id keys. Idempotent — a no-op (no SQL/JSON write) once no key matches
|
|
296
|
+
// /^watch-cc-triage:/, so it's safe to call unconditionally on every process
|
|
297
|
+
// startup via migrateWatchCcTriageKeysOnce().
|
|
298
|
+
const WATCH_CC_TRIAGE_LEGACY_KEY_RE = /^watch-cc-triage:/;
|
|
299
|
+
const WATCH_CC_TRIAGE_FLAT_KEY = 'watch-cc-triage';
|
|
300
|
+
|
|
301
|
+
function migrateWatchCcTriageKeys() {
|
|
302
|
+
return applyMetricsMutation((metrics) => {
|
|
303
|
+
const engineMetrics = metrics._engine;
|
|
304
|
+
if (!engineMetrics || typeof engineMetrics !== 'object') return metrics;
|
|
305
|
+
const staleKeys = Object.keys(engineMetrics).filter((k) => WATCH_CC_TRIAGE_LEGACY_KEY_RE.test(k));
|
|
306
|
+
if (staleKeys.length === 0) return metrics;
|
|
307
|
+
|
|
308
|
+
if (!engineMetrics[WATCH_CC_TRIAGE_FLAT_KEY]) {
|
|
309
|
+
engineMetrics[WATCH_CC_TRIAGE_FLAT_KEY] = { calls: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0 };
|
|
310
|
+
}
|
|
311
|
+
const flat = engineMetrics[WATCH_CC_TRIAGE_FLAT_KEY];
|
|
312
|
+
for (const key of staleKeys) {
|
|
313
|
+
const cat = engineMetrics[key] || {};
|
|
314
|
+
flat.calls = (flat.calls || 0) + (cat.calls || 0);
|
|
315
|
+
flat.costUsd = (flat.costUsd || 0) + (cat.costUsd || 0);
|
|
316
|
+
flat.inputTokens = (flat.inputTokens || 0) + (cat.inputTokens || 0);
|
|
317
|
+
flat.outputTokens = (flat.outputTokens || 0) + (cat.outputTokens || 0);
|
|
318
|
+
flat.cacheRead = (flat.cacheRead || 0) + (cat.cacheRead || 0);
|
|
319
|
+
flat.cacheCreation = (flat.cacheCreation || 0) + (cat.cacheCreation || 0);
|
|
320
|
+
if (cat.totalDurationMs) flat.totalDurationMs = (flat.totalDurationMs || 0) + cat.totalDurationMs;
|
|
321
|
+
if (cat.timedCalls) flat.timedCalls = (flat.timedCalls || 0) + cat.timedCalls;
|
|
322
|
+
if (cat.errorsByCode) {
|
|
323
|
+
if (!flat.errorsByCode) flat.errorsByCode = {};
|
|
324
|
+
for (const [code, count] of Object.entries(cat.errorsByCode)) {
|
|
325
|
+
flat.errorsByCode[code] = (flat.errorsByCode[code] || 0) + count;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
delete engineMetrics[key];
|
|
329
|
+
}
|
|
330
|
+
return metrics;
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let _watchCcTriageMigratedThisProcess = false;
|
|
335
|
+
|
|
336
|
+
// Startup-safe wrapper: runs migrateWatchCcTriageKeys() at most once per
|
|
337
|
+
// process (subsequent calls, e.g. on every readMetrics(), are a cheap
|
|
338
|
+
// no-op flag check). Failures (e.g. SQLite unavailable) are swallowed and
|
|
339
|
+
// retried on the next process start rather than surfaced to callers.
|
|
340
|
+
function migrateWatchCcTriageKeysOnce() {
|
|
341
|
+
if (_watchCcTriageMigratedThisProcess) return;
|
|
342
|
+
_watchCcTriageMigratedThisProcess = true;
|
|
343
|
+
try { migrateWatchCcTriageKeys(); }
|
|
344
|
+
catch { /* best-effort — next process start retries */ }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function _resetWatchCcTriageMigrationFlagForTest() {
|
|
348
|
+
_watchCcTriageMigratedThisProcess = false;
|
|
349
|
+
}
|
|
350
|
+
|
|
288
351
|
module.exports = {
|
|
289
352
|
readMetrics,
|
|
290
353
|
applyMetricsMutation,
|
|
291
354
|
_mirrorJsonFromSql,
|
|
292
355
|
_resetMetricsTableForTest,
|
|
356
|
+
migrateWatchCcTriageKeys,
|
|
357
|
+
migrateWatchCcTriageKeysOnce,
|
|
358
|
+
_resetWatchCcTriageMigrationFlagForTest,
|
|
293
359
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
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"
|