lynkr 9.9.1 → 9.11.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 +50 -9
- package/bin/cli.js +2 -0
- package/bin/lynkr-init.js +11 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/public/dashboard.html +29 -0
- package/scripts/validate-difficulty-classifier.js +27 -6
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +309 -160
- package/src/clients/databricks.js +215 -71
- package/src/clients/gpt-utils.js +8 -1
- package/src/clients/ollama-utils.js +19 -10
- package/src/clients/prompt-cache-injection.js +93 -30
- package/src/config/index.js +82 -43
- package/src/context/compression.js +27 -8
- package/src/context/distill.js +2 -1
- package/src/dashboard/api.js +5 -1
- package/src/memory/distiller.js +350 -0
- package/src/memory/extractor.js +0 -8
- package/src/memory/format.js +17 -2
- package/src/memory/index.js +10 -0
- package/src/memory/retriever.js +7 -2
- package/src/memory/skills-cache.js +180 -0
- package/src/memory/tencentdb-launcher.js +459 -0
- package/src/memory/wiki.js +171 -0
- package/src/orchestrator/index.js +202 -1237
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/prompts/system.js +20 -6
- package/src/routing/affinity-store.js +85 -4
- package/src/routing/agentic-detector.js +9 -1
- package/src/routing/cache-economics.js +123 -0
- package/src/routing/cache-switch-cost.js +194 -0
- package/src/routing/calibration.js +8 -1
- package/src/routing/complexity-analyzer.js +7 -1
- package/src/routing/context-compressor.js +170 -0
- package/src/routing/difficulty-classifier.js +161 -17
- package/src/routing/index.js +196 -56
- package/src/routing/intent-score.js +20 -2
- package/src/routing/model-registry.js +35 -10
- package/src/routing/session-affinity.js +68 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/routing/telemetry.js +139 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -49
- package/src/tools/code-graph.js +6 -4
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- package/src/tools/workspace.js +0 -204
|
@@ -114,12 +114,18 @@ function getPin(sessionId) {
|
|
|
114
114
|
* Accepts a routing decision plus session-scoped stats used by the re-pin
|
|
115
115
|
* heuristics.
|
|
116
116
|
*
|
|
117
|
+
* `hasToolHistory` is sticky-true: once set to true it stays true for the
|
|
118
|
+
* pin's lifetime — a follow-up refresh that didn't carry tool blocks (e.g.
|
|
119
|
+
* a plain-text turn between tool exchanges) must not clear the flag, or the
|
|
120
|
+
* Signal-2 side-channel check would flip open again.
|
|
121
|
+
*
|
|
117
122
|
* @param {string} sessionId
|
|
118
123
|
* @param {{provider:string, model?:string|null, tier?:string|null}} decision
|
|
119
|
-
* @param {{messageCount?:number|null, promptTokensEst?:number|null}} [stats]
|
|
124
|
+
* @param {{messageCount?:number|null, promptTokensEst?:number|null, hasToolHistory?:boolean}} [stats]
|
|
120
125
|
*/
|
|
121
126
|
function setPin(sessionId, decision, stats = {}) {
|
|
122
127
|
if (!sessionId || !decision?.provider) return;
|
|
128
|
+
const prev = pins.get(sessionId);
|
|
123
129
|
const pin = {
|
|
124
130
|
provider: decision.provider,
|
|
125
131
|
model: decision.model ?? null,
|
|
@@ -127,6 +133,7 @@ function setPin(sessionId, decision, stats = {}) {
|
|
|
127
133
|
score: typeof decision.score === 'number' ? decision.score : null,
|
|
128
134
|
messageCount: stats.messageCount ?? null,
|
|
129
135
|
promptTokensEst: stats.promptTokensEst ?? null,
|
|
136
|
+
hasToolHistory: !!(stats.hasToolHistory || prev?.hasToolHistory),
|
|
130
137
|
ts: Date.now(),
|
|
131
138
|
};
|
|
132
139
|
// Refresh insertion order so active sessions aren't evicted.
|
|
@@ -165,6 +172,64 @@ function shouldRepin(pin, payload) {
|
|
|
165
172
|
return { repin: false, reason: null };
|
|
166
173
|
}
|
|
167
174
|
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Cache-aware routing (Phase 1) — per-session prompt-cache state.
|
|
177
|
+
//
|
|
178
|
+
// After every upstream response we record how much of the session's prefix
|
|
179
|
+
// is warm on the provider side: cache_read + cache_creation tokens together
|
|
180
|
+
// describe the cached prefix as of that response. Anthropic-style explicit
|
|
181
|
+
// caches refresh their TTL on every read, so `lastRequestAt + ttlMs` is a
|
|
182
|
+
// live cache clock the switch-timing logic (Phase 2) can consult.
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Record prompt-cache usage from an upstream response. No-ops when the
|
|
187
|
+
* response carried no cache signal (both counters zero/absent) so providers
|
|
188
|
+
* without cache reporting simply never accrue state.
|
|
189
|
+
*
|
|
190
|
+
* Overwrites unconditionally on signal: if the session switched models, the
|
|
191
|
+
* new response's numbers ARE the new model's cache state (caches are
|
|
192
|
+
* model-scoped — never carry a warm-prefix figure across a switch).
|
|
193
|
+
*
|
|
194
|
+
* @param {string} sessionId
|
|
195
|
+
* @param {{provider:string, model?:string|null, cacheReadTokens?:number, cacheCreationTokens?:number}} usage
|
|
196
|
+
*/
|
|
197
|
+
function recordCacheUsage(sessionId, usage) {
|
|
198
|
+
if (!sessionId || !usage?.provider) return;
|
|
199
|
+
const read = Number(usage.cacheReadTokens) || 0;
|
|
200
|
+
const created = Number(usage.cacheCreationTokens) || 0;
|
|
201
|
+
const warm = read + created;
|
|
202
|
+
if (warm <= 0) return; // no cache signal — state stays absent
|
|
203
|
+
|
|
204
|
+
let ttlMs = 5 * 60 * 1000;
|
|
205
|
+
try {
|
|
206
|
+
const { resolveCacheEconomics } = require("./cache-economics");
|
|
207
|
+
ttlMs = resolveCacheEconomics(usage.provider, usage.model ?? null).ttlMs;
|
|
208
|
+
} catch { /* keep default */ }
|
|
209
|
+
|
|
210
|
+
store.saveCacheState(sessionId, {
|
|
211
|
+
warmPrefixTokens: warm,
|
|
212
|
+
provider: usage.provider,
|
|
213
|
+
model: usage.model ?? null,
|
|
214
|
+
lastRequestAt: Date.now(),
|
|
215
|
+
ttlMs,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Load the session's cache state, annotated with liveness. Returns null when
|
|
221
|
+
* no state exists (provider reports no cache usage, or no response yet).
|
|
222
|
+
*
|
|
223
|
+
* @param {string} sessionId
|
|
224
|
+
* @returns {({warmPrefixTokens:number, provider:string, model:string|null, lastRequestAt:number, ttlMs:number, cold:boolean})|null}
|
|
225
|
+
*/
|
|
226
|
+
function getCacheState(sessionId) {
|
|
227
|
+
const state = store.loadCacheState(sessionId);
|
|
228
|
+
if (!state || typeof state.lastRequestAt !== "number") return null;
|
|
229
|
+
const ttl = typeof state.ttlMs === "number" ? state.ttlMs : 5 * 60 * 1000;
|
|
230
|
+
return { ...state, cold: Date.now() - state.lastRequestAt > ttl };
|
|
231
|
+
}
|
|
232
|
+
|
|
168
233
|
/** Test/maintenance helper — clear the in-memory Map only. */
|
|
169
234
|
function _clear() {
|
|
170
235
|
pins.clear();
|
|
@@ -216,6 +281,8 @@ module.exports = {
|
|
|
216
281
|
setPin,
|
|
217
282
|
removePin,
|
|
218
283
|
shouldRepin,
|
|
284
|
+
recordCacheUsage,
|
|
285
|
+
getCacheState,
|
|
219
286
|
// legacy
|
|
220
287
|
getPinned,
|
|
221
288
|
setPinned,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side-Channel Detector — first-user-message fingerprint drift
|
|
3
|
+
*
|
|
4
|
+
* Complements the pin-based side-channel signals in router.js:
|
|
5
|
+
* 1. Message-count regression → needs pin
|
|
6
|
+
* 2. Tool-history mismatch → needs pin with hasToolHistory
|
|
7
|
+
* 3. First-message fingerprint → THIS module — works before any pin exists
|
|
8
|
+
*
|
|
9
|
+
* Purpose: on a session's first request, cache a fingerprint of messages[0].
|
|
10
|
+
* On later requests within the TTL, if messages[0] no longer matches, the
|
|
11
|
+
* payload has been replayed by a background utility caller (Claude Code
|
|
12
|
+
* wraps the original user text in `<session>...</session>` for title-gen,
|
|
13
|
+
* substitutes recap prompts, etc.).
|
|
14
|
+
*
|
|
15
|
+
* Trade-off: if a background call fires BEFORE the first real user turn
|
|
16
|
+
* lands, its wrapper prompt becomes the anchor. That's rare in practice —
|
|
17
|
+
* observed order in real traffic is user turn first, background call after.
|
|
18
|
+
* If it does happen, the real user's next request will be mislabelled once,
|
|
19
|
+
* then the anchor will "settle" once ttl expires.
|
|
20
|
+
*
|
|
21
|
+
* @module routing/side-channel-detector
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
const logger = require('../logger');
|
|
26
|
+
|
|
27
|
+
const TTL_MS = 30 * 60 * 1000; // 30 min — matches an interactive Claude Code session
|
|
28
|
+
const MAX_ENTRIES = 2000; // LRU cap (same order of magnitude as session-affinity)
|
|
29
|
+
|
|
30
|
+
/** @type {Map<string, {hash:string, ts:number}>} */
|
|
31
|
+
const anchors = new Map();
|
|
32
|
+
|
|
33
|
+
function _evictIfNeeded() {
|
|
34
|
+
if (anchors.size <= MAX_ENTRIES) return;
|
|
35
|
+
const oldest = anchors.keys().next().value;
|
|
36
|
+
if (oldest !== undefined) anchors.delete(oldest);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _firstUserText(messages) {
|
|
40
|
+
if (!Array.isArray(messages)) return null;
|
|
41
|
+
for (const m of messages) {
|
|
42
|
+
if (m?.role !== 'user') continue;
|
|
43
|
+
if (typeof m.content === 'string') return m.content;
|
|
44
|
+
if (Array.isArray(m.content)) {
|
|
45
|
+
const t = m.content.filter(b => b?.type === 'text').map(b => b.text || '').join('');
|
|
46
|
+
if (t) return t;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _hash(str) {
|
|
53
|
+
return crypto.createHash('sha1').update(str).digest('hex').slice(0, 16);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check whether the payload's first user message drifts from the cached
|
|
58
|
+
* anchor for this session. Returns true only when we have an anchor AND
|
|
59
|
+
* it disagrees; returns false when no anchor exists yet (bootstrap case)
|
|
60
|
+
* and stores this payload's hash as the new anchor.
|
|
61
|
+
*
|
|
62
|
+
* @param {string|null} sessionId
|
|
63
|
+
* @param {Array} messages
|
|
64
|
+
* @returns {boolean} true if drift detected (caller should treat as side channel)
|
|
65
|
+
*/
|
|
66
|
+
function check(sessionId, messages) {
|
|
67
|
+
if (!sessionId) return false;
|
|
68
|
+
const text = _firstUserText(messages);
|
|
69
|
+
if (!text) return false;
|
|
70
|
+
const hash = _hash(text);
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
|
|
73
|
+
const cached = anchors.get(sessionId);
|
|
74
|
+
if (cached) {
|
|
75
|
+
// Refresh insertion order for LRU
|
|
76
|
+
anchors.delete(sessionId);
|
|
77
|
+
if (now - cached.ts > TTL_MS) {
|
|
78
|
+
// Expired — reset the anchor, no drift claim.
|
|
79
|
+
anchors.set(sessionId, { hash, ts: now });
|
|
80
|
+
_evictIfNeeded();
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
anchors.set(sessionId, cached);
|
|
84
|
+
if (cached.hash !== hash) {
|
|
85
|
+
logger.debug({ sessionId, cachedHash: cached.hash, newHash: hash },
|
|
86
|
+
'[SideChannel] First-message fingerprint drift');
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Bootstrap — first sighting of this session, store and pass.
|
|
93
|
+
anchors.set(sessionId, { hash, ts: now });
|
|
94
|
+
_evictIfNeeded();
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Test/maintenance helper — clear the in-memory anchors. */
|
|
99
|
+
function _clear() {
|
|
100
|
+
anchors.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { check, _clear };
|
package/src/routing/telemetry.js
CHANGED
|
@@ -165,6 +165,11 @@ function init() {
|
|
|
165
165
|
["candidates", "TEXT"],
|
|
166
166
|
["pinned", "INTEGER DEFAULT 0"],
|
|
167
167
|
["switch_reason", "TEXT"],
|
|
168
|
+
// Phase 6 (cache-aware routing) — per-decision cache economics
|
|
169
|
+
// receipt: {decision, reason, warmPrefixTokens, breakEvenTurns,
|
|
170
|
+
// projectedSwitchCostUsd, projectedStaySavingsUsd,
|
|
171
|
+
// expectedRemainingTurns} as JSON.
|
|
172
|
+
["cache_decision", "TEXT"],
|
|
168
173
|
];
|
|
169
174
|
for (const [col, type] of additiveCols) {
|
|
170
175
|
if (!existingCols.has(col)) {
|
|
@@ -226,7 +231,8 @@ function record(data) {
|
|
|
226
231
|
latency_ms, status_code, error_type, cost_usd, tool_calls_made,
|
|
227
232
|
retry_count, circuit_breaker_state, quality_score, tokens_per_second,
|
|
228
233
|
cost_efficiency, request_text, response_text,
|
|
229
|
-
base_tier, escalation_source, propensity, candidates, pinned, switch_reason
|
|
234
|
+
base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
|
|
235
|
+
cache_decision
|
|
230
236
|
) VALUES (
|
|
231
237
|
@request_id, @session_id, @timestamp, @complexity_score, @tier,
|
|
232
238
|
@agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
|
|
@@ -234,7 +240,8 @@ function record(data) {
|
|
|
234
240
|
@latency_ms, @status_code, @error_type, @cost_usd, @tool_calls_made,
|
|
235
241
|
@retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
|
|
236
242
|
@cost_efficiency, @request_text, @response_text,
|
|
237
|
-
@base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason
|
|
243
|
+
@base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
|
|
244
|
+
@cache_decision
|
|
238
245
|
)`
|
|
239
246
|
);
|
|
240
247
|
if (!insert) return;
|
|
@@ -280,6 +287,11 @@ function record(data) {
|
|
|
280
287
|
candidates: candidatesJson,
|
|
281
288
|
pinned: data.pinned ? 1 : 0,
|
|
282
289
|
switch_reason: data.switch_reason ?? null,
|
|
290
|
+
cache_decision: data.cache_decision == null
|
|
291
|
+
? null
|
|
292
|
+
: (typeof data.cache_decision === "string"
|
|
293
|
+
? data.cache_decision
|
|
294
|
+
: JSON.stringify(data.cache_decision)),
|
|
283
295
|
});
|
|
284
296
|
} catch (err) {
|
|
285
297
|
logger.debug({ err: err.message }, "Telemetry record failed");
|
|
@@ -791,6 +803,129 @@ function _resetForTests() {
|
|
|
791
803
|
_testDbDisabled = false;
|
|
792
804
|
}
|
|
793
805
|
|
|
806
|
+
// Cache-aware routing (Phase 6) — "cache dollars saved / burned by routing".
|
|
807
|
+
//
|
|
808
|
+
// Every gated switch/hold decision carries a receipt. Aggregating them
|
|
809
|
+
// answers the public question "does model routing break prefix caching?"
|
|
810
|
+
// with a dollar figure:
|
|
811
|
+
// - a HOLD avoided a switch whose one-time cache write exceeded its
|
|
812
|
+
// projected per-turn savings over the expected horizon
|
|
813
|
+
// (saved = switchOnce − savings × remainingTurns)
|
|
814
|
+
// - a SWITCH cleared break-even; its projected net gain is
|
|
815
|
+
// (savings × remainingTurns − switchOnce)
|
|
816
|
+
/**
|
|
817
|
+
* @param {Object} [opts]
|
|
818
|
+
* @param {number} [opts.since] - default: last 7 days.
|
|
819
|
+
* @returns {{decisions:number, holds:number, switches:number,
|
|
820
|
+
* dollarsSavedByHolds:number, dollarsSavedBySwitches:number,
|
|
821
|
+
* totalDollarsSaved:number, byReason:Object}|null}
|
|
822
|
+
*/
|
|
823
|
+
function getCacheEconomics(opts = {}) {
|
|
824
|
+
if (!init()) return null;
|
|
825
|
+
const since = opts.since ?? Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
826
|
+
try {
|
|
827
|
+
const rows = db
|
|
828
|
+
.prepare(
|
|
829
|
+
`SELECT cache_decision FROM routing_telemetry
|
|
830
|
+
WHERE timestamp > ? AND cache_decision IS NOT NULL
|
|
831
|
+
LIMIT 20000`
|
|
832
|
+
)
|
|
833
|
+
.all(since);
|
|
834
|
+
|
|
835
|
+
const out = {
|
|
836
|
+
decisions: 0,
|
|
837
|
+
holds: 0,
|
|
838
|
+
switches: 0,
|
|
839
|
+
dollarsSavedByHolds: 0,
|
|
840
|
+
dollarsSavedBySwitches: 0,
|
|
841
|
+
totalDollarsSaved: 0,
|
|
842
|
+
byReason: {},
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
for (const row of rows) {
|
|
846
|
+
let d;
|
|
847
|
+
try {
|
|
848
|
+
d = JSON.parse(row.cache_decision);
|
|
849
|
+
} catch {
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
if (!d || typeof d !== "object") continue;
|
|
853
|
+
out.decisions++;
|
|
854
|
+
out.byReason[d.reason ?? "unknown"] = (out.byReason[d.reason ?? "unknown"] ?? 0) + 1;
|
|
855
|
+
|
|
856
|
+
const savings = typeof d.projectedStaySavingsUsd === "number" ? d.projectedStaySavingsUsd : null;
|
|
857
|
+
const once = typeof d.projectedSwitchCostUsd === "number" ? d.projectedSwitchCostUsd : null;
|
|
858
|
+
const horizon = typeof d.expectedRemainingTurns === "number" ? d.expectedRemainingTurns : null;
|
|
859
|
+
|
|
860
|
+
if (d.decision === "hold") {
|
|
861
|
+
out.holds++;
|
|
862
|
+
if (savings != null && once != null && horizon != null) {
|
|
863
|
+
out.dollarsSavedByHolds += Math.max(0, once - savings * horizon);
|
|
864
|
+
}
|
|
865
|
+
} else if (d.decision === "switch") {
|
|
866
|
+
out.switches++;
|
|
867
|
+
if (savings != null && once != null && horizon != null) {
|
|
868
|
+
out.dollarsSavedBySwitches += Math.max(0, savings * horizon - once);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
out.totalDollarsSaved = out.dollarsSavedByHolds + out.dollarsSavedBySwitches;
|
|
874
|
+
return out;
|
|
875
|
+
} catch (err) {
|
|
876
|
+
logger.debug({ err: err.message }, "Telemetry getCacheEconomics failed");
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Cache-aware routing (Phase 3) — expected remaining turns.
|
|
882
|
+
//
|
|
883
|
+
// Conditional median: given a session has already reached `currentTurns`
|
|
884
|
+
// messages, how many MORE messages do comparable sessions run? Uses the max
|
|
885
|
+
// message_count each recent session reached. Conditional (len > current)
|
|
886
|
+
// rather than a global median because long sessions systematically outlive
|
|
887
|
+
// the average — exactly the sessions whose warm prefixes are worth pricing.
|
|
888
|
+
const _remainingTurnsCache = new Map();
|
|
889
|
+
const REMAINING_TURNS_CACHE_TTL_MS = 60 * 1000;
|
|
890
|
+
const REMAINING_TURNS_MIN_SESSIONS = 20;
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* @param {number} currentTurns - message_count the session has reached now.
|
|
894
|
+
* @returns {number|null} median remaining messages, or null when telemetry
|
|
895
|
+
* is too sparse (< 20 comparable sessions in the last 7 days).
|
|
896
|
+
*/
|
|
897
|
+
function getExpectedRemainingTurns(currentTurns = 0) {
|
|
898
|
+
if (!init()) return null;
|
|
899
|
+
const bucket = Math.max(0, Math.floor((Number(currentTurns) || 0) / 5) * 5);
|
|
900
|
+
const cached = _remainingTurnsCache.get(bucket);
|
|
901
|
+
if (cached && Date.now() - cached.ts < REMAINING_TURNS_CACHE_TTL_MS) {
|
|
902
|
+
return cached.value;
|
|
903
|
+
}
|
|
904
|
+
try {
|
|
905
|
+
const since = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
906
|
+
const rows = db
|
|
907
|
+
.prepare(
|
|
908
|
+
`SELECT MAX(message_count) AS len
|
|
909
|
+
FROM routing_telemetry
|
|
910
|
+
WHERE timestamp > ? AND session_id IS NOT NULL AND message_count IS NOT NULL
|
|
911
|
+
GROUP BY session_id
|
|
912
|
+
HAVING len > ?
|
|
913
|
+
LIMIT 5000`
|
|
914
|
+
)
|
|
915
|
+
.all(since, bucket);
|
|
916
|
+
let value = null;
|
|
917
|
+
if (rows.length >= REMAINING_TURNS_MIN_SESSIONS) {
|
|
918
|
+
const remaining = rows.map((r) => r.len - bucket).sort((a, b) => a - b);
|
|
919
|
+
value = remaining[Math.floor(remaining.length / 2)];
|
|
920
|
+
}
|
|
921
|
+
_remainingTurnsCache.set(bucket, { value, ts: Date.now() });
|
|
922
|
+
return value;
|
|
923
|
+
} catch (err) {
|
|
924
|
+
logger.debug({ err: err.message }, "Telemetry getExpectedRemainingTurns failed");
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
794
929
|
module.exports = {
|
|
795
930
|
record,
|
|
796
931
|
query,
|
|
@@ -799,6 +934,8 @@ module.exports = {
|
|
|
799
934
|
getRoutingAccuracy,
|
|
800
935
|
getEscalationStats,
|
|
801
936
|
getQualityByTierAndType,
|
|
937
|
+
getExpectedRemainingTurns,
|
|
938
|
+
getCacheEconomics,
|
|
802
939
|
recordSavings,
|
|
803
940
|
getSavingsSummary,
|
|
804
941
|
cleanup,
|
package/src/routing/verifier.js
CHANGED
|
@@ -204,6 +204,28 @@ function contentScore(userText, answerText, responseBody) {
|
|
|
204
204
|
return Math.max(0, Math.min(100, score));
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Caveman detection
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
// context/caveman.js appends a brevity instruction tagged with this literal
|
|
212
|
+
// marker whenever CAVEMAN is enabled. Presence in the outbound system prompt
|
|
213
|
+
// is a reliable, config-independent signal that the model was told to be
|
|
214
|
+
// terse — so the length-based effort penalty in contentScore() is a false
|
|
215
|
+
// negative here (short answer = compliant, not lazy) and must not fire.
|
|
216
|
+
const CAVEMAN_MARKER = "[brevity]";
|
|
217
|
+
|
|
218
|
+
function _cavemanActive(payload) {
|
|
219
|
+
const s = payload?.system;
|
|
220
|
+
if (typeof s === "string") return s.includes(CAVEMAN_MARKER);
|
|
221
|
+
if (Array.isArray(s)) {
|
|
222
|
+
for (const b of s) {
|
|
223
|
+
if (typeof b?.text === "string" && b.text.includes(CAVEMAN_MARKER)) return true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
207
229
|
// ---------------------------------------------------------------------------
|
|
208
230
|
// Public API
|
|
209
231
|
// ---------------------------------------------------------------------------
|
|
@@ -240,11 +262,26 @@ function verify({ payload, responseBody } = {}) {
|
|
|
240
262
|
].filter(Boolean);
|
|
241
263
|
|
|
242
264
|
const score = contentScore(userText, answerText, responseBody);
|
|
243
|
-
|
|
265
|
+
const cavemanActive = _cavemanActive(payload);
|
|
266
|
+
// When caveman brevity is active the model was explicitly told to be
|
|
267
|
+
// terse; a short answer is compliant, not lazy, so the length-driven
|
|
268
|
+
// low-score check would misfire and thrash the cascade. Structural
|
|
269
|
+
// checks above (language drift, degeneration, truncation, malformed
|
|
270
|
+
// tools, echo) still run — those catch real failures regardless.
|
|
271
|
+
if (
|
|
272
|
+
reasons.length === 0 &&
|
|
273
|
+
score < CONTENT_SCORE_FAIL_THRESHOLD &&
|
|
274
|
+
!cavemanActive
|
|
275
|
+
) {
|
|
244
276
|
reasons.push(`low-content-score (${score} < ${CONTENT_SCORE_FAIL_THRESHOLD})`);
|
|
245
277
|
}
|
|
246
278
|
|
|
247
|
-
return {
|
|
279
|
+
return {
|
|
280
|
+
verdict: reasons.length ? 'fail' : 'pass',
|
|
281
|
+
score,
|
|
282
|
+
reasons,
|
|
283
|
+
cavemanActive,
|
|
284
|
+
};
|
|
248
285
|
} catch (err) {
|
|
249
286
|
// Fail-open: a broken verifier must never block serving.
|
|
250
287
|
logger.debug({ err: err.message }, '[Verifier] error — failing open');
|
|
@@ -263,5 +300,7 @@ module.exports = {
|
|
|
263
300
|
checkMalformedToolCalls,
|
|
264
301
|
checkEmptyOrEcho,
|
|
265
302
|
contentScore,
|
|
303
|
+
_cavemanActive,
|
|
304
|
+
CAVEMAN_MARKER,
|
|
266
305
|
},
|
|
267
306
|
};
|
package/src/server.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const express = require("express");
|
|
2
|
-
const compression = require("compression");
|
|
3
2
|
const config = require("./config");
|
|
4
3
|
const loggingMiddleware = require("./api/middleware/logging");
|
|
5
4
|
const router = require("./api/router");
|
|
@@ -18,52 +17,13 @@ const { getCircuitBreakerRegistry } = require("./clients/circuit-breaker");
|
|
|
18
17
|
const metrics = require("./metrics");
|
|
19
18
|
const logger = require("./logger");
|
|
20
19
|
const { initialiseMcp } = require("./mcp");
|
|
21
|
-
const { registerStubTools } = require("./tools/stubs");
|
|
22
|
-
const { registerWorkspaceTools } = require("./tools/workspace");
|
|
23
|
-
const { registerExecutionTools } = require("./tools/execution");
|
|
24
|
-
const { registerWebTools } = require("./tools/web");
|
|
25
|
-
const { registerIndexerTools } = require("./tools/indexer");
|
|
26
|
-
const { registerEditTools } = require("./tools/edits");
|
|
27
|
-
const { registerGitTools } = require("./tools/git");
|
|
28
|
-
const { registerTaskTools } = require("./tools/tasks");
|
|
29
|
-
const { registerTestTools } = require("./tools/tests");
|
|
30
|
-
const { registerMcpTools } = require("./tools/mcp");
|
|
31
|
-
const { registerAgentTaskTool } = require("./tools/agent-task");
|
|
32
|
-
const { registerDecomposeTool } = require("./tools/decompose");
|
|
33
20
|
const { initConfigWatcher, getConfigWatcher } = require("./config/watcher");
|
|
34
|
-
const { initializeHeadroom, shutdownHeadroom
|
|
21
|
+
const { initializeHeadroom, shutdownHeadroom } = require("./headroom");
|
|
35
22
|
const { getWorkerPool, isWorkerPoolReady } = require("./workers/pool");
|
|
36
|
-
const lazyLoader = require("./tools/lazy-loader");
|
|
37
|
-
const { setLazyLoader } = require("./tools");
|
|
38
23
|
const { waitForOllama } = require("./clients/ollama-startup");
|
|
39
24
|
|
|
40
25
|
initialiseMcp();
|
|
41
26
|
|
|
42
|
-
setLazyLoader(lazyLoader);
|
|
43
|
-
|
|
44
|
-
const LAZY_TOOLS_ENABLED = process.env.LAZY_TOOLS_ENABLED !== "false";
|
|
45
|
-
|
|
46
|
-
if (LAZY_TOOLS_ENABLED) {
|
|
47
|
-
// Only load core tools at startup (stubs, workspace, execution)
|
|
48
|
-
lazyLoader.loadCoreTools();
|
|
49
|
-
logger.info({ mode: "lazy" }, "Lazy tool loading enabled - other tools will load on demand");
|
|
50
|
-
} else {
|
|
51
|
-
// Backwards compatibility: load all tools at startup
|
|
52
|
-
registerStubTools();
|
|
53
|
-
registerWorkspaceTools();
|
|
54
|
-
registerExecutionTools();
|
|
55
|
-
registerWebTools();
|
|
56
|
-
registerIndexerTools();
|
|
57
|
-
registerEditTools();
|
|
58
|
-
registerGitTools();
|
|
59
|
-
registerTaskTools();
|
|
60
|
-
registerTestTools();
|
|
61
|
-
registerMcpTools();
|
|
62
|
-
registerAgentTaskTool();
|
|
63
|
-
registerDecomposeTool();
|
|
64
|
-
logger.info({ mode: "eager" }, "All tools loaded at startup");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
27
|
function createApp() {
|
|
68
28
|
const app = express();
|
|
69
29
|
const path = require('path');
|
|
@@ -171,13 +131,6 @@ function createApp() {
|
|
|
171
131
|
res.json({ enabled: true, ...cache.getStats() });
|
|
172
132
|
});
|
|
173
133
|
|
|
174
|
-
app.get("/metrics/lazy-tools", (req, res) => {
|
|
175
|
-
res.json({
|
|
176
|
-
enabled: LAZY_TOOLS_ENABLED,
|
|
177
|
-
...lazyLoader.getLoaderStats(),
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
|
|
181
134
|
app.use(router);
|
|
182
135
|
|
|
183
136
|
app.use('/dashboard', require('./dashboard/router'));
|
|
@@ -242,10 +195,44 @@ async function start() {
|
|
|
242
195
|
}
|
|
243
196
|
}
|
|
244
197
|
|
|
245
|
-
const server = app.listen(config.port, () => {
|
|
198
|
+
const server = app.listen(config.port, (err) => {
|
|
199
|
+
// Express 5 invokes this callback on bind errors too (it wires the
|
|
200
|
+
// callback to the server 'error' event) — without the guard, a failed
|
|
201
|
+
// bind prints "listening" right before the EADDRINUSE failure.
|
|
202
|
+
if (err) return;
|
|
246
203
|
console.log(`Claude→Databricks proxy listening on http://localhost:${config.port}`);
|
|
247
204
|
});
|
|
248
205
|
|
|
206
|
+
// Surface bind failures to the caller. Without this, EADDRINUSE from a
|
|
207
|
+
// stale instance is swallowed, and `lynkr wrap`'s readiness probe then
|
|
208
|
+
// gets answered by the OLD server still holding the port — the new
|
|
209
|
+
// session silently runs against stale code.
|
|
210
|
+
await new Promise((resolve, reject) => {
|
|
211
|
+
server.once("listening", resolve);
|
|
212
|
+
server.once("error", reject);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// TencentDB-Agent-Memory sidecar — non-blocking (first start pulls two
|
|
216
|
+
// Docker images). Launched after listen so the proxy is usable immediately;
|
|
217
|
+
// the memory stack's LLM calls route back through Lynkr, which is why
|
|
218
|
+
// Lynkr must already be accepting requests when the containers come up.
|
|
219
|
+
if (config.tencentdbMemory?.enabled) {
|
|
220
|
+
(async () => {
|
|
221
|
+
try {
|
|
222
|
+
const tencentdbLauncher = require("./memory/tencentdb-launcher");
|
|
223
|
+
const result = await tencentdbLauncher.ensureRunning();
|
|
224
|
+
if (result.started) {
|
|
225
|
+
logger.info(result.endpoints, "[tdai] TencentDB Agent Memory stack ready");
|
|
226
|
+
console.log(`TencentDB Agent Memory ready — Panel UI: ${result.endpoints.panel}`);
|
|
227
|
+
} else {
|
|
228
|
+
logger.debug({ reason: result.reason }, "[tdai] Sidecar not started");
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
logger.warn({ err }, "[tdai] TencentDB memory sidecar failed to start — continuing without it");
|
|
232
|
+
}
|
|
233
|
+
})();
|
|
234
|
+
}
|
|
235
|
+
|
|
249
236
|
// Classifier bootstrap check — non-blocking, log-only.
|
|
250
237
|
// Detects ollama + confirms the classifier model is pulled. Never auto-
|
|
251
238
|
// installs (that's `lynkr init`'s job); warns and lets scoring fall back
|
package/src/tools/code-graph.js
CHANGED
|
@@ -313,9 +313,10 @@ async function getBlastRadius(filePaths, options = {}) {
|
|
|
313
313
|
if (cached) return cached;
|
|
314
314
|
|
|
315
315
|
// Query neighbors for each file to estimate blast radius
|
|
316
|
+
const depth = String(config.codeGraph?.maxDepth ?? 2);
|
|
316
317
|
const result = await execGraph(
|
|
317
318
|
"query",
|
|
318
|
-
["get_neighbors", "--files", ...filePaths, "--depth",
|
|
319
|
+
["get_neighbors", "--files", ...filePaths, "--depth", depth, "--json"],
|
|
319
320
|
ws
|
|
320
321
|
);
|
|
321
322
|
if (!result) return null;
|
|
@@ -364,11 +365,11 @@ async function getBlastRadius(filePaths, options = {}) {
|
|
|
364
365
|
* Uses Graphify's BFS-based query to find related nodes.
|
|
365
366
|
*
|
|
366
367
|
* @param {string[]} filePaths — seed file paths
|
|
367
|
-
* @param {number} [maxFiles
|
|
368
|
+
* @param {number} [maxFiles] — maximum files to return (default from config.codeGraph.maxFiles)
|
|
368
369
|
* @param {CodeGraphOptions} [options]
|
|
369
370
|
* @returns {Promise<string[]|null>}
|
|
370
371
|
*/
|
|
371
|
-
async function getRelevantContext(filePaths, maxFiles =
|
|
372
|
+
async function getRelevantContext(filePaths, maxFiles = config.codeGraph?.maxFiles ?? 5, options = {}) {
|
|
372
373
|
if (!Array.isArray(filePaths) || filePaths.length === 0) return null;
|
|
373
374
|
|
|
374
375
|
const ws = resolveWorkspace({ ...options, filePaths });
|
|
@@ -419,8 +420,9 @@ async function getComplexitySignals(filePaths, options = {}) {
|
|
|
419
420
|
if (cached) return cached;
|
|
420
421
|
|
|
421
422
|
// Run parallel queries: neighbors (blast radius) + god_nodes + graph_stats
|
|
423
|
+
const signalDepth = String(config.codeGraph?.maxDepth ?? 2);
|
|
422
424
|
const [neighborsResult, godNodesResult, statsResult] = await Promise.all([
|
|
423
|
-
execGraph("query", ["get_neighbors", "--files", ...filePaths, "--depth",
|
|
425
|
+
execGraph("query", ["get_neighbors", "--files", ...filePaths, "--depth", signalDepth, "--json"], ws),
|
|
424
426
|
execGraph("query", ["god_nodes", "--json"], ws),
|
|
425
427
|
execGraph("query", ["graph_stats", "--json"], ws),
|
|
426
428
|
]);
|