@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.
- package/3p/pi-subagents/package.json +0 -1
- package/extensions/orchestrator/flant-infra.more.test.ts +55 -0
- package/extensions/orchestrator/flant-infra.ts +17 -6
- package/extensions/orchestrator/pp-menu.ts +1 -1
- package/package.json +5 -2
- package/scripts/lib/smoke-resolve.mjs +99 -0
- package/scripts/test-package.sh +62 -0
|
@@ -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
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
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
|
-
|
|
915
|
-
let
|
|
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.
|
|
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:
|
|
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"
|