@ilya-lesikov/pi-pi 0.10.0 → 0.11.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.
@@ -13,7 +13,6 @@
13
13
  "url": "https://github.com/tintinweb/pi-subagents/issues"
14
14
  },
15
15
  "keywords": [
16
- "pi-package",
17
16
  "pi",
18
17
  "pi-extension",
19
18
  "subagent",
@@ -235,6 +235,61 @@ describe("updateFlantInfra", () => {
235
235
  expect([...pi.registered.keys()].sort()).toEqual(["pp-flant-anthropic", "pp-flant-openai"]);
236
236
  });
237
237
 
238
+ it("serves a fresh cache without re-fetching by default", async () => {
239
+ const dir = makeTempDir();
240
+ const cacheDir = join(dir, "extensions", "pp", "cache");
241
+ mkdirSync(cacheDir, { recursive: true });
242
+ writeFileSync(
243
+ join(cacheDir, "flant-models.json"),
244
+ JSON.stringify({
245
+ enabled: true,
246
+ cacheTTLDays: 7,
247
+ lastUpdated: new Date().toISOString(),
248
+ cachedFlantModels: ["claude-opus-4-8"],
249
+ cachedOpenRouterData: {},
250
+ }),
251
+ "utf-8",
252
+ );
253
+ process.env.FLANT_API_KEY = "flant-k";
254
+ const mod = await loadModule(dir);
255
+ const fetchFn = stubFetch(() => { throw new Error("should not fetch"); });
256
+ const res = await mod.updateFlantInfra(makePi());
257
+ expect(res.ok).toBe(true);
258
+ expect(res.models).toEqual(["claude-opus-4-8"]);
259
+ expect(fetchFn).not.toHaveBeenCalled();
260
+ });
261
+
262
+ it("force bypasses a fresh cache and re-fetches the model list", async () => {
263
+ const dir = makeTempDir();
264
+ const cacheDir = join(dir, "extensions", "pp", "cache");
265
+ mkdirSync(cacheDir, { recursive: true });
266
+ writeFileSync(
267
+ join(cacheDir, "flant-models.json"),
268
+ JSON.stringify({
269
+ enabled: true,
270
+ cacheTTLDays: 7,
271
+ lastUpdated: new Date().toISOString(),
272
+ cachedFlantModels: ["claude-opus-4-8"],
273
+ cachedOpenRouterData: {},
274
+ }),
275
+ "utf-8",
276
+ );
277
+ process.env.FLANT_API_KEY = "flant-k";
278
+ const mod = await loadModule(dir);
279
+ stubFetch((url: string) => {
280
+ if (url.includes("llm-api.flant.ru/v1/models")) {
281
+ return { ok: true, status: 200, json: async () => ({ data: [{ id: "sub/claude-fable-5" }, { id: "claude-fable-5" }] }) };
282
+ }
283
+ if (url.includes("openrouter.ai")) {
284
+ return { ok: true, status: 200, json: async () => ({ data: [] }) };
285
+ }
286
+ throw new Error(`unexpected ${url}`);
287
+ });
288
+ const res = await mod.updateFlantInfra(makePi(), { force: true });
289
+ expect(res.ok).toBe(true);
290
+ expect(res.models).toContain("sub/claude-fable-5");
291
+ });
292
+
238
293
  it("falls back to cached models when discovery throws", async () => {
239
294
  const dir = makeTempDir();
240
295
  const cacheDir = join(dir, "extensions", "pp", "cache");
@@ -606,10 +606,10 @@ export function registerFlantProviders(
606
606
  const subscription = options.subscription ?? loadFlantSettings().subscription;
607
607
  let subModels: string[] = [];
608
608
  if (subscription) {
609
- // The gateway only defines `sub/` model groups for a subset of claude
610
- // models (e.g. no sub/claude-fable-5) registering unconfirmed ones
611
- // yields "Invalid model name" 400s. Model lists cached before the gateway
612
- // exposed `sub/…` ids have none; fall back to all claude models then.
609
+ // The gateway defines `sub/` model groups for only some claude models —
610
+ // registering unconfirmed ones yields "Invalid model name" 400s. Model
611
+ // lists cached before the gateway exposed `sub/…` ids have none; fall back
612
+ // to all claude models then.
613
613
  const subEligible = subConfirmed.size > 0
614
614
  ? anthropicModels.filter((m) => subConfirmed.has(m))
615
615
  : anthropicModels;
@@ -899,8 +899,18 @@ export function getFlantGeneratedConfig(): Partial<PiPiConfig> | null {
899
899
 
900
900
  (globalThis as any)[Symbol.for("pi-pi:flant-config")] = getFlantGeneratedConfig;
901
901
 
902
+ export interface UpdateFlantOptions {
903
+ /**
904
+ * Bypass the TTL cache and re-fetch the model list from the gateway even when
905
+ * the cache is still fresh. Set by the explicit "Update now" action so it does
906
+ * what its label promises; auto/startup callers omit it and stay cache-bound.
907
+ */
908
+ force?: boolean;
909
+ }
910
+
902
911
  export async function updateFlantInfra(
903
912
  pi: ExtensionAPI,
913
+ options: UpdateFlantOptions = {},
904
914
  ): Promise<{ ok: boolean; error?: string; models?: string[] }> {
905
915
  setPI(pi);
906
916
  const settings = loadFlantSettings();
@@ -911,8 +921,9 @@ export async function updateFlantInfra(
911
921
  await refreshClaudeOAuthToken();
912
922
  }
913
923
 
914
- let models = isCacheValid(settings) ? settings.cachedFlantModels : null;
915
- let metadata = isCacheValid(settings) ? settings.cachedOpenRouterData : null;
924
+ const cacheOk = !options.force && isCacheValid(settings);
925
+ let models = cacheOk ? settings.cachedFlantModels : null;
926
+ let metadata = cacheOk ? settings.cachedOpenRouterData : null;
916
927
  let refreshed = false;
917
928
 
918
929
  if (!models || !metadata) {
@@ -1094,7 +1094,7 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
1094
1094
  }
1095
1095
 
1096
1096
  if (choice === "Update now") {
1097
- const result = await updateFlantInfra(orchestrator.pi);
1097
+ const result = await updateFlantInfra(orchestrator.pi, { force: true });
1098
1098
  const message = describeUpdateResult(result);
1099
1099
  ctx.ui.notify(message.text, message.kind);
1100
1100
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilya-lesikov/pi-pi",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Pi-Pi Coding Agent: based on Pi Coding Agent, but twice as good",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -37,7 +37,8 @@
37
37
  "test": "vitest run --exclude '3p/**'",
38
38
  "test:watch": "vitest --exclude '3p/**'",
39
39
  "test:3p": "bash scripts/test-3p.sh",
40
- "test:all": "vitest run --exclude '3p/**' && bash scripts/test-3p.sh",
40
+ "test:package": "bash scripts/test-package.sh",
41
+ "test:all": "vitest run --exclude '3p/**' && bash scripts/test-3p.sh && bash scripts/test-package.sh",
41
42
  "coverage": "vitest run --exclude '3p/**' --coverage"
42
43
  },
43
44
  "pi": {
@@ -53,8 +54,10 @@
53
54
  "dependencies": {
54
55
  "@joplin/turndown-plugin-gfm": "^1.0.64",
55
56
  "@pierre/diffs": "^1.2.0",
57
+ "croner": "^10.0.1",
56
58
  "effect": "^3.0.0",
57
59
  "minimatch": "^10.0.0",
60
+ "nanoid": "^5.0.0",
58
61
  "pino": "^10.3.1",
59
62
  "proper-lockfile": "^4.1.2",
60
63
  "turndown": "^7.2.0",
@@ -0,0 +1,99 @@
1
+ // Runs from INSIDE the installed package directory (import.meta.url is under the package), so
2
+ // dynamic import() of a bare specifier resolves through the installed artifact's own
3
+ // node_modules chain — the same resolution pi performs at extension load. Exits non-zero if a
4
+ // registered extension path is missing or a required runtime specifier fails to load.
5
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const PKG_DIR = dirname(fileURLToPath(import.meta.url));
10
+
11
+ function fail(msg) {
12
+ console.error(` ✗ ${msg}`);
13
+ process.exitCode = 1;
14
+ }
15
+
16
+ const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8"));
17
+
18
+ const extEntries = pkg?.pi?.extensions ?? [];
19
+ if (extEntries.length === 0) fail("package.json has no pi.extensions entries");
20
+ for (const entry of extEntries) {
21
+ if (existsSync(resolve(PKG_DIR, entry))) {
22
+ console.log(` ✓ extension present: ${entry}`);
23
+ } else {
24
+ fail(`registered extension path missing from artifact: ${entry}`);
25
+ }
26
+ }
27
+
28
+ // Bare specifiers pi itself supplies to loaded extensions — never mirrored into dependencies.
29
+ const peerSupplied = new Set(Object.keys(pkg.peerDependencies ?? {}));
30
+
31
+ function isBuiltin(spec) {
32
+ return spec.startsWith("node:");
33
+ }
34
+ function isRelative(spec) {
35
+ return spec.startsWith(".") || spec.startsWith("/");
36
+ }
37
+ function packageName(spec) {
38
+ const parts = spec.split("/");
39
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
40
+ }
41
+
42
+ function collectTsFiles(dir) {
43
+ const out = [];
44
+ for (const name of readdirSync(dir)) {
45
+ const full = join(dir, name);
46
+ const st = statSync(full);
47
+ if (st.isDirectory()) out.push(...collectTsFiles(full));
48
+ else if (name.endsWith(".ts")) out.push(full);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ // Scope the scan to the pi-subagents source only. Other shipped 3p sources reference SDK
54
+ // specifiers behind lazy `await import()` guards that are intentionally NOT root dependencies;
55
+ // scanning them would produce a permanently failing test.
56
+ const SCAN_DIR = join(PKG_DIR, "3p", "pi-subagents", "src");
57
+ if (!existsSync(SCAN_DIR)) {
58
+ fail(`expected shipped source dir missing: 3p/pi-subagents/src`);
59
+ process.exit(process.exitCode ?? 1);
60
+ }
61
+
62
+ // Value (non-type) import/export ... from "spec" and dynamic import("spec"). `import type` /
63
+ // `export type` statements are type-only and erased at build, so they carry no runtime requirement.
64
+ const FROM_RE = /(^|\n)\s*(import|export)\s+(?!type\b)(?:[^;]*?\sfrom\s+)?["']([^"']+)["']/g;
65
+ const DYN_RE = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
66
+
67
+ const required = new Set();
68
+ for (const file of collectTsFiles(SCAN_DIR)) {
69
+ const src = readFileSync(file, "utf8");
70
+ for (const m of src.matchAll(FROM_RE)) addSpec(m[3]);
71
+ for (const m of src.matchAll(DYN_RE)) addSpec(m[1]);
72
+ }
73
+ function addSpec(spec) {
74
+ if (isBuiltin(spec) || isRelative(spec)) return;
75
+ const name = packageName(spec);
76
+ if (peerSupplied.has(name)) return;
77
+ required.add(name);
78
+ }
79
+
80
+ // Floor: these two are the runtime imports that broke consumer installs; assert them explicitly
81
+ // even if the extractor's shape ever changes.
82
+ required.add("croner");
83
+ required.add("nanoid");
84
+
85
+ const specs = [...required].sort();
86
+ console.log(` runtime specifiers to resolve: ${specs.join(", ")}`);
87
+
88
+ for (const spec of specs) {
89
+ try {
90
+ await import(spec);
91
+ console.log(` ✓ resolved: ${spec}`);
92
+ } catch (err) {
93
+ fail(`cannot resolve '${spec}' from installed package: ${err?.message ?? err}`);
94
+ }
95
+ }
96
+
97
+ if (process.exitCode) {
98
+ console.error(" → the root package is missing a runtime dependency its shipped extensions need.");
99
+ }
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env bash
2
+ # Release-layout smoke test: pack the root package, install the tarball into a throwaway
3
+ # directory OUTSIDE this checkout, and verify the shipped extension artifact resolves its
4
+ # runtime imports from the installed tree alone.
5
+ #
6
+ # Why this exists: the root `npm test` excludes 3p/** and the vendored suites run inside
7
+ # 3p/*/node_modules, so a bare import that a vendored extension needs but the root package
8
+ # forgets to declare is invisible in the dev checkout (its node_modules still has the package).
9
+ # It only surfaces after a clean `npm install` by a consumer — exactly the croner/nanoid
10
+ # regression this guards against. Resolving bare specifiers from the installed package tree is
11
+ # the only shape that reproduces that failure.
12
+ #
13
+ # Registry access is required (npm install of the tarball fetches deps, runs the root
14
+ # postinstall, and npm v7+ auto-installs peerDependencies). Offline CI: warm the npm cache and
15
+ # export a --prefer-offline-friendly registry mirror.
16
+ set -uo pipefail
17
+ cd "$(dirname "$0")/.."
18
+ ROOT="$(pwd)"
19
+
20
+ TMPDIR_SMOKE="$(mktemp -d)"
21
+ trap 'rm -rf "$TMPDIR_SMOKE"' EXIT
22
+
23
+ echo "▶ packing root package"
24
+ PACK_JSON="$(npm pack --json --pack-destination "$TMPDIR_SMOKE" 2>/dev/null)"
25
+ TARBALL="$(node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const a=JSON.parse(s);process.stdout.write(a[0].filename)})' <<<"$PACK_JSON")"
26
+ if [ -z "$TARBALL" ] || [ ! -f "$TMPDIR_SMOKE/$TARBALL" ]; then
27
+ echo "ERROR: npm pack did not produce a tarball (got: '$TARBALL')." >&2
28
+ exit 1
29
+ fi
30
+ echo " tarball: $TARBALL"
31
+
32
+ INSTALL_DIR="$TMPDIR_SMOKE/install"
33
+ mkdir -p "$INSTALL_DIR"
34
+ ( cd "$INSTALL_DIR" && npm init -y >/dev/null 2>&1 )
35
+
36
+ echo "▶ installing tarball into a clean consumer directory"
37
+ if ! ( cd "$INSTALL_DIR" && npm install "$TMPDIR_SMOKE/$TARBALL" --no-audit --no-fund 2>&1 ); then
38
+ echo "ERROR: installing the packed tarball failed." >&2
39
+ exit 1
40
+ fi
41
+
42
+ PKG_NAME="$(node -e 'process.stdout.write(require("./package.json").name)')"
43
+ PKG_DIR="$INSTALL_DIR/node_modules/$PKG_NAME"
44
+ if [ ! -d "$PKG_DIR" ]; then
45
+ echo "ERROR: installed package not found at $PKG_DIR" >&2
46
+ exit 1
47
+ fi
48
+
49
+ # Run the resolution check from a helper placed INSIDE the installed package, so bare-specifier
50
+ # resolution provably walks the artifact's own node_modules hierarchy (not this checkout's).
51
+ CHECK="$PKG_DIR/__smoke-resolve.mjs"
52
+ cp "$ROOT/scripts/lib/smoke-resolve.mjs" "$CHECK"
53
+
54
+ echo "▶ resolving shipped extension runtime imports from the install tree"
55
+ ( cd "$PKG_DIR" && node "$CHECK" )
56
+ STATUS=$?
57
+
58
+ if [ $STATUS -ne 0 ]; then
59
+ echo "✗ package smoke test FAILED" >&2
60
+ exit $STATUS
61
+ fi
62
+ echo "✓ package smoke test passed"