@sema-agent/settings-schema 14.7.0 → 15.0.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 +306 -0
- package/README.md +80 -3
- package/conformance/VERSION +1 -1
- package/conformance/cases/c59-s784-g3-k8s-missing-image/input.json +4 -1
- package/conformance/cases/c61-s784-g3-other-lane-key/input.json +1 -0
- package/conformance/cases/c64-s784-multitenant-local-store/input.json +2 -1
- package/dist/atomic-write.d.ts +15 -0
- package/dist/atomic-write.js +109 -5
- package/dist/catalog-manages.d.ts +15 -0
- package/dist/catalog-manages.js +60 -0
- package/dist/center-refs.d.ts +68 -0
- package/dist/center-refs.js +70 -0
- package/dist/config-dir-migration.js +1 -0
- package/dist/config-fns.js +5 -3
- package/dist/config-root-documents.d.ts +106 -0
- package/dist/config-root-documents.js +93 -0
- package/dist/config-root-node.d.ts +117 -0
- package/dist/config-root-node.js +260 -0
- package/dist/conformance/run.d.ts +3 -0
- package/dist/conformance/run.js +10 -6
- package/dist/conformance/temp-roots.d.ts +31 -0
- package/dist/conformance/temp-roots.js +184 -0
- package/dist/default-model.d.ts +1 -1
- package/dist/default-model.js +5 -4
- package/dist/env-model-table.d.ts +163 -0
- package/dist/env-model-table.js +257 -0
- package/dist/execution-lane-index.d.ts +6 -0
- package/dist/execution-lane-index.js +8 -0
- package/dist/execution-lanes.d.ts +2 -1
- package/dist/execution-lanes.js +6 -4
- package/dist/fleet.d.ts +25 -7
- package/dist/fleet.js +24 -6
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mcp-request-entry.d.ts +58 -6
- package/dist/mcp-request-entry.js +45 -13
- package/dist/mcp-server-name.d.ts +17 -0
- package/dist/mcp-server-name.js +19 -0
- package/dist/migration-receipt.d.ts +10 -6
- package/dist/migration-receipt.js +5 -3
- package/dist/node.d.ts +1 -0
- package/dist/node.js +2 -0
- package/dist/one-time-migrations.d.ts +4 -2
- package/dist/one-time-migrations.js +29 -1
- package/dist/own-key-read.d.ts +1 -1
- package/dist/remote-exec.d.ts +1 -1
- package/dist/remote-exec.js +4 -2
- package/dist/retired-form.d.ts +19 -0
- package/dist/retired-form.js +24 -1
- package/dist/safety-merge-spec.d.ts +6 -6
- package/dist/tier-words.d.ts +33 -0
- package/dist/tier-words.js +43 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.js +29 -5
- package/dist/worker-row-verdict.js +7 -1
- package/docs/API-SURFACE.md +68 -4
- package/docs/CONSUMER-INTEGRATION.md +72 -2
- package/llms-full.txt +8 -3
- package/llms.txt +9 -3
- package/package.json +2 -2
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 符合性 kit 的临时根:唯一铸点 + 两道收口 —— 包内私有(不进 `./conformance` 的导出面)。
|
|
3
|
+
*
|
|
4
|
+
* `materializeLocalRoot` 每个 case 建一个 `<tmp>/settings-conformance-<随机>/`(`mkdtemp` 直铸的形);正常路径上由适配器 `finally` 里的 `cleanup()` 删。
|
|
5
|
+
* 进程在两者之间死掉(`process.exit`、未捕获异常、信号、SIGKILL)时 `finally` 不跑,根就留在宿主 tmp 下 —— 语料一大就是成千上万个残件。两道收口:
|
|
6
|
+
* ① **旧件清扫**:活性证据是**根里的心跳文件**。铸根时在根内以 `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW` 建一个空文件 {@link LOCAL_ROOT_ALIVE_FILE}(`.alive`,静态为空),`fstat` 记下它的 `(dev, ino)` 就**关掉**;再起一只 `unref()` 的定时器,
|
|
7
|
+
* 每 {@link ALIVE_TOUCH_INTERVAL_MS}(5 分钟)一拍:按路径 `O_WRONLY | O_NOFOLLOW | O_NONBLOCK` 打开 `.alive`(不带 `O_CREAT`;`O_NONBLOCK` 让被换成没有读端的命名管道的 `.alive` 当场打不开〔`ENXIO`〕作罢,不在文件系统线程池里一直阻塞)、`fstat` 比 `(dev, ino)`,**相等才**写一个字节(换行,空白)再截回空,mtime 由文件系统自己打(两步都刷新它),写完关掉;不相等就关掉、这一拍不写;
|
|
8
|
+
* **心跳按 inode 身份写、不持句柄**:规则只有一条「同一 inode 才写」—— `.alive` 被换成符号链接(有 `O_NOFOLLOW` 的平台上打开就被拒、这一拍作罢;没有它的平台上打开会跟过去,身份比对仍拦住指向别的文件的链接)、被硬链接到别的文件、被换成另一个文件或目录、被删,或根被删后同名重建,心跳都不写别的文件,也不把任何路径建出来(打开不带 `O_CREAT`);
|
|
9
|
+
* 身份是文件系统的 `(dev, ino)`:要让它错认,得先在这个根(`mkdtemp` 铸出的、只有属主进得去的目录)里删掉 `.alive`、再让另一个文件拿到同一个 inode 号并放在这个名字下 —— 能做到这一步的,本来就能直接写这个根里的任何文件;
|
|
10
|
+
* **模块里没有常驻的文件描述符**:铸根时那一只用完即关,每一拍那一只也是一拍一开一关,所以一次铸许多个根、`cleanup()` 留到最后统一调的调用方,铸多少个根都不因此多占描述符;
|
|
11
|
+
* 进程以任何方式死掉,心跳随它停,`.alive` 就一分钟一分钟变老。本进程第一次铸根时清扫:名字恰是 `<前缀><六位随机>`、`lstat` 是目录、属主是当前用户、根内有 `.alive` 常规文件、
|
|
12
|
+
* 且 `.alive` 的 mtime 早于 {@link STALE_LOCAL_ROOT_AGE_MS}(60 分钟)的旧件,整根删 —— 规则只有这一条。
|
|
13
|
+
* 这管所有进程自己没 cleanup 的死法:根都留着,`.alive` 超过 60 分钟没更新之后,由下一轮第一次铸根时的清扫收。
|
|
14
|
+
* **不问进程号、不问主机名**:证据与文件系统同域,共享同一 tmp 的几个进程、几个容器(跨 pid 命名空间也一样)读到的是同一个答案 —— 谁的 `.alive` 在更新,谁的根就不被清。
|
|
15
|
+
* 根里没有 `.alive` 的(更早的版本铸的、运维手造的同形目录)一概不动,拿不准就不删。
|
|
16
|
+
* **限制**:心跳停了 60 分钟的活进程,它的根会被别的进程当作死进程的根清掉 —— 心跳停有两种:事件循环停转超过 60 分钟(被 `SIGSTOP` 暂停、容器被冻结、停在调试器断点上);
|
|
17
|
+
* 心跳连续 60 分钟写不进去(文件系统写满、被重挂成只读 —— 这一拍作罢、不抛、不报)。
|
|
18
|
+
* {@link ALIVE_TOUCH_INTERVAL_MS} 与 {@link STALE_LOCAL_ROOT_AGE_MS} 是源里的具名常量、读得到,**不是旋钮**(没有 env、没有参数改它们)。
|
|
19
|
+
* **`.alive` 静态为空,只给清扫看**:`loadLocalConfig` 只按名读 `config.d/<域>.json`、`config.d/remote-exec.json` 与 `.env`,列文件只列 `config.d/`,所以它不被自动读到;谁在 `.env` 里用 `file:` 显式引用它,读到的是空文件、被拒(`file:` 引用不接受去掉首尾空白后为空的文件,与引用一个不存在的文件同拒),
|
|
20
|
+
* 不会解析成一个值。心跳写进去的那一个字节是换行(空白),只存在于心跳那两步之间;截回失败或进程恰在两步之间死掉时它会留在文件里,但 `file:` 引用读到后会去掉首尾空白,留着一个换行的文件去掉首尾空白后仍为空,照样被拒。
|
|
21
|
+
* 心跳一拍可能卡在文件系统上(挂住的网络盘):上一拍还没做完时,到期的这一拍直接跳过(每个根一面在飞旗)—— 不叠写、不排队;挂住的那一拍做完(成功或失败)后旗复位,心跳照常。
|
|
22
|
+
* 还有四条边界:宿主进程活着时,它铸出而没 cleanup 的根一直带着新鲜的 `.alive`,清扫不碰(进程死后才开始变老);
|
|
23
|
+
* 宿主活着却没 `cleanup()` 的根,它的心跳定时器(`unref`,不拖住进程退出)每 {@link ALIVE_TOUCH_INTERVAL_MS} 开写一次、直到进程结束 —— 代价是一次一字节的小文件写(一拍一开一关,不占常驻的文件描述符),补偿是调 `cleanup()`(契约本就要求);
|
|
24
|
+
* 定时器取自 `node:timers`:只接管全局 `setInterval` 的伪计时器(vitest 的 `vi.useFakeTimers()` 默认如此)接管不了它;连 `timers` 模块一起接管、且先于本包首次 import 装上的伪计时器库会把这只心跳接过去,跑尽定时器的调用(如 `runAll`)会撞上它 ——
|
|
25
|
+
* 补偿是跑尽定时器之前先调 `cleanup()`(契约本就要求;它清掉的正是这只心跳);
|
|
26
|
+
* `mkdtemp` 与写 `.alive` 之间被杀的进程留下一个没有 `.alive` 的根,清扫不认它;
|
|
27
|
+
* ② **正常路径**:{@link removeLocalRoot}(`cleanup()` 调它)停这个根自己的心跳定时器、再删根(只删自己的根;不开不关任何文件;还在飞的一拍随后在已摘出目录的文件上做完、或打不开而落空,被吞掉)。
|
|
28
|
+
*
|
|
29
|
+
* 本模块不在进程上装任何监听:不碰 `exit`,也不碰 `SIGINT` / `SIGTERM` —— 宿主自己的 `exit` / 信号处理器(含排空、含装在铸根之后的)一概不受影响,
|
|
30
|
+
* 宿主的 `exit` 处理器里照样读得到根。死法的收口只有旧件清扫这一种,没有第二套机制;心跳定时器不是进程监听,且 `unref`,不拖住进程退出。
|
|
31
|
+
*
|
|
32
|
+
* 清扫是**尽力而为**:删不掉的(别的用户的、没权限的)留着,之后的清扫再试,绝不让一次清扫失败改变一次符合性结论。
|
|
33
|
+
* 本模块不读进程的钟:清扫的「现在」是刚铸出的根目录的 mtime(建 `.alive` 那一刻由文件系统打),心跳是在 `.alive` 上写一个字节再截回空、mtime 同样由文件系统打 ——
|
|
34
|
+
* 与它比的 `.alive` 的 mtime 和它的「现在」是同一只钟(文件系统的),所以进程自己的钟拨偏多少都不改变清扫的答案。
|
|
35
|
+
*/
|
|
36
|
+
import { constants, lstat, mkdtemp, open, readdir, rm, stat } from "node:fs/promises";
|
|
37
|
+
import { tmpdir } from "node:os";
|
|
38
|
+
import { join } from "node:path";
|
|
39
|
+
import { clearInterval, setInterval } from "node:timers"; // 心跳定时器取自模块,不取全局:只接管全局的伪计时器(vitest 的 `vi.useFakeTimers()` 默认如此)接管不了这只永不停的间隔(连模块一起接管的,见头注)
|
|
40
|
+
/** 临时根的目录名前缀;名字 = 前缀 + `mkdtemp` 的六位随机(清扫只认这一种形)。 */
|
|
41
|
+
export const LOCAL_ROOT_PREFIX = "settings-conformance-";
|
|
42
|
+
/** 根内的心跳文件名:静态为空的文件,活进程定时(同一个 inode 才写)写一个字节再截回空、mtime 由文件系统自己打;清扫只看它的 mtime 年龄。 */
|
|
43
|
+
export const LOCAL_ROOT_ALIVE_FILE = ".alive";
|
|
44
|
+
/** 心跳每一拍写进 `.alive` 的那一个字节:换行(空白)—— `file:` 引用读到后会去掉首尾空白再判空,所以它留在文件里多久,引用 `.alive` 都读到空 ⇒ 被拒,不会解析成值;写完立即截回空(文件静态为空,铸根时不写它)。 */
|
|
45
|
+
const ALIVE_BYTE = "\n";
|
|
46
|
+
/**
|
|
47
|
+
* 旧件的年龄线:60 分钟 —— 根里 `.alive` 的 mtime 早于它,清扫才删这个根。一个 case 的一条本地腿只要几毫秒、整套语料也只要几秒,60 分钟远长于一次正常的跑,
|
|
48
|
+
* 所以心跳停了这么久的根绝大多数是上一轮被收走的残件;线又短到清扫能及时收走它们,不让它们一天天攒。它必须远大于 {@link ALIVE_TOUCH_INTERVAL_MS},事件循环照常转的活进程的 `.alive` 才不会老到这条线。
|
|
49
|
+
* 具名常量,不是旋钮。
|
|
50
|
+
*/
|
|
51
|
+
export const STALE_LOCAL_ROOT_AGE_MS = 60 * 60 * 1000;
|
|
52
|
+
/** 心跳间隔:5 分钟 —— 活进程每隔这么久在根里的 `.alive` 上写一个字节再截回空(文件系统给它打新的 mtime)。它必须远小于 {@link STALE_LOCAL_ROOT_AGE_MS}(现为十二分之一),偶尔一拍慢了也不会让活进程的 `.alive` 老过年龄线。具名常量,不是旋钮。 */
|
|
53
|
+
export const ALIVE_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
|
|
54
|
+
const OWN_NAME = new RegExp(`^${LOCAL_ROOT_PREFIX}[A-Za-z0-9]{6}$`);
|
|
55
|
+
/**
|
|
56
|
+
* 清扫 `dir` 下的旧临时根:名字恰是「前缀 + 六位字母数字」、`lstat` 是目录(符号链接与文件一概不动)、属主是当前用户(有 `getuid` 的平台)、
|
|
57
|
+
* 根内有 `.alive` 且 `lstat` 是常规文件(目录、符号链接都不算)、`nowMs - <.alive 的 mtime>` 超过 `maxAgeMs` ⇒ 整根删。没有 `.alive` 的一概不动。
|
|
58
|
+
* 不问进程号、不问主机名 —— 活性证据只有 `.alive` 的 mtime 年龄。返回被删的目录名。读不了 `dir` / 删不掉某一个 ⇒ 跳过,不抛。
|
|
59
|
+
*/
|
|
60
|
+
export async function sweepStaleLocalRoots(opts) {
|
|
61
|
+
const maxAgeMs = opts.maxAgeMs ?? STALE_LOCAL_ROOT_AGE_MS;
|
|
62
|
+
const uid = process.getuid?.();
|
|
63
|
+
const removed = [];
|
|
64
|
+
let names;
|
|
65
|
+
try {
|
|
66
|
+
names = await readdir(opts.dir);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return removed;
|
|
70
|
+
}
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
if (!OWN_NAME.test(name))
|
|
73
|
+
continue;
|
|
74
|
+
const path = join(opts.dir, name);
|
|
75
|
+
try {
|
|
76
|
+
const st = await lstat(path);
|
|
77
|
+
if (!st.isDirectory() || (uid !== undefined && st.uid !== uid))
|
|
78
|
+
continue;
|
|
79
|
+
const alive = await lstat(join(path, LOCAL_ROOT_ALIVE_FILE)); // 没有 `.alive` ⇒ 抛 ENOENT ⇒ 跳过(没有活性证据,不删)
|
|
80
|
+
if (!alive.isFile() || opts.nowMs - alive.mtimeMs <= maxAgeMs)
|
|
81
|
+
continue;
|
|
82
|
+
await rm(path, { recursive: true, force: true });
|
|
83
|
+
removed.push(name);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// 尽力而为(见文件头):留着,之后的清扫再试
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return removed;
|
|
90
|
+
}
|
|
91
|
+
/** 本进程铸出、还没删的根 → 它的心跳。 */
|
|
92
|
+
const heartbeats = new Map();
|
|
93
|
+
/**
|
|
94
|
+
* 建 `.alive`:`O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW`(路径上已有任何东西 —— 符号链接也算 —— 就抛),`fstat` 记下 `(dev, ino)`,**关掉**再返回;
|
|
95
|
+
* `fstat` 失败时也关掉,抛的是 `fstat` 的错(关闭的错不盖它)。
|
|
96
|
+
*/
|
|
97
|
+
async function createAliveFile(path) {
|
|
98
|
+
const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
|
|
99
|
+
let identity;
|
|
100
|
+
try {
|
|
101
|
+
const { dev, ino } = await file.stat({ bigint: true });
|
|
102
|
+
identity = { dev, ino };
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
await file.close().catch(() => { });
|
|
106
|
+
throw e;
|
|
107
|
+
}
|
|
108
|
+
await file.close();
|
|
109
|
+
return identity;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 心跳的一拍:按路径 `O_WRONLY | O_NOFOLLOW | O_NONBLOCK` 打开 `.alive`(不带 `O_CREAT`:文件或根已被删,这一拍 `ENOENT` 作罢,不把它们重建出来;符号链接 `ELOOP`、目录 `EISDIR`、没有读端的命名管道 `ENXIO`〔`O_NONBLOCK` 的用处:不然这个打开会一直等读端〕同样作罢),
|
|
113
|
+
* `fstat` 比 `(dev, ino)`:不等(换成了别的文件,含硬链接到别处的文件)⇒ 本拍不写;相等才写一个字节(换行)、再截回空 —— 两步都改变文件大小,所以都由文件系统给 mtime 打新戳;
|
|
114
|
+
* 文件静态为空,那一个字节只存在于这两步之间(截回失败或进程恰在两步之间死掉时会留着,留着也是空白,见头注)。无论哪条路都在 `finally` 里关掉这一拍自己打开的文件。
|
|
115
|
+
*/
|
|
116
|
+
async function beat(path, identity) {
|
|
117
|
+
const file = await open(path, constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
118
|
+
try {
|
|
119
|
+
const { dev, ino } = await file.stat({ bigint: true });
|
|
120
|
+
if (dev !== identity.dev || ino !== identity.ino)
|
|
121
|
+
return;
|
|
122
|
+
await file.write(ALIVE_BYTE, 0);
|
|
123
|
+
await file.truncate(0);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await file.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** 定时器到期:上一拍还没做完就跳过;否则起一拍,失败吞掉(尽力而为:文件系统暂时写不了 / 根已被删 / `.alive` 已不是铸根时那个文件 ⇒ 这一拍作罢,下一拍再看),做完(成功或失败)旗复位。 */
|
|
130
|
+
function tick(path, heartbeat) {
|
|
131
|
+
if (heartbeat.beating)
|
|
132
|
+
return;
|
|
133
|
+
heartbeat.beating = true;
|
|
134
|
+
beat(path, heartbeat.identity)
|
|
135
|
+
.catch(() => { })
|
|
136
|
+
.finally(() => {
|
|
137
|
+
heartbeat.beating = false;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
let sweptThisProcess = false;
|
|
141
|
+
/**
|
|
142
|
+
* 铸一个临时根:根内建空文件 `.alive`、记下它的身份就关(本模块按路径建它只此一处)、起它的心跳定时器;本进程第一次铸根时先清扫旧件(见文件头 ①)。
|
|
143
|
+
* 铸出这个根之前任何一步失败(建 `.alive`、起定时器),都回滚到什么也没留下 —— 停定时器、删根,再把原错抛出去。返回根的绝对路径。
|
|
144
|
+
*/
|
|
145
|
+
export async function mintLocalRoot() {
|
|
146
|
+
const dir = tmpdir();
|
|
147
|
+
const root = await mkdtemp(join(dir, LOCAL_ROOT_PREFIX));
|
|
148
|
+
const alive = join(root, LOCAL_ROOT_ALIVE_FILE);
|
|
149
|
+
let started;
|
|
150
|
+
try {
|
|
151
|
+
const identity = await createAliveFile(alive); // 静态为空,心跳的那一个字节只在每一拍的两步之间存在
|
|
152
|
+
const heartbeat = { identity, beating: false, timer: setInterval(() => tick(alive, heartbeat), ALIVE_TOUCH_INTERVAL_MS) };
|
|
153
|
+
started = heartbeat;
|
|
154
|
+
heartbeat.timer.unref();
|
|
155
|
+
heartbeats.set(root, heartbeat);
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
if (started !== undefined)
|
|
159
|
+
clearInterval(started.timer);
|
|
160
|
+
await rm(root, { recursive: true, force: true }); // 没有 `.alive` 的根没人清得掉:建不成就不留这个根
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
if (!sweptThisProcess) {
|
|
164
|
+
sweptThisProcess = true;
|
|
165
|
+
try {
|
|
166
|
+
await sweepStaleLocalRoots({ dir, nowMs: (await stat(root)).mtimeMs });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// 读不到刚铸出的根的 mtime ⇒ 这一轮不清扫(尽力而为)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return root;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 删一个临时根:先停它的心跳定时器,再只删这一个(删不掉就抛,留下的根等 `.alive` 超过 60 分钟没更新之后的旧件清扫收)。不开不关任何文件;还在飞的一拍不等它。
|
|
176
|
+
*/
|
|
177
|
+
export async function removeLocalRoot(root) {
|
|
178
|
+
const heartbeat = heartbeats.get(root);
|
|
179
|
+
if (heartbeat !== undefined) {
|
|
180
|
+
clearInterval(heartbeat.timer);
|
|
181
|
+
heartbeats.delete(root);
|
|
182
|
+
}
|
|
183
|
+
await rm(root, { recursive: true, force: true });
|
|
184
|
+
}
|
package/dist/default-model.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { ModelsConfig, RoleTarget } from "./types.js";
|
|
9
9
|
/** core 对 `default` 角色的档语义:从它的缺省档 `pro` 起**只向低档**扫 —— pro → flash → lite,绝不向上抓 max / ultra
|
|
10
|
-
* (core `ROLE_TIER_DEFAULTS.default` + `DEFAULT_TIER_ORDER
|
|
10
|
+
* (core `ROLE_TIER_DEFAULTS.default` + `DEFAULT_TIER_ORDER`;档序只在 `./tier-words` 一处,这里按起点派生;对拍钉对 devDependency 里精确钉住的那一版 core)。 */
|
|
11
11
|
export declare const DEFAULT_ROLE_TIER_LADDER: readonly ["pro", "flash", "lite"];
|
|
12
12
|
/** 命名链每一级的来源词(闭集)。 */
|
|
13
13
|
export type CatalogDefaultSource = "roles.default" | `tierGroup.${(typeof DEFAULT_ROLE_TIER_LADDER)[number]}` | "fallback";
|
package/dist/default-model.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { resolveActiveTiers } from "./config-fns.js";
|
|
2
|
+
import { tierLadderFrom } from "./tier-words.js";
|
|
2
3
|
/** core 对 `default` 角色的档语义:从它的缺省档 `pro` 起**只向低档**扫 —— pro → flash → lite,绝不向上抓 max / ultra
|
|
3
|
-
* (core `ROLE_TIER_DEFAULTS.default` + `DEFAULT_TIER_ORDER
|
|
4
|
-
export const DEFAULT_ROLE_TIER_LADDER =
|
|
4
|
+
* (core `ROLE_TIER_DEFAULTS.default` + `DEFAULT_TIER_ORDER`;档序只在 `./tier-words` 一处,这里按起点派生;对拍钉对 devDependency 里精确钉住的那一版 core)。 */
|
|
5
|
+
export const DEFAULT_ROLE_TIER_LADDER = tierLadderFrom("pro");
|
|
5
6
|
/**
|
|
6
7
|
* 缺省模型由 `roles.default` 的形分两路(一条规则:点名的走命名链,不点名的交回引擎):
|
|
7
8
|
* · `{select}` ⇒ `{ kind: "select", select }`(见 {@link CatalogDefaultModel});
|
|
@@ -13,8 +14,8 @@ export function resolveCatalogDefaultModel(models, has, fallback) {
|
|
|
13
14
|
const target = models.roles?.default;
|
|
14
15
|
const named = target !== undefined && "model" in target ? target.model : undefined;
|
|
15
16
|
const select = target !== undefined && "select" in target ? target.select : undefined;
|
|
16
|
-
//
|
|
17
|
-
//
|
|
17
|
+
// 判序按真值判:`model` 为非空串 ⇒ 按名;否则 `select` 为真 ⇒ 按属性挑。schema 读过的值只有一形(`model` 是非空串),这一判序只对绕过 schema
|
|
18
|
+
// 递进来的空串 `model` 起作用 —— 本包把它读作没写;引擎(devDependency 钉的那一版)按在场判、空串按名拒(`unknown_model_ref`),这一形两边不同答。
|
|
18
19
|
if (!named && select)
|
|
19
20
|
return { kind: "select", select };
|
|
20
21
|
const tiers = resolveActiveTiers(models) ?? {};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { type EnvModelsDecision } from "./config-root-documents.js";
|
|
2
|
+
import { SUMMARIZE_MODEL_MISSING_CODE } from "./fleet.js";
|
|
3
|
+
import type { ModelRole } from "./types.js";
|
|
4
|
+
/** 一份 env 快照(`process.env` 也满足这个形)。 */
|
|
5
|
+
export type EnvSnapshot = Readonly<Record<string, string | undefined>>;
|
|
6
|
+
/** env 模型表的键名(闭集)。 */
|
|
7
|
+
export declare const ENV_MODEL_TABLE_KEYS: {
|
|
8
|
+
readonly mainId: "MODEL_ID";
|
|
9
|
+
readonly cheapId: "MODEL_CHEAP_ID";
|
|
10
|
+
readonly verifier: "MODEL_VERIFIER";
|
|
11
|
+
readonly codeRoles: "MODEL_CODE_ROLES";
|
|
12
|
+
};
|
|
13
|
+
/** env 模型表的一把键名。 */
|
|
14
|
+
export type EnvModelTableKey = (typeof ENV_MODEL_TABLE_KEYS)[keyof typeof ENV_MODEL_TABLE_KEYS];
|
|
15
|
+
/** env 表里主模型的别名键(角色底的 `default` 绑它;引擎 env 表的条目键 = 这个别名 + 主 id + cheap id)。 */
|
|
16
|
+
export declare const DEFAULT_MODEL_KEY: "default";
|
|
17
|
+
/**
|
|
18
|
+
* env 表的 **cheap 槽**铸不铸(闭集四态,消费方穷举 `switch`;{@link mintedCheapId}):
|
|
19
|
+
* · `absent` —— 没设(或空串);
|
|
20
|
+
* · `without_main` —— 设了而主槽没设:cheap 槽是主模型「同网关换个 id」的形,没有主槽就没有它 ⇒ 不铸;
|
|
21
|
+
* · `same_as_main` —— 与主 id 相同:说的就是主模型 ⇒ 不铸;
|
|
22
|
+
* · `minted` —— 铸(`id` = `MODEL_CHEAP_ID`)。
|
|
23
|
+
*/
|
|
24
|
+
export type EnvCheapSlot = {
|
|
25
|
+
readonly kind: "absent";
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: "without_main" | "same_as_main" | "minted";
|
|
28
|
+
readonly id: string;
|
|
29
|
+
};
|
|
30
|
+
/** cheap 槽铸出来的 id(不铸 ⇒ `undefined`)。 */
|
|
31
|
+
export declare function mintedCheapId(slot: EnvCheapSlot): string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* 包内(不进包根):把 env 模型表的键(缺省四把;调用方只要两槽时只拍两把)**读一次**进一份快照(下标读,同「读到即算」)。计划助手与一次性迁移在**要用的那一刻**拍、
|
|
34
|
+
* 此后的每个读法都答自同一份 —— 一只 getter 两次读答两个值时,判「env 表已声明」与列 env 模型不会各读到一个(那样结局与回执自相矛盾);用不到的键不读。
|
|
35
|
+
*/
|
|
36
|
+
export declare function snapshotEnvModelTable(env: EnvSnapshot, keys?: readonly EnvModelTableKey[]): EnvSnapshot;
|
|
37
|
+
/** env 两槽的**唯一读口**(见文件头)。`mainId` = `MODEL_ID`(空串 = 没有主槽)。 */
|
|
38
|
+
export declare function readEnvModelSlots(env: EnvSnapshot): {
|
|
39
|
+
readonly mainId: string;
|
|
40
|
+
readonly cheapSlot: EnvCheapSlot;
|
|
41
|
+
};
|
|
42
|
+
/** **env 模型表已声明吗**(= `MODEL_ID` 在场且非空串;纯空白算在场)—— 引擎一侧的顶替闸(今天还不强制)与计划助手共用的那一只谓词。 */
|
|
43
|
+
export declare function envModelTableDeclared(env: EnvSnapshot): boolean;
|
|
44
|
+
/** env 表里的一条模型(槽序 main → cheap)。 */
|
|
45
|
+
export interface EnvModelTableEntry {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly slot: "main" | "cheap";
|
|
48
|
+
readonly env: typeof ENV_MODEL_TABLE_KEYS.mainId | typeof ENV_MODEL_TABLE_KEYS.cheapId;
|
|
49
|
+
}
|
|
50
|
+
/** env 底的一个角色绑定:env 表键(别名 `default`、主 id 或 cheap id),或带开发模式提示词标记的同一个键(`MODEL_CODE_ROLES`)。 */
|
|
51
|
+
export type EnvRoleBinding = string | {
|
|
52
|
+
readonly model: string;
|
|
53
|
+
readonly promptRole: "code";
|
|
54
|
+
};
|
|
55
|
+
/** env 模型表铸出来的全部事实(纯数据)。 */
|
|
56
|
+
export interface EnvModelBaseline {
|
|
57
|
+
readonly entries: readonly EnvModelTableEntry[];
|
|
58
|
+
/** 角色 → env 表键(键序 verifier → default → subagent → summarize)。 */
|
|
59
|
+
readonly roles: Readonly<Partial<Record<ModelRole, EnvRoleBinding>>>;
|
|
60
|
+
/** `MODEL_VERIFIER` 的读数:未设 / 指 env 表里的一个键 / 指表外(⇒ verifier 落 `default`,由消费方告警)。 */
|
|
61
|
+
readonly verifier: {
|
|
62
|
+
readonly kind: "unset";
|
|
63
|
+
} | {
|
|
64
|
+
readonly kind: "bound";
|
|
65
|
+
readonly ref: string;
|
|
66
|
+
} | {
|
|
67
|
+
readonly kind: "rejected";
|
|
68
|
+
readonly raw: string;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* env 模型表的**角色底**(纯数据):条目(主槽在 ⇒ 主;cheap 槽铸了 ⇒ cheap)、角色(verifier ⇒ `MODEL_VERIFIER` 指 env 表里的键时是它、否则 `default`;
|
|
73
|
+
* 主角色族(`ROSTER_PRIMARY_ROLES`)⇒ `default`;cheap 角色族(`ROSTER_CHEAP_ROLES`)⇒ cheap id,cheap 没铸 ⇒ `default`;`MODEL_CODE_ROLES` 点名的、角色底自己有的键
|
|
74
|
+
* 加开发模式提示词标记)。env 表键 = 别名 `default` + 主 id + cheap id(主槽不在 ⇒ 表空)。
|
|
75
|
+
*/
|
|
76
|
+
export declare function buildEnvModelBaseline(env: EnvSnapshot): EnvModelBaseline;
|
|
77
|
+
/** 目标引擎读哪条配置腿:`local` = 读配置根的 `config.d`;`remote` = 读中心;`none` = 纯 env 部署,不读 `config.d`。 */
|
|
78
|
+
export type EnvModelTableTarget = "local" | "remote" | "none";
|
|
79
|
+
/** 一个落点:目录条目的名与它的上游 id。 */
|
|
80
|
+
export interface EnvModelTableLanding {
|
|
81
|
+
readonly name: string;
|
|
82
|
+
readonly id: string;
|
|
83
|
+
}
|
|
84
|
+
/** 回执的一个角色行。`before.id = null` ⇒ env 表空,这个键在写前不解到任何 env 模型。 */
|
|
85
|
+
export interface EnvModelTableRoleRow {
|
|
86
|
+
readonly role: string;
|
|
87
|
+
readonly before: {
|
|
88
|
+
readonly ref: string;
|
|
89
|
+
readonly id: string | null;
|
|
90
|
+
};
|
|
91
|
+
readonly promptRole?: "code";
|
|
92
|
+
/** `candidate_binds` = 候选自己的 `roles` 绑了这个角色({select} ⇒ `landing: null`);`catalog_default` = 落到目录缺省(env 底绑别名 `default`,或 `default` 角色本身
|
|
93
|
+
* —— 它恒落在缺省解析器的答案上;候选把它绑到停用条目时解析器落到下一级;{select} 缺省 ⇒ `null`);
|
|
94
|
+
* `candidate_entry` = env 底绑的名在候选目录里解得到(一条启用条目的名,或经活跃档位组展开的档位词 / 别名);`dangles` = 落不到任何启用条目。 */
|
|
95
|
+
readonly after: {
|
|
96
|
+
readonly kind: "candidate_binds" | "catalog_default" | "candidate_entry" | "dangles";
|
|
97
|
+
readonly landing: EnvModelTableLanding | null;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** 回执(纯数据;确认键 = 它的定序 JSON)。 */
|
|
101
|
+
export interface EnvModelTableReceipt {
|
|
102
|
+
/** 配置根的决定读数(`readEnvModelsDecision` 的 `kind`):读不懂的会在确认后被整份覆盖。 */
|
|
103
|
+
readonly decision: EnvModelsDecision["kind"];
|
|
104
|
+
/** 退场的 env 条目(槽序);`servedAs` = 候选里同 id 的启用条目名。 */
|
|
105
|
+
readonly envModels: readonly (EnvModelTableEntry & {
|
|
106
|
+
readonly servedAs: readonly string[];
|
|
107
|
+
})[];
|
|
108
|
+
readonly roles: readonly EnvModelTableRoleRow[];
|
|
109
|
+
}
|
|
110
|
+
/** 调用方带回的确认:上一次 `confirm` 给的键。 */
|
|
111
|
+
export interface EnvModelTableWriteIntent {
|
|
112
|
+
readonly kind: "replace";
|
|
113
|
+
readonly confirmKey: string;
|
|
114
|
+
}
|
|
115
|
+
export interface EnvModelTableWriteInput {
|
|
116
|
+
/** 目标引擎起服看到的 env(只用于预测与回执;引擎用它自己的 env 复判)。 */
|
|
117
|
+
readonly env: EnvSnapshot;
|
|
118
|
+
readonly target: EnvModelTableTarget;
|
|
119
|
+
/** 配置根上的决定读数(`readEnvModelsDecision` 的答案)。 */
|
|
120
|
+
readonly decision: EnvModelsDecision;
|
|
121
|
+
/** 将要原子写入的完整 models 文档。 */
|
|
122
|
+
readonly candidate: unknown;
|
|
123
|
+
readonly intent?: EnvModelTableWriteIntent;
|
|
124
|
+
}
|
|
125
|
+
/** 计划(闭集;写者穷举 `switch`)。 */
|
|
126
|
+
export type EnvModelTableWritePlan = {
|
|
127
|
+
readonly kind: "write";
|
|
128
|
+
readonly why: "not_read" | "not_managing" | "env_table_empty";
|
|
129
|
+
} | {
|
|
130
|
+
readonly kind: "write";
|
|
131
|
+
readonly why: "acknowledged";
|
|
132
|
+
readonly receipt: EnvModelTableReceipt;
|
|
133
|
+
}
|
|
134
|
+
/** 先写 `config.env-models.json = replaced`(`writeEnvModelsDecision(root, "replaced", { write: "overwrite" })`),再写 models.json。 */
|
|
135
|
+
| {
|
|
136
|
+
readonly kind: "write_with_decision";
|
|
137
|
+
readonly receipt: EnvModelTableReceipt;
|
|
138
|
+
}
|
|
139
|
+
/** 把回执给用户看;用户确认 ⇒ 带 `{ kind: "replace", confirmKey }` 再调一次。`intent`:没带过键 `absent` / 带的键已过期 `stale`。 */
|
|
140
|
+
| {
|
|
141
|
+
readonly kind: "confirm";
|
|
142
|
+
readonly receipt: EnvModelTableReceipt;
|
|
143
|
+
readonly confirmKey: string;
|
|
144
|
+
readonly intent: "absent" | "stale";
|
|
145
|
+
}
|
|
146
|
+
/** 不可确认:先改文档(summarize 要落到一条启用条目)。 */
|
|
147
|
+
| {
|
|
148
|
+
readonly kind: "fix_first";
|
|
149
|
+
readonly code: typeof SUMMARIZE_MODEL_MISSING_CODE;
|
|
150
|
+
readonly receipt: EnvModelTableReceipt;
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* 写者在写 `config.d/models.json` 之前调的**计划**(判序见文件头)。纯函数:不读环境、不读盘、不改入参。
|
|
154
|
+
* 目标腿 / 决定读数不在闭集 ⇒ `TypeError`;候选读不出(撤销的 Proxy 等)或整份读不进 models 域(重名、悬空的角色名 …)⇒ `TypeError`(先修文档)。
|
|
155
|
+
*/
|
|
156
|
+
export declare function planEnvModelTableWrite(input: EnvModelTableWriteInput): EnvModelTableWritePlan;
|
|
157
|
+
/**
|
|
158
|
+
* 计划停下来的那两种结局(`confirm` / `fix_first`)的**一句话**(三端同一句;只用回执里的 env 键名、目录名与模型 id —— 可披露的原样,秘密形的打成记号;凭据与别的 env 值从不进句子)。
|
|
159
|
+
* 写类结局不是拒 ⇒ `TypeError`。
|
|
160
|
+
*/
|
|
161
|
+
export declare function envModelTableWriteRefusal(plan: Extract<EnvModelTableWritePlan, {
|
|
162
|
+
readonly kind: "confirm" | "fix_first";
|
|
163
|
+
}>): string;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* env 模型表 —— `MODEL_ID` / `MODEL_CHEAP_ID` 两槽与它们铸出的角色底 —— 的**一份读法**,与写者在写 `config.d/models.json` 之前调的**计划助手**
|
|
3
|
+
* (纯、零 I/O、浏览器安全;读配置根上的决定文档的函数在 `./node`)。
|
|
4
|
+
*
|
|
5
|
+
* ## 律
|
|
6
|
+
* · **env 两槽只在这里读一次**({@link readEnvModelSlots}):`MODEL_ID` 缺席或空串 = 没有主槽(纯空白算在场 —— 与引擎的 env 路由读口同尺);
|
|
7
|
+
* cheap 槽四态({@link EnvCheapSlot})。「env 模型表已声明」({@link envModelTableDeclared})与角色底({@link buildEnvModelBaseline})都由它派生 ——
|
|
8
|
+
* 这道闸的执行方(引擎一侧;今天引擎还不强制)与写者的计划助手调同一份(一次性迁移 SS-M11 只用两槽点名被顶替的 env 模型,铸不铸不看它),不各写一份。env 快照按「读到即算」(下标读;原型分层的快照里继承来的键算在场)。
|
|
9
|
+
* 引擎另有「名册占位 ⇒ env 表算空」一格,那是引擎自己的部署事实,不在本包。
|
|
10
|
+
* · **计划助手判序**({@link planEnvModelTableWrite}):目标腿不读 `config.d`(remote / none)⇒ 照写 → 候选不管模型面 ⇒ 照写 → summarize 会悬空 ⇒
|
|
11
|
+
* `fix_first`(不可确认:每个任务都要解 summarize,采纳后任务全拒)→ env 表未声明 ⇒ 照写、**不写确认** → 配置根的决定是 `replaced` ⇒ 照写 →
|
|
12
|
+
* 调用方带回的确认键等于这一次的 ⇒ `write_with_decision`(先经 `writeEnvModelsDecision` 写 `replaced`,再写目录;两次写在写者对 models.json 的同一把锁里)→
|
|
13
|
+
* 否则 `confirm`(把回执给用户看)。执法在引擎一侧(本地腿的顶替闸;今天引擎不拒这个组),本助手只是让写者在写之前知道这道闸强制起来会不会拒、会顶掉什么。
|
|
14
|
+
* · **回执按 id**:每个退场的 env 条目带 `servedAs`(候选里同 id 的启用条目名 —— 上游模型仍被服务,形可能不同);每个 env 底角色带写前绑谁
|
|
15
|
+
* (env 表键 + 它解到的 id)与采纳后落到哪个条目(`{name, id}`)。缺省落点经 `resolveCatalogDefaultModel`(与引擎的 stage 同一只解析器);
|
|
16
|
+
* 绑到停用条目或目录外的名 = `dangles`。确认键 = 回执的定序 JSON —— 后果(落点名 / id、env 槽、决定读数)一变,旧键就过期。
|
|
17
|
+
* · 可用性(引擎的 brain 腿、端点、读腿逐条丢)计划判不了;决定性的 summarize 那一格由引擎的不变量兜。
|
|
18
|
+
*/
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import { entryEnabledFlag, catalogManagesModelPlane } from "./catalog-manages.js";
|
|
21
|
+
import { parseDomainSalvaged, isDisclosableIdentity, resolveActiveTiers, withheldNameToken } from "./config-fns.js";
|
|
22
|
+
import { ENV_MODELS_DOCUMENT } from "./config-root-documents.js";
|
|
23
|
+
import { resolveCatalogDefaultModel } from "./default-model.js";
|
|
24
|
+
import { SUMMARIZE_MODEL_MISSING_CODE } from "./fleet.js";
|
|
25
|
+
import { landCatalogWord } from "./tier-words.js";
|
|
26
|
+
import { stableStringify } from "./hash.js";
|
|
27
|
+
import { captureJson, dataDescriptor, isolatedObject } from "./own-key-read.js";
|
|
28
|
+
import { ROSTER_CHEAP_ROLES, ROSTER_PRIMARY_ROLES } from "./resolve-roster.js";
|
|
29
|
+
/** env 模型表的键名(闭集)。 */
|
|
30
|
+
export const ENV_MODEL_TABLE_KEYS = { mainId: "MODEL_ID", cheapId: "MODEL_CHEAP_ID", verifier: "MODEL_VERIFIER", codeRoles: "MODEL_CODE_ROLES" };
|
|
31
|
+
/** env 表里主模型的别名键(角色底的 `default` 绑它;引擎 env 表的条目键 = 这个别名 + 主 id + cheap id)。 */
|
|
32
|
+
export const DEFAULT_MODEL_KEY = "default";
|
|
33
|
+
/** cheap 槽铸出来的 id(不铸 ⇒ `undefined`)。 */
|
|
34
|
+
export function mintedCheapId(slot) {
|
|
35
|
+
switch (slot.kind) {
|
|
36
|
+
case "minted":
|
|
37
|
+
return slot.id;
|
|
38
|
+
case "absent":
|
|
39
|
+
case "without_main":
|
|
40
|
+
case "same_as_main":
|
|
41
|
+
return undefined;
|
|
42
|
+
default: {
|
|
43
|
+
const unhandled = slot;
|
|
44
|
+
throw new Error(`mintedCheapId: unhandled slot ${JSON.stringify(unhandled)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 包内(不进包根):把 env 模型表的键(缺省四把;调用方只要两槽时只拍两把)**读一次**进一份快照(下标读,同「读到即算」)。计划助手与一次性迁移在**要用的那一刻**拍、
|
|
50
|
+
* 此后的每个读法都答自同一份 —— 一只 getter 两次读答两个值时,判「env 表已声明」与列 env 模型不会各读到一个(那样结局与回执自相矛盾);用不到的键不读。
|
|
51
|
+
*/
|
|
52
|
+
export function snapshotEnvModelTable(env, keys = Object.values(ENV_MODEL_TABLE_KEYS)) {
|
|
53
|
+
const snapshot = isolatedObject(); // the package's copy container (no inherited members to read)
|
|
54
|
+
for (const key of keys)
|
|
55
|
+
Object.defineProperty(snapshot, key, dataDescriptor(env[key]));
|
|
56
|
+
return snapshot;
|
|
57
|
+
}
|
|
58
|
+
/** env 两槽的**唯一读口**(见文件头)。`mainId` = `MODEL_ID`(空串 = 没有主槽)。 */
|
|
59
|
+
export function readEnvModelSlots(env) {
|
|
60
|
+
const mainId = env[ENV_MODEL_TABLE_KEYS.mainId] ?? "";
|
|
61
|
+
const rawCheapId = env[ENV_MODEL_TABLE_KEYS.cheapId];
|
|
62
|
+
const cheapSlot = !rawCheapId
|
|
63
|
+
? { kind: "absent" }
|
|
64
|
+
: mainId === ""
|
|
65
|
+
? { kind: "without_main", id: rawCheapId }
|
|
66
|
+
: rawCheapId === mainId
|
|
67
|
+
? { kind: "same_as_main", id: rawCheapId }
|
|
68
|
+
: { kind: "minted", id: rawCheapId };
|
|
69
|
+
return { mainId, cheapSlot };
|
|
70
|
+
}
|
|
71
|
+
/** **env 模型表已声明吗**(= `MODEL_ID` 在场且非空串;纯空白算在场)—— 引擎一侧的顶替闸(今天还不强制)与计划助手共用的那一只谓词。 */
|
|
72
|
+
export function envModelTableDeclared(env) {
|
|
73
|
+
return readEnvModelSlots(env).mainId !== "";
|
|
74
|
+
}
|
|
75
|
+
/** 逗号串:逐项 trim、丢空项。 */
|
|
76
|
+
const csv = (raw) => (raw ?? "")
|
|
77
|
+
.split(",")
|
|
78
|
+
.map((s) => s.trim())
|
|
79
|
+
.filter(Boolean);
|
|
80
|
+
/**
|
|
81
|
+
* env 模型表的**角色底**(纯数据):条目(主槽在 ⇒ 主;cheap 槽铸了 ⇒ cheap)、角色(verifier ⇒ `MODEL_VERIFIER` 指 env 表里的键时是它、否则 `default`;
|
|
82
|
+
* 主角色族(`ROSTER_PRIMARY_ROLES`)⇒ `default`;cheap 角色族(`ROSTER_CHEAP_ROLES`)⇒ cheap id,cheap 没铸 ⇒ `default`;`MODEL_CODE_ROLES` 点名的、角色底自己有的键
|
|
83
|
+
* 加开发模式提示词标记)。env 表键 = 别名 `default` + 主 id + cheap id(主槽不在 ⇒ 表空)。
|
|
84
|
+
*/
|
|
85
|
+
export function buildEnvModelBaseline(env) {
|
|
86
|
+
const { mainId, cheapSlot } = readEnvModelSlots(env);
|
|
87
|
+
const cheapId = mintedCheapId(cheapSlot);
|
|
88
|
+
const entries = mainId === "" ? [] : [{ id: mainId, slot: "main", env: ENV_MODEL_TABLE_KEYS.mainId }, ...(cheapId === undefined ? [] : [{ id: cheapId, slot: "cheap", env: ENV_MODEL_TABLE_KEYS.cheapId }])];
|
|
89
|
+
const tableKeys = new Set(mainId === "" ? [] : [DEFAULT_MODEL_KEY, mainId, ...(cheapId === undefined ? [] : [cheapId])]);
|
|
90
|
+
const rawVerifier = env[ENV_MODEL_TABLE_KEYS.verifier];
|
|
91
|
+
const verifier = !rawVerifier ? { kind: "unset" } : tableKeys.has(rawVerifier) ? { kind: "bound", ref: rawVerifier } : { kind: "rejected", raw: rawVerifier };
|
|
92
|
+
const roles = new Map([["verifier", verifier.kind === "bound" ? verifier.ref : DEFAULT_MODEL_KEY]]);
|
|
93
|
+
for (const r of ROSTER_PRIMARY_ROLES)
|
|
94
|
+
roles.set(r, DEFAULT_MODEL_KEY);
|
|
95
|
+
for (const r of ROSTER_CHEAP_ROLES)
|
|
96
|
+
roles.set(r, cheapId ?? DEFAULT_MODEL_KEY);
|
|
97
|
+
for (const name of csv(env[ENV_MODEL_TABLE_KEYS.codeRoles])) {
|
|
98
|
+
const role = name;
|
|
99
|
+
const cur = roles.get(role);
|
|
100
|
+
if (cur === undefined)
|
|
101
|
+
continue; // 只装饰角色底自己有的键(Map 没有原型上的名字)
|
|
102
|
+
roles.set(role, { model: typeof cur === "string" ? cur : cur.model, promptRole: "code" });
|
|
103
|
+
}
|
|
104
|
+
return { entries, roles: Object.fromEntries(roles), verifier };
|
|
105
|
+
}
|
|
106
|
+
const ENV_MODEL_TABLE_TARGETS = ["local", "remote", "none"];
|
|
107
|
+
const decisionKindOf = (decision) => {
|
|
108
|
+
const kind = decision?.kind;
|
|
109
|
+
switch (kind) {
|
|
110
|
+
case "absent":
|
|
111
|
+
case "replaced":
|
|
112
|
+
case "kept":
|
|
113
|
+
case "unreadable":
|
|
114
|
+
return kind;
|
|
115
|
+
default:
|
|
116
|
+
throw new TypeError(`planEnvModelTableWrite: decision must be a readEnvModelsDecision answer (got kind ${JSON.stringify(kind)})`);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
/** 候选按引擎本地读腿的视图读(逐条抢救;整份读不进 ⇒ 抛 TypeError —— 引擎读不进的文档,本助手不替它算回执,先修文档)。 */
|
|
120
|
+
function candidateView(candidate) {
|
|
121
|
+
try {
|
|
122
|
+
return parseDomainSalvaged("models", candidate);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
const first = e instanceof z.ZodError ? e.issues[0] : undefined;
|
|
126
|
+
const why = first === undefined ? (e instanceof Error ? e.message : String(e)) : `${first.path.join(".") || "(document)"}: ${first.message}`;
|
|
127
|
+
throw new TypeError(`planEnvModelTableWrite: the candidate does not read as a models document — fix it first (${why})`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function buildReceipt(baseline, view, decision) {
|
|
131
|
+
const enabled = view.models.filter((m) => entryEnabledFlag(m) !== false).map((m) => ({ name: m.name, id: m.id }));
|
|
132
|
+
const byName = new Map(enabled.map((e) => [e.name, e]));
|
|
133
|
+
const tiers = resolveActiveTiers(view);
|
|
134
|
+
/** 一个名采纳后落到哪条:经与引擎同一张档位展开(目录键先赢;env 底继承来的 `flash` 一类档位词按活跃组解)。 */
|
|
135
|
+
const land = (word) => {
|
|
136
|
+
const name = landCatalogWord(word, (n) => byName.has(n), tiers);
|
|
137
|
+
const e = name === undefined ? undefined : byName.get(name);
|
|
138
|
+
return e === undefined ? undefined : { name: e.name, id: e.id };
|
|
139
|
+
};
|
|
140
|
+
const main = baseline.entries.find((e) => e.slot === "main");
|
|
141
|
+
const idOf = (ref) => (ref === DEFAULT_MODEL_KEY ? (main?.id ?? null) : (baseline.entries.find((e) => e.id === ref)?.id ?? null));
|
|
142
|
+
/** 目录缺省的落点 —— 恒是 `resolveCatalogDefaultModel` 的答案(与引擎的 stage 同一只解析器;它的第一级就是候选自己的 `roles.default`,点名停用条目时它落到下一级)。
|
|
143
|
+
* `bound` = 候选自己绑了 `default`:解析器用的正是那一级 ⇒ `candidate_binds`,否则 `catalog_default`。 */
|
|
144
|
+
const viaCatalogDefault = (bound) => {
|
|
145
|
+
const fallback = enabled[0];
|
|
146
|
+
if (fallback === undefined)
|
|
147
|
+
return { kind: "dangles", landing: null };
|
|
148
|
+
const d = resolveCatalogDefaultModel(view, (n) => byName.has(n), fallback.name);
|
|
149
|
+
if (d.kind === "select")
|
|
150
|
+
return { kind: bound ? "candidate_binds" : "catalog_default", landing: null };
|
|
151
|
+
const l = land(d.name);
|
|
152
|
+
if (l === undefined)
|
|
153
|
+
return { kind: "dangles", landing: null };
|
|
154
|
+
return { kind: bound && d.source === "roles.default" ? "candidate_binds" : "catalog_default", landing: l };
|
|
155
|
+
};
|
|
156
|
+
const afterOf = (role, ref) => {
|
|
157
|
+
const bound = Object.hasOwn(view.roles, role) ? view.roles[role] : undefined;
|
|
158
|
+
if (role === DEFAULT_MODEL_KEY)
|
|
159
|
+
return viaCatalogDefault(bound !== undefined); // the default role lands where the catalog default resolves, bound or not
|
|
160
|
+
if (bound !== undefined) {
|
|
161
|
+
if (!("model" in bound))
|
|
162
|
+
return { kind: "candidate_binds", landing: null };
|
|
163
|
+
const l = land(bound.model);
|
|
164
|
+
return l === undefined ? { kind: "dangles", landing: null } : { kind: "candidate_binds", landing: l };
|
|
165
|
+
}
|
|
166
|
+
if (ref === DEFAULT_MODEL_KEY)
|
|
167
|
+
return viaCatalogDefault(false);
|
|
168
|
+
const l = land(ref);
|
|
169
|
+
return l === undefined ? { kind: "dangles", landing: null } : { kind: "candidate_entry", landing: l };
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
decision,
|
|
173
|
+
envModels: baseline.entries.map((e) => ({ id: e.id, slot: e.slot, env: e.env, servedAs: enabled.filter((c) => c.id === e.id).map((c) => c.name) })),
|
|
174
|
+
roles: Object.entries(baseline.roles).map(([role, spec]) => {
|
|
175
|
+
const ref = typeof spec === "string" ? spec : spec.model;
|
|
176
|
+
const promptRole = typeof spec === "string" ? undefined : spec.promptRole;
|
|
177
|
+
return { role, before: { ref, id: idOf(ref) }, ...(promptRole === undefined ? {} : { promptRole }), after: afterOf(role, ref) };
|
|
178
|
+
}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* 写者在写 `config.d/models.json` 之前调的**计划**(判序见文件头)。纯函数:不读环境、不读盘、不改入参。
|
|
183
|
+
* 目标腿 / 决定读数不在闭集 ⇒ `TypeError`;候选读不出(撤销的 Proxy 等)或整份读不进 models 域(重名、悬空的角色名 …)⇒ `TypeError`(先修文档)。
|
|
184
|
+
*/
|
|
185
|
+
export function planEnvModelTableWrite(input) {
|
|
186
|
+
const decision = decisionKindOf(input.decision);
|
|
187
|
+
const target = input.target;
|
|
188
|
+
if (!ENV_MODEL_TABLE_TARGETS.includes(target))
|
|
189
|
+
throw new TypeError(`planEnvModelTableWrite: target must be one of ${ENV_MODEL_TABLE_TARGETS.join(" / ")} (got ${JSON.stringify(target)})`);
|
|
190
|
+
switch (target) {
|
|
191
|
+
case "remote":
|
|
192
|
+
case "none":
|
|
193
|
+
return { kind: "write", why: "not_read" };
|
|
194
|
+
case "local":
|
|
195
|
+
break;
|
|
196
|
+
default: {
|
|
197
|
+
const unhandled = target;
|
|
198
|
+
throw new TypeError(`planEnvModelTableWrite: unhandled target ${JSON.stringify(unhandled)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const copy = captureJson(input.candidate);
|
|
202
|
+
if (!copy.ok)
|
|
203
|
+
throw new TypeError(`planEnvModelTableWrite: the candidate could not be read — ${copy.sentence}`);
|
|
204
|
+
if (!catalogManagesModelPlane(copy.value))
|
|
205
|
+
return { kind: "write", why: "not_managing" };
|
|
206
|
+
const env = snapshotEnvModelTable(input.env); // read once: the receipt and the env-table rule answer from the same reading
|
|
207
|
+
const receipt = buildReceipt(buildEnvModelBaseline(env), candidateView(copy.value), decision);
|
|
208
|
+
if (receipt.roles.find((r) => r.role === "summarize")?.after.kind === "dangles")
|
|
209
|
+
return { kind: "fix_first", code: SUMMARIZE_MODEL_MISSING_CODE, receipt };
|
|
210
|
+
if (!envModelTableDeclared(env))
|
|
211
|
+
return { kind: "write", why: "env_table_empty" };
|
|
212
|
+
if (decision === "replaced")
|
|
213
|
+
return { kind: "write", why: "acknowledged", receipt };
|
|
214
|
+
const confirmKey = stableStringify(receipt);
|
|
215
|
+
const intent = input.intent;
|
|
216
|
+
if (intent !== undefined && intent.kind === "replace" && intent.confirmKey === confirmKey)
|
|
217
|
+
return { kind: "write_with_decision", receipt };
|
|
218
|
+
return { kind: "confirm", receipt, confirmKey, intent: intent === undefined ? "absent" : "stale" };
|
|
219
|
+
}
|
|
220
|
+
// ── 拒句投影 ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
221
|
+
/** 句子里的一个名 / id:可披露 ⇒ 原样(加引号);否则 ⇒ 不可逆记号(秘密形不出门)。 */
|
|
222
|
+
const shown = (v) => (isDisclosableIdentity(v) ? JSON.stringify(v) : withheldNameToken(v));
|
|
223
|
+
const landingText = (after) => {
|
|
224
|
+
if (after.landing !== null)
|
|
225
|
+
return `${shown(after.landing.name)} (id ${shown(after.landing.id)})`;
|
|
226
|
+
return after.kind === "dangles" ? "nothing (no enabled catalog entry)" : "an entry the engine selects ({select})";
|
|
227
|
+
};
|
|
228
|
+
/**
|
|
229
|
+
* 计划停下来的那两种结局(`confirm` / `fix_first`)的**一句话**(三端同一句;只用回执里的 env 键名、目录名与模型 id —— 可披露的原样,秘密形的打成记号;凭据与别的 env 值从不进句子)。
|
|
230
|
+
* 写类结局不是拒 ⇒ `TypeError`。
|
|
231
|
+
*/
|
|
232
|
+
export function envModelTableWriteRefusal(plan) {
|
|
233
|
+
const file = `${ENV_MODELS_DOCUMENT}.json`;
|
|
234
|
+
const kind = plan.kind;
|
|
235
|
+
switch (kind) {
|
|
236
|
+
case "confirm": {
|
|
237
|
+
const r = plan.receipt;
|
|
238
|
+
const models = r.envModels.map((e) => `${e.env} ${shown(e.id)} (${e.slot} slot) ${e.servedAs.length > 0 ? `stays served as ${e.servedAs.map(shown).join(", ")}` : "stops being served"}`).join("; ");
|
|
239
|
+
const roles = r.roles.map((row) => `${row.role} → ${landingText(row.after)}`).join(", ");
|
|
240
|
+
const decision = r.decision === "unreadable"
|
|
241
|
+
? ` The config root's ${file} is unreadable; confirming overwrites it as a whole.`
|
|
242
|
+
: r.decision === "kept"
|
|
243
|
+
? ` The config root's ${file} says "kept"; confirming overwrites it.`
|
|
244
|
+
: "";
|
|
245
|
+
return (`writing this models document replaces the env model table on the local config leg: ${models}; after adoption ${roles}.${decision} ` +
|
|
246
|
+
`To let the catalog replace them, confirm — the writer records ${file} = {"envModels":"replaced"} in the config root first, then writes the models document; to keep the env models, do not write it.`);
|
|
247
|
+
}
|
|
248
|
+
case "fix_first": {
|
|
249
|
+
const row = plan.receipt.roles.find((x) => x.role === "summarize");
|
|
250
|
+
const ref = row === undefined ? "" : ` (env binding ${shown(row.before.ref)})`;
|
|
251
|
+
return (`this models document leaves the summarize role unresolved${ref}: it lands on no enabled catalog entry, so every task would be refused after adoption (${SUMMARIZE_MODEL_MISSING_CODE}). ` +
|
|
252
|
+
"Bind roles.summarize to an enabled entry of the document, or add an enabled entry under the name it points at, then plan the write again.");
|
|
253
|
+
}
|
|
254
|
+
default:
|
|
255
|
+
throw new TypeError(`envModelTableWriteRefusal: only a "confirm" or "fix_first" plan is a refusal (got ${JSON.stringify(kind)})`);
|
|
256
|
+
}
|
|
257
|
+
}
|