@openbmb/clawxrouter 1.0.4

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/src/types.ts ADDED
@@ -0,0 +1,269 @@
1
+ export type SensitivityLevel = "S1" | "S2" | "S3";
2
+
3
+ export type SensitivityLevelNumeric = 1 | 2 | 3;
4
+
5
+ export type DetectorType = "ruleDetector" | "localModelDetector";
6
+
7
+ export type Checkpoint = "onUserMessage" | "onToolCallProposed" | "onToolCallExecuted";
8
+
9
+ /**
10
+ * Edge provider API protocol type.
11
+ * - "openai-compatible": POST /v1/chat/completions (Ollama, vLLM, LiteLLM, LocalAI, LMStudio, SGLang โ€ฆ)
12
+ * - "ollama-native": POST /api/chat (Ollama native API, supports streaming natively)
13
+ * - "custom": User-supplied module exporting a callChat function
14
+ */
15
+ export type EdgeProviderType = "openai-compatible" | "ollama-native" | "custom";
16
+
17
+ export type PrivacyConfig = {
18
+ enabled?: boolean;
19
+ /** S2 handling: "proxy" strips PII via local HTTP proxy (default), "local" routes to local model */
20
+ s2Policy?: "proxy" | "local";
21
+ /** Port for the privacy proxy server (default: 8403) */
22
+ proxyPort?: number;
23
+ checkpoints?: {
24
+ onUserMessage?: DetectorType[];
25
+ onToolCallProposed?: DetectorType[];
26
+ onToolCallExecuted?: DetectorType[];
27
+ };
28
+ rules?: {
29
+ keywords?: {
30
+ S2?: string[];
31
+ S3?: string[];
32
+ };
33
+ /** Regex patterns for matching sensitive content (strings are compiled to RegExp) */
34
+ patterns?: {
35
+ S2?: string[];
36
+ S3?: string[];
37
+ };
38
+ tools?: {
39
+ S2?: {
40
+ tools?: string[];
41
+ paths?: string[];
42
+ };
43
+ S3?: {
44
+ tools?: string[];
45
+ paths?: string[];
46
+ };
47
+ };
48
+ };
49
+ localModel?: {
50
+ enabled?: boolean;
51
+ /** API protocol type (default: "openai-compatible") */
52
+ type?: EdgeProviderType;
53
+ /** Provider name for OpenClaw routing (e.g. "ollama", "vllm", "lmstudio") */
54
+ provider?: string;
55
+ model?: string;
56
+ endpoint?: string;
57
+ apiKey?: string;
58
+ /** Path to custom provider module (type="custom" only). Must export callChat(). */
59
+ module?: string;
60
+ };
61
+ guardAgent?: {
62
+ id?: string;
63
+ workspace?: string;
64
+ /** Full model reference in "provider/model" format (e.g. "ollama/llama3.2:3b", "vllm/qwen2.5:7b") */
65
+ model?: string;
66
+ };
67
+ session?: {
68
+ isolateGuardHistory?: boolean;
69
+ /** Base directory for session histories (default: ~/.openclaw) */
70
+ baseDir?: string;
71
+ /**
72
+ * Inject full-track conversation history as context when routing to
73
+ * local models (S3 / S2-local). This replaces the sanitized placeholders
74
+ * ("๐Ÿ”’ [Private content]") with actual previous sensitive interactions
75
+ * so the local model has full conversational context.
76
+ * Default: true (when isolateGuardHistory is true)
77
+ */
78
+ injectDualHistory?: boolean;
79
+ /** Max number of messages to inject from dual-track history (default: 20) */
80
+ historyLimit?: number;
81
+ };
82
+ /**
83
+ * Additional provider names to treat as "local" (safe for S3 routing).
84
+ * Built-in local providers: ollama, llama.cpp, localai, llamafile, lmstudio, vllm, mlx, sglang, tgi.
85
+ * Add custom entries here if you run your own inference backend.
86
+ */
87
+ localProviders?: string[];
88
+ /**
89
+ * Tool names exempt from privacy pipeline detection and PII redaction.
90
+ * Default: empty (no tools are exempt). Users can opt-in via config.
91
+ */
92
+ toolAllowlist?: string[];
93
+ /**
94
+ * Per-model pricing for cloud API cost estimation (USD per 1M tokens).
95
+ * Keys are model name strings; lookup tries exact match, then substring match.
96
+ */
97
+ modelPricing?: Record<string, {
98
+ inputPer1M?: number;
99
+ outputPer1M?: number;
100
+ }>;
101
+ /**
102
+ * Toggle high-false-positive redaction rules individually.
103
+ * All default to false (off) to avoid over-redaction.
104
+ */
105
+ redaction?: RedactionOptions;
106
+ };
107
+
108
+ export type RedactionOptions = {
109
+ /** Internal IP addresses (10.x, 172.16-31.x, 192.168.x). Default: false */
110
+ internalIp?: boolean;
111
+ /** Email addresses. Default: false */
112
+ email?: boolean;
113
+ /** .env file content (KEY=VALUE lines). Default: false */
114
+ envVar?: boolean;
115
+ /** Credit card number pattern (13-19 digits). Default: false */
116
+ creditCard?: boolean;
117
+ /** Chinese mobile phone number (1[3-9]x 11 digits). Default: false */
118
+ chinesePhone?: boolean;
119
+ /** Chinese ID card number (18 digits / 17+X). Default: false */
120
+ chineseId?: boolean;
121
+ /** Chinese address patterns (็œ/ๅธ‚/ๅŒบ/่ทฏ/ๅท etc.). Default: false */
122
+ chineseAddress?: boolean;
123
+ /** PIN / pin code contextual rule. Default: false */
124
+ pin?: boolean;
125
+ };
126
+
127
+ export type DetectionContext = {
128
+ checkpoint: Checkpoint;
129
+ message?: string;
130
+ toolName?: string;
131
+ toolParams?: Record<string, unknown>;
132
+ toolResult?: unknown;
133
+ sessionKey?: string;
134
+ agentId?: string;
135
+ recentContext?: string[];
136
+ /** When true, routers should skip the `enabled` check (dry-run from dashboard). */
137
+ dryRun?: boolean;
138
+ };
139
+
140
+ export type DetectionResult = {
141
+ level: SensitivityLevel;
142
+ levelNumeric: SensitivityLevelNumeric;
143
+ reason?: string;
144
+ detectorType: DetectorType;
145
+ confidence?: number;
146
+ };
147
+
148
+ // โ”€โ”€ Router Pipeline Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
149
+
150
+ export type RouterAction = "passthrough" | "redirect" | "transform" | "block";
151
+
152
+ export type RouterDecision = {
153
+ level: SensitivityLevel;
154
+ action?: RouterAction;
155
+ target?: {
156
+ provider: string;
157
+ model: string;
158
+ /** Set by pipeline merge when the winning provider (clawxrouter-privacy) differs
159
+ * from the router that originally selected the model.
160
+ * Used by hooks to stash the correct provider endpoint for the proxy. */
161
+ originalProvider?: string;
162
+ };
163
+ /** When action is "transform", the transformed prompt content */
164
+ transformedContent?: string;
165
+ reason?: string;
166
+ confidence?: number;
167
+ routerId?: string;
168
+ };
169
+
170
+ /**
171
+ * Interface for pluggable routers.
172
+ * The built-in "privacy" router wraps the existing detector + desensitization logic.
173
+ * Users can implement custom routers (cost optimization, content filtering, etc.)
174
+ * and register them in the pipeline config.
175
+ */
176
+ export interface ClawXrouterRouter {
177
+ id: string;
178
+ detect(
179
+ context: DetectionContext,
180
+ config: Record<string, unknown>,
181
+ ): Promise<RouterDecision>;
182
+ }
183
+
184
+ export type RouterRegistration = {
185
+ enabled?: boolean;
186
+ /** "builtin" for privacy/rules, "custom" for user modules, "configurable" for dashboard-created */
187
+ type?: "builtin" | "custom" | "configurable";
188
+ /** Path to custom router module (type="custom" only) */
189
+ module?: string;
190
+ /** Arbitrary config passed to the router's detect() */
191
+ options?: Record<string, unknown>;
192
+ /**
193
+ * Merge weight (0โ€“100, default 50). Higher weight wins when multiple routers
194
+ * produce non-passthrough decisions at the same sensitivity level.
195
+ * Safety routers (privacy) should use high weights; optimization routers
196
+ * (token-saver) should use lower weights so they only take effect when
197
+ * safety routers pass through.
198
+ */
199
+ weight?: number;
200
+ };
201
+
202
+ export type PipelineConfig = {
203
+ onUserMessage?: string[];
204
+ onToolCallProposed?: string[];
205
+ onToolCallExecuted?: string[];
206
+ };
207
+
208
+ // โ”€โ”€ Session / History Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
209
+
210
+ export type SessionPrivacyState = {
211
+ sessionKey: string;
212
+ /** @deprecated Replaced by per-turn currentTurnLevel. Kept for backward compat. */
213
+ isPrivate: boolean;
214
+ highestLevel: SensitivityLevel;
215
+ /** Highest sensitivity level detected in the CURRENT turn (reset each turn). */
216
+ currentTurnLevel: SensitivityLevel;
217
+ detectionHistory: Array<{
218
+ timestamp: number;
219
+ level: SensitivityLevel;
220
+ checkpoint: Checkpoint;
221
+ reason?: string;
222
+ routerId?: string;
223
+ action?: string;
224
+ target?: string;
225
+ loopId?: string;
226
+ }>;
227
+ };
228
+
229
+ export type LoopMeta = {
230
+ loopId: string;
231
+ sessionKey: string;
232
+ userMessagePreview: string;
233
+ startedAt: number;
234
+ highestLevel: SensitivityLevel;
235
+ routingTier?: string;
236
+ routedModel?: string;
237
+ routerAction?: string;
238
+ };
239
+
240
+ export function levelToNumeric(level: SensitivityLevel): SensitivityLevelNumeric {
241
+ switch (level) {
242
+ case "S1":
243
+ return 1;
244
+ case "S2":
245
+ return 2;
246
+ case "S3":
247
+ return 3;
248
+ }
249
+ }
250
+
251
+ export function numericToLevel(numeric: SensitivityLevelNumeric): SensitivityLevel {
252
+ switch (numeric) {
253
+ case 1:
254
+ return "S1";
255
+ case 2:
256
+ return "S2";
257
+ case 3:
258
+ return "S3";
259
+ default:
260
+ return "S1";
261
+ }
262
+ }
263
+
264
+ export function maxLevel(...levels: SensitivityLevel[]): SensitivityLevel {
265
+ if (levels.length === 0) return "S1";
266
+ const numeric = levels.map(levelToNumeric);
267
+ const max = Math.max(...numeric) as SensitivityLevelNumeric;
268
+ return numericToLevel(max);
269
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Normalize path for comparison (expand ~, resolve relative paths)
3
+ */
4
+ export function normalizePath(path: string): string {
5
+ if (path.startsWith("~/")) {
6
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
7
+ return path.replace("~", home);
8
+ }
9
+ return path;
10
+ }
11
+
12
+ /**
13
+ * Check if a path matches any of the patterns
14
+ */
15
+ export function matchesPathPattern(path: string, patterns: string[]): boolean {
16
+ const normalizedPath = normalizePath(path);
17
+
18
+ for (const pattern of patterns) {
19
+ const normalizedPattern = normalizePath(pattern);
20
+
21
+ // Exact match
22
+ if (normalizedPath === normalizedPattern) {
23
+ return true;
24
+ }
25
+
26
+ // Prefix match (directory)
27
+ if (normalizedPath.startsWith(normalizedPattern + "/") ||
28
+ normalizedPath.startsWith(normalizedPattern + "\\")) {
29
+ return true;
30
+ }
31
+
32
+ // Suffix match (file extension)
33
+ if (pattern.startsWith("*") && normalizedPath.endsWith(pattern.slice(1))) {
34
+ return true;
35
+ }
36
+ }
37
+
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Extract paths from tool parameters
43
+ */
44
+ export function extractPathsFromParams(params: Record<string, unknown>): string[] {
45
+ const paths: string[] = [];
46
+
47
+ // Common path parameter names
48
+ const pathKeys = ["path", "file", "filepath", "filename", "dir", "directory", "target", "source"];
49
+
50
+ for (const key of pathKeys) {
51
+ const value = params[key];
52
+ if (typeof value === "string" && value.trim()) {
53
+ paths.push(value.trim());
54
+ }
55
+ }
56
+
57
+ // Extract filesystem paths embedded in command strings
58
+ const commandKeys = ["command", "cmd", "script"];
59
+ for (const key of commandKeys) {
60
+ const value = params[key];
61
+ if (typeof value === "string" && value.trim()) {
62
+ paths.push(...extractPathsFromCommand(value));
63
+ }
64
+ }
65
+
66
+ // Also check nested objects
67
+ for (const value of Object.values(params)) {
68
+ if (value && typeof value === "object" && !Array.isArray(value)) {
69
+ paths.push(...extractPathsFromParams(value as Record<string, unknown>));
70
+ }
71
+ }
72
+
73
+ return paths;
74
+ }
75
+
76
+ /**
77
+ * Extract filesystem paths from a shell command string.
78
+ * Matches absolute paths (/...) and home-relative paths (~/).
79
+ */
80
+ function extractPathsFromCommand(command: string): string[] {
81
+ const pathRegex = /(?:\/[\w.\-]+(?:\/[\w.\-]*)*|~\/[\w.\-]+(?:\/[\w.\-]*)*)/g;
82
+ const matches = command.match(pathRegex);
83
+ return matches ?? [];
84
+ }
85
+
86
+ /**
87
+ * Sanitize sensitive information from text (comprehensive rule-based redaction).
88
+ * Used for S2 desensitization: redact known patterns then forward to cloud.
89
+ *
90
+ * Two-phase approach:
91
+ * Phase 1 โ€“ Pattern-based: well-known formats (SSH keys, API keys, IPs, etc.)
92
+ * Phase 2 โ€“ Context-based: keyword + connecting words + value
93
+ * e.g. "password is in abc123" โ†’ "[REDACTED:PASSWORD]"
94
+ *
95
+ * Some rules are opt-in via `RedactionOptions` to avoid false positives.
96
+ */
97
+ export function redactSensitiveInfo(text: string, opts?: import("./types.js").RedactionOptions): string {
98
+ let redacted = text;
99
+
100
+ // โ”€โ”€ Phase 1: Pattern-based redaction (always on โ€” low false-positive) โ”€โ”€โ”€โ”€โ”€
101
+
102
+ // Redact SSH private key blocks
103
+ redacted = redacted.replace(
104
+ /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g,
105
+ "[REDACTED:PRIVATE_KEY]"
106
+ );
107
+
108
+ // Redact API keys (sk-xxx, key-xxx patterns)
109
+ redacted = redacted.replace(/\b(?:sk|key|token)-[A-Za-z0-9]{16,}\b/g, "[REDACTED:KEY]");
110
+
111
+ // Redact AWS Access Key IDs
112
+ redacted = redacted.replace(/AKIA[0-9A-Z]{16}/g, "[REDACTED:AWS_KEY]");
113
+
114
+ // Redact database connection strings
115
+ redacted = redacted.replace(
116
+ /(?:mysql|postgres|postgresql|mongodb|redis|amqp):\/\/[^\s"']+/gi,
117
+ "[REDACTED:DB_CONNECTION]"
118
+ );
119
+
120
+ // โ”€โ”€ Phase 1a: Opt-in pattern rules (off by default to avoid false positives) โ”€โ”€
121
+
122
+ if (opts?.internalIp) {
123
+ redacted = redacted.replace(
124
+ /\b(?:10|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b/g,
125
+ "[REDACTED:INTERNAL_IP]"
126
+ );
127
+ }
128
+
129
+ if (opts?.email) {
130
+ redacted = redacted.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "[REDACTED:EMAIL]");
131
+ }
132
+
133
+ if (opts?.envVar) {
134
+ redacted = redacted.replace(
135
+ /^(?:export\s+)?[A-Z_]{2,}=(?:["'])?[^\s"']+(?:["'])?$/gm,
136
+ "[REDACTED:ENV_VAR]"
137
+ );
138
+ }
139
+
140
+ if (opts?.creditCard) {
141
+ redacted = redacted.replace(
142
+ /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{1,7}\b/g,
143
+ "[REDACTED:CARD_NUMBER]"
144
+ );
145
+ }
146
+
147
+ // โ”€โ”€ Phase 1b: Chinese PII pattern-based redaction (opt-in) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
148
+
149
+ if (opts?.chinesePhone) {
150
+ redacted = redacted.replace(/(?<!\d)1[3-9]\d{9}(?!\d)/g, "[REDACTED:PHONE]");
151
+ }
152
+
153
+ if (opts?.chineseId) {
154
+ redacted = redacted.replace(/(?<!\d)\d{17}[\dXx](?!\d)/g, "[REDACTED:ID]");
155
+ }
156
+
157
+ // Chinese delivery tracking numbers (keyword-gated, low false-positive โ€” always on)
158
+ redacted = redacted.replace(
159
+ /(?:ๅฟซ้€’ๅ•ๅท|่ฟๅ•ๅท|ๅ–ไปถ็ )[๏ผš:\s]*[A-Za-z0-9]{6,20}/g,
160
+ "[REDACTED:DELIVERY]"
161
+ );
162
+
163
+ // Door access codes following keywords (keyword-gated, low false-positive โ€” always on)
164
+ redacted = redacted.replace(
165
+ /(?:้—จ็ฆ็ |้—จ็ฆๅฏ†็ |้—จ้”ๅฏ†็ |ๅผ€้—จๅฏ†็ )[๏ผš:\s]*[A-Za-z0-9#*]{3,12}/g,
166
+ "[REDACTED:ACCESS_CODE]"
167
+ );
168
+
169
+ if (opts?.chineseAddress) {
170
+ redacted = redacted.replace(
171
+ /[\u4e00-\u9fa5]{2,}(?:็œ|ๅธ‚|ๅŒบ|ๅŽฟ|้•‡|่ทฏ|่ก—|ๅทท|ๅผ„|ๅท|ๆ ‹|ๅนข|ๅฎค|ๆฅผ|ๅ•ๅ…ƒ|้—จ็‰Œ)\d*[\u4e00-\u9fa5\d]*/g,
172
+ "[REDACTED:ADDRESS]"
173
+ );
174
+ }
175
+
176
+ // โ”€โ”€ Phase 2: Context-based redaction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
177
+ // Match: <keyword> <connecting words> <actual value>
178
+ // This catches patterns like "password is abc123", "credit card number is in 12896489bf"
179
+ //
180
+ // Two CONNECT modes:
181
+ // STRICT โ€” requires a verb (is/are/was) or delimiter (=/:) before the value.
182
+ // Used for broad keywords like "credit card" to avoid false positives.
183
+ // LOOSE โ€” also accepts a plain space between keyword and value.
184
+ // Used for credential keywords like "password" where the next word is very
185
+ // likely the value.
186
+
187
+ const STRICT_CONNECT = "(?:\\s+(?:is|are|was|were)(?:\\s+(?:in|at|on|of|for))*|\\s*[=:])\\s*";
188
+ const LOOSE_CONNECT = "(?:\\s+(?:is|are|was|were)(?:\\s+(?:in|at|on|of|for))*\\s*|\\s*[=:]\\s*|\\s+)";
189
+
190
+ const contextualRules: Array<{ pattern: RegExp; label: string }> = [
191
+ {
192
+ pattern: new RegExp(`(?:password|passwd|pwd|passcode)${LOOSE_CONNECT}["']?([^\\s"']{2,})["']?`, "gi"),
193
+ label: "PASSWORD",
194
+ },
195
+ {
196
+ pattern: new RegExp(`(?:credit\\s*card|card\\s*(?:number|no\\.?))${STRICT_CONNECT}["']?([^\\s"']{2,})["']?`, "gi"),
197
+ label: "CARD",
198
+ },
199
+ {
200
+ pattern: new RegExp(`(?:api[_\\s]?key|access[_\\s]?key|SECRET_KEY|API_KEY)${LOOSE_CONNECT}["']?([^\\s"']{2,})["']?`, "gi"),
201
+ label: "API_KEY",
202
+ },
203
+ {
204
+ pattern: new RegExp(`(?:secret)${STRICT_CONNECT}["']?([^\\s"']{2,})["']?`, "gi"),
205
+ label: "SECRET",
206
+ },
207
+ {
208
+ pattern: new RegExp(`(?:(?:auth[_\\s]?)?token|bearer)${LOOSE_CONNECT}["']?([^\\s"']{2,})["']?`, "gi"),
209
+ label: "TOKEN",
210
+ },
211
+ {
212
+ pattern: new RegExp(`(?:credential|cred)s?${LOOSE_CONNECT}["']?([^\\s"']{2,})["']?`, "gi"),
213
+ label: "CREDENTIAL",
214
+ },
215
+ {
216
+ pattern: new RegExp(`(?:ssn|social\\s*security(?:\\s*(?:number|no\\.?))?)${STRICT_CONNECT}["']?([^\\s"']{2,})["']?`, "gi"),
217
+ label: "SSN",
218
+ },
219
+ ];
220
+
221
+ if (opts?.pin) {
222
+ contextualRules.push({
223
+ pattern: new RegExp(`(?:pin(?:\\s*(?:code|number))?)${STRICT_CONNECT}["']?([^\\s"']{2,})["']?`, "gi"),
224
+ label: "PIN",
225
+ });
226
+ }
227
+
228
+ for (const rule of contextualRules) {
229
+ redacted = redacted.replace(rule.pattern, `[REDACTED:${rule.label}]`);
230
+ }
231
+
232
+ return redacted;
233
+ }
234
+
235
+ /**
236
+ * Check if a path refers to protected memory/history directories that cloud models should not access.
237
+ */
238
+ export function isProtectedMemoryPath(filePath: string, baseDir: string = "~/.openclaw"): boolean {
239
+ const normalizedFile = normalizePath(filePath);
240
+ const normalizedBase = normalizePath(baseDir);
241
+ const escapedBase = normalizedBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
242
+
243
+ // Patterns that cloud models must NOT read
244
+ const protectedPaths = [
245
+ `${escapedBase}/agents/[^/]+/sessions/full`,
246
+ `${escapedBase}/[^/]+/MEMORY-FULL\\.md`,
247
+ `${escapedBase}/[^/]+/memory-full`,
248
+ ];
249
+
250
+ for (const regexStr of protectedPaths) {
251
+ const regex = new RegExp(`^${regexStr}`);
252
+ if (regex.test(normalizedFile)) {
253
+ return true;
254
+ }
255
+ }
256
+
257
+ // Also check for direct "full" history paths
258
+ if (
259
+ normalizedFile.includes("/sessions/full/") ||
260
+ normalizedFile.includes("/memory-full/") ||
261
+ normalizedFile.endsWith("/MEMORY-FULL.md")
262
+ ) {
263
+ return true;
264
+ }
265
+
266
+ return false;
267
+ }
268
+
269
+ /**
270
+ * Resolve the default base URL for a provider based on its name and API type.
271
+ */
272
+ export function resolveDefaultBaseUrl(provider: string, api?: string): string {
273
+ const p = provider.toLowerCase();
274
+ const a = (api ?? "").toLowerCase();
275
+ if (p === "google" || p.includes("gemini") || p.includes("vertex") ||
276
+ a.includes("google") || a.includes("gemini")) {
277
+ return "https://generativelanguage.googleapis.com/v1beta";
278
+ }
279
+ if (p === "anthropic" || a === "anthropic-messages") {
280
+ return "https://api.anthropic.com";
281
+ }
282
+ return "https://api.openai.com/v1";
283
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Minimal ESM resolve hook that maps .js imports to .ts files
3
+ * when the .js file does not exist on disk.
4
+ *
5
+ * Needed because Node.js v25 strips TS types natively but does NOT
6
+ * rewrite ".js" โ†’ ".ts" in import specifiers the way tsx/ts-node do.
7
+ * Worker threads spawned by synckit therefore fail to resolve
8
+ * co-located .ts modules imported via the conventional ".js" extension.
9
+ */
10
+ import { register } from "node:module";
11
+
12
+ const hooks = [
13
+ "export async function resolve(specifier, context, nextResolve) {",
14
+ " if (specifier.endsWith('.js') && !specifier.startsWith('node:')) {",
15
+ " try {",
16
+ " return await nextResolve(specifier.replace(/\\.js$/, '.ts'), context);",
17
+ " } catch {",
18
+ " // .ts variant not found โ€” fall through to original specifier",
19
+ " }",
20
+ " }",
21
+ " return nextResolve(specifier, context);",
22
+ "}",
23
+ ].join("\n");
24
+
25
+ register("data:text/javascript," + encodeURIComponent(hooks), import.meta.url);