amalfa 1.1.0 → 1.3.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ All notable changes to AMALFA will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0] - 2026-01-13
9
+
10
+ ### Changed
11
+ - **Database Schema**: Migrated to Drizzle ORM for schema management (internal implementation detail)
12
+ - **Content Storage**: Database now stores only metadata and embeddings (hollow nodes). Content read from filesystem via `GraphGardener.getContent()`
13
+ - **Vector Search**: Fixed embedding model consistency - now uses `BGESmallENV15` throughout for improved recall accuracy
14
+ - **Sonar Integration**: Added proper content hydration before reranking to resolve empty placeholder issue
15
+
16
+ ### Added
17
+ - **Content Hydrator**: `src/utils/ContentHydrator.ts` for explicit filesystem content loading
18
+ - **Database Procedures**: `src/resonance/DATABASE-PROCEDURES.md` documenting canonical database operations
19
+ - **Sonar Diagnostics**: Test suite and assessment tools for reranking service quality
20
+
21
+ ### Fixed
22
+ - **Vector Recall**: Resolved embedding model mismatch causing poor search results
23
+ - **Sonar Content**: Fixed hollow node issue where Sonar received empty content
24
+
25
+ ### Removed
26
+ - **Custom Migration System**: Replaced with Drizzle ORM (232 lines deleted from `src/resonance/schema.ts`)
27
+
28
+ ### Migration
29
+
30
+ **The database is a disposable runtime artifact.** If experiencing issues after upgrade:
31
+
32
+ ```bash
33
+ rm -rf .amalfa/
34
+ bun run scripts/cli/ingest.ts
35
+ ```
36
+
37
+ Your documents are the single source of truth. Database can be regenerated anytime.
38
+
39
+ ## [1.2.0] - 2026-01-13
40
+
41
+ ### Added
42
+ - **Scratchpad Protocol (Phase 7)**: Intercepts large MCP tool outputs (>4KB) and caches them to `.amalfa/cache/scratchpad/`, returning a reference with preview instead of full content. Reduces context window usage for verbose responses.
43
+ - New `scratchpad_read` and `scratchpad_list` MCP tools for retrieving cached content.
44
+ - Content-addressable storage with SHA256 deduplication.
45
+ - Configurable threshold, max age (24h), and cache size limit (50MB).
46
+
8
47
  ## [1.1.0] - 2026-01-13
9
48
 
10
49
  ### Added
package/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  **A Memory Layer For Agents**
4
4
 
5
- MCP server that gives AI agents semantic access to project knowledge graphs.
5
+ Local-first knowledge graph with semantic search for AI agents.
6
+
7
+ **Core Design**: Your documents are the source of truth. The database is a disposable runtime artifact.
6
8
 
7
9
  ---
8
10
 
@@ -24,6 +26,8 @@ Amalfa is a **Model Context Protocol (MCP) server** that provides AI agents with
24
26
 
25
27
  Built with **Bun + SQLite + FastEmbed**.
26
28
 
29
+ **Core distinguisher**: Database is a **disposable runtime artifact**. Documents are the source of truth.
30
+
27
31
  ---
28
32
 
29
33
  ## The Problem
@@ -36,7 +40,60 @@ Built with **Bun + SQLite + FastEmbed**.
36
40
 
37
41
  ---
38
42
 
39
- ## Core Concepts
43
+ ## Core Architecture: Disposable Database
44
+
45
+ **The Foundation**: AMALFA treats your filesystem as the single source of truth and the database as an ephemeral cache.
46
+
47
+ ### The Philosophy
48
+
49
+ **Documents = Truth, Database = Cache**
50
+
51
+ ```
52
+ Markdown Files (filesystem)
53
+
54
+ [Ingestion Pipeline]
55
+
56
+ SQLite Database (.amalfa/)
57
+
58
+ [Vector Search]
59
+
60
+ MCP Server (AI agents)
61
+ ```
62
+
63
+ **Key Insight**: The database can be deleted and regenerated at any time without data loss.
64
+
65
+ - **Source of Truth**: Your markdown documents (immutable filesystem)
66
+ - **Runtime Artifact**: SQLite database with embeddings and metadata
67
+ - **Regeneration**: `rm -rf .amalfa/ && bun run scripts/cli/ingest.ts`
68
+
69
+ ### Why This Matters
70
+
71
+ **Benefits**:
72
+ - ✅ **No Migration Hell**: Upgrading? Just re-ingest. No migration scripts.
73
+ - ✅ **Deterministic Rebuilds**: Same documents → same database state
74
+ - ✅ **Version Freedom**: Switch between AMALFA versions without fear
75
+ - ✅ **Corruption Immunity**: Database corrupt? Delete and rebuild in seconds
76
+ - ✅ **Model Flexibility**: Change embedding models by re-ingesting
77
+
78
+ **Distinguisher**: Unlike traditional systems where the database *is* the truth, AMALFA inverts this. Your prose is permanent, the index is disposable.
79
+
80
+ ### When to Re-Ingest
81
+
82
+ Just delete `.amalfa/` and re-run ingestion:
83
+
84
+ ```bash
85
+ rm -rf .amalfa/
86
+ bun run scripts/cli/ingest.ts
87
+ ```
88
+
89
+ **Common scenarios**:
90
+ - After upgrading AMALFA versions
91
+ - When experiencing search issues
92
+ - When changing embedding models
93
+ - After adding/modifying many documents
94
+ - Anytime you want a clean slate
95
+
96
+ **Speed**: 308 nodes in <1 second. Re-ingestion is fast enough to be casual.
40
97
 
41
98
  ### Brief-Debrief-Playbook Pattern
42
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalfa",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Local-first knowledge graph engine for AI agents. Transforms markdown into searchable memory with MCP protocol.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/pjsvis/amalfa#readme",
@@ -15,8 +15,6 @@ export async function cmdServe(_args: string[]) {
15
15
  console.error(`📊 Database: ${dbPath}`);
16
16
  console.error("");
17
17
 
18
- // Run MCP server (it handles stdio transport)
19
- // Note: We need to resolve from project root, not relative to this new file location
20
18
  const serverPath = join(process.cwd(), "src/mcp/index.ts");
21
19
  const proc = spawn("bun", ["run", serverPath, "serve"], {
22
20
  stdio: "inherit",
@@ -29,6 +27,21 @@ export async function cmdServe(_args: string[]) {
29
27
  }
30
28
 
31
29
  export async function cmdServers(args: string[]) {
30
+ const action = args[1];
31
+ // If action is provided and isn't a flag (like --dot), treat it as a lifecycle command
32
+ if (
33
+ action &&
34
+ !action.startsWith("-") &&
35
+ ["start", "stop", "restart", "status"].includes(action)
36
+ ) {
37
+ if (action === "status") {
38
+ // Just fall through to normal status display
39
+ } else {
40
+ await manageAllServers(action as "start" | "stop" | "restart");
41
+ return;
42
+ }
43
+ }
44
+
32
45
  const showDot = args.includes("--dot");
33
46
 
34
47
  const SERVICES = [
@@ -178,10 +191,53 @@ export async function cmdServers(args: string[]) {
178
191
 
179
192
  console.log("─".repeat(95));
180
193
  console.log(
181
- "\n💡 Commands: amalfa serve | amalfa vector start | amalfa daemon start\n",
194
+ "\n💡 Commands: amalfa servers [start|stop|restart] | amalfa vector start | amalfa daemon start\n",
182
195
  );
183
196
  }
184
197
 
198
+ // Background services to manage via 'amalfa servers start/restart'
199
+ const BACKGROUND_SERVICES = [
200
+ {
201
+ name: "Vector Daemon",
202
+ cmd: "amalfa",
203
+ args: ["vector", "start"],
204
+ },
205
+ {
206
+ name: "File Watcher",
207
+ cmd: "amalfa",
208
+ args: ["daemon", "start"],
209
+ },
210
+ {
211
+ name: "Sonar Agent",
212
+ cmd: "amalfa",
213
+ args: ["sonar", "start"],
214
+ },
215
+ ];
216
+
217
+ async function manageAllServers(action: "start" | "stop" | "restart") {
218
+ if (action === "stop" || action === "restart") {
219
+ await cmdStopAll([]);
220
+ }
221
+
222
+ if (action === "start" || action === "restart") {
223
+ console.log("🚀 Starting background services...\n");
224
+
225
+ for (const svc of BACKGROUND_SERVICES) {
226
+ console.log(`▶️ Starting ${svc.name}...`);
227
+ const child = spawn(svc.cmd, svc.args, {
228
+ detached: true,
229
+ stdio: "ignore", // Daemons manage their own logs
230
+ cwd: process.cwd(),
231
+ });
232
+ child.unref();
233
+ // Brief pause to allow pid file creation / logging
234
+ await new Promise((resolve) => setTimeout(resolve, 500));
235
+ }
236
+ console.log("\n✅ All background services triggered.");
237
+ console.log("Run 'amalfa servers' to check status.");
238
+ }
239
+ }
240
+
185
241
  export async function cmdStopAll(_args: string[]) {
186
242
  console.log("🛑 Stopping ALL Amalfa Services...\n");
187
243
 
@@ -18,6 +18,12 @@ export const AMALFA_DIRS = {
18
18
  get agent() {
19
19
  return join(this.base, "agent");
20
20
  },
21
+ get cache() {
22
+ return join(this.base, "cache");
23
+ },
24
+ get scratchpad() {
25
+ return join(this.base, "cache", "scratchpad");
26
+ },
21
27
  get tasks() {
22
28
  return {
23
29
  pending: join(this.base, "agent", "tasks", "pending"),
@@ -33,6 +39,8 @@ export function initAmalfaDirs(): void {
33
39
  AMALFA_DIRS.base,
34
40
  AMALFA_DIRS.logs,
35
41
  AMALFA_DIRS.runtime,
42
+ AMALFA_DIRS.cache,
43
+ AMALFA_DIRS.scratchpad,
36
44
  AMALFA_DIRS.tasks.pending,
37
45
  AMALFA_DIRS.tasks.processing,
38
46
  AMALFA_DIRS.tasks.completed,
@@ -82,6 +90,15 @@ export interface AmalfaConfig {
82
90
  phi3?: SonarConfig;
83
91
  /** Ember automated enrichment configuration */
84
92
  ember: EmberConfig;
93
+ /** Scratchpad cache configuration */
94
+ scratchpad?: ScratchpadConfig;
95
+ }
96
+
97
+ export interface ScratchpadConfig {
98
+ enabled: boolean;
99
+ thresholdBytes: number;
100
+ maxAgeMs: number;
101
+ maxCacheSizeBytes: number;
85
102
  }
86
103
 
87
104
  export interface SonarConfig {
@@ -153,6 +170,12 @@ export const DEFAULT_CONFIG: AmalfaConfig = {
153
170
  autoSquash: false,
154
171
  backupDir: ".amalfa/backups/ember",
155
172
  },
173
+ scratchpad: {
174
+ enabled: true,
175
+ thresholdBytes: 4 * 1024,
176
+ maxAgeMs: 24 * 60 * 60 * 1000,
177
+ maxCacheSizeBytes: 50 * 1024 * 1024,
178
+ },
156
179
  watch: {
157
180
  enabled: true,
158
181
  debounce: 1000,
@@ -102,8 +102,9 @@ export class VectorEngine {
102
102
  }
103
103
 
104
104
  // Lazy load the model
105
+ // MUST match Embedder model (BGESmallENV15) for compatibility
105
106
  this.modelPromise = FlagEmbedding.init({
106
- model: EmbeddingModel.AllMiniLML6V2,
107
+ model: EmbeddingModel.BGESmallENV15,
107
108
  });
108
109
  }
109
110
 
@@ -188,37 +189,33 @@ export class VectorEngine {
188
189
  // 4. Sort & Limit
189
190
  const topK = scored.sort((a, b) => b.score - a.score).slice(0, limit);
190
191
 
191
- // 5. Hydrate Content (for top K only)
192
- // Note: Hollow Nodes have content=NULL, use meta.source to read from filesystem if needed
192
+ // 5. Hydrate Metadata (for top K only)
193
193
  const results: SearchResult[] = [];
194
- const contentStmt = this.db.prepare(
195
- "SELECT title, content, meta, date FROM nodes WHERE id = ?",
194
+ const metaStmt = this.db.prepare(
195
+ "SELECT title, meta, date FROM nodes WHERE id = ?",
196
196
  );
197
197
 
198
198
  for (const item of topK) {
199
- const row = contentStmt.get(item.id) as {
199
+ const row = metaStmt.get(item.id) as {
200
200
  title: string;
201
- content: string | null;
202
201
  meta: string | null;
203
202
  date: string | null;
204
203
  };
205
204
  if (row) {
206
- // For hollow nodes, extract a preview from title or meta
207
- let content = row.content || "";
208
- if (!content && row.meta) {
205
+ let contentPlaceholder = "";
206
+ if (row.meta) {
209
207
  try {
210
208
  const meta = JSON.parse(row.meta);
211
- // Provide source path as content placeholder for hollow nodes
212
- content = `[Hollow Node: ${meta.source || "no source"}]`;
209
+ contentPlaceholder = `[Hollow Node: ${meta.source || "no source"}]`;
213
210
  } catch {
214
- content = "[Hollow Node: parse error]";
211
+ contentPlaceholder = "[Hollow Node: parse error]";
215
212
  }
216
213
  }
217
214
  results.push({
218
215
  id: item.id,
219
216
  score: item.score,
220
217
  title: row.title,
221
- content: content,
218
+ content: contentPlaceholder,
222
219
  date: row.date || undefined,
223
220
  });
224
221
  }
@@ -99,14 +99,17 @@ export async function callOllama(
99
99
  const result = await response.json();
100
100
 
101
101
  if (provider === "openrouter") {
102
- // OpenAI format
102
+ const openAIResult = result as { choices: Array<{ message: Message }> };
103
+ if (!openAIResult.choices?.[0]?.message) {
104
+ throw new Error("Invalid OpenRouter response format");
105
+ }
103
106
  return {
104
- message: (result as any).choices[0].message,
107
+ message: openAIResult.choices[0].message,
105
108
  };
106
109
  }
107
- // Ollama format
110
+ const ollamaResult = result as { message: Message };
108
111
  return {
109
- message: (result as any).message,
112
+ message: ollamaResult.message,
110
113
  };
111
114
  } catch (error) {
112
115
  const errorMsg = error instanceof Error ? error.message : String(error);
@@ -145,20 +145,18 @@ Current Date: ${new Date().toISOString().split("T")[0]}`,
145
145
  let augmentContext = "\n\nRELEVANT CONTEXT FROM KNOWLEDGE BASE:\n";
146
146
  if (results.length > 0) {
147
147
  augmentContext += `\n--- [DIRECT SEARCH RESULTS] ---\n`;
148
- results.forEach((r) => {
149
- const node = context.db.getNode(r.id);
150
- const content = node?.content ?? "";
148
+ for (const r of results) {
149
+ const content = (await context.gardener.getContent(r.id)) || "";
151
150
  augmentContext += `[Document: ${r.id}] (Similarity: ${r.score.toFixed(2)})\n${content.slice(0, 800)}\n\n`;
152
- });
151
+ }
153
152
 
154
153
  if (relatedNodeIds.size > 0) {
155
154
  augmentContext += `\n--- [RELATED NEIGHBORS (GRAPH DISCOVERY)] ---\n`;
156
- Array.from(relatedNodeIds)
157
- .slice(0, 5)
158
- .forEach((nrId) => {
159
- const node = context.db.getNode(nrId);
160
- augmentContext += `[Related: ${nrId}] (Via: ${node?.label || nrId})\n${(node?.content ?? "").slice(0, 400)}\n\n`;
161
- });
155
+ for (const nrId of Array.from(relatedNodeIds).slice(0, 5)) {
156
+ const node = context.db.getNode(nrId);
157
+ const content = (await context.gardener.getContent(nrId)) || "";
158
+ augmentContext += `[Related: ${nrId}] (Via: ${node?.label || nrId})\n${content.slice(0, 400)}\n\n`;
159
+ }
162
160
  }
163
161
  }
164
162
 
@@ -531,18 +529,17 @@ Return JSON: { "action": "SEARCH"|"READ"|"EXPLORE"|"FINISH", "query": "...", "no
531
529
  );
532
530
 
533
531
  const content = actionResponse.message.content;
534
- let decision: {
532
+ const parsed = safeJsonParse(content);
533
+ if (!parsed || typeof parsed !== "object") {
534
+ throw new Error("Could not parse JSON from response");
535
+ }
536
+ const decision = parsed as {
535
537
  action: "SEARCH" | "READ" | "EXPLORE" | "FINISH";
536
538
  query?: string;
537
539
  nodeId?: string;
538
540
  reasoning: string;
539
541
  answer?: string;
540
- } | null = null;
541
-
542
- decision = safeJsonParse(content);
543
- if (!decision) {
544
- throw new Error("Could not parse JSON from response");
545
- }
542
+ };
546
543
  output += `> **Reasoning:** ${decision.reasoning}\n\n`;
547
544
 
548
545
  if (decision.action === "FINISH") {
@@ -669,7 +666,7 @@ Return JSON: { "answered": true|false, "missing_info": "...", "final_answer": ".
669
666
  /**
670
667
  * Helper to safely parse JSON from LLM responses, handling markdown blocks
671
668
  */
672
- function safeJsonParse(content: string): any {
669
+ function safeJsonParse(content: string): unknown {
673
670
  try {
674
671
  return JSON.parse(content);
675
672
  } catch {