@yesvara/svara 0.1.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/LICENSE +21 -0
- package/README.md +497 -0
- package/dist/chunk-CIESM3BP.mjs +33 -0
- package/dist/chunk-FEA5KIJN.mjs +418 -0
- package/dist/cli/index.d.mts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +328 -0
- package/dist/cli/index.mjs +39 -0
- package/dist/dev-OYGXXK2B.mjs +69 -0
- package/dist/index.d.mts +967 -0
- package/dist/index.d.ts +967 -0
- package/dist/index.js +1976 -0
- package/dist/index.mjs +1502 -0
- package/dist/new-7K4NIDZO.mjs +177 -0
- package/dist/retriever-4QY667XF.mjs +7 -0
- package/examples/01-basic/index.ts +26 -0
- package/examples/02-with-tools/index.ts +73 -0
- package/examples/03-rag-knowledge/index.ts +41 -0
- package/examples/04-multi-channel/index.ts +91 -0
- package/package.json +74 -0
- package/src/app/index.ts +176 -0
- package/src/channels/telegram.ts +122 -0
- package/src/channels/web.ts +118 -0
- package/src/channels/whatsapp.ts +161 -0
- package/src/cli/commands/dev.ts +87 -0
- package/src/cli/commands/new.ts +213 -0
- package/src/cli/index.ts +78 -0
- package/src/core/agent.ts +607 -0
- package/src/core/llm.ts +406 -0
- package/src/core/types.ts +183 -0
- package/src/database/schema.ts +79 -0
- package/src/database/sqlite.ts +239 -0
- package/src/index.ts +94 -0
- package/src/memory/context.ts +49 -0
- package/src/memory/conversation.ts +51 -0
- package/src/rag/chunker.ts +165 -0
- package/src/rag/loader.ts +216 -0
- package/src/rag/retriever.ts +248 -0
- package/src/tools/executor.ts +54 -0
- package/src/tools/index.ts +89 -0
- package/src/tools/registry.ts +44 -0
- package/src/types.ts +131 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module rag/loader
|
|
3
|
+
* SvaraJS — Document loader
|
|
4
|
+
*
|
|
5
|
+
* Loads documents from various sources into a normalized format.
|
|
6
|
+
* Supported formats: TXT, MD, PDF, DOCX, HTML, JSON
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const loader = new DocumentLoader();
|
|
10
|
+
* const docs = await loader.loadMany(['./docs/*.pdf', './knowledge/*.txt']);
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs/promises';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import crypto from 'crypto';
|
|
16
|
+
import type { Document, DocumentType } from '../core/types.js';
|
|
17
|
+
|
|
18
|
+
// ─── Loader Strategy Interface ────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
interface FileLoader {
|
|
21
|
+
extensions: string[];
|
|
22
|
+
load(filePath: string): Promise<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Plain Text Loader ────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
class TextFileLoader implements FileLoader {
|
|
28
|
+
extensions = ['.txt', '.md', '.mdx', '.rst', '.csv', '.log'];
|
|
29
|
+
|
|
30
|
+
async load(filePath: string): Promise<string> {
|
|
31
|
+
return fs.readFile(filePath, 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── JSON Loader ──────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
class JsonFileLoader implements FileLoader {
|
|
38
|
+
extensions = ['.json', '.jsonl'];
|
|
39
|
+
|
|
40
|
+
async load(filePath: string): Promise<string> {
|
|
41
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
42
|
+
|
|
43
|
+
if (path.extname(filePath) === '.jsonl') {
|
|
44
|
+
// JSON Lines — one JSON object per line
|
|
45
|
+
return raw
|
|
46
|
+
.split('\n')
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map((line) => {
|
|
49
|
+
const obj = JSON.parse(line) as Record<string, unknown>;
|
|
50
|
+
return Object.values(obj).join(' ');
|
|
51
|
+
})
|
|
52
|
+
.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const data = JSON.parse(raw);
|
|
56
|
+
return JSON.stringify(data, null, 2);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── HTML Loader ──────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
class HtmlFileLoader implements FileLoader {
|
|
63
|
+
extensions = ['.html', '.htm'];
|
|
64
|
+
|
|
65
|
+
async load(filePath: string): Promise<string> {
|
|
66
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
67
|
+
// Strip HTML tags — simple regex, good enough for RAG
|
|
68
|
+
return raw
|
|
69
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
70
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
71
|
+
.replace(/<[^>]+>/g, ' ')
|
|
72
|
+
.replace(/\s+/g, ' ')
|
|
73
|
+
.trim();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── PDF Loader ───────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
class PdfFileLoader implements FileLoader {
|
|
80
|
+
extensions = ['.pdf'];
|
|
81
|
+
|
|
82
|
+
async load(filePath: string): Promise<string> {
|
|
83
|
+
try {
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
85
|
+
const pdfParse = require('pdf-parse') as (buffer: Buffer) => Promise<{ text: string }>;
|
|
86
|
+
const buffer = await fs.readFile(filePath);
|
|
87
|
+
const data = await pdfParse(buffer);
|
|
88
|
+
return data.text;
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error(
|
|
91
|
+
'[SvaraJS] PDF loading requires the "pdf-parse" package.\n' +
|
|
92
|
+
'Run: npm install pdf-parse'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── DOCX Loader ──────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
class DocxFileLoader implements FileLoader {
|
|
101
|
+
extensions = ['.docx'];
|
|
102
|
+
|
|
103
|
+
async load(filePath: string): Promise<string> {
|
|
104
|
+
try {
|
|
105
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
106
|
+
const mammoth = require('mammoth') as {
|
|
107
|
+
extractRawText: (opts: { path: string }) => Promise<{ value: string }>;
|
|
108
|
+
};
|
|
109
|
+
const result = await mammoth.extractRawText({ path: filePath });
|
|
110
|
+
return result.value;
|
|
111
|
+
} catch {
|
|
112
|
+
throw new Error(
|
|
113
|
+
'[SvaraJS] DOCX loading requires the "mammoth" package.\n' +
|
|
114
|
+
'Run: npm install mammoth'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── DocumentLoader ───────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export class DocumentLoader {
|
|
123
|
+
private loaders: FileLoader[];
|
|
124
|
+
private extensionMap: Map<string, FileLoader>;
|
|
125
|
+
|
|
126
|
+
constructor() {
|
|
127
|
+
this.loaders = [
|
|
128
|
+
new TextFileLoader(),
|
|
129
|
+
new JsonFileLoader(),
|
|
130
|
+
new HtmlFileLoader(),
|
|
131
|
+
new PdfFileLoader(),
|
|
132
|
+
new DocxFileLoader(),
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
this.extensionMap = new Map();
|
|
136
|
+
for (const loader of this.loaders) {
|
|
137
|
+
for (const ext of loader.extensions) {
|
|
138
|
+
this.extensionMap.set(ext, loader);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Load a single file into a Document.
|
|
145
|
+
*/
|
|
146
|
+
async load(filePath: string): Promise<Document> {
|
|
147
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
148
|
+
const loader = this.extensionMap.get(ext);
|
|
149
|
+
|
|
150
|
+
if (!loader) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`[SvaraJS] Unsupported file type: "${ext}". ` +
|
|
153
|
+
`Supported: ${[...this.extensionMap.keys()].join(', ')}`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const content = await loader.load(filePath);
|
|
158
|
+
const stats = await fs.stat(filePath);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
id: this.hashFile(filePath),
|
|
162
|
+
content,
|
|
163
|
+
type: this.detectType(ext),
|
|
164
|
+
source: filePath,
|
|
165
|
+
metadata: {
|
|
166
|
+
filename: path.basename(filePath),
|
|
167
|
+
extension: ext,
|
|
168
|
+
size: stats.size,
|
|
169
|
+
lastModified: stats.mtime.toISOString(),
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Load multiple files. Silently skips unreadable files with a warning.
|
|
176
|
+
*/
|
|
177
|
+
async loadMany(filePaths: string[]): Promise<Document[]> {
|
|
178
|
+
const results: Document[] = [];
|
|
179
|
+
|
|
180
|
+
for (const filePath of filePaths) {
|
|
181
|
+
try {
|
|
182
|
+
const doc = await this.load(filePath);
|
|
183
|
+
results.push(doc);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.warn(`[SvaraJS:RAG] Skipping "${filePath}": ${(err as Error).message}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return results;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Check if this loader supports a given file extension. */
|
|
193
|
+
supports(filePath: string): boolean {
|
|
194
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
195
|
+
return this.extensionMap.has(ext);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private detectType(ext: string): DocumentType {
|
|
199
|
+
const map: Record<string, DocumentType> = {
|
|
200
|
+
'.txt': 'text',
|
|
201
|
+
'.md': 'markdown',
|
|
202
|
+
'.mdx': 'markdown',
|
|
203
|
+
'.pdf': 'pdf',
|
|
204
|
+
'.html': 'html',
|
|
205
|
+
'.htm': 'html',
|
|
206
|
+
'.json': 'json',
|
|
207
|
+
'.jsonl': 'json',
|
|
208
|
+
'.docx': 'docx',
|
|
209
|
+
};
|
|
210
|
+
return map[ext] ?? 'text';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private hashFile(filePath: string): string {
|
|
214
|
+
return crypto.createHash('md5').update(filePath).digest('hex');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module rag/retriever
|
|
3
|
+
* SvaraJS — Vector retrieval for RAG
|
|
4
|
+
*
|
|
5
|
+
* Embeds document chunks and performs similarity search.
|
|
6
|
+
* Uses in-memory vector store by default (great for dev),
|
|
7
|
+
* with SQLite persistence for production.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const retriever = new VectorRetriever();
|
|
11
|
+
* await retriever.init({ embeddings: { provider: 'openai' } });
|
|
12
|
+
* await retriever.addDocuments(['./docs/*.pdf']);
|
|
13
|
+
*
|
|
14
|
+
* const context = await retriever.retrieve('What is the refund policy?');
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { RAGConfig, DocumentChunk, RetrievedContext } from '../core/types.js';
|
|
18
|
+
import type { RAGRetriever } from '../core/agent.js';
|
|
19
|
+
import { DocumentLoader } from './loader.js';
|
|
20
|
+
import { Chunker } from './chunker.js';
|
|
21
|
+
|
|
22
|
+
// ─── Embedding Interface ──────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
interface EmbeddingProvider {
|
|
25
|
+
embed(texts: string[]): Promise<number[][]>;
|
|
26
|
+
embedOne(text: string): Promise<number[]>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── OpenAI Embeddings ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
class OpenAIEmbeddings implements EmbeddingProvider {
|
|
32
|
+
private client: unknown;
|
|
33
|
+
private model: string;
|
|
34
|
+
|
|
35
|
+
constructor(apiKey?: string, model = 'text-embedding-3-small') {
|
|
36
|
+
this.model = model;
|
|
37
|
+
try {
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
|
+
const { default: OpenAI } = require('openai');
|
|
40
|
+
this.client = new OpenAI({ apiKey: apiKey ?? process.env.OPENAI_API_KEY });
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error('[SvaraJS] OpenAI embeddings require the "openai" package.');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async embed(texts: string[]): Promise<number[][]> {
|
|
47
|
+
const client = this.client as {
|
|
48
|
+
embeddings: {
|
|
49
|
+
create: (params: unknown) => Promise<{ data: Array<{ embedding: number[] }> }>;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Batch to avoid rate limits (max 2048 inputs per request)
|
|
54
|
+
const BATCH_SIZE = 100;
|
|
55
|
+
const results: number[][] = [];
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
|
|
58
|
+
const batch = texts.slice(i, i + BATCH_SIZE);
|
|
59
|
+
const response = await client.embeddings.create({
|
|
60
|
+
model: this.model,
|
|
61
|
+
input: batch,
|
|
62
|
+
});
|
|
63
|
+
results.push(...response.data.map((d) => d.embedding));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async embedOne(text: string): Promise<number[]> {
|
|
70
|
+
const [embedding] = await this.embed([text]);
|
|
71
|
+
return embedding;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Ollama Embeddings (local) ────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
class OllamaEmbeddings implements EmbeddingProvider {
|
|
78
|
+
private baseURL: string;
|
|
79
|
+
private model: string;
|
|
80
|
+
|
|
81
|
+
constructor(model = 'nomic-embed-text', baseURL = 'http://localhost:11434') {
|
|
82
|
+
this.model = model;
|
|
83
|
+
this.baseURL = baseURL;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async embed(texts: string[]): Promise<number[][]> {
|
|
87
|
+
return Promise.all(texts.map((t) => this.embedOne(t)));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async embedOne(text: string): Promise<number[]> {
|
|
91
|
+
const response = await fetch(`${this.baseURL}/api/embeddings`, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { 'Content-Type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({ model: this.model, prompt: text }),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`[SvaraJS] Ollama embeddings failed: ${response.statusText}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const data = await response.json() as { embedding: number[] };
|
|
102
|
+
return data.embedding;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── In-Memory Vector Store ───────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
interface VectorEntry {
|
|
109
|
+
chunk: DocumentChunk;
|
|
110
|
+
embedding: number[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
class InMemoryVectorStore {
|
|
114
|
+
private entries: VectorEntry[] = [];
|
|
115
|
+
|
|
116
|
+
add(chunk: DocumentChunk, embedding: number[]): void {
|
|
117
|
+
// Replace if same chunk id
|
|
118
|
+
const existing = this.entries.findIndex((e) => e.chunk.id === chunk.id);
|
|
119
|
+
if (existing >= 0) {
|
|
120
|
+
this.entries[existing] = { chunk, embedding };
|
|
121
|
+
} else {
|
|
122
|
+
this.entries.push({ chunk, embedding });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
search(queryEmbedding: number[], topK: number, threshold = 0): DocumentChunk[] {
|
|
127
|
+
const scored = this.entries.map((entry) => ({
|
|
128
|
+
chunk: entry.chunk,
|
|
129
|
+
score: cosineSimilarity(queryEmbedding, entry.embedding),
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
return scored
|
|
133
|
+
.filter((s) => s.score >= threshold)
|
|
134
|
+
.sort((a, b) => b.score - a.score)
|
|
135
|
+
.slice(0, topK)
|
|
136
|
+
.map((s) => s.chunk);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get size(): number {
|
|
140
|
+
return this.entries.length;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── VectorRetriever ─────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export class VectorRetriever implements RAGRetriever {
|
|
147
|
+
private embedder!: EmbeddingProvider;
|
|
148
|
+
private store: InMemoryVectorStore;
|
|
149
|
+
private loader: DocumentLoader;
|
|
150
|
+
private chunker: Chunker;
|
|
151
|
+
private config!: RAGConfig;
|
|
152
|
+
|
|
153
|
+
constructor() {
|
|
154
|
+
this.store = new InMemoryVectorStore();
|
|
155
|
+
this.loader = new DocumentLoader();
|
|
156
|
+
this.chunker = new Chunker();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async init(config: RAGConfig): Promise<void> {
|
|
160
|
+
this.config = config;
|
|
161
|
+
|
|
162
|
+
// Init chunker with config
|
|
163
|
+
if (config.chunking) {
|
|
164
|
+
this.chunker = new Chunker({
|
|
165
|
+
strategy: config.chunking.strategy ?? 'sentence',
|
|
166
|
+
size: config.chunking.size ? config.chunking.size * 4 : 2000, // rough token→char
|
|
167
|
+
overlap: config.chunking.overlap ? config.chunking.overlap * 4 : 200,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Init embeddings provider
|
|
172
|
+
const emb = config.embeddings ?? { provider: 'openai' };
|
|
173
|
+
switch (emb.provider) {
|
|
174
|
+
case 'openai':
|
|
175
|
+
this.embedder = new OpenAIEmbeddings(emb.apiKey, emb.model);
|
|
176
|
+
break;
|
|
177
|
+
case 'ollama':
|
|
178
|
+
this.embedder = new OllamaEmbeddings(emb.model);
|
|
179
|
+
break;
|
|
180
|
+
default:
|
|
181
|
+
throw new Error(`[SvaraJS] Unknown embeddings provider: "${emb.provider}"`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async addDocuments(filePaths: string[]): Promise<void> {
|
|
186
|
+
const documents = await this.loader.loadMany(filePaths);
|
|
187
|
+
if (!documents.length) return;
|
|
188
|
+
|
|
189
|
+
const chunks = this.chunker.chunkMany(documents);
|
|
190
|
+
if (!chunks.length) return;
|
|
191
|
+
|
|
192
|
+
console.log(`[SvaraJS:RAG] Embedding ${chunks.length} chunk(s)...`);
|
|
193
|
+
const embeddings = await this.embedder.embed(chunks.map((c) => c.content));
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
196
|
+
this.store.add(chunks[i], embeddings[i]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log(`[SvaraJS:RAG] Vector store now has ${this.store.size} chunk(s).`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async retrieve(query: string, topK = 5): Promise<string> {
|
|
203
|
+
if (this.store.size === 0) return '';
|
|
204
|
+
|
|
205
|
+
const queryEmbedding = await this.embedder.embedOne(query);
|
|
206
|
+
const threshold = this.config.retrieval?.threshold ?? 0.3;
|
|
207
|
+
|
|
208
|
+
const chunks = this.store.search(queryEmbedding, topK, threshold);
|
|
209
|
+
|
|
210
|
+
if (!chunks.length) return '';
|
|
211
|
+
|
|
212
|
+
// Format chunks into a context string
|
|
213
|
+
return chunks
|
|
214
|
+
.map((chunk, i) => `[Context ${i + 1}]\nSource: ${String(chunk.metadata.filename ?? chunk.documentId)}\n${chunk.content}`)
|
|
215
|
+
.join('\n\n---\n\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async retrieveChunks(query: string, topK = 5): Promise<RetrievedContext> {
|
|
219
|
+
const queryEmbedding = await this.embedder.embedOne(query);
|
|
220
|
+
const threshold = this.config.retrieval?.threshold ?? 0.3;
|
|
221
|
+
const chunks = this.store.search(queryEmbedding, topK, threshold);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
chunks,
|
|
225
|
+
query,
|
|
226
|
+
totalFound: chunks.length,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Math Utils ──────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function cosineSimilarity(a: number[], b: number[]): number {
|
|
234
|
+
if (a.length !== b.length) return 0;
|
|
235
|
+
|
|
236
|
+
let dot = 0;
|
|
237
|
+
let normA = 0;
|
|
238
|
+
let normB = 0;
|
|
239
|
+
|
|
240
|
+
for (let i = 0; i < a.length; i++) {
|
|
241
|
+
dot += a[i] * b[i];
|
|
242
|
+
normA += a[i] * a[i];
|
|
243
|
+
normB += b[i] * b[i];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
|
|
247
|
+
return denominator === 0 ? 0 : dot / denominator;
|
|
248
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @internal
|
|
3
|
+
* Tool executor — runs tool calls with timeout protection and error isolation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { LLMToolCall, ToolExecution, InternalAgentContext } from '../core/types.js';
|
|
7
|
+
import type { ToolRegistry } from './registry.js';
|
|
8
|
+
|
|
9
|
+
export class ToolExecutor {
|
|
10
|
+
constructor(private registry: ToolRegistry) {}
|
|
11
|
+
|
|
12
|
+
async execute(call: LLMToolCall, ctx: InternalAgentContext): Promise<ToolExecution> {
|
|
13
|
+
const start = Date.now();
|
|
14
|
+
const tool = this.registry.get(call.name);
|
|
15
|
+
|
|
16
|
+
if (!tool) {
|
|
17
|
+
return {
|
|
18
|
+
toolCallId: call.id,
|
|
19
|
+
name: call.name,
|
|
20
|
+
result: null,
|
|
21
|
+
error: `Tool "${call.name}" is not registered. Available: ${this.registry.getAll().map((t) => t.name).join(', ')}`,
|
|
22
|
+
duration: Date.now() - start,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const result = await Promise.race([
|
|
28
|
+
tool.run(call.arguments, ctx),
|
|
29
|
+
this.timeout(tool.timeout ?? 30_000, tool.name),
|
|
30
|
+
]);
|
|
31
|
+
return { toolCallId: call.id, name: call.name, result, duration: Date.now() - start };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const message = (err as Error).message;
|
|
34
|
+
console.error(`[@yesvara/svara] Tool "${call.name}" failed: ${message}`);
|
|
35
|
+
return {
|
|
36
|
+
toolCallId: call.id,
|
|
37
|
+
name: call.name,
|
|
38
|
+
result: null,
|
|
39
|
+
error: message,
|
|
40
|
+
duration: Date.now() - start,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async executeAll(calls: LLMToolCall[], ctx: InternalAgentContext): Promise<ToolExecution[]> {
|
|
46
|
+
return Promise.all(calls.map((c) => this.execute(c, ctx)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private timeout(ms: number, name: string): Promise<never> {
|
|
50
|
+
return new Promise((_, reject) =>
|
|
51
|
+
setTimeout(() => reject(new Error(`Tool "${name}" timed out after ${ms}ms`)), ms)
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tools
|
|
3
|
+
*
|
|
4
|
+
* `createTool` — the elegant way to define tools for your agent.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { createTool } from '@yesvara/svara';
|
|
9
|
+
*
|
|
10
|
+
* const weatherTool = createTool({
|
|
11
|
+
* name: 'get_weather',
|
|
12
|
+
* description: 'Get the current weather for a location',
|
|
13
|
+
* parameters: {
|
|
14
|
+
* city: { type: 'string', description: 'City name', required: true },
|
|
15
|
+
* units: { type: 'string', description: 'Temperature units', enum: ['celsius', 'fahrenheit'] },
|
|
16
|
+
* },
|
|
17
|
+
* async run({ city, units = 'celsius' }) {
|
|
18
|
+
* const data = await fetchWeather(city as string);
|
|
19
|
+
* return { temp: data.temp, condition: data.description, city };
|
|
20
|
+
* },
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* agent.addTool(weatherTool);
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { Tool } from '../types.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a type-safe tool definition with IDE autocomplete.
|
|
31
|
+
*
|
|
32
|
+
* This is a convenience wrapper — it validates your tool at definition time
|
|
33
|
+
* and returns a properly typed `Tool` object.
|
|
34
|
+
*
|
|
35
|
+
* @example Basic tool
|
|
36
|
+
* ```ts
|
|
37
|
+
* const timeTool = createTool({
|
|
38
|
+
* name: 'get_current_time',
|
|
39
|
+
* description: 'Get the current date and time',
|
|
40
|
+
* parameters: {},
|
|
41
|
+
* async run() {
|
|
42
|
+
* return { datetime: new Date().toISOString() };
|
|
43
|
+
* },
|
|
44
|
+
* });
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @example Tool with parameters and error handling
|
|
48
|
+
* ```ts
|
|
49
|
+
* const searchTool = createTool({
|
|
50
|
+
* name: 'search_database',
|
|
51
|
+
* description: 'Search for records in the database',
|
|
52
|
+
* parameters: {
|
|
53
|
+
* query: { type: 'string', description: 'Search query', required: true },
|
|
54
|
+
* limit: { type: 'number', description: 'Max results to return' },
|
|
55
|
+
* },
|
|
56
|
+
* async run({ query, limit = 10 }, ctx) {
|
|
57
|
+
* console.log(`[${ctx.agentName}] Searching for: ${query}`);
|
|
58
|
+
* const results = await db.search(String(query), Number(limit));
|
|
59
|
+
* return { results, count: results.length };
|
|
60
|
+
* },
|
|
61
|
+
* timeout: 10_000, // 10 seconds
|
|
62
|
+
* });
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function createTool(definition: Tool): Tool {
|
|
66
|
+
// Validate at definition time — fail fast, not at runtime
|
|
67
|
+
if (!definition.name?.trim()) {
|
|
68
|
+
throw new Error('[@yesvara/svara] createTool: "name" is required.');
|
|
69
|
+
}
|
|
70
|
+
if (!definition.description?.trim()) {
|
|
71
|
+
throw new Error(`[@yesvara/svara] createTool "${definition.name}": "description" is required.`);
|
|
72
|
+
}
|
|
73
|
+
if (typeof definition.run !== 'function') {
|
|
74
|
+
throw new Error(`[@yesvara/svara] createTool "${definition.name}": "run" must be a function.`);
|
|
75
|
+
}
|
|
76
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(definition.name)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`[@yesvara/svara] createTool: Invalid tool name "${definition.name}". ` +
|
|
79
|
+
'Use only letters, numbers, underscores, or hyphens.'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
parameters: {},
|
|
85
|
+
...definition,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type { Tool } from '../types.js';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @internal
|
|
3
|
+
* Tool registry — stores and validates InternalTool definitions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { InternalTool } from '../core/types.js';
|
|
7
|
+
|
|
8
|
+
export class ToolRegistry {
|
|
9
|
+
private tools: Map<string, InternalTool> = new Map();
|
|
10
|
+
|
|
11
|
+
register(tool: InternalTool): void {
|
|
12
|
+
if (this.tools.has(tool.name)) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`[@yesvara/svara] Tool "${tool.name}" is already registered. ` +
|
|
15
|
+
'Use a different name or call registry.update() to replace it.'
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
this.tools.set(tool.name, tool);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
update(tool: InternalTool): void {
|
|
22
|
+
this.tools.set(tool.name, tool);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
unregister(name: string): void {
|
|
26
|
+
this.tools.delete(name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get(name: string): InternalTool | undefined {
|
|
30
|
+
return this.tools.get(name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getAll(): InternalTool[] {
|
|
34
|
+
return [...this.tools.values()];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
has(name: string): boolean {
|
|
38
|
+
return this.tools.has(name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get size(): number {
|
|
42
|
+
return this.tools.size;
|
|
43
|
+
}
|
|
44
|
+
}
|