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.
- package/README.md +160 -90
- package/dist/cli.js +128 -1
- package/dist/hooks/elicitation-result.js +82 -1
- package/dist/hooks/post-tool-use.js +82 -1
- package/dist/hooks/pre-compact.js +30 -5
- package/dist/hooks/session-start.js +127 -22
- package/dist/hooks/stop.js +389 -29
- package/dist/server.js +361 -19
- package/package.json +3 -2
|
@@ -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/
|
|
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
|
|
1446
|
-
const
|
|
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/
|
|
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
|
|
425
|
-
const
|
|
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
|
-
["
|
|
2503
|
-
["
|
|
2504
|
-
["
|
|
2505
|
-
["
|
|
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 =
|
|
2509
|
-
if (formatted) {
|
|
2510
|
-
lines.push(`${c2.cyan}${label}:${c2.reset}
|
|
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,
|
|
2554
|
+
return lines.slice(0, 10);
|
|
2525
2555
|
}
|
|
2526
|
-
function
|
|
2556
|
+
function toSplashLines(value, maxItems) {
|
|
2527
2557
|
if (!value)
|
|
2528
|
-
return
|
|
2529
|
-
const
|
|
2530
|
-
`).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean).
|
|
2531
|
-
|
|
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)
|