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.
- package/README.md +29 -19
- package/bin/cli.js +11 -0
- package/bin/lynkr-init.js +14 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +24 -3
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/agents/context-manager.js +18 -2
- package/src/agents/definitions/loader.js +90 -0
- package/src/agents/executor.js +24 -2
- package/src/agents/index.js +9 -1
- package/src/agents/parallel-coordinator.js +2 -2
- package/src/agents/reflector.js +11 -1
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +450 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +459 -146
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +51 -9
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +144 -88
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +48 -11
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +897 -87
- package/src/routing/intent-score.js +339 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +298 -10
- package/src/routing/verifier.js +267 -0
- package/src/server.js +66 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- 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
|
-
|
|
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") &&
|
|
177
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 },
|
package/src/dashboard/api.js
CHANGED
|
@@ -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 },
|
package/src/logger/index.js
CHANGED
|
@@ -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:
|
|
114
|
+
level: rootLevel,
|
|
102
115
|
name: "claude-backend",
|
|
103
116
|
base: {
|
|
104
117
|
env: config.env,
|
package/src/memory/search.js
CHANGED
|
@@ -10,7 +10,7 @@ const MAX_QUERY_LENGTH = 1000;
|
|
|
10
10
|
const MAX_OR_TERMS = 50;
|
|
11
11
|
|
|
12
12
|
// ============================================================================
|
|
13
|
-
// KEYWORD SANITIZATION
|
|
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
|
|
55
|
+
// FTS5 QUERY PREPARATION
|
|
56
56
|
// ============================================================================
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Prepare FTS5 query - handle special characters and phrases
|
|
60
60
|
*
|
|
61
|
-
*
|
|
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
|
|
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);
|
|
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) {
|
|
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
|
|
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);
|
|
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 ");
|
|
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, //
|
|
376
|
-
sanitizeKeywords, //
|
|
347
|
+
sanitizeKeyword, // exported for testing
|
|
348
|
+
sanitizeKeywords, // exported for testing
|
|
377
349
|
};
|