@sema-agent/client-core 0.11.20 → 0.11.23
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 +2 -1
- package/dist/adapt.js +15 -0
- package/dist/adapter/runStream.js +21 -6
- package/dist/controlRouter.d.ts +17 -10
- package/dist/controlRouter.js +14 -6
- package/dist/engineAgentPanelStore.d.ts +6 -0
- package/dist/engineAgentPanelStore.js +11 -0
- package/dist/env/localeGeo.d.ts +63 -0
- package/dist/env/localeGeo.js +141 -0
- package/dist/fleet/fleetProjection.d.ts +36 -3
- package/dist/fleet/fleetProjection.js +109 -0
- package/dist/fleetAgentPanelProjection.d.ts +18 -2
- 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.23
|
|
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
|
|
@@ -215,6 +215,7 @@ itself (FAILED names + skipped names + arithmetic reconciliation). All-SKIP repo
|
|
|
215
215
|
| `scripts/run-engine-vocab-floor-test.mjs` | Engine-mirrored vocabularies (structured card whitelist, self-reported tool face, control verbs, recogniser sets) against the *installed* `@sema-agent/core` |
|
|
216
216
|
| `scripts/run-streamjson-timing-honesty-test.mjs` | Stream timing & terminal honesty ([2084]): held errored fs-write results release on model progress; wall-clock timeout maps to `error_during_execution` with a truthful salvage note; the synthetic API-error assistant row carries the `<synthetic>` in-message sentinel |
|
|
217
217
|
| `scripts/run-background-view-test.mjs` | `createBackgroundView` lifecycle: polling/notify pairing, per-source degrade (`501 → not-configured` vs `unavailable`), the capabilities `scheduler` probe, and dispose really aborting the in-flight fleet snapshot (pure projection lives in the pure suite's W-A segment) |
|
|
218
|
+
| `scripts/run-fleet-view-keys-test.mjs` | The fleet projection views, **both directions**: `FleetTaskView`/`FleetWorkflowView` ⇄ their key lists (compile-pinned) ⇄ what a maximal/minimal row really projects, plus a wire-key coverage ledger (every `FleetTaskRow` key is either projected or carries a written reason why not) and a drift ledger against the shell's render contract. A one-directional assignability check is blind to optional keys — which is how `startedAt` was silently dropped |
|
|
218
219
|
| `scripts/run-public-surface-test.mjs` | The outward promises: the npm export surface baseline, the peer floor witness, and this README's claims |
|
|
219
220
|
|
|
220
221
|
Each suite carries a floor that only moves up — a refactor that stops executing a group of
|
package/dist/adapt.js
CHANGED
|
@@ -1081,6 +1081,21 @@ class WireToCcAdapterImpl {
|
|
|
1081
1081
|
...(description !== undefined ? { description } : {}),
|
|
1082
1082
|
...(prompt !== undefined ? { prompt } : {}),
|
|
1083
1083
|
...(currentAction !== undefined ? { currentAction } : {}),
|
|
1084
|
+
// 🔴 这里的 `: 0` 与 `fleetAgentPanelProjection` 那句「绝不在本层 `?? 0`」**不矛盾**,
|
|
1085
|
+
// 因为不是同一条 lane 的同一种供给形(两层的分层实情写在 `engineAgentPanelStore.ts`
|
|
1086
|
+
// 的 `PANEL_TOOLUSES_LANE_POLICY` 上,两条 lane 的可选性由编译钉锁住):
|
|
1087
|
+
// · **tick lane(本处)**:`usage` 是 core `task_progress` 臂的**必填**字段
|
|
1088
|
+
// (core 2.12.0 `dist/core/types.d.ts:606-610` 无 `?`),两处发帧点
|
|
1089
|
+
// (`runner/runtask.js:1080` 每轮边界 / `:2925` 终态 settle 拍)**无条件**构造
|
|
1090
|
+
// `{ totalTokens, toolUses: stats.toolCalls, durationMs }`,server 白名单整键透传
|
|
1091
|
+
// (`trace/project.js:118`)⇒ 缺席在这条 lane 上不是「不知道」,是**没有这种帧**。
|
|
1092
|
+
// 值本身是**累计**计数(`stats.toolCalls`,从 0 起算),所以真值域含真 0。
|
|
1093
|
+
// · **fleet-row lane(那处)**:同名键是 server 1.278.0 才**新加**的可选位,老引擎
|
|
1094
|
+
// 真的不发 ⇒ 缺席就是「不知道」,兜 0 会把它冒充成「跑了 0 个工具」,而且会把
|
|
1095
|
+
// 台账里已知的累计值**擦回 0**。
|
|
1096
|
+
// ⚠️ 别把两层「统一」:把本处改成传播缺席要先把事件形改成可选,而那正是本 lane
|
|
1097
|
+
// 刻意不做的事(`engineAgentPanelStore.ts:54-57` 记的就是这条 —— 复用同一个可选形
|
|
1098
|
+
// 会让 fleet-row lane 每秒把前台子代由真 tick 写进去的进度数字清一次)。
|
|
1084
1099
|
toolUses: typeof usage.toolUses === 'number' ? usage.toolUses : 0,
|
|
1085
1100
|
totalTokens: typeof usage.totalTokens === 'number' ? usage.totalTokens : 0,
|
|
1086
1101
|
},
|
|
@@ -129,21 +129,36 @@ async function* runStreamInner(events, ctx, handle = {}) {
|
|
|
129
129
|
// errorMessage 'session already has an active run'(+ result.activeTaskId)骑在
|
|
130
130
|
// done{status:'failed'} 帧上(ai-agent-service server.ts:1526 同型)——裸 "API Error:" 行看着
|
|
131
131
|
// 像 provider 故障,是误导。识别该终帧 → 一行专属文案(告知在等什么 + 出路);其它错误保持
|
|
132
|
-
// 原 API Error 行(未知错误 fallback 不变)
|
|
133
|
-
//
|
|
132
|
+
// 原 API Error 行(未知错误 fallback 不变)。引擎侧解锁腿已到货([868]:cancel 对
|
|
133
|
+
// suspended/needs_review 就地终态化 ⇒ claim 释放),所以文案指的就是那个端点;
|
|
134
|
+
// 会自动执行「cancel + 重发」的自愈腿在宿主侧(cli: src/sema/activeRunSelfHeal.ts),
|
|
135
|
+
// 本层只负责:识别 + 说真话。
|
|
134
136
|
const activeTaskId = failedResult?.activeTaskId ??
|
|
135
137
|
(ev.type === 'failed' ? ev.activeTaskId : undefined);
|
|
136
|
-
|
|
138
|
+
// 🔴 2026-07-31:锚必须是 `includes` 不是全等 —— 引擎发的原文是
|
|
139
|
+
// 「session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it …」
|
|
140
|
+
// (server dist/http/routes/tasks.js,3.10.0/3.18.0 逐字同形),比锚长。原来的全等式在
|
|
141
|
+
// 今天的任何引擎上**一次都命中不了**,整条判别一直只靠 activeTaskId 那一半在兜。
|
|
142
|
+
const isActiveRunBusy = errText.includes('session already has an active run') || typeof activeTaskId === 'string';
|
|
137
143
|
// 件14 rev2 (2026-07-14): provider max_tokens cap rejection (e.g. glm-5.2 preset 131072 >
|
|
138
144
|
// a third-party gateway's 128000) → append the way out. Keyword match on the upstream text,
|
|
139
145
|
// supplement never mask (raw provider words stay on the row).
|
|
140
146
|
const maxTokHint = /max_tokens/i.test(errText)
|
|
141
147
|
? '\nyour provider caps max_tokens lower — lower it in /model (press m, or M to type a value)'
|
|
142
148
|
: '';
|
|
149
|
+
// 🔴 2026-07-31 假承诺修复:这里原本写的是「Press Esc to cancel it, or retry shortly.」——
|
|
150
|
+
// 两条出路**都是假的**,而且不是「某个客户端没接」,是没有任何客户端能靠它们脱困:
|
|
151
|
+
// · Esc:全 bundle 里 `/v1/runs/{id}/cancel` 在交互车道一处调用都没有,而且这一行上屏
|
|
152
|
+
// 时那条 turn 已经结束、取消键位根本没注册;
|
|
153
|
+
// · "retry shortly":park(suspended/needs_review)会**永久**留住 session claim ——
|
|
154
|
+
// 重启引擎捞不回来(boot 期孤儿回收只捞 running)、时间型 reap 挂在
|
|
155
|
+
// APPROVAL_TIMEOUT_SEC(默认 0 ⇒ 整条腿不跑),唯一兜底窗是 30 天。
|
|
156
|
+
// 换成真话 + 真的存在的两条动作(引擎的 cancel 端点 / 换一个会话)。
|
|
157
|
+
const busyHandle = typeof activeTaskId === 'string' && activeTaskId.length > 0 ? activeTaskId : null;
|
|
143
158
|
const rowText = isActiveRunBusy
|
|
144
|
-
? `
|
|
145
|
-
|
|
146
|
-
|
|
159
|
+
? `This session is locked by an earlier run${busyHandle ? ` (run ${busyHandle})` : ''} that ` +
|
|
160
|
+
`has not been released, so this message was NOT sent. Nothing releases it on its own — ` +
|
|
161
|
+
`cancel that run (POST /v1/runs/${busyHandle ?? '<id>'}/cancel), or start a new session.`
|
|
147
162
|
: `API Error: ${errText}${maxTokHint}`;
|
|
148
163
|
yield {
|
|
149
164
|
session_id: ctx.sessionId ?? '',
|
package/dist/controlRouter.d.ts
CHANGED
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
*
|
|
29
29
|
* 3. **cancel** (IH-8, §5.3, catalog L84). HARD-STOP an async run: `runs.cancel(taskId)` → 202 `CancelAck`
|
|
30
30
|
* (`status:"cancelling"` or a terminal no-op). The run then SETTLES to `failed` + `errorCode:"cancelled"`
|
|
31
|
-
* (NOT a new status — the UI shows "cancelled", not an error). A 409
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* (NOT a new status — the UI shows "cancelled", not an error). 🔴 A 409 no longer means "suspended"
|
|
32
|
+
* (server [868] cancels a suspended/needs_review run in place — that IS the unlock handle); it now only
|
|
33
|
+
* means the pending gate was decided/expired concurrently, surfaced as
|
|
34
|
+
* `ControlSafetyError('cancel_lost_race')` = re-read state and retry. 404 =
|
|
34
35
|
* non-owner/unknown (no existence leak). Server-idempotent; not a submit → no SDK retry.
|
|
35
36
|
*
|
|
36
37
|
* 4. **queued commands** (§5.2, catalog L82 `contract-extension`). CC enqueues next turns with a per-message
|
|
@@ -61,7 +62,8 @@ export interface RunsResourceLike {
|
|
|
61
62
|
signal?: AbortSignal;
|
|
62
63
|
}): Promise<unknown>;
|
|
63
64
|
/** POST /v1/runs/:id/cancel → 202 CancelAck. Hard-stop; server-idempotent; not a submit → no retry
|
|
64
|
-
* (runs.ts:34).
|
|
65
|
+
* (runs.ts:34). 🔴 suspended/needs_review 也走这里就地终态化([868]);409 只剩「挂起的 gate 被
|
|
66
|
+
* 并发决定/过期」这一种(重读状态后重试);404 non-owner。 */
|
|
65
67
|
cancel(taskId: string, opts?: {
|
|
66
68
|
signal?: AbortSignal;
|
|
67
69
|
}): Promise<CancelAck>;
|
|
@@ -71,12 +73,12 @@ export interface ControlClientLike {
|
|
|
71
73
|
}
|
|
72
74
|
/** A supervision-verb stop the shell must HANDLE, not retry (contract/04 §9.1). The `code` is stable so the
|
|
73
75
|
* shell can branch: `not_running` (steer a non-running run) | `invalid_content` (steer carried an escape) |
|
|
74
|
-
* `
|
|
76
|
+
* `cancel_lost_race` (409: 挂起的 gate 被并发决定/过期 — 重读状态后重试 cancel) | `not_found`。 */
|
|
75
77
|
export declare class ControlSafetyError extends Error {
|
|
76
|
-
readonly code: 'not_running' | 'invalid_content' | '
|
|
78
|
+
readonly code: 'not_running' | 'invalid_content' | 'cancel_lost_race' | 'not_found';
|
|
77
79
|
/** The original SDK error, for logging (never re-thrown blind). */
|
|
78
80
|
readonly cause?: unknown | undefined;
|
|
79
|
-
constructor(message: string, code: 'not_running' | 'invalid_content' | '
|
|
81
|
+
constructor(message: string, code: 'not_running' | 'invalid_content' | 'cancel_lost_race' | 'not_found',
|
|
80
82
|
/** The original SDK error, for logging (never re-thrown blind). */
|
|
81
83
|
cause?: unknown | undefined);
|
|
82
84
|
}
|
|
@@ -157,9 +159,14 @@ export declare class ControlRouter {
|
|
|
157
159
|
* NOT an error (the router does not synthesize that terminal; the downstream stream / `terminalToSdkResult`
|
|
158
160
|
* does). Branching (contract/04 §9.1):
|
|
159
161
|
*
|
|
160
|
-
* - **409 =
|
|
161
|
-
*
|
|
162
|
-
*
|
|
162
|
+
* - **409 = a LOST CAS RACE, not "suspended".** 🔴 2026-07-31 更正:这段原本写的是「409 = run 是
|
|
163
|
+
* suspended,要改走 deny」—— 那是 **[868] 之前**的世界。服务端自 [868] 起对
|
|
164
|
+
* suspended/needs_review 的 run **就地取消**(先 CAS 结掉挂起的 checkpoint,再 setTerminal
|
|
165
|
+
* 释放 session claim),`cancel` 就是那种 run 的恢复把手,不再 409。今天的 409 只剩一种成因:
|
|
166
|
+
* 挂起的那个 gate 被并发决定/过期了(server `conflict.approval_settled`)。正确处置是**重读
|
|
167
|
+
* run 状态后重试 cancel**,而不是去 deny 一个已经不存在的审批 —— 照旧文指路只会指进空处。
|
|
168
|
+
* Surfaced as `ControlSafetyError('cancel_lost_race')`(干净切:旧码名 `cancel_suspended` 已
|
|
169
|
+
* 退役,本仓/壳/web/桌面均无行为消费方,只有一条注释引用,同批改)。
|
|
163
170
|
* - **404 = non-owner / unknown.** No existence oracle (404, not 403) → `ControlSafetyError('not_found')`.
|
|
164
171
|
*
|
|
165
172
|
* Server-idempotent and NOT a submit, so a successful cancel is safe to repeat (a terminal run no-ops);
|
package/dist/controlRouter.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// router catches the SDK's typed errors and re-raises a stable, shell-branchable code.
|
|
6
6
|
/** A supervision-verb stop the shell must HANDLE, not retry (contract/04 §9.1). The `code` is stable so the
|
|
7
7
|
* shell can branch: `not_running` (steer a non-running run) | `invalid_content` (steer carried an escape) |
|
|
8
|
-
* `
|
|
8
|
+
* `cancel_lost_race` (409: 挂起的 gate 被并发决定/过期 — 重读状态后重试 cancel) | `not_found`。 */
|
|
9
9
|
export class ControlSafetyError extends Error {
|
|
10
10
|
code;
|
|
11
11
|
cause;
|
|
@@ -129,9 +129,14 @@ export class ControlRouter {
|
|
|
129
129
|
* NOT an error (the router does not synthesize that terminal; the downstream stream / `terminalToSdkResult`
|
|
130
130
|
* does). Branching (contract/04 §9.1):
|
|
131
131
|
*
|
|
132
|
-
* - **409 =
|
|
133
|
-
*
|
|
134
|
-
*
|
|
132
|
+
* - **409 = a LOST CAS RACE, not "suspended".** 🔴 2026-07-31 更正:这段原本写的是「409 = run 是
|
|
133
|
+
* suspended,要改走 deny」—— 那是 **[868] 之前**的世界。服务端自 [868] 起对
|
|
134
|
+
* suspended/needs_review 的 run **就地取消**(先 CAS 结掉挂起的 checkpoint,再 setTerminal
|
|
135
|
+
* 释放 session claim),`cancel` 就是那种 run 的恢复把手,不再 409。今天的 409 只剩一种成因:
|
|
136
|
+
* 挂起的那个 gate 被并发决定/过期了(server `conflict.approval_settled`)。正确处置是**重读
|
|
137
|
+
* run 状态后重试 cancel**,而不是去 deny 一个已经不存在的审批 —— 照旧文指路只会指进空处。
|
|
138
|
+
* Surfaced as `ControlSafetyError('cancel_lost_race')`(干净切:旧码名 `cancel_suspended` 已
|
|
139
|
+
* 退役,本仓/壳/web/桌面均无行为消费方,只有一条注释引用,同批改)。
|
|
135
140
|
* - **404 = non-owner / unknown.** No existence oracle (404, not 403) → `ControlSafetyError('not_found')`.
|
|
136
141
|
*
|
|
137
142
|
* Server-idempotent and NOT a submit, so a successful cancel is safe to repeat (a terminal run no-ops);
|
|
@@ -146,7 +151,8 @@ export class ControlRouter {
|
|
|
146
151
|
}
|
|
147
152
|
catch (e) {
|
|
148
153
|
if (isCancelSuspendedConflict(e)) {
|
|
149
|
-
throw new ControlSafetyError('cancel
|
|
154
|
+
throw new ControlSafetyError('runs.cancel 409 — the pending gate was decided or expired concurrently (lost CAS race); ' +
|
|
155
|
+
're-read the run state and retry cancel if it is still active', 'cancel_lost_race', e);
|
|
150
156
|
}
|
|
151
157
|
if (isNotFound(e)) {
|
|
152
158
|
throw new ControlSafetyError('runs.cancel 404 — non-owner / unknown run (no existence leak)', 'not_found', e);
|
|
@@ -234,7 +240,9 @@ function isSteeringInvalidContent(e) {
|
|
|
234
240
|
const { code, name } = errCodes(e);
|
|
235
241
|
return code === 'steering.invalid_content' || name === 'SteeringInvalidContentError';
|
|
236
242
|
}
|
|
237
|
-
/** A 409 on `cancel`
|
|
243
|
+
/** A 409 on `cancel` = the pending gate was decided/expired concurrently (lost CAS race; server
|
|
244
|
+
* `conflict.approval_settled`). 🔴 它**不再**表示「run 是 suspended」——[868] 起 suspended/needs_review
|
|
245
|
+
* 的 run 由 cancel 就地终态化。
|
|
238
246
|
* The SDK raises a generic `ConflictError` (status 409) for this case — there is no dedicated subclass. */
|
|
239
247
|
function isCancelSuspendedConflict(e) {
|
|
240
248
|
const { name, status } = errCodes(e);
|
|
@@ -107,6 +107,12 @@ export type EngineAgentPanelEvent = {
|
|
|
107
107
|
* the consumer settles ALL rows it owns that are still running. Idempotent. */
|
|
108
108
|
kind: 'sweep';
|
|
109
109
|
};
|
|
110
|
+
/**
|
|
111
|
+
* 两条 lane 对 `toolUses` 的口径(运行期读面,给门用;上面的编译钉保证它没在撒谎)。
|
|
112
|
+
* `required-engine-always-emits` = 缺席不可达 ⇒ 兜底值合法;
|
|
113
|
+
* `optional-tolerate-absent` = 缺席即「不知道」⇒ 必须传播缺席(键不落)。
|
|
114
|
+
*/
|
|
115
|
+
export declare const PANEL_TOOLUSES_LANE_POLICY: Readonly<Record<'tick' | 'fleet-row', string>>;
|
|
110
116
|
export declare function markEnginePanelTaskResident(taskId: string): void;
|
|
111
117
|
export declare function clearEnginePanelTaskResident(taskId: string): void;
|
|
112
118
|
export declare function isEnginePanelTaskResident(taskId: string): boolean;
|
|
@@ -22,6 +22,17 @@
|
|
|
22
22
|
* Buffering: ticks published before the panel mounts are buffered (bounded) and replayed to the first
|
|
23
23
|
* subscriber, so early frames in a fast turn aren't lost to mount timing.
|
|
24
24
|
*/
|
|
25
|
+
const _panelToolUsesLanePins = [true, true];
|
|
26
|
+
void _panelToolUsesLanePins;
|
|
27
|
+
/**
|
|
28
|
+
* 两条 lane 对 `toolUses` 的口径(运行期读面,给门用;上面的编译钉保证它没在撒谎)。
|
|
29
|
+
* `required-engine-always-emits` = 缺席不可达 ⇒ 兜底值合法;
|
|
30
|
+
* `optional-tolerate-absent` = 缺席即「不知道」⇒ 必须传播缺席(键不落)。
|
|
31
|
+
*/
|
|
32
|
+
export const PANEL_TOOLUSES_LANE_POLICY = {
|
|
33
|
+
tick: 'required-engine-always-emits',
|
|
34
|
+
'fleet-row': 'optional-tolerate-absent',
|
|
35
|
+
};
|
|
25
36
|
// ── #6(frame-lane-matrix)session 常驻行台账 ─────────────────────────────────────────────────
|
|
26
37
|
// 行寿命 ≠ turn 寿命:跨 turn 存活的 bg 子代行(tick 绑不到本 turn 开着的卡 = inert/unbound)不该被
|
|
27
38
|
// turn 末防御 sweep(bridge settlePanelTasks(null) / seamQuery 'sweep' 事件)结成假 completed——一旦
|
|
@@ -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
|
+
}
|
|
@@ -21,9 +21,21 @@ import type { FleetTaskRow, FleetWorkflowRow } from '@sema-agent/sdk';
|
|
|
21
21
|
/** 渲染面的 fleet 状态词汇(cli `overrides/fleet-tree.tsx` 的 `FleetTaskStatus` 同集同序)。 */
|
|
22
22
|
export type FleetTaskStatus = 'queued' | 'running' | 'waiting' | 'stopping' | 'awaiting approval' | 'idle' | 'completed' | 'failed' | 'killed';
|
|
23
23
|
/**
|
|
24
|
-
* 投影后的一条 task
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* 投影后的一条 task 行。渲染器类型(cli `overrides/fleet-tree.tsx` 的 `FleetTask`)留在端上——
|
|
25
|
+
* 它是**渲染契约**,本接口是**产出侧**类型,两者同族但不是同一个物。
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ **两侧漂移到底被什么看住 —— 精确版(2026-07-31 更正)**:此处原写「两侧漂移由 pure 门的
|
|
28
|
+
* 键集断言钉死」,那是**假话**:pure 门里从来没有键集断言(只有逐字段的取值断言,
|
|
29
|
+
* `run-client-core-pure-test.mjs:2411-2452`)。当时真正在跑的看守只有一条 ——
|
|
30
|
+
* · **编译期、壳侧、单向**:cli `src/sema/fleetClient.ts` 的 `projectTasksForTest` 把
|
|
31
|
+
* `projectTasks(...)`(`FleetTaskView[]`)当 `FleetTask[]` 返回,于是 tsc 校
|
|
32
|
+
* `FleetTaskView` **可赋给** `FleetTask`。它只拦一件事:渲染契约要一个**必填**键而投影没产。
|
|
33
|
+
* 它拦不住的两个方向:①「投影多产一个渲染契约没声明的键」(非字面量赋值不做多余属性检查)、
|
|
34
|
+
* ②「渲染契约多声明一个**可选**键而投影永不产」。而本接口的键**几乎全是可选的** ⇒ 那道
|
|
35
|
+
* 检查对本接口绝大多数键零判别力。`startedAt` 就是从这个缝里漏出去的(见该键的注)。
|
|
36
|
+
* 现在补上的是运行期、本仓、**双向**的那半边:`scripts/run-fleet-view-keys-test.mjs` ——
|
|
37
|
+
* 本接口 ⇄ `FLEET_TASK_VIEW_KEYS`(文件末 `_fleetViewKeyPins`,编译期两个方向)⇄ 最大行/最小行
|
|
38
|
+
* 的**真产出键集**(运行期逐元素相等),外加 wire 键投影覆盖账 + 壳渲染契约漂移账。
|
|
27
39
|
*/
|
|
28
40
|
export interface FleetTaskView {
|
|
29
41
|
id: string;
|
|
@@ -199,3 +211,24 @@ export interface ProjectTasksOptions {
|
|
|
199
211
|
export declare function projectTasks(allRows: readonly FleetTaskRow[], opts: ProjectTasksOptions): FleetTaskView[];
|
|
200
212
|
/** wire workflow 行 → 渲染行。 */
|
|
201
213
|
export declare function projectWorkflows(rows: readonly FleetWorkflowRow[]): FleetWorkflowView[];
|
|
214
|
+
/** `FleetTaskView` 的键名清单(运行期物;编译期与 `keyof FleetTaskView` 双向等值)。 */
|
|
215
|
+
export declare const FLEET_TASK_VIEW_KEYS: readonly string[];
|
|
216
|
+
/** `FleetWorkflowView` 的键名清单(同上)。 */
|
|
217
|
+
export declare const FLEET_WORKFLOW_VIEW_KEYS: readonly string[];
|
|
218
|
+
/** SDK `FleetTaskRow` 的**入口**键名清单(编译期与 `keyof FleetTaskRow` 双向等值 ⇒ SDK 加一个
|
|
219
|
+
* wire 键,本元组不跟就编译红;跟了之后覆盖账那条腿再逼你表态「投不投」)。 */
|
|
220
|
+
export declare const FLEET_TASK_ROW_WIRE_KEYS: readonly string[];
|
|
221
|
+
/** SDK `FleetWorkflowRow` 的入口键名清单(同上)。 */
|
|
222
|
+
export declare const FLEET_WORKFLOW_ROW_WIRE_KEYS: readonly string[];
|
|
223
|
+
/**
|
|
224
|
+
* **不投影登记表**:wire 上有、`FleetTaskView` 上**故意没有**的键,每条必须写清理由。
|
|
225
|
+
*
|
|
226
|
+
* 🔴 这张表是 `startedAt` 那类事故的正面看守:那次不是「谁反对投影它」,是**没有人被逼着表态**——
|
|
227
|
+
* wire 有、ledger 读到了、另外两个投影面都补过,唯独这一层静默丢弃,编译期一声不吭。有了本表,
|
|
228
|
+
* 每个 wire 键只有两条出路:**要么投影出去,要么在这里写下不投的理由**;新 wire 键在覆盖账那条
|
|
229
|
+
* 腿上当场红,不给「悄悄没人管」留位置。
|
|
230
|
+
*/
|
|
231
|
+
export declare const FLEET_TASK_ROW_KEYS_NOT_PROJECTED: Readonly<Record<string, string>>;
|
|
232
|
+
/** workflow 行:wire 十键与视图十键一一同名同义 ⇒ 本表**空**(空不是「忘了填」,是覆盖账那条腿
|
|
233
|
+
* 每跑一次都在证明它该空:少投一个键就红)。 */
|
|
234
|
+
export declare const FLEET_WORKFLOW_ROW_KEYS_NOT_PROJECTED: Readonly<Record<string, string>>;
|
|
@@ -268,3 +268,112 @@ export function projectWorkflows(rows) {
|
|
|
268
268
|
};
|
|
269
269
|
});
|
|
270
270
|
}
|
|
271
|
+
// ── 键集契约(`run-fleet-view-keys-test.mjs` 的运行期锚)─────────────────────────────────────
|
|
272
|
+
//
|
|
273
|
+
// 两跳链条,一跳都断不了(形与 `seatContract.ts` 的座位门同款):
|
|
274
|
+
// interface ──类型钉(本文件末 `_fleetViewKeyPins`,编译期,**两个方向**)──> 键名元组
|
|
275
|
+
// ──双向键集门(运行期,对最大行/最小行的真产出逐元素相等)──> 实装投影
|
|
276
|
+
//
|
|
277
|
+
// 为什么钉在**内部 const 元组**而不是导出的 `readonly string[]`:后者的 `[number]` 塌回 `string`,
|
|
278
|
+
// `Covers` 恒真 = 零判别力(seatContract `:838-843` 同一个坑的记录)。公开面给 `readonly string[]`,
|
|
279
|
+
// 消费者的 `includes(someString)` 不受影响。
|
|
280
|
+
const FLEET_TASK_VIEW_KEY_TUPLE = [
|
|
281
|
+
'id',
|
|
282
|
+
'name',
|
|
283
|
+
'description',
|
|
284
|
+
'depth',
|
|
285
|
+
'parentId',
|
|
286
|
+
'status',
|
|
287
|
+
'elapsedMs',
|
|
288
|
+
'tokens',
|
|
289
|
+
'tokenDir',
|
|
290
|
+
'queuedCount',
|
|
291
|
+
'awaitingPlanApproval',
|
|
292
|
+
'descendantCount',
|
|
293
|
+
'viewed',
|
|
294
|
+
'toolUses',
|
|
295
|
+
'transcriptId',
|
|
296
|
+
'currentTool',
|
|
297
|
+
'startedAt',
|
|
298
|
+
'parentToolCallId',
|
|
299
|
+
'stoppedBy',
|
|
300
|
+
'usage',
|
|
301
|
+
'resumable',
|
|
302
|
+
'editedFiles',
|
|
303
|
+
];
|
|
304
|
+
/** `FleetTaskView` 的键名清单(运行期物;编译期与 `keyof FleetTaskView` 双向等值)。 */
|
|
305
|
+
export const FLEET_TASK_VIEW_KEYS = FLEET_TASK_VIEW_KEY_TUPLE;
|
|
306
|
+
const FLEET_WORKFLOW_VIEW_KEY_TUPLE = [
|
|
307
|
+
'id',
|
|
308
|
+
'name',
|
|
309
|
+
'description',
|
|
310
|
+
'status',
|
|
311
|
+
'doneCount',
|
|
312
|
+
'totalCount',
|
|
313
|
+
'elapsedMs',
|
|
314
|
+
'tokens',
|
|
315
|
+
'failedCount',
|
|
316
|
+
'startedCount',
|
|
317
|
+
];
|
|
318
|
+
/** `FleetWorkflowView` 的键名清单(同上)。 */
|
|
319
|
+
export const FLEET_WORKFLOW_VIEW_KEYS = FLEET_WORKFLOW_VIEW_KEY_TUPLE;
|
|
320
|
+
const FLEET_TASK_ROW_WIRE_KEY_TUPLE = [
|
|
321
|
+
'id',
|
|
322
|
+
'name',
|
|
323
|
+
'description',
|
|
324
|
+
'agentType',
|
|
325
|
+
'agentName',
|
|
326
|
+
'parentId',
|
|
327
|
+
'parentToolCallId',
|
|
328
|
+
'workflowRunId',
|
|
329
|
+
'status',
|
|
330
|
+
'startedAt',
|
|
331
|
+
'elapsedMs',
|
|
332
|
+
'tokens',
|
|
333
|
+
'queuedCount',
|
|
334
|
+
'awaitingPlanApproval',
|
|
335
|
+
'currentAction',
|
|
336
|
+
'currentTool',
|
|
337
|
+
'toolUses',
|
|
338
|
+
'transcriptId',
|
|
339
|
+
'stoppedBy',
|
|
340
|
+
'usage',
|
|
341
|
+
'resumable',
|
|
342
|
+
'editedFiles',
|
|
343
|
+
];
|
|
344
|
+
/** SDK `FleetTaskRow` 的**入口**键名清单(编译期与 `keyof FleetTaskRow` 双向等值 ⇒ SDK 加一个
|
|
345
|
+
* wire 键,本元组不跟就编译红;跟了之后覆盖账那条腿再逼你表态「投不投」)。 */
|
|
346
|
+
export const FLEET_TASK_ROW_WIRE_KEYS = FLEET_TASK_ROW_WIRE_KEY_TUPLE;
|
|
347
|
+
const FLEET_WORKFLOW_ROW_WIRE_KEY_TUPLE = [
|
|
348
|
+
'id',
|
|
349
|
+
'name',
|
|
350
|
+
'description',
|
|
351
|
+
'status',
|
|
352
|
+
'doneCount',
|
|
353
|
+
'totalCount',
|
|
354
|
+
'failedCount',
|
|
355
|
+
'startedCount',
|
|
356
|
+
'elapsedMs',
|
|
357
|
+
'tokens',
|
|
358
|
+
];
|
|
359
|
+
/** SDK `FleetWorkflowRow` 的入口键名清单(同上)。 */
|
|
360
|
+
export const FLEET_WORKFLOW_ROW_WIRE_KEYS = FLEET_WORKFLOW_ROW_WIRE_KEY_TUPLE;
|
|
361
|
+
/**
|
|
362
|
+
* **不投影登记表**:wire 上有、`FleetTaskView` 上**故意没有**的键,每条必须写清理由。
|
|
363
|
+
*
|
|
364
|
+
* 🔴 这张表是 `startedAt` 那类事故的正面看守:那次不是「谁反对投影它」,是**没有人被逼着表态**——
|
|
365
|
+
* wire 有、ledger 读到了、另外两个投影面都补过,唯独这一层静默丢弃,编译期一声不吭。有了本表,
|
|
366
|
+
* 每个 wire 键只有两条出路:**要么投影出去,要么在这里写下不投的理由**;新 wire 键在覆盖账那条
|
|
367
|
+
* 腿上当场红,不给「悄悄没人管」留位置。
|
|
368
|
+
*/
|
|
369
|
+
export const FLEET_TASK_ROW_KEYS_NOT_PROJECTED = {
|
|
370
|
+
agentType: '折进 `name`(deriveAgentLabel:agentName ?? agentType,都缺才退 objective 派生)——渲染契约只有一个身份列,187 的 tjl 同形。',
|
|
371
|
+
agentName: '同 agentType:两位在本层合成一个身份 label,分开带出去等于把 187 的 tjl 规则推给每个消费端各写一遍。',
|
|
372
|
+
workflowRunId: 'workflow 归属**不走行字段**:权威判定在 adapt 的 task_progress 臂(recordWorkflowAgentTaskId,判据④),端按 taskId 尾段查台账过滤双生行。行上再带一份 = 第二真源。',
|
|
373
|
+
currentAction: '结构化同源体 `currentTool` 已投影(SDK 注:currentAction 是它的**文本回落**)。🔴 诚实记账:旧端(server <1.288)只发 currentAction ⇒ 那一档上本视图今天确实拿不到活动文本,这是**已知缺口**不是设计取舍;补它 = 加 `currentAction?: string` 进本视图 + 端侧回落渲染。',
|
|
374
|
+
};
|
|
375
|
+
/** workflow 行:wire 十键与视图十键一一同名同义 ⇒ 本表**空**(空不是「忘了填」,是覆盖账那条腿
|
|
376
|
+
* 每跑一次都在证明它该空:少投一个键就红)。 */
|
|
377
|
+
export const FLEET_WORKFLOW_ROW_KEYS_NOT_PROJECTED = {};
|
|
378
|
+
const _fleetViewKeyPins = [true, true, true, true, true, true, true, true];
|
|
379
|
+
void _fleetViewKeyPins;
|
|
@@ -7,8 +7,24 @@ export interface FleetAgentRowLike {
|
|
|
7
7
|
description?: string;
|
|
8
8
|
status?: string;
|
|
9
9
|
tokens?: number;
|
|
10
|
-
/**
|
|
11
|
-
*
|
|
10
|
+
/**
|
|
11
|
+
* server 1.278.0 `toolUses` —— 子代**累计**工具调用数。🔴 三态:有键真值(含真 0)/
|
|
12
|
+
* 无键 `undefined` = 老引擎不知道。**本层绝不 `?? 0`**(那等于把「不知道」冒充成「跑了 0 个」,
|
|
13
|
+
* 而且会把台账里已知的累计值擦回 0)。
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ **这句话只对本层成立 —— 分层实情**(2026-07-31 更正:原文写成绝对句「绝不在本层 `?? 0`」,
|
|
16
|
+
* 读起来像是全包禁令,于是与 `adapt.ts` tick 臂那句 `: 0` 构成一处**包内自相矛盾**;真相是两条
|
|
17
|
+
* lane 的供给形不同,判据见 `engineAgentPanelStore.ts` 的 `PANEL_TOOLUSES_LANE_POLICY`):
|
|
18
|
+
* · **本 lane(fleet 行帧)**:该键是 server 1.278.0 **新加**的可选位,老引擎真的不发 ⇒
|
|
19
|
+
* 缺席 = 不知道 ⇒ 必须传播缺席(键不落)。
|
|
20
|
+
* · **tick lane(`task_progress`)**:`usage` 在 core 的事件类型里是**必填**
|
|
21
|
+
* (core 2.12.0 `core/types.d.ts:606-610`),两处发帧点无条件构造它
|
|
22
|
+
* (`core/runner/runtask.js:1080` / `:2925`),server 白名单整键透传
|
|
23
|
+
* (`trace/project.js:118`)⇒ 那条 lane 上「缺席」不是「不知道」,是没有这种帧;那里的
|
|
24
|
+
* `: 0` 是对一个**恒发的累计计数**兜底,不是编造一个不知道的值。
|
|
25
|
+
* 两条 lane 的可选性差异由 `engineAgentPanelStore.ts` 的编译钉锁住:谁想把两层「统一」成
|
|
26
|
+
* 同一种形,先在那里撞红。
|
|
27
|
+
*/
|
|
12
28
|
toolUses?: number;
|
|
13
29
|
/** server 1.278.0 `transcriptId`(子代 childSessionId)—— 委派 prompt 的取件锚,不是 prompt 本身。 */
|
|
14
30
|
transcriptId?: string;
|
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.23",
|
|
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",
|