lynkr 9.7.2 → 9.9.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.
Files changed (80) hide show
  1. package/README.md +29 -19
  2. package/bin/cli.js +11 -0
  3. package/bin/lynkr-init.js +14 -1
  4. package/bin/lynkr-usage.js +78 -0
  5. package/bin/wrap.js +60 -35
  6. package/config/difficulty-anchors.json +22 -0
  7. package/package.json +24 -3
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/calibrate-thresholds.js +38 -157
  10. package/scripts/compact-dictionary.js +204 -0
  11. package/scripts/test-deduplication.js +448 -0
  12. package/scripts/ws7-anchor-replay.js +108 -0
  13. package/skills/lynkr/SKILL.md +195 -0
  14. package/src/agents/context-manager.js +18 -2
  15. package/src/agents/definitions/loader.js +90 -0
  16. package/src/agents/executor.js +24 -2
  17. package/src/agents/index.js +9 -1
  18. package/src/agents/parallel-coordinator.js +2 -2
  19. package/src/agents/reflector.js +11 -1
  20. package/src/api/middleware/loop-guard.js +87 -0
  21. package/src/api/middleware/request-logging.js +5 -64
  22. package/src/api/middleware/session.js +0 -0
  23. package/src/api/openai-router.js +120 -101
  24. package/src/api/providers-handler.js +27 -2
  25. package/src/api/router.js +450 -125
  26. package/src/budget/index.js +2 -19
  27. package/src/cache/semantic.js +9 -0
  28. package/src/clients/databricks.js +459 -146
  29. package/src/clients/gpt-utils.js +11 -105
  30. package/src/clients/openai-format.js +10 -3
  31. package/src/clients/openrouter-utils.js +49 -24
  32. package/src/clients/prompt-cache-injection.js +1 -0
  33. package/src/clients/provider-capabilities.js +1 -1
  34. package/src/clients/responses-format.js +34 -3
  35. package/src/clients/routing.js +15 -0
  36. package/src/config/index.js +36 -2
  37. package/src/context/gcf.js +275 -0
  38. package/src/context/tool-result-compressor.js +51 -9
  39. package/src/dashboard/api.js +1 -0
  40. package/src/logger/index.js +14 -1
  41. package/src/memory/search.js +12 -40
  42. package/src/memory/tools.js +3 -24
  43. package/src/orchestrator/bypass.js +4 -2
  44. package/src/orchestrator/index.js +144 -88
  45. package/src/routing/affinity-store.js +194 -0
  46. package/src/routing/agentic-detector.js +36 -6
  47. package/src/routing/bandit.js +25 -6
  48. package/src/routing/calibration.js +212 -0
  49. package/src/routing/client-profiles.js +292 -0
  50. package/src/routing/complexity-analyzer.js +48 -11
  51. package/src/routing/deescalator.js +148 -0
  52. package/src/routing/degradation.js +109 -0
  53. package/src/routing/feedback.js +157 -0
  54. package/src/routing/index.js +897 -87
  55. package/src/routing/intent-score.js +339 -0
  56. package/src/routing/interaction.js +3 -0
  57. package/src/routing/knn-router.js +70 -21
  58. package/src/routing/model-registry.js +28 -7
  59. package/src/routing/model-tiers.js +25 -2
  60. package/src/routing/reward-pipeline.js +68 -2
  61. package/src/routing/risk-analyzer.js +30 -1
  62. package/src/routing/risk-classifier.js +6 -2
  63. package/src/routing/session-affinity.js +162 -34
  64. package/src/routing/telemetry.js +298 -10
  65. package/src/routing/verifier.js +267 -0
  66. package/src/server.js +66 -21
  67. package/src/sessions/cleanup.js +17 -0
  68. package/src/tools/index.js +1 -15
  69. package/src/tools/smart-selection.js +10 -0
  70. package/src/tools/web-client.js +3 -3
  71. package/.eslintrc.cjs +0 -12
  72. package/benchmark-configs/litellm_config.yaml +0 -86
  73. package/benchmark-configs/lynkr.env +0 -48
  74. package/benchmark-configs/portkey-config.json +0 -60
  75. package/benchmark-configs/portkey-docker.sh +0 -23
  76. package/benchmark-tier-routing.js +0 -449
  77. package/funding.json +0 -110
  78. package/src/api/middleware/validation.js +0 -261
  79. package/src/routing/drift-monitor.js +0 -113
  80. package/src/workers/helpers.js +0 -185
@@ -0,0 +1,275 @@
1
+ const logger = require("../logger");
2
+ const { countTokens } = require("../routing/tokenizer");
3
+
4
+ // GCF (Graph Compact Format) context compression. Drop-in alternative to the
5
+ // TOON adapter (src/context/toon.js): same encode-only, fail-open,
6
+ // read-only-context contract; only the encoder differs. Round-trips losslessly.
7
+ //
8
+ // @blackwell-systems/gcf ships a CommonJS build, so the encoder is resolved with
9
+ // a synchronous require and cached.
10
+
11
+ let cachedEncode;
12
+ let cachedDecode;
13
+ let cachedLoadError;
14
+ let warnedMissingDependency = false;
15
+
16
+ // A clear byte reduction reliably implies a token reduction, so blobs that shrink by
17
+ // at least this margin convert without paying to tokenize both strings. Blobs in the
18
+ // ambiguous zone (or larger) fall through to the exact token comparison.
19
+ const BYTE_FASTPATH_RATIO = 0.9;
20
+
21
+ function normaliseSettings(settings = {}) {
22
+ const minBytesRaw =
23
+ typeof settings.minBytes === "number" ? settings.minBytes : Number.parseInt(settings.minBytes ?? "4096", 10);
24
+ return {
25
+ enabled: settings.enabled === true,
26
+ minBytes: Number.isFinite(minBytesRaw) && minBytesRaw > 0 ? minBytesRaw : 4096,
27
+ failOpen: settings.failOpen !== false,
28
+ logStats: settings.logStats !== false,
29
+ verify: settings.verify !== false,
30
+ };
31
+ }
32
+
33
+ // Order-insensitive for object keys, order-sensitive for arrays: the round-trip
34
+ // check treats two JSON values as equal when they carry the same data.
35
+ function deepEqual(a, b) {
36
+ if (a === b) return true;
37
+ if (a === null || b === null || typeof a !== typeof b) return false;
38
+ if (Array.isArray(a)) {
39
+ if (!Array.isArray(b) || a.length !== b.length) return false;
40
+ return a.every((v, i) => deepEqual(v, b[i]));
41
+ }
42
+ if (typeof a === "object") {
43
+ const ka = Object.keys(a);
44
+ const kb = Object.keys(b);
45
+ if (ka.length !== kb.length) return false;
46
+ return ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
47
+ }
48
+ return false;
49
+ }
50
+
51
+ function resolveEncodeFn(overrideEncode) {
52
+ if (typeof overrideEncode === "function") return overrideEncode;
53
+ if (cachedEncode !== undefined) return cachedEncode;
54
+ try {
55
+ const mod = require("@blackwell-systems/gcf");
56
+ const fn = mod?.encodeGeneric ?? mod?.default?.encodeGeneric ?? null;
57
+ cachedEncode = typeof fn === "function" ? fn : null;
58
+ cachedLoadError = cachedEncode
59
+ ? null
60
+ : new Error("Missing encodeGeneric() export from @blackwell-systems/gcf");
61
+ } catch (err) {
62
+ cachedEncode = null;
63
+ cachedLoadError = err;
64
+ }
65
+ return cachedEncode;
66
+ }
67
+
68
+ function resolveDecodeFn(overrideDecode) {
69
+ if (typeof overrideDecode === "function") return overrideDecode;
70
+ if (cachedDecode !== undefined) return cachedDecode;
71
+ try {
72
+ const mod = require("@blackwell-systems/gcf");
73
+ const fn = mod?.decodeGeneric ?? mod?.default?.decodeGeneric ?? null;
74
+ cachedDecode = typeof fn === "function" ? fn : null;
75
+ } catch {
76
+ cachedDecode = null;
77
+ }
78
+ return cachedDecode;
79
+ }
80
+
81
+ function looksLikeJsonObjectOrArray(text) {
82
+ if (typeof text !== "string") return false;
83
+ const trimmed = text.trim();
84
+ if (trimmed.length < 2) return false;
85
+ return (
86
+ (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
87
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))
88
+ );
89
+ }
90
+
91
+ function safeJsonParse(text) {
92
+ try {
93
+ return JSON.parse(text);
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function toGcfString(encodeFn, value) {
100
+ const encoded = encodeFn(value);
101
+ if (typeof encoded === "string") return encoded;
102
+ if (encoded && typeof encoded[Symbol.iterator] === "function") {
103
+ return Array.from(encoded).join("\n");
104
+ }
105
+ return "";
106
+ }
107
+
108
+ function compressStringContent(content, cfg, encodeFn, decodeFn, stats, model) {
109
+ if (typeof content !== "string") return content;
110
+
111
+ const originalBytes = Buffer.byteLength(content, "utf8");
112
+ if (originalBytes < cfg.minBytes) {
113
+ stats.skippedBySize += 1;
114
+ return content;
115
+ }
116
+
117
+ stats.candidateCount += 1;
118
+ if (!looksLikeJsonObjectOrArray(content)) {
119
+ stats.skippedByShape += 1;
120
+ return content;
121
+ }
122
+
123
+ const parsed = safeJsonParse(content);
124
+ if (!parsed || typeof parsed !== "object") {
125
+ stats.skippedByParse += 1;
126
+ return content;
127
+ }
128
+
129
+ const gcfText = toGcfString(encodeFn, parsed);
130
+ if (typeof gcfText !== "string" || gcfText.trim().length === 0) {
131
+ return content;
132
+ }
133
+
134
+ const gcfBytes = Buffer.byteLength(gcfText, "utf8");
135
+
136
+ // Fast path: a clear byte reduction reliably means a token reduction too, so skip
137
+ // the (per-blob, hot-path) token comparison.
138
+ if (gcfBytes <= originalBytes * BYTE_FASTPATH_RATIO) {
139
+ if (!verifiesLossless(parsed, gcfText, cfg, decodeFn, stats)) return content;
140
+ stats.convertedCount += 1;
141
+ stats.originalBytes += originalBytes;
142
+ stats.compressedBytes += gcfBytes;
143
+ return gcfText;
144
+ }
145
+
146
+ // Ambiguous zone (or larger in bytes): compare token counts with the target model's
147
+ // encoding, and keep the original JSON if GCF does not reduce the token count.
148
+ const originalTokens = countTokens(content, model);
149
+ const gcfTokens = countTokens(gcfText, model);
150
+ if (gcfTokens >= originalTokens) {
151
+ stats.skippedByGrowth += 1;
152
+ return content;
153
+ }
154
+
155
+ if (!verifiesLossless(parsed, gcfText, cfg, decodeFn, stats)) return content;
156
+ stats.convertedCount += 1;
157
+ stats.originalBytes += originalBytes;
158
+ stats.compressedBytes += gcfBytes;
159
+ stats.originalTokens += originalTokens;
160
+ stats.compressedTokens += gcfTokens;
161
+ return gcfText;
162
+ }
163
+
164
+ // Round-trip check on a payload we have otherwise decided to convert: decode the
165
+ // encoding and require that it reproduces the input exactly. GCF is lossless by
166
+ // design, so this is insurance rather than an expected path; it makes the
167
+ // compression provably lossless per payload. Returns true (safe to convert) when
168
+ // verification is off or the decoder is unavailable.
169
+ function verifiesLossless(parsed, gcfText, cfg, decodeFn, stats) {
170
+ if (!cfg.verify || typeof decodeFn !== "function") return true;
171
+ let decoded;
172
+ try {
173
+ decoded = decodeFn(gcfText);
174
+ } catch {
175
+ decoded = undefined;
176
+ }
177
+ if (deepEqual(parsed, decoded)) return true;
178
+ stats.skippedByVerify += 1;
179
+ return false;
180
+ }
181
+
182
+ function applyGcfCompression(payload, settings = {}, options = {}) {
183
+ const cfg = normaliseSettings(settings);
184
+ const stats = {
185
+ enabled: cfg.enabled,
186
+ available: true,
187
+ convertedCount: 0,
188
+ candidateCount: 0,
189
+ skippedBySize: 0,
190
+ skippedByShape: 0,
191
+ skippedByParse: 0,
192
+ skippedByGrowth: 0,
193
+ skippedByVerify: 0,
194
+ failureCount: 0,
195
+ originalBytes: 0,
196
+ compressedBytes: 0,
197
+ originalTokens: 0,
198
+ compressedTokens: 0,
199
+ };
200
+
201
+ if (!cfg.enabled) return { payload, stats };
202
+ if (!payload || !Array.isArray(payload.messages) || payload.messages.length === 0) {
203
+ return { payload, stats };
204
+ }
205
+
206
+ // Token counts for the never-grow guard use the target model's encoding when known.
207
+ const model = typeof payload.model === "string" ? payload.model : null;
208
+
209
+ const encodeFn = resolveEncodeFn(options.encode);
210
+ if (typeof encodeFn !== "function") {
211
+ stats.available = false;
212
+ const err = cachedLoadError ?? new Error("GCF encoder unavailable");
213
+ if (!cfg.failOpen) throw err;
214
+ if (!warnedMissingDependency) {
215
+ logger.warn(
216
+ { error: err.message },
217
+ "GCF enabled but encoder dependency is unavailable; falling back to JSON",
218
+ );
219
+ warnedMissingDependency = true;
220
+ }
221
+ return { payload, stats };
222
+ }
223
+
224
+ const decodeFn = cfg.verify ? resolveDecodeFn(options.decode) : null;
225
+
226
+ for (const message of payload.messages) {
227
+ if (!message || typeof message !== "object") continue;
228
+ if (message.role === "tool") continue; // Never mutate machine-executed protocol payloads
229
+ try {
230
+ if (typeof message.content === "string") {
231
+ message.content = compressStringContent(message.content, cfg, encodeFn, decodeFn, stats, model);
232
+ continue;
233
+ }
234
+
235
+ if (!Array.isArray(message.content)) continue;
236
+ for (const block of message.content) {
237
+ if (!block || typeof block !== "object") continue;
238
+
239
+ // Keep protocol blocks untouched. Only compress user-language text fields.
240
+ if (block.type === "text" && typeof block.text === "string") {
241
+ block.text = compressStringContent(block.text, cfg, encodeFn, decodeFn, stats, model);
242
+ continue;
243
+ }
244
+
245
+ if (block.type === "input_text" && typeof block.input_text === "string") {
246
+ block.input_text = compressStringContent(block.input_text, cfg, encodeFn, decodeFn, stats, model);
247
+ }
248
+ }
249
+ } catch (err) {
250
+ stats.failureCount += 1;
251
+ if (!cfg.failOpen) throw err;
252
+ }
253
+ }
254
+
255
+ if (cfg.logStats && (stats.convertedCount > 0 || stats.skippedByVerify > 0)) {
256
+ logger.info(
257
+ {
258
+ convertedCount: stats.convertedCount,
259
+ candidateCount: stats.candidateCount,
260
+ skippedByVerify: stats.skippedByVerify,
261
+ originalBytes: stats.originalBytes,
262
+ compressedBytes: stats.compressedBytes,
263
+ originalTokens: stats.originalTokens,
264
+ compressedTokens: stats.compressedTokens,
265
+ },
266
+ "GCF compression applied to message context",
267
+ );
268
+ }
269
+
270
+ return { payload, stats };
271
+ }
272
+
273
+ module.exports = {
274
+ applyGcfCompression,
275
+ };
@@ -56,7 +56,11 @@ function recordMetric(pattern, originalLen, compressedLen) {
56
56
  metrics.patterns[pattern] = { count: 0, tokensSaved: 0 };
57
57
  }
58
58
  metrics.patterns[pattern].count++;
59
- metrics.patterns[pattern].tokensSaved += Math.ceil((originalLen - compressedLen) / 4);
59
+ const saved = Math.ceil((originalLen - compressedLen) / 4);
60
+ metrics.patterns[pattern].tokensSaved += saved;
61
+ try {
62
+ require('../routing/telemetry').recordSavings('compression', saved);
63
+ } catch { /* telemetry is best-effort */ }
60
64
  }
61
65
 
62
66
  function getMetrics() {
@@ -120,6 +124,17 @@ function compressTestOutput(text) {
120
124
 
121
125
  if (summary.length === 0 && failures.length === 0) return null;
122
126
 
127
+ // Corroboration: one summary-looking line alone is not a test run — a
128
+ // README quoting "1041 passing" would otherwise compress the whole doc
129
+ // down to that quote. Require failures, a second summary line, or at
130
+ // least three per-test result markers before treating it as a test run.
131
+ if (failures.length === 0 && summary.length < 2) {
132
+ const perTestMarkers = lines.filter(l =>
133
+ /^\s*(?:✓|✔|✗|✘|ok \d+|not ok \d+|PASS\b|FAIL\b)/.test(l.trim())
134
+ ).length;
135
+ if (perTestMarkers < 3) return null;
136
+ }
137
+
123
138
  const parts = [];
124
139
  if (summary.length > 0) parts.push(summary.join("\n"));
125
140
  if (failures.length > 0) {
@@ -172,9 +187,12 @@ function compressGitDiff(text) {
172
187
 
173
188
  // 3. Git status
174
189
  function compressGitStatus(text) {
190
+ // Anchor on git-status STRUCTURE (branch line or section headers), not on
191
+ // bare "modified:" / "new file:" substrings — source code or prose that
192
+ // merely contains those strings must not be compressed into a fake status.
175
193
  if (!text.includes("Changes not staged") && !text.includes("Changes to be committed") &&
176
- !text.includes("Untracked files") && !text.includes("On branch") &&
177
- !text.includes("modified:") && !text.includes("new file:")) return null;
194
+ !text.includes("Untracked files") && !/^On branch \S+/m.test(text) &&
195
+ !/^HEAD detached/m.test(text)) return null;
178
196
 
179
197
  const staged = [];
180
198
  const modified = [];
@@ -438,18 +456,35 @@ function extractJSONStructure(obj, depth, maxDepth) {
438
456
  return typeof obj;
439
457
  }
440
458
 
441
- // 10. Docker/kubectl output
459
+ // 10. Docker/kubectl table output (docker ps, docker images, kubectl get).
460
+ // Detection anchors on the TABLE HEADER, never on a keyword appearing
461
+ // anywhere in the text: the old /docker/i containment trigger fired on any
462
+ // `ls` output that contained "Dockerfile", truncated the listing to 10
463
+ // lines, and the model hallucinated the dropped file names.
464
+ const CONTAINER_HEADER_COLUMNS = [
465
+ "CONTAINER ID", "IMAGE ID", "IMAGE", "COMMAND", "CREATED", "STATUS",
466
+ "PORTS", "NAMES", "REPOSITORY", "TAG", "SIZE",
467
+ "NAMESPACE", "NAME", "READY", "RESTARTS", "AGE", "CLUSTER-IP",
468
+ "EXTERNAL-IP", "TYPE", "DESIRED", "CURRENT", "AVAILABLE", "UP-TO-DATE",
469
+ ];
442
470
  function compressContainerOutput(text) {
443
- const isDocker = /(?:CONTAINER ID|IMAGE|PORTS|STATUS|docker|NAMESPACE|READY|RESTARTS|AGE|kubectl|pod\/)/i.test(text);
444
- if (!isDocker) return null;
445
-
446
471
  const lines = text.split("\n").filter(l => l.trim());
447
472
  if (lines.length < 3) return null;
448
473
 
449
- // Keep header + data rows, strip verbose columns
474
+ // The first line must be a real docker/kubectl column header: at least
475
+ // three known column tokens and nothing but known tokens and whitespace.
450
476
  const header = lines[0];
451
- const dataLines = lines.slice(1).filter(l => l.trim());
477
+ let rest = header;
478
+ let columns = 0;
479
+ for (const col of CONTAINER_HEADER_COLUMNS) {
480
+ if (rest.includes(col)) {
481
+ columns++;
482
+ rest = rest.split(col).join(" ");
483
+ }
484
+ }
485
+ if (columns < 3 || rest.trim() !== "") return null;
452
486
 
487
+ const dataLines = lines.slice(1);
453
488
  if (dataLines.length <= 10) return null; // Not enough to compress
454
489
 
455
490
  return `${header}\n${dataLines.slice(0, 10).join("\n")}\n... +${dataLines.length - 10} more (${dataLines.length} total)`;
@@ -557,6 +592,13 @@ function compressSmartTruncate(text) {
557
592
  }
558
593
 
559
594
  // ── Compression Pipeline ─────────────────────────────────────────────
595
+ //
596
+ // DETECTION RULE: a compressor's trigger must anchor on the STRUCTURE of
597
+ // its target format (header line anatomy, per-line shape, section markers)
598
+ // — never on a keyword appearing anywhere in the text. A misfire doesn't
599
+ // just waste tokens: it silently drops content the model then hallucinates
600
+ // back (live incident: /docker/i matched "Dockerfile" in an ls listing,
601
+ // truncated it to 10 lines, and the model invented the rest).
560
602
 
561
603
  const COMPRESSORS = [
562
604
  { name: "test_output", fn: compressTestOutput },
@@ -13,6 +13,7 @@ function providerMeta() {
13
13
  'azure-anthropic': { type: 'cloud', configured: !!(c.azureAnthropic?.endpoint && c.azureAnthropic?.apiKey) },
14
14
  bedrock: { type: 'cloud', configured: !!c.bedrock?.apiKey },
15
15
  openrouter: { type: 'cloud', configured: !!c.openrouter?.apiKey },
16
+ edenai: { type: 'cloud', configured: !!c.edenai?.apiKey },
16
17
  openai: { type: 'cloud', configured: !!c.openai?.apiKey },
17
18
  'azure-openai': { type: 'cloud', configured: !!(c.azureOpenAI?.endpoint && c.azureOpenAI?.apiKey) },
18
19
  vertex: { type: 'cloud', configured: !!c.vertex?.projectId },
@@ -95,10 +95,23 @@ if (config.oversizedErrorLogging?.enabled) {
95
95
  });
96
96
  }
97
97
 
98
+ // Root level must be the most verbose of any enabled stream, or quieter
99
+ // streams starve louder ones: LOG_LEVEL=silent (the recommended wrap
100
+ // setting) used to make LOG_FILE_ENABLED capture nothing — the exact
101
+ // sessions that most need a log file. Per-stream levels still apply, so
102
+ // the console stays at LOG_LEVEL.
103
+ const LEVEL_ORDER = ["trace", "debug", "info", "warn", "error", "fatal", "silent"];
104
+ const rootLevel = streams
105
+ .map((s) => s.level)
106
+ .reduce(
107
+ (min, l) => (LEVEL_ORDER.indexOf(l) < LEVEL_ORDER.indexOf(min) ? l : min),
108
+ config.logger.level,
109
+ );
110
+
98
111
  // Create logger with multistream
99
112
  const logger = pino(
100
113
  {
101
- level: config.logger.level,
114
+ level: rootLevel,
102
115
  name: "claude-backend",
103
116
  base: {
104
117
  env: config.env,
@@ -10,7 +10,7 @@ const MAX_QUERY_LENGTH = 1000;
10
10
  const MAX_OR_TERMS = 50;
11
11
 
12
12
  // ============================================================================
13
- // KEYWORD SANITIZATION (NEW - Critical for FTS5 Safety)
13
+ // KEYWORD SANITIZATION
14
14
  // ============================================================================
15
15
 
16
16
  /**
@@ -52,13 +52,13 @@ function sanitizeKeywords(keywords) {
52
52
  }
53
53
 
54
54
  // ============================================================================
55
- // FTS5 QUERY PREPARATION (UPDATED - Hardened)
55
+ // FTS5 QUERY PREPARATION
56
56
  // ============================================================================
57
57
 
58
58
  /**
59
59
  * Prepare FTS5 query - handle special characters and phrases
60
60
  *
61
- * CRITICAL FIX for better-sqlite3 v12+ (SQLite 3.46+):
61
+ * For better-sqlite3 v12+ (SQLite 3.46+):
62
62
  * - Commas and periods inside quoted strings cause "fts5: syntax error near ,"
63
63
  * - Solution: Extract keywords and search for them individually with OR
64
64
  * - This is more robust than attempting to quote complex phrases
@@ -66,7 +66,6 @@ function sanitizeKeywords(keywords) {
66
66
  function prepareFTS5Query(query) {
67
67
  let cleaned = query.trim();
68
68
 
69
- // Length validation
70
69
  if (cleaned.length > MAX_QUERY_LENGTH) {
71
70
  logger.warn({
72
71
  queryLength: cleaned.length,
@@ -145,7 +144,7 @@ function prepareFTS5Query(query) {
145
144
  }
146
145
 
147
146
  // ============================================================================
148
- // SEARCH FUNCTIONS (UPDATED)
147
+ // SEARCH FUNCTIONS
149
148
  // ============================================================================
150
149
 
151
150
  /**
@@ -175,7 +174,6 @@ function searchMemories(options) {
175
174
  ftsQuery: ftsQuery.substring(0, 100)
176
175
  }, 'FTS5 query prepared');
177
176
 
178
- // Build SQL with filters
179
177
  let sql = `
180
178
  SELECT m.id, m.session_id, m.content, m.type, m.category,
181
179
  m.importance, m.surprise_score, m.access_count, m.decay_factor,
@@ -188,7 +186,6 @@ function searchMemories(options) {
188
186
 
189
187
  const params = [ftsQuery];
190
188
 
191
- // Add filters
192
189
  if (sessionId) {
193
190
  sql += ` AND (m.session_id = ? OR m.session_id IS NULL)`;
194
191
  params.push(sessionId);
@@ -211,7 +208,6 @@ function searchMemories(options) {
211
208
  params.push(minImportance);
212
209
  }
213
210
 
214
- // Order by FTS5 rank and importance
215
211
  sql += ` ORDER BY memories_fts.rank, m.importance DESC LIMIT ?`;
216
212
  params.push(limit);
217
213
 
@@ -264,9 +260,8 @@ function searchMemories(options) {
264
260
  function searchWithExpansion(options) {
265
261
  const { query, limit = 10 } = options;
266
262
 
267
- // Extract keywords from query
268
263
  const keywords = extractKeywords(query);
269
- const sanitizedKeywords = sanitizeKeywords(keywords); // ✅ ADDED
264
+ const sanitizedKeywords = sanitizeKeywords(keywords);
270
265
 
271
266
  // Search with original query (already sanitized by prepareFTS5Query)
272
267
  const results = searchMemories({ ...options, limit: limit * 2 });
@@ -275,7 +270,7 @@ function searchWithExpansion(options) {
275
270
  if (results.length < limit && sanitizedKeywords.length > 1) {
276
271
  const seen = new Set(results.map((r) => r.id));
277
272
 
278
- for (const keyword of sanitizedKeywords) { // ✅ CHANGED - use sanitized
273
+ for (const keyword of sanitizedKeywords) {
279
274
  if (results.length >= limit) break;
280
275
 
281
276
  const kwResults = searchMemories({
@@ -315,7 +310,7 @@ function extractKeywords(text) {
315
310
  }
316
311
 
317
312
  /**
318
- * Find similar memories by keyword overlap (UPDATED - sanitized)
313
+ * Find similar memories by keyword overlap
319
314
  */
320
315
  function findSimilar(memoryId, limit = 5) {
321
316
  const memory = store.getMemory(memoryId);
@@ -324,12 +319,12 @@ function findSimilar(memoryId, limit = 5) {
324
319
  }
325
320
 
326
321
  const keywords = extractKeywords(memory.content);
327
- const sanitizedKeywords = sanitizeKeywords(keywords); // ✅ ADDED
328
-
322
+ const sanitizedKeywords = sanitizeKeywords(keywords);
323
+
329
324
  if (sanitizedKeywords.length === 0) return [];
330
325
 
331
326
  // Build OR query with SANITIZED keywords
332
- const query = sanitizedKeywords.join(" OR "); // ✅ CHANGED - use sanitized
327
+ const query = sanitizedKeywords.join(" OR ");
333
328
 
334
329
  const results = searchMemories({
335
330
  query,
@@ -339,27 +334,6 @@ function findSimilar(memoryId, limit = 5) {
339
334
  return results.filter((r) => r.id !== memoryId).slice(0, limit);
340
335
  }
341
336
 
342
- /**
343
- * Search by content similarity (UPDATED - sanitized)
344
- */
345
- function searchByContent(content, options = {}) {
346
- const keywords = extractKeywords(content);
347
- const sanitizedKeywords = sanitizeKeywords(keywords); // ✅ ADDED
348
-
349
- if (sanitizedKeywords.length === 0) return [];
350
-
351
- const query = sanitizedKeywords.slice(0, 5).join(" OR "); // ✅ CHANGED
352
- return searchMemories({ ...options, query });
353
- }
354
-
355
- /**
356
- * Count search results without fetching them
357
- */
358
- function countSearchResults(options) {
359
- const results = searchMemories({ ...options, limit: 1000 });
360
- return results.length;
361
- }
362
-
363
337
  // ============================================================================
364
338
  // EXPORTS
365
339
  // ============================================================================
@@ -369,9 +343,7 @@ module.exports = {
369
343
  searchWithExpansion,
370
344
  extractKeywords,
371
345
  findSimilar,
372
- searchByContent,
373
- countSearchResults,
374
346
  prepareFTS5Query,
375
- sanitizeKeyword, // ✅ NEW - exported for testing
376
- sanitizeKeywords, // ✅ NEW - exported for testing
347
+ sanitizeKeyword, // exported for testing
348
+ sanitizeKeywords, // exported for testing
377
349
  };