@zhushanwen/pi-llm-shared 0.3.1 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-llm-shared",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Shared LLM invocation library for Pi extensions — model resolution (ref exact only), LLM calling (completeSimple), and config read/write with mtime caching. Shared library, not a Pi extension.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -18,11 +18,12 @@
18
18
  "index.ts"
19
19
  ],
20
20
  "dependencies": {
21
- "@zhushanwen/pi-file-lock": "0.1.1"
21
+ "@zhushanwen/pi-extension-logger": "0.3.0",
22
+ "@zhushanwen/pi-file-lock": "0.1.2"
22
23
  },
23
24
  "peerDependencies": {
24
- "@earendil-works/pi-ai": "*",
25
- "@earendil-works/pi-coding-agent": "*"
25
+ "@earendil-works/pi-ai": "^0.84.1",
26
+ "@earendil-works/pi-coding-agent": "^0.84.1"
26
27
  },
27
28
  "peerDependenciesMeta": {
28
29
  "@earendil-works/pi-ai": {
@@ -33,8 +34,8 @@
33
34
  }
34
35
  },
35
36
  "devDependencies": {
36
- "@earendil-works/pi-ai": "*",
37
- "@earendil-works/pi-coding-agent": "*",
37
+ "@earendil-works/pi-ai": "^0.84.2",
38
+ "@earendil-works/pi-coding-agent": "^0.84.2",
38
39
  "@vitest/coverage-v8": "^4.1.9",
39
40
  "vitest": "^4.1.8"
40
41
  },
@@ -7,15 +7,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
7
  import { clearConfigCache, getConfigPath, loadConfig, saveConfig } from "../config.ts";
8
8
  import * as fileLock from "@zhushanwen/pi-file-lock";
9
9
 
10
+ // mock 掉共享 logger,使 loggerMock.warn 可被断言(saveConfig 非致命降级路径的留痕)
11
+ const { loggerMock } = vi.hoisted(() => ({
12
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
13
+ }));
14
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
15
+ getLogger: () => loggerMock,
16
+ createLogger: () => loggerMock,
17
+ setPiHandle: vi.fn(),
18
+ }));
19
+
10
20
  // node:fs 的 ESM namespace 不可配置,vi.spyOn 对具名导出失效(vitest 限制)。
11
- // 用 vi.mock 包装 readFileSync/renameSync(默认走 actual,个别 test override),
12
- // 其他 fs 操作(writeFileSync/existsSync/statSync/mkdirSync...)原样透传 actual。
21
+ // 用 vi.mock 包装 readFileSync/renameSync/statSync/unlinkSync(默认走 actual
22
+ // 个别 test override),其他 fs 操作(writeFileSync/existsSync/mkdirSync...)原样
23
+ // 透传 actual。statSync/unlinkSync 供 U4 logger.warn 留痕用例 override。
24
+ // 注意:proper-lockfile(withFileLockSync 内部)走 graceful-fs,不受本 mock 影响。
13
25
  vi.mock("node:fs", async (importOriginal) => {
14
26
  const actual = await importOriginal() as typeof import("node:fs");
15
27
  return {
16
28
  ...actual,
17
29
  readFileSync: vi.fn(actual.readFileSync),
18
30
  renameSync: vi.fn(actual.renameSync),
31
+ statSync: vi.fn(actual.statSync),
32
+ unlinkSync: vi.fn(actual.unlinkSync),
19
33
  };
20
34
  });
21
35
 
@@ -33,6 +47,9 @@ beforeEach(() => {
33
47
  afterEach(() => {
34
48
  vi.mocked(fs.readFileSync).mockClear();
35
49
  vi.mocked(fs.renameSync).mockClear();
50
+ vi.mocked(fs.statSync).mockClear();
51
+ vi.mocked(fs.unlinkSync).mockClear();
52
+ loggerMock.warn.mockClear();
36
53
  rmSync(dir, { recursive: true, force: true });
37
54
  vi.unstubAllEnvs();
38
55
  });
@@ -217,6 +234,55 @@ describe("saveConfig", () => {
217
234
  expect(existsSync(join(dir, "config", "eperm-ext-config.json"))).toBe(false);
218
235
  });
219
236
 
237
+ it("U4: rename 成功但写后 stat 失败 → logger.warn 留痕 + 保存仍成功(缓存未更新,下次 load 重读)", () => {
238
+ // saveConfig 流程内 node:fs 的 statSync 只有写后更新缓存这一次调用
239
+ // (withFileLockSync 走 graceful-fs,不经过本 mock),once 必命中
240
+ vi.mocked(fs.statSync).mockImplementationOnce(() => {
241
+ throw new Error("EACCES: permission denied, stat");
242
+ });
243
+
244
+ const result = saveConfig("statfail", { x: 1 });
245
+
246
+ // stat 失败不影响保存成功语义(缓存下次 load 时会重读)
247
+ expect(result.success).toBe(true);
248
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
249
+ expect(String(loggerMock.warn.mock.calls[0]![0])).toContain("saveConfig stat after write failed");
250
+ expect(JSON.stringify(loggerMock.warn.mock.calls[0]![1])).toContain("EACCES");
251
+ // 文件已落盘(rename 已成功)
252
+ expect(JSON.parse(readFileSync(join(dir, "config", "statfail-ext-config.json"), "utf-8"))).toEqual({ x: 1 });
253
+ // 缓存未更新(stat 失败跳过 set)→ 下次 load 重读盘而非命中缓存
254
+ vi.mocked(fs.readFileSync).mockClear();
255
+ const loaded = loadConfig("statfail", { a: 0 }, normalize);
256
+ expect(loaded).toEqual({ a: 0 }); // normalize({x:1}) 不含 a → defaults
257
+ expect(vi.mocked(fs.readFileSync)).toHaveBeenCalledTimes(1);
258
+ });
259
+
260
+ it("U4: rename 失败且 tmp 清理也失败 → logger.warn 留痕 + {success:false}(清理失败不吞保存失败)", () => {
261
+ vi.mocked(fs.renameSync).mockImplementationOnce(() => {
262
+ throw new Error("EPERM: operation not permitted, rename");
263
+ });
264
+ // unlinkSync 在本流程内只有 catch 路径的 tmp 清理这一次调用,once 必命中
265
+ vi.mocked(fs.unlinkSync).mockImplementationOnce(() => {
266
+ throw new Error("EBUSY: resource busy or locked, unlink");
267
+ });
268
+ const onWarning = vi.fn();
269
+
270
+ const result = saveConfig("cleanupfail", { x: 1 }, onWarning);
271
+
272
+ // 保存失败的返回不被清理失败吞掉
273
+ expect(result.success).toBe(false);
274
+ expect(result.error).toContain("EPERM");
275
+ expect(onWarning).toHaveBeenCalledTimes(1);
276
+ // tmp 清理失败留痕
277
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
278
+ expect(String(loggerMock.warn.mock.calls[0]![0])).toContain("saveConfig tmp cleanup failed");
279
+ expect(JSON.stringify(loggerMock.warn.mock.calls[0]![1])).toContain("EBUSY");
280
+ // unlink 失败 → tmp 残留(反向证明清理路径确实失败)
281
+ expect(tmpResidues("cleanupfail")).toHaveLength(1);
282
+ // 目标文件未被创建(rename 失败)
283
+ expect(existsSync(join(dir, "config", "cleanupfail-ext-config.json"))).toBe(false);
284
+ });
285
+
220
286
  it("saveConfig 多次写同文件 → 每次成功 + 最新内容", () => {
221
287
  expect(saveConfig("test", { v: 1 }).success).toBe(true);
222
288
  expect(saveConfig("test", { v: 2 }).success).toBe(true);
package/src/config.ts CHANGED
@@ -23,8 +23,11 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync,
23
23
  import { dirname, join } from "node:path";
24
24
 
25
25
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
26
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
26
27
  import { withFileLockSync } from "@zhushanwen/pi-file-lock";
27
28
 
29
+ const logger = getLogger("llm-shared");
30
+
28
31
  /** JSON 序列化缩进格数(permission/config.ts 同款)。 */
29
32
  const JSON_INDENT = 2;
30
33
 
@@ -179,10 +182,7 @@ export function saveConfig(
179
182
  });
180
183
  } catch (statErr) {
181
184
  // stat 失败不影响保存成功;缓存下次 load 时会重读
182
- console.warn(
183
- `[llm-shared] saveConfig stat after write failed:`,
184
- statErr instanceof Error ? statErr.message : String(statErr),
185
- );
185
+ logger.warn("saveConfig stat after write failed", { detail: { err: statErr instanceof Error ? statErr.message : String(statErr) } });
186
186
  }
187
187
 
188
188
  return { success: true };
@@ -192,10 +192,7 @@ export function saveConfig(
192
192
  if (existsSync(tmpPath)) unlinkSync(tmpPath);
193
193
  } catch (cleanupErr) {
194
194
  // tmp 清理失败不能阻塞保存失败的返回;记录原因
195
- console.warn(
196
- `[llm-shared] saveConfig tmp cleanup failed:`,
197
- cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
198
- );
195
+ logger.warn("saveConfig tmp cleanup failed", { detail: { err: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr) } });
199
196
  }
200
197
  const message = error instanceof Error ? error.message : String(error);
201
198
  onWarning?.(`[llm-shared] Failed to save config at '${configPath}': ${message}`);
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // resolve: 模型解析(仅 ref 精确指定)
3
3
  // call: LLM 调用(completeSimple + 凭证 + 文本提取)
4
4
  // config: 泛型配置读写(mtime 缓存 + 原子写)
5
- export { resolveModel, type ModelSelector } from "./resolve.ts";
5
+ export { resolveModel, getCurrentModelId, type ModelSelector } from "./resolve.ts";
6
6
  export { callLLM, extractText, type CallLLMOptions, type CallLLMResult } from "./call.ts";
7
7
  export { getConfigPath, loadConfig, saveConfig, clearConfigCache } from "./config.ts";
8
8
  export { migrateLegacyConfig, type MigrationResult } from "./migrate.ts";
package/src/migrate.ts CHANGED
@@ -15,6 +15,10 @@
15
15
  import { existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
16
16
  import { dirname, join } from "node:path";
17
17
 
18
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
19
+
20
+ const logger = getLogger("migrate-config");
21
+
18
22
  export interface MigrationResult {
19
23
  migrated: boolean;
20
24
  /** 新路径已存在时已删除旧文件(清理残留副本),true 表示旧文件被删。 */
@@ -39,14 +43,14 @@ export function migrateLegacyConfig(agentDir: string, oldRel: string, newRel: st
39
43
  // pi 运行时只读新路径,旧文件无用。安全前提:session_start 迁移在 pi 进程内、
40
44
  // 用户主动启动时触发(非 postinstall 开发环境误触发;e112a14fc 场景随 session_start 消除)。
41
45
  unlinkSync(oldPath);
42
- console.warn(`[migrate-config] new config already exists, removed legacy file: ${oldPath}`);
46
+ logger.warn("new config already exists, removed legacy file", { detail: { oldPath } });
43
47
  return { migrated: false, removedLegacy: true };
44
48
  }
45
49
  renameSync(oldPath, newPath);
46
- console.warn(`[migrate-config] migrated: ${oldPath} -> ${newPath}`);
50
+ logger.warn("migrated", { detail: { oldPath, newPath } });
47
51
  return { migrated: true };
48
52
  } catch (e) {
49
- console.warn(`[migrate-config] migration failed for ${oldPath} -> ${newPath}:`, e);
53
+ logger.warn("migration failed", { detail: { oldPath, newPath, err: String(e) } });
50
54
  return { migrated: false, error: e };
51
55
  }
52
56
  }
package/src/resolve.ts CHANGED
@@ -44,3 +44,9 @@ function resolveRef(ctx: ExtensionContext, ref: string): Model<Api> | null {
44
44
  export function resolveModel(ctx: ExtensionContext, selector: ModelSelector): Model<Api> | null {
45
45
  return resolveRef(ctx, selector.ref);
46
46
  }
47
+
48
+ /** 当前模型的 "provider/modelId" 复合串(model 缺失返回空串)——smart-context 消费口径。 */
49
+ export function getCurrentModelId(model: { provider?: string; id?: string } | undefined | null): string {
50
+ if (!model) return "";
51
+ return `${model.provider ?? ""}/${model.id ?? ""}`;
52
+ }