@inobit/pi-instabox 0.0.0-stage → 0.1.0
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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +214 -2
- package/README.zh-CN.md +215 -0
- package/package.json +64 -3
- package/src/boxes.ts +158 -0
- package/src/cli.ts +440 -0
- package/src/config.ts +310 -0
- package/src/devtools.ts +257 -0
- package/src/entry-render.ts +42 -0
- package/src/index.ts +1141 -0
- package/src/log.ts +203 -0
- package/src/pool.ts +651 -0
- package/src/remote.ts +678 -0
- package/src/resolve.ts +216 -0
- package/src/schema.ts +370 -0
- package/src/session-target.ts +100 -0
- package/src/template.ts +449 -0
- package/src/tools.ts +322 -0
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @inobit/pi-instabox — 解析链纯函数:决定「这一次打到哪台 box」。
|
|
3
|
+
*
|
|
4
|
+
* 契约(方案 §4.3/§4.4、§5.1、T6):
|
|
5
|
+
* - 5 级来源,顺序固定:`box` 参数 → 本轮最后一条 user 消息 → 会话目标 entry →
|
|
6
|
+
* 配置 `defaultBox` → `instabox ls` 第一台。先命中先返回,命中即停。
|
|
7
|
+
* - **不静默降级**:`box` 参数与会话目标写错是硬错误(`invalid-param` /
|
|
8
|
+
* `invalid-session`),绝不退到下一级;消息里出现多个箱名是 `ambiguous`,不猜。
|
|
9
|
+
* 唯一允许降级的是配置里的 `defaultBox`:`onConfigMissing` 默认 `"skip"`(**默认降级**),
|
|
10
|
+
* 并在结果里带 `skippedConfig: true`;`"fail"` 时才返回 `invalid-config`。
|
|
11
|
+
* - 调用方义务(T14/T15 参照):看到 `skippedConfig: true` 时按**每会话只 warn 一次**提示
|
|
12
|
+
* 「`config.defaultBox` 指向不存在的箱,已回落到 `ls` 第一台」,并继续按 `first` 执行;
|
|
13
|
+
* 本模块只负责把「配置没生效」标出来,不打印、不去重、不记忆会话。
|
|
14
|
+
* - 名字匹配是**精确 token**(前后不是字母/数字/`_`/`-`)且大小写敏感,不做子串匹配:
|
|
15
|
+
* `agentic` / `agent-browser` / `Agent` 都不算 `agent`。
|
|
16
|
+
* - 只把 `boxes` 里存在的名字当作候选:消息里提到一个不存在的箱名等于没提(那种名字
|
|
17
|
+
* 本来也打不到),因此走下一级。
|
|
18
|
+
* - 纯函数:不 import pi 运行时、不调 CLI、不读文件、无时钟;`boxes` 的顺序是唯一语义。
|
|
19
|
+
* 埋点只经 `logEvent`(未配置 logger 时 no-op),不参与结果构造。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { BoxInfo } from "./boxes.ts";
|
|
23
|
+
import { logEvent } from "./log.ts";
|
|
24
|
+
|
|
25
|
+
/** 埋点里候选名单的上限:`ls` 可能很长,日志只记前 N 个名字(箱数本身另由名单长度体现)。 */
|
|
26
|
+
const LOG_CANDIDATE_LIMIT = 10;
|
|
27
|
+
|
|
28
|
+
/** 命中来源,与解析链 5 级一一对应 */
|
|
29
|
+
export type ResolveSource = "param" | "message" | "session" | "config" | "first";
|
|
30
|
+
|
|
31
|
+
/** 解析失败原因(前 4 种是硬错误,`no-boxes` 是环境问题) */
|
|
32
|
+
export type ResolveFailureKind = "no-boxes" | "invalid-param" | "invalid-session" | "ambiguous" | "invalid-config";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 解析结果:成功带来源(供 `[box=…]` 回显与 `/instabox-status` 展示),
|
|
36
|
+
* 失败带可直接展示的 `message` 与 `candidates`(当前可用箱名,供提示与命令补全)。
|
|
37
|
+
*/
|
|
38
|
+
export type ResolveResult =
|
|
39
|
+
| {
|
|
40
|
+
ok: true;
|
|
41
|
+
box: string;
|
|
42
|
+
source: ResolveSource;
|
|
43
|
+
/** 仅在跳过不存在的配置 `defaultBox` 并回落到 `ls` 第一台时出现 */
|
|
44
|
+
skippedConfig?: true;
|
|
45
|
+
}
|
|
46
|
+
| { ok: false; kind: ResolveFailureKind; message: string; candidates: string[] };
|
|
47
|
+
|
|
48
|
+
/** 配置 `defaultBox` 指向不存在的箱时的处理:`skip` 降级到第一台(默认),`fail` 硬错误 */
|
|
49
|
+
export type ConfigMissingPolicy = "fail" | "skip";
|
|
50
|
+
|
|
51
|
+
/** `resolveBox` 的输入:所有来源都由调用方取好再传进来(本模块不碰宿主与 CLI) */
|
|
52
|
+
export interface ResolveBoxInput {
|
|
53
|
+
/** 模型显式传的 `box` 参数(空串/纯空白视为未传) */
|
|
54
|
+
paramBox?: string;
|
|
55
|
+
/** 本轮最后一条 user 消息的纯文本(只用来找箱名提及) */
|
|
56
|
+
lastUserMessage?: string;
|
|
57
|
+
/** `readTarget(getBranch())` 的结果 */
|
|
58
|
+
sessionTarget?: string;
|
|
59
|
+
/** 配置里的 `defaultBox`(空串=未配置) */
|
|
60
|
+
configDefaultBox?: string;
|
|
61
|
+
/** `instabox ls` 的结果(可能为空;只有顺序有意义) */
|
|
62
|
+
boxes: BoxInfo[];
|
|
63
|
+
/** 配置 `defaultBox` 不存在时的策略,默认 `"skip"` */
|
|
64
|
+
onConfigMissing?: ConfigMissingPolicy;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 5 级解析链:返回第一个命中的来源,或一个带候选的失败结果。
|
|
69
|
+
* 失败结果**不 throw**,由调用方决定转成工具 `isError` 结果还是命令提示。
|
|
70
|
+
*/
|
|
71
|
+
export function resolveBox(input: ResolveBoxInput): ResolveResult {
|
|
72
|
+
const names = boxNames(input.boxes);
|
|
73
|
+
const param = nonEmpty(input.paramBox);
|
|
74
|
+
// 提及名单提前算好(纯函数,下面第 2 级用的就是这一份):零命中也要留下一条埋点
|
|
75
|
+
const mentions = findBoxMentions(input.lastUserMessage ?? "", names);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 埋点出口:成功记 `resolve`(命中的箱、来源、提及、本次扫描的候选、是否降级),
|
|
79
|
+
* 失败记 `resolve.fail`。只记名字,不记消息全文。
|
|
80
|
+
* `resolve` 是 `info` 级(每次工具调用最多一条,量小):默认级别下也要能事后分析选箱。
|
|
81
|
+
*/
|
|
82
|
+
const done = (result: ResolveResult): ResolveResult => {
|
|
83
|
+
if (result.ok) {
|
|
84
|
+
logEvent(
|
|
85
|
+
"resolve",
|
|
86
|
+
{
|
|
87
|
+
param,
|
|
88
|
+
source: result.source,
|
|
89
|
+
box: result.box,
|
|
90
|
+
mentions,
|
|
91
|
+
candidates: names.slice(0, LOG_CANDIDATE_LIMIT),
|
|
92
|
+
degraded: result.skippedConfig === true,
|
|
93
|
+
},
|
|
94
|
+
"info",
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
logEvent(
|
|
98
|
+
"resolve.fail",
|
|
99
|
+
{
|
|
100
|
+
kind: result.kind,
|
|
101
|
+
message: result.message,
|
|
102
|
+
candidates: result.candidates.slice(0, LOG_CANDIDATE_LIMIT),
|
|
103
|
+
},
|
|
104
|
+
"warn",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (param !== undefined) {
|
|
111
|
+
if (names.includes(param)) return done({ ok: true, box: param, source: "param" });
|
|
112
|
+
return done(failure("invalid-param", `unknown box "${param}"`, names));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const mention = mentions.length === 1 ? mentions[0] : undefined;
|
|
116
|
+
if (mention !== undefined) return done({ ok: true, box: mention, source: "message" });
|
|
117
|
+
if (mentions.length > 1) {
|
|
118
|
+
return done(
|
|
119
|
+
failure("ambiguous", `the message mentions ${mentions.length} boxes (${mentions.join(", ")})`, mentions),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const session = nonEmpty(input.sessionTarget);
|
|
124
|
+
if (session !== undefined) {
|
|
125
|
+
if (names.includes(session)) return done({ ok: true, box: session, source: "session" });
|
|
126
|
+
return done(failure("invalid-session", `session target "${session}" is not an existing box`, names));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const config = nonEmpty(input.configDefaultBox);
|
|
130
|
+
let skippedConfig = false;
|
|
131
|
+
if (config !== undefined) {
|
|
132
|
+
if (names.includes(config)) return done({ ok: true, box: config, source: "config" });
|
|
133
|
+
if ((input.onConfigMissing ?? "skip") === "fail") {
|
|
134
|
+
return done(failure("invalid-config", `config defaultBox "${config}" is not an existing box`, names));
|
|
135
|
+
}
|
|
136
|
+
// 配置写错只降级、不硬失败;用 skippedConfig 让调用方仍能 warn 一次
|
|
137
|
+
skippedConfig = true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 取过滤后的名字(而非 `boxes[0]`):`name: ""` 的条目在别处都被当无效,
|
|
141
|
+
// 这里也不能把空箱名当成命中的第一台。
|
|
142
|
+
const first = names[0];
|
|
143
|
+
if (first === undefined) return done(failure("no-boxes", "instabox ls returned no boxes", []));
|
|
144
|
+
if (skippedConfig) return done({ ok: true, box: first, source: "first", skippedConfig: true });
|
|
145
|
+
return done({ ok: true, box: first, source: "first" });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 在文本里找出所有**精确 token** 形式的箱名提及:去重、按首次出现位置排序。
|
|
150
|
+
* 大小写敏感;`agentic` / `agent-browser` / `Agent` 都不匹配 `agent`。
|
|
151
|
+
*/
|
|
152
|
+
export function findBoxMentions(text: string, names: string[]): string[] {
|
|
153
|
+
const hits: { name: string; at: number }[] = [];
|
|
154
|
+
for (const name of uniqueNames(names)) {
|
|
155
|
+
const at = firstTokenIndex(text, name);
|
|
156
|
+
if (at >= 0) hits.push({ name, at });
|
|
157
|
+
}
|
|
158
|
+
hits.sort((a, b) => a.at - b.at);
|
|
159
|
+
return hits.map((hit) => hit.name);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* token 边界字符集:ASCII 字母、数字、`_`、`-`。
|
|
164
|
+
*
|
|
165
|
+
* 只把 ASCII 算作 token 内字符,因此中文/日文等直接相邻的写法(如 `用agent跑`)仍算
|
|
166
|
+
* 一次提及——instabox 箱名本身是 ASCII,CJK 贴着箱名写是正常表达而不是子串误判;
|
|
167
|
+
* 反过来,箱名被夹在非拉丁单词里(现实中不出现)会漏判,这个方向的误判是安全的。
|
|
168
|
+
*/
|
|
169
|
+
const TOKEN_CHAR = /[A-Za-z0-9_-]/;
|
|
170
|
+
|
|
171
|
+
/** `name` 在 `text` 里作为独立 token 的首次出现位置;没有则 `-1` */
|
|
172
|
+
function firstTokenIndex(text: string, name: string): number {
|
|
173
|
+
let from = 0;
|
|
174
|
+
while (from <= text.length) {
|
|
175
|
+
const at = text.indexOf(name, from);
|
|
176
|
+
if (at < 0) return -1;
|
|
177
|
+
if (isBoundary(text.charAt(at - 1)) && isBoundary(text.charAt(at + name.length))) return at;
|
|
178
|
+
from = at + 1;
|
|
179
|
+
}
|
|
180
|
+
return -1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 空串(越界)或非 token 字符即边界 */
|
|
184
|
+
function isBoundary(ch: string): boolean {
|
|
185
|
+
return !TOKEN_CHAR.test(ch);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `boxes` → 去重后的名字列表(保持 `ls` 顺序;空名忽略) */
|
|
189
|
+
function boxNames(boxes: readonly BoxInfo[]): string[] {
|
|
190
|
+
return uniqueNames(boxes.map((box) => box.name));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 去重并丢掉空名,保持首次出现顺序 */
|
|
194
|
+
function uniqueNames(names: readonly string[]): string[] {
|
|
195
|
+
const seen = new Set<string>();
|
|
196
|
+
const out: string[] = [];
|
|
197
|
+
for (const name of names) {
|
|
198
|
+
if (name === "" || seen.has(name)) continue;
|
|
199
|
+
seen.add(name);
|
|
200
|
+
out.push(name);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 归一化单值来源:`undefined` 表示「这一级没给值」(空串/纯空白等同未给) */
|
|
206
|
+
function nonEmpty(value: string | undefined): string | undefined {
|
|
207
|
+
if (value === undefined) return undefined;
|
|
208
|
+
const trimmed = value.trim();
|
|
209
|
+
return trimmed === "" ? undefined : trimmed;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 构造失败结果:`message` 后统一附上候选,便于直接展示与给模型纠错 */
|
|
213
|
+
function failure(kind: ResolveFailureKind, head: string, candidates: string[]): ResolveResult {
|
|
214
|
+
const tail = candidates.length > 0 ? `available boxes: ${candidates.join(", ")}` : "instabox ls returned no boxes";
|
|
215
|
+
return { ok: false, kind, message: `${head}; ${tail}`, candidates: [...candidates] };
|
|
216
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @inobit/pi-instabox — 远端 `inputSchema` → TypeBox,并注入 `box` 目标参数(方案 §4.8 D8)。
|
|
3
|
+
*
|
|
4
|
+
* 保守子集转换:认识的形态用 `Type.*` 顶层工厂重建(模型看到的是可读 schema);
|
|
5
|
+
* 其余(`$ref`/`allOf`/`not`/未知 `format`/白名单外的关键字/超深递归)把该节点**整体**交给
|
|
6
|
+
* `Type.Unsafe(raw)`——pi 的工具参数按 JSON Schema 校验,原样透传仍然可校验,只是不美化。
|
|
7
|
+
* 只用 `Type.*` 顶层工厂(typebox 1.3.x 稳定面),不用 `typebox/compile`、`typebox/value`。
|
|
8
|
+
*
|
|
9
|
+
* 两条方案未覆盖处的选择:
|
|
10
|
+
* - 非法输入(`null`/数组/标量)→ `Type.Any()`:没有可保留的 raw,用 typebox 原生的「任意值」,
|
|
11
|
+
* 不用 `Type.Unsafe({})`(那会假装存在一份 raw schema)。
|
|
12
|
+
* - `injectBoxParam` 只认顶层像 object 的 schema(`type: "object"` 或带 `properties`);否则
|
|
13
|
+
* 不注入、只回 warning —— 与「远端自带 `box`」走同一降级(解析链退到 2–5 级),
|
|
14
|
+
* 绝不改动远端 schema 的形状(凭空挂一个参数名会让调用语义变味)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
Type,
|
|
19
|
+
type TArrayOptions,
|
|
20
|
+
type TNumberOptions,
|
|
21
|
+
type TObjectOptions,
|
|
22
|
+
type TSchema,
|
|
23
|
+
type TSchemaOptions,
|
|
24
|
+
type TStringOptions,
|
|
25
|
+
} from "typebox";
|
|
26
|
+
|
|
27
|
+
type JsonRecord = Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 递归深度上限(层数,不是最大下标):`depth` 为 0-based 层号,`depth >= MAX_SCHEMA_DEPTH`
|
|
31
|
+
* 的节点整体交给 `Type.Unsafe` —— 至多 `MAX_SCHEMA_DEPTH` 层(depth `0..MAX_SCHEMA_DEPTH-1`)
|
|
32
|
+
* 被转换(方案 §4.8 ③)。
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_SCHEMA_DEPTH = 8;
|
|
35
|
+
|
|
36
|
+
/** 注入的 `box` 参数文案:一次调用指定目标箱,缺省走会话目标,失败不静默换箱。 */
|
|
37
|
+
export const BOX_PARAM_DESCRIPTION =
|
|
38
|
+
"Target instabox box for this call. Omit to use the session's target box. A failure is reported; it never falls back to another box silently.";
|
|
39
|
+
|
|
40
|
+
/** 远端 schema 已自带 `box` → 不注入(方案 §4.8 ④:解析链退到 2–5 级,由调用方记一次 warning)。 */
|
|
41
|
+
const BOX_CONFLICT_WARNING =
|
|
42
|
+
"remote inputSchema already declares a `box` property, so the instabox `box` parameter was not injected; per-call box targeting is unavailable for this tool";
|
|
43
|
+
|
|
44
|
+
/** 顶层不是 object schema → `box` 无处安放,同一降级。 */
|
|
45
|
+
const NON_OBJECT_SCHEMA_WARNING =
|
|
46
|
+
"remote inputSchema is not an object schema, so the instabox `box` parameter was not injected; per-call box targeting is unavailable for this tool";
|
|
47
|
+
|
|
48
|
+
/** 每个 kind 允许出现且会被转换的关键字;白名单外的键 → 整节点回落,不静默丢校验语义。 */
|
|
49
|
+
const KEYWORDS_BY_KIND = {
|
|
50
|
+
object: ["type", "properties", "required", "additionalProperties"],
|
|
51
|
+
array: ["type", "items", "minItems", "maxItems"],
|
|
52
|
+
string: ["type", "format", "minLength", "maxLength", "pattern"],
|
|
53
|
+
number: ["type", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"],
|
|
54
|
+
integer: ["type", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"],
|
|
55
|
+
boolean: ["type"],
|
|
56
|
+
null: ["type"],
|
|
57
|
+
enum: ["type", "enum"],
|
|
58
|
+
union: ["anyOf", "oneOf"],
|
|
59
|
+
any: [],
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
type Kind = keyof typeof KEYWORDS_BY_KIND;
|
|
63
|
+
|
|
64
|
+
/** 任何 kind 都允许的键:注释类关键字(透传给模型)+ OpenAPI 的 `nullable`(转成 `Union([T, Null])`)。 */
|
|
65
|
+
const SHARED_KEYS: readonly string[] = [
|
|
66
|
+
"title",
|
|
67
|
+
"description",
|
|
68
|
+
"default",
|
|
69
|
+
"examples",
|
|
70
|
+
"readOnly",
|
|
71
|
+
"writeOnly",
|
|
72
|
+
"nullable",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** 标准 `format` 白名单(JSON Schema 标准集);白名单外一律回落(方案 §4.8 ②)。 */
|
|
76
|
+
const STANDARD_FORMATS: ReadonlySet<string> = new Set([
|
|
77
|
+
"date-time",
|
|
78
|
+
"date",
|
|
79
|
+
"time",
|
|
80
|
+
"duration",
|
|
81
|
+
"email",
|
|
82
|
+
"idn-email",
|
|
83
|
+
"hostname",
|
|
84
|
+
"idn-hostname",
|
|
85
|
+
"ipv4",
|
|
86
|
+
"ipv6",
|
|
87
|
+
"uri",
|
|
88
|
+
"uri-reference",
|
|
89
|
+
"uri-template",
|
|
90
|
+
"iri",
|
|
91
|
+
"iri-reference",
|
|
92
|
+
"uuid",
|
|
93
|
+
"json-pointer",
|
|
94
|
+
"relative-json-pointer",
|
|
95
|
+
"regex",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
99
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `TSchema` 是 typebox 的空接口(运行时就是 JSON Schema 对象);读/改顶层关键字前转成键值视图。 */
|
|
103
|
+
function jsonView(schema: TSchema): JsonRecord {
|
|
104
|
+
return schema as JsonRecord;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 键序稳定(方案 §4.8 ⑤):按名字码位序重排,输出与远端 key 顺序无关。 */
|
|
108
|
+
function sortByKey<T>(source: Record<string, T>): Record<string, T> {
|
|
109
|
+
return Object.fromEntries(
|
|
110
|
+
Object.entries(source).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function numberOrUndefined(value: unknown): number | undefined {
|
|
115
|
+
return typeof value === "number" ? value : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 注释类关键字按固定顺序重建,保证键序稳定。 */
|
|
119
|
+
function annotationsOf(node: JsonRecord): TSchemaOptions {
|
|
120
|
+
const options: TSchemaOptions = {};
|
|
121
|
+
const title = node["title"];
|
|
122
|
+
if (typeof title === "string") options.title = title;
|
|
123
|
+
const description = node["description"];
|
|
124
|
+
if (typeof description === "string") options.description = description;
|
|
125
|
+
const defaultValue = node["default"];
|
|
126
|
+
if (defaultValue !== undefined) options.default = defaultValue;
|
|
127
|
+
const examples = node["examples"];
|
|
128
|
+
if (Array.isArray(examples)) options.examples = examples;
|
|
129
|
+
if (node["readOnly"] === true) options.readOnly = true;
|
|
130
|
+
if (node["writeOnly"] === true) options.writeOnly = true;
|
|
131
|
+
return options;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 整节点回落:原样透传给 pi 的 JSON Schema 校验器(方案 §4.8 ②)。 */
|
|
135
|
+
function unsafeCopy(node: JsonRecord): TSchema {
|
|
136
|
+
return Type.Unsafe<Record<string, unknown>>(node);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 标量 → `Type.Literal`/`Type.Null`;对象、数组、大整数等用 `Literal` 表达不了。 */
|
|
140
|
+
function scalarVariant(value: unknown, options: TSchemaOptions): TSchema | undefined {
|
|
141
|
+
if (value === null) return Type.Null(options);
|
|
142
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
143
|
+
return Type.Literal(value, options);
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resolveKind(node: JsonRecord): Kind | undefined {
|
|
149
|
+
if (Array.isArray(node["enum"])) return "enum";
|
|
150
|
+
const anyOf = node["anyOf"];
|
|
151
|
+
const oneOf = node["oneOf"];
|
|
152
|
+
if (anyOf !== undefined || oneOf !== undefined) {
|
|
153
|
+
// 两种分支语义不同(`oneOf` 要求恰好命中一个),同时出现无法用一个 `Union` 表达
|
|
154
|
+
return anyOf !== undefined && oneOf !== undefined ? undefined : "union";
|
|
155
|
+
}
|
|
156
|
+
const type = node["type"];
|
|
157
|
+
if (
|
|
158
|
+
type === "object" ||
|
|
159
|
+
type === "array" ||
|
|
160
|
+
type === "string" ||
|
|
161
|
+
type === "number" ||
|
|
162
|
+
type === "integer" ||
|
|
163
|
+
type === "boolean" ||
|
|
164
|
+
type === "null"
|
|
165
|
+
) {
|
|
166
|
+
return type;
|
|
167
|
+
}
|
|
168
|
+
// 数组型 `type`(`["string", "null"]`)与未知/非字符串 `type` 都不在子集里
|
|
169
|
+
if (type !== undefined) return undefined;
|
|
170
|
+
if (isRecord(node["properties"])) return "object";
|
|
171
|
+
if (node["items"] !== undefined) return "array";
|
|
172
|
+
return "any";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function hasOnlySupportedKeywords(node: JsonRecord, kind: Kind): boolean {
|
|
176
|
+
const allowed = new Set<string>([...SHARED_KEYS, ...KEYWORDS_BY_KIND[kind]]);
|
|
177
|
+
return Object.keys(node).every((key) => allowed.has(key));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readRequired(node: JsonRecord): readonly string[] | undefined {
|
|
181
|
+
const required = node["required"];
|
|
182
|
+
if (required === undefined) return [];
|
|
183
|
+
if (Array.isArray(required) && required.every((name): name is string => typeof name === "string")) {
|
|
184
|
+
return required;
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildObject(node: JsonRecord, depth: number): TSchema {
|
|
190
|
+
const required = readRequired(node);
|
|
191
|
+
if (required === undefined) return unsafeCopy(node);
|
|
192
|
+
const requiredNames = new Set(required);
|
|
193
|
+
const rawProperties = node["properties"];
|
|
194
|
+
// `required` 由 properties 的 `~optional` 标记派生,所以可选性在这里落;
|
|
195
|
+
// 用 `Object.fromEntries` 而不是逐键赋值,属性名不经过 `__proto__` 之类的 setter。
|
|
196
|
+
const converted: [string, TSchema][] = [];
|
|
197
|
+
if (rawProperties !== undefined) {
|
|
198
|
+
if (!isRecord(rawProperties)) return unsafeCopy(node);
|
|
199
|
+
for (const [name, property] of Object.entries(sortByKey(rawProperties))) {
|
|
200
|
+
const schema = toTypeBox(property, depth + 1);
|
|
201
|
+
converted.push([name, requiredNames.has(name) ? schema : Type.Optional(schema)]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// `required` 指向一个没有 properties 条目的名字:`Type.Optional` 表达不了,整节点回落
|
|
205
|
+
if ([...requiredNames].some((name) => !converted.some(([key]) => key === name))) return unsafeCopy(node);
|
|
206
|
+
const properties = Object.fromEntries(converted);
|
|
207
|
+
const options: TObjectOptions = { ...annotationsOf(node) };
|
|
208
|
+
const additional = node["additionalProperties"];
|
|
209
|
+
if (additional === false || additional === true) options.additionalProperties = additional;
|
|
210
|
+
else if (isRecord(additional)) options.additionalProperties = toTypeBox(additional, depth + 1);
|
|
211
|
+
else if (additional !== undefined) return unsafeCopy(node);
|
|
212
|
+
return Type.Object(properties, options);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function buildArray(node: JsonRecord, depth: number): TSchema {
|
|
216
|
+
const items = node["items"];
|
|
217
|
+
let element: TSchema;
|
|
218
|
+
if (items === undefined) element = Type.Any();
|
|
219
|
+
else if (isRecord(items)) element = toTypeBox(items, depth + 1);
|
|
220
|
+
else return unsafeCopy(node); // 元组型 `items: [...]` 不在子集里
|
|
221
|
+
const options: TArrayOptions = { ...annotationsOf(node) };
|
|
222
|
+
const minItems = numberOrUndefined(node["minItems"]);
|
|
223
|
+
if (minItems !== undefined) options.minItems = minItems;
|
|
224
|
+
const maxItems = numberOrUndefined(node["maxItems"]);
|
|
225
|
+
if (maxItems !== undefined) options.maxItems = maxItems;
|
|
226
|
+
return Type.Array(element, options);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function buildString(node: JsonRecord): TSchema {
|
|
230
|
+
const options: TStringOptions = { ...annotationsOf(node) };
|
|
231
|
+
const format = node["format"];
|
|
232
|
+
if (typeof format === "string") {
|
|
233
|
+
if (!STANDARD_FORMATS.has(format)) return unsafeCopy(node);
|
|
234
|
+
options.format = format;
|
|
235
|
+
}
|
|
236
|
+
const minLength = numberOrUndefined(node["minLength"]);
|
|
237
|
+
if (minLength !== undefined) options.minLength = minLength;
|
|
238
|
+
const maxLength = numberOrUndefined(node["maxLength"]);
|
|
239
|
+
if (maxLength !== undefined) options.maxLength = maxLength;
|
|
240
|
+
const pattern = node["pattern"];
|
|
241
|
+
if (typeof pattern === "string") options.pattern = pattern;
|
|
242
|
+
return Type.String(options);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function numericOptions(node: JsonRecord): TNumberOptions {
|
|
246
|
+
const options: TNumberOptions = { ...annotationsOf(node) };
|
|
247
|
+
const minimum = numberOrUndefined(node["minimum"]);
|
|
248
|
+
if (minimum !== undefined) options.minimum = minimum;
|
|
249
|
+
const maximum = numberOrUndefined(node["maximum"]);
|
|
250
|
+
if (maximum !== undefined) options.maximum = maximum;
|
|
251
|
+
const exclusiveMinimum = numberOrUndefined(node["exclusiveMinimum"]);
|
|
252
|
+
if (exclusiveMinimum !== undefined) options.exclusiveMinimum = exclusiveMinimum;
|
|
253
|
+
const exclusiveMaximum = numberOrUndefined(node["exclusiveMaximum"]);
|
|
254
|
+
if (exclusiveMaximum !== undefined) options.exclusiveMaximum = exclusiveMaximum;
|
|
255
|
+
const multipleOf = numberOrUndefined(node["multipleOf"]);
|
|
256
|
+
if (multipleOf !== undefined) options.multipleOf = multipleOf;
|
|
257
|
+
return options;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function buildEnum(node: JsonRecord): TSchema {
|
|
261
|
+
const values = node["enum"];
|
|
262
|
+
if (!Array.isArray(values) || values.length === 0) return unsafeCopy(node);
|
|
263
|
+
const options = annotationsOf(node);
|
|
264
|
+
// 单值 `enum` → `Type.Literal`(注释挂在该节点上);多值 → `Union`(注释挂在 union 上)
|
|
265
|
+
const single = values.length === 1;
|
|
266
|
+
const variants: TSchema[] = [];
|
|
267
|
+
for (const value of values) {
|
|
268
|
+
const variant = scalarVariant(value, single ? options : {});
|
|
269
|
+
if (variant === undefined) return unsafeCopy(node);
|
|
270
|
+
variants.push(variant);
|
|
271
|
+
}
|
|
272
|
+
const [only] = variants;
|
|
273
|
+
if (only !== undefined && variants.length === 1) return only;
|
|
274
|
+
return Type.Union(variants, options);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function buildUnion(node: JsonRecord, depth: number): TSchema {
|
|
278
|
+
const branches = node["anyOf"] ?? node["oneOf"];
|
|
279
|
+
if (!Array.isArray(branches) || branches.length === 0) return unsafeCopy(node);
|
|
280
|
+
const variants = branches.map((branch) => toTypeBox(branch, depth + 1));
|
|
281
|
+
return Type.Union(variants, annotationsOf(node));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildBase(node: JsonRecord, kind: Kind, depth: number): TSchema {
|
|
285
|
+
switch (kind) {
|
|
286
|
+
case "object":
|
|
287
|
+
return buildObject(node, depth);
|
|
288
|
+
case "array":
|
|
289
|
+
return buildArray(node, depth);
|
|
290
|
+
case "string":
|
|
291
|
+
return buildString(node);
|
|
292
|
+
case "number":
|
|
293
|
+
return Type.Number(numericOptions(node));
|
|
294
|
+
case "integer":
|
|
295
|
+
return Type.Integer(numericOptions(node));
|
|
296
|
+
case "boolean":
|
|
297
|
+
return Type.Boolean(annotationsOf(node));
|
|
298
|
+
case "null":
|
|
299
|
+
return Type.Null(annotationsOf(node));
|
|
300
|
+
case "enum":
|
|
301
|
+
return buildEnum(node);
|
|
302
|
+
case "union":
|
|
303
|
+
return buildUnion(node, depth);
|
|
304
|
+
case "any":
|
|
305
|
+
return Type.Any(annotationsOf(node));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** 一个 JSON Schema 节点 → TypeBox(`depth` 为当前递归深度,顶层为 0)。 */
|
|
310
|
+
function toTypeBox(node: unknown, depth: number): TSchema {
|
|
311
|
+
if (!isRecord(node)) return Type.Any();
|
|
312
|
+
// 深度上限只截断递归本身:该层整节点原样透传,校验语义不丢(方案 §4.8 ③)
|
|
313
|
+
if (depth >= MAX_SCHEMA_DEPTH) return unsafeCopy(node);
|
|
314
|
+
const kind = resolveKind(node);
|
|
315
|
+
if (kind === undefined) return unsafeCopy(node);
|
|
316
|
+
if (!hasOnlySupportedKeywords(node, kind)) return unsafeCopy(node);
|
|
317
|
+
const base = buildBase(node, kind, depth);
|
|
318
|
+
return node["nullable"] === true ? Type.Union([base, Type.Null()], annotationsOf(node)) : base;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 远端 `inputSchema` → TypeBox。不 throw:非法输入 → `Type.Any()`,不支持的节点 → `Type.Unsafe(raw)`。
|
|
323
|
+
*
|
|
324
|
+
* `depth` 只给递归内部用;外部调用保持默认 0。
|
|
325
|
+
*/
|
|
326
|
+
export function jsonSchemaToTypeBox(schema: unknown, depth = 0): TSchema {
|
|
327
|
+
return toTypeBox(schema, depth);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface InjectBoxParamOptions {
|
|
331
|
+
/** 覆盖 `box` 参数文案(默认 `BOX_PARAM_DESCRIPTION`)。 */
|
|
332
|
+
description?: string;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface InjectBoxParamResult {
|
|
336
|
+
schema: TSchema;
|
|
337
|
+
injected: boolean;
|
|
338
|
+
warning?: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 在**顶层 `properties`** 加一个可选 `box` 参数(方案 §4.8 ④)。
|
|
343
|
+
*
|
|
344
|
+
* - 只改顶层:返回值是替换了顶层 `properties` 的 JSON Schema 对象,其它顶层关键字
|
|
345
|
+
* (含 Unsafe 回落带过来的 `allOf` 等)原样保留。
|
|
346
|
+
* - 返回值是**纯 JSON Schema 视图**(`{ ...node, properties }`):对象展开只复制可枚举键,
|
|
347
|
+
* typebox 的 `~kind`/`~unsafe` 反射标记是非枚举属性,注入后不保留。pi 按 JSON Schema
|
|
348
|
+
* 校验工具参数,运行时无影响;要判断某节点是否为 Unsafe 回落,用**注入前**的对象统计。
|
|
349
|
+
* - 不进 `required`:`box` 是可选参数,缺省走会话目标。
|
|
350
|
+
* - 远端已有 `box`(属性或 `required` 里的名字)→ 不注入、原样返回并给 warning。
|
|
351
|
+
* - 顶层不是 object schema → 同样不注入、原样返回并给 warning。
|
|
352
|
+
*/
|
|
353
|
+
export function injectBoxParam(schema: TSchema, opts: InjectBoxParamOptions = {}): InjectBoxParamResult {
|
|
354
|
+
const node = jsonView(schema);
|
|
355
|
+
const rawProperties = node["properties"];
|
|
356
|
+
if (node["type"] !== "object" && !isRecord(rawProperties)) {
|
|
357
|
+
return { schema, injected: false, warning: NON_OBJECT_SCHEMA_WARNING };
|
|
358
|
+
}
|
|
359
|
+
const existing: JsonRecord = isRecord(rawProperties) ? rawProperties : {};
|
|
360
|
+
const required = readRequired(node) ?? [];
|
|
361
|
+
if ("box" in existing || required.includes("box")) {
|
|
362
|
+
return { schema, injected: false, warning: BOX_CONFLICT_WARNING };
|
|
363
|
+
}
|
|
364
|
+
const description = opts.description ?? BOX_PARAM_DESCRIPTION;
|
|
365
|
+
const properties = sortByKey({
|
|
366
|
+
...existing,
|
|
367
|
+
box: Type.Optional(Type.String({ description })),
|
|
368
|
+
});
|
|
369
|
+
return { schema: { ...node, properties }, injected: true };
|
|
370
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @inobit/pi-instabox — 会话级目标 entry 的读写(纯函数,不 import pi 运行时类型)。
|
|
3
|
+
*
|
|
4
|
+
* 两处会话级目标都用 CustomEntry 存(不进 LLM 上下文,重载会话后从会话条目重建):
|
|
5
|
+
* - {@link TARGET_ENTRY}:桌面工具的当前目标箱,由 `/instabox-use <box>` 写入;
|
|
6
|
+
* - {@link DEVTOOLS_TARGET_ENTRY}:DevTools MCP 的会话绑定箱,由 `/instabox-devtools on <box>` 写入。
|
|
7
|
+
*
|
|
8
|
+
* 唯一合法的读取来源是 `ctx.sessionManager.getBranch()`:`getEntries()` 会带上被
|
|
9
|
+
* `/tree` 放弃的分支,读到那里的目标会把「切分支后目标回到未设置」这条承诺破掉。
|
|
10
|
+
* 因此本模块只接受调用方传入的分支条目,不自己碰会话管理器。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 目标 entry 的 customType,写入与识别的唯一依据 */
|
|
14
|
+
export const TARGET_ENTRY = "instabox-target";
|
|
15
|
+
|
|
16
|
+
/** 目标 entry 的 data 形状(`at` 为写入时刻的 ISO 时间串,仅供排查) */
|
|
17
|
+
export interface TargetEntryData {
|
|
18
|
+
box: string;
|
|
19
|
+
at: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** DevTools 会话绑定 entry 的 customType,写入与识别的唯一依据 */
|
|
23
|
+
export const DEVTOOLS_TARGET_ENTRY = "instabox-devtools-target";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* DevTools 绑定 entry 的 data 形状。
|
|
27
|
+
*
|
|
28
|
+
* `reset: true` 是一条**清除标记**(`box` 恒为空串):它只终止向后扫描,让更早的绑定失效,
|
|
29
|
+
* 但不清除任何已注册的 MCP server——开关状态与注册都归装配层管,entry 只表达「box 绑到哪台」。
|
|
30
|
+
*/
|
|
31
|
+
export interface DevtoolsTargetEntryData {
|
|
32
|
+
box: string;
|
|
33
|
+
at: string;
|
|
34
|
+
reset?: true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 会话条目的最小形态:只声明本模块用到的字段 */
|
|
38
|
+
export interface SessionEntryLike {
|
|
39
|
+
type: string;
|
|
40
|
+
customType?: string;
|
|
41
|
+
data?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 是否为某个 customType 的 custom entry(仅认 type=custom + customType,不做其它推断) */
|
|
45
|
+
function isEntryOf(entry: unknown, customType: string): boolean {
|
|
46
|
+
const e = entry as SessionEntryLike | null | undefined;
|
|
47
|
+
return typeof e === "object" && e !== null && e.type === "custom" && e.customType === customType;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 是否为本次目标 entry(仅认 type=custom + customType) */
|
|
51
|
+
export function isTargetEntry(entry: unknown): boolean {
|
|
52
|
+
return isEntryOf(entry, TARGET_ENTRY);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 构造 entry data;`at` 缺省取当前时刻,测试可显式传入以获得确定性 */
|
|
56
|
+
export function targetEntryData(box: string, at?: string): TargetEntryData {
|
|
57
|
+
return { box, at: at ?? new Date().toISOString() };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 构造 DevTools 绑定 entry data:`box` 为空串且 `reset: true` 时是清除标记。
|
|
62
|
+
* `at` 缺省取当前时刻,测试可显式传入以获得确定性。
|
|
63
|
+
*/
|
|
64
|
+
export function devtoolsTargetEntryData(box: string, at?: string): DevtoolsTargetEntryData {
|
|
65
|
+
const data: DevtoolsTargetEntryData = { box, at: at ?? new Date().toISOString() };
|
|
66
|
+
if (box === "") data.reset = true;
|
|
67
|
+
return data;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 从分支条目末尾向前扫,返回最近一次写入的目标箱名;无目标 entry 时返回 undefined。
|
|
72
|
+
* `entries` 必须是 `getBranch()` 的结果(调用方负责),被放弃分支上的旧目标读不到。
|
|
73
|
+
*/
|
|
74
|
+
export function readTarget(entries: readonly SessionEntryLike[]): string | undefined {
|
|
75
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
76
|
+
const entry = entries[i];
|
|
77
|
+
if (!isTargetEntry(entry)) continue;
|
|
78
|
+
const box = (entry?.data as Partial<TargetEntryData> | undefined)?.box;
|
|
79
|
+
if (typeof box === "string" && box.length > 0) return box;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 从分支条目末尾向前扫,返回最近一次的 DevTools 绑定箱名。
|
|
86
|
+
*
|
|
87
|
+
* `reset: true` 的清除标记**终止**扫描并返回 undefined:它之后的绑定才算数,更早的绑定已失效。
|
|
88
|
+
* 无绑定(或最近一条是清除标记)时返回 undefined,调用方据此回退到 flag / config。
|
|
89
|
+
*/
|
|
90
|
+
export function readDevtoolsTarget(entries: readonly SessionEntryLike[]): string | undefined {
|
|
91
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
92
|
+
const entry = entries[i];
|
|
93
|
+
if (!isEntryOf(entry, DEVTOOLS_TARGET_ENTRY)) continue;
|
|
94
|
+
const data = entry?.data as Partial<DevtoolsTargetEntryData> | undefined;
|
|
95
|
+
if (data?.reset === true) return undefined;
|
|
96
|
+
const box = data?.box;
|
|
97
|
+
if (typeof box === "string" && box.length > 0) return box;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|