claude-mem-lite 6.22.1 → 6.23.2
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +47 -11
- package/README.zh-CN.md +38 -8
- package/cli/common.mjs +2 -0
- package/haiku-client.mjs +125 -21
- package/hook.mjs +5 -1
- package/lib/anthropic-base-url.mjs +97 -0
- package/lib/delete-core.mjs +5 -0
- package/lib/llm-provider-probe.mjs +45 -3
- package/lib/proxy-fetch.mjs +71 -7
- package/lib/search-telemetry.mjs +517 -0
- package/mem-cli.mjs +24 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -1
- package/schema.mjs +51 -0
- package/server.mjs +123 -11
- package/source-files.mjs +2 -0
- package/tool-schemas.mjs +36 -3
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// lib/anthropic-base-url.mjs - one resolver for ANTHROPIC_BASE_URL.
|
|
2
|
+
//
|
|
3
|
+
// Two callers need the same answer and used to derive it differently:
|
|
4
|
+
// haiku-client's callModelAPI builds the request URL from it, and
|
|
5
|
+
// lib/llm-provider-probe.mjs picks the host + port doctor probes. When the two
|
|
6
|
+
// disagree, doctor certifies a hop the product never uses - the false-green
|
|
7
|
+
// shape this seam exists to remove. So the trim, the URL parse, and the
|
|
8
|
+
// validity verdict live here and both sites read the same result.
|
|
9
|
+
//
|
|
10
|
+
// ANTHROPIC_BASE_URL is the Claude Code / Anthropic SDK convention: an origin
|
|
11
|
+
// (+ optional /anthropic path on Azure Foundry) with NO /v1 suffix - callers
|
|
12
|
+
// append the endpoint path. Unset or blank keeps the public API, which is the
|
|
13
|
+
// behaviour before the gateway override existed.
|
|
14
|
+
|
|
15
|
+
import net from 'node:net';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
|
|
18
|
+
|
|
19
|
+
// Plain http:// is allowed only to loopback. Anywhere else it puts the
|
|
20
|
+
// x-api-key on the wire unencrypted (and prompts in the clear), so that value
|
|
21
|
+
// is refused rather than silently honoured.
|
|
22
|
+
function isLoopbackHost(host) {
|
|
23
|
+
return (
|
|
24
|
+
host === 'localhost' ||
|
|
25
|
+
host.endsWith('.localhost') ||
|
|
26
|
+
host === '::1' ||
|
|
27
|
+
host === '[::1]' ||
|
|
28
|
+
// A real dotted-quad loopback, not a DNS name that merely starts "127."
|
|
29
|
+
// ("127.attacker.example" resolves wherever its owner says).
|
|
30
|
+
(net.isIP(host) === 4 && host.startsWith('127.'))
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the configured base URL into a usable origin + path.
|
|
36
|
+
*
|
|
37
|
+
* Never throws and always returns a well-formed `url` a caller can append
|
|
38
|
+
* `/v1/messages` to: blank / unset keeps the public default, and a value that is
|
|
39
|
+
* set but unusable (unparseable, non-http(s) scheme, userinfo, a query string or
|
|
40
|
+
* fragment, or plain http to a non-loopback host) also returns the default so
|
|
41
|
+
* callers always get a parseable value. `error` names why; the direct API leg
|
|
42
|
+
* ignores the fallback and skips itself when a configured value is unusable, and
|
|
43
|
+
* doctor surfaces the same reason instead of probing a hop nothing uses.
|
|
44
|
+
*
|
|
45
|
+
* @returns {{url: string, configured: boolean, error: string|null}}
|
|
46
|
+
* url normalized origin+path, no trailing slash
|
|
47
|
+
* configured ANTHROPIC_BASE_URL was set to a non-blank value
|
|
48
|
+
* error why a configured value is unusable, or null when it is fine
|
|
49
|
+
*/
|
|
50
|
+
export function resolveAnthropicBaseUrl() {
|
|
51
|
+
const raw = (process.env.ANTHROPIC_BASE_URL || '').trim();
|
|
52
|
+
if (!raw) return { url: DEFAULT_ANTHROPIC_BASE_URL, configured: false, error: null };
|
|
53
|
+
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = new URL(raw);
|
|
57
|
+
} catch {
|
|
58
|
+
return {
|
|
59
|
+
url: DEFAULT_ANTHROPIC_BASE_URL,
|
|
60
|
+
configured: true,
|
|
61
|
+
error: 'ANTHROPIC_BASE_URL is not a valid URL',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (parsed.username || parsed.password) {
|
|
65
|
+
// fetch() refuses to build a request with credentials in the URL, so every
|
|
66
|
+
// call would fall back to the CLI while doctor reported the host reachable.
|
|
67
|
+
return {
|
|
68
|
+
url: DEFAULT_ANTHROPIC_BASE_URL,
|
|
69
|
+
configured: true,
|
|
70
|
+
error: 'ANTHROPIC_BASE_URL must not include userinfo (user:pass@)',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
74
|
+
return {
|
|
75
|
+
url: DEFAULT_ANTHROPIC_BASE_URL,
|
|
76
|
+
configured: true,
|
|
77
|
+
error: `ANTHROPIC_BASE_URL must use http or https (got ${parsed.protocol})`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// Raw string, not URL.search/hash: a bare trailing "?" or "#" leaves both
|
|
81
|
+
// empty while still swallowing the appended path into the query.
|
|
82
|
+
if (/[?#]/.test(raw)) {
|
|
83
|
+
return {
|
|
84
|
+
url: DEFAULT_ANTHROPIC_BASE_URL,
|
|
85
|
+
configured: true,
|
|
86
|
+
error: 'ANTHROPIC_BASE_URL must not include a query string or fragment',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) {
|
|
90
|
+
return {
|
|
91
|
+
url: DEFAULT_ANTHROPIC_BASE_URL,
|
|
92
|
+
configured: true,
|
|
93
|
+
error: `ANTHROPIC_BASE_URL uses plain http to a non-loopback host (${parsed.hostname}); the direct API leg will not send the API key there unencrypted, but the claude CLI fallback reads the same variable and still does`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { url: raw.replace(/\/+$/, ''), configured: true, error: null };
|
|
97
|
+
}
|
package/lib/delete-core.mjs
CHANGED
|
@@ -86,6 +86,11 @@ export function deleteObservations(db, ids, { snapshotTag = 'pre-delete' } = {})
|
|
|
86
86
|
WHERE superseded_by IN (${placeholders}) AND id NOT IN (${placeholders})`,
|
|
87
87
|
)
|
|
88
88
|
.run(...ids, ...ids).changes;
|
|
89
|
+
if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'search_results'").get()) {
|
|
90
|
+
db.prepare(
|
|
91
|
+
`UPDATE search_results SET snapshot_label = NULL WHERE source = 'obs' AND result_id IN (${placeholders})`,
|
|
92
|
+
).run(...ids);
|
|
93
|
+
}
|
|
89
94
|
// Execute deletion (FTS5 cleanup handled by the observations_ad trigger).
|
|
90
95
|
const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
|
|
91
96
|
return { changes: deleted.changes, recovered, restoredSuperseded };
|
|
@@ -18,6 +18,7 @@ import net from 'node:net';
|
|
|
18
18
|
import { accessSync, statSync, constants as fsConstants } from 'node:fs';
|
|
19
19
|
import { join, delimiter } from 'node:path';
|
|
20
20
|
import { httpConnectProxyFor, connectProbeViaProxy, redactProxyUrl } from './proxy-fetch.mjs';
|
|
21
|
+
import { resolveAnthropicBaseUrl } from './anthropic-base-url.mjs';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Does `cmd` name an executable file — directly when it carries a path separator, else by a
|
|
@@ -45,6 +46,21 @@ function resolvesToExecutable(cmd, env = process.env) {
|
|
|
45
46
|
|
|
46
47
|
const PROVIDER_HOST = { api: 'api.anthropic.com', openrouter: 'openrouter.ai' };
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Host and port the keyed provider actually serves from. The direct-API leg
|
|
51
|
+
* honours ANTHROPIC_BASE_URL (haiku-client's callModelAPI), so probing
|
|
52
|
+
* api.anthropic.com would certify a gateway deployment against a hop the
|
|
53
|
+
* product never uses - the same false-green shape the proxy seam below exists
|
|
54
|
+
* to avoid.
|
|
55
|
+
* @param {'api'|'openrouter'} mode
|
|
56
|
+
* @returns {{host: string, port: number}}
|
|
57
|
+
*/
|
|
58
|
+
function providerEndpoint(mode) {
|
|
59
|
+
if (mode !== 'api') return { host: PROVIDER_HOST[mode], port: 443 };
|
|
60
|
+
const u = new URL(resolveAnthropicBaseUrl().url);
|
|
61
|
+
return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 };
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
/**
|
|
49
65
|
* Open and immediately close a TCP connection. No TLS, no request, no key.
|
|
50
66
|
* @param {string} host
|
|
@@ -126,8 +142,30 @@ export async function llmProviderStatus({
|
|
|
126
142
|
};
|
|
127
143
|
}
|
|
128
144
|
|
|
129
|
-
|
|
130
|
-
|
|
145
|
+
// A set-but-unusable base URL is the always-unset variant of the false green:
|
|
146
|
+
// callModelAPI skips the direct leg entirely, so every keyed call falls back
|
|
147
|
+
// to the claude CLI and probing any host would just certify a hop nothing
|
|
148
|
+
// uses. Warn and stop instead of probing a hop the configuration does not
|
|
149
|
+
// describe.
|
|
150
|
+
const base = resolveAnthropicBaseUrl();
|
|
151
|
+
if (mode === 'api' && base.error) {
|
|
152
|
+
return {
|
|
153
|
+
mode,
|
|
154
|
+
level: 'warn',
|
|
155
|
+
message:
|
|
156
|
+
`LLM provider: ${base.error} - ` +
|
|
157
|
+
'the direct API leg is skipped and every background LLM call falls back to the ' +
|
|
158
|
+
'claude CLI, which starts a process per call; fix or unset ANTHROPIC_BASE_URL.',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const { host, port } = providerEndpoint(mode);
|
|
163
|
+
// Select the proxy off the SAME URL the product calls, so doctor exercises the
|
|
164
|
+
// hop the direct leg actually takes. A plain http gateway (loopback) bypasses
|
|
165
|
+
// the TLS-only tunnel in the product, so it must bypass it here too or doctor
|
|
166
|
+
// reports "via proxy" for a request that never touches one.
|
|
167
|
+
const proxyTarget = mode === 'api' ? `${base.url}/` : `https://${host}/`;
|
|
168
|
+
const proxy = httpConnectProxyFor(proxyTarget);
|
|
131
169
|
// Report the hop actually exercised. When a proxy is configured the request
|
|
132
170
|
// path is node → proxy → host, so probing the host directly would answer a
|
|
133
171
|
// question the product never asks — and on a machine where only the proxy is
|
|
@@ -142,7 +180,11 @@ export async function llmProviderStatus({
|
|
|
142
180
|
// occupied": a plain TCP connect passes against a SOCKS-only listener or a
|
|
143
181
|
// proxy that forbids CONNECT, and doctor would then certify a dead
|
|
144
182
|
// provider. (pre-tag review)
|
|
145
|
-
|
|
183
|
+
// URL.hostname keeps an IPv6 literal's brackets, which CONNECT needs ([::1]:443) and
|
|
184
|
+
// net.connect cannot resolve (ENOTFOUND), so only the direct probe drops them.
|
|
185
|
+
result = proxy
|
|
186
|
+
? await _proxyProbe(proxy, host, { timeout: 4000, port })
|
|
187
|
+
: await _probe(host.replace(/^\[|\]$/g, ''), { port });
|
|
146
188
|
} catch (e) {
|
|
147
189
|
result = { reachable: false, error: e?.message || String(e) };
|
|
148
190
|
}
|
package/lib/proxy-fetch.mjs
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import http from 'node:http';
|
|
23
23
|
import https from 'node:https';
|
|
24
|
+
import net from 'node:net';
|
|
24
25
|
import tls from 'node:tls';
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -35,18 +36,61 @@ export function httpConnectProxyFor(targetUrl) {
|
|
|
35
36
|
process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
|
|
36
37
|
if (!proxy || !/^https?:\/\//.test(proxy)) return null; // socks5 ALL_PROXY not supported here
|
|
37
38
|
try {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
const target = new URL(targetUrl);
|
|
40
|
+
// The tunnel below is TLS-only (https.request + tls.connect). A plain
|
|
41
|
+
// http:// target must NOT take it: the CONNECT succeeds, then the request
|
|
42
|
+
// throws ERR_INVALID_PROTOCOL from inside an event handler, past the
|
|
43
|
+
// caller's try/catch, and takes the process (MCP server included) down.
|
|
44
|
+
// Native fetch handles http directly, so the caller keeps that path.
|
|
45
|
+
if (target.protocol !== 'https:') return null;
|
|
46
|
+
const noProxy = process.env.NO_PROXY || process.env.no_proxy || '';
|
|
47
|
+
if (noProxyMatches(target.hostname, Number(target.port) || 443, noProxy)) return null;
|
|
44
48
|
return proxy;
|
|
45
49
|
} catch {
|
|
46
50
|
return null;
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Does a NO_PROXY list exempt this host? NO_PROXY has no standard (survey:
|
|
56
|
+
* about.gitlab.com/blog/we-need-to-talk-no-proxy). Every common client suffix-matches a
|
|
57
|
+
* name; beyond that they disagree, so for names and ports this accepts the UNION of the
|
|
58
|
+
* forms curl, Python and Go accept, and it can exempt a host one of them would still
|
|
59
|
+
* proxy. Case-insensitive; a '*' entry exempts every host (Go); a name entry covers itself
|
|
60
|
+
* and every subdomain on a '.' boundary, with or without a leading '.' (curl, Python) or
|
|
61
|
+
* '*.' (Go); an optional :port narrows the entry to that port (Python, Go); IPv6 with or
|
|
62
|
+
* without brackets. Narrower than the union: an IP entry matches only the canonical
|
|
63
|
+
* spelling URL.hostname uses, never as a suffix (Python suffix-matches IPs), no CIDR
|
|
64
|
+
* (curl, Go), no IDN-to-xn-- conversion (Go), no DNS lookups. NO_PROXY is read before
|
|
65
|
+
* no_proxy; curl, Python and Go (golang.org/x/net/http/httpproxy) read no_proxy first.
|
|
66
|
+
* @param {string} hostname URL.hostname (lowercase; IPv6 bracketed)
|
|
67
|
+
* @param {number} port effective target port
|
|
68
|
+
* @param {string} list raw NO_PROXY value
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
function noProxyMatches(hostname, port, list) {
|
|
72
|
+
const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
73
|
+
for (const raw of list.split(',')) {
|
|
74
|
+
let entry = raw.trim().toLowerCase();
|
|
75
|
+
if (!entry) continue;
|
|
76
|
+
if (entry === '*') return true;
|
|
77
|
+
let entryPort = null;
|
|
78
|
+
const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(entry);
|
|
79
|
+
if (bracketed) {
|
|
80
|
+
entry = bracketed[1];
|
|
81
|
+
entryPort = bracketed[2] ?? null;
|
|
82
|
+
} else if (entry.split(':').length === 2) {
|
|
83
|
+
[entry, entryPort] = entry.split(':');
|
|
84
|
+
}
|
|
85
|
+
if (entryPort !== null && Number(entryPort) !== port) continue;
|
|
86
|
+
entry = entry.replace(/^\*?\./, '').replace(/\.$/, '');
|
|
87
|
+
if (!entry) continue;
|
|
88
|
+
if (host === entry) return true;
|
|
89
|
+
if (!net.isIP(host) && host.endsWith(`.${entry}`)) return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
50
94
|
/**
|
|
51
95
|
* Proxy-Authorization header for a proxy URL that carries credentials, or {} when it does
|
|
52
96
|
* not. R10 P2-14.
|
|
@@ -173,6 +217,13 @@ export function onceViaConnectProxy(
|
|
|
173
217
|
} catch (e) {
|
|
174
218
|
return reject(e);
|
|
175
219
|
}
|
|
220
|
+
// TLS-only: reject here, synchronously, rather than let https.request throw
|
|
221
|
+
// ERR_INVALID_PROTOCOL inside the CONNECT callback where no caller can see
|
|
222
|
+
// it. httpConnectProxyFor already declines http targets; this is the second
|
|
223
|
+
// lock on the same door for any direct caller. (pre-tag review on #33)
|
|
224
|
+
if (t.protocol !== 'https:') {
|
|
225
|
+
return reject(new Error(`CONNECT tunnel supports https targets only (got ${t.protocol})`));
|
|
226
|
+
}
|
|
176
227
|
const port = Number(t.port) || 443;
|
|
177
228
|
let settled = false;
|
|
178
229
|
const finish = (fn, arg) => {
|
|
@@ -220,11 +271,24 @@ export function onceViaConnectProxy(
|
|
|
220
271
|
socket.destroy();
|
|
221
272
|
return finish(reject, new Error(`proxy CONNECT ${res.statusCode}`));
|
|
222
273
|
}
|
|
274
|
+
// SNI carries DNS names only: Node throws ERR_INVALID_ARG_VALUE for an IP
|
|
275
|
+
// servername, and a base URL (#33) can be an IP literal. `host` still drives
|
|
276
|
+
// the certificate identity check, brackets stripped for IPv6.
|
|
277
|
+
const tlsHost = t.hostname.replace(/^\[|\]$/g, '');
|
|
278
|
+
const sni = net.isIP(tlsHost) ? {} : { servername: tlsHost };
|
|
223
279
|
const req = https.request(
|
|
224
280
|
url,
|
|
225
|
-
{ method, headers, createConnection: () => tls.connect({ socket,
|
|
281
|
+
{ method, headers, createConnection: () => tls.connect({ socket, host: tlsHost, ...sni }) },
|
|
226
282
|
(resp) => {
|
|
227
283
|
const chunks = [];
|
|
284
|
+
// A connection dropped mid-body destroys the response without 'end' (and
|
|
285
|
+
// emits 'error' only to a listener), but 'close' always fires. Without this
|
|
286
|
+
// the call stayed pending until the unref'd overall timer, so an otherwise
|
|
287
|
+
// idle worker exited with it unsettled and its caller's fallback never ran.
|
|
288
|
+
// Native fetch rejects here too.
|
|
289
|
+
resp.on('close', () => {
|
|
290
|
+
if (!resp.complete) finish(reject, new Error('proxy response closed before the body ended'));
|
|
291
|
+
});
|
|
228
292
|
resp.on('data', (c) => chunks.push(Buffer.from(c)));
|
|
229
293
|
resp.on('end', () => {
|
|
230
294
|
const buf = Buffer.concat(chunks);
|