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.
@@ -2,6 +2,39 @@
2
2
  import { createRequire } from "node:module";
3
3
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
 
5
+ // src/intelligence/observation-priority.ts
6
+ var RECENCY_WINDOW_SECONDS = 30 * 86400;
7
+ function computeBlendedScore(quality, createdAtEpoch, nowEpoch) {
8
+ const age = nowEpoch - createdAtEpoch;
9
+ const recencyNorm = Math.max(0, Math.min(1, 1 - age / RECENCY_WINDOW_SECONDS));
10
+ return quality * 0.6 + recencyNorm * 0.4;
11
+ }
12
+ function observationTypeBoost(type) {
13
+ switch (type) {
14
+ case "decision":
15
+ return 0.2;
16
+ case "pattern":
17
+ return 0.18;
18
+ case "bugfix":
19
+ return 0.14;
20
+ case "feature":
21
+ return 0.12;
22
+ case "discovery":
23
+ return 0.1;
24
+ case "refactor":
25
+ return 0.05;
26
+ case "digest":
27
+ return 0.03;
28
+ case "change":
29
+ return 0;
30
+ default:
31
+ return 0;
32
+ }
33
+ }
34
+ function computeObservationPriority(obs, nowEpoch) {
35
+ return computeBlendedScore(obs.quality, obs.created_at_epoch, nowEpoch) + observationTypeBoost(obs.type);
36
+ }
37
+
5
38
  // src/capture/retrospective.ts
6
39
  function extractRetrospective(observations, sessionId, projectId, userId) {
7
40
  if (observations.length === 0)
@@ -26,62 +59,140 @@ function extractRetrospective(observations, sessionId, projectId, userId) {
26
59
  };
27
60
  }
28
61
  function extractRequest(observations) {
62
+ const requestCandidate = observations.find((obs) => ["decision", "feature", "change", "bugfix", "discovery"].includes(obs.type) && obs.title.trim().length > 0 && !looksLikeFileOperation(obs.title));
63
+ if (requestCandidate)
64
+ return requestCandidate.title;
29
65
  const first = observations[0];
30
- if (!first)
66
+ if (!first || !first.title.trim())
31
67
  return null;
32
68
  return first.title;
33
69
  }
34
70
  function extractInvestigated(observations) {
35
- const discoveries = observations.filter((o) => o.type === "discovery");
71
+ const discoveries = observations.filter((o) => o.type === "discovery").sort((a, b) => scoreNarrativeObservation(b) - scoreNarrativeObservation(a));
36
72
  if (discoveries.length === 0)
37
73
  return null;
38
- return discoveries.slice(0, 5).map((o) => {
39
- const facts = extractTopFacts(o, 2);
40
- return facts ? `- ${o.title}
41
- ${facts}` : `- ${o.title}`;
42
- }).join(`
43
- `);
74
+ return formatObservationGroup(discoveries, {
75
+ limit: 4,
76
+ factsPerItem: 2
77
+ });
44
78
  }
45
79
  function extractLearned(observations) {
46
80
  const learnTypes = new Set(["bugfix", "decision", "pattern"]);
47
- const learned = observations.filter((o) => learnTypes.has(o.type));
81
+ const learned = observations.filter((o) => learnTypes.has(o.type)).sort((a, b) => scoreNarrativeObservation(b) - scoreNarrativeObservation(a));
48
82
  if (learned.length === 0)
49
83
  return null;
50
- return learned.slice(0, 5).map((o) => {
51
- const facts = extractTopFacts(o, 2);
52
- return facts ? `- ${o.title}
53
- ${facts}` : `- ${o.title}`;
54
- }).join(`
55
- `);
84
+ return formatObservationGroup(learned, {
85
+ limit: 4,
86
+ factsPerItem: 2
87
+ });
56
88
  }
57
89
  function extractCompleted(observations) {
58
90
  const completeTypes = new Set(["change", "feature", "refactor"]);
59
91
  const completed = observations.filter((o) => completeTypes.has(o.type));
60
92
  if (completed.length === 0)
61
93
  return null;
62
- return completed.slice(0, 5).map((o) => {
63
- const files = o.files_modified ? parseJsonArray(o.files_modified) : [];
64
- const fileCtx = files.length > 0 ? ` (${files.slice(0, 2).map((f) => f.split("/").pop()).join(", ")})` : "";
65
- return `- ${o.title}${fileCtx}`;
66
- }).join(`
94
+ const prioritized = dedupeObservationsByTitle(completed).sort((a, b) => scoreCompletedObservation(b) - scoreCompletedObservation(a)).slice(0, 4);
95
+ const lines = prioritized.map((o) => {
96
+ const title = normalizeCompletedTitle(o.title, o.files_modified);
97
+ const facts = extractTopFacts(o, 1);
98
+ return facts ? `- ${title}
99
+ ${facts}` : `- ${title}`;
100
+ });
101
+ return dedupeBulletLines(lines).join(`
67
102
  `);
68
103
  }
69
104
  function extractNextSteps(observations) {
70
105
  if (observations.length < 2)
71
106
  return null;
72
- const lastQuarterStart = Math.floor(observations.length * 0.75);
107
+ const lastQuarterStart = Math.max(0, Math.min(observations.length - 1, observations.length - 3, Math.floor(observations.length * 0.75)));
73
108
  const lastQuarter = observations.slice(lastQuarterStart);
74
109
  const unresolved = lastQuarter.filter((o) => o.type === "bugfix" && o.narrative && /error|fail|exception/i.test(o.narrative));
75
- if (unresolved.length === 0)
110
+ const explicitDecisions = lastQuarter.filter((o) => o.type === "decision").sort((a, b) => scoreNarrativeObservation(b) - scoreNarrativeObservation(a)).slice(0, 2).map((o) => `- Follow through: ${o.title}`);
111
+ if (unresolved.length === 0 && explicitDecisions.length === 0)
76
112
  return null;
77
- return unresolved.map((o) => `- Investigate: ${o.title}`).slice(0, 3).join(`
113
+ const lines = unresolved.map((o) => `- Investigate: ${o.title}`).slice(0, 3).concat(explicitDecisions);
114
+ return dedupeBulletLines(lines).join(`
78
115
  `);
79
116
  }
117
+ function formatObservationGroup(observations, options) {
118
+ const lines = dedupeObservationsByTitle(observations).sort((a, b) => scoreNarrativeObservation(b) - scoreNarrativeObservation(a)).slice(0, options.limit).map((o) => {
119
+ const facts = extractTopFacts(o, options.factsPerItem);
120
+ return facts ? `- ${o.title}
121
+ ${facts}` : `- ${o.title}`;
122
+ });
123
+ const deduped = dedupeBulletLines(lines);
124
+ return deduped.length ? deduped.join(`
125
+ `) : null;
126
+ }
127
+ function dedupeBulletLines(lines) {
128
+ const seen = new Set;
129
+ const deduped = [];
130
+ for (const line of lines) {
131
+ const normalized = line.toLowerCase().replace(/\([^)]*\)/g, "").replace(/\s+/g, " ").trim();
132
+ if (!normalized || seen.has(normalized))
133
+ continue;
134
+ seen.add(normalized);
135
+ deduped.push(line);
136
+ }
137
+ return deduped;
138
+ }
139
+ function dedupeObservationsByTitle(observations) {
140
+ const seen = new Set;
141
+ const deduped = [];
142
+ for (const obs of observations) {
143
+ const normalized = normalizeObservationKey(obs.title);
144
+ if (!normalized || seen.has(normalized))
145
+ continue;
146
+ seen.add(normalized);
147
+ deduped.push(obs);
148
+ }
149
+ return deduped;
150
+ }
151
+ function scoreCompletedObservation(obs) {
152
+ let score = scoreNarrativeObservation(obs);
153
+ if (obs.type === "feature")
154
+ score += 0.5;
155
+ if (obs.type === "refactor")
156
+ score += 0.2;
157
+ if (looksLikeFileOperation(obs.title))
158
+ score -= 0.6;
159
+ return score;
160
+ }
161
+ function scoreNarrativeObservation(obs) {
162
+ const nowEpoch = Math.floor(Date.now() / 1000);
163
+ let score = computeObservationPriority(obs, nowEpoch);
164
+ if (hasMeaningfulFacts(obs))
165
+ score += 0.4;
166
+ if (obs.narrative && obs.narrative.length > 80)
167
+ score += 0.2;
168
+ if (looksLikeFileOperation(obs.title))
169
+ score -= 0.25;
170
+ return score;
171
+ }
172
+ function hasMeaningfulFacts(obs) {
173
+ return parseJsonArray(obs.facts).some((fact) => fact.trim().length > 20);
174
+ }
175
+ function looksLikeFileOperation(title) {
176
+ return /^(modified|updated|edited|touched|changed)\s+[A-Za-z0-9_.\-\/]+$/i.test(title.trim());
177
+ }
178
+ function normalizeCompletedTitle(title, filesModified) {
179
+ const trimmed = title.trim();
180
+ if (!trimmed)
181
+ return "Completed work";
182
+ if (!looksLikeFileOperation(trimmed))
183
+ return trimmed;
184
+ const files = parseJsonArray(filesModified);
185
+ const filename = files[0]?.split("/").pop();
186
+ if (filename) {
187
+ return `Updated implementation in ${filename}`;
188
+ }
189
+ return trimmed;
190
+ }
80
191
  function extractTopFacts(obs, n) {
81
- const facts = parseJsonArray(obs.facts);
192
+ const facts = parseJsonArray(obs.facts).filter((fact) => isUsefulFact(fact, obs.title)).slice(0, n);
82
193
  if (facts.length === 0)
83
194
  return null;
84
- return facts.slice(0, n).map((f) => ` ${f}`).join(`
195
+ return facts.map((f) => ` ${f}`).join(`
85
196
  `);
86
197
  }
87
198
  function parseJsonArray(json) {
@@ -95,6 +206,23 @@ function parseJsonArray(json) {
95
206
  } catch {}
96
207
  return [];
97
208
  }
209
+ function isUsefulFact(fact, title) {
210
+ const cleaned = fact.trim();
211
+ if (!cleaned)
212
+ return false;
213
+ const normalizedFact = normalizeObservationKey(cleaned);
214
+ const normalizedTitle = normalizeObservationKey(title);
215
+ if (normalizedFact && normalizedFact === normalizedTitle)
216
+ return false;
217
+ if (/^[A-Za-z0-9_.\-\/]+\.[A-Za-z0-9]+$/.test(cleaned))
218
+ return false;
219
+ if (/^\(?[A-Za-z0-9_.\-\/]+\.[A-Za-z0-9]+\)?$/.test(cleaned))
220
+ return false;
221
+ return cleaned.length > 16 || /[:;]/.test(cleaned);
222
+ }
223
+ function normalizeObservationKey(value) {
224
+ return value.toLowerCase().replace(/\([^)]*\)/g, "").replace(/\b(modified|updated|edited|touched|changed)\b/g, "").replace(/\s+/g, " ").trim();
225
+ }
98
226
 
99
227
  // src/config.ts
100
228
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -1423,6 +1551,31 @@ function markFailed(db, entryId, error) {
1423
1551
  WHERE id = ?`).run(error, now, entryId);
1424
1552
  }
1425
1553
 
1554
+ // src/intelligence/value-signals.ts
1555
+ var LESSON_TYPES = new Set(["bugfix", "decision", "pattern"]);
1556
+ function computeSessionValueSignals(observations, securityFindings = []) {
1557
+ const decisionsCount = observations.filter((o) => o.type === "decision").length;
1558
+ const lessonsCount = observations.filter((o) => LESSON_TYPES.has(o.type)).length;
1559
+ const discoveriesCount = observations.filter((o) => o.type === "discovery").length;
1560
+ const featuresCount = observations.filter((o) => o.type === "feature").length;
1561
+ const refactorsCount = observations.filter((o) => o.type === "refactor").length;
1562
+ const repeatedPatternsCount = observations.filter((o) => o.type === "pattern").length;
1563
+ const hasRequestSignal = observations.some((o) => ["feature", "decision", "change", "bugfix", "discovery"].includes(o.type));
1564
+ const hasCompletionSignal = observations.some((o) => ["feature", "change", "refactor", "bugfix"].includes(o.type));
1565
+ return {
1566
+ decisions_count: decisionsCount,
1567
+ lessons_count: lessonsCount,
1568
+ discoveries_count: discoveriesCount,
1569
+ features_count: featuresCount,
1570
+ refactors_count: refactorsCount,
1571
+ repeated_patterns_count: repeatedPatternsCount,
1572
+ security_findings_count: securityFindings.length,
1573
+ critical_security_findings_count: securityFindings.filter((f) => f.severity === "critical").length,
1574
+ delivery_review_ready: hasRequestSignal && hasCompletionSignal,
1575
+ vibe_guardian_active: securityFindings.length > 0
1576
+ };
1577
+ }
1578
+
1426
1579
  // src/sync/push.ts
1427
1580
  function buildVectorDocument(obs, config, project) {
1428
1581
  const parts = [obs.title];
@@ -1471,7 +1624,7 @@ function buildVectorDocument(obs, config, project) {
1471
1624
  }
1472
1625
  };
1473
1626
  }
1474
- function buildSummaryVectorDocument(summary, config, project) {
1627
+ function buildSummaryVectorDocument(summary, config, project, observations = []) {
1475
1628
  const parts = [];
1476
1629
  if (summary.request)
1477
1630
  parts.push(`Request: ${summary.request}`);
@@ -1483,6 +1636,7 @@ function buildSummaryVectorDocument(summary, config, project) {
1483
1636
  parts.push(`Completed: ${summary.completed}`);
1484
1637
  if (summary.next_steps)
1485
1638
  parts.push(`Next Steps: ${summary.next_steps}`);
1639
+ const valueSignals = computeSessionValueSignals(observations, []);
1486
1640
  return {
1487
1641
  site_id: config.site_id,
1488
1642
  namespace: config.namespace,
@@ -1501,6 +1655,19 @@ function buildSummaryVectorDocument(summary, config, project) {
1501
1655
  learned: summary.learned,
1502
1656
  completed: summary.completed,
1503
1657
  next_steps: summary.next_steps,
1658
+ summary_sections_present: countPresentSections(summary),
1659
+ investigated_items: extractSectionItems(summary.investigated),
1660
+ learned_items: extractSectionItems(summary.learned),
1661
+ completed_items: extractSectionItems(summary.completed),
1662
+ next_step_items: extractSectionItems(summary.next_steps),
1663
+ decisions_count: valueSignals.decisions_count,
1664
+ lessons_count: valueSignals.lessons_count,
1665
+ discoveries_count: valueSignals.discoveries_count,
1666
+ features_count: valueSignals.features_count,
1667
+ refactors_count: valueSignals.refactors_count,
1668
+ repeated_patterns_count: valueSignals.repeated_patterns_count,
1669
+ delivery_review_ready: valueSignals.delivery_review_ready,
1670
+ vibe_guardian_active: valueSignals.vibe_guardian_active,
1504
1671
  created_at_epoch: summary.created_at_epoch,
1505
1672
  local_id: summary.id
1506
1673
  }
@@ -1530,10 +1697,11 @@ async function pushOutbox(db, client, config, batchSize = 50) {
1530
1697
  continue;
1531
1698
  }
1532
1699
  markSyncing(db, entry.id);
1700
+ const summaryObservations = db.getObservationsBySession(summary.session_id);
1533
1701
  const doc2 = buildSummaryVectorDocument(summary, config, {
1534
1702
  canonical_id: project2.canonical_id,
1535
1703
  name: project2.name
1536
- });
1704
+ }, summaryObservations);
1537
1705
  batch.push({ entryId: entry.id, doc: doc2 });
1538
1706
  continue;
1539
1707
  }
@@ -1592,6 +1760,21 @@ async function pushOutbox(db, client, config, batchSize = 50) {
1592
1760
  }
1593
1761
  return { pushed, failed, skipped };
1594
1762
  }
1763
+ function countPresentSections(summary) {
1764
+ return [
1765
+ summary.request,
1766
+ summary.investigated,
1767
+ summary.learned,
1768
+ summary.completed,
1769
+ summary.next_steps
1770
+ ].filter((value) => Boolean(value && value.trim())).length;
1771
+ }
1772
+ function extractSectionItems(section) {
1773
+ if (!section)
1774
+ return [];
1775
+ return section.split(`
1776
+ `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "").trim()).filter(Boolean).slice(0, 4);
1777
+ }
1595
1778
 
1596
1779
  // src/embeddings/embedder.ts
1597
1780
  var _available = null;
@@ -1764,6 +1947,77 @@ function detectStacks(filePaths) {
1764
1947
  return Array.from(stacks).sort();
1765
1948
  }
1766
1949
 
1950
+ // src/intelligence/session-insights.ts
1951
+ function computeSessionInsights(summaries, observations) {
1952
+ const orderedSummaries = [...summaries].sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
1953
+ const summaryCount = orderedSummaries.length;
1954
+ const summariesWithLearned = orderedSummaries.filter((s) => hasContent(s.learned)).length;
1955
+ const summariesWithCompleted = orderedSummaries.filter((s) => hasContent(s.completed)).length;
1956
+ const summariesWithNextSteps = orderedSummaries.filter((s) => hasContent(s.next_steps)).length;
1957
+ const totalSummarySectionsPresent = orderedSummaries.reduce((total, summary) => total + countPresentSections2(summary), 0);
1958
+ const recentRequests = dedupeLines(orderedSummaries.map((summary) => summary.request?.trim() ?? "").filter(Boolean)).slice(0, 3);
1959
+ const recentLessons = dedupeLines([
1960
+ ...orderedSummaries.flatMap((summary) => extractSectionItems2(summary.learned)),
1961
+ ...extractObservationTitles(observations, ["decision", "pattern", "bugfix"])
1962
+ ]).slice(0, 4);
1963
+ const recentCompleted = dedupeLines([
1964
+ ...orderedSummaries.flatMap((summary) => extractSectionItems2(summary.completed)),
1965
+ ...extractObservationTitles(observations, ["feature", "refactor", "change"])
1966
+ ]).slice(0, 4);
1967
+ const nextSteps = dedupeLines([
1968
+ ...orderedSummaries.flatMap((summary) => extractSectionItems2(summary.next_steps)),
1969
+ ...extractObservationTitles(observations, ["decision"]).map((title) => `Follow through: ${title}`)
1970
+ ]).slice(0, 4);
1971
+ return {
1972
+ summary_count: summaryCount,
1973
+ summaries_with_learned: summariesWithLearned,
1974
+ summaries_with_completed: summariesWithCompleted,
1975
+ summaries_with_next_steps: summariesWithNextSteps,
1976
+ total_summary_sections_present: totalSummarySectionsPresent,
1977
+ recent_requests: recentRequests,
1978
+ recent_lessons: recentLessons,
1979
+ recent_completed: recentCompleted,
1980
+ next_steps: nextSteps
1981
+ };
1982
+ }
1983
+ function countPresentSections2(summary) {
1984
+ return [
1985
+ summary.request,
1986
+ summary.investigated,
1987
+ summary.learned,
1988
+ summary.completed,
1989
+ summary.next_steps
1990
+ ].filter(hasContent).length;
1991
+ }
1992
+ function extractSectionItems2(section) {
1993
+ if (!hasContent(section))
1994
+ return [];
1995
+ return section.split(`
1996
+ `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
1997
+ }
1998
+ function extractObservationTitles(observations, types) {
1999
+ const typeSet = new Set(types);
2000
+ return observations.filter((obs) => typeSet.has(obs.type)).sort((a, b) => b.created_at_epoch - a.created_at_epoch).map((obs) => obs.title.trim()).filter(Boolean);
2001
+ }
2002
+ function dedupeLines(lines) {
2003
+ const seen = new Set;
2004
+ const result = [];
2005
+ for (const line of lines) {
2006
+ const cleaned = line.replace(/\s+/g, " ").trim();
2007
+ if (!cleaned)
2008
+ continue;
2009
+ const key = cleaned.toLowerCase();
2010
+ if (seen.has(key))
2011
+ continue;
2012
+ seen.add(key);
2013
+ result.push(cleaned);
2014
+ }
2015
+ return result;
2016
+ }
2017
+ function hasContent(value) {
2018
+ return Boolean(value && value.trim());
2019
+ }
2020
+
1767
2021
  // src/telemetry/beacon.ts
1768
2022
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1769
2023
  import { join as join3 } from "node:path";
@@ -1794,6 +2048,17 @@ function buildBeacon(db, config, sessionId, metrics) {
1794
2048
  for (const obs of observations) {
1795
2049
  byType[obs.type] = (byType[obs.type] ?? 0) + 1;
1796
2050
  }
2051
+ let securityFindings = [];
2052
+ try {
2053
+ if (session.project_id) {
2054
+ securityFindings = db.getSecurityFindings(session.project_id, { limit: 200 }).filter((f) => f.session_id === sessionId);
2055
+ }
2056
+ } catch {
2057
+ securityFindings = [];
2058
+ }
2059
+ const valueSignals = computeSessionValueSignals(observations, securityFindings);
2060
+ const summaries = session.project_id ? db.getRecentSummaries(session.project_id, 20).filter((summary) => summary.session_id === sessionId) : [];
2061
+ const sessionInsights = computeSessionInsights(summaries, observations);
1797
2062
  const filePaths = [];
1798
2063
  for (const obs of observations) {
1799
2064
  if (obs.files_modified) {
@@ -1843,7 +2108,7 @@ function buildBeacon(db, config, sessionId, metrics) {
1843
2108
  observer_events: observerEvents,
1844
2109
  observer_observations: observerObservations,
1845
2110
  observer_skips: observerSkips,
1846
- sentinel_used: false,
2111
+ sentinel_used: valueSignals.security_findings_count > 0,
1847
2112
  risk_score: riskScore,
1848
2113
  stacks_detected: stacks,
1849
2114
  client_version: "0.4.0",
@@ -1853,6 +2118,20 @@ function buildBeacon(db, config, sessionId, metrics) {
1853
2118
  recall_hits: metrics?.recallHits ?? 0,
1854
2119
  search_count: metrics?.searchCount ?? 0,
1855
2120
  search_results_total: metrics?.searchResultsTotal ?? 0,
2121
+ decisions_count: valueSignals.decisions_count,
2122
+ lessons_count: valueSignals.lessons_count,
2123
+ discoveries_count: valueSignals.discoveries_count,
2124
+ features_count: valueSignals.features_count,
2125
+ refactors_count: valueSignals.refactors_count,
2126
+ repeated_patterns_count: valueSignals.repeated_patterns_count,
2127
+ security_findings_count: valueSignals.security_findings_count,
2128
+ critical_security_findings_count: valueSignals.critical_security_findings_count,
2129
+ delivery_review_ready: valueSignals.delivery_review_ready,
2130
+ vibe_guardian_active: valueSignals.vibe_guardian_active,
2131
+ summaries_with_learned: sessionInsights.summaries_with_learned,
2132
+ summaries_with_completed: sessionInsights.summaries_with_completed,
2133
+ summaries_with_next_steps: sessionInsights.summaries_with_next_steps,
2134
+ total_summary_sections_present: sessionInsights.total_summary_sections_present,
1856
2135
  config_hash: configHash,
1857
2136
  config_changed: configChanged,
1858
2137
  config_fingerprint_detail: configFingerprintDetail
@@ -2232,6 +2511,80 @@ function findDuplicate(newTitle, candidates) {
2232
2511
  return bestMatch;
2233
2512
  }
2234
2513
 
2514
+ // src/capture/facts.ts
2515
+ var FACT_ELIGIBLE_TYPES = new Set([
2516
+ "bugfix",
2517
+ "decision",
2518
+ "discovery",
2519
+ "pattern",
2520
+ "feature",
2521
+ "refactor",
2522
+ "change"
2523
+ ]);
2524
+ function buildStructuredFacts(input) {
2525
+ const seedFacts = dedupeFacts(input.facts ?? []);
2526
+ if (!FACT_ELIGIBLE_TYPES.has(input.type)) {
2527
+ return seedFacts;
2528
+ }
2529
+ const derived = [...seedFacts];
2530
+ if (seedFacts.length === 0 && looksMeaningful(input.title)) {
2531
+ derived.push(input.title.trim());
2532
+ }
2533
+ for (const sentence of extractNarrativeFacts(input.narrative)) {
2534
+ derived.push(sentence);
2535
+ }
2536
+ const fileFact = buildFilesFact(input.filesModified);
2537
+ if (fileFact) {
2538
+ derived.push(fileFact);
2539
+ }
2540
+ return dedupeFacts(derived).slice(0, 4);
2541
+ }
2542
+ function extractNarrativeFacts(narrative) {
2543
+ if (!narrative)
2544
+ return [];
2545
+ const cleaned = narrative.replace(/\s+/g, " ").trim();
2546
+ if (cleaned.length < 24)
2547
+ return [];
2548
+ const parts = cleaned.split(/(?<=[.!?;])\s+/).map((part) => part.trim().replace(/[.!?;]+$/, "")).filter(Boolean).filter(looksMeaningful);
2549
+ return parts.slice(0, 2);
2550
+ }
2551
+ function buildFilesFact(filesModified) {
2552
+ if (!filesModified || filesModified.length === 0)
2553
+ return null;
2554
+ const cleaned = filesModified.map((file) => file.trim()).filter(Boolean).slice(0, 3);
2555
+ if (cleaned.length === 0)
2556
+ return null;
2557
+ if (cleaned.length === 1) {
2558
+ return `Touched ${cleaned[0]}`;
2559
+ }
2560
+ return `Touched ${cleaned.join(", ")}`;
2561
+ }
2562
+ function dedupeFacts(facts) {
2563
+ const seen = new Set;
2564
+ const result = [];
2565
+ for (const fact of facts) {
2566
+ const cleaned = fact.trim().replace(/\s+/g, " ");
2567
+ if (!looksMeaningful(cleaned))
2568
+ continue;
2569
+ const key = cleaned.toLowerCase().replace(/\([^)]*\)/g, "").replace(/\s+/g, " ").trim();
2570
+ if (!key || seen.has(key))
2571
+ continue;
2572
+ seen.add(key);
2573
+ result.push(cleaned);
2574
+ }
2575
+ return result;
2576
+ }
2577
+ function looksMeaningful(value) {
2578
+ const cleaned = value.trim();
2579
+ if (cleaned.length < 12)
2580
+ return false;
2581
+ if (/^[A-Za-z0-9_.\-\/]+\.[A-Za-z0-9]+$/.test(cleaned))
2582
+ return false;
2583
+ if (/^(updated|modified|edited|changed|touched)\s+[A-Za-z0-9_.\-\/]+$/i.test(cleaned))
2584
+ return false;
2585
+ return true;
2586
+ }
2587
+
2235
2588
  // src/capture/recurrence.ts
2236
2589
  var DISTANCE_THRESHOLD = 0.15;
2237
2590
  async function detectRecurrence(db, config, observation) {
@@ -2449,10 +2802,17 @@ async function saveObservation(db, config, input) {
2449
2802
  const customPatterns = config.scrubbing.enabled ? config.scrubbing.custom_patterns : [];
2450
2803
  const title = config.scrubbing.enabled ? scrubSecrets(input.title, customPatterns) : input.title;
2451
2804
  const narrative = input.narrative ? config.scrubbing.enabled ? scrubSecrets(input.narrative, customPatterns) : input.narrative : null;
2452
- const factsJson = input.facts ? config.scrubbing.enabled ? scrubSecrets(JSON.stringify(input.facts), customPatterns) : JSON.stringify(input.facts) : null;
2453
2805
  const conceptsJson = input.concepts ? JSON.stringify(input.concepts) : null;
2454
2806
  const filesRead = input.files_read ? input.files_read.map((f) => toRelativePath(f, cwd)) : null;
2455
2807
  const filesModified = input.files_modified ? input.files_modified.map((f) => toRelativePath(f, cwd)) : null;
2808
+ const structuredFacts = buildStructuredFacts({
2809
+ type: input.type,
2810
+ title: input.title,
2811
+ narrative: input.narrative,
2812
+ facts: input.facts,
2813
+ filesModified
2814
+ });
2815
+ const factsJson = structuredFacts.length > 0 ? config.scrubbing.enabled ? scrubSecrets(JSON.stringify(structuredFacts), customPatterns) : JSON.stringify(structuredFacts) : null;
2456
2816
  const filesReadJson = filesRead ? JSON.stringify(filesRead) : null;
2457
2817
  const filesModifiedJson = filesModified ? JSON.stringify(filesModified) : null;
2458
2818
  let sensitivity = input.sensitivity ?? config.scrubbing.default_sensitivity;