@4da/mcp-server 4.6.1 → 4.6.3

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 (139) hide show
  1. package/README.md +3 -1
  2. package/dist/db.d.ts +21 -2
  3. package/dist/db.js +66 -16
  4. package/dist/doctor.js +5 -3
  5. package/dist/embeddings.d.ts +88 -0
  6. package/dist/embeddings.js +292 -0
  7. package/dist/index.d.ts +9 -9
  8. package/dist/index.js +21 -15
  9. package/dist/live/cvss.d.ts +16 -0
  10. package/dist/live/cvss.js +62 -0
  11. package/dist/live/index.d.ts +9 -1
  12. package/dist/live/index.js +40 -3
  13. package/dist/live/npm-registry.js +29 -6
  14. package/dist/live/osv-scanner.d.ts +8 -0
  15. package/dist/live/osv-scanner.js +115 -30
  16. package/dist/live/platform.d.ts +32 -0
  17. package/dist/live/platform.js +156 -0
  18. package/dist/live/semver-utils.d.ts +8 -0
  19. package/dist/live/semver-utils.js +24 -0
  20. package/dist/live/types.d.ts +31 -9
  21. package/dist/live/version-resolver.d.ts +12 -2
  22. package/dist/live/version-resolver.js +63 -20
  23. package/dist/llm.js +1 -1
  24. package/dist/project-scanner.d.ts +2 -0
  25. package/dist/project-scanner.js +34 -0
  26. package/dist/scheduler/core.d.ts +25 -0
  27. package/dist/scheduler/core.js +61 -0
  28. package/dist/scheduler/freshness.d.ts +17 -0
  29. package/dist/scheduler/freshness.js +41 -0
  30. package/dist/scheduler/index.d.ts +21 -0
  31. package/dist/scheduler/index.js +32 -0
  32. package/dist/scheduler/refreshers.d.ts +21 -0
  33. package/dist/scheduler/refreshers.js +21 -0
  34. package/dist/schemas/get-relevant-content.json +1 -1
  35. package/dist/schemas/upgrade-planner.json +1 -1
  36. package/dist/schemas/what-should-i-know.json +1 -1
  37. package/dist/tools/agent-memory.d.ts +11 -1
  38. package/dist/tools/agent-memory.js +120 -14
  39. package/dist/tools/decision-enforcement.d.ts +17 -1
  40. package/dist/tools/decision-enforcement.js +109 -81
  41. package/dist/tools/decision-memory.d.ts +1 -1
  42. package/dist/tools/decision-memory.js +58 -28
  43. package/dist/tools/decision-recall.d.ts +59 -0
  44. package/dist/tools/decision-recall.js +87 -0
  45. package/dist/tools/dependency-health.js +6 -4
  46. package/dist/tools/developer-dna.js +4 -2
  47. package/dist/tools/knowledge-gaps.js +16 -4
  48. package/dist/tools/recall.d.ts +33 -0
  49. package/dist/tools/recall.js +134 -0
  50. package/dist/tools/upgrade-planner.d.ts +25 -1
  51. package/dist/tools/upgrade-planner.js +111 -30
  52. package/dist/tools/vulnerability-scan.d.ts +46 -0
  53. package/dist/tools/vulnerability-scan.js +64 -24
  54. package/dist/tools/what-should-i-know.d.ts +3 -1
  55. package/dist/tools/what-should-i-know.js +210 -106
  56. package/dist/types.d.ts +9 -0
  57. package/package.json +8 -3
  58. package/dist/auth.d.ts.map +0 -1
  59. package/dist/auth.js.map +0 -1
  60. package/dist/db.d.ts.map +0 -1
  61. package/dist/db.js.map +0 -1
  62. package/dist/doctor.d.ts.map +0 -1
  63. package/dist/doctor.js.map +0 -1
  64. package/dist/http-transport.d.ts.map +0 -1
  65. package/dist/http-transport.js.map +0 -1
  66. package/dist/index.d.ts.map +0 -1
  67. package/dist/index.js.map +0 -1
  68. package/dist/live/cache.d.ts.map +0 -1
  69. package/dist/live/cache.js.map +0 -1
  70. package/dist/live/crates-registry.d.ts.map +0 -1
  71. package/dist/live/crates-registry.js.map +0 -1
  72. package/dist/live/go-registry.d.ts.map +0 -1
  73. package/dist/live/go-registry.js.map +0 -1
  74. package/dist/live/hn-fetcher.d.ts.map +0 -1
  75. package/dist/live/hn-fetcher.js.map +0 -1
  76. package/dist/live/http-utils.d.ts.map +0 -1
  77. package/dist/live/http-utils.js.map +0 -1
  78. package/dist/live/index.d.ts.map +0 -1
  79. package/dist/live/index.js.map +0 -1
  80. package/dist/live/npm-registry.d.ts.map +0 -1
  81. package/dist/live/npm-registry.js.map +0 -1
  82. package/dist/live/osv-scanner.d.ts.map +0 -1
  83. package/dist/live/osv-scanner.js.map +0 -1
  84. package/dist/live/pypi-registry.d.ts.map +0 -1
  85. package/dist/live/pypi-registry.js.map +0 -1
  86. package/dist/live/rate-limiter.d.ts.map +0 -1
  87. package/dist/live/rate-limiter.js.map +0 -1
  88. package/dist/live/semver-utils.d.ts.map +0 -1
  89. package/dist/live/semver-utils.js.map +0 -1
  90. package/dist/live/types.d.ts.map +0 -1
  91. package/dist/live/types.js.map +0 -1
  92. package/dist/live/version-resolver.d.ts.map +0 -1
  93. package/dist/live/version-resolver.js.map +0 -1
  94. package/dist/live-singleton.d.ts.map +0 -1
  95. package/dist/live-singleton.js.map +0 -1
  96. package/dist/llm.d.ts.map +0 -1
  97. package/dist/llm.js.map +0 -1
  98. package/dist/output-manager.d.ts.map +0 -1
  99. package/dist/output-manager.js.map +0 -1
  100. package/dist/project-scanner.d.ts.map +0 -1
  101. package/dist/project-scanner.js.map +0 -1
  102. package/dist/schema-registry.d.ts.map +0 -1
  103. package/dist/schema-registry.js.map +0 -1
  104. package/dist/setup.d.ts.map +0 -1
  105. package/dist/setup.js.map +0 -1
  106. package/dist/tool-dispatch.d.ts.map +0 -1
  107. package/dist/tool-dispatch.js.map +0 -1
  108. package/dist/tools/agent-memory.d.ts.map +0 -1
  109. package/dist/tools/agent-memory.js.map +0 -1
  110. package/dist/tools/decision-enforcement.d.ts.map +0 -1
  111. package/dist/tools/decision-enforcement.js.map +0 -1
  112. package/dist/tools/decision-memory.d.ts.map +0 -1
  113. package/dist/tools/decision-memory.js.map +0 -1
  114. package/dist/tools/dependency-health.d.ts.map +0 -1
  115. package/dist/tools/dependency-health.js.map +0 -1
  116. package/dist/tools/developer-dna.d.ts.map +0 -1
  117. package/dist/tools/developer-dna.js.map +0 -1
  118. package/dist/tools/ecosystem-pulse.d.ts.map +0 -1
  119. package/dist/tools/ecosystem-pulse.js.map +0 -1
  120. package/dist/tools/get-actionable-signals.d.ts.map +0 -1
  121. package/dist/tools/get-actionable-signals.js.map +0 -1
  122. package/dist/tools/get-context.d.ts.map +0 -1
  123. package/dist/tools/get-context.js.map +0 -1
  124. package/dist/tools/get-relevant-content.d.ts.map +0 -1
  125. package/dist/tools/get-relevant-content.js.map +0 -1
  126. package/dist/tools/index.d.ts.map +0 -1
  127. package/dist/tools/index.js.map +0 -1
  128. package/dist/tools/knowledge-gaps.d.ts.map +0 -1
  129. package/dist/tools/knowledge-gaps.js.map +0 -1
  130. package/dist/tools/record-feedback.d.ts.map +0 -1
  131. package/dist/tools/record-feedback.js.map +0 -1
  132. package/dist/tools/upgrade-planner.d.ts.map +0 -1
  133. package/dist/tools/upgrade-planner.js.map +0 -1
  134. package/dist/tools/vulnerability-scan.d.ts.map +0 -1
  135. package/dist/tools/vulnerability-scan.js.map +0 -1
  136. package/dist/tools/what-should-i-know.d.ts.map +0 -1
  137. package/dist/tools/what-should-i-know.js.map +0 -1
  138. package/dist/types.d.ts.map +0 -1
  139. package/dist/types.js.map +0 -1
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Scheduler composition (S4) — wire core + freshness + refreshers into one switchable unit.
3
+ *
4
+ * The whole scheduler is OFF unless explicitly enabled (fail-safe: an MCP server must never
5
+ * grow background network activity by surprise). Enabled, it registers every refresh unit on
6
+ * the cadence engine, starts the timers, and exposes a status view the server (or a future
7
+ * `scheduler_status` tool) can render: every unit, its cadence, and when it last ran.
8
+ */
9
+ import { type SchedulerJobView } from "./core.js";
10
+ export interface InitSchedulerOpts {
11
+ enabled: boolean;
12
+ pull: Record<string, () => Promise<void>>;
13
+ freshnessPath?: string;
14
+ }
15
+ export interface RunningScheduler {
16
+ started: boolean;
17
+ status(): SchedulerJobView[];
18
+ stop(): void;
19
+ }
20
+ export declare function initScheduler(opts: InitSchedulerOpts): RunningScheduler;
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Scheduler composition (S4) — wire core + freshness + refreshers into one switchable unit.
3
+ *
4
+ * The whole scheduler is OFF unless explicitly enabled (fail-safe: an MCP server must never
5
+ * grow background network activity by surprise). Enabled, it registers every refresh unit on
6
+ * the cadence engine, starts the timers, and exposes a status view the server (or a future
7
+ * `scheduler_status` tool) can render: every unit, its cadence, and when it last ran.
8
+ */
9
+ import { createScheduler } from "./core.js";
10
+ import { createFreshness } from "./freshness.js";
11
+ import { buildRefreshers } from "./refreshers.js";
12
+ export function initScheduler(opts) {
13
+ if (!opts.enabled) {
14
+ return { started: false, status: () => [], stop: () => { } };
15
+ }
16
+ const freshness = createFreshness(opts.freshnessPath);
17
+ freshness.load();
18
+ const scheduler = createScheduler();
19
+ for (const unit of buildRefreshers({ pull: opts.pull, freshness })) {
20
+ scheduler.register(unit.name, unit.intervalMs, () => unit.run());
21
+ }
22
+ scheduler.start();
23
+ return {
24
+ started: true,
25
+ status: () => scheduler.jobs(),
26
+ stop: () => {
27
+ scheduler.stop();
28
+ freshness.save();
29
+ },
30
+ };
31
+ }
32
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Refresh adapters (S3) — wrap each named data pull into a schedulable refresh unit.
3
+ *
4
+ * Pure dependency injection: the pulls and the freshness sink are handed in, nothing is
5
+ * imported from the fetcher layer and nothing touches the network here. A refresh unit runs
6
+ * its pull and only THEN marks freshness — a failing pull rethrows and leaves the watermark
7
+ * untouched, so staleness stays honest.
8
+ */
9
+ export interface RefreshDeps {
10
+ pull: Record<string, () => Promise<void>>;
11
+ freshness: {
12
+ markFresh(name: string, at?: number): void;
13
+ };
14
+ }
15
+ export interface RefreshUnit {
16
+ name: string;
17
+ intervalMs: number;
18
+ run(): Promise<void>;
19
+ }
20
+ export declare function buildRefreshers(deps: RefreshDeps): RefreshUnit[];
21
+ //# sourceMappingURL=refreshers.d.ts.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Refresh adapters (S3) — wrap each named data pull into a schedulable refresh unit.
3
+ *
4
+ * Pure dependency injection: the pulls and the freshness sink are handed in, nothing is
5
+ * imported from the fetcher layer and nothing touches the network here. A refresh unit runs
6
+ * its pull and only THEN marks freshness — a failing pull rethrows and leaves the watermark
7
+ * untouched, so staleness stays honest.
8
+ */
9
+ /** Default cadence per refresh unit; callers can re-register with their own interval. */
10
+ const DEFAULT_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes — fresh enough for feed data, kind to upstreams
11
+ export function buildRefreshers(deps) {
12
+ return Object.entries(deps.pull).map(([name, pull]) => ({
13
+ name,
14
+ intervalMs: DEFAULT_INTERVAL_MS,
15
+ async run() {
16
+ await pull(); // a throw propagates — freshness must NOT advance on a failed pull
17
+ deps.freshness.markFresh(name);
18
+ },
19
+ }));
20
+ }
21
+ //# sourceMappingURL=refreshers.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "get_relevant_content",
3
- "description": "Get filtered relevant content from 4DA's personalized feed.\n\nReturns content items that match the user's interests, tech stack, and learned preferences.\nItems are scored based on:\n- Explicit interests declared by the user\n- Tech stack and domains\n- ACE-detected context (recent files, projects, git activity)\n- Learned preferences from past interactions\n\nCall when the user asks what to read, what is relevant to them, or for content on a topic.",
3
+ "description": "Get filtered relevant content from 4DA's personalized feed.\n\nReturns content items that match the user's interests, tech stack, and learned preferences.\nItems are scored based on:\n- Explicit interests declared by the user\n- Tech stack and domains\n- ACE-detected context (recent files, projects, git activity)\n- Learned preferences from past interactions\n\nIMPORTANT for autonomous use: each item carries an `evidence_class` describing how it is grounded. The relevance_score float is NOT comparable across classes — gate actions on evidence_class, not the number:\n- \"osv_verified\": a security advisory matching one of the user's INSTALLED dependencies — safe to act on\n- \"dependency_grounded\": matched one of the user's declared dependencies — trustworthy\n- \"semantic_only\": topical/embedding relevance with NO dependency edge — surface to the human, verify before acting\n- \"keyword_heuristic\": standalone-mode keyword score (no Rust pipeline) — weakest signal\n\nCall when the user asks what to read, what is relevant to them, or for content on a topic.",
4
4
  "inputSchema": {
5
5
  "type": "object",
6
6
  "properties": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "upgrade_planner",
3
- "description": "Ranked upgrade recommendations for project dependencies. Combines vulnerability severity, deprecation status, and version distance to produce a prioritized upgrade plan.\n\nShows quick wins (safe patch/minor upgrades) separately from breaking changes (major version bumps). Call after dependency_health to plan your upgrade strategy.\n\nPrivacy: uses cached registry data. No additional network calls beyond what dependency_health already fetched.",
3
+ "description": "Ranked standalone upgrade recommendations for project dependencies — call before upgrading, adding, or auditing any dependency to pick the safest order. Combines vulnerability severity, deprecation status, and version distance into a prioritized plan.\n\nSplits recommendations three ways: quick wins (safe patch/minor upgrades), breaking changes (major version bumps), and transitive CVEs marked waiting_on_upstream (not declared in your manifest — the fix arrives via a parent package update or a lockfile refresh). Call dependency_health first for registry context and vulnerability_scan first for a CVE-aware plan; without a scan the output says it is CVE-blind.\n\nProvenance: standalone heuristic — a point-in-time plan from local lockfiles plus live registry/OSV lookups, not the 4DA desktop app's cross-project, version-confirmed plan.\n\nPrivacy: live mode may query public registries and OSV with package names and versions. It never sends source code.",
4
4
  "inputSchema": {
5
5
  "type": "object",
6
6
  "properties": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what_should_i_know",
3
- "description": "Pre-task intelligence briefing. Given a task description and optional file paths, returns filtered advisories (security alerts, breaking changes), active decision windows, signal chains, relevant past decisions/memories, and a delegation assessment. Call this before starting any non-trivial task to get caught up on what matters.",
3
+ "description": "Pre-task intelligence briefing. Given a task description and optional file paths, returns filtered advisories (security alerts, breaking changes), active decision windows, signal chains, relevant past decisions/memories, and a delegation assessment. Call this before starting any non-trivial task to get caught up on what matters. If the task involves upgrading, adding, or auditing dependencies, follow with upgrade_planner for the ranked plan.",
4
4
  "inputSchema": {
5
5
  "type": "object",
6
6
  "properties": {
@@ -90,5 +90,15 @@ export declare const agentMemoryTool: {
90
90
  required: string[];
91
91
  };
92
92
  };
93
- export declare function executeAgentMemory(db: FourDADatabase, params: AgentMemoryParams): object;
93
+ /**
94
+ * The text embedded for a memory. Exported so every surface that embeds a memory
95
+ * row (this tool AND the what_should_i_know briefing) uses IDENTICAL text —
96
+ * otherwise they would write conflicting vectors into the shared
97
+ * agent_memory.embedding column.
98
+ */
99
+ export declare function memoryEmbedText(row: {
100
+ subject: string;
101
+ content: string;
102
+ }): string;
103
+ export declare function executeAgentMemory(db: FourDADatabase, params: AgentMemoryParams): object | Promise<object>;
94
104
  //# sourceMappingURL=agent-memory.d.ts.map
@@ -5,6 +5,8 @@
5
5
  * Cross-agent persistent memory. What Claude Code learns, Cursor can access.
6
6
  * Enables AI agents to store and recall memories across sessions and tools.
7
7
  */
8
+ import { rankRowsByRecall } from "./recall.js";
9
+ import { getEmbeddingConfig, semanticScores, } from "../embeddings.js";
8
10
  // ============================================================================
9
11
  // Tool Definition
10
12
  // ============================================================================
@@ -81,6 +83,24 @@ export const agentMemoryTool = {
81
83
  const MAX_CONTENT_BYTES = 10 * 1024;
82
84
  /** Maximum entries per agent */
83
85
  const MAX_ENTRIES_PER_AGENT = 1000;
86
+ /** Weighted text fields for lexical recall ranking (shared by lexical + hybrid paths). */
87
+ const MEMORY_RECALL_FIELDS = [
88
+ { name: "subject", weight: 4, value: (row) => row.subject },
89
+ { name: "tags", weight: 3, value: (row) => row.context_tags },
90
+ { name: "content", weight: 2, value: (row) => row.content },
91
+ { name: "type", weight: 1, value: (row) => row.memory_type },
92
+ ];
93
+ /** Blend weight for semantic cosine vs normalized lexical score in hybrid recall. */
94
+ const SEMANTIC_BLEND = 0.65;
95
+ /**
96
+ * The text embedded for a memory. Exported so every surface that embeds a memory
97
+ * row (this tool AND the what_should_i_know briefing) uses IDENTICAL text —
98
+ * otherwise they would write conflicting vectors into the shared
99
+ * agent_memory.embedding column.
100
+ */
101
+ export function memoryEmbedText(row) {
102
+ return `${row.subject}\n${row.content}`;
103
+ }
84
104
  // ============================================================================
85
105
  // Helpers
86
106
  // ============================================================================
@@ -92,12 +112,21 @@ function parseMemoryRow(row) {
92
112
  memory_type: row.memory_type,
93
113
  subject: row.subject,
94
114
  content: row.content,
95
- context_tags: JSON.parse(row.context_tags || "[]"),
115
+ context_tags: parseStringList(row.context_tags),
96
116
  created_at: row.created_at,
97
117
  expires_at: row.expires_at,
98
118
  promoted_to_decision_id: row.promoted_to_decision_id,
99
119
  };
100
120
  }
121
+ function parseStringList(json) {
122
+ try {
123
+ const parsed = JSON.parse(json || "[]");
124
+ return Array.isArray(parsed) ? parsed.map(String) : [];
125
+ }
126
+ catch {
127
+ return [];
128
+ }
129
+ }
101
130
  // ============================================================================
102
131
  // Execute
103
132
  // ============================================================================
@@ -161,24 +190,28 @@ export function executeAgentMemory(db, params) {
161
190
  return { error: "query is required for recall action" };
162
191
  }
163
192
  try {
164
- const pattern = `%${params.query.toLowerCase()}%`;
165
193
  let sql = `SELECT id, session_id, agent_type, memory_type, subject, content,
166
194
  context_tags, created_at, expires_at, promoted_to_decision_id
167
195
  FROM agent_memory
168
- WHERE (LOWER(subject) LIKE ? OR LOWER(context_tags) LIKE ?)
169
- AND (expires_at IS NULL OR expires_at > datetime('now'))`;
170
- const sqlParams = [pattern, pattern];
196
+ WHERE (expires_at IS NULL OR expires_at > datetime('now'))`;
197
+ const sqlParams = [];
171
198
  if (params.filter_agent) {
172
199
  sql += ` AND agent_type = ?`;
173
200
  sqlParams.push(params.filter_agent);
174
201
  }
175
202
  sql += ` ORDER BY created_at DESC LIMIT ?`;
176
- sqlParams.push(params.limit || 20);
203
+ sqlParams.push(MAX_ENTRIES_PER_AGENT);
177
204
  const rows = rawDb.prepare(sql).all(...sqlParams);
178
- return {
179
- memories: rows.map(parseMemoryRow),
180
- count: rows.length,
181
- };
205
+ const limit = params.limit || 20;
206
+ // Optional semantic recall — only when an embedding provider is configured.
207
+ // Returns a Promise (dispatch awaits uniformly); every other path stays
208
+ // synchronous, so callers without a provider behave exactly as before.
209
+ const embedConfig = getEmbeddingConfig();
210
+ if (embedConfig) {
211
+ return enhanceRecallWithSemantics(db, params.query, rows, limit, embedConfig);
212
+ }
213
+ const ranked = rankRowsByRecall(rows, params.query, MEMORY_RECALL_FIELDS, limit);
214
+ return lexicalRecallResult(ranked, rows.length);
182
215
  }
183
216
  catch (error) {
184
217
  return {
@@ -196,7 +229,7 @@ export function executeAgentMemory(db, params) {
196
229
  return { error: "tags array is required for recall_by_tags action" };
197
230
  }
198
231
  try {
199
- // Build OR conditions for each tag
232
+ // Prefilter to entries whose tags literally contain ANY requested tag...
200
233
  const conditions = params.tags.map(() => `LOWER(context_tags) LIKE ?`);
201
234
  const tagParams = params.tags.map((t) => `%${t.toLowerCase()}%`);
202
235
  const sql = `SELECT id, session_id, agent_type, memory_type, subject, content,
@@ -207,10 +240,23 @@ export function executeAgentMemory(db, params) {
207
240
  ORDER BY created_at DESC LIMIT ?`;
208
241
  const rows = rawDb
209
242
  .prepare(sql)
210
- .all(...tagParams, params.limit || 20);
243
+ .all(...tagParams, MAX_ENTRIES_PER_AGENT);
244
+ // ...then rank by relevance so the strongest tag/subject/content matches
245
+ // lead, returning the same shape as `recall` (matched_fields, recall_score).
246
+ const ranked = rankRowsByRecall(rows, params.tags.join(" "), [
247
+ { name: "tags", weight: 4, value: (row) => row.context_tags },
248
+ { name: "subject", weight: 2, value: (row) => row.subject },
249
+ { name: "content", weight: 1, value: (row) => row.content },
250
+ ], params.limit || 20);
211
251
  return {
212
- memories: rows.map(parseMemoryRow),
213
- count: rows.length,
252
+ memories: ranked.map((item) => ({
253
+ ...parseMemoryRow(item.row),
254
+ matched_fields: item.matched_fields,
255
+ recall_score: item.score,
256
+ })),
257
+ count: ranked.length,
258
+ candidate_count: rows.length,
259
+ recall_mode: "ranked_lexical",
214
260
  };
215
261
  }
216
262
  catch (error) {
@@ -259,4 +305,64 @@ export function executeAgentMemory(db, params) {
259
305
  return { error: `Unknown action: ${params.action}` };
260
306
  }
261
307
  }
308
+ /** Shape a lexical-only recall response (the default, provider-free path). */
309
+ function lexicalRecallResult(ranked, candidateCount) {
310
+ return {
311
+ memories: ranked.map((item) => ({
312
+ ...parseMemoryRow(item.row),
313
+ matched_fields: item.matched_fields,
314
+ recall_score: item.score,
315
+ })),
316
+ count: ranked.length,
317
+ candidate_count: candidateCount,
318
+ recall_mode: "ranked_lexical",
319
+ };
320
+ }
321
+ /**
322
+ * Hybrid recall: blends alias-aware lexical scoring with cosine similarity over
323
+ * provider-backed embeddings. Embeddings are cached in the agent_memory table and
324
+ * backfilled lazily (capped per call). ANY failure to embed the query degrades to
325
+ * pure lexical recall, and recall_mode reports exactly which path produced the result.
326
+ */
327
+ async function enhanceRecallWithSemantics(db, query, rows, limit, config) {
328
+ // Lexical baseline over ALL candidates — used for blending and as the fallback.
329
+ const lexical = rankRowsByRecall(rows, query, MEMORY_RECALL_FIELDS, rows.length);
330
+ const lexById = new Map();
331
+ for (const item of lexical)
332
+ lexById.set(item.row.id, item);
333
+ const maxLex = lexical.length ? lexical[0].score : 0;
334
+ const sem = await semanticScores(db, "agent_memory", query, rows.map((row) => ({ id: row.id, text: memoryEmbedText(row) })), config);
335
+ // Query could not be embedded -> the provider is effectively unavailable.
336
+ if (!sem.queryEmbedded) {
337
+ return {
338
+ ...lexicalRecallResult(lexical.slice(0, limit), rows.length),
339
+ semantic_note: "embedding provider unreachable — used lexical recall",
340
+ };
341
+ }
342
+ const blended = rows
343
+ .map((row) => {
344
+ const semantic = Math.max(0, sem.semanticById.get(row.id) ?? 0);
345
+ const lex = lexById.get(row.id);
346
+ const lexNorm = maxLex > 0 && lex ? lex.score / maxLex : 0;
347
+ const score = SEMANTIC_BLEND * semantic + (1 - SEMANTIC_BLEND) * lexNorm;
348
+ return { row, semantic, matched: lex?.matched_fields ?? [], score };
349
+ })
350
+ .filter((x) => x.score > 0)
351
+ .sort((a, b) => b.score - a.score)
352
+ .slice(0, limit);
353
+ return {
354
+ memories: blended.map((x) => ({
355
+ ...parseMemoryRow(x.row),
356
+ matched_fields: x.matched,
357
+ recall_score: Math.round(x.score * 1000) / 1000,
358
+ semantic_score: Math.round(x.semantic * 1000) / 1000,
359
+ })),
360
+ count: blended.length,
361
+ candidate_count: rows.length,
362
+ embedded_candidates: sem.embeddedCount,
363
+ newly_embedded: sem.newlyEmbedded,
364
+ embedding_model: sem.model,
365
+ recall_mode: sem.embeddedCount > 0 ? "hybrid" : "ranked_lexical",
366
+ };
367
+ }
262
368
  //# sourceMappingURL=agent-memory.js.map
@@ -4,6 +4,13 @@
4
4
  * The key tool AI agents call BEFORE suggesting major changes.
5
5
  * Checks if a technology or pattern aligns with the developer's active decisions.
6
6
  * Returns alignment status, relevant decisions, and any conflicts.
7
+ *
8
+ * Retrieval of "relevant decisions" is lexical by default and OPTIONALLY hybrid
9
+ * (semantic) when an embedding provider is configured — so a decision that is a
10
+ * paraphrase of the proposal surfaces even with no shared words. Hard conflict
11
+ * detection ("X was explicitly rejected") stays lexical/alias-aware so semantic
12
+ * similarity can never fabricate a grounded conflict; paraphrase risks are
13
+ * reported separately as `possible_conflicts`.
7
14
  */
8
15
  import type { FourDADatabase } from "../db.js";
9
16
  export interface CheckDecisionAlignmentParams {
@@ -24,13 +31,22 @@ interface DecisionConflict {
24
31
  subject: string;
25
32
  reason: string;
26
33
  }
34
+ interface PossibleConflict {
35
+ decision_id: number;
36
+ subject: string;
37
+ similarity: number;
38
+ reason: string;
39
+ }
27
40
  interface AlignmentResult {
28
41
  aligned: boolean;
29
42
  technology: string;
30
43
  relevant_decisions: RelevantDecision[];
31
44
  conflicts: DecisionConflict[];
45
+ /** Paraphrase risks: semantically close to a rejected alternative, not grounded. */
46
+ possible_conflicts: PossibleConflict[];
32
47
  confidence: number;
33
48
  recommendation: string;
49
+ recall_mode: "hybrid" | "ranked_lexical";
34
50
  }
35
51
  export declare const checkDecisionAlignmentTool: {
36
52
  name: string;
@@ -54,6 +70,6 @@ export declare const checkDecisionAlignmentTool: {
54
70
  required: string[];
55
71
  };
56
72
  };
57
- export declare function executeCheckDecisionAlignment(db: FourDADatabase, params: CheckDecisionAlignmentParams): AlignmentResult;
73
+ export declare function executeCheckDecisionAlignment(db: FourDADatabase, params: CheckDecisionAlignmentParams): AlignmentResult | Promise<AlignmentResult>;
58
74
  export {};
59
75
  //# sourceMappingURL=decision-enforcement.d.ts.map
@@ -5,7 +5,17 @@
5
5
  * The key tool AI agents call BEFORE suggesting major changes.
6
6
  * Checks if a technology or pattern aligns with the developer's active decisions.
7
7
  * Returns alignment status, relevant decisions, and any conflicts.
8
+ *
9
+ * Retrieval of "relevant decisions" is lexical by default and OPTIONALLY hybrid
10
+ * (semantic) when an embedding provider is configured — so a decision that is a
11
+ * paraphrase of the proposal surfaces even with no shared words. Hard conflict
12
+ * detection ("X was explicitly rejected") stays lexical/alias-aware so semantic
13
+ * similarity can never fabricate a grounded conflict; paraphrase risks are
14
+ * reported separately as `possible_conflicts`.
8
15
  */
16
+ import { matchesRecallQuery } from "./recall.js";
17
+ import { getEmbeddingConfig } from "../embeddings.js";
18
+ import { rankDecisionsLexical, hybridDecisionRecall, POSSIBLE_CONFLICT_THRESHOLD, } from "./decision-recall.js";
9
19
  // ============================================================================
10
20
  // Tool Definition
11
21
  // ============================================================================
@@ -34,100 +44,71 @@ export const checkDecisionAlignmentTool = {
34
44
  // ============================================================================
35
45
  // Helpers
36
46
  // ============================================================================
37
- /**
38
- * Search active decisions matching a given term against subject,
39
- * context_tags, and alternatives_rejected columns.
40
- */
41
- function searchActiveDecisions(rawDb, searchTerm) {
42
- const pattern = `%${searchTerm.toLowerCase()}%`;
47
+ /** Load active decisions (unranked); ranking/retrieval is layered on top. */
48
+ function loadActiveDecisions(rawDb) {
43
49
  return rawDb
44
50
  .prepare(`SELECT id, decision_type, subject, decision, rationale,
45
51
  alternatives_rejected, context_tags, confidence,
46
52
  status, superseded_by, created_at, updated_at
47
53
  FROM developer_decisions
48
54
  WHERE status = 'active'
49
- AND (LOWER(subject) LIKE ?
50
- OR LOWER(context_tags) LIKE ?
51
- OR LOWER(alternatives_rejected) LIKE ?)`)
52
- .all(pattern, pattern, pattern);
55
+ ORDER BY updated_at DESC
56
+ LIMIT 500`)
57
+ .all();
58
+ }
59
+ function parseAlts(row) {
60
+ try {
61
+ return JSON.parse(row.alternatives_rejected || "[]");
62
+ }
63
+ catch {
64
+ return [];
65
+ }
53
66
  }
54
67
  /**
55
- * Classify a decision row's relationship to the queried technology.
68
+ * Classify a decision row's relationship to the queried technology. Hard
69
+ * conflicts require a literal/alias-aware match against rejected alternatives.
56
70
  */
57
71
  function classifyRelationship(row, tech) {
58
72
  const techLower = tech.toLowerCase();
59
- const alts = JSON.parse(row.alternatives_rejected || "[]");
60
73
  // If the technology appears in rejected alternatives, it's a conflict
61
- const isRejected = alts.some((alt) => alt.toLowerCase().includes(techLower));
74
+ const isRejected = parseAlts(row).some((alt) => matchesRecallQuery(alt, techLower));
62
75
  if (isRejected) {
63
76
  return "conflict";
64
77
  }
65
78
  // If the technology matches the decision subject, it's aligned
66
- if (row.subject.toLowerCase().includes(techLower)) {
79
+ if (matchesRecallQuery(row.subject, techLower) ||
80
+ matchesRecallQuery(row.decision, techLower)) {
67
81
  return "aligned";
68
82
  }
69
83
  // Otherwise it's tangentially related
70
84
  return "related";
71
85
  }
72
- /**
73
- * Build a human-readable recommendation string.
74
- */
75
- function buildRecommendation(technology, conflicts, relevantCount) {
76
- if (conflicts.length === 0) {
77
- if (relevantCount === 0) {
78
- return `No existing decisions found for '${technology}'. No conflicts detected — proceed with caution.`;
79
- }
80
- return `No conflicts found. Proceed with ${technology}.`;
86
+ /** Build a human-readable recommendation string. */
87
+ function buildRecommendation(technology, conflicts, relevantCount, possibleCount) {
88
+ if (conflicts.length > 0) {
89
+ const first = conflicts[0];
90
+ return (`CONFLICT: ${technology} was explicitly rejected. ` +
91
+ `Active decision: '${first.subject}' — ${first.reason}`);
81
92
  }
82
- // Report the first (most relevant) conflict
83
- const first = conflicts[0];
84
- return (`CONFLICT: ${technology} was explicitly rejected. ` +
85
- `Active decision: '${first.subject}' — ${first.reason}`);
86
- }
87
- // ============================================================================
88
- // Execute
89
- // ============================================================================
90
- export function executeCheckDecisionAlignment(db, params) {
91
- const rawDb = db.getRawDb();
92
- const { technology, pattern, context } = params;
93
- // Collect all matching decision rows (deduplicated by id)
94
- const seenIds = new Set();
95
- const allRows = [];
96
- try {
97
- // Primary search: technology name
98
- const techRows = searchActiveDecisions(rawDb, technology);
99
- for (const row of techRows) {
100
- if (!seenIds.has(row.id)) {
101
- seenIds.add(row.id);
102
- allRows.push(row);
103
- }
104
- }
105
- // Secondary search: pattern (if provided)
106
- if (pattern) {
107
- const patternRows = searchActiveDecisions(rawDb, pattern);
108
- for (const row of patternRows) {
109
- if (!seenIds.has(row.id)) {
110
- seenIds.add(row.id);
111
- allRows.push(row);
112
- }
113
- }
114
- }
93
+ if (possibleCount > 0) {
94
+ return (`No explicit conflict, but ${possibleCount} prior decision(s) are semantically ` +
95
+ `close to '${technology}' and rejected alternatives — review before proceeding.`);
115
96
  }
116
- catch {
117
- // Table may not exist yet — return a safe default
118
- return {
119
- aligned: true,
120
- technology,
121
- relevant_decisions: [],
122
- conflicts: [],
123
- confidence: 0.5,
124
- recommendation: `No decision history available. The developer_decisions table may not exist yet. Proceed with ${technology}.`,
125
- };
97
+ if (relevantCount === 0) {
98
+ return `No existing decisions found for '${technology}'. No conflicts detected — proceed with caution.`;
126
99
  }
127
- // Classify each row
100
+ return `No conflicts found. Proceed with ${technology}.`;
101
+ }
102
+ /**
103
+ * Classify the retrieved decisions into aligned/conflict/related, derive hard and
104
+ * possible (paraphrase) conflicts, and assemble the alignment result. Shared by
105
+ * the lexical and hybrid retrieval paths.
106
+ */
107
+ function assembleAlignment(params, rows, semanticById, recallMode) {
108
+ const { technology, pattern } = params;
128
109
  const relevantDecisions = [];
129
110
  const conflicts = [];
130
- for (const row of allRows) {
111
+ for (const row of rows) {
131
112
  const relationship = classifyRelationship(row, technology);
132
113
  relevantDecisions.push({
133
114
  id: row.id,
@@ -138,8 +119,7 @@ export function executeCheckDecisionAlignment(db, params) {
138
119
  relationship,
139
120
  });
140
121
  if (relationship === "conflict") {
141
- const alts = JSON.parse(row.alternatives_rejected || "[]");
142
- const rejectedAlt = alts.find((alt) => alt.toLowerCase().includes(technology.toLowerCase()));
122
+ const rejectedAlt = parseAlts(row).find((alt) => matchesRecallQuery(alt, technology));
143
123
  conflicts.push({
144
124
  decision_id: row.id,
145
125
  subject: row.subject,
@@ -147,21 +127,17 @@ export function executeCheckDecisionAlignment(db, params) {
147
127
  });
148
128
  }
149
129
  }
150
- // If pattern was provided but didn't surface via technology search,
151
- // also check pattern against decision relationships
130
+ // Pattern rejected-alternative check (if a pattern was supplied separately).
152
131
  if (pattern) {
153
- for (const row of allRows) {
154
- const patternLower = pattern.toLowerCase();
155
- const alts = JSON.parse(row.alternatives_rejected || "[]");
156
- const patternRejected = alts.some((alt) => alt.toLowerCase().includes(patternLower));
157
- if (patternRejected &&
158
- !conflicts.some((c) => c.decision_id === row.id)) {
132
+ const patternLower = pattern.toLowerCase();
133
+ for (const row of rows) {
134
+ const patternRejected = parseAlts(row).some((alt) => matchesRecallQuery(alt, patternLower));
135
+ if (patternRejected && !conflicts.some((c) => c.decision_id === row.id)) {
159
136
  conflicts.push({
160
137
  decision_id: row.id,
161
138
  subject: row.subject,
162
139
  reason: `Pattern '${pattern}' was rejected in favor of '${row.decision}' (rationale: ${row.rationale || "none"})`,
163
140
  });
164
- // Update the relationship for this decision
165
141
  const existing = relevantDecisions.find((d) => d.id === row.id);
166
142
  if (existing && existing.relationship !== "conflict") {
167
143
  existing.relationship = "conflict";
@@ -169,17 +145,69 @@ export function executeCheckDecisionAlignment(db, params) {
169
145
  }
170
146
  }
171
147
  }
148
+ // Possible (paraphrase) conflicts: semantically close to a decision that rejected
149
+ // alternatives, but NOT a literal match. Advisory only — never flips `aligned`.
150
+ const possibleConflicts = [];
151
+ for (const row of rows) {
152
+ const sim = semanticById.get(row.id) ?? 0;
153
+ if (sim < POSSIBLE_CONFLICT_THRESHOLD)
154
+ continue;
155
+ const alts = parseAlts(row);
156
+ if (alts.length === 0)
157
+ continue;
158
+ if (conflicts.some((c) => c.decision_id === row.id))
159
+ continue;
160
+ possibleConflicts.push({
161
+ decision_id: row.id,
162
+ subject: row.subject,
163
+ similarity: Math.round(sim * 1000) / 1000,
164
+ reason: `'${technology}' is semantically close (${Math.round(sim * 100)}%) to a decision that rejected: ${alts.join(", ")}. Review before proceeding.`,
165
+ });
166
+ }
172
167
  const maxConfidence = relevantDecisions.length > 0
173
168
  ? Math.max(...relevantDecisions.map((d) => d.confidence))
174
169
  : 0.5;
175
- const recommendation = buildRecommendation(technology, conflicts, relevantDecisions.length);
176
170
  return {
177
171
  aligned: conflicts.length === 0,
178
172
  technology,
179
173
  relevant_decisions: relevantDecisions,
180
174
  conflicts,
175
+ possible_conflicts: possibleConflicts,
181
176
  confidence: maxConfidence,
182
- recommendation,
177
+ recommendation: buildRecommendation(technology, conflicts, relevantDecisions.length, possibleConflicts.length),
178
+ recall_mode: recallMode,
183
179
  };
184
180
  }
181
+ // ============================================================================
182
+ // Execute
183
+ // ============================================================================
184
+ export function executeCheckDecisionAlignment(db, params) {
185
+ const rawDb = db.getRawDb();
186
+ let candidates;
187
+ try {
188
+ candidates = loadActiveDecisions(rawDb);
189
+ }
190
+ catch {
191
+ // developer_decisions table may not exist yet — safe default.
192
+ return {
193
+ aligned: true,
194
+ technology: params.technology,
195
+ relevant_decisions: [],
196
+ conflicts: [],
197
+ possible_conflicts: [],
198
+ confidence: 0.5,
199
+ recommendation: `No decision history available. The developer_decisions table may not exist yet. Proceed with ${params.technology}.`,
200
+ recall_mode: "ranked_lexical",
201
+ };
202
+ }
203
+ const query = [params.technology, params.pattern].filter(Boolean).join(" ");
204
+ const config = getEmbeddingConfig();
205
+ // No provider -> synchronous lexical retrieval (classic behaviour).
206
+ if (!config) {
207
+ const rows = rankDecisionsLexical(candidates, query, 50);
208
+ return assembleAlignment(params, rows, new Map(), "ranked_lexical");
209
+ }
210
+ // Provider configured -> hybrid retrieval (returns a Promise; dispatch awaits).
211
+ return hybridDecisionRecall(db, query, candidates, 50, config).then(({ ranked, semanticById, recall_mode }) => assembleAlignment(params, ranked, semanticById, recall_mode));
212
+ }
185
213
  //# sourceMappingURL=decision-enforcement.js.map