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 +39 -0
- package/README.md +59 -2
- package/package.json +1 -1
- package/src/cli/commands/server.ts +59 -3
- package/src/config/defaults.ts +23 -0
- package/src/core/VectorEngine.ts +11 -14
- package/src/daemon/sonar-inference.ts +7 -4
- package/src/daemon/sonar-logic.ts +15 -18
- package/src/mcp/index.ts +126 -32
- package/src/pipeline/AmalfaIngestor.ts +0 -1
- package/src/resonance/DATABASE-PROCEDURES.md +347 -0
- package/src/resonance/README.md +15 -8
- package/src/resonance/db.ts +15 -57
- package/src/utils/ContentHydrator.ts +38 -0
- package/src/utils/Scratchpad.ts +427 -0
- package/src/utils/sonar-client.ts +1 -1
- package/src/resonance/schema.ts +0 -190
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
package/src/config/defaults.ts
CHANGED
|
@@ -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,
|
package/src/core/VectorEngine.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
195
|
-
"SELECT title,
|
|
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 =
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
if (!content && row.meta) {
|
|
205
|
+
let contentPlaceholder = "";
|
|
206
|
+
if (row.meta) {
|
|
209
207
|
try {
|
|
210
208
|
const meta = JSON.parse(row.meta);
|
|
211
|
-
|
|
212
|
-
content = `[Hollow Node: ${meta.source || "no source"}]`;
|
|
209
|
+
contentPlaceholder = `[Hollow Node: ${meta.source || "no source"}]`;
|
|
213
210
|
} catch {
|
|
214
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
107
|
+
message: openAIResult.choices[0].message,
|
|
105
108
|
};
|
|
106
109
|
}
|
|
107
|
-
|
|
110
|
+
const ollamaResult = result as { message: Message };
|
|
108
111
|
return {
|
|
109
|
-
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
|
-
|
|
149
|
-
const
|
|
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
|
-
.
|
|
158
|
-
.
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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):
|
|
669
|
+
function safeJsonParse(content: string): unknown {
|
|
673
670
|
try {
|
|
674
671
|
return JSON.parse(content);
|
|
675
672
|
} catch {
|