lynkr 9.14.1 → 9.14.3
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 +5 -1
- package/bin/cli.js +14 -6
- package/bin/lynkr-desktop-token.js +108 -0
- package/bin/lynkr-init.js +13 -1
- package/bin/lynkr-restart.js +125 -0
- package/bin/opencode-setup.js +175 -0
- package/bin/run.js +92 -0
- package/config/model-capabilities.json +20 -0
- package/config/model-capability-seeds.json +75 -0
- package/config/model-tiers.json +13 -1
- package/package.json +16 -22
- package/scripts/claude-desktop.js +212 -0
- package/scripts/ope-report.js +78 -0
- package/scripts/seed-capabilities.js +230 -0
- package/src/api/claude-desktop-gateway.js +123 -0
- package/src/api/health.js +0 -1
- package/src/api/mcp-broker.js +149 -0
- package/src/api/middleware/budget-enforcer.js +77 -7
- package/src/api/middleware/budget.js +36 -2
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +210 -15
- package/src/api/providers-handler.js +16 -1
- package/src/api/router.js +73 -106
- package/src/auth-mode.js +40 -4
- package/src/budget/index.js +86 -0
- package/src/cache/embeddings.js +125 -35
- package/src/cache/onnx-embedder.js +92 -0
- package/src/cache/prompt.js +0 -7
- package/src/clients/bedrock-utils.js +2 -1
- package/src/clients/codex-process.js +1 -1
- package/src/clients/codex-utils.js +0 -2
- package/src/clients/databricks.js +337 -46
- package/src/clients/gpt-utils.js +84 -8
- package/src/clients/health-probe.js +157 -0
- package/src/clients/openai-format.js +73 -23
- package/src/clients/provider-capabilities.js +27 -2
- package/src/clients/resilience.js +0 -1
- package/src/clients/responses-format.js +0 -56
- package/src/clients/routing.js +1 -1
- package/src/config/index.js +13 -2
- package/src/context/budget.js +1 -1
- package/src/context/compression.js +127 -19
- package/src/context/tool-result-compressor.js +3 -5
- package/src/dashboard/api.js +1 -1
- package/src/logger/oversized-error-stream.js +1 -1
- package/src/memory/extractor.js +3 -3
- package/src/memory/search.js +2 -2
- package/src/memory/store.js +9 -24
- package/src/memory/tools.js +14 -0
- package/src/observability/metrics.js +52 -3
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +216 -212
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +130 -13
- package/src/policy/safe-commands.js +0 -1
- package/src/routing/bandit.js +39 -0
- package/src/routing/calibration.js +21 -1
- package/src/routing/capabilities.js +88 -0
- package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
- package/src/routing/capability-seeds/family-heuristics.js +77 -0
- package/src/routing/capability-seeds/family.js +105 -0
- package/src/routing/capability-seeds/models-dev.js +64 -0
- package/src/routing/capability-seeds/normalize.js +160 -0
- package/src/routing/capability-seeds/registry.js +125 -0
- package/src/routing/capability-seeds/sources.js +69 -0
- package/src/routing/capability-seeds/swebench.js +66 -0
- package/src/routing/client-profiles.js +26 -0
- package/src/routing/complexity-analyzer.js +5 -8
- package/src/routing/confidence-scorer.js +2 -2
- package/src/routing/cost-optimizer.js +1 -2
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +38 -1
- package/src/routing/embedding-cache.js +0 -1
- package/src/routing/index.js +202 -86
- package/src/routing/intent-score.js +0 -1
- package/src/routing/knn-router.js +27 -5
- package/src/routing/latency-tracker.js +1 -1
- package/src/routing/model-registry.js +25 -0
- package/src/routing/model-slots.js +65 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/ope.js +255 -0
- package/src/routing/openai-model-slots.js +82 -0
- package/src/routing/regret-estimator.js +1 -1
- package/src/routing/shortfall.js +290 -0
- package/src/routing/stuck-detector.js +127 -0
- package/src/routing/telemetry.js +11 -2
- package/src/server.js +41 -3
- package/src/stores/file-store.js +5 -1
- package/src/tools/web-search-exec.js +182 -0
- package/src/training/trajectory-compressor.js +1 -1
- package/src/workers/pool.js +1 -1
- package/src/workers/worker.js +1 -1
- package/scripts/convert-routellm.py +0 -105
- package/src/diff/comments.js +0 -153
- package/src/policy/web-fallback.js +0 -33
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side web_search / web_fetch execution — narrowly scoped resurrection.
|
|
3
|
+
*
|
|
4
|
+
* Context: src/tools/web.js used to implement this (SearXNG-backed search,
|
|
5
|
+
* host-allowlisted fetch) and was deleted 2026-07-22 in commit b32e988,
|
|
6
|
+
* "Remove server-mode tool execution: Lynkr always forwards tool calls to
|
|
7
|
+
* the client." That was a deliberate architecture pivot, not a bug — Lynkr
|
|
8
|
+
* stopped executing ANY tool itself.
|
|
9
|
+
*
|
|
10
|
+
* This does NOT reintroduce general server-mode tool execution. It only
|
|
11
|
+
* auto-resolves `web_search`/`web_fetch` calls, and ONLY when the calling
|
|
12
|
+
* client is unrecognized by src/routing/client-profiles.js's detectClient()
|
|
13
|
+
* — i.e. Lynkr has no signal the caller can fulfill the call itself.
|
|
14
|
+
* Known harnesses (Claude Code / Claude Desktop's shared agent-sdk — both
|
|
15
|
+
* present as `claude-cli/...`, Cursor, goose, Codex) already execute these
|
|
16
|
+
* client-side today and are never routed through this module; see the call
|
|
17
|
+
* site in src/orchestrator/index.js's runAgentLoop.
|
|
18
|
+
*
|
|
19
|
+
* @module tools/web-search-exec
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const config = require("../config");
|
|
23
|
+
const logger = require("../logger");
|
|
24
|
+
|
|
25
|
+
const AUTO_RESOLVABLE_NAMES = new Set(["web_search", "websearch", "web_fetch", "webfetch"]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} name
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
function isAutoResolvable(name) {
|
|
32
|
+
return AUTO_RESOLVABLE_NAMES.has(String(name || "").toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* True only when every call in this batch is one we can resolve ourselves —
|
|
37
|
+
* a mixed batch (one auto-resolvable + one arbitrary client tool) falls
|
|
38
|
+
* through to the normal "forward to client" path untouched, since we can't
|
|
39
|
+
* partially resolve a batch the model expects answered together.
|
|
40
|
+
*
|
|
41
|
+
* @param {Array} toolCalls - normalized {id, function:{name}} or {name} calls
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function canAutoResolveAll(toolCalls) {
|
|
45
|
+
return Array.isArray(toolCalls) && toolCalls.length > 0
|
|
46
|
+
&& toolCalls.every((tc) => isAutoResolvable(tc?.function?.name ?? tc?.name));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isHostAllowed(urlStr) {
|
|
50
|
+
if (config.webSearch?.allowAllHosts) return true;
|
|
51
|
+
try {
|
|
52
|
+
const host = new URL(urlStr).hostname.toLowerCase();
|
|
53
|
+
const allowed = config.webSearch?.allowedHosts;
|
|
54
|
+
return Array.isArray(allowed) && allowed.includes(host);
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function _withTimeout(fn, timeoutMs) {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
63
|
+
try {
|
|
64
|
+
return await fn(controller.signal);
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* SearXNG-backed search. Retries transient failures per config.webSearch
|
|
72
|
+
* (retryEnabled/maxRetries) — mirrors the retry policy the deleted web.js
|
|
73
|
+
* had, since a single dropped connection to a local sidecar shouldn't fail
|
|
74
|
+
* the whole tool call.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} query
|
|
77
|
+
* @returns {Promise<Object>} { query, results: [{title,url,snippet}] } or { error }
|
|
78
|
+
*/
|
|
79
|
+
async function searchWeb(query) {
|
|
80
|
+
const endpoint = config.webSearch?.endpoint || "http://localhost:8888/search";
|
|
81
|
+
const timeoutMs = config.webSearch?.timeoutMs || 10000;
|
|
82
|
+
const maxRetries = config.webSearch?.retryEnabled ? (config.webSearch?.maxRetries ?? 2) : 0;
|
|
83
|
+
const url = `${endpoint}?q=${encodeURIComponent(query || "")}&format=json`;
|
|
84
|
+
|
|
85
|
+
let lastErr = null;
|
|
86
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
87
|
+
try {
|
|
88
|
+
const res = await _withTimeout(
|
|
89
|
+
(signal) => fetch(url, { signal }),
|
|
90
|
+
timeoutMs,
|
|
91
|
+
);
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
lastErr = `search backend returned HTTP ${res.status}`;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const json = await res.json();
|
|
97
|
+
const results = (Array.isArray(json.results) ? json.results : [])
|
|
98
|
+
.slice(0, 8)
|
|
99
|
+
.map((r) => ({ title: r.title, url: r.url, snippet: r.content }));
|
|
100
|
+
return { query, results };
|
|
101
|
+
} catch (err) {
|
|
102
|
+
lastErr = err.message;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
logger.warn({ query, endpoint, err: lastErr }, "[web-search-exec] search failed after retries");
|
|
106
|
+
return { query, error: `search failed: ${lastErr}` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Generic URL fetch, host-allowlisted per config.webSearch (allowAllHosts /
|
|
111
|
+
* allowedHosts), truncated per config.webSearch.bodyPreviewMax.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} targetUrl
|
|
114
|
+
* @returns {Promise<Object>}
|
|
115
|
+
*/
|
|
116
|
+
async function fetchUrl(targetUrl) {
|
|
117
|
+
if (!targetUrl) return { error: "no url provided" };
|
|
118
|
+
if (!isHostAllowed(targetUrl)) {
|
|
119
|
+
logger.warn({ url: targetUrl }, "[web-search-exec] fetch blocked — host not allowed");
|
|
120
|
+
return { url: targetUrl, error: "host not in allowedHosts (WEB_SEARCH_ALLOW_ALL=false)" };
|
|
121
|
+
}
|
|
122
|
+
const maxPreview = config.webSearch?.bodyPreviewMax || 10000;
|
|
123
|
+
const timeoutMs = config.webSearch?.timeoutMs || 10000;
|
|
124
|
+
try {
|
|
125
|
+
const res = await _withTimeout(
|
|
126
|
+
(signal) => fetch(targetUrl, { signal }),
|
|
127
|
+
timeoutMs,
|
|
128
|
+
);
|
|
129
|
+
const text = await res.text();
|
|
130
|
+
return {
|
|
131
|
+
url: targetUrl,
|
|
132
|
+
status: res.status,
|
|
133
|
+
content: text.slice(0, maxPreview),
|
|
134
|
+
truncated: text.length > maxPreview,
|
|
135
|
+
};
|
|
136
|
+
} catch (err) {
|
|
137
|
+
logger.warn({ url: targetUrl, err: err.message }, "[web-search-exec] fetch failed");
|
|
138
|
+
return { url: targetUrl, error: `fetch failed: ${err.message}` };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Execute every call in this (pre-checked via canAutoResolveAll) batch and
|
|
144
|
+
* append the matching assistant tool_use + user tool_result turns to
|
|
145
|
+
* `messages`, mirroring exactly what a real client round-trip would send
|
|
146
|
+
* back — so the next agent-loop iteration behaves identically to a normal
|
|
147
|
+
* client-fulfilled tool exchange, and the client never sees the exchange.
|
|
148
|
+
*
|
|
149
|
+
* @param {Array} toolCalls
|
|
150
|
+
* @param {Array} messages - mutated in place (push only, never rewritten)
|
|
151
|
+
*/
|
|
152
|
+
async function autoResolve(toolCalls, messages) {
|
|
153
|
+
const toolUseBlocks = [];
|
|
154
|
+
const toolResultBlocks = [];
|
|
155
|
+
|
|
156
|
+
for (const tc of toolCalls) {
|
|
157
|
+
const rawName = tc.function?.name ?? tc.name ?? "";
|
|
158
|
+
const name = String(rawName).toLowerCase();
|
|
159
|
+
let input = {};
|
|
160
|
+
try {
|
|
161
|
+
const raw = tc.function?.arguments ?? tc.input ?? {};
|
|
162
|
+
input = typeof raw === "string" ? JSON.parse(raw) : (raw || {});
|
|
163
|
+
} catch { /* leave input empty — result will just reflect an empty query/url */ }
|
|
164
|
+
|
|
165
|
+
toolUseBlocks.push({ type: "tool_use", id: tc.id, name: rawName, input });
|
|
166
|
+
|
|
167
|
+
const result = (name === "web_search" || name === "websearch")
|
|
168
|
+
? await searchWeb(input.query || input.q || "")
|
|
169
|
+
: await fetchUrl(input.url || "");
|
|
170
|
+
|
|
171
|
+
toolResultBlocks.push({
|
|
172
|
+
type: "tool_result",
|
|
173
|
+
tool_use_id: tc.id,
|
|
174
|
+
content: JSON.stringify(result),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
messages.push({ role: "assistant", content: toolUseBlocks });
|
|
179
|
+
messages.push({ role: "user", content: toolResultBlocks });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { canAutoResolveAll, autoResolve, searchWeb, fetchUrl, isAutoResolvable };
|
|
@@ -38,7 +38,7 @@ const telemetry = require("../routing/telemetry");
|
|
|
38
38
|
const ANONYMIZE_PATTERNS = [
|
|
39
39
|
// API keys and bearer tokens
|
|
40
40
|
[/sk-[A-Za-z0-9_-]{20,}/g, "<API_KEY>"],
|
|
41
|
-
[/Bearer\s+[A-Za-z0-9._
|
|
41
|
+
[/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer <REDACTED>"],
|
|
42
42
|
[/dapi_[A-Za-z0-9_-]+/g, "<DATABRICKS_KEY>"],
|
|
43
43
|
[/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<JWT>"],
|
|
44
44
|
// AWS keys
|
package/src/workers/pool.js
CHANGED
|
@@ -310,7 +310,7 @@ class WorkerPool {
|
|
|
310
310
|
logger.info('[WorkerPool] Shutting down...');
|
|
311
311
|
|
|
312
312
|
// Reject all pending tasks
|
|
313
|
-
for (const [
|
|
313
|
+
for (const [_taskId, pending] of this.pendingTasks) {
|
|
314
314
|
clearTimeout(pending.timeout);
|
|
315
315
|
pending.reject(new Error('Worker pool shutting down'));
|
|
316
316
|
}
|
package/src/workers/worker.js
CHANGED
|
@@ -65,7 +65,7 @@ const handlers = {
|
|
|
65
65
|
let tokensReduced = 0;
|
|
66
66
|
const seenToolResults = new Map(); // For deduplication
|
|
67
67
|
|
|
68
|
-
const transformed = messages.map((msg
|
|
68
|
+
const transformed = messages.map((msg) => {
|
|
69
69
|
if (!msg) return msg;
|
|
70
70
|
|
|
71
71
|
// Clone to avoid mutating original
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Convert routellm/gpt4_dataset (train.jsonl) → Lynkr kNN bootstrap JSONL.
|
|
4
|
-
|
|
5
|
-
Stream-downloads from HuggingFace and maps mixtral_score (1-5) to your
|
|
6
|
-
TIER_* model config so the kNN index learns to recommend YOUR real models,
|
|
7
|
-
not RouteLLM's pool (mixtral / gpt-4).
|
|
8
|
-
|
|
9
|
-
Output format matches scripts/build-knn-index.js bootstrap input:
|
|
10
|
-
{"query": "...", "provider": "ollama", "model": "minimax-m2.5:cloud",
|
|
11
|
-
"quality": 95, "cost": 0.001, "latency": 200, "tier": "SIMPLE"}
|
|
12
|
-
|
|
13
|
-
Usage:
|
|
14
|
-
.venv/bin/python scripts/convert-routellm.py [--limit 30000] -o routellm.jsonl
|
|
15
|
-
"""
|
|
16
|
-
import argparse
|
|
17
|
-
import json
|
|
18
|
-
import sys
|
|
19
|
-
import urllib.request
|
|
20
|
-
import ssl
|
|
21
|
-
|
|
22
|
-
# Map mixtral_score → user's tier config.
|
|
23
|
-
# Spread is intentionally aggressive: only score=5 (Mixtral truly nailed it)
|
|
24
|
-
# stays SIMPLE; score=4 escalates to MEDIUM so the kNN index has enough MEDIUM
|
|
25
|
-
# examples to actually recommend non-SIMPLE tiers (vs the previous mapping
|
|
26
|
-
# where 86% of records were SIMPLE and kNN never disagreed with the heuristic).
|
|
27
|
-
SCORE_TO_ROUTE = {
|
|
28
|
-
5: {"tier": "SIMPLE", "provider": "ollama", "model": "minimax-m2.5:cloud", "quality": 95, "cost": 0.001, "latency": 200},
|
|
29
|
-
4: {"tier": "MEDIUM", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 80, "cost": 0.003, "latency": 1500},
|
|
30
|
-
3: {"tier": "MEDIUM", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 70, "cost": 0.003, "latency": 1500},
|
|
31
|
-
2: {"tier": "COMPLEX", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 55, "cost": 0.003, "latency": 1500},
|
|
32
|
-
1: {"tier": "REASONING", "provider": "azure-anthropic", "model": "claude-opus-4-8", "quality": 40, "cost": 0.015, "latency": 3500},
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
URL = "https://huggingface.co/datasets/routellm/gpt4_dataset/resolve/main/train.jsonl"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def main():
|
|
39
|
-
p = argparse.ArgumentParser()
|
|
40
|
-
p.add_argument("--output", "-o", default="data/routellm-bootstrap.jsonl")
|
|
41
|
-
p.add_argument("--limit", type=int, default=None,
|
|
42
|
-
help="Only convert first N rows")
|
|
43
|
-
args = p.parse_args()
|
|
44
|
-
|
|
45
|
-
print(f"Streaming {URL} …", file=sys.stderr)
|
|
46
|
-
# Some macOS Python builds have SSL issues with huggingface.co — fall back
|
|
47
|
-
# to unverified context if needed. The data is public, no auth.
|
|
48
|
-
ctx = ssl.create_default_context()
|
|
49
|
-
try:
|
|
50
|
-
with urllib.request.urlopen(URL, context=ctx, timeout=60) as resp:
|
|
51
|
-
_stream(resp, args.output, args.limit)
|
|
52
|
-
except (ssl.SSLError, OSError) as e:
|
|
53
|
-
print(f"SSL strict failed ({e}); retrying with unverified context", file=sys.stderr)
|
|
54
|
-
ctx = ssl._create_unverified_context()
|
|
55
|
-
with urllib.request.urlopen(URL, context=ctx, timeout=60) as resp:
|
|
56
|
-
_stream(resp, args.output, args.limit)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _stream(resp, out_path, limit):
|
|
60
|
-
written = 0
|
|
61
|
-
by_score = {s: 0 for s in SCORE_TO_ROUTE}
|
|
62
|
-
skipped = 0
|
|
63
|
-
with open(out_path, "w") as out:
|
|
64
|
-
for line in resp:
|
|
65
|
-
if limit and written >= limit:
|
|
66
|
-
break
|
|
67
|
-
try:
|
|
68
|
-
row = json.loads(line)
|
|
69
|
-
except Exception:
|
|
70
|
-
skipped += 1
|
|
71
|
-
continue
|
|
72
|
-
score = row.get("mixtral_score")
|
|
73
|
-
if score not in SCORE_TO_ROUTE:
|
|
74
|
-
skipped += 1
|
|
75
|
-
continue
|
|
76
|
-
route = SCORE_TO_ROUTE[score]
|
|
77
|
-
rec = {
|
|
78
|
-
"query": row.get("prompt", ""),
|
|
79
|
-
"provider": route["provider"],
|
|
80
|
-
"model": route["model"],
|
|
81
|
-
"quality": route["quality"],
|
|
82
|
-
"cost": route["cost"],
|
|
83
|
-
"latency": route["latency"],
|
|
84
|
-
"tier": route["tier"],
|
|
85
|
-
}
|
|
86
|
-
if not rec["query"]:
|
|
87
|
-
skipped += 1
|
|
88
|
-
continue
|
|
89
|
-
out.write(json.dumps(rec) + "\n")
|
|
90
|
-
written += 1
|
|
91
|
-
by_score[score] += 1
|
|
92
|
-
if written % 5000 == 0:
|
|
93
|
-
print(f" {written} written…", file=sys.stderr)
|
|
94
|
-
|
|
95
|
-
print(f"\nWrote {written} records to {out_path} ({skipped} skipped)", file=sys.stderr)
|
|
96
|
-
print(f"By mixtral_score: {by_score}", file=sys.stderr)
|
|
97
|
-
print(f"\nTier distribution:", file=sys.stderr)
|
|
98
|
-
for score, route in SCORE_TO_ROUTE.items():
|
|
99
|
-
n = by_score[score]
|
|
100
|
-
if n:
|
|
101
|
-
print(f" {route['tier']:>10} {route['provider']}:{route['model']:30s} n={n}", file=sys.stderr)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if __name__ == "__main__":
|
|
105
|
-
main()
|
package/src/diff/comments.js
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
const crypto = require("crypto");
|
|
2
|
-
const db = require("../db");
|
|
3
|
-
const logger = require("../logger");
|
|
4
|
-
|
|
5
|
-
const insertCommentStmt = db.prepare(
|
|
6
|
-
`INSERT INTO diff_comments (
|
|
7
|
-
thread_id,
|
|
8
|
-
session_id,
|
|
9
|
-
file_path,
|
|
10
|
-
hunk,
|
|
11
|
-
line,
|
|
12
|
-
comment,
|
|
13
|
-
author,
|
|
14
|
-
created_at,
|
|
15
|
-
metadata
|
|
16
|
-
) VALUES (
|
|
17
|
-
@thread_id,
|
|
18
|
-
@session_id,
|
|
19
|
-
@file_path,
|
|
20
|
-
@hunk,
|
|
21
|
-
@line,
|
|
22
|
-
@comment,
|
|
23
|
-
@author,
|
|
24
|
-
@created_at,
|
|
25
|
-
@metadata
|
|
26
|
-
)`,
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
const deleteCommentStmt = db.prepare("DELETE FROM diff_comments WHERE id = ?");
|
|
30
|
-
|
|
31
|
-
function buildSelectCommentsQuery({ filePath, threadId }) {
|
|
32
|
-
let sql = `SELECT id,
|
|
33
|
-
thread_id,
|
|
34
|
-
session_id,
|
|
35
|
-
file_path,
|
|
36
|
-
hunk,
|
|
37
|
-
line,
|
|
38
|
-
comment,
|
|
39
|
-
author,
|
|
40
|
-
created_at,
|
|
41
|
-
metadata
|
|
42
|
-
FROM diff_comments`;
|
|
43
|
-
const conditions = [];
|
|
44
|
-
const params = [];
|
|
45
|
-
if (filePath) {
|
|
46
|
-
conditions.push("file_path = ?");
|
|
47
|
-
params.push(filePath);
|
|
48
|
-
}
|
|
49
|
-
if (threadId) {
|
|
50
|
-
conditions.push("thread_id = ?");
|
|
51
|
-
params.push(threadId);
|
|
52
|
-
}
|
|
53
|
-
if (conditions.length) {
|
|
54
|
-
sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
55
|
-
}
|
|
56
|
-
sql += " ORDER BY created_at ASC, id ASC";
|
|
57
|
-
return { sql, params };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function normaliseCommentRow(row) {
|
|
61
|
-
return {
|
|
62
|
-
id: row.id,
|
|
63
|
-
threadId: row.thread_id ?? null,
|
|
64
|
-
sessionId: row.session_id ?? null,
|
|
65
|
-
filePath: row.file_path,
|
|
66
|
-
hunk: row.hunk ?? null,
|
|
67
|
-
line: typeof row.line === "number" ? row.line : null,
|
|
68
|
-
comment: row.comment,
|
|
69
|
-
author: row.author ?? null,
|
|
70
|
-
createdAt: row.created_at,
|
|
71
|
-
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function addDiffComment({
|
|
76
|
-
threadId,
|
|
77
|
-
sessionId,
|
|
78
|
-
filePath,
|
|
79
|
-
line,
|
|
80
|
-
hunk,
|
|
81
|
-
comment,
|
|
82
|
-
author,
|
|
83
|
-
metadata,
|
|
84
|
-
}) {
|
|
85
|
-
if (typeof filePath !== "string" || filePath.trim().length === 0) {
|
|
86
|
-
throw new Error("Diff comment requires a file path.");
|
|
87
|
-
}
|
|
88
|
-
if (typeof comment !== "string" || comment.trim().length === 0) {
|
|
89
|
-
throw new Error("Diff comment requires non-empty comment text.");
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const createdAt = Date.now();
|
|
93
|
-
const thread = threadId ?? crypto.randomUUID();
|
|
94
|
-
|
|
95
|
-
const params = {
|
|
96
|
-
thread_id: thread,
|
|
97
|
-
session_id: sessionId ?? null,
|
|
98
|
-
file_path: filePath,
|
|
99
|
-
hunk: hunk ?? null,
|
|
100
|
-
line: typeof line === "number" ? line : null,
|
|
101
|
-
comment,
|
|
102
|
-
author: author ?? null,
|
|
103
|
-
created_at: createdAt,
|
|
104
|
-
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
const result = insertCommentStmt.run(params);
|
|
108
|
-
|
|
109
|
-
logger.debug(
|
|
110
|
-
{
|
|
111
|
-
id: result.lastInsertRowid,
|
|
112
|
-
thread,
|
|
113
|
-
filePath,
|
|
114
|
-
line,
|
|
115
|
-
},
|
|
116
|
-
"Recorded diff comment",
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
return normaliseCommentRow({
|
|
120
|
-
id: result.lastInsertRowid,
|
|
121
|
-
thread_id: thread,
|
|
122
|
-
session_id: sessionId ?? null,
|
|
123
|
-
file_path: filePath,
|
|
124
|
-
hunk: hunk ?? null,
|
|
125
|
-
line: typeof line === "number" ? line : null,
|
|
126
|
-
comment,
|
|
127
|
-
author: author ?? null,
|
|
128
|
-
created_at: createdAt,
|
|
129
|
-
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function listDiffComments({ filePath, threadId } = {}) {
|
|
134
|
-
const { sql, params } = buildSelectCommentsQuery({ filePath, threadId });
|
|
135
|
-
return db
|
|
136
|
-
.prepare(sql)
|
|
137
|
-
.all(...params)
|
|
138
|
-
.map(normaliseCommentRow);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function deleteDiffComment({ id }) {
|
|
142
|
-
if (!id) {
|
|
143
|
-
throw new Error("diff_comment_delete requires an id.");
|
|
144
|
-
}
|
|
145
|
-
const result = deleteCommentStmt.run(id);
|
|
146
|
-
return result.changes > 0;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
module.exports = {
|
|
150
|
-
addDiffComment,
|
|
151
|
-
listDiffComments,
|
|
152
|
-
deleteDiffComment,
|
|
153
|
-
};
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
const BROWSING_FALLBACK_PATTERNS = [
|
|
2
|
-
/i (do|don't|cannot) have (browser|browsing|internet) (capability|access)/i,
|
|
3
|
-
/cannot look up information/i,
|
|
4
|
-
/no web browsing capability/i,
|
|
5
|
-
/can'?t (access|reach) the internet/i,
|
|
6
|
-
/(do not|don't) have access to .*web (?:browsing|browser|internet)/i,
|
|
7
|
-
/(do not|don't) have .*browser/i,
|
|
8
|
-
/web(fetch|_fetch| search).*(not available|disabled|unavailable)/i,
|
|
9
|
-
/tool.*(not available|disabled|unavailable)/i,
|
|
10
|
-
/don't have access to real-time/i,
|
|
11
|
-
];
|
|
12
|
-
|
|
13
|
-
function needsWebFallback(text) {
|
|
14
|
-
if (typeof text !== "string") return false;
|
|
15
|
-
const trimmed = text.trim();
|
|
16
|
-
if (!trimmed) return false;
|
|
17
|
-
|
|
18
|
-
// If the response already includes concrete financial data, skip fallback.
|
|
19
|
-
if (
|
|
20
|
-
/\bclosed at \$\d[\d.,]*/i.test(trimmed) ||
|
|
21
|
-
/\bprevious close\b/i.test(trimmed) ||
|
|
22
|
-
/\bday'?s range\b/i.test(trimmed) ||
|
|
23
|
-
/\btrading volume\b/i.test(trimmed)
|
|
24
|
-
) {
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return BROWSING_FALLBACK_PATTERNS.some((regex) => regex.test(trimmed));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
module.exports = {
|
|
32
|
-
needsWebFallback,
|
|
33
|
-
};
|