@4da/mcp-server 5.0.4 → 6.0.1

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 (172) hide show
  1. package/CHANGELOG.md +698 -0
  2. package/README.md +70 -39
  3. package/dist/build-staleness.d.ts +35 -0
  4. package/dist/build-staleness.js +95 -0
  5. package/dist/db.d.ts +53 -58
  6. package/dist/db.js +226 -299
  7. package/dist/doctor.js +27 -7
  8. package/dist/embeddings.js +7 -1
  9. package/dist/freshness-markers.d.ts +34 -0
  10. package/dist/freshness-markers.js +74 -0
  11. package/dist/gitignore.d.ts +37 -0
  12. package/dist/gitignore.js +130 -0
  13. package/dist/index.js +127 -44
  14. package/dist/live/bun-lockfile.d.ts +26 -0
  15. package/dist/live/bun-lockfile.js +123 -0
  16. package/dist/live/cargo-platform.d.ts +38 -0
  17. package/dist/live/cargo-platform.js +178 -0
  18. package/dist/live/changelog-classify.d.ts +41 -0
  19. package/dist/live/changelog-classify.js +207 -0
  20. package/dist/live/changelog.d.ts +64 -0
  21. package/dist/live/changelog.js +318 -0
  22. package/dist/live/crates-registry.js +3 -1
  23. package/dist/live/crates-versions.d.ts +47 -0
  24. package/dist/live/crates-versions.js +201 -0
  25. package/dist/live/dependency-set.d.ts +13 -0
  26. package/dist/live/dependency-set.js +95 -0
  27. package/dist/live/dev-scope.d.ts +32 -0
  28. package/dist/live/dev-scope.js +102 -0
  29. package/dist/live/file-signature.d.ts +18 -0
  30. package/dist/live/file-signature.js +43 -0
  31. package/dist/live/go-registry.js +3 -1
  32. package/dist/live/hn-fetcher.js +9 -3
  33. package/dist/live/index.d.ts +105 -2
  34. package/dist/live/index.js +266 -139
  35. package/dist/live/install-state.d.ts +43 -0
  36. package/dist/live/install-state.js +150 -0
  37. package/dist/live/js-lockfile-readers.d.ts +46 -0
  38. package/dist/live/js-lockfile-readers.js +181 -0
  39. package/dist/live/lockfile-parsers-pygo.d.ts +60 -0
  40. package/dist/live/lockfile-parsers-pygo.js +274 -0
  41. package/dist/live/lockfile-parsers.d.ts +69 -0
  42. package/dist/live/lockfile-parsers.js +311 -0
  43. package/dist/live/lockfile-types.d.ts +34 -0
  44. package/dist/live/lockfile-types.js +42 -0
  45. package/dist/live/maintenance.d.ts +33 -0
  46. package/dist/live/maintenance.js +37 -0
  47. package/dist/live/npm-packument.d.ts +47 -0
  48. package/dist/live/npm-packument.js +150 -0
  49. package/dist/live/npm-registry.js +67 -53
  50. package/dist/live/osv-scanner.d.ts +12 -1
  51. package/dist/live/osv-scanner.js +50 -15
  52. package/dist/live/package-archive.d.ts +49 -0
  53. package/dist/live/package-archive.js +186 -0
  54. package/dist/live/pypi-registry.js +3 -1
  55. package/dist/live/registry-health.d.ts +16 -0
  56. package/dist/live/registry-health.js +74 -0
  57. package/dist/live/release-metadata.d.ts +67 -0
  58. package/dist/live/release-metadata.js +16 -0
  59. package/dist/live/resolution.d.ts +43 -0
  60. package/dist/live/resolution.js +62 -0
  61. package/dist/live/semver-precedence.d.ts +30 -0
  62. package/dist/live/semver-precedence.js +83 -0
  63. package/dist/live/severity-scope.d.ts +57 -0
  64. package/dist/live/severity-scope.js +89 -0
  65. package/dist/live/types.d.ts +83 -0
  66. package/dist/live/upgrade-sources.d.ts +71 -0
  67. package/dist/live/upgrade-sources.js +198 -0
  68. package/dist/live/version-compare.d.ts +39 -0
  69. package/dist/live/version-compare.js +130 -0
  70. package/dist/live/version-resolver.d.ts +14 -1
  71. package/dist/live/version-resolver.js +93 -303
  72. package/dist/native-bindings.d.ts +19 -0
  73. package/dist/native-bindings.js +39 -0
  74. package/dist/project-manifests.d.ts +40 -0
  75. package/dist/project-manifests.js +193 -0
  76. package/dist/project-scanner.js +38 -48
  77. package/dist/project-tree.d.ts +41 -0
  78. package/dist/project-tree.js +110 -0
  79. package/dist/prompts.d.ts +28 -0
  80. package/dist/prompts.js +54 -0
  81. package/dist/schema-registry.d.ts +41 -29
  82. package/dist/schema-registry.js +98 -105
  83. package/dist/server-instructions.d.ts +11 -0
  84. package/dist/server-instructions.js +36 -0
  85. package/dist/tool-args.d.ts +28 -0
  86. package/dist/tool-args.js +93 -0
  87. package/dist/tool-dispatch.d.ts +1 -1
  88. package/dist/tool-dispatch.js +42 -8
  89. package/dist/tools/app-plan.d.ts +92 -0
  90. package/dist/tools/app-plan.js +155 -0
  91. package/dist/tools/briefing-stored-advisories.d.ts +20 -0
  92. package/dist/tools/briefing-stored-advisories.js +58 -0
  93. package/dist/tools/briefing-task-scope.d.ts +73 -0
  94. package/dist/tools/briefing-task-scope.js +197 -0
  95. package/dist/tools/briefing-wisdom.d.ts +31 -0
  96. package/dist/tools/briefing-wisdom.js +148 -0
  97. package/dist/tools/decision-enforcement.d.ts +0 -5
  98. package/dist/tools/decision-enforcement.js +0 -4
  99. package/dist/tools/decision-memory.js +18 -66
  100. package/dist/tools/dependency-check-signals.d.ts +74 -0
  101. package/dist/tools/dependency-check-signals.js +264 -0
  102. package/dist/tools/dependency-check.d.ts +98 -0
  103. package/dist/tools/dependency-check.js +295 -0
  104. package/dist/tools/dependency-health.js +74 -21
  105. package/dist/tools/developer-dna.d.ts +3 -0
  106. package/dist/tools/developer-dna.js +16 -15
  107. package/dist/tools/ecosystem-pulse.d.ts +6 -2
  108. package/dist/tools/ecosystem-pulse.js +53 -14
  109. package/dist/tools/get-actionable-signals.d.ts +26 -4
  110. package/dist/tools/get-actionable-signals.js +214 -251
  111. package/dist/tools/get-context.d.ts +2 -0
  112. package/dist/tools/get-context.js +9 -1
  113. package/dist/tools/get-relevant-content.d.ts +3 -1
  114. package/dist/tools/get-relevant-content.js +34 -3
  115. package/dist/tools/index.d.ts +4 -2
  116. package/dist/tools/index.js +4 -2
  117. package/dist/tools/install-drift-notes.d.ts +51 -0
  118. package/dist/tools/install-drift-notes.js +172 -0
  119. package/dist/tools/knowledge-gap-citations.d.ts +55 -0
  120. package/dist/tools/knowledge-gap-citations.js +132 -0
  121. package/dist/tools/knowledge-gap-exposure.d.ts +72 -0
  122. package/dist/tools/knowledge-gap-exposure.js +160 -0
  123. package/dist/tools/knowledge-gap-grading.d.ts +91 -0
  124. package/dist/tools/knowledge-gap-grading.js +157 -0
  125. package/dist/tools/knowledge-gap-installs.d.ts +65 -0
  126. package/dist/tools/knowledge-gap-installs.js +134 -0
  127. package/dist/tools/knowledge-gap-ranges.d.ts +51 -0
  128. package/dist/tools/knowledge-gap-ranges.js +146 -0
  129. package/dist/tools/knowledge-gap-scope.d.ts +63 -0
  130. package/dist/tools/knowledge-gap-scope.js +118 -0
  131. package/dist/tools/knowledge-gaps.d.ts +12 -3
  132. package/dist/tools/knowledge-gaps.js +172 -121
  133. package/dist/tools/package-presence.d.ts +24 -0
  134. package/dist/tools/package-presence.js +35 -0
  135. package/dist/tools/record-feedback.d.ts +0 -1
  136. package/dist/tools/record-feedback.js +7 -8
  137. package/dist/tools/signal-classifier.d.ts +25 -0
  138. package/dist/tools/signal-classifier.js +40 -0
  139. package/dist/tools/upgrade-impact-callsites.d.ts +52 -0
  140. package/dist/tools/upgrade-impact-callsites.js +226 -0
  141. package/dist/tools/upgrade-impact-literals.d.ts +45 -0
  142. package/dist/tools/upgrade-impact-literals.js +118 -0
  143. package/dist/tools/upgrade-impact-report.d.ts +80 -0
  144. package/dist/tools/upgrade-impact-report.js +178 -0
  145. package/dist/tools/upgrade-impact.d.ts +84 -0
  146. package/dist/tools/upgrade-impact.js +305 -0
  147. package/dist/tools/upgrade-planner.d.ts +41 -8
  148. package/dist/tools/upgrade-planner.js +201 -53
  149. package/dist/tools/vulnerability-scan-concise.d.ts +72 -0
  150. package/dist/tools/vulnerability-scan-concise.js +97 -0
  151. package/dist/tools/vulnerability-scan-format.d.ts +137 -0
  152. package/dist/tools/vulnerability-scan-format.js +276 -0
  153. package/dist/tools/vulnerability-scan.d.ts +338 -18
  154. package/dist/tools/vulnerability-scan.js +84 -125
  155. package/dist/tools/what-should-i-know.d.ts +86 -19
  156. package/dist/tools/what-should-i-know.js +277 -283
  157. package/dist/types.d.ts +4 -136
  158. package/package.json +35 -35
  159. package/dist/schemas/agent-memory.json +0 -49
  160. package/dist/schemas/decision-enforcement.json +0 -22
  161. package/dist/schemas/decision-memory.json +0 -54
  162. package/dist/schemas/dependency-health.json +0 -27
  163. package/dist/schemas/developer-dna.json +0 -219
  164. package/dist/schemas/ecosystem-pulse.json +0 -17
  165. package/dist/schemas/get-actionable-signals.json +0 -29
  166. package/dist/schemas/get-context.json +0 -17
  167. package/dist/schemas/get-relevant-content.json +0 -26
  168. package/dist/schemas/knowledge-gaps.json +0 -14
  169. package/dist/schemas/record-feedback.json +0 -24
  170. package/dist/schemas/upgrade-planner.json +0 -22
  171. package/dist/schemas/vulnerability-scan.json +0 -26
  172. package/dist/schemas/what-should-i-know.json +0 -19
@@ -6,9 +6,13 @@
6
6
  * deprecation status, and known vulnerabilities across npm, crates.io,
7
7
  * PyPI, and Go ecosystems. Zero config — auto-detects from lock files.
8
8
  */
9
+ import { isActionableVulnerability } from "../live/maintenance.js";
10
+ import { driftFor } from "./install-drift-notes.js";
11
+ /** How long a health check waits for its own OSV scan before answering without CVE data (the scan keeps running). */
12
+ const HEALTH_SCAN_TIMEOUT_MS = 60_000;
9
13
  export const dependencyHealthTool = {
10
14
  name: "dependency_health",
11
- description: "Assess health of project dependencies — version freshness, deprecation status, known CVEs. Auto-detects stack from lock files. Covers npm, Rust, Python, and Go.",
15
+ description: "Assess health of project dependencies — version freshness, deprecation status, known CVEs. Auto-detects stack from lock files. Covers npm, Rust, Python, and Go. For npm, `installedVersion` appears beside `currentVersion` when node_modules holds a different version than the lockfile.",
12
16
  inputSchema: {
13
17
  type: "object",
14
18
  properties: {
@@ -33,6 +37,19 @@ export const dependencyHealthTool = {
33
37
  },
34
38
  },
35
39
  };
40
+ /** Stamp each npm row with the version node_modules holds, where it differs from the lockfile's. */
41
+ function withInstalledVersions(rows, drift) {
42
+ if (drift.length === 0)
43
+ return rows;
44
+ return rows.map((row) => {
45
+ if (row.ecosystem !== "npm")
46
+ return row;
47
+ const records = driftFor(drift, row.name, row.currentVersion);
48
+ if (records.length === 0)
49
+ return row;
50
+ return { ...row, installedVersion: [...new Set(records.map((r) => r.installedVersion))].join(", ") };
51
+ });
52
+ }
36
53
  export async function executeDependencyHealth(_db, params, liveIntel) {
37
54
  const start = Date.now();
38
55
  if (!liveIntel || !liveIntel.isInitialized()) {
@@ -44,6 +61,7 @@ export async function executeDependencyHealth(_db, params, liveIntel) {
44
61
  outdatedCount: 0,
45
62
  deprecatedCount: 0,
46
63
  vulnerableCount: 0,
64
+ advisoryCount: 0,
47
65
  healthScore: 0,
48
66
  dependencies: [],
49
67
  vulnerabilitySummary: null,
@@ -52,6 +70,9 @@ export async function executeDependencyHealth(_db, params, liveIntel) {
52
70
  cached: false,
53
71
  };
54
72
  }
73
+ // A long-lived server must not answer from the versions it read at startup:
74
+ // re-resolve if a lockfile (or node_modules) changed. Stat calls only.
75
+ liveIntel.refreshIfLockfilesChanged();
55
76
  // Get resolved deps
56
77
  const includeDev = params.include_dev ?? false;
57
78
  let deps = liveIntel.getResolvedDeps();
@@ -63,15 +84,29 @@ export async function executeDependencyHealth(_db, params, liveIntel) {
63
84
  }
64
85
  const ecosystems = [...new Set(deps.map((d) => d.ecosystem))];
65
86
  // Fetch registry health for all deps
66
- const registryData = await liveIntel.fetchRegistryHealth(deps);
67
- // Merge vulnerability data
68
- const vulnResult = liveIntel.getVulnerabilities();
87
+ const registryData = withInstalledVersions(await liveIntel.fetchRegistryHealth(deps), liveIntel.getInstallDrift());
88
+ // Merge vulnerability data. "Vulnerable" means the actionable set — built
89
+ // on this host and not a maintenance notice — which is exactly what
90
+ // vulnerability_scan reports as `total_vulnerable_packages`. Counting every
91
+ // row here made the two tools disagree over one scan (this one said 11
92
+ // vulnerable packages where the scan listed 5 and filed the rest under
93
+ // platform-inactive and maintenance notices). `advisoryCount` keeps the raw
94
+ // row count visible so the filter hides nothing.
95
+ // Loaded here (bounded) when no scan has run: telling the agent to "run
96
+ // vulnerability_scan first" left a session's first health check CVE-blind.
97
+ const root = liveIntel.getProjectRoot() ?? process.cwd();
98
+ const vulnResult = (await liveIntel.ensureVulnerabilities(root, HEALTH_SCAN_TIMEOUT_MS, { includeDev })) ?? liveIntel.getVulnerabilities();
99
+ const allVulns = vulnResult?.vulnerabilities ?? [];
100
+ const activeVulns = allVulns.filter(isActionableVulnerability);
101
+ // Keyed by (ecosystem, name): a bare name let an npm advisory mark a
102
+ // same-named crate as vulnerable, and counted the two as one package.
103
+ const vulnKey = (ecosystem, name) => `${ecosystem}\u0000${name}`;
69
104
  const vulnMap = new Map();
70
- if (vulnResult) {
71
- for (const v of vulnResult.vulnerabilities) {
72
- vulnMap.set(v.package, (vulnMap.get(v.package) || 0) + 1);
73
- }
105
+ for (const v of activeVulns) {
106
+ const key = vulnKey(v.ecosystem, v.package);
107
+ vulnMap.set(key, (vulnMap.get(key) || 0) + 1);
74
108
  }
109
+ const advisoryCount = allVulns.length;
75
110
  // Compute stats
76
111
  let outdated = 0;
77
112
  let deprecated = 0;
@@ -86,6 +121,7 @@ export async function executeDependencyHealth(_db, params, liveIntel) {
86
121
  // breakdown shown beside it. registryData is direct deps only — counting there
87
122
  // undercounts CVEs that live in transitive dependencies.
88
123
  const vulnerable = vulnMap.size;
124
+ const drifted = registryData.filter((d) => d.installedVersion).length;
89
125
  // Sort
90
126
  const sortBy = params.sort_by ?? "risk";
91
127
  const sorted = [...registryData].sort((a, b) => {
@@ -99,7 +135,7 @@ export async function executeDependencyHealth(_db, params, liveIntel) {
99
135
  // risk: vulnerable > deprecated > major outdated > minor > patch > current
100
136
  const riskScore = (dep) => {
101
137
  let score = 0;
102
- if (vulnMap.has(dep.name))
138
+ if (vulnMap.has(vulnKey(dep.ecosystem, dep.name)))
103
139
  score += 1000;
104
140
  if (dep.deprecated)
105
141
  score += 500;
@@ -121,13 +157,13 @@ export async function executeDependencyHealth(_db, params, liveIntel) {
121
157
  // Vulnerabilities: severity-weighted; transitive findings at one-third
122
158
  // weight (their fix arrives via a parent bump — the direct surface is what
123
159
  // the user controls directly).
160
+ // Penalised over the same actionable set the count reports, so a score
161
+ // below 100 always has a vulnerable package beside it.
124
162
  const sevWeight = { critical: 20, high: 10, medium: 4, low: 1 };
125
163
  let vulnPenaltyRaw = 0;
126
- if (vulnResult) {
127
- for (const v of vulnResult.vulnerabilities) {
128
- const w = sevWeight[v.severity] ?? 2; // "unknown" (mostly unmaintained-notices) counts low
129
- vulnPenaltyRaw += v.isDirect ? w : w / 3;
130
- }
164
+ for (const v of activeVulns) {
165
+ const w = sevWeight[v.severity] ?? 2; // "unknown" severity with a real (non-maintenance) advisory
166
+ vulnPenaltyRaw += v.isDirect ? w : w / 3;
131
167
  }
132
168
  const vulnPenalty = Math.min(Math.round(vulnPenaltyRaw), 55);
133
169
  // Staleness: degree-weighted and proportional to the tracked set — a major
@@ -145,12 +181,14 @@ export async function executeDependencyHealth(_db, params, liveIntel) {
145
181
  const outdatedPenalty = Math.min(30, Math.round((stalenessWeight / total) * 60));
146
182
  const deprecatedPenalty = Math.min(deprecated * 8, 15);
147
183
  const healthScore = Math.max(0, 100 - vulnPenalty - deprecatedPenalty - outdatedPenalty);
148
- const vulnSummary = vulnResult ? {
149
- critical: vulnResult.bySeverity.critical,
150
- high: vulnResult.bySeverity.high,
151
- medium: vulnResult.bySeverity.medium,
152
- low: vulnResult.bySeverity.low,
153
- } : null;
184
+ // Severity breakdown over the actionable set, matching the count above.
185
+ const vulnSummary = vulnResult ? { critical: 0, high: 0, medium: 0, low: 0 } : null;
186
+ if (vulnSummary) {
187
+ for (const v of activeVulns) {
188
+ if (v.severity in vulnSummary)
189
+ vulnSummary[v.severity]++;
190
+ }
191
+ }
154
192
  // Summary
155
193
  const parts = [];
156
194
  parts.push(`${total} dependenc${total !== 1 ? "ies" : "y"} scanned`);
@@ -160,8 +198,22 @@ export async function executeDependencyHealth(_db, params, liveIntel) {
160
198
  parts.push(`${deprecated} deprecated`);
161
199
  if (outdated > 0)
162
200
  parts.push(`${outdated} outdated`);
163
- if (vulnerable === 0 && deprecated === 0 && outdated === 0)
201
+ if (drifted > 0) {
202
+ parts.push(`${drifted} installed at a different version than the lockfile pins (see installedVersion; vulnerability_scan lists install_drift)`);
203
+ }
204
+ // "All healthy" is a claim about CVEs too; it needs a scan behind it.
205
+ if (vulnResult && vulnerable === 0 && deprecated === 0 && outdated === 0 && drifted === 0) {
164
206
  parts.push("all healthy");
207
+ }
208
+ const hidden = advisoryCount - activeVulns.length;
209
+ if (hidden > 0) {
210
+ parts.push(`${hidden} advisor${hidden !== 1 ? "ies" : "y"} not counted (platform-inactive or maintenance notices)`);
211
+ }
212
+ if (!vulnResult) {
213
+ parts.push(liveIntel.isEnabled()
214
+ ? `CVE data not loaded: the OSV scan did not finish within ${HEALTH_SCAN_TIMEOUT_MS / 1000}s or OSV was unreachable. Call again to use it once it completes`
215
+ : "CVE data not loaded: offline mode (FOURDA_OFFLINE)");
216
+ }
165
217
  return {
166
218
  scannedAt: new Date().toISOString(),
167
219
  projectPath: liveIntel.getProjectRoot() ?? process.cwd(),
@@ -170,6 +222,7 @@ export async function executeDependencyHealth(_db, params, liveIntel) {
170
222
  outdatedCount: outdated,
171
223
  deprecatedCount: deprecated,
172
224
  vulnerableCount: vulnerable,
225
+ advisoryCount,
173
226
  healthScore,
174
227
  dependencies: limited,
175
228
  vulnerabilitySummary: vulnSummary,
@@ -36,7 +36,10 @@ export declare const developerDnaTool: {
36
36
  };
37
37
  interface DependencyEntry {
38
38
  name: string;
39
+ /** One project that declares it. */
39
40
  project_path: string;
41
+ /** How many of the user's projects declare it. */
42
+ projects: number;
40
43
  }
41
44
  interface EngagedTopic {
42
45
  topic: string;
@@ -77,13 +77,20 @@ export function executeDeveloperDna(db, params) {
77
77
  // 3. Top Dependencies — from project_dependencies table
78
78
  // -------------------------------------------------------------------------
79
79
  let topDependencies = [];
80
+ /** How many of the user's projects declare each dependency (lowercased name). */
81
+ const projectCounts = new Map();
80
82
  try {
83
+ // "Top" means most used: declared by the most projects. It meant the
84
+ // first N alphabetically ("@11ty/eleventy..." led the live profile).
81
85
  const depRows = rawDb
82
- .prepare(`SELECT package_name, project_path
86
+ .prepare(`SELECT package_name, MIN(project_path) AS project_path, COUNT(DISTINCT project_path) AS projects
83
87
  FROM project_dependencies
84
- ORDER BY package_name
88
+ GROUP BY LOWER(package_name)
89
+ ORDER BY projects DESC, package_name
85
90
  LIMIT ?`)
86
- .all(maxDependencies);
91
+ .all(maxDependencies * 3);
92
+ for (const row of depRows)
93
+ projectCounts.set(row.package_name.toLowerCase(), row.projects);
87
94
  // Filter out invalid package names (template literals, path aliases, non-packages)
88
95
  const isValidPackageName = (name) => {
89
96
  if (!name)
@@ -104,19 +111,12 @@ export function executeDeveloperDna(db, params) {
104
111
  };
105
112
  topDependencies = depRows
106
113
  .filter((r) => isValidPackageName(r.package_name))
114
+ .slice(0, maxDependencies)
107
115
  .map((r) => ({
108
116
  name: r.package_name,
109
117
  project_path: r.project_path,
118
+ projects: r.projects,
110
119
  }));
111
- // Deduplicate (case-insensitive)
112
- const seen = new Set();
113
- topDependencies = topDependencies.filter(d => {
114
- const key = d.name.toLowerCase();
115
- if (seen.has(key))
116
- return false;
117
- seen.add(key);
118
- return true;
119
- });
120
120
  }
121
121
  catch {
122
122
  // table may not exist
@@ -217,9 +217,10 @@ export function executeDeveloperDna(db, params) {
217
217
  });
218
218
  }
219
219
  }
220
- else {
221
- // Never interacted with content about this dependency — null, not
222
- // a -1 sentinel leaking into user-facing output.
220
+ else if ((projectCounts.get(depLower) ?? 0) >= 3) {
221
+ // Never read about a dependency three or more projects rely on.
222
+ // Every never-read dependency used to be listed, so the section
223
+ // was the whole dependency list and said nothing.
223
224
  blindSpots.push({
224
225
  dependency: dep.name,
225
226
  severity: "low",
@@ -44,8 +44,12 @@ export declare const ecosystemPulseTool: {
44
44
  };
45
45
  };
46
46
  /**
47
- * Derive the tech stack used to filter HN headlines from the database:
48
- * explicit tech_stack entries plus the languages of tracked dependencies.
47
+ * The terms HN headlines are searched and matched on: the frameworks the
48
+ * project uses and its most-used direct dependencies — names specific enough
49
+ * that a headline naming one is about something this project runs. Language
50
+ * names are the fallback only: matched on "rust", the pulse was "What Zig felt
51
+ * like, coming from Rust" and an Emacs fork (live 2026-10-01). Package names
52
+ * that are everyday words are left out (they match unrelated titles).
49
53
  * Works on both the desktop DB and the standalone minimal schema.
50
54
  */
51
55
  export declare function deriveTechStackForHeadlines(db: FourDADatabase): string[];
@@ -6,6 +6,7 @@
6
6
  * Data is already fetched on server startup from Hacker News via Algolia API.
7
7
  * This tool makes it queryable.
8
8
  */
9
+ import { COMMON_WORDS } from "./briefing-task-scope.js";
9
10
  export const ecosystemPulseTool = {
10
11
  name: "ecosystem_pulse",
11
12
  description: "Live ecosystem news relevant to your tech stack. Surfaces trending Hacker News discussions filtered by your detected technologies. Updated on server startup.",
@@ -23,34 +24,72 @@ export const ecosystemPulseTool = {
23
24
  },
24
25
  },
25
26
  };
27
+ /** Languages: matching a headline on one is matching the whole language's news. */
28
+ const LANGUAGES = new Set(["rust", "javascript", "typescript", "python", "go", "golang", "java", "kotlin", "ruby", "php", "c", "c++", "c#", "swift", "dart", "npm"]);
29
+ const MAX_TERMS = 8;
26
30
  /**
27
- * Derive the tech stack used to filter HN headlines from the database:
28
- * explicit tech_stack entries plus the languages of tracked dependencies.
31
+ * The terms HN headlines are searched and matched on: the frameworks the
32
+ * project uses and its most-used direct dependencies — names specific enough
33
+ * that a headline naming one is about something this project runs. Language
34
+ * names are the fallback only: matched on "rust", the pulse was "What Zig felt
35
+ * like, coming from Rust" and an Emacs fork (live 2026-10-01). Package names
36
+ * that are everyday words are left out (they match unrelated titles).
29
37
  * Works on both the desktop DB and the standalone minimal schema.
30
38
  */
31
39
  export function deriveTechStackForHeadlines(db) {
32
- const stack = new Set();
40
+ const terms = [];
41
+ const add = (term) => {
42
+ const t = (term ?? "").toLowerCase().trim();
43
+ if (t.length >= 3 && !LANGUAGES.has(t) && !COMMON_WORDS.has(t) && !terms.includes(t) && !t.includes("/"))
44
+ terms.push(t);
45
+ };
33
46
  const rawDb = db.getRawDb();
34
47
  try {
35
- for (const row of rawDb.prepare("SELECT technology FROM tech_stack").all()) {
36
- if (row.technology)
37
- stack.add(row.technology.toLowerCase());
48
+ for (const row of rawDb
49
+ .prepare("SELECT name FROM detected_tech WHERE category IN ('framework', 'library') ORDER BY confidence DESC LIMIT 20")
50
+ .all()) {
51
+ add(row.name);
52
+ }
53
+ }
54
+ catch {
55
+ // detected_tech may not exist on exotic DBs.
56
+ }
57
+ try {
58
+ const direct = db.hasColumn("project_dependencies", "is_direct") ? "WHERE is_direct = 1" : "";
59
+ for (const row of rawDb
60
+ .prepare(`SELECT package_name FROM project_dependencies ${direct}
61
+ GROUP BY LOWER(package_name) ORDER BY COUNT(DISTINCT project_path) DESC, package_name LIMIT 40`)
62
+ .all()) {
63
+ if (terms.length >= MAX_TERMS)
64
+ break;
65
+ add(row.package_name);
38
66
  }
39
67
  }
40
68
  catch {
41
- // tech_stack may not exist on exotic DBs — languages below still apply.
69
+ // project_dependencies may not exist.
42
70
  }
71
+ // Then the project's languages, as language-level news (labelled so by the
72
+ // fetcher): HN titles rarely name a library in a two-week window, and an
73
+ // asked-for ecosystem pulse should still say what moved in the language.
74
+ const specific = terms.slice(0, MAX_TERMS - 2);
75
+ const languages = [];
43
76
  try {
44
77
  for (const row of rawDb.prepare("SELECT DISTINCT language FROM project_dependencies").all()) {
45
- if (row.language)
46
- stack.add(row.language.toLowerCase());
78
+ const term = (row.language ?? "").toLowerCase();
79
+ if (term && PULSE_LANGUAGES.has(term) && !languages.includes(term))
80
+ languages.push(term);
47
81
  }
48
82
  }
49
83
  catch {
50
- // project_dependencies may not exist — an empty stack yields an honest empty result.
84
+ // An empty stack yields an honest empty result.
51
85
  }
52
- return [...stack];
86
+ return [...specific, ...languages.slice(0, MAX_TERMS - specific.length)];
53
87
  }
88
+ /**
89
+ * Languages with distinctive enough names to search HN for: not
90
+ * "javascript"/"typescript" (they flood), not "go" (an everyday word in titles).
91
+ */
92
+ const PULSE_LANGUAGES = new Set(["rust", "python", "zig", "kotlin", "swift", "ruby", "elixir"]);
54
93
  export async function executeEcosystemPulse(db, params, liveIntel) {
55
94
  if (!liveIntel) {
56
95
  return {
@@ -60,9 +99,9 @@ export async function executeEcosystemPulse(db, params, liveIntel) {
60
99
  note: "Live intelligence not available. Set FOURDA_OFFLINE=false and restart.",
61
100
  };
62
101
  }
63
- // The startup prefetch only ever ran in standalone mode, so full-DB servers
64
- // returned an empty cache forever. Fetch on demand when the cache is empty —
65
- // the cache still serves warm repeat calls.
102
+ // Fetched when asked for, never at startup: the search terms are names from
103
+ // the user's projects, and HN should see them only when the user asks for
104
+ // the pulse. The cache serves warm repeat calls.
66
105
  let headlines = liveIntel.getHeadlines();
67
106
  if (headlines.length === 0 && liveIntel.isEnabled()) {
68
107
  const techStack = deriveTechStackForHeadlines(db);
@@ -6,8 +6,10 @@
6
6
  * Cross-references against the user's ACE-detected tech stack.
7
7
  */
8
8
  import type { FourDADatabase } from "../db.js";
9
- type SignalType = "security_alert" | "breaking_change" | "tool_discovery" | "tech_trend" | "learning" | "competitive_intel";
10
- type SignalPriority = "critical" | "high" | "medium" | "low";
9
+ import type { LiveIntelligence } from "../live/index.js";
10
+ import type { VulnerabilityEntry } from "../live/types.js";
11
+ import { type SignalPriority, type SignalType } from "./signal-classifier.js";
12
+ export { normalizeStoredPriority, type SignalPriority, type SignalType } from "./signal-classifier.js";
11
13
  interface ClassifiedSignal {
12
14
  id: number;
13
15
  title: string;
@@ -56,9 +58,29 @@ export interface GetActionableSignalsParams {
56
58
  limit?: number;
57
59
  since_hours?: number;
58
60
  }
59
- export declare function executeGetActionableSignals(db: FourDADatabase, params: GetActionableSignalsParams): {
61
+ /** The slice of the live layer this tool reads; a stub suffices in tests. */
62
+ export type SignalsLiveIntel = Pick<LiveIntelligence, "getVulnerabilities">;
63
+ /** One signal's worth of live findings: the entry to report and every id it is known by. */
64
+ export interface VulnerabilityCluster {
65
+ representative: VulnerabilityEntry;
66
+ ids: string[];
67
+ }
68
+ /**
69
+ * Group scan entries that are the same vulnerability under different ids.
70
+ *
71
+ * OSV returns a GHSA record and a RUSTSEC (or CVE) record for one bug, each
72
+ * naming the other in `aliases`; injected one-per-record they read as two
73
+ * problems and doubled the advisory count in every briefing. Union-find over
74
+ * `[vulnId, ...aliases]` connects the records transitively (A knows B, B knows
75
+ * C), scoped to the affected (ecosystem, package, version) so one advisory
76
+ * against two packages — or two pinned versions of one package — stays two
77
+ * things to fix. The representative is the GHSA record when the cluster has
78
+ * one (the richest and most stable id), else the first seen; `ids` lists
79
+ * every identifier in first-seen order.
80
+ */
81
+ export declare function clusterVulnerabilities(vulns: VulnerabilityEntry[]): VulnerabilityCluster[];
82
+ export declare function executeGetActionableSignals(db: FourDADatabase, params: GetActionableSignalsParams, liveIntel?: SignalsLiveIntel | null): {
60
83
  signals: ClassifiedSignal[];
61
84
  total: number;
62
85
  summary: Record<string, number>;
63
86
  };
64
- export {};