lynkr 9.0.1 → 9.1.2
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 +70 -21
- package/bin/cli.js +34 -4
- package/bin/lynkr-trajectory.js +136 -0
- package/bin/lynkr-usage.js +219 -0
- package/funding.json +110 -0
- package/index.js +7 -3
- package/install.sh +3 -3
- package/lynkr-skill.tar.gz +0 -0
- package/native/Cargo.toml +26 -0
- package/native/index.js +29 -0
- package/native/lynkr-native.node +0 -0
- package/native/src/lib.rs +321 -0
- package/package.json +6 -5
- package/public/dashboard.html +665 -0
- package/src/api/files-multipart.js +30 -0
- package/src/api/files-router.js +81 -0
- package/src/api/middleware/budget.js +19 -1
- package/src/api/middleware/load-shedding.js +17 -0
- package/src/api/openai-router.js +353 -301
- package/src/api/router.js +275 -40
- package/src/cache/prompt.js +13 -0
- package/src/clients/databricks.js +42 -18
- package/src/clients/ollama-utils.js +21 -17
- package/src/clients/openai-format.js +50 -10
- package/src/clients/openrouter-utils.js +42 -37
- package/src/clients/prompt-cache-injection.js +140 -0
- package/src/clients/provider-capabilities.js +41 -0
- package/src/clients/responses-format.js +8 -7
- package/src/clients/standard-tools.js +1 -1
- package/src/clients/xml-tool-extractor.js +307 -0
- package/src/cluster.js +82 -0
- package/src/config/index.js +16 -0
- package/src/context/distill.js +15 -0
- package/src/context/tool-result-compressor.js +563 -0
- package/src/dashboard/api.js +170 -0
- package/src/dashboard/router.js +13 -0
- package/src/headroom/client.js +3 -109
- package/src/headroom/index.js +0 -14
- package/src/memory/extractor.js +22 -0
- package/src/memory/search.js +0 -50
- package/src/orchestrator/index.js +163 -204
- package/src/orchestrator/preflight.js +188 -0
- package/src/routing/index.js +64 -32
- package/src/routing/interaction.js +183 -0
- package/src/routing/risk-analyzer.js +194 -0
- package/src/routing/telemetry.js +47 -2
- package/src/server.js +15 -0
- package/src/stores/file-store.js +104 -0
- package/src/stores/response-store.js +25 -0
- package/src/tools/index.js +1 -1
- package/src/tools/smart-selection.js +11 -2
- package/src/tools/web.js +1 -1
- package/src/training/trajectory-compressor.js +266 -0
- package/src/usage/aggregator.js +206 -0
- package/src/utils/markdown-ansi.js +146 -0
- package/.lynkr/telemetry.db +0 -0
- package/.lynkr/telemetry.db-shm +0 -0
- package/.lynkr/telemetry.db-wal +0 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const config = require('../config');
|
|
2
|
+
const telemetry = require('../routing/telemetry');
|
|
3
|
+
const { getUsage } = require('../usage/aggregator');
|
|
4
|
+
const metrics = require('../metrics');
|
|
5
|
+
const { getMetricsCollector } = require('../observability/metrics');
|
|
6
|
+
const { TIER_DEFINITIONS } = require('../routing/model-tiers');
|
|
7
|
+
|
|
8
|
+
function getConfiguredProviders() {
|
|
9
|
+
const c = config;
|
|
10
|
+
const providers = [];
|
|
11
|
+
const add = (name, type, ok) => ok && providers.push({ name, type });
|
|
12
|
+
|
|
13
|
+
add('databricks', 'cloud', c.databricks?.url && c.databricks?.apiKey);
|
|
14
|
+
add('azure-anthropic','cloud', c.azureAnthropic?.endpoint && c.azureAnthropic?.apiKey);
|
|
15
|
+
add('bedrock', 'cloud', c.bedrock?.apiKey);
|
|
16
|
+
add('openrouter', 'cloud', c.openrouter?.apiKey);
|
|
17
|
+
add('openai', 'cloud', c.openai?.apiKey);
|
|
18
|
+
add('azure-openai', 'cloud', c.azureOpenAI?.endpoint && c.azureOpenAI?.apiKey);
|
|
19
|
+
add('vertex', 'cloud', c.vertex?.projectId);
|
|
20
|
+
add('moonshot', 'cloud', c.moonshot?.apiKey);
|
|
21
|
+
add('ollama', 'local', c.ollama?.endpoint);
|
|
22
|
+
add('llamacpp', 'local', c.llamacpp?.endpoint);
|
|
23
|
+
add('lmstudio', 'local', c.lmstudio?.endpoint);
|
|
24
|
+
|
|
25
|
+
return providers;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Noise provider names injected by unit tests — filter them out of UI
|
|
29
|
+
const TEST_PROVIDER_RE = /^(accuracy-|stats-|provider-stats-|roundtrip-|latency-)/;
|
|
30
|
+
|
|
31
|
+
// Find the widest window that has at least one row, so the UI never shows
|
|
32
|
+
// empty panels just because there were no requests in the last 24 hours.
|
|
33
|
+
function findActiveWindow() {
|
|
34
|
+
const newest = telemetry.query({ limit: 1 });
|
|
35
|
+
if (!newest.length) return { since: Date.now() - 86400000, label: '24h' };
|
|
36
|
+
|
|
37
|
+
const ageMs = Date.now() - newest[0].timestamp;
|
|
38
|
+
if (ageMs <= 86400000) return { since: Date.now() - 86400000, label: '24h' };
|
|
39
|
+
if (ageMs <= 7*86400000) return { since: Date.now() - 7*86400000, label: '7d' };
|
|
40
|
+
if (ageMs <= 30*86400000) return { since: Date.now() - 30*86400000, label: '30d' };
|
|
41
|
+
return { since: 0, label: 'all time' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getCircuitBreakerStates() {
|
|
45
|
+
try {
|
|
46
|
+
const { getCircuitBreakerRegistry } = require('../clients/circuit-breaker');
|
|
47
|
+
const reg = getCircuitBreakerRegistry();
|
|
48
|
+
return reg.getAll();
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Group telemetry rows by calendar day (UTC), returning last `days` buckets
|
|
55
|
+
function dailyBreakdown(rows, days = 7) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const DAY = 86400000;
|
|
58
|
+
const result = [];
|
|
59
|
+
|
|
60
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
61
|
+
const start = now - (i + 1) * DAY;
|
|
62
|
+
const end = now - i * DAY;
|
|
63
|
+
const bucket = rows.filter(r => r.timestamp >= start && r.timestamp < end);
|
|
64
|
+
|
|
65
|
+
const byTier = {};
|
|
66
|
+
let cost = 0;
|
|
67
|
+
for (const r of bucket) {
|
|
68
|
+
const t = r.tier || 'UNKNOWN';
|
|
69
|
+
byTier[t] = (byTier[t] || 0) + 1;
|
|
70
|
+
cost += Number(r.cost_usd) || 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
result.push({
|
|
74
|
+
label: new Date(start).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
|
75
|
+
total: bucket.length,
|
|
76
|
+
byTier,
|
|
77
|
+
cost: Math.round(cost * 10000) / 10000,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function overview(req, res) {
|
|
84
|
+
const win = findActiveWindow();
|
|
85
|
+
const todayUsage = getUsage({ window: win.label === '24h' ? '1d' : win.label === 'all time' ? 'all' : win.label });
|
|
86
|
+
const recentRows = telemetry.query({ limit: 10 });
|
|
87
|
+
const todayStats = telemetry.getStats({ since: win.since });
|
|
88
|
+
const snap = metrics.snapshot();
|
|
89
|
+
|
|
90
|
+
res.json({
|
|
91
|
+
uptime: Math.floor(process.uptime()),
|
|
92
|
+
port: config.port,
|
|
93
|
+
version: process.env.npm_package_version || '9.0.2',
|
|
94
|
+
modelProvider: config.modelProvider?.type || 'unknown',
|
|
95
|
+
providers: getConfiguredProviders(),
|
|
96
|
+
statsWindow: win.label,
|
|
97
|
+
metrics: {
|
|
98
|
+
requestsTotal: snap.requestsTotal,
|
|
99
|
+
responsesSuccess: snap.responses?.success || 0,
|
|
100
|
+
responsesError: snap.responses?.error || 0,
|
|
101
|
+
},
|
|
102
|
+
today: {
|
|
103
|
+
requests: todayUsage.totals?.requests || 0,
|
|
104
|
+
totalTokens: todayUsage.totals?.totalTokens || 0,
|
|
105
|
+
cost: todayUsage.totals?.actualCost || 0,
|
|
106
|
+
saved: todayUsage.totals?.saved || 0,
|
|
107
|
+
savedPercent: todayUsage.totals?.savedPercent || 0,
|
|
108
|
+
},
|
|
109
|
+
stats: todayStats,
|
|
110
|
+
recentRequests: recentRows,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function usage(req, res) {
|
|
115
|
+
const window = req.query.window || '7d';
|
|
116
|
+
const provider = req.query.provider || undefined;
|
|
117
|
+
const model = req.query.model || undefined;
|
|
118
|
+
|
|
119
|
+
const data = getUsage({ window, provider, model });
|
|
120
|
+
|
|
121
|
+
// Add daily breakdown for chart (last 7 or 30 days depending on window)
|
|
122
|
+
const days = window === '1d' ? 1 : window === '30d' ? 30 : 7;
|
|
123
|
+
const since = window === 'all' ? 0 : Date.now() - days * 86400000;
|
|
124
|
+
const rawRows = since > 0
|
|
125
|
+
? telemetry.query({ since, limit: 50000 })
|
|
126
|
+
: telemetry.query({ limit: 50000 });
|
|
127
|
+
|
|
128
|
+
data.daily = dailyBreakdown(rawRows, Math.min(days, 30));
|
|
129
|
+
|
|
130
|
+
res.json(data);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function routing(req, res) {
|
|
134
|
+
const win = findActiveWindow();
|
|
135
|
+
const { since } = win;
|
|
136
|
+
|
|
137
|
+
const accuracy = telemetry.getRoutingAccuracy({ since });
|
|
138
|
+
const stats = telemetry.getStats({ since });
|
|
139
|
+
const cbStates = getCircuitBreakerStates();
|
|
140
|
+
|
|
141
|
+
// Derive providers from actual DB rows — never miss a provider not in config
|
|
142
|
+
const dbRows = telemetry.query({ limit: 100000, since });
|
|
143
|
+
const dbProviders = [...new Set(
|
|
144
|
+
dbRows.map(r => r.provider).filter(p => p && !TEST_PROVIDER_RE.test(p))
|
|
145
|
+
)];
|
|
146
|
+
|
|
147
|
+
const providerStats = {};
|
|
148
|
+
for (const p of dbProviders) {
|
|
149
|
+
const s = telemetry.getProviderStats(p, { since });
|
|
150
|
+
if (s) providerStats[p] = s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
res.json({ tierDefinitions: TIER_DEFINITIONS, accuracy, stats, providerStats, circuitBreakers: cbStates, window: win.label });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function logs(req, res) {
|
|
157
|
+
const limit = Math.min(parseInt(req.query.limit || '100', 10), 500);
|
|
158
|
+
const filters = { limit };
|
|
159
|
+
|
|
160
|
+
if (req.query.provider) filters.provider = req.query.provider;
|
|
161
|
+
if (req.query.tier) filters.tier = req.query.tier;
|
|
162
|
+
if (req.query.since) filters.since = parseInt(req.query.since, 10);
|
|
163
|
+
|
|
164
|
+
let rows = telemetry.query(filters);
|
|
165
|
+
if (req.query.error === 'true') rows = rows.filter(r => r.error_type);
|
|
166
|
+
|
|
167
|
+
res.json(rows);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = { overview, usage, routing, logs };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const api = require('./api');
|
|
4
|
+
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
|
|
7
|
+
router.get('/', (_req, res) => res.sendFile(path.join(__dirname, '../../public/dashboard.html')));
|
|
8
|
+
router.get('/api/overview', api.overview);
|
|
9
|
+
router.get('/api/usage', api.usage);
|
|
10
|
+
router.get('/api/routing', api.routing);
|
|
11
|
+
router.get('/api/logs', api.logs);
|
|
12
|
+
|
|
13
|
+
module.exports = router;
|
package/src/headroom/client.js
CHANGED
|
@@ -58,6 +58,7 @@ async function checkHealth() {
|
|
|
58
58
|
return {
|
|
59
59
|
available: data.headroom_loaded === true,
|
|
60
60
|
status: data.status,
|
|
61
|
+
version: data.headroom_version,
|
|
61
62
|
ccrEnabled: data.ccr_enabled,
|
|
62
63
|
llmlinguaEnabled: data.llmlingua_enabled,
|
|
63
64
|
entriesCached: data.entries_cached,
|
|
@@ -154,8 +155,10 @@ async function compressMessages(messages, tools = [], options = {}) {
|
|
|
154
155
|
tokensBefore: result.stats?.tokens_before,
|
|
155
156
|
tokensAfter: result.stats?.tokens_after,
|
|
156
157
|
savingsPercent: result.stats?.savings_percent,
|
|
158
|
+
compressionRatio: result.stats?.compression_ratio,
|
|
157
159
|
latencyMs: result.stats?.latency_ms,
|
|
158
160
|
transforms: result.stats?.transforms_applied,
|
|
161
|
+
headroomVersion: result.stats?.headroom_version,
|
|
159
162
|
},
|
|
160
163
|
"Headroom compression applied"
|
|
161
164
|
);
|
|
@@ -245,112 +248,6 @@ async function ccrRetrieve(hash, query = null, maxResults = 20) {
|
|
|
245
248
|
}
|
|
246
249
|
}
|
|
247
250
|
|
|
248
|
-
/**
|
|
249
|
-
* Track compression for proactive CCR expansion
|
|
250
|
-
*/
|
|
251
|
-
async function ccrTrack(hashKey, turnNumber, toolName, sample) {
|
|
252
|
-
const headroomConfig = getConfig();
|
|
253
|
-
|
|
254
|
-
if (!isEnabled()) {
|
|
255
|
-
return { tracked: false };
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
try {
|
|
259
|
-
const params = new URLSearchParams({
|
|
260
|
-
hash_key: hashKey,
|
|
261
|
-
turn_number: String(turnNumber),
|
|
262
|
-
tool_name: toolName,
|
|
263
|
-
sample: sample.substring(0, 500),
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
const response = await fetch(`${headroomConfig.endpoint}/ccr/track?${params}`, {
|
|
267
|
-
method: "POST",
|
|
268
|
-
signal: AbortSignal.timeout(2000),
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
if (response.ok) {
|
|
272
|
-
return await response.json();
|
|
273
|
-
}
|
|
274
|
-
return { tracked: false };
|
|
275
|
-
} catch (err) {
|
|
276
|
-
logger.debug({ error: err.message }, "CCR tracking failed");
|
|
277
|
-
return { tracked: false };
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Analyze query for proactive CCR expansion
|
|
283
|
-
*/
|
|
284
|
-
async function ccrAnalyze(query, turnNumber) {
|
|
285
|
-
const headroomConfig = getConfig();
|
|
286
|
-
|
|
287
|
-
if (!isEnabled()) {
|
|
288
|
-
return { expansions: [] };
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
try {
|
|
292
|
-
const response = await fetch(`${headroomConfig.endpoint}/ccr/analyze`, {
|
|
293
|
-
method: "POST",
|
|
294
|
-
headers: { "Content-Type": "application/json" },
|
|
295
|
-
body: JSON.stringify({ query, turn_number: turnNumber }),
|
|
296
|
-
signal: AbortSignal.timeout(2000),
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
if (response.ok) {
|
|
300
|
-
return await response.json();
|
|
301
|
-
}
|
|
302
|
-
return { expansions: [] };
|
|
303
|
-
} catch (err) {
|
|
304
|
-
logger.debug({ error: err.message }, "CCR analysis failed");
|
|
305
|
-
return { expansions: [] };
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Compress text using LLMLingua-2 ML compression
|
|
311
|
-
* (Optional - requires LLMLingua enabled in sidecar)
|
|
312
|
-
*/
|
|
313
|
-
async function llmlinguaCompress(text, targetRatio = 0.5, forceTokens = null) {
|
|
314
|
-
const headroomConfig = getConfig();
|
|
315
|
-
|
|
316
|
-
if (!isEnabled()) {
|
|
317
|
-
return { success: false, error: "Headroom disabled" };
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
try {
|
|
321
|
-
const params = new URLSearchParams({
|
|
322
|
-
text,
|
|
323
|
-
target_ratio: String(targetRatio),
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
if (forceTokens && Array.isArray(forceTokens)) {
|
|
327
|
-
params.append("force_tokens", JSON.stringify(forceTokens));
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const response = await fetch(`${headroomConfig.endpoint}/compress/llmlingua?${params}`, {
|
|
331
|
-
method: "POST",
|
|
332
|
-
signal: AbortSignal.timeout(30000), // LLMLingua can be slow
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
if (!response.ok) {
|
|
336
|
-
const error = await response.text();
|
|
337
|
-
return { success: false, error };
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const result = await response.json();
|
|
341
|
-
return {
|
|
342
|
-
success: true,
|
|
343
|
-
compressed: result.compressed,
|
|
344
|
-
originalTokens: result.original_tokens,
|
|
345
|
-
compressedTokens: result.compressed_tokens,
|
|
346
|
-
ratio: result.ratio,
|
|
347
|
-
};
|
|
348
|
-
} catch (err) {
|
|
349
|
-
logger.error({ error: err.message }, "LLMLingua compression failed");
|
|
350
|
-
return { success: false, error: err.message };
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
251
|
/**
|
|
355
252
|
* Get client-side metrics
|
|
356
253
|
*/
|
|
@@ -424,9 +321,6 @@ module.exports = {
|
|
|
424
321
|
checkHealth,
|
|
425
322
|
compressMessages,
|
|
426
323
|
ccrRetrieve,
|
|
427
|
-
ccrTrack,
|
|
428
|
-
ccrAnalyze,
|
|
429
|
-
llmlinguaCompress,
|
|
430
324
|
getMetrics,
|
|
431
325
|
getServerMetrics,
|
|
432
326
|
getCombinedMetrics,
|
package/src/headroom/index.js
CHANGED
|
@@ -125,20 +125,6 @@ class HeadroomManager {
|
|
|
125
125
|
return client.ccrRetrieve(hash, query, maxResults);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
/**
|
|
129
|
-
* Track compression for proactive expansion
|
|
130
|
-
*/
|
|
131
|
-
async ccrTrack(hashKey, turnNumber, toolName, sample) {
|
|
132
|
-
return client.ccrTrack(hashKey, turnNumber, toolName, sample);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Analyze query for proactive CCR expansion
|
|
137
|
-
*/
|
|
138
|
-
async ccrAnalyze(query, turnNumber) {
|
|
139
|
-
return client.ccrAnalyze(query, turnNumber);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
128
|
/**
|
|
143
129
|
* Check if Headroom is enabled
|
|
144
130
|
*/
|
package/src/memory/extractor.js
CHANGED
|
@@ -312,6 +312,28 @@ async function createMemoryWithSurprise(options) {
|
|
|
312
312
|
|
|
313
313
|
return memory;
|
|
314
314
|
} catch (err) {
|
|
315
|
+
// FK constraint fails when session is ephemeral (passthrough mode) —
|
|
316
|
+
// retry without session link so the memory still gets saved
|
|
317
|
+
if (err.code === 'SQLITE_CONSTRAINT_FOREIGNKEY' && sessionId) {
|
|
318
|
+
try {
|
|
319
|
+
return store.createMemory({
|
|
320
|
+
sessionId: null,
|
|
321
|
+
content,
|
|
322
|
+
type,
|
|
323
|
+
category,
|
|
324
|
+
importance,
|
|
325
|
+
surpriseScore,
|
|
326
|
+
metadata: {
|
|
327
|
+
...metadata,
|
|
328
|
+
extractedAt: Date.now(),
|
|
329
|
+
originalSessionId: sessionId,
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
} catch (retryErr) {
|
|
333
|
+
logger.warn({ err: retryErr, content }, 'Failed to store memory (retry without session)');
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
315
337
|
logger.warn({ err, content }, 'Failed to store memory');
|
|
316
338
|
return null;
|
|
317
339
|
}
|
package/src/memory/search.js
CHANGED
|
@@ -258,58 +258,8 @@ function searchMemories(options) {
|
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
/**
|
|
262
|
-
|
|
263
|
-
* Search with keyword expansion (UPDATED - now uses sanitized keywords)
|
|
264
|
-
=======
|
|
265
|
-
* Prepare FTS5 query - handle special characters and phrases
|
|
266
|
-
*/
|
|
267
|
-
function prepareFTS5Query(query) {
|
|
268
|
-
// FTS5 special characters: " * ( ) < > - : AND OR NOT
|
|
269
|
-
// Strategy: Strip XML/HTML tags, then sanitize remaining text
|
|
270
|
-
let cleaned = query.trim();
|
|
271
|
-
|
|
272
|
-
// Step 1: Remove XML/HTML tags (common in error messages)
|
|
273
|
-
// Matches: <tag>, </tag>, <tag attr="value">
|
|
274
|
-
cleaned = cleaned.replace(/<[^>]+>/g, ' ');
|
|
275
|
-
|
|
276
|
-
// Step 2: Remove excess whitespace from tag removal
|
|
277
|
-
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
278
|
-
|
|
279
|
-
if (!cleaned) {
|
|
280
|
-
// Query was all tags, return safe fallback
|
|
281
|
-
return '"empty query"';
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Step 3: Check if query contains FTS5 operators (AND, OR, NOT)
|
|
285
|
-
const hasFTS5Operators = /\b(AND|OR|NOT)\b/i.test(cleaned);
|
|
286
|
-
|
|
287
|
-
// Step 4: ENHANCED - Remove ALL special characters that could break FTS5
|
|
288
|
-
// Keep only: letters, numbers, spaces
|
|
289
|
-
// Remove: * ( ) < > - : [ ] | , + = ? ! ; / \ @ # $ % ^ & { }
|
|
290
|
-
cleaned = cleaned.replace(/[*()<>\-:\[\]|,+=?!;\/\\@#$%^&{}]/g, ' ');
|
|
291
|
-
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
292
|
-
|
|
293
|
-
// Step 5: Escape double quotes (FTS5 uses "" for literal quote)
|
|
294
|
-
cleaned = cleaned.replace(/"/g, '""');
|
|
295
|
-
|
|
296
|
-
// Step 6: Additional safety - remove any remaining non-alphanumeric except spaces
|
|
297
|
-
cleaned = cleaned.replace(/[^\w\s""]/g, ' ');
|
|
298
|
-
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
299
|
-
|
|
300
|
-
// Step 7: Wrap in quotes for phrase search (safest approach)
|
|
301
|
-
if (!hasFTS5Operators) {
|
|
302
|
-
// Treat as literal phrase search
|
|
303
|
-
cleaned = `"${cleaned}"`;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// If query has FTS5 operators, let FTS5 parse them (advanced users)
|
|
307
|
-
return cleaned;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
261
|
/**
|
|
311
262
|
* Search with keyword expansion (extract key terms)
|
|
312
|
-
|
|
313
263
|
*/
|
|
314
264
|
function searchWithExpansion(options) {
|
|
315
265
|
const { query, limit = 10 } = options;
|