amalfa 1.0.28 → 1.0.30

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.
@@ -0,0 +1,187 @@
1
+ import { loadConfig } from "@src/config/defaults";
2
+ import { getLogger } from "@src/utils/Logger";
3
+ import { callOllama, inferenceState } from "./sonar-inference";
4
+ import type { SonarTask } from "./sonar-types";
5
+
6
+ const log = getLogger("SonarStrategies");
7
+
8
+ /**
9
+ * Get the recommended model for a task based on configuration
10
+ */
11
+ export async function getTaskModel(
12
+ taskType: string,
13
+ ): Promise<string | undefined> {
14
+ const config = await loadConfig();
15
+ const useCloud = config.sonar.cloud?.enabled === true;
16
+ const provider = config.sonar.cloud?.provider || "ollama";
17
+
18
+ if (useCloud && provider === "openrouter") {
19
+ const models: Record<string, string> = {
20
+ garden: "google/gemini-2.0-flash-exp:free",
21
+ synthesis: "google/gemini-2.0-flash-exp:free",
22
+ timeline: "google/gemini-2.0-flash-exp:free",
23
+ research: "google/gemini-2.0-flash-exp:free",
24
+ };
25
+ return models[taskType];
26
+ }
27
+
28
+ return undefined;
29
+ }
30
+
31
+ /**
32
+ * Strategy 1: The "Judge"
33
+ * Verifies if two nodes should actually be linked and classifies the relationship.
34
+ */
35
+ export async function judgeRelationship(
36
+ source: { id: string; content: string },
37
+ target: { id: string; content: string },
38
+ model?: string,
39
+ ): Promise<{ related: boolean; type?: string; reason?: string }> {
40
+ if (!inferenceState.ollamaAvailable) return { related: false };
41
+
42
+ try {
43
+ const response = await callOllama(
44
+ [
45
+ {
46
+ role: "system",
47
+ content: `You are a Semantic Graph Architect. Analyze two markdown notes and determine if they share a direct, non-trivial logical relationship.
48
+ Possible Relationship Types:
49
+ - EXTENDS: One note provides more detail or a follow-up to the other.
50
+ - SUPPORTS: One note provides evidence or arguments for the other.
51
+ - CONTRADICTS: Notes present opposing views or conflict.
52
+ - REFERENCES: A general citation or mention without a strong logical link.
53
+ - DUPLICATE: Notes cover significantly the same information.
54
+
55
+ Guidelines:
56
+ - Return JSON.
57
+ - relate: boolean (true if a link is justified)
58
+ - type: string (the uppercase relation type)
59
+ - reason: string (brief explanation)
60
+
61
+ If the link is trivial (e.g. just common words), set relate: false.`,
62
+ },
63
+ {
64
+ role: "user",
65
+ content: `Node A (ID: ${source.id}):\n${source.content.slice(0, 1500)}\n\n---\n\nNode B (ID: ${target.id}):\n${target.content.slice(0, 1500)}`,
66
+ },
67
+ ],
68
+ {
69
+ temperature: 0.1,
70
+ format: "json",
71
+ model,
72
+ },
73
+ );
74
+
75
+ const result = JSON.parse(response.message.content);
76
+ return {
77
+ related: !!result.relate,
78
+ type: result.type?.toUpperCase(),
79
+ reason: result.reason,
80
+ };
81
+ } catch (error) {
82
+ log.warn({ error }, "LLM Judging failed");
83
+ return { related: false };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Strategy 2: Community Synthesis
89
+ * Summarizes a group of related nodes.
90
+ */
91
+ export async function summarizeCommunity(
92
+ nodes: { id: string; content: string }[],
93
+ model?: string,
94
+ ): Promise<{ label: string; summary: string }> {
95
+ if (!inferenceState.ollamaAvailable)
96
+ return { label: "Synthesis", summary: "LLM Not available" };
97
+
98
+ try {
99
+ const response = await callOllama(
100
+ [
101
+ {
102
+ role: "system",
103
+ content: `You are a Knowledge Architect. Analyze a cluster of related documents and generate a canonical Label and a concise Synthesis (3 sentences max).
104
+ The Label should be a clear topic name (e.g. "MCP Authentication Protocols").
105
+ The Synthesis should explain the core theme connecting these documents.
106
+
107
+ Return JSON format: { "label": "string", "summary": "string" }`,
108
+ },
109
+ {
110
+ role: "user",
111
+ content: nodes
112
+ .map((n) => `Node ${n.id}:\n${n.content.slice(0, 1000)}`)
113
+ .join("\n\n---\n\n"),
114
+ },
115
+ ],
116
+ {
117
+ temperature: 0.3,
118
+ format: "json",
119
+ model,
120
+ },
121
+ );
122
+
123
+ const content = response.message.content;
124
+ try {
125
+ // Extract JSON if it's wrapped in code blocks
126
+ const jsonMatch = content.match(/```json\n([\s\S]*?)\n```/) || [
127
+ null,
128
+ content,
129
+ ];
130
+ const jsonStr = jsonMatch[1] || content;
131
+ return JSON.parse(jsonStr);
132
+ } catch (error) {
133
+ log.warn({ error, content }, "Failed to parse community summary JSON");
134
+ throw error;
135
+ }
136
+ } catch (error) {
137
+ log.warn(
138
+ { error: error instanceof Error ? error.message : String(error) },
139
+ "Community summary failed",
140
+ );
141
+ return { label: "Untitled Cluster", summary: "Failed to generate summary" };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Strategy 3: Chronos
147
+ * Extracts the primary temporal anchor from a document.
148
+ */
149
+ export async function extractDate(
150
+ nodeId: string,
151
+ content: string,
152
+ model?: string,
153
+ ): Promise<string | null> {
154
+ // First try regex for common patterns
155
+ const dateMatch =
156
+ content.match(/Date:\*\*\s*(\d{4}-\d{2}-\d{2})/i) ||
157
+ content.match(/date:\s*(\d{4}-\d{2}-\d{2})/i) ||
158
+ content.match(/#\s*(\d{4}-\d{2}-\d{2})/i);
159
+
160
+ if (dateMatch) return dateMatch[1] || null;
161
+
162
+ if (!inferenceState.ollamaAvailable) return null;
163
+
164
+ try {
165
+ const response = await callOllama(
166
+ [
167
+ {
168
+ role: "system",
169
+ content: `You are a Temporal Chronologist. Extract the primary creation or event date from the following markdown note.
170
+ Return only the date in YYYY-MM-DD format. If no specific date is found, return "null".`,
171
+ },
172
+ {
173
+ role: "user",
174
+ content: `Node: ${nodeId}\nContent:\n${content.slice(0, 2000)}`,
175
+ },
176
+ ],
177
+ { temperature: 0, model },
178
+ );
179
+
180
+ const result = response.message.content.trim();
181
+ log.info({ nodeId, result }, "LLM Chronos result");
182
+ if (result === "null" || !/^\d{4}-\d{2}-\d{2}$/.test(result)) return null;
183
+ return result;
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Common types and interfaces for Sonar Agent
3
+ */
4
+
5
+ export interface Message {
6
+ role: "system" | "user" | "assistant";
7
+ content: string;
8
+ }
9
+
10
+ export interface ChatSession {
11
+ id: string;
12
+ messages: Message[];
13
+ startedAt: Date;
14
+ }
15
+
16
+ export interface SonarTask {
17
+ type:
18
+ | "synthesis"
19
+ | "timeline"
20
+ | "enhance_batch"
21
+ | "garden"
22
+ | "research"
23
+ | "chat";
24
+ minSize?: number;
25
+ limit?: number;
26
+ autoApply?: boolean;
27
+ notify?: boolean;
28
+ query?: string;
29
+ model?: string;
30
+ sessionId?: string;
31
+ message?: string;
32
+ }
33
+
34
+ export interface RequestOptions {
35
+ temperature?: number;
36
+ num_predict?: number;
37
+ stream?: boolean;
38
+ format?: "json";
39
+ model?: string;
40
+ }
41
+
42
+ /**
43
+ * API Request Types
44
+ */
45
+ export interface ChatRequest {
46
+ sessionId: string;
47
+ message: string;
48
+ model?: string;
49
+ }
50
+
51
+ export interface MetadataEnhanceRequest {
52
+ docId: string;
53
+ }
54
+
55
+ export interface SearchAnalyzeRequest {
56
+ query: string;
57
+ }
58
+
59
+ export interface SearchRerankRequest {
60
+ results: Array<{ id: string; content: string; score: number }>;
61
+ query: string;
62
+ intent?: string;
63
+ }
64
+
65
+ export interface SearchContextRequest {
66
+ result: { id: string; content: string };
67
+ query: string;
68
+ }
package/src/mcp/index.ts CHANGED
@@ -112,6 +112,7 @@ async function runServer() {
112
112
  EXPLORE: "explore_links",
113
113
  LIST: "list_directory_structure",
114
114
  GARDEN: "inject_tags",
115
+ GAPS: "find_gaps",
115
116
  };
116
117
 
117
118
  // 3. Register Handlers
@@ -158,16 +159,15 @@ async function runServer() {
158
159
  inputSchema: { type: "object", properties: {} },
159
160
  },
160
161
  {
161
- name: TOOLS.GARDEN,
162
+ name: TOOLS.GAPS,
162
163
  description:
163
- "Inject semantic tags into a source file (Gardener Agent).",
164
+ "Find semantic gaps (documents that are similar but not linked) in the knowledge graph.",
164
165
  inputSchema: {
165
166
  type: "object",
166
167
  properties: {
167
- file_path: { type: "string" },
168
- tags: { type: "array", items: { type: "string" } },
168
+ limit: { type: "number", default: 10 },
169
+ threshold: { type: "number", default: 0.8 },
169
170
  },
170
- required: ["file_path", "tags"],
171
171
  },
172
172
  },
173
173
  ],
@@ -407,6 +407,21 @@ async function runServer() {
407
407
  };
408
408
  }
409
409
 
410
+ if (name === TOOLS.GAPS) {
411
+ const limit = Number(args?.limit || 10);
412
+ try {
413
+ const gaps = await sonarClient.getGaps(limit);
414
+ return {
415
+ content: [{ type: "text", text: JSON.stringify(gaps, null, 2) }],
416
+ };
417
+ } catch (e) {
418
+ return {
419
+ content: [{ type: "text", text: `Failed to fetch gaps: ${e}` }],
420
+ isError: true,
421
+ };
422
+ }
423
+ }
424
+
410
425
  if (name === TOOLS.GARDEN) {
411
426
  const filePath = String(args?.file_path);
412
427
  const tags = args?.tags as string[];
@@ -181,7 +181,7 @@ export class AmalfaIngestor {
181
181
  */
182
182
  private async discoverFiles(): Promise<string[]> {
183
183
  const files: string[] = [];
184
- const glob = new Glob("**/*.md");
184
+ const glob = new Glob("**/*.{md,ts,js}");
185
185
  const sources = this.config.sources || ["./docs"];
186
186
 
187
187
  // Scan each source directory
@@ -245,7 +245,7 @@ export class AmalfaIngestor {
245
245
  // Generate ID from filename
246
246
  const filename = filePath.split("/").pop() || "unknown";
247
247
  const id = filename
248
- .replace(".md", "")
248
+ .replace(/\.(md|ts|js)$/, "")
249
249
  .toLowerCase()
250
250
  .replace(/[^a-z0-9-]/g, "-");
251
251
 
@@ -16,6 +16,7 @@ export interface Node {
16
16
  embedding?: Float32Array;
17
17
  hash?: string;
18
18
  meta?: Record<string, unknown>; // JSON object for flexible metadata
19
+ date?: string; // ISO-8601 or similar temporal anchor
19
20
  }
20
21
 
21
22
  export class ResonanceDB {
@@ -107,8 +108,8 @@ export class ResonanceDB {
107
108
  // Schema v6: content column deprecated, always set to NULL
108
109
  // Content is read from filesystem via meta.source
109
110
  const stmt = this.db.prepare(`
110
- INSERT OR REPLACE INTO nodes (id, type, title, content, domain, layer, embedding, hash, meta)
111
- VALUES ($id, $type, $title, NULL, $domain, $layer, $embedding, $hash, $meta)
111
+ INSERT OR REPLACE INTO nodes (id, type, title, content, domain, layer, embedding, hash, meta, date)
112
+ VALUES ($id, $type, $title, NULL, $domain, $layer, $embedding, $hash, $meta, $date)
112
113
  `);
113
114
 
114
115
  try {
@@ -132,6 +133,7 @@ export class ResonanceDB {
132
133
  $embedding: blob,
133
134
  $hash: node.hash ? String(node.hash) : null,
134
135
  $meta: node.meta ? JSON.stringify(node.meta) : null,
136
+ $date: node.date ? String(node.date) : null,
135
137
  });
136
138
  } catch (err) {
137
139
  log.error(
@@ -244,6 +246,10 @@ export class ResonanceDB {
244
246
  return rows.map((row) => this.mapRowToNode(row));
245
247
  }
246
248
 
249
+ updateNodeDate(id: string, date: string) {
250
+ this.db.run("UPDATE nodes SET date = ? WHERE id = ?", [date, id]);
251
+ }
252
+
247
253
  getLexicon(): {
248
254
  id: string;
249
255
  label: string;
@@ -293,6 +299,7 @@ export class ResonanceDB {
293
299
  : undefined,
294
300
  hash: row.hash,
295
301
  meta: row.meta ? JSON.parse(row.meta) : {},
302
+ date: row.date,
296
303
  };
297
304
  }
298
305
 
@@ -1,4 +1,4 @@
1
- export const CURRENT_SCHEMA_VERSION = 6;
1
+ export const CURRENT_SCHEMA_VERSION = 7;
2
2
 
3
3
  export const GENESIS_SQL = `
4
4
  CREATE TABLE IF NOT EXISTS nodes (
@@ -9,7 +9,8 @@ export const GENESIS_SQL = `
9
9
  layer TEXT,
10
10
  embedding BLOB,
11
11
  hash TEXT,
12
- meta TEXT
12
+ meta TEXT,
13
+ date TEXT
13
14
  );
14
15
 
15
16
  CREATE TABLE IF NOT EXISTS edges (
@@ -153,4 +154,16 @@ export const MIGRATIONS: Migration[] = [
153
154
  );
154
155
  },
155
156
  },
157
+ {
158
+ version: 7,
159
+ description: "Add 'date' column for temporal grounding",
160
+ up: (db) => {
161
+ try {
162
+ db.run("ALTER TABLE nodes ADD COLUMN date TEXT");
163
+ } catch (e: unknown) {
164
+ const err = e as { message: string };
165
+ if (!err.message.includes("duplicate column")) throw e;
166
+ }
167
+ },
168
+ },
156
169
  ];
@@ -0,0 +1,90 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { getLogger } from "@src/utils/Logger";
3
+
4
+ const log = getLogger("TagInjector");
5
+
6
+ /**
7
+ * TagInjector - Utility for safely injecting semantic tags and links into markdown files.
8
+ * Supports FAFCAS-compliant tag syntax.
9
+ */
10
+ export class TagInjector {
11
+ /**
12
+ * Injects a semantic tag into a markdown file.
13
+ * If the file has frontmatter, it appends to the frontmatter.
14
+ * Otherwise, it adds a tag block at the top.
15
+ */
16
+ static injectTag(
17
+ filePath: string,
18
+ relation: string,
19
+ targetId: string,
20
+ ): boolean {
21
+ if (!existsSync(filePath)) {
22
+ log.error({ filePath }, "File not found for tag injection");
23
+ return false;
24
+ }
25
+
26
+ try {
27
+ let content = readFileSync(filePath, "utf-8");
28
+ const tagString = `[${relation.toUpperCase()}: ${targetId}]`;
29
+
30
+ // Check if tag already exists to avoid duplicates
31
+ if (content.includes(tagString)) {
32
+ return true;
33
+ }
34
+
35
+ // Look for existing tag block: <!-- tags: ... -->
36
+ const tagBlockRegex = /<!-- tags: (.*?) -->/;
37
+ const match = content.match(tagBlockRegex);
38
+
39
+ if (match) {
40
+ // Append to existing block
41
+ const oldBlock = match[0];
42
+ const innerTags = match[1];
43
+ const newBlock = `<!-- tags: ${innerTags} ${tagString} -->`;
44
+ content = content.replace(oldBlock, newBlock);
45
+ } else {
46
+ // Create new block after frontmatter or at top
47
+ const frontmatterRegex = /^---\n[\s\S]*?\n---\n/;
48
+ const fmMatch = content.match(frontmatterRegex);
49
+ const newTagBlock = `\n<!-- tags: ${tagString} -->\n`;
50
+
51
+ if (fmMatch) {
52
+ content = content.replace(fmMatch[0], fmMatch[0] + newTagBlock);
53
+ } else {
54
+ content = newTagBlock + content;
55
+ }
56
+ }
57
+
58
+ writeFileSync(filePath, content, "utf-8");
59
+ return true;
60
+ } catch (error) {
61
+ log.error({ error, filePath }, "Failed to inject tag");
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Injects a WikiLink into the end of a markdown file.
68
+ */
69
+ static injectLink(
70
+ filePath: string,
71
+ targetId: string,
72
+ label?: string,
73
+ ): boolean {
74
+ if (!existsSync(filePath)) return false;
75
+
76
+ try {
77
+ let content = readFileSync(filePath, "utf-8");
78
+ const link = label ? `[[${targetId}|${label}]]` : `[[${targetId}]]`;
79
+
80
+ if (content.includes(link)) return true;
81
+
82
+ content = content.trimEnd() + `\n\nSee also: ${link}\n`;
83
+ writeFileSync(filePath, content, "utf-8");
84
+ return true;
85
+ } catch (error) {
86
+ log.error({ error, filePath }, "Failed to inject link");
87
+ return false;
88
+ }
89
+ }
90
+ }
@@ -35,6 +35,7 @@ export interface SonarClient {
35
35
  result: { id: string; content: string },
36
36
  query: string,
37
37
  ): Promise<{ snippet: string; context: string; confidence: number } | null>;
38
+ getGaps(limit?: number): Promise<any[]>;
38
39
  }
39
40
 
40
41
  /**
@@ -255,6 +256,19 @@ export async function createSonarClient(): Promise<SonarClient> {
255
256
  return null;
256
257
  }
257
258
  },
259
+
260
+ async getGaps(limit?: number): Promise<any[]> {
261
+ if (!(await isAvailable())) return [];
262
+ try {
263
+ const response = await fetch(`${baseUrl}/graph/explore`);
264
+ if (!response.ok) return [];
265
+ const data = (await response.json()) as { gaps?: any[] };
266
+ return data.gaps || [];
267
+ } catch (error) {
268
+ log.error({ error }, "Failed to fetch gaps");
269
+ return [];
270
+ }
271
+ },
258
272
  };
259
273
  }
260
274
 
@@ -290,5 +304,8 @@ function createDisabledClient(): SonarClient {
290
304
  } | null> {
291
305
  return null;
292
306
  },
307
+ async getGaps(): Promise<any[]> {
308
+ return [];
309
+ },
293
310
  };
294
311
  }