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.
- package/README.md +6 -0
- package/package.json +4 -2
- package/src/core/GraphEngine.ts +252 -0
- package/src/core/GraphGardener.ts +244 -0
- package/src/core/VectorEngine.ts +4 -1
- package/src/daemon/sonar-agent.ts +179 -859
- package/src/daemon/sonar-inference.ts +116 -0
- package/src/daemon/sonar-logic.ts +662 -0
- package/src/daemon/sonar-strategies.ts +187 -0
- package/src/daemon/sonar-types.ts +68 -0
- package/src/mcp/index.ts +20 -5
- package/src/pipeline/AmalfaIngestor.ts +2 -2
- package/src/resonance/db.ts +9 -2
- package/src/resonance/schema.ts +15 -2
- package/src/utils/TagInjector.ts +90 -0
- package/src/utils/sonar-client.ts +17 -0
|
@@ -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.
|
|
162
|
+
name: TOOLS.GAPS,
|
|
162
163
|
description:
|
|
163
|
-
"
|
|
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
|
-
|
|
168
|
-
|
|
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(
|
|
248
|
+
.replace(/\.(md|ts|js)$/, "")
|
|
249
249
|
.toLowerCase()
|
|
250
250
|
.replace(/[^a-z0-9-]/g, "-");
|
|
251
251
|
|
package/src/resonance/db.ts
CHANGED
|
@@ -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
|
|
package/src/resonance/schema.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const CURRENT_SCHEMA_VERSION =
|
|
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
|
}
|