@sema-agent/client-core 0.11.20 → 0.11.21
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 +1 -1
- package/dist/env/localeGeo.d.ts +63 -0
- package/dist/env/localeGeo.js +141 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +14 -0
- package/dist/websearch/searchProviderPresets.d.ts +126 -0
- package/dist/websearch/searchProviderPresets.js +152 -0
- package/dist/websearch/searchProviderPresets.json +37 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
|
|
|
23
23
|
|
|
24
24
|
## Scope
|
|
25
25
|
|
|
26
|
-
**Version:** 0.11.
|
|
26
|
+
**Version:** 0.11.21
|
|
27
27
|
|
|
28
28
|
- **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
|
|
29
29
|
B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localeGeo.ts — **地域预选 v1(零网络)**:只看系统 locale 与 IANA 时区,给出一个「默认选哪个
|
|
3
|
+
* 地址更可达」的提示。三端复用(CLI / web / 桌面)。
|
|
4
|
+
*
|
|
5
|
+
* ## 🔴 零耦合约束:模型 provider 目录不吃地域信号(clay 令 2026-07-31)
|
|
6
|
+
*
|
|
7
|
+
* 模型 provider **不分国内国外,完全用户选择,走全表** —— 模型 provider 表恒**全表呈现、顺序
|
|
8
|
+
* 不随地域变**,选哪家 100% 是用户的事。因此本模块与模型目录之间是**设计上的零耦合**,不是巧合:
|
|
9
|
+
* · 本模块**不导出**任何 `sortProvidersByRegion` / `filterProvidersFor(region)` 形状的函数;
|
|
10
|
+
* · `src/model/**` **不许** import 本模块(pure 门 GEO 段有机械断言把这条钉住,不靠自觉);
|
|
11
|
+
* · 唯一合法用途:**搜索 provider / 镜像源 / SearXNG 镜像地址**这类「取哪个地址更可达」的
|
|
12
|
+
* **默认预选**,而且**永远只是默认值,用户可改**。
|
|
13
|
+
*
|
|
14
|
+
* ## 为什么 v1 零网络
|
|
15
|
+
*
|
|
16
|
+
* 拨号探测(测 RTT / 查出口 IP)会在**用户还没同意任何事**之前就产生一次出站请求,而它换来的
|
|
17
|
+
* 只是一个初始默认值。locale 与时区是宿主已经知道的事实,不需要问任何人。⇒ 本模块是纯函数,
|
|
18
|
+
* 输入由调用方注入(不读宿主的环境变量,与本包 hostEnv 纪律一致:环境是宿主资产)。
|
|
19
|
+
* 门里那条「零环境读取」断言查的是**整份源文件**(注释也算),所以本文里刻意不出现那个成员
|
|
20
|
+
* 表达式的字面写法 —— 判据不必先长出一个词法器,代价只是这一行说明。
|
|
21
|
+
*
|
|
22
|
+
* ## 三档,`unknown` 不是 `intl`
|
|
23
|
+
*
|
|
24
|
+
* 两个信号都没有 ⇒ `unknown`,**绝不猜 `intl`**。「不知道」和「知道它在境外」是两件事:端拿到
|
|
25
|
+
* `unknown` 应当**不预选**(让用户自己挑),拿到 `intl` 才是预选国际地址。把不知道渲染成一个
|
|
26
|
+
* 具体答案,就是[honest-absence-not-fabricated-zero]里那条「编造零值」的同族错误。
|
|
27
|
+
*
|
|
28
|
+
* ## 冲突时**时区赢**(locale 说 zh 但时区是 America/New_York)
|
|
29
|
+
*
|
|
30
|
+
* 理由锚在本模块的用途上 —— 判的是「**哪个地址更可达**」,那是**机器在哪张网上**决定的:
|
|
31
|
+
* · **时区** = 系统对自己**所在位置**的记录(装机/NTP 时按位置设),与网络出口高度相关;
|
|
32
|
+
* · **locale** = 用户偏好**哪种语言**,与机器在哪张网上无关(在纽约用中文界面的人很多)。
|
|
33
|
+
* ⇒ 时区是位置的更强证据,冲突时它赢。两个信号一致时 `reason` 同时点名两者(证据更足)。
|
|
34
|
+
*
|
|
35
|
+
* ## `reason` 是「我看到了什么」,不是「你在哪」
|
|
36
|
+
*
|
|
37
|
+
* 端会把它渲成「已按你的系统区域预选,可随时改」。所以 reason 只陈述观测到的事实
|
|
38
|
+
* (`system time zone Asia/Shanghai`),不做身份断言(不写 "you are in China")—— 用户看到的
|
|
39
|
+
* 是判据本身,于是「这判据不对」是他能当场看出来并改掉的。
|
|
40
|
+
*/
|
|
41
|
+
/** 三档。🔴 `unknown` ≠ `intl`:前者是没有判据,后者是有判据且指向境外。 */
|
|
42
|
+
export type RegionHint = 'cn' | 'intl' | 'unknown';
|
|
43
|
+
/** 全部输入由调用方注入(Node 侧传 `env.LANG`/`env.LC_ALL` 与 `Intl.DateTimeFormat().resolvedOptions().timeZone`)。 */
|
|
44
|
+
export interface RegionHintInput {
|
|
45
|
+
/** `LANG`,如 `zh_CN.UTF-8`。 */
|
|
46
|
+
lang?: string;
|
|
47
|
+
/** `LC_ALL`。POSIX 下它压过 `LANG`(见 localeTag)。 */
|
|
48
|
+
lcAll?: string;
|
|
49
|
+
/** IANA 时区名,如 `Asia/Shanghai`。 */
|
|
50
|
+
timeZone?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface RegionHintResult {
|
|
53
|
+
region: RegionHint;
|
|
54
|
+
/** 给端直接显示的诚实说明(英文,与本包其它面向宿主的文案同口径)。 */
|
|
55
|
+
reason: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 地域预选提示(纯函数,零网络、零环境读取)。
|
|
59
|
+
*
|
|
60
|
+
* 结果**永远只是默认值**:端必须让用户能当场改掉,并把 `reason` 显示出来(「已按你的系统区域
|
|
61
|
+
* 预选,可随时改」)。见文件头零耦合约束 —— 它不得被用于对模型 provider 目录做任何排序/过滤。
|
|
62
|
+
*/
|
|
63
|
+
export declare function resolveRegionHint(input?: RegionHintInput): RegionHintResult;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localeGeo.ts — **地域预选 v1(零网络)**:只看系统 locale 与 IANA 时区,给出一个「默认选哪个
|
|
3
|
+
* 地址更可达」的提示。三端复用(CLI / web / 桌面)。
|
|
4
|
+
*
|
|
5
|
+
* ## 🔴 零耦合约束:模型 provider 目录不吃地域信号(clay 令 2026-07-31)
|
|
6
|
+
*
|
|
7
|
+
* 模型 provider **不分国内国外,完全用户选择,走全表** —— 模型 provider 表恒**全表呈现、顺序
|
|
8
|
+
* 不随地域变**,选哪家 100% 是用户的事。因此本模块与模型目录之间是**设计上的零耦合**,不是巧合:
|
|
9
|
+
* · 本模块**不导出**任何 `sortProvidersByRegion` / `filterProvidersFor(region)` 形状的函数;
|
|
10
|
+
* · `src/model/**` **不许** import 本模块(pure 门 GEO 段有机械断言把这条钉住,不靠自觉);
|
|
11
|
+
* · 唯一合法用途:**搜索 provider / 镜像源 / SearXNG 镜像地址**这类「取哪个地址更可达」的
|
|
12
|
+
* **默认预选**,而且**永远只是默认值,用户可改**。
|
|
13
|
+
*
|
|
14
|
+
* ## 为什么 v1 零网络
|
|
15
|
+
*
|
|
16
|
+
* 拨号探测(测 RTT / 查出口 IP)会在**用户还没同意任何事**之前就产生一次出站请求,而它换来的
|
|
17
|
+
* 只是一个初始默认值。locale 与时区是宿主已经知道的事实,不需要问任何人。⇒ 本模块是纯函数,
|
|
18
|
+
* 输入由调用方注入(不读宿主的环境变量,与本包 hostEnv 纪律一致:环境是宿主资产)。
|
|
19
|
+
* 门里那条「零环境读取」断言查的是**整份源文件**(注释也算),所以本文里刻意不出现那个成员
|
|
20
|
+
* 表达式的字面写法 —— 判据不必先长出一个词法器,代价只是这一行说明。
|
|
21
|
+
*
|
|
22
|
+
* ## 三档,`unknown` 不是 `intl`
|
|
23
|
+
*
|
|
24
|
+
* 两个信号都没有 ⇒ `unknown`,**绝不猜 `intl`**。「不知道」和「知道它在境外」是两件事:端拿到
|
|
25
|
+
* `unknown` 应当**不预选**(让用户自己挑),拿到 `intl` 才是预选国际地址。把不知道渲染成一个
|
|
26
|
+
* 具体答案,就是[honest-absence-not-fabricated-zero]里那条「编造零值」的同族错误。
|
|
27
|
+
*
|
|
28
|
+
* ## 冲突时**时区赢**(locale 说 zh 但时区是 America/New_York)
|
|
29
|
+
*
|
|
30
|
+
* 理由锚在本模块的用途上 —— 判的是「**哪个地址更可达**」,那是**机器在哪张网上**决定的:
|
|
31
|
+
* · **时区** = 系统对自己**所在位置**的记录(装机/NTP 时按位置设),与网络出口高度相关;
|
|
32
|
+
* · **locale** = 用户偏好**哪种语言**,与机器在哪张网上无关(在纽约用中文界面的人很多)。
|
|
33
|
+
* ⇒ 时区是位置的更强证据,冲突时它赢。两个信号一致时 `reason` 同时点名两者(证据更足)。
|
|
34
|
+
*
|
|
35
|
+
* ## `reason` 是「我看到了什么」,不是「你在哪」
|
|
36
|
+
*
|
|
37
|
+
* 端会把它渲成「已按你的系统区域预选,可随时改」。所以 reason 只陈述观测到的事实
|
|
38
|
+
* (`system time zone Asia/Shanghai`),不做身份断言(不写 "you are in China")—— 用户看到的
|
|
39
|
+
* 是判据本身,于是「这判据不对」是他能当场看出来并改掉的。
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* 判为「内地」的 IANA 时区(含 backward 链接别名与老 `PRC` 形)。
|
|
43
|
+
* 🔴 `Asia/Hong_Kong` / `Asia/Macau` / `Asia/Taipei` **不在**表内:本模块判的是「哪个镜像地址
|
|
44
|
+
* 更可达」,这三处的网络出口与内地不同,按内地预选反而会给出更慢的默认值。
|
|
45
|
+
*/
|
|
46
|
+
const CN_TIME_ZONES = new Set([
|
|
47
|
+
'asia/shanghai',
|
|
48
|
+
'asia/chongqing',
|
|
49
|
+
'asia/chungking',
|
|
50
|
+
'asia/harbin',
|
|
51
|
+
'asia/urumqi',
|
|
52
|
+
'asia/kashgar',
|
|
53
|
+
'prc',
|
|
54
|
+
]);
|
|
55
|
+
/** IANA 时区名的形(`Area/Location`,允许多级如 `America/Argentina/Salta`)。 */
|
|
56
|
+
const IANA_SHAPE = /^[a-z]+(?:\/[a-z0-9_+-]+)+$/i;
|
|
57
|
+
/** 语言标签的形:头一段是 2–3 位字母的语言码。不合形的串(`1234` / 空串)当没信号。 */
|
|
58
|
+
const LANGUAGE_TAG_SHAPE = /^[a-z]{2,3}(?:[-_]|$)/i;
|
|
59
|
+
/** 显式「无 locale」的 POSIX 值 —— 它们不携带任何地域信息。 */
|
|
60
|
+
const NEUTRAL_LOCALES = new Set(['c', 'posix']);
|
|
61
|
+
/** 不含地理信息的时区(UTC 家族):有值,但不构成判据。 */
|
|
62
|
+
const GEOLESS_ZONES = new Set(['utc', 'gmt', 'z', 'universal', 'zulu']);
|
|
63
|
+
/** reason 里回显观测值的长度上限 —— 输入来自宿主环境变量,不该由它决定一行 UI 有多长。 */
|
|
64
|
+
const ECHO_MAX = 64;
|
|
65
|
+
const echo = (v) => (v.length > ECHO_MAX ? `${v.slice(0, ECHO_MAX)}…` : v);
|
|
66
|
+
/**
|
|
67
|
+
* 取生效的 locale 标签(已剥 codeset 与 modifier:`zh_CN.UTF-8@pinyin` → `zh_CN`)。
|
|
68
|
+
*
|
|
69
|
+
* 🔴 `LC_ALL` 非空时**独赢,不回落 `LANG`** —— POSIX 就是这个覆盖语义,而 reason 必须说的是
|
|
70
|
+
* **真正生效**的那个 locale。`LC_ALL=C` 时回落去报 `LANG` 的值,等于在 reason 里陈述一件
|
|
71
|
+
* 当前并不成立的事。
|
|
72
|
+
*/
|
|
73
|
+
function localeTag(input) {
|
|
74
|
+
const pick = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
75
|
+
const raw = pick(input.lcAll) || pick(input.lang);
|
|
76
|
+
const tag = raw.split('.')[0].split('@')[0].trim();
|
|
77
|
+
if (!tag || NEUTRAL_LOCALES.has(tag.toLowerCase()))
|
|
78
|
+
return '';
|
|
79
|
+
return LANGUAGE_TAG_SHAPE.test(tag) ? tag : '';
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* locale → 信号。`zh` 系按**地区码优先、脚本次之**读(地区码比脚本更接近地理事实:
|
|
83
|
+
* `zh-Hans-TW` 用的是简体,但机器在台北那张网上)。
|
|
84
|
+
*/
|
|
85
|
+
function localeSignal(tag) {
|
|
86
|
+
if (!tag)
|
|
87
|
+
return 'unknown';
|
|
88
|
+
const parts = tag.toLowerCase().replace(/_/g, '-').split('-');
|
|
89
|
+
if (parts[0] !== 'zh')
|
|
90
|
+
return 'intl'; // 非中文 locale = 有信号且不指向内地
|
|
91
|
+
const rest = parts.slice(1);
|
|
92
|
+
if (rest.includes('cn'))
|
|
93
|
+
return 'cn';
|
|
94
|
+
if (rest.some(p => p === 'tw' || p === 'hk' || p === 'mo' || p === 'sg'))
|
|
95
|
+
return 'intl';
|
|
96
|
+
if (rest.includes('hans'))
|
|
97
|
+
return 'cn';
|
|
98
|
+
if (rest.includes('hant'))
|
|
99
|
+
return 'intl';
|
|
100
|
+
return 'cn'; // 裸 `zh`:实践中是内地系统的默认写法
|
|
101
|
+
}
|
|
102
|
+
/** 时区 → 信号。形不对/UTC 家族 ⇒ 没信号(不是 `intl`)。 */
|
|
103
|
+
function timeZoneSignal(zone) {
|
|
104
|
+
const z = zone.toLowerCase();
|
|
105
|
+
if (!z)
|
|
106
|
+
return 'unknown';
|
|
107
|
+
if (CN_TIME_ZONES.has(z))
|
|
108
|
+
return 'cn';
|
|
109
|
+
if (GEOLESS_ZONES.has(z) || z.startsWith('etc/'))
|
|
110
|
+
return 'unknown';
|
|
111
|
+
return IANA_SHAPE.test(z) ? 'intl' : 'unknown';
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 地域预选提示(纯函数,零网络、零环境读取)。
|
|
115
|
+
*
|
|
116
|
+
* 结果**永远只是默认值**:端必须让用户能当场改掉,并把 `reason` 显示出来(「已按你的系统区域
|
|
117
|
+
* 预选,可随时改」)。见文件头零耦合约束 —— 它不得被用于对模型 provider 目录做任何排序/过滤。
|
|
118
|
+
*/
|
|
119
|
+
export function resolveRegionHint(input = {}) {
|
|
120
|
+
const zone = typeof input.timeZone === 'string' ? input.timeZone.trim() : '';
|
|
121
|
+
const tag = localeTag(input);
|
|
122
|
+
const zoneHint = timeZoneSignal(zone);
|
|
123
|
+
const localeHint = localeSignal(tag);
|
|
124
|
+
if (zoneHint !== 'unknown') {
|
|
125
|
+
if (localeHint === 'unknown') {
|
|
126
|
+
return { region: zoneHint, reason: `system time zone ${echo(zone)}` };
|
|
127
|
+
}
|
|
128
|
+
if (localeHint === zoneHint) {
|
|
129
|
+
return { region: zoneHint, reason: `system time zone ${echo(zone)} and locale ${echo(tag)}` };
|
|
130
|
+
}
|
|
131
|
+
// 冲突:两个观测都说出来,并说明用了哪一个(见文件头「冲突时时区赢」)。
|
|
132
|
+
return {
|
|
133
|
+
region: zoneHint,
|
|
134
|
+
reason: `system time zone ${echo(zone)} (locale ${echo(tag)} differs; time zone wins)`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (localeHint !== 'unknown') {
|
|
138
|
+
return { region: localeHint, reason: `system locale ${echo(tag)}` };
|
|
139
|
+
}
|
|
140
|
+
return { region: 'unknown', reason: 'no system locale or time zone signal' };
|
|
141
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -221,3 +221,5 @@ export * from './seatContract.js';
|
|
|
221
221
|
export * from './agentSession/contract.js';
|
|
222
222
|
export * from './agentSession/backgroundView.js';
|
|
223
223
|
export * from './model/catalog.js';
|
|
224
|
+
export * from './websearch/searchProviderPresets.js';
|
|
225
|
+
export * from './env/localeGeo.js';
|
package/dist/index.js
CHANGED
|
@@ -310,3 +310,17 @@ export * from './agentSession/backgroundView.js';
|
|
|
310
310
|
// 🔴 绝不半解析:schemaVersion 超区间/任一行形状坏 ⇒ 整份弃用走兜底(端渲「内置版本(离线)」
|
|
311
311
|
// 靠 `source` + `online.reason`,所以「静默用兜底」在本层是可观测的)。
|
|
312
312
|
export * from './model/catalog.js';
|
|
313
|
+
// ── 搜索 provider 目录 + 地域预选(2026-07-31)──────────────────────────────────────────────────
|
|
314
|
+
// `websearch/searchProviderPresets` = model 目录的**同形不同表**姊妹件:数据在 json、类型与查询
|
|
315
|
+
// 在 ts。它编译出来的不是模型目录而是**引擎部署 env**(`WEB_SEARCH_*`)。
|
|
316
|
+
// 🔴 `buildWebSearchEnv` 的硬纪律:用户没配 ⇒ 空对象,绝不产出空串键 —— 引擎看到「键在场」
|
|
317
|
+
// 就当作配了,空串能把「没开搜索」变成「开了一个必然失败的搜索」。
|
|
318
|
+
// 🔴 provider 枚举**不在这里再声明一份**:`WebSearchProvider` 单向 type-import 自 webSearchWireCaps
|
|
319
|
+
// (两份枚举漂了编译器永远不会告诉你)。
|
|
320
|
+
export * from './websearch/searchProviderPresets.js';
|
|
321
|
+
// `env/localeGeo` = 零网络的地域预选(locale + IANA 时区两个信号,`unknown` ≠ `intl`)。
|
|
322
|
+
// 🔴 零耦合约束(clay 令 2026-07-31):**模型 provider 目录不吃地域信号** —— 模型表恒全表呈现、
|
|
323
|
+
// 顺序不随地域变,选哪家 100% 是用户的事。故本件不导出任何 provider 排序/过滤函数,
|
|
324
|
+
// `src/model/**` 也不许 import 它(pure 门 GEO 段有机械断言)。合法用途只有搜索 provider /
|
|
325
|
+
// 镜像源地址这类「取哪个地址更可达」的**默认值**预选。
|
|
326
|
+
export * from './env/localeGeo.js';
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* searchProviderPresets.ts — **搜索 provider 目录**(三端复用:CLI / web / 桌面同吃一份)。
|
|
3
|
+
*
|
|
4
|
+
* ## 它是什么、不是什么
|
|
5
|
+
*
|
|
6
|
+
* 与 `model/providerPresets.{ts,json}` **同形不同表**:数据在同名 `.json`(加一家 = 加一行,不改码),
|
|
7
|
+
* 类型与查询函数在本文件。区别在于它编译出来的不是「模型目录」而是**引擎的部署 env**:
|
|
8
|
+
*
|
|
9
|
+
* | env 键 | 取值 | 缺席时 |
|
|
10
|
+
* |---|---|---|
|
|
11
|
+
* | `WEB_SEARCH_PROVIDER` | `brave` / `tavily` / `searxng` | 🔴 **引擎根本不挂 WebSearch 工具** |
|
|
12
|
+
* | `WEB_SEARCH_API_KEY` | brave/tavily 必填;searxng 不需要 | — |
|
|
13
|
+
* | `WEB_SEARCH_ENDPOINT` | searxng 必填;brave/tavily 是可选 base-URL 覆盖 | 用厂商默认地址 |
|
|
14
|
+
* | `WEB_SEARCH_MAX_RESULTS` | 1..20 | 引擎默认 10 |
|
|
15
|
+
* | `WEB_SEARCH_TIMEOUT_MS` | 正整数毫秒 | 引擎默认 10000 |
|
|
16
|
+
*
|
|
17
|
+
* 🔴 `WEB_SEARCH_ENDPOINT` 的语义是「**引擎**可达的地址」,不是用户机器可达 —— 云端引擎 + 用户
|
|
18
|
+
* 内网的 SearXNG(`http://searxng.lan:8080`)是配得上但**跑不通**的组合,端在这一格要提醒用户
|
|
19
|
+
* 按引擎所在网络填。本模块只做形校验,拨不拨得通是引擎那一侧的事(本包零网络)。
|
|
20
|
+
*
|
|
21
|
+
* ## 与 `webSearchWireCaps.ts` 的分工(两条不同的车道,别混)
|
|
22
|
+
*
|
|
23
|
+
* · `webSearchWireCaps` = **每请求**的 `TaskRequest.settings.webSearch`(单用户宿主车道,
|
|
24
|
+
* 服务端 `web-search.ts` 读它、且它压过部署 env);
|
|
25
|
+
* · 本文件 = **部署 env** 那一侧的编译器(引擎进程启动时吃的 `WEB_SEARCH_*`)。
|
|
26
|
+
* provider 枚举是**同一个**类型(`WebSearchProvider` 从 wireCaps 单向 import),绝不在这里
|
|
27
|
+
* 再声明一份 —— 两份枚举漂了编译器永远不会告诉你([多端 wire 契约单一真源])。
|
|
28
|
+
*
|
|
29
|
+
* ## 三条硬纪律(pure 门 WS 段逐条有断言)
|
|
30
|
+
*
|
|
31
|
+
* 1. 🔴 **用户没配 ⇒ 一个键都不产出**。`buildWebSearchEnv({})` 恒返回 `{}`,尤其**绝不**产出
|
|
32
|
+
* `WEB_SEARCH_PROVIDER: ''` 这类空串键 —— 引擎读到「键在场」就会当作配了,一个空串能把
|
|
33
|
+
* 「没开搜索」变成「开了一个必然失败的搜索」。空串比缺席更坏,因为它撒谎。
|
|
34
|
+
* 2. 🔴 **不替用户填默认值**。`maxResults`/`timeoutMs` 用户没选就不出键,让引擎用自己的默认
|
|
35
|
+
* (`WEB_SEARCH_ENGINE_DEFAULTS` 只供端**显示**「默认 10」)。客户端把当下的引擎默认值钉进
|
|
36
|
+
* env,等于引擎哪天改默认值,所有老客户端都在静默压制它。
|
|
37
|
+
* 3. 🔴 **不可用的配置一律 fail-closed 且可观测**:缺 key / 缺 endpoint / 数值越界 ⇒
|
|
38
|
+
* `buildWebSearchEnv` 返回 `{}`(宁可不挂工具,也不挂一个必然报错的工具),同时
|
|
39
|
+
* `validateWebSearchChoice` 给出**具体** reason 供端渲提示 —— 「静默什么都不做」在本层
|
|
40
|
+
* 必须是可解释的([honest-absence-not-fabricated-zero])。
|
|
41
|
+
*
|
|
42
|
+
* ## URL 位的诚实缺席
|
|
43
|
+
*
|
|
44
|
+
* `consoleUrl`/`signupUrl` 会被端直接拿去开浏览器,编一个 = 把用户送到错误的地方。故只填能可靠
|
|
45
|
+
* 确认的官方位;拿不准就**留 null**。JSON 里用显式 `null` 而不是省略键:那是一条「查过、确实没有」
|
|
46
|
+
* 的记录,而省略键读起来像是漏了(SearXNG 按定义没有厂商控制台,与 model 表里 ollama/lmstudio
|
|
47
|
+
* 那几家同一个道理)。
|
|
48
|
+
*/
|
|
49
|
+
import type { WebSearchProvider } from '../webSearchWireCaps.js';
|
|
50
|
+
/** 目录一行。`id` 是表行主键、`provider` 是 wire 值 —— 两者刻意分开:日后同一个 provider 可以有
|
|
51
|
+
* 多行(比如几个公共 SearXNG 实例各一行),那时 id 才是唯一的那个。 */
|
|
52
|
+
export interface SearchProviderPreset {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
provider: WebSearchProvider;
|
|
56
|
+
/** brave/tavily 为 true。为 false 时本模块**不会**把 apiKey 写进 env(见 buildWebSearchEnv)。 */
|
|
57
|
+
needsKey: boolean;
|
|
58
|
+
/** searxng 为 true —— 没有 endpoint 这家根本无从谈起,故缺席时整份配置 fail-closed。 */
|
|
59
|
+
endpointRequired: boolean;
|
|
60
|
+
/** 端拿去做输入框 placeholder 的**示例**地址。🔴 它不是默认值:本模块绝不自动把它写进 env。 */
|
|
61
|
+
endpointTemplate?: string;
|
|
62
|
+
/** 去哪拿 key 的官方控制台页(纯链接,离线也能显示)。拿不准的家缺席,绝不编。 */
|
|
63
|
+
consoleUrl?: string;
|
|
64
|
+
/** 没账号时的注册入口。与 `consoleUrl` 同页的家不重复填(缺席 = 用 consoleUrl 那条)。 */
|
|
65
|
+
signupUrl?: string;
|
|
66
|
+
note?: string;
|
|
67
|
+
}
|
|
68
|
+
/** 搜索 provider 目录(表序即端的展示序)。 */
|
|
69
|
+
export declare const SEARCH_PROVIDER_PRESETS: SearchProviderPreset[];
|
|
70
|
+
/**
|
|
71
|
+
* 数据表自检口(与 `compensationSplitViolations()` 同一个套路:表的不变量做成可执行判据,
|
|
72
|
+
* 由门断言恒空)。上面 `toPreset` 对非法 provider 的行只能**丢掉**(类型化的表里放不下它),
|
|
73
|
+
* 丢掉本身是静默的 —— 这个函数就是那份静默的账。
|
|
74
|
+
*/
|
|
75
|
+
export declare function searchPresetTableViolations(): string[];
|
|
76
|
+
/** 按 id 取一行(trim + 大小写不敏感,与 wire 侧 provider 归一同口径)。查不到 ⇒ undefined。 */
|
|
77
|
+
export declare function findSearchPreset(id: string | undefined): SearchProviderPreset | undefined;
|
|
78
|
+
/** 引擎吃的五个 env 键(单一真源:端别再手写字面量)。 */
|
|
79
|
+
export declare const WEB_SEARCH_ENV_KEYS: {
|
|
80
|
+
readonly provider: "WEB_SEARCH_PROVIDER";
|
|
81
|
+
readonly apiKey: "WEB_SEARCH_API_KEY";
|
|
82
|
+
readonly endpoint: "WEB_SEARCH_ENDPOINT";
|
|
83
|
+
readonly maxResults: "WEB_SEARCH_MAX_RESULTS";
|
|
84
|
+
readonly timeoutMs: "WEB_SEARCH_TIMEOUT_MS";
|
|
85
|
+
};
|
|
86
|
+
export declare const WEB_SEARCH_MAX_RESULTS_MIN = 1;
|
|
87
|
+
export declare const WEB_SEARCH_MAX_RESULTS_MAX = 20;
|
|
88
|
+
/** 引擎侧的默认值,**只供端显示**(「默认 10」)。🔴 绝不由本模块写进 env,理由见文件头纪律 2。 */
|
|
89
|
+
export declare const WEB_SEARCH_ENGINE_DEFAULTS: {
|
|
90
|
+
readonly maxResults: 10;
|
|
91
|
+
readonly timeoutMs: 10000;
|
|
92
|
+
};
|
|
93
|
+
/** 用户在端上做出的选择。全可选 —— 「什么都没选」是合法输入,答案是空 env。 */
|
|
94
|
+
export interface WebSearchChoice {
|
|
95
|
+
presetId?: string;
|
|
96
|
+
apiKey?: string;
|
|
97
|
+
/** 🔴 语义 = **引擎**可达的地址。http 与 https 都收(内网自建 SearXNG 常是明文口)。 */
|
|
98
|
+
endpoint?: string;
|
|
99
|
+
maxResults?: number;
|
|
100
|
+
timeoutMs?: number;
|
|
101
|
+
}
|
|
102
|
+
/** 配置不可用的**具体**理由(端据此渲提示;「就是没生效」不算交代)。 */
|
|
103
|
+
export type WebSearchChoiceReason = 'no-preset' | 'unknown-preset' | 'missing-api-key' | 'missing-endpoint' | 'invalid-endpoint' | 'max-results-out-of-range' | 'invalid-timeout';
|
|
104
|
+
export type WebSearchChoiceVerdict = {
|
|
105
|
+
ok: true;
|
|
106
|
+
preset: SearchProviderPreset;
|
|
107
|
+
} | {
|
|
108
|
+
ok: false;
|
|
109
|
+
reason: WebSearchChoiceReason;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* 判定一份选择能不能编译成可用的 env。**纯判定,不产出任何值** —— 端可以在用户还在输入时
|
|
113
|
+
* 反复调它做即时提示,不必担心把半截配置漏给引擎。
|
|
114
|
+
*
|
|
115
|
+
* 🔴 越界的 `maxResults` 判红而不是 clamp:引擎那侧确实会 clamp,但客户端替用户把 50 改成 20
|
|
116
|
+
* 是**替他改了他填的数**却不告诉他。宁可红一下让端说「1..20」。
|
|
117
|
+
*/
|
|
118
|
+
export declare function validateWebSearchChoice(choice?: WebSearchChoice): WebSearchChoiceVerdict;
|
|
119
|
+
/**
|
|
120
|
+
* 用户选择 → 引擎 env。**唯一**的编译口(端别自己拼字面量键)。
|
|
121
|
+
*
|
|
122
|
+
* 🔴 三条已在文件头写死的纪律在这里落地:配置不可用 ⇒ `{}`(fail-closed,理由去问
|
|
123
|
+
* `validateWebSearchChoice`);用户没填的可选项不出键(不替引擎钉默认值);任何值都先 trim,
|
|
124
|
+
* 空串一律当没填 —— 返回的对象里**不存在**值为空串的键。
|
|
125
|
+
*/
|
|
126
|
+
export declare function buildWebSearchEnv(choice?: WebSearchChoice): Record<string, string>;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import presetData from './searchProviderPresets.json' with { type: 'json' };
|
|
2
|
+
const rawTable = presetData;
|
|
3
|
+
/** wire 枚举守卫(与服务端 `web-search.ts` 的三值校验同一份口径)。 */
|
|
4
|
+
function isWebSearchProvider(v) {
|
|
5
|
+
return v === 'brave' || v === 'tavily' || v === 'searxng';
|
|
6
|
+
}
|
|
7
|
+
/** `null` = 诚实缺席 ⇒ 键整个不出现在对象上(而不是留一个 `undefined` 值位)。 */
|
|
8
|
+
function optional(v, key) {
|
|
9
|
+
return v === null ? {} : { [key]: v };
|
|
10
|
+
}
|
|
11
|
+
function toPreset(row) {
|
|
12
|
+
if (!isWebSearchProvider(row.provider))
|
|
13
|
+
return undefined;
|
|
14
|
+
return {
|
|
15
|
+
id: row.id,
|
|
16
|
+
name: row.name,
|
|
17
|
+
provider: row.provider,
|
|
18
|
+
needsKey: row.needsKey,
|
|
19
|
+
endpointRequired: row.endpointRequired,
|
|
20
|
+
...optional(row.endpointTemplate, 'endpointTemplate'),
|
|
21
|
+
...optional(row.consoleUrl, 'consoleUrl'),
|
|
22
|
+
...optional(row.signupUrl, 'signupUrl'),
|
|
23
|
+
...optional(row.note, 'note'),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** 搜索 provider 目录(表序即端的展示序)。 */
|
|
27
|
+
export const SEARCH_PROVIDER_PRESETS = rawTable.presets
|
|
28
|
+
.map(toPreset)
|
|
29
|
+
.filter((p) => p !== undefined);
|
|
30
|
+
/**
|
|
31
|
+
* 数据表自检口(与 `compensationSplitViolations()` 同一个套路:表的不变量做成可执行判据,
|
|
32
|
+
* 由门断言恒空)。上面 `toPreset` 对非法 provider 的行只能**丢掉**(类型化的表里放不下它),
|
|
33
|
+
* 丢掉本身是静默的 —— 这个函数就是那份静默的账。
|
|
34
|
+
*/
|
|
35
|
+
export function searchPresetTableViolations() {
|
|
36
|
+
const out = [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (const row of rawTable.presets) {
|
|
39
|
+
if (!isWebSearchProvider(row.provider)) {
|
|
40
|
+
out.push(`${row.id}: provider "${row.provider}" 不在 wire 枚举 brave|tavily|searxng 内(该行已被丢弃)`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (seen.has(row.id))
|
|
44
|
+
out.push(`${row.id}: id 重复(findSearchPreset 只会拿到第一行)`);
|
|
45
|
+
seen.add(row.id);
|
|
46
|
+
if (row.provider === 'searxng' && !row.endpointRequired) {
|
|
47
|
+
out.push(`${row.id}: searxng 没有厂商默认地址,endpointRequired 必须为 true`);
|
|
48
|
+
}
|
|
49
|
+
if (row.provider === 'searxng' && row.needsKey) {
|
|
50
|
+
out.push(`${row.id}: searxng 不吃 API key,needsKey 必须为 false`);
|
|
51
|
+
}
|
|
52
|
+
for (const [key, url] of [
|
|
53
|
+
['endpointTemplate', row.endpointTemplate],
|
|
54
|
+
['consoleUrl', row.consoleUrl],
|
|
55
|
+
['signupUrl', row.signupUrl],
|
|
56
|
+
]) {
|
|
57
|
+
if (url !== null && !/^https:\/\/\S+$/.test(url))
|
|
58
|
+
out.push(`${row.id}.${key}: "${url}" 不是 https 形`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/** 按 id 取一行(trim + 大小写不敏感,与 wire 侧 provider 归一同口径)。查不到 ⇒ undefined。 */
|
|
64
|
+
export function findSearchPreset(id) {
|
|
65
|
+
const key = typeof id === 'string' ? id.trim().toLowerCase() : '';
|
|
66
|
+
if (!key)
|
|
67
|
+
return undefined;
|
|
68
|
+
return SEARCH_PROVIDER_PRESETS.find(p => p.id.toLowerCase() === key);
|
|
69
|
+
}
|
|
70
|
+
/** 引擎吃的五个 env 键(单一真源:端别再手写字面量)。 */
|
|
71
|
+
export const WEB_SEARCH_ENV_KEYS = {
|
|
72
|
+
provider: 'WEB_SEARCH_PROVIDER',
|
|
73
|
+
apiKey: 'WEB_SEARCH_API_KEY',
|
|
74
|
+
endpoint: 'WEB_SEARCH_ENDPOINT',
|
|
75
|
+
maxResults: 'WEB_SEARCH_MAX_RESULTS',
|
|
76
|
+
timeoutMs: 'WEB_SEARCH_TIMEOUT_MS',
|
|
77
|
+
};
|
|
78
|
+
export const WEB_SEARCH_MAX_RESULTS_MIN = 1;
|
|
79
|
+
export const WEB_SEARCH_MAX_RESULTS_MAX = 20;
|
|
80
|
+
/** 引擎侧的默认值,**只供端显示**(「默认 10」)。🔴 绝不由本模块写进 env,理由见文件头纪律 2。 */
|
|
81
|
+
export const WEB_SEARCH_ENGINE_DEFAULTS = { maxResults: 10, timeoutMs: 10000 };
|
|
82
|
+
/** 绝对 http/https URL 形校验。相对地址/其它协议一律拒 —— 引擎会拿它去拨号。 */
|
|
83
|
+
function isDialableUrl(raw) {
|
|
84
|
+
try {
|
|
85
|
+
const u = new URL(raw);
|
|
86
|
+
return (u.protocol === 'http:' || u.protocol === 'https:') && u.host.length > 0;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const trimmed = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
93
|
+
/**
|
|
94
|
+
* 判定一份选择能不能编译成可用的 env。**纯判定,不产出任何值** —— 端可以在用户还在输入时
|
|
95
|
+
* 反复调它做即时提示,不必担心把半截配置漏给引擎。
|
|
96
|
+
*
|
|
97
|
+
* 🔴 越界的 `maxResults` 判红而不是 clamp:引擎那侧确实会 clamp,但客户端替用户把 50 改成 20
|
|
98
|
+
* 是**替他改了他填的数**却不告诉他。宁可红一下让端说「1..20」。
|
|
99
|
+
*/
|
|
100
|
+
export function validateWebSearchChoice(choice = {}) {
|
|
101
|
+
const id = trimmed(choice.presetId);
|
|
102
|
+
if (!id)
|
|
103
|
+
return { ok: false, reason: 'no-preset' };
|
|
104
|
+
const preset = findSearchPreset(id);
|
|
105
|
+
if (!preset)
|
|
106
|
+
return { ok: false, reason: 'unknown-preset' };
|
|
107
|
+
if (preset.needsKey && !trimmed(choice.apiKey))
|
|
108
|
+
return { ok: false, reason: 'missing-api-key' };
|
|
109
|
+
const endpoint = trimmed(choice.endpoint);
|
|
110
|
+
if (preset.endpointRequired && !endpoint)
|
|
111
|
+
return { ok: false, reason: 'missing-endpoint' };
|
|
112
|
+
if (endpoint && !isDialableUrl(endpoint))
|
|
113
|
+
return { ok: false, reason: 'invalid-endpoint' };
|
|
114
|
+
const { maxResults, timeoutMs } = choice;
|
|
115
|
+
if (maxResults !== undefined) {
|
|
116
|
+
const inRange = Number.isInteger(maxResults) &&
|
|
117
|
+
maxResults >= WEB_SEARCH_MAX_RESULTS_MIN &&
|
|
118
|
+
maxResults <= WEB_SEARCH_MAX_RESULTS_MAX;
|
|
119
|
+
if (!inRange)
|
|
120
|
+
return { ok: false, reason: 'max-results-out-of-range' };
|
|
121
|
+
}
|
|
122
|
+
if (timeoutMs !== undefined && !(Number.isInteger(timeoutMs) && timeoutMs > 0)) {
|
|
123
|
+
return { ok: false, reason: 'invalid-timeout' };
|
|
124
|
+
}
|
|
125
|
+
return { ok: true, preset };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 用户选择 → 引擎 env。**唯一**的编译口(端别自己拼字面量键)。
|
|
129
|
+
*
|
|
130
|
+
* 🔴 三条已在文件头写死的纪律在这里落地:配置不可用 ⇒ `{}`(fail-closed,理由去问
|
|
131
|
+
* `validateWebSearchChoice`);用户没填的可选项不出键(不替引擎钉默认值);任何值都先 trim,
|
|
132
|
+
* 空串一律当没填 —— 返回的对象里**不存在**值为空串的键。
|
|
133
|
+
*/
|
|
134
|
+
export function buildWebSearchEnv(choice = {}) {
|
|
135
|
+
const verdict = validateWebSearchChoice(choice);
|
|
136
|
+
if (!verdict.ok)
|
|
137
|
+
return {};
|
|
138
|
+
const { preset } = verdict;
|
|
139
|
+
const env = { [WEB_SEARCH_ENV_KEYS.provider]: preset.provider };
|
|
140
|
+
// needsKey=false 的家不带 key:那家用不上它,把凭证多送一程没有收益只有暴露面。
|
|
141
|
+
const apiKey = trimmed(choice.apiKey);
|
|
142
|
+
if (preset.needsKey && apiKey)
|
|
143
|
+
env[WEB_SEARCH_ENV_KEYS.apiKey] = apiKey;
|
|
144
|
+
const endpoint = trimmed(choice.endpoint);
|
|
145
|
+
if (endpoint)
|
|
146
|
+
env[WEB_SEARCH_ENV_KEYS.endpoint] = endpoint;
|
|
147
|
+
if (choice.maxResults !== undefined)
|
|
148
|
+
env[WEB_SEARCH_ENV_KEYS.maxResults] = String(choice.maxResults);
|
|
149
|
+
if (choice.timeoutMs !== undefined)
|
|
150
|
+
env[WEB_SEARCH_ENV_KEYS.timeoutMs] = String(choice.timeoutMs);
|
|
151
|
+
return env;
|
|
152
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"presets": [
|
|
3
|
+
{
|
|
4
|
+
"id": "brave",
|
|
5
|
+
"name": "Brave Search",
|
|
6
|
+
"provider": "brave",
|
|
7
|
+
"needsKey": true,
|
|
8
|
+
"endpointRequired": false,
|
|
9
|
+
"endpointTemplate": "https://api.search.brave.com",
|
|
10
|
+
"consoleUrl": "https://api-dashboard.search.brave.com/",
|
|
11
|
+
"signupUrl": "https://brave.com/search/api/",
|
|
12
|
+
"note": "Key-based hosted API. WEB_SEARCH_ENDPOINT is only needed to put a proxy/mirror in front of the vendor host; leave it empty to use the vendor default. Verified 2026-08-01: the consoleUrl host root answers 303 to the product page when signed out (that is the dashboard's own signed-out behaviour, not a dead link) — do not 'fix' it to a deep path we cannot reach without an account."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "tavily",
|
|
16
|
+
"name": "Tavily",
|
|
17
|
+
"provider": "tavily",
|
|
18
|
+
"needsKey": true,
|
|
19
|
+
"endpointRequired": false,
|
|
20
|
+
"endpointTemplate": "https://api.tavily.com",
|
|
21
|
+
"consoleUrl": "https://app.tavily.com/",
|
|
22
|
+
"signupUrl": null,
|
|
23
|
+
"note": "Key-based hosted API; keys are issued on the same page as the console, so signupUrl is deliberately absent (see consoleUrl). WEB_SEARCH_ENDPOINT is an optional proxy/mirror override."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "searxng",
|
|
27
|
+
"name": "SearXNG (self-hosted or public instance)",
|
|
28
|
+
"provider": "searxng",
|
|
29
|
+
"needsKey": false,
|
|
30
|
+
"endpointRequired": true,
|
|
31
|
+
"endpointTemplate": "https://searxng.example.com",
|
|
32
|
+
"consoleUrl": null,
|
|
33
|
+
"signupUrl": null,
|
|
34
|
+
"note": "No vendor account exists, so console/signup are honestly absent (same rationale as the local model families). The instance must have `json` listed under `search.formats` in settings.yml — a stock instance serves html only and every API call against it fails. endpointTemplate uses the RFC 2606 reserved example domain: it is a placeholder to type over, never a real instance."
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/client-core",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.21",
|
|
4
4
|
"description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|