chainlesschain 0.37.8 → 0.37.10
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 +403 -8
- package/bin/chainlesschain.js +4 -0
- package/package.json +7 -2
- package/src/commands/agent.js +30 -0
- package/src/commands/ask.js +114 -0
- package/src/commands/audit.js +286 -0
- package/src/commands/auth.js +387 -0
- package/src/commands/browse.js +184 -0
- package/src/commands/chat.js +35 -0
- package/src/commands/db.js +152 -0
- package/src/commands/did.js +376 -0
- package/src/commands/encrypt.js +233 -0
- package/src/commands/export.js +125 -0
- package/src/commands/git.js +215 -0
- package/src/commands/import.js +259 -0
- package/src/commands/instinct.js +202 -0
- package/src/commands/llm.js +288 -0
- package/src/commands/mcp.js +302 -0
- package/src/commands/memory.js +282 -0
- package/src/commands/note.js +489 -0
- package/src/commands/org.js +505 -0
- package/src/commands/p2p.js +274 -0
- package/src/commands/plugin.js +398 -0
- package/src/commands/search.js +237 -0
- package/src/commands/session.js +238 -0
- package/src/commands/skill.js +479 -0
- package/src/commands/sync.js +249 -0
- package/src/commands/tokens.js +214 -0
- package/src/commands/wallet.js +416 -0
- package/src/index.js +65 -0
- package/src/lib/audit-logger.js +364 -0
- package/src/lib/bm25-search.js +322 -0
- package/src/lib/browser-automation.js +216 -0
- package/src/lib/crypto-manager.js +246 -0
- package/src/lib/did-manager.js +270 -0
- package/src/lib/ensure-utf8.js +59 -0
- package/src/lib/git-integration.js +220 -0
- package/src/lib/instinct-manager.js +190 -0
- package/src/lib/knowledge-exporter.js +302 -0
- package/src/lib/knowledge-importer.js +293 -0
- package/src/lib/llm-providers.js +325 -0
- package/src/lib/mcp-client.js +413 -0
- package/src/lib/memory-manager.js +211 -0
- package/src/lib/note-versioning.js +244 -0
- package/src/lib/org-manager.js +424 -0
- package/src/lib/p2p-manager.js +317 -0
- package/src/lib/pdf-parser.js +96 -0
- package/src/lib/permission-engine.js +374 -0
- package/src/lib/plan-mode.js +333 -0
- package/src/lib/platform.js +15 -0
- package/src/lib/plugin-manager.js +312 -0
- package/src/lib/response-cache.js +156 -0
- package/src/lib/session-manager.js +189 -0
- package/src/lib/sync-manager.js +347 -0
- package/src/lib/token-tracker.js +200 -0
- package/src/lib/wallet-manager.js +348 -0
- package/src/repl/agent-repl.js +912 -0
- package/src/repl/chat-repl.js +262 -0
- package/src/runtime/bootstrap.js +159 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge importer — parse Markdown, Evernote ENEX, and Notion exports
|
|
3
|
+
* into the notes table.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "fs";
|
|
7
|
+
import { join, basename, extname, relative } from "path";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Ensure the notes table exists
|
|
11
|
+
*/
|
|
12
|
+
export function ensureNotesTable(db) {
|
|
13
|
+
db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
title TEXT NOT NULL,
|
|
17
|
+
content TEXT DEFAULT '',
|
|
18
|
+
tags TEXT DEFAULT '[]',
|
|
19
|
+
category TEXT DEFAULT 'general',
|
|
20
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
21
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
22
|
+
deleted_at TEXT DEFAULT NULL
|
|
23
|
+
)
|
|
24
|
+
`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Generate a simple UUID-like ID
|
|
29
|
+
*/
|
|
30
|
+
function generateId() {
|
|
31
|
+
const hex = () =>
|
|
32
|
+
Math.floor(Math.random() * 0x10000)
|
|
33
|
+
.toString(16)
|
|
34
|
+
.padStart(4, "0");
|
|
35
|
+
return `${hex()}${hex()}-${hex()}-${hex()}-${hex()}-${hex()}${hex()}${hex()}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Insert a note into the database
|
|
40
|
+
*/
|
|
41
|
+
export function insertNote(db, { title, content, tags, category, createdAt }) {
|
|
42
|
+
const id = generateId();
|
|
43
|
+
const tagsJson = JSON.stringify(tags || []);
|
|
44
|
+
const cat = category || "general";
|
|
45
|
+
const created =
|
|
46
|
+
createdAt || new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
47
|
+
|
|
48
|
+
db.prepare(
|
|
49
|
+
"INSERT INTO notes (id, title, content, tags, category, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
50
|
+
).run(id, title, content || "", tagsJson, cat, created, created);
|
|
51
|
+
|
|
52
|
+
return { id, title, tags: tags || [], category: cat, created_at: created };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Markdown Import ────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse a single markdown file into a note object.
|
|
59
|
+
* Extracts YAML frontmatter if present.
|
|
60
|
+
*/
|
|
61
|
+
export function parseMarkdownFile(filePath) {
|
|
62
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
63
|
+
const name = basename(filePath, extname(filePath));
|
|
64
|
+
|
|
65
|
+
let title = name;
|
|
66
|
+
let content = raw;
|
|
67
|
+
let tags = [];
|
|
68
|
+
let category = "markdown";
|
|
69
|
+
let createdAt = null;
|
|
70
|
+
|
|
71
|
+
// Try to extract YAML frontmatter
|
|
72
|
+
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
73
|
+
if (fmMatch) {
|
|
74
|
+
const frontmatter = fmMatch[1];
|
|
75
|
+
content = fmMatch[2].trim();
|
|
76
|
+
|
|
77
|
+
// Parse simple YAML fields
|
|
78
|
+
const titleMatch = frontmatter.match(/^title:\s*["']?(.+?)["']?\s*$/m);
|
|
79
|
+
if (titleMatch) title = titleMatch[1];
|
|
80
|
+
|
|
81
|
+
const tagsMatch = frontmatter.match(/^tags:\s*\[([^\]]*)\]/m);
|
|
82
|
+
if (tagsMatch) {
|
|
83
|
+
tags = tagsMatch[1]
|
|
84
|
+
.split(",")
|
|
85
|
+
.map((t) => t.trim().replace(/["']/g, ""))
|
|
86
|
+
.filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const catMatch = frontmatter.match(/^category:\s*["']?(.+?)["']?\s*$/m);
|
|
90
|
+
if (catMatch) category = catMatch[1];
|
|
91
|
+
|
|
92
|
+
const dateMatch = frontmatter.match(/^date:\s*["']?(.+?)["']?\s*$/m);
|
|
93
|
+
if (dateMatch) createdAt = dateMatch[1];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Use first H1 as title if no frontmatter title
|
|
97
|
+
if (title === name && !fmMatch) {
|
|
98
|
+
const h1Match = content.match(/^#\s+(.+)$/m);
|
|
99
|
+
if (h1Match) title = h1Match[1];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { title, content, tags, category, createdAt };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Recursively collect all .md files from a directory.
|
|
107
|
+
*/
|
|
108
|
+
export function collectMarkdownFiles(dir) {
|
|
109
|
+
const results = [];
|
|
110
|
+
|
|
111
|
+
function walk(currentDir) {
|
|
112
|
+
const entries = readdirSync(currentDir);
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
const fullPath = join(currentDir, entry);
|
|
115
|
+
const stat = statSync(fullPath);
|
|
116
|
+
if (stat.isDirectory()) {
|
|
117
|
+
walk(fullPath);
|
|
118
|
+
} else if (extname(entry).toLowerCase() === ".md") {
|
|
119
|
+
results.push(fullPath);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
walk(dir);
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Import all markdown files from a directory into the database.
|
|
130
|
+
*/
|
|
131
|
+
export function importMarkdownDir(db, dir) {
|
|
132
|
+
ensureNotesTable(db);
|
|
133
|
+
const files = collectMarkdownFiles(dir);
|
|
134
|
+
const imported = [];
|
|
135
|
+
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
const parsed = parseMarkdownFile(file);
|
|
138
|
+
const note = insertNote(db, parsed);
|
|
139
|
+
note.source = relative(dir, file);
|
|
140
|
+
imported.push(note);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return imported;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Evernote ENEX Import ───────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Parse an ENEX (Evernote Export) XML string into note objects.
|
|
150
|
+
* ENEX format: <en-export><note><title>...</title><content>...</content><tag>...</tag></note>...</en-export>
|
|
151
|
+
*/
|
|
152
|
+
export function parseEnex(xmlString) {
|
|
153
|
+
const notes = [];
|
|
154
|
+
const noteRegex = /<note>([\s\S]*?)<\/note>/gi;
|
|
155
|
+
let noteMatch;
|
|
156
|
+
|
|
157
|
+
while ((noteMatch = noteRegex.exec(xmlString)) !== null) {
|
|
158
|
+
const noteXml = noteMatch[1];
|
|
159
|
+
|
|
160
|
+
const titleMatch = noteXml.match(/<title>([\s\S]*?)<\/title>/i);
|
|
161
|
+
const title = titleMatch ? titleMatch[1].trim() : "Untitled";
|
|
162
|
+
|
|
163
|
+
// Content is wrapped in CDATA inside <content>
|
|
164
|
+
const contentMatch = noteXml.match(/<content>([\s\S]*?)<\/content>/i);
|
|
165
|
+
let content = "";
|
|
166
|
+
if (contentMatch) {
|
|
167
|
+
let raw = contentMatch[1];
|
|
168
|
+
// Strip CDATA wrapper
|
|
169
|
+
const cdataMatch = raw.match(/<!\[CDATA\[([\s\S]*?)\]\]>/);
|
|
170
|
+
if (cdataMatch) raw = cdataMatch[1];
|
|
171
|
+
// Strip ENML/HTML tags for plain text
|
|
172
|
+
content = stripHtml(raw);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Tags
|
|
176
|
+
const tags = [];
|
|
177
|
+
const tagRegex = /<tag>([\s\S]*?)<\/tag>/gi;
|
|
178
|
+
let tagMatch;
|
|
179
|
+
while ((tagMatch = tagRegex.exec(noteXml)) !== null) {
|
|
180
|
+
tags.push(tagMatch[1].trim());
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Created date
|
|
184
|
+
const createdMatch = noteXml.match(/<created>([\s\S]*?)<\/created>/i);
|
|
185
|
+
let createdAt = null;
|
|
186
|
+
if (createdMatch) {
|
|
187
|
+
// ENEX dates: 20210315T120000Z → 2021-03-15 12:00:00
|
|
188
|
+
const d = createdMatch[1].trim();
|
|
189
|
+
const parsed = d.match(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/);
|
|
190
|
+
if (parsed) {
|
|
191
|
+
createdAt = `${parsed[1]}-${parsed[2]}-${parsed[3]} ${parsed[4]}:${parsed[5]}:${parsed[6]}`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
notes.push({ title, content, tags, category: "evernote", createdAt });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return notes;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Strip HTML/ENML tags to plain text.
|
|
203
|
+
*/
|
|
204
|
+
export function stripHtml(html) {
|
|
205
|
+
return html
|
|
206
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
207
|
+
.replace(/<\/p>/gi, "\n")
|
|
208
|
+
.replace(/<\/div>/gi, "\n")
|
|
209
|
+
.replace(/<\/li>/gi, "\n")
|
|
210
|
+
.replace(/<[^>]+>/g, "")
|
|
211
|
+
.replace(/ /g, " ")
|
|
212
|
+
.replace(/&/g, "&")
|
|
213
|
+
.replace(/</g, "<")
|
|
214
|
+
.replace(/>/g, ">")
|
|
215
|
+
.replace(/"/g, '"')
|
|
216
|
+
.replace(/'/g, "'")
|
|
217
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
218
|
+
.trim();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Import an Evernote ENEX file into the database.
|
|
223
|
+
*/
|
|
224
|
+
export function importEnexFile(db, filePath) {
|
|
225
|
+
ensureNotesTable(db);
|
|
226
|
+
const xmlString = readFileSync(filePath, "utf-8");
|
|
227
|
+
const parsed = parseEnex(xmlString);
|
|
228
|
+
const imported = [];
|
|
229
|
+
|
|
230
|
+
for (const note of parsed) {
|
|
231
|
+
const result = insertNote(db, note);
|
|
232
|
+
imported.push(result);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return imported;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Notion Export Import ───────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Parse a Notion export directory.
|
|
242
|
+
* Notion exports contain markdown files and may have metadata in filenames
|
|
243
|
+
* or accompanying JSON/CSV files.
|
|
244
|
+
*/
|
|
245
|
+
export function parseNotionExport(dir) {
|
|
246
|
+
const notes = [];
|
|
247
|
+
const files = collectMarkdownFiles(dir);
|
|
248
|
+
|
|
249
|
+
for (const file of files) {
|
|
250
|
+
const raw = readFileSync(file, "utf-8");
|
|
251
|
+
const fileName = basename(file, ".md");
|
|
252
|
+
|
|
253
|
+
// Notion filenames often have a UUID suffix: "My Page abc123def456"
|
|
254
|
+
// Remove the hex suffix to get the clean title
|
|
255
|
+
const title = fileName.replace(/\s+[a-f0-9]{32}$/i, "") || fileName;
|
|
256
|
+
|
|
257
|
+
// Notion uses # for the first heading, which is usually the page title
|
|
258
|
+
let content = raw;
|
|
259
|
+
const h1Match = raw.match(/^#\s+(.+)\n([\s\S]*)$/);
|
|
260
|
+
if (h1Match) {
|
|
261
|
+
content = h1Match[2].trim();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Try to detect tags from Notion properties (sometimes at the top)
|
|
265
|
+
const tags = [];
|
|
266
|
+
const relPath = relative(dir, file);
|
|
267
|
+
const parts = relPath.split(/[/\\]/);
|
|
268
|
+
if (parts.length > 1) {
|
|
269
|
+
// Use parent folder as a tag
|
|
270
|
+
tags.push(parts[0]);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
notes.push({ title, content, tags, category: "notion", createdAt: null });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return notes;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Import a Notion export directory into the database.
|
|
281
|
+
*/
|
|
282
|
+
export function importNotionDir(db, dir) {
|
|
283
|
+
ensureNotesTable(db);
|
|
284
|
+
const parsed = parseNotionExport(dir);
|
|
285
|
+
const imported = [];
|
|
286
|
+
|
|
287
|
+
for (const note of parsed) {
|
|
288
|
+
const result = insertNote(db, note);
|
|
289
|
+
imported.push(result);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return imported;
|
|
293
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Provider registry — supports multiple AI providers with a unified interface.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Built-in provider definitions.
|
|
7
|
+
*/
|
|
8
|
+
export const BUILT_IN_PROVIDERS = {
|
|
9
|
+
ollama: {
|
|
10
|
+
name: "ollama",
|
|
11
|
+
displayName: "Ollama (Local)",
|
|
12
|
+
baseUrl: "http://localhost:11434",
|
|
13
|
+
apiKeyEnv: null,
|
|
14
|
+
models: ["qwen2:7b", "llama3:8b", "mistral:7b", "codellama:7b"],
|
|
15
|
+
free: true,
|
|
16
|
+
},
|
|
17
|
+
openai: {
|
|
18
|
+
name: "openai",
|
|
19
|
+
displayName: "OpenAI",
|
|
20
|
+
baseUrl: "https://api.openai.com/v1",
|
|
21
|
+
apiKeyEnv: "OPENAI_API_KEY",
|
|
22
|
+
models: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo", "o1"],
|
|
23
|
+
free: false,
|
|
24
|
+
},
|
|
25
|
+
anthropic: {
|
|
26
|
+
name: "anthropic",
|
|
27
|
+
displayName: "Anthropic",
|
|
28
|
+
baseUrl: "https://api.anthropic.com/v1",
|
|
29
|
+
apiKeyEnv: "ANTHROPIC_API_KEY",
|
|
30
|
+
models: [
|
|
31
|
+
"claude-opus-4-6",
|
|
32
|
+
"claude-sonnet-4-6",
|
|
33
|
+
"claude-haiku-4-5-20251001",
|
|
34
|
+
],
|
|
35
|
+
free: false,
|
|
36
|
+
},
|
|
37
|
+
deepseek: {
|
|
38
|
+
name: "deepseek",
|
|
39
|
+
displayName: "DeepSeek",
|
|
40
|
+
baseUrl: "https://api.deepseek.com/v1",
|
|
41
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
42
|
+
models: ["deepseek-chat", "deepseek-coder", "deepseek-reasoner"],
|
|
43
|
+
free: false,
|
|
44
|
+
},
|
|
45
|
+
dashscope: {
|
|
46
|
+
name: "dashscope",
|
|
47
|
+
displayName: "DashScope (Alibaba)",
|
|
48
|
+
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
49
|
+
apiKeyEnv: "DASHSCOPE_API_KEY",
|
|
50
|
+
models: ["qwen-turbo", "qwen-plus", "qwen-max"],
|
|
51
|
+
free: false,
|
|
52
|
+
},
|
|
53
|
+
gemini: {
|
|
54
|
+
name: "gemini",
|
|
55
|
+
displayName: "Google Gemini",
|
|
56
|
+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
57
|
+
apiKeyEnv: "GEMINI_API_KEY",
|
|
58
|
+
models: ["gemini-2.0-flash", "gemini-2.0-pro", "gemini-1.5-flash"],
|
|
59
|
+
free: false,
|
|
60
|
+
},
|
|
61
|
+
mistral: {
|
|
62
|
+
name: "mistral",
|
|
63
|
+
displayName: "Mistral AI",
|
|
64
|
+
baseUrl: "https://api.mistral.ai/v1",
|
|
65
|
+
apiKeyEnv: "MISTRAL_API_KEY",
|
|
66
|
+
models: [
|
|
67
|
+
"mistral-large-latest",
|
|
68
|
+
"mistral-medium-latest",
|
|
69
|
+
"mistral-small-latest",
|
|
70
|
+
],
|
|
71
|
+
free: false,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Provider registry — manages available providers and active selection.
|
|
77
|
+
*/
|
|
78
|
+
export class LLMProviderRegistry {
|
|
79
|
+
constructor(db) {
|
|
80
|
+
this.db = db;
|
|
81
|
+
this.providers = new Map();
|
|
82
|
+
this._ensureTable();
|
|
83
|
+
this._loadBuiltins();
|
|
84
|
+
this._loadCustom();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_ensureTable() {
|
|
88
|
+
this.db.exec(`
|
|
89
|
+
CREATE TABLE IF NOT EXISTS llm_providers (
|
|
90
|
+
name TEXT PRIMARY KEY,
|
|
91
|
+
display_name TEXT NOT NULL,
|
|
92
|
+
base_url TEXT NOT NULL,
|
|
93
|
+
api_key_env TEXT,
|
|
94
|
+
models TEXT DEFAULT '[]',
|
|
95
|
+
is_active INTEGER DEFAULT 0,
|
|
96
|
+
custom INTEGER DEFAULT 0,
|
|
97
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
98
|
+
)
|
|
99
|
+
`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_loadBuiltins() {
|
|
103
|
+
for (const [name, def] of Object.entries(BUILT_IN_PROVIDERS)) {
|
|
104
|
+
this.providers.set(name, { ...def, custom: false });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
_loadCustom() {
|
|
109
|
+
const rows = this.db
|
|
110
|
+
.prepare("SELECT * FROM llm_providers WHERE custom = 1")
|
|
111
|
+
.all();
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
this.providers.set(row.name, {
|
|
114
|
+
name: row.name,
|
|
115
|
+
displayName: row.display_name,
|
|
116
|
+
baseUrl: row.base_url,
|
|
117
|
+
apiKeyEnv: row.api_key_env,
|
|
118
|
+
models: JSON.parse(row.models || "[]"),
|
|
119
|
+
custom: true,
|
|
120
|
+
free: false,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* List all providers.
|
|
127
|
+
*/
|
|
128
|
+
list() {
|
|
129
|
+
const result = [];
|
|
130
|
+
for (const [name, provider] of this.providers) {
|
|
131
|
+
const hasKey = provider.apiKeyEnv
|
|
132
|
+
? !!process.env[provider.apiKeyEnv]
|
|
133
|
+
: true;
|
|
134
|
+
result.push({
|
|
135
|
+
name,
|
|
136
|
+
displayName: provider.displayName,
|
|
137
|
+
baseUrl: provider.baseUrl,
|
|
138
|
+
models: provider.models,
|
|
139
|
+
hasApiKey: hasKey,
|
|
140
|
+
custom: provider.custom || false,
|
|
141
|
+
free: provider.free || false,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get a specific provider.
|
|
149
|
+
*/
|
|
150
|
+
get(name) {
|
|
151
|
+
return this.providers.get(name) || null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Add a custom provider.
|
|
156
|
+
*/
|
|
157
|
+
addProvider(name, config) {
|
|
158
|
+
const provider = {
|
|
159
|
+
name,
|
|
160
|
+
displayName: config.displayName || name,
|
|
161
|
+
baseUrl: config.baseUrl,
|
|
162
|
+
apiKeyEnv: config.apiKeyEnv || null,
|
|
163
|
+
models: config.models || [],
|
|
164
|
+
custom: true,
|
|
165
|
+
free: config.free || false,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
this.db
|
|
169
|
+
.prepare(
|
|
170
|
+
"INSERT OR REPLACE INTO llm_providers (name, display_name, base_url, api_key_env, models, custom) VALUES (?, ?, ?, ?, ?, 1)",
|
|
171
|
+
)
|
|
172
|
+
.run(
|
|
173
|
+
name,
|
|
174
|
+
provider.displayName,
|
|
175
|
+
provider.baseUrl,
|
|
176
|
+
provider.apiKeyEnv,
|
|
177
|
+
JSON.stringify(provider.models),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
this.providers.set(name, provider);
|
|
181
|
+
return provider;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Remove a custom provider.
|
|
186
|
+
*/
|
|
187
|
+
removeProvider(name) {
|
|
188
|
+
const provider = this.providers.get(name);
|
|
189
|
+
if (!provider) return false;
|
|
190
|
+
if (!provider.custom)
|
|
191
|
+
throw new Error(`Cannot remove built-in provider "${name}"`);
|
|
192
|
+
|
|
193
|
+
this.db
|
|
194
|
+
.prepare("DELETE FROM llm_providers WHERE name = ? AND custom = 1")
|
|
195
|
+
.run(name);
|
|
196
|
+
this.providers.delete(name);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get/set the active provider.
|
|
202
|
+
*/
|
|
203
|
+
getActive() {
|
|
204
|
+
const row = this.db
|
|
205
|
+
.prepare("SELECT name FROM llm_providers WHERE is_active = 1")
|
|
206
|
+
.get();
|
|
207
|
+
return row ? row.name : "ollama";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
setActive(name) {
|
|
211
|
+
if (!this.providers.has(name)) {
|
|
212
|
+
throw new Error(`Provider "${name}" not found`);
|
|
213
|
+
}
|
|
214
|
+
// Reset all
|
|
215
|
+
this.db.prepare("UPDATE llm_providers SET is_active = 0 WHERE 1=1").run();
|
|
216
|
+
// Set active
|
|
217
|
+
this.db
|
|
218
|
+
.prepare(
|
|
219
|
+
"INSERT OR REPLACE INTO llm_providers (name, display_name, base_url, api_key_env, models, is_active) VALUES (?, ?, ?, ?, ?, 1)",
|
|
220
|
+
)
|
|
221
|
+
.run(
|
|
222
|
+
name,
|
|
223
|
+
this.providers.get(name).displayName,
|
|
224
|
+
this.providers.get(name).baseUrl,
|
|
225
|
+
this.providers.get(name).apiKeyEnv,
|
|
226
|
+
JSON.stringify(this.providers.get(name).models),
|
|
227
|
+
);
|
|
228
|
+
return this.providers.get(name);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get API key for a provider (from env or config).
|
|
233
|
+
*/
|
|
234
|
+
getApiKey(name) {
|
|
235
|
+
const provider = this.providers.get(name);
|
|
236
|
+
if (!provider) return null;
|
|
237
|
+
if (!provider.apiKeyEnv) return null;
|
|
238
|
+
return process.env[provider.apiKeyEnv] || null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Test provider connectivity.
|
|
243
|
+
*/
|
|
244
|
+
async testProvider(name, model) {
|
|
245
|
+
const provider = this.providers.get(name);
|
|
246
|
+
if (!provider) throw new Error(`Provider "${name}" not found`);
|
|
247
|
+
|
|
248
|
+
const start = Date.now();
|
|
249
|
+
const testModel = model || provider.models[0];
|
|
250
|
+
|
|
251
|
+
if (name === "ollama") {
|
|
252
|
+
const res = await fetch(`${provider.baseUrl}/api/generate`, {
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers: { "Content-Type": "application/json" },
|
|
255
|
+
body: JSON.stringify({ model: testModel, prompt: "Hi", stream: false }),
|
|
256
|
+
});
|
|
257
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
258
|
+
const data = await res.json();
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
elapsed: Date.now() - start,
|
|
262
|
+
response: data.response?.trim(),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (name === "gemini") {
|
|
267
|
+
const key = this.getApiKey(name);
|
|
268
|
+
if (!key) throw new Error("GEMINI_API_KEY not set");
|
|
269
|
+
const res = await fetch(
|
|
270
|
+
`${provider.baseUrl}/models/${testModel}:generateContent?key=${key}`,
|
|
271
|
+
{
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { "Content-Type": "application/json" },
|
|
274
|
+
body: JSON.stringify({ contents: [{ parts: [{ text: "Hi" }] }] }),
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
278
|
+
const data = await res.json();
|
|
279
|
+
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
|
280
|
+
return { ok: true, elapsed: Date.now() - start, response: text.trim() };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (name === "anthropic") {
|
|
284
|
+
const key = this.getApiKey(name);
|
|
285
|
+
if (!key) throw new Error("ANTHROPIC_API_KEY not set");
|
|
286
|
+
const res = await fetch(`${provider.baseUrl}/messages`, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: {
|
|
289
|
+
"Content-Type": "application/json",
|
|
290
|
+
"x-api-key": key,
|
|
291
|
+
"anthropic-version": "2023-06-01",
|
|
292
|
+
},
|
|
293
|
+
body: JSON.stringify({
|
|
294
|
+
model: testModel,
|
|
295
|
+
max_tokens: 10,
|
|
296
|
+
messages: [{ role: "user", content: "Hi" }],
|
|
297
|
+
}),
|
|
298
|
+
});
|
|
299
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
300
|
+
const data = await res.json();
|
|
301
|
+
const text = data.content?.[0]?.text || "";
|
|
302
|
+
return { ok: true, elapsed: Date.now() - start, response: text.trim() };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// OpenAI-compatible (openai, deepseek, dashscope, mistral)
|
|
306
|
+
const key = this.getApiKey(name);
|
|
307
|
+
if (!key) throw new Error(`${provider.apiKeyEnv} not set`);
|
|
308
|
+
const res = await fetch(`${provider.baseUrl}/chat/completions`, {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: {
|
|
311
|
+
"Content-Type": "application/json",
|
|
312
|
+
Authorization: `Bearer ${key}`,
|
|
313
|
+
},
|
|
314
|
+
body: JSON.stringify({
|
|
315
|
+
model: testModel,
|
|
316
|
+
messages: [{ role: "user", content: "Hi" }],
|
|
317
|
+
max_tokens: 10,
|
|
318
|
+
}),
|
|
319
|
+
});
|
|
320
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
321
|
+
const data = await res.json();
|
|
322
|
+
const text = data.choices?.[0]?.message?.content || "";
|
|
323
|
+
return { ok: true, elapsed: Date.now() - start, response: text.trim() };
|
|
324
|
+
}
|
|
325
|
+
}
|