@yeaft/webchat-agent 1.0.639 → 1.0.640

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.
@@ -0,0 +1,169 @@
1
+ /** Query-local web evidence and request lifecycle. Nothing persists across queries. */
2
+ import { STATUS_CODES } from 'node:http';
3
+
4
+ const MAX_TRACKED = 64;
5
+ export const WEB_REQUEST_TIMEOUT_MS = 15_000;
6
+
7
+ export function createWebResearchState() {
8
+ return {
9
+ searches: [],
10
+ fetches: new Map(),
11
+ authorizationFailures: new Map(),
12
+ inFlight: new Map(),
13
+ completedCalls: 0,
14
+ };
15
+ }
16
+
17
+ // Search operators, punctuation, case and quoted whitespace may change meaning.
18
+ // Only surrounding whitespace is safe to discard for evidence reuse.
19
+ export function normalizeSearchQuery(value) {
20
+ return String(value || '').trim();
21
+ }
22
+
23
+ function grams(value) {
24
+ const result = new Set();
25
+ for (let i = 0; i < Math.max(1, value.length - 2); i += 1) result.add(value.slice(i, i + 3));
26
+ return result;
27
+ }
28
+
29
+ export function findPriorSearch(state, query, limit = 5) {
30
+ const normalized = normalizeSearchQuery(query);
31
+ return state?.searches?.findLast(prior => prior.normalized === normalized && prior.limit >= limit) || null;
32
+ }
33
+
34
+ /** Similarity is advisory ONLY; never use this to suppress a semantic variant. */
35
+ export function findRelatedSearch(state, query) {
36
+ const normalized = normalizeSearchQuery(query);
37
+ const left = grams(normalized);
38
+ return state?.searches?.findLast(prior => {
39
+ if (prior.normalized === normalized) return false;
40
+ const right = grams(prior.normalized);
41
+ let overlap = 0;
42
+ for (const item of left) if (right.has(item)) overlap += 1;
43
+ return overlap / (left.size + right.size - overlap) >= 0.75;
44
+ }) || null;
45
+ }
46
+
47
+ export function recordSearch(state, query, limit, output) {
48
+ if (!state) return;
49
+ state.searches.push({ query, normalized: normalizeSearchQuery(query), limit, output });
50
+ if (state.searches.length > MAX_TRACKED) state.searches.shift();
51
+ }
52
+
53
+ export function recordWebResearchCall(state) {
54
+ if (state) state.completedCalls += 1;
55
+ }
56
+
57
+ export function setBounded(map, key, value) {
58
+ if (!(map instanceof Map)) return;
59
+ map.delete(key);
60
+ map.set(key, value);
61
+ while (map.size > MAX_TRACKED) map.delete(map.keys().next().value);
62
+ }
63
+
64
+ export function canonicalWebUrl(value) {
65
+ const url = new URL(value);
66
+ url.hash = '';
67
+ return url.href;
68
+ }
69
+
70
+ export function webFetchKey(url, { raw = false, maxLength = 50000 } = {}) {
71
+ return JSON.stringify([canonicalWebUrl(url), raw, maxLength]);
72
+ }
73
+
74
+ /** Diagnostic identity only. Keep request credentials out of model-visible URLs. */
75
+ export function safeWebUrl(value) {
76
+ try {
77
+ const url = new URL(value);
78
+ if (!['http:', 'https:'].includes(url.protocol)) return undefined;
79
+ url.username = '';
80
+ url.password = '';
81
+ url.hash = '';
82
+ for (const key of new Set(url.searchParams.keys())) {
83
+ if (/key|token|secret|pass(?:word|wd)?|auth|credential|signature|(?:^|[-_])sig(?:$|[-_])/i.test(key)) {
84
+ url.searchParams.set(key, '[redacted]');
85
+ }
86
+ }
87
+ return url.href;
88
+ } catch { return undefined; }
89
+ }
90
+
91
+ /** Wait for completion OR abort, including non-cooperative fetch/body implementations. */
92
+ async function abortable(work, signal) {
93
+ signal?.throwIfAborted();
94
+ if (!signal) return work();
95
+ let onAbort;
96
+ const aborted = new Promise((_, reject) => {
97
+ onAbort = () => reject(signal.reason || new DOMException('Cancelled', 'AbortError'));
98
+ signal.addEventListener('abort', onAbort, { once: true });
99
+ });
100
+ try {
101
+ return await Promise.race([Promise.resolve().then(() => { signal.throwIfAborted(); return work(); }), aborted]);
102
+ } finally {
103
+ signal.removeEventListener('abort', onAbort);
104
+ }
105
+ }
106
+
107
+ /** Bound fetch AND body consumption, retaining the caller's cancellation signal. */
108
+ export async function withWebDeadline(signal, work, timeoutMs = WEB_REQUEST_TIMEOUT_MS) {
109
+ const deadline = new AbortController();
110
+ const timer = setTimeout(() => deadline.abort(new DOMException('Web request deadline exceeded', 'TimeoutError')), timeoutMs);
111
+ timer.unref?.();
112
+ const combined = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
113
+ try { return await abortable(() => work(combined), combined); }
114
+ finally {
115
+ clearTimeout(timer);
116
+ // Also release an unread error response body instead of leaving a socket open.
117
+ if (!deadline.signal.aborted) deadline.abort();
118
+ }
119
+ }
120
+
121
+ /** Share only active identical calls. Settled errors/cancellation are NEVER cached. */
122
+ export async function shareWebRequest(state, key, signal, work) {
123
+ signal?.throwIfAborted();
124
+ if (!state) return work();
125
+ let entry = state.inFlight.get(key);
126
+ if (!entry || entry.signal?.aborted) {
127
+ entry = { signal, promise: null };
128
+ entry.promise = Promise.resolve().then(work).finally(() => {
129
+ if (state.inFlight.get(key) === entry) state.inFlight.delete(key);
130
+ });
131
+ state.inFlight.set(key, entry);
132
+ }
133
+ return abortable(() => entry.promise, signal);
134
+ }
135
+
136
+ export function webHttpFailure(status) {
137
+ // Remote reason phrases can contain arbitrary text, including request secrets.
138
+ const statusText = STATUS_CODES[status] || 'Unknown status';
139
+ let hint = 'Inspect the HTTP status and use another authoritative URL if this endpoint remains unavailable.';
140
+ if (status === 401 || status === 403) hint = 'This URL denied access; use a publicly accessible official page or an authorized access path. Other URLs on this host may still work.';
141
+ else if (status === 404 || status === 410) hint = 'Verify the exact URL against the official site index; this page may have moved or been removed.';
142
+ else if (status === 429 || status >= 500) hint = 'The endpoint may be temporarily unavailable; retry later or use another authoritative source. Avoid rapid retries.';
143
+ return { error: `HTTP ${status}: ${statusText}`, code: 'http_error', status, errorEffect: 'none', hint };
144
+ }
145
+
146
+ export function webRequestFailure(error, signal) {
147
+ const cancelled = signal?.aborted || error?.name === 'AbortError';
148
+ const timeout = !cancelled && error?.name === 'TimeoutError';
149
+ return {
150
+ // Fetch/Request/parser exception text can echo configured URL credentials.
151
+ // Keep diagnostics categorical; never forward arbitrary exception messages.
152
+ error: cancelled ? 'Web request cancelled' : timeout ? 'Web request timed out'
153
+ : error?.name === 'SyntaxError' ? 'Web response could not be parsed' : 'Web request failed',
154
+ code: cancelled ? 'cancelled' : timeout ? 'timeout' : error?.name === 'SyntaxError' ? 'invalid_response' : 'network_error',
155
+ errorEffect: 'none',
156
+ hint: cancelled ? 'Stop research for this cancelled call. A later explicit call can retry.'
157
+ : timeout ? 'The request exceeded its deadline; retry later or use a smaller, more targeted official endpoint.'
158
+ : 'Verify the endpoint and network access; retry later or use another authoritative source.',
159
+ };
160
+ }
161
+
162
+ export function attachResearchGuidance(output, state, extra = {}) {
163
+ const value = typeof output === 'string' ? JSON.parse(output) : output;
164
+ const count = state?.completedCalls || 0;
165
+ return JSON.stringify({
166
+ ...value, ...extra,
167
+ ...(count >= 4 ? { researchGuidance: `This turn has completed ${count} web research calls. Before another request, identify the specific unresolved fact. Prefer answering with stated uncertainty when existing evidence is sufficient.` } : {}),
168
+ });
169
+ }