engrm 0.4.5 → 0.4.7

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.
@@ -1537,6 +1537,80 @@ function findDuplicate(newTitle, candidates) {
1537
1537
  return bestMatch;
1538
1538
  }
1539
1539
 
1540
+ // src/capture/facts.ts
1541
+ var FACT_ELIGIBLE_TYPES = new Set([
1542
+ "bugfix",
1543
+ "decision",
1544
+ "discovery",
1545
+ "pattern",
1546
+ "feature",
1547
+ "refactor",
1548
+ "change"
1549
+ ]);
1550
+ function buildStructuredFacts(input) {
1551
+ const seedFacts = dedupeFacts(input.facts ?? []);
1552
+ if (!FACT_ELIGIBLE_TYPES.has(input.type)) {
1553
+ return seedFacts;
1554
+ }
1555
+ const derived = [...seedFacts];
1556
+ if (seedFacts.length === 0 && looksMeaningful(input.title)) {
1557
+ derived.push(input.title.trim());
1558
+ }
1559
+ for (const sentence of extractNarrativeFacts(input.narrative)) {
1560
+ derived.push(sentence);
1561
+ }
1562
+ const fileFact = buildFilesFact(input.filesModified);
1563
+ if (fileFact) {
1564
+ derived.push(fileFact);
1565
+ }
1566
+ return dedupeFacts(derived).slice(0, 4);
1567
+ }
1568
+ function extractNarrativeFacts(narrative) {
1569
+ if (!narrative)
1570
+ return [];
1571
+ const cleaned = narrative.replace(/\s+/g, " ").trim();
1572
+ if (cleaned.length < 24)
1573
+ return [];
1574
+ const parts = cleaned.split(/(?<=[.!?;])\s+/).map((part) => part.trim().replace(/[.!?;]+$/, "")).filter(Boolean).filter(looksMeaningful);
1575
+ return parts.slice(0, 2);
1576
+ }
1577
+ function buildFilesFact(filesModified) {
1578
+ if (!filesModified || filesModified.length === 0)
1579
+ return null;
1580
+ const cleaned = filesModified.map((file) => file.trim()).filter(Boolean).slice(0, 3);
1581
+ if (cleaned.length === 0)
1582
+ return null;
1583
+ if (cleaned.length === 1) {
1584
+ return `Touched ${cleaned[0]}`;
1585
+ }
1586
+ return `Touched ${cleaned.join(", ")}`;
1587
+ }
1588
+ function dedupeFacts(facts) {
1589
+ const seen = new Set;
1590
+ const result = [];
1591
+ for (const fact of facts) {
1592
+ const cleaned = fact.trim().replace(/\s+/g, " ");
1593
+ if (!looksMeaningful(cleaned))
1594
+ continue;
1595
+ const key = cleaned.toLowerCase().replace(/\([^)]*\)/g, "").replace(/\s+/g, " ").trim();
1596
+ if (!key || seen.has(key))
1597
+ continue;
1598
+ seen.add(key);
1599
+ result.push(cleaned);
1600
+ }
1601
+ return result;
1602
+ }
1603
+ function looksMeaningful(value) {
1604
+ const cleaned = value.trim();
1605
+ if (cleaned.length < 12)
1606
+ return false;
1607
+ if (/^[A-Za-z0-9_.\-\/]+\.[A-Za-z0-9]+$/.test(cleaned))
1608
+ return false;
1609
+ if (/^(updated|modified|edited|changed|touched)\s+[A-Za-z0-9_.\-\/]+$/i.test(cleaned))
1610
+ return false;
1611
+ return true;
1612
+ }
1613
+
1540
1614
  // src/storage/projects.ts
1541
1615
  import { execSync } from "node:child_process";
1542
1616
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
@@ -1917,10 +1991,17 @@ async function saveObservation(db, config, input) {
1917
1991
  const customPatterns = config.scrubbing.enabled ? config.scrubbing.custom_patterns : [];
1918
1992
  const title = config.scrubbing.enabled ? scrubSecrets(input.title, customPatterns) : input.title;
1919
1993
  const narrative = input.narrative ? config.scrubbing.enabled ? scrubSecrets(input.narrative, customPatterns) : input.narrative : null;
1920
- const factsJson = input.facts ? config.scrubbing.enabled ? scrubSecrets(JSON.stringify(input.facts), customPatterns) : JSON.stringify(input.facts) : null;
1921
1994
  const conceptsJson = input.concepts ? JSON.stringify(input.concepts) : null;
1922
1995
  const filesRead = input.files_read ? input.files_read.map((f) => toRelativePath(f, cwd)) : null;
1923
1996
  const filesModified = input.files_modified ? input.files_modified.map((f) => toRelativePath(f, cwd)) : null;
1997
+ const structuredFacts = buildStructuredFacts({
1998
+ type: input.type,
1999
+ title: input.title,
2000
+ narrative: input.narrative,
2001
+ facts: input.facts,
2002
+ filesModified
2003
+ });
2004
+ const factsJson = structuredFacts.length > 0 ? config.scrubbing.enabled ? scrubSecrets(JSON.stringify(structuredFacts), customPatterns) : JSON.stringify(structuredFacts) : null;
1924
2005
  const filesReadJson = filesRead ? JSON.stringify(filesRead) : null;
1925
2006
  const filesModifiedJson = filesModified ? JSON.stringify(filesModified) : null;
1926
2007
  let sensitivity = input.sensitivity ?? config.scrubbing.default_sensitivity;
@@ -1343,13 +1343,40 @@ function findStaleDecisionsGlobal(db, options) {
1343
1343
  return stale.slice(0, 5);
1344
1344
  }
1345
1345
 
1346
- // src/context/inject.ts
1346
+ // src/intelligence/observation-priority.ts
1347
1347
  var RECENCY_WINDOW_SECONDS = 30 * 86400;
1348
1348
  function computeBlendedScore(quality, createdAtEpoch, nowEpoch) {
1349
1349
  const age = nowEpoch - createdAtEpoch;
1350
1350
  const recencyNorm = Math.max(0, Math.min(1, 1 - age / RECENCY_WINDOW_SECONDS));
1351
1351
  return quality * 0.6 + recencyNorm * 0.4;
1352
1352
  }
1353
+ function observationTypeBoost(type) {
1354
+ switch (type) {
1355
+ case "decision":
1356
+ return 0.2;
1357
+ case "pattern":
1358
+ return 0.18;
1359
+ case "bugfix":
1360
+ return 0.14;
1361
+ case "feature":
1362
+ return 0.12;
1363
+ case "discovery":
1364
+ return 0.1;
1365
+ case "refactor":
1366
+ return 0.05;
1367
+ case "digest":
1368
+ return 0.03;
1369
+ case "change":
1370
+ return 0;
1371
+ default:
1372
+ return 0;
1373
+ }
1374
+ }
1375
+ function computeObservationPriority(obs, nowEpoch) {
1376
+ return computeBlendedScore(obs.quality, obs.created_at_epoch, nowEpoch) + observationTypeBoost(obs.type);
1377
+ }
1378
+
1379
+ // src/context/inject.ts
1353
1380
  function estimateTokens(text) {
1354
1381
  if (!text)
1355
1382
  return 0;
@@ -1442,10 +1469,8 @@ function buildSessionContext(db, cwd, options = {}) {
1442
1469
  }
1443
1470
  const nowEpoch = Math.floor(Date.now() / 1000);
1444
1471
  const sorted = [...deduped].sort((a, b) => {
1445
- const boostA = a.type === "digest" ? 0.15 : 0;
1446
- const boostB = b.type === "digest" ? 0.15 : 0;
1447
- const scoreA = computeBlendedScore(a.quality, a.created_at_epoch, nowEpoch) + boostA;
1448
- const scoreB = computeBlendedScore(b.quality, b.created_at_epoch, nowEpoch) + boostB;
1472
+ const scoreA = computeObservationPriority(a, nowEpoch);
1473
+ const scoreB = computeObservationPriority(b, nowEpoch);
1449
1474
  return scoreB - scoreA;
1450
1475
  });
1451
1476
  const projectName = project?.name ?? detected.name;
@@ -322,13 +322,40 @@ function findStaleDecisionsGlobal(db, options) {
322
322
  return stale.slice(0, 5);
323
323
  }
324
324
 
325
- // src/context/inject.ts
325
+ // src/intelligence/observation-priority.ts
326
326
  var RECENCY_WINDOW_SECONDS = 30 * 86400;
327
327
  function computeBlendedScore(quality, createdAtEpoch, nowEpoch) {
328
328
  const age = nowEpoch - createdAtEpoch;
329
329
  const recencyNorm = Math.max(0, Math.min(1, 1 - age / RECENCY_WINDOW_SECONDS));
330
330
  return quality * 0.6 + recencyNorm * 0.4;
331
331
  }
332
+ function observationTypeBoost(type) {
333
+ switch (type) {
334
+ case "decision":
335
+ return 0.2;
336
+ case "pattern":
337
+ return 0.18;
338
+ case "bugfix":
339
+ return 0.14;
340
+ case "feature":
341
+ return 0.12;
342
+ case "discovery":
343
+ return 0.1;
344
+ case "refactor":
345
+ return 0.05;
346
+ case "digest":
347
+ return 0.03;
348
+ case "change":
349
+ return 0;
350
+ default:
351
+ return 0;
352
+ }
353
+ }
354
+ function computeObservationPriority(obs, nowEpoch) {
355
+ return computeBlendedScore(obs.quality, obs.created_at_epoch, nowEpoch) + observationTypeBoost(obs.type);
356
+ }
357
+
358
+ // src/context/inject.ts
332
359
  function estimateTokens(text) {
333
360
  if (!text)
334
361
  return 0;
@@ -421,10 +448,8 @@ function buildSessionContext(db, cwd, options = {}) {
421
448
  }
422
449
  const nowEpoch = Math.floor(Date.now() / 1000);
423
450
  const sorted = [...deduped].sort((a, b) => {
424
- const boostA = a.type === "digest" ? 0.15 : 0;
425
- const boostB = b.type === "digest" ? 0.15 : 0;
426
- const scoreA = computeBlendedScore(a.quality, a.created_at_epoch, nowEpoch) + boostA;
427
- const scoreB = computeBlendedScore(b.quality, b.created_at_epoch, nowEpoch) + boostB;
451
+ const scoreA = computeObservationPriority(a, nowEpoch);
452
+ const scoreB = computeObservationPriority(b, nowEpoch);
428
453
  return scoreB - scoreA;
429
454
  });
430
455
  const projectName = project?.name ?? detected.name;
@@ -2497,17 +2522,22 @@ function formatSplashScreen(data) {
2497
2522
  function formatVisibleStartupBrief(context) {
2498
2523
  const lines = [];
2499
2524
  const latest = pickBestSummary(context);
2525
+ const observationFallbacks = buildObservationFallbacks(context);
2500
2526
  if (latest) {
2501
2527
  const sections = [
2502
- ["Investigated", latest.investigated],
2503
- ["Learned", latest.learned],
2504
- ["Completed", latest.completed],
2505
- ["Next Steps", latest.next_steps]
2528
+ ["Request", chooseRequest(latest.request, observationFallbacks.request), 1],
2529
+ ["Investigated", chooseSection(latest.investigated, observationFallbacks.investigated, "Investigated"), 2],
2530
+ ["Learned", latest.learned, 2],
2531
+ ["Completed", chooseSection(latest.completed, observationFallbacks.completed, "Completed"), 2],
2532
+ ["Next Steps", latest.next_steps, 2]
2506
2533
  ];
2507
- for (const [label, value] of sections) {
2508
- const formatted = toSplashBullet(value, label === "Next Steps" ? 140 : 180);
2509
- if (formatted) {
2510
- lines.push(`${c2.cyan}${label}:${c2.reset} ${formatted}`);
2534
+ for (const [label, value, maxItems] of sections) {
2535
+ const formatted = toSplashLines(value, maxItems ?? 2);
2536
+ if (formatted.length > 0) {
2537
+ lines.push(`${c2.cyan}${label}:${c2.reset}`);
2538
+ for (const item of formatted) {
2539
+ lines.push(` ${item}`);
2540
+ }
2511
2541
  }
2512
2542
  }
2513
2543
  }
@@ -2516,21 +2546,19 @@ function formatVisibleStartupBrief(context) {
2516
2546
  lines.push(`${c2.yellow}Watch:${c2.reset} ${truncateInline(`Decision still looks unfinished: ${stale.title}`, 170)}`);
2517
2547
  }
2518
2548
  if (lines.length === 0 && context.observations.length > 0) {
2519
- const top = context.observations.slice(0, 2);
2549
+ const top = context.observations.filter((obs) => obs.type !== "digest").filter((obs) => !looksLikeFileOperationTitle(obs.title)).slice(0, 2);
2520
2550
  for (const obs of top) {
2521
2551
  lines.push(`${c2.cyan}${capitalize(obs.type)}:${c2.reset} ${truncateInline(obs.title, 170)}`);
2522
2552
  }
2523
2553
  }
2524
- return lines.slice(0, 5);
2554
+ return lines.slice(0, 10);
2525
2555
  }
2526
- function toSplashBullet(value, maxLen) {
2556
+ function toSplashLines(value, maxItems) {
2527
2557
  if (!value)
2528
- return null;
2529
- const cleaned = dedupeFragments(value.split(`
2530
- `).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean).join("; "));
2531
- if (!cleaned)
2532
- return null;
2533
- return truncateInline(cleaned, maxLen);
2558
+ return [];
2559
+ const lines = value.split(`
2560
+ `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "")).map((line) => stripInlineSectionLabel(line)).map((line) => dedupeFragments(line)).filter(Boolean).sort((a, b) => scoreSplashLine(b) - scoreSplashLine(a)).slice(0, maxItems).map((line) => `- ${truncateInline(line, 140)}`);
2561
+ return dedupeFragmentsInLines(lines);
2534
2562
  }
2535
2563
  function pickBestSummary(context) {
2536
2564
  const summaries = context.summaries || [];
@@ -2572,6 +2600,83 @@ function dedupeFragments(text) {
2572
2600
  }
2573
2601
  return deduped.join("; ");
2574
2602
  }
2603
+ function dedupeFragmentsInLines(lines) {
2604
+ const seen = new Set;
2605
+ const deduped = [];
2606
+ for (const line of lines) {
2607
+ const normalized = stripInlineSectionLabel(line).toLowerCase().replace(/\s+/g, " ").trim();
2608
+ if (!normalized || seen.has(normalized))
2609
+ continue;
2610
+ seen.add(normalized);
2611
+ deduped.push(line);
2612
+ }
2613
+ return deduped;
2614
+ }
2615
+ function chooseRequest(primary, fallback) {
2616
+ if (primary && !looksLikeFileOperationTitle(primary))
2617
+ return primary;
2618
+ return fallback;
2619
+ }
2620
+ function chooseSection(primary, fallback, label) {
2621
+ if (!primary)
2622
+ return fallback;
2623
+ if (label === "Completed" && isWeakCompletedSection(primary))
2624
+ return fallback || primary;
2625
+ if (label === "Investigated" && sectionItemCount(primary) === 0)
2626
+ return fallback;
2627
+ return primary;
2628
+ }
2629
+ function isWeakCompletedSection(value) {
2630
+ const items = value.split(`
2631
+ `).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean);
2632
+ if (!items.length)
2633
+ return true;
2634
+ const weakCount = items.filter((item) => looksLikeFileOperationTitle(item)).length;
2635
+ return weakCount === items.length;
2636
+ }
2637
+ function looksLikeFileOperationTitle(value) {
2638
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
2639
+ }
2640
+ function scoreSplashLine(value) {
2641
+ let score = 0;
2642
+ if (!looksLikeFileOperationTitle(value))
2643
+ score += 2;
2644
+ if (/[:;]/.test(value))
2645
+ score += 1;
2646
+ if (value.length > 30)
2647
+ score += 0.5;
2648
+ return score;
2649
+ }
2650
+ function buildObservationFallbacks(context) {
2651
+ const request = context.observations.find((obs) => !looksLikeFileOperationTitle(obs.title))?.title ?? null;
2652
+ const investigated = collectObservationTitles(context, (obs) => obs.type === "discovery", 2);
2653
+ const completed = collectObservationTitles(context, (obs) => ["feature", "refactor", "change"].includes(obs.type) && !looksLikeFileOperationTitle(obs.title), 2);
2654
+ return {
2655
+ request,
2656
+ investigated,
2657
+ completed
2658
+ };
2659
+ }
2660
+ function collectObservationTitles(context, predicate, limit) {
2661
+ const seen = new Set;
2662
+ const picked = [];
2663
+ for (const obs of context.observations) {
2664
+ if (!predicate(obs))
2665
+ continue;
2666
+ const normalized = stripInlineSectionLabel(obs.title).toLowerCase().replace(/\s+/g, " ").trim();
2667
+ if (!normalized || seen.has(normalized))
2668
+ continue;
2669
+ seen.add(normalized);
2670
+ picked.push(`- ${stripInlineSectionLabel(obs.title)}`);
2671
+ if (picked.length >= limit)
2672
+ break;
2673
+ }
2674
+ return picked.length ? picked.join(`
2675
+ `) : null;
2676
+ }
2677
+ function stripInlineSectionLabel(value) {
2678
+ return value.replace(/^(request|investigated|learned|completed|next steps|digest|summary):\s*/i, "").trim();
2679
+ }
2575
2680
  function pickRelevantStaleDecision(context, summary) {
2576
2681
  const stale = context.staleDecisions || [];
2577
2682
  if (!stale.length)