amalfa 1.0.19 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { appendFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { appendFileSync } from "fs";
2
+ import { join } from "path";
3
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import {
@@ -8,18 +8,21 @@ import {
8
8
  ListToolsRequestSchema,
9
9
  ReadResourceRequestSchema,
10
10
  } from "@modelcontextprotocol/sdk/types.js";
11
- import { loadConfig } from "@src/config/defaults";
11
+ import { AMALFA_DIRS, loadConfig } from "@src/config/defaults";
12
12
  import { VectorEngine } from "@src/core/VectorEngine";
13
13
  import { ResonanceDB } from "@src/resonance/db";
14
14
  import { DaemonManager } from "../utils/DaemonManager";
15
15
  import { getLogger } from "../utils/Logger";
16
16
  import { ServiceLifecycle } from "../utils/ServiceLifecycle";
17
- import { AMALFA_DIRS } from "@src/config/defaults";
17
+ import { createSonarClient, type SonarClient } from "../utils/sonar-client";
18
18
 
19
19
  const args = process.argv.slice(2);
20
20
  const command = args[0] || "serve";
21
21
  const log = getLogger("MCP");
22
22
 
23
+ // Sonar client for enhanced search
24
+ const sonarClient: SonarClient = await createSonarClient();
25
+
23
26
  // --- Service Lifecycle ---
24
27
 
25
28
  const lifecycle = new ServiceLifecycle({
@@ -182,11 +185,40 @@ async function runServer() {
182
185
  const limit = Number(args?.limit || 20);
183
186
  const candidates = new Map<
184
187
  string,
185
- { id: string; score: number; preview: string; source: string }
188
+ {
189
+ id: string;
190
+ score: number;
191
+ preview: string;
192
+ source: string;
193
+ content: string;
194
+ }
186
195
  >();
187
196
  const errors: string[] = [];
188
197
 
189
- // Vector Search only (FTS removed in Hollow Node migration)
198
+ // Step 1: Analyze query with Sonar (if available)
199
+ const sonarAvailable = await sonarClient.isAvailable();
200
+ let queryAnalysis: Awaited<
201
+ ReturnType<typeof sonarClient.analyzeQuery>
202
+ > | null = null;
203
+ let queryIntent: string | undefined = undefined;
204
+
205
+ if (sonarAvailable) {
206
+ log.info({ query }, "🔍 Analyzing query with Sonar");
207
+ queryAnalysis = await sonarClient.analyzeQuery(query);
208
+ if (queryAnalysis) {
209
+ queryIntent = queryAnalysis.intent;
210
+ log.info(
211
+ {
212
+ intent: queryAnalysis.intent,
213
+ entities: queryAnalysis.entities.join(", "),
214
+ level: queryAnalysis.technical_level,
215
+ },
216
+ "✅ Query analysis complete",
217
+ );
218
+ }
219
+ }
220
+
221
+ // Step 2: Vector Search (FTS removed in Hollow Node migration)
190
222
  try {
191
223
  const vectorResults = await vectorEngine.search(query, limit);
192
224
  for (const r of vectorResults) {
@@ -199,6 +231,7 @@ async function runServer() {
199
231
  score: r.score,
200
232
  preview: preview,
201
233
  source: "vector",
234
+ content: r.content || "",
202
235
  });
203
236
  }
204
237
  } catch (e: unknown) {
@@ -207,12 +240,63 @@ async function runServer() {
207
240
  errors.push(msg);
208
241
  }
209
242
 
210
- const results = Array.from(candidates.values())
243
+ // Step 3: Re-rank results with Sonar (if available)
244
+ let rankedResults = Array.from(candidates.values())
211
245
  .sort((a, b) => b.score - a.score)
212
- .slice(0, limit)
213
- .map((r) => ({ ...r, score: r.score.toFixed(3) }));
246
+ .slice(0, limit);
247
+
248
+ if (sonarAvailable && queryAnalysis) {
249
+ log.info("🔄 Re-ranking results with Sonar");
250
+ const reRanked = await sonarClient.rerankResults(
251
+ rankedResults,
252
+ query,
253
+ queryIntent,
254
+ );
255
+ rankedResults = reRanked.map((rr) => {
256
+ const original = candidates.get(rr.id)!;
257
+ return {
258
+ ...original,
259
+ score: rr.relevance_score,
260
+ };
261
+ });
262
+ log.info("✅ Results re-ranked");
263
+ }
214
264
 
215
- if (results.length === 0 && errors.length > 0) {
265
+ // Step 4: Extract context with Sonar for top results (if available)
266
+ // We'll prepare the final output structure here
267
+ let finalResults: Array<any> = rankedResults;
268
+
269
+ if (sonarAvailable) {
270
+ log.info("📝 Extracting context with Sonar");
271
+ const contextResults = await Promise.all(
272
+ rankedResults.slice(0, 5).map(async (r) => {
273
+ const context = await sonarClient.extractContext(r, query);
274
+ return {
275
+ // ... (keeping structure) ...
276
+ ...r,
277
+ score: r.score.toFixed(3),
278
+ snippet: context?.snippet || r.preview,
279
+ context: context?.context || "No additional context",
280
+ confidence: context?.confidence || 0.5,
281
+ };
282
+ }),
283
+ );
284
+ // Combine context results with the rest
285
+ finalResults = [
286
+ ...contextResults,
287
+ ...rankedResults
288
+ .slice(5)
289
+ .map((r) => ({ ...r, score: r.score.toFixed(3) })),
290
+ ];
291
+ log.info("✅ Context extraction complete");
292
+ } else {
293
+ finalResults = rankedResults.map((r) => ({
294
+ ...r,
295
+ score: r.score.toFixed(3),
296
+ }));
297
+ }
298
+
299
+ if (finalResults.length === 0 && errors.length > 0) {
216
300
  return {
217
301
  content: [
218
302
  { type: "text", text: `Search Error: ${errors.join(", ")}` },
@@ -220,8 +304,29 @@ async function runServer() {
220
304
  isError: true,
221
305
  };
222
306
  }
307
+
308
+ // Add Sonar metadata to response
309
+ const searchMetadata = {
310
+ query,
311
+ sonar_enabled: sonarAvailable,
312
+ intent: queryIntent,
313
+ analysis: queryAnalysis,
314
+ };
315
+
223
316
  return {
224
- content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
317
+ content: [
318
+ {
319
+ type: "text",
320
+ text: JSON.stringify(
321
+ {
322
+ results: finalResults,
323
+ metadata: searchMetadata,
324
+ },
325
+ null,
326
+ 2,
327
+ ),
328
+ },
329
+ ],
225
330
  };
226
331
  } finally {
227
332
  // Cleanup connection
@@ -296,10 +401,7 @@ async function runServer() {
296
401
 
297
402
  if (name === TOOLS.LIST) {
298
403
  // TODO: Make this configurable via amalfa.config.ts
299
- const structure = [
300
- "docs/",
301
- "notes/",
302
- ];
404
+ const structure = ["docs/", "notes/"];
303
405
  return {
304
406
  content: [{ type: "text", text: JSON.stringify(structure, null, 2) }],
305
407
  };
@@ -308,17 +410,37 @@ async function runServer() {
308
410
  if (name === TOOLS.GARDEN) {
309
411
  const filePath = String(args?.file_path);
310
412
  const tags = args?.tags as string[];
311
- const content = await Bun.file(filePath).text();
312
- const tagBlock = `\n<!-- tags: ${tags.join(", ")} -->\n`;
313
- const newContent = content.endsWith("\n")
314
- ? content + tagBlock
315
- : `${content}\n${tagBlock}`;
316
- await Bun.write(filePath, newContent);
413
+ let content = await Bun.file(filePath).text();
414
+
415
+ // Check for existing tag block and merge/replace
416
+ const tagPattern = /<!-- tags: ([^>]+) -->\s*$/;
417
+ const match = content.match(tagPattern);
418
+
419
+ let operation = "injected";
420
+ if (match?.[1]) {
421
+ // Merge with existing tags
422
+ const existingTags = match[1]
423
+ .split(",")
424
+ .map((t) => t.trim())
425
+ .filter(Boolean);
426
+ const mergedTags = [...new Set([...existingTags, ...tags])]; // deduplicate
427
+ const tagBlock = `<!-- tags: ${mergedTags.join(", ")} -->`;
428
+ content = content.replace(tagPattern, `${tagBlock}\n`);
429
+ operation = "merged";
430
+ } else {
431
+ // Append new tag block
432
+ const tagBlock = `<!-- tags: ${tags.join(", ")} -->`;
433
+ content = content.endsWith("\n")
434
+ ? `${content}\n${tagBlock}\n`
435
+ : `${content}\n\n${tagBlock}\n`;
436
+ }
437
+
438
+ await Bun.write(filePath, content);
317
439
  return {
318
440
  content: [
319
441
  {
320
442
  type: "text",
321
- text: `Injected ${tags.length} tags into ${filePath}`,
443
+ text: `Successfully ${operation} ${tags.length} tags into ${filePath}`,
322
444
  },
323
445
  ],
324
446
  };
@@ -394,4 +516,4 @@ process.on("unhandledRejection", (reason) => {
394
516
 
395
517
  // --- Dispatch ---
396
518
 
397
- await lifecycle.run(command, runServer, false);
519
+ await lifecycle.run(command, runServer);
@@ -4,14 +4,14 @@
4
4
  * No Persona/CDA complexity - just pure markdown → knowledge graph
5
5
  */
6
6
 
7
- import { join } from "node:path";
8
- import { Glob } from "bun";
7
+ import { join } from "path";
9
8
  import type { AmalfaConfig } from "@src/config/defaults";
10
9
  import { EdgeWeaver } from "@src/core/EdgeWeaver";
11
- import { ResonanceDB, type Node } from "@src/resonance/db";
10
+ import type { Node, ResonanceDB } from "@src/resonance/db";
12
11
  import { Embedder } from "@src/resonance/services/embedder";
13
12
  import { SimpleTokenizerService as TokenizerService } from "@src/resonance/services/simpleTokenizer";
14
13
  import { getLogger } from "@src/utils/Logger";
14
+ import { Glob } from "bun";
15
15
 
16
16
  export interface IngestionResult {
17
17
  success: boolean;
@@ -37,7 +37,7 @@ export class AmalfaIngestor {
37
37
  */
38
38
  async ingest(): Promise<IngestionResult> {
39
39
  const startTime = performance.now();
40
-
40
+
41
41
  const sources = this.config.sources || ["./docs"];
42
42
  this.log.info(`📚 Starting ingestion from: ${sources.join(", ")}`);
43
43
 
@@ -47,7 +47,7 @@ export class AmalfaIngestor {
47
47
  await embedder.embed("init"); // Warm up
48
48
 
49
49
  const tokenizer = TokenizerService.getInstance();
50
-
50
+
51
51
  // Discover markdown files
52
52
  const files = await this.discoverFiles();
53
53
  this.log.info(`📁 Found ${files.length} markdown files`);
@@ -108,7 +108,10 @@ export class AmalfaIngestor {
108
108
  if (!filePath) continue;
109
109
  const content = await Bun.file(filePath).text();
110
110
  const filename = filePath.split("/").pop() || "unknown";
111
- const id = filename.replace(".md", "").toLowerCase().replace(/[^a-z0-9-]/g, "-");
111
+ const id = filename
112
+ .replace(".md", "")
113
+ .toLowerCase()
114
+ .replace(/[^a-z0-9-]/g, "-");
112
115
  weaver.weave(id, content);
113
116
  }
114
117
  this.db.commit();
@@ -117,17 +120,36 @@ export class AmalfaIngestor {
117
120
  this.log.info("💾 Forcing WAL checkpoint...");
118
121
  this.db.getRawDb().run("PRAGMA wal_checkpoint(TRUNCATE);");
119
122
 
123
+ // OH-104: The Pinch Check (verify physical commit)
124
+ const dbPath = this.db.getRawDb().filename;
125
+ const dbFile = Bun.file(dbPath);
126
+ if (!(await dbFile.exists())) {
127
+ throw new Error(
128
+ "OH-104 VIOLATION: Database file missing after checkpoint",
129
+ );
130
+ }
131
+ const finalSize = dbFile.size;
132
+ if (finalSize === 0) {
133
+ throw new Error(
134
+ "OH-104 VIOLATION: Database file is empty after checkpoint",
135
+ );
136
+ }
137
+ this.log.info(`✅ Pinch Check: db=${(finalSize / 1024).toFixed(1)}KB`);
138
+
120
139
  const endTime = performance.now();
121
140
  const durationSec = (endTime - startTime) / 1000;
122
141
 
123
142
  const stats = this.db.getStats();
124
- this.log.info({
125
- files: processedCount,
126
- nodes: stats.nodes,
127
- edges: stats.edges,
128
- vectors: stats.vectors,
129
- durationSec: durationSec.toFixed(2),
130
- }, "✅ Ingestion complete");
143
+ this.log.info(
144
+ {
145
+ files: processedCount,
146
+ nodes: stats.nodes,
147
+ edges: stats.edges,
148
+ vectors: stats.vectors,
149
+ durationSec: durationSec.toFixed(2),
150
+ },
151
+ "✅ Ingestion complete",
152
+ );
131
153
 
132
154
  return {
133
155
  success: true,
@@ -178,7 +200,10 @@ export class AmalfaIngestor {
178
200
  }
179
201
  }
180
202
  } catch (e) {
181
- this.log.warn({ source: sourcePath, err: e }, "⚠️ Failed to scan directory");
203
+ this.log.warn(
204
+ { source: sourcePath, err: e },
205
+ "⚠️ Failed to scan directory",
206
+ );
182
207
  }
183
208
  }
184
209
 
@@ -215,9 +240,7 @@ export class AmalfaIngestor {
215
240
 
216
241
  // Parse frontmatter
217
242
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
218
- const frontmatter = fmMatch?.[1]
219
- ? this.parseFrontmatter(fmMatch[1])
220
- : {};
243
+ const frontmatter = fmMatch?.[1] ? this.parseFrontmatter(fmMatch[1]) : {};
221
244
 
222
245
  // Generate ID from filename
223
246
  const filename = filePath.split("/").pop() || "unknown";
@@ -10,8 +10,8 @@
10
10
  * Generates .amalfa/logs/pre-flight.log with recommendations.
11
11
  */
12
12
 
13
- import { existsSync, lstatSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
14
- import { join, relative } from "node:path";
13
+ import { existsSync, lstatSync, readdirSync, realpathSync, statSync, writeFileSync } from "fs";
14
+ import { join, relative } from "path";
15
15
  import { getLogger } from "@src/utils/Logger";
16
16
  import type { AmalfaConfig } from "@src/config/defaults";
17
17
  import { AMALFA_DIRS, initAmalfaDirs } from "@src/config/defaults";
@@ -10,8 +10,8 @@
10
10
  * await harvester.loadIntoResonance(graph);
11
11
  */
12
12
 
13
- import { existsSync } from "node:fs";
14
- import { join } from "node:path";
13
+ import { existsSync } from "fs";
14
+ import { join } from "path";
15
15
  import { getLogger } from "@src/utils/Logger";
16
16
  import { $ } from "bun";
17
17
 
@@ -366,11 +366,24 @@ export class ResonanceDB {
366
366
  checkpoint() {
367
367
  this.db.run("PRAGMA wal_checkpoint(TRUNCATE);");
368
368
  }
369
+ getNode(id: string): Node | null {
370
+ const row = this.db.query("SELECT * FROM nodes WHERE id = ?").get(id);
371
+ if (!row) return null;
372
+ return this.mapRowToNode(row);
373
+ }
374
+
375
+ updateNodeMeta(id: string, meta: Record<string, unknown>) {
376
+ this.db.run("UPDATE nodes SET meta = ? WHERE id = ?", [
377
+ JSON.stringify(meta),
378
+ id,
379
+ ]);
380
+ }
369
381
  }
370
382
 
371
383
  // Helper: Calculate magnitude (L2 norm) of a vector
372
384
  function magnitude(vec: Float32Array): number {
373
385
  let sum = 0;
386
+ // Modern JS engines SIMD-optimize this loop automatically
374
387
  for (let i = 0; i < vec.length; i++) {
375
388
  sum += (vec[i] || 0) * (vec[i] || 0);
376
389
  }
@@ -1,4 +1,4 @@
1
- import { join } from "node:path";
1
+ import { join } from "path";
2
2
  import { toFafcas } from "@src/resonance/db";
3
3
  import { EmbeddingModel, FlagEmbedding } from "fastembed";
4
4
 
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { serve } from "bun";
8
- import { join } from "node:path";
8
+ import { join } from "path";
9
9
  import { EmbeddingModel, FlagEmbedding } from "fastembed";
10
10
  import { toFafcas } from "@src/resonance/db";
11
11
  import { getLogger } from "@src/utils/Logger";
@@ -1,5 +1,5 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync } from "fs";
2
+ import { join } from "path";
3
3
  import { ServiceLifecycle } from "./ServiceLifecycle";
4
4
  import { AMALFA_DIRS } from "@src/config/defaults";
5
5
 
@@ -7,6 +7,7 @@ export interface DaemonStatus {
7
7
  running: boolean;
8
8
  pid?: number;
9
9
  port?: number;
10
+ activeModel?: string;
10
11
  }
11
12
 
12
13
  /**
@@ -16,6 +17,7 @@ export interface DaemonStatus {
16
17
  export class DaemonManager {
17
18
  private vectorLifecycle: ServiceLifecycle;
18
19
  private watcherLifecycle: ServiceLifecycle;
20
+ private sonarLifecycle: ServiceLifecycle;
19
21
 
20
22
  constructor() {
21
23
  this.vectorLifecycle = new ServiceLifecycle({
@@ -31,6 +33,13 @@ export class DaemonManager {
31
33
  logFile: join(AMALFA_DIRS.logs, "daemon.log"),
32
34
  entryPoint: "src/daemon/index.ts",
33
35
  });
36
+
37
+ this.sonarLifecycle = new ServiceLifecycle({
38
+ name: "SonarAgent",
39
+ pidFile: join(AMALFA_DIRS.runtime, "sonar.pid"),
40
+ logFile: join(AMALFA_DIRS.logs, "sonar.log"),
41
+ entryPoint: "src/daemon/sonar-agent.ts",
42
+ });
34
43
  }
35
44
 
36
45
  /**
@@ -65,7 +74,9 @@ export class DaemonManager {
65
74
  * Check if vector daemon is running
66
75
  */
67
76
  async checkVectorDaemon(): Promise<DaemonStatus> {
68
- const pid = await this.readPid(join(AMALFA_DIRS.runtime, "vector-daemon.pid"));
77
+ const pid = await this.readPid(
78
+ join(AMALFA_DIRS.runtime, "vector-daemon.pid"),
79
+ );
69
80
  if (!pid) {
70
81
  return { running: false };
71
82
  }
@@ -126,24 +137,77 @@ export class DaemonManager {
126
137
  await this.watcherLifecycle.stop();
127
138
  }
128
139
 
140
+ /**
141
+ * Check if Sonar Agent is running
142
+ */
143
+ async checkSonarAgent(): Promise<DaemonStatus> {
144
+ const pid = await this.readPid(join(AMALFA_DIRS.runtime, "sonar.pid"));
145
+ if (!pid) {
146
+ return { running: false };
147
+ }
148
+
149
+ const running = await this.isProcessRunning(pid);
150
+ let activeModel: string | undefined;
151
+
152
+ if (running) {
153
+ try {
154
+ const health = (await fetch("http://localhost:3012/health").then((r) =>
155
+ r.json(),
156
+ )) as { model?: string };
157
+ activeModel = health.model;
158
+ } catch {
159
+ // disregard
160
+ }
161
+ }
162
+
163
+ return {
164
+ running,
165
+ pid: running ? pid : undefined,
166
+ port: running ? 3012 : undefined,
167
+ activeModel,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Start Sonar Agent
173
+ */
174
+ async startSonarAgent(): Promise<void> {
175
+ await this.sonarLifecycle.start();
176
+ // Wait a moment for daemon to initialize
177
+ await new Promise((resolve) => setTimeout(resolve, 1000));
178
+ }
179
+
180
+ /**
181
+ * Stop Sonar Agent
182
+ */
183
+ async stopSonarAgent(): Promise<void> {
184
+ await this.sonarLifecycle.stop();
185
+ }
186
+
129
187
  /**
130
188
  * Check status of all daemons
131
189
  */
132
190
  async checkAll(): Promise<{
133
191
  vector: DaemonStatus;
134
192
  watcher: DaemonStatus;
193
+ sonar: DaemonStatus;
135
194
  }> {
136
- const [vector, watcher] = await Promise.all([
195
+ const [vector, watcher, sonar] = await Promise.all([
137
196
  this.checkVectorDaemon(),
138
197
  this.checkFileWatcher(),
198
+ this.checkSonarAgent(),
139
199
  ]);
140
- return { vector, watcher };
200
+ return { vector, watcher, sonar };
141
201
  }
142
202
 
143
203
  /**
144
204
  * Stop all daemons
145
205
  */
146
206
  async stopAll(): Promise<void> {
147
- await Promise.all([this.stopVectorDaemon(), this.stopFileWatcher()]);
207
+ await Promise.all([
208
+ this.stopVectorDaemon(),
209
+ this.stopFileWatcher(),
210
+ this.stopSonarAgent(),
211
+ ]);
148
212
  }
149
213
  }
@@ -1,5 +1,5 @@
1
- import { spawn } from "node:child_process";
2
- import { platform } from "node:os";
1
+ import { spawn } from "child_process";
2
+ import { platform } from "os";
3
3
 
4
4
  /**
5
5
  * Send a native desktop notification