@zhushanwen/pi-provider-live-sync 0.2.1 → 0.3.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/README.md +25 -4
- package/package.json +4 -2
- package/skills/provider-live-sync-ext-config/SKILL.md +53 -15
- package/src/__tests__/catalog-bridge.test.ts +891 -0
- package/src/__tests__/model-sync.test.ts +6 -32
- package/src/__tests__/pi-runtime-testkit.ts +57 -0
- package/src/__tests__/pi-semantics-catalog-injection.test.ts +632 -0
- package/src/__tests__/sdk-contract.test.ts +290 -0
- package/src/__tests__/tick-harness.ts +48 -0
- package/src/catalog-bridge.ts +482 -0
- package/src/index.ts +93 -29
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 目录投喂桥(provider-catalog-sync U2)——把 taiji 的模型目录缓存经 pi 官方注册点
|
|
3
|
+
* 注入 pi 注册表,单点修复 8 个同根因症状(rename 静默跳过 / `set_model` 报
|
|
4
|
+
* ENGINE_MODEL_MISSING / subagent 模型校验抛错 / smart-context 压缩回退 /
|
|
5
|
+
* permission 分类器 fail-closed / scheduler 切模型失效 / 对账 config_only 漂移 /
|
|
6
|
+
* spawn `--model` 近似回退)。
|
|
7
|
+
*
|
|
8
|
+
* 职责边界(设计 §3.3 D1/D2/D5):
|
|
9
|
+
* - **注册时机(D1)**:工厂函数体内 `pi.registerProvider(...)`——pi 内部进 pending
|
|
10
|
+
* 队列,装配链在 `--model` 解析之前统一 flush + 本地 refresh(PS-82)。不用
|
|
11
|
+
* `session_start` 钩子:它 emit 于 `createAgentSessionFromServices` 内,晚于
|
|
12
|
+
* `--model` 解析,覆盖不了 spawn 近似回退那个症状。
|
|
13
|
+
* - **注入集(D2)**:「内置基线 ⊕ 有效条目」全量并集,同 id 缓存版本整对象替换
|
|
14
|
+
* 基线版本,数组内去重。有效条目 = taiji 缓存与 pi store 两份经条目级新者胜
|
|
15
|
+
* 合并的结果——两份都读、缺一不可(任一份独有模型若不在注入集内会被挤出视图)。
|
|
16
|
+
* 判据本体在 `@zhushanwen/pi-llm-shared` 的 catalog-overlay(零 IO 纯函数),
|
|
17
|
+
* 本模块只做 IO + 日志 + 注册。
|
|
18
|
+
* - **运行中窗口(D5)**:注册时携带 `refreshModels` 钩子——钩子重读两份缓存
|
|
19
|
+
* (带 mtime 短路:文件未变直接返回上次结果不重算),fresh 返回新并集、
|
|
20
|
+
* expired/never-seen 返回纯内置基线(R6 撤销)、运行期读失败返回 undefined
|
|
21
|
+
* (R7 保留当前注入)。触发方 = provider-live-sync 既有 tick 挂载的第三个
|
|
22
|
+
* 监视条目(`createOverlayCacheWatcher`,绝对路径 + mtime 比较),其基线在
|
|
23
|
+
* **工厂期播种**(`injectCatalogOverlay` 在 runPass 之前捕获 mtime 传入)——
|
|
24
|
+
* 「工厂期注入 → tick 1」约 2s 窗内的缓存变更必须可检出,否则运行中窗口在该
|
|
25
|
+
* 窗口断裂且此后 mtime 稳定即永不重检。钩子只覆盖**已注册**的 provider(pi-ai
|
|
26
|
+
* `ModelsStore.refresh` 只刷新带 `refreshModels` 的 provider),故 tick 检出变化后
|
|
27
|
+
* 先经 `reconcile` 做一次**注册对账**:对本拍 fresh 且尚未注册的 provider 补
|
|
28
|
+
* `registerProvider`(挂同一钩子)——覆盖「工厂期 expired / never-seen / 损坏 →
|
|
29
|
+
* 运行中变 fresh」的跃迁;工厂返回后的注册直连注册表立即生效、无需 /reload(PS-88)。
|
|
30
|
+
*
|
|
31
|
+
* 失败路径映射(设计 §3.1 R1-R7,全部 fail-safe 降级不抛错):
|
|
32
|
+
* - R1 无 `TAIJI_AGENT_DATA_DIR`(原生 pi 用户)→ 入口返回 null,零文件访问、零注册;
|
|
33
|
+
* - R2 缓存缺失记 debug / 损坏或不可读记 warn,两分支都按 never-seen 跳过注入;文件
|
|
34
|
+
* 恢复后下一拍 tick 的注册对账重建注入(恢复路径不依赖重开会话);
|
|
35
|
+
* - R3 fresh 条目缺 `api`/`baseUrl` 且同 id 无内置定义可继承 → 判据层已滤并回传
|
|
36
|
+
* `skipped`,本模块负责 warn 日志(保守档:只认同 id 继承,不从任意同 provider
|
|
37
|
+
* 模型继承——兜底继承到的端点语义未经上游目录声明);
|
|
38
|
+
* - R4 `registerProvider` 抛错(结构校验失败)→ 捕获 + error 日志(含 pi 原文与
|
|
39
|
+
* 恢复动作)+ 不阻断其他 provider 的注入;失败的 provider 不入已注册集,缓存下次
|
|
40
|
+
* 重写后的对账自动重试;
|
|
41
|
+
* - R5 expired(含远端 404/501 落盘的否定信号)与 never-seen 不注入 + debug;
|
|
42
|
+
* - R6 钩子对非 fresh 态返回纯内置基线(等价撤销注入);
|
|
43
|
+
* - R7 钩子运行期任一份缓存损坏/不可读 → warn + 返回 undefined。
|
|
44
|
+
*
|
|
45
|
+
* R2 与 R7 对「损坏」的处置**有意不同**:注册侧(工厂期与运行中对账)对尚未注册的
|
|
46
|
+
* provider 没有已注入的集合可保,损坏即降级为 never-seen(不注入);已注册的 provider
|
|
47
|
+
* 在运行期有注入在,损坏按 R7 由钩子保留最后一份好快照。同一份文件对两类 provider 的
|
|
48
|
+
* 风险面不同,不是双轨判据。
|
|
49
|
+
*
|
|
50
|
+
* 文件读取用同步 API(`readFileSync`/`statSync`):工厂期注册必须发生在**同步的工厂
|
|
51
|
+
* 函数体内**——pi 的 pending 队列在全部工厂返回后统一 flush,异步化会把注册推到
|
|
52
|
+
* flush 之后,D1 的时机前提即失效(运行中对账已过 flush 期,走 post-bind 直连路径,
|
|
53
|
+
* 同步读在此只是沿用);且两份缓存都是 KB 级小文件、读取频率 ≤ 每 2s 一拍,与写入方
|
|
54
|
+
* runtime `provider-catalog-refresh.ts` 的同款同步读一致。
|
|
55
|
+
*
|
|
56
|
+
* 日志通道 = `@zhushanwen/pi-extension-logger` 的 `provider-live-sync` 具名
|
|
57
|
+
* singleton(`getLogger` 按名缓存,与 src/index.ts 是同一实例)——warn/error 落
|
|
58
|
+
* pi.appendEntry 持久化 + `TAIJI_AGENT_DEBUG`/`TAIJI_AGENT_EXT_LOG` 文件日志,
|
|
59
|
+
* 不依赖 pi 不捕获的 extension stderr。
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { readFileSync, statSync } from "node:fs";
|
|
63
|
+
import { basename } from "node:path";
|
|
64
|
+
|
|
65
|
+
import type { ExtensionAPI, ProviderConfig, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
66
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
67
|
+
import {
|
|
68
|
+
computeInjectionSet,
|
|
69
|
+
getPiStorePath,
|
|
70
|
+
getTaijiOverlayCachePath,
|
|
71
|
+
mergeEntriesNewerWins,
|
|
72
|
+
parseOverlayCache,
|
|
73
|
+
type ComputeInjectionSetResult,
|
|
74
|
+
type OverlayEntry,
|
|
75
|
+
type OverlayModel,
|
|
76
|
+
} from "@zhushanwen/pi-llm-shared";
|
|
77
|
+
|
|
78
|
+
const logger = getLogger("provider-live-sync");
|
|
79
|
+
|
|
80
|
+
/** `RefreshModelsContext` 不经 pi 主入口导出,从 `ProviderConfig.refreshModels` 的函数类型反推。 */
|
|
81
|
+
type RefreshModelsContext = Parameters<NonNullable<ProviderConfig["refreshModels"]>>[0];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 本模块 `refreshModels` 钩子的实际返回类型——比 pi 的声明类型宽一档(多 `undefined`)。
|
|
85
|
+
*
|
|
86
|
+
* pi 声明 `Promise<ProviderModelConfig[]>`(`dist/core/extensions/types.d.ts:1403`),
|
|
87
|
+
* 但实装对 falsy 返回值有显式分支:`dist/core/provider-composer.js:388-396` 的
|
|
88
|
+
* `if (refreshed) { ...publish... }`——返回 undefined 即「本次不发布新列表」,注册表
|
|
89
|
+
* 保留当前注入。R7 依赖的正是这个语义(宁可保留最后一份好快照,不因瞬时读失败清空
|
|
90
|
+
* 可见集合),故钩子按实装语义声明,挂到 `ProviderConfig` 上时做一次显式断言。
|
|
91
|
+
*/
|
|
92
|
+
type RefreshModelsFn = (context: RefreshModelsContext) => Promise<ProviderModelConfig[] | undefined>;
|
|
93
|
+
|
|
94
|
+
// ──────────────────────── 缓存文件读取 ────────────────────────
|
|
95
|
+
|
|
96
|
+
/** 单份缓存文件的读取态。`absent` 是合法态(never-seen 的输入),不是失败。 */
|
|
97
|
+
type CacheRead =
|
|
98
|
+
| { status: "ok"; entries: Record<string, OverlayEntry> }
|
|
99
|
+
| { status: "absent" }
|
|
100
|
+
| { status: "corrupt" }
|
|
101
|
+
| { status: "unreadable" };
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 读一份缓存文件。ENOENT → absent(首装未开过设置页的正常形态);JSON/结构非法 →
|
|
105
|
+
* corrupt;其它 IO 故障(EACCES 等)→ unreadable。双形态解析(taiji 的 version
|
|
106
|
+
* 包装 / pi store 的顶层 providerId 分桶)由 llm-shared 的 `parseOverlayCache` 承担。
|
|
107
|
+
*/
|
|
108
|
+
function readCacheFile(path: string): CacheRead {
|
|
109
|
+
let raw: string;
|
|
110
|
+
try {
|
|
111
|
+
raw = readFileSync(path, "utf8");
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const code = (err as NodeJS.ErrnoException | null)?.code;
|
|
114
|
+
if (code === "ENOENT") return { status: "absent" };
|
|
115
|
+
return { status: "unreadable" };
|
|
116
|
+
}
|
|
117
|
+
const parsed = parseOverlayCache(raw);
|
|
118
|
+
return parsed.status === "ok" ? { status: "ok", entries: parsed.entries } : { status: "corrupt" };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 读一份缓存并按 R2 分级记日志(缺失 debug / 损坏与不可读 warn——级别区分常态与异常)。
|
|
123
|
+
* 返回该文件贡献的 entries(失败 = 空)与是否失败(失败时整拍作废,见 R2/R7 注释)。
|
|
124
|
+
*/
|
|
125
|
+
function readCacheEntries(
|
|
126
|
+
label: string,
|
|
127
|
+
path: string,
|
|
128
|
+
): { entries: Record<string, OverlayEntry>; failed: boolean } {
|
|
129
|
+
const read = readCacheFile(path);
|
|
130
|
+
if (read.status === "absent") {
|
|
131
|
+
logger.debug(
|
|
132
|
+
`catalog cache absent (${label}): ${path} — its providers fall to never-seen `
|
|
133
|
+
+ "(normal before the first Settings provider-page catalog refresh)",
|
|
134
|
+
);
|
|
135
|
+
return { entries: {}, failed: false };
|
|
136
|
+
}
|
|
137
|
+
if (read.status === "corrupt") {
|
|
138
|
+
logger.warn(
|
|
139
|
+
`catalog cache corrupt (${label}), overlay injection skipped: ${path} — fix or delete the file; `
|
|
140
|
+
+ "the next catalog refresh rewrites it",
|
|
141
|
+
);
|
|
142
|
+
return { entries: {}, failed: true };
|
|
143
|
+
}
|
|
144
|
+
if (read.status === "unreadable") {
|
|
145
|
+
logger.warn(`catalog cache unreadable (${label}), overlay injection skipped: ${path} — check file permissions`);
|
|
146
|
+
return { entries: {}, failed: true };
|
|
147
|
+
}
|
|
148
|
+
return { entries: read.entries, failed: false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 两份缓存条目级新者胜合并(缺 `lastModified` 视为最旧;判据在 llm-shared)。 */
|
|
152
|
+
function mergeSources(
|
|
153
|
+
taiji: Record<string, OverlayEntry>,
|
|
154
|
+
store: Record<string, OverlayEntry>,
|
|
155
|
+
): Record<string, OverlayEntry> {
|
|
156
|
+
const merged: Record<string, OverlayEntry> = {};
|
|
157
|
+
for (const providerId of new Set([...Object.keys(taiji), ...Object.keys(store)])) {
|
|
158
|
+
const entry = mergeEntriesNewerWins(taiji[providerId], store[providerId]);
|
|
159
|
+
if (entry !== undefined) merged[providerId] = entry;
|
|
160
|
+
}
|
|
161
|
+
return merged;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ──────────────────────── 桥状态与单拍计算 ────────────────────────
|
|
165
|
+
|
|
166
|
+
/** 两份缓存文件的 mtime 戳(缺失 = -1,与 runtime `mtimeOf` 同哨兵)。 */
|
|
167
|
+
type CacheStamp = { taiji: number; store: number };
|
|
168
|
+
|
|
169
|
+
/** 投喂桥的进程内状态(taiji 每会话一个独立 pi 进程,无跨会话残留问题)。 */
|
|
170
|
+
type BridgeState = {
|
|
171
|
+
taijiPath: string;
|
|
172
|
+
/** pi store 路径(`getPiStorePath` 委托 pi `getAgentDir` 推导,恒有值)。 */
|
|
173
|
+
storePath: string;
|
|
174
|
+
/** 已成功注册的 provider id——缓存里消失的 provider 也要进计算集(否则撤销注入无从触发)。 */
|
|
175
|
+
registered: Set<string>;
|
|
176
|
+
/** 上次成功计算的 mtime 戳与结果;null = 尚未计算过。 */
|
|
177
|
+
cache: { stamp: CacheStamp; results: Map<string, ComputeInjectionSetResult> } | null;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 单拍计算结果。`read-failed` = 两份缓存之一损坏或不可读——注册期按 R2 跳过注入,
|
|
182
|
+
* 钩子期按 R7 返回 undefined 保留当前注入。
|
|
183
|
+
*/
|
|
184
|
+
type PassOutcome =
|
|
185
|
+
| { status: "ok"; results: Map<string, ComputeInjectionSetResult> }
|
|
186
|
+
| { status: "read-failed" };
|
|
187
|
+
|
|
188
|
+
/** 单文件 mtime 观测态(与既有 `FileObservation` 同构的三分态,比较对象是 mtime 而非内容字节)。 */
|
|
189
|
+
export type MtimeObservation =
|
|
190
|
+
| { state: "present"; mtimeMs: number }
|
|
191
|
+
| { state: "absent" }
|
|
192
|
+
| { state: "unreadable" };
|
|
193
|
+
|
|
194
|
+
/** stat 单个监视目标:ENOENT → absent;其它错误 → unreadable(含权限、I/O 故障)。 */
|
|
195
|
+
function observeMtime(path: string): MtimeObservation {
|
|
196
|
+
try {
|
|
197
|
+
return { state: "present", mtimeMs: statSync(path).mtimeMs };
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const code = (err as NodeJS.ErrnoException | null)?.code;
|
|
200
|
+
if (code === "ENOENT") return { state: "absent" };
|
|
201
|
+
return { state: "unreadable" };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 观测态 → mtime 戳(非 present 一律 -1,与 runtime `mtimeOf` 同哨兵)。 */
|
|
206
|
+
function stampOf(observation: MtimeObservation): number {
|
|
207
|
+
return observation.state === "present" ? observation.mtimeMs : -1;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function readStamp(state: BridgeState): CacheStamp {
|
|
211
|
+
return {
|
|
212
|
+
taiji: stampOf(observeMtime(state.taijiPath)),
|
|
213
|
+
store: stampOf(observeMtime(state.storePath)),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sameStamp(previous: CacheStamp, current: CacheStamp): boolean {
|
|
218
|
+
return previous.taiji === current.taiji && previous.store === current.store;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 单拍计算:读两份缓存 → 条目级新者胜合并 → per-provider 并集判据。
|
|
223
|
+
*
|
|
224
|
+
* mtime 短路(设计 D5):两份文件 mtime 与上次成功计算相同 → 直接返回上次结果,
|
|
225
|
+
* 不重读不重算。钩子被 pi 在**每次**注册表刷新时调用,其中绝大多数与目录缓存无关
|
|
226
|
+
* (凭据变化、其它 provider 注册、tick 的 models.json/auth.json 变更),短路省掉
|
|
227
|
+
* 每拍的 JSON 解析与并集计算。
|
|
228
|
+
*/
|
|
229
|
+
function runPass(state: BridgeState): PassOutcome {
|
|
230
|
+
const stamp = readStamp(state);
|
|
231
|
+
if (state.cache !== null && sameStamp(state.cache.stamp, stamp)) {
|
|
232
|
+
return { status: "ok", results: state.cache.results };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const taiji = readCacheEntries("taiji overlay cache", state.taijiPath);
|
|
236
|
+
const store = readCacheEntries("pi models store", state.storePath);
|
|
237
|
+
if (taiji.failed || store.failed) return { status: "read-failed" };
|
|
238
|
+
|
|
239
|
+
const merged = mergeSources(taiji.entries, store.entries);
|
|
240
|
+
const results = new Map<string, ComputeInjectionSetResult>();
|
|
241
|
+
// 计算集 = 缓存里的 provider ∪ 已注册的 provider:后者在缓存里消失时也要算出
|
|
242
|
+
// never-seen 的纯基线,撤销注入才有触发点(R6)。
|
|
243
|
+
for (const providerId of new Set([...Object.keys(merged), ...state.registered])) {
|
|
244
|
+
results.set(providerId, computeInjectionSet({ providerId, entry: merged[providerId] }));
|
|
245
|
+
}
|
|
246
|
+
state.cache = { stamp, results };
|
|
247
|
+
return { status: "ok", results };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ──────────────────────── 注入集形状转换 ────────────────────────
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 注入集元素 → pi 注册入参形状。
|
|
254
|
+
*
|
|
255
|
+
* llm-shared 的 `OverlayModel` 是「内置基线(pi-ai `Model`)与缓存条目(pi.dev
|
|
256
|
+
* 返回的宽松 JSON)」的共同可读面上界(该类型注释即此定位),运行时对象是完整
|
|
257
|
+
* 模型定义。pi 的组合入口 `extensionModelFromDefinition` 只补 `api`/`baseUrl`,
|
|
258
|
+
* 其余字段经 `{...definition}` 原样透传(`dist/core/provider-composer.js:135`),
|
|
259
|
+
* 故此处不做字段重建。
|
|
260
|
+
*
|
|
261
|
+
* 等价性由 U4 的 SDK 契约测试锁定(`__tests__/sdk-contract.test.ts`:Model 对象
|
|
262
|
+
* 全字段经组合后保留,唯一规范化是 headers 恒 undefined)。缺 `api`/`baseUrl`
|
|
263
|
+
* 的条目已在判据层按 R3 滤掉,其余字段完整性属上游目录数据的责任——本层不发明
|
|
264
|
+
* 缺省值(凭空补的 contextWindow/maxTokens 比缺字段更危险)。
|
|
265
|
+
*/
|
|
266
|
+
function toProviderModels(models: OverlayModel[]): ProviderModelConfig[] {
|
|
267
|
+
return models as ProviderModelConfig[];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ──────────────────────── refreshModels 钩子 ────────────────────────
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 注册时携带的 `refreshModels` 钩子(设计 D5 运行中窗口)。
|
|
274
|
+
*
|
|
275
|
+
* pi 在每次注册表刷新时调用它——含 provider-live-sync 既有 tick 检出缓存文件
|
|
276
|
+
* mtime 变化后的 `refresh({ allowNetwork: false })`。返回值语义:
|
|
277
|
+
* - fresh → 新并集(新模型立即可见,无需重开会话);
|
|
278
|
+
* - expired / never-seen → 纯内置基线(R6 撤销注入);
|
|
279
|
+
* - 任一份缓存损坏或不可读 → undefined(R7:pi 保留当前注入——宁可保留最后一份
|
|
280
|
+
* 好快照,不因瞬时读失败清空可见集合)。
|
|
281
|
+
*/
|
|
282
|
+
function makeRefreshModels(state: BridgeState, providerId: string): RefreshModelsFn {
|
|
283
|
+
return async () => {
|
|
284
|
+
const outcome = runPass(state);
|
|
285
|
+
if (outcome.status === "read-failed") {
|
|
286
|
+
logger.warn(
|
|
287
|
+
`catalog cache unreadable during refresh for ${providerId}, keeping the current injection — `
|
|
288
|
+
+ "recovers on the next poll once the file is readable again",
|
|
289
|
+
);
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
const result = outcome.results.get(providerId);
|
|
293
|
+
// 不可达:注册成功后才挂本钩子,`registered` 恒含该 provider,计算集必含它。
|
|
294
|
+
// Map.get 的 undefined 类型面要求显式收窄,按 R7 的保守语义返回 undefined。
|
|
295
|
+
if (result === undefined) return undefined;
|
|
296
|
+
if (result.state !== "fresh") {
|
|
297
|
+
logger.debug(
|
|
298
|
+
`catalog overlay revoked for ${providerId}: state=${result.state} — returning the builtin baseline`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return toProviderModels(result.models);
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ──────────────────────── 第三个监视条目 ────────────────────────
|
|
306
|
+
|
|
307
|
+
/** 第三个监视条目的观测器(provider-live-sync 既有 tick 每拍调用一次)。 */
|
|
308
|
+
export type OverlayCacheWatcher = {
|
|
309
|
+
/** 监视目标标签(tick 日志用;= taiji 自刷缓存文件名)。 */
|
|
310
|
+
readonly label: string;
|
|
311
|
+
/** 本拍观测:mtime 变化 → true(内部推进基线)。unreadable → 本拍不比较、不推进基线。 */
|
|
312
|
+
observe(): boolean;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 建第三个监视条目的观测器:taiji 目录缓存(绝对路径 + mtime 比较)。
|
|
317
|
+
*
|
|
318
|
+
* 为什么用 mtime 而非既有 models.json/auth.json 的内容字节比较:写入方是 taiji
|
|
319
|
+
* runtime 的 tmp+rename 原子替换写(`provider-catalog-refresh.ts` 的 `writeFile`
|
|
320
|
+
* + `rename`),mtime 可靠;且该文件含**全部** provider 的模型目录,逐拍字节比较
|
|
321
|
+
* 是纯浪费。辖域仅限本条目——既有两条目的内容字节比较与「曾存在 → 缺失」抑制语义
|
|
322
|
+
* 一字不变(设计 D5)。
|
|
323
|
+
*
|
|
324
|
+
* `initial` = 工厂期捕获的 mtime 观测(`injectCatalogOverlay` 在 runPass 之前观测后
|
|
325
|
+
* 传入)。第三个条目的基线**必须工厂期播种**:pi 不读目录缓存,扩展工厂期注入才是
|
|
326
|
+
* 初始态——若基线留到 tick 1 才建立,「工厂期注入 → tick 1」约 2s 窗内的缓存变更会
|
|
327
|
+
* 被首拍基线吞掉(与「无变更」无法区分),且此后 mtime 稳定即永不重检,运行中窗口
|
|
328
|
+
* (设计 D5)在该窗口断裂。缺省 undefined = 首拍建基线(恒判「变化」,由调用方的
|
|
329
|
+
* firstBaseline 抑制刷新)——仅无工厂期观测的形态(测试)使用。
|
|
330
|
+
*/
|
|
331
|
+
export function createOverlayCacheWatcher(path: string, initial?: MtimeObservation): OverlayCacheWatcher {
|
|
332
|
+
let baseline: MtimeObservation | undefined = initial;
|
|
333
|
+
return {
|
|
334
|
+
label: basename(path),
|
|
335
|
+
observe(): boolean {
|
|
336
|
+
const observation = observeMtime(path);
|
|
337
|
+
// unreadable:本拍不比较、不推进基线(与既有 per-file 框架的 unreadable 语义一致)
|
|
338
|
+
if (observation.state === "unreadable") return false;
|
|
339
|
+
const changed = !sameObservation(baseline, observation);
|
|
340
|
+
baseline = observation;
|
|
341
|
+
return changed;
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 两次观测是否同一态(undefined 基线 = 首拍,恒判「变化」,由调用方的 firstBaseline 抑制刷新)。 */
|
|
347
|
+
function sameObservation(previous: MtimeObservation | undefined, current: MtimeObservation): boolean {
|
|
348
|
+
if (previous === undefined) return false;
|
|
349
|
+
if (previous.state === "present" && current.state === "present") return previous.mtimeMs === current.mtimeMs;
|
|
350
|
+
return previous.state === current.state;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ──────────────────────── 注册执行 ────────────────────────
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 把一个 fresh 态 provider 注入 pi 注册表(工厂期循环与运行中对账共用的单 provider
|
|
357
|
+
* 注册体):R3 的 skipped warn 与 R4 的抛错捕获都在此;注册成功才入 `state.registered`
|
|
358
|
+
* (失败的 provider 留在未注册集,缓存下次重写后的对账自动重试)。返回是否注册成功。
|
|
359
|
+
*/
|
|
360
|
+
function registerFreshProvider(
|
|
361
|
+
pi: ExtensionAPI,
|
|
362
|
+
state: BridgeState,
|
|
363
|
+
providerId: string,
|
|
364
|
+
result: ComputeInjectionSetResult,
|
|
365
|
+
): boolean {
|
|
366
|
+
for (const skipped of result.skipped) {
|
|
367
|
+
// R3:缺 api/baseUrl 且同 id 无内置定义可继承(判据层已滤,此处只负责日志)
|
|
368
|
+
logger.warn(
|
|
369
|
+
`catalog overlay model skipped: ${providerId}/${skipped.modelId} is missing api or baseUrl `
|
|
370
|
+
+ "and has no same-id builtin model to inherit from — waiting for the next catalog refresh "
|
|
371
|
+
+ "to bring complete data",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
pi.registerProvider(providerId, {
|
|
376
|
+
models: toProviderModels(result.models),
|
|
377
|
+
// 断言理由见 `RefreshModelsFn` 的类型注释:pi 声明不收 undefined,实装收(R7 靠它)
|
|
378
|
+
refreshModels: makeRefreshModels(state, providerId) as ProviderConfig["refreshModels"],
|
|
379
|
+
});
|
|
380
|
+
state.registered.add(providerId);
|
|
381
|
+
logger.debug(
|
|
382
|
+
`catalog overlay injected for ${providerId}: ${result.models.length} models `
|
|
383
|
+
+ "(builtin baseline ⊕ cache, state=fresh)",
|
|
384
|
+
);
|
|
385
|
+
return true;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
// R4:结构校验失败等——不阻断其他 provider 的注入
|
|
388
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
389
|
+
logger.error(
|
|
390
|
+
`registerProvider failed for ${providerId}: ${message} — the provider keeps pi's current `
|
|
391
|
+
+ `behavior; fix the catalog data source (${state.taijiPath}) — the next catalog refresh `
|
|
392
|
+
+ "retries the registration automatically, or restart the session to retry",
|
|
393
|
+
);
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 对一拍计算结果中**尚未注册**的 provider 执行注入判定:fresh → 注册(挂钩子);
|
|
400
|
+
* 非 fresh → R5 debug 不注入。已注册的 provider 由其 `refreshModels` 钩子负责(R6/R7),
|
|
401
|
+
* 此处跳过。工厂期(已注册集为空)与运行中对账共用。返回是否有新注册。
|
|
402
|
+
*/
|
|
403
|
+
function injectUnregistered(
|
|
404
|
+
pi: ExtensionAPI,
|
|
405
|
+
state: BridgeState,
|
|
406
|
+
results: Map<string, ComputeInjectionSetResult>,
|
|
407
|
+
): boolean {
|
|
408
|
+
let registered = false;
|
|
409
|
+
for (const [providerId, result] of results) {
|
|
410
|
+
if (state.registered.has(providerId)) continue;
|
|
411
|
+
if (result.state !== "fresh") {
|
|
412
|
+
// R5:过期(含远端 404/501 的否定信号)与从未见过都不注入——凭空造模型禁止
|
|
413
|
+
logger.debug(
|
|
414
|
+
`catalog overlay not injected for ${providerId}: state=${result.state} — `
|
|
415
|
+
+ "falling back to pi's builtin behavior",
|
|
416
|
+
);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (registerFreshProvider(pi, state, providerId, result)) registered = true;
|
|
420
|
+
}
|
|
421
|
+
return registered;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ──────────────────────── 入口 ────────────────────────
|
|
425
|
+
|
|
426
|
+
/** 工厂期入口的返回值:第三个监视条目的观测器 + 运行中窗口的注册对账入口(tick 挂载用)。 */
|
|
427
|
+
export type CatalogOverlayBridge = {
|
|
428
|
+
/** 第三个监视条目的观测器(provider-live-sync 既有 tick 每拍调用一次)。 */
|
|
429
|
+
readonly watcher: OverlayCacheWatcher;
|
|
430
|
+
/**
|
|
431
|
+
* 注册对账(tick 检出目录缓存变化后、触发刷新之前调用一次):重算单拍,对
|
|
432
|
+
* state=fresh 且尚未注册的 provider 补 `registerProvider`(挂同一 `refreshModels` 钩子)。
|
|
433
|
+
*
|
|
434
|
+
* 覆盖「工厂期 expired / never-seen / 损坏 → 运行中变 fresh」的跃迁——pi-ai
|
|
435
|
+
* `ModelsStore.refresh` 只刷新带 `refreshModels` 的 provider,没有钩子的 provider 单靠
|
|
436
|
+
* 刷新永远重建不了注入。工厂返回后的 `registerProvider` 直连注册表立即生效(PS-88)。
|
|
437
|
+
*
|
|
438
|
+
* 返回本次是否有新注册。read-failed 拍不动作:未注册的 provider 没有注入可建(R2 同款,
|
|
439
|
+
* 读取层已记 warn),已注册的由各自钩子按 R7 保留。
|
|
440
|
+
*/
|
|
441
|
+
reconcile(): boolean;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* 工厂期目录投喂入口(设计 D1)。
|
|
446
|
+
*
|
|
447
|
+
* 在扩展工厂函数体内**同步**完成注册:pi 的 `registerProvider` 在加载期只进
|
|
448
|
+
* pending 队列,装配链在 `--model` 解析之前统一 flush + 本地 refresh(PS-82)。
|
|
449
|
+
*
|
|
450
|
+
* 返回值 = 第三个监视条目的观测器 + 运行中窗口的注册对账入口(供 provider-live-sync
|
|
451
|
+
* 既有 tick 挂载:每拍 `watcher.observe()`,检出变化先 `reconcile()` 再刷新);
|
|
452
|
+
* R1(`TAIJI_AGENT_DATA_DIR` 缺失 = 非 taiji 托管环境)返回 null——调用方整条
|
|
453
|
+
* 跳过,零文件访问、零注册,该 provider 自然落 never-seen、上层零投喂。
|
|
454
|
+
*/
|
|
455
|
+
export function injectCatalogOverlay(pi: ExtensionAPI): CatalogOverlayBridge | null {
|
|
456
|
+
const taijiPath = getTaijiOverlayCachePath();
|
|
457
|
+
if (taijiPath === null) return null; // R1:原生 pi 用户的 pass-through 设计态
|
|
458
|
+
const storePath = getPiStorePath();
|
|
459
|
+
const state: BridgeState = { taijiPath, storePath, registered: new Set(), cache: null };
|
|
460
|
+
|
|
461
|
+
// 工厂期播种第三个监视条目的基线(读取前捕获 = 保守方向:最坏一次无害重复刷新,
|
|
462
|
+
// 不会漏检)。首拍抑制的前提(「进程启动时 pi 已按磁盘配置建过快照」)只对既有
|
|
463
|
+
// models.json/auth.json 两条目成立;第三个条目不成立——pi 不读目录缓存,扩展工厂期
|
|
464
|
+
// 注入才是初始态,基线必须工厂期播种,否则「工厂期注入 → tick 1」约 2s 窗内的缓存
|
|
465
|
+
// 变更会被 tick 首拍基线吞掉且此后 mtime 稳定即永不重检(运行中窗口 D5 断裂)。
|
|
466
|
+
const initialObservation = observeMtime(taijiPath);
|
|
467
|
+
|
|
468
|
+
const outcome = runPass(state);
|
|
469
|
+
// R2 的损坏/不可读分支(read-failed):本次不注入——没有已注入的集合可保。监视条目与
|
|
470
|
+
// 对账入口照样返回:文件修复后 mtime 变化,下一拍 tick 的 reconcile 重建注入,不依赖
|
|
471
|
+
// 重开会话(设计 §3.1 R2)
|
|
472
|
+
if (outcome.status === "ok") injectUnregistered(pi, state, outcome.results);
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
watcher: createOverlayCacheWatcher(taijiPath, initialObservation),
|
|
476
|
+
reconcile: (): boolean => {
|
|
477
|
+
const pass = runPass(state);
|
|
478
|
+
if (pass.status === "read-failed") return false;
|
|
479
|
+
return injectUnregistered(pi, state, pass.results);
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,9 +17,25 @@
|
|
|
17
17
|
* runProviderRefreshPhase)
|
|
18
18
|
* ——**零网络请求**。
|
|
19
19
|
*
|
|
20
|
-
*
|
|
20
|
+
* 目录投喂(第二个职责,设计 §3.3 D1/D2/D5):taiji 的模型目录缓存
|
|
21
|
+
* (`<getDataDir()>/provider-catalog-overlay.json`)经 pi 官方注册点
|
|
22
|
+
* `pi.registerProvider` 注入注册表,修的是「上游目录新模型在 pi 执行侧不可见」那类
|
|
23
|
+
* 断裂(rename 静默跳过 / `set_model` 报 ENGINE_MODEL_MISSING / spawn `--model`
|
|
24
|
+
* 近似回退等 8 个同根因症状)。注册发生在**工厂函数体内**(pi 内部进 pending 队列,
|
|
25
|
+
* 装配链在 `--model` 解析之前统一 flush),运行中窗口由本扩展既有的轮询 tick 承载:
|
|
26
|
+
* 该缓存文件作为**第三个监视条目**挂进 tick(绝对路径 + mtime 比较,与既有两条目的
|
|
27
|
+
* 内容字节比较正交),检出变化先做一次**注册对账**(`reconcile`:对本拍 fresh 且尚未
|
|
28
|
+
* 注册的 provider 补 `registerProvider`——工厂期 expired / never-seen / 损坏的 provider
|
|
29
|
+
* 没有 `refreshModels` 钩子,pi 的刷新不会碰它,单靠刷新重建不了注入;工厂返回后的注册
|
|
30
|
+
* 直连注册表立即生效,PS-88),再触发同一次零网络刷新,由注册时携带的 `refreshModels`
|
|
31
|
+
* 钩子重读缓存返回新并集。实装在 `src/catalog-bridge.ts`。
|
|
32
|
+
*
|
|
33
|
+
* 设计取舍(行为语义以本头注释为权威;包定位与投喂写入单点见根 AGENTS.md 的
|
|
34
|
+
* provider-live-sync 括注与 constraints.json 约束 C-ext-32;术语见 docs/CONTEXT.md
|
|
35
|
+
* 「目录投喂(catalog overlay)」词条):
|
|
21
36
|
* - **轮询而非 fs.watch**:仓内 `skill-registry` 有 macOS `fs.watch` 触发率 ~40% 前科;轮询不依赖
|
|
22
|
-
* 事件送达,无静默漏报面。代价 = 每进程
|
|
37
|
+
* 事件送达,无静默漏报面。代价 = 每进程 3 个监视目标 / 2s 的只读 I/O(稳态每拍
|
|
38
|
+
* 2 次内容读 + 1 次 mtime stat;检出变化时钩子重读两份目录缓存)。
|
|
23
39
|
* - **按文件独立「存在性 + 内容」基线**:状态 = 缺失/存在 × 内容字节。① 持续缺失 = 非变化信号
|
|
24
40
|
* (**models.json 本就不存在是 catalog-only 安装的常态**,不能因此关停另一文件的比较);
|
|
25
41
|
* ② 「曾存在 → 缺失」(quarantine / 外部删除窗口)→ 更新基线但**抑制 refresh**(否则会以空配置
|
|
@@ -45,6 +61,7 @@ import { join } from "node:path";
|
|
|
45
61
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
46
62
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
47
63
|
import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
|
|
64
|
+
import { injectCatalogOverlay, type CatalogOverlayBridge } from "./catalog-bridge.ts";
|
|
48
65
|
|
|
49
66
|
/** 轮询周期(ms)。设计标定:内容变更到生效的上界 ≈ 2s,小于「保存 → 切回会话 → 点选」的人工时延。 */
|
|
50
67
|
export const POLL_INTERVAL_MS = 2_000;
|
|
@@ -66,7 +83,9 @@ export interface Snapshot {
|
|
|
66
83
|
baselines: Map<WatchedFile, FileBaseline>;
|
|
67
84
|
/**
|
|
68
85
|
* 是否已完成首个**可比**基线拍:两受监视文件各完成一次可比观察(absent 计入、
|
|
69
|
-
* unreadable 不计)。此后首拍只建基线、不触发 refresh(pi spawn
|
|
86
|
+
* unreadable 不计)。此后首拍只建基线、不触发 refresh(pi spawn 时已按磁盘建过快照
|
|
87
|
+
* ——该前提只对 models.json/auth.json 两条目成立;第三个监视条目 taiji 目录缓存的
|
|
88
|
+
* 基线工厂期已播种,其变化在首拍必须放行,见 tick 内 firstBaseline 抑制处注释)。
|
|
70
89
|
* 置位与当拍 decision 无关——双 absent 首拍(catalog-only 常态)也构成完整基线。
|
|
71
90
|
*/
|
|
72
91
|
initialized: boolean;
|
|
@@ -75,8 +94,8 @@ export interface Snapshot {
|
|
|
75
94
|
*
|
|
76
95
|
* 抑制是**持续**的(不是一拍):只要 `models.json` 处于这个集合里,任何 refresh 都会重读磁盘、
|
|
77
96
|
* 以「无 override 的空配置」重建可用集合——把一次坏写放大成用户自定义 provider 的**真正丢失**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
97
|
+
* (取舍 = 「『曾存在 → 缺失』跃迁抑制 refresh」,见本文件头注释与 evaluateSnapshot 实装)。
|
|
98
|
+
* 恢复 = 该文件重新出现(内容变化)或会话重启(spawn 期重读)。
|
|
80
99
|
*
|
|
81
100
|
* 只对 `models.json` 生效:`auth.json` 消失不威胁配置本身(凭据缺失是自愈的——文件写回后
|
|
82
101
|
* 下一拍 refresh 即恢复),按普通内容变化处理,避免「凭据变更被静默丢弃」。
|
|
@@ -222,9 +241,57 @@ function comparableCount(observations: Map<WatchedFile, FileObservation>): numbe
|
|
|
222
241
|
*/
|
|
223
242
|
const logger = getLogger("provider-live-sync");
|
|
224
243
|
|
|
244
|
+
/** skip-missing 拍的跃迁去重日志:只在「曾存在 → 缺失」那一拍记,持续缺失零输出。 */
|
|
245
|
+
function logMissingTransitions(missingLogged: Set<WatchedFile>, missingFiles: WatchedFile[]): void {
|
|
246
|
+
for (const file of missingFiles) {
|
|
247
|
+
if (missingLogged.has(file)) continue;
|
|
248
|
+
missingLogged.add(file);
|
|
249
|
+
logger.error(
|
|
250
|
+
`config file disappeared: ${file} — refresh suppressed until it is written again `
|
|
251
|
+
+ "(rebuilding from an empty config would drop the user's custom providers from the running "
|
|
252
|
+
+ "engine; it keeps its last good snapshot meanwhile; restarting the session also recovers).",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 第三个监视条目(taiji 目录缓存)的观测并进本拍变更集:mtime 变化 → 其标签进 changed。
|
|
259
|
+
* 必须在 skip-missing 的 early return **之后**调用:配置源缺失窗口内一律不刷新,目录缓存
|
|
260
|
+
* 的变化不得穿透该抑制;代价是抑制期内该条目的基线不推进,抑制解除后补一次刷新
|
|
261
|
+
* (保守方向,不会漏)。
|
|
262
|
+
*/
|
|
263
|
+
function mergeOverlayChange(
|
|
264
|
+
decision: SyncDecision,
|
|
265
|
+
overlayBridge: CatalogOverlayBridge | null,
|
|
266
|
+
): { changed: string[]; overlayChanged: string | null } {
|
|
267
|
+
const overlayChanged =
|
|
268
|
+
overlayBridge !== null && overlayBridge.watcher.observe() ? overlayBridge.watcher.label : null;
|
|
269
|
+
const changed: string[] = decision.action === "refresh" ? [...decision.changedFiles] : [];
|
|
270
|
+
if (overlayChanged !== null) changed.push(overlayChanged);
|
|
271
|
+
return { changed, overlayChanged };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** 拍内零网络本地刷新 + 结果报告(`getError()` 全文是唯一有效判据,见模块头注释)。 */
|
|
275
|
+
async function refreshAndReport(modelRegistry: ExtensionContext["modelRegistry"]): Promise<void> {
|
|
276
|
+
const result = await modelRegistry.refresh({ allowNetwork: false });
|
|
277
|
+
// 有效判据 = getError() 全文(本退化类 errors.size/aborted 恒为空/假,见模块头注释)。
|
|
278
|
+
const error = modelRegistry.getError();
|
|
279
|
+
if (error) {
|
|
280
|
+
// 不限定来源(models.json/auth.json/目录投喂注入集都可能被拒):composition error 的
|
|
281
|
+
// 归因在各自产生侧(目录投喂侧见 catalog-bridge.ts 的 registerProvider/skipped 日志)。
|
|
282
|
+
logger.error(`model config rejected by the engine: ${error}`);
|
|
283
|
+
} else if (result.errors.size > 0 || result.aborted) {
|
|
284
|
+
logger.error(`refresh finished with errors (aborted=${String(result.aborted)}, providers=${result.errors.size})`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
225
288
|
export default function providerLiveSync(pi: ExtensionAPI): void {
|
|
226
289
|
// 最早期注入 pi handle——上方 logger 的 error 通道(appendEntry)由此生效(session-reader 同款)。
|
|
227
290
|
setPiHandle(pi);
|
|
291
|
+
// 目录投喂(设计 D1):必须在同步的工厂体内完成——pi 的 pending 队列在全部工厂返回后
|
|
292
|
+
// 统一 flush,晚于 `--model` 解析就覆盖不了 spawn 近似回退。返回 null = 非 taiji 托管
|
|
293
|
+
// 环境(R1),第三个监视条目与注册对账整条不挂。
|
|
294
|
+
const overlayBridge: CatalogOverlayBridge | null = injectCatalogOverlay(pi);
|
|
228
295
|
const snapshot: Snapshot = { baselines: new Map(), initialized: false, suppressed: new Set() };
|
|
229
296
|
/** ctx 每次 session_start 重新捕获(pi 可能换实例;未就绪时本拍跳过且不推进基线)。 */
|
|
230
297
|
let ctx: ExtensionContext | null = null;
|
|
@@ -253,32 +320,28 @@ export default function providerLiveSync(pi: ExtensionAPI): void {
|
|
|
253
320
|
const firstBaseline = !snapshot.initialized;
|
|
254
321
|
if (comparableCount(observations) === WATCHED_FILES.length) snapshot.initialized = true;
|
|
255
322
|
if (decision.action === "skip-missing") {
|
|
256
|
-
|
|
257
|
-
if (!missingLogged.has(file)) {
|
|
258
|
-
missingLogged.add(file);
|
|
259
|
-
logger.error(
|
|
260
|
-
`config file disappeared: ${file} — refresh suppressed until it is written again `
|
|
261
|
-
+ "(rebuilding from an empty config would drop the user's custom providers from the running "
|
|
262
|
-
+ "engine; it keeps its last good snapshot meanwhile; restarting the session also recovers).",
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
323
|
+
logMissingTransitions(missingLogged, decision.missingFiles);
|
|
266
324
|
return;
|
|
267
325
|
}
|
|
268
326
|
for (const file of WATCHED_FILES) missingLogged.delete(file);
|
|
269
|
-
if (decision.action !== "refresh") return;
|
|
270
|
-
// 首个基线拍不刷新(进程启动时 pi 已按磁盘配置建过快照,重复刷新纯浪费)。
|
|
271
|
-
if (firstBaseline) return;
|
|
272
327
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
328
|
+
const { changed, overlayChanged } = mergeOverlayChange(decision, overlayBridge);
|
|
329
|
+
if (changed.length === 0) return;
|
|
330
|
+
// 首个基线拍不刷新(进程启动时 pi 已按磁盘配置建过快照,重复刷新纯浪费)——该前提
|
|
331
|
+
// 只对既有 models.json/auth.json 两条目成立:pi spawn 时按磁盘建快照读的是它们。
|
|
332
|
+
// 第三个条目(taiji 目录缓存)不成立——pi 不读目录缓存,扩展工厂期注入才是初始态
|
|
333
|
+
// (基线工厂期播种),「工厂期注入 → tick 1」约 2s 窗内的缓存变更必须刷新,否则
|
|
334
|
+
// 运行中窗口(设计 D5)在该窗口断裂且此后 mtime 稳定即永不重检。
|
|
335
|
+
if (firstBaseline && overlayChanged === null) return;
|
|
336
|
+
|
|
337
|
+
// 目录缓存变化先做注册对账(D5):工厂期 expired / never-seen / 损坏的 provider 没有
|
|
338
|
+
// refreshModels 钩子,pi 的 refresh 只刷新带钩子的 provider(pi-ai refreshable 过滤),
|
|
339
|
+
// 单靠下面的刷新重建不了注入——对本拍 fresh 且尚未注册的 provider 先补注册(post-bind
|
|
340
|
+
// registerProvider 直连注册表立即生效,PS-88),再刷新让已注册 provider 的钩子重读。
|
|
341
|
+
if (overlayBridge !== null && overlayChanged !== null) overlayBridge.reconcile();
|
|
342
|
+
|
|
343
|
+
logger.debug(`config change detected (${changed.join(", ")}) → refreshing model snapshot`);
|
|
344
|
+
await refreshAndReport(currentCtx.modelRegistry);
|
|
282
345
|
} catch (err) {
|
|
283
346
|
// 轮询绝不自杀:本拍失败下一拍继续(内容未变则基线已推进 → 不会重复刷新同一内容)。
|
|
284
347
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -293,7 +356,8 @@ export default function providerLiveSync(pi: ExtensionAPI): void {
|
|
|
293
356
|
timer.unref();
|
|
294
357
|
};
|
|
295
358
|
|
|
296
|
-
//
|
|
297
|
-
//
|
|
359
|
+
// 首拍对既有两条目只建基线(不 refresh);此后每拍比较。第三个监视条目(taiji 目录
|
|
360
|
+
// 缓存)的基线工厂期已播种,其变化首拍即放行。(pi 无 extension 生命周期 shutdown
|
|
361
|
+
// 钩子,进程退出时 unref 定时器自然消失。)
|
|
298
362
|
schedule();
|
|
299
363
|
}
|