@zhushanwen/pi-provider-live-sync 0.2.0 → 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 +5 -3
- 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 +101 -34
package/src/index.ts
CHANGED
|
@@ -1,22 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* provider-live-sync —— 运行中 pi 进程的**模型快照实时同步**扩展。
|
|
3
3
|
*
|
|
4
|
-
* 问题(根因①):pi 0.
|
|
5
|
-
* `session.modelRuntime.getAvailableSnapshot()` 解析(`dist/modes/rpc/rpc-mode.js
|
|
6
|
-
*
|
|
4
|
+
* 问题(根因①):pi 1.0.0 的可用模型集合冻结在 **spawn 时刻**——`set_model` 从
|
|
5
|
+
* `session.modelRuntime.getAvailableSnapshot()` 解析(`dist/modes/rpc/rpc-mode.js`
|
|
6
|
+
* case "set_model"),
|
|
7
|
+
* 该快照建于 `ModelRuntime.create()` 的 refresh(`dist/core/model-runtime.js`
|
|
8
|
+
* create 内 ModelConfig.load + rebuildProviders),
|
|
7
9
|
* 而 pi 自身**没有文件 watcher、没有 refresh RPC**。于是「会话开着 → 在设置页新增 provider/
|
|
8
10
|
* 模型/凭据 → 直接切到新模型」会报 `Model not found`(缺凭据与缺模型同文案),只能重开会话。
|
|
9
11
|
*
|
|
10
12
|
* 解法:本扩展按固定周期读 `<agentDir>/models.json` 与 `<agentDir>/auth.json`,**内容变化**时
|
|
11
13
|
* 调 pi 自己的 `ctx.modelRegistry.refresh({ allowNetwork: false })`——`ModelRuntime.refresh()`
|
|
12
14
|
* 首行即 `ModelConfig.load(modelsPath)` **重读磁盘配置**,随后 `rebuildProviders()` 按新配置重组
|
|
13
|
-
* (`dist/core/model-runtime.js
|
|
14
|
-
* `allowNetwork:false` 走本地恢复相位并在任何凭据解析/入网前返回(pi-ai `dist/models.js
|
|
15
|
+
* (`dist/core/model-runtime.js` refresh),因此新增/删除 provider、模型与凭据都会立即生效;
|
|
16
|
+
* `allowNetwork:false` 走本地恢复相位并在任何凭据解析/入网前返回(pi-ai `dist/models.js`
|
|
17
|
+
* runProviderRefreshPhase)
|
|
15
18
|
* ——**零网络请求**。
|
|
16
19
|
*
|
|
17
|
-
*
|
|
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)」词条):
|
|
18
36
|
* - **轮询而非 fs.watch**:仓内 `skill-registry` 有 macOS `fs.watch` 触发率 ~40% 前科;轮询不依赖
|
|
19
|
-
* 事件送达,无静默漏报面。代价 = 每进程
|
|
37
|
+
* 事件送达,无静默漏报面。代价 = 每进程 3 个监视目标 / 2s 的只读 I/O(稳态每拍
|
|
38
|
+
* 2 次内容读 + 1 次 mtime stat;检出变化时钩子重读两份目录缓存)。
|
|
20
39
|
* - **按文件独立「存在性 + 内容」基线**:状态 = 缺失/存在 × 内容字节。① 持续缺失 = 非变化信号
|
|
21
40
|
* (**models.json 本就不存在是 catalog-only 安装的常态**,不能因此关停另一文件的比较);
|
|
22
41
|
* ② 「曾存在 → 缺失」(quarantine / 外部删除窗口)→ 更新基线但**抑制 refresh**(否则会以空配置
|
|
@@ -42,6 +61,7 @@ import { join } from "node:path";
|
|
|
42
61
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
43
62
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
44
63
|
import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
|
|
64
|
+
import { injectCatalogOverlay, type CatalogOverlayBridge } from "./catalog-bridge.ts";
|
|
45
65
|
|
|
46
66
|
/** 轮询周期(ms)。设计标定:内容变更到生效的上界 ≈ 2s,小于「保存 → 切回会话 → 点选」的人工时延。 */
|
|
47
67
|
export const POLL_INTERVAL_MS = 2_000;
|
|
@@ -63,7 +83,9 @@ export interface Snapshot {
|
|
|
63
83
|
baselines: Map<WatchedFile, FileBaseline>;
|
|
64
84
|
/**
|
|
65
85
|
* 是否已完成首个**可比**基线拍:两受监视文件各完成一次可比观察(absent 计入、
|
|
66
|
-
* unreadable 不计)。此后首拍只建基线、不触发 refresh(pi spawn
|
|
86
|
+
* unreadable 不计)。此后首拍只建基线、不触发 refresh(pi spawn 时已按磁盘建过快照
|
|
87
|
+
* ——该前提只对 models.json/auth.json 两条目成立;第三个监视条目 taiji 目录缓存的
|
|
88
|
+
* 基线工厂期已播种,其变化在首拍必须放行,见 tick 内 firstBaseline 抑制处注释)。
|
|
67
89
|
* 置位与当拍 decision 无关——双 absent 首拍(catalog-only 常态)也构成完整基线。
|
|
68
90
|
*/
|
|
69
91
|
initialized: boolean;
|
|
@@ -72,8 +94,8 @@ export interface Snapshot {
|
|
|
72
94
|
*
|
|
73
95
|
* 抑制是**持续**的(不是一拍):只要 `models.json` 处于这个集合里,任何 refresh 都会重读磁盘、
|
|
74
96
|
* 以「无 override 的空配置」重建可用集合——把一次坏写放大成用户自定义 provider 的**真正丢失**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
97
|
+
* (取舍 = 「『曾存在 → 缺失』跃迁抑制 refresh」,见本文件头注释与 evaluateSnapshot 实装)。
|
|
98
|
+
* 恢复 = 该文件重新出现(内容变化)或会话重启(spawn 期重读)。
|
|
77
99
|
*
|
|
78
100
|
* 只对 `models.json` 生效:`auth.json` 消失不威胁配置本身(凭据缺失是自愈的——文件写回后
|
|
79
101
|
* 下一拍 refresh 即恢复),按普通内容变化处理,避免「凭据变更被静默丢弃」。
|
|
@@ -219,9 +241,57 @@ function comparableCount(observations: Map<WatchedFile, FileObservation>): numbe
|
|
|
219
241
|
*/
|
|
220
242
|
const logger = getLogger("provider-live-sync");
|
|
221
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
|
+
|
|
222
288
|
export default function providerLiveSync(pi: ExtensionAPI): void {
|
|
223
289
|
// 最早期注入 pi handle——上方 logger 的 error 通道(appendEntry)由此生效(session-reader 同款)。
|
|
224
290
|
setPiHandle(pi);
|
|
291
|
+
// 目录投喂(设计 D1):必须在同步的工厂体内完成——pi 的 pending 队列在全部工厂返回后
|
|
292
|
+
// 统一 flush,晚于 `--model` 解析就覆盖不了 spawn 近似回退。返回 null = 非 taiji 托管
|
|
293
|
+
// 环境(R1),第三个监视条目与注册对账整条不挂。
|
|
294
|
+
const overlayBridge: CatalogOverlayBridge | null = injectCatalogOverlay(pi);
|
|
225
295
|
const snapshot: Snapshot = { baselines: new Map(), initialized: false, suppressed: new Set() };
|
|
226
296
|
/** ctx 每次 session_start 重新捕获(pi 可能换实例;未就绪时本拍跳过且不推进基线)。 */
|
|
227
297
|
let ctx: ExtensionContext | null = null;
|
|
@@ -250,32 +320,28 @@ export default function providerLiveSync(pi: ExtensionAPI): void {
|
|
|
250
320
|
const firstBaseline = !snapshot.initialized;
|
|
251
321
|
if (comparableCount(observations) === WATCHED_FILES.length) snapshot.initialized = true;
|
|
252
322
|
if (decision.action === "skip-missing") {
|
|
253
|
-
|
|
254
|
-
if (!missingLogged.has(file)) {
|
|
255
|
-
missingLogged.add(file);
|
|
256
|
-
logger.error(
|
|
257
|
-
`config file disappeared: ${file} — refresh suppressed until it is written again `
|
|
258
|
-
+ "(rebuilding from an empty config would drop the user's custom providers from the running "
|
|
259
|
-
+ "engine; it keeps its last good snapshot meanwhile; restarting the session also recovers).",
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
323
|
+
logMissingTransitions(missingLogged, decision.missingFiles);
|
|
263
324
|
return;
|
|
264
325
|
}
|
|
265
326
|
for (const file of WATCHED_FILES) missingLogged.delete(file);
|
|
266
|
-
if (decision.action !== "refresh") return;
|
|
267
|
-
// 首个基线拍不刷新(进程启动时 pi 已按磁盘配置建过快照,重复刷新纯浪费)。
|
|
268
|
-
if (firstBaseline) return;
|
|
269
327
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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);
|
|
279
345
|
} catch (err) {
|
|
280
346
|
// 轮询绝不自杀:本拍失败下一拍继续(内容未变则基线已推进 → 不会重复刷新同一内容)。
|
|
281
347
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -290,7 +356,8 @@ export default function providerLiveSync(pi: ExtensionAPI): void {
|
|
|
290
356
|
timer.unref();
|
|
291
357
|
};
|
|
292
358
|
|
|
293
|
-
//
|
|
294
|
-
//
|
|
359
|
+
// 首拍对既有两条目只建基线(不 refresh);此后每拍比较。第三个监视条目(taiji 目录
|
|
360
|
+
// 缓存)的基线工厂期已播种,其变化首拍即放行。(pi 无 extension 生命周期 shutdown
|
|
361
|
+
// 钩子,进程退出时 unref 定时器自然消失。)
|
|
295
362
|
schedule();
|
|
296
363
|
}
|